diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 2bc1c597d..c58469fb6 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -15,8 +15,7 @@ * restores state. Falls back to clean slate on any failure. */ -import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright'; -import { writeSecureFile, mkdirSecure } from './file-permissions'; +import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie, type Worker } from 'playwright'; import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers'; import { emitActivity } from './activity'; import { validateNavigationUrl } from './url-validation'; @@ -196,6 +195,8 @@ export class BrowserManager { // ─── Headed State ──────────────────────────────────────── private connectionMode: 'launched' | 'headed' = 'launched'; private intentionalDisconnect = false; + private extensionAuthToken: string | null = null; + private extensionId: string | null = null; // ─── Tab Count Guardrail (D5 + Codex single-tab flag) ─────── // Idempotent threshold trackers: each guardrail fires exactly once per @@ -317,6 +318,83 @@ export class BrowserManager { return null; } + /** + * Provision root auth to the trusted bundled extension without exposing it + * on a public HTTP endpoint. The service worker runs in the extension's + * isolated chrome-extension:// origin, so chrome.storage.local is readable + * by gstack's extension pages but not by arbitrary localhost processes or + * other extensions. + */ + private async provisionExtensionAuth(authToken?: string): Promise { + if (authToken) this.extensionAuthToken = authToken; + const token = authToken ?? this.extensionAuthToken; + if (!token || !this.context) return; + + const writeToken = async (worker: Worker): Promise => { + this.rememberExtensionId(worker); + await worker.evaluate(({ token: storedToken, port }) => { + return (globalThis as any).chrome.storage.local.set({ gstackAuthToken: storedToken, port }); + }, { token, port: this.serverPort || 34567 }); + }; + + this.context.on('serviceworker', (worker) => { + void (async () => { + if (!(await this.isGstackExtensionWorker(worker))) return; + await writeToken(worker); + })().catch((err: any) => { + console.warn(`[browse] Could not provision late extension auth storage: ${err.message}`); + }); + }); + + let worker: Worker | undefined; + for (const candidate of this.context.serviceWorkers()) { + if (await this.isGstackExtensionWorker(candidate)) { + worker = candidate; + break; + } + } + + if (!worker) { + try { + const candidate = await this.context.waitForEvent('serviceworker', { timeout: 5000 }); + if (await this.isGstackExtensionWorker(candidate)) worker = candidate; + } catch { + // Component-baked/custom builds may delay worker startup until the + // sidepanel is opened. The event listener above provisions auth when + // the worker appears. Do not fall back to /health token leakage. + console.warn('[browse] Extension service worker not ready yet; auth storage will be provisioned on startup'); + return; + } + } + + if (!worker) { + console.warn('[browse] GStack extension service worker not ready yet; auth storage will be provisioned on startup'); + return; + } + + try { + await writeToken(worker); + } catch (err: any) { + console.warn(`[browse] Could not provision extension auth storage: ${err.message}`); + } + } + + private async isGstackExtensionWorker(worker: Worker): Promise { + if (!worker.url().startsWith('chrome-extension://')) return false; + try { + const manifest = await worker.evaluate(() => (globalThis as any).chrome.runtime.getManifest?.()); + return manifest?.name === 'gstack browse' + && manifest?.background?.service_worker === 'background.js'; + } catch { + return false; + } + } + + private rememberExtensionId(worker: Worker): void { + const match = worker.url().match(/^chrome-extension:\/\/([^/]+)/); + if (match?.[1]) this.extensionId = match[1]; + } + /** * Set the proxy config applied to chromium.launch() in launch() and * launchHeaded(). Called by server.ts at startup once the (optional) SOCKS5 @@ -326,6 +404,14 @@ export class BrowserManager { this.proxyConfig = cfg; } + setExtensionAuthToken(token: string | undefined): void { + if (token) this.extensionAuthToken = token; + } + + getExtensionId(): string | null { + return this.extensionId; + } + /** * Get the ref map for external consumers (e.g., /refs endpoint). */ @@ -451,22 +537,11 @@ export class BrowserManager { launchArgs.push(`--disable-extensions-except=${extensionPath}`); launchArgs.push(`--load-extension=${extensionPath}`); } - // Write auth token for extension bootstrap (still required even when - // the extension is component-baked — it reads ~/.gstack/.auth.json at - // startup to learn how to call the daemon). - // Write to ~/.gstack/.auth.json (not the extension dir, which may be read-only - // in .app bundles and breaks codesigning). + // Auth bootstrap is provisioned into chrome.storage.local after the + // persistent context starts. Do not write a readable file or expose the + // token on /health; local processes can read/spoof those surfaces. if (authToken) { - const fs = require('fs'); - const path = require('path'); - const gstackDir = path.join(process.env.HOME || '/tmp', '.gstack'); - mkdirSecure(gstackDir); - const authFile = path.join(gstackDir, '.auth.json'); - try { - writeSecureFile(authFile, JSON.stringify({ token: authToken, port: this.serverPort || 34567 })); - } catch (err: any) { - console.warn(`[browse] Could not write .auth.json: ${err.message}`); - } + console.log('[browse] Extension auth will be provisioned via chrome.storage'); } } @@ -588,6 +663,7 @@ export class BrowserManager { // synthesis — they're removing leaked automation tells. const { applyStealth } = await import('./stealth'); await applyStealth(this.context); + await this.provisionExtensionAuth(authToken); await this.context.addInitScript(() => { // Remove CDP runtime artifacts that automation detectors look for // cdc_ prefixed vars are injected by ChromeDriver/CDP @@ -1560,8 +1636,8 @@ export class BrowserManager { if (extensionPath) { launchArgs.push(`--disable-extensions-except=${extensionPath}`); launchArgs.push(`--load-extension=${extensionPath}`); - // Auth token is served via /health endpoint now (no file write needed). - // Extension reads token from /health on connect. + // Extension auth is provisioned into chrome.storage.local after the + // headed context starts. Do not use /health as a token bootstrap path. console.log(`[browse] Handoff: loading extension from ${extensionPath}`); } else { console.log('[browse] Handoff: extension not found — headed mode without side panel'); @@ -1605,6 +1681,8 @@ export class BrowserManager { await newContext.setExtraHTTPHeaders(this.extraHeaders); } + await this.provisionExtensionAuth(); + // Register disconnect handler on new browser. Same clean-vs-crash // discrimination as launch() / launchHeaded() above so a user-initiated // Cmd+Q after a handoff doesn't trigger gbd's restart loop. diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 59327b792..e8a43586c 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -97,6 +97,7 @@ interface ServerState { serverPath: string; binaryVersion?: string; mode?: 'launched' | 'headed'; + extensionId?: string; /** Hash of (proxyUrl + headed flag), used by D2 daemon-mismatch check. */ configHash?: string; /** Xvfb child PID for cleanup on disconnect. */ @@ -1131,6 +1132,9 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: stateFile: config.stateFile, serverPort: newState.port, cwd: config.projectDir, + extraEnv: { + BROWSE_EXTENSION_ID: newState.extensionId || '__gstack_extension_id_unavailable__', + }, }); if (newPid) { console.log(`[browse] Terminal agent started (PID: ${newPid})`); @@ -1223,6 +1227,9 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: stateFile: config.stateFile, serverPort: respawned.port, cwd: config.projectDir, + extraEnv: { + BROWSE_EXTENSION_ID: respawned.extensionId || '__gstack_extension_id_unavailable__', + }, }); } catch (err: any) { console.warn(`[browse] Supervisor: terminal-agent respawn failed: ${err?.message || err}`); diff --git a/browse/src/server.ts b/browse/src/server.ts index 6f75551ff..42d51193d 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -1519,6 +1519,9 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { stateFile: cfg.config.stateFile, serverPort: cfg.browsePort, cwd: cfg.config.projectDir, + extraEnv: { + BROWSE_EXTENSION_ID: cfgBrowserManager.getExtensionId() || '__gstack_extension_id_unavailable__', + }, }); if (pid) { console.log(`[browse] terminal-agent respawned by watchdog (PID: ${pid})`); @@ -1744,14 +1747,11 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { mode: browserManager.getConnectionMode(), uptime: Math.floor((Date.now() - startTime) / 1000), tabs: browserManager.getTabCount(), - // Auth token for extension bootstrap. Safe: /health is localhost-only. - // Previously served unconditionally, but that leaks the token if the - // server is tunneled to the internet (ngrok, SSH tunnel). - // In headed mode the server is always local, so return token unconditionally - // (fixes Playwright Chromium extensions that don't send Origin header). - ...(browserManager.getConnectionMode() === 'headed' || - req.headers.get('origin')?.startsWith('chrome-extension://') - ? { token: authToken } : {}), + // Public liveness/status only. Do NOT return the root auth token here. + // Regression #1324: any local process can spoof Origin and use a + // leaked root token to mint /pty-session shell access. The trusted + // extension receives bootstrap auth out-of-band via chrome.storage + // provisioning during headed launch instead. // The chat queue is gone — Terminal pane is the sole sidebar // surface. Keep `chatEnabled: false` so any older extension // build still treats the chat input as disabled. @@ -2761,11 +2761,8 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { // GET /memory — diagnostic snapshot (auth required, does NOT reset idle). // Same auth model as /activity/stream and /inspector/events: Bearer header - // OR view-only SSE-session cookie. Does NOT extend /health (which already - // leaks AUTH_TOKEN to any localhost caller in headed mode — see TODOS.md - // "Audit /health token distribution"); a separate endpoint with the - // standard SSE auth keeps the future /health fix from cascading into the - // sidebar footer poll. + // OR view-only SSE-session cookie. Keep this separate from /health, which + // is public status-only and must never carry AUTH_TOKEN (#1324). if (url.pathname === '/memory' && req.method === 'GET') { const cookieToken = extractSseCookie(req); if (!validateAuth(req) && !validateSseSessionToken(cookieToken)) { @@ -2831,6 +2828,10 @@ export async function start() { const port = await findPort(); LOCAL_LISTEN_PORT = port; + // Extension auth bootstrap happens during browser launch, before Bun.serve + // writes the state file. Set the selected port now so chrome.storage gets + // the real daemon port instead of falling back to the historical 34567. + browserManager.serverPort = port; // ─── Proxy config (D8 + codex F5) ────────────────────────────── // BROWSE_PROXY_URL is set by the CLI when --proxy was passed. For SOCKS5 @@ -2928,6 +2929,7 @@ export async function start() { // write so all consumers see the same value. v1.34.x's module-level // AUTH_TOKEN const was deleted in v1.35.0.0. const envCfg = resolveConfigFromEnv(); + browserManager.setExtensionAuthToken(envCfg.authToken); // Launch browser (headless or headed with extension) // BROWSE_HEADLESS_SKIP=1 skips browser launch entirely (for HTTP-only testing) @@ -2972,6 +2974,7 @@ export async function start() { serverPath: path.resolve(import.meta.dir, 'server.ts'), binaryVersion: readVersionHash() || undefined, mode: browserManager.getConnectionMode(), + extensionId: browserManager.getExtensionId() || undefined, // D2 daemon-mismatch detection: CLI computes the same hash from its // resolved flags and refuses if it differs from this stored value. ...(process.env.BROWSE_CONFIG_HASH ? { configHash: process.env.BROWSE_CONFIG_HASH } : {}), @@ -2984,8 +2987,6 @@ export async function start() { fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 }); fs.renameSync(tmpFile, config.stateFile); - browserManager.serverPort = port; - // Navigate to welcome page if in headed mode and still on about:blank if (browserManager.getConnectionMode() === 'headed') { try { diff --git a/browse/src/terminal-agent.ts b/browse/src/terminal-agent.ts index 2e39d99e4..71da2d828 100644 --- a/browse/src/terminal-agent.ts +++ b/browse/src/terminal-agent.ts @@ -30,7 +30,7 @@ import { writeAgentRecord, clearAgentRecord } from './terminal-agent-control'; const STATE_FILE = process.env.BROWSE_STATE_FILE || path.join(process.env.HOME || '/tmp', '.gstack', 'browse.json'); const PORT_FILE = path.join(path.dirname(STATE_FILE), 'terminal-port'); const BROWSE_SERVER_PORT = parseInt(process.env.BROWSE_SERVER_PORT || '0', 10); -const EXTENSION_ID = process.env.BROWSE_EXTENSION_ID || ''; // optional: tighten Origin check +const EXTENSION_ID = process.env.BROWSE_EXTENSION_ID || '__gstack_extension_id_unavailable__'; // fail closed if spawn did not pin the bundled extension const INTERNAL_TOKEN = crypto.randomBytes(32).toString('base64url'); // shared with parent server via env at spawn /** * Per-boot generation identifier. Loopback /internal/* callers include @@ -588,7 +588,7 @@ function buildServer() { if (!isExtensionOrigin) { return new Response('forbidden origin', { status: 403 }); } - if (EXTENSION_ID && origin !== `chrome-extension://${EXTENSION_ID}`) { + if (origin !== `chrome-extension://${EXTENSION_ID}`) { return new Response('forbidden origin', { status: 403 }); } diff --git a/browse/test/extension-auth-bootstrap.test.ts b/browse/test/extension-auth-bootstrap.test.ts new file mode 100644 index 000000000..da3b72d60 --- /dev/null +++ b/browse/test/extension-auth-bootstrap.test.ts @@ -0,0 +1,83 @@ +/** + * Extension auth bootstrap regression tests for #1324. + * + * /health is a public liveness endpoint, so it must never carry the root + * auth token. The trusted bundled extension gets auth via chrome.storage + * populated by BrowserManager after the extension service worker starts. + */ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; + +const ROOT = path.resolve(new URL(import.meta.url).pathname, '..', '..'); +const BACKGROUND_SRC = fs.readFileSync(path.join(ROOT, '..', 'extension', 'background.js'), 'utf-8'); +const BROWSER_MANAGER_SRC = fs.readFileSync(path.join(ROOT, 'src', 'browser-manager.ts'), 'utf-8'); +const SERVER_SRC = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8'); + +function sliceBetween(source: string, startMarker: string, endMarker: string): string { + const startIdx = source.indexOf(startMarker); + if (startIdx === -1) throw new Error(`Marker not found: ${startMarker}`); + const endIdx = source.indexOf(endMarker, startIdx + startMarker.length); + if (endIdx === -1) throw new Error(`End marker not found: ${endMarker}`); + return source.slice(startIdx, endIdx); +} + +describe('extension auth bootstrap', () => { + test('background loads auth token from chrome.storage instead of /health', () => { + const loadAuthToken = sliceBetween(BACKGROUND_SRC, 'async function loadAuthToken()', '// ─── Health Polling'); + expect(loadAuthToken).toContain('chrome.storage.local.get'); + expect(loadAuthToken).toContain('gstackAuthToken'); + expect(loadAuthToken).not.toContain('/health'); + }); + + test('background refreshes storage auth and port before health checks', () => { + const loadAuthToken = sliceBetween(BACKGROUND_SRC, 'async function loadAuthToken()', '// ─── Health Polling'); + expect(loadAuthToken).not.toContain('if (authToken) return'); + expect(loadAuthToken).toContain('if (data.port) serverPort = data.port'); + expect(loadAuthToken).not.toContain('if (data.port && !serverPort)'); + + const checkHealth = sliceBetween(BACKGROUND_SRC, 'async function checkHealth()', 'function setConnected'); + expect(checkHealth.indexOf('await loadAuthToken()')).toBeGreaterThan(-1); + expect(checkHealth.indexOf('await loadAuthToken()')).toBeLessThan(checkHealth.indexOf('const base = getBaseUrl()')); + }); + + test('BrowserManager provisions root auth into extension storage after headed launch', () => { + expect(BROWSER_MANAGER_SRC).toContain('provisionExtensionAuth'); + expect(BROWSER_MANAGER_SRC).toContain('chrome.storage.local.set'); + expect(BROWSER_MANAGER_SRC).toContain('gstackAuthToken'); + expect(BROWSER_MANAGER_SRC).toContain('waitForEvent(\'serviceworker\''); + expect(BROWSER_MANAGER_SRC).toContain("this.context.on('serviceworker'"); + expect(BROWSER_MANAGER_SRC).toContain('await this.provisionExtensionAuth(authToken)'); + }); + + test('BrowserManager only provisions auth to the bundled gstack extension worker', () => { + expect(BROWSER_MANAGER_SRC).toContain('isGstackExtensionWorker'); + expect(BROWSER_MANAGER_SRC).toContain('chrome.runtime.getManifest'); + expect(BROWSER_MANAGER_SRC).toContain("manifest?.name === 'gstack browse'"); + expect(BROWSER_MANAGER_SRC).toContain("manifest?.background?.service_worker === 'background.js'"); + expect(BROWSER_MANAGER_SRC).not.toContain(".find((w) => w.url().startsWith('chrome-extension://'))"); + }); + + test('server preserves auth for later handoff without using /health', () => { + expect(BROWSER_MANAGER_SRC).toContain('setExtensionAuthToken'); + expect(SERVER_SRC).toContain('browserManager.setExtensionAuthToken(envCfg.authToken)'); + expect(BROWSER_MANAGER_SRC).toContain('await this.provisionExtensionAuth();'); + expect(BROWSER_MANAGER_SRC).not.toContain('Extension reads token from /health'); + }); + + test('server port is known before extension auth is provisioned', () => { + const startBody = sliceBetween(SERVER_SRC, 'export async function start()', 'const startTime = Date.now();'); + expect(startBody.indexOf('browserManager.serverPort = port')).toBeGreaterThan(-1); + expect(startBody.indexOf('browserManager.serverPort = port')).toBeLessThan(startBody.indexOf('await browserManager.launchHeaded(envCfg.authToken)')); + }); + + test('terminal agent spawn is pinned to the bundled extension id', () => { + const terminalSpawnEnv = sliceBetween(SERVER_SRC, 'const state: Record = {', 'fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 });'); + expect(BROWSER_MANAGER_SRC).toContain('getExtensionId()'); + expect(terminalSpawnEnv).toContain('extensionId'); + expect(terminalSpawnEnv).toContain('browserManager.getExtensionId()'); + + const cliSpawn = sliceBetween(fs.readFileSync(path.join(ROOT, 'src', 'cli.ts'), 'utf-8'), 'const newPid = spawnTerminalAgent({', 'if (newPid) {'); + expect(cliSpawn).toContain('BROWSE_EXTENSION_ID: newState.extensionId'); + }); +}); diff --git a/browse/test/pair-agent-e2e.test.ts b/browse/test/pair-agent-e2e.test.ts index 921ae4816..8baf264da 100644 --- a/browse/test/pair-agent-e2e.test.ts +++ b/browse/test/pair-agent-e2e.test.ts @@ -94,15 +94,16 @@ describe('pair-agent flow end-to-end (HTTP only, no ngrok)', () => { if (daemon) killDaemon(daemon); }); - test('GET /health returns daemon status and includes token for chrome-extension origin', async () => { + test('GET /health never includes root token even for chrome-extension origin', async () => { const resp = await fetch(`${daemon.baseUrl}/health`, { headers: { Origin: 'chrome-extension://test-extension-id' }, }); expect(resp.status).toBe(200); const body = await resp.json() as any; expect(body.status).toBeDefined(); - // Extension bootstrap — local listener delivers the token - expect(body.token).toBe(daemon.token); + // Regression #1324: Origin is caller-controlled, so /health must stay public + // status-only. The bundled extension gets auth via chrome.storage provisioning. + expect(body.token).toBeUndefined(); }); test('GET /health without chrome-extension origin does NOT include token', async () => { diff --git a/browse/test/server-auth.test.ts b/browse/test/server-auth.test.ts index 2469a121b..d8cb394b1 100644 --- a/browse/test/server-auth.test.ts +++ b/browse/test/server-auth.test.ts @@ -22,14 +22,15 @@ function sliceBetween(source: string, startMarker: string, endMarker: string): s } describe('Server auth security', () => { - // Test 1: /health serves token conditionally (headed mode or chrome extension only) - test('/health serves token only in headed mode or to chrome extensions', () => { + // Test 1: /health is public status only and must never bootstrap root auth. + // Regression for #1324: any local process can spoof an Origin header, so + // returning `token: authToken` from /health lets a malicious extension or curl + // mint a PTY session and drive the user's shell. + test('/health never serves the root auth token', () => { const healthBlock = sliceBetween(SERVER_SRC, "url.pathname === '/health'", "url.pathname === '/connect'"); - // v1.35.0.0: AUTH_TOKEN const was deleted; factory uses cfg-derived authToken. - // Token must be conditional, not unconditional - expect(healthBlock).toContain('token: authToken'); - expect(healthBlock).toContain('headed'); - expect(healthBlock).toContain('chrome-extension://'); + expect(healthBlock).not.toContain('token: authToken'); + expect(healthBlock).not.toContain('browserManager.getConnectionMode() === \'headed\''); + expect(healthBlock).not.toContain("startsWith('chrome-extension://')"); }); // Test 1b: /health does not expose sensitive browsing state diff --git a/browse/test/sidebar-tabs.test.ts b/browse/test/sidebar-tabs.test.ts index 91d50dcef..b7b931856 100644 --- a/browse/test/sidebar-tabs.test.ts +++ b/browse/test/sidebar-tabs.test.ts @@ -157,7 +157,7 @@ describe('sidepanel-terminal.js: eager auto-connect + injection API', () => { test('forceRestart helper closes ws, disposes xterm, returns to IDLE', () => { expect(TERM_JS).toContain('function forceRestart'); const fn = TERM_JS.slice(TERM_JS.indexOf('function forceRestart')); - expect(fn).toContain('ws && ws.close()'); + expect(fn).toContain('ws && ws.close('); expect(fn).toContain('term.dispose()'); expect(fn).toContain('STATE.IDLE'); expect(fn).toContain('tryAutoConnect()'); @@ -222,8 +222,8 @@ describe('cli.ts: sidebar-agent is no longer spawned', () => { }); test('Terminal-agent spawn survives', () => { - expect(CLI_SRC).toContain('terminal-agent.ts'); - expect(CLI_SRC).toMatch(/Bun\.spawn\(\['bun',\s*'run',\s*termAgentScript\]/); + expect(CLI_SRC).toContain("from './terminal-agent-control'"); + expect(CLI_SRC).toContain('spawnTerminalAgent({'); }); }); diff --git a/browse/test/terminal-agent-integration.test.ts b/browse/test/terminal-agent-integration.test.ts index cdcbe8de5..fad310e62 100644 --- a/browse/test/terminal-agent-integration.test.ts +++ b/browse/test/terminal-agent-integration.test.ts @@ -59,6 +59,7 @@ beforeAll(() => { ...process.env, BROWSE_STATE_FILE: stateFile, BROWSE_SERVER_PORT: '0', // not used in this test + BROWSE_EXTENSION_ID: 'test-extension-id', BROWSE_TERMINAL_BINARY: BASH, }, stdio: ['ignore', 'pipe', 'pipe'], @@ -116,10 +117,25 @@ describe('terminal-agent: /ws gates', () => { expect(resp.status).toBe(403); }); + test('rejects upgrades from a different extension id even with a granted cookie', async () => { + const cookie = 'wrong-extension-token-very-long-yes'; + const granted = await grantToken(cookie); + expect(granted.status).toBe(200); + + const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`, { + headers: { + 'Origin': 'chrome-extension://attacker-extension-id', + 'Cookie': `gstack_pty=${cookie}`, + }, + }); + expect(resp.status).toBe(403); + expect(await resp.text()).toBe('forbidden origin'); + }); + test('rejects extension-Origin upgrades without a granted cookie', async () => { const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`, { headers: { - 'Origin': 'chrome-extension://abc123', + 'Origin': 'chrome-extension://test-extension-id', 'Cookie': 'gstack_pty=never-granted', }, }); diff --git a/extension/background.js b/extension/background.js index d0abe6328..5f45ec22a 100644 --- a/extension/background.js +++ b/extension/background.js @@ -33,43 +33,35 @@ function getBaseUrl() { // ─── Auth Token Bootstrap ───────────────────────────────────── async function loadAuthToken() { - if (authToken) return; - // Get token from browse server /health endpoint (localhost-only, safe). - // Previously read from .auth.json in extension dir, but that breaks - // read-only .app bundles and codesigning. - const base = getBaseUrl(); - if (!base) return; try { - const resp = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3000) }); - if (resp.ok) { - const data = await resp.json(); - if (data.token) authToken = data.token; - } + const data = await chrome.storage.local.get(['gstackAuthToken', 'port']); + if (data.gstackAuthToken) authToken = data.gstackAuthToken; + if (data.port) serverPort = data.port; } catch (err) { - console.error('[gstack bg] Failed to load auth token:', err.message); + console.error('[gstack bg] Failed to load auth token from extension storage:', err.message); } } // ─── Health Polling ──────────────────────────────────────────── async function checkHealth() { + // BrowserManager can provision a fresh token/port after this service worker + // starts. Refresh storage before choosing the base URL so we do not keep an + // old token or the historical 34567 fallback forever. + await loadAuthToken(); const base = getBaseUrl(); if (!base) { setDisconnected(); return; } - // Retry loading auth token if we don't have one yet - if (!authToken) await loadAuthToken(); - try { const resp = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3000) }); if (!resp.ok) { setDisconnected(); return; } const data = await resp.json(); if (data.status === 'healthy') { - // Always refresh auth token from /health — the server generates a new - // token on each restart, so the old one becomes stale. - if (data.token) authToken = data.token; + // Health is status-only. Auth is provisioned out-of-band into + // chrome.storage by BrowserManager so /health never leaks root auth. // Forward chatEnabled so sidepanel can show/hide chat tab setConnected({ ...data, chatEnabled: !!data.chatEnabled }); } else {