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.
This commit is contained in:
Yaojia Wang
2026-07-30 11:46:51 +02:00
parent 2bfc76b397
commit d39a0ab8d1
5 changed files with 204 additions and 42 deletions

View File

@@ -187,6 +187,42 @@ describe('orphan sessions — tmux enabled', () => {
).toContain(name)
})
itTmux('cannot reach a look-alike session by tmux prefix matching', async () => {
// tmux -t resolves exact name, then NAME PREFIX, then fnmatch. `web_<uuid>_mine`
// is refused by parseSessionList (suffix is not a UUID) and so is never listed —
// but a bare `-t web_<uuid>` prefix-matches it, which would let a DELETE aimed at
// a non-existent id end a session the user created for their own work.
const decoyId = randomUUID()
const decoy = `web_${decoyId}_mine`
execFileSync('tmux', ['new-session', '-d', '-s', decoy, 'sleep 300'], { stdio: 'ignore' })
try {
// It must not appear in the listing…
const list = (await (await fetch(`${origin()}/orphan-sessions`)).json()) as OrphanSessionInfo[]
expect(list.some((o) => o.id === decoyId)).toBe(false)
// …and the exact id must be reported as absent, not silently matched to it.
const preview = await fetch(`${origin()}/orphan-sessions/${decoyId}/preview`)
expect(preview.status).toBe(404)
const del = await fetch(`${origin()}/orphan-sessions/${decoyId}`, {
method: 'DELETE',
headers: { Origin: origin() },
})
expect(del.status).toBe(404)
// The decoy is still alive — that is the whole point.
expect(
execFileSync('tmux', ['ls', '-F', '#{session_name}'], { encoding: 'utf8' }),
).toContain(decoy)
} finally {
try {
execFileSync('tmux', ['kill-session', '-t', `=${decoy}`], { stdio: 'ignore' })
} catch {
// already gone
}
}
})
itTmux('kills it, and 404s the second time', async () => {
const first = await fetch(`${origin()}/orphan-sessions/${id}`, {
method: 'DELETE',