collin/browser-terminal-extension
b9b5b120efbd61817fb954ea0574156d8e39a302 / README.md
RenderedSource
| 1 | # terminal |
| 2 | |
| 3 | A tmux sidebar for Chrome and Firefox. |
| 4 | |
| 5 | The extension is called **terminal**; the daemon it talks to is `termbridge`. |
| 6 | They are deliberately separate names — the daemon is a browser-neutral CLI with |
| 7 | its own config directory, and keeping it stable means renaming the extension |
| 8 | never touches `~/.config/termbridge/`, the token, or the certificate. |
| 9 | |
| 10 | ``` |
| 11 | sidebar (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 | |
| 22 | The daemon runs a PTY. tmux inside it does all multiplexing and, crucially, all |
| 23 | persistence — close the sidebar, restart the browser, reattach and everything is |
| 24 | where you left it. |
| 25 | |
| 26 | ## Setup |
| 27 | |
| 28 | ```sh |
| 29 | cd daemon && cargo build --release |
| 30 | ./build.sh # produces dist/chrome and dist/firefox |
| 31 | ``` |
| 32 | |
| 33 | Load 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 | |
| 40 | Open the sidebar, hit ⚙, and it shows you the command to run. Then: |
| 41 | |
| 42 | ```sh |
| 43 | termbridge pair chrome-extension://<the id it showed you> |
| 44 | termbridge token # paste this into the sidebar |
| 45 | termbridge 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 |
| 50 | connection by sniffing the first byte. The certificate is self-signed, so trust |
| 51 | it once: open `https://127.0.0.1:7681/` and accept the warning (the sidebar has a |
| 52 | button for this). Chrome uses plaintext and skips the step entirely. |
| 53 | |
| 54 | Verify you're trusting the right certificate — `termbridge cert` prints the |
| 55 | SHA-256 the browser will show you. |
| 56 | |
| 57 | Pairing is per-browser. Firefox's `moz-extension://` origin is a random UUID |
| 58 | regenerated on each temporary install, so you'll re-pair each time until the |
| 59 | add-on is signed. |
| 60 | |
| 61 | ## Theme |
| 62 | |
| 63 | The **◐** button in the header cycles *follow system → light → dark*, and the ⚙ |
| 64 | panel has the same setting. `auto` tracks `prefers-color-scheme` live, so it |
| 65 | follows the OS without a reconnect. |
| 66 | |
| 67 | Both palettes live in `extension/lib/theme.js` as the single source of truth: |
| 68 | the `ui` block becomes CSS custom properties on `:root`, the `xterm` block goes |
| 69 | to `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 | |
| 81 | Changing it refits the terminal and sends the new size to the pty, so tmux |
| 82 | reflows immediately. |
| 83 | |
| 84 | Note this only covers *our* header. The bar above it — extension name, close ✕, |
| 85 | panel switcher — is browser chrome. Chrome's side panel and Firefox's sidebar |
| 86 | both render it and neither exposes any way for an extension to remove or restyle |
| 87 | it. |
| 88 | |
| 89 | ## Choosing a tmux session |
| 90 | |
| 91 | By default the sidebar joins the tmux session you already have running, when |
| 92 | there is exactly one — no point starting a second session beside the only one |
| 93 | you 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 |
| 95 | reflects what tmux holds when the sidebar connects, not when the daemon |
| 96 | started. |
| 97 | |
| 98 | To pin a specific session and skip that guessing entirely: |
| 99 | |
| 100 | ```sh |
| 101 | termbridge serve --session my-existing-work |
| 102 | ``` |
| 103 | |
| 104 | Or pick it live: the dropdown at the left of the header lists every session on |
| 105 | the 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 |
| 107 | the session you left keeps running. The ⚙ panel's **tmux session** field is the |
| 108 | way to reach a session that doesn't exist yet: it creates-or-attaches and |
| 109 | switches to it. |
| 110 | |
| 111 | The dropdown shows the session you are *actually* on, not the one you asked |
| 112 | for, so a `switch-client`, `choose-tree` or prefix-`(`/`)` typed in the terminal |
| 113 | updates it too. |
| 114 | |
| 115 | Both names are validated server-side — see the security notes below. |
| 116 | |
| 117 | ## Window tabs |
| 118 | |
| 119 | The rest of the header is a browser-style tab bar, one tab per **window** in the |
| 120 | attached session — the same windows `prefix 2` selects and the tmux status line |
| 121 | lists. Clicking one runs `select-window`, and **+** runs `new-window`. Neither |
| 122 | touches the connection: the pty, the session and everything running in it stay |
| 123 | exactly as they were. |
| 124 | |
| 125 | Selecting a window deliberately moves *every* client watching that session, not |
| 126 | just the sidebar — a window belongs to the session, so this behaves the same as |
| 127 | pressing prefix-2 in your terminal, and the tab bar tracks what you do there. |
| 128 | |
| 129 | The active tab is drawn in the terminal's own background so the two read as one |
| 130 | surface. The dot in its favicon slot is what Claude Code is doing in that |
| 131 | window — amber and pulsing for working, blue for waiting on you — and stays |
| 132 | empty for a window that is just a shell, rather than lighting up a status |
| 133 | indicator with no status to report. A background window that has produced |
| 134 | output since you last looked wears tmux's activity flag as a bolder name. |
| 135 | |
| 136 | A new window needs no name (tmux names it after what it runs), so **+** is one |
| 137 | click with nothing to fill in. |
| 138 | |
| 139 | The **✕** closes a window (`kill-window`) on the first click, like a browser |
| 140 | tab. Unlike a browser tab there is no undo — it kills whatever was running in |
| 141 | that window — so it goes red under the pointer, and the log records what went. |
| 142 | |
| 143 | It only appears on the window you are on and the one you are pointing at, and |
| 144 | never on a session's *last* window: that would take the session and the |
| 145 | sidebar's own client with it, which is not a tab close. |
| 146 | |
| 147 | Right-clicking a tab offers **Pin**, **Select** and **Close window**. |
| 148 | |
| 149 | Pinning works like a browser's: the tab moves to the head of the strip and |
| 150 | shrinks to its dot and index, and loses its ✕ so it can't be closed by a |
| 151 | mis-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 |
| 153 | window keeps its real index, which is why the index is the thing a pinned tab |
| 154 | keeps showing: `prefix 3` still selects it, pinned or not. The gain is purely |
| 155 | that a window you care about stays visible when the strip overflows, at about a |
| 156 | quarter of the width. |
| 157 | |
| 158 | Pins live in extension storage, keyed by session name, and are dropped when the |
| 159 | window they point at closes. They are per-browser-profile, not shared with |
| 160 | anyone else attached to the session. |
| 161 | |
| 162 | ## How the daemon talks to tmux |
| 163 | |
| 164 | The interactive client in the pty is busy being a terminal, so the daemon |
| 165 | attaches a *second* client in [control |
| 166 | mode](https://github.com/tmux/tmux/wiki/Control-Mode) to use as a query and |
| 167 | event channel: |
| 168 | |
| 169 | ``` |
| 170 | tmux -C attach -t <session> -f read-only,ignore-size,no-output |
| 171 | ``` |
| 172 | |
| 173 | Each flag is load-bearing. `read-only` means the channel can never send |
| 174 | keystrokes to a pane. `ignore-size` stops an 80x24 control client from shrinking |
| 175 | your windows to fit itself. `no-output` stops tmux streaming every byte every |
| 176 | pane produces to a client with no use for it. |
| 177 | |
| 178 | tmux pushes `%client-session-changed`, `%sessions-changed`, `%window-renamed` |
| 179 | and friends, so the sidebar updates when something happens rather than on a |
| 180 | timer, and no `tmux` process is spawned per refresh. |
| 181 | |
| 182 | `read-only` governs keys, not commands, so the same channel carries the |
| 183 | sidebar's requests. Those are a closed allowlist — switch to a session, |
| 184 | create-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 |
| 186 | tmux 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 | |
| 189 | Exactly one of them destroys anything, `kill-window`, and it can only ever name |
| 190 | one window: a window id is `@` plus digits, so `-a` (which would kill every |
| 191 | window *but* the target) and `session:` targets do not parse. There is no |
| 192 | kill-session and no kill-pane. |
| 193 | |
| 194 | ## Claude Code status |
| 195 | |
| 196 | The glyph on each window tab is what Claude Code is doing in that window. It is |
| 197 | Claude Code's own asterisk spinner, so a window that is thinking looks in the |
| 198 | tab 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 | |
| 208 | One timer drives the whole strip and only runs while something is working, so |
| 209 | an idle panel is not repainting forever. `prefers-reduced-motion` parks the |
| 210 | glyph on a single frame rather than dropping the indicator. |
| 211 | |
| 212 | Joined on the tmux window id, so a window shows the loudest state in it — |
| 213 | waiting beats working. The tab's tooltip carries the detail the dot can't: the |
| 214 | tool in flight, or what Claude is blocked on. |
| 215 | |
| 216 | There used to be a second row of per-pane chips under the header saying the |
| 217 | same thing at more length. It was costing a terminal line to repeat what the |
| 218 | tabs already show, so it's gone; the trade is that a window whose tab is |
| 219 | scrolled out of a narrow panel no longer announces itself. |
| 220 | |
| 221 | This comes from [Claude Code's hook |
| 222 | interface](https://code.claude.com/docs/en/hooks), not from reading the screen: |
| 223 | Claude runs `termbridge hook` on `UserPromptSubmit`, `PreToolUse`, |
| 224 | `PostToolUse`, `Notification`, `Stop`, `SessionStart` and `SessionEnd`, and each |
| 225 | event's JSON tells us the state directly. `Notification` even distinguishes |
| 226 | `permission_prompt` (blocked on you) from `idle_prompt` (just quiet). |
| 227 | |
| 228 | Install it once: |
| 229 | |
| 230 | ```sh |
| 231 | termbridge hooks # prints the block to merge into ~/.claude/settings.json |
| 232 | ``` |
| 233 | |
| 234 | Each session's state lands in `~/.config/termbridge/agents/<session_id>.json`. |
| 235 | The pane a session belongs to comes from `$TMUX_PANE`, which Claude's process |
| 236 | inherits and passes to the hook — that is the join between a Claude session and |
| 237 | a 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 |
| 239 | when you are being asked to approve something). |
| 240 | |
| 241 | ### The same glyph in tmux's own status line |
| 242 | |
| 243 | The sidebar reads its state over the daemon's control channel, which exists |
| 244 | only while a browser is attached — exactly the case where you are not looking |
| 245 | at the browser. So the terminal gets it from the other end: the hook already |
| 246 | runs inside the pane it is reporting on, with `$TMUX` naming the right server, |
| 247 | and it sets a window user option on the way out. |
| 248 | |
| 249 | ```sh |
| 250 | termbridge tmux # prints the ~/.tmux.conf lines |
| 251 | ``` |
| 252 | |
| 253 | ```tmux |
| 254 | set -g window-status-format "#{@tb_claude}#I:#W#F" |
| 255 | set -g window-status-current-format "#{@tb_claude}#I:#W#F" |
| 256 | ``` |
| 257 | |
| 258 | The option holds a styled glyph and a space, and is unset — expanding to |
| 259 | nothing — for a window with no Claude in it, so windows that never see one look |
| 260 | exactly as they do now. A window with several Claudes in it shows the loudest, |
| 261 | the same rule the tabs use: `list-panes` on the hook's own pane is the join. |
| 262 | |
| 263 | It advances one frame per hook event rather than on a timer. tmux only redraws |
| 264 | its status when an option changes or `status-interval` elapses, so a true |
| 265 | spinner would mean forcing a full status repaint on every attached client |
| 266 | several times a second; stepping on events costs nothing and moves the glyph |
| 267 | exactly when Claude crosses a tool boundary. |
| 268 | |
| 269 | The only command this issues is `set-option -w @tb_claude`. It cannot rename a |
| 270 | window, change a layout, or send a key. |
| 271 | |
| 272 | ## Picking elements off the page |
| 273 | |
| 274 | Two 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 | |
| 281 | Then: hover highlights the element under the cursor with its tag and size, click |
| 282 | selects. Clicks are swallowed, so picking a link or a submit button doesn't |
| 283 | navigate or submit. |
| 284 | |
| 285 | To get out: **Esc** (from either the page or the sidebar), or press the |
| 286 | crosshair again — it toggles. |
| 287 | |
| 288 | Picking sends the result straight to the terminal, screenshot first: |
| 289 | |
| 290 | 1. The element's box is cropped out of a screenshot of the tab and put on the |
| 291 | system clipboard as a PNG. |
| 292 | 2. 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. |
| 296 | 3. The selector follows, control-character stripped and single-quoted, with no |
| 297 | trailing newline. You press Enter yourself. |
| 298 | |
| 299 | This works because the daemon runs on the same machine as the browser, so the |
| 300 | clipboard 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 |
| 302 | actually captured; if the capture fails, only the selector is inserted and the |
| 303 | reason is logged. |
| 304 | |
| 305 | The panel still shows the element afterwards: CSS selector, XPath, `id`, test |
| 306 | id, 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 |
| 308 | if you want it again). |
| 309 | |
| 310 | **Why the button can fail.** Touching a page needs `activeTab`, which the |
| 311 | browser grants only on certain user gestures — a toolbar click, a context menu, |
| 312 | or a keyboard command. A click inside the side panel is not one of them, so the |
| 313 | button 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 | |
| 323 | The shortcut needs none of this — it runs in the background worker, which is a |
| 324 | gesture the browser does honour, and works on any page. |
| 325 | |
| 326 | **Why the button can pick but not screenshot.** `tabs.captureVisibleTab` accepts |
| 327 | exactly two things: the `activeTab` grant a keyboard command mints, or a host |
| 328 | permission set containing the literal `<all_urls>` pattern. A per-origin grant is |
| 329 | enough to read the element but not to capture it, so the button hands back a |
| 330 | selector and no image even on a site you approved. When that happens the panel |
| 331 | offers an *Allow screenshots on all sites* button, which requests `<all_urls>`; |
| 332 | it is optional and revocable in the extension's settings, and Alt+Shift+P keeps |
| 333 | capturing without it. |
| 334 | |
| 335 | Picks made while the sidebar is closed are parked in `storage.local` and appear |
| 336 | when you next open it. |
| 337 | |
| 338 | Nothing is inserted automatically — see below for why that matters. |
| 339 | |
| 340 | ## Security model |
| 341 | |
| 342 | This daemon hands out shell access. It is `sshd` with a smaller feature set, and |
| 343 | is 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 | |
| 360 | A process running as *you* can already read `~/.ssh`, patch `~/.bashrc`, and |
| 361 | ptrace your browser. Same-uid isolation is not a thing, and pretending otherwise |
| 362 | would 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 | |
| 406 | Every one of those is covered by a test, and the suite is mutation-checked: |
| 407 | reverting the origin check to "allow any origin" turns 5 tests red. |
| 408 | |
| 409 | ```sh |
| 410 | cd daemon && cargo test # 46 |
| 411 | npm test # 25 |
| 412 | npm run check # types, see below |
| 413 | ``` |
| 414 | |
| 415 | The sanitizer tests don't just assert on strings — they hand the quoted output |
| 416 | to a real `/bin/sh` and check it comes back as one literal argument, including |
| 417 | for `x'; rm -rf ~; echo '` and `harmless\nid`. |
| 418 | |
| 419 | The theme tests compute WCAG contrast ratios for all 16 ANSI slots against their |
| 420 | own background, plus body text, muted text, buttons and selection. They already |
| 421 | earned 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 | |
| 426 | The 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 |
| 428 | added is a type *checker* over it: JSDoc annotations plus `npm run check`, |
| 429 | which runs `tsc --noEmit` and emits nothing. Editors pick the same config up |
| 430 | automatically and give completion on the daemon's frames. |
| 431 | |
| 432 | ```sh |
| 433 | npm install # one dev dependency: typescript |
| 434 | npm run check |
| 435 | ``` |
| 436 | |
| 437 | Two 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 |
| 447 | extension touches — adding a call means adding it there first, which is the |
| 448 | same conversation as growing the manifest's permission list. It also carries |
| 449 | the daemon's wire frames (`TbOkFrame`, `TbStatusFrame`, `TbSessionInfo`, |
| 450 | `TbAgent`); the other half of those shapes is `daemon/src/status.rs`, and the |
| 451 | two have to move together. |
| 452 | |
| 453 | Nothing in `node_modules/` reaches `dist/`. |
| 454 | |
| 455 | ## Known gotchas |
| 456 | |
| 457 | **tmux resizes for everyone.** Default `window-size` is `latest`, so attaching a |
| 458 | narrow sidebar shrinks the same session in your real terminal. Verified: a |
| 459 | 100x30 window became 40x20 on sidebar attach. Either give the sidebar its own |
| 460 | session, or: |
| 461 | |
| 462 | ```sh |
| 463 | setw -g window-size manual |
| 464 | ``` |
| 465 | |
| 466 | **Firefox temporary add-ons** get a new UUID per install, so re-pair after each |
| 467 | reload. |
| 468 | |
| 469 | ## Layout |
| 470 | |
| 471 | ``` |
| 472 | daemon/src/paths.rs token + paired-origin files, permission enforcement |
| 473 | daemon/src/auth.rs origin / host / token checks |
| 474 | daemon/src/server.rs listener, handshake, session pump |
| 475 | daemon/src/pty.rs portable-pty backend |
| 476 | daemon/src/tls.rs self-signed cert generation, rustls config |
| 477 | daemon/src/rewind.rs replayable stream, so we can inspect the request head |
| 478 | daemon/tests/ security.rs (28), tls.rs (10), pty_e2e.rs (8) |
| 479 | extension/picker.js injected element picker (no privileges, runs in page) |
| 480 | extension/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) |
| 483 | extension/types/ hand-written ambients: extension API surface, the |
| 484 | daemon's wire frames, the vendored xterm build |
| 485 | extension/ 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. |