anvilsign in

collin/browser-terminal-extension

RenderedSource

1# terminal
2
3A tmux sidebar for Chrome and Firefox.
4
5The extension is called **terminal**; the daemon it talks to is `termbridge`.
6They are deliberately separate names — the daemon is a browser-neutral CLI with
7its own config directory, and keeping it stable means renaming the extension
8never touches `~/.config/termbridge/`, the token, or the certificate.
9
10```
11sidebar (xterm.js) daemon (Rust)
12 │ │
13 │ ws://127.0.0.1:7681 │
14 │ ─── {"type":"auth","token":"…"} ──────▶ │ origin + host + token
15 │ ◀── {"type":"ok"} ───────────────────── │
16 │ ─── {"type":"open","cols":80,…} ──────▶ │ spawn pty
17 │ ═══ binary frames (raw bytes) ════════▶ │ ──▶ tmux new-session -A -s browser
18 │ ◀══ binary frames (raw bytes) ═════════ │
19 │ ─── {"type":"resize","cols":…} ───────▶ │ TIOCSWINSZ
20```
21
22The daemon runs a PTY. tmux inside it does all multiplexing and, crucially, all
23persistence — close the sidebar, restart the browser, reattach and everything is
24where you left it.
25
26## Setup
27
28```sh
29cd daemon && cargo build --release
30./build.sh # produces dist/chrome and dist/firefox
31```
32
33Load the extension:
34
35| | |
36|---|---|
37| Chrome | `chrome://extensions` → Developer mode → Load unpacked → `dist/chrome` |
38| Firefox | `about:debugging#/runtime/this-firefox` → Load Temporary Add-on → `dist/firefox/manifest.json` |
39
40Open the sidebar, hit ⚙, and it shows you the command to run. Then:
41
42```sh
43termbridge pair chrome-extension://<the id it showed you>
44termbridge token # paste this into the sidebar
45termbridge serve
46```
47
48**Firefox needs one extra step.** HTTPS-Only Mode silently rewrites `ws://` to
49`wss://`, so the daemon serves TLS and plaintext on the same port, choosing per
50connection by sniffing the first byte. The certificate is self-signed, so trust
51it once: open `https://127.0.0.1:7681/` and accept the warning (the sidebar has a
52button for this). Chrome uses plaintext and skips the step entirely.
53
54Verify you're trusting the right certificate — `termbridge cert` prints the
55SHA-256 the browser will show you.
56
57Pairing is per-browser. Firefox's `moz-extension://` origin is a random UUID
58regenerated on each temporary install, so you'll re-pair each time until the
59add-on is signed.
60
61## Theme
62
63The **◐** button in the header cycles *follow system → light → dark*, and the ⚙
64panel has the same setting. `auto` tracks `prefers-color-scheme` live, so it
65follows the OS without a reconnect.
66
67Both palettes live in `extension/lib/theme.js` as the single source of truth:
68the `ui` block becomes CSS custom properties on `:root`, the `xterm` block goes
69to `term.options.theme`. Chrome and terminal cannot drift apart.
70
71## Header size
72
73⚙ → Appearance has a header density control:
74
75| | |
76|---|---|
77| Normal | Status text plus icons |
78| Compact | Icons only, tighter padding — about one extra terminal row |
79| Hide header | No header; hover the top edge of the panel to bring it back |
80
81Changing it refits the terminal and sends the new size to the pty, so tmux
82reflows immediately.
83
84Note this only covers *our* header. The bar above it — extension name, close ✕,
85panel switcher — is browser chrome. Chrome's side panel and Firefox's sidebar
86both render it and neither exposes any way for an extension to remove or restyle
87it.
88
89## Choosing a tmux session
90
91By default the sidebar joins the tmux session you already have running, when
92there is exactly one — no point starting a second session beside the only one
93you are using. With none, or with several, it attaches to a session called
94`browser` and creates it if needed. The check happens per connection, so it
95reflects what tmux holds when the sidebar connects, not when the daemon
96started.
97
98To pin a specific session and skip that guessing entirely:
99
100```sh
101termbridge serve --session my-existing-work
102```
103
104Or pick it live: the dropdown at the left of the header lists every session on
105the server. Choosing one runs `switch-client` on the sidebar's own tmux client
106— the WebSocket stays up, no second pty is spawned, and whatever is running in
107the session you left keeps running. The ⚙ panel's **tmux session** field is the
108way to reach a session that doesn't exist yet: it creates-or-attaches and
109switches to it.
110
111The dropdown shows the session you are *actually* on, not the one you asked
112for, so a `switch-client`, `choose-tree` or prefix-`(`/`)` typed in the terminal
113updates it too.
114
115Both names are validated server-side — see the security notes below.
116
117## Window tabs
118
119The rest of the header is a browser-style tab bar, one tab per **window** in the
120attached session — the same windows `prefix 2` selects and the tmux status line
121lists. Clicking one runs `select-window`, and **+** runs `new-window`. Neither
122touches the connection: the pty, the session and everything running in it stay
123exactly as they were.
124
125Selecting a window deliberately moves *every* client watching that session, not
126just the sidebar — a window belongs to the session, so this behaves the same as
127pressing prefix-2 in your terminal, and the tab bar tracks what you do there.
128
129The active tab is drawn in the terminal's own background so the two read as one
130surface. The dot in its favicon slot is what Claude Code is doing in that
131window — amber and pulsing for working, blue for waiting on you — and stays
132empty for a window that is just a shell, rather than lighting up a status
133indicator with no status to report. A background window that has produced
134output since you last looked wears tmux's activity flag as a bolder name.
135
136A new window needs no name (tmux names it after what it runs), so **+** is one
137click with nothing to fill in.
138
139The **✕** closes a window (`kill-window`) on the first click, like a browser
140tab. Unlike a browser tab there is no undo — it kills whatever was running in
141that window — so it goes red under the pointer, and the log records what went.
142
143It only appears on the window you are on and the one you are pointing at, and
144never on a session's *last* window: that would take the session and the
145sidebar's own client with it, which is not a tab close.
146
147Right-clicking a tab offers **Pin**, **Select** and **Close window**.
148
149Pinning works like a browser's: the tab moves to the head of the strip and
150shrinks to its dot and index, and loses its ✕ so it can't be closed by a
151mis-aimed click. What it does *not* do is touch tmux. Nothing is renumbered, no
152`move-window` is sent, and your terminal's status line doesn't change — the
153window keeps its real index, which is why the index is the thing a pinned tab
154keeps showing: `prefix 3` still selects it, pinned or not. The gain is purely
155that a window you care about stays visible when the strip overflows, at about a
156quarter of the width.
157
158Pins live in extension storage, keyed by session name, and are dropped when the
159window they point at closes. They are per-browser-profile, not shared with
160anyone else attached to the session.
161
162## How the daemon talks to tmux
163
164The interactive client in the pty is busy being a terminal, so the daemon
165attaches a *second* client in [control
166mode](https://github.com/tmux/tmux/wiki/Control-Mode) to use as a query and
167event channel:
168
169```
170tmux -C attach -t <session> -f read-only,ignore-size,no-output
171```
172
173Each flag is load-bearing. `read-only` means the channel can never send
174keystrokes to a pane. `ignore-size` stops an 80x24 control client from shrinking
175your windows to fit itself. `no-output` stops tmux streaming every byte every
176pane produces to a client with no use for it.
177
178tmux pushes `%client-session-changed`, `%sessions-changed`, `%window-renamed`
179and friends, so the sidebar updates when something happens rather than on a
180timer, and no `tmux` process is spawned per refresh.
181
182`read-only` governs keys, not commands, so the same channel carries the
183sidebar's requests. Those are a closed allowlist — switch to a session,
184create-and-switch, focus a pane, select a window, open a window, close a window
185— expressed as an enum, not a command string. The wire protocol cannot name a
186tmux command, and every argument is validated (`valid_session_name`,
187`valid_pane_id`, `valid_window_id`) before it is quoted into a command line.
188
189Exactly one of them destroys anything, `kill-window`, and it can only ever name
190one window: a window id is `@` plus digits, so `-a` (which would kill every
191window *but* the target) and `session:` targets do not parse. There is no
192kill-session and no kill-pane.
193
194## Claude Code status
195
196The glyph on each window tab is what Claude Code is doing in that window. It is
197Claude Code's own asterisk spinner, so a window that is thinking looks in the
198tab strip the way it looks in the pane:
199
200| Glyph | Meaning |
201|---|---|
202| amber, cycling `· ✢ ✳ ∗ ✻ ✽` | working |
203| blue `✳` | waiting on you |
204| grey `✻` | idle at the prompt |
205| faded `·` | Claude is there, but no hooks are installed for it |
206| nothing | no Claude in this window |
207
208One timer drives the whole strip and only runs while something is working, so
209an idle panel is not repainting forever. `prefers-reduced-motion` parks the
210glyph on a single frame rather than dropping the indicator.
211
212Joined on the tmux window id, so a window shows the loudest state in it —
213waiting beats working. The tab's tooltip carries the detail the dot can't: the
214tool in flight, or what Claude is blocked on.
215
216There used to be a second row of per-pane chips under the header saying the
217same thing at more length. It was costing a terminal line to repeat what the
218tabs already show, so it's gone; the trade is that a window whose tab is
219scrolled out of a narrow panel no longer announces itself.
220
221This comes from [Claude Code's hook
222interface](https://code.claude.com/docs/en/hooks), not from reading the screen:
223Claude runs `termbridge hook` on `UserPromptSubmit`, `PreToolUse`,
224`PostToolUse`, `Notification`, `Stop`, `SessionStart` and `SessionEnd`, and each
225event's JSON tells us the state directly. `Notification` even distinguishes
226`permission_prompt` (blocked on you) from `idle_prompt` (just quiet).
227
228Install it once:
229
230```sh
231termbridge hooks # prints the block to merge into ~/.claude/settings.json
232```
233
234Each session's state lands in `~/.config/termbridge/agents/<session_id>.json`.
235The pane a session belongs to comes from `$TMUX_PANE`, which Claude's process
236inherits and passes to the hook — that is the join between a Claude session and
237a tmux pane. `permission_mode` is carried forward across the events that omit it
238(`Notification` is the one that matters: the mode must not blink out exactly
239when you are being asked to approve something).
240
241### The same glyph in tmux's own status line
242
243The sidebar reads its state over the daemon's control channel, which exists
244only while a browser is attached — exactly the case where you are not looking
245at the browser. So the terminal gets it from the other end: the hook already
246runs inside the pane it is reporting on, with `$TMUX` naming the right server,
247and it sets a window user option on the way out.
248
249```sh
250termbridge tmux # prints the ~/.tmux.conf lines
251```
252
253```tmux
254set -g window-status-format "#{@tb_claude}#I:#W#F"
255set -g window-status-current-format "#{@tb_claude}#I:#W#F"
256```
257
258The option holds a styled glyph and a space, and is unset — expanding to
259nothing — for a window with no Claude in it, so windows that never see one look
260exactly as they do now. A window with several Claudes in it shows the loudest,
261the same rule the tabs use: `list-panes` on the hook's own pane is the join.
262
263It advances one frame per hook event rather than on a timer. tmux only redraws
264its status when an option changes or `status-interval` elapses, so a true
265spinner would mean forcing a full status repaint on every attached client
266several times a second; stepping on events costs nothing and moves the glyph
267exactly when Claude crosses a tool boundary.
268
269The only command this issues is `set-option -w @tb_claude`. It cannot rename a
270window, change a layout, or send a key.
271
272## Picking elements off the page
273
274Two ways to start it, and the difference matters:
275
276| | |
277|---|---|
278| **Alt+Shift+P** | Always works. Rebindable at `chrome://extensions/shortcuts` or `about:addons` → gear → Manage Extension Shortcuts |
279| Crosshair button in the header | Convenient, but may fail — see below |
280
281Then: hover highlights the element under the cursor with its tag and size, click
282selects. Clicks are swallowed, so picking a link or a submit button doesn't
283navigate or submit.
284
285To get out: **Esc** (from either the page or the sidebar), or press the
286crosshair again — it toggles.
287
288Picking sends the result straight to the terminal, screenshot first:
289
2901. The element's box is cropped out of a screenshot of the tab and put on the
291 system clipboard as a PNG.
2922. A literal **^V** goes down the wire. That byte is not a paste in the panel —
293 xterm.js never sees it — it reaches the program in the pty, and an agent that
294 handles image paste (Claude Code does) reads the clipboard and attaches the
295 PNG itself.
2963. The selector follows, control-character stripped and single-quoted, with no
297 trailing newline. You press Enter yourself.
298
299This works because the daemon runs on the same machine as the browser, so the
300clipboard the extension writes is the clipboard the agent reads. In a bare shell
301^V is literal-next-character instead, so it is only sent when a screenshot was
302actually captured; if the capture fails, only the selector is inserted and the
303reason is logged.
304
305The panel still shows the element afterwards: CSS selector, XPath, `id`, test
306id, text, or `href`. **copy** puts the selected one on the clipboard, and
307**insert** re-types it (text only — the screenshot is already on the clipboard
308if you want it again).
309
310**Why the button can fail.** Touching a page needs `activeTab`, which the
311browser grants only on certain user gestures — a toolbar click, a context menu,
312or a keyboard command. A click inside the side panel is not one of them, so the
313button falls back to standing host permissions. Those are:
314
315- **localhost is granted up front** — `localhost`, `*.localhost` and `127.0.0.1`
316 on both schemes. Match patterns carry no port, so `:5173`, `:3000` and
317 everything else are covered.
318- **Every other site is opt-in, one origin at a time.** When the button hits a
319 site it can't reach, it offers an *Allow https://example.com/\** button that
320 triggers the browser's own permission prompt. Nothing is granted until you say
321 so.
322
323The shortcut needs none of this — it runs in the background worker, which is a
324gesture the browser does honour, and works on any page.
325
326**Why the button can pick but not screenshot.** `tabs.captureVisibleTab` accepts
327exactly two things: the `activeTab` grant a keyboard command mints, or a host
328permission set containing the literal `<all_urls>` pattern. A per-origin grant is
329enough to read the element but not to capture it, so the button hands back a
330selector and no image even on a site you approved. When that happens the panel
331offers an *Allow screenshots on all sites* button, which requests `<all_urls>`;
332it is optional and revocable in the extension's settings, and Alt+Shift+P keeps
333capturing without it.
334
335Picks made while the sidebar is closed are parked in `storage.local` and appear
336when you next open it.
337
338Nothing is inserted automatically — see below for why that matters.
339
340## Security model
341
342This daemon hands out shell access. It is `sshd` with a smaller feature set, and
343is treated that way.
344
345**Threats it stops**
346
347| Attacker | Defense |
348|---|---|
349| Any webpage you visit (WebSocket has no CORS — every page can reach 127.0.0.1) | Origin allowlist; `Origin` is browser-set and unforgeable from page JS |
350| `evil.com` rebound to 127.0.0.1 | `Host` header must be loopback |
351| Another user on the machine | Token in a `0600` file, `0700` dir; refuses to load if the mode loosens |
352| The network | Binds `127.0.0.1` only, not configurable |
353| Token brute-force | Constant-time compare, lockout after repeated failures |
354| Firefox HTTPS-Only Mode breaking the connection | Serves TLS and plaintext on one port; no browser setting has to be weakened |
355| A page injecting shell commands via the element picker | Picked text is control-character stripped and single-quoted before it can reach the pty |
356| A page reading the clipboard after the picker writes a screenshot to it | The write happens in the extension's isolated world; the page could already read its own clipboard, and the image is a picture of the page itself |
357
358**Threats it does not stop**
359
360A process running as *you* can already read `~/.ssh`, patch `~/.bashrc`, and
361ptrace your browser. Same-uid isolation is not a thing, and pretending otherwise
362would be theatre.
363
364**Design rules**
365
366- Token travels in a post-upgrade frame, never the URL — query strings leak into
367 logs, crash dumps, and devtools history.
368- Pairing is explicit. No trust-on-first-use, no blanket `chrome-extension://`
369 prefix match (that would admit every other extension you have installed).
370- The extension requests `storage` and `sidePanel`. No host permissions, no
371 content scripts, no `web_accessible_resources`, no `externally_connectable` —
372 so there is no bridge from page content to the socket.
373- The sidebar owns the WebSocket directly. Terminal data never passes through
374 `runtime.sendMessage`, which content scripts can reach.
375- The client cannot choose what runs. The protocol has no argv field, so an auth
376 bypass yields the configured profile rather than arbitrary exec.
377- TLS changes no policy: origin, host and token are enforced identically over
378 `wss://`. The private key is `0600` and refused if the mode loosens, and the
379 certificate is a leaf with `CA:FALSE` — trusting it cannot be leveraged to
380 vouch for any other host.
381- **The element picker treats the page as hostile.** A selector is built from
382 attributes the page chose, so an `id` of `x'; rm -rf ~; '` or an `aria-label`
383 containing a newline is arbitrary code execution — a newline typed at a
384 terminal is a pressed Enter. Two independent defenses, both required: strip
385 every C0 control character, then POSIX single-quote. `insert` never appends a
386 newline; you press Enter yourself. The UI says so when a value had to be
387 modified.
388- The picker's standing page access is limited to loopback hosts, which are your
389 own machine. Everything else is `optional_host_permissions`, granted per-origin
390 through the browser's prompt and revocable in the extension's settings — never
391 a blanket `<all_urls>` at install time. `<all_urls>` is offered as an optional
392 permission too, because the screenshot API takes nothing narrower, but only
393 when a capture has already failed and only behind an explicit button. Still no
394 declared content scripts and no `web_accessible_resources`.
395- The picker returns its result as the `executeScript` **return value**, not via
396 `runtime.sendMessage` — so the sidebar still has no inbound message listener
397 that a content script could reach.
398- tmux session names are client-selectable but validated: no leading dash (tmux
399 would read it as a flag), and `[A-Za-z0-9_-]` only. They reach `execvp` as a
400 separate argv element, never a shell. An invalid name falls back to the
401 default rather than erroring.
402- The HTTPS landing page exists only so the certificate-trust visit is
403 comprehensible. It serves no data and, unlike the implementation we looked at,
404 there is no endpoint anywhere that hands out the auth token.
405
406Every one of those is covered by a test, and the suite is mutation-checked:
407reverting the origin check to "allow any origin" turns 5 tests red.
408
409```sh
410cd daemon && cargo test # 46
411npm test # 25
412npm run check # types, see below
413```
414
415The sanitizer tests don't just assert on strings — they hand the quoted output
416to a real `/bin/sh` and check it comes back as one literal argument, including
417for `x'; rm -rf ~; echo '` and `harmless\nid`.
418
419The theme tests compute WCAG contrast ratios for all 16 ANSI slots against their
420own background, plus body text, muted text, buttons and selection. They already
421earned their keep: `brightBlack` landed at exactly 3.00:1 on the dark background
422— the slot most tools use for comments — and was corrected.
423
424## Types without a build step
425
426The extension is plain `.js` that the browser loads exactly as it sits on disk
427— no bundler, no transpile, `build.sh` is a `cp`. That stays true. What was
428added is a type *checker* over it: JSDoc annotations plus `npm run check`,
429which runs `tsc --noEmit` and emits nothing. Editors pick the same config up
430automatically and give completion on the daemon's frames.
431
432```sh
433npm install # one dev dependency: typescript
434npm run check
435```
436
437Two projects, because the manifest creates two global scopes and both declare
438`const api` at the top level:
439
440| Config | Realm | Files |
441|---|---|---|
442| `extension/jsconfig.json` | sidebar document | `sidebar.js`, `picker.js`, `lib/` |
443| `extension/jsconfig.sw.json` | service worker | `sw.js`, `picker.js`, `lib/shot.js` |
444
445`extension/types/globals.d.ts` is hand-written rather than pulled from
446`@types/chrome`, so it doubles as the inventory of extension API surface this
447extension touches — adding a call means adding it there first, which is the
448same conversation as growing the manifest's permission list. It also carries
449the daemon's wire frames (`TbOkFrame`, `TbStatusFrame`, `TbSessionInfo`,
450`TbAgent`); the other half of those shapes is `daemon/src/status.rs`, and the
451two have to move together.
452
453Nothing in `node_modules/` reaches `dist/`.
454
455## Known gotchas
456
457**tmux resizes for everyone.** Default `window-size` is `latest`, so attaching a
458narrow sidebar shrinks the same session in your real terminal. Verified: a
459100x30 window became 40x20 on sidebar attach. Either give the sidebar its own
460session, or:
461
462```sh
463setw -g window-size manual
464```
465
466**Firefox temporary add-ons** get a new UUID per install, so re-pair after each
467reload.
468
469## Layout
470
471```
472daemon/src/paths.rs token + paired-origin files, permission enforcement
473daemon/src/auth.rs origin / host / token checks
474daemon/src/server.rs listener, handshake, session pump
475daemon/src/pty.rs portable-pty backend
476daemon/src/tls.rs self-signed cert generation, rustls config
477daemon/src/rewind.rs replayable stream, so we can inspect the request head
478daemon/tests/ security.rs (28), tls.rs (10), pty_e2e.rs (8)
479extension/picker.js injected element picker (no privileges, runs in page)
480extension/lib/ sanitize.js (page-text-to-shell boundary), theme.js
481 (light/dark palettes) — both with tests; shot.js
482 (element screenshot crop, clipboard write)
483extension/types/ hand-written ambients: extension API surface, the
484 daemon's wire frames, the vendored xterm build
485extension/ sidebar, two manifests, vendored xterm.js
486```
487
488## Not done yet
489
490- Firefox reaches the daemon (confirmed: HTTPS-Only Mode was rewriting the
491 scheme, which is why TLS exists). Not yet confirmed end-to-end after trusting
492 the certificate.
493- Chrome has not been loaded at all; whether MV3 needs anything in
494 `host_permissions` or CSP `connect-src` is still unverified.
495- No packaging: the daemon is started by hand, and there's no systemd unit.