This commit is contained in:
Time Attakc 2026-07-14 19:02:46 -07:00 committed by GitHub
commit 890b629fd3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 943 additions and 149 deletions

View File

@ -83,7 +83,7 @@ The build writes `git rev-parse HEAD` to `browse/dist/.version`. On each CLI inv
### Localhost only
The HTTP server binds to `127.0.0.1`, not `0.0.0.0`. It's not reachable from the network.
The HTTP server binds to `127.0.0.1`, not `0.0.0.0`, and rejects unexpected Host headers to prevent DNS rebinding. `/health` is status-only and never returns credentials. BrowserManager provisions the root credential directly into `chrome.storage.session` for the manifest-key-pinned GStack extension; session storage is restricted to trusted extension contexts, so content scripts cannot read it.
### Dual-listener tunnel architecture (v1.6.0.0)
@ -91,14 +91,14 @@ When a user runs `pair-agent --client`, the daemon starts an ngrok tunnel so a r
The fix is **two HTTP listeners**, not one:
- **Local listener** (`127.0.0.1:LOCAL_PORT`) — always bound. Serves bootstrap (`/health` with token delivery), `/cookie-picker`, `/inspector/*`, `/welcome`, `/refs`, the sidebar-agent API, and the full command surface. Never forwarded.
- **Local listener** (`127.0.0.1:LOCAL_PORT`) — always bound. Serves status-only `/health`, `/cookie-picker`, `/inspector/*`, `/welcome`, `/refs`, the sidebar-agent API, and the full command surface. Never forwarded.
- **Tunnel listener** (`127.0.0.1:TUNNEL_PORT`) — bound lazily on `/tunnel/start`, torn down on `/tunnel/stop`. Serves a locked allowlist: `/connect` (pairing ceremony, unauth + rate-limited), `/command` (scoped tokens only, further restricted to a browser-driving command allowlist), and `/sidebar-chat`. Everything else 404s.
ngrok forwards only the tunnel port. The security property comes from **physical port separation**: a tunnel caller cannot reach `/health` or `/cookie-picker` because those paths don't exist on that TCP socket. Header inference (check `x-forwarded-for`, check origin) is unreliable (ngrok header behavior changes; local proxies can add these headers); socket separation isn't.
| Endpoint | Local listener | Tunnel listener | Notes |
|---|---|---|---|
| `GET /health` | public (no token unless headed/extension) | 404 | Token bootstrap for extension happens locally only |
| `GET /health` | public, status-only | 404 | Never returns the root token; extension auth is provisioned outside HTTP |
| `GET /connect` | public (`{alive:true}`) | public (`{alive:true}`) | Probe path for tunnel liveness |
| `POST /connect` | public (rate-limited 300/min) | public (rate-limited) | Setup-key exchange for pair-agent |
| `POST /command` | auth (Bearer root OR scoped) | auth (scoped only, allowlisted commands) | Root token on tunnel = 403 |

View File

@ -785,13 +785,27 @@ The root token is written to `<project>/.gstack/browse.json` with chmod 600.
Every command that mutates browser state must include
`Authorization: Bearer <token>`.
The Side Panel extension does not fetch that token from `/health`. The daemon
only provisions it to the manifest-key-pinned GStack extension worker, using
`chrome.storage.session` with access restricted to trusted extension contexts.
Ordinary web pages, content scripts, and unrelated extensions cannot use the
status-only `/health` endpoint to obtain Browse credentials.
`./setup` automatically retires legacy path-identified GStack extensions from
the dedicated `~/.gstack/chromium-profile`, moving their old storage into a
private rollback backup under `~/.gstack/browser-migrations/`. If the GStack
Browser is open, migration safely defers; close it and rerun `./setup`. To retry
directly, run `bun bin/gstack-browse-migrate.ts --apply
--legacy-extension-path extension` from the gstack checkout. The migrator never
touches a user's normal Chrome profile.
### SSE session cookie (v1.6.0.0+)
SSE endpoints (`/activity/stream`, `/inspector/events`) accept the Bearer
token OR a 30-minute HttpOnly `gstack_sse` cookie minted via
`POST /sse-session`. The `?token=<ROOT>` query-param auth is no longer
supported. This is what lets the Chrome extension subscribe to the activity
feed without putting the root token in extension storage.
supported. This keeps the root token out of URLs and limits the stream cookie
to the read-only activity/inspector surface.
### PTY session cookie
@ -1201,6 +1215,7 @@ the global `~/.gstack/browser-skills/foo/` only inside project-a.
| `GSTACK_SECURITY_ENSEMBLE` | unset | Set to `deberta` for 3-classifier ensemble (721MB download) |
| `GSTACK_STEALTH` | unset | Set to `extended` (also accepts `1`/`true`) to layer six aggressive patches (WebGL spoof, faked plugins, mediaDevices) on top of Layer C. Actively lies; can break sites. |
| `GSTACK_CDP_STEALTH` | unset | Set to `on`/`1`/`true` to emit `--gstack-suppress-prepare-stack-trace` (gbrowser Pack 2 / B11 C++ patch only; no-op on stock Chromium) |
| `GSTACK_SKIP_BROWSER_REPAIR` | 0 | Set to `1` to skip setup's bounded Playwright browser-cache health check and repair |
| `GSTACK_GPU_VENDOR`, `GSTACK_GPU_RENDERER`, `GSTACK_GPU_CHIPSET` | unset | Per-install GPU spoof fed to the Pack 1 WebGL/UA-CH C++ patches. Set by gbd from the host profile; emitted as `--gstack-gpu-vendor` / `--gstack-gpu-renderer` / `--gstack-ua-model` cmdline switches only when present. |
| `GSTACK_PLATFORM` | unset | Host platform classification (`MacARM`/`MacIntel` → `macOS`, `Win32``Windows`, `Linux*``Linux`) emitted as `--gstack-ua-platform` |
| `GSTACK_HW_CONCURRENCY`, `GSTACK_DEVICE_MEMORY` | host profile (fallback 8) | Per-install `hardwareConcurrency`/`deviceMemory` reported by Layer C and emitted as `--gstack-hw-concurrency` / `--gstack-device-memory` for the worker-navigator C++ patch |
@ -1239,6 +1254,7 @@ browse/
│ ├── buffers.ts # Console/network/dialog circular buffers (O(1) ring)
│ ├── tab-session.ts # Per-tab session state (load-html replay, ref map scope)
│ ├── token-registry.ts # Mint/validate/revoke for root + setup keys + scoped tokens
│ ├── extension-identity.ts # Fixed extension ID + trusted service-worker URL check
│ ├── sse-session-cookie.ts # 30-min HttpOnly cookie for /activity/stream + /inspector/events
│ ├── pty-session-cookie.ts # Separate scope: live Claude PTY auth
│ ├── tunnel-denial-log.ts # ~/.gstack/security/attempts.jsonl writer (salted)

View File

@ -0,0 +1,208 @@
#!/usr/bin/env bun
/**
* Retire the pre-#1324 unpacked gstack browser extension from GStack's
* dedicated Chromium profile.
*
* The old extension's ID was derived from its installation path, so it cannot
* be a trust anchor. The current extension has a manifest key and a fixed ID.
* This tool only edits ~/.gstack/chromium-profile (or an explicit test/profile
* directory); it never touches a user's normal Chrome profile.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { createHash } from 'crypto';
const TRUSTED_EXTENSION_ID = 'hjcdllcckghjebjopehjhplcilonljjk';
const EXTENSION_NAME = 'gstack browse';
type Result = {
profile: string;
status: 'clean' | 'migrated' | 'deferred';
legacyExtensionIds: string[];
backup?: string;
reason?: string;
};
function usage(): never {
console.error('Usage: gstack-browse-migrate [--check|--apply] [--profile <gstack-profile>] [--legacy-extension-path <path>] [--json]');
process.exit(2);
}
function parseArgs(argv: string[]) {
let mode: 'check' | 'apply' = 'check';
let profile = process.env.CHROMIUM_PROFILE
|| path.join(process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack'), 'chromium-profile');
let json = false;
const legacyExtensionPaths: string[] = [];
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--check') mode = 'check';
else if (argv[i] === '--apply') mode = 'apply';
else if (argv[i] === '--json') json = true;
else if (argv[i] === '--profile') {
profile = argv[++i] || usage();
} else if (argv[i] === '--legacy-extension-path') {
legacyExtensionPaths.push(argv[++i] || usage());
} else usage();
}
return { mode, profile: path.resolve(profile), json, legacyExtensionPaths };
}
function writeAtomic(file: string, content: string): void {
const tmp = `${file}.gstack-migrate-${process.pid}.tmp`;
fs.writeFileSync(tmp, content, { mode: 0o600 });
fs.renameSync(tmp, file);
}
function profileDirectories(profileRoot: string): string[] {
if (!fs.existsSync(profileRoot)) return [];
return fs.readdirSync(profileRoot, { withFileTypes: true })
.filter(entry => entry.isDirectory() && (entry.name === 'Default' || entry.name.startsWith('Profile ')))
.map(entry => path.join(profileRoot, entry.name));
}
function findLegacyIds(preferences: any): string[] {
const settings = preferences?.extensions?.settings;
if (!settings || typeof settings !== 'object') return [];
return Object.entries(settings)
.filter(([id, value]: [string, any]) => id !== TRUSTED_EXTENSION_ID && value?.manifest?.name === EXTENSION_NAME)
.map(([id]) => id);
}
/**
* Before manifest.key, Chromium generated an unpacked extension's ID from the
* absolute extension directory. The current manifest has a fixed key, but the
* old ID can still be derived from that same path during an upgrade.
*/
function legacyIdForExtensionPath(extensionPath: string): string[] {
const candidates = new Set([path.resolve(extensionPath)]);
try { candidates.add(fs.realpathSync(extensionPath)); } catch {}
return [...candidates].map(candidate => [...createHash('sha256').update(candidate).digest('hex').slice(0, 32)]
.map(char => String.fromCharCode('a'.charCodeAt(0) + parseInt(char, 16)))
.join(''));
}
function storageLegacyIds(profileDir: string, extensionPaths: string[]): string[] {
const storageRoots = ['Local Extension Settings', 'Sync Extension Settings'];
const storedIds = new Set<string>();
for (const storageRoot of storageRoots) {
const root = path.join(profileDir, storageRoot);
if (!fs.existsSync(root)) continue;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (entry.isDirectory()) storedIds.add(entry.name);
}
}
const knownLegacyIds = extensionPaths.flatMap(legacyIdForExtensionPath);
return knownLegacyIds.filter(id => id !== TRUSTED_EXTENSION_ID && storedIds.has(id));
}
function output(result: Result, asJson: boolean): void {
if (asJson) console.log(JSON.stringify(result));
else {
console.log(`[browse-migrate] ${result.status}: ${result.profile}`);
if (result.legacyExtensionIds.length) console.log(`[browse-migrate] legacy extension IDs: ${result.legacyExtensionIds.join(', ')}`);
if (result.backup) console.log(`[browse-migrate] backup: ${result.backup}`);
if (result.reason) console.log(`[browse-migrate] ${result.reason}`);
}
}
/** Chromium normally represents SingletonLock as a dangling hostname-PID symlink. */
function profileLockIsActive(lock: string): boolean {
let stat: fs.Stats;
try {
stat = fs.lstatSync(lock);
} catch (err: any) {
if (err?.code === 'ENOENT') return false;
// If lock state cannot be inspected, do not risk editing the profile.
return true;
}
if (!stat.isSymbolicLink()) return true;
try {
const target = fs.readlinkSync(lock);
const match = target.match(/^(.*)-(\d+)$/);
if (!match || match[1] !== os.hostname()) return true;
const pid = Number(match[2]);
if (!Number.isSafeInteger(pid) || pid <= 0) return true;
try {
process.kill(pid, 0);
return true;
} catch (err: any) {
return err?.code !== 'ESRCH';
}
} catch {
return true;
}
}
function migrateProfile(profileRoot: string, mode: 'check' | 'apply', extensionPaths: string[]): Result[] {
const lock = path.join(profileRoot, 'SingletonLock');
if (profileLockIsActive(lock)) {
return [{ profile: profileRoot, status: 'deferred', legacyExtensionIds: [], reason: 'browser profile is active; close GStack Browser and retry' }];
}
const results: Result[] = [];
for (const profileDir of profileDirectories(profileRoot)) {
const preferencesPath = path.join(profileDir, 'Preferences');
const hasPreferences = fs.existsSync(preferencesPath);
let preferences: any = { extensions: { settings: {} } };
if (hasPreferences) {
try {
preferences = JSON.parse(fs.readFileSync(preferencesPath, 'utf8'));
} catch {
results.push({ profile: profileDir, status: 'deferred', legacyExtensionIds: [], reason: 'Preferences is not valid JSON; left unchanged' });
continue;
}
}
// Chrome may omit an unpacked extension from Preferences. In that case,
// derive the old ID from the upgraded extension's path and require a
// matching storage directory before acting. This remains narrowly scoped:
// unrelated extension IDs are never selected by a broad directory scan.
const legacyIds = [...new Set([
...findLegacyIds(preferences),
...storageLegacyIds(profileDir, extensionPaths),
])];
if (!legacyIds.length) {
results.push({ profile: profileDir, status: 'clean', legacyExtensionIds: [] });
continue;
}
if (mode === 'check') {
results.push({ profile: profileDir, status: 'deferred', legacyExtensionIds: legacyIds, reason: 'run with --apply to retire legacy extension state' });
continue;
}
const migrationRoot = path.join(profileRoot, '..', 'browser-migrations', new Date().toISOString().replace(/[:.]/g, '-'));
const backupDir = path.join(migrationRoot, path.basename(profileDir));
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
if (hasPreferences) fs.copyFileSync(preferencesPath, path.join(backupDir, 'Preferences'));
if (hasPreferences) {
for (const id of legacyIds) delete preferences.extensions?.settings?.[id];
writeAtomic(preferencesPath, `${JSON.stringify(preferences, null, 2)}\n`);
}
// Extension storage is the only place the old design could retain its
// daemon credential. Move it aside instead of deleting it so rollback is
// possible without ever copying credentials into the new extension ID.
for (const storageRoot of ['Local Extension Settings', 'Sync Extension Settings']) {
for (const id of legacyIds) {
const source = path.join(profileDir, storageRoot, id);
if (!fs.existsSync(source)) continue;
const target = path.join(backupDir, storageRoot, id);
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
fs.renameSync(source, target);
}
}
results.push({ profile: profileDir, status: 'migrated', legacyExtensionIds: legacyIds, backup: backupDir });
}
return results.length ? results : [{ profile: profileRoot, status: 'clean', legacyExtensionIds: [] }];
}
const args = parseArgs(process.argv.slice(2));
const results = migrateProfile(args.profile, args.mode, args.legacyExtensionPaths);
for (const result of results) output(result, args.json);
// Check mode uses "deferred" to report work without failing. Apply mode must
// fail for every deferred profile so the versioned updater never writes its
// done marker while legacy credential state remains untouched.
process.exit(args.mode === 'apply' && results.some(result => result.status === 'deferred') ? 3 : 0);

128
bin/gstack-browse-repair.ts Normal file
View File

@ -0,0 +1,128 @@
#!/usr/bin/env bun
/** Repair the Playwright browser cache used by gstack browse. */
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const ROOT = path.resolve(import.meta.dir, '..');
const CACHE = process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), 'Library', 'Caches', 'ms-playwright');
const MAX_INSTALL_MS = 300_000;
type BrowserSpec = { name: string; revision: string; browserVersion: string };
function fail(message: string): never {
console.error(`[browse-repair] ${message}`);
process.exit(1);
}
function readSpec(name: string): BrowserSpec {
const file = path.join(ROOT, 'node_modules', 'playwright-core', 'browsers.json');
const data = JSON.parse(fs.readFileSync(file, 'utf8')) as { browsers: BrowserSpec[] };
const spec = data.browsers.find(browser => browser.name === name);
if (!spec) fail(`Playwright browser spec ${name} not found`);
return spec;
}
function expectedExecutable(spec: BrowserSpec): string | null {
if (process.platform !== 'darwin' || process.arch !== 'arm64') return null;
if (spec.name === 'chromium') {
return path.join(CACHE, `chromium-${spec.revision}`, 'chrome-mac-arm64', 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing');
}
if (spec.name === 'chromium-headless-shell') {
return path.join(CACHE, `chromium_headless_shell-${spec.revision}`, 'chrome-headless-shell-mac-arm64', 'chrome-headless-shell');
}
return null;
}
function installationDirectory(spec: BrowserSpec): string {
return spec.name === 'chromium'
? path.join(CACHE, `chromium-${spec.revision}`)
: path.join(CACHE, `chromium_headless_shell-${spec.revision}`);
}
function isHealthy(spec: BrowserSpec): boolean {
const executable = expectedExecutable(spec);
if (!executable || !fs.existsSync(executable)) return false;
if (!fs.existsSync(path.join(installationDirectory(spec), 'INSTALLATION_COMPLETE'))) return false;
const result = Bun.spawnSync([executable, '--version'], { stdout: 'pipe', stderr: 'pipe', timeout: 10_000 });
return result.exitCode === 0;
}
function archiveUrl(spec: BrowserSpec): string | null {
if (process.platform !== 'darwin' || process.arch !== 'arm64') return null;
const archive = spec.name === 'chromium'
? 'chrome-mac-arm64.zip'
: spec.name === 'chromium-headless-shell'
? 'chrome-headless-shell-mac-arm64.zip'
: null;
return archive ? `https://storage.googleapis.com/chrome-for-testing-public/${spec.browserVersion}/mac-arm64/${archive}` : null;
}
async function repairMacArtifact(spec: BrowserSpec): Promise<void> {
const url = archiveUrl(spec);
const executable = expectedExecutable(spec);
if (!url || !executable) fail(`no direct repair path for ${process.platform}/${process.arch}`);
const installDir = installationDirectory(spec);
if (fs.existsSync(installDir)) {
const backup = `${installDir}.incomplete-${Date.now()}`;
fs.renameSync(installDir, backup);
console.error(`[browse-repair] preserved incomplete cache at ${backup}`);
}
fs.mkdirSync(installDir, { recursive: true, mode: 0o700 });
const archive = path.join(os.tmpdir(), `gstack-${spec.name}-${spec.revision}-${process.pid}.zip`);
try {
// macOS ships curl, and its file-backed transfer avoids buffering a
// 150MB+ Chromium archive inside Bun. The earlier fetch + Bun.write path
// could spin indefinitely while materializing a large Response object.
const download = Bun.spawnSync([
'curl', '--fail', '--location', '--silent', '--show-error',
'--max-time', String(MAX_INSTALL_MS / 1000), '--output', archive, url,
], { stdout: 'pipe', stderr: 'pipe', timeout: MAX_INSTALL_MS + 5_000 });
if (download.exitCode !== 0) {
fail(`download failed for ${spec.name}: ${download.stderr.toString().trim() || 'timed out'}`);
}
const unpack = Bun.spawnSync(['ditto', '-x', '-k', archive, installDir], { stdout: 'pipe', stderr: 'pipe', timeout: MAX_INSTALL_MS });
if (unpack.exitCode !== 0) fail(`could not unpack ${spec.name}: ${unpack.stderr.toString().trim()}`);
} finally {
try { fs.unlinkSync(archive); } catch {}
}
if (!expectedExecutable(spec) || !fs.existsSync(expectedExecutable(spec)!)) {
fail(`${spec.name} still failed its executable health check`);
}
// Playwright's registry only recognizes browser directories carrying this
// marker. Without it, a later installer/GC can discard a working repair.
fs.writeFileSync(path.join(installDir, 'INSTALLATION_COMPLETE'), '', { mode: 0o600 });
if (!isHealthy(spec)) fail(`${spec.name} still failed its registry health check`);
}
function repairViaPlaywright(): void {
const cli = path.join(ROOT, 'node_modules', 'playwright-core', 'cli.js');
const result = Bun.spawnSync(['node', cli, 'install', 'chromium', 'chromium-headless-shell'], {
stdout: 'pipe', stderr: 'pipe', timeout: MAX_INSTALL_MS,
});
if (result.exitCode !== 0) fail(`Playwright install failed: ${result.stderr.toString().trim() || 'timed out'}`);
}
const repair = process.argv.slice(2).includes('--repair');
// The direct executable health check below is intentionally macOS/Apple
// Silicon-specific. Other Playwright platforms use different archive layouts;
// defer their validation/install semantics to Playwright's own CLI rather than
// declaring a healthy cache broken on every setup run.
if (process.platform !== 'darwin' || process.arch !== 'arm64') {
if (repair) repairViaPlaywright();
console.log('[browse-repair] Playwright browser repair delegated to Playwright');
process.exit(0);
}
const specs = [readSpec('chromium'), readSpec('chromium-headless-shell')];
const unhealthy = specs.filter(spec => !isHealthy(spec));
if (!unhealthy.length) {
console.log('[browse-repair] Playwright browser cache is healthy');
process.exit(0);
}
if (!repair) fail(`missing or unhealthy: ${unhealthy.map(spec => spec.name).join(', ')} (run with --repair)`);
for (const spec of unhealthy) await repairMacArtifact(spec);
const stillUnhealthy = specs.filter(spec => !isHealthy(spec));
if (stillUnhealthy.length) fail(`repair incomplete: ${stillUnhealthy.map(spec => spec.name).join(', ')}`);
console.log('[browse-repair] Playwright browser cache repaired');

View File

@ -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';
@ -24,6 +23,7 @@ import { TabSession, type RefEntry } from './tab-session';
import { resolveChromiumProfile, cleanSingletonLocks } from './config';
import { withCdpSession } from './cdp-bridge';
import type { MemorySnapshot, MemoryStructureStats, MemoryTabSnapshot, MemoryProcess } from './memory-snapshot';
import { isTrustedGstackExtensionWorkerUrl } from './extension-identity';
/**
* Detect whether GSTACK_CHROMIUM_PATH points at a custom Chromium build that
@ -196,6 +196,8 @@ export class BrowserManager {
// ─── Headed State ────────────────────────────────────────
private connectionMode: 'launched' | 'headed' = 'launched';
private intentionalDisconnect = false;
/** Root auth is provisioned only into the loaded gstack extension's storage. */
private extensionAuthToken: string | null = null;
// ─── Tab Count Guardrail (D5 + Codex single-tab flag) ───────
// Idempotent threshold trackers: each guardrail fires exactly once per
@ -317,6 +319,78 @@ export class BrowserManager {
return null;
}
/**
* Give the bundled extension its bearer without publishing it through a
* loopback HTTP endpoint. chrome.storage.session is isolated per extension
* ID and restricted to trusted extension contexts, unlike an Origin header,
* which any local caller can forge.
*/
private async provisionExtensionAuth(authToken?: string): Promise<void> {
if (authToken) this.extensionAuthToken = authToken;
const token = authToken ?? this.extensionAuthToken;
if (!token || !this.context) return;
const writeToken = async (worker: Worker): Promise<void> => {
await worker.evaluate(async ({ storedToken, port }) => {
const chromeApi = (globalThis as any).chrome;
// storage.session defaults to trusted contexts, but set the access
// level explicitly so a future manifest/content-script change cannot
// silently expose the root bearer. The port is non-secret and remains
// in local storage for existing discovery behavior.
await chromeApi.storage.session.setAccessLevel({ accessLevel: 'TRUSTED_CONTEXTS' });
await chromeApi.storage.session.set({ gstackAuthToken: storedToken });
await chromeApi.storage.local.remove('gstackAuthToken');
await chromeApi.storage.local.set({ port });
}, { storedToken: token, port: this.serverPort || 34567 });
};
// A component-baked extension may start after the browser context. Keep
// listening rather than falling back to the unsafe /health bootstrap.
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 {
console.warn('[browse] Extension service worker not ready; auth will be provisioned when it starts');
return;
}
}
if (!worker) 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<boolean> {
if (!isTrustedGstackExtensionWorkerUrl(worker.url())) 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;
}
}
/**
* Set the proxy config applied to chromium.launch() in launch() and
* launchHeaded(). Called by server.ts at startup once the (optional) SOCKS5
@ -326,6 +400,10 @@ export class BrowserManager {
this.proxyConfig = cfg;
}
setExtensionAuthToken(token: string | undefined): void {
if (token) this.extensionAuthToken = token;
}
/**
* Get the ref map for external consumers (e.g., /refs endpoint).
*/
@ -369,17 +447,44 @@ export class BrowserManager {
console.log(`[browse] Extensions loaded from: ${extensionsDir}`);
}
this.browser = await chromium.launch({
headless: useHeadless,
// On Windows, Chromium's sandbox fails when the server is spawned through
// the Bun→Node process chain (GitHub #276). Disable it — local daemon
// browsing user-specified URLs has marginal sandbox benefit. Also disabled
// on Linux root/CI/container, where the sandbox requires unprivileged user
// namespaces that aren't available.
chromiumSandbox: shouldEnableChromiumSandbox(),
...(launchArgs.length > 0 ? { args: launchArgs } : {}),
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
});
const contextOptions: BrowserContextOptions = {
viewport: { width: this.currentViewport.width, height: this.currentViewport.height },
deviceScaleFactor: this.deviceScaleFactor,
};
if (this.customUserAgent) {
contextOptions.userAgent = this.customUserAgent;
}
if (extensionsDir) {
// Extensions do not attach to incognito contexts created by
// browser.newContext(). An empty userDataDir gives this off-screen mode a
// temporary persistent profile, so its real service worker can receive
// auth without sharing state with the user's headed GStack profile.
const { STEALTH_IGNORE_DEFAULT_ARGS } = await import('./stealth');
this.context = await chromium.launchPersistentContext('', {
...contextOptions,
headless: false,
chromiumSandbox: shouldEnableChromiumSandbox(),
args: launchArgs,
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
ignoreDefaultArgs: STEALTH_IGNORE_DEFAULT_ARGS,
});
this.browser = this.context.browser();
if (!this.browser) throw new Error('Persistent extension browser did not start');
} else {
this.browser = await chromium.launch({
headless: useHeadless,
// On Windows, Chromium's sandbox fails when the server is spawned through
// the Bun→Node process chain (GitHub #276). Disable it — local daemon
// browsing user-specified URLs has marginal sandbox benefit. Also disabled
// on Linux root/CI/container, where the sandbox requires unprivileged user
// namespaces that aren't available.
chromiumSandbox: shouldEnableChromiumSandbox(),
...(launchArgs.length > 0 ? { args: launchArgs } : {}),
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
});
this.context = await this.browser.newContext(contextOptions);
}
// Chromium disconnect → distinguish clean user-quit from crash. Both
// events look identical to Playwright (one 'disconnected' fires), but
@ -394,15 +499,6 @@ export class BrowserManager {
void handleChromiumDisconnect(this.browser);
});
const contextOptions: BrowserContextOptions = {
viewport: { width: this.currentViewport.width, height: this.currentViewport.height },
deviceScaleFactor: this.deviceScaleFactor,
};
if (this.customUserAgent) {
contextOptions.userAgent = this.customUserAgent;
}
this.context = await this.browser.newContext(contextOptions);
if (Object.keys(this.extraHeaders).length > 0) {
await this.context.setExtraHTTPHeaders(this.extraHeaders);
}
@ -414,6 +510,7 @@ export class BrowserManager {
// faking those to fixed values flags more bot-like, not less (D7).
const { applyStealth } = await import('./stealth');
await applyStealth(this.context);
if (extensionsDir) await this.provisionExtensionAuth();
// Create first tab
await this.newTab();
@ -460,22 +557,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 is provisioned into extension storage after the persistent
// context starts. Do not write a reusable token to a local file or
// return it from a public endpoint.
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');
}
}
@ -604,6 +690,7 @@ export class BrowserManager {
// this headed path.
const { applyStealth } = await import('./stealth');
await applyStealth(this.context);
await this.provisionExtensionAuth(authToken);
// Inject visual indicator — subtle top-edge amber gradient
// Extension's content script handles the floating pill
@ -1561,14 +1648,14 @@ 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.
// Auth is provisioned into extension storage after the context starts.
// /health deliberately remains public status-only.
console.log(`[browse] Handoff: loading extension from ${extensionPath}`);
} else {
console.log('[browse] Handoff: extension not found — headed mode without side panel');
}
const userDataDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
const userDataDir = resolveChromiumProfile();
fs.mkdirSync(userDataDir, { recursive: true });
// T1: same automation-tell-stripping defaults as launchHeaded().
@ -1614,6 +1701,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.

View File

@ -0,0 +1,17 @@
/**
* The public key in extension/manifest.json fixes this extension's Chrome ID.
* A display name is attacker-controlled metadata; this ID is the trust anchor
* used before the daemon provisions its root bearer to extension storage.
*/
export const GSTACK_EXTENSION_ID = 'hjcdllcckghjebjopehjhplcilonljjk';
export function isTrustedGstackExtensionWorkerUrl(rawUrl: string): boolean {
try {
const url = new URL(rawUrl);
return url.protocol === 'chrome-extension:'
&& url.hostname === GSTACK_EXTENSION_ID
&& url.pathname === '/background.js';
} catch {
return false;
}
}

View File

@ -4,9 +4,9 @@
* Why this exists: WebSocket clients in browsers cannot send Authorization
* headers on the upgrade request. The terminal-agent's /ws upgrade therefore
* authenticates via cookie. We never put the PTY token in /health (codex
* outside-voice finding #2: /health already leaks AUTH_TOKEN to any
* localhost caller in headed mode; reusing that path for shell access would
* widen an existing bug). Instead, the extension does an authenticated
* outside-voice finding #2: the public health endpoint must never carry
* AUTH_TOKEN; reusing it for shell access would widen the trust boundary).
* Instead, the extension does an authenticated
* POST /pty-session with the bootstrap AUTH_TOKEN; the server mints a
* short-lived cookie scoped to this terminal session and pushes it to the
* agent via loopback. The browser then carries the cookie automatically on

View File

@ -1665,8 +1665,21 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
// `authToken` (the cfg-derived value) explicitly.
const browserManager = cfgBrowserManager;
const isExpectedLoopbackHost = (host: string | null): boolean => {
if (!host) return false;
const normalized = host.toLowerCase();
return normalized === `127.0.0.1:${browsePort}` || normalized === `localhost:${browsePort}`;
};
const makeFetchHandler = (surface: Surface) => async (req: Request): Promise<Response> => {
// A loopback bind is not, by itself, a DNS-rebinding defense: after a
// rebinding a page can send Host: attacker.example to 127.0.0.1. Reject
// it before dispatch. Tunnel traffic is a separately authenticated surface.
if (surface === 'local' && req.headers.has('host') && !isExpectedLoopbackHost(req.headers.get('host'))) {
return new Response(JSON.stringify({ error: 'Forbidden host' }), {
status: 403, headers: { 'Content-Type': 'application/json' },
});
}
const url = new URL(req.url);
// ─── Tunnel surface filter (runs before any route dispatch) ──
@ -1777,14 +1790,8 @@ 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. Origin is caller-controlled; exposing
// the root bearer here lets a hostile extension mint a PTY session.
// 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.
@ -2309,7 +2316,7 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
// Dual-listener model: binds a SECOND Bun.serve listener on an
// ephemeral 127.0.0.1 port dedicated to tunnel traffic, then points
// ngrok.forward() at THAT port. The existing local listener (which
// serves /health+token, /cookie-picker, /inspector/*, welcome, etc.)
// serves /health, /cookie-picker, /inspector/*, welcome, etc.)
// is never exposed to ngrok.
//
// Hard fail if the tunnel listener bind fails — NEVER fall back to
@ -2794,11 +2801,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.
if (url.pathname === '/memory' && req.method === 'GET') {
const cookieToken = extractSseCookie(req);
if (!validateAuth(req) && !validateSseSessionToken(cookieToken)) {
@ -2864,6 +2868,8 @@ export async function start() {
const port = await findPort();
LOCAL_LISTEN_PORT = port;
// The extension needs the real port before it starts and receives auth.
browserManager.serverPort = port;
// ─── Proxy config (D8 + codex F5) ──────────────────────────────
// BROWSE_PROXY_URL is set by the CLI when --proxy was passed. For SOCKS5
@ -2961,6 +2967,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)
@ -3017,8 +3024,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 {

View File

@ -26,11 +26,14 @@ import * as crypto from 'crypto';
import { writeSecureFile, mkdirSecure } from './file-permissions';
import { safeUnlink } from './error-handling';
import { writeAgentRecord, clearAgentRecord } from './terminal-agent-control';
import { GSTACK_EXTENSION_ID } from './extension-identity';
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
// Fail closed. The static ID is derived from the bundled extension's manifest
// key, not from a caller-controlled Origin or display name.
const EXTENSION_ID = GSTACK_EXTENSION_ID;
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 +591,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 });
}
@ -634,11 +637,10 @@ function buildServer() {
const sessionId = validTokens.get(token) ?? null;
const upgraded = server.upgrade(req, {
data: { cookie: token, sessionId },
// Echo the protocol back so the browser accepts the upgrade.
// Required when the client sends Sec-WebSocket-Protocol — the
// server MUST select one of the offered protocols, otherwise
// the browser closes the connection immediately.
...(acceptedProtocol ? { headers: { 'Sec-WebSocket-Protocol': acceptedProtocol } } : {}),
// Bun negotiates the requested subprotocol itself. Manually adding
// Sec-WebSocket-Protocol duplicates the response header on current
// Bun, causing Chrome and standards-compliant clients to abort the
// otherwise-successful upgrade with code 1006.
});
return upgraded ? undefined : new Response('upgrade failed', { status: 500 });
}

View File

@ -0,0 +1,55 @@
/** Regression guards for the #1324 auth bootstrap redesign. */
import { describe, expect, test } from 'bun:test';
import { createHash } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import { GSTACK_EXTENSION_ID, isTrustedGstackExtensionWorkerUrl } from '../src/extension-identity';
const ROOT = path.resolve(import.meta.dir, '../..');
const BACKGROUND_SRC = fs.readFileSync(path.join(ROOT, 'extension/background.js'), 'utf-8');
const BROWSER_MANAGER_SRC = fs.readFileSync(path.join(ROOT, 'browse/src/browser-manager.ts'), 'utf-8');
const MANIFEST = JSON.parse(fs.readFileSync(path.join(ROOT, 'extension/manifest.json'), 'utf-8'));
function sliceBetween(source: string, start: string, end: string): string {
const startIndex = source.indexOf(start);
const endIndex = source.indexOf(end, startIndex + start.length);
if (startIndex < 0 || endIndex < 0) throw new Error(`Could not find ${start} through ${end}`);
return source.slice(startIndex, endIndex);
}
describe('extension auth bootstrap', () => {
test('reads root auth from trusted session storage, never /health or local storage', () => {
const loadAuth = sliceBetween(BACKGROUND_SRC, 'async function loadAuthToken()', '// ─── Health Polling');
expect(loadAuth).toContain('chrome.storage.session.get');
expect(loadAuth).toContain("accessLevel: 'TRUSTED_CONTEXTS'");
expect(loadAuth).toContain('gstackAuthToken');
expect(loadAuth).not.toContain('/health');
});
test('pins auth provisioning to the manifest-derived extension ID', () => {
const manifestId = [...createHash('sha256')
.update(Buffer.from(MANIFEST.key, 'base64'))
.digest('hex')
.slice(0, 32)]
.map(char => String.fromCharCode('a'.charCodeAt(0) + parseInt(char, 16)))
.join('');
expect(manifestId).toBe(GSTACK_EXTENSION_ID);
expect(isTrustedGstackExtensionWorkerUrl(`chrome-extension://${GSTACK_EXTENSION_ID}/background.js`)).toBe(true);
expect(isTrustedGstackExtensionWorkerUrl('chrome-extension://attacker/background.js')).toBe(false);
expect(BROWSER_MANAGER_SRC).toContain('provisionExtensionAuth');
expect(BROWSER_MANAGER_SRC).toContain('chromeApi.storage.session.set');
expect(BROWSER_MANAGER_SRC).toContain("accessLevel: 'TRUSTED_CONTEXTS'");
expect(BROWSER_MANAGER_SRC).toContain("chromeApi.storage.local.remove('gstackAuthToken')");
expect(BROWSER_MANAGER_SRC).toContain('isGstackExtensionWorker');
expect(BROWSER_MANAGER_SRC).toContain('isTrustedGstackExtensionWorkerUrl(worker.url())');
expect(BROWSER_MANAGER_SRC).toContain('if (extensionsDir) await this.provisionExtensionAuth()');
});
test('does not return root auth through the content-script port channel', () => {
const getPort = sliceBetween(BACKGROUND_SRC, "if (msg.type === 'getPort')", "if (msg.type === 'getTabState')");
expect(getPort).not.toContain('authToken');
const getToken = sliceBetween(BACKGROUND_SRC, "if (msg.type === 'getToken')", "if (msg.type === 'fetchRefs')");
expect(getToken).toContain('if (sender.tab)');
});
});

View File

@ -0,0 +1,124 @@
/**
* Opt-in real-Chromium receipt for the extension auth trust boundary.
* Run with: GSTACK_LIVE_BROWSER_TESTS=1 bun test browse/test/extension-auth-live.test.ts
*/
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { BrowserManager } from '../src/browser-manager';
import { GSTACK_EXTENSION_ID } from '../src/extension-identity';
const RUN = process.env.GSTACK_LIVE_BROWSER_TESTS === '1' && process.platform === 'darwin';
const originalProfile = process.env.CHROMIUM_PROFILE;
const originalExtensionsDir = process.env.BROWSE_EXTENSIONS_DIR;
let temp = '';
let manager: BrowserManager | null = null;
let probeServer: ReturnType<typeof Bun.serve> | null = null;
describe.skipIf(!RUN)('extension auth live Chromium', () => {
beforeAll(() => {
temp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-extension-auth-live-'));
process.env.CHROMIUM_PROFILE = path.join(temp, 'chromium-profile');
probeServer = Bun.serve({
hostname: '127.0.0.1',
port: 0,
fetch(req) {
const url = new URL(req.url);
if (url.pathname === '/page') return new Response('<!doctype html><title>probe</title>', { headers: { 'Content-Type': 'text/html' } });
if (url.pathname === '/health') return Response.json({ status: 'healthy', mode: 'headed', tabs: 1 });
if (url.pathname === '/probe') {
return Response.json({ authorization: req.headers.get('authorization') });
}
return Response.json({ error: 'not found' }, { status: 404 });
},
});
});
afterAll(async () => {
await manager?.close();
probeServer?.stop(true);
if (originalProfile === undefined) delete process.env.CHROMIUM_PROFILE;
else process.env.CHROMIUM_PROFILE = originalProfile;
if (originalExtensionsDir === undefined) delete process.env.BROWSE_EXTENSIONS_DIR;
else process.env.BROWSE_EXTENSIONS_DIR = originalExtensionsDir;
if (temp) fs.rmSync(temp, { recursive: true, force: true });
});
test('provisions and rotates auth for trusted pages while content scripts cannot read it', async () => {
const token = `live-${crypto.randomUUID()}`;
manager = new BrowserManager();
manager.serverPort = probeServer!.port;
await manager.launchHeaded(token);
const context = (manager as any).context;
const worker = context.serviceWorkers().find((candidate: any) => candidate.url() === `chrome-extension://${GSTACK_EXTENSION_ID}/background.js`)
?? await context.waitForEvent('serviceworker', { timeout: 10_000 });
expect(worker.url()).toBe(`chrome-extension://${GSTACK_EXTENSION_ID}/background.js`);
const stored = await worker.evaluate(async () => {
const api = (globalThis as any).chrome.storage;
return {
session: await api.session.get('gstackAuthToken'),
local: await api.local.get('gstackAuthToken'),
};
});
expect(stored.session.gstackAuthToken).toBe(token);
expect(stored.local.gstackAuthToken).toBeUndefined();
const page = context.pages()[0] ?? await context.newPage();
await page.goto(`http://127.0.0.1:${probeServer!.port}/page`);
const contentRead = await worker.evaluate(async (pageUrl: string) => {
const chromeApi = (globalThis as any).chrome;
const [tab] = await chromeApi.tabs.query({ url: pageUrl });
const [injection] = await chromeApi.scripting.executeScript({
target: { tabId: tab.id },
func: async () => {
try {
const data = await (globalThis as any).chrome.storage.session.get('gstackAuthToken');
return { token: data.gstackAuthToken ?? null, error: null };
} catch (err: any) {
return { token: null, error: String(err?.message || err) };
}
},
});
return injection.result;
}, page.url());
expect(contentRead.token).toBeNull();
const authenticated = await worker.evaluate(async (port: number) => {
const chromeApi = (globalThis as any).chrome;
const { gstackAuthToken } = await chromeApi.storage.session.get('gstackAuthToken');
const resp = await fetch(`http://127.0.0.1:${port}/probe`, {
headers: { Authorization: `Bearer ${gstackAuthToken}` },
});
return resp.json();
}, probeServer!.port);
expect(authenticated.authorization).toBe(`Bearer ${token}`);
const rotated = `rotated-${crypto.randomUUID()}`;
await (manager as any).provisionExtensionAuth(rotated);
const rotatedStore = await worker.evaluate(async () => (
(globalThis as any).chrome.storage.session.get('gstackAuthToken')
));
expect(rotatedStore.gstackAuthToken).toBe(rotated);
// The off-screen extension mode is a separate supported launch path. It
// must use a persistent context too; browser.newContext() has no extension
// service worker and previously made this mode silently unauthenticated.
await manager.close();
process.env.BROWSE_EXTENSIONS_DIR = path.resolve(import.meta.dir, '../../extension');
const offscreenToken = `offscreen-${crypto.randomUUID()}`;
manager = new BrowserManager();
manager.serverPort = probeServer!.port;
manager.setExtensionAuthToken(offscreenToken);
await manager.launch();
const offscreenContext = (manager as any).context;
const offscreenWorker = offscreenContext.serviceWorkers().find((candidate: any) => candidate.url() === `chrome-extension://${GSTACK_EXTENSION_ID}/background.js`)
?? await offscreenContext.waitForEvent('serviceworker', { timeout: 10_000 });
const offscreenStore = await offscreenWorker.evaluate(async () => (
(globalThis as any).chrome.storage.session.get('gstackAuthToken')
));
expect(offscreenStore.gstackAuthToken).toBe(offscreenToken);
}, 60_000);
});

View File

@ -94,15 +94,27 @@ 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 for a forged 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);
expect(body.token).toBeUndefined();
});
test('the leaked-health attack cannot mint a PTY session without a bearer', async () => {
const resp = await fetch(`${daemon.baseUrl}/pty-session`, { method: 'POST' });
expect(resp.status).toBe(401);
});
test('local routes reject a DNS-rebinding-style Host header', async () => {
const resp = await fetch(`${daemon.baseUrl}/health`, {
headers: { Host: `attacker.example:${daemon.port}` },
});
expect(resp.status).toBe(403);
expect((await resp.json() as any).error).toBe('Forbidden host');
});
test('GET /health without chrome-extension origin does NOT include token', async () => {

View File

@ -22,14 +22,10 @@ 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('/health is public status-only and never bootstraps root auth', () => {
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("startsWith('chrome-extension://')");
});
// Test 1b: /health does not expose sensitive browsing state

View File

@ -1405,22 +1405,23 @@ describe('sidebar auth race prevention', () => {
const bgSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'background.js'), 'utf-8');
const spSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8');
test('getPort response includes authToken (not just port + connected)', () => {
// The auth race: sidepanel calls getPort, gets {port, connected} but no token.
// All subsequent requests fail 401. Token must be in the getPort response.
test('getPort response never includes authToken', () => {
// Content scripts can call getPort. Root auth must remain behind getToken,
// which rejects content-script contexts.
const getPortHandler = bgSrc.slice(
bgSrc.indexOf("msg.type === 'getPort'"),
bgSrc.indexOf("msg.type === 'setPort'"),
);
expect(getPortHandler).toContain('token: authToken');
expect(getPortHandler).not.toContain('authToken');
});
test('tryConnect uses token from getPort response', () => {
// Sidepanel must pass resp.token to updateConnection, not null
test('tryConnect uses the extension-page-only getToken response', () => {
const start = spSrc.indexOf('function tryConnect()');
const end = spSrc.indexOf('\ntryConnect();', start); // top-level call after the function
const tryConnectFn = spSrc.slice(start, end);
expect(tryConnectFn).toContain('resp.token');
expect(tryConnectFn).toContain("type: 'getToken'");
expect(tryConnectFn).toContain('tokenResp.token');
expect(tryConnectFn).not.toContain('resp.token');
expect(tryConnectFn).not.toContain('updateConnection(url, null)');
});
});

View File

@ -18,6 +18,7 @@ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { GSTACK_EXTENSION_ID } from '../src/extension-identity';
const AGENT_SCRIPT = path.join(import.meta.dir, '../src/terminal-agent.ts');
const BASH = '/bin/bash';
@ -119,12 +120,25 @@ describe('terminal-agent: /ws gates', () => {
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://${GSTACK_EXTENSION_ID}`,
'Cookie': 'gstack_pty=never-granted',
},
});
expect(resp.status).toBe(401);
});
test('rejects a different extension ID even with a granted cookie', async () => {
const cookie = 'wrong-extension-token-very-long-yes';
expect((await grantToken(cookie)).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');
});
});
describe('terminal-agent: PTY round-trip via real WebSocket (Cookie auth)', () => {
@ -135,7 +149,7 @@ describe('terminal-agent: PTY round-trip via real WebSocket (Cookie auth)', () =
const ws = new WebSocket(`ws://127.0.0.1:${agentPort}/ws`, {
headers: {
'Origin': 'chrome-extension://test-extension-id',
'Origin': `chrome-extension://${GSTACK_EXTENSION_ID}`,
'Cookie': `gstack_pty=${cookie}`,
},
} as any);
@ -199,32 +213,20 @@ describe('terminal-agent: PTY round-trip via real WebSocket (Cookie auth)', () =
const token = 'sec-protocol-token-must-be-at-least-seventeen-chars';
await grantToken(token);
// We exercise the protocol path by raw-handshaking via fetch+Upgrade,
// because Bun's test-client WebSocket constructor doesn't propagate
// `protocols` cleanly when also passed `headers` (the constructor
// detects the third-arg form unreliably). Real browsers (Chromium)
// use the standard protocols arg fine — the server-side handler is
// identical either way, so this test still locks the load-bearing
// invariant: the agent accepts a token via Sec-WebSocket-Protocol
// and echoes the protocol back so a browser would accept the upgrade.
const handshakeKey = 'dGhlIHNhbXBsZSBub25jZQ==';
const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`, {
headers: {
'Connection': 'Upgrade',
'Upgrade': 'websocket',
'Sec-WebSocket-Version': '13',
'Sec-WebSocket-Key': handshakeKey,
'Sec-WebSocket-Protocol': `gstack-pty.${token}`,
'Origin': 'chrome-extension://test-extension-id',
},
// Use a standards-compliant client. Raw fetch+Upgrade cannot complete a
// WebSocket handshake under current Bun and previously hid a duplicated
// Sec-WebSocket-Protocol response header.
const protocol = token;
const probe = Bun.spawnSync(['node', '-e', `
const WebSocket = require('ws');
const ws = new WebSocket(process.argv[1], process.argv[2], { origin: process.argv[3] });
ws.once('open', () => { if (ws.protocol !== process.argv[2]) process.exit(2); ws.close(); process.exit(0); });
ws.once('error', error => { console.error(error); process.exit(1); });
setTimeout(() => process.exit(3), 4000);
`, `ws://127.0.0.1:${agentPort}/ws`, protocol, `chrome-extension://${GSTACK_EXTENSION_ID}`], {
stdout: 'pipe', stderr: 'pipe', timeout: 5000,
});
// 101 Switching Protocols + protocol echoed back = browser would accept.
// 401/403/anything else = browser would close the connection immediately
// (the bug we hit in manual dogfood).
expect(resp.status).toBe(101);
expect(resp.headers.get('upgrade')?.toLowerCase()).toBe('websocket');
expect(resp.headers.get('sec-websocket-protocol')).toBe(`gstack-pty.${token}`);
expect(probe.exitCode, probe.stderr.toString()).toBe(0);
});
test('Sec-WebSocket-Protocol auth: rejects unknown token even with valid Origin', async () => {
@ -235,7 +237,7 @@ describe('terminal-agent: PTY round-trip via real WebSocket (Cookie auth)', () =
'Sec-WebSocket-Version': '13',
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
'Sec-WebSocket-Protocol': 'gstack-pty.never-granted-token',
'Origin': 'chrome-extension://test-extension-id',
'Origin': `chrome-extension://${GSTACK_EXTENSION_ID}`,
},
});
expect(resp.status).toBe(401);
@ -247,7 +249,7 @@ describe('terminal-agent: PTY round-trip via real WebSocket (Cookie auth)', () =
const ws = new WebSocket(`ws://127.0.0.1:${agentPort}/ws`, {
headers: {
'Origin': 'chrome-extension://test-extension-id',
'Origin': `chrome-extension://${GSTACK_EXTENSION_ID}`,
'Cookie': `gstack_pty=${cookie}`,
},
} as any);

View File

@ -56,7 +56,7 @@ All command endpoints require a Bearer token:
Authorization: Bearer gsk_sess_...
```
`/connect` is unauthenticated (rate-limited) — it's how a remote agent exchanges a setup key for a scoped session token. `/health` is unauthenticated on the local listener (bootstrap) but does NOT exist on the tunnel listener (404).
`/connect` is unauthenticated (rate-limited) — it's how a remote agent exchanges a setup key for a scoped session token. `/health` is an unauthenticated, status-only liveness endpoint on the local listener and does NOT exist on the tunnel listener (404); it never returns the root credential.
SSE endpoints (`/activity/stream`, `/inspector/events`) accept either a Bearer token or the HttpOnly `gstack_sse` cookie (minted via `POST /sse-session`, 30-minute TTL, stream-scope only — cannot be used against `/command`). As of v1.6.0.0 the `?token=<ROOT>` query-string auth is no longer accepted.

View File

@ -319,7 +319,7 @@ From DESIGN.md:
| DMG packaging | **SHIPPED** | 189MB compressed |
| `GSTACK_CHROMIUM_PATH` | **SHIPPED** | Custom Chromium binary support |
| `BROWSE_EXTENSIONS_DIR` | **SHIPPED** | Extension path override |
| Auth via `/health` | **SHIPPED** | Replaces .auth.json file approach, auto-refreshes on server restart |
| Auth via trusted extension session storage | **SHIPPED** | Fixed extension identity, content-script isolation, auto-refresh on server restart |
| Build script | **SHIPPED** | `scripts/build-app.sh` |
| Model routing | **SHIPPED** | Sonnet for actions, Opus for analysis (`pickSidebarModel`) |
| Debug logging | **SHIPPED** | 40+ silent catches → prefixed console logging across 4 files |

View File

@ -33,43 +33,41 @@ 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;
}
// Session storage is restricted to trusted extension contexts. Content
// scripts run in untrusted contexts and cannot read the root bearer even
// though they legitimately use chrome.storage.local for non-secret state.
await chrome.storage.session.setAccessLevel({ accessLevel: 'TRUSTED_CONTEXTS' });
const session = await chrome.storage.session.get('gstackAuthToken');
const local = await chrome.storage.local.get('port');
if (session.gstackAuthToken) authToken = session.gstackAuthToken;
if (local.port) serverPort = local.port;
// Clean up credentials written by prerelease builds of this migration.
await chrome.storage.local.remove('gstackAuthToken');
} catch (err) {
console.error('[gstack bg] Failed to load auth token:', err.message);
console.error('[gstack bg] Failed to load auth token from trusted extension storage:', err.message);
}
}
// ─── Health Polling ────────────────────────────────────────────
async function checkHealth() {
// Refresh before choosing the target: BrowserManager writes a new token and
// port whenever the daemon restarts.
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 intentionally status-only. Auth is delivered through the
// extension's isolated storage, not a caller-controlled HTTP request.
// Forward chatEnabled so sidepanel can show/hide chat tab
setConnected({ ...data, chatEnabled: !!data.chatEnabled });
} else {
@ -299,7 +297,9 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
}
if (msg.type === 'getPort') {
sendResponse({ port: serverPort, connected: isConnected, token: authToken });
// Content scripts can query the port for UI behavior. Never include a
// credential here; only extension pages may request it via getToken.
sendResponse({ port: serverPort, connected: isConnected });
return true;
}

View File

@ -2,7 +2,9 @@
"manifest_version": 3,
"name": "gstack browse",
"version": "0.1.0",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoXbtKYtvKA+STN/yRtOWb5ibKZXhHyTtiITl6K1qquF0g4Sd0zFO4LwmkGdzBCTUBLyVESeqjCLpP0WGJ0MS/g/YLMtmzLXWa2Bf/TNvrKIG/TxCfXLWDvdKayhS+lugLUX+tbrhRQiJ96Zoe2cFI0EXHa62mcLL9sNAwqmYNJAXK3uCJlveNa0NpeD8Jjmt3l2S9sjypVvz9JZmHnsgZbbK18VM3V65GpBs2BB/gzS8UDQbvR1vcrA5o9FHjgF0+NLrryNfyCJjm3YAHYJFKVda7uSHr4FlUZXl+IL9YynqODgx4Pp4kiY7bFCI7l9zzJIUkWnorskwzD3ziNUbJQIDAQAB",
"description": "Live activity feed and @ref overlays for gstack browse",
"externally_connectable": { "ids": [] },
"permissions": ["sidePanel", "storage", "activeTab", "scripting", "tabs"],
"host_permissions": ["http://127.0.0.1:*/", "ws://127.0.0.1:*/"],
"action": {

View File

@ -305,7 +305,7 @@
setState(STATE.LIVE);
ensureXterm();
nextBinaryIsReplay = false;
ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [`gstack-pty.${attachToken}`]);
ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [attachToken]);
ws.binaryType = 'arraybuffer';
ws.addEventListener('open', () => {
@ -599,7 +599,7 @@
// SameSite=Strict don't survive the jump from server.ts:34567 to the
// agent's random port from a chrome-extension origin, so cookies
// alone weren't reliable.
ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [`gstack-pty.${attachToken}`]);
ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [attachToken]);
ws.binaryType = 'arraybuffer';
ws.addEventListener('open', () => {
@ -821,7 +821,7 @@
setState(STATE.LIVE);
ensureXterm();
ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [`gstack-pty.${token}`]);
ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [token]);
ws.binaryType = 'arraybuffer';
ws.addEventListener('open', () => {

View File

@ -1281,13 +1281,19 @@ async function tryConnect() {
const port = resp.port || 34567;
// Step 2: If background says connected + has token, use that
if (resp.port && resp.connected && resp.token) {
// Step 2: Background owns the port; request the token through the separate
// extension-page-only channel. getPort intentionally never returns it.
const tokenResp = await new Promise(resolve => {
chrome.runtime.sendMessage({ type: 'getToken' }, (r) => {
resolve(r || {});
});
});
if (resp.port && resp.connected && tokenResp.token) {
setLoadingStatus(
`Server found on port ${port}, connecting...`,
`token: yes\nStarting SSE + chat polling...`
);
updateConnection(`http://127.0.0.1:${port}`, resp.token);
updateConnection(`http://127.0.0.1:${port}`, tokenResp.token);
return;
}
@ -1304,12 +1310,12 @@ async function tryConnect() {
});
if (healthResp.ok) {
const data = await healthResp.json();
if (data.status === 'healthy' && data.token) {
if (data.status === 'healthy' && tokenResp.token) {
setLoadingStatus(
`Server healthy on port ${port}, connecting...`,
`token: yes (from /health)\nStarting SSE + activity feed...`
`token: yes (from extension storage)\nStarting SSE + activity feed...`
);
updateConnection(`http://127.0.0.1:${port}`, data.token);
updateConnection(`http://127.0.0.1:${port}`, tokenResp.token);
// The SEC shield used to drive off /health.security via the chat
// path's classifier; with the chat path ripped, the indicator is
// not driven yet. Leaving the shield element hidden by default.
@ -1317,7 +1323,7 @@ async function tryConnect() {
}
setLoadingStatus(
`Server responded but not healthy (attempt ${connectAttempts})`,
`status: ${data.status}\ntoken: ${data.token ? 'yes' : 'no'}`
`status: ${data.status}\ntoken: ${tokenResp.token ? 'yes' : 'no'}`
);
} else {
setLoadingStatus(

View File

@ -65,7 +65,8 @@ chmod +x "$APP_DIR/Contents/Resources/browse"
# Extension
cp -r "$ROOT/extension" "$APP_DIR/Contents/Resources/extension"
# Remove .auth.json if present (auth now via /health endpoint)
# Remove legacy file-based auth if present. Current builds provision the
# bearer through the trusted extension's isolated Chrome storage.
rm -f "$APP_DIR/Contents/Resources/extension/.auth.json"
# Server source (needed for `bun run server.ts` subprocess)

21
setup
View File

@ -1200,6 +1200,27 @@ if [ "$INSTALL_CODEX" -eq 1 ]; then
create_agents_sidecar "$SOURCE_GSTACK_DIR"
fi
# 7.5. Repair a missing or corrupt Playwright cache before Browse is first
# launched. This is bounded and opt-out so setup cannot hang on a bad network.
# The normal macOS repair uses Chrome for Testing's versioned archive because
# Playwright's installer can leave a partial cache behind after interruption.
if [ "${GSTACK_SKIP_BROWSER_REPAIR:-0}" != "1" ] && [ -f "$SOURCE_GSTACK_DIR/bin/gstack-browse-repair.ts" ]; then
log "Checking Browse browser runtime..."
bun "$SOURCE_GSTACK_DIR/bin/gstack-browse-repair.ts" --repair \
|| log "warning: Browse browser runtime repair failed; run bin/gstack-browse-repair.ts --repair"
fi
# 7.6. Security migration for the pre-#1324 unpacked Browse extension. This
# intentionally runs on every setup, not only a version transition: a fresh
# install can reuse an existing ~/.gstack Chromium profile. The migrator is
# idempotent and leaves a private rollback copy of only the old extension data.
if [ -f "$SOURCE_GSTACK_DIR/bin/gstack-browse-migrate.ts" ]; then
log "Migrating legacy Browse extension identity..."
bun "$SOURCE_GSTACK_DIR/bin/gstack-browse-migrate.ts" --apply \
--legacy-extension-path "$SOURCE_GSTACK_DIR/extension" \
|| log "warning: Browse migration deferred; close GStack Browser and rerun ./setup"
fi
# 8. Run pending version migrations
# Migrations handle state fixes that ./setup alone can't cover (stale config,
# orphaned files, directory structure changes). Each migration is idempotent.

View File

@ -0,0 +1,85 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { spawnSync } from 'child_process';
import { createHash } from 'crypto';
const ROOT = path.resolve(import.meta.dir, '..');
const MIGRATOR = path.join(ROOT, 'bin/gstack-browse-migrate.ts');
const TRUSTED = 'hjcdllcckghjebjopehjhplcilonljjk';
let legacyExtensionPath: string;
let legacyId: string;
let temp: string;
beforeEach(() => {
temp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-browse-migration-'));
legacyExtensionPath = path.join(temp, 'legacy-extension');
fs.mkdirSync(legacyExtensionPath);
legacyId = [...createHash('sha256').update(legacyExtensionPath).digest('hex').slice(0, 32)]
.map(char => String.fromCharCode('a'.charCodeAt(0) + parseInt(char, 16))).join('');
const profile = path.join(temp, 'Default');
fs.mkdirSync(path.join(profile, 'Local Extension Settings', legacyId), { recursive: true });
fs.mkdirSync(path.join(profile, 'Sync Extension Settings', legacyId), { recursive: true });
fs.writeFileSync(path.join(profile, 'Local Extension Settings', legacyId, '000003.log'), 'legacy token storage');
fs.writeFileSync(path.join(profile, 'Preferences'), JSON.stringify({
extensions: { settings: {
[TRUSTED]: { manifest: { name: 'gstack browse' } },
another_extension: { manifest: { name: 'other extension' } },
} },
}));
});
afterEach(() => fs.rmSync(temp, { recursive: true, force: true }));
function run(...args: string[]) {
return spawnSync('bun', [MIGRATOR, '--profile', temp, '--legacy-extension-path', legacyExtensionPath, '--json', ...args], { encoding: 'utf8' });
}
describe('gstack-browse-migrate', () => {
test('reports legacy state without modifying it in check mode', () => {
const result = run('--check');
expect(result.status).toBe(0);
expect(JSON.parse(result.stdout).legacyExtensionIds).toEqual([legacyId]);
expect(fs.existsSync(path.join(temp, 'Default', 'Local Extension Settings', legacyId))).toBe(true);
});
test('retires only the legacy gstack extension and preserves a rollback copy', () => {
const result = run('--apply');
expect(result.status).toBe(0);
const report = JSON.parse(result.stdout);
expect(report.status).toBe('migrated');
expect(fs.existsSync(path.join(temp, 'Default', 'Local Extension Settings', legacyId))).toBe(false);
expect(fs.existsSync(path.join(report.backup, 'Local Extension Settings', legacyId, '000003.log'))).toBe(true);
const preferences = JSON.parse(fs.readFileSync(path.join(temp, 'Default', 'Preferences'), 'utf8'));
expect(preferences.extensions.settings[TRUSTED]).toBeDefined();
expect(preferences.extensions.settings.another_extension).toBeDefined();
});
test('defers safely while the dedicated GStack Browser profile is active', () => {
const lock = path.join(temp, 'SingletonLock');
if (process.platform === 'win32') fs.writeFileSync(lock, 'active');
else fs.symlinkSync(`${os.hostname()}-${process.pid}`, lock);
const result = run('--apply');
expect(result.status).toBe(3);
expect(JSON.parse(result.stdout).status).toBe('deferred');
expect(fs.existsSync(path.join(temp, 'Default', 'Local Extension Settings', legacyId))).toBe(true);
});
test('does not mistake a dangling stale Chromium lock symlink for an active profile', () => {
if (process.platform === 'win32') return;
fs.symlinkSync(`${os.hostname()}-2147483647`, path.join(temp, 'SingletonLock'));
const result = run('--apply');
expect(result.status).toBe(0);
expect(JSON.parse(result.stdout).status).toBe('migrated');
});
test('does not mark an invalid Preferences profile as migrated', () => {
fs.writeFileSync(path.join(temp, 'Default', 'Preferences'), '{not json');
const result = run('--apply');
expect(result.status).toBe(3);
expect(JSON.parse(result.stdout).status).toBe('deferred');
expect(fs.existsSync(path.join(temp, 'Default', 'Local Extension Settings', legacyId))).toBe(true);
});
});

View File

@ -0,0 +1,24 @@
import { describe, expect, test } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
const ROOT = path.resolve(import.meta.dir, '..');
const SETUP = fs.readFileSync(path.join(ROOT, 'setup'), 'utf8');
const REPAIR = fs.readFileSync(path.join(ROOT, 'bin/gstack-browse-repair.ts'), 'utf8');
describe('setup Browse security recovery', () => {
test('runs the legacy extension migration on every setup, not only version changes', () => {
const beforeVersionMigrations = SETUP.slice(0, SETUP.indexOf('# 8. Run pending version migrations'));
expect(beforeVersionMigrations).toContain('gstack-browse-migrate.ts');
expect(beforeVersionMigrations).toContain('--legacy-extension-path "$SOURCE_GSTACK_DIR/extension"');
expect(beforeVersionMigrations).toContain('close GStack Browser and rerun ./setup');
});
test('repairs a broken browser cache with a bounded health-checked path', () => {
expect(REPAIR).toContain('const MAX_INSTALL_MS = 300_000');
expect(REPAIR).toContain("'chrome-mac-arm64.zip'");
expect(REPAIR).toContain("'chrome-headless-shell-mac-arm64.zip'");
expect(REPAIR).toContain("[executable, '--version']");
expect(REPAIR).toContain("'INSTALLATION_COMPLETE'");
});
});