Skill Tracer
Skills › gstack › browse › pairing.ts

pairing.ts

158 lines · browse/src/routes/pairing.ts at 3f79d81

1/**
2 * Pair-agent ceremony routes: the /connect alive probe and setup-key
3 * exchange (the only unauthenticated tunnel endpoints) and root-only /pair.
4 */
5
6import { json, jsonError, type RouteEntry } from './table';
7import {
8 checkConnectRateLimit, exchangeSetupKey, createSetupKey, revokeToken, revokeSetupKeys,
9 getClientSession, grantReducesAccess, assertValidClientId, assertValidTokenOptions,
10 DEFAULT_PAIR_SCOPES, InvalidScopeError, ReservedClientIdError, type ScopeCategory,
11} from '../token-registry';
13export const pairingRoutes: RouteEntry[] = [
14 // GET /connect — alive probe. Unauth on both surfaces. Used by /pair and
15 // /tunnel/start to detect dead ngrok tunnels via the tunnel URL, since
16 // /health is not tunnel-reachable under the dual-listener design.
17 //
18 // Shares the same rate limit as POST /connect — otherwise a tunnel caller
19 // can probe unlimited GETs, which makes the endpoint a free
20 // daemon-enumeration surface.
21 {
22 method: 'GET', path: '/connect', auth: 'none', surfaces: ['local', 'tunnel'],
23 handler: () => {
24 if (!checkConnectRateLimit()) return jsonError(429, 'Rate limited');
25 return json({ alive: true });
26 },
27 },
29 // ─── /connect — setup key exchange for /pair-agent ceremony ────
30 {
31 method: 'POST', path: '/connect', auth: 'none', surfaces: ['local', 'tunnel'],
32 handler: async (req) => {
33 if (!checkConnectRateLimit()) return jsonError(429, 'Too many connection attempts. Wait 1 minute.');
34 try {
35 const connectBody = await req.json() as { setup_key?: string };
36 if (!connectBody.setup_key) return jsonError(400, 'Missing setup_key');
37 const session = exchangeSetupKey(connectBody.setup_key);
38 if (!session) return jsonError(401, 'Invalid, expired, or already-used setup key');
39 console.log(`[browse] Remote agent connected: ${session.clientId} (scopes: ${session.scopes.join(',')})`);
40 return json({
41 token: session.token,
42 expires: session.expiresAt,
43 scopes: session.scopes,
44 agent: session.clientId,
45 });
46 } catch {
47 return jsonError(400, 'Invalid request body');
48 }
49 },
50 },
52 // ─── /pair — create setup key for pair-agent ceremony (root-only) ───
53 {
54 method: 'POST', path: '/pair', auth: 'root-token', surfaces: ['local'],
55 handler: async (req, _r, ctx) => {
56 try {
57 const pairBody = await req.json() as any;
58 // Reject a reserved/invalid clientId up front (createSetupKey enforces
59 // it too, but this makes the 400 unambiguous and skips the teardown).
60 if (pairBody.clientId !== undefined) assertValidClientId(pairBody.clientId);
61 // Default: DEFAULT_PAIR_SCOPES (full page access). The trust boundary
62 // is the pairing ceremony itself, not the scope. --control adds
63 // browser-wide destructive commands (stop, restart, disconnect).
64 // --restrict limits scope — but can never grant control: that scope
65 // stays behind the explicit control flag.
66 if (!pairBody.control && !pairBody.admin
67 && Array.isArray(pairBody.scopes) && pairBody.scopes.includes('control')) {
68 return jsonError(400, 'The control scope requires the control flag (--control); it cannot be granted via a scopes list.');
69 }
70 const scopes = pairBody.control || pairBody.admin
71 ? [...DEFAULT_PAIR_SCOPES, 'control' as const]
72 : ((pairBody.scopes || [...DEFAULT_PAIR_SCOPES]) as ScopeCategory[]);
73 // D1: a re-pair supersedes prior grants. ALWAYS drop stale setup keys
74 // so a superseded broad key can never be exchanged — this closes the
75 // shadow-key hole where a narrowing re-pair before the agent connects
76 // would otherwise leave the old broad key live. Revoke the live
77 // SESSION only when the new grant actually reduces access, so a
78 // broaden/refresh never strands a working agent mid-task. Compare
79 // against the resolved grant (not raw pairBody) so dropping 'control'
80 // or a default re-pair is classified correctly. Revoke runs BEFORE
81 // createSetupKey — revokeToken deletes all of a clientId's tokens, so
82 // minting first would nuke the fresh key.
83 const grant = {
84 scopes: [...scopes] as ScopeCategory[],
85 domains: pairBody.domains as string[] | undefined,
86 rateLimit: pairBody.rateLimit ?? 10,
87 tabPolicy: 'own-only' as const,
88 };
89 // Validate BEFORE any revoke (createSetupKey validates too, but that
90 // runs after the teardown below). A bad scope or negative rateLimit
91 // must 400 without knocking a live session offline — otherwise a
92 // reducing re-pair with a typo (--restrict red) destroys the session
93 // and mints no replacement.
94 assertValidTokenOptions(grant.scopes, grant.rateLimit);
95 const priorSession = pairBody.clientId ? getClientSession(pairBody.clientId) : null;
96 let superseded: { tokens_deleted: number; tabs_released: number } | undefined;
97 if (priorSession && grantReducesAccess(priorSession, grant)) {
98 const tokensDeleted = revokeToken(pairBody.clientId);
99 const tabsReleased = ctx.browserManager.releaseClientTabs(pairBody.clientId).length;
100 superseded = { tokens_deleted: tokensDeleted, tabs_released: tabsReleased };
101 console.log(`[browse] Superseded ${tokensDeleted} token(s), released ${tabsReleased} tab(s) for reducing re-pair: ${pairBody.clientId}`);
102 } else if (pairBody.clientId) {
103 revokeSetupKeys(pairBody.clientId);
104 // No live session, but tab ownership outlives token expiry: free any
105 // tabs orphaned by an expired session so this re-pair can't inherit
106 // an earlier incarnation's authenticated pages (mirrors DELETE
107 // /token's unconditional release). A live-session broaden keeps its
108 // tabs — the working agent still owns them.
109 if (!priorSession) ctx.browserManager.releaseClientTabs(pairBody.clientId);
110 }
111 const setupKey = createSetupKey({
112 clientId: pairBody.clientId,
113 scopes: [...scopes],
114 domains: pairBody.domains,
115 rateLimit: pairBody.rateLimit,
116 });
117 // Verify tunnel is actually alive before reporting it (ngrok may have died externally).
118 // Probe via GET /connect — under dual-listener /health is NOT on the tunnel allowlist,
119 // so the old probe would return 404 and always mark the tunnel as dead.
120 let verifiedTunnelUrl: string | null = null;
121 const tunnel = ctx.tunnel.state();
122 if (tunnel.active && tunnel.url) {
123 try {
124 const probe = await fetch(`${tunnel.url}/connect`, {
125 method: 'GET',
126 headers: { 'ngrok-skip-browser-warning': 'true' },
127 signal: AbortSignal.timeout(5000),
128 });
129 if (probe.ok) {
130 verifiedTunnelUrl = tunnel.url;
131 } else {
132 console.warn(`[browse] Tunnel probe failed (HTTP ${probe.status}), marking tunnel as dead`);
133 await ctx.tunnel.close();
134 }
135 } catch {
136 console.warn('[browse] Tunnel probe timed out or unreachable, marking tunnel as dead');
137 await ctx.tunnel.close();
138 }
139 }
140 return json({
141 setup_key: setupKey.token,
142 expires_at: setupKey.expiresAt,
143 scopes: setupKey.scopes,
144 tunnel_url: verifiedTunnelUrl,
145 server_url: `http://127.0.0.1:${ctx.browsePort}`,
146 ...(superseded ? { superseded } : {}),
147 });
148 } catch (err) {
149 // Name the caller's typo (bad scope, negative rateLimit, reserved
150 // clientId) instead of hiding it behind the generic body error.
151 if (err instanceof InvalidScopeError || err instanceof ReservedClientIdError) {
152 return jsonError(400, err.message);
153 }
154 return jsonError(400, 'Invalid request body');
155 }
156 },
157 },
158];