Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,22 +83,22 @@ 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)

When a user runs `pair-agent --client`, the daemon starts an ngrok tunnel so a remote paired agent can drive the browser. Exposing the full daemon surface to the internet (even behind a random ngrok subdomain) meant `/health` leaked the root token on any Origin spoof, and `/cookie-picker` embedded the token into HTML that any caller could fetch.

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 |
Expand Down
20 changes: 18 additions & 2 deletions BROWSER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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)
Expand Down
208 changes: 208 additions & 0 deletions bin/gstack-browse-migrate.ts
Original file line number Diff line number Diff line change
@@ -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);
Loading