feat(server): /pty-session 4-tuple + /pty-restart + /pty-dispose + lease-refresh

Wires the lease + attachToken model end-to-end on the server side. The
client side (extension) lands in the next commit; agent side already
shipped in 449144cd.

Routes:
  * POST /pty-session — mints sessionId (stable, loggable) + lease
    (server-side bookkeeping) + attachToken (short-lived bearer for the
    WS upgrade). Returns the 4-tuple in one round trip. Legacy
    ptySessionToken / expiresAt aliases kept for one minor release so
    extensions on the v1.43 wire shape keep working.
  * POST /pty-session/reattach — validates a sessionId's lease and mints
    a FRESH attachToken bound to the same sessionId. Used by Commit 3's
    re-attach loop; 410 Gone when the lease has expired so the client
    knows to fall back to a brand-new /pty-session.
  * POST /pty-restart — one transaction: dispose the caller's existing
    PtySession on the agent (via /internal/restart, scoped to one
    sessionId — codex T2), revoke the old lease, mint a fresh
    sessionId + lease + attachToken, return the 4-tuple. Zero race
    window between kill and mint (codex T2 + D8 of the eng review).
  * POST /pty-dispose — explicit teardown. sendBeacon-compatible: accepts
    auth token in the body so the extension's pagehide handler (Commit 2C)
    can fire it without setting custom headers (sendBeacon doesn't
    support those). Without this route, every clean browser quit leaves
    a zombie PTY alive for the 60s detach window — codex T3 caught it.
  * POST /internal/lease-refresh — loopback from terminal-agent on its
    25s keepalive cycle (lazy: only when lease is within 5 min of
    expiry). Refreshes the lease AND resets the daemon idle timer. T6
    of the eng review: PTY activity (not arbitrary SSE consumers) is
    what keeps the daemon alive when the sidebar is in use.

Helpers:
  * grantPtyToken now accepts optional sessionId and passes it through
    to the agent's /internal/grant body. The agent binds token → sessionId
    in its validTokens Map so /ws upgrades carry the sessionId for
    /internal/restart and Commit 3 re-attach lookups.
  * restartPtySession() — new loopback helper that POSTs the agent's
    scoped /internal/restart with a sessionId body. Used by /pty-restart
    and /pty-dispose.

Auth contract on /pty-dispose deliberately accepts the auth token in
EITHER the Authorization header OR the request body. The body path is
required for sendBeacon (which can't set custom headers); the header
path stays available for non-beacon callers and tests.

Test (browse/test/server-pty-lease-routes.test.ts):
  * 7 static-grep tripwires pinning the 4-tuple shape, validate-first
    re-attach with 410 fallback, one-transaction restart semantics,
    sendBeacon-compatible dispose auth, and the T6 PTY-only idle reset.
  * Live route exercises (full mint + grant + WS upgrade cycle) belong
    in the e2e tier — they require a real terminal-agent loopback and
    take seconds per assertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-23 23:17:42 -07:00
parent 449144cda5
commit 25ef24e92e
2 changed files with 314 additions and 28 deletions
@@ -0,0 +1,94 @@
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
// Server-side route shape for the v1.44 lease + restart + dispose +
// lease-refresh wiring. Live route exercises require the terminal-agent
// loopback to be live (e2e-tier); these static-grep tripwires pin the
// load-bearing protocol invariants.
const SERVER_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'server.ts');
describe('server: PTY lease routes (v1.44+ Commit 2)', () => {
test('1. /pty-session returns the 4-tuple shape (sessionId, attachToken, leaseExpiresAt)', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
const block = sliceBetween(src, "url.pathname === '/pty-session' &&", "url.pathname === '/pty-session/reattach'");
expect(block).toContain('mintLease()');
expect(block).toContain('grantPtyToken(minted.token, lease.sessionId)');
expect(block).toContain('sessionId: lease.sessionId');
expect(block).toContain('attachToken: minted.token');
expect(block).toContain('leaseExpiresAt: lease.expiresAt');
// Backward compat: legacy ptySessionToken alias preserved for one release.
expect(block).toContain('ptySessionToken: minted.token');
});
test('2. /pty-session/reattach validates lease + mints fresh attachToken', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
const block = sliceBetween(src, "url.pathname === '/pty-session/reattach'", "url.pathname === '/pty-restart'");
// Validate-first: rejects unknown/expired sessionId with 410 Gone so
// the client knows to fall back to a fresh /pty-session.
expect(block).toContain('validateLease(sessionId)');
expect(block).toContain('status: 410');
// Mint fresh token bound to SAME sessionId.
expect(block).toContain('grantPtyToken(minted.token, sessionId!)');
});
test('3. /pty-restart is one transaction — dispose + revoke + fresh mint', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
const block = sliceBetween(src, "url.pathname === '/pty-restart'", "url.pathname === '/pty-dispose'");
// Disposes old session (best-effort — missing sessionId is non-fatal).
expect(block).toContain('restartPtySession(oldSessionId)');
expect(block).toContain('revokeLease(oldSessionId)');
// Then mints fresh sessionId + lease + attachToken in the same handler.
expect(block).toContain('mintLease()');
expect(block).toContain('grantPtyToken(minted.token, lease.sessionId)');
// Returns the same 4-tuple shape so the client doesn't need a
// separate /pty-session round-trip.
expect(block).toContain('attachToken: minted.token');
expect(block).toContain('leaseExpiresAt: lease.expiresAt');
});
test('4. /pty-dispose accepts body-token (sendBeacon-compatible)', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
const block = sliceBetween(src, "url.pathname === '/pty-dispose'", "url.pathname === '/internal/lease-refresh'");
// sendBeacon can't set custom headers, so the route MUST accept the
// auth token in the request body. Otherwise pagehide cleanup fails
// silently every time the user closes the browser.
expect(block).toContain('body?.authToken');
expect(block).toContain('authedByBody');
// Both auth paths must validate against authToken — never just trust
// a body-supplied token without the equality check.
expect(block).toContain('authTokenFromBody === authToken');
});
test('5. /internal/lease-refresh resets the daemon idle timer (T6)', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
const block = sliceBetween(src, "url.pathname === '/internal/lease-refresh'", '─── /pty-inject-scan');
expect(block).toContain('refreshLease(sessionId)');
expect(block).toContain('resetIdleTimer()');
// Refresh failure (unknown / expired) MUST 410, not 200, so the
// agent knows to close the WS and force a clean re-auth.
expect(block).toContain('status: 410');
});
test('6. grantPtyToken loopback carries sessionId binding', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
expect(src).toMatch(/grantPtyToken\(token: string, sessionId\?: string\)/);
expect(src).toContain('sessionId ? { token, sessionId } : { token }');
});
test('7. restartPtySession helper exists and POSTs the agent /internal/restart', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
expect(src).toMatch(/async function restartPtySession\(sessionId: string\)/);
expect(src).toContain('/internal/restart');
expect(src).toContain('JSON.stringify({ sessionId })');
});
});
function sliceBetween(source: string, start: string, end: string): string {
const i = source.indexOf(start);
if (i === -1) throw new Error(`marker not found: ${start}`);
const j = source.indexOf(end, i + start.length);
if (j === -1) throw new Error(`end marker not found: ${end}`);
return source.slice(i, j);
}