Skill Tracer
Skills › gstack › browse › pty-session-lease.ts

pty-session-lease.ts

137 lines · browse/src/pty-session-lease.ts at 3f79d81

1/**
2 * PTY session lease registry (v1.44+).
3 *
4 * Separates two concerns that pre-v1.44 were conflated under one token:
5 *
6 * - **sessionId** — stable, non-secret identifier for a single PTY session.
7 * Safe to log, safe to include in URLs and server access logs, safe to
8 * keep in DevTools. Identifies "this terminal," not "you're allowed to
9 * use this terminal."
10 *
11 * - **attachToken** — secret, short-lived (30 s) bearer credential that
12 * grants the WS upgrade for ONE attach attempt against a session. Minted
13 * on every /pty-session and /pty-session/reattach call; revoked when
14 * the WS upgrade consumes it. Kept out of logs.
15 *
16 * - **lease** — server-side bookkeeping that maps sessionId → expiresAt.
17 * Re-attach within the lease window resumes the same PTY (and replays
18 * the ring buffer from terminal-agent). Lease expiry tears down the
19 * session.
20 *
21 * Codex outside-voice (T1 of the eng review) pushed for this separation:
22 * "the auth token IS the session id" collapsed identity into a secret,
23 * meaning re-attach URLs and logs carry the bearer credential. The lease
24 * model fixes that without changing the user experience.
25 *
26 * Mint cadence:
27 * - Initial /pty-session: mint sessionId + lease + attachToken (one round trip).
28 * - /pty-session/reattach: validate sessionId/lease, mint fresh attachToken.
29 * - /pty-restart: revoke old lease, mint fresh sessionId + lease + attachToken.
30 * - /pty-dispose: revoke lease (and the terminal-agent disposes the PTY).
31 *
32 * Lease TTL is env-overridable so v1.44 e2e tests can compress detach
33 * windows to 1 s instead of waiting 30 minutes per assertion.
34 */
35import * as crypto from 'crypto';
37interface Lease {
38 createdAt: number;
39 expiresAt: number;
42const LEASE_TTL_MS = parseInt(
43 process.env.GSTACK_PTY_LEASE_TTL_MS || `${30 * 60 * 1000}`,
44 10,
45); // 30 minutes default; covers idle-but-engaged user sessions
46const MAX_LEASES = 10_000;
47const leases = new Map<string, Lease>();
49/**
50 * Mint a fresh sessionId + lease. Returns the non-secret sessionId and
51 * the expiry timestamp (caller surfaces both to the client). Never throws.
52 */
53export function mintLease(): { sessionId: string; expiresAt: number } {
54 const sessionId = crypto.randomBytes(32).toString('base64url');
55 const now = Date.now();
56 const expiresAt = now + LEASE_TTL_MS;
57 leases.set(sessionId, { createdAt: now, expiresAt });
58 pruneExpired(now);
59 return { sessionId, expiresAt };
62/**
63 * Check whether a lease is still valid (exists AND not expired). Returns
64 * the current expiresAt for valid leases; null otherwise. Lazily prunes
65 * stale entries.
66 */
67export function validateLease(sessionId: string | null | undefined): { ok: true; expiresAt: number } | { ok: false } {
68 if (!sessionId) return { ok: false };
69 const lease = leases.get(sessionId);
70 if (!lease) {
71 pruneExpired(Date.now());
72 return { ok: false };
73 }
74 if (Date.now() > lease.expiresAt) {
75 leases.delete(sessionId);
76 pruneExpired(Date.now());
77 return { ok: false };
78 }
79 return { ok: true, expiresAt: lease.expiresAt };
82/**
83 * Extend the lease's expiresAt to `now + LEASE_TTL_MS`. Caller should
84 * gate refresh on `expiresAt - now < REFRESH_THRESHOLD` (D10 lazy
85 * refresh: avoid refreshing on every keepalive when the lease is
86 * comfortably far from expiry).
87 *
88 * Returns `{ ok: true, expiresAt }` on success, `{ ok: false }` if the
89 * lease is unknown or already expired (the agent must close the WS and
90 * surface auth-invalid). Critical security invariant: never resurrect
91 * an expired lease — the 30-min TTL is what bounds blast radius for a
92 * leaked attach token whose lease should have been GC'd.
93 */
94export function refreshLease(sessionId: string | null | undefined): { ok: true; expiresAt: number } | { ok: false } {
95 if (!sessionId) return { ok: false };
96 const lease = leases.get(sessionId);
97 if (!lease) return { ok: false };
98 const now = Date.now();
99 if (now > lease.expiresAt) {
100 leases.delete(sessionId);
101 return { ok: false };
102 }
103 lease.expiresAt = now + LEASE_TTL_MS;
104 return { ok: true, expiresAt: lease.expiresAt };
107/**
108 * Drop a lease. Called on explicit dispose (/pty-dispose, /pty-restart,
109 * WS close with code 4001) and on session timeout in terminal-agent.
110 */
111export function revokeLease(sessionId: string | null | undefined): void {
112 if (!sessionId) return;
113 leases.delete(sessionId);
116/** Returns the lease count — test + observability helper. */
117export function leaseCount(): number {
118 return leases.size;
121/** Test-only reset. */
122export function __resetLeases(): void {
123 leases.clear();
126function pruneExpired(now: number): void {
127 let checked = 0;
128 for (const [sessionId, lease] of leases) {
129 if (checked++ >= 20) break;
130 if (lease.expiresAt <= now) leases.delete(sessionId);
131 }
132 while (leases.size > MAX_LEASES) {
133 const first = leases.keys().next().value;
134 if (!first) break;
135 leases.delete(first);
136 }