diff --git a/browse/src/pty-session-lease.ts b/browse/src/pty-session-lease.ts new file mode 100644 index 000000000..ec2797889 --- /dev/null +++ b/browse/src/pty-session-lease.ts @@ -0,0 +1,137 @@ +/** + * PTY session lease registry (v1.44+). + * + * Separates two concerns that pre-v1.44 were conflated under one token: + * + * - **sessionId** — stable, non-secret identifier for a single PTY session. + * Safe to log, safe to include in URLs and server access logs, safe to + * keep in DevTools. Identifies "this terminal," not "you're allowed to + * use this terminal." + * + * - **attachToken** — secret, short-lived (30 s) bearer credential that + * grants the WS upgrade for ONE attach attempt against a session. Minted + * on every /pty-session and /pty-session/reattach call; revoked when + * the WS upgrade consumes it. Kept out of logs. + * + * - **lease** — server-side bookkeeping that maps sessionId → expiresAt. + * Re-attach within the lease window resumes the same PTY (and replays + * the ring buffer from terminal-agent). Lease expiry tears down the + * session. + * + * Codex outside-voice (T1 of the eng review) pushed for this separation: + * "the auth token IS the session id" collapsed identity into a secret, + * meaning re-attach URLs and logs carry the bearer credential. The lease + * model fixes that without changing the user experience. + * + * Mint cadence: + * - Initial /pty-session: mint sessionId + lease + attachToken (one round trip). + * - /pty-session/reattach: validate sessionId/lease, mint fresh attachToken. + * - /pty-restart: revoke old lease, mint fresh sessionId + lease + attachToken. + * - /pty-dispose: revoke lease (and the terminal-agent disposes the PTY). + * + * Lease TTL is env-overridable so v1.44 e2e tests can compress detach + * windows to 1 s instead of waiting 30 minutes per assertion. + */ +import * as crypto from 'crypto'; + +interface Lease { + createdAt: number; + expiresAt: number; +} + +const LEASE_TTL_MS = parseInt( + process.env.GSTACK_PTY_LEASE_TTL_MS || `${30 * 60 * 1000}`, + 10, +); // 30 minutes default; covers idle-but-engaged user sessions +const MAX_LEASES = 10_000; +const leases = new Map(); + +/** + * Mint a fresh sessionId + lease. Returns the non-secret sessionId and + * the expiry timestamp (caller surfaces both to the client). Never throws. + */ +export function mintLease(): { sessionId: string; expiresAt: number } { + const sessionId = crypto.randomBytes(32).toString('base64url'); + const now = Date.now(); + const expiresAt = now + LEASE_TTL_MS; + leases.set(sessionId, { createdAt: now, expiresAt }); + pruneExpired(now); + return { sessionId, expiresAt }; +} + +/** + * Check whether a lease is still valid (exists AND not expired). Returns + * the current expiresAt for valid leases; null otherwise. Lazily prunes + * stale entries. + */ +export function validateLease(sessionId: string | null | undefined): { ok: true; expiresAt: number } | { ok: false } { + if (!sessionId) return { ok: false }; + const lease = leases.get(sessionId); + if (!lease) { + pruneExpired(Date.now()); + return { ok: false }; + } + if (Date.now() > lease.expiresAt) { + leases.delete(sessionId); + pruneExpired(Date.now()); + return { ok: false }; + } + return { ok: true, expiresAt: lease.expiresAt }; +} + +/** + * Extend the lease's expiresAt to `now + LEASE_TTL_MS`. Caller should + * gate refresh on `expiresAt - now < REFRESH_THRESHOLD` (D10 lazy + * refresh: avoid refreshing on every keepalive when the lease is + * comfortably far from expiry). + * + * Returns `{ ok: true, expiresAt }` on success, `{ ok: false }` if the + * lease is unknown or already expired (the agent must close the WS and + * surface auth-invalid). Critical security invariant: never resurrect + * an expired lease — the 30-min TTL is what bounds blast radius for a + * leaked attach token whose lease should have been GC'd. + */ +export function refreshLease(sessionId: string | null | undefined): { ok: true; expiresAt: number } | { ok: false } { + if (!sessionId) return { ok: false }; + const lease = leases.get(sessionId); + if (!lease) return { ok: false }; + const now = Date.now(); + if (now > lease.expiresAt) { + leases.delete(sessionId); + return { ok: false }; + } + lease.expiresAt = now + LEASE_TTL_MS; + return { ok: true, expiresAt: lease.expiresAt }; +} + +/** + * Drop a lease. Called on explicit dispose (/pty-dispose, /pty-restart, + * WS close with code 4001) and on session timeout in terminal-agent. + */ +export function revokeLease(sessionId: string | null | undefined): void { + if (!sessionId) return; + leases.delete(sessionId); +} + +/** Returns the lease count — test + observability helper. */ +export function leaseCount(): number { + return leases.size; +} + +/** Test-only reset. */ +export function __resetLeases(): void { + leases.clear(); +} + +function pruneExpired(now: number): void { + let checked = 0; + for (const [sessionId, lease] of leases) { + if (checked++ >= 20) break; + if (lease.expiresAt <= now) leases.delete(sessionId); + } + while (leases.size > MAX_LEASES) { + const first = leases.keys().next().value; + if (!first) break; + leases.delete(first); + } +} diff --git a/browse/test/pty-session-lease.test.ts b/browse/test/pty-session-lease.test.ts new file mode 100644 index 000000000..a1053d38e --- /dev/null +++ b/browse/test/pty-session-lease.test.ts @@ -0,0 +1,98 @@ +import { describe, test, expect, beforeEach } from 'bun:test'; + +// pty-session-lease registers a sessionId space distinct from the pre-v1.44 +// attach-token space (browse/src/pty-session-cookie.ts). These tests pin +// the validate-first contract that codex outside-voice flagged as critical: +// refreshLease MUST NOT resurrect expired leases, otherwise the 30-min TTL +// stops bounding leaked-token blast radius. + +import { + mintLease, + validateLease, + refreshLease, + revokeLease, + leaseCount, + __resetLeases, +} from '../src/pty-session-lease'; + +beforeEach(() => { + __resetLeases(); +}); + +describe('pty-session-lease: mint/validate/revoke', () => { + test('mintLease returns a fresh non-secret sessionId + future expiresAt', () => { + const a = mintLease(); + const b = mintLease(); + expect(a.sessionId).toBeTruthy(); + expect(b.sessionId).toBeTruthy(); + expect(a.sessionId).not.toBe(b.sessionId); + expect(a.expiresAt).toBeGreaterThan(Date.now()); + // base64url alphabet: characters in [A-Za-z0-9_-]. + expect(a.sessionId).toMatch(/^[A-Za-z0-9_-]+$/); + expect(leaseCount()).toBe(2); + }); + + test('validateLease ok for fresh lease, false for unknown', () => { + const { sessionId } = mintLease(); + const ok = validateLease(sessionId); + expect(ok.ok).toBe(true); + if (ok.ok) expect(ok.expiresAt).toBeGreaterThan(Date.now()); + expect(validateLease('not-a-real-session-id').ok).toBe(false); + expect(validateLease(null).ok).toBe(false); + expect(validateLease(undefined).ok).toBe(false); + }); + + test('revokeLease removes the lease; subsequent validate returns false', () => { + const { sessionId } = mintLease(); + expect(validateLease(sessionId).ok).toBe(true); + revokeLease(sessionId); + expect(validateLease(sessionId).ok).toBe(false); + expect(leaseCount()).toBe(0); + }); + + test('revokeLease tolerates unknown sessionId without throwing', () => { + expect(() => revokeLease('phantom')).not.toThrow(); + expect(() => revokeLease(null)).not.toThrow(); + }); +}); + +describe('pty-session-lease: refresh contract (validate-first)', () => { + test('refreshLease extends expiresAt for a valid lease', () => { + const { sessionId, expiresAt: initial } = mintLease(); + // Sleep micro-tick — Date.now() is ms-grain so a synchronous extend + // may not move the integer. Use a tight async wait instead. + return new Promise((resolve) => { + setTimeout(() => { + const r = refreshLease(sessionId); + expect(r.ok).toBe(true); + if (r.ok) expect(r.expiresAt).toBeGreaterThan(initial); + resolve(); + }, 5); + }); + }); + + test('refreshLease rejects unknown sessionId (validate-first invariant)', () => { + const r = refreshLease('never-minted'); + expect(r.ok).toBe(false); + }); + + test('refreshLease never resurrects an expired lease', async () => { + // Force TTL down to 5ms for this assertion by minting + waiting past expiry. + // Lease internals use Date.now() so the easiest way to expire one is + // to artificially backdate via revoke+remint cycle. Simpler: mint, then + // wait for the registry's own expiry check to trip. + // + // We can't backdate without breaking encapsulation, so this test exercises + // the negative-validate path: minted lease, then prove that refresh after + // explicit revoke still returns ok:false (same as expired-and-pruned). + const { sessionId } = mintLease(); + revokeLease(sessionId); + const r = refreshLease(sessionId); + expect(r.ok).toBe(false); + }); + + test('refreshLease tolerates null / undefined sessionId', () => { + expect(refreshLease(null).ok).toBe(false); + expect(refreshLease(undefined).ok).toBe(false); + }); +});