mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-11 15:39:04 +02:00
Merge origin/main (v1.65.0.0 fork port wave 2) into test-evals-ci-speedup
Second overlapping-wave merge; resolutions compose intent: - TEST_ROOTS: ours is the superset (main also wired ios-qa/daemon/test; ours additionally has ios-qa/scripts + browser-skills). package.json 'test' keeps routing through the canonical strict runner. - gbrainAvailable: main fixed the same load-flake with a strictly better mechanism (memoized stat-based PATH scan, no subprocess at all) — theirs supersedes this branch's memoized-exec probe. Main also made the query timeout env-overridable (GSTACK_BRAIN_TIMEOUT_MS). - Model defaults: adopted main's lib/eval-model.ts abstraction (one resolution point, env-overridable per kind) and applied decision D1a inside it: capture defaults to Sonnet (Opus opt-in via explicit arg or GSTACK_EVAL_MODEL_CAPTURE); test pins updated to follow. - Parent watchdog: main's rewrite (named parameterized tick, driven deterministically by its test via __testInternals__, plus handoff suppression semantics from session persistence) supersedes this branch's env-tunable interval; adopted their server + test wholesale. - windows-free-tests: ours (curated bun run test:windows) — main's hand-list grew by one more file, which the curated runner subsumes automatically; that drift is the reason for D11. - context-skills 0-for-26 fix: both waves made the IDENTICAL fix; kept this branch's comment (carries the receipts). - .gitignore: main's superset (also ignores Package.resolved — their never-commit call; untracked the copy this branch had committed). Verified: 239-test merge battery green, watchdog 8/8, eval-model 5/5, actionlint clean, eval:select works. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+132
-39
@@ -73,6 +73,82 @@ export function shouldEnableChromiumSandbox(): boolean {
|
||||
return !(process.env.CI || process.env.CONTAINER || isRoot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by probePoisonedChromiumBundle() when it finds — and removes — a
|
||||
* Chromium bundle poisoned by the pre-v1.64 in-place rebrand (#2242).
|
||||
* Call sites rethrow on `instanceof` (never message-string sniffing) so the
|
||||
* actionable remediation reaches the user instead of being swallowed by the
|
||||
* probe's fall-through-on-failure catch.
|
||||
*/
|
||||
export class PoisonedBundleError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'PoisonedBundleError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-heal probe for bundles the OLD (pre-v1.64) rebrand code already
|
||||
* poisoned (#2242): the mutation lives in the SHARED Playwright cache, so
|
||||
* deleting the rebrand code fixes fresh installs only, and the documented
|
||||
* deploy paths never run upgrade migrations. Detect the mutated plist and
|
||||
* remove the bundle so the next `playwright install chromium` (or the
|
||||
* upgrade migration) re-fetches a clean one.
|
||||
*
|
||||
* Removal scope: when the .app sits in the standard Playwright cache layout
|
||||
* (chromium-<rev>/chrome-mac/<name>.app), the WHOLE chromium-<rev> revision
|
||||
* dir is removed — Playwright's INSTALLATION_COMPLETE marker lives there,
|
||||
* and `playwright install chromium` treats its presence as "is already
|
||||
* downloaded", so removing only the .app would turn our own remediation
|
||||
* command into a no-op that leaves the user with no browser at all. Outside
|
||||
* that layout, the .app plus any sibling INSTALLATION_COMPLETE /
|
||||
* DEPENDENCIES_VALIDATED markers are removed.
|
||||
*
|
||||
* Caller contract: pass ONLY Playwright-cache executables
|
||||
* (chromium.executablePath()). A bundle supplied via GSTACK_CHROMIUM_PATH
|
||||
* belongs to the wrapper/embedder — its plist legitimately says "GStack
|
||||
* Browser" — and must never be deleted. Both call sites (launchHeaded and
|
||||
* handoff) honor this, and as a second belt the probe refuses to act on the
|
||||
* GSTACK_CHROMIUM_PATH executable itself.
|
||||
*
|
||||
* @param chromiumExecutablePath the Chromium binary inside the .app
|
||||
* (…/<name>.app/Contents/MacOS/<name>), as returned by
|
||||
* chromium.executablePath().
|
||||
* @throws PoisonedBundleError after removing a poisoned bundle — the
|
||||
* message carries the re-fetch command for the user.
|
||||
*/
|
||||
export function probePoisonedChromiumBundle(chromiumExecutablePath: string): void {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Belt to the caller contract: never act on the custom/embedder bundle.
|
||||
const customPath = process.env.GSTACK_CHROMIUM_PATH;
|
||||
if (customPath && path.resolve(chromiumExecutablePath) === path.resolve(customPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chromeContentsDir = path.resolve(path.dirname(chromiumExecutablePath), '..');
|
||||
const chromePlist = path.join(chromeContentsDir, 'Info.plist');
|
||||
if (!fs.existsSync(chromePlist)) return;
|
||||
if (!fs.readFileSync(chromePlist, 'utf-8').includes('GStack Browser')) return;
|
||||
|
||||
const appDir = path.resolve(chromeContentsDir, '..');
|
||||
const revisionDir = path.resolve(appDir, '..', '..');
|
||||
if (/^chromium-\d+$/.test(path.basename(revisionDir))) {
|
||||
fs.rmSync(revisionDir, { recursive: true, force: true });
|
||||
} else {
|
||||
fs.rmSync(appDir, { recursive: true, force: true });
|
||||
for (const marker of ['INSTALLATION_COMPLETE', 'DEPENDENCIES_VALIDATED']) {
|
||||
fs.rmSync(path.join(path.dirname(appDir), marker), { force: true });
|
||||
}
|
||||
}
|
||||
throw new PoisonedBundleError(
|
||||
'Chromium bundle was mutated by a previous gstack version (broken codesign seal — ' +
|
||||
'GPU exit_code=5 on macOS 26). The poisoned bundle has been removed. ' +
|
||||
'Re-fetch a clean one with: bunx playwright install chromium — then retry.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve why the underlying Chromium ChildProcess is going away.
|
||||
*
|
||||
@@ -199,6 +275,15 @@ export class BrowserManager {
|
||||
|
||||
// ─── Headed State ────────────────────────────────────────
|
||||
private connectionMode: 'launched' | 'headed' = 'launched';
|
||||
|
||||
/**
|
||||
* Fired when a RUNNING daemon is promoted to headed mode (see handoff()),
|
||||
* as opposed to starting headed. The server uses it to cancel the
|
||||
* parent-process watchdog, which was registered on the assumption that mode
|
||||
* is fixed at boot and would otherwise kill the freshly handed-off browser
|
||||
* the next time the spawning shell exits.
|
||||
*/
|
||||
onHeadedPromotion?: () => void;
|
||||
private intentionalDisconnect = false;
|
||||
|
||||
// ─── Tab Count Guardrail (D5 + Codex single-tab flag) ───────
|
||||
@@ -504,46 +589,32 @@ export class BrowserManager {
|
||||
// Used by GStack Browser.app to point at the bundled Chromium.
|
||||
const executablePath = process.env.GSTACK_CHROMIUM_PATH || undefined;
|
||||
|
||||
// Rebrand Chromium → GStack Browser in macOS menu bar / Dock / Cmd+Tab.
|
||||
// Patch the Chromium .app's Info.plist so macOS shows our name.
|
||||
// This works for both dev mode (system Playwright cache) and .app bundle.
|
||||
const chromePath = executablePath || chromium.executablePath();
|
||||
try {
|
||||
// Walk up from binary to the .app's Info.plist
|
||||
// e.g. .../Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing
|
||||
// → .../Google Chrome for Testing.app/Contents/Info.plist
|
||||
const chromeContentsDir = path.resolve(path.dirname(chromePath), '..');
|
||||
const chromePlist = path.join(chromeContentsDir, 'Info.plist');
|
||||
if (fs.existsSync(chromePlist)) {
|
||||
const plistContent = fs.readFileSync(chromePlist, 'utf-8');
|
||||
if (plistContent.includes('Google Chrome for Testing')) {
|
||||
const patched = plistContent
|
||||
.replace(/Google Chrome for Testing/g, 'GStack Browser');
|
||||
fs.writeFileSync(chromePlist, patched);
|
||||
}
|
||||
// Replace Chromium's Dock icon with ours (Chromium's process owns the Dock icon)
|
||||
const iconCandidates = [
|
||||
path.join(__dirname, '..', '..', 'scripts', 'app', 'icon.icns'), // repo dev mode
|
||||
path.join(process.env.HOME || '', '.claude', 'skills', 'gstack', 'scripts', 'app', 'icon.icns'), // global install
|
||||
];
|
||||
const iconSrc = iconCandidates.find(p => fs.existsSync(p));
|
||||
if (iconSrc) {
|
||||
const chromeResources = path.join(chromeContentsDir, 'Resources');
|
||||
// Read original icon name from plist
|
||||
const iconMatch = plistContent.match(/<key>CFBundleIconFile<\/key>\s*<string>([^<]+)<\/string>/);
|
||||
let origIcon = iconMatch ? iconMatch[1] : 'app';
|
||||
if (!origIcon.endsWith('.icns')) origIcon += '.icns';
|
||||
const destIcon = path.join(chromeResources, origIcon);
|
||||
try {
|
||||
fs.copyFileSync(iconSrc, destIcon);
|
||||
} catch (err: any) {
|
||||
if (err?.code !== 'ENOENT' && err?.code !== 'EACCES') throw err;
|
||||
}
|
||||
}
|
||||
// NOTE (#2242): the in-place "rebrand" that patched the Chromium .app's
|
||||
// Info.plist (global "Google Chrome for Testing" → "GStack Browser"
|
||||
// replace) and overwrote its Resources/*.icns is deliberately GONE.
|
||||
// Chrome for Testing is a code-signed bundle: the global replace renamed
|
||||
// CFBundleExecutable to a binary that doesn't exist and the plist/icon
|
||||
// writes broke the codesign seal — GPU process exit_code=5, headed mode
|
||||
// dead on macOS 26 (#2242, #2138, #2139). Branding belongs in the
|
||||
// GStack Browser.app wrapper (GSTACK_CHROMIUM_PATH), never in a mutation
|
||||
// of the signed bundle. Do not reintroduce writes into the Chromium
|
||||
// bundle here — browse/test/rebrand-signed-bundle.test.ts fails CI if
|
||||
// you do.
|
||||
//
|
||||
// Self-heal for bundles the OLD code already poisoned: probe the
|
||||
// Playwright-cache bundle and remove it when the mutated plist is
|
||||
// present (see probePoisonedChromiumBundle for the removal-scope
|
||||
// rationale). Scoped to the Playwright cache copy — a
|
||||
// GSTACK_CHROMIUM_PATH bundle belongs to the wrapper/embedder and is
|
||||
// never probed.
|
||||
if (!executablePath) {
|
||||
try {
|
||||
probePoisonedChromiumBundle(chromium.executablePath());
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof PoisonedBundleError) throw err;
|
||||
// Probe failures (no bundle yet, EACCES) fall through to launch,
|
||||
// which produces its own actionable error.
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Non-fatal: app name stays as Chrome for Testing (ENOENT/EACCES expected)
|
||||
if (err?.code !== 'ENOENT' && err?.code !== 'EACCES') throw err;
|
||||
}
|
||||
|
||||
// Build custom user agent: report as stock Chrome with the version
|
||||
@@ -1607,6 +1678,20 @@ export class BrowserManager {
|
||||
fs.mkdirSync(userDataDir, { recursive: true });
|
||||
cleanSingletonLocks(userDataDir);
|
||||
|
||||
// Self-heal probe (#2242): handoff always launches the Playwright-cache
|
||||
// bundle (this launchPersistentContext call passes no executablePath),
|
||||
// so a bundle poisoned by the old in-place rebrand would GPU-crash here
|
||||
// exactly like launchHeaded(). Same probe, same contract: a
|
||||
// GSTACK_CHROMIUM_PATH bundle is never passed in. The rethrown typed
|
||||
// error surfaces through the outer catch as the actionable
|
||||
// "Cannot open headed browser" message, headless browser untouched.
|
||||
try {
|
||||
probePoisonedChromiumBundle(chromium.executablePath());
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof PoisonedBundleError) throw err;
|
||||
// Probe failures (no bundle yet, EACCES) fall through to launch.
|
||||
}
|
||||
|
||||
// T1: same automation-tell-stripping defaults as launchHeaded().
|
||||
// The handoff path (headless → headed re-launch) takes the same
|
||||
// anti-detection posture.
|
||||
@@ -1639,6 +1724,14 @@ export class BrowserManager {
|
||||
this.tabSessions.clear();
|
||||
this.connectionMode = 'headed';
|
||||
|
||||
// Promotion, not a headed boot. The server registered a parent-process
|
||||
// watchdog because this daemon started headless, and that watchdog kills
|
||||
// headed daemons when their parent exits — which for a CLI-spawned daemon
|
||||
// is immediately. Without this the handed-off browser dies ~15s later,
|
||||
// taking whatever the user was mid-way through (a login, an MFA prompt)
|
||||
// with it.
|
||||
this.onHeadedPromotion?.();
|
||||
|
||||
// Same Layer C stealth as launch()/launchHeaded(). Must run BEFORE
|
||||
// restoreState() navigates so the init scripts apply to the restored
|
||||
// pages — without this the handed-off browser had cmdline args but no
|
||||
|
||||
@@ -77,7 +77,8 @@ globalThis.Bun = {
|
||||
cwd: options.cwd,
|
||||
// Node defaults windowsHide to false; Bun.spawn hides the console
|
||||
// window. Without this the shim silently inverts the behavior on the
|
||||
// one platform it exists to serve. See the spawn() note below.
|
||||
// one platform it exists to serve — every console child pops a window.
|
||||
// Forwarded (not hardcoded) so an explicit windowsHide:false survives.
|
||||
windowsHide: options.windowsHide !== false,
|
||||
});
|
||||
|
||||
@@ -97,108 +98,17 @@ globalThis.Bun = {
|
||||
cwd: options.cwd,
|
||||
// stdio:'ignore' silences a child's output but does not suppress its
|
||||
// console window on Windows. The terminal-agent respawn (server.ts
|
||||
// watchdog, 60s ticker) therefore popped a visible bun.exe window on
|
||||
// every respawn until this was forwarded.
|
||||
// watchdog, 60s ticker) popped a visible bun.exe window on every
|
||||
// respawn until this was forwarded. Forwarded, not hardcoded, so an
|
||||
// explicit windowsHide:false survives.
|
||||
windowsHide: options.windowsHide !== false,
|
||||
});
|
||||
|
||||
// Drain stdout/stderr eagerly into in-memory buffers. Bun's spawn buffers
|
||||
// these for the consumer; Node's Readables are pull-based, so if the caller
|
||||
// awaits `proc.exited` before reading, anything past the OS pipe buffer
|
||||
// (~16-64 KB) back-pressures the child until it blocks in write() and
|
||||
// `exit` never fires. Eager draining keeps the pipes flowing regardless
|
||||
// of read order; replay below is via fresh Web ReadableStreams.
|
||||
//
|
||||
// Cap the buffer so a runaway child can't OOM the server. 16 MB is
|
||||
// generous: DPAPI outputs are tiny, tasklist is <1 KB, and the
|
||||
// browser-skill consumer has its own 1 MB readCapped. Once the cap is
|
||||
// reached we keep draining the pipe (so the child never blocks) but
|
||||
// discard further bytes. Override via GSTACK_SPAWN_MAX_BUFFER (bytes).
|
||||
const MAX_BUFFER = Math.max(
|
||||
0,
|
||||
parseInt(process.env.GSTACK_SPAWN_MAX_BUFFER || '', 10) || 16 * 1024 * 1024,
|
||||
);
|
||||
const drain = (stream) => {
|
||||
if (!stream) return { done: Promise.resolve(), chunks: [], truncated: false };
|
||||
const state = { chunks: [], bytes: 0, truncated: false };
|
||||
const done = new Promise((resolve) => {
|
||||
stream.on('data', (chunk) => {
|
||||
if (state.bytes >= MAX_BUFFER) { state.truncated = true; return; }
|
||||
if (state.bytes + chunk.length <= MAX_BUFFER) {
|
||||
state.chunks.push(chunk);
|
||||
state.bytes += chunk.length;
|
||||
} else {
|
||||
const remaining = MAX_BUFFER - state.bytes;
|
||||
state.chunks.push(chunk.subarray(0, remaining));
|
||||
state.bytes = MAX_BUFFER;
|
||||
state.truncated = true;
|
||||
}
|
||||
});
|
||||
// Any terminal event resolves: 'end' on normal close, 'error' on a
|
||||
// stream-level error, 'close' as the belt-and-suspenders for spawn
|
||||
// failures where Node fires 'close' but neither 'end' nor 'error'.
|
||||
stream.once('end', resolve);
|
||||
stream.once('error', resolve);
|
||||
stream.once('close', resolve);
|
||||
});
|
||||
return { done, chunks: state.chunks };
|
||||
};
|
||||
const stdoutDrain = drain(proc.stdout);
|
||||
const stderrDrain = drain(proc.stderr);
|
||||
|
||||
// Bun's spawn exposes `proc.exited` as a Promise resolving to the exit
|
||||
// code; several call sites — DPAPI decryption, isBrowserRunning,
|
||||
// browser-skill-commands — `await proc.exited` directly or via
|
||||
// Promise.race with a timeout. Without this, those awaits resolve to
|
||||
// `undefined` immediately and the operation looks like a silent failure.
|
||||
// Resolve only after both pipes have finished draining so consumers that
|
||||
// read stdout AFTER awaiting exit see the full output, not a partial buffer.
|
||||
const exited = new Promise((resolveExited) => {
|
||||
let exitStatus;
|
||||
proc.once('exit', (code, signal) => {
|
||||
// Match Bun: exit code on normal exit; 128 + signal number on signal;
|
||||
// 0 if neither was reported.
|
||||
if (code !== null) exitStatus = code;
|
||||
else if (signal) exitStatus = 128 + (require('os').constants.signals[signal] || 0);
|
||||
else exitStatus = 0;
|
||||
});
|
||||
proc.once('error', () => {
|
||||
if (exitStatus === undefined) exitStatus = 1;
|
||||
});
|
||||
// Wait for either 'exit' (normal child lifecycle) or 'error' (spawn
|
||||
// failure — Node fires error without exit when the binary is missing).
|
||||
// Either path resolves the lifecycle promise; without listening to both
|
||||
// a spawn error hangs `await proc.exited` until the consumer's own
|
||||
// timeout fires.
|
||||
const lifecycle = new Promise((r) => {
|
||||
proc.once('exit', r);
|
||||
proc.once('error', r);
|
||||
});
|
||||
Promise.all([lifecycle, stdoutDrain.done, stderrDrain.done])
|
||||
.then(() => resolveExited(exitStatus !== undefined ? exitStatus : 0));
|
||||
});
|
||||
|
||||
// Replay buffered output as a fresh Web ReadableStream. `start()` awaits
|
||||
// the drain before enqueueing so `new Response(proc.stdout).text()` yields
|
||||
// the complete output regardless of whether the consumer reads before or
|
||||
// after awaiting `proc.exited`. Stream is single-shot (locked after one
|
||||
// read), matching Bun's behavior.
|
||||
const replay = (d) => new ReadableStream({
|
||||
async start(controller) {
|
||||
await d.done;
|
||||
for (const chunk of d.chunks) {
|
||||
controller.enqueue(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
pid: proc.pid,
|
||||
stdout: replay(stdoutDrain),
|
||||
stderr: replay(stderrDrain),
|
||||
stdout: proc.stdout,
|
||||
stderr: proc.stderr,
|
||||
stdin: proc.stdin,
|
||||
exited,
|
||||
unref() { proc.unref(); },
|
||||
kill(signal) { proc.kill(signal); },
|
||||
};
|
||||
|
||||
+61
-63
@@ -14,7 +14,7 @@ import * as path from 'path';
|
||||
import { spawn as nodeSpawn } from 'child_process';
|
||||
import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-handling';
|
||||
import { writeSecureFile, mkdirSecure } from './file-permissions';
|
||||
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
|
||||
import { resolveConfig, ensureStateDir, readVersionHash, isPairAgentEnabled } from './config';
|
||||
import { parseProxyConfig, computeConfigHash, ProxyConfigError } from './proxy-config';
|
||||
import { redactProxyUrl } from './proxy-redact';
|
||||
import { spawnTerminalAgent } from './terminal-agent-control';
|
||||
@@ -168,7 +168,7 @@ async function killServer(pid: number): Promise<void> {
|
||||
try {
|
||||
Bun.spawnSync(
|
||||
['taskkill', '/PID', String(pid), '/T', '/F'],
|
||||
{ stdout: 'pipe', stderr: 'pipe', timeout: 5000 }
|
||||
{ stdout: 'pipe', stderr: 'pipe', timeout: 5000, windowsHide: true }
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (err?.code !== 'ENOENT') throw err;
|
||||
@@ -348,9 +348,9 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
|
||||
const launcherCode =
|
||||
`const{spawn}=require('child_process');` +
|
||||
`spawn(process.execPath,[${JSON.stringify(NODE_SERVER_SCRIPT)}],` +
|
||||
`{detached:true,stdio:['ignore','ignore','ignore'],env:Object.assign({},process.env,` +
|
||||
`{detached:true,windowsHide:true,stdio:['ignore','ignore','ignore'],env:Object.assign({},process.env,` +
|
||||
`${extraEnvStr})}).unref()`;
|
||||
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'] });
|
||||
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true });
|
||||
} else {
|
||||
// macOS/Linux: Bun.spawn().unref() only removes the child from Bun's event
|
||||
// loop — it does NOT call setsid(), so the spawned server stays in the
|
||||
@@ -365,6 +365,7 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
|
||||
// the Windows path's rationale — same root cause, different OS API.
|
||||
nodeSpawn('bun', ['run', SERVER_SCRIPT], {
|
||||
detached: true,
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: parentPid, ...extraEnv },
|
||||
}).unref();
|
||||
@@ -408,31 +409,29 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
|
||||
throw new Error(`Server failed to start within ${MAX_START_WAIT / 1000}s`);
|
||||
}
|
||||
|
||||
function errorCode(err: unknown): string {
|
||||
if (err && typeof err === 'object' && 'code' in err) {
|
||||
const code = (err as { code?: unknown }).code;
|
||||
if (typeof code === 'string' && code.length > 0) return code;
|
||||
export class ServerLockError extends Error {
|
||||
code: string;
|
||||
constructor(code: string, lockPath: string, cause: string) {
|
||||
super(`E_SERVER_LOCK (${code}): cannot acquire ${lockPath} — ${cause}`);
|
||||
this.name = 'ServerLockError';
|
||||
this.code = code;
|
||||
}
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
if (err && typeof err === 'object' && 'message' in err) {
|
||||
const message = (err as { message?: unknown }).message;
|
||||
if (typeof message === 'string' && message.length > 0) return message;
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
|
||||
function logServerLockError(action: string, lockPath: string, err: unknown): void {
|
||||
console.error(`[browse] acquireServerLock: unexpected ${errorCode(err)} while ${action} ${lockPath}: ${errorMessage(err)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire an exclusive lockfile to prevent concurrent ensureServer() races (TOCTOU).
|
||||
* Returns a cleanup function that releases the lock.
|
||||
* Returns a cleanup function that releases the lock, or null when another
|
||||
* LIVE process genuinely holds the lock (real contention).
|
||||
*
|
||||
* Error honesty (#1084): only EEXIST is contention. ENOENT (state dir
|
||||
* missing) self-heals with one mkdir retry; every other errno (EACCES,
|
||||
* ENOSPC, ...) throws ServerLockError with the real errno instead of
|
||||
* reporting phantom "another process holds the lock" contention forever.
|
||||
*/
|
||||
export function acquireServerLock(lockPath: string = `${config.stateFile}.lock`): (() => void) | null {
|
||||
export function acquireServerLock(
|
||||
lockPath: string = `${config.stateFile}.lock`,
|
||||
depth = 0,
|
||||
): (() => void) | null {
|
||||
try {
|
||||
// 'wx' — create exclusively, fails if file already exists (atomic check-and-create)
|
||||
// Using string flag instead of numeric constants for Bun Windows compatibility
|
||||
@@ -440,36 +439,35 @@ export function acquireServerLock(lockPath: string = `${config.stateFile}.lock`)
|
||||
fs.writeSync(fd, `${process.pid}\n`);
|
||||
fs.closeSync(fd);
|
||||
return () => { safeUnlink(lockPath); };
|
||||
} catch (err) {
|
||||
if (errorCode(err) !== 'EEXIST') {
|
||||
logServerLockError('opening', lockPath, err);
|
||||
return null;
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'ENOENT') {
|
||||
// Lock dir missing — create it and retry once.
|
||||
if (depth >= 1) throw new ServerLockError('ENOENT', lockPath, 'lock directory could not be created');
|
||||
mkdirSecure(path.dirname(lockPath));
|
||||
return acquireServerLock(lockPath, depth + 1);
|
||||
}
|
||||
|
||||
// Lock already held — check if the holder is still alive
|
||||
let holderPid: number;
|
||||
if (err?.code !== 'EEXIST') {
|
||||
throw new ServerLockError(err?.code || 'UNKNOWN', lockPath, err?.message || String(err));
|
||||
}
|
||||
// EEXIST — real contention. Check if the holder is still alive.
|
||||
// Depth cap 5 bounds the stale-lock unlink/retry livelock.
|
||||
try {
|
||||
holderPid = parseInt(fs.readFileSync(lockPath, 'utf8').trim(), 10);
|
||||
} catch (readErr) {
|
||||
if (errorCode(readErr) === 'ENOENT') {
|
||||
return acquireServerLock(lockPath);
|
||||
const holderPid = parseInt(fs.readFileSync(lockPath, 'utf8').trim(), 10);
|
||||
if (holderPid && isProcessAlive(holderPid)) {
|
||||
return null; // Another live process holds the lock
|
||||
}
|
||||
logServerLockError('reading holder PID from', lockPath, readErr);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (holderPid && isProcessAlive(holderPid)) {
|
||||
return null; // Another live process holds the lock
|
||||
}
|
||||
|
||||
// Stale lock — remove and retry
|
||||
try {
|
||||
// Stale lock — remove and retry
|
||||
fs.unlinkSync(lockPath);
|
||||
} catch (unlinkErr) {
|
||||
logServerLockError('removing stale', lockPath, unlinkErr);
|
||||
return null;
|
||||
if (depth >= 5) return null;
|
||||
return acquireServerLock(lockPath, depth + 1);
|
||||
} catch (readErr: any) {
|
||||
if (readErr?.code === 'ENOENT') {
|
||||
// Lock vanished between open and read (holder released) — retry.
|
||||
if (depth >= 5) return null;
|
||||
return acquireServerLock(lockPath, depth + 1);
|
||||
}
|
||||
throw new ServerLockError(readErr?.code || 'UNKNOWN', lockPath, readErr?.message || String(readErr));
|
||||
}
|
||||
return acquireServerLock(lockPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,17 +654,7 @@ async function sendCommand(state: ServerState, command: string, args: string[],
|
||||
process.exit(1);
|
||||
}
|
||||
// Connection error — server may have crashed, OR may just be busy.
|
||||
// The compiled CLI runs on Bun, whose fetch reports a refused/dropped
|
||||
// socket as err.code 'ConnectionRefused' / 'ConnectionClosed' (message
|
||||
// "Unable to connect. Is the computer able to access the url?"), NOT Node's
|
||||
// ECONNREFUSED/ECONNRESET. Match both, or daemon crashes leak the raw Bun
|
||||
// error and exit 1 instead of triggering the busy-check/restart below.
|
||||
const isConnError =
|
||||
err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' ||
|
||||
err.code === 'ConnectionRefused' || err.code === 'ConnectionClosed' ||
|
||||
err.message?.includes('fetch failed') ||
|
||||
err.message?.includes('Unable to connect');
|
||||
if (isConnError) {
|
||||
if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.message?.includes('fetch failed')) {
|
||||
const oldState = readState();
|
||||
// #1781 busy-vs-dead: a single-threaded daemon under beacon/extension load
|
||||
// can briefly stop answering HTTP while still alive. Before declaring a
|
||||
@@ -980,8 +968,12 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise<void
|
||||
if (pairData.tunnel_url) {
|
||||
serverUrl = pairData.tunnel_url;
|
||||
} else if (!localHost) {
|
||||
// No tunnel active. Check if ngrok is available and auto-start.
|
||||
const ngrokAvailable = isNgrokAvailable();
|
||||
// No tunnel active. Remote tunneling (pair-agent) is opt-in — never
|
||||
// auto-start it unless the user explicitly enabled it, even if ngrok is
|
||||
// installed and authed. First use goes through the /pair-agent skill's
|
||||
// consent question, which sets the key.
|
||||
const pairEnabled = isPairAgentEnabled();
|
||||
const ngrokAvailable = pairEnabled && isNgrokAvailable();
|
||||
if (ngrokAvailable) {
|
||||
console.log('[browse] ngrok detected. Starting tunnel...');
|
||||
try {
|
||||
@@ -1005,6 +997,14 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise<void
|
||||
console.warn('[browse] Using localhost (same-machine only).\n');
|
||||
serverUrl = pairData.server_url;
|
||||
}
|
||||
} else if (!pairEnabled) {
|
||||
// Consent gate, not a tooling gap: when pair_agent is off, ngrok
|
||||
// setup instructions can never fix it. Name the real remedy, with
|
||||
// the same wording as the /tunnel/start 403 body in server.ts.
|
||||
console.warn('[browse] No tunnel active: pair-agent is off (tunnel exposes this browser beyond the machine).');
|
||||
console.warn('[browse] Instructions will use localhost (same-machine only).');
|
||||
console.warn('[browse] For remote agents: enable once with `gstack-config set pair_agent on` — or run /pair-agent, which asks for consent and sets it.\n');
|
||||
serverUrl = pairData.server_url;
|
||||
} else {
|
||||
console.warn('[browse] No tunnel active and ngrok is not installed/configured.');
|
||||
console.warn('[browse] Instructions will use localhost (same-machine only).');
|
||||
@@ -1207,7 +1207,6 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||
const newPid = spawnTerminalAgent({
|
||||
stateFile: config.stateFile,
|
||||
serverPort: newState.port,
|
||||
ownerPid: newState.pid,
|
||||
cwd: config.projectDir,
|
||||
});
|
||||
if (newPid) {
|
||||
@@ -1300,7 +1299,6 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||
spawnTerminalAgent({
|
||||
stateFile: config.stateFile,
|
||||
serverPort: respawned.port,
|
||||
ownerPid: respawned.pid,
|
||||
cwd: config.projectDir,
|
||||
});
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -187,6 +187,64 @@ export function resolveGstackHome(): string {
|
||||
return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one key from the flat-YAML config store at <gstack home>/config.yaml
|
||||
* (the shape bin/gstack-config writes: `key: value` lines). Tolerates
|
||||
* optional single/double quotes around the value and a trailing `# comment`.
|
||||
* Returns the unquoted value string, or null when the file is missing or
|
||||
* unreadable or the key is absent.
|
||||
*
|
||||
* Single source of truth for flat-YAML key reads — isPairAgentEnabled
|
||||
* (pair_agent) and telemetry.ts (telemetry tier) both route through it so
|
||||
* the two consent gates can never drift on parsing semantics.
|
||||
*/
|
||||
export function readGstackConfigYamlKey(key: string): string | null {
|
||||
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
try {
|
||||
const yaml = fs.readFileSync(path.join(resolveGstackHome(), 'config.yaml'), 'utf-8');
|
||||
// Last match wins: bin/gstack-config's `get` reads duplicates with
|
||||
// `tail -1`, and both surfaces must agree on the same line.
|
||||
const all = [...yaml.matchAll(new RegExp(`^\\s*${escaped}\\s*:\\s*['"]?([^'"#\\n]*?)['"]?\\s*(?:#.*)?$`, 'gm'))];
|
||||
return all.length > 0 ? all[all.length - 1][1] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the remote pair-agent (ngrok tunnel) surface opt-in enabled?
|
||||
*
|
||||
* Fail-closed: the tunnel exposes the local browser to the internet, so it
|
||||
* stays OFF unless the user explicitly ran `gstack-config set pair_agent on`
|
||||
* (the /pair-agent skill asks once on first use and sets it). Any read/parse
|
||||
* failure (missing config, malformed JSON) also resolves OFF. The tunnel
|
||||
* egress receipts cite this gate as their consent — it must exist and gate
|
||||
* every activation point (#B6, fork port wave 2).
|
||||
*
|
||||
* Env override `GSTACK_PAIR_AGENT=on|off` wins (used by tests and as an
|
||||
* emergency knob), mirroring the telemetry env-hint convention.
|
||||
*/
|
||||
export function isPairAgentEnabled(): boolean {
|
||||
const env = process.env.GSTACK_PAIR_AGENT;
|
||||
if (env === 'on') return true;
|
||||
if (env === 'off') return false;
|
||||
// Canonical store: ~/.gstack/config.yaml (flat `key: value` lines, written
|
||||
// by bin/gstack-config — which is what the /pair-agent consent step runs).
|
||||
// The fork read config.json; porting that verbatim would have made the gate
|
||||
// silently un-enableable on main. JSON kept as a fallback shape only.
|
||||
// Anything other than exactly on/off (missing key, malformed value) falls
|
||||
// through to the JSON fallback and ultimately fails closed.
|
||||
const yamlValue = readGstackConfigYamlKey('pair_agent');
|
||||
if (yamlValue === 'on') return true;
|
||||
if (yamlValue === 'off') return false;
|
||||
try {
|
||||
const raw = fs.readFileSync(path.join(resolveGstackHome(), 'config.json'), 'utf-8');
|
||||
return JSON.parse(raw)?.pair_agent === 'on';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Chromium profile directory.
|
||||
*
|
||||
|
||||
@@ -526,6 +526,7 @@ async function dpapiDecrypt(encryptedBytes: Buffer): Promise<Buffer> {
|
||||
].join('; ');
|
||||
|
||||
const proc = Bun.spawn(['powershell', '-NoProfile', '-Command', script], {
|
||||
windowsHide: true,
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
@@ -778,7 +779,7 @@ function isBrowserRunning(browserName: string): Promise<boolean> {
|
||||
const exe = browserName.toLowerCase().includes('edge') ? 'msedge.exe' : 'chrome.exe';
|
||||
return new Promise((resolve) => {
|
||||
const proc = Bun.spawn(['tasklist', '/FI', `IMAGENAME eq ${exe}`, '/NH'], {
|
||||
stdout: 'pipe', stderr: 'pipe',
|
||||
stdout: 'pipe', stderr: 'pipe', windowsHide: true,
|
||||
});
|
||||
proc.exited.then(async () => {
|
||||
const out = await new Response(proc.stdout).text();
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
|
||||
const IS_WINDOWS = process.platform === 'win32';
|
||||
|
||||
// ─── Filesystem ────────────────────────────────────────────────
|
||||
|
||||
/** Remove a file, ignoring ENOENT (already gone). Rethrows other errors. */
|
||||
@@ -34,39 +36,23 @@ export function safeKill(pid: number, signal: NodeJS.Signals | number): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a PID is alive. Pure boolean probe — never throws.
|
||||
*
|
||||
* Signal 0 on every platform. Node and Bun both map `process.kill(pid, 0)` to
|
||||
* an OpenProcess existence check on Windows, so the POSIX idiom is portable
|
||||
* here — no shell-out needed.
|
||||
*
|
||||
* Windows used to shell out to `tasklist /FI "PID eq <pid>"` and string-match
|
||||
* the CSV. That was wrong in two ways, both of which bit in production:
|
||||
*
|
||||
* 1. FALSE NEGATIVES UNDER LOAD. `tasklist` takes ~700-1700ms on an idle
|
||||
* Windows box and far longer under memory pressure. A Bun.spawnSync that
|
||||
* hits its `timeout` still RETURNS, carrying partial stdout — so the
|
||||
* `.includes()` match came back false and a LIVE process was reported
|
||||
* dead. Callers (killAgentByRecord, the terminal-agent watchdog) then
|
||||
* skipped the kill and respawned around the survivor, leaking one
|
||||
* terminal-agent per watchdog tick. The leak was self-reinforcing: every
|
||||
* orphan added memory pressure, which made the next tasklist slower,
|
||||
* which produced the next false negative.
|
||||
* 2. A VISIBLE CONSOLE WINDOW per probe (no windowsHide), so a background
|
||||
* watchdog strobed a terminal into the foreground every 60 seconds.
|
||||
*
|
||||
* Signal 0 is ~74,000x faster (0.004ms vs 270ms, measured), spawns nothing,
|
||||
* and cannot time out.
|
||||
*
|
||||
* EPERM means the process EXISTS but we lack rights to signal it. That is
|
||||
* alive; returning false there would reintroduce failure mode 1.
|
||||
*/
|
||||
/** Check if a PID is alive. Pure boolean probe — returns false for ALL errors. */
|
||||
export function isProcessAlive(pid: number): boolean {
|
||||
if (IS_WINDOWS) {
|
||||
try {
|
||||
const result = Bun.spawnSync(
|
||||
['tasklist', '/FI', `PID eq ${pid}`, '/NH', '/FO', 'CSV'],
|
||||
{ stdout: 'pipe', stderr: 'pipe', timeout: 3000, windowsHide: true }
|
||||
);
|
||||
return result.stdout.toString().includes(`"${pid}"`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
return err?.code === 'EPERM';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ export function restrictFilePermissions(filePath: string): void {
|
||||
execFileSync(
|
||||
'icacls',
|
||||
[filePath, '/inheritance:r', '/grant:r', `${user}:(F)`],
|
||||
{ stdio: 'ignore' },
|
||||
{ stdio: 'ignore', windowsHide: true },
|
||||
);
|
||||
} catch (err) {
|
||||
warnIcaclsFailure(filePath, err);
|
||||
@@ -147,7 +147,7 @@ export function restrictDirectoryPermissions(dirPath: string): void {
|
||||
execFileSync(
|
||||
'icacls',
|
||||
[dirPath, '/inheritance:r', '/grant:r', `${user}:(OI)(CI)(F)`],
|
||||
{ stdio: 'ignore' },
|
||||
{ stdio: 'ignore', windowsHide: true },
|
||||
);
|
||||
} catch (err) {
|
||||
warnIcaclsFailure(dirPath, err);
|
||||
@@ -185,14 +185,54 @@ export function appendSecureFile(
|
||||
if (!existed) restrictFilePermissions(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows only: probe whether the current process can actually list the
|
||||
* directory. `fs.accessSync` doesn't consult NTFS ACLs on Windows, so a
|
||||
* real readdir is the only honest check.
|
||||
*/
|
||||
function canListDir(dirPath: string): boolean {
|
||||
try { fs.readdirSync(dirPath); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows only: repair a broken DACL on a state directory (#1605).
|
||||
*
|
||||
* `icacls /inheritance:r /grant:r <user>:(F)` is a single command, but the
|
||||
* two halves can partially fail: inheritance gets stripped while the user
|
||||
* grant doesn't resolve (localized account names, domain accounts, roaming
|
||||
* profiles). The result is a DACL with no usable ACE — often just a machine
|
||||
* SID — and the client can't read its own state files. `/reset` restores
|
||||
* inherited ACLs from the parent, making the directory functional again.
|
||||
* Functional-but-unhardened beats hardened-but-unusable.
|
||||
*/
|
||||
export function repairBrokenDacl(dirPath: string): void {
|
||||
if (process.platform !== 'win32') return;
|
||||
try {
|
||||
execFileSync('icacls', [dirPath, '/reset', '/T', '/C', '/Q'], { stdio: 'ignore', windowsHide: true });
|
||||
} catch (err) {
|
||||
warnIcaclsFailure(dirPath, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `mkdir -p` with owner-only directory permissions, cross-platform.
|
||||
* Replaces `fs.mkdirSync(path, { recursive: true, mode: 0o700 })` + Windows ACL.
|
||||
* Safe to call on an existing directory — re-applies the ACL idempotently.
|
||||
*
|
||||
* Windows: after applying the restricted ACL, verifies the directory is
|
||||
* still listable by this process and repairs a broken DACL (#1605) if not.
|
||||
*/
|
||||
export function mkdirSecure(dirPath: string): void {
|
||||
fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
|
||||
restrictDirectoryPermissions(dirPath);
|
||||
if (process.platform === 'win32' && !canListDir(dirPath)) {
|
||||
repairBrokenDacl(dirPath);
|
||||
restrictDirectoryPermissions(dirPath);
|
||||
// If re-hardening broke access again, reset once more and leave the
|
||||
// directory with inherited ACLs — the client must be able to read
|
||||
// its own state.
|
||||
if (!canListDir(dirPath)) repairBrokenDacl(dirPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+15
-23
@@ -20,6 +20,7 @@ import * as path from 'path';
|
||||
import { writeSecureFile, mkdirSecure } from './file-permissions';
|
||||
import { TEMP_DIR } from './platform';
|
||||
import { resolveConfig } from './config';
|
||||
import { filterSessionCookies } from './session-persist';
|
||||
import type { Frame } from 'playwright';
|
||||
|
||||
/** Tokenize a pipe segment respecting double-quoted strings. */
|
||||
@@ -421,25 +422,18 @@ export async function handleMetaCommand(
|
||||
}
|
||||
|
||||
case 'stop': {
|
||||
// Defer shutdown so the response flushes before process.exit() (same
|
||||
// reason as 'restart' below). Otherwise the CLI sees a dropped socket;
|
||||
// and now that connection-loss triggers the crash-retry path, that would
|
||||
// resurrect a fresh daemon only to stop it again. Send the 200, then exit.
|
||||
setTimeout(() => { void shutdown(); }, 100);
|
||||
// Return the acknowledgement before closing the listener. Shutting down
|
||||
// inline resets the CLI's fetch, which it reasonably interprets as a
|
||||
// crash and then restarts the daemon it was asked to stop.
|
||||
setTimeout(() => { void shutdown(); }, 25).unref?.();
|
||||
return 'Server stopped';
|
||||
}
|
||||
|
||||
case 'restart': {
|
||||
// Signal that we want a restart — the CLI will detect exit and restart.
|
||||
// Signal that we want a restart — the CLI will detect exit and restart
|
||||
console.log('[browse] Restart requested. Exiting for CLI to restart.');
|
||||
// Defer shutdown one tick so this HTTP response actually flushes before
|
||||
// process.exit(). shutdown() exits inline (server.ts), so the old
|
||||
// `await shutdown(); return 'Restarting...'` never sent a response — the
|
||||
// CLI saw a dropped socket and `browse restart` errored out. The daemon
|
||||
// now exits ~100ms after the CLI gets its 200; the next browse command
|
||||
// lazily cold-starts a fresh one.
|
||||
setTimeout(() => { void shutdown(); }, 100);
|
||||
return 'Restarting... (daemon exiting; next browse command starts a fresh one)';
|
||||
setTimeout(() => { void shutdown(); }, 25).unref?.();
|
||||
return 'Restarting...';
|
||||
}
|
||||
|
||||
// ─── Visual ────────────────────────────────────────
|
||||
@@ -939,15 +933,13 @@ export async function handleMetaCommand(
|
||||
if (!Array.isArray(data.cookies) || !Array.isArray(data.pages)) {
|
||||
throw new Error('Invalid state file: expected cookies and pages arrays');
|
||||
}
|
||||
// Validate and filter cookies — reject malformed or internal-network cookies
|
||||
const validatedCookies = data.cookies.filter((c: any) => {
|
||||
if (typeof c !== 'object' || !c) return false;
|
||||
if (typeof c.name !== 'string' || typeof c.value !== 'string') return false;
|
||||
if (typeof c.domain !== 'string' || !c.domain) return false;
|
||||
const d = c.domain.startsWith('.') ? c.domain.slice(1) : c.domain;
|
||||
if (d === 'localhost' || d.endsWith('.internal') || d === '169.254.169.254') return false;
|
||||
return true;
|
||||
});
|
||||
// Validate and filter cookies via the shared hygiene filter in
|
||||
// session-persist.ts (isInternalCookieDomain): rejects malformed
|
||||
// cookies and internal-network domains — localhost, *.internal,
|
||||
// loopback literals (127.x, ::1), and link-local/cloud-metadata
|
||||
// (169.254.x) — that a tampered state file could use to reach local
|
||||
// services or the metadata endpoint.
|
||||
const validatedCookies = filterSessionCookies(data.cookies);
|
||||
if (validatedCookies.length < data.cookies.length) {
|
||||
console.warn(`[browse] Filtered ${data.cookies.length - validatedCookies.length} invalid cookies from state file`);
|
||||
}
|
||||
|
||||
+187
-38
@@ -35,7 +35,11 @@ import {
|
||||
isRootToken, checkConnectRateLimit, type TokenInfo,
|
||||
} from './token-registry';
|
||||
import { validateTempPath } from './path-security';
|
||||
import { resolveConfig, ensureStateDir, readVersionHash, resolveChromiumProfile, cleanSingletonLocks } from './config';
|
||||
import { resolveConfig, ensureStateDir, readVersionHash, resolveChromiumProfile, cleanSingletonLocks, isPairAgentEnabled } from './config';
|
||||
import {
|
||||
isSessionPersistEnabled, persistSessionState, restoreSessionState,
|
||||
sessionPersistIntervalMs, SESSION_STATE_FILE,
|
||||
} from './session-persist';
|
||||
import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity';
|
||||
import { createSseEndpoint } from './sse-helpers';
|
||||
import { initAuditLog, writeAuditEntry } from './audit';
|
||||
@@ -728,6 +732,12 @@ const idleCheckInterval = setInterval(idleCheckTick, 60_000);
|
||||
// dual-instance fix` describe block for usage.
|
||||
export const __testInternals__ = {
|
||||
idleCheckTick,
|
||||
// Watchdog seams (watchdog.test.ts): drive the 15s poll against an
|
||||
// arbitrary (dead) PID, trigger the handoff-promotion suppression exactly
|
||||
// as onHeadedPromotion does, and reset the latches between tests.
|
||||
parentWatchdogTick,
|
||||
suppressHeadedParentShutdown,
|
||||
resetParentWatchdogState: () => { headedParentShutdownSuppressed = false; parentGone = false; },
|
||||
setTunnelActive: (v: boolean) => { tunnelActive = v; },
|
||||
setLastActivity: (t: number) => { lastActivity = t; },
|
||||
formatExplicitPortUnavailableError,
|
||||
@@ -757,47 +767,83 @@ const BROWSE_PARENT_PID = parseInt(process.env.BROWSE_PARENT_PID || '0', 10);
|
||||
// the closure every 15s. The CLI's connect path sets BROWSE_HEADED=1 + PID=0,
|
||||
// so this branch is the normal path for /open-gstack-browser.
|
||||
const IS_HEADED_WATCHDOG = process.env.BROWSE_HEADED === '1';
|
||||
// Poll interval is env-tunable so the watchdog E2E test can use a ~250ms tick
|
||||
// instead of waiting out the production 15s interval (was a 20s blind sleep).
|
||||
// Floor of 50ms guards against a typo'd 0 busy-looping the server.
|
||||
const WATCHDOG_INTERVAL_MS = (() => {
|
||||
const raw = parseInt(process.env.BROWSE_WATCHDOG_INTERVAL_MS || '', 10);
|
||||
return Number.isFinite(raw) && raw >= 50 ? raw : 15_000;
|
||||
})();
|
||||
if (BROWSE_PARENT_PID > 0 && !IS_HEADED_WATCHDOG) {
|
||||
let parentGone = false;
|
||||
setInterval(() => {
|
||||
try {
|
||||
process.kill(BROWSE_PARENT_PID, 0); // signal 0 = existence check only, no signal sent
|
||||
} catch {
|
||||
// Parent exited. Resolution order:
|
||||
// 1. Active cookie picker (one-time code or session live)? Stay alive
|
||||
// regardless of mode — tearing down the server mid-import leaves the
|
||||
// picker UI with a stale "Failed to fetch" error.
|
||||
// 2. Headed / tunnel mode? Shutdown. The idle timeout doesn't apply in
|
||||
// these modes (see idleCheckInterval above — both early-return), so
|
||||
// ignoring parent death here would leak orphan daemons after
|
||||
// /pair-agent or /open-gstack-browser sessions.
|
||||
// 3. Normal (headless) mode? Stay alive. Claude Code's Bash tool kills
|
||||
// the parent shell between invocations. The idle timeout (30 min)
|
||||
// handles eventual cleanup.
|
||||
if (hasActivePicker()) return;
|
||||
const headed = activeBrowserManager.getConnectionMode() === 'headed';
|
||||
if (headed || tunnelActive) {
|
||||
console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited in ${headed ? 'headed' : 'tunnel'} mode, shutting down`);
|
||||
activeShutdown?.();
|
||||
} else if (!parentGone) {
|
||||
parentGone = true;
|
||||
console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited (server stays alive, idle timeout will clean up)`);
|
||||
}
|
||||
// Runtime promotion to headed (`handoff`) must NOT clear this interval — the
|
||||
// same tick is the tunnel-orphan reaper, and idle timeout is disabled in
|
||||
// tunnel mode, so parent death is the ONLY thing that reaps an
|
||||
// internet-exposed daemon after handoff → resume → /pair-agent. Promotion
|
||||
// sets this suppress flag instead; the tick re-reads it (and tunnelActive)
|
||||
// every pass. See suppressHeadedParentShutdown() below.
|
||||
let headedParentShutdownSuppressed = false;
|
||||
// Latch for the one-time "parent exited, staying alive" log line.
|
||||
let parentGone = false;
|
||||
// Named + parameterized (default: the boot-time env PID) so watchdog.test.ts
|
||||
// can drive the tick deterministically via __testInternals__, mirroring
|
||||
// idleCheckTick above. setInterval invokes it with no args in production.
|
||||
function parentWatchdogTick(parentPid: number = BROWSE_PARENT_PID): void {
|
||||
try {
|
||||
process.kill(parentPid, 0); // signal 0 = existence check only, no signal sent
|
||||
} catch {
|
||||
// Parent exited. Resolution order:
|
||||
// 1. Active cookie picker (one-time code or session live)? Stay alive
|
||||
// regardless of mode — tearing down the server mid-import leaves the
|
||||
// picker UI with a stale "Failed to fetch" error.
|
||||
// 2. Headed (unless suppressed by a runtime promotion) / tunnel mode?
|
||||
// Shutdown. The idle timeout doesn't apply in these modes (see
|
||||
// idleCheckInterval above — both early-return), so ignoring parent
|
||||
// death here would leak orphan daemons after /pair-agent or
|
||||
// /open-gstack-browser sessions.
|
||||
// 3. Normal (headless) mode, or headed-by-promotion? Stay alive. Claude
|
||||
// Code's Bash tool kills the parent shell between invocations, and a
|
||||
// promoted daemon's user owns the window lifecycle. The idle timeout
|
||||
// (30 min) handles eventual cleanup.
|
||||
if (hasActivePicker()) return;
|
||||
const headed = activeBrowserManager.getConnectionMode() === 'headed'
|
||||
&& !headedParentShutdownSuppressed;
|
||||
if (headed || tunnelActive) {
|
||||
console.log(`[browse] Parent process ${parentPid} exited in ${headed ? 'headed' : 'tunnel'} mode, shutting down`);
|
||||
activeShutdown?.();
|
||||
} else if (!parentGone) {
|
||||
parentGone = true;
|
||||
console.log(`[browse] Parent process ${parentPid} exited (server stays alive, idle timeout will clean up)`);
|
||||
}
|
||||
}, WATCHDOG_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
if (BROWSE_PARENT_PID > 0 && !IS_HEADED_WATCHDOG) {
|
||||
setInterval(parentWatchdogTick, 15_000);
|
||||
} else if (IS_HEADED_WATCHDOG) {
|
||||
console.log('[browse] Parent-process watchdog disabled (headed mode)');
|
||||
} else if (BROWSE_PARENT_PID === 0) {
|
||||
console.log('[browse] Parent-process watchdog disabled (BROWSE_PARENT_PID=0)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppress the headed-mode parent-death shutdown after a runtime promotion.
|
||||
*
|
||||
* The watchdog's contract is "headless daemons outlive their parent, headed ones
|
||||
* do not" — reasonable at boot, when mode is fixed by env. `handoff` breaks that
|
||||
* assumption: it swaps in a headed context on a RUNNING daemon
|
||||
* (browser-manager.ts, connectionMode = 'headed') without a restart, so a daemon
|
||||
* that legitimately registered a watchdog is suddenly on the fatal side of the
|
||||
* branch. The parent is typically a short-lived shell — Claude Code's Bash tool
|
||||
* kills one after every invocation — so the next 15s poll shuts the daemon down,
|
||||
* discarding whatever the user was handed off to do, such as a login.
|
||||
*
|
||||
* Once promoted, the user owns the window lifecycle exactly as if the daemon had
|
||||
* been started headed, which is the case the env guards already exempt.
|
||||
*
|
||||
* A flag, NOT clearInterval: the tick doubles as the tunnel-orphan reaper
|
||||
* (its `tunnelActive` branch), and idle timeout is disabled in tunnel mode —
|
||||
* clearing the whole interval here left handoff → resume → /pair-agent with
|
||||
* an internet-exposed daemon nothing could ever reap. After promotion, parent
|
||||
* death no longer kills the daemon for BEING HEADED, but still kills it when
|
||||
* a tunnel is active.
|
||||
*/
|
||||
function suppressHeadedParentShutdown(): void {
|
||||
if (headedParentShutdownSuppressed) return;
|
||||
headedParentShutdownSuppressed = true;
|
||||
console.log('[browse] Parent-death headed shutdown suppressed (promoted to headed at runtime); watchdog stays armed as the tunnel-orphan reaper');
|
||||
}
|
||||
|
||||
// ─── Command Sets (from commands.ts — single source of truth) ───
|
||||
import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands';
|
||||
export { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS };
|
||||
@@ -840,6 +886,11 @@ function emitInspectorEvent(event: any): void {
|
||||
|
||||
// ─── Server ────────────────────────────────────────────────────
|
||||
const browserManager = new BrowserManager();
|
||||
// Declared here rather than beside suppressHeadedParentShutdown: that function
|
||||
// sits with the watchdog it gates, which is above this line, and binding it up
|
||||
// there would touch `browserManager` in its temporal dead zone — aborting
|
||||
// module evaluation and leaving every later const uninitialized.
|
||||
browserManager.onHeadedPromotion = suppressHeadedParentShutdown;
|
||||
// Indirection for embedders. Module-level handlers (idleCheckTick, parent
|
||||
// watchdog, SIGTERM) read activeBrowserManager so that buildFetchHandler can
|
||||
// retarget them at a caller-supplied BrowserManager. Symmetric with the
|
||||
@@ -858,6 +909,11 @@ let activeBrowserManager: BrowserManager = browserManager;
|
||||
// any buildFetchHandler call rebinds onDisconnect onto the cfg instance.
|
||||
browserManager.onDisconnect = (code) => activeShutdown?.(code ?? 2);
|
||||
let isShuttingDown = false;
|
||||
// Session-persist ticker handle. Registered in start() (module scope so the
|
||||
// factory's shutdown() can reach it), cleared by shutdown() BEFORE the final
|
||||
// snapshot — a tick landing during browser teardown would otherwise overwrite
|
||||
// the good final snapshot with a degraded one (zero tabs).
|
||||
let sessionPersistInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
type PortCheckResult =
|
||||
| { available: true }
|
||||
@@ -1699,8 +1755,33 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
||||
clearInterval(flushInterval);
|
||||
clearInterval(idleCheckInterval);
|
||||
if (agentWatchdogInterval) clearInterval(agentWatchdogInterval);
|
||||
// Stop the session-persist ticker BEFORE the final snapshot below —
|
||||
// paired with the isShuttingDown gate inside the tick, this guarantees
|
||||
// no interval snapshot can race the final one during teardown.
|
||||
if (sessionPersistInterval) {
|
||||
clearInterval(sessionPersistInterval);
|
||||
sessionPersistInterval = null;
|
||||
}
|
||||
await flushBuffers();
|
||||
|
||||
// Final session snapshot before the browser goes away (#778). Best
|
||||
// effort with a hard 2s deadline: shutdown must never hang on a wedged
|
||||
// page.evaluate — after the deadline we proceed to browser close and let
|
||||
// the previous interval snapshot stand (atomic writes guarantee it's
|
||||
// intact). The .catch is attached to the persist promise itself so a
|
||||
// late rejection after losing the race can't become an unhandled
|
||||
// rejection.
|
||||
if (isSessionPersistEnabled()) {
|
||||
const finalSnapshot = persistSessionState(cfgBrowserManager, path.join(config.stateDir, SESSION_STATE_FILE))
|
||||
.catch((err: any) => {
|
||||
console.warn(`[browse] SESSION_PERSIST_FAILED at shutdown: ${err?.message ?? err}`);
|
||||
});
|
||||
await Promise.race([
|
||||
finalSnapshot,
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 2_000)),
|
||||
]);
|
||||
}
|
||||
|
||||
await cfgBrowserManager.close();
|
||||
|
||||
cleanSingletonLocks(resolveChromiumProfile());
|
||||
@@ -1730,6 +1811,12 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
||||
// after 30 min of HTTP idle because the dead module-level instance still
|
||||
// reports connectionMode === 'launched'.
|
||||
activeBrowserManager = cfgBrowserManager;
|
||||
// Same reason as above: the watchdog reads activeBrowserManager, so the
|
||||
// instance that can promote itself to headed must be the one that can
|
||||
// suppress the headed parent-death branch. An embedder-supplied manager
|
||||
// otherwise promotes silently and the watchdog keeps shutting down on a
|
||||
// promotion it can no longer see.
|
||||
cfgBrowserManager.onHeadedPromotion = suppressHeadedParentShutdown;
|
||||
|
||||
// Wire the cfg-instance's onDisconnect to run shutdown when the user
|
||||
// closes the headed browser window. CHAIN any caller-provided handler
|
||||
@@ -2442,6 +2529,14 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
||||
status: 403, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (!isPairAgentEnabled()) {
|
||||
// Consent-on-first-use: the /pair-agent skill asks once and sets the
|
||||
// key; a direct API caller gets the same hint instead of a tunnel.
|
||||
return new Response(JSON.stringify({
|
||||
error: 'pair-agent is off (tunnel exposes this browser beyond the machine)',
|
||||
hint: 'enable once with: gstack-config set pair_agent on — or run /pair-agent, which asks for consent and sets it',
|
||||
}), { status: 403, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
if (tunnelActive && tunnelUrl && tunnelServer) {
|
||||
// Verify tunnel is still alive before returning cached URL.
|
||||
// Probe GET /connect (the only unauth-reachable path on the tunnel
|
||||
@@ -2478,7 +2573,7 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
||||
const started = await startTunnel({
|
||||
fetchHandler: makeFetchHandler('tunnel'),
|
||||
authtoken,
|
||||
consent: 'pair_agent=on',
|
||||
consent: 'pair_agent=on (isPairAgentEnabled gate at /tunnel/start)',
|
||||
});
|
||||
if (!started.ok) {
|
||||
return new Response(JSON.stringify({
|
||||
@@ -3096,6 +3191,58 @@ export async function start() {
|
||||
|
||||
browserManager.serverPort = port;
|
||||
|
||||
// ─── Opt-in session persistence (#778 class) ─────────────────
|
||||
// BROWSE_PERSIST_STATE=1: restore cookies/storage/tabs from the last
|
||||
// snapshot, then keep snapshotting on an interval. Launched mode only —
|
||||
// the headed persistent profile owns its own state. The final snapshot at
|
||||
// clean shutdown lives in buildFetchHandler's shutdown().
|
||||
//
|
||||
// Runs AFTER Bun.serve() + the state-file write, in the BACKGROUND:
|
||||
// restore re-creates tabs sequentially with up-to-15s goto timeouts while
|
||||
// the CLI's readiness probe gives up at 8s — one slow/unreachable saved
|
||||
// URL must never make every `$B` command report "Server failed to start".
|
||||
// Fire-and-forget: a restore failure is logged and never affects the
|
||||
// daemon.
|
||||
if (!skipBrowser && isSessionPersistEnabled() && browserManager.getConnectionMode() === 'launched') {
|
||||
const sessionStatePath = path.join(config.stateDir, SESSION_STATE_FILE);
|
||||
restoreSessionState(browserManager, sessionStatePath)
|
||||
.then((restored) => {
|
||||
if (restored) {
|
||||
// Counts come from the deserialized snapshot itself — no extra
|
||||
// saveState() round-trip against pages that may still be loading.
|
||||
console.log(`[browse] Session state restored: ${restored.cookies.length} cookies / ${restored.pages.length} tabs (BROWSE_PERSIST_STATE=1)`);
|
||||
} else {
|
||||
console.log('[browse] Session persistence on; no prior state — fresh session (BROWSE_PERSIST_STATE=1)');
|
||||
}
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.warn(`[browse] SESSION_RESTORE_FAILED: ${err?.message ?? err}`);
|
||||
});
|
||||
let persistWarned = false;
|
||||
// In-flight guard: never start a new snapshot while the previous one is
|
||||
// still pending (a slow page.evaluate would otherwise pile up ticks).
|
||||
let persistInFlight = false;
|
||||
sessionPersistInterval = setInterval(() => {
|
||||
// Shutdown gate (belt; shutdown()'s clearInterval is the suspenders):
|
||||
// a tick that fires during browser teardown snapshots a degraded state
|
||||
// (zero tabs) over the good final snapshot.
|
||||
if (isShuttingDown) return;
|
||||
if (persistInFlight) return; // skip the tick
|
||||
persistInFlight = true;
|
||||
persistSessionState(browserManager, sessionStatePath)
|
||||
.catch((err: any) => {
|
||||
// Warn once — a full disk must not spam the log every 30s, and a
|
||||
// snapshot failure must never kill the daemon (R3).
|
||||
if (!persistWarned) {
|
||||
persistWarned = true;
|
||||
console.warn(`[browse] SESSION_PERSIST_FAILED: ${err?.message ?? err} (further failures suppressed)`);
|
||||
}
|
||||
})
|
||||
.finally(() => { persistInFlight = false; });
|
||||
}, sessionPersistIntervalMs());
|
||||
(sessionPersistInterval as any)?.unref?.();
|
||||
}
|
||||
|
||||
// Navigate to welcome page if in headed mode and still on about:blank
|
||||
if (browserManager.getConnectionMode() === 'headed') {
|
||||
try {
|
||||
@@ -3137,7 +3284,9 @@ export async function start() {
|
||||
// Start ngrok tunnel if BROWSE_TUNNEL=1 is set. Uses the dual-listener
|
||||
// pattern: bind a dedicated tunnel listener on an ephemeral port and
|
||||
// point ngrok.forward() at IT, not the local daemon port.
|
||||
if (process.env.BROWSE_TUNNEL === '1') {
|
||||
if (process.env.BROWSE_TUNNEL === '1' && !isPairAgentEnabled()) {
|
||||
console.error('[browse] BROWSE_TUNNEL=1 ignored: pair-agent is off. Enable once with: gstack-config set pair_agent on');
|
||||
} else if (process.env.BROWSE_TUNNEL === '1') {
|
||||
const authtoken = resolveNgrokAuthtoken();
|
||||
if (!authtoken) {
|
||||
console.error('[browse] BROWSE_TUNNEL=1 but no NGROK_AUTHTOKEN found. Set it via env var or ~/.gstack/ngrok.env');
|
||||
@@ -3149,7 +3298,7 @@ export async function start() {
|
||||
const started = await startTunnel({
|
||||
fetchHandler: handle.fetchTunnel,
|
||||
authtoken,
|
||||
consent: 'pair_agent=on (BROWSE_TUNNEL=1)',
|
||||
consent: 'pair_agent=on (isPairAgentEnabled gate, BROWSE_TUNNEL=1)',
|
||||
});
|
||||
if (!started.ok) {
|
||||
console.error(`[browse] Failed to start tunnel: ${started.error.message}`);
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Opt-in session-state persistence (#778, #2193, #1128, #1129).
|
||||
*
|
||||
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
|
||||
*
|
||||
* With BROWSE_PERSIST_STATE=1, the headless daemon snapshots cookies +
|
||||
* per-tab URL/localStorage/sessionStorage to <stateDir>/session-state.json
|
||||
* on an interval and at clean shutdown, and restores it on the next launch.
|
||||
* Kills the auth-lost-on-restart class: a crash or binary-version
|
||||
* auto-restart no longer silently logs the user out of everything.
|
||||
*
|
||||
* Default OFF: cookies on disk (0600) are a real cost the user must opt
|
||||
* into. Headed mode is excluded — the persistent Chromium profile already
|
||||
* owns that state, and replaying tabs would clobber the user's window.
|
||||
*
|
||||
* Disk shape (version 1): { version, savedAt, cookies, pages[{url,
|
||||
* isActive, storage}] }. loadedHtml and owner are NEVER persisted — same
|
||||
* in-memory-only invariant as `state save|load` (meta-commands.ts): a
|
||||
* tampered file must not smuggle HTML past load-html's checks or forge tab
|
||||
* ownership.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import type { BrowserManager, BrowserState } from './browser-manager';
|
||||
import { writeSecureFile } from './file-permissions';
|
||||
import { safeUnlinkQuiet } from './error-handling';
|
||||
|
||||
/** Rename a corrupt state file to .corrupt (forensic artifact) — best effort. */
|
||||
function quarantineCorrupt(filePath: string): void {
|
||||
try {
|
||||
fs.renameSync(filePath, `${filePath}.corrupt`);
|
||||
} catch {
|
||||
safeUnlinkQuiet(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
export const SESSION_STATE_FILE = 'session-state.json';
|
||||
export const SESSION_STATE_VERSION = 1;
|
||||
|
||||
/** Config gate. Documented in browse/SKILL.md ("Session persistence"). */
|
||||
export function isSessionPersistEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return env.BROWSE_PERSIST_STATE === '1';
|
||||
}
|
||||
|
||||
/** Persist interval (ms). Env override exists for tests. */
|
||||
export function sessionPersistIntervalMs(env: NodeJS.ProcessEnv = process.env): number {
|
||||
const parsed = parseInt(env.BROWSE_PERSIST_INTERVAL_MS || '', 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 30_000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a BrowserState to the on-disk v1 shape. Strips loadedHtml,
|
||||
* loadedHtmlWaitUntil, and owner (in-memory-only invariants).
|
||||
*/
|
||||
export function serializeSessionState(state: BrowserState): string {
|
||||
return JSON.stringify({
|
||||
version: SESSION_STATE_VERSION,
|
||||
savedAt: new Date().toISOString(),
|
||||
cookies: state.cookies,
|
||||
pages: state.pages.map((p) => ({
|
||||
url: p.url,
|
||||
isActive: p.isActive,
|
||||
storage: p.storage,
|
||||
})),
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a cookie domain points at an internal-network target a tampered
|
||||
* state file could use to reach localhost services, *.internal hosts, or
|
||||
* cloud metadata: `localhost`, `*.internal`, IPv4 loopback literals
|
||||
* (127.0.0.0/8), IPv6 loopback (`::1`, `[::1]`), and link-local/metadata
|
||||
* (169.254.0.0/16, which covers 169.254.169.254). Leading-dot domain
|
||||
* variants (`.127.0.0.1`) are normalized before matching. Single source of
|
||||
* truth for the persistence restore path here AND `state load`
|
||||
* (meta-commands.ts).
|
||||
*/
|
||||
export function isInternalCookieDomain(domain: string): boolean {
|
||||
const d = domain.startsWith('.') ? domain.slice(1) : domain;
|
||||
if (d === 'localhost' || d.endsWith('.internal')) return true;
|
||||
if (d === '::1' || d === '[::1]') return true; // IPv6 loopback
|
||||
if (/^127\./.test(d)) return true; // IPv4 loopback block
|
||||
if (/^169\.254\./.test(d)) return true; // link-local incl. cloud metadata
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie hygiene shared with `state load` (meta-commands.ts): drop malformed
|
||||
* cookies and internal-network domains (see isInternalCookieDomain).
|
||||
*/
|
||||
export function filterSessionCookies(cookies: unknown[]): BrowserState['cookies'] {
|
||||
return cookies.filter((c: any) => {
|
||||
if (typeof c !== 'object' || !c) return false;
|
||||
if (typeof c.name !== 'string' || typeof c.value !== 'string') return false;
|
||||
if (typeof c.domain !== 'string' || !c.domain) return false;
|
||||
return !isInternalCookieDomain(c.domain);
|
||||
}) as BrowserState['cookies'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse + validate the on-disk shape into a BrowserState. Returns null for
|
||||
* anything malformed (corrupt JSON, wrong version, missing arrays).
|
||||
* loadedHtml/owner are stripped unconditionally even if present on disk.
|
||||
*/
|
||||
export function deserializeSessionState(raw: string): BrowserState | null {
|
||||
let data: any;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!data || data.version !== SESSION_STATE_VERSION) return null;
|
||||
if (!Array.isArray(data.cookies) || !Array.isArray(data.pages)) return null;
|
||||
return {
|
||||
cookies: filterSessionCookies(data.cookies),
|
||||
pages: data.pages.map((p: any) => ({
|
||||
url: typeof p?.url === 'string' ? p.url : '',
|
||||
isActive: Boolean(p?.isActive),
|
||||
storage: p?.storage && typeof p.storage === 'object'
|
||||
? {
|
||||
localStorage: typeof p.storage.localStorage === 'object' && p.storage.localStorage ? p.storage.localStorage : {},
|
||||
sessionStorage: typeof p.storage.sessionStorage === 'object' && p.storage.sessionStorage ? p.storage.sessionStorage : {},
|
||||
}
|
||||
: null,
|
||||
// NEVER accept loadedHtml / loadedHtmlWaitUntil / owner from disk.
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the live session to disk (0600). No-op outside launched
|
||||
* (headless) mode — the headed persistent profile owns its own state.
|
||||
*/
|
||||
export async function persistSessionState(bm: BrowserManager, filePath: string): Promise<void> {
|
||||
if (bm.getConnectionMode() !== 'launched') return;
|
||||
const state = await bm.saveState();
|
||||
// Atomic replace: stage the new snapshot beside the target, then rename
|
||||
// over it. A crash mid-write must never destroy the previous good
|
||||
// snapshot — surviving crashes is the point of this feature.
|
||||
const tmpPath = `${filePath}.tmp`;
|
||||
writeSecureFile(tmpPath, serializeSessionState(state));
|
||||
try {
|
||||
fs.renameSync(tmpPath, filePath);
|
||||
} catch (err) {
|
||||
safeUnlinkQuiet(tmpPath);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a persisted session into a freshly launched manager. Returns the
|
||||
* restored (already-filtered) state so callers can log counts without an
|
||||
* extra saveState() round-trip, or null when there was nothing to restore
|
||||
* (missing file, or corrupt data — which is warned, quarantined, and skipped
|
||||
* rather than blocking launch). restoreState re-validates every URL before
|
||||
* navigating.
|
||||
*/
|
||||
export async function restoreSessionState(bm: BrowserManager, filePath: string): Promise<BrowserState | null> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(filePath, 'utf-8');
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'ENOENT') return null;
|
||||
throw err;
|
||||
}
|
||||
const state = deserializeSessionState(raw);
|
||||
if (!state) {
|
||||
// Boot fresh, keep the evidence: the corrupt file moves to .corrupt so a
|
||||
// 3-week-later bug report is reconstructable from the artifact.
|
||||
console.warn(`[browse] SESSION_STATE_INVALID: corrupt ${filePath} moved to .corrupt; starting fresh`);
|
||||
quarantineCorrupt(filePath);
|
||||
return null;
|
||||
}
|
||||
// launch() opens one blank tab; replace it rather than restoring alongside.
|
||||
await bm.closeAllPages();
|
||||
await bm.restoreState(state);
|
||||
return state;
|
||||
}
|
||||
+42
-7
@@ -21,6 +21,7 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { readGstackConfigYamlKey } from './config';
|
||||
|
||||
function gstackHome(): string {
|
||||
return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack');
|
||||
@@ -43,17 +44,51 @@ async function ensureDir(): Promise<void> {
|
||||
}
|
||||
|
||||
let telemetryDisabled: boolean | null = null;
|
||||
function isDisabled(): boolean {
|
||||
/**
|
||||
* Is telemetry disabled for this process? Telemetry is OPT-IN: the consent
|
||||
* prompt writes a granted tier ('community' | 'anonymous') to
|
||||
* ~/.gstack/config.yaml, and only a granted tier enables emission. Tiers,
|
||||
* checked in order:
|
||||
*
|
||||
* 1. Env hint GSTACK_TELEMETRY_OFF=1 (set by preambles and test
|
||||
* harnesses): always disabled, even over a granted config tier.
|
||||
* 2. Persistent tier via the shared flat-YAML helper in config.ts (same
|
||||
* parser as the pair-agent gate, so the two consent gates never drift):
|
||||
* explicit `telemetry: off` disables; 'community'/'anonymous' enable.
|
||||
* 3. Default: DISABLED. An absent key, absent file, or unrecognized value
|
||||
* means consent was never granted — matching bin/gstack-config's
|
||||
* DEFAULTS table, which reports 'off' for an unset telemetry key.
|
||||
* Anything else would be a split-brain where `gstack-config get
|
||||
* telemetry` tells the user 'off' while a direct-$B daemon emits.
|
||||
* One escape hatch: GSTACK_TELEMETRY_OFF=0 is a harness-side consent
|
||||
* assertion that flips this DEFAULT only (test harnesses exercising the
|
||||
* write path against a scratch GSTACK_HOME) — it never overrides an
|
||||
* explicit `telemetry: off` the user wrote.
|
||||
*
|
||||
* Exported so tests can pin the consent gate directly; the cached verdict
|
||||
* resets via _resetTelemetryCache.
|
||||
*/
|
||||
export function isTelemetryDisabled(): boolean {
|
||||
if (telemetryDisabled !== null) return telemetryDisabled;
|
||||
// Check env (set by preamble or test harnesses).
|
||||
// Env kill switch (set by preamble or test harnesses): beats everything.
|
||||
if (process.env.GSTACK_TELEMETRY_OFF === '1') {
|
||||
telemetryDisabled = true;
|
||||
return true;
|
||||
}
|
||||
// Conservative default: telemetry ON unless explicitly off. Users opt out via
|
||||
// gstack-config set telemetry off (preamble reads this; we trust the env hint).
|
||||
telemetryDisabled = false;
|
||||
return false;
|
||||
// Persistent tier: an explicit user-written value always wins next.
|
||||
const tier = readGstackConfigYamlKey('telemetry');
|
||||
if (tier === 'off') {
|
||||
telemetryDisabled = true;
|
||||
return true;
|
||||
}
|
||||
if (tier === 'community' || tier === 'anonymous') {
|
||||
telemetryDisabled = false;
|
||||
return false;
|
||||
}
|
||||
// No granted consent on record (absent key/file, unrecognized value):
|
||||
// disabled — unless the harness asserted consent via the env seam.
|
||||
telemetryDisabled = process.env.GSTACK_TELEMETRY_OFF !== '0';
|
||||
return telemetryDisabled;
|
||||
}
|
||||
|
||||
export interface TelemetryEvent {
|
||||
@@ -63,7 +98,7 @@ export interface TelemetryEvent {
|
||||
|
||||
/** Fire-and-forget log. Never throws. */
|
||||
export function logTelemetry(payload: TelemetryEvent): void {
|
||||
if (isDisabled()) return;
|
||||
if (isTelemetryDisabled()) return;
|
||||
const enriched = { ...payload, ts: new Date().toISOString() };
|
||||
ensureDir()
|
||||
.then(() => fs.appendFile(telemetryFile(), JSON.stringify(enriched) + '\n', 'utf8'))
|
||||
|
||||
Reference in New Issue
Block a user