core.ts
129 lines · browse/src/routes/core.ts at 3f79d81
1/**
2 * Daemon routes outside the named areas: the cookie-picker sub-router, the
3 * welcome page, the pinned-origin token bootstrap, liveness, refs and the
4 * memory diagnostic.
5 */
7import * as fs from 'fs';
8import * as path from 'path';
9import { resolveStateRoot } from '../../../lib/state-root';
10import { json, type RouteEntry } from './table';
11import { handleCookiePickerRoute } from '../cookie-picker-routes';
12import { sanitizeReplacer } from '../sanitize';
14function resolveWelcomePath(): string | null {
15 // Gate GSTACK_SLUG on a strict regex BEFORE interpolating it into the
16 // filesystem path. Without this, a slug like "../../etc/passwd" would
17 // resolve to ~/.gstack/projects/../../etc/passwd/... — path traversal.
18 const rawSlug = process.env.GSTACK_SLUG || 'unknown';
19 const slug = /^[a-z0-9_-]+$/.test(rawSlug) ? rawSlug : 'unknown';
20 const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp';
21 const projectWelcome = path.join(resolveStateRoot(), 'projects', slug, 'designs', 'welcome-page-20260331', 'finalized.html');
22 if (fs.existsSync(projectWelcome)) return projectWelcome;
23 // Fallback: built-in welcome page from gstack install. Reject SKILL_ROOT
24 // values containing '..' for the same defense-in-depth reason.
25 const rawSkillRoot = process.env.GSTACK_SKILL_ROOT || `${homeDir}/.claude/skills/gstack`;
26 if (rawSkillRoot.includes('..')) return null;
27 const builtinWelcome = `${rawSkillRoot}/browse/src/welcome.html`;
28 if (fs.existsSync(builtinWelcome)) return builtinWelcome;
29 return null;
30}
32export const coreRoutes: RouteEntry[] = [
33 // Cookie picker sub-router — HTML page unauthenticated, data/action routes require auth
34 {
35 method: '*', path: '/cookie-picker', prefix: true, auth: 'handler', surfaces: ['local'],
36 handlerAuth: 'sub-router in cookie-picker-routes.ts: OPTIONS preflight open; GET /cookie-picker needs a one-time code or picker session cookie (403 text); every other /cookie-picker/* needs the root bearer or a picker session (401 Unauthorized)',
37 handler: (req, { url }, ctx) => handleCookiePickerRoute(url, req, ctx.browserManager, ctx.bootstrapRootToken),
38 },
40 // Welcome page — served when GStack Browser launches in headed mode
41 {
42 method: '*', path: '/welcome', auth: 'none', surfaces: ['local'],
43 handler: () => {
44 const welcomePath = resolveWelcomePath();
45 if (welcomePath) {
46 try {
47 const html = fs.readFileSync(welcomePath, 'utf-8');
48 return new Response(html, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
49 } catch (err: any) {
50 console.error('[browse] Failed to read welcome page:', welcomePath, err.message);
51 }
52 }
53 // No welcome page found — serve a simple fallback (avoid ERR_UNSAFE_REDIRECT on Windows)
54 return new Response(
55 `<!DOCTYPE html><html><head><title>GStack Browser</title>
56 <style>body{background:#111;color:#fff;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;}
57 .msg{text-align:center;opacity:.7;}.gold{color:#f5a623;font-size:2em;margin-bottom:12px;}</style></head>
58 <body><div class="msg"><div class="gold">◈</div><p>GStack Browser ready.</p><p style="font-size:.85em">Waiting for commands from Claude Code.</p></div></body></html>`,
59 { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }
60 );
61 },
62 },
64 // ─── POST /extension-token — pinned-origin token bootstrap ──────
65 //
66 // The ONLY endpoint that hands out AUTH_TOKEN. The token is released only
67 // to the one extension identity we ship: the Origin header must be exactly
68 // `chrome-extension://<GSTACK_EXTENSION_ID>` and the Host must be loopback
69 // (the extension-origin gate). Chrome sets Origin on cross-origin POSTs
70 // from extension contexts and web pages cannot forge a chrome-extension://
71 // Origin. Local listener only: NEVER added to TUNNEL_PATHS.
72 {
73 method: 'POST', path: '/extension-token', auth: 'extension-origin', surfaces: ['local'],
74 handler: (_req, _r, ctx) => json({ token: ctx.bootstrapRootToken }),
75 },
77 // Health check — no auth required, does NOT reset idle timer. NEVER carries
78 // a token in any mode: token bootstrap is POST /extension-token and shell
79 // auth is POST /pty-session. Liveness/status only.
80 //
81 // `status` is daemon liveness (Chromium connected); the CLI gates every
82 // command on it. `activeTab.responsive` is the active tab's main thread,
83 // probed for at most TAB_PROBE_TIMEOUT_MS and reported beside it: one busy
84 // page must not read as a dead daemon (#3247).
85 {
86 method: '*', path: '/health', auth: 'none', surfaces: ['local'],
87 handler: async (_req, _r, ctx) => {
88 const { browserManager } = ctx;
89 const healthy = browserManager.isHealthy();
90 return json({
91 status: healthy ? 'healthy' : 'unhealthy',
92 activeTab: healthy ? await browserManager.probeActiveTab() : null,
93 mode: browserManager.getConnectionMode(),
94 uptime: Math.floor((Date.now() - ctx.startTime) / 1000),
95 tabs: browserManager.getTabCount(),
96 // No `security` field (#2557): the live defenses report through
97 // their own call sites, not through /health.
98 // Terminal-agent discovery. ONLY a port number — never a token.
99 // Tokens flow via the /pty-session HttpOnly cookie path.
100 terminalPort: ctx.terminal.readPort(),
101 });
102 },
103 },
105 // Refs endpoint — does NOT reset idle timer
106 {
107 method: '*', path: '/refs', auth: 'root-bearer', surfaces: ['local'],
108 handler: (_req, _r, { browserManager }) => json({
109 refs: browserManager.getRefMap(),
110 url: browserManager.getCurrentUrl(),
111 mode: browserManager.getConnectionMode(),
112 }),
113 },
115 // GET /memory — diagnostic snapshot, does NOT reset idle. Root-bearer: it
116 // sat behind the if-chain's blanket root-bearer check, so the SSE cookie
117 // its handler also accepted never reached it.
118 {
119 method: 'GET', path: '/memory', auth: 'root-bearer', surfaces: ['local'],
120 handler: async (_req, _r, ctx) => {
121 const { buildMemorySnapshotJson } = await import('../memory-command');
122 const snapshot = await buildMemorySnapshotJson(ctx.browserManager);
123 // sanitizeReplacer is required at every JSON egress that ships
124 // page-content-derived strings — tab.url and tab.title come from page
125 // content.
126 return json(snapshot, { replacer: sanitizeReplacer });
127 },
128 },
129];