Skip to content

share: publish a running tmux session as a URL - #12

Merged
richlegrand merged 8 commits into
richlegrand:mainfrom
kodareef5:feature/share-pr
Aug 16, 2026
Merged

share: publish a running tmux session as a URL#12
richlegrand merged 8 commits into
richlegrand:mainfrom
kodareef5:feature/share-pr

Conversation

@kodareef5

Copy link
Copy Markdown
Contributor

share: publish a running tmux session as a URL

Closes #5.

Summary

bitbang share publishes a tmux session that is already in progress. It
returns a control URL and a separate view-only URL, or only the view URL when
started with --read-only.

bitbang share
bitbang share --read-only
bitbang share status
bitbang share rotate
bitbang share stop

The command returns after publication, so suspending a task, sharing it, and
resuming it works without changing how the task was started.

Design

  • Each peer gets its own stock tmux attach-session process. Control peers
    attach read-write; viewers attach with -r.
  • The shell handler pins argv, environment, and cwd for these processes. It
    also drops viewer stdin, signals, and EOF before tmux sees them.
  • One ephemeral identity carries two independent access codes. Authorization
    maps each code to a control or view role without signaling-server changes.
  • The control role has one slot. The view role has --max-viewers slots.
    Reservations begin at authorization and last for the connection lifetime,
    including peers that never open a terminal.
  • A detached _bbshare_* tmux session supervises the worker. There is no
    daemonization or PID file.
  • Shares end on --ttl, share stop, source-session removal, or signaling
    preemption. The source session is never stopped by share cleanup.

Share URLs carry !ephemeral in the fragment. Browser parsing already ignores
unknown fragment flags, and CLI clients use the flag to avoid saving an expired
share credential to devices.json.

Lifecycle safety

The command changes no tmux options. It reads the effective window-size and
warns when it is not latest, but preserves the user's configuration.

State contains bearer credentials and is written with mode 0600 inside a 0700
directory. A per-target advisory lock spans classification, cleanup, worker
creation, and state acceptance. Cleanup only acts after a successful
server-wide tmux listing proves the management pane is gone, or after the
socket proves that no server remains. Failed or ambiguous probes preserve
state.

Connection teardown now also covers peers whose data channel never opens, and
both normal listeners and share workers bound incomplete handshakes. This
prevents abandoned WebRTC requests from permanently consuming admission
slots.

Platform behavior

Hosting requires tmux 3.2 or newer on Unix or WSL. Native Windows clients can
open control and view URLs; attempting to host reports the platform limitation
before looking for tmux. The changes preserve the current ConPTY shell path and
compile as Windows test binaries.

Verification

  • go build ./...
  • go vet ./...
  • go test -count=1 ./...
  • go test -race -count=1 ./...
  • full suite with TERM=dumb
  • full suite with tmux absent from PATH
  • Windows cross-compiled test binaries
  • every commit independently built, vetted, tested, and cross-compiled
  • real tmux 3.6a integration tests for output capture, read-only attachment,
    resize isolation, source loss, exact targeting, lifecycle locks, retained
    dead panes, and stale-state cleanup
  • live bitba.ng smoke test: existing output rendered, view input was blocked,
    control input executed, share URLs were not persisted, status recovered the
    same URLs, and stop left the source session running

Linux CI now installs tmux, so the tmux integration tests execute in pull
requests instead of silently skipping.

Review order

  1. peer: add role-aware terminal access
  2. share: add the tmux publication worker
  3. share: add tmux session lifecycle commands
  4. docs: document tmux session sharing

kodareef5 and others added 6 commits August 3, 2026 09:19
Allow a listener to map access codes to control or view roles and advertise the granted role during session setup. Restricted shell handlers now pin argv, environment, and cwd, while view-only handlers reject input at the transport boundary.

Make connection teardown reliable before a data channel opens, bound incomplete handshakes, and return useful client errors when verification ends early. Ephemeral URL flags keep short-lived share credentials out of the device table.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Add target discovery, role-specific tmux attach commands, ephemeral credentials, peer admission, TTL and source-session watches, and credential-protected state files.

Run each peer through its own shell handler and delivery goroutine. Lifecycle probes use successful server-wide tmux listings, and target locks keep cleanup from racing a replacement share.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Add share, status, stop, and rotate commands with per-target locking, detached tmux supervision, exact target matching, startup diagnostics, and bounded cleanup of stranded state.

Hosting requires tmux 3.2 or newer on Unix or WSL. Other platforms retain client support and report that hosting is unavailable.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Add share usage, security boundaries, lifecycle behavior, platform requirements, and command flags to the README and CLI help.

Install tmux in Linux CI so the real integration suite runs, and include command packages in the local unit-test script.
main gained TCP forwarding (connect -L, streamtype/tcp) and refactored
connect's flag parsing into connectOptions; the only textual conflict
was cmd/bitbang/connect.go.

Resolution keeps both features:
- ephemeral-URL detection and the -name skip move onto opts.name
- the view-only mode switch adapts to the renamed shellOpts
- the forwarder-only path is preserved before shell setup

One semantic conflict: main's e2e harness wired handler teardown
through the removed Connection.OnClose field; it now uses SetOnClose
and closes both CloseAll() and Close() handlers, mirroring serve.go.
@richlegrand

Copy link
Copy Markdown
Owner

Thanks @kodareef5 -- this is a chunk of work and is carefully built. I went
through the authorization path in detail, since two codes on one identity is where this
kind of feature usually goes wrong, and it holds up. One finding, and it is a design
question rather than a bug.

The finding: view-only peers can resize the controller's terminal

ViewOnly deliberately keeps resize enabled, and the comment says why -- the remote PTY
should match the viewer's terminal. But both roles attach to the same session:

controlArgv := append(..., "attach-session", "-t", cfg.SessionID)
viewArgv    := append(..., "attach-session", "-r", "-t", cfg.SessionID)

tmux sizes a shared window from its attached clients, so a viewer's resize propagates to
the controller. Under the default window-size smallest a viewer on a phone shrinks
everyone; under latest the most recent client wins outright. Either way, resize is a
write to shared state that other peers observe, which makes "view-only" not quite
read-only. A bored viewer can make the controller's session unusable, and -r will not
stop them because tmux applies it to keystrokes, not geometry.

I could not verify this -- tmux is not installed on my machine, so this is read from
the argv and tmux's documented behavior rather than observed. Worth a two-minute check
before you act on it: attach two clients of different sizes to one session and watch what
the larger one does.

Not a regression -- nothing behaved differently before, this is a new feature -- so it
does not block by the rule I have been applying. But it is the one place the read-only
boundary leaks, and I would rather it be a decision than an accident. Three ways out, in
increasing order of effort:

  1. Drop resize for view-only peers as well, and letterbox in the browser. Simplest, one
    if h.ViewOnly next to the three that already exist.
  2. Pin the geometry with window-size manual plus an explicit resize-window, so no
    client's size affects the window.
  3. Attach viewers to a grouped session (new-session -t), which is the tmux idiom for
    clients that need independent sizes -- though I have not confirmed it actually
    decouples sizing, so check before committing to it.

If you take option 1, the doc comment on ViewOnly should lose the resize sentence.

What I checked and found clean

Listing this because "no findings" is not very informative on a PR this size.

authorize is genuinely constant-time, not just labeled that way. Both comparisons
run on every call, and pointing controlProbe at the view code in read-only mode so both
still run is a nice touch -- the obvious version would have skipped one and leaked the
mode through timing. Documenting the residual length leak rather than claiming perfection
is the right call too.

buildSession fails closed. An unrecognized access level hits default: and drops
the peer. And ViewOnly is set in the same switch arm as viewArgv, so the two cannot
drift apart in a later edit -- which is exactly the bug I was looking for.

Forcing env alongside argv, for the right reason. The comment nails it: pinned argv
without pinned environment is still steerable through PATH or loader variables. Easy to
miss.

Slots reserved at authorization, not at terminal open. The comment explains that a
peer can complete the handshake and never open a terminal, which would leave both limits
unenforced. Correct, and not the obvious choice.

SetOnClose closes a real race -- a terminal WebRTC state beating listener setup
would previously have dropped the callback. Good catch, and unrelated to the feature.

Also confirmed: the two codes are independently generated with distinctness enforced;
OnSYN really does honor h.closed, so Close's "prevents future spawns" claim holds;
release funcs are idempotent via sync.Once; build tags are exhaustive and
non-overlapping (unix / !unix); and old CLI clients handle !ephemeral correctly,
since strings.IndexAny(code, "!/") already truncates there -- so share URLs will not
confuse someone on 0.4.7.

Build, vet, and the full suite pass. Race detector clean on share, streamtype, peer,
session, and cmd/bitbang. Cross-compiles for linux/amd64, windows/amd64, and
darwin/arm64.

Roughly half this diff is tests, and shell_restricted_test.go in particular is testing
the right things -- the enforcement boundary rather than the happy path. That is the part
that will keep this correct a year from now.

@kodareef5

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed pass, the clean-bill section especially.

I ran your two-client check on tmux 3.6a and it doesn't reproduce.
A view-only peer can't resize the controller.

What I did: attached a read-write control client at 200x50, recorded the window, then
attached a read-only (-r) client at 80x24 and watched. The window held steady, under
latest, smallest, and largest. As a control I reran that second client read-write at
the same 80x24, and that one did drop the window to 80 columns under both smallest and
latest. So it's the -r, not a dead test.

The reason -r does more than gate keystrokes now:

▎ tmux 3.2: attach -r ... set or toggle both flags together
▎ tmux 3.1: Do not let read-only clients limit the size, unless all clients are
▎ read-only

Since 3.2, -r is an alias for -f read-only,ignore-size, and ignore-size means "the
client does not affect the size of other clients." The geometry half landed in 3.1. We
require tmux >= 3.2, so -r always carries ignore-size across the whole supported range,
not just the 3.6a I tested on. I think the keystrokes-not-geometry behavior you
remembered is pre-3.1.

This is already pinned in the integration tests, which is the easy bit to miss without
tmux to hand: TestViewerAttachesReadOnly asserts the viewer's client_flags contains
ignore-size, and TestViewerCannotResizeWhileControlAttached is your exact experiment
(rw control, then a 40x12 read-only viewer, assert the window doesn't move). Both run
against real tmux in CI now.

Additionally, and the reason that test is scoped "WhileControlAttached": the 3.1
rule has an "unless all clients are read-only" clause. With no controller attached and
only viewers, tmux does let them size the window among themselves. It never degrades
the controller though, since the moment a read-write control client attaches it governs
the size again. So view-only stays view-only for the controller's session.

Net: I don't think it needs a code change. That ViewOnly "resize remains enabled"
comment could be clearer, so if you want, I can add a line there pointing
at the ignore-size guarantee and the test, so the next person doesn't have to
re-derive it. Same for pinning window-size smallest into that test, if you'd like the
belt-and-suspenders. Your call on both.

@richlegrand

Copy link
Copy Markdown
Owner

You're right and I was wrong. Thanks for running it properly.

The control condition is what makes it convincing -- rerunning that same second client
read-write and watching it drop the window to 80 columns rules out the "test was dead"
explanation, which is the usual reason a non-reproduction is unconvincing. Nothing left to
argue with there.

And the mechanism explains my error precisely: I was reasoning from pre-3.1 tmux, where
-r really did gate keystrokes only. ignore-size landing in 3.1 and -r becoming an
alias for read-only,ignore-size in 3.2 is exactly the part I did not know.

I checked the version floor rather than take it on faith, since the whole argument rests
on it: CheckVersion enforces >= 3.2 and is genuinely wired up at cmd/bitbang/share.go:188,
not just documented. So -r carries ignore-size for every tmux this can run against, and
the guarantee is structural rather than a property of the version you happened to test.
TestViewerAttachesReadOnly asserting on client_flags is the right thing to pin, too --
it tests the guarantee rather than the symptom, so it will still fail loudly if a future
tmux decouples them.

The "unless all clients are read-only" clause is a good catch to have surfaced, and I agree
it is not a problem: viewers sizing the window among themselves with no controller attached
costs nothing, and a read-write client takes over the moment it attaches. Worth having in
the record.

Yes to both offers, please:

  • The doc line on ViewOnly. Right now that comment reads as "we allow resize and rely on
    tmux," which is what sent me down this path. Pointing at ignore-size and the test turns
    it into a stated guarantee with a named enforcement point.
  • Pinning window-size smallest alongside the existing latest case. You verified all
    three by hand, but only latest is pinned in CI, and smallest is the default a lot of
    people will actually be running.

Neither blocks. Push them and I will merge.

For what it is worth, this is the ideal outcome of a review finding: it was a real question
about the boundary, the answer turned out to be "already handled, and here is the test that
proves it," and the code comes out better documented for it. :)

kodareef5 and others added 2 commits August 7, 2026 07:56
Review of richlegrand#12 asked whether a view-only peer can resize the controller's
terminal, since both roles attach to the same tmux session and tmux sizes a
shared window from its attached clients.

It cannot. `tmux attach -r` has been an alias for read-only,ignore-size since
tmux 3.2 (the geometry half landed in 3.1), and ignore-size means the client
does not affect the size of other clients. The >= 3.2 floor is enforced by
share.CheckVersion, so -r always carries ignore-size for any tmux this can run
against. Verified by hand on tmux 3.6a under window-size latest, smallest, and
largest; a read-write client of the same small size does shrink the window,
which confirms the check is live rather than dead.

- Rewrite the ViewOnly doc comment to state the guarantee, name ignore-size and
  the version floor, and point at the tests, instead of reading as "we allow
  resize and rely on tmux."
- Extend TestViewerCannotResizeWhileControlAttached to run under window-size
  smallest as well as latest; only latest was pinned in CI, and smallest is a
  common configuration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
main gained SWSP v4 (per-stream flow control) and the proxy auth-ordering
fixes while this branch was open. Five conflicts, all where both sides
added something to the same place:

  protocol/swsp.go     Access type vs the v4 control messages -- both kept
  session/control.go   `access` vs `negotiated_version` on ready -- both kept
  cmd/serve.go         teardown lists merged: deadline.Done, sess.Close,
                       release, forget. deadline first so it cannot fire
                       during teardown; release stays after the session
                       closes, matching the order OnReady already used
  client/session.go    setNegotiatedVersion owns ServerVersion and the v2
                       default now, so the branch's manual assignment goes
                       and only ServerAccess is added alongside it
  e2e_harness_test.go  main assigned the OnClose field this branch replaced
                       with SetOnClose; moved to the new API, and
                       newSession.Close() now always runs rather than only
                       when closeHandlers is non-empty

One semantic fixup beyond the conflicts: access_test.go built a Session
with streamHandler and reasm, both of which v4 replaced with the
streams map, so it now matches what session_test.go constructs.

The PIN gate reordering from main survived the automatic merge -- handler
setup still happens in notifyHandlers after auth, and this branch's
Authorize hook is unaffected by it.
@richlegrand
richlegrand merged commit 5ec1d9d into richlegrand:main Aug 16, 2026
4 checks passed
@richlegrand

Copy link
Copy Markdown
Owner

Thanks for the doc line and the window-size smallest pin -- pinning the guarantee rather
than the symptom is the right call.

I've pushed a merge of main into your branch (5b561c9). Your branch predated SWSP v4,
which rewrote the session receive path underneath it, so it had gone conflicted through
nothing you did. Five conflicts, all additive; the merge commit has the details. Your
Authorize hook was untouched -- different code path from the auth change that landed on
main meanwhile.

Green, and merging. Goes out in 0.5.0 alongside Windows and macOS support, TCP forwarding,
and SWSP v4.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Terminal sharing

2 participants