mirror of https://github.com/garrytan/gstack.git
fix(browse): lock local auth to trusted extension
This commit is contained in:
parent
7c9df1c568
commit
7b3f391bbc
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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');
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
21
setup
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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'");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue