fix(browse): lock local auth to trusted extension

This commit is contained in:
Sina
2026-07-10 12:48:25 -07:00
parent 7c9df1c568
commit 7b3f391bbc
24 changed files with 925 additions and 147 deletions
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env bun
/**
* Retire the pre-#1324 unpacked gstack browser extension from GStack's
* dedicated Chromium profile.
*
* The old extension's ID was derived from its installation path, so it cannot
* be a trust anchor. The current extension has a manifest key and a fixed ID.
* This tool only edits ~/.gstack/chromium-profile (or an explicit test/profile
* directory); it never touches a user's normal Chrome profile.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { createHash } from 'crypto';
const TRUSTED_EXTENSION_ID = 'hjcdllcckghjebjopehjhplcilonljjk';
const EXTENSION_NAME = 'gstack browse';
type Result = {
profile: string;
status: 'clean' | 'migrated' | 'deferred';
legacyExtensionIds: string[];
backup?: string;
reason?: string;
};
function usage(): never {
console.error('Usage: gstack-browse-migrate [--check|--apply] [--profile <gstack-profile>] [--legacy-extension-path <path>] [--json]');
process.exit(2);
}
function parseArgs(argv: string[]) {
let mode: 'check' | 'apply' = 'check';
let profile = process.env.CHROMIUM_PROFILE
|| path.join(process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack'), 'chromium-profile');
let json = false;
const legacyExtensionPaths: string[] = [];
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--check') mode = 'check';
else if (argv[i] === '--apply') mode = 'apply';
else if (argv[i] === '--json') json = true;
else if (argv[i] === '--profile') {
profile = argv[++i] || usage();
} else if (argv[i] === '--legacy-extension-path') {
legacyExtensionPaths.push(argv[++i] || usage());
} else usage();
}
return { mode, profile: path.resolve(profile), json, legacyExtensionPaths };
}
function writeAtomic(file: string, content: string): void {
const tmp = `${file}.gstack-migrate-${process.pid}.tmp`;
fs.writeFileSync(tmp, content, { mode: 0o600 });
fs.renameSync(tmp, file);
}
function profileDirectories(profileRoot: string): string[] {
if (!fs.existsSync(profileRoot)) return [];
return fs.readdirSync(profileRoot, { withFileTypes: true })
.filter(entry => entry.isDirectory() && (entry.name === 'Default' || entry.name.startsWith('Profile ')))
.map(entry => path.join(profileRoot, entry.name));
}
function findLegacyIds(preferences: any): string[] {
const settings = preferences?.extensions?.settings;
if (!settings || typeof settings !== 'object') return [];
return Object.entries(settings)
.filter(([id, value]: [string, any]) => id !== TRUSTED_EXTENSION_ID && value?.manifest?.name === EXTENSION_NAME)
.map(([id]) => id);
}
/**
* Before manifest.key, Chromium generated an unpacked extension's ID from the
* absolute extension directory. The current manifest has a fixed key, but the
* old ID can still be derived from that same path during an upgrade.
*/
function legacyIdForExtensionPath(extensionPath: string): string[] {
const candidates = new Set([path.resolve(extensionPath)]);
try { candidates.add(fs.realpathSync(extensionPath)); } catch {}
return [...candidates].map(candidate => [...createHash('sha256').update(candidate).digest('hex').slice(0, 32)]
.map(char => String.fromCharCode('a'.charCodeAt(0) + parseInt(char, 16)))
.join(''));
}
function storageLegacyIds(profileDir: string, extensionPaths: string[]): string[] {
const storageRoots = ['Local Extension Settings', 'Sync Extension Settings'];
const storedIds = new Set<string>();
for (const storageRoot of storageRoots) {
const root = path.join(profileDir, storageRoot);
if (!fs.existsSync(root)) continue;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (entry.isDirectory()) storedIds.add(entry.name);
}
}
const knownLegacyIds = extensionPaths.flatMap(legacyIdForExtensionPath);
return knownLegacyIds.filter(id => id !== TRUSTED_EXTENSION_ID && storedIds.has(id));
}
function output(result: Result, asJson: boolean): void {
if (asJson) console.log(JSON.stringify(result));
else {
console.log(`[browse-migrate] ${result.status}: ${result.profile}`);
if (result.legacyExtensionIds.length) console.log(`[browse-migrate] legacy extension IDs: ${result.legacyExtensionIds.join(', ')}`);
if (result.backup) console.log(`[browse-migrate] backup: ${result.backup}`);
if (result.reason) console.log(`[browse-migrate] ${result.reason}`);
}
}
/** Chromium normally represents SingletonLock as a dangling hostname-PID symlink. */
function profileLockIsActive(lock: string): boolean {
let stat: fs.Stats;
try {
stat = fs.lstatSync(lock);
} catch (err: any) {
if (err?.code === 'ENOENT') return false;
// If lock state cannot be inspected, do not risk editing the profile.
return true;
}
if (!stat.isSymbolicLink()) return true;
try {
const target = fs.readlinkSync(lock);
const match = target.match(/^(.*)-(\d+)$/);
if (!match || match[1] !== os.hostname()) return true;
const pid = Number(match[2]);
if (!Number.isSafeInteger(pid) || pid <= 0) return true;
try {
process.kill(pid, 0);
return true;
} catch (err: any) {
return err?.code !== 'ESRCH';
}
} catch {
return true;
}
}
function migrateProfile(profileRoot: string, mode: 'check' | 'apply', extensionPaths: string[]): Result[] {
const lock = path.join(profileRoot, 'SingletonLock');
if (profileLockIsActive(lock)) {
return [{ profile: profileRoot, status: 'deferred', legacyExtensionIds: [], reason: 'browser profile is active; close GStack Browser and retry' }];
}
const results: Result[] = [];
for (const profileDir of profileDirectories(profileRoot)) {
const preferencesPath = path.join(profileDir, 'Preferences');
const hasPreferences = fs.existsSync(preferencesPath);
let preferences: any = { extensions: { settings: {} } };
if (hasPreferences) {
try {
preferences = JSON.parse(fs.readFileSync(preferencesPath, 'utf8'));
} catch {
results.push({ profile: profileDir, status: 'deferred', legacyExtensionIds: [], reason: 'Preferences is not valid JSON; left unchanged' });
continue;
}
}
// Chrome may omit an unpacked extension from Preferences. In that case,
// derive the old ID from the upgraded extension's path and require a
// matching storage directory before acting. This remains narrowly scoped:
// unrelated extension IDs are never selected by a broad directory scan.
const legacyIds = [...new Set([
...findLegacyIds(preferences),
...storageLegacyIds(profileDir, extensionPaths),
])];
if (!legacyIds.length) {
results.push({ profile: profileDir, status: 'clean', legacyExtensionIds: [] });
continue;
}
if (mode === 'check') {
results.push({ profile: profileDir, status: 'deferred', legacyExtensionIds: legacyIds, reason: 'run with --apply to retire legacy extension state' });
continue;
}
const migrationRoot = path.join(profileRoot, '..', 'browser-migrations', new Date().toISOString().replace(/[:.]/g, '-'));
const backupDir = path.join(migrationRoot, path.basename(profileDir));
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
if (hasPreferences) fs.copyFileSync(preferencesPath, path.join(backupDir, 'Preferences'));
if (hasPreferences) {
for (const id of legacyIds) delete preferences.extensions?.settings?.[id];
writeAtomic(preferencesPath, `${JSON.stringify(preferences, null, 2)}\n`);
}
// Extension storage is the only place the old design could retain its
// daemon credential. Move it aside instead of deleting it so rollback is
// possible without ever copying credentials into the new extension ID.
for (const storageRoot of ['Local Extension Settings', 'Sync Extension Settings']) {
for (const id of legacyIds) {
const source = path.join(profileDir, storageRoot, id);
if (!fs.existsSync(source)) continue;
const target = path.join(backupDir, storageRoot, id);
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
fs.renameSync(source, target);
}
}
results.push({ profile: profileDir, status: 'migrated', legacyExtensionIds: legacyIds, backup: backupDir });
}
return results.length ? results : [{ profile: profileRoot, status: 'clean', legacyExtensionIds: [] }];
}
const args = parseArgs(process.argv.slice(2));
const results = migrateProfile(args.profile, args.mode, args.legacyExtensionPaths);
for (const result of results) output(result, args.json);
// Check mode uses "deferred" to report work without failing. Apply mode must
// fail for every deferred profile so the versioned updater never writes its
// done marker while legacy credential state remains untouched.
process.exit(args.mode === 'apply' && results.some(result => result.status === 'deferred') ? 3 : 0);
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bun
/** Repair the Playwright browser cache used by gstack browse. */
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const ROOT = path.resolve(import.meta.dir, '..');
const CACHE = process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), 'Library', 'Caches', 'ms-playwright');
const MAX_INSTALL_MS = 300_000;
type BrowserSpec = { name: string; revision: string; browserVersion: string };
function fail(message: string): never {
console.error(`[browse-repair] ${message}`);
process.exit(1);
}
function readSpec(name: string): BrowserSpec {
const file = path.join(ROOT, 'node_modules', 'playwright-core', 'browsers.json');
const data = JSON.parse(fs.readFileSync(file, 'utf8')) as { browsers: BrowserSpec[] };
const spec = data.browsers.find(browser => browser.name === name);
if (!spec) fail(`Playwright browser spec ${name} not found`);
return spec;
}
function expectedExecutable(spec: BrowserSpec): string | null {
if (process.platform !== 'darwin' || process.arch !== 'arm64') return null;
if (spec.name === 'chromium') {
return path.join(CACHE, `chromium-${spec.revision}`, 'chrome-mac-arm64', 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing');
}
if (spec.name === 'chromium-headless-shell') {
return path.join(CACHE, `chromium_headless_shell-${spec.revision}`, 'chrome-headless-shell-mac-arm64', 'chrome-headless-shell');
}
return null;
}
function installationDirectory(spec: BrowserSpec): string {
return spec.name === 'chromium'
? path.join(CACHE, `chromium-${spec.revision}`)
: path.join(CACHE, `chromium_headless_shell-${spec.revision}`);
}
function isHealthy(spec: BrowserSpec): boolean {
const executable = expectedExecutable(spec);
if (!executable || !fs.existsSync(executable)) return false;
if (!fs.existsSync(path.join(installationDirectory(spec), 'INSTALLATION_COMPLETE'))) return false;
const result = Bun.spawnSync([executable, '--version'], { stdout: 'pipe', stderr: 'pipe', timeout: 10_000 });
return result.exitCode === 0;
}
function archiveUrl(spec: BrowserSpec): string | null {
if (process.platform !== 'darwin' || process.arch !== 'arm64') return null;
const archive = spec.name === 'chromium'
? 'chrome-mac-arm64.zip'
: spec.name === 'chromium-headless-shell'
? 'chrome-headless-shell-mac-arm64.zip'
: null;
return archive ? `https://storage.googleapis.com/chrome-for-testing-public/${spec.browserVersion}/mac-arm64/${archive}` : null;
}
async function repairMacArtifact(spec: BrowserSpec): Promise<void> {
const url = archiveUrl(spec);
const executable = expectedExecutable(spec);
if (!url || !executable) fail(`no direct repair path for ${process.platform}/${process.arch}`);
const installDir = installationDirectory(spec);
if (fs.existsSync(installDir)) {
const backup = `${installDir}.incomplete-${Date.now()}`;
fs.renameSync(installDir, backup);
console.error(`[browse-repair] preserved incomplete cache at ${backup}`);
}
fs.mkdirSync(installDir, { recursive: true, mode: 0o700 });
const archive = path.join(os.tmpdir(), `gstack-${spec.name}-${spec.revision}-${process.pid}.zip`);
try {
// macOS ships curl, and its file-backed transfer avoids buffering a
// 150MB+ Chromium archive inside Bun. The earlier fetch + Bun.write path
// could spin indefinitely while materializing a large Response object.
const download = Bun.spawnSync([
'curl', '--fail', '--location', '--silent', '--show-error',
'--max-time', String(MAX_INSTALL_MS / 1000), '--output', archive, url,
], { stdout: 'pipe', stderr: 'pipe', timeout: MAX_INSTALL_MS + 5_000 });
if (download.exitCode !== 0) {
fail(`download failed for ${spec.name}: ${download.stderr.toString().trim() || 'timed out'}`);
}
const unpack = Bun.spawnSync(['ditto', '-x', '-k', archive, installDir], { stdout: 'pipe', stderr: 'pipe', timeout: MAX_INSTALL_MS });
if (unpack.exitCode !== 0) fail(`could not unpack ${spec.name}: ${unpack.stderr.toString().trim()}`);
} finally {
try { fs.unlinkSync(archive); } catch {}
}
if (!expectedExecutable(spec) || !fs.existsSync(expectedExecutable(spec)!)) {
fail(`${spec.name} still failed its executable health check`);
}
// Playwright's registry only recognizes browser directories carrying this
// marker. Without it, a later installer/GC can discard a working repair.
fs.writeFileSync(path.join(installDir, 'INSTALLATION_COMPLETE'), '', { mode: 0o600 });
if (!isHealthy(spec)) fail(`${spec.name} still failed its registry health check`);
}
function repairViaPlaywright(): void {
const cli = path.join(ROOT, 'node_modules', 'playwright-core', 'cli.js');
const result = Bun.spawnSync(['node', cli, 'install', 'chromium', 'chromium-headless-shell'], {
stdout: 'pipe', stderr: 'pipe', timeout: MAX_INSTALL_MS,
});
if (result.exitCode !== 0) fail(`Playwright install failed: ${result.stderr.toString().trim() || 'timed out'}`);
}
const repair = process.argv.slice(2).includes('--repair');
// The direct executable health check below is intentionally macOS/Apple
// Silicon-specific. Other Playwright platforms use different archive layouts;
// defer their validation/install semantics to Playwright's own CLI rather than
// declaring a healthy cache broken on every setup run.
if (process.platform !== 'darwin' || process.arch !== 'arm64') {
if (repair) repairViaPlaywright();
console.log('[browse-repair] Playwright browser repair delegated to Playwright');
process.exit(0);
}
const specs = [readSpec('chromium'), readSpec('chromium-headless-shell')];
const unhealthy = specs.filter(spec => !isHealthy(spec));
if (!unhealthy.length) {
console.log('[browse-repair] Playwright browser cache is healthy');
process.exit(0);
}
if (!repair) fail(`missing or unhealthy: ${unhealthy.map(spec => spec.name).join(', ')} (run with --repair)`);
for (const spec of unhealthy) await repairMacArtifact(spec);
const stillUnhealthy = specs.filter(spec => !isHealthy(spec));
if (stillUnhealthy.length) fail(`repair incomplete: ${stillUnhealthy.map(spec => spec.name).join(', ')}`);
console.log('[browse-repair] Playwright browser cache repaired');