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.
This commit is contained in:
Yaojia Wang
2026-07-30 10:40:10 +02:00
parent ffc185aa2d
commit 4892fa7b49
10 changed files with 1162 additions and 6 deletions

View File

@@ -0,0 +1,208 @@
/**
* Integration test for the orphan-session routes (A):
* GET /orphan-sessions
* GET /orphan-sessions/:id/preview
* DELETE /orphan-sessions/:id
*
* SAFETY: this file NEVER issues `DELETE /orphan-sessions` (the bulk cleanup),
* because these tests run against the host's real tmux server and would end the
* developer's own long-running sessions. Bulk cleanup is covered with mocks in
* test/manager-orphans.test.ts. The only session touched here is one this file
* creates under a fresh UUID and tears down itself.
*/
import net from 'node:net'
import { execFileSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { loadConfig } from '../../src/config.js'
import { startServer } from '../../src/server.js'
import type { OrphanSessionInfo } from '../../src/types.js'
const TMUX_AVAILABLE = (() => {
try {
execFileSync('tmux', ['-V'], { stdio: 'ignore' })
return true
} catch {
return false
}
})()
const itTmux = TMUX_AVAILABLE ? it : it.skip
function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer()
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address()
if (addr === null || typeof addr === 'string') {
srv.close()
reject(new Error('unexpected address type'))
return
}
const port = addr.port
srv.close(() => resolve(port))
})
srv.on('error', reject)
})
}
/* ── tmux off: the whole feature must be inert ──────────────────────────────── */
describe('orphan sessions — tmux disabled', () => {
let port: number
let handle: { close(): Promise<void> }
const origin = (): string => `http://127.0.0.1:${port}`
beforeAll(async () => {
port = await getFreePort()
handle = startServer(
loadConfig({
PORT: String(port),
BIND_HOST: '127.0.0.1',
SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
ALLOWED_ORIGINS: `http://127.0.0.1:${port}`,
USE_TMUX: '0',
IDLE_TTL: '86400',
}),
)
await new Promise<void>((r) => setTimeout(r, 100))
})
afterAll(async () => {
await handle.close()
})
it('lists nothing, and does not require an Origin header (read-only)', async () => {
const res = await fetch(`${origin()}/orphan-sessions`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
it('404s a preview instead of shelling out to tmux', async () => {
const res = await fetch(`${origin()}/orphan-sessions/${randomUUID()}/preview`)
expect(res.status).toBe(404)
})
it('404s a kill', async () => {
const res = await fetch(`${origin()}/orphan-sessions/${randomUUID()}`, {
method: 'DELETE',
headers: { Origin: origin() },
})
expect(res.status).toBe(404)
})
})
/* ── tmux on: real session, real capture, real kill ─────────────────────────── */
describe('orphan sessions — tmux enabled', () => {
let port: number
let handle: { close(): Promise<void> }
const origin = (): string => `http://127.0.0.1:${port}`
/** Our throwaway session. Never any other. */
const id = randomUUID()
const name = `web_${id}`
let created = false
beforeAll(async () => {
port = await getFreePort()
handle = startServer(
loadConfig({
PORT: String(port),
BIND_HOST: '127.0.0.1',
SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
ALLOWED_ORIGINS: `http://127.0.0.1:${port}`,
USE_TMUX: '1',
IDLE_TTL: '86400',
}),
)
await new Promise<void>((r) => setTimeout(r, 100))
if (TMUX_AVAILABLE) {
// Detached, printing a known marker so capture-pane has something to find.
execFileSync(
'tmux',
['new-session', '-d', '-s', name, `printf 'ORPHAN_MARKER_%s' OK; sleep 300`],
{ stdio: 'ignore' },
)
created = true
await new Promise<void>((r) => setTimeout(r, 400))
}
})
afterAll(async () => {
if (created) {
try {
execFileSync('tmux', ['kill-session', '-t', name], { stdio: 'ignore' })
} catch {
// the kill test already removed it — fine
}
}
await handle.close()
})
itTmux('lists our untracked tmux session as an orphan', async () => {
const res = await fetch(`${origin()}/orphan-sessions`)
expect(res.status).toBe(200)
const body = (await res.json()) as OrphanSessionInfo[]
const mine = body.find((o) => o.id === id)
expect(mine).toBeDefined()
expect(mine!.attached).toBe(false) // created with -d, nobody attached
expect(mine!.cols).toBeGreaterThan(0)
expect(mine!.lastActivityAt).toBeGreaterThan(0)
})
itTmux('previews the live screen without attaching to it', async () => {
const res = await fetch(`${origin()}/orphan-sessions/${id}/preview`)
expect(res.status).toBe(200)
const body = (await res.json()) as { id: string; data: string }
expect(body.id).toBe(id)
expect(body.data).toContain('ORPHAN_MARKER_OK')
// The capture must not have left a client behind: still unattached.
const after = execFileSync('tmux', ['ls', '-F', '#{session_name} #{session_attached}'], {
encoding: 'utf8',
})
expect(after).toContain(`${name} 0`)
})
itTmux('rejects a non-UUID id with 400, never reaching tmux', async () => {
for (const bad of ['-t', 'not-a-uuid', '../../etc/passwd']) {
const res = await fetch(`${origin()}/orphan-sessions/${encodeURIComponent(bad)}/preview`)
expect(res.status).toBe(400)
}
})
itTmux('refuses a kill with a foreign Origin (CSRF guard)', async () => {
const res = await fetch(`${origin()}/orphan-sessions/${id}`, {
method: 'DELETE',
headers: { Origin: 'http://evil.example' },
})
expect(res.status).toBe(403)
// and it is still alive
expect(
execFileSync('tmux', ['ls', '-F', '#{session_name}'], { encoding: 'utf8' }),
).toContain(name)
})
itTmux('kills it, and 404s the second time', async () => {
const first = await fetch(`${origin()}/orphan-sessions/${id}`, {
method: 'DELETE',
headers: { Origin: origin() },
})
expect(first.status).toBe(204)
const listing = execFileSync('tmux', ['ls', '-F', '#{session_name}'], {
encoding: 'utf8',
}).split('\n')
expect(listing).not.toContain(name)
const second = await fetch(`${origin()}/orphan-sessions/${id}`, {
method: 'DELETE',
headers: { Origin: origin() },
})
expect(second.status).toBe(404)
})
})

View File

@@ -0,0 +1,280 @@
/**
* A — orphan tmux sessions: the manager side.
*
* An "orphan" is a `web_*` tmux session on the host that this server does NOT have
* in its table (it outlived the process that made it). These tests pin the three
* rules that keep the feature safe:
*
* 1. never enumerate or act on a tmux session that is not ours (`web_` + UUID v4);
* 2. never act on an id the table already owns — that path must stay killById, or
* we would kill the shell out from under a live PTY and leave a zombie entry;
* 3. do nothing at all when tmux is off, so the non-tmux deployment is unchanged.
*
* Both node-pty and the tmux CLI wrappers are mocked: no shell, no tmux binary.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { Config, Dims, WebSocketLike } from '../src/types.js';
import { WS_OPEN } from '../src/types.js';
import { createMockPty, type MockIPty } from './helpers/mock-pty.js';
let nextPty: MockIPty = createMockPty();
vi.mock('node-pty', () => ({
spawn: () => nextPty,
}));
// ── tmux CLI mock ────────────────────────────────────────────────────────────
const tmuxList = vi.fn<() => unknown[]>(() => []);
const tmuxCapture = vi.fn<(name: string) => string | null>(() => null);
const tmuxKill = vi.fn<(name: string) => void>(() => {});
const tmuxHas = vi.fn<(name: string) => boolean>(() => true);
vi.mock('../src/session/tmux.js', () => ({
tmuxAvailable: () => true,
tmuxName: (id: string) => `web_${id}`,
hasSession: (n: string) => tmuxHas(n),
killSession: (n: string) => tmuxKill(n),
listSessions: () => tmuxList(),
capturePane: (n: string) => tmuxCapture(n),
}));
const { createSessionManager } = await import('../src/session/manager.js');
const U1 = '05caa88b-1f5f-45d9-bbbe-fc5955314870';
const U2 = '3a63f3c7-acfe-4081-992f-c4dbd55b3d6b';
const BASE_CFG = {
port: 3000,
bindHost: '0.0.0.0',
shellPath: '/bin/zsh',
homeDir: '/home/tester',
idleTtlMs: 10_000,
scrollbackBytes: 2 * 1024 * 1024,
maxPayloadBytes: 1024 * 1024,
wsPath: '/term',
maxSessions: 50,
maxMsgsPerSec: 2000,
permTimeoutMs: 300_000,
reapIntervalMs: 60_000,
previewBytes: 24 * 1024,
useTmux: true,
allowedOrigins: [],
projectRoots: ['/home/tester'],
projectScanDepth: 4,
projectScanTtlMs: 10_000,
projectDirtyCheck: true,
editorCmd: 'code',
vapidPublicKey: undefined,
vapidPrivateKey: undefined,
vapidSubject: undefined,
pushStorePath: '/home/tester/.push.json',
pushMaxSubs: 50,
notifyDone: true,
notifyDnd: false,
decisionTokenTtlMs: 300_000,
timelineMax: 200,
timelineEnabled: true,
stuckTtlMs: 10_000,
stuckAlert: true,
diffTimeoutMs: 2000,
diffMaxBytes: 2 * 1024 * 1024,
diffMaxFiles: 300,
statuslineTtlMs: 30_000,
worktreeEnabled: true,
worktreeRoot: undefined,
worktreeTimeoutMs: 10_000,
maxFanoutLanes: 6,
defaultPermissionMode: 'default' as const,
allowAutoMode: false,
queueEnabled: true,
queueMaxItems: 10,
queueItemMaxBytes: 4096,
queueSettleMs: 1500,
} satisfies Config;
const CFG: Config = BASE_CFG;
const CFG_NO_TMUX: Config = { ...BASE_CFG, useTmux: false };
const DIMS: Dims = { cols: 80, rows: 24 };
function createMockWs(): WebSocketLike & { readyState: number } {
return {
readyState: WS_OPEN,
send() {},
close() {},
};
}
/** A tmux summary row as src/session/tmux.ts would produce it. */
function summary(id: string, activityMs = 5_000, attached = false) {
return {
id,
createdAtMs: 1_000,
lastActivityAtMs: activityMs,
attached,
cols: 120,
rows: 40,
};
}
beforeEach(() => {
nextPty = createMockPty();
tmuxList.mockReset().mockReturnValue([]);
tmuxCapture.mockReset().mockReturnValue(null);
tmuxKill.mockReset();
tmuxHas.mockReset().mockReturnValue(true);
});
describe('listOrphans', () => {
it('reports a tmux session the table does not know about', () => {
tmuxList.mockReturnValue([summary(U1, 9_000, true)]);
const mgr = createSessionManager(CFG);
expect(mgr.listOrphans()).toEqual([
{
id: U1,
createdAt: 1_000,
lastActivityAt: 9_000,
attached: true,
cols: 120,
rows: 40,
},
]);
});
it('excludes sessions the table already owns — those are live, not orphans', () => {
const mgr = createSessionManager(CFG);
const live = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
tmuxList.mockReturnValue([summary(live.meta.id), summary(U2)]);
expect(mgr.listOrphans().map((o) => o.id)).toEqual([U2]);
});
it('is empty when tmux is off — the non-tmux deployment is untouched', () => {
tmuxList.mockReturnValue([summary(U1)]);
const mgr = createSessionManager(CFG_NO_TMUX);
expect(mgr.listOrphans()).toEqual([]);
expect(tmuxList).not.toHaveBeenCalled();
});
});
describe('captureOrphan', () => {
it('returns the captured screen for an untracked session', () => {
tmuxCapture.mockReturnValue('screen contents');
const mgr = createSessionManager(CFG);
expect(mgr.captureOrphan(U1)).toBe('screen contents');
expect(tmuxCapture).toHaveBeenCalledWith(`web_${U1}`);
});
it('refuses an id that is not a UUID v4 without invoking tmux at all', () => {
const mgr = createSessionManager(CFG);
expect(mgr.captureOrphan('../../etc/passwd')).toBeNull();
expect(mgr.captureOrphan('-t')).toBeNull();
expect(tmuxCapture).not.toHaveBeenCalled();
});
it('refuses when no such tmux session exists', () => {
tmuxHas.mockReturnValue(false);
const mgr = createSessionManager(CFG);
expect(mgr.captureOrphan(U1)).toBeNull();
expect(tmuxCapture).not.toHaveBeenCalled();
});
it('refuses an id the table owns — a live session previews from its ring buffer', () => {
const mgr = createSessionManager(CFG);
const live = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
expect(mgr.captureOrphan(live.meta.id)).toBeNull();
expect(tmuxCapture).not.toHaveBeenCalled();
});
it('returns null when tmux is off', () => {
const mgr = createSessionManager(CFG_NO_TMUX);
expect(mgr.captureOrphan(U1)).toBeNull();
expect(tmuxCapture).not.toHaveBeenCalled();
});
});
describe('killOrphan', () => {
it('kills the tmux session by its prefixed name', () => {
const mgr = createSessionManager(CFG);
expect(mgr.killOrphan(U1)).toBe(true);
expect(tmuxKill).toHaveBeenCalledWith(`web_${U1}`);
});
it('refuses a non-UUID id without invoking tmux', () => {
const mgr = createSessionManager(CFG);
expect(mgr.killOrphan('web_x')).toBe(false);
expect(mgr.killOrphan('-t')).toBe(false);
expect(mgr.killOrphan('')).toBe(false);
expect(tmuxKill).not.toHaveBeenCalled();
});
it('refuses an id the table owns — that is killByIds job', () => {
const mgr = createSessionManager(CFG);
const live = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
expect(mgr.killOrphan(live.meta.id)).toBe(false);
expect(tmuxKill).not.toHaveBeenCalled();
});
it('reports false when the session is already gone', () => {
tmuxHas.mockReturnValue(false);
const mgr = createSessionManager(CFG);
expect(mgr.killOrphan(U1)).toBe(false);
expect(tmuxKill).not.toHaveBeenCalled();
});
it('does nothing when tmux is off', () => {
const mgr = createSessionManager(CFG_NO_TMUX);
expect(mgr.killOrphan(U1)).toBe(false);
expect(tmuxKill).not.toHaveBeenCalled();
});
});
describe('killOrphansIdleSince', () => {
it('kills only sessions whose last activity is older than the cutoff', () => {
tmuxList.mockReturnValue([summary(U1, 1_000), summary(U2, 9_000)]);
const mgr = createSessionManager(CFG);
expect(mgr.killOrphansIdleSince(5_000)).toEqual([U1]);
expect(tmuxKill).toHaveBeenCalledWith(`web_${U1}`);
expect(tmuxKill).toHaveBeenCalledTimes(1);
});
it('never kills a session someone is attached to from a terminal', () => {
// "Untracked by this server" is not "abandoned" — a plain `tmux attach` or a
// second server process both show up as attached, and bulk cleanup must not
// reach into either.
tmuxList.mockReturnValue([summary(U1, 1_000, true)]);
const mgr = createSessionManager(CFG);
expect(mgr.killOrphansIdleSince(5_000)).toEqual([]);
expect(tmuxKill).not.toHaveBeenCalled();
});
it('never kills a session the table owns, however idle tmux thinks it is', () => {
const mgr = createSessionManager(CFG);
const live = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
tmuxList.mockReturnValue([summary(live.meta.id, 0)]);
expect(mgr.killOrphansIdleSince(5_000)).toEqual([]);
expect(tmuxKill).not.toHaveBeenCalled();
});
it('does nothing when tmux is off', () => {
tmuxList.mockReturnValue([summary(U1, 0)]);
const mgr = createSessionManager(CFG_NO_TMUX);
expect(mgr.killOrphansIdleSince(5_000)).toEqual([]);
expect(tmuxKill).not.toHaveBeenCalled();
});
});

View File

@@ -12,7 +12,8 @@ vi.mock('node:child_process', () => ({
execFileSync: (...a: unknown[]) => mockExec(...a),
}))
const { tmuxAvailable, tmuxName, hasSession, killSession } = await import('../src/session/tmux.js')
const { tmuxAvailable, tmuxName, hasSession, killSession, parseSessionList, listSessions, capturePane } =
await import('../src/session/tmux.js')
beforeEach(() => {
mockExec.mockReset()
@@ -68,3 +69,95 @@ describe('killSession', () => {
expect(() => killSession('web_gone')).not.toThrow()
})
})
/* ── A: enumerate + preview tmux sessions the server does not track ─────────── */
const U1 = '05caa88b-1f5f-45d9-bbbe-fc5955314870'
const U2 = '3a63f3c7-acfe-4081-992f-c4dbd55b3d6b'
describe('parseSessionList', () => {
it('parses tmux rows into summaries, converting seconds to ms', () => {
const out = `web_${U1}\t1783911991\t1783911992\t0\t80\t23\n`
expect(parseSessionList(out)).toEqual([
{
id: U1,
createdAtMs: 1783911991_000,
lastActivityAtMs: 1783911992_000,
attached: false,
cols: 80,
rows: 23,
},
])
})
it('sorts most-recently-active first', () => {
const out = [`web_${U1}\t100\t100\t0\t80\t24`, `web_${U2}\t100\t900\t1\t80\t24`].join('\n')
expect(parseSessionList(out).map((s) => s.id)).toEqual([U2, U1])
})
it('reports a session attached from a real terminal', () => {
const out = `web_${U1}\t100\t100\t1\t80\t24`
expect(parseSessionList(out)[0]!.attached).toBe(true)
})
it('ignores sessions that are not ours — never touch the users own tmux', () => {
const out = ['mywork\t100\t100\t0\t80\t24', `web_${U1}\t100\t100\t0\t80\t24`].join('\n')
expect(parseSessionList(out).map((s) => s.id)).toEqual([U1])
})
it('ignores a web_-prefixed name whose suffix is not a UUID v4', () => {
// Only ids the protocol could have produced are actionable: anything else is
// someone else's session that merely starts with web_.
const out = ['web_../../etc\t100\t100\t0\t80\t24', 'web_-t\t100\t100\t0\t80\t24'].join('\n')
expect(parseSessionList(out)).toEqual([])
})
it('skips malformed and blank rows instead of throwing', () => {
const out = ['', 'garbage', `web_${U1}\tNaN\t100\t0\t80\t24`, `web_${U2}\t100\t100\t0\t80\t24`].join('\n')
expect(parseSessionList(out).map((s) => s.id)).toEqual([U2])
})
it('returns an empty list for empty output (no tmux server)', () => {
expect(parseSessionList('')).toEqual([])
})
})
describe('listSessions', () => {
it('asks tmux for a machine-readable list', () => {
mockExec.mockReturnValue(`web_${U1}\t100\t100\t0\t80\t24\n`)
expect(listSessions().map((s) => s.id)).toEqual([U1])
const [file, args] = mockExec.mock.calls[0] as [string, string[]]
expect(file).toBe('tmux')
expect(args[0]).toBe('list-sessions')
expect(args).toContain('-F')
})
it('returns [] when there is no tmux server (exec throws)', () => {
mockExec.mockImplementation(() => {
throw new Error('no server running')
})
expect(listSessions()).toEqual([])
})
})
describe('capturePane', () => {
it('captures the current screen WITHOUT attaching', () => {
mockExec.mockReturnValue('hello\n')
expect(capturePane('web_x')).toBe('hello\n')
const [file, args] = mockExec.mock.calls[0] as [string, string[]]
expect(file).toBe('tmux')
expect(args[0]).toBe('capture-pane')
expect(args).toContain('-p') // print to stdout — read-only, no client
expect(args).toContain('-e') // keep escape sequences so colour survives
expect(args).toContain('web_x')
// Attaching would resize the session and SIGWINCH whatever is running in it.
expect(args).not.toContain('attach-session')
})
it('returns null when the session is gone', () => {
mockExec.mockImplementation(() => {
throw new Error("can't find session")
})
expect(capturePane('web_gone')).toBeNull()
})
})