osc-terminal-bridge
v0.6.2
Published
Run interactive CLI agents (claude / codex / copilot / a shell) on this machine and drive them from the osc app — a token-authed sidecar over node-pty + tmux. Add a remote machine to your bridges with one command.
Maintainers
Readme
osc-terminal-bridge
Run an interactive CLI agent — Claude Code / Codex / Copilot / Cursor — from the chatbot UI (or a plain browser tab). Keystrokes flow in, the live TUI streams back. Add this machine to the app with one command:
npx osc-terminal-bridge --token <your-secret>browser (xterm.js) ──WebSocket──► node-pty ──► tmux session ──► claude / codex / copilot
▲ keystrokes / resize durable + multi-attach
└──────────────── ANSI bytes ◄────────────────────────────┘Two front-ends, one sidecar:
- In the chatbot UI — the
Chat · Terminalswitch in a chat swaps the body to a live terminal: one shell session per chat, opened in that chat's workspace (the same dir the chat's bash/readFile tools use, uploads included), with one-click launch chips for Claude / Codex / Copilot / Cursor. Config comes from/api/terminal/config. - Standalone page —
http://<host>:7681/serves a self-contained xterm page with per-agent buttons (handy for quick tests or phone access).
It's a sidecar, not a Next route: Next handlers can't hold a long-lived WebSocket or own a PTY. Local backend only — there's no Vercel-prod version (no long-lived WS, no PTY), the same boundary the app uses for background/bash.
Prerequisites
- Node — uses
node-pty(ships prebuilt; only needs a C/C++ toolchain if it falls back to building — see Troubleshooting). - tmux — required for session resume / durability. Without it every
connect spawns a fresh agent and disconnect kills it.
brew install tmux(macOS) orapt install tmux(Linux). The bridge detects tmux once at startup, so install it before starting (or restart the bridge after).
Quick start — add this machine to your bridges
1. Run the bridge (one line)
npx osc-terminal-bridge --token <your-secret> # local URL (tunnel it yourself)
npx osc-terminal-bridge --token <your-secret> --tunnel # ALSO opens a public https:// URLIt installs, listens on port 7681, and prints the URL + Token to add to the app.
Re-running on the same box reuses the token (it's remembered in ~/.osc-terminal-bridge.token).
--tunnel— if the app is hosted / not on this box, this opens a publichttps://URL automatically and prints that — no separate tunnel step. The URL is ephemeral (changes each run), so it's for quick connects; a permanent box wants a stable URL (a named cloudflared tunnel / reserved ngrok domain). Keep the process running.Choosing the provider —
--tunneltakes an optional argument. Bare--tunnelisauto, which tries cloudflared first and only falls back to ngrok if cloudflared fails; on a box with both installed, auto always lands on cloudflared, so name the one you want:npx osc-terminal-bridge --token <t> --tunnel # auto: cloudflared, else ngrok npx osc-terminal-bridge --token <t> --tunnel ngrok # force ngrok (alias: ng) npx osc-terminal-bridge --token <t> --tunnel cloudflared # force cloudflared (aliases: cloudflare, cf) TB_TUNNEL=ngrok npx osc-terminal-bridge --token <t> # same, via env--tunnel=ngrokworks too. A pinned provider never falls back — if it isn't installed or can't start, you get that provider's error instead of a surprise URL from the other one. Needs the chosen provider installed (brew install cloudflared/brew install ngrok).
Requirements: Node ≥ 18, plus tmux for durable/resumable sessions (brew install tmux /
apt install tmux — install it before starting). Options: --port <p>, --host <h> (default
127.0.0.1; use 0.0.0.0 for LAN), --tunnel [provider], --help. To run from a checkout
instead: cd terminal-bridge && npm install && npm start.
2. Add it in the app's /bridges page
Open the app → Bridges (sidebar) → Add bridge, and paste the URL and Token the
command printed. The app stores the token encrypted, shows the box online, and new chats can run
on it (or set it as your ★ default). No app restart, no .env.local editing.
| How the app reaches this box | URL to add | Start the bridge with |
|---|---|---|
| App on the same machine | http://localhost:7681 | default (127.0.0.1) |
| Another device on your LAN | http://<lan-ip>:7681 | --host 0.0.0.0 |
| A hosted app (over https) | the tunnel's https://… URL | default + tunnel it (step 3) |
A hosted/TLS app can only reach a public https/wss bridge, so it must be tunneled
(step 3); a raw http://localhost only works when the app runs on the same box or LAN.
The legacy
TERMINAL_BRIDGE_URL/TERMINAL_BRIDGE_TOKENenv vars still work as an optional bootstrap, but the/bridgesregistry above is the primary way to add boxes now.
3. Tunnel it — remote access (optional)
Only needed to cross a network or an https origin; same-machine use needs none.
Either provider works — pick by what you need:
| | cloudflared | ngrok |
|---|---|---|
| Account / setup | none for quick tunnels | authtoken required, once |
| URL | random per run | random, or reserved domain (paid) |
| Region control | automatic (nearest edge) | explicit --region |
| Concurrent agents | unlimited | one on the free plan |
| Install | brew install cloudflared | brew install ngrok |
A. Let the bridge run it
npx osc-terminal-bridge --token <t> --tunnel cloudflared # aliases: cloudflare, cf
npx osc-terminal-bridge --token <t> --tunnel ngrok # alias: ng
npx osc-terminal-bridge --token <t> --tunnel # auto: cloudflared, else ngrokOne command, and the printed box already carries the public URL. The URL is
ephemeral — new every run, so you re-paste it into /bridges each time. See
Tunnel provider for precedence and
pinning.
B. Run the tunnel yourself
--tunnel hardcodes its arguments, so provider-specific flags — a reserved domain,
a custom config, a region — mean running it yourself:
# cloudflared — start the bridge WITHOUT --tunnel (no adoption; see below)
cloudflared tunnel --url http://localhost:7681 # -> https://abc123.trycloudflare.com
npx osc-terminal-bridge --token <t>
# ngrok — your flags, and `--tunnel ngrok` will ADOPT this tunnel rather than add one
ngrok http 7681 --url=<your>.ngrok.app --config=./ngrok.yml --region=ap
npx osc-terminal-bridge --token <t> --port 7681 --tunnel ngrokOnly ngrok can be adopted — it has a local :4040 inspector to ask. cloudflared
quick tunnels register nowhere, so running one and passing --tunnel cloudflared
yields two tunnels with two URLs (you get a warning, not adoption). Details in
Preflight.
Then point something at the resulting host:
- Standalone page: open
https://<your-tunnel-host>/?token=<TB_TOKEN>. - In-chat terminal from a remote browser: set
TERMINAL_BRIDGE_URL=wss://<your-tunnel-host>(+ matching token) in.env.localand restartpnpm dev.
Latency: each keystroke is a round-trip, so a far edge adds 200–400 ms of typing
lag. ngrok exposes the choice (--region ap / eu / us / au / jp / in / sa);
cloudflared picks its nearest edge automatically. If one feels laggy, try the other.
Shutdown: a tunnel the bridge started dies with it — Ctrl-C (SIGINT), SIGTERM
and SIGHUP (closing the terminal) all close it. kill -9 cannot, and a tunnel you
started yourself is never touched; both leave it running with a public hostname still
aimed at the port.
Free URLs on both providers change every restart — update the env line and restart
pnpm deveach session, or get a stable one (a named cloudflared tunnel / a reserved ngrok domain). ngrok can add--basic-auth "user:pass"as a second lock.
Resume / durability (tmux)
With tmux active, the per-chat session is named tb-<chatId> and uses
tmux new-session -A (attach-or-create):
- Refresh / switch chats / re-open Terminal → re-attaches the same live session — scrollback intact, and whatever agent you launched still running.
- Disconnecting only detaches the viewer — the session keeps running in the background.
- Watch/drive from your own terminal:
tmux attach -t tb-<chatId>. /quit-ing an agent drops the pane back to the shell — hit a launch chip (or type the command) to start another.- Hide tmux's status bar if you don't want it:
tmux set -g status off.
Live attach to any session — /ws?attach= (0.3.0)
Besides the per-chat tb-<chatId> flow, the WS can attach to an existing
session by exact name — any tmux session on the box (not just tb-*) or a GNU
screen session. This backs the app's Live fleet view, which lists every
running session/agent on your boxes and opens any of them live. Attach-only:
it never creates a session and never changes its options.
/ws?attach=tmux:<session-name> # tmux attach-session -t =<name> (exact match)
/ws?attach=screen:<pid>.<name> # screen -x <id> || screen -r <id>- tmux needs no extra software. screen attach needs
screeninstalled (caps.screen). A missing target closes with 4404, a bad target 4400, screen-unavailable 4501 — so the client shows a reason instead of retrying. - Disconnecting detaches the viewer; the session (tmux server / screen
backend) keeps running, same as the
tb-flow.
Feature detection. GET /control/health advertises what this bridge supports
so the app can gate its UI (and never send attach= to an old bridge that would
ignore it):
{ "ok": true, "tmux": true, "dataDir": "…", "hostname": "…",
"version": "0.3.0",
"caps": { "rawAttach": true, "screen": true, "listPanes": true } }A bridge with no caps field is ≤ 0.2.0 — treat raw attach as unavailable
and update it (npm i -g osc-terminal-bridge@latest, or just re-run
npx osc-terminal-bridge@latest). Existing tb-* sessions still attach on old
bridges via the per-chat flow.
Theme follow — {"type":"theme"} (0.4.0)
The app's light/dark toggle sends a control frame over the WS (text frames are
control JSON, like resize):
{ "type": "theme", "mode": "light" }On the per-chat spawn path the bridge re-styles the tb-* session's tmux
status bar to match (status-style; dark restores tmux's stock green). Sent on
connect and on every toggle; the bar is session-global, so with several viewers
the last toggler wins. Raw attaches (/ws?attach=) never restyle — a foreign
session's options are never touched. Older bridges parse and ignore the frame,
so no version gate is needed.
Truecolor (0.4.2)
TUIs decide their colour depth once at launch from the environment, so a 24-bit brand colour only survives if the app sees a truecolor signal. Two things make that happen:
- The pty is spawned with
COLORTERM=truecolor(PTY_ENV), which the tmux client reports — tmux ≥3.2 then auto-enables RGB passthrough to the viewer. - Inside tmux, that client
COLORTERMis not copied into the session's pane (it isn't inupdate-environment, and a reused durable server's global env predates it), so the pane's shell alsoexports it — the one place tmux can't strip it. Without this, an app launched in the pane (e.g. typingclaude) detects no truecolor and downsamples its RGB to ANSI red.
Only NEW sessions are affected — an already-running one keeps the depth it chose
at launch until it's respawned. A box on tmux <3.2 (no COLORTERM
auto-detect) additionally needs set -ga terminal-overrides ",*:Tc" in its
tmux.conf so tmux forwards the RGB the app now emits.
Tunnel provider — --tunnel [provider] (0.4.3)
--tunnel used to try cloudflared and fall back to ngrok only if cloudflared
failed. On a box with both installed that fallback never fires, so ngrok was
unreachable — there was no way to ask for it. The flag now takes an optional
provider:
| Form | Provider |
|---|---|
| --tunnel | auto — cloudflared, else ngrok (unchanged from ≤0.4.2) |
| --tunnel ngrok / --tunnel=ng | ngrok |
| --tunnel cloudflared / =cloudflare / =cf | cloudflared |
| TB_TUNNEL=ngrok | same, via env — an explicit flag value beats it |
Precedence is explicit value → TB_TUNNEL → auto; TB_TUNNEL=false|0|no|off
disables the tunnel, since env vars carry boolean-ish values in practice.
A pinned provider never falls back. Asking for ngrok and silently getting a
cloudflared URL would defeat the point of asking, so you get ngrok's own error
instead — with a bare ENOENT rewritten to ngrok not found on PATH (brew
install ngrok), that being the usual cause. An unknown provider exits 1 rather
than quietly starting something that works.
The argument is positional and optional, so --tunnel --port 9000 still reads as
auto: a following arg starting with - is the next flag, not a provider.
Also fixed here: the ngrok path read the first https entry from ngrok's
:4040 inspector, but that API lists tunnels from every ngrok process on the
box — so an unrelated ngrok running alongside would have had its URL printed as
the bridge's. It now matches config.addr against this bridge's port.
Preflight — an already-running tunnel (0.4.4)
Running a tunnel yourself and then also passing --tunnel is an easy mistake,
and the two providers failed at it differently. --tunnel now checks first:
ngrok is checked properly, because :4040 is a real answer. If an agent is
already tunnelling this bridge's port, it is adopted — no second process
is spawned, and the box says so:
│ REUSING the ngrok tunnel already running on this box — it already
│ points at :7681, so a second one would just be a second URL.
│ This bridge didn't start it, so Ctrl-C here will NOT close it.Adoption is deliberate rather than accidental. Previously the spawned second agent would be refused (free ngrok allows ONE session) while the poll raced to read the first agent's tunnel and report it as ours — a URL the bridge thought it owned and would try to kill. An adopted tunnel carries no child process, so shutdown leaves it alone. If an agent is up but on a different port, that's a warning instead: the session limit still applies, so the spawn may fail.
cloudflared gets a best-effort hint only, and the asymmetry is the point: quick tunnels register nowhere local — no inspector, no state file — so there is nothing to adopt and no URL to read back. A process-table scan can only say "something that looks like cloudflared is already pointed at this port", so it warns and continues rather than blocking:
note: cloudflared already appears to be tunnelling :7681. Starting a SECOND tunnel —
you'll get a different public URL, and the existing one stays open after this
bridge stops. Ctrl-C it first if you meant to reuse it.That second sentence is the real hazard: your own tunnel outlives the bridge,
leaving a public hostname aimed at the port — which a later pnpm dev will
happily bind again. Both URLs are gated only by TB_TOKEN.
Shutdown — what actually closes a spawned tunnel (0.4.4)
A tunnel the bridge started is a child process, killed on the way out:
| Stopping the bridge with | Its spawned tunnel |
|---|---|
| Ctrl-C (SIGINT) | closed |
| SIGTERM | closed |
| SIGHUP — closing the terminal window | closed |
| kill -9 (SIGKILL) | survives — nothing can run on SIGKILL |
SIGHUP was added here because it's a normal way to stop this: without a listener
node takes the OS default and dies without running exit handlers, orphaning a
public tunnel with nothing left holding a reference to close it. An adopted
tunnel is deliberately exempt from all of the above — it carries a null child, so
shutdown never touches what the bridge didn't start.
Config reference
Bridge env vars (passed to npm start)
| Var | Default | Purpose |
|---|---|---|
| PORT / HOST | 7681 / 127.0.0.1 | where the sidecar listens (HOST=0.0.0.0 for LAN) |
| TB_TOKEN | (none) | shared secret; set before exposing. Must equal the app's TERMINAL_BRIDGE_TOKEN |
| TB_CWD | $HOME | working dir the agent starts in |
| TB_CMD_CLAUDE / TB_CMD_CODEX / TB_CMD_COPILOT / TB_CMD_CURSOR | claude / codex / copilot / agent | per-agent command (e.g. gh copilot; Cursor CLI is agent, override → cursor-agent) |
| TB_USE_TMUX | 1 | 0 bypasses tmux (no durability) |
| TB_SESSION_PREFIX | tb- | tmux session name prefix |
| TB_DATA_DIR | $HOME/.osc-sandboxes | per-chat workspace root for remote-spawned workers; the control API remaps a new-session start-dir here by chatId |
| TB_TUNNEL | (none) | tunnel provider when --tunnel is used: auto / cloudflared / ngrok (also off to disable). An explicit --tunnel <provider> beats it |
Chatbot app env vars (project-root .env.local)
| Var | Purpose |
|---|---|
| TERMINAL_BRIDGE_URL | WS base the browser connects to (table above). Unset → the app guesses ws://<app-host>:7681 |
| TERMINAL_BRIDGE_TOKEN | Must equal the bridge's TB_TOKEN. Handed only to signed-in users via /api/terminal/config, never shipped in the bundle |
| REMOTE_NODE_URL / REMOTE_NODE_TOKEN | Point the orchestrator's control plane at this box's /control/* API (the https:// origin + TB_TOKEN). Set both → chats spawn/read/tell/stop here. See below + docs/remote-nodes.md |
Control API — remote orchestration (v1a + v1b)
Besides the live-view WebSocket (/ws), the bridge exposes a small token-authed
HTTP surface so the app can drive an orchestrator's worker agents on THIS box (not
the app's host) — the "node agent" half of the remote-nodes design (see
docs/remote-nodes.md). Every request needs Authorization: Bearer <TB_TOKEN>
(when TB_TOKEN is set).
| Method + path | Body | Purpose |
|---|---|---|
| GET /control/health | — | { ok, tmux, dataDir, hostname, version, caps } — liveness + the box's TB_DATA_DIR, its hostname, this bridge's version, and caps (feature flags, see below) |
| POST /control/tmux | { "args": string[] } | run ONE tmux command → { stdout, ok } (the terminal seam) |
| GET /control/envs | ?dir= (optional) | this box's conda envs → { environments: [{name,value}] } — backs the app's env picker |
| POST /control/exec | { cmd, args?, cwd?, timeoutMs?, detached?, logPath? } | run a command → { exitCode, stdout, stderr }, or detached → { pid } (the sandbox runCommand) |
| POST /control/fs | { op: read\|readBuffer\|write\|mkdir\|exists\|stat\|rm\|readdir\|realpath, path, … } | filesystem ops on the box; bytes are base64 (the sandbox fs.* — uploads, skills, file-browser) |
| GET /control/jupyter | — | running local Jupyter servers → { servers: [{port, baseUrl}] } (see JupyterLab passthrough below) |
| POST /control/jupyter/grant | — | one-time, 60s browser grant → { grant, ttlMs } — how the app opens the lab without the token riding a URL |
/control/tmux is the single seam: the app builds the tmux args (spawn / capture /
send-keys / kill / list) and this runs them. It accepts only the verbs the app uses
(new-session, has-session, capture-pane, send-keys, kill-session,
list-sessions, list-panes, set-option) and remaps a new-session start-dir
under TB_DATA_DIR. Point the app at it with REMOTE_NODE_URL (the https://
origin) + REMOTE_NODE_TOKEN (= TB_TOKEN).
/control/exec + /control/fs back the RemoteNodeSandbox (v1b): the chat's
own bash/readFile/writeFile and uploaded files run on this box too, sharing
one workspace (TB_DATA_DIR/<chatId>) with the spawned workers.
Smoke test:
curl -s -X POST "$URL/control/tmux" -H "authorization: Bearer $TB_TOKEN" \
-d '{"args":["new-session","-d","-s","tb-smoke","--","bash","-lc","echo hi; exec bash -l"]}'
curl -s -X POST "$URL/control/tmux" -H "authorization: Bearer $TB_TOKEN" \
-d '{"args":["capture-pane","-t","tb-smoke","-p"]}' # → shows "hi"JupyterLab passthrough (0.6.0)
If this box runs a JupyterLab (say, a platform-managed one at
--ServerApp.base_url=/notebook/<ns>/<pod>/ with auth disabled), the bridge can
serve it through its own tunnel — no second tunnel, no Jupyter credentials:
- Discovery —
GET /control/jupyterreads thejpserver-*.jsonruntime files (whatjupyter server listreads), falls back to the process table, and drops anything that fails a local liveness probe. Advertised ascaps.jupyterProxy. Root-mounted servers (base_url=/) are out of scope — they'd swallow the bridge's own routes. Since 0.6.1 each server also reports itsroot_dir, so the app can deep-link straight to a notebook (…/lab/tree/<path>). - Passthrough — any path under a discovered
base_urlpipes to127.0.0.1:<port>; WebSocket upgrades (kernels) are raw TCP splices, so subprotocols and binary frames pass untouched. Path-preserving: no URL rewriting exists anywhere. - Gate — browser traffic needs a session cookie, minted by
GET /jupyter/open?grant=…&next=…from a one-time grant the app fetched over/control/jupyter/grant(Bearer).nextmust land inside a discoveredbase_url(no open redirect); the cookie is a random id, neverTB_TOKEN. With noTB_TOKENset the passthrough is open, like everything else here.
In the app this surfaces as the Jupyter chip in a chat terminal's header (shown only when this box reports a live server).
Security ⚠️
A full interactive shell on the host, plus agents that run arbitrary code (RCE by design):
- Never expose without
TB_TOKEN. Prefer keeping it on127.0.0.1and reaching it through the authenticated app, or via an SSH tunnel. - Treat the tunnel URL + token as secrets, whichever provider minted it — a
trycloudflare.comURL is as public as an ngrok one. ngrok can add--basic-auth; cloudflared wants Cloudflare Access for the equivalent. - The
/control/*API is the same RCE surface over HTTP (it runs LLM-driven tmux/shell on this box). Same rule: strongTB_TOKEN, TLS only, a box you trust. - The JupyterLab passthrough exposes a (typically auth-disabled) Jupyter — also
RCE — through the tunnel. Its grant/cookie gate rides
TB_TOKEN: set one, and never widen the passthrough beyond discovered base_urls. - Local backend only — no Vercel-prod version (
isLocalBackend()boundary).
Troubleshooting
- Every click starts a NEW session / nothing resumes — tmux isn't active.
Install tmux and restart the bridge (detected only at startup); confirm
tmux=truein the log. - In-chat: "Couldn't reach the terminal bridge at ws://…" — wrong/missing
TERMINAL_BRIDGE_URL(the browser can't reach that address) or it fell back to a guess. Set it per the URL table and restartpnpm dev. If it instead connects then immediately drops, the token doesn't match (TB_TOKEN≠TERMINAL_BRIDGE_TOKEN). - Typing lag (200–400 ms) — a network round-trip per keystroke. Use a nearer
ngrok
--region, switch provider (cloudflared and ngrok route over different networks, so try--tunnel ngrokagainst--tunnel cloudflared), or stay local (ws://localhost).TCP_NODELAYis already enabled in the bridge. posix_spawnp failedon macOS — node-pty's prebuiltspawn-helperlost its+xbit;npm run postinstall(auto-run on install) restores it.EACCES … ~/.npm/_cacache— borked global npm cache (often a pastsudo npm). Install with a scratch cache:npm install --cache /tmp/tb-npm-cache.
If node-pty won't build
Needs Python 3 + a C/C++ toolchain (Xcode CLT on macOS, build-essential on
Linux). Or use the prebuilt drop-in node-pty-prebuilt-multiarch and change the
import in server.mjs.
Files
Sidecar (terminal-bridge/):
cli.mjs— the launcher: token,--tunnel, the connect box.server.mjs— HTTP + WS sidecar; spawns agents via node-pty (+ tmux).jupyter.mjs— pure routing/guard logic for the JupyterLab passthrough (unit-tested from the app repo, thecwd.mjspattern).public/index.html— standalone xterm page.test/—npm test: the CLI's tunnel behaviour (argument parsing, provider routing, preflight/adoption, shutdown signals). Self-contained — stub cloudflared/ngrok shadow the real ones onPATHand bridge ports are OS-assigned, so no public tunnel is ever opened. The ngrok cases fake the inspector on:4040, which the CLI hardcodes; if a real agent is holding that port they skip rather than test against a live tunnel, so run them with ngrok stopped for full coverage. Filter withnpm test -- <substring>. Not shipped in the npm tarball.smoke.mjs— headless PTY round-trip test (npm run smoke, needs a running bridge).durtest.mjs— headless resume test (npm run durtest, needs a running bridge + tmux).scripts/fix-pty-perms.mjs— postinstall chmod for node-pty'sspawn-helper.
In the app:
components/chat/terminal-view.tsx— xterm client (connects to the sidecar).components/chat/chat-mode-selector.tsx— theChat · Terminalswitch.lib/terminal-bridge.ts— shared mode type,AGENT_LAUNCHERS+ URL/session helpers.app/(app)/(chat)/api/terminal/config/route.ts— auth-gated{ url, token, cwd }(cwd = the chat's workspace dir).components/chat/shell.tsx— holds the per-chatmodeand swaps body ⇄ terminal.
