config.ts
318 lines · browse/src/config.ts at 3f79d81
1/**
2 * Shared config for browse CLI + server.
3 *
4 * Resolution:
5 * 1. BROWSE_STATE_FILE env → derive stateDir from parent
6 * 2. git rev-parse --show-toplevel → projectDir/.gstack/
7 * 3. process.cwd() fallback (non-git environments)
8 *
9 * The CLI computes the config and passes BROWSE_STATE_FILE to the
10 * spawned server. The server derives all paths from that env var.
11 */
13import * as fs from 'fs';
14import * as path from 'path';
15import { mkdirSecure } from './file-permissions';
16import { safeUnlinkQuiet } from './error-handling';
17import { readConfigKey, resolveStateRoot } from '../../lib/state-root';
18import { remoteSlug } from '../../lib/remote-identity';
20export interface BrowseConfig {
21 projectDir: string;
22 stateDir: string;
23 stateFile: string;
24 consoleLog: string;
25 networkLog: string;
26 dialogLog: string;
27 auditLog: string;
28}
30/**
31 * Detect the git repository root, or null if not in a repo / git unavailable.
32 */
33export function getGitRoot(): string | null {
34 try {
35 const proc = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], {
36 windowsHide: true,
37 stdout: 'pipe',
38 stderr: 'pipe',
39 // Raised from 2s: under heavy machine load `git rev-parse` routinely
40 // takes >2s (measured 6.3s spikes). Timing out here returns null →
41 // resolveConfig falls back to process.cwd() → state files scatter across
42 // cwds (split-brain daemons; `goto` and `url` hit different servers). 8s
43 // still bounds a genuinely broken .git from hanging the CLI forever.
44 timeout: 8_000,
45 });
46 if (proc.exitCode !== 0) return null;
47 return proc.stdout.toString().trim() || null;
48 } catch {
49 return null;
50 }
51}
53/**
54 * Resolve all browse config paths.
55 *
56 * If BROWSE_STATE_FILE is set (e.g. by CLI when spawning server, or by
57 * tests for isolation), all paths are derived from it. Otherwise, the
58 * project root is detected via git or cwd.
59 */
60export function resolveConfig(
61 env: Record<string, string | undefined> = process.env,
62): BrowseConfig {
63 let stateFile: string;
64 let stateDir: string;
65 let projectDir: string;
67 if (env.BROWSE_STATE_FILE) {
68 stateFile = env.BROWSE_STATE_FILE;
69 stateDir = path.dirname(stateFile);
70 projectDir = path.dirname(stateDir); // parent of .gstack/
71 } else {
72 projectDir = getGitRoot() || process.cwd();
73 stateDir = path.join(projectDir, '.gstack');
74 stateFile = path.join(stateDir, 'browse.json');
75 }
77 return {
78 projectDir,
79 stateDir,
80 stateFile,
81 consoleLog: path.join(stateDir, 'browse-console.log'),
82 networkLog: path.join(stateDir, 'browse-network.log'),
83 dialogLog: path.join(stateDir, 'browse-dialog.log'),
84 auditLog: path.join(stateDir, 'browse-audit.jsonl'),
85 };
86}
88function isIgnoredByGit(projectDir: string, relPath: string): boolean {
89 try {
90 const proc = Bun.spawnSync(['git', 'check-ignore', '-q', '--', relPath], {
91 windowsHide: true,
92 cwd: projectDir, stdout: 'pipe', stderr: 'pipe',
93 timeout: 2_000,
94 });
95 return proc.exitCode === 0;
96 } catch {
97 // git not found, timed out, or not a repo (exit 128). Fall through to
98 // the text-check path — appending is the safe default when unsure.
99 return false;
100 }
101}
103function gitExcludePath(projectDir: string): string | null {
104 try {
105 const proc = Bun.spawnSync(['git', 'rev-parse', '--git-path', 'info/exclude'], {
106 windowsHide: true,
107 cwd: projectDir, stdout: 'pipe', stderr: 'pipe',
108 timeout: 2_000,
109 });
110 if (proc.exitCode !== 0) return null;
111 const rel = proc.stdout.toString().trim();
112 return rel ? path.resolve(projectDir, rel) : null;
113 } catch {
114 return null;
115 }
116}
118/**
119 * Create the .gstack/ state directory if it doesn't exist.
120 * Throws with a clear message on permission errors.
121 */
122export function ensureStateDir(config: BrowseConfig): void {
123 try {
124 mkdirSecure(config.stateDir);
125 } catch (err: any) {
126 if (err.code === 'EACCES') {
127 throw new Error(`Cannot create state directory ${config.stateDir}: permission denied`);
128 }
129 if (err.code === 'ENOTDIR') {
130 throw new Error(`Cannot create state directory ${config.stateDir}: a file exists at that path`);
131 }
132 throw err;
133 }
135 // Load-bearing guard: a self-contained ignore INSIDE the state dir so its
136 // contents can NEVER be `git add`-ed, regardless of the project's own
137 // ignore rules. The state dir holds session-state.json (live cookies +
138 // localStorage/sessionStorage tokens) and browse-network.log /
139 // browse-audit.jsonl (captured request headers can carry bearer tokens).
140 // Written unconditionally, synchronously, before return.
141 try {
142 fs.writeFileSync(path.join(config.stateDir, '.gitignore'), '*\n');
143 } catch {
144 // Best-effort; the repository's info/exclude below is the fallback.
145 }
147 // Ensure git reads .gstack/ as ignored through the repository's own
148 // info/exclude, never the project's .gitignore: that file is tracked, and
149 // editing it mid-/ship changes the tree after pregate (PREGATE_STALE, #3560).
150 if (isIgnoredByGit(config.projectDir, '.gstack/')) return;
151 const excludePath = gitExcludePath(config.projectDir);
152 if (!excludePath) return;
153 try {
154 const content = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, 'utf-8') : '';
155 if (!content.match(/^\.gstack\/?$/m)) {
156 const separator = content === '' || content.endsWith('\n') ? '' : '\n';
157 fs.mkdirSync(path.dirname(excludePath), { recursive: true });
158 fs.appendFileSync(excludePath, `${separator}.gstack/\n`);
159 }
160 } catch (err: any) {
161 const logPath = path.join(config.stateDir, 'browse-server.log');
162 try {
163 fs.appendFileSync(logPath, `[${new Date().toISOString()}] Warning: could not update ${excludePath}: ${err.message}\n`);
164 } catch {
165 // stateDir write failed too — nothing more we can do
166 }
167 }
168}
170/**
171 * Derive a slug from the git remote origin URL via the shared rule in
172 * lib/remote-identity.ts (owner-repo; "<last-two>-<16 hex>" for 3+-segment
173 * nested-group remotes, #3003), so browse files state where gstack-slug does.
174 * Falls back to the directory basename if no remote is configured.
175 */
176export function getRemoteSlug(): string {
177 try {
178 const proc = Bun.spawnSync(['git', 'remote', 'get-url', 'origin'], {
179 windowsHide: true,
180 stdout: 'pipe',
181 stderr: 'pipe',
182 timeout: 2_000,
183 });
184 if (proc.exitCode !== 0) throw new Error('no remote');
185 const slug = remoteSlug(proc.stdout.toString().trim());
186 if (slug) return slug;
187 throw new Error('unparseable');
188 } catch {
189 const root = getGitRoot();
190 return path.basename(root || process.cwd());
191 }
192}
194/**
195 * Read the binary version (git SHA) from browse/dist/.version.
196 * Returns null if the file doesn't exist or can't be read.
197 */
198export function readVersionHash(execPath: string = process.execPath): string | null {
199 try {
200 const versionFile = path.resolve(path.dirname(execPath), '.version');
201 return fs.readFileSync(versionFile, 'utf-8').trim() || null;
202 } catch {
203 return null;
204 }
205}
207/** The gstack state root: delegates to the shared chain in lib/state-root.ts. */
208export function resolveGstackHome(): string {
209 return resolveStateRoot();
210}
212/**
213 * Read one key from the flat-YAML config store via readConfigKey (the same
214 * reader bin/gstack-config uses, including the most-restrictive merge for
215 * privacy keys such as telemetry). Tolerates optional single/double quotes
216 * around the value and a trailing `# comment`. Returns the unquoted value
217 * string, or null when the file is missing or unreadable or the key is absent.
218 *
219 * Single source of truth for flat-YAML key reads — isPairAgentEnabled
220 * (pair_agent) and telemetry.ts (telemetry tier) both route through it so
221 * the two consent gates can never drift on parsing semantics.
222 */
223export function readGstackConfigYamlKey(key: string): string | null {
224 const raw = readConfigKey(key);
225 if (raw === null) return null;
226 return raw.replace(/\s*#.*$/, '').trim().replace(/^(['"])(.*)\1$/, '$2');
227}
229/**
230 * Is the remote pair-agent (ngrok tunnel) surface opt-in enabled?
231 *
232 * Fail-closed: the tunnel exposes the local browser to the internet, so it
233 * stays OFF unless the user explicitly ran `gstack-config set pair_agent on`
234 * (the /pair-agent skill asks once on first use and sets it). Any read/parse
235 * failure (missing config, malformed JSON) also resolves OFF. The tunnel
236 * egress receipts cite this gate as their consent — it must exist and gate
237 * every activation point (#B6, fork port wave 2).
238 *
239 * Env override `GSTACK_PAIR_AGENT=on|off` wins (used by tests and as an
240 * emergency knob), mirroring the telemetry env-hint convention.
241 */
242export function isPairAgentEnabled(): boolean {
243 const env = process.env.GSTACK_PAIR_AGENT;
244 if (env === 'on') return true;
245 if (env === 'off') return false;
246 // Canonical store: ~/.gstack/config.yaml (flat `key: value` lines, written
247 // by bin/gstack-config — which is what the /pair-agent consent step runs).
248 // The fork read config.json; porting that verbatim would have made the gate
249 // silently un-enableable on main. JSON kept as a fallback shape only.
250 // Anything other than exactly on/off (missing key, malformed value) falls
251 // through to the JSON fallback and ultimately fails closed.
252 const yamlValue = readGstackConfigYamlKey('pair_agent');
253 if (yamlValue === 'on') return true;
254 if (yamlValue === 'off') return false;
255 try {
256 const raw = fs.readFileSync(path.join(resolveGstackHome(), 'config.json'), 'utf-8');
257 return JSON.parse(raw)?.pair_agent === 'on';
258 } catch {
259 return false;
260 }
261}
263/**
264 * Resolve the headed Chromium profile directory.
265 *
266 * Resolution order:
267 * 1. `explicit` arg (no production caller passes one today; kept for
268 * direct programmatic use)
269 * 2. CHROMIUM_PROFILE env (used by gbrowser's gbd per-workspace)
270 * 3. <project state dir>/chromium-profile (default; per project since D5,
271 * #2492 — a machine-wide profile let one project's headed launch kill
272 * another project's live browser)
273 */
274export function resolveChromiumProfile(explicit?: string): string {
275 if (explicit && explicit.length > 0) return explicit;
276 const env = process.env.CHROMIUM_PROFILE;
277 if (env && env.length > 0) return env;
278 return path.join(resolveConfig().stateDir, 'chromium-profile');
279}
281/**
282 * Pre-launch / shutdown cleanup of stale Chromium singleton lockfiles
283 * (SingletonLock, SingletonSocket, SingletonCookie). Chromium's
284 * ProcessSingleton refuses to start when these exist from a prior crash
285 * (SIGKILL, hard crash, etc.) since they point at a PID that no longer exists.
286 *
287 * Defensive guard: refuses to operate unless ALL of these hold:
288 * 1. `userDataDir` is an absolute path (no CWD-relative footguns)
289 * 2. basename is exactly 'chromium-profile' OR the absolute path matches
290 * the absolute form of $CHROMIUM_PROFILE env value
291 *
292 * Prevents accidentally deleting lock files from an unrelated directory if
293 * profile resolution is misconfigured upstream (CWD drift, env injection).
294 *
295 * Caller MUST ensure external coordination has already guaranteed no live
296 * peer is using this profile (gbd.lock for gbrowser; single-instance CLI
297 * check for gstack).
298 */
299export function cleanSingletonLocks(userDataDir: string): void {
300 if (!path.isAbsolute(userDataDir)) {
301 console.warn(`[browse] cleanSingletonLocks: refusing relative path: ${userDataDir}`);
302 return;
303 }
304 const resolved = path.resolve(userDataDir);
305 const basename = path.basename(resolved);
306 const explicitProfile = process.env.CHROMIUM_PROFILE;
307 const explicitAbs = explicitProfile && path.isAbsolute(explicitProfile)
308 ? path.resolve(explicitProfile)
309 : null;
310 const isSafe = basename === 'chromium-profile' || (explicitAbs !== null && resolved === explicitAbs);
311 if (!isSafe) {
312 console.warn(`[browse] cleanSingletonLocks: refusing to clean unrecognized profile dir: ${resolved}`);
313 return;
314 }
315 for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
316 safeUnlinkQuiet(path.join(resolved, lockFile));
317 }
318}