mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-20 20:00:45 +02:00
fix(browse): lock local auth to trusted extension
This commit is contained in:
@@ -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');
|
||||
Reference in New Issue
Block a user