diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 03410fda5..e310c515c 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -23,6 +23,7 @@ import { emitActivity } from './activity'; import { validateNavigationUrl, blockedNavigationReason } from './url-validation'; import { TabSession, type RefEntry } from './tab-session'; import { resolveChromiumProfile, cleanSingletonLocks, resolveConfig } from './config'; +import { ensureProjectProfile } from './chromium-profiles'; import { launchWithXProtectHeal } from './xprotect-heal'; import { readPidStartTime, shouldSpawnXvfb, pickFreeDisplay, spawnXvfb, xvfbInstallHint, type XvfbHandle } from './xvfb'; import { withCdpSession } from './cdp-bridge'; @@ -750,6 +751,7 @@ export class BrowserManager { const fs = require('fs'); const path = require('path'); const userDataDir = resolveChromiumProfile(); + ensureProjectProfile(userDataDir); fs.mkdirSync(userDataDir, { recursive: true }); // Pre-launch cleanup of stale SingletonLock/Socket/Cookie. Chromium's @@ -1913,6 +1915,7 @@ export class BrowserManager { // ignoring $CHROMIUM_PROFILE / $GSTACK_HOME and skipping the lock // cleanup — the third shipped drift between the three launch paths. const userDataDir = resolveChromiumProfile(); + ensureProjectProfile(userDataDir); fs.mkdirSync(userDataDir, { recursive: true }); cleanSingletonLocks(userDataDir); diff --git a/browse/src/chromium-profiles.ts b/browse/src/chromium-profiles.ts new file mode 100644 index 000000000..b3cfe6666 --- /dev/null +++ b/browse/src/chromium-profiles.ts @@ -0,0 +1,208 @@ +/** + * Per-project headed Chromium profiles (D5, #2492). + * + * The headed profile used to be machine-wide (/chromium-profile), + * so starting headed browse in one project SIGKILLed another project's live + * headed Chromium. Each project now gets /.gstack/chromium-profile + * (config.resolveChromiumProfile). This module owns the profile lifecycle: + * who holds a profile's SingletonLock, first-use seeding from the old + * machine-wide profile, and `browse profiles list|prune`. + */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { resolveGstackHome } from './config'; +import { isProcessAlive } from './error-handling'; +import { readPidCmdline } from './xvfb'; +import { atomicWriteSync } from '../../lib/fs-atomic'; + +export type ProfileOwner = + | { state: 'free' } + | { state: 'orphan'; pid: number } + | { state: 'live'; pid: number; detail: string }; + +/** Processes an orphan is reparented to: init, launchd, and common subreapers. */ +const REAPERS = new Set(['init', 'systemd', 'launchd', 'tini', 'dumb-init', 'docker-init']); + +export function legacyProfileDir(): string { + return path.join(resolveGstackHome(), 'chromium-profile'); +} + +function registryPath(): string { + return path.join(resolveGstackHome(), 'browse-profiles.json'); +} + +function readParentPid(pid: number): number { + try { + const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf-8'); + return Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[1]); + } catch { + try { + const r = Bun.spawnSync(['ps', '-o', 'ppid=', '-p', String(pid)], { stdout: 'pipe', stderr: 'pipe', timeout: 2000, windowsHide: true }); + return r.exitCode === 0 ? Number(r.stdout.toString().trim()) : 0; + } catch { + return 0; + } + } +} + +/** + * Who holds this profile. The SingletonLock symlink target is "host-PID". + * Only a process on this host that is a Chromium using this exact profile + * counts as the holder; a dead or recycled PID means the lock is stale. An + * orphan (reparented to init or a subreaper after its daemon died) may be + * reaped; anything else, including a lock from another host, is a live owner + * that must never be killed. + */ +export function profileOwner(profileDir: string): ProfileOwner { + let target: string; + try { + target = fs.readlinkSync(path.join(profileDir, 'SingletonLock')); + } catch { + return { state: 'free' }; + } + const dash = target.lastIndexOf('-'); + const host = target.slice(0, dash); + const pid = Number(target.slice(dash + 1)); + if (!Number.isSafeInteger(pid) || pid <= 0) return { state: 'free' }; + if (host !== os.hostname()) { + return { state: 'live', pid, detail: `locked by PID ${pid} on another host (${host})` }; + } + if (!isProcessAlive(pid)) return { state: 'free' }; + const cmd = readPidCmdline(pid); + const dirs = new Set([profileDir]); + try { dirs.add(fs.realpathSync(profileDir)); } catch {} + const usesProfile = [...dirs].some(d => cmd.includes(`--user-data-dir=${d}`)); + if (!/chrom/i.test(cmd) || !usesProfile) return { state: 'free' }; + const ppid = readParentPid(pid); + const parent = ppid > 1 ? path.basename(readPidCmdline(ppid).split(' ')[0] || '') : ''; + if (ppid <= 1 || !isProcessAlive(ppid) || REAPERS.has(parent)) return { state: 'orphan', pid }; + return { state: 'live', pid, detail: `in use by Chromium PID ${pid} (started by PID ${ppid}: ${parent || 'unknown'})` }; +} + +function readRegistry(): string[] { + try { + const list = JSON.parse(fs.readFileSync(registryPath(), 'utf-8')); + return Array.isArray(list) ? list.filter((p): p is string => typeof p === 'string') : []; + } catch { + return []; + } +} + +function writeRegistry(list: string[]): void { + fs.mkdirSync(path.dirname(registryPath()), { recursive: true }); + atomicWriteSync(registryPath(), JSON.stringify([...new Set(list)].sort(), null, 2) + '\n'); +} + +/** + * Create a project's headed profile on first use. Seeds it from the old + * machine-wide profile only when nothing holds that profile, copying into a + * temporary 0700 directory without Singleton* files and renaming it into + * place, so a concurrent first use or an interrupted copy never leaves a + * half-written profile. Says what happened once, through `log`. + */ +export function ensureProjectProfile(profileDir: string, log: (msg: string) => void = console.log): void { + if (fs.existsSync(profileDir)) return; + const legacy = legacyProfileDir(); + if (path.resolve(profileDir) === path.resolve(legacy)) return; + writeRegistry([...readRegistry(), path.resolve(profileDir)]); + fs.mkdirSync(path.dirname(profileDir), { recursive: true }); + const fresh = (why: string) => { + fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 }); + if (why) log(why); + }; + if (!fs.existsSync(legacy)) return fresh(''); + const before = profileOwner(legacy); + if (before.state !== 'free') { + return fresh(`[browse] Headed browse now keeps a profile per project (${profileDir}). The old shared profile (${legacy}) is ${'detail' in before ? before.detail : `held by orphaned PID ${before.pid}`}, so this project starts with a fresh profile. To import it later: close that browser, delete ${profileDir}, and start headed browse again.`); + } + const tmp = `${profileDir}.seed-${process.pid}`; + fs.rmSync(tmp, { recursive: true, force: true }); + try { + fs.cpSync(legacy, tmp, { + recursive: true, + verbatimSymlinks: true, + filter: (src) => !path.basename(src).startsWith('Singleton'), + }); + fs.chmodSync(tmp, 0o700); + if (profileOwner(legacy).state !== 'free') { + fs.rmSync(tmp, { recursive: true, force: true }); + return fresh(`[browse] The old shared profile (${legacy}) came into use while it was being copied, so this project (${profileDir}) starts with a fresh profile.`); + } + fs.renameSync(tmp, profileDir); + } catch (err) { + fs.rmSync(tmp, { recursive: true, force: true }); + if (fs.existsSync(profileDir)) return; + throw err; + } + log(`[browse] Headed browse now keeps a profile per project. Copied your logins and settings from ${legacy} to ${profileDir} (${formatBytes(dirBytes(profileDir))}). See \`browse profiles\` to list or prune profiles.`); +} + +function dirBytes(dir: string): number { + let total = 0; + const walk = (d: string) => { + for (const e of fs.readdirSync(d, { withFileTypes: true })) { + const p = path.join(d, e.name); + if (e.isDirectory()) walk(p); + else if (e.isFile()) total += fs.statSync(p).size; + } + }; + try { walk(dir); } catch {} + return total; +} + +function formatBytes(n: number): string { + if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + +function describeOwner(owner: ProfileOwner): string { + if (owner.state === 'free') return 'idle'; + if (owner.state === 'orphan') return `held by orphaned PID ${owner.pid}`; + return owner.detail; +} + +/** `browse profiles [list]` and `browse profiles prune [--days N]`. Returns the exit code. */ +export function runProfilesCommand(args: string[], out: (line: string) => void = console.log): number { + const sub = args[0] ?? 'list'; + const known = readRegistry(); + const legacy = legacyProfileDir(); + if (sub === 'list') { + const rows = [...(fs.existsSync(legacy) ? [legacy] : []), ...known]; + if (rows.length === 0) { + out('No headed browser profiles yet. One is created per project on first `browse --headed` or `browse connect`.'); + return 0; + } + for (const dir of rows) { + if (!fs.existsSync(dir)) { out(`${dir} (missing: project moved or deleted; \`browse profiles prune\` forgets it)`); continue; } + const age = Math.floor((Date.now() - fs.statSync(dir).mtimeMs) / 86_400_000); + out(`${dir} ${formatBytes(dirBytes(dir))} last used ${age}d ago ${describeOwner(profileOwner(dir))}${dir === legacy ? ' (old shared profile)' : ''}`); + } + return 0; + } + if (sub === 'prune') { + const daysIdx = args.indexOf('--days'); + const days = daysIdx >= 0 ? Number(args[daysIdx + 1]) : 30; + if (!Number.isFinite(days) || days < 0) { + out('Usage: browse profiles prune [--days N] (default 30)'); + return 2; + } + const keep: string[] = []; + let freed = 0; + for (const dir of known) { + if (!fs.existsSync(dir)) { out(`forgot ${dir} (no longer exists)`); continue; } + const idleDays = (Date.now() - fs.statSync(dir).mtimeMs) / 86_400_000; + const owner = profileOwner(dir); + if (idleDays < days || owner.state !== 'free') { keep.push(dir); continue; } + const bytes = dirBytes(dir); + fs.rmSync(dir, { recursive: true, force: true }); + freed += bytes; + out(`removed ${dir} (${formatBytes(bytes)}, idle ${Math.floor(idleDays)}d)`); + } + writeRegistry(keep); + out(`Pruned profiles idle for ${days}+ days; freed ${formatBytes(freed)}. Profiles in use are never removed. The old shared profile (${legacy}) is left alone.`); + return 0; + } + out('Usage: browse profiles [list] | browse profiles prune [--days N]'); + return 2; +} diff --git a/browse/src/cli.ts b/browse/src/cli.ts index cba6c1ab1..5e7c0d0e7 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -19,6 +19,7 @@ import { resolveConfig, ensureStateDir, readVersionHash, isPairAgentEnabled, res import { parseProxyConfig, computeConfigHash, ProxyConfigError } from './proxy-config'; import { redactProxyUrl } from './proxy-redact'; import { spawnTerminalAgent, BUN_CHILD_FLAGS } from './terminal-agent-control'; +import { profileOwner, ensureProjectProfile, runProfilesCommand } from './chromium-profiles'; // Zero side effects on import (documented invariant in token-registry.ts) — // safe to pull the shared pairing default into the CLI. import { DEFAULT_PAIR_SCOPES } from './token-registry'; @@ -285,23 +286,21 @@ function cleanChromiumProfileLocks(profileDir: string = chromiumProfileDir()): v } } -/** Kill an orphaned Chromium that still holds the profile's SingletonLock. The - * lock symlink target is "hostname-PID"; killing that PID tears down its - * renderer tree so the next launch starts clean. No-op when absent/stale. */ +/** Kill an orphaned Chromium that still holds the profile's SingletonLock so + * the next launch starts clean (#1781). Only a Chromium on this host that uses + * this profile and whose daemon is gone is killed; a profile held by a live + * process is never killed — browse stops and names the holder (D5, #2492). */ async function killOrphanChromium(profileDir: string = chromiumProfileDir()): Promise { - try { - const lockTarget = fs.readlinkSync(path.join(profileDir, 'SingletonLock')); // "hostname-12345" - const orphanPid = parseInt(lockTarget.split('-').pop() || '', 10); - if (orphanPid && isProcessAlive(orphanPid)) { - safeKill(orphanPid, 'SIGTERM'); - await new Promise(r => setTimeout(r, 1000)); - if (isProcessAlive(orphanPid)) { - safeKill(orphanPid, 'SIGKILL'); - await new Promise(r => setTimeout(r, 500)); - } - } - } catch (err: any) { - if (err?.code !== 'ENOENT' && err?.code !== 'EINVAL') throw err; + const owner = profileOwner(profileDir); + if (owner.state === 'free') return; + if (owner.state === 'live') { + throw new Error(`Headed Chromium profile ${profileDir} is ${owner.detail}. Close that browser first, or set CHROMIUM_PROFILE to a different directory.`); + } + safeKill(owner.pid, 'SIGTERM'); + await new Promise(r => setTimeout(r, 1000)); + if (isProcessAlive(owner.pid)) { + safeKill(owner.pid, 'SIGKILL'); + await new Promise(r => setTimeout(r, 500)); } } @@ -621,6 +620,7 @@ async function startServer(extraEnv?: Record): Promise | newtab [url] | closetab [id] Server: status | cookie = | header : useragent | stop | restart + profiles [list] | profiles prune [--days N] (per-project headed profiles) tunnel revoke | tunnel agents (paired-agent tokens) --force-restart: replace a live-but-busy daemon (any command; LOSES tabs/cookies/logins — never done automatically) @@ -1693,6 +1694,8 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: const command = args[0]; const commandArgs = args.slice(1); + if (command === 'profiles') process.exit(runProfilesCommand(commandArgs)); + // ─── Headed Connect (pre-server command) ──────────────────── // connect must be handled BEFORE ensureServer() because it needs // to restart the server in headed mode with the Chrome extension. diff --git a/browse/src/config.ts b/browse/src/config.ts index a9080e89e..89cd21418 100644 --- a/browse/src/config.ts +++ b/browse/src/config.ts @@ -250,19 +250,21 @@ export function isPairAgentEnabled(): boolean { } /** - * Resolve the Chromium profile directory. + * Resolve the headed Chromium profile directory. * * Resolution order: * 1. `explicit` arg (no production caller passes one today; kept for * direct programmatic use) * 2. CHROMIUM_PROFILE env (used by gbrowser's gbd per-workspace) - * 3. /chromium-profile (default) + * 3. /chromium-profile (default; per project since D5, + * #2492 — a machine-wide profile let one project's headed launch kill + * another project's live browser) */ export function resolveChromiumProfile(explicit?: string): string { if (explicit && explicit.length > 0) return explicit; const env = process.env.CHROMIUM_PROFILE; if (env && env.length > 0) return env; - return path.join(resolveGstackHome(), 'chromium-profile'); + return path.join(resolveConfig().stateDir, 'chromium-profile'); } /** diff --git a/browse/test/chromium-profile-isolation.test.ts b/browse/test/chromium-profile-isolation.test.ts index d35eda405..57fdcc036 100644 --- a/browse/test/chromium-profile-isolation.test.ts +++ b/browse/test/chromium-profile-isolation.test.ts @@ -52,7 +52,9 @@ describe('Chromium profile isolation cleanup (#2908)', () => { describe.skipIf(process.platform === 'win32')('Chromium profile isolation (#2817)', () => { let scratch: string; let profile: string; + let projectProfile: string; let stateFile: string; + let orphans: number[]; let env: Record; let children: ReturnType[]; let daemonPid: number | undefined; @@ -61,6 +63,7 @@ describe.skipIf(process.platform === 'win32')('Chromium profile isolation (#2817 scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-profile-isolation-')); profile = path.join(scratch, '.gstack', 'chromium-profile'); stateFile = path.join(scratch, 'project-b', '.gstack', 'browse.json'); + projectProfile = path.join(scratch, 'project-b', '.gstack', 'chromium-profile'); fs.mkdirSync(profile, { recursive: true }); fs.mkdirSync(path.dirname(stateFile), { recursive: true }); env = {}; @@ -83,6 +86,7 @@ describe.skipIf(process.platform === 'win32')('Chromium profile isolation (#2817 BROWSE_START_TIMEOUT: '30000', }); children = []; + orphans = []; daemonPid = undefined; }); @@ -92,14 +96,16 @@ describe.skipIf(process.platform === 'win32')('Chromium profile isolation (#2817 } if (daemonPid) killDaemonGroup(daemonPid); for (const child of children) child.kill('SIGKILL'); + for (const pid of orphans) { try { process.kill(pid, 'SIGKILL'); } catch {} } await Promise.all(children.map(child => child.exited)); fs.rmSync(scratch, { recursive: true, force: true }); }); - async function spawnChromiumHolder() { + /** A stand-in Chromium: its command line names Chromium and the profile it uses. */ + async function spawnChromiumHolder(userDataDir = profile) { const script = path.join(scratch, `chromium-holder-${children.length}.ts`); fs.writeFileSync(script, 'console.log("ready"); await Bun.sleep(60000);'); - const child = Bun.spawn([process.execPath, script], { + const child = Bun.spawn([process.execPath, script, `--user-data-dir=${userDataDir}`], { env, stdin: 'ignore', stdout: 'pipe', @@ -113,17 +119,66 @@ describe.skipIf(process.platform === 'win32')('Chromium profile isolation (#2817 return child; } - function seedLocks(pid: number) { - fs.symlinkSync(`${os.hostname()}-${pid}`, path.join(profile, LOCKS[0])); - fs.symlinkSync('socket-target', path.join(profile, LOCKS[1])); - fs.symlinkSync('cookie-target', path.join(profile, LOCKS[2])); + /** A stand-in Chromium whose daemon is gone: reparented to init. */ + async function spawnOrphanedChromium(userDataDir: string) { + const script = path.join(scratch, `chromium-orphan-${orphans.length}.ts`); + const ready = `${script}.ready`; + fs.writeFileSync(script, `require('node:fs').writeFileSync(${JSON.stringify(ready)}, 'ready'); await Bun.sleep(60000);`); + const launcher = Bun.spawnSync(['sh', '-c', `"$0" "$1" "$2" >/dev/null 2>&1 & echo $!`, process.execPath, script, `--user-data-dir=${userDataDir}`], { env, stdout: 'pipe', timeout: 10_000 }); + const pid = Number(launcher.stdout.toString().trim()); + orphans.push(pid); + const deadline = Date.now() + 10_000; + while (!fs.existsSync(ready) && Date.now() < deadline) await Bun.sleep(20); + expect(fs.existsSync(ready)).toBe(true); + return { pid }; } - function expectLocksIntact(pid: number) { + function seedLocks(pid: number, dir = profile, host = os.hostname()) { + fs.mkdirSync(dir, { recursive: true }); + fs.symlinkSync(`${host}-${pid}`, path.join(dir, LOCKS[0])); + fs.symlinkSync('socket-target', path.join(dir, LOCKS[1])); + fs.symlinkSync('cookie-target', path.join(dir, LOCKS[2])); + } + + function expectLocksIntact(pid: number, dir = profile, host = os.hostname()) { expect(isProcessAlive(pid)).toBe(true); - expect(fs.readlinkSync(path.join(profile, LOCKS[0]))).toBe(`${os.hostname()}-${pid}`); - expect(fs.readlinkSync(path.join(profile, LOCKS[1]))).toBe('socket-target'); - expect(fs.readlinkSync(path.join(profile, LOCKS[2]))).toBe('cookie-target'); + expect(fs.readlinkSync(path.join(dir, LOCKS[0]))).toBe(`${host}-${pid}`); + expect(fs.readlinkSync(path.join(dir, LOCKS[1]))).toBe('socket-target'); + expect(fs.readlinkSync(path.join(dir, LOCKS[2]))).toBe('cookie-target'); + } + + function writeStubServer() { + const serverScript = path.join(scratch, 'stub-server.ts'); + fs.writeFileSync(serverScript, ` + import * as fs from 'node:fs'; + const server = Bun.serve({ + hostname: '127.0.0.1', port: 0, + fetch: () => Response.json({ status: 'healthy' }), + }); + fs.writeFileSync(process.env.BROWSE_STATE_FILE!, JSON.stringify({ + pid: process.pid, port: server.port, token: 'test-token', + mode: process.env.BROWSE_HEADED === '1' ? 'headed' : 'launched', + })); + `); + env.BROWSE_SERVER_SCRIPT = serverScript; + } + + async function runCliRaw(args: string[]) { + const child = Bun.spawn([process.execPath, CLI, ...args], { + cwd: path.join(scratch, 'project-b'), + env, + stdin: 'ignore', + stdout: 'pipe', + stderr: 'pipe', + }); + children.push(child); + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + if (fs.existsSync(stateFile)) daemonPid = JSON.parse(fs.readFileSync(stateFile, 'utf-8')).pid; + return { code, stdout, stderr }; } async function runCli(args: string[]) { @@ -176,34 +231,108 @@ describe.skipIf(process.platform === 'win32')('Chromium profile isolation (#2817 }, 60_000); for (const mode of ['flag', 'environment'] as const) { - test(`headed startup via ${mode} still reaps a profile orphan and removes stale locks`, async () => { - const orphan = await spawnChromiumHolder(); - seedLocks(orphan.pid); + test(`headed startup via ${mode} reaps an orphaned Chromium on this project's profile and removes stale locks`, async () => { + const orphan = await spawnOrphanedChromium(projectProfile); + seedLocks(orphan.pid, projectProfile); env.BROWSE_HEADED = mode === 'environment' ? '1' : '0'; - const serverScript = path.join(scratch, 'stub-server.ts'); - fs.writeFileSync(serverScript, ` - import * as fs from 'node:fs'; - const server = Bun.serve({ - hostname: '127.0.0.1', port: 0, - fetch: () => Response.json({ status: 'healthy' }), - }); - fs.writeFileSync(process.env.BROWSE_STATE_FILE!, JSON.stringify({ - pid: process.pid, port: server.port, token: 'test-token', - mode: process.env.BROWSE_HEADED === '1' ? 'headed' : 'launched', - })); - `); - env.BROWSE_SERVER_SCRIPT = serverScript; + writeStubServer(); await runCli(mode === 'flag' ? ['--headed', 'status'] : ['status']); expect(JSON.parse(fs.readFileSync(stateFile, 'utf-8')).mode).toBe('headed'); expect(isProcessAlive(orphan.pid)).toBe(false); - expect(fs.readdirSync(profile)).not.toContain(LOCKS[0]); - expect(fs.readdirSync(profile)).not.toContain(LOCKS[1]); - expect(fs.readdirSync(profile)).not.toContain(LOCKS[2]); + for (const lock of LOCKS) expect(fs.readdirSync(projectProfile)).not.toContain(lock); }, 60_000); } + test('headed startup in one project leaves another project\'s live headed Chromium alone (D5, #2492)', async () => { + const otherProject = path.join(scratch, 'project-a', '.gstack', 'chromium-profile'); + const holder = await spawnChromiumHolder(otherProject); + seedLocks(holder.pid, otherProject); + writeStubServer(); + + await runCli(['--headed', 'status']); + + expectLocksIntact(holder.pid, otherProject); + expect(fs.existsSync(projectProfile)).toBe(true); + }, 60_000); + + test('a live owner of this project\'s profile is never killed; browse names it and stops', async () => { + const holder = await spawnChromiumHolder(projectProfile); + seedLocks(holder.pid, projectProfile); + writeStubServer(); + + const r = await runCliRaw(['--headed', 'status']); + + expect(r.code).not.toBe(0); + expect(r.stdout + r.stderr).toContain(`in use by Chromium PID ${holder.pid}`); + expectLocksIntact(holder.pid, projectProfile); + }, 60_000); + + test('a lock written by another host is never acted on', async () => { + const holder = await spawnOrphanedChromium(projectProfile); + seedLocks(holder.pid, projectProfile, 'some-other-host'); + writeStubServer(); + + const r = await runCliRaw(['--headed', 'status']); + + expect(r.code).not.toBe(0); + expect(r.stdout + r.stderr).toContain('on another host (some-other-host)'); + expectLocksIntact(holder.pid, projectProfile, 'some-other-host'); + }, 60_000); + + test('first headed use seeds the project profile from a quiescent shared profile, without singleton files', async () => { + fs.mkdirSync(path.join(profile, 'Default'), { recursive: true }); + fs.writeFileSync(path.join(profile, 'Default', 'Cookies'), 'cookie-db'); + seedLocks(999_999_999); + writeStubServer(); + + const r = await runCliRaw(['--headed', 'status']); + + expect(r.code, r.stdout + r.stderr).toBe(0); + expect(r.stdout).toContain(`Copied your logins and settings from ${profile} to ${projectProfile}`); + expect(fs.readFileSync(path.join(projectProfile, 'Default', 'Cookies'), 'utf-8')).toBe('cookie-db'); + expect(fs.statSync(projectProfile).mode & 0o777).toBe(0o700); + expect(fs.readdirSync(projectProfile).filter(f => f.startsWith('Singleton') || f.includes('.seed-'))).toEqual([]); + expect(fs.readdirSync(path.dirname(projectProfile)).filter(f => f.includes('.seed-'))).toEqual([]); + }, 60_000); + + test('a shared profile in use is not copied; the project starts fresh and says how to import later', async () => { + fs.writeFileSync(path.join(profile, 'Cookies'), 'cookie-db'); + const holder = await spawnChromiumHolder(profile); + seedLocks(holder.pid); + writeStubServer(); + + const r = await runCliRaw(['--headed', 'status']); + + expect(r.code, r.stdout + r.stderr).toBe(0); + expect(r.stdout).toContain('starts with a fresh profile. To import it later'); + expect(fs.existsSync(path.join(projectProfile, 'Cookies'))).toBe(false); + expectLocksIntact(holder.pid); + }, 60_000); + + test('browse profiles lists per-project profiles and prune removes idle ones but never one in use', async () => { + writeStubServer(); + await runCli(['--headed', 'status']); + const listed = await runCliRaw(['profiles']); + expect(listed.code).toBe(0); + expect(listed.stdout).toContain(projectProfile); + expect(listed.stdout).toContain('(old shared profile)'); + + const holder = await spawnChromiumHolder(projectProfile); + seedLocks(holder.pid, projectProfile); + await runCliRaw(['profiles', 'prune', '--days', '0']); + expect(fs.existsSync(projectProfile)).toBe(true); + + holder.kill('SIGKILL'); + await holder.exited; + const pruned = await runCliRaw(['profiles', 'prune', '--days', '0']); + expect(pruned.code).toBe(0); + expect(pruned.stdout).toContain(`removed ${projectProfile}`); + expect(fs.existsSync(projectProfile)).toBe(false); + expect(fs.existsSync(profile)).toBe(true); + }, 60_000); + for (const args of [['stop'], ['--force-restart', 'stop'], ['disconnect']]) { test(`headless ${args.join(' ')} preserves another project's headed profile`, async () => { await runCli(['status']); @@ -228,6 +357,7 @@ describe.skipIf(process.platform === 'win32')('Chromium profile isolation (#2817 test(`${mode} factory shutdown scopes profile cleanup to the active browser mode`, async () => { const holder = await spawnChromiumHolder(); seedLocks(holder.pid); + seedLocks(999_999_999, projectProfile); const child = Bun.spawn([process.execPath, '-e', ` import { buildFetchHandler } from ${JSON.stringify(path.resolve(import.meta.dir, '../src/server.ts'))}; import { resolveConfig } from ${JSON.stringify(path.resolve(import.meta.dir, '../src/config.ts'))}; @@ -249,10 +379,13 @@ describe.skipIf(process.platform === 'win32')('Chromium profile isolation (#2817 const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]); expect(code, stderr).toBe(0); + // Headed shutdown cleans only this project's profile; another + // project's (here the old shared one) is never touched (D5). + expectLocksIntact(holder.pid); if (mode === 'headed') { - for (const lock of LOCKS) expect(fs.readdirSync(profile)).not.toContain(lock); + for (const lock of LOCKS) expect(fs.readdirSync(projectProfile)).not.toContain(lock); } else { - expectLocksIntact(holder.pid); + expect(fs.readdirSync(projectProfile)).toContain(LOCKS[0]); } }, 60_000); } diff --git a/browse/test/config.test.ts b/browse/test/config.test.ts index c20bad8f2..53a2081a6 100644 --- a/browse/test/config.test.ts +++ b/browse/test/config.test.ts @@ -431,17 +431,21 @@ describe('resolveChromiumProfile', () => { } }); - test('falls back to resolveGstackHome()/chromium-profile when nothing set', () => { + test('falls back to the project state dir, not the machine-wide gstack home (D5, #2492)', () => { const origEnv = process.env.CHROMIUM_PROFILE; const origHome = process.env.GSTACK_HOME; + const origState = process.env.BROWSE_STATE_FILE; delete process.env.CHROMIUM_PROFILE; process.env.GSTACK_HOME = '/tmp/fallback-gstack'; + process.env.BROWSE_STATE_FILE = '/tmp/project-a/.gstack/browse.json'; try { - expect(resolveChromiumProfile()).toBe('/tmp/fallback-gstack/chromium-profile'); + expect(resolveChromiumProfile()).toBe(path.join('/tmp/project-a/.gstack', 'chromium-profile')); } finally { if (origEnv !== undefined) process.env.CHROMIUM_PROFILE = origEnv; if (origHome === undefined) delete process.env.GSTACK_HOME; else process.env.GSTACK_HOME = origHome; + if (origState === undefined) delete process.env.BROWSE_STATE_FILE; + else process.env.BROWSE_STATE_FILE = origState; } });