Files
web-terminal/src/session/tmux.ts
Yaojia Wang d39a0ab8d1 fix(sessions): tmux -t prefix-matching let orphan ops reach the user's own sessions
Round two of adversarial review. The skeptic confirmed the session_activity finding
with extra evidence from this host and raised three things the first pass missed.

SECURITY — `tmux -t <name>` resolves exact name, then NAME PREFIX, then fnmatch.
The whole module rests on "we only ever touch `web_` + a UUID v4, so a tmux session
the user made for their own work is never enumerated and never offered for
deletion". Prefix matching goes around that: a session named `web_<uuid>_mine` is
refused by parseSessionList (its suffix is not a UUID) and so never appears in the
listing — but `-t web_<uuid>` matches it. So a DELETE aimed at an id that does not
exist would end the user's session, and a preview would print its screen.

Verified on tmux 3.6a, isolated socket:
  has-session -t web_<uuid>    → succeeds against web_<uuid>_MINE
  has-session -t =web_<uuid>   → "can't find session"
  capture-pane -p -t web_<uuid>   → printed the DECOY's screen
  capture-pane -p -t =web_<uuid>: → "can't find session"

Two target forms are needed, because `=` only qualifies the session part of a
target: session targets (has-session, kill-session) take `=name`, while
capture-pane takes a PANE and rejects a bare `=name` outright ("can't find pane"),
so it needs `=name:`. Both are now the only way a name reaches tmux, with an
integration test that stands up a real decoy and asserts the listing omits it and
both the preview and the DELETE report 404 while it stays alive.

CORRECTNESS — take max(window_activity, session_activity), not window_activity
alone. The previous commit swapped one clock for the other, but they are not
ordered: window_activity tracks pane output while an attach with no output bumps
only session_activity. On this host one session's session_activity was 4 s NEWER
than its window_activity (and another's window_activity was 25 days newer). The
question is "has anything happened here", so the answer is the later of the two.
Also documents the caveat that window_activity resolves against the session's
current window only — exact for the single-window sessions this app creates, and
bounded by the max for anything else.

CORRECTNESS — parseSessionList accepted an empty numeric field. Number('') is 0
and 0 is finite, so a blank clock parsed as "created at the epoch, idle ever
since": instantly eligible for the idle cleanup. Fields must now be actual digits.

SAFETY — bulk cleanup re-reads the world immediately before killing. The candidate
list is a snapshot and the kill loop takes time, so a session could be attached, or
adopted into the table, inside that window. This is the irreversible path; it now
verifies each candidate against a fresh listing rather than trusting the snapshot.

PERFORMANCE — captureOrphan no longer probes with hasSession first. That was a
second tmux spawn per thumbnail, and a SYNCHRONOUS one on the path a whole grid
refreshes, buying nothing: capture-pane already fails cleanly on a missing session
and we already turn that into null. The guard splits into a pure half
(mayActOnOrphan: UUID shape + not in the table) used by both paths, and the
existence probe, which only the destructive path needs.

Tests: 2213 unit, 9 orphan integration (incl. the decoy regression). Confirmed
`has-session -t =<name>` still resolves the 69 real sessions, so the Case 3.5
re-attach path is unaffected.

Note: test/integration/server.test.ts "H1 shell state survives a server restart"
flakes ~1 run in 3 when all 27 run together on a loaded machine, and passes 4/4 in
isolation. That is the pre-existing real-PTY timing flake already recorded in
PROGRESS_LOG, not this change — it exercises Case 3.5, which is verified above.
2026-07-30 11:46:51 +02:00

262 lines
9.7 KiB
TypeScript

/**
* src/session/tmux.ts (H1) — thin synchronous wrappers around the tmux CLI.
*
* Used only when cfg.useTmux is on. Running the shell inside a tmux session
* lets it survive a server/host restart: the node-pty process is just a tmux
* *client*; killing it detaches, and the tmux server keeps the shell alive.
*
* Every call is best-effort and never throws (tmux missing / session gone are
* treated as "false"/no-op).
*/
import { execFile, execFileSync } from 'node:child_process'
import { promisify } from 'node:util'
import { SESSION_ID_RE } from '../protocol.js'
const execFileAsync = promisify(execFile)
/** Upper bound on one `capture-pane` payload. A pane is one screen, so this is
* generous; it exists so a wedged tmux can never hand us unbounded output. */
const CAPTURE_MAX_BUFFER = 512 * 1024
/** Every tmux call is bounded. maxBuffer caps a flood but nothing caps the WAIT,
* and a tmux server stopped mid-syscall (SIGSTOP, a hung filesystem) would
* otherwise block forever — fatally so for the synchronous calls, which the event
* loop cannot interrupt. */
const TMUX_TIMEOUT_MS = 3000
/**
* Tab-separated, machine-readable. tmux reports both clocks in unix SECONDS.
*
* BOTH activity clocks are requested, because neither alone answers "has anything
* happened here" and they are not ordered:
*
* - `session_activity` tracks CLIENT activity — it advances on attach and on
* keypresses, and never moves for a detached session no matter how much its shell
* is printing. Measured on tmux 3.6a with a detached session in a `while true; do
* echo tick; sleep 1; done` loop: after 6 s it still equalled session_created
* while window_activity had advanced. Using it alone as the liveness signal makes
* idle-cleanup kill precisely the long-running, unattended, actively-working
* sessions this feature exists to protect — on one real host, 3 of them.
* - `window_activity` tracks pane OUTPUT, but an attach with no output bumps only
* session_activity. Observed on the same host: one session whose session_activity
* was 4 s newer than its window_activity, another whose window_activity was 25
* days newer.
*
* So the parser takes max(window_activity, session_activity).
*
* CAVEAT: in `list-sessions`, `#{window_activity}` resolves against the session's
* CURRENT window only. Every session this app creates is single-window (plain
* `new-session`), so it is exact here; a user who opens a second window inside one
* (Ctrl-b c) and leaves output only in a background window could still read as
* quieter than it is. Taking the max limits that to "as stale as session_activity",
* never staler.
*/
const LIST_FORMAT = [
'#{session_name}',
'#{session_created}',
'#{window_activity}',
'#{session_activity}',
'#{session_attached}',
'#{window_width}',
'#{window_height}',
].join('\t')
/**
* One `web_*` tmux session as tmux itself describes it (A).
*
* `attached` is tmux's own client count, NOT "this server is streaming it": a
* session can be attached from a plain terminal, or by a second web-terminal
* process. So "we do not track it" and "nobody is using it" are different
* questions, and the UI must not conflate them.
*/
export interface TmuxSessionSummary {
readonly id: string
readonly createdAtMs: number
readonly lastActivityAtMs: number
readonly attached: boolean
readonly cols: number
readonly rows: number
}
/** Is the tmux binary available on PATH? */
export function tmuxAvailable(): boolean {
try {
execFileSync('tmux', ['-V'], { stdio: 'ignore', timeout: TMUX_TIMEOUT_MS })
return true
} catch {
return false
}
}
/** tmux session name for a web-terminal session id (UUIDs are tmux-safe). */
export function tmuxName(sessionId: string): string {
return `web_${sessionId}`
}
/**
* A tmux `-t` target that matches ONE session by exact name.
*
* Without the `=`, tmux resolves a target as exact name, then NAME PREFIX, then
* fnmatch. That is a hole in this module's central promise: a session named
* `web_<uuid>_mine` is refused by parseSessionList (its suffix is not a UUID) and so
* is never listed — but `-t web_<uuid>` prefix-matches it, so a kill aimed at a
* non-existent id would end the user's own session instead. Verified on tmux 3.6a:
* `has-session -t web_<uuid>` succeeds against `web_<uuid>_MINE`, and fails with the
* `=` prefix. Every -t in this file goes through here.
*/
function exact(name: string): string {
return `=${name}`
}
/**
* The same, for a target-PANE. `capture-pane` takes a pane, and tmux rejects a bare
* `=name` there outright ("can't find pane: =web_x") — the `=` only qualifies the
* session part of a target, so the session has to be named as such with a trailing
* `:`. Verified on tmux 3.6a against a decoy named `web_<uuid>_decoy`: with the real
* session gone, `-t web_<uuid>` printed the DECOY's screen while `-t =web_<uuid>:`
* failed with "can't find session". Without this the preview leaks the contents of a
* session the user created for their own work.
*/
function exactPane(name: string): string {
return `=${name}:`
}
/** Does a tmux session with this name already exist (e.g. after a restart)? */
export function hasSession(name: string): boolean {
try {
execFileSync('tmux', ['has-session', '-t', exact(name)], {
stdio: 'ignore',
timeout: TMUX_TIMEOUT_MS,
})
return true
} catch {
return false
}
}
/** Kill a tmux session (ends the shell). No-op if it's already gone. */
export function killSession(name: string): void {
try {
execFileSync('tmux', ['kill-session', '-t', exact(name)], {
stdio: 'ignore',
timeout: TMUX_TIMEOUT_MS,
})
} catch {
// already gone — fine
}
}
/**
* Parse `tmux list-sessions -F LIST_FORMAT` output. Pure, so the parsing rules are
* testable without a tmux binary.
*
* Two filters, both load-bearing:
* - the name must start with `web_`, so we never enumerate (and therefore never
* offer to kill) a tmux session the user created for their own work;
* - the remainder must be a UUID v4 per SESSION_ID_RE — the same gate the attach
* protocol applies (M7). Anything else cannot have been created by this app, and
* refusing it here is also what keeps a hostile name from reaching `tmux -t`
* (e.g. a literal `-t`, or a name full of path traversal).
*/
export function parseSessionList(stdout: string): TmuxSessionSummary[] {
const rows: TmuxSessionSummary[] = []
for (const line of stdout.split('\n')) {
if (line === '') continue
const parts = line.split('\t')
if (parts.length < 7) continue
const [name, created, winActivity, sessActivity, attached, width, height] = parts as [
string,
string,
string,
string,
string,
string,
string,
]
if (!name.startsWith('web_')) continue
const id = name.slice('web_'.length)
if (!SESSION_ID_RE.test(id)) continue
// Number('') is 0, which IS finite — so an empty field would sail through as
// "created at the epoch, idle ever since", i.e. instantly eligible for the idle
// cleanup. Require actual digits.
const num = (v: string): number => (/^\d+$/.test(v) ? Number(v) : NaN)
const createdSec = num(created)
const winSec = num(winActivity)
const sessSec = num(sessActivity)
const cols = num(width)
const rows_ = num(height)
if (
!Number.isFinite(createdSec) ||
!Number.isFinite(winSec) ||
!Number.isFinite(sessSec) ||
!Number.isFinite(cols) ||
!Number.isFinite(rows_)
) {
continue
}
// The two clocks are NOT ordered: window_activity tracks pane output, while an
// attach bumps session_activity without producing any. Observed on a real host:
// one session with session_activity 4s NEWER than window_activity, another with
// window_activity 25 DAYS newer. The question being asked is "has anything
// happened here", so take whichever is later.
const activitySec = Math.max(winSec, sessSec)
rows.push({
id,
createdAtMs: createdSec * 1000,
lastActivityAtMs: activitySec * 1000,
attached: attached !== '0',
cols,
rows: rows_,
})
}
return rows.sort((a, b) => b.lastActivityAtMs - a.lastActivityAtMs)
}
/**
* Every `web_*` tmux session on the host, most recently active first. Never
* throws; [] when tmux is missing or no tmux server is running.
*
* ASYNC on purpose. This is polled — every device's home screen refreshes on a
* timer — and the spawn costs ~73 ms on a host with 69 sessions. Done
* synchronously that is 73 ms with the event loop stopped, i.e. 73 ms during which
* no PTY byte reaches any terminal on any device. The server is a byte-shuttle
* first; nothing on a polling path may block it.
*/
export async function listSessions(): Promise<TmuxSessionSummary[]> {
try {
const { stdout } = await execFileAsync('tmux', ['list-sessions', '-F', LIST_FORMAT], {
encoding: 'utf8',
maxBuffer: CAPTURE_MAX_BUFFER,
timeout: TMUX_TIMEOUT_MS,
})
return parseSessionList(stdout)
} catch {
return []
}
}
/**
* The session's CURRENT screen, without attaching. Returns null if it is gone.
*
* `-p` prints to stdout and `-e` keeps the escape sequences, so the bytes can go
* straight into a read-only xterm and keep their colour. Deliberately NOT
* `attach-session`: attaching would make tmux resize the window to the new
* client's dimensions and SIGWINCH whatever is running inside it.
*/
export async function capturePane(name: string): Promise<string | null> {
try {
const { stdout } = await execFileAsync('tmux', ['capture-pane', '-p', '-e', '-t', exactPane(name)], {
encoding: 'utf8',
maxBuffer: CAPTURE_MAX_BUFFER,
timeout: TMUX_TIMEOUT_MS,
})
return stdout
} catch {
return null
}
}