217206e9a91e283f9c93eaa229667c8a0b8582b3
2 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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.
|
||
|
|
4892fa7b49 |
feat(sessions): surface tmux sessions the server does not track
A `web_*` tmux session that outlives the process which created it was unreachable
from the UI, permanently. Nothing enumerated tmux — src/session/tmux.ts had
hasSession but no list — so recovery only worked if a client still had the id in
localStorage. list() walks the in-memory table, and so does reapIdle, so such a
session could not be listed, previewed, joined or killed, and never aged out.
On this host that was 69 tmux sessions with 5 clients: 64 unreachable, the oldest
from 26 Jun, one of them still running a Claude Code session with 7 agents.
This is the "catalogue" approach: make them visible and actionable WITHOUT
adopting them. Nothing is attached, because attaching makes tmux resize the window
to the new client's dimensions and SIGWINCH whatever is running inside — doing
that to dozens of live TUIs at startup is exactly the outcome to avoid. Adoption
stays an explicit user action: opening an orphan re-attaches by id through the
existing re-attach path, and it becomes an ordinary live session.
src/session/tmux.ts listSessions() + parseSessionList() (pure, so the filter
rules are unit-testable) and capturePane(), which reads a
session's current screen via `capture-pane -p -e` — no
client, no resize, colour preserved.
src/session/manager.ts listOrphans/captureOrphan/killOrphan/killOrphansIdleSince
src/server.ts GET /orphan-sessions, GET /orphan-sessions/:id/preview,
DELETE /orphan-sessions/:id, DELETE /orphan-sessions
?idleDays=N
public/ a "Recoverable" section under the home grid, reusing the
existing card/thumbnail machinery via orphanAsLive()
Guards, all enforced in the manager so no route can forget one:
- the tmux name must be `web_` + a UUID v4 (SESSION_ID_RE, the same gate the
attach protocol applies). This is what stops a hostile or malformed name from
reaching `tmux -t` — a literal `-t`, or path traversal — and it means a tmux
session the user created for their own work is never enumerated, never
previewed and never offered for deletion.
- an id the table already owns is refused everywhere: killing it via tmux would
end the shell behind a live PTY's back and leave a zombie table entry. Those go
through killById.
- every function is inert when cfg.useTmux is off, so the non-tmux deployment is
byte-for-byte unchanged.
- bulk cleanup skips ATTACHED sessions and requires idleDays >= 1: there is no
"delete everything" form of the route. "This server does not track it" is not
evidence that it is abandoned — a plain `tmux attach` and a second server
process both report as attached.
- reads carry no Origin guard (same threat model as /live-sessions); both DELETEs
do. Preview gets its own rate-limit bucket since each call spawns a process.
Two things the live run exposed and this fixes: previews were loading
sequentially (69 tmux spawns per 5 s refresh) — now once per card, fire-and-forget
— and the grid is capped at 24 cards because each thumbnail is an xterm instance.
The remainder is stated in the UI with the tmux command to reach it, never
silently truncated.
Separate wire type from LiveSessionInfo on purpose: an orphan supports none of the
live-session operations, and the Android/iOS clients decode /live-sessions, so a
variant shape there would break them.
Tests: 2203 unit (+42: parse/filter rules, all four manager guards, tmux-off
inertness) and 8 integration against real tmux — create a throwaway session,
list it, capture it while asserting it stays unattached, reject non-UUID ids,
reject a foreign Origin, kill it, 404 the second time. The integration file never
issues the bulk DELETE: it runs against the host's real tmux server and would end
the developer's own sessions. Verified live against all 69 with none harmed.
|