Repository navigation
Technical Reference
Browserland (codename webterm, still the name of the Python package and its
directories) is a self-contained web terminal system: headless PTY agents + a
WebSocket broker, speaking Browserland's own web-terminal producer protocol. The
name evokes a place — a little world inside your browser where a whole fleet of
terminals and AI coding agents live, are launched, and are driven from a single
tab. (webterm remains the Python package/module name.) The protocol is
deliberately small: any producer that speaks these JSON frames over a single
WebSocket can register with the broker.
┌─────────┐ binary ANSI + JSON ┌────────┐ /ws?session=<id> ┌─────────┐
│ agent │ ──────────────────▶ │ broker │ ──────────────────▶ │ browser │
│ (PTY + │ ◀────────────────── │ :4445 │ ◀────────────────── │ xterm.js│
│ ConPTY/ │ input/resize/ └────────┘ input/paste/resize └─────────┘
│ openpty)│ snapshot_please
└─────────┘
# broker (default 127.0.0.1:4445)
python -m webterm.broker
# an agent running cmd.exe, registered with the local broker
python -m webterm.agent -- cmd.exe
# then open http://127.0.0.1:4445/ and click the session (or "new terminal")Linux:
./launchers/run-broker.sh # broker
./launchers/run-agent.sh -- bash -l # agent| Path | What |
|---|---|
webterm/protocol.py |
the ONLY place the JSON frame shapes live |
webterm/agent/ |
headless producer: PTY backend + ring buffer + OSC title sniffer + reconnecting WS client |
webterm/agent/backends/linux_pty.py |
pty.openpty + Popen + loop.add_reader; TIOCSCTTY so Ctrl-C works |
webterm/agent/backends/win_conpty.py |
Windows PTY via low-level winpty.PTY + reader thread (Proactor loop has no add_reader for pipes). Backend auto-selection: ConPTY silently drops the 0x03 → CTRL_C_EVENT translation when the hosting process has no console (verified empirically — exactly the headless cases this agent exists for), so on auto (and forced conpty) the agent first acquires a hidden console (AllocConsole + SW_HIDE) and re-enables Ctrl-C (SetConsoleCtrlHandler(None, FALSE), undoing CREATE_NEW_PROCESS_GROUP's disable — #25), and detached agents then run ConPTY; auto falls back to WinPTY only when acquisition or the re-enable fails, and winpty forces the legacy backend without acquiring a console. |
webterm/agent/snapshot/raw.py |
tier-1 snapshot: ESC[0m ESC[2J ESC[H + ring replay |
webterm/agent/snapshot/pyte_snap.py |
tier-2 (optional, --snapshot-mode pyte): replay ring through pyte, render the settled grid |
webterm/broker/ |
Sanic app: desktop UI page, /ws relay, /browserland producer WS, /sessions, profiles-only POST /launch
|
launchers/ |
run scripts for both OSes (the Linux ones bootstrap a venv) and systemd units |
python -m webterm.agent [opts] [--] command...
--broker-url URL $BROWSERLAND_BROKER_URL > flag > ws://127.0.0.1:4445/browserland
--auth-token TOK flag > $WEB_TERMINAL_TOKEN; appended as ?token= (REQUIRED
for every broker since #142, loopback included)
--cols/--rows initial PTY size (default 80x24)
--title T initial title (default: command basename)
--window-id N pin the session id (default: random 48-bit)
--ring-bytes N snapshot ring cap (default 262144)
--snapshot-mode raw|pyte
--cwd DIR
--profile NAME launch-profile name, echoed in the hello so the UI can
pick that profile's colour (#115)
--pty-backend auto|conpty|winpty
Windows only (see the backend row above, #25);
ignored on POSIX
The agent's exit code is the child's exit code. While the broker is down
the PTY keeps running; the client reconnects with exponential backoff
(0.5 s → 10 s cap, ×2 per failure, reset on success) and re-hellos with the
current title/dims. Missed bytes are not replayed — the browser's attach
triggers snapshot_please, which heals from the ring.
psutil is an optional, best-effort dependency (pip install -e ".[procs]"),
not declared in core deps. It powers the task-manager process list
(enumerate_procs), the foreground-agent badge (foreground_command), and
live-cwd tracking (cwd) — each degrades to empty/None without it. Destroying
a window also prefers the psutil path (identity-checked create_time guard),
but the Linux backend's kill_proc_fallback kills the shell's whole POSIX
session by SID (the shell is its own session leader via start_new_session,
disjoint from the agent's session) when psutil is absent, so "destroy window"
works either way. Windows without psutil still returns psutil_unavailable
for destroy (no fallback yet).
python -m webterm.broker [--host 127.0.0.1] [--port 4445] [--config PATH]
[--role hub|node] [--headless] [--no-terminals]
[--print-token]
[--scan-recordings [--recordings-dir DIR] [--secret S] [--json]]
Roles (#253). A broker is a hub or a node. A hub serves the desktop
page; you open it in a browser. A node serves the JSON/WS API only, for another
Browserland to reach. The role is derived from the stored key serve_ui
(default true = hub) and never stored itself. --role node (or a config
"role": "node", accepted as an alias and folded into serve_ui) means
serve_ui: false; --headless is the older spelling of --role node. A node
serves the full JSON/WS API but not the desktop page or the in-app Help —
GET / returns 200 {"ui": false, "role": "node"}, /help-corpus.json 404s,
and both UI constants (INDEX_HTML, HELP_CORPUS) are never assembled (#87).
The CLI overrides the config (like --host/--port); there is no
--no-headless. --headless together with --role hub is a contradiction and
refused, as is a config role that disagrees with an explicit serve_ui.
The second stored key, terminals (default true), says whether this broker
hosts terminals. --no-terminals sets it false: the broker then refuses new
launches — POST /launch, POST /mcp/launch, POST /profiles/config and
GET /profiles/detect answer 403 terminals_disabled — while terminals that
already exist keep running and WS /browserland keeps accepting agents, so an
agent launched before the flag reconnects as usual. Profile reads
(GET /profiles, GET /profiles/config) and /sessions still answer. Use it on
a hub that is only the front door to its nodes. A node must host terminals:
serve_ui: false with terminals: false is refused at startup — one line on
stderr, exit 2, before the port is bound (--print-token and
--scan-recordings still run on that config; they bind nothing).
GET /info reports serve_ui, the derived role ("hub" / "node") and
terminals; GET /mcp/info reports terminals. The boot log names both
(broker role: hub - terminals on). Existing JSON/WS behavior is otherwise
unchanged in every combination; a broker that predates #253 omits role and
terminals, which a client reads as an ordinary broker that launches.
Config (broker_config.json, path overridable via $WEB_TERMINAL_CONFIG;
see broker_config.example.json): auth_token, plus agent.profiles for
/launch. Profiles only — /launch accepts
{"profile": "cmd", "cols": 120, "rows": 32, "title": "...", "cwd": "..."} and never a
client-supplied command or environment (a cwd must be an existing directory). Responses: 200 registered, 202 spawned
but no hello within 10 s, 400 unknown profile (or a bad body), 401
auth_required, 503 restarting while the broker quiesces, 403
terminals_disabled on a terminals: false broker (#253, checked before the
body is parsed), 429 too many pending, 500 agent exited early.
GET /update/check?refresh=1 re-asks GitHub through the daily cache; the 30-minute poll never sets it, and only a literal 1 counts. Because a forced refresh bypasses the TTL it bypasses the quota strategy with it, so the broker floors it at one per 60 s and caps it at six per rolling hour, leaving most of GitHub's 60/hour-per-source-IP for everything else on that machine. An active rate-limit negative cache outranks the button entirely — the reset GitHub named in a header is honoured, not clicked through. Admission is decided and reserved inside the same lock the fetch holds (checking before the lock would let a queue of requests each pre-authorise themselves and then fetch in turn), and attempts are stamped before the request goes out, since a failed fetch spends the same quota as a successful one. A refresh that finds a newer publish than the one it queued behind coalesces rather than re-asking, tracked by a publish serial read before the lock. A refused refresh answers 200 with the cached result plus refreshed: false and refresh_refused: {reason, retry_after_s} — never an error, and never a bare 200 a client could render as "just checked".
POST /update/apply applies the previewed upstream commit, then exits with code 75 so the supervisor restarts into the new code. It is manual-trigger only — no schedule, no timer, no auto-install. It needs update_apply_enabled (else 503 update_apply_disabled) and a restart that is available now, which needs restart_enabled; the preview it applies comes from /update/check, which needs update_check_enabled, though the apply route does not read that gate again. Each gate resolves independently: a key present in broker_config.json (either true or false) owns that one gate and locks it against the GUI; an absent key leaves the gate to a sidecar (webterm_update_policy.json) written by POST /update/policy — the desktop's one-click consent when the update mod is turned on grants whichever of the three the config hasn't already claimed, and the Update window's "Allow this broker to update itself" row grants apply + restart together afterward. POST /update/policy takes any subset of its keys (check_enabled, apply_enabled, restart_enabled, and status_fetch_enabled for /status/fetch, #189); a key the config file owns answers 409 policy_locked; keys it does not name keep whatever the sidecar already had, so two operators granting different gates never clobber each other. A corrupt sidecar fails every gate it would otherwise decide closed, never through to a default.
The route takes {"target_sha": "<40-hex>"} — exactly the commit the UI previewed in the last successful check; a body that is not JSON is 400 bad_json, and a target_sha that is not one full 40-hex sha is 400 bad_target_sha. The server re-proves every precondition fresh:
- This install is a git checkout (
not-a-checkout). - The target commit is not some random hash — it is exactly the upstream sha of the last established check (
state-unknownwhen no check established one,preview-sha-mismatchwhen upstream moved since the preview), and the checkout is behind it (already-currentat 0 behind). - The working tree is clean (
dirty-tree: no staged/unstaged changes; uncommitted work must be committed or stashed). - No local commits would be discarded (
ahead-or-diverged: if the check said "3 behind", applying is safe; if it said "2 ahead / 3 behind", applying would discard work). - Restart is possible now (
restart-unavailable: the gate is on, the supervisor exists, the mechanism is available). A closed apply gate never appears in this list: it is the earlier 503update_apply_disabled. - After the fetch: the target object arrived (
target-object-missing), is reachable from what was fetched (target-not-upstream) and is a fast-forward (not-fast-forward), and no dependency file changed (dependency-delta: any change, or one that could not be diffed, refuses; the broker never installs anything). The deploy journal must be writable (no-deploy-journal).
The git phase lives in webterm/broker/update.py's marked mutation section: git fetch <explicit https URL> <ref> (never git pull, never a remote name — a fork's origin must be irrelevant), then git merge --ff-only <sha> with hooks disabled. Objects are fetched with a 120-second timeout; the merge with a 60-second timeout. Stderr is captured for reporting; the report names every locked file on Windows, bounded to 2000 characters.
The up-front refusals are reported accumulating every reason — 409 apply_refused with the whole refusals list, so the UI shows all unmet preconditions in one pass rather than trial-and-error; the git phase stops at its first failure (409 apply_failed, with stage, its refusals, and tree_suspect: true when the merge failed part way and the checkout may need a human). A second apply while one runs is 409 apply_in_progress; a restart already under way is 409 restart_in_progress (the apply claims the restart before it touches the tree, so it never merges under a broker that is stopping); a failure of the apply machinery itself is 503 apply_failed with reason_code: "apply-error" (the broker is not left stopping, and the pending deploy journal is cancelled); a merge that landed but whose restart could not be armed is 503 apply_incomplete (the old code keeps running). Success is 202, and the outcome surfaces as last_deploy in a later /update/check (below).
The broker that was running the old code exits and is replaced. Before it asks for the restart, the worker writes a deploy journal, deploy.journal (the sha before and the target sha), into the supervisor's run directory: a private temporary directory (browserland-supervisor-…) the supervisor creates when it starts and deletes when it exits, outside the checkout, so no git operation on the checkout can touch it. The supervisor judges the next worker generation against the journal: a fresh worker still up after 10 s on the target sha is came-up-ready-on-target; one up on any other sha is came-up-on-wrong-sha (never reverted — that worker is healthy); one that never comes up is never-came-up. A rollback is automatic only when never-came-up names a startup failure — the worker exited, could not bind its port, could not be started, or used up the restart budget: the supervisor runs git reset --hard to the journalled sha (hooks disabled, no network) and relaunches the old code with a fresh budget, recorded as rolled-back. rollback-impossible means the revert was refused before git ran (the journalled sha is not a full 40-hex sha, the checkout has no .git, or HEAD moved since the deploy); rollback-failed means git itself failed. Neither is retried. A stop signal, or an exit 75 without a matching sentinel, never reverts. An apply that fails before its restart (the merge failed, or the restart could not be armed) is closed by the worker itself as cancelled-before-restart. The outcome rides in every /update/check response under last_deploy (the string is last_deploy.outcome), read fresh per request.
Agents survive the restart and reconnect to the new worker, but terminals do not re-execute: they keep running the old code they imported. The update window's Last update section offers a New terminal button to spawn a new one with the updated code. Whatever runs inside a terminal (a shell, an AI coding CLI) keeps running untouched; what a restart drops is the broker's own in-memory work, such as an open upload or recording save.
There is one current journal (pending) and one current outcome file, deploy.outcome (the journal record, the outcome, the sha observed, the reason and when it was finalized) — deliberately not a growing log: each deploy gets exactly one outcome, and the next deploy's replaces it. Both live in the run directory, so the outcome is gone once the supervisor itself exits, and an unsupervised broker never reports one. last_deploy can therefore only show an outcome a running supervisor kept: came-up-ready-on-target, came-up-on-wrong-sha, rolled-back or cancelled-before-restart. never-came-up without a revert, rollback-impossible and rollback-failed end the supervisor, so they survive only as its log line on stderr, and the last two leave the checkout for manual repair. A journal this build cannot validate is moved aside to deploy.journal.corrupt, never guessed at. A rollback shows as rolled-back plus a changed bootId (reported in /info).
python -m webterm.broker is now a supervisor that spawns the actual server as a worker subprocess. The bash launcher execs the supervisor (and the systemd unit's ExecStart is that launcher), making it the main process; the PowerShell launcher runs it as a child. Neither has a wrapper loop. When restarted from the UI, the supervisor stays alive and re-spawns the worker, a broker-generation restart: the server process is replaced with fresh code, but agents reconnect to the new worker and survive. It is not a service restart — the launcher preamble and systemd unit environment are not re-run.
Restart is opt-in, resolved the same per-key way every update gate is: if broker_config.json names restart_enabled (either true or false), that key owns the gate and only editing the file can move it; if it is absent, the gate is the desktop's to grant, via the Update window's "Allow this broker to update itself" row (which grants apply_enabled and restart_enabled together — see the update mod's own docs). A restart from the UI drains first: POST /launch, POST /file/upload_begin and POST /recording/begin (and an update-policy write) answer 503 restarting from then on — POST /mcp/launch and the single-shot POST /file/upload are not checked — and it waits up to 20 s for in-flight critical writes. If one is still running then, the restart is abandoned and nothing is destroyed; otherwise every open upload and recording-save session is aborted and named in the answer. The confirm dialog reports session continuity: guaranteed (agents that survive), at_risk (lost), and unknown. A per-boot cooldown (restart_cooldown_seconds, default 90, 0 disables — also config-file only) refuses back-to-back restarts; 90 is deliberately longer than the supervisor's 60-second budget window, so deliberate restarts can never exhaust the crash budget.
The mechanism is a sentinel file and exit code 75: the worker exits 75 (normally reserved for another purpose — this supervisor owns it now) and writes a nonce-matched intent sentinel. The supervisor relaunches only if the sentinel matches, refuses any exit-75 without a sentinel, and treats an unarmed 75 as a crash. The supervisor itself can run under systemd (Restart=always or on-failure, not on-success), a Windows scheduled task (Stop-ScheduledTask kills abruptly — graceful drains do not complete), or by hand as python -m webterm.broker (in-app restarts work; nothing relaunches the supervisor if it exits). It relaunches the worker only on an authorized exit 75: any other worker exit ends the supervisor with that exit code, unless a pending deploy's automatic rollback (above) relaunches the old code. Authorized restarts have a budget — five relaunches inside 60 seconds, with exponential backoff for a worker that exited before it had been up 10 s (0.5 s initial, doubling, clamped at 30 s); only a worker that stayed up 10 s clears it. Exhausting the budget exits with code 70; systemd's own StartLimitAction can then step in.
GET /info reports restart.configured (the gate), restart.available (can we restart now) with restart.reason_code saying why not, restart.mechanism (supervisor / systemd / none), restart.lifecycle, restart.retry_after_s while the cooldown runs, restart.bootId (this process's id — changes on restart, lets a client confirm it happened), and session continuity. A client confirms a restart by watching bootId change.
POST /restart answers 202 once drained and armed (the body carries operation_id, the drain report, continuity, mechanism and the bootId to watch); 503 restart_disabled with the gate off; 409 restart_unsupported, restart_cooldown or restart_in_progress; 409 restart_failed with reason_code not_supervised; and 503 restart_failed when the drain did not complete.
There is one build id in this project: webterm.build_version() — the package
version from webterm/__init__.py (the single source; pyproject.toml
declares dynamic = ["version"] and reads that attribute) plus the git short
hash when running from a checkout, e.g. 1.0.0+ba4b62e. It is derived from the
package's own directory (never the process cwd, so a wheel installed inside an
unrelated repo cannot report that repo's commit), cached per process, and never
raises.
It rides the agent hello, GET /info and GET /mcp/info — and it is baked into
the served desktop page:
<meta name="webterm-build" content="1.0.0+ba4b62e">A broker serves the page it assembled at import, so a git pull changes
nothing until the process is restarted. That is the gap this closes:
curl -s http://host:4445/ | grep webterm-build
git -C /path/to/checkout rev-parse --short HEAD
The stamp's +hash matches → the running process is the pulled code; it does
not → a restart is still pending. The stamp describes the running process,
not the checkout on disk, exactly because build_version() is cached at import
and never recomputed per request.
Read it for what it is: the revision HEAD pointed at when that process first
asked. It cannot see uncommitted edits (there is no dirty marker — two different
dirty trees would share a hash, which is a worse signal than none), and if a
pull lands while a broker is running, modules imported before and after it
come from different revisions and no single stamp can say so. A caching reverse
proxy in front of GET / will also happily serve you an old stamp.
It is a <meta> in the head and deliberately not a value inside the page's
one inline script: script-src authorizes that script by sha256 of its exact
bytes (see Auth model), so a per-commit string in there would
change the CSP hash on every commit. Script that wants the value reads the tag —
document.querySelector('meta[name="webterm-build"]').content.
A token is required on every surface, on every interface, always (#142) —
no loopback exemption, no opt-out. Resolution (never returns nothing):
$WEB_TERMINAL_TOKEN → config auth_token → the webterm_token.json sidecar
beside the state store → mint secrets.token_urlsafe(32) into it. Compared
with hmac.compare_digest. python -m webterm.broker --print-token reports it
without minting or starting a server. A resolved browser token shorter than 16
characters refuses to start; the MCP token has no such floor yet (#260).
A wrong token is answered late (#260). A wrong token's 401 is answered
after one second while fewer than 32 are waiting, and at once beyond that, on
every browser gate, /mcp/*, /peer/*, /ws and /control. A correct token
is never delayed, and a missing one is a plain 401. Not delayed: the producer
/browserland dial and the help corpus, which answers a wrong token with the
public corpus (a 200). A browser still holding a rotated token waits a second
on each poll until you log in again.
A terminal's shell inherits the broker's environment without
WEB_TERMINAL_TOKEN, the MCP tokens (WEB_TERMINAL_MCP_TOKEN,
WEB_TERMINAL_MCP_ADMIN_TOKEN, BROWSERLAND_MCP_TOKEN,
BROWSERLAND_MCP_ADMIN_TOKEN) and BROWSERLAND_MCP_HOSTS (#260). When the
environment pins them, they come from the environment you set for the broker
(a unit file, a shell profile); otherwise the Control Panel's MCP access fields
and Copy .mcp.json show the MCP tokens. --print-token prints only the
browser token.
Loopback was never a sound exemption: tailscale serve in front of a
127.0.0.1 bind (the topology Setup-and-Onboarding recommends) makes every
tailnet request arrive from loopback, and any page in the user's browser
reaches loopback too. Upgrading a tokenless install is a breaking change —
see Upgrading.
How the token travels (#144). HTTP sends it as Authorization: Bearer, never in the
query string. A URL credential leaks where a header does not: any script on the page can
read the full URL out of performance.getEntriesByType('resource'), a DevTools HAR export
carries it into a bug report, and a reverse proxy logs it. There is deliberately no
client function that builds a token-bearing HTTP URL, so one cannot be reintroduced by
accident — hostFetch(host, path, opts) is the only way to call the API.
Two places still carry ?token=, both unavoidable and both deliberate:
-
WebSocket dials (
/ws,/control,/browserland). The browser WebSocket API cannot set request headers on the handshake, sohostWsUrlappends the token. A host reached through this broker is the exception: it dials with a one-time ticket instead (see Peer proxy → Sockets), so no token rides that URL. -
The
?token=deep link (http://host:4445/?token=…). It is how a token reaches a fresh browser at all; it is adopted into localStorage and scrubbed from the address bar viahistory.replaceStateon load.
So this narrows the exposure from every request to the handshake and the first load — it
does not eliminate it. Operationally: an Authorization header makes a cross-origin
request non-simple, so remote hosts pay an OPTIONS preflight; the broker answers every
one and sets Access-Control-Max-Age: 86400, making it one round trip per host per day
rather than per request. ACAO: * stays legal because a header set explicitly by
fetch() is not a credentialed request in the CORS sense (that needs
credentials: 'include'), which is also why a wrong token still returns a readable 401
instead of an opaque failure — that is what keeps the login overlay working. If you
front the broker with a reverse proxy, it must forward the Authorization header; a
proxy that strips it makes every host look like a wrong password.
The mint is O_CREAT|O_EXCL (0600 on POSIX; Windows inherits the directory
ACL), not an atomic replace: two brokers racing on one state dir both see no
file, and last-writer-wins would leave the loser running a token that is not
the one on disk. The O_EXCL loser re-reads and adopts the winner. An unwritable
directory yields an ephemeral token — the broker still starts, but warns on
every boot that it changes on restart and --print-token cannot recover it.
| Surface | Rule |
|---|---|
GET /, GET /help-corpus.json
|
unauthenticated. The token is typed into that page and auth is query/header-only with no cookies, so gating the document deadlocks the bootstrap — every reload, bookmark and new tab would 401 forever. Neither carries host- or session-derived data. On a node (serve_ui: false), GET / is 200 {"ui": false, "role": "node"}, so health probes keep working. The corpus is public but auth-sensitive (#173): with no token it is the wiki + shipped-mod corpus alone, exactly what it was before #163; the sections contributed by installed mods — their help text, their manifest label/icon, and hence the list of installed ids — are merged in only for a caller holding the token. Same request_token_ok as every gated route, answering with a smaller 200 instead of a 401, plus Cache-Control: no-store and Vary: Authorization so no cache can hand one audience the other's body. |
GET /vendor/*, GET /mods/<id>/<gen>/<name> (#163) |
unauthenticated, and forced rather than chosen: the browser needs the vendored xterm to render the login page, before any token exists, and a <script src> cannot carry an Authorization header (?token= is structurally banned by #144). Both serve from an in-memory allowlist dict, so a client-supplied name can never reach the filesystem. Consequence: an installed mod's source and styles are publicly readable, exactly as GET / already carries every shipped mod's source — but reaching one takes the id and the generation and the file name, and #173 stopped the help corpus from handing out that first ingredient. Neither ingredient is a broker secret, though: <gen> is a content hash of the package, so someone holding a publicly distributed mod's bytes recomputes it and can confirm that mod is installed here (200 vs 404). #173 stops the corpus enumerating installed ids; it cannot hide a mod you already know to ask for by name. help.md is not servable here at all. |
OPTIONS preflights |
unauthenticated by design (they carry no credentials). Explicit routes, because route resolution happens before request middleware. |
WS /browserland (producers) |
token required, loopback included. Was the one gate the token never covered — and WebSockets are not CORS-gated, so any website could dial ws://127.0.0.1:4445/browserland, re-register a live window_id (displacing the real agent and closing its viewers with 1012) and inject fabricated terminal output. Refusal is a post-upgrade WS close 4401 (an HTTP reject would surface as an opaque 1006). A re-register whose host and nonzero pid match the entry it replaces now keeps that window's MCP access override instead of falling back to the broker default; that match is a collision heuristic, not a security boundary (both are self-reported), and the producer token is the boundary. |
POST /launch |
token required — never an open RCE on any bind. Refusal order: 401 auth_required, then 503 restarting while the broker quiesces, then 403 terminals_disabled on a broker with terminals: false (#253) — checked before the body is parsed. |
WS /ws, WS /control, GET /sessions, GET /profiles
|
token required (?token=, ?auth=, or Authorization: Bearer). The HTTP routes answer 401 auth_required, never 403, and the sockets close 4401: the login overlay and the taskbar host chips pop on those only. |
GET/POST /profiles/config, GET /profiles/detect (profile editor, #70) |
browser token, same as /file/* and /mcp/config. Full commands are browser-realm only; /profiles and /mcp/profiles stay names-only. POST /profiles/config and GET /profiles/detect answer 403 terminals_disabled on a terminals: false broker (#253). |
GET/POST/PUT /peer/<id>/… (peer proxy, #252) |
two gates, see Peer proxy: browser routes take this broker's token as the bearer (header only — a ?token= does not count here) plus the peer's own token in X-Browserland-Peer-Token and the pairing in X-Browserland-Peer-Origin; /peer/<id>/mcp/* takes the peer's MCP token alone, and only for a peer configured with mcp: true. OPTIONS is not routed (405). |
POST /peer/<id>/ws-ticket, WS /peer/<id>/ws, WS /peer/<id>/control (peer proxy sockets, #252) |
the mint takes the browser gate above and answers a one-time ticket; the two socket routes take only ?ticket= — no token of any kind — and close 4403 on a bad, expired, reused or revoked one. See Sockets. |
GET /status/fetch (AI-provider status proxy, #112) |
browser token, same as /info and /state. One of the broker's three HTTP-library egresses (/status/fetch, /update/check, the configured peers) plus the update apply's git fetch subprocess: an allowlist of AI-provider Atlassian Statuspage hosts, reached by provider id (the client passes ?provider= ids, never a URL; none means all of them, unknown ids are dropped, and an all-unknown request is 400 no_valid_provider). Answers carry Cache-Control: no-store. Each fetch is https-only, no-redirect, no-proxy, 200-only, 512 KiB-capped, 4 s timeout, with a 60 s per-id cache; any failure degrades to an unknown row (never blocks the UI). Privacy: the egress is behind the broker's own status_fetch_enabled gate (#189; off by default, resolved per key like the update gates: a config key owns it, else the sidecar a POST /update/policy grant writes — the default-off aistatus mod's window has a button that asks for that grant). While the gate is off the route is 503 status_fetch_disabled and nothing is dialled; once on, the broker's egress IP becomes visible to those status hosts. |
| CORS (JSON API) |
Access-Control-Allow-Origin: * unconditionally on every response, including the 401 — lets the multi-host UI on another broker's origin read this one, and without it a cross-origin login probe surfaces as a fetch TypeError ("wrong password" indistinguishable from "host down"). Auth is token-in-query/header and never cookies, so * grants no ambient credential; the real gate is the token on every route. |
| Response headers |
Referrer-Policy: no-referrer (the desktop URL carries the token, so an outbound link must not leak it in Referer), X-Frame-Options: DENY + CSP frame-ancestors 'none' (GET / is public so login can bootstrap, but an attacker page must not be able to iframe the real UI and clickjack a browser that already holds a token), and CSP script-src — see below. A relayed /peer/* answer carries the sandbox CSP sandbox; default-src 'none'; frame-ancestors 'none' instead (see Peer proxy). |
Third-party code (#143, #146). The page used to pull xterm from jsdelivr and, lazily, CodeMirror from esm.sh — third-party code executing in the same origin that holds prefs._hosts[].token for every configured host, tokens that gate /launch and host-wide /file/*. Both are now vendored, and no third-party origin executes in the page at all:
-
xterm is vendored into
webterm/broker/vendor/and served by the broker at/vendor/*(public, likeGET /— the browser needs it to render the login page, before any token exists). There is no CDN to compromise, no SRI hash to keep in sync, and the terminal now works offline, which it never did before. The bytes are the exact published npm files, pinned by sha384 intests/test_vendor_assets.py; an upgrade is a commit that changes both. Served from an allowlist dict, so a client-supplied name can never reach the filesystem. -
CodeMirror is vendored into
webterm/broker/vendor/codemirror/and served at/vendor/codemirror/*by a second route with a literal segment (so the lookup is still one dict get on a prefixed key — a name can't express a path). The text editor works offline too. See below for why it took its own generator. -
script-srcallows only'self'and a'sha256-…'for the page's single inline<script>. The hash is computed at startup from the assembledINDEX_HTMLitself (ui.inline_script_hash), so it tracks the bundle automatically and needs no'unsafe-inline'— meaning an injected inline script cannot execute either. Nothing else is enforced (nodefault-src/style-src/connect-src): those would have to enumerate every inline style, thedata:favicon,blob:download URLs and every federated host, each a way to break the app for no gain here. A fuller policy (#190) rides every response asContent-Security-Policy-Report-Onlywhile it soaks; it reports and blocks nothing.
Why CodeMirror needed a generator (#146). xterm was four self-contained files with exact-pinned URLs — download, hash, commit. CodeMirror 6 is about a hundred modules across 40 npm packages that must share one instance each of @codemirror/state, view and language; a second concrete build of state throws and drops the editor to a plain <textarea>, while a second view or language is silent — syntax highlighting just stops. That is why CM_VER in mods/editor/codemirror.js uses semver ranges: esm.sh collapsed every range onto one build at request time. Mutable URLs meant nothing could be hashed, which is why esm.sh stayed in script-src after #143.
python -m webterm.broker.vendor_codemirror moves that guarantee from request time to generate time. It parses CM_VER (still the single source of truth for what the editor loads), crawls the graph from esm.sh keying every module by its final resolved URL, rewrites each import specifier to a relative sibling path, and refuses to write anything if any package appears twice. Run it by hand on upgrade; --check re-verifies the committed tree offline. Two details worth knowing:
- It does not rewrite with a file-wide regex.
@codemirror/lang-javascriptships completion snippets whose payload is literal JavaScript source (p('import {${names}} from "${module}"…')), and a file-wide regex matches inside that string and corrupts it. It rewrites the module prologue (where esbuild puts the imports), plus any later import that starts at a statement boundary (after;,}or{— esm.sh's node polyfills import after declaring helpers), which keeps it off string contents; it then refuses to write a file in which any package path is left unrewritten. - The first run found
autocomplete,lang-htmlandlang-javascripteach loading twice — our exact pins had drifted behind what the transitive^ranges resolved to, so esm.sh was serving both. Those three became ranges. The assertion that caught it now runs intests/test_vendor_assets.pyagainst the committed graph.
Per-file sha384s live in the generated vendor/codemirror/manifest.json (which is also the served allowlist); only the manifest's own hash is pinned in the test file, so an upgrade is one hand-updated constant. Like the xterm pins, that is change-control friction, not a security guarantee — it makes a silent change impossible, not a deliberate one.
Recordings are stored gzipped (#159). A recording is JSONL carrying base64 PTY output —
about the most compressible thing the app produces, and an always-on capture rolls a fresh
50 MiB segment forever. New recordings land as <id>.blrec.gz (measured ~8x smaller);
<id>.blrec keeps meaning an older uncompressed file and is never rewritten. The suffix
is the encoding — nothing sniffs content, so a corrupt .gz reports as a corrupt gzip
rather than being retried as JSONL. Each uploaded chunk is compressed as its own gzip
member, which keeps appends streaming and commit a plain os.replace; concatenated
members are valid gzip and Python/GNU tooling reads them transparently. GET /recording
still serves plain JSONL on the wire, deliberately: HTTP client decoders commonly stop
at the first member (httpx does), so passing the stored bytes through under
Content-Encoding: gzip would silently truncate multi-chunk recordings. Disk was the
growth problem; the wire is unchanged.
Auditing recordings for secrets (#145). A recording captures the terminal's output
byte-for-byte, so anything the terminal echoed is in it — a secret pasted onto a visible
command line, an API key printed by a config dump, or this broker's own token if someone
ran --print-token while recording. Recordings are durable and downloadable, so:
python -m webterm.broker --scan-recordings # 0 = clean, 1 = found, 2 = incomplete (wins over found)
python -m webterm.broker --scan-recordings --json # machine-readable
python -m webterm.broker --scan-recordings --secret <OLD_TOKEN> # rotated tokenIt resolves the live auth_token and the MCP token seed (WEB_TERMINAL_MCP_TOKEN, else config mcp_token) read-only (auditing must never mint
one — that would search for a value the broker isn't even running) and streams each file,
base64-decoding d before searching. That decode is the whole point: a plain
grep <token> *.blrec finds nothing even when the token is present and returns a
confident false all-clear. Output payloads arrive in arbitrary PTY chunks, so the scanner
carries a len(secret)-1 byte tail across events — a secret split over two (or twenty)
events is still found, and one that straddles a resize/gap event is too. It scans both
encodings; a compressed recording it could not open would be a live secret that quietly
stopped being findable, so a file that fails to decompress is reported as an error
(exit 2, incomplete) and never skipped into a clean result. By hand, the compressed
equivalent of the futile grep above is zgrep / gzip -dc … | grep — equally futile,
and for the same base64 reason.
Its limits are printed on every human-readable run, including clean ones (not with --json), because a tool like this
recreating the false all-clear would be worse than not having it: it finds a secret only
where it appears as contiguous bytes. A secret the terminal broke up — interleaved with
colour/cursor escapes, redrawn by the shell as you typed, echoed a character at a time —
is not found, and a rotated secret is only searched for if passed with --secret — so is an MCP token generated in the Control Panel (it lives in webterm_mcp.json, not the seed); the admin tokens are never searched. It is
read-only: recordings are archived artifacts and nothing here rewrites one.
Tokens are passed to spawned agents via env only (never argv — visible
in process lists), and auth failures log only path + client IP (the token
rides in query strings — Sanic's access log is pinned off for the same
reason). The agent then removes WEB_TERMINAL_TOKEN before spawning the
user's shell, so a terminal cannot echo the credential that gates host-wide
file access and shell spawning.
An agent refused with 4401 backs off to the 10 s cap (the rejection arrives
post-upgrade, after the backoff has already reset, so the naive loop retries
at ~1 Hz forever) and logs one error naming $WEB_TERMINAL_TOKEN. It keeps
retrying rather than giving up: the token can come back without the process
restarting.
Browser login: the token doubles as the UI's password. The page is
served ungated; a login overlay probes /sessions and stores the token in
localStorage (per browser, per host). ?token=/?auth= URLs still
work as deep links — the token is adopted into localStorage and then
scrubbed from the URL via history.replaceState (behavior change from the
URL-persistence era: copied links no longer carry auth; ?session= deep
links survive the scrub).
Note: the broker pins app.config.AUTO_EXTEND = False. sanic-ext, when
merely installed, silently injects its own CORS middleware and an
unauthenticated /docs + /openapi.json; CORS here is hand-rolled and
token-gated instead.
Control Panel → Browser → Hosts lets the UI attach to sessions on additional brokers
(e.g. a WSL box over Tailscale). By default the browser connects
directly to each host (cross-origin fetch /sessions + ws://host/ws);
a host can instead be reached through this broker (below). Hosts and
their passwords live per-browser in localStorage. Requirements for a
direct host:
- you need the remote broker's token (every broker has one — either
configured or minted;
python -m webterm.broker --print-tokenprints it) and a way in: either a non-loopback bind (plain http), or — recommended — a loopback bind fronted bytailscale serve/ an https reverse proxy. Settingauth_tokenexplicitly is worth it across a fleet so every host shares one password instead of a per-machine minted value; - keep every broker on the same webterm version — there is no version gate
(
Access-Control-Allow-Origin: *on every response), but an older direct remote refuses a preflight for any header its build doesn't list, and it lacks newer routes and fields; a from-server host is same-origin and needs no CORS; - one scheme for the whole fleet — an https page fetching an http remote is blocked as mixed content, so cockpit + hosts must be all-https (preferred: unlocks secure-context features like clipboard image paste) or all-plain-http (see Setup-and-Onboarding#multiple-machines-over-tailscale).
A single broker's status chip is shown in the taskbar by default, even
healthy and local. Since the per-broker actions moved onto the (+)
menu's rows it is a pure indicator, so the browser-global
hostStatusChip setting can switch it to attention (drawn only while
some broker is auth-needed / down / lease — a broker the user HID does
not count) or never; hiding is a hide-broker-chip body class, never
an inline display, so the node stays put as the ws-pager's anchor and
eats no taskbar gap slot.
Four states: green ok / red down / amber password-needed / blue lease (plus a neutral chip while a broker's first poll is still out);
an ok/down chip toggles that host's window visibility, while auth and
lease chips are click-to-log-in / take-over. With two or more brokers
the chips collapse into one worst-state aggregate badge whose click
opens the start (+) menu, where each broker has a live status row with
the same per-host actions (the hide toggle keeps the menu open).
Window prefs are keyed per host; a down host never closes or re-dials
another host's windows.
Remote agents against this broker:
BROWSERLAND_BROKER_URL='ws://broker-host:4445/browserland' \
WEB_TERMINAL_TOKEN='...' ./launchers/run-agent.sh -- bash -lAny producer can register the same way:
BROWSERLAND_BROKER_URL=ws://host:4445/browserland?token=....
A broker can relay an allowlisted set of another broker's HTTP routes, so a
page or a phone that can reach only this broker (say, the public one) still
reaches a broker behind it (on its LAN or tailnet), and that broker needs no
upgrade and no CORS. This broker stores no credential of the peer's
(a socket ticket holds the peer's token in memory until it is
used or its 30 s run out). The
operator names each peer in broker_config.json:
"peers": {"b": {"origin": "http://100.64.0.2:4445", "mcp": false}}-
The peer list is validated at startup — a bad one is one stderr line and
exit 2, like a bad role. An id is 1–64 of
A-Za-z0-9_-; an origin ishttporhttpswith a host and an optional port: no path, no query, no fragment, no userinfo, and never this broker itself (loopback or the bind host on this broker's port). It is stored rebuilt (HTTP://B:80/→http://b; an IPv6 literal compressed,[0:0::1]→[::1]). Unknown keys are refused.mcpdefaults tofalse. -
The client names a peer id, never a URL. The origin is read from the
config at dial time.
GET /infopublishespeersas{id: origin}(never themcpflag, never a token) andpeer_proxy(true when this broker has peers and the proxy extra). -
The
proxyextra. The upstream clients arehttpxandwebsockets15 or later (its asyncio client, for the sockets), installed bypip install -e ".[proxy]"from the checkout; core stayswebsockets12 or later +sanic. One check covers HTTP and sockets: a broker that names peers without both logs one line naming what is missing (peer proxy unavailable: install webterm[proxy]: websockets 13.1 < 15; …) and answers every/peer/*, the ticket mint included, with 503proxy_unavailable.
The two gates. The first decoded segment after /peer/<id>/ decides:
| Gate | Credential | Refusals, in order |
|---|---|---|
| browser routes |
Authorization: Bearer <this broker's token> (the header only) and X-Browserland-Peer-Token: <the peer's browser token> and X-Browserland-Peer-Origin: <the origin that token was paired with>
|
401 auth_required (this broker's token) → 503 proxy_unavailable → 404 unknown_peer → 404 peer_hop_refused / not_relayed → 401 auth_required (no peer token) → 409 peer_origin_changed (the pairing header is missing or names another origin) → 400 bad_scope (a repeated scope header) → 413 body_too_large / 400 bad_length / 408 body_timeout (only a GET or other non-POST/PUT that carries a body, which is drained and dropped) → 503 peer_busy
|
/peer/<id>/mcp/* |
Authorization: Bearer <the peer's MCP token> and nothing of this broker's; X-Browserland-Peer-Origin optional |
401 auth_required (no bearer — checked before any lookup) → 404 unknown_peer (also for a peer without mcp: true; the answer is the same whatever the bearer is, a hub token included) → 403 hub_credential_refused (only for a known mcp: true peer: the bearer, or the X-Browserland-Mcp-Admin header, equals one of this broker's own tokens — browser, MCP, MCP admin or broker admin; compared in constant time, never logged, nothing forwarded; a peer configured with the same token is refused too) → 503 proxy_unavailable → 404 peer_hop_refused / 404 not_relayed → 409 peer_origin_changed (only when the pairing header is sent and names another origin) → 400 bad_scope → 413 body_too_large / 400 bad_length / 408 body_timeout (the body is read whole first) → 503 peer_busy
|
Every refusal happens before anything is dialed. Browser-gate bodies are
{"ok": false, "error": …}; MCP-gate bodies are {"error": …}, the MCP
realm's shape. Each of this broker's own refusals also carries
X-Browserland-Peer-Refusal: <error>, a header a relayed answer can never
carry, so the page acts on this broker's refusals and never on a body a peer
wrote. The 409 is what keeps a token on the machine it was entered for: a
page that has not seen a re-pointed origin cannot send that token to the new
one. Without a bearer the MCP prefix says nothing about which peers exist;
with one, a peer that opted in answers with the peer's own refusal, which
this broker cannot hide (it cannot check the peer's MCP token). Turning mcp: true on publishes the peer's MCP surface at this
broker's address; an agent then adds http://<this broker>/peer/<id> as a
second mcptool host with the peer's MCP token. This broker's MCP token,
scopes and admin credential do not apply to it. That host entry may carry
"peer_origin": the mcptool server then sends it, in this broker's own
canonical form (http://B:4445/ → http://b:4445), as
X-Browserland-Peer-Origin on every call, so a re-pointed peer is refused
here instead of receiving the token. The check runs after the route table,
so a path off the table answers 404 whatever the header says; on a relayed
path a caller with any bearer can still tell a wrong origin (409) from the
right one (the peer's own answer). That is accepted: the origin is operator
configuration, GET /info publishes it to this broker's token holders, and
the MCP relay is opt-in per peer. A value that is not an origin (a path,
a query, userinfo, surrounding spaces) is refused when the server starts,
naming the host; it is never trimmed. Without it, nothing is sent and
nothing changes.
The page. A host record reaches a peer when it carries via: "server",
peerId and peerOrigin (the origin its token was paired with); only the
Hosts row's mode control writes them (a registry import never does).
-
Switching keeps the token only where it was paired with the address
dialed afterwards: direct → peer when the host's url is the peer's
published origin, peer → direct when
peerOriginis the url -- and only when that origin's host is a dotted DNS name: an IP literal (v4 or v6, any spelling), a single-label name (localhost,ip6-localhost,nas, …) or a name under.localhost,.local,.lan,.internal,.home.arpaor.localdomainnever keeps it, since the browser and this broker may resolve it to different machines. A switch drops the learned broker id and the OSC 52 grant, and re-dials that host's sockets. A switch that would make a twin (a second direct record at that url, or a second record for that peer id) is refused like the add form refuses it: nothing written, nothing re-dialed, the Hosts form says "host already configured". -
OSC 52 grants are keyed
[id, url]for a direct host (unchanged) and[id, url, peerId, peerOrigin]for a proxied one, so a re-pointed, re-paired or switched peer loses its grant. -
Whose 401. This broker checks its own bearer first, so a 401 carrying
X-Browserland-Peer-Refusalon a request that sent a peer token is this broker refusing its own password: the page opens this broker's login and answers the caller with its ownhub_auth_required(status 503, made in the page, never sent by a broker). An unmarked 401 is the peer's own; a marked one with no peer token sent is left to the peer's login (it needs a password either way). A wrong token before/infowas ever read fails that read, and the host reads as down. -
/infois read again when this broker's/controlstatus shows a newbootId(it restarted, and itspeersmay have changed). -
No
/state. A proxied host's per-host settings are never fetched or written (the prefix never relays/state); they are the defaults. - Duplicates are per mode: a proxied record is keyed on its peer id, so a direct and a proxied record of one broker are two ways in, not twins.
| Method | Paths |
|---|---|
| GET |
/info, /sessions, /profiles, /profiles/config, /profiles/detect, /recordings, /recording, /recording/notes, /update/check
|
| POST |
/launch, /file/list, /file/read, /file/read_chunk, /file/hash, /file/write, /file/upload, /file/upload_begin, /file/upload_chunk, /file/upload_commit, /file/upload_abort, /file/paste_image, /file/delete, /file/mkdir, /file/copy, /file/move, /file/zip, /file/unzip, /file/stat, /file/setattr, /session/procs, /session/kill, /session/git, /session/mcp, /session/control, /recording/begin, /recording/chunk, /recording/commit, /recording/abort, /recording/delete, /recording/notes, /update/apply, /update/policy, /mods/policy, /profiles/config
|
| GET, PUT | /mod-store/<modId> |
| Method | Paths |
|---|---|
| GET |
/mcp/info, /mcp/terminals, /mcp/profiles, /mcp/desktop
|
| POST |
/mcp/read, /mcp/input, /mcp/reset, /mcp/flush, /mcp/pace, /mcp/launch, /mcp/window, /mcp/desktop, /mcp/knobs
|
Anything absent is denied (exact match, no prefixes). Never relayed:
GET /, /help-corpus.json, /vendor/*, /mods/<id>/<gen>/*,
/mods/install|uninstall|rescan|installed, GET/POST /mcp/config (a
browser-realm route on the peer, so the MCP gate's bearer never goes there; it
answers 404 not_relayed), POST /restart,
WS /browserland, GET/PUT /state and /status/fetch (/ws and
/control cross as sockets). A path naming another /peer/ hop is refused
(peer_hop_refused). OPTIONS is not routed on the prefix (405, never
dialed): the page is same-origin with this broker, so no preflight arises.
Sanic resolves . and .. segments before the relay sees the path
(/peer/b/./info is /peer/b/info; /peer/b/../info is the peer named
info); the relay then decodes the rest once, and the table's path is
what is dialed, never the client's bytes. A body sent with a bodiless
method (GET) is read and dropped, never forwarded.
What crosses. Allowlists both ways. Upstream: Content-Type,
Content-Length (only when a body is sent), Content-Encoding, Accept,
exactly one
X-Browserland-Scope, X-Webterm-Admin only when the caller sent it and
only on the browser gate, X-Browserland-Mcp-Admin only on the MCP gate, and the one Authorization this broker sets (the
peer's token), plus Accept-Encoding: identity, because the relay passes the
peer's bytes through untouched. The upstream client adds no headers of its own (no
User-Agent) and keeps no cookies. This broker's own bearer, the peer-token and pairing headers,
Cookie, Host, Origin, X-Forwarded-* and every hop-by-hop header never
cross, and token / auth are dropped from the forwarded query. Back:
Content-Length, Content-Encoding, Content-Disposition. Set-Cookie,
Location, every Access-Control-* and every cache header (Cache-Control,
ETag, Last-Modified, Expires, Age) are dropped, and every /peer/*
response is Cache-Control: no-store, so a peer cannot pin its answers in
the browser.
A relayed body is never a page. Content-Type is forced:
application/json when the peer said JSON, otherwise
application/octet-stream (a peer's text/html included), and every
/peer/* response — this broker's own refusals too — carries
X-Content-Type-Options: nosniff and
Content-Security-Policy: sandbox; default-src 'none'; frame-ancestors 'none'.
A peer's script on this origin could read every host token the page holds.
Loops, timeouts, load. Redirects are never followed (a 3xx is relayed
without Location; a 1xx is never relayed). A peer's broker_id is
advisory only. The origin's host name is resolved when it is dialed, so name
a peer by an address, or by a name whose DNS you control. The "not this
broker" check catches a typo, not every alias of this machine; the hop
refusal is what stops a loop. Concurrent dials
on the browser gate are capped at 8 per peer within 32 overall; the MCP
gate (whose callers this broker cannot authenticate) has its own pool of
2 per peer within 8, so it can never take a browser-gate slot, and it
reads each request body whole (at most 1 MiB, within 5 s) before it takes a
slot. Over either cap: 503 peer_busy, never a queue. A whole dial ends
after 600 s however steadily the peer drips (504 peer_timeout, or a
dropped connection once the answer has started); a recording download
(GET /recording, up to 256 MiB) gets 3600 s, enough at about
0.6 Mbit/s. Range and If-None-Match are not forwarded: the peer answers
a recording whole, with no partial answer and no ETag. Connect timeout 2.5 s (under the page's 3 s fetch deadline),
read timeout 30 s (past the 15 s /mcp/read wait and the 10 s desktop relay).
A dial that fails any other way (connection refused, reset, a transport
error) answers 502 peer_unreachable.
Bodies stream both ways, one chunk at a time — a 256 MiB recording is never
held whole; a body that dies half-way drops the connection rather than
arriving short. Nothing is retried. Log lines name the peer id, the
method, the table path and the error class — never the upstream URL or its
query (the httpx loggers are held at WARNING for that reason).
A page cannot set headers on a WebSocket handshake, so a proxied host's
/ws and /control take a one-time ticket instead of a token.
-
The mint.
POST /peer/<id>/ws-ticketwith the body{"route": "ws" | "control", "clientId": "<this page's client id>"}(at most 4 KiB) goes through the browser gate above — the same credentials and the same refusals in the same order — and answers{"ok": true, "ticket": "<43 characters>", "ttl": 30}. A body that is not that is 400bad_ticket; aGETis 405. -
The ticket lives 30 s and works once: its first presentation
burns it whatever the outcome, so a ticket shown with the wrong peer,
route or clientId is gone. It is bound to the peer, the route, the
clientId, the peer's origin and this broker's token as they were at the
mint. At most 16 per peer within 64 tickets wait unredeemed; over
either, 503
peer_busy. The page mints at most 4 tickets per host at a time, so a burst of re-dials (a restore, or every window after the peer restarted) waits its turn instead of hitting that cap. -
The socket.
WS /peer/<id>/ws?session=…&clientId=…&ticket=…(or/control?clientId=…&ticket=…) carries the ticket and nothing else. A bad, expired, reused, mis-bound or voided ticket closes it 4403 (this broker's own code; a 4401 on these routes only ever comes from the peer) after the upgrade, before anything is dialed. The page answers a 4403 by minting a new ticket once, at once, and a second 4403 in a row backs off like any failed dial. A ticket is not bound to the browser'sOrigin: it is minted only with this broker's token in a header (never a cookie), so another site cannot mint one, it lives 30 s and works once, and anything outside a browser can send anyOriginit likes. -
The upstream handshake. This broker dials the peer's own
/wsor/controlwith the page's query minusticket,tokenandauth, and with the peer's token only in the handshake'sAuthorizationheader — never in a URI, never logged. The server-side handshake never follows a redirect (a 3xx fails the dial), reads no proxy from the environment, sends noUser-Agentand negotiates no compression. A peer on a build before this one needs nothing new: it reads that header when there is no?token=. - The relay. One upstream socket per page socket. Messages cross unchanged both ways — text stays text, binary stays binary, nothing is parsed (each leg keeps its own framing and its own pings) — and each direction waits for its send before it reads again, with at most 2 messages buffered from the peer, so a slow page slows the peer. Nothing reconnects here: the page's own reattach does, with a new ticket.
Pools. Relayed sockets have a pool of their own, apart from the dial
pools: 32 per peer within 128 sockets. A ticket holds its slot from the
mint, so an upgrade never fails for the pool; the refusal is the mint's
503 peer_busy.
Liveness. A pair is never closed for being quiet: a terminal with no output overnight stays connected. Each leg is pinged every 20 s and dropped after 20 s without an answer — the peer's by this broker, the page's by the server (as every socket of this broker is) — and a leg that drops ends the pair.
Revocation. Every ticket mint and every redemption first closes each
pair, and voids each ticket, whose peer is gone, whose origin is no longer
the configured one, or that was opened under another token of this
broker's: the peer's leg gets 1000, then the page's 4403 revoked. Neither
changes at runtime today — a changed config takes effect on restart; a
future editor calls apply_change after it writes — so existing sockets
stay open until the next mint or redemption, or apply_change.
| Close code | Sent to | When |
|---|---|---|
| the peer's code and reason | the page | the peer closed with a code a close frame may carry |
| 1012 | the page | the peer vanished (no close frame), or its code is one no close frame may carry (1005, 1006, 2000, …) |
1011 peer_unreachable
|
the page | the dial at redemption failed (its slot is released) |
4403 ticket
|
the page | the ticket was bad, expired, reused, mis-bound or voided |
4403 revoked
|
the page (the peer gets 1000) | a peer, origin or token change unbound the pair |
| 4401 | the page | only ever the peer's own (it refused its token), passed through: the page asks for the peer's password |
| 1000 | the peer | the page went: its close, a lost connection, or a close this broker started for it (1009 for an oversized message, say) |
| 1001 | both | this broker is stopping (signalled before Sanic cancels the socket handlers, never guessed from who closed first) |
| 1011 | both | anything else (one log line naming the error class) |
Log lines name the peer id, the route, the first 8 characters of the
clientId and why a pair ended — never a ticket, a token, the upstream URL
or its query (the websockets logger is held at WARNING).
Limits. A peer checks its token at the handshake only, so a relayed socket outlives a change of the peer's token until either side closes it, and the peer's own sockets to other clients are not this broker's to close. The peer sees every proxied client as this broker (its logs lose the client address).
Install (for running and testing):
python3 -m venv .venv # needs Python 3.10+; else e.g. python3.10 -m venv .venv
.venv/bin/pip install -e ".[dev,pyte,proxy,mcp]" sanic-testingThe tests need more than the dev extra: most use Sanic's test client
(sanic-testing, which no extra lists), the peer-proxy tests import httpx
(the proxy extra), and the MCP-server tests skip without mcp.
Zero-setup alternative: launchers/run-broker.sh / run-agent.sh
bootstrap a venv with the runtime deps on first run, using whatever python3
is on PATH (it must be 3.10+) — fine for running,
but they don't install pytest or any extra, so use the editable install for
testing. Both use the same .venv and install only when they create it, so
run run-broker.sh first: a venv that run-agent.sh created holds only
websockets, and the broker then fails to import sanic.
Config: copy broker_config.linux.example.json to
broker_config.json (or point $WEB_TERMINAL_CONFIG at it). Default
profiles are bash (bash -l, the default) and sh (plain sh — the
escape hatch when login-shell rc noise is unwanted). A token is required on
every bind including loopback; set auth_token (or $WEB_TERMINAL_TOKEN)
to choose the value yourself, or let the broker mint one into
webterm_token.json and read it back with --print-token.
systemd: installable units live in launchers/systemd/
(webterm-broker.service, webterm-agent.service). Copy to
/etc/systemd/system/, edit User=, the /opt/web_terminal paths and
the token, then systemctl daemon-reload && systemctl enable --now ....
Caveats: the bash -l profile sources the service user's login profile
(use the sh profile if that produces noise), and /launch-spawned
agents are session leaders that survive an in-app broker restart (the
supervisor stays up) — they re-register within ~10 s of the broker coming
back. systemctl stop / restart of the unit is different: the unit sets no
KillMode=, so systemd's default stops every process in its control group,
agents included.
Tests on Linux: python -m pytest tests -q → the full suite passes;
the Windows-ConPTY e2e suites skip on Linux. This
includes a real-POSIX-PTY suite (tests/test_linux_pty.py) that was
written blind on Windows and verified against a real Linux PTY before
deploying.
Producer → broker: hello (required first frame; same window_id
replaces), title, resized as JSON text; raw ANSI output and
snapshots as unframed binary. Broker → producer: input, resize (reply
resized), snapshot_please (reply one binary full redraw). The full set
lives in webterm/protocol.py: a producer also sends agent (the detected
foreground agent), cwd, mode (DECCKM) and exit, and the broker's
request/reply pairs are procs_please/procs, kill/killed,
git_status_please/git_status, screen_text_please/screen_text,
reset_please/reset_done and flush_input_please/flush_input_done. Browser
attach: broker sends resized before requesting the snapshot. Snapshots
start ESC[0m ESC[2J ESC[H — deliberately no ESC c/ESC[3J, since
already-attached browsers receive them too — and end with a DEC-mode
re-assert postamble (?2004h/l + ?1h/l + mouse reporting, per the
agent's live sniffer; #138, #154) so a reloaded xterm.js recovers
bracketed-paste, application-cursor and mouse-tracking state. Mouse
reporting is two mutually-exclusive groups — tracking level
(9/1000/1002/1003) and extended encoding (1006/1016) — and within
each the postamble emits the resets first and the set last, because
xterm.js maps DECRST of any group member back to the group default, so
a trailing reset would undo the set. Without this, a repainting TUI's
ESC[2J reliably survives raw.py's _trim while the mouse DECSETs it
emitted at startup do not, and every click in lazygit/btop dies at
the first browser reload. The browser→broker paste frame type is a
legacy alias for input (#138), kept only for tabs served before the UI
switched to xterm's paste(). Windows caveat: ConPTY never forwards an
app's ?2004h request to the terminal side (verified live on Server
2022), so for a detected foreground Claude Code — which parses
ESC[200~ regardless — the UI hand-brackets pastes when xterm's own
bracketed-paste mode is off (pasteTextToTerm in the window lifecycle
fragment).
Known limits: no scrollback replay, no alt-screen modeling in snapshots.
Mouse input is forwarded (xterm.js encodes reports onto onData like
any other key), but the sniffer inherits altscreen.py's heuristic
limits — 8-bit CSI (0x9b) is not matched, and 1005/1015 encodings
are untracked because the vendored xterm.js logs both as unsupported.
An outside client (first user: Notifier) can drive one terminal — its
input and its size — without taking the broker's lease, so the desktop keeps
every other terminal. It authenticates with the same browser token as /ws
(no new token class); GET /info carries "terminal_control": 1 on a build
that enforces it. A client feature-detects on that integer.
Every surface carries one view: {"state": "free"|"controlled", "label": <str|null>, "since": <epoch ms|null>, "gen": <int>} (label/since are
null when free). Within one broker run gen is strictly increasing per
terminal id — across a same-id producer reconnect too — so clients may order
views by it and drop any older than the last one they applied. Across a broker
restart it is seeded from the wall clock in milliseconds: that normally keeps
it increasing, but it is not guaranteed if the clock steps back, so a client
re-baselines (forgets its last gen) when the broker restarts.
-
Claim / release, on the controller's own
/ws(so the grant can never outlive the socket): attach as usual (/ws?session=<id>&clientId=<own id>, e.g.ext-<uuid>, never a browser's lease id), then send{"type": "control", "op": "claim", "label": "<str>"}or{"type": "control", "op": "release"}. A claim while another socket holds the terminal takes over. A claim from the holder relabels it at a newgen. The terminal is also released when the controller's socket closes, the terminal exits, or its producer drops or reconnects. A refused op answers{"type": "error", "reason": "bad_label"|"not_controller"|"bad_op"|"unknown_session", "session_id": <id>}and leaves the socket open. -
Label: 1–64 Unicode code points (a JS
lengthcan read up to twice that), shown to the owner verbatim (never normalized). It needs at least one visible character — a letter, number, punctuation or symbol other than the fillers U+115F, U+1160, U+3164, U+FFA0, U+2800, U+17B4, U+17B5 — so a label of only spaces, combining marks, joiners or variation selectors is refused. Refused anywhere: any control, surrogate, unassigned, line/paragraph-separator or format character, except ZWNJ/ZWJ (U+200C/U+200D) — so every bidi control (U+061C, U+200E–200F, U+202A–202E, U+2066–2069) and invisible ones like U+200B/U+FEFF are out. Categories come from the broker's Python Unicode database, so a code point newer than it counts as unassigned. -
The gate: while a controller holds a terminal, only that socket's input
and resize reach the producer. Everyone else's, the lease holder's included,
is dropped, exactly like a non-active socket's. With no controller the lease
rule applies unchanged. A socket that lost the terminal (taken back, or
another claim) watches, and its input is dropped even if its clientId holds
the lease, until it claims again. A controller that dials with a browser's
lease id is one of that browser's sockets: a
become_activeelsewhere closes it with 4409 like the rest, which releases. -
What reaches the producer while controlled: the controller's
input, binary input andresize; legacypasteis gated likeinput, and mouse reports rideinput. Themouseframe type stays a no-op. The snapshot request on every attach, MCP/mcp/input, and the/session/*and/mcp/*management RPCs (read, reset, flush, kill, pace) are not gated: MCP agents are not viewers (they can seecontrolledon/mcp/terminals). -
Size: only the controller resizes; the producer's
resizedecho goes to every viewer as always. Nothing is remembered or restored on release — the next driver's firstresizedecides.
Telling everyone (each change is published to the terminal's /ws
subscribers in gen order, each send bounded at 0.5 s, and every socket ends on
the live view; the /control pushes go out after, so two of them may arrive out
of order — order them by gen). A /ws or /control socket that cannot take a
frame within that bound is closed with 1012, so its page reconnects and re-reads
the state; a controller dropped that way loses control at once:
-
/ws, to every subscriber of that terminal:{"type": "control", ...view}on every change, and once on attach, right afterresizedand before the snapshot. The controller that lost the terminal first gets{"type": "control", "state": "revoked", "by": "browser"|"claim", "gen": <new gen>}. -
/control, to every connected browser:{"type": "terminal_control", "id": <id>, ...view}(a controlled desktop window has closed its/ws, so this is how it hears of a release it did not cause). A terminal that exits while controlled still gets itsfreeview on both surfaces, afterexitand for an id that is already gone, so a page can clear its state. -
GET /sessionsrows:"controller": {"label", "since", "gen"} | null, so a page that boots or rebuilds mid-control re-enters the state.
POST /session/control (browser token) — body {"id": <int>, "op": "release", "gen": <int>, "clientId": "<lease id>"} takes the terminal back
from its controller. The released controller gets revoked (by: "browser")
and keeps its socket as a watcher. Answers, in the order they are checked:
401 auth_required; 400 bad_json / bad_id; 404 unknown_session (also for
a terminal that is exiting); 400 bad_op (op is not "release"); 400 bad_gen (not an integer ≥ 0; a boolean counts as bad); 409 {"ok": false, "error": "not_active"} when another browser holds the lease (clientId,
trimmed, is not the holder) — the same rule as PUT /state, so with no lease
holder at all the browser token alone passes; 409 {"ok": false, "error": "stale_gen", "id", ...live view} when the terminal is free or held at
another gen (a double click or a crossed claim never releases a newer
controller); 200 {"ok": true, "id", ...the new free view}.
The desktop's side (#239). A desktop window whose terminal is controlled
holds no /ws: it closes its socket (handlers nulled), and
attachWebSocket refuses to dial while win.controlled is set, so it receives
nothing and sends nothing. isResizable refuses it too, so no tiling, drag or
browser resize sends a resize, and nothing is queued for later. The page keeps
one record per terminal (<hostId>:<id>, runtime only) fed by all four
surfaces:
- a
/wsframe, a/controlpush or aPOSTanswer is applied only when itsgenis newer than the last one applied; - a
/sessionsrow is applied when that poll was sent after the record last changed, whatever itsgen, which heals a missed push and a broker restart; - a new
/controlsocket to a host forgets that host's gens.
On attach, the resized that comes before the control frame is held until
that frame arrives; a controlled view drops it, so a window never snaps to
the controller's grid. Leaving the state clears the record first and then
reattaches, so the attach's forced initial resize sends this window's size. A
409 not_active from Take back shows the Become active prompt (home broker) or
masks that broker (remote), as a lost lease does.
WS /control carries the single-active-browser lease for one browser profile
(?clientId=<lease id>; without one the socket closes 4400, without the
token 4401). The first socket to connect while nobody holds the lease takes
it (a new gen); the lease is released only when the holder's last /control
socket closes, and nobody else is promoted then. Frames the page sends: {"type": "become_active"},
{"type": "ping", "n": <int>} and desktop_result (the answers to a relayed desktop op). Frames it gets:
-
{"type": "status", "active": <bool>, "activeClientId": <str|null>, "gen": <int>, "bootId": "<str>"}— on connect, afterbecome_active, on release, and wherever a status went out before.genis the broker's lease generation: it bumps on every holder change and restarts with the broker, so a page keys "a new holding" on the pair (bootId,gen) —bootIdnames the broker run. A broker that predates #248 sends neither field. -
{"type": "pong", "n": <int|null>, "active", "activeClientId", "gen", "bootId"}— the answer to a ping, on the same socket.nechoes the ping's nonce (nullif the ping carried no integern); the rest is the live lease, built exactly like a status, so a page whose status went missing re-learns it from any ping, and the page runs it through the same path as a status frame. -
{"type": "session_launched", "id": <int>, "gen": <int>, "bootId": "<str>"}(#262) — to ONE socket of the lease holder, after a successfulPOST /mcp/launchwithallow_desktopon: open windowid. There is no answer. The page parks the id the way its own launch does and opens it once/sessionslists it, but only if that page holds the lease at thatgenand run, its view is fully up, and it has not seen this launch before. A page that predates the frame ignores it. Delivery is best-effort: a frame that lands on a page that cannot open the window (a popped-out window or a background tab of the same profile whose view is not up) is dropped, and the window stays a chip, which any desktop op opens. -
A repeat
become_activefrom the current holder is idempotent. It bumps no generation, closes no socket (no 4409) and tells nobody else: only the sending socket gets the status again. Abecome_activefrom anyone else flips the lease, bumpsgen, closes the previous holder's terminal sockets with 4409, and sends every/controlsocket its status. - Every status or pong send is bounded at 0.5 s (
CONTROL_SEND_TIMEOUT); a socket that cannot take one is closed with 1012 and its page reconnects, so one stalled browser never delays the rest. Releasing the lease closes nothing with 4409.
Page-side liveness. The page pings every 25 s with an increasing n. Only a
pong whose n matches an outstanding ping clears the 10 s pong deadline, and
only a socket whose first status carried gen and bootId gets a deadline at
all, so an old broker is never declared dead. On a missed deadline the page
dials a replacement /control socket and keeps the old one working until the
replacement's first status arrives (make-before-break). A 5 s connect/status
watchdog abandons a socket that has not delivered its first status that soon,
and restarts the 5 s once the socket opens. The home broker's channel down for
10 s shows the "lost contact with this broker — retrying…" notice.
Terminal /ws: not_active. When a browser that does not hold the lease
sends input, a paste, a binary frame or a resize on a terminal /ws, the broker
drops it (as before) and answers {"type": "error", "reason": "not_active", "session": <id>}. It is advisory: sent at most once per socket per lease
generation, and never for a socket refused because of a terminal controller (see
Terminal control) or a revoked one. The page writes nothing to the terminal for
it; it asks the lease channel for the live state (a ping), which settles whether
this browser really lost the lease. PUT /state answers a non-holder 409 {"error": "not_active"} as before.
The broker exposes an opt-in HTTP API (the /mcp/* surface) that an MCP
server wraps as MCP tools so an AI agent can list, observe, drive, and launch
terminals. A ready-to-run server ships in webterm/mcptool/
(stdio transport; pip install -e ".[mcp]"). The token-gated endpoints make
up the contract:
| Method | Path | Purpose |
|---|---|---|
| GET | /mcp/info |
feature flags (allow_launch, terminals #253, default_mode, "admin": <bool> #245, "window_edit": true #243, "allow_desktop": <bool> #244, "mcp_peers": [<id>, …] #261), the broker version and the declared scope echoed back (#230) |
| GET | /mcp/terminals |
list MCP-visible terminals |
| POST | /mcp/read |
render a terminal's screen as plain text |
| POST | /mcp/input |
type into a terminal (readwrite only) |
| POST | /mcp/reset |
clear a terminal's screen-render buffer (readwrite only) |
| POST | /mcp/flush |
discard a terminal's unread queued input (readwrite only) |
| POST | /mcp/pace |
set a terminal's default send_keys pacing (readwrite only) |
| GET | /mcp/profiles |
launchable profile names |
| POST | /mcp/launch |
spawn a new terminal from a profile (every launch mints an ownership handle, #243) |
| POST | /mcp/window |
set a window's scope/mode with its launch handle, or any window with the admin credential #245 (#243) |
| POST | /mcp/knobs |
admin-only: set broker-wide knobs (default_mode/allow_launch/allow_desktop) #245 |
| GET | /mcp/desktop |
read the lease-holding browser's desktop snapshot (#244) |
| POST | /mcp/desktop |
relay one desktop op to the lease-holding browser (#244) |
The interface is disabled by default — nothing under /mcp/* answers until
an admin both enables it and sets a token (below). The broker serves the desktop
UI and this API, not this document, so editing these docs needs no restart.
MCP needs both the master enabled flag and a token. With either
missing every MCP-token /mcp/* request is 403 {"error":"mcp_disabled"}
(the browser-realm /mcp/config below still answers, so you can turn it on).
Get a token any of three ways (precedence env > sidecar > config seed):
-
Control Panel → Access → MCP access → Generate — mints a
token_urlsafe(32)secret and persists it to the sidecar (below). The normal path. - config
mcp_token— a seed value inbroker_config.json. - env
WEB_TERMINAL_MCP_TOKEN— pins the token over both of the above, is never written to disk, and disables UI token edits (the Control Panel showstoken_env_pinned: true).
Pass it on every /mcp/* request as an Authorization: Bearer <token> header
or a ?token=<token> / ?auth=<token> query parameter (compared in constant
time). A missing/invalid token while the feature is enabled is 401
{"error":"auth_required"}.
This MCP token is a separate secret from the browser auth_token and gates
only the /mcp/* data plane. Like every other surface it has no
loopback exemption (MCP is opt-in, so even a local caller needs the token).
CORS preflights exist for
browser callers; a server-side MCP client makes ordinary requests and is not
subject to CORS.
Each terminal has an effective access mode — its per-window override if set,
otherwise the broker-wide default_mode:
| Mode | Effect |
|---|---|
off |
hidden from /mcp/terminals; /mcp/read + /mcp/input → 404 unknown_or_off (the same answer as a window outside the caller's declared scope) |
read |
visible + /mcp/read; /mcp/input → 403 read_only
|
readwrite |
read and /mcp/input
|
-
default_mode(global, defaultoff) — applies to every window without an override; change it live in Control Panel → Access → MCP access orPOST /mcp/config. -
per-window override — set from the window title-bar MCP access menu or
POST /session/mcp, where"mode": nullclears it so the window inheritsdefault_modeagain. Durable: it is stored with the window's scope inwebterm_mcp_windows.json(see the admin surface below) and re-applied whenever the same producer registers again, across an agent reconnect or a broker restart: same window id, same host, same nonzero process id. An agent relaunched under the same window id is a new process, so it starts back atdefault_mode, and so does a reconnecting producer that reports process id 0. -
allow_launch(global, defaultfalse) — independent flag gating/mcp/launchonly.
A client may declare a scope on any /mcp/* request with the header
X-Browserland-Scope: <name> (#230). The name must fullmatch
[A-Za-z0-9][A-Za-z0-9._-]{0,63}: no : (window ids are host:int) and no
whitespace. A missing header or an empty value means unscoped. A value
outside the grammar, or the header sent more than once, is 400 bad_scope
on every token route, checked after mcp_disabled and the token, so a caller
without the token cannot probe names.
- Unscoped sees every window its mode allows, tagged or not: the legacy/admin view.
-
Scoped
Ssees only windows taggedS./mcp/terminalsomits the rest, and every id-taking route answers an out-of-scope window with the same 404unknown_or_offbytes as a missing one, so existence does not leak. An untagged window is invisible to every scoped caller. -
/mcp/terminalsrows carryscope(the window's tag,nullwhen untagged) and/mcp/infoechoes the caller's declaredscope./mcp/profilesignores the scope. - CORS preflights allow the header unconditionally, and every
/mcp/*response lists it inVary. - A window's tag is set by a scoped
/mcp/launch(below) or by the browser-realmPOST /session/mcp(admin surface), and stored with its mode override inwebterm_mcp_windows.json, so both survive a broker restart. -
GET /info(browser token) carries"mcp_scopes": trueon every broker that implements all of this: the partition,/session/mcp'sscope, and a durable scope and raw mode override. A client feature-detects on it; a broker without the key predates all three. The desktop shows its scope controls only for a broker that says so, and there it stops re-asserting its own synced copy of each window's mode: the broker's stored override is the one authority. - A scope is a convention, not a credential: every client holds the same MCP token and may declare any scope, or none.
The shipped MCP server (webterm.mcptool) fails closed: before its first call to
a scoped host it reads /mcp/info, and it refuses every call to that host with
scope_unsupported when the scope echo is absent (an older broker) or differs
(a proxy dropped or rewrote the header).
One designated agent can manage the whole MCP surface on the owner's behalf.
Admin is its own secret — mcp_admin_token, minted in Control Panel → Access → MCP
access (generate = mint/rotate, clear = remove), pinnable with env
WEB_TERMINAL_MCP_ADMIN_TOKEN (same rules as the token pin: env wins, the
value never reaches disk, UI edits are disabled) — and it is unset by
default, so admin is off out of the box. One shared admin token serves every
admin agent; it is separate from both the MCP token and the #191 browser
admin_token (an MCP admin gets no ADMIN_ROUTES).
Wire. The credential travels as the X-Browserland-Mcp-Admin header
in addition to the normal MCP token — never instead of it. A credential in
the URL query string is refused outright (400 admin_token_in_url, both keys
and values scanned in constant time, logged with the route and peer but never
the value), even when the header is also correct, following the #191 rule —
the broker keeps its own access log off, but a reverse proxy or tunnel in
front of it (nginx, tailscale serve) or the browser's history may record
full URLs, so a token in a query string can leak there: the header is the
only accepted channel. A repeated admin header is refused like a repeated
scope header (403 admin_required — fail closed rather than silently
picking one).
The refusal ladder on every token route is fixed order:
mcp_disabled → 401 auth_required → 400 bad_scope → 400
admin_token_in_url (the credential in the query string) → 403
admin_required (the header is present but wrong) → the route's own checks;
the two admin steps exist only while an admin token is configured. A header sent
while no admin token is configured is ignored (that caller is a plain
non-admin, and /mcp/info answers admin: false); an admin-only operation
called without admin also answers 403 admin_required. CORS: the header name
is advertised unconditionally in /mcp/* preflights (the realm is
runtime-mutable, so a cached conditional answer would go stale for the 86400 s
Max-Age), and every /mcp/* response lists it in Vary.
Reach. An admin lists every window — off windows and foreign-scope
windows included, each row carrying its raw scope and effective mode —
and the out-of-scope unknown_or_off filter is skipped for it (reads and
input still require the window's own mode; mode gates terminal I/O, not
visibility). An admin may set any window's scope/mode (POST /mcp/window
without a launch handle, #243), arrange the desktop when allow_desktop is
on (#244), and set the broker-wide knobs (POST /mcp/knobs, below). An admin
that also declares a scope keeps all of this: the scope is ignored for
visibility and reach, and only tags the admin's own launches.
Never. The MCP token, the admin token and enabled stay browser-only —
/mcp/knobs answers 400 bad_key for them before any write.
allow_desktop (Control Panel → Access → MCP access, POST /mcp/config, or an
admin's POST /mcp/knobs; default off) is the master switch for the whole
desktop relay — window arranging and workspace management, admin included.
GET /mcp/info advertises it as "allow_desktop": true|false. The two routes
below are gated by the full /mcp/* ladder and then by the flag (403
desktop_disabled), and they relay each op to the browser holding the
control lease over its /control socket: one frame per op
({"type":"desktop_rpc","rid":<int>,"op":…,"args":…,"gen":<int>,"bootId":<str>}),
one answer per rid. gen is the lease generation the call was made under and
bootId the broker run; rids start at a random offset per run, and the
browser keys the answers it has given on host, run and rid (#250).
-
No lease holder → 409
no_active_browser(fail, never queue); a browser that does not answer within the 10 s end-to-end deadline → 504desktop_timeout; a browser that lost the lease mid-op → 409{"error":"lost_lease","dispatched":true|false}. Each op applies whole or not at all, butdispatched: truemeans a send of the op was started, so a browser may have it and may have applied it before it learned it was deposed: check withgetbefore retrying. Onlydispatched: false(no browser was ever sent the op) promises nothing was applied. The broker never starts sending an op once the lease has moved on; a send it started while the lease was live can still reach the deposed browser, and the agent is then tolddispatched: true. The browser refuses an op from a host whose lease it lost, from an older lease generation, or from another broker run (#250). -
Visibility. Every frame relayed for a NON-admin caller carries
args.visible— the int ids of the caller's visible windows on this broker (effective mode ≠off, in scope), computed at relay time; an admin's frame carriesnull. The browser refusesunknown_windowunless every window the op affects — the named window, plus its whole column while it is tiled for every op exceptfocus, plus both windows' columns fortab_into/split— is in that set, and the broker re-checks every named id before relaying, answering the identical 404unknown_or_offbytes.getfor a non-admin keeps only rows whose id is in the visible set (the broker re-filters rows against its own registry as a belt: identity and titles come from the registry, never from the browser). Workspace ops are admin-only at the broker (403 admin_requiredbefore any relay). -
Ops. Window ops:
get,focus,move_column(to=left/rightor an absolute column index — an index move expels a shared window to its own column first, like a drag),set_width(preset=1/3/1/2/2/3/max),tile,float,set_geometry(x,y,w,h, floating only, browser-clamped, answered with the applied box),tab_into(target),split(target,axis=row/column). Workspace ops (workspaces:list/add/rename/remove/switch/send-window, andmove/color/stack/unstackfor order, colour and stacks) are forwarded to the mod'sctx.commands; the mod being off/absent answers 200{"ok": false, "reason": "inactive"|"absent"}, not an error.workspaces:listanswers the workspaces (each with itscolorandstack), the active index, thestacksand thepalettea colour must come from.rename,moveandcoloraddress a workspace byindexor a stack bystack(exactly one);movetakesdirectionleft/right;colortakes apalettecolour, ornull/""/"none"to clear (anything else isbad_args);stacktakesindexandtarget;unstacktakesindex. A desktop tab loaded before its broker gained an op (opened before an update and not reloaded since) does not know that op and answers it 400bad_op, the same bytes as the broker's own allowlist refusal: reload that tab. -
Chip-only windows (#262). A terminal can be live on the broker but
only a taskbar chip on that desktop: in its session list with no window
(launched over MCP, or closed by the user). Every window op except
get, andworkspaces:send-window, opens such a window first with the sameopenWindowa chip click runs, then applies. The open happens only after the op's arguments and the visibility gate pass (and, forsend-window, the mod answersworkspaces:listand the target is one of its workspaces), so an op refused there opens nothing;tab_into/splitcheck that both windows can open before opening either.move_column/set_widthon a chip that would open floating answerbad_argsnot_tiled, andset_geometryon one that would open tilednot_floating, without opening it. An op can therefore reopen a window the user closed. Onlyfocustakes keyboard focus: every other op, and the open it may do, runs insidewithoutFocus, sobringToFrontmoves no focus,lastTermId,frontIdor focused column (a float still stacks), for the window it names and for the next two frames. A window opened from asession_launchedframe opens the same way. A layout entry with no session is never opened. An op naming an int id the page has not polled yet (just afterlaunch_terminal) first waits for one session refresh, bounded at 2 s, then answers.
GET /mcp/desktop — op get; answers 200 {"ok": true, …snapshot}.
The snapshot (as the lease-holding browser produces it, after the broker's
registry re-identification) carries viewport, mode, focused_column,
workspaces + active_workspace (null with the mod off), home (whether
that browser's home is this broker), and one row per live terminal of this broker (app windows and other hosts'
windows are left out): id (an int), title, kind, tiled, column, row, tabIndex, width_preset (+widthFrac
for a custom width), tabbed/split, minimized, workspace, and
geometry ({x,y,w,h} when floating — per-browser pixel geometry, as that
browser sees it; never stored on the broker; absent when tiled).
chips (#262) lists, separately, the terminals of this broker that are only
a taskbar chip on that desktop (no window yet: launched over MCP, or closed by
the user), as {id, title} with the same identity and visibility rules as the
rows; any arrange op opens one. The rows never hold them. A page or broker that
predates #262 sends no chips (an older broker drops the unknown key whole).
Every top-level field is rebuilt and type-checked broker-side — viewport
ints, mode a short string, focused_column/active_workspace int or null,
home a bool, workspaces a bounded list of small typed rows or null — so
a lying or buggy browser cannot smuggle foreign ids or oversized strings
past the relay. Each workspace row is reduced to {index, id, name} with an
unnamed workspace appearing as name: null (only a present, non-string or
oversized name drops the row), plus its color (#rrggbb or null) and
stack (a stack id or null) when the browser sent them; a top-level
stacks list ({id, name, color, last} rows) appears only when the browser
sent one. A bad value drops that key, never the row, so an older page's
snapshot keeps its exact shape.
POST /mcp/desktop — body {"op": …, "args": …} with the op allowlist
above (else 400 bad_op; a body that is not a JSON object is 400
bad_json); op-specific arg errors answer 400 bad_args. Success is
200 {"ok": true, "value": …} with the op's own answer as value. The browser's own error codes map as:
browser error
|
HTTP | body |
|---|---|---|
unknown_window |
404 |
unknown_or_off — byte-identical to the chokepoint's 404 |
bad_args / bad_op
|
400 |
bad_args / bad_op
|
not_home |
409 | workspace op arrived on a non-home broker's relay |
lost_lease |
409 |
{"error":"lost_lease","dispatched":true} — the browser lost the lease mid-op |
needs_confirm |
409 |
{"error":"needs_confirm","count":<live windows>} — pass force: true
|
last_workspace |
409 | the last workspace cannot be removed |
absent / inactive
|
200 |
{"ok": false, "reason": …} — the workspaces mod is off/absent |
error (op threw) |
502 | desktop_op_failed |
force: true on workspaces:remove moves the windows to the adjacent
workspace; no dialog is ever shown in the browser for an MCP op.
All require a valid MCP token; read/input additionally require the target's
effective mode to permit them.
| Method · Path | Requires | Purpose |
|---|---|---|
GET /mcp/info |
token | feature flags |
GET /mcp/terminals |
token | visible terminals |
POST /mcp/read |
mode ≥ read
|
screen as text |
POST /mcp/input |
mode readwrite
|
send keystrokes |
POST /mcp/reset |
mode readwrite
|
clear the screen-render buffer |
POST /mcp/flush |
mode readwrite
|
discard unread queued input |
POST /mcp/pace |
mode readwrite
|
set the default send_keys pacing |
GET /mcp/profiles |
token | profile names |
POST /mcp/launch |
allow_launch |
spawn a terminal |
POST /mcp/window |
handle or admin | set a window's scope/mode (#243) |
POST /mcp/knobs |
admin credential | broker-wide knobs (#245) |
GET /mcp/desktop |
allow_desktop |
desktop snapshot (#244) |
POST /mcp/desktop |
allow_desktop |
one desktop op (#244) |
GET /mcp/info →
{"ok":true,"allow_launch":false,"terminals":true,"default_mode":"off","version":"1.0.0+ba4b62e", "scope":null,"admin":false,"window_edit":true,"allow_desktop":false,"mcp_peers":[]}.
terminals (#253) is false on a broker that hosts no terminals: every launch
is refused with terminals_disabled whatever allow_launch says. Absent from an
older broker, which launches.
mcp_peers (#261) lists the ids of this broker's peers that have "mcp": true,
sorted: ids only, never an origin or a credential. Absent from an older broker; a
client reads absence as nothing advertised.
version is this broker's build id (webterm.build_version() — package version +
git short hash, or the bare package version off a checkout) for stale-deploy
detection (#22). scope is the caller's own declared scope echoed back: null
when it declared none or a proxy stripped the header, and absent from a broker
that predates scopes. A client that declared a scope checks it here to fail
closed (#230). admin (#245) is true only when this call carries a matching
X-Browserland-Mcp-Admin credential — absent from a pre-#245 broker, which
mcptool reads as "not admin" and fails closed on. window_edit (#243) and
allow_desktop (#244) advertise POST /mcp/window and the desktop relay the
same way; a flag absent from an older broker reads as false.
GET /mcp/terminals → an array; for a non-admin caller, windows whose
effective mode is off are omitted, and so are windows outside the caller's
declared scope (an admin, #245, gets every window — see Reach above). agent is the
detected foreground-agent name ("" when none), kind the producer kind
("agent" vs a non-agent "terminal"), mode the effective access mode,
scope the window's scope tag (null when untagged). version is the
producer's reported build id ("" for a pre-#22 agent / a non-agent producer);
for agent producers a stale boolean flags a
build differing from this broker's (a deploy predating a fix — reliable when
builds carry a git hash). app_cursor is the cached DECCKM (application-cursor
mode) the MCP send_keys reads to pick CSI vs SS3 arrows (#23); pace_ms is the
window's default send_keys inter-key pacing (#133, 0 = single-burst, set via
/mcp/pace) the MCP server reads so a no-delay_ms send auto-paces. pyte (#134)
is whether the agent has pyte installed: false means its read_screen uses the
dependency-free textgrid fallback — no attr_runs (#128) and no keyframe repair
(#130), so a sparse alt-screen frame after ring eviction comes back flagged
partial only. It defaults true for a pre-#134 agent that omits the field.
controlled (#238) is whether an outside client is driving the terminal right
now (see Terminal control); MCP input is not gated by it:
[{"id":4503603655475937,"title":"bash","host":"JC-SERVER","cwd":"/home/me",
"agent":"","kind":"agent","cols":80,"rows":24,"mode":"read","scope":null,
"version":"1.0.0+ba4b62e","stale":false,"app_cursor":false,"pace_ms":0,
"controlled":false,"pyte":true}]POST /mcp/read — body {"id": <int>} plus optional fields:
view: "scrollback" + lines reads scrollback history instead of the live
screen (lines is hard-capped at 1000, #21; the reply echoes view and
history_lines); since (a prior content_hash) asks for a delta reply —
the response always carries a delta boolean, with changed_rows present
only for a real delta (#52); attrs: true opts into an attr_runs styled-run
map (fg/bg/reverse) so a color-only change is observable (#128).
{"ok":true,"id":4503603655475937,"cols":80,"rows":24,"text":"<screen lines>\n...","content_hash":"…","stable_hash":"…","idle_ms":0}Every read also carries alt_screen (the app is on the alternate screen),
app_cursor (DECCKM) and cursor (the cursor position the agent reported).
content_hash is a stable 128-bit digest of the rendered text (#26).
stable_hash (#135) is the same digest with the HARDWARE-cursor cell masked to a
sentinel, so a cursor BLINK in place (the cell toggling between the block glyph
and the character under it) leaves it unchanged while a cursor MOVE — which
exposes a different underlying cell — still changes it. It is always present
(empty string for a degraded read or an older agent) and equals content_hash
when there is no cursor.
idle_ms is a best-effort count of ms since the terminal last emitted PTY output
(a current agent always reports it — 0 means output landed just now; an older
agent omits it, so it reads as unknown rather than a misleading 0). It is
unreliable for a perpetually-animating app that paints every frame (its idle_ms
never grows), so pacing/flush and a semantic content wait are the real settle
signals there, not idle_ms.
The read can also BLOCK for one bounded round-trip instead of returning
immediately, on any ONE of four mutually-exclusive wait signals (combining two →
400 conflicting_wait), all capped by timeout_ms (≤ 15000):
wait_for_change (a prior content_hash — wake when the screen differs, #26),
wait_for_text / wait_for_regex (+ wait_absent — wake when the grid contains
or no longer contains a match, #51), and wait_for_idle (a settle window in ms —
wake once stable_hash has held steady for that many ms, i.e. output went quiet,
#135). The predicate and idle waits set matched (true when satisfied, false on
timeout); the RPC timeout is stretched by timeout_ms so the agent's hold
outlives it. wait_for_idle settles only a CALMER TUI — a fully-animating app
(Dwarf Fortress animates creatures/water/the *PAUSED* marquee off the cursor
every frame) never reaches output-idle, so there use pacing/flush plus a semantic
wait_for_text check, not idle.
The agent renders the screen off its event loop. With pyte it returns the full
grid; without pyte it falls back to a dependency-free in-house emulator
(agent/snapshot/textgrid.py) that still produces a bounded rows×cols
grid — so a full-screen TUI reads as a clean grid (box-drawing/braille intact),
not an unbounded raw-ANSI dump (#15). Both paths are real grid renders, so
"degraded": true is now reserved for the rare last-ditch raw decode (it
no longer appears for ordinary TUIs). The textgrid fallback has no keyframe
repair (that is pyte-only, #130), so a sparse alt-screen frame after ring
eviction comes back flagged "partial": true (#134) — the grid is a real
render, just possibly incomplete; the per-terminal pyte flag on
/mcp/terminals tells a caller up front which path an agent is on. Only
agent producers answer; a non-agent terminal producer has no handler, so the
request times out → 502 {"error":"no_producer_rpc"}.
POST /mcp/input — body {"id": <int>, "data": "<str>"} → {"ok":true}.
Requires effective mode readwrite (else 403 read_only); data must be a
string (else 400 bad_data) and ≤ 256 KiB UTF-8 (else 413
too_large). Forwarded verbatim straight to the PTY and deliberately
bypasses the single-active-browser lease — MCP is its own authorized channel.
It bypasses a terminal's external controller (#238) the same way.
The high-level MCP send_input tool maps newlines in data to a carriage
return before calling this endpoint, so a command submits on PowerShell/PSReadLine
(which treats a bare \n as a soft continuation) and on a Unix shell alike (#13);
the endpoint itself is byte-exact, so drive it directly for a literal LF or
raw-mode bytes.
POST /mcp/reset — body {"id": <int>} → {"ok":true,"id":<int>}. Requires
effective mode readwrite (else 403 read_only). A correlated producer
round-trip (like /mcp/read): the agent clears its PTY-output ring so the next
read_screen renders from a clean slate, then acks. Only agent producers
answer; a non-agent producer times out → 502 no_producer_rpc (and a rare
agent-side failure → 502 reset_failed). It touches Browserland's render
buffer only — it sends nothing to the running app.
POST /mcp/flush — body {"id": <int>} → {"ok":true,"id":<int>}. Requires
effective mode readwrite (else 403 read_only). The input-side mirror
of /mcp/reset: where reset clears the OUTPUT ring, this discards keystrokes
queued toward the app but not yet consumed (a runaway send_keys backlog a
frame-polling TUI hasn't drained), so the next read_screen reflects the settled
state. Same correlated round-trip — only agent producers answer, so a
non-agent producer times out → 502 no_producer_rpc (a rare agent-side
failure → 502 flush_failed). On a Windows/ConPTY agent it is a best-effort
no-op (that backend exposes no input-queue flush primitive) and still acks ok.
POST /mcp/pace — body {"id": <int>, "pace_ms": <int>} →
{"ok":true,"id":<int>,"pace_ms":<clamped int>}. Requires effective mode
readwrite (else 403 read_only). Sets the window's default send_keys
inter-key pacing so a subsequent MCP send_keys that passes no delay_ms
auto-paces (one key per POST) — for a frame-polling raw-input TUI (Dwarf Fortress)
that drops a burst read faster than it renders. pace_ms must be an integer (else
400 bad_pace) and is clamped to [0, 1000] (0 disables → single-burst;
an over-cap value pins to 1000). Unlike /mcp/reset//mcp/flush this is
broker-local with no producer round-trip — it just stamps the window's
in-memory pace_ms, which /mcp/terminals surfaces for the MCP server's
client-side pacer. The value is ephemeral per-connection: it resets whenever
the window registers again (agent reconnect or relaunch). Unlike a per-window
mode override, it never carries over a reconnect.
GET /mcp/profiles → {"default":"cmd","profiles":["cmd","powershell"]}
(the broker's configured agent.profiles; bash/sh on Linux). Profiles
are global: a declared scope never changes this list, and a scoped caller may
launch any of them.
POST /mcp/launch — after the shared /mcp/* refusals (403
mcp_disabled, 401 auth_required, the scope and admin-header errors)
it is refused with 503 restarting while the broker quiesces (#260, the
same order POST /launch uses, before every refusal below and before the body
is parsed), then with 403 terminals_disabled when the
broker has terminals: false (#253, checked before allow_launch, whatever it
says), then requires allow_launch (else 403 launch_disabled).
Body reuses the /launch shape plus an MCP-only mode: {"profile": <str>, "cols": 80, "rows": 24, "title": <str>, "cwd": <str>, "mode": "off"|"read"|"readwrite"}, all optional (dims default 80×24 and are clamped to
1–1000, title is cut to 256 characters, profile defaults to the broker default; cwd must be an existing dir; an invalid
mode is 400 bad_mode, scoped or not, and null counts as absent).
Response is the launcher's:
{"ok":true,"id":4503603655475937,"registered":true,"agent_pid":12345}200 when the agent registered within 10 s, 202 when it spawned but had
not said hello yet ("registered":false).
After either (#262), when allow_desktop is on, the broker sends the lease
holder a session_launched frame for the new id in a detached task, so the
browser opens the window by itself. The reply never waits for it, and a launch
with no lease holder sends nothing (the window stays a chip that any desktop
op opens). A failed launch sends nothing.
Every MCP launch, scoped or not, takes the same path (#243): the window's
row in webterm_mcp_windows.json is written before the agent is spawned, with
a null pid and host, and the agent's first hello claims it — even a hello
that reaches a restarted broker after a 202. The window comes up in the body's
mode, or in readwrite when there is none (not default_mode,
scoped or unscoped alike: a launched worker is meant to be driven by its
launcher, and allow_launch is already its own opt-in). The broker also mints
a launch handle — secrets.token_urlsafe(32) — and stores only its SHA-256
hex digest as the row's owner; the plaintext handle is returned in the
response ("handle": "…", alongside "scope" and "mode"; "scope" is
null for an unscoped launch) and nowhere else. An unscoped launch's row
survives on its owner (and mode) even with no scope. If the launch fails
after the row was written, the row is removed again — unless the agent's
hello already claimed it. A launch whose new window id already has a stored
row retries once on a fresh id; the collided row is deleted only when it
is a pre-spawn orphan (no pid and no host) or older than the 7-day prune age
— never a row a reconnecting producer could still claim, whose off/scope
policy stays untouched (the retry runs whether or not the row was deleted).
Only a row write that fails, or a retry whose fresh id collides again, is
500 prespawn_failed, and nothing is spawned. The browser's
/launch is unchanged: it never reads mode and writes no row.
POST /mcp/window — body {"id": <int>, "handle"?: <str>, "scope"?: <str>|null, "mode"?: "off"|"read"|"readwrite"|null} (#243). Sets the scope
and/or MCP mode of one window; an absent key leaves its field, an explicit
null clears it (/session/mcp's body rules, except that an empty scope
string is 400 bad_scope here: clear a scope with null). Two paths, tried in
order:
-
handle (preferred, admin included): the row must carry an
owner, the caller's handle must hash to it (constant-time compare), and a live entry for the id must be the row's own producer. The handle bypasses the off/out-of-scope filter — an owner can set its window tooff, or to a scope it does not hold, and undo it. A matching-handle write keeps the owner. - no handle: requires the admin credential (a non-admin call without a handle gets the same 404 as a failed handle); the write targets any window with a live entry or a stored row, and clears the old owner (a stale agent handle cannot undo an admin decision) while stamping the window as admin-driven (the taskbar badge).
Every handle-path failure — a wrong or missing handle, an unknown id, an
unowned row, a foreign producer — answers the identical 404
unknown_or_off bytes as every other id-taking route, so the route cannot
be used to probe which ids exist. Validation (bad_json/bad_id, bad_mode,
bad_scope — 400) runs before any write, and the authorization is re-checked
inside the store's single locked write, so an admin's owner-clear landing
between validation and commit turns a stale handle into the same 404 rather
than a write. The change persists across a broker restart and is re-applied on
the same producer's reconnect; a /session/mcp edit clears the row's owner,
after which the old handle gets the 404; a pre-spawn row (202 window before
its first hello) is editable by its owner. GET /mcp/info answers
"window_edit": true on a broker that has this route.
| Status | error |
When |
|---|---|---|
| 403 | mcp_disabled |
feature disabled or no token configured |
| 401 | auth_required |
missing/invalid MCP token |
| 400 | bad_scope |
X-Browserland-Scope is not a valid scope name, or is sent more than once (so a proxy that adds its own copy makes every call a 400); every token route, checked after mcp_disabled and the token. An empty value is not refused: it means unscoped, which sees every window its mode allows. Also POST /mcp/window with a body scope that is not a valid name or null
|
| 400 | bad_json |
body is not a JSON object |
| 400 | bad_request |
/mcp/launch body is valid JSON but not an object |
| 400 | bad_id |
id missing or not convertible to an integer (int(): "12" and 7.5 are accepted and converted) |
| 404 | unknown_or_off |
no such window, or its effective mode is off, or it is outside the caller's declared scope; on POST /mcp/window (#243) every handle-path failure answers these same bytes, so ids cannot be probed |
| 400 | bad_data |
/mcp/input data is not a string |
| 403 | read_only |
/mcp/input on a window not in readwrite
|
| 413 | too_large |
/mcp/input payload > 256 KiB |
| 400 | bad_pace |
/mcp/pace pace_ms missing or not convertible to an integer |
| 400 | bad_regex |
/mcp/read wait_for_regex does not compile (the body carries detail) |
| 400 | conflicting_wait |
/mcp/read with more than one of wait_for_change / wait_for_text / wait_for_regex / wait_for_idle
|
| 502 | no_producer_rpc |
/mcp/read · /mcp/reset · /mcp/flush producer did not answer (non-agent, timeout, too many requests already in flight on that window, or the producer disconnected) |
| 502 | reset_failed |
/mcp/reset agent could not clear its render buffer |
| 502 | flush_failed |
/mcp/flush agent could not flush its pending input |
| 403 | terminals_disabled |
/mcp/launch on a broker with terminals: false (#253); checked before launch_disabled
|
| 403 | launch_disabled |
/mcp/launch with allow_launch:false
|
| 400 | unknown_profile |
/mcp/launch profile not in config |
| 400 |
bad_dims / bad_cwd / cwd_not_dir
|
/mcp/launch bad cols/rows/cwd
|
| 429 | too_many_pending_launches |
/mcp/launch backpressure |
| 400 | bad_mode |
/mcp/launch mode is not off/read/readwrite (or null); POST /mcp/window with such a mode, or with neither mode nor scope; POST /mcp/knobs with such a default_mode
|
| 500 |
spawn_failed / agent_exited_early
|
/mcp/launch agent failed to start (spawn_failed: <reason> carries the OS error after the colon; agent_exited_early adds the agent's returncode) |
| 500 | prespawn_failed |
/mcp/launch: the new window's row could not be written, or the one retry's fresh id also had a stored row; nothing was spawned |
| 400 | admin_token_in_url |
an MCP admin credential was sent in the URL query string (#245) — the header is the only accepted channel, and the refusal holds even when the header is also correct |
| 403 | admin_required |
the X-Browserland-Mcp-Admin header is present but wrong, or an admin-only operation was called without admin (#245) |
| 400 | bad_key |
POST /mcp/knobs carries a key outside its three knobs — token, admin_token and enabled are browser-only (#245) |
| 400 | bad_value |
POST /mcp/knobs boolean knob is not a real JSON boolean ("false" is refused, not coerced) |
| 403 | desktop_disabled |
the desktop relay's allow_desktop switch is off (#244) |
| 409 | no_active_browser |
/mcp/desktop with no browser holding the control lease (ops are never queued) |
| 504 | desktop_timeout |
the lease-holding browser did not answer the desktop op in time |
| 409 | not_home |
a workspaces:* op arrived on a relay whose lease holder's home is another broker |
| 409 | lost_lease |
the browser lost the control lease mid-op. dispatched: false: the op was never sent, so nothing was applied; dispatched: true: it may have applied, so check before retrying (#250) |
| 409 | needs_confirm |
workspaces:remove of a non-empty workspace without force: true — the body carries the live window count
|
| 409 | last_workspace |
the last workspace cannot be removed |
| 404 | unknown_peer |
through a broker's /peer/<id>/mcp prefix: no peer <id>, or it is not configured with mcp: true (#252) |
| 503 | proxy_unavailable |
through a broker's /peer/<id>/mcp prefix: that broker lacks the proxy extra (#252) |
| 409 | peer_origin_changed |
through a broker's /peer/<id>/mcp prefix: the mcptool host's peer_origin is not the address that broker now dials for the peer (#252) |
| 503 | peer_busy |
through a broker's /peer/<id>/mcp prefix: over the relay's own pool, 2 calls per peer within 8 (a waiting read_screen holds one for up to 15 s); the call is never sent and never queued (#252) |
| 504 | peer_timeout |
through a broker's /peer/<id>/mcp prefix: the peer did not answer within the relay's timeouts (#252) |
| 502 | peer_unreachable |
through a broker's /peer/<id>/mcp prefix: the broker could not reach the peer (#252) |
| 404 | not_relayed |
through a broker's /peer/<id>/mcp prefix: the path is not one of the relayed MCP routes (#252) |
| 404 | peer_hop_refused |
through a broker's /peer/<id>/mcp prefix: the path names another /peer/ hop: one hop only (#252) |
| 413 | body_too_large |
through a broker's /peer/<id>/mcp prefix: the request body is over 1 MiB; refused before any dial (#252) |
| 408 | body_timeout |
through a broker's /peer/<id>/mcp prefix: the whole request body did not arrive within 5 s; refused before any dial (#252) |
| 400 | bad_length |
through a broker's /peer/<id>/mcp prefix: the request's Content-Length is not an integer; refused before any dial (#252) |
| 403 | hub_credential_refused |
through a broker's /peer/<id>/mcp prefix, for a peer that exists and has mcp: true (an unknown or not-opted-in peer answers unknown_peer instead): the bearer (or the X-Browserland-Mcp-Admin header) is one of that broker's own tokens, which is never sent to a peer, a peer given the same token included; nothing is forwarded (#261) |
| 502 | desktop_op_failed |
the browser's desktop op raised |
| 400 |
bad_op / bad_args
|
/mcp/desktop op outside the allowlist / op arguments invalid |
Raised by the webterm.mcptool client itself, never sent by a broker (its status is 0 and no request was made, or none was answered): connection_error, scope_unsupported (above) and token_placeholder (a hosts entry still holding the placeholder that Control Panel → Copy .mcp.json writes for a peer; nothing is sent until the peer's own token replaces it).
These configure MCP and are not part of the MCP token's surface. They use
the browser auth_token (the same mandatory-token gate as /state and
/file/*): always required, on every interface, as Authorization: Bearer or
?token=.
GET /mcp/config →
{"ok":true,"enabled":false,"token":"","admin_token":"","admin_token_env_pinned":false, "default_mode":"off","allow_launch":false,"allow_desktop":false, "token_env_pinned":false,"known_scopes":[],"python":"/usr/bin/python3", "pythonpath":"/opt/browserland","local_url":"http://127.0.0.1:4445"} (token
and admin_token are the live secrets, "" when unset). admin_token (#245)
is the MCP admin credential, shown to the browser-token holder like token
(the browser is the root of trust); admin_token_env_pinned mirrors
token_env_pinned for it — WEB_TERMINAL_MCP_ADMIN_TOKEN pins the admin
token (env wins over the sidecar, the value never reaches disk, and the
Control Panel's admin controls are disabled). allow_desktop (#244) is the
desktop relay's master switch — a browser checkbox and an admin knob.
known_scopes lists the scopes on live
windows (any mode) and on stored per-window rows, sorted; a name a caller only
declared on the header never appears. python is the interpreter running this
broker (sys.executable), pythonpath the directory holding the webterm
package it runs, and local_url a loopback URL on the port this request
arrived on (the configured port when there is no socket to read); the Control
Panel's Copy .mcp.json builds a client file from them (#235). They are
absolute server paths, shown only to a holder of the browser token.
POST /mcp/config — partial update of any of enabled, default_mode
(off/read/readwrite), allow_launch, allow_desktop (#244), token,
generate:true (mint a fresh token_urlsafe(32)), admin_token (the #245
credential: null or "" clears it; any other string is refused) or
admin_generate:true (mint/rotate — a server-minted value only, never a
client-chosen secret). Validated before any write; returns the GET shape. While
the env pins the token, token/generate edits are ignored (the live token
stays the env value), and the same rule holds for admin_token/admin_generate
under WEB_TERMINAL_MCP_ADMIN_TOKEN. Errors: bad_json (400), bad_mode
(400), bad_token (400, non-string token/admin_token, or a non-empty
admin_token string).
POST /mcp/knobs (#245, admin credential required) — body
{"default_mode"?: "off"|"read"|"readwrite", "allow_launch"?: bool, "allow_desktop"?: bool} →
{"ok":true,"default_mode":…,"allow_launch":…,"allow_desktop":…}.
The admin agent's broker-wide
knobs, validated before any write: an unknown key — in particular token,
admin_token or enabled, which stay browser-only — is 400 bad_key, and
the booleans must be real JSON booleans (a string "false" is truthy junk →
400 bad_value, not coerced: the browser realm's /mcp/config keeps its
historical coercion as pinned wire). A non-admin call is 403
admin_required before any validation; a body that is not a JSON object is
400 bad_json, and a default_mode outside the three modes 400
bad_mode. The write shares /mcp/config's locked sidecar writer, so the
two routes never interleave; a failed sidecar write answers 500 with the
OS error as error.
POST /session/mcp — body {"id": <int>, "mode": "off"|"read"|"readwrite"|null, "scope": <str>|null}, both keys optional →
{"ok":true,"id":<int>,"mode":<str|null>,"scope":<str|null>}. Sets the
window's per-window mode override, its scope, or both; a key left out leaves
that field as it was. "mode": null clears the override (the window inherits
default_mode); "scope": null or "" untags the window; a scope name matches
[A-Za-z0-9][A-Za-z0-9._-]{0,63}. The response carries the stored values: mode
is the RAW override (null while inheriting), never the effective mode. Every
change is written to webterm_mcp_windows.json before it takes effect, so it
survives a broker restart (see Access modes), and an identical repeat writes
nothing. A browser-side edit also clears the row's owner (#243) — the human
decision wins over the launching agent's handle, which stops working at once.
Errors, in the order they are checked: bad_json / bad_id (400),
unknown_session (404, no live window with that id; nothing is stored),
bad_mode (400, an invalid mode, or neither mode nor scope given),
bad_scope (400), write_failed (500, the store could not be written; nothing
changed).
Sidecar webterm_mcp.json — the durable MCP config, written atomically next
to the /state store (default <state_path dir>/webterm_mcp.json; override with
config mcp_state_path). Schema:
{"token": "<secret or null>", "admin_token": "<secret or null>",
"default_mode": "off", "allow_launch": false,
"allow_desktop": false, "enabled": true}token is null when the env pins it (the secret stays off disk), and so is
admin_token under its own pin; allow_desktop (#244) is the desktop relay's
master switch, default off. The file self-heals if hand-edited or
truncated. It holds only these broker-wide knobs: per-window modes and scopes
live in webterm_mcp_windows.json, below.
Sidecar webterm_mcp_windows.json — the durable per-window MCP store,
written atomically next to the /state store (default
<state_path dir>/webterm_mcp_windows.json; override with config
mcp_windows_path). One row per window id:
{"windows": {"<window id>": {"scope": "<str or null>", "mode": "<str or null>",
"owner": "<64-hex sha256 or null>",
"admin_set": false,
"pid": 12345, "host": "<str or null>",
"seen": 1790000000}}}mode is the RAW override (null = inherit default_mode), owner (#243)
is the SHA-256 hex digest of a launch handle (never the handle itself), and
admin_set (#245) marks a window an admin credential launched or last
re-tagged (it feeds the taskbar's mcp_admin flag on /sessions). A row
carrying none of scope, mode or owner is deleted. pid (an int or null) and
host name
the producer the row was written for. Window ids are reused (an OS window
handle is recycled), so a row is re-applied only to a hello from the same host
with the same nonzero pid; a null pid or host is a wildcard, claimed by the
first hello with a nonzero pid that matches the rest (an /mcp/launch
writes its row that way, before the agent exists). seen (epoch seconds)
moves whenever a hello re-applies the row or a write changes it, and once a day
while the window stays connected; the hello bumps reach disk through a
coalesced writer, at most once a minute. Rows are never pruned at load, nor
during the first 10 minutes after the broker starts (every window gets that
long to reconnect). After that, each write drops rows whose window is not
connected and that were not seen for 7 days, then, while more than 1000 rows
remain, the oldest rows whose window is not connected (a connected window's
row is never dropped). A missing, unreadable, wrong-shaped or over-8-MiB file
starts the store empty and a row with a bad scope, mode, pid, host or
seen is dropped, both logged (a bad owner or admin_set alone is reset to
null and the row kept); the next change
rewrites the file.
Precedence. Token: env WEB_TERMINAL_MCP_TOKEN > sidecar token > config
mcp_token. Effective mode: per-window override > global default_mode.
The Control Panel's per-host tabs (#255). The Control Panel edits these
routes, the host's /state settings and /profiles/config for one host at a
time: the host of the open tab, held as settingsTarget (the Browser tab
keeps the previous host's target). Two seams are safe by construction rather
than by a check, and a change there must keep them so. First, four families
of writers resolve settingsTarget alone when they fire, with no tab check:
the MCP access settings (saveMcpConfig), the size / strip / close / confirm
/ snap / slide / tiling inputs, the default-profile select, and keybinding
Set / Clear. While a remote tab loads, the target is null and they do
nothing; on the Browser tab it is still the previous host, and they are safe
only because their pane is hidden there. Second, only a tab switch closes an
open profile Add / Edit dialog (cancelProfileDialog, called from
blankHostSections); removing its host or closing the Control Panel leaves
it open. Its Save then goes through profileHostIsCurrent: after a removal
the open tab has fallen back to the local host, so the save is refused; after
a Control Panel close the tab still names the dialog's host, so the save
lands there, as it would have with the panel open.
The Control Panel edits the launch-profile allow-list here. Same mandatory
browser-token gate as /mcp/config above — never the MCP token, so the
commands (the RCE-by-design half of profiles-only) only ever travel to an
already-authenticated browser. /profiles and /mcp/profiles stay names
only, so an MCP/AI agent still can't read a command or define a profile.
GET /profiles/config → the full objects for this host:
{"ok":true,"default_profile":"cmd","profiles":{"cmd":{"command":[...],"title":..., "cwd":...,"color":...}},"os":"windows|posix","source":"config|sidecar","exists":{"cmd":true}}.
exists[name] is shutil.which(command[0]) is not None (a red flag for a
shell that isn't installed).
POST /profiles/config — replace semantics; body
{"profiles":{...},"default_profile":"..."}. Validated in full before any
write (a bad field changes nothing), then written to the sidecar atomically and
the live launcher is swapped — no restart. Returns the GET shape. Errors
(all 400 unless noted): too_large (413, body > 256 KiB), bad_json,
bad_profiles (not a dict), no_profiles (empty — would brick /launch),
too_many_profiles (> 200), bad_name (empty / > 64 chars / control chars /
outside [A-Za-z0-9 ._+-]), bad_profile, bad_command (not a non-empty list
of non-empty control-char-free strings), command_too_long,
command_token_too_long, bad_title/title_too_long, bad_cwd/cwd_too_long, bad_color (a color
that is not #rrggbb, #115), bad_default (default_profile not a string),
default_not_member, and 403 terminals_disabled on a terminals: false
broker (#253). An empty default_profile resolves to the first profile.
GET /profiles/detect → read-only environment scan seeding the editor:
{"ok":true,"suggestions":[{"name","title","command","exists"}]}. Windows lists
WSL distros (wsl.exe -l -q); POSIX lists installed bash/zsh/fish/sh.
Never errors on a missing tool — the list is just empty; a terminals: false
broker answers 403 terminals_disabled (#253). Runs off the event loop.
Sidecar webterm_profiles.json — the durable profile set, written atomically
next to the /state store (default <state_path dir>/webterm_profiles.json;
override with config profiles_state_path). Schema:
{"profiles": {"<name>": {"command": ["..."], "title": null, "cwd": null, "color": null}},
"default_profile": "<name>"}Seeded from broker_config.json's agent.profiles; once written it owns the
set (agent.profiles becomes seed-only). Self-heals — a missing/corrupt/empty
sidecar falls back to the seed, never bricking startup or /launch. Full recipe
catalog + the security rationale: Launch-Profiles.
The desktop's mod system. Authoring contract, the ctx API and the trust model:
Writing-a-Mod. All of these except POST /mods/policy are serve_ui-gated — a node
(serve_ui: false) registers none of them and reports mods: [] — and, like
/mcp/config, they take the browser token, never the MCP one. With a
config admin_token set (#191), install, uninstall, rescan and policy also
need it in the X-Webterm-Admin header (403 admin_required without it,
400 admin_token_in_url if it rides the query); /mods/installed needs
the page token only. They are
deliberately not lease-gated: this is broker configuration, and the point is
being able to administer a broker somebody else is using right now.
The admin token guards routes, not files. The admin token does not stop a
holder of the page token from writing files into mods_dir through /file/*:
those routes are host-wide (no root confines them), carry no admin gate, and a
broker that serves the UI scans mods_dir at its next boot and serves whatever
passes the scanner there. That boot-time scan applies the same rules as POST /mods/rescan and asks for no admin header. Those rules are coherence checks,
not a barrier against someone who can write mods_dir: a generation whose own
.gen.json says the store installed it is not re-linted for permissions, and
a writer can write that file too. So with admin_token set, install,
uninstall, rescan and the two policy writes still need the header, but a
page-token holder can still get a package served after a restart by writing it
into mods_dir directly. A check that refuses /file/* writes inside
mods_dir is not part of 1.0; keep mods_dir as protected as the broker's own
configuration.
The admin token is typed into the page the mods run in. The Admin token
required dialog is page code, the page holds the token in memory, and page
code adds the X-Webterm-Admin header. Every installed mod runs in that same
page with the page's full authority, and its script is fetched and run even
while it is unchecked in the Mods pane. So a hostile installed mod can read the
admin token the next time you type it, or send its own admin request once you
have. The admin token ends the standing ability of the page token, and of
every mod holding it, to install the next mod; it does not protect an admin act
made from a page where a hostile mod is loaded. ?nomods=1 loads no mod, but
it mounts no Mods pane either, so install and uninstall are not on that page.
When you do not trust every installed mod, make the admin request from outside
the page, with B, AUTH and ADMIN set to the broker's URL, page token and
admin token, for example:
curl -s -X POST "$B/mods/uninstall" \
-H "Authorization: Bearer $AUTH" -H "X-Webterm-Admin: $ADMIN" \
-H 'content-type: application/json' -d '{"id":"x-notes"}'A mod is not sandboxed. It runs same-origin with the page's full authority
and can reach /file/*, /session/*, /state and /mcp/* directly; ctx
grants it nothing it lacks. So the trust event is the token-gated install,
not the fetch that follows.
POST /mods/policy — a patch of this broker's mod pins,
{"set": {modId: true|false|null}} (null clears a pin), applied to every
browser that loads its page (an absent id leaves the choice to that browser).
Answers {"ok":true,"policy":…}; refuses bad_set (400, not a non-empty
object), too_many (413, over 256 pins), bad_mod_id and bad_pin (400),
all before any write. Stored in the webterm_mod_policy.json sidecar beside the /state
store (override with config mod_policy_path); reported by GET /info as
mod_policy. Applies on the next page load.
POST /mods/install — body
{"manifest": {…}, "files": {"<name>": "<text>"}, "replace": false}, ≤ 2 MiB,
validated entirely in memory before a byte is written. The mod id must match
[a-z0-9][a-z0-9-]{0,63} and start with x- (reserved_id otherwise — the
un-prefixed namespace is reserved for shipped mods). manifest.scripts is a
required non-empty list of .js names; entry and every other unknown key are
rejected, not ignored. Files are text only (.js/.css/.md), UTF-8,
BOM-free, newline-terminated, ≤ ui._MAX_LINES lines; a .css may not use
@import, contain http:// or https:// anywhere outside a comment, contain
a backslash (a CSS escape) outside a comment, or start a url() argument or
string with //. A manifest permissions list (from
clipboard, egress, file, remote-admin, session, #193) must declare
what the source uses. Caps: 32 mods, 32 files/mod,
256 KiB/file, 512 KiB/mod. Returns
{"ok":true,"id","gen","replaced","adopts_existing_state":{"mod_store","pin"}, "mod":{…},"applies":"next_page_load"}. Errors (each distinct): too_large
(413), bad_json, bad_mod_id, bad_generation, reserved_id, id_in_use
(409), bad_file_name, reserved_file_name, too_many_files, file_too_large
(413), total_too_large (413), bad_encoding, bad_scripts, bad_styles,
bad_requires, bad_manifest_field, unknown_manifest_key,
css_external_reference, undeclared_capability (the source uses what
permissions omits), too_many_mods (409), write_failed (500).
POST /mods/uninstall — {"id": "x-notes", "purge": false}. purge:true
also clears this broker's /mod-store/<id> and its pin (lock order
mods_install → mod_policy → modstore, data-first/code-last); it cannot touch
what other browsers hold in localStorage. Not idempotent at the HTTP level:
a retry after a lost response answers 404 not_installed even though the first
attempt succeeded — treat that as possible success.
POST /mods/rescan — re-read mods_dir and sweep litter. An operator
convenience, not a trust boundary (anyone who can write mods_dir can write
the broker's source tree); the scanner refuses symlinks/reparse points, refuses
anything whose realpath leaves mods_dir, refuses a non-x- directory, and
validates every byte under the same rules an install obeys. The destructive
sweep refuses to run in a directory without the .browserland-mods marker.
GET /mods/installed — the operator detail /info leaves out: mods_dir,
per-file sizes and sha256s, installed_at, declared tiers, has_help, the
effective limits, and what was skipped and why.
GET /mods/<modId>/<gen>/<name> — one file of one generation of one
installed mod. Public, and forced rather than chosen: a <script src>
cannot carry an Authorization header, ?token= is structurally banned (#144),
and a fetch+blob: workaround would need blob: in script-src. Same
posture as GET /, which already carries every shipped mod's source. Served
from an in-memory allowlist dict keyed "<id>/<gen>/<name>" — a client-supplied
segment can only ever hit a known key, so traversal is unrepresentable rather
than defended against, and no blocking IO reaches the event loop. Only
.js/.css are servable. Cache-Control: public, max-age=31536000, immutable
is honest because <gen> is a content hash. Installed source and styles are
therefore readable by anyone who knows the id, the generation and the file name
— do not put a secret in a mod. help.md is not servable here at all
(content_type returns None for it); its only surface is
/help-corpus.json, which since #173 withholds the installed sections — and so
the installed ids — from a caller with no token.
<gen> is not a second secret. compute_gen is sha256 over the canonical
manifest plus the sorted (name, sha256) pairs — no salt, no broker id, no
install timestamp — so anyone holding the exact bytes of a distributed package
recomputes the gen this broker stored, and a 200 here rather than a 404
confirms it is installed. That is a confirmation oracle over a candidate set
the caller already has, not enumeration: a mod whose bytes were never
published stays unguessable in both segments, and what leaks about a public one
is one bit about code this route serves in full anyway. #173 is therefore
partial by construction — it stops the corpus listing installed ids, it
cannot hide a publicly distributed mod from someone who asks for it by name.
Salting the gen would close it and is deliberately not done: a generation's
directory name must content-address its own bytes (the scanner refuses one that
does not), which is what makes reinstalling identical bytes a no-op, keeps two
brokers' URLs for one package agreeing, and makes immutable honest.
Store mods_dir — runtime-installed mods live in a directory beside the
/state store (default <state dir>/webterm_mods; override with config
mods_dir), deliberately not webterm/broker/mods/, whose drift guard is
bidirectional. Layout <id>/CURRENT (the atomic {"gen": …} commit pointer) +
<id>/<gen>/ holding mod.json, the mod's files and broker-written
.gen.json. gen is a sha256 over the canonical manifest plus the sorted
(name, sha256) pairs; two generations are retained, so a page that started
booting against one cannot be handed a file from the next mid-flight.
Install/uninstall applies on the NEXT PAGE LOAD, never live, and that is
forced: global lexical bindings cannot be removed, so re-executing a mod script
in the same page dies with SyntaxError: Identifier … has already been declared. ?nomods=1 on the desktop URL boots with no mod fetched or
initialized at all — the rescue hatch when an installed mod makes the Control
Panel unreachable.
B=http://127.0.0.1:4445
# Admin (add -H "Authorization: Bearer $AUTH" — a token is always required):
# enable MCP, mint a token, default new windows to read, allow launching.
curl -s -X POST $B/mcp/config -H 'content-type: application/json' \
-d '{"enabled":true,"generate":true,"default_mode":"read","allow_launch":true}'
# -> {"ok":true,"enabled":true,"token":"abc123...","default_mode":"read",...}
TOK='abc123...' # the token from above
curl -s $B/mcp/info -H "Authorization: Bearer $TOK"
curl -s $B/mcp/terminals -H "Authorization: Bearer $TOK"
curl -s $B/mcp/profiles -H "Authorization: Bearer $TOK"
# Read a terminal's screen (use an id from /mcp/terminals):
curl -s -X POST $B/mcp/read -H "Authorization: Bearer $TOK" \
-H 'content-type: application/json' -d '{"id":4503603655475937}'
# Promote that window to readwrite (admin), then type into it:
curl -s -X POST $B/session/mcp -H 'content-type: application/json' \
-d '{"id":4503603655475937,"mode":"readwrite"}'
curl -s -X POST $B/mcp/input -H "Authorization: Bearer $TOK" \
-H 'content-type: application/json' -d '{"id":4503603655475937,"data":"ls\n"}'
# Launch a new terminal from a profile:
curl -s -X POST $B/mcp/launch -H "Authorization: Bearer $TOK" \
-H 'content-type: application/json' -d '{"profile":"bash","cols":100,"rows":30}'A ready-to-run MCP server lives in webterm/mcptool/ — a
thin stdio wrapper that maps each endpoint above to an MCP tool (mcp_info,
list_terminals, list_profiles, read_screen, send_input, send_keys,
set_pace, reset_terminal, flush_input, launch_terminal, set_window
(#243), get_desktop and arrange_window (#244), plus set_mcp_knobs and
workspaces, which are registered only when a host's /mcp/info answers
admin: true (#245)). It connects to the broker over HTTP and authenticates
with the MCP token.
pip install -e ".[mcp]" # Python >=3.10, like the rest of webterm
claude mcp add browserland --env BROWSERLAND_MCP_TOKEN=… -- python -m webterm.mcptoolSee webterm/mcptool/README.md for config (env/flags), the
tool list, and how access modes / allow_launch still govern behavior.
python -m pytest tests -qThe full suite passes (the platform PTY e2e suites skip on the other
OS): protocol shapes, ring eviction, OSC sniffer split at every byte
index, raw/pyte snapshot rendering, agent↔fake-broker integration
(reconnect, snapshot ordering, title re-hello), real-ConPTY round trip
on Windows, real-POSIX-PTY round trip on Linux (bash
echo/resize/stty size/OSC title/exit code, Ctrl-C via TIOCSCTTY,
huge-paste backpressure), broker e2e as a subprocess (auth gates, CORS on
error paths and preflights, sanic-ext neutralized, relay invariants,
/launch → detached agent on both platforms), the mandatory-token policy
(token bootstrap/mint/adopt, a router-enumerating guard that every non-public
route answers 401 auth_required, and the WS 4401 gates on loopback),
non-loopback auth negatives + CORS positives via the box's LAN IP, plus the
newer surfaces: the MCP tools (read/reset/flush/pace, scopes, launch handles
and /mcp/window, the admin credential, the desktop relay), broker roles,
terminal control and the /control lease, /state sync, update check/apply,
restart and the supervisor, the peer proxy and its sockets, mod install and
policy, the admin gate, the CSP and egress pins, the host registry, the
workspaces mod, the file API, session recording, the status-fetch proxy and
its gate, the textgrid fallback renderer, and UI asset assembly. The
playwright_*.py files are live-browser scripts, not part of the pytest run.
The test dependencies are listed under Linux deployment
(sanic-testing is not in the dev extra).
pyte is optional: pip install pyte. It is required only for
--snapshot-mode pyte (raw starts without it), but installing it also
upgrades MCP read_screen regardless of snapshot mode — attr_runs (#128)
and keyframe repair (#130); without it the dependency-free textgrid
fallback is used and a sparse alt-screen frame after ring eviction comes
back flagged partial (#134). Windows agents need pywinpty>=2.
This broker's relay and registry were adapted from xterm-py
(https://github.com/JohnConnorNPC/xterm-py), a separate
relay/registry/UI codebase, which is not part of or modified by this
project. This broker defaults to port 4445.
Browserland — a web-based terminal desktop. This wiki is the end-user guide; for the wire protocol, auth model, and HTTP/MCP contract see Technical Reference. Released under the MIT License.
Start here
Building layouts
The desktop
Windows & content
Multi-host & AI
For developers