mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-19 11:22:21 +02:00
Merge origin/main (v1.67.0.0) — reconcile convergent iOS Release-guard fixes
main's v1.67.0.0 independently landed the DebugBridgeTouch Release compile-out with a stronger shape (`#if !defined(DEBUG)` short-circuit before the platform gate, measured via nm -j on a real Release binary) than this branch's `#if TARGET_OS_IOS && DEBUG`. Resolution: take main's templates/fixtures, keep this branch's free-tier static tripwire and adapt it to pin main's shape (short-circuit present, ordered before the platform branch, cSettings DEBUG define intact, no bare platform-only gate). VERSION/package.json stay 1.67.1.0; CHANGELOG keeps both entries with 1.67.1.0 on top, its iOS claims reworded to the residual contribution (the tripwire, not the compile-out itself). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+24
-6
@@ -108,9 +108,11 @@ else
|
||||
fi
|
||||
~/.claude/skills/gstack/bin/gstack-timeline-log '{"skill":"browse","event":"started","branch":"'"$_BRANCH"'","session":"'"$_SESSION_ID"'"}' 2>/dev/null &
|
||||
_HAS_ROUTING="no"
|
||||
if [ -f CLAUDE.md ] && grep -q "## Skill routing" CLAUDE.md 2>/dev/null; then
|
||||
_HAS_ROUTING="yes"
|
||||
fi
|
||||
for _RF in CLAUDE.md AGENTS.md; do
|
||||
if [ -f "$_RF" ] && grep -q "## Skill routing" "$_RF" 2>/dev/null; then
|
||||
_HAS_ROUTING="yes"
|
||||
fi
|
||||
done
|
||||
_ROUTING_DECLINED=$(~/.claude/skills/gstack/bin/gstack-config get routing_declined 2>/dev/null || echo "false")
|
||||
echo "HAS_ROUTING: $_HAS_ROUTING"
|
||||
echo "ROUTING_DECLINED: $_ROUTING_DECLINED"
|
||||
@@ -379,10 +381,13 @@ _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || e
|
||||
# Detect remote-MCP mode (Path 4 of /setup-gbrain). Local artifacts sync is
|
||||
# a no-op in remote mode; the brain server pulls from GitHub/GitLab on its
|
||||
# own cadence. Read claude.json directly to keep this preamble fast (no
|
||||
# subprocess to claude CLI on every skill start).
|
||||
# subprocess to claude CLI on every skill start). Both registration scopes
|
||||
# are read (#2499): user scope, then the nearest-ancestor project scope.
|
||||
_GBRAIN_MCP_MODE="none"
|
||||
_GBRAIN_MCP_ENTRY=""
|
||||
if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then
|
||||
_GBRAIN_MCP_TYPE=$(jq -r '.mcpServers.gbrain.type // .mcpServers.gbrain.transport // empty' "$HOME/.claude.json" 2>/dev/null)
|
||||
_GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '.mcpServers.gbrain // ((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // empty' "$HOME/.claude.json" 2>/dev/null)
|
||||
_GBRAIN_MCP_TYPE=$(printf '%s' "$_GBRAIN_MCP_ENTRY" | jq -r '.type // .transport // empty' 2>/dev/null)
|
||||
case "$_GBRAIN_MCP_TYPE" in
|
||||
url|http|sse) _GBRAIN_MCP_MODE="remote-http" ;;
|
||||
stdio) _GBRAIN_MCP_MODE="local-stdio" ;;
|
||||
@@ -403,6 +408,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then
|
||||
_BRAIN_DO_PULL=1
|
||||
if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then
|
||||
_BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0)
|
||||
case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac
|
||||
_BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST ))
|
||||
[ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0
|
||||
fi
|
||||
@@ -416,7 +422,7 @@ fi
|
||||
if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then
|
||||
# Remote-MCP mode: local artifacts sync is a no-op (brain admin's server
|
||||
# pulls from GitHub/GitLab). Show the user this is by design, not broken.
|
||||
_GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|')
|
||||
_GBRAIN_HOST=$(printf '%s' "${_GBRAIN_MCP_ENTRY:-}" | jq -r '.url // empty' 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-')
|
||||
echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})"
|
||||
elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then
|
||||
_BRAIN_QUEUE_DEPTH=0
|
||||
@@ -618,6 +624,18 @@ $B screenshot /tmp/bug.png # plain screenshot
|
||||
$B console # error log
|
||||
```
|
||||
|
||||
Two behaviors that silently invalidate screenshots (#2445 — designed, but
|
||||
surprising):
|
||||
- **`hover` scrolls its target into view.** Hovering anything below the fold
|
||||
scrolls the page first, so a "rest state" shot taken afterwards captures
|
||||
the wrong section with exit 0. Before a rest-state screenshot, hover only
|
||||
something already visible, and assert position when it matters:
|
||||
`$B js "window.scrollY"` should be `0` (or your intended offset).
|
||||
- **The tab persists across sessions.** The daemon keeps its tab between your
|
||||
sessions, so `reload` or `screenshot` without a preceding `goto` can act on
|
||||
whatever page earlier work left open. Start verification passes with an
|
||||
explicit `$B goto <url>`, never a bare `reload`.
|
||||
|
||||
### 5. Find all clickable elements (including non-ARIA)
|
||||
```bash
|
||||
$B snapshot -C # finds divs with cursor:pointer, onclick, tabindex
|
||||
|
||||
@@ -65,6 +65,18 @@ $B screenshot /tmp/bug.png # plain screenshot
|
||||
$B console # error log
|
||||
```
|
||||
|
||||
Two behaviors that silently invalidate screenshots (#2445 — designed, but
|
||||
surprising):
|
||||
- **`hover` scrolls its target into view.** Hovering anything below the fold
|
||||
scrolls the page first, so a "rest state" shot taken afterwards captures
|
||||
the wrong section with exit 0. Before a rest-state screenshot, hover only
|
||||
something already visible, and assert position when it matters:
|
||||
`$B js "window.scrollY"` should be `0` (or your intended offset).
|
||||
- **The tab persists across sessions.** The daemon keeps its tab between your
|
||||
sessions, so `reload` or `screenshot` without a preceding `goto` can act on
|
||||
whatever page earlier work left open. Start verification passes with an
|
||||
explicit `$B goto <url>`, never a bare `reload`.
|
||||
|
||||
### 5. Find all clickable elements (including non-ARIA)
|
||||
```bash
|
||||
$B snapshot -C # finds divs with cursor:pointer, onclick, tabindex
|
||||
|
||||
@@ -8,8 +8,14 @@
|
||||
set -e
|
||||
|
||||
GSTACK_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
# Windows (MSYS/Git Bash): convert to a Windows-style path — Bun cannot open
|
||||
# MSYS /c/... absolute paths ("FileNotFound opening root directory").
|
||||
case "$(uname -s)" in
|
||||
MINGW*|MSYS*|CYGWIN*) GSTACK_DIR="$(cygpath -m "$GSTACK_DIR")" ;;
|
||||
esac
|
||||
SRC_DIR="$GSTACK_DIR/browse/src"
|
||||
DIST_DIR="$GSTACK_DIR/browse/dist"
|
||||
mkdir -p "$DIST_DIR"
|
||||
|
||||
echo "Building Node-compatible server bundle..."
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ export function resolveBrowseAuth(opts: BrowseClientOptions = {}): ResolvedAuth
|
||||
|
||||
function defaultStateFile(): string | null {
|
||||
try {
|
||||
const proc = cp.spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf-8', timeout: 2000 });
|
||||
const proc = cp.spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf-8', timeout: 2000, windowsHide: true });
|
||||
const root = proc.status === 0 ? proc.stdout.trim() : null;
|
||||
const base = root || process.cwd();
|
||||
return path.join(base, '.gstack', 'browse.json');
|
||||
|
||||
@@ -22,6 +22,7 @@ import { emitActivity } from './activity';
|
||||
import { validateNavigationUrl } from './url-validation';
|
||||
import { TabSession, type RefEntry } from './tab-session';
|
||||
import { resolveChromiumProfile, cleanSingletonLocks } from './config';
|
||||
import { launchWithXProtectHeal } from './xprotect-heal';
|
||||
import { withCdpSession } from './cdp-bridge';
|
||||
import type { MemorySnapshot, MemoryStructureStats, MemoryTabSnapshot, MemoryProcess } from './memory-snapshot';
|
||||
|
||||
@@ -458,8 +459,23 @@ export class BrowserManager {
|
||||
console.log(`[browse] Extensions loaded from: ${extensionsDir}`);
|
||||
}
|
||||
|
||||
this.browser = await chromium.launch({
|
||||
// XProtect self-heal wrapper (P0 #2554): a macOS definition update can
|
||||
// start SIGKILLing the pinned Chromium at spawn. On the classified
|
||||
// signature, clear quarantine on the Playwright cache + force-reinstall
|
||||
// once, then retry this launch once. This headless path always uses the
|
||||
// Playwright cache (no executablePath), so the heal is never scoped out.
|
||||
this.browser = await launchWithXProtectHeal(() => chromium.launch({
|
||||
headless: useHeadless,
|
||||
// #2220: the daemon owns signal policy, not Playwright. Playwright's
|
||||
// default handlers close Chromium the moment THIS process receives
|
||||
// SIGINT/SIGTERM/SIGHUP — which fights the deliberate headless
|
||||
// SIGTERM-ignore in server.ts (the daemon survives the signal but
|
||||
// loses its browser out from under it). All three are false; server.ts
|
||||
// routes the signals it actually honors through activeShutdown, which
|
||||
// closes Chromium itself.
|
||||
handleSIGINT: false,
|
||||
handleSIGTERM: false,
|
||||
handleSIGHUP: false,
|
||||
// On Windows, Chromium's sandbox fails when the server is spawned through
|
||||
// the Bun→Node process chain (GitHub #276). Disable it — local daemon
|
||||
// browsing user-specified URLs has marginal sandbox benefit. Also disabled
|
||||
@@ -468,7 +484,7 @@ export class BrowserManager {
|
||||
chromiumSandbox: shouldEnableChromiumSandbox(),
|
||||
...(launchArgs.length > 0 ? { args: launchArgs } : {}),
|
||||
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
|
||||
});
|
||||
}));
|
||||
|
||||
// Chromium disconnect → distinguish clean user-quit from crash. Both
|
||||
// events look identical to Playwright (one 'disconnected' fires), but
|
||||
@@ -651,8 +667,16 @@ export class BrowserManager {
|
||||
// three more (--disable-popup-blocking, --disable-component-update,
|
||||
// --disable-default-apps — each a documented automation tell per Patchright).
|
||||
const { STEALTH_IGNORE_DEFAULT_ARGS } = await import('./stealth');
|
||||
this.context = await chromium.launchPersistentContext(userDataDir, {
|
||||
// XProtect self-heal wrapper (P0 #2554). usesCustomExecutable scopes the
|
||||
// heal out when GSTACK_CHROMIUM_PATH supplies the bundle — that bundle
|
||||
// belongs to the wrapper/embedder and is never quarantine-cleared or
|
||||
// reinstalled over (probePoisonedChromiumBundle's scope contract).
|
||||
this.context = await launchWithXProtectHeal(() => chromium.launchPersistentContext(userDataDir, {
|
||||
headless: false,
|
||||
// #2220: daemon owns signal policy — see launch() for the rationale.
|
||||
handleSIGINT: false,
|
||||
handleSIGTERM: false,
|
||||
handleSIGHUP: false,
|
||||
// Match the sandbox policy used by launch() above. Without this,
|
||||
// Playwright auto-adds --no-sandbox on every headed launch and the user
|
||||
// sees Chromium's "unsupported command-line flag" yellow infobar.
|
||||
@@ -663,7 +687,7 @@ export class BrowserManager {
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
|
||||
ignoreDefaultArgs: STEALTH_IGNORE_DEFAULT_ARGS,
|
||||
});
|
||||
}), { usesCustomExecutable: Boolean(executablePath) });
|
||||
this.browser = this.context.browser();
|
||||
this.connectionMode = 'headed';
|
||||
this.intentionalDisconnect = false;
|
||||
@@ -1702,8 +1726,15 @@ export class BrowserManager {
|
||||
// The handoff path (headless → headed re-launch) takes the same
|
||||
// anti-detection posture.
|
||||
const { STEALTH_IGNORE_DEFAULT_ARGS } = await import('./stealth');
|
||||
newContext = await chromium.launchPersistentContext(userDataDir, {
|
||||
// XProtect self-heal wrapper (P0 #2554): handoff always launches the
|
||||
// Playwright-cache bundle (no executablePath), so the heal applies
|
||||
// exactly as in launch()/launchHeaded().
|
||||
newContext = await launchWithXProtectHeal(() => chromium.launchPersistentContext(userDataDir, {
|
||||
headless: false,
|
||||
// #2220: daemon owns signal policy — see launch() for the rationale.
|
||||
handleSIGINT: false,
|
||||
handleSIGTERM: false,
|
||||
handleSIGHUP: false,
|
||||
// Match the sandbox policy used by launchHeaded() / launch(). The
|
||||
// handoff path is the headless→headed re-launch and shares the same
|
||||
// anti-detection posture, including no spurious --no-sandbox infobar.
|
||||
@@ -1713,7 +1744,7 @@ export class BrowserManager {
|
||||
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
|
||||
ignoreDefaultArgs: STEALTH_IGNORE_DEFAULT_ARGS,
|
||||
timeout: 15000,
|
||||
});
|
||||
}));
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return `ERROR: Cannot open headed browser — ${msg}. Headless browser still running.`;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
listBrowserSkills,
|
||||
@@ -185,19 +186,122 @@ async function handleTest(args: string[], ctx: SkillCommandContext): Promise<str
|
||||
throw new Error(`Skill "${name}" has no script.test.ts at ${testFile}`);
|
||||
}
|
||||
|
||||
const proc = Bun.spawn(['bun', 'test', testFile], {
|
||||
const { stdout, stderr, exitCode } = await runToFiles(['bun', 'test', testFile], {
|
||||
cwd: skill.dir,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: process.env,
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = proc.stdout ? await new Response(proc.stdout).text() : '';
|
||||
const stderr = proc.stderr ? await new Response(proc.stderr).text() : '';
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`Skill "${name}" tests failed (exit ${exitCode}).\n${stderr}`);
|
||||
throw new Error(`Skill "${name}" tests failed (exit ${exitCode}).\n${stderr || stdout}`);
|
||||
}
|
||||
|
||||
// Return both streams, concatenated in bun's own layout (banner, blank line,
|
||||
// summary). Picking one drops half the report, and the half callers assert on
|
||||
// (the summary) is the half that lives on stderr.
|
||||
const report = (stdout + stderr).trim();
|
||||
if (!report) {
|
||||
// A passing `bun test` always prints a summary, so exit 0 with no output at
|
||||
// all means we failed to capture the run rather than that it went well.
|
||||
// Say so instead of returning a synthetic "passed" that can't be verified.
|
||||
throw new Error(`Skill "${name}" tests exited 0 but produced no output — the run was not captured.`);
|
||||
}
|
||||
return report + '\n';
|
||||
}
|
||||
|
||||
interface RunToFilesOptions {
|
||||
cwd: string;
|
||||
env: Record<string, string> | NodeJS.ProcessEnv;
|
||||
/** Kill the child after this many ms. Omit for no timeout. */
|
||||
timeoutMs?: number;
|
||||
/** Cap the captured stdout. Bytes past the cap are dropped, `truncated` set. */
|
||||
maxStdoutBytes?: number;
|
||||
}
|
||||
|
||||
interface RunToFilesResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
timedOut: boolean;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command, capturing stdout/stderr by pointing the child's file
|
||||
* descriptors at temp files rather than at pipes.
|
||||
*
|
||||
* Why not `stdout: 'pipe'`: under a loaded parent, the FIRST piped spawn in a
|
||||
* process intermittently yields an empty stderr even though the child wrote it
|
||||
* and exited 0. The data is lost inside Bun's async pipe plumbing, so neither
|
||||
* draining before awaiting exit nor a manual `getReader()` loop avoids it —
|
||||
* both were measured losing the same bytes in the same position. It surfaced in
|
||||
* `$B skill test`, where `bun test` splits its report across streams (banner ->
|
||||
* stdout, pass/fail summary -> stderr) so a dropped stderr silently degraded
|
||||
* the result to just the banner; for `$B skill run` the same loss would blank
|
||||
* the skill's JSON result and still look like success.
|
||||
*
|
||||
* Writing to files takes user-space streams out of the path: the kernel has
|
||||
* flushed every byte by the time the child exits, so the post-exit read is
|
||||
* always complete. It also removes the pipe-buffer stall risk on chatty
|
||||
* children. `Bun.spawnSync` captures reliably too, but blocking the event loop
|
||||
* is not an option here — a spawned skill calls back into this same daemon on
|
||||
* GSTACK_PORT, so a synchronous wait would deadlock it.
|
||||
*/
|
||||
async function runToFiles(cmd: string[], opts: RunToFilesOptions): Promise<RunToFilesResult> {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-skill-'));
|
||||
const outPath = path.join(dir, 'stdout');
|
||||
const errPath = path.join(dir, 'stderr');
|
||||
try {
|
||||
// Hand Bun the destinations as BunFiles rather than raw fds we opened: Bun
|
||||
// then owns the descriptors for the child's whole lifetime. Opening them
|
||||
// here and closing them after exit instead put us in Bun's fd bookkeeping,
|
||||
// which surfaced as a stray EBADF from epoll_ctl on a later spawn.
|
||||
const proc = Bun.spawn(cmd, {
|
||||
cwd: opts.cwd,
|
||||
env: opts.env as any,
|
||||
stdout: Bun.file(outPath) as any,
|
||||
stderr: Bun.file(errPath) as any,
|
||||
});
|
||||
|
||||
let timedOut = false;
|
||||
const killer = opts.timeoutMs === undefined ? undefined : setTimeout(() => {
|
||||
timedOut = true;
|
||||
try { proc.kill(); } catch {}
|
||||
}, opts.timeoutMs);
|
||||
|
||||
const exitCode = await proc.exited;
|
||||
if (killer !== undefined) clearTimeout(killer);
|
||||
|
||||
// The child's own writes are flushed by the kernel when it exits, so
|
||||
// everything it wrote is readable here.
|
||||
const cap = opts.maxStdoutBytes ?? Infinity;
|
||||
const stdout = readCappedFile(outPath, cap);
|
||||
const stderr = readCappedFile(errPath, cap);
|
||||
return {
|
||||
stdout: stdout.text,
|
||||
stderr: stderr.text,
|
||||
exitCode: timedOut ? 124 : exitCode,
|
||||
timedOut,
|
||||
truncated: stdout.truncated,
|
||||
};
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
interface CappedRead { text: string; truncated: boolean; }
|
||||
|
||||
/** Read at most `capBytes` from a file, reporting whether anything was dropped. */
|
||||
function readCappedFile(p: string, capBytes: number): CappedRead {
|
||||
const size = fs.statSync(p).size;
|
||||
if (size <= capBytes) return { text: fs.readFileSync(p, 'utf-8'), truncated: false };
|
||||
const fd = fs.openSync(p, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(capBytes);
|
||||
const read = fs.readSync(fd, buf, 0, capBytes, 0);
|
||||
return { text: buf.subarray(0, read).toString('utf-8'), truncated: true };
|
||||
} finally {
|
||||
try { fs.closeSync(fd); } catch {}
|
||||
}
|
||||
return stderr || stdout || `tests passed for "${name}"`;
|
||||
}
|
||||
|
||||
// ─── rm ─────────────────────────────────────────────────────────
|
||||
@@ -263,71 +367,19 @@ export async function spawnSkill(opts: SpawnSkillOptions): Promise<SpawnSkillRes
|
||||
throw new Error(`Skill "${opts.skill.name}" missing script.ts at ${scriptPath}`);
|
||||
}
|
||||
|
||||
const proc = Bun.spawn(['bun', 'run', scriptPath, '--', ...opts.skillArgs], {
|
||||
// Captured via temp files, not pipes — see runToFiles for why. A dropped
|
||||
// read here would blank the skill's JSON result and still report success.
|
||||
return await runToFiles(['bun', 'run', scriptPath, '--', ...opts.skillArgs], {
|
||||
cwd: opts.skill.dir,
|
||||
env,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
timeoutMs: opts.timeoutSeconds * 1000,
|
||||
maxStdoutBytes: MAX_STDOUT_BYTES,
|
||||
});
|
||||
|
||||
let timedOut = false;
|
||||
const killer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
try { proc.kill(); } catch {}
|
||||
}, opts.timeoutSeconds * 1000);
|
||||
|
||||
const stdoutPromise = readCapped(proc.stdout, MAX_STDOUT_BYTES);
|
||||
const stderrPromise = readCapped(proc.stderr, MAX_STDOUT_BYTES);
|
||||
|
||||
const exitCode = await proc.exited;
|
||||
clearTimeout(killer);
|
||||
|
||||
const stdoutResult = await stdoutPromise;
|
||||
const stderrResult = await stderrPromise;
|
||||
|
||||
return {
|
||||
stdout: stdoutResult.text,
|
||||
stderr: stderrResult.text,
|
||||
exitCode: timedOut ? 124 : exitCode,
|
||||
timedOut,
|
||||
truncated: stdoutResult.truncated,
|
||||
};
|
||||
} finally {
|
||||
revokeSkillToken(opts.skill.name, spawnId);
|
||||
}
|
||||
}
|
||||
|
||||
interface CappedRead { text: string; truncated: boolean; }
|
||||
|
||||
async function readCapped(stream: ReadableStream<Uint8Array> | undefined, capBytes: number): Promise<CappedRead> {
|
||||
if (!stream) return { text: '', truncated: false };
|
||||
const reader = stream.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
let truncated = false;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
total += value.length;
|
||||
if (total > capBytes) {
|
||||
truncated = true;
|
||||
// Take only what fits; drop the rest of the stream (release reader).
|
||||
const fits = value.length - (total - capBytes);
|
||||
if (fits > 0) chunks.push(value.subarray(0, fits));
|
||||
try { await reader.cancel(); } catch {}
|
||||
break;
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
try { reader.releaseLock(); } catch {}
|
||||
}
|
||||
const buf = Buffer.concat(chunks.map(c => Buffer.from(c)));
|
||||
return { text: buf.toString('utf-8'), truncated };
|
||||
}
|
||||
|
||||
// ─── env construction (security-critical) ───────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -98,7 +98,7 @@ export function defaultTierPaths(opts: { projectRoot?: string; home?: string; bu
|
||||
|
||||
function detectProjectRoot(): string | null {
|
||||
try {
|
||||
const proc = cp.spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf-8', timeout: 2000 });
|
||||
const proc = cp.spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf-8', timeout: 2000, windowsHide: true });
|
||||
if (proc.status === 0) {
|
||||
const out = proc.stdout.trim();
|
||||
return out || null;
|
||||
|
||||
@@ -11,7 +11,43 @@
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
const { spawnSync, spawn } = require('child_process');
|
||||
const { spawnSync: nodeSpawnSync, spawn: nodeSpawn } = require('child_process');
|
||||
// Node's spawn on Windows without shell:true only matches an EXACT
|
||||
// executable name — no PATHEXT resolution the way a real shell (or
|
||||
// Bun.spawn, which this file exists to polyfill) does. A bare command
|
||||
// name like 'bun' (no .exe/.cmd) then fails ENOENT even though `bun`
|
||||
// works fine typed at a prompt (confirmed live in #2461: this is what
|
||||
// produced "[browse] FATAL uncaught exception: spawn bun ENOENT" from
|
||||
// terminal-agent-control.ts's respawn path, once daemon output was
|
||||
// actually being captured to a file instead of silently discarded).
|
||||
//
|
||||
// Two things this is NOT fixed with, both tried and rejected in #2461:
|
||||
//
|
||||
// 1. shell:true + array args. This file is also reached (via server.ts →
|
||||
// write-commands.ts/meta-commands.ts → cookie-import-browser.ts/
|
||||
// browser-skill-commands.ts) by calls that pass genuinely variable
|
||||
// content — browser-skill-commands.ts spreads `...opts.skillArgs`,
|
||||
// sourced from `$B skill run <name> --arg k=v`'s passthrough CLI args,
|
||||
// into the spawned argv. shell:true on Windows routes through cmd.exe,
|
||||
// and Node's own array-arg handling for that combination does NOT
|
||||
// neutralize cmd.exe metacharacters (& | ^ % < >) — verified in #2461 by
|
||||
// directly spawning a resolved .cmd path with an arg containing
|
||||
// `& echo INJECTED > proof.txt`: the file was created. Hand-rolled
|
||||
// double-quote-only escaping doesn't close that either.
|
||||
//
|
||||
// 2. Resolve the .exe/.cmd path ourselves and spawn it with NO shell.
|
||||
// Works for .exe targets, but Node refuses (EINVAL) to spawn a
|
||||
// .cmd/.bat file without shell:true — deliberately, as part of Node's
|
||||
// CVE-2024-27980 fix for implicit unsafe .cmd execution. bun's own
|
||||
// Windows install (npm global) is exactly a .cmd shim, so this path is
|
||||
// not optional to support.
|
||||
//
|
||||
// cross-spawn (previously a transitive dep, now direct) is the established
|
||||
// library for precisely this problem: PATHEXT resolution AND correct
|
||||
// Windows/cmd.exe argument escaping together. #2461 verified the injection
|
||||
// payload above reaches the child as a single literal argument while
|
||||
// normal resolution (`bun --version`) still works.
|
||||
const crossSpawn = require('cross-spawn');
|
||||
|
||||
globalThis.Bun = {
|
||||
serve(options) {
|
||||
@@ -66,7 +102,8 @@ globalThis.Bun = {
|
||||
|
||||
spawnSync(cmd, options = {}) {
|
||||
const [command, ...args] = cmd;
|
||||
const result = spawnSync(command, args, {
|
||||
const spawnSyncFn = process.platform === 'win32' ? crossSpawn.sync : nodeSpawnSync;
|
||||
const result = spawnSyncFn(command, args, {
|
||||
stdio: [
|
||||
options.stdin || 'pipe',
|
||||
options.stdout === 'pipe' ? 'pipe' : 'ignore',
|
||||
@@ -92,7 +129,8 @@ globalThis.Bun = {
|
||||
spawn(cmd, options = {}) {
|
||||
const [command, ...args] = cmd;
|
||||
const stdio = options.stdio || ['pipe', 'pipe', 'pipe'];
|
||||
const proc = spawn(command, args, {
|
||||
const spawnFn = process.platform === 'win32' ? crossSpawn : nodeSpawn;
|
||||
const proc = spawnFn(command, args, {
|
||||
stdio,
|
||||
env: options.env,
|
||||
cwd: options.cwd,
|
||||
|
||||
@@ -155,6 +155,13 @@ export const CDP_ALLOWLIST: ReadonlyArray<CdpAllowEntry> = Object.freeze([
|
||||
output: 'trusted',
|
||||
justification: 'UA override on the active tab. NOTE: changes affect future requests; fine for tests.',
|
||||
},
|
||||
{
|
||||
domain: 'Emulation',
|
||||
method: 'setEmulatedMedia',
|
||||
scope: 'tab',
|
||||
output: 'trusted',
|
||||
justification: 'Media type/feature override (prefers-color-scheme, prefers-reduced-motion, prefers-contrast, forced-colors) so a11y and dark-mode CSS branches are testable. Returns an empty result; no page content. NOTE: like setUserAgentOverride the override persists on the tab until cleared with an empty features array.',
|
||||
},
|
||||
// ─── Page capture (output, not navigation) ─────────────────
|
||||
{
|
||||
domain: 'Page',
|
||||
|
||||
+265
-24
@@ -146,10 +146,10 @@ function readState(): ServerState | null {
|
||||
* HTTP health check — definitive proof the server is alive and responsive.
|
||||
* Used in all polling loops instead of isProcessAlive() (which is slow on Windows).
|
||||
*/
|
||||
export async function isServerHealthy(port: number): Promise<boolean> {
|
||||
export async function isServerHealthy(port: number, timeoutMs = 2000): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(`http://127.0.0.1:${port}/health`, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!resp.ok) return false;
|
||||
const health = await resp.json() as any;
|
||||
@@ -270,15 +270,82 @@ async function killOrphanChromium(profileDir: string = chromiumProfileDir()): Pr
|
||||
}
|
||||
}
|
||||
|
||||
/** Bounded /health probe. Returns true if the server answers within `attempts`
|
||||
* tries spaced `backoffMs` apart — distinguishes a busy-but-alive daemon from a
|
||||
* dead one (#1781) so a slow server isn't killed and restarted into a crash-loop. */
|
||||
async function probeHealthWithBackoff(port: number, attempts = 3, backoffMs = 250): Promise<boolean> {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
if (await isServerHealthy(port)) return true;
|
||||
if (i < attempts - 1) await Bun.sleep(backoffMs);
|
||||
/** Total wall-clock budget for the busy-vs-dead health probe (#2219,
|
||||
* decision F10). The old ~1s window (3 × 250ms) was shorter than how long a
|
||||
* daemon stays unresponsive while Chromium chews a heavy dev-mode page with a
|
||||
* timed-out navigation still in flight — so live daemons got killed and every
|
||||
* kill lost the session's cookies/tabs/logins. ~8s covers the observed busy
|
||||
* windows; past it we REPORT busy instead of killing (never auto-kill). */
|
||||
export const HEALTH_PROBE_TOTAL_BUDGET_MS = 8_000;
|
||||
|
||||
/** Bounded /health probe. Returns true if the server answers within the
|
||||
* total budget — distinguishes a busy-but-alive daemon from a dead one
|
||||
* (#1781, #2219) so a slow server isn't killed and restarted into a
|
||||
* crash-loop.
|
||||
*
|
||||
* P4 wall-time honesty: every call site reaches here right after a probe or
|
||||
* command already failed, so iterations START with the sleep (an immediate
|
||||
* re-probe would just re-fail), and each probe's timeout is clamped to the
|
||||
* remaining budget — otherwise the last 2s probe could start 1ms before the
|
||||
* deadline and the reported "~8s" budget would really be ~10s. */
|
||||
async function probeHealthWithBackoff(
|
||||
port: number,
|
||||
totalBudgetMs = HEALTH_PROBE_TOTAL_BUDGET_MS,
|
||||
intervalMs = 500,
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + totalBudgetMs;
|
||||
for (;;) {
|
||||
if (Date.now() + intervalMs >= deadline) return false;
|
||||
await Bun.sleep(intervalMs);
|
||||
const remainingMs = deadline - Date.now();
|
||||
if (remainingMs <= 0) return false;
|
||||
if (await isServerHealthy(port, Math.min(2000, remainingMs))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export type DaemonRestartAction =
|
||||
| 'retry-command' // healthy again after the bounded probe — retry against the SAME daemon
|
||||
| 'report-busy' // alive but unresponsive — report + nonzero exit, daemon untouched
|
||||
| 'force-restart' // alive but the user explicitly passed --force-restart
|
||||
| 'restart-dead'; // process is gone — safe to clean up and restart
|
||||
|
||||
/**
|
||||
* Decide what to do about a daemon that failed to answer (#2219, decision 9).
|
||||
*
|
||||
* IRON RULE: an alive pid is NEVER auto-killed. A kill loses the session's
|
||||
* tabs, cookies, and logins — strictly worse than a slow command. The ONLY
|
||||
* path that kills a live daemon is the user explicitly passing
|
||||
* --force-restart. Pure and exported for unit coverage.
|
||||
*/
|
||||
export function decideDaemonRestart(opts: {
|
||||
pidAlive: boolean;
|
||||
healthyAfterProbe: boolean;
|
||||
forceRestart: boolean;
|
||||
}): DaemonRestartAction {
|
||||
if (opts.pidAlive && opts.healthyAfterProbe) return 'retry-command';
|
||||
if (opts.pidAlive && opts.forceRestart) return 'force-restart';
|
||||
if (opts.pidAlive) return 'report-busy';
|
||||
return 'restart-dead';
|
||||
}
|
||||
|
||||
/** #2219 IRON RULE refusal for `connect`: a live daemon is never replaced
|
||||
* without explicit consent. Single source for the refusal text (M7) — the
|
||||
* two call sites (healthy fast-path, busy-but-alive after the bounded probe)
|
||||
* previously duplicated it, and the tabs/cookies/logins explainer had
|
||||
* already drifted out of one of them. */
|
||||
function refuseHeadedOverLiveDaemon(state: { pid: number; mode?: string }): never {
|
||||
console.error(`[browse] A healthy daemon is already running (PID ${state.pid}, ${state.mode} mode).`);
|
||||
console.error('[browse] Connecting headed would kill it and lose its tabs/cookies/logins.');
|
||||
console.error("[browse] Run 'browse disconnect' first, or pass --force-restart to replace it.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** The busy report (F10): what happened, what to do, what a force costs. */
|
||||
function reportDaemonBusyAndExit(pid: number): never {
|
||||
console.error(`[browse] Daemon busy — process ${pid} is alive but did not answer /health within ~${HEALTH_PROBE_TOTAL_BUDGET_MS / 1000}s.`);
|
||||
console.error('[browse] Retry shortly (heavy page loads pass), or force a restart — which LOSES tabs, cookies, and logins:');
|
||||
console.error('[browse] browse --force-restart <command>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -310,6 +377,7 @@ function raiseHeadedWindowMacOS(): void {
|
||||
nodeSpawn('osascript', ['-e', 'tell application "Google Chrome for Testing" to activate'], {
|
||||
stdio: 'ignore',
|
||||
detached: true,
|
||||
windowsHide: true,
|
||||
}).unref();
|
||||
} catch {
|
||||
// osascript missing or app not present — non-fatal
|
||||
@@ -317,9 +385,64 @@ function raiseHeadedWindowMacOS(): void {
|
||||
}
|
||||
|
||||
// ─── Server Lifecycle ──────────────────────────────────────────
|
||||
// The detached daemon's stdout/stderr used to be wired to 'ignore' on every
|
||||
// platform, so console.error('[browse] FATAL: ...') from a Chromium crash,
|
||||
// an uncaughtException, or an unhandledRejection (see server.ts's handlers
|
||||
// and browser-manager.ts's handleChromiumDisconnect) went nowhere — not to
|
||||
// a file, not to the terminal, discarded at the OS level (#2461). That made
|
||||
// a crash-and-respawn indistinguishable from any other cause of a dropped
|
||||
// session: nothing on disk ever recorded WHY. Redirect both streams to
|
||||
// <stateDir>/browse-daemon.log — append mode, so it accumulates across the
|
||||
// daemon's full lifetime and every respawn stays visible in one place.
|
||||
//
|
||||
// F6 log hygiene: nothing that reaches the daemon's stdout/stderr may carry
|
||||
// an auth token or unsanitized page-derived strings —
|
||||
// browse/test/daemon-log-hygiene.test.ts pins this with needle tests.
|
||||
//
|
||||
// Single source for the log path (M4): the Unix fd-open path and the Windows
|
||||
// launcher string both build it, and a drifted spelling would silently split
|
||||
// the daemon's history across two files.
|
||||
function daemonLogPath(): string {
|
||||
return path.join(config.stateDir, 'browse-daemon.log');
|
||||
}
|
||||
|
||||
/** Append-mode growth bound: the log accumulates across every respawn (a
|
||||
* crash-respawn loop would otherwise fill the disk), so on daemon start a
|
||||
* log past 10MB (the repo's rotation convention — tunnel-denial-log.ts uses
|
||||
* the same cap) is renamed to browse-daemon.log.1, single generation.
|
||||
* Best-effort: a failed stat/rename must never block the launch.
|
||||
* Path + cap injectable for unit coverage; exported for the same reason. */
|
||||
export const DAEMON_LOG_MAX_BYTES = 10 * 1024 * 1024;
|
||||
export function rotateDaemonLogIfOversized(
|
||||
p: string = daemonLogPath(),
|
||||
maxBytes: number = DAEMON_LOG_MAX_BYTES,
|
||||
): void {
|
||||
try {
|
||||
if (fs.statSync(p).size > maxBytes) {
|
||||
fs.renameSync(p, `${p}.1`);
|
||||
}
|
||||
} catch {
|
||||
// Missing log (first launch) or unwritable state dir — rotation is
|
||||
// best-effort, the launch matters more.
|
||||
}
|
||||
}
|
||||
|
||||
function openDaemonLogSink(): number | 'ignore' {
|
||||
try {
|
||||
return fs.openSync(daemonLogPath(), 'a');
|
||||
} catch {
|
||||
// stateDir not writable (permissions, disk full) — fall back to the
|
||||
// previous behavior rather than fail the whole launch over logging.
|
||||
return 'ignore';
|
||||
}
|
||||
}
|
||||
|
||||
async function startServer(extraEnv?: Record<string, string>): Promise<ServerState> {
|
||||
ensureStateDir(config);
|
||||
|
||||
// Bound the append-mode daemon log before the new daemon starts writing.
|
||||
rotateDaemonLogIfOversized();
|
||||
|
||||
// Clean up stale state file and error log
|
||||
safeUnlink(config.stateFile);
|
||||
safeUnlink(path.join(config.stateDir, 'browse-startup-error.log'));
|
||||
@@ -345,10 +468,18 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
|
||||
// with { detached: true } instead, which is the gold standard for Windows
|
||||
// process independence. Credit: PR #191 by @fqueiro.
|
||||
const extraEnvStr = JSON.stringify({ BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: parentPid, ...(extraEnv || {}) });
|
||||
// The daemon's real process is spawned inside the launcher's own
|
||||
// `node -e` invocation, not in cli.ts's process — so the log file has
|
||||
// to be opened from inside the launcher string too; an fd opened here
|
||||
// in cli.ts wouldn't cross the spawn boundary. Falls back to 'ignore'
|
||||
// the same way openDaemonLogSink() does if the state dir isn't writable.
|
||||
const daemonLogPathStr = JSON.stringify(daemonLogPath());
|
||||
const launcherCode =
|
||||
`const{spawn}=require('child_process');` +
|
||||
`const fs=require('fs');` +
|
||||
`let logFd;try{logFd=fs.openSync(${daemonLogPathStr},'a');}catch(e){logFd='ignore';}` +
|
||||
`spawn(process.execPath,[${JSON.stringify(NODE_SERVER_SCRIPT)}],` +
|
||||
`{detached:true,windowsHide:true,stdio:['ignore','ignore','ignore'],env:Object.assign({},process.env,` +
|
||||
`{detached:true,windowsHide:true,stdio:['ignore',logFd,logFd],env:Object.assign({},process.env,` +
|
||||
`${extraEnvStr})}).unref()`;
|
||||
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true });
|
||||
} else {
|
||||
@@ -363,10 +494,11 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
|
||||
// which calls setsid() so the server becomes its own session leader
|
||||
// (PPID=1, STAT=Ss) and survives the spawning shell's exit. Mirrors
|
||||
// the Windows path's rationale — same root cause, different OS API.
|
||||
const daemonLogFd = openDaemonLogSink();
|
||||
nodeSpawn('bun', ['run', SERVER_SCRIPT], {
|
||||
detached: true,
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
stdio: ['ignore', daemonLogFd, daemonLogFd],
|
||||
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: parentPid, ...extraEnv },
|
||||
}).unref();
|
||||
}
|
||||
@@ -482,7 +614,12 @@ async function ensureServer(flags?: GlobalFlags): Promise<ServerState> {
|
||||
// Health-check-first: HTTP is definitive proof the server is alive and responsive.
|
||||
// This replaces the PID-gated approach which breaks on Windows (Bun's process.kill
|
||||
// always throws ESRCH for Windows PIDs in compiled binaries).
|
||||
if (state && await isServerHealthy(state.port)) {
|
||||
//
|
||||
// #2219: when the single 2s probe fails but the PID is alive, extend to the
|
||||
// bounded ~8s probe before concluding anything — a daemon chewing a heavy
|
||||
// page is busy, not dead, and killing it loses the session.
|
||||
const daemonPidAlive = Boolean(state?.pid && isProcessAlive(state.pid));
|
||||
if (state && (await isServerHealthy(state.port) || (daemonPidAlive && await probeHealthWithBackoff(state.port)))) {
|
||||
// D2 daemon-mismatch check: existing daemon's configHash must match the
|
||||
// CLI's resolved hash. If --proxy or --headed are passed and the existing
|
||||
// daemon was started with different config, refuse with a `disconnect`
|
||||
@@ -530,6 +667,18 @@ async function ensureServer(flags?: GlobalFlags): Promise<ServerState> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// #2219 IRON RULE: never auto-kill an alive pid. The daemon didn't answer
|
||||
// /health within the bounded ~8s budget but its process is alive — that's
|
||||
// busy, not dead. Report + nonzero exit; only an explicit --force-restart
|
||||
// proceeds to the kill-and-restart below.
|
||||
if (state && daemonPidAlive) {
|
||||
if (flags?.forceRestart) {
|
||||
console.error('[browse] --force-restart: replacing live-but-unresponsive daemon (tabs/cookies/logins will be lost)...');
|
||||
} else {
|
||||
reportDaemonBusyAndExit(state.pid);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure state directory exists before lock acquisition (lock file lives there)
|
||||
ensureStateDir(config);
|
||||
|
||||
@@ -656,18 +805,42 @@ async function sendCommand(state: ServerState, command: string, args: string[],
|
||||
// Connection error — server may have crashed, OR may just be busy.
|
||||
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
|
||||
// crash, if the process is alive give /health a bounded chance to recover
|
||||
// and just retry the command — never kill+restart a live-but-busy server.
|
||||
if (oldState?.pid && isProcessAlive(oldState.pid) && await probeHealthWithBackoff(oldState.port)) {
|
||||
// #1781/#2219 busy-vs-dead: a single-threaded daemon under beacon/
|
||||
// extension load (or with a timed-out navigation still churning) can
|
||||
// stop answering HTTP for seconds while fully alive. Give /health a
|
||||
// bounded ~8s to recover, then decide via the pure rule: retry against
|
||||
// the same daemon, report busy (NEVER kill an alive pid), or restart a
|
||||
// genuinely dead one. Only --force-restart may kill a live daemon.
|
||||
const pidAlive = Boolean(oldState?.pid && isProcessAlive(oldState.pid));
|
||||
const healthyAfterProbe = pidAlive ? await probeHealthWithBackoff(oldState!.port) : false;
|
||||
const action = decideDaemonRestart({
|
||||
pidAlive,
|
||||
healthyAfterProbe,
|
||||
forceRestart: Boolean(_globalFlags?.forceRestart),
|
||||
});
|
||||
if (action === 'retry-command') {
|
||||
if (retries >= 1) throw new Error('[browse] Server unresponsive after retry — aborting');
|
||||
console.error('[browse] Server was briefly unresponsive (busy); retrying command...');
|
||||
return sendCommand(oldState, command, args, retries + 1);
|
||||
return sendCommand(oldState!, command, args, retries + 1);
|
||||
}
|
||||
// Truly dead (or health never recovered) → restart.
|
||||
if (action === 'report-busy') {
|
||||
reportDaemonBusyAndExit(oldState!.pid);
|
||||
}
|
||||
// #2254: `stop` against a daemon that died mid-flight is SUCCESS — the
|
||||
// desired end state (no daemon) already holds. Restarting a daemon just
|
||||
// to stop it again was the crash-restart loop the issue reports.
|
||||
if (action === 'restart-dead' && command === 'stop') {
|
||||
safeUnlinkQuiet(config.stateFile);
|
||||
console.log('Daemon already stopped (cleaned stale state).');
|
||||
process.exit(0);
|
||||
}
|
||||
// 'restart-dead' or explicit 'force-restart' → restart.
|
||||
if (retries >= 1) throw new Error('[browse] Server crashed twice in a row — aborting');
|
||||
console.error('[browse] Server connection lost. Restarting...');
|
||||
if (action === 'force-restart') {
|
||||
console.error('[browse] --force-restart: killing live daemon and restarting (tabs/cookies/logins will be lost)...');
|
||||
} else {
|
||||
console.error('[browse] Server connection lost. Restarting...');
|
||||
}
|
||||
if (oldState && oldState.pid) {
|
||||
await killServer(oldState.pid);
|
||||
}
|
||||
@@ -844,6 +1017,9 @@ export interface GlobalFlags {
|
||||
configHash: string;
|
||||
/** Redacted form of proxyUrl, safe for logs. */
|
||||
redactedProxyUrl: string;
|
||||
/** Whether --force-restart was passed (#2219): the ONLY thing that may
|
||||
* kill a live-but-unresponsive daemon. */
|
||||
forceRestart: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -857,9 +1033,11 @@ export function extractGlobalFlags(rawArgs: string[], env: NodeJS.ProcessEnv): G
|
||||
const out: string[] = [];
|
||||
let proxyUrl: string | null = null;
|
||||
let headed = false;
|
||||
let forceRestart = false;
|
||||
|
||||
for (let i = 0; i < rawArgs.length; i++) {
|
||||
const arg = rawArgs[i];
|
||||
if (arg === '--force-restart') { forceRestart = true; continue; }
|
||||
if (arg === '--proxy') {
|
||||
const value = rawArgs[i + 1];
|
||||
if (!value) {
|
||||
@@ -902,6 +1080,7 @@ export function extractGlobalFlags(rawArgs: string[], env: NodeJS.ProcessEnv): G
|
||||
headed,
|
||||
configHash: computeConfigHash({ proxyUrl: canonicalProxyUrl, headed }),
|
||||
redactedProxyUrl: redactProxyUrl(canonicalProxyUrl),
|
||||
forceRestart,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1107,6 +1286,8 @@ Multi-step: chain (reads JSON from stdin)
|
||||
Tabs: tabs | tab <id> | newtab [url] | closetab [id]
|
||||
Server: status | cookie <n>=<v> | header <n>:<v>
|
||||
useragent <str> | stop | restart
|
||||
--force-restart: replace a live-but-busy daemon (any command;
|
||||
LOSES tabs/cookies/logins — never done automatically)
|
||||
Dialogs: dialog-accept [text] | dialog-dismiss
|
||||
|
||||
Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||
@@ -1137,12 +1318,30 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||
process.exit(0);
|
||||
}
|
||||
} catch {
|
||||
// Headed server alive but not responding — kill and restart
|
||||
// Headed server alive but not responding — handled below (#2219:
|
||||
// busy semantics; only --force-restart may kill it).
|
||||
}
|
||||
}
|
||||
|
||||
// Kill ANY existing server (SIGTERM → wait 2s → SIGKILL)
|
||||
// #2219 IRON RULE: a HEALTHY daemon survives connect. The old behavior
|
||||
// ("kill ANY existing server") silently destroyed a working headless
|
||||
// session — tabs, cookies, logins — whenever someone opened the headed
|
||||
// browser. A live daemon is only replaced with explicit consent.
|
||||
if (existingState && isProcessAlive(existingState.pid) && !globalFlags.forceRestart) {
|
||||
if (await isServerHealthy(existingState.port)) {
|
||||
refuseHeadedOverLiveDaemon(existingState);
|
||||
}
|
||||
// Alive but unhealthy after the bounded probe → busy, not dead.
|
||||
if (await probeHealthWithBackoff(existingState.port)) {
|
||||
refuseHeadedOverLiveDaemon(existingState);
|
||||
}
|
||||
reportDaemonBusyAndExit(existingState.pid);
|
||||
}
|
||||
|
||||
// Explicit --force-restart (or a dead pid): kill any remnant
|
||||
// (SIGTERM → wait 2s → SIGKILL).
|
||||
if (existingState && isProcessAlive(existingState.pid)) {
|
||||
console.error('[browse] --force-restart: replacing live daemon (tabs/cookies/logins will be lost)...');
|
||||
safeKill(existingState.pid, 'SIGTERM');
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
if (isProcessAlive(existingState.pid)) {
|
||||
@@ -1386,6 +1585,45 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ─── Stop (pre-server short-circuit, #2254) ──────────────────
|
||||
// stop must be handled BEFORE ensureServer(): stopping a daemon that is
|
||||
// not running must not START one just to stop it. The old flow booted a
|
||||
// fresh daemon + Chromium (multi-second, resource churn) and then told it
|
||||
// to shut down — or crashed trying. No state, or dead pid + dead port →
|
||||
// report "nothing to stop" and exit 0.
|
||||
if (command === 'stop') {
|
||||
const stopState = readState();
|
||||
if (!stopState) {
|
||||
console.log('No daemon running — nothing to stop.');
|
||||
process.exit(0);
|
||||
}
|
||||
if (!isProcessAlive(stopState.pid) && !(await isServerHealthy(stopState.port))) {
|
||||
safeUnlinkQuiet(config.stateFile);
|
||||
console.log('No daemon running (cleaned stale state) — nothing to stop.');
|
||||
process.exit(0);
|
||||
}
|
||||
// stop --force-restart on a LIVE daemon (healthy or busy): kill it and
|
||||
// clean up right here. Falling through would hand ensureServer() the
|
||||
// force-restart flag, which kills the daemon and then BOOTS A FRESH ONE
|
||||
// (daemon + Chromium, multi-second churn) just so sendCommand('stop')
|
||||
// can shut it down again — the #2254 churn in force clothing, and
|
||||
// gstack-upgrade's Step 4.8 sends users down exactly this path when a
|
||||
// stale daemon is busy. The desired end state is "no daemon"; get there
|
||||
// directly.
|
||||
if (isProcessAlive(stopState.pid) && globalFlags.forceRestart) {
|
||||
await killServer(stopState.pid);
|
||||
// Reap the orphaned Chromium child + clear its profile locks so the
|
||||
// NEXT launch is clean (same cleanup as the disconnect force path).
|
||||
await killOrphanChromium();
|
||||
cleanChromiumProfileLocks();
|
||||
safeUnlinkQuiet(config.stateFile);
|
||||
console.log('Daemon stopped (forced — tabs/cookies/logins discarded).');
|
||||
process.exit(0);
|
||||
}
|
||||
// Live daemon without --force-restart → fall through to the normal
|
||||
// sendCommand('stop') path (graceful shutdown; busy semantics apply).
|
||||
}
|
||||
|
||||
// Special case: chain reads from stdin
|
||||
if (command === 'chain' && commandArgs.length === 0) {
|
||||
const stdin = await Bun.stdin.text();
|
||||
@@ -1403,7 +1641,10 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||
// In compiled binaries, process.argv[1] is /$bunfs/... (virtual).
|
||||
// Use process.execPath which is the real binary on disk.
|
||||
const browseBin = process.execPath;
|
||||
const connectProc = Bun.spawn([browseBin, 'connect'], {
|
||||
// --force-restart: the headed switch is this command's explicit purpose
|
||||
// (the user asked to SEE the shared browser), and connect's #2219 guard
|
||||
// would otherwise refuse to replace the healthy headless daemon.
|
||||
const connectProc = Bun.spawn([browseBin, 'connect', '--force-restart'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
// Disable parent-PID monitoring: pair-agent needs the server to outlive
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
|
||||
const IS_WINDOWS = process.platform === 'win32';
|
||||
|
||||
// ─── Filesystem ────────────────────────────────────────────────
|
||||
|
||||
/** Remove a file, ignoring ENOENT (already gone). Rethrows other errors. */
|
||||
@@ -36,23 +34,39 @@ export function safeKill(pid: number, signal: NodeJS.Signals | number): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if a PID is alive. Pure boolean probe — returns false for ALL errors. */
|
||||
/**
|
||||
* Check if a PID is alive. Pure boolean probe — never throws.
|
||||
*
|
||||
* Signal 0 on EVERY platform (#1952). Node maps `process.kill(pid, 0)` to an
|
||||
* OpenProcess existence check on Windows — and on Windows the browse daemon
|
||||
* runs under Node (dist/server-node.mjs + bun-polyfill, the documented
|
||||
* fallback for oven-sh/bun#4253) — so the POSIX idiom is portable here.
|
||||
*
|
||||
* Windows used to shell out to `tasklist /FI "PID eq <pid>"` and
|
||||
* string-match the CSV. That was wrong in two ways, both hit in production:
|
||||
*
|
||||
* 1. FALSE NEGATIVES UNDER LOAD (#2414/#2295): tasklist takes ~700-1700ms
|
||||
* on an idle 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 that validate liveness before killing
|
||||
* (killAgentByRecord, the terminal-agent watchdog) then skipped the
|
||||
* kill and respawned around the survivor — one leaked terminal-agent
|
||||
* per tick, self-reinforcing (each orphan slows the next tasklist).
|
||||
* 2. A console window per probe (#1952): the watchdog blinked a conhost
|
||||
* window into the foreground every 60s for the whole session.
|
||||
*
|
||||
* Signal 0 spawns nothing, cannot time out, and is orders of magnitude
|
||||
* faster (~0.004ms vs ~270ms measured in #2414).
|
||||
*
|
||||
* EPERM means the process EXISTS but we lack rights to signal it. That is
|
||||
* alive — returning false there would reintroduce failure mode 1.
|
||||
*/
|
||||
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 {
|
||||
return false;
|
||||
} catch (err: any) {
|
||||
return err?.code === 'EPERM';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ function currentUserSid(): string | null {
|
||||
const systemRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows';
|
||||
const out = execFileSync(`${systemRoot}\\System32\\whoami.exe`, ['/user', '/fo', 'csv', '/nh'], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
});
|
||||
const match = out.match(/S-1-[\d-]+/);
|
||||
cachedSid = match ? match[0] : null;
|
||||
|
||||
@@ -30,7 +30,7 @@ export interface SidecarLocation {
|
||||
|
||||
function nodeOnPath(): string | null {
|
||||
try {
|
||||
execFileSync("node", ["--version"], { stdio: "ignore", timeout: 2000 });
|
||||
execFileSync("node", ["--version"], { stdio: "ignore", timeout: 2000, windowsHide: true });
|
||||
return "node";
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -777,7 +777,7 @@ export async function handleMetaCommand(
|
||||
let activated = false;
|
||||
for (const appName of appNames) {
|
||||
try {
|
||||
execSync(`osascript -e 'tell application "${appName}" to activate'`, { stdio: 'pipe', timeout: 3000 });
|
||||
execSync(`osascript -e 'tell application "${appName}" to activate'`, { stdio: 'pipe', timeout: 3000, windowsHide: true });
|
||||
activated = true;
|
||||
break;
|
||||
} catch (err: any) {
|
||||
@@ -841,7 +841,7 @@ export async function handleMetaCommand(
|
||||
const { execSync } = await import('child_process');
|
||||
let gitRoot: string;
|
||||
try {
|
||||
gitRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
||||
gitRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }).trim();
|
||||
} catch (err: any) {
|
||||
// execSync throws with exit status on non-git directories
|
||||
if (err?.status === undefined && !err?.message?.includes('Command failed')) throw err;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Shared loopback port allocation (#2314, decision 8).
|
||||
*
|
||||
* One fixed scan range (10000-49151) for EVERY long-lived gstack listener:
|
||||
* the main browse daemon and the terminal-agent. Binding `port: 0` instead
|
||||
* hands out a port from the OS EPHEMERAL range (49152-65535 on macOS) — the
|
||||
* same pool every short-lived test server draws from — so a daemon that
|
||||
* lives for weeks ends up squatting ports that `app.listen(0)` test servers
|
||||
* expect to receive, silently absorbing their traffic as phantom 404s. The
|
||||
* range therefore ends AT 49151: a max above it would put a fraction of
|
||||
* picks back inside the pool this module exists to avoid (the original
|
||||
* 60000 cap left ~22% of allocations in 49152-59999).
|
||||
*
|
||||
* Extracted from server.ts (which had this logic since #486) so
|
||||
* terminal-agent.ts can reuse it without importing the whole server module.
|
||||
*/
|
||||
|
||||
import * as net from 'net';
|
||||
|
||||
export type PortCheckResult =
|
||||
| { available: true }
|
||||
| { available: false; code?: string; message: string };
|
||||
|
||||
export type FailedPortAttempt = {
|
||||
port: number;
|
||||
result: Extract<PortCheckResult, { available: false }>;
|
||||
};
|
||||
|
||||
export const RANDOM_PORT_MIN = 10000;
|
||||
export const RANDOM_PORT_MAX = 49151; // last port BELOW the macOS ephemeral pool (49152-65535)
|
||||
export const RANDOM_PORT_RETRIES = 5;
|
||||
|
||||
export function normalizePortError(err: unknown): Extract<PortCheckResult, { available: false }> {
|
||||
const maybeNodeError = err as NodeJS.ErrnoException | undefined;
|
||||
return {
|
||||
available: false,
|
||||
code: maybeNodeError?.code,
|
||||
message: maybeNodeError?.message || String(err),
|
||||
};
|
||||
}
|
||||
|
||||
export function isOccupiedPort(result: Extract<PortCheckResult, { available: false }>): boolean {
|
||||
return result.code === 'EADDRINUSE';
|
||||
}
|
||||
|
||||
export function formatPortFailureDetail(attempt: FailedPortAttempt): string {
|
||||
const { code, message } = attempt.result;
|
||||
return code ? `${attempt.port} (${code}: ${message})` : `${attempt.port} (${message})`;
|
||||
}
|
||||
|
||||
export function formatExplicitPortUnavailableError(
|
||||
port: number,
|
||||
result: Extract<PortCheckResult, { available: false }>
|
||||
): Error {
|
||||
if (isOccupiedPort(result)) {
|
||||
return new Error(`[browse] Port ${port} (from BROWSE_PORT env) is in use`);
|
||||
}
|
||||
|
||||
const detail = result.code ? `${result.code}: ${result.message}` : result.message;
|
||||
return new Error(
|
||||
`[browse] Cannot bind BROWSE_PORT=${port} on 127.0.0.1 (${detail}). ` +
|
||||
`This usually means localhost port binding is blocked by the current sandbox or OS permissions, ` +
|
||||
`not that the port is occupied. Allow localhost binding, or run browse from an unrestricted terminal.`
|
||||
);
|
||||
}
|
||||
|
||||
export function formatRandomPortUnavailableError(attempts: FailedPortAttempt[]): Error {
|
||||
const blockingAttempts = attempts.filter((attempt) => !isOccupiedPort(attempt.result));
|
||||
|
||||
if (blockingAttempts.length > 0) {
|
||||
const last = blockingAttempts[blockingAttempts.length - 1];
|
||||
return new Error(
|
||||
`[browse] Cannot bind localhost ports after ${attempts.length} attempts in range ` +
|
||||
`${RANDOM_PORT_MIN}-${RANDOM_PORT_MAX}. Last error: ${formatPortFailureDetail(last)}. ` +
|
||||
`This usually means the current sandbox or OS permissions are blocking localhost port binding, ` +
|
||||
`not that every sampled port is occupied. Allow localhost binding, set BROWSE_PORT to an approved ` +
|
||||
`port, or run browse from an unrestricted terminal.`
|
||||
);
|
||||
}
|
||||
|
||||
return new Error(
|
||||
`[browse] No available port after ${RANDOM_PORT_RETRIES} attempts in range ` +
|
||||
`${RANDOM_PORT_MIN}-${RANDOM_PORT_MAX}; every sampled port was already in use`
|
||||
);
|
||||
}
|
||||
|
||||
// Test if a port is available by binding and immediately releasing.
|
||||
// Uses net.createServer instead of Bun.serve to avoid a race condition
|
||||
// in the Node.js polyfill where listen/close are async but the caller
|
||||
// expects synchronous bind semantics. See: #486
|
||||
export function checkPortAvailable(port: number, hostname: string = '127.0.0.1'): Promise<PortCheckResult> {
|
||||
return new Promise((resolve) => {
|
||||
const srv = net.createServer();
|
||||
let settled = false;
|
||||
const finish = (result: PortCheckResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
srv.once('error', (err) => finish(normalizePortError(err)));
|
||||
try {
|
||||
srv.listen(port, hostname, () => {
|
||||
srv.close(() => finish({ available: true }));
|
||||
});
|
||||
} catch (err) {
|
||||
finish(normalizePortError(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function isPortAvailable(port: number, hostname: string = '127.0.0.1'): Promise<boolean> {
|
||||
return checkPortAvailable(port, hostname).then((result) => result.available);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a port: the explicit override when given, otherwise a random port in
|
||||
* the fixed 10000-49151 scan range with bounded retries. NEVER `port: 0` —
|
||||
* see the module header for why the ephemeral range is off-limits.
|
||||
*/
|
||||
export async function findAvailablePort(explicitPort?: number | null): Promise<number> {
|
||||
if (explicitPort) {
|
||||
const result = await checkPortAvailable(explicitPort);
|
||||
if (result.available) {
|
||||
return explicitPort;
|
||||
}
|
||||
throw formatExplicitPortUnavailableError(explicitPort, result);
|
||||
}
|
||||
|
||||
const attempts: FailedPortAttempt[] = [];
|
||||
for (let attempt = 0; attempt < RANDOM_PORT_RETRIES; attempt++) {
|
||||
const port = RANDOM_PORT_MIN + Math.floor(Math.random() * (RANDOM_PORT_MAX - RANDOM_PORT_MIN));
|
||||
const result = await checkPortAvailable(port);
|
||||
if (result.available) {
|
||||
return port;
|
||||
}
|
||||
attempts.push({ port, result });
|
||||
}
|
||||
throw formatRandomPortUnavailableError(attempts);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export function getCurrentProjectSlug(): string {
|
||||
}
|
||||
try {
|
||||
const slugBin = path.join(os.homedir(), '.claude/skills/gstack/bin/gstack-slug');
|
||||
const out = execSync(slugBin, { encoding: 'utf8', timeout: 2000 }).trim();
|
||||
const out = execSync(slugBin, { encoding: 'utf8', timeout: 2000, windowsHide: true }).trim();
|
||||
const m = out.match(/SLUG="?([^"\n]+)"?/);
|
||||
cachedSlug = m ? m[1]! : (out || 'unknown');
|
||||
} catch {
|
||||
|
||||
@@ -138,6 +138,9 @@ function spawnSidecar(): boolean {
|
||||
const child = spawn(location.node, [location.entry], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
detached: false,
|
||||
// Long-lived Node sidecar — without this, Windows gives it a console
|
||||
// window that sits on the taskbar for the daemon's whole lifetime.
|
||||
windowsHide: true,
|
||||
});
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
s.buffer += chunk.toString("utf-8");
|
||||
|
||||
+13
-94
@@ -23,18 +23,19 @@
|
||||
* host process): the combiner is pure and tested, and server.ts's
|
||||
* inline L4 path is the consumer of record.
|
||||
*
|
||||
* Cross-process state lives at ~/.gstack/security/session-state.json.
|
||||
* classifierStatus in that state has no live writer since the chat-path rip
|
||||
* (the sidecar reports status over its own NDJSON protocol instead).
|
||||
* There is no longer any cross-process session state (#2557).
|
||||
* ~/.gstack/security/session-state.json existed to carry classifier status
|
||||
* across the server.ts / sidebar-agent.ts boundary; sidebar-agent.ts went
|
||||
* away with the PTY terminal rewrite, leaving nothing to write the file and
|
||||
* a /health.security status that reported stale or empty data — a permanent
|
||||
* 'inactive', or a false-green 'protected' wherever an old state file
|
||||
* survived on disk. getStatus / SessionState / read+writeSessionState and
|
||||
* the /health field were removed together. Per-tab decision files under
|
||||
* ~/.gstack/security/decisions/ are unaffected, and the L4 sidecar reports
|
||||
* status over its own NDJSON protocol (security-sidecar-client.ts).
|
||||
*/
|
||||
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
import { spawn } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { restrictFilePermissions, appendSecureFile, mkdirSecure } from './file-permissions';
|
||||
import { atomicWriteQuiet } from '../../lib/fs-atomic';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
// ─── Thresholds + verdict types ──────────────────────────────
|
||||
|
||||
@@ -83,17 +84,6 @@ export interface SecurityResult {
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export type SecurityStatus = 'protected' | 'degraded' | 'inactive';
|
||||
|
||||
export interface StatusDetail {
|
||||
status: SecurityStatus;
|
||||
layers: {
|
||||
testsavant: 'ok' | 'degraded' | 'off';
|
||||
canary: 'ok' | 'off';
|
||||
};
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
// ─── Verdict combiner (ensemble rule, label-first for transcript) ────
|
||||
|
||||
/**
|
||||
@@ -322,79 +312,8 @@ export function checkCanaryInStructure(value: unknown, canary: string): boolean
|
||||
// attempts.jsonl rotation + telemetry spawn plumbing) lived here until the
|
||||
// chat-path scanner that called it was ripped with sidebar-agent.ts. The
|
||||
// LIVE attempts.jsonl writer is tunnel-denial-log.ts, which owns its own
|
||||
// rotation.
|
||||
|
||||
const SECURITY_DIR = path.join(os.homedir(), '.gstack', 'security');
|
||||
|
||||
// ─── Cross-process session state ─────────────────────────────
|
||||
|
||||
const STATE_FILE = path.join(SECURITY_DIR, 'session-state.json');
|
||||
|
||||
/**
|
||||
* SessionState is a DISK FORMAT (~/.gstack/security/session-state.json).
|
||||
* Old files may carry a `transcript` field inside classifierStatus from the
|
||||
* removed Haiku layer — readSessionState tolerates it (JSON.parse keeps the
|
||||
* extra key; getStatus ignores it), but we never write it.
|
||||
*/
|
||||
export interface SessionState {
|
||||
sessionId: string;
|
||||
canary: string;
|
||||
warnedDomains: string[]; // per-session rate limit for special telemetry
|
||||
classifierStatus: {
|
||||
testsavant: 'ok' | 'degraded' | 'off';
|
||||
};
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic write of session state (via lib/fs-atomic). Writes are safe
|
||||
* across process boundaries. Swallow-with-log polarity: a failed write
|
||||
* must never take down the caller (security state is best-effort cache).
|
||||
*/
|
||||
export function writeSessionState(state: SessionState): void {
|
||||
try { mkdirSecure(SECURITY_DIR); } catch { /* write below fails and logs */ }
|
||||
if (atomicWriteQuiet(STATE_FILE, JSON.stringify(state, null, 2), { mode: 0o600 })) {
|
||||
// Windows ACL hardening (POSIX chmod is redundant with mode above).
|
||||
restrictFilePermissions(STATE_FILE);
|
||||
} else {
|
||||
console.error('[security] writeSessionState failed');
|
||||
}
|
||||
}
|
||||
|
||||
export function readSessionState(): SessionState | null {
|
||||
try {
|
||||
if (!fs.existsSync(STATE_FILE)) return null;
|
||||
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Status reporting (for shield icon via /health) ──────────
|
||||
|
||||
export function getStatus(): StatusDetail {
|
||||
const state = readSessionState();
|
||||
// Read the field explicitly (never spread classifierStatus): old on-disk
|
||||
// state may carry a stale `transcript` key from the removed Haiku layer,
|
||||
// and spreading would leak it into the /health payload.
|
||||
const testsavant = state?.classifierStatus?.testsavant ?? 'off';
|
||||
const canary = state?.canary ? 'ok' : 'off';
|
||||
|
||||
let status: SecurityStatus;
|
||||
if (testsavant === 'ok' && canary === 'ok') {
|
||||
status = 'protected';
|
||||
} else if (testsavant === 'off' && canary === 'off') {
|
||||
status = 'inactive';
|
||||
} else {
|
||||
status = 'degraded';
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
layers: { testsavant, canary: canary as 'ok' | 'off' },
|
||||
lastUpdated: state?.lastUpdated ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
// rotation. The cross-process session state + getStatus shield feed went
|
||||
// the same way (#2557) — see the module header.
|
||||
|
||||
/**
|
||||
* Extract url domain for logging. Never logs path or query string.
|
||||
|
||||
+23
-121
@@ -24,7 +24,6 @@ import {
|
||||
runContentFilters, type ContentFilterResult,
|
||||
markHiddenElements, getCleanTextWithStripping, cleanupHiddenMarkers,
|
||||
} from './content-security';
|
||||
import { getStatus as getSecurityStatus } from './security';
|
||||
import { isSidecarAvailable, scanWithSidecar } from './security-sidecar-client';
|
||||
import { writeSecureFile, mkdirSecure, appendSecureFile } from './file-permissions';
|
||||
import { handleSnapshot, SNAPSHOT_FLAGS } from './snapshot';
|
||||
@@ -47,6 +46,9 @@ import { inspectElement, modifyStyle, resetModifications, getModificationHistory
|
||||
// Bun.spawn used instead of child_process.spawn (compiled bun binaries
|
||||
// fail posix_spawn on all executables including /bin/bash)
|
||||
import { safeUnlink, safeUnlinkQuiet, safeKill } from './error-handling';
|
||||
import {
|
||||
findAvailablePort, formatExplicitPortUnavailableError, formatRandomPortUnavailableError,
|
||||
} from './port-allocator';
|
||||
import { readAgentRecord, killAgentByRecord, agentRecordPath, spawnTerminalAgent } from './terminal-agent-control';
|
||||
import { isProcessAlive } from './error-handling';
|
||||
import { sanitizeBody, stripLoneSurrogateEscapes, stripLoneSurrogates, sanitizeReplacer } from './sanitize';
|
||||
@@ -915,124 +917,14 @@ let isShuttingDown = false;
|
||||
// the good final snapshot with a degraded one (zero tabs).
|
||||
let sessionPersistInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
type PortCheckResult =
|
||||
| { available: true }
|
||||
| { available: false; code?: string; message: string };
|
||||
|
||||
type FailedPortAttempt = {
|
||||
port: number;
|
||||
result: Extract<PortCheckResult, { available: false }>;
|
||||
};
|
||||
|
||||
const RANDOM_PORT_MIN = 10000;
|
||||
const RANDOM_PORT_MAX = 60000;
|
||||
const RANDOM_PORT_RETRIES = 5;
|
||||
|
||||
function normalizePortError(err: unknown): Extract<PortCheckResult, { available: false }> {
|
||||
const maybeNodeError = err as NodeJS.ErrnoException | undefined;
|
||||
return {
|
||||
available: false,
|
||||
code: maybeNodeError?.code,
|
||||
message: maybeNodeError?.message || String(err),
|
||||
};
|
||||
}
|
||||
|
||||
function isOccupiedPort(result: Extract<PortCheckResult, { available: false }>): boolean {
|
||||
return result.code === 'EADDRINUSE';
|
||||
}
|
||||
|
||||
function formatPortFailureDetail(attempt: FailedPortAttempt): string {
|
||||
const { code, message } = attempt.result;
|
||||
return code ? `${attempt.port} (${code}: ${message})` : `${attempt.port} (${message})`;
|
||||
}
|
||||
|
||||
function formatExplicitPortUnavailableError(
|
||||
port: number,
|
||||
result: Extract<PortCheckResult, { available: false }>
|
||||
): Error {
|
||||
if (isOccupiedPort(result)) {
|
||||
return new Error(`[browse] Port ${port} (from BROWSE_PORT env) is in use`);
|
||||
}
|
||||
|
||||
const detail = result.code ? `${result.code}: ${result.message}` : result.message;
|
||||
return new Error(
|
||||
`[browse] Cannot bind BROWSE_PORT=${port} on 127.0.0.1 (${detail}). ` +
|
||||
`This usually means localhost port binding is blocked by the current sandbox or OS permissions, ` +
|
||||
`not that the port is occupied. Allow localhost binding, or run browse from an unrestricted terminal.`
|
||||
);
|
||||
}
|
||||
|
||||
function formatRandomPortUnavailableError(attempts: FailedPortAttempt[]): Error {
|
||||
const blockingAttempts = attempts.filter((attempt) => !isOccupiedPort(attempt.result));
|
||||
|
||||
if (blockingAttempts.length > 0) {
|
||||
const last = blockingAttempts[blockingAttempts.length - 1];
|
||||
return new Error(
|
||||
`[browse] Cannot bind localhost ports after ${attempts.length} attempts in range ` +
|
||||
`${RANDOM_PORT_MIN}-${RANDOM_PORT_MAX}. Last error: ${formatPortFailureDetail(last)}. ` +
|
||||
`This usually means the current sandbox or OS permissions are blocking localhost port binding, ` +
|
||||
`not that every sampled port is occupied. Allow localhost binding, set BROWSE_PORT to an approved ` +
|
||||
`port, or run browse from an unrestricted terminal.`
|
||||
);
|
||||
}
|
||||
|
||||
return new Error(
|
||||
`[browse] No available port after ${RANDOM_PORT_RETRIES} attempts in range ` +
|
||||
`${RANDOM_PORT_MIN}-${RANDOM_PORT_MAX}; every sampled port was already in use`
|
||||
);
|
||||
}
|
||||
|
||||
// Test if a port is available by binding and immediately releasing.
|
||||
// Uses net.createServer instead of Bun.serve to avoid a race condition
|
||||
// in the Node.js polyfill where listen/close are async but the caller
|
||||
// expects synchronous bind semantics. See: #486
|
||||
function checkPortAvailable(port: number, hostname: string = '127.0.0.1'): Promise<PortCheckResult> {
|
||||
return new Promise((resolve) => {
|
||||
const srv = net.createServer();
|
||||
let settled = false;
|
||||
const finish = (result: PortCheckResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
srv.once('error', (err) => finish(normalizePortError(err)));
|
||||
try {
|
||||
srv.listen(port, hostname, () => {
|
||||
srv.close(() => finish({ available: true }));
|
||||
});
|
||||
} catch (err) {
|
||||
finish(normalizePortError(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function isPortAvailable(port: number, hostname: string = '127.0.0.1'): Promise<boolean> {
|
||||
return checkPortAvailable(port, hostname).then((result) => result.available);
|
||||
}
|
||||
// Port allocation lives in port-allocator.ts (#2314, decision 8) so the
|
||||
// terminal-agent shares the SAME fixed 10000-60000 scan range instead of
|
||||
// binding port:0 into the OS ephemeral range. The imports at the top of
|
||||
// this file re-expose the pieces __testInternals__ pins.
|
||||
|
||||
// Find port: explicit BROWSE_PORT, or random in 10000-60000
|
||||
async function findPort(): Promise<number> {
|
||||
// Explicit port override (for debugging)
|
||||
if (BROWSE_PORT) {
|
||||
const result = await checkPortAvailable(BROWSE_PORT);
|
||||
if (result.available) {
|
||||
return BROWSE_PORT;
|
||||
}
|
||||
throw formatExplicitPortUnavailableError(BROWSE_PORT, result);
|
||||
}
|
||||
|
||||
// Random port with retry
|
||||
const attempts: FailedPortAttempt[] = [];
|
||||
for (let attempt = 0; attempt < RANDOM_PORT_RETRIES; attempt++) {
|
||||
const port = RANDOM_PORT_MIN + Math.floor(Math.random() * (RANDOM_PORT_MAX - RANDOM_PORT_MIN));
|
||||
const result = await checkPortAvailable(port);
|
||||
if (result.available) {
|
||||
return port;
|
||||
}
|
||||
attempts.push({ port, result });
|
||||
}
|
||||
throw formatRandomPortUnavailableError(attempts);
|
||||
function findPort(): Promise<number> {
|
||||
return findAvailablePort(BROWSE_PORT);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1493,6 +1385,13 @@ async function handleCommand(body: any, tokenInfo?: TokenInfo | null): Promise<R
|
||||
if (import.meta.main) {
|
||||
// SIGINT (Ctrl+C): user intentionally stopping → shutdown.
|
||||
process.on('SIGINT', () => activeShutdown?.());
|
||||
// SIGHUP (terminal hangup): with handleSIGHUP:false at the three launch
|
||||
// sites (#2220), Playwright no longer closes Chromium when this process
|
||||
// gets hung up on — this handler is now the ONLY Chromium cleanup on
|
||||
// SIGHUP (ENG-OV4). Route to the same shutdown path as SIGINT:
|
||||
// activeShutdown closes the browser, releases ports, and removes the
|
||||
// state file. Without it, a hangup would leak a live Chromium.
|
||||
process.on('SIGHUP', () => activeShutdown?.());
|
||||
// SIGTERM behavior depends on mode:
|
||||
// - Normal (headless) mode: Claude Code's Bash sandbox fires SIGTERM when the
|
||||
// parent shell exits between tool invocations. Ignoring it keeps the server
|
||||
@@ -2005,10 +1904,13 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
||||
mode: browserManager.getConnectionMode(),
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
tabs: browserManager.getTabCount(),
|
||||
// Security module status — drives the shield icon in the sidepanel.
|
||||
// Returns {status: 'protected'|'degraded'|'inactive', layers: {...}}.
|
||||
// Fed by the page-content side (testsavant sidecar, canary state).
|
||||
security: getSecurityStatus(),
|
||||
// No `security` field (#2557): the only writer of the status it
|
||||
// reported (sidebar-agent.ts's session-state file) went away with
|
||||
// the chat path, so it read from a file nothing wrote — reporting
|
||||
// a permanent 'inactive', or a stale false-green 'protected'
|
||||
// wherever an old state file survived on disk. The live defenses
|
||||
// (content-security L1-L3, the L4 sidecar on /pty-inject-scan)
|
||||
// report through their own call sites, not through /health.
|
||||
// Terminal-agent discovery. ONLY a port number — never a token.
|
||||
// Tokens flow via the /pty-session HttpOnly cookie path. See
|
||||
// `pty-session-cookie.ts` for the rationale (codex outside-voice
|
||||
|
||||
@@ -27,6 +27,7 @@ import { writeSecureFile, restrictFilePermissions, mkdirSecure } from './file-pe
|
||||
import { atomicWriteSync, atomicWriteQuiet } from '../../lib/fs-atomic';
|
||||
import { safeUnlink } from './error-handling';
|
||||
import { writeAgentRecord, clearAgentRecord } from './terminal-agent-control';
|
||||
import { findAvailablePort } from './port-allocator';
|
||||
import { extractPtyCookie } from './pty-session-cookie';
|
||||
|
||||
const STATE_FILE = process.env.BROWSE_STATE_FILE || path.join(process.env.HOME || '/tmp', '.gstack', 'browse.json');
|
||||
@@ -490,10 +491,15 @@ function maybeSpawnPty(ws: any, session: PtySession): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildServer() {
|
||||
function buildServer(port: number) {
|
||||
return Bun.serve({
|
||||
hostname: '127.0.0.1',
|
||||
port: 0,
|
||||
// #2314: allocated from the SAME fixed 10000-60000 scan range the main
|
||||
// server uses (port-allocator.ts, decision 8) — never `port: 0`. Binding
|
||||
// 0 drew from the OS EPHEMERAL range (49152-65535 on macOS), where this
|
||||
// weeks-lived agent squatted ports that short-lived `app.listen(0)` test
|
||||
// servers expected to receive, absorbing their traffic as phantom 404s.
|
||||
port,
|
||||
idleTimeout: 0, // PTY connections are long-lived; default idleTimeout would kill them
|
||||
|
||||
fetch(req, server) {
|
||||
@@ -944,9 +950,27 @@ function readBrowseToken(): string {
|
||||
}
|
||||
|
||||
// Boot.
|
||||
function main() {
|
||||
async function main() {
|
||||
writeClaudeAvailable();
|
||||
const server = buildServer();
|
||||
// #2314: allocate from the shared fixed scan range, then bind. Probe-then-
|
||||
// bind has a TOCTOU window — a concurrent process can take the port between
|
||||
// the probe and Bun.serve, which throws and would kill the boot with no
|
||||
// retry (main().catch → exit 1). Re-allocate and retry a few times; each
|
||||
// iteration probes fresh, so only a genuine race lands here.
|
||||
let server: ReturnType<typeof buildServer> | undefined;
|
||||
let lastBindErr: unknown;
|
||||
for (let attempt = 0; attempt < 5 && !server; attempt++) {
|
||||
const allocatedPort = await findAvailablePort();
|
||||
try {
|
||||
server = buildServer(allocatedPort);
|
||||
} catch (err) {
|
||||
lastBindErr = err;
|
||||
}
|
||||
}
|
||||
if (!server) {
|
||||
console.error(`[terminal-agent] failed to bind after 5 attempts: ${lastBindErr}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const port = (server as any).port || (server as any).address?.port;
|
||||
if (!port) {
|
||||
console.error('[terminal-agent] failed to bind: no port');
|
||||
@@ -1015,4 +1039,7 @@ try {
|
||||
writeSecureFile(INTERNAL_TOKEN_FILE, INTERNAL_TOKEN);
|
||||
} catch {}
|
||||
|
||||
main();
|
||||
main().catch((err) => {
|
||||
console.error(`[terminal-agent] boot failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* XProtect launch-kill self-heal (P0 #2554).
|
||||
*
|
||||
* macOS XProtect definition updates can start killing the exact Chromium
|
||||
* revision the committed bun.lock pins (observed: revision 1208 under
|
||||
* playwright 1.58.2 — xprotectd SIGKILLs chrome-headless-shell at spawn, so
|
||||
* the failure surfaces as a Playwright launch timeout or a "Browser closed"
|
||||
* error carrying `signal=SIGKILL`, never anything naming XProtect).
|
||||
*
|
||||
* The heal, in order, at most ONCE per process (F4):
|
||||
* 1. Classify the launch failure against the XProtect kill signature
|
||||
* (positive AND negative fixtures under test, F9).
|
||||
* 2. Clear com.apple.quarantine on the Playwright cache bundles ONLY —
|
||||
* a GSTACK_CHROMIUM_PATH bundle belongs to the wrapper/embedder and is
|
||||
* never touched (same scope contract as probePoisonedChromiumBundle).
|
||||
* 3. Force-reinstall Chromium FROM THE GSTACK INSTALL ROOT (ENG-OV3: the
|
||||
* root whose node_modules pins the same playwright-core our compiled
|
||||
* binary embeds — a cwd-resolved `bunx playwright install` would fetch
|
||||
* the LATEST playwright's revision, which the embedded playwright-core
|
||||
* won't find, and the one-shot guard would then block the retry).
|
||||
* The install is BOUNDED (~120s, process-GROUP kill on timeout; E1).
|
||||
* 4. Verify the revision dir the embedded playwright-core EXPECTS exists
|
||||
* post-heal (registry-derived expectation, not merely install exit 0).
|
||||
*
|
||||
* Every action emits one structured stderr line (F11). When the heal cannot
|
||||
* complete (offline, timeout, no install root, one-shot spent), the caller
|
||||
* surfaces the ORIGINAL launch error plus manual
|
||||
* `bunx playwright install chromium` guidance — the CLI never hangs on it.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
/** F11: one structured stderr line per self-heal action. */
|
||||
function logHeal(action: string, fields: Record<string, unknown> = {}): void {
|
||||
console.error(`[browse:xprotect-heal] ${JSON.stringify({ action, ...fields })}`);
|
||||
}
|
||||
|
||||
// ─── Classifier (F9: positives AND negatives) ────────────────────────────
|
||||
|
||||
/**
|
||||
* Failure shapes that are definitively NOT an XProtect kill. Checked before
|
||||
* the positives so an ambiguous message never triggers a pointless reinstall:
|
||||
* - missing executable (browser was never installed / cache wiped)
|
||||
* - spawn-level permission errors (EACCES / EPERM / ENOENT)
|
||||
* - Linux sandbox denials (wrong OS anyway, but the text is distinctive)
|
||||
*/
|
||||
const NEGATIVE_SIGNATURES: RegExp[] = [
|
||||
/executable doesn't exist/i,
|
||||
/spawn\s+\S+\s+(EACCES|EPERM|ENOENT)/i,
|
||||
/\b(EACCES|EPERM)\b/,
|
||||
/no usable sandbox/i,
|
||||
/failed to move to new namespace/i,
|
||||
/suid sandbox helper/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Failure shapes an OS-level kill produces (sourced from the #2554 report
|
||||
* plus Playwright's launch-error format): the browser process SPAWNED, then
|
||||
* died to SIGKILL, or never became ready (launch timeout with a `<launched>`
|
||||
* marker — the report's visible symptom, since xprotectd kills the child
|
||||
* without Playwright ever learning why).
|
||||
*/
|
||||
const POSITIVE_SIGNATURES: RegExp[] = [
|
||||
/<process did exit:[^>]*signal=SIGKILL/i,
|
||||
/signal[:=]\s*['"]?SIGKILL/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* True when a launch failure message matches the macOS XProtect kill
|
||||
* signature. Platform-gated: XProtect exists only on darwin.
|
||||
*/
|
||||
export function isXProtectKillSignature(
|
||||
message: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): boolean {
|
||||
if (platform !== 'darwin') return false;
|
||||
if (!message) return false;
|
||||
for (const neg of NEGATIVE_SIGNATURES) {
|
||||
if (neg.test(message)) return false;
|
||||
}
|
||||
for (const pos of POSITIVE_SIGNATURES) {
|
||||
if (pos.test(message)) return true;
|
||||
}
|
||||
// XProtect kill at spawn also surfaces as a launch timeout where the
|
||||
// process DID launch (<launched> marker present) but never became ready —
|
||||
// this is the exact symptom the #2554 report describes.
|
||||
return /timeout \d+\s*ms exceeded/i.test(message) && /<launched>/i.test(message);
|
||||
}
|
||||
|
||||
// ─── Playwright cache path helpers (pure) ────────────────────────────────
|
||||
|
||||
const REVISION_DIR_RE = /^chromium(?:_headless_shell)?-\d+$/;
|
||||
|
||||
/**
|
||||
* Walk up from a Chromium executable to its Playwright cache revision dir
|
||||
* (e.g. …/ms-playwright/chromium-1234 or …/chromium_headless_shell-1234).
|
||||
* Returns null when the executable is not in the standard cache layout.
|
||||
*/
|
||||
export function findPlaywrightRevisionDir(executablePath: string): string | null {
|
||||
let dir = path.dirname(executablePath);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (REVISION_DIR_RE.test(path.basename(dir))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Chromium revision the EMBEDDED playwright-core expects, derived from
|
||||
* the registry-computed executable path (chromium.executablePath() embeds
|
||||
* the revision from playwright-core's browsers.json — it is not read from
|
||||
* disk, so it stays correct even when nothing is installed yet).
|
||||
*/
|
||||
export function expectedChromiumRevision(executablePath: string): string | null {
|
||||
const revDir = findPlaywrightRevisionDir(executablePath);
|
||||
if (!revDir) return null;
|
||||
const m = path.basename(revDir).match(/-(\d+)$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the gstack install root whose node_modules pins the SAME
|
||||
* playwright-core revision our binary embeds (ENG-OV3). Candidates:
|
||||
* the dev checkout (source runs) and the global ./setup install. A candidate
|
||||
* qualifies only when its playwright-core/browsers.json chromium revision
|
||||
* matches — running the reinstall anywhere else heals to the WRONG revision.
|
||||
*/
|
||||
export function findGstackInstallRoot(
|
||||
expectedRevision: string,
|
||||
candidates?: string[],
|
||||
): string | null {
|
||||
const roots = candidates ?? [
|
||||
// Dev checkout: browse/src/ → repo root. In the compiled binary
|
||||
// __dirname points into the bunfs bundle and won't exist on disk,
|
||||
// so this candidate simply fails the existsSync below.
|
||||
path.resolve(__dirname, '..', '..'),
|
||||
// Global install root (the ./setup target). os.homedir() rather than
|
||||
// process.env.HOME: with HOME unset the env form produced the RELATIVE
|
||||
// path '.claude/skills/gstack' under the daemon's cwd — often an
|
||||
// untrusted repo being QA'd, whose planted node_modules would then be
|
||||
// where the heal runs the playwright install (repo-controlled code
|
||||
// execution). The absolute-or-skip guard below backstops the class.
|
||||
path.join(os.homedir(), '.claude', 'skills', 'gstack'),
|
||||
];
|
||||
for (const root of roots) {
|
||||
if (!path.isAbsolute(root)) continue;
|
||||
try {
|
||||
const browsersJson = path.join(root, 'node_modules', 'playwright-core', 'browsers.json');
|
||||
if (!fs.existsSync(browsersJson)) continue;
|
||||
const parsed = JSON.parse(fs.readFileSync(browsersJson, 'utf-8'));
|
||||
const rev = parsed?.browsers?.find((b: { name?: string }) => b?.name === 'chromium')?.revision;
|
||||
if (String(rev) === String(expectedRevision)) return root;
|
||||
} catch {
|
||||
continue; // unreadable/malformed candidate — try the next one
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Quarantine clear ────────────────────────────────────────────────────
|
||||
|
||||
function defaultRunXattr(target: string): number | null {
|
||||
const res = Bun.spawnSync(['xattr', '-dr', 'com.apple.quarantine', target], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
timeout: 10_000,
|
||||
});
|
||||
return res.exitCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear com.apple.quarantine on every chromium* revision dir in the
|
||||
* Playwright cache (the headless shell is what XProtect actually killed in
|
||||
* #2554; the headed bundle rides along so a later headed launch doesn't
|
||||
* re-trip). Scope contract mirrors probePoisonedChromiumBundle: NEVER act
|
||||
* on a GSTACK_CHROMIUM_PATH bundle — that belongs to the wrapper/embedder.
|
||||
* Best-effort: xattr failures are logged, never thrown (the forced
|
||||
* reinstall below is the real heal).
|
||||
*/
|
||||
export function clearQuarantineOnPlaywrightCache(
|
||||
executablePath: string,
|
||||
runXattr: (target: string) => number | null = defaultRunXattr,
|
||||
): boolean {
|
||||
const customPath = process.env.GSTACK_CHROMIUM_PATH;
|
||||
if (customPath && path.resolve(executablePath) === path.resolve(customPath)) {
|
||||
logHeal('quarantine-clear-skipped', { reason: 'custom-chromium-path' });
|
||||
return false;
|
||||
}
|
||||
const revDir = findPlaywrightRevisionDir(executablePath);
|
||||
if (!revDir) {
|
||||
logHeal('quarantine-clear-skipped', { reason: 'not-in-playwright-cache', executablePath });
|
||||
return false;
|
||||
}
|
||||
const cacheRoot = path.dirname(revDir);
|
||||
let cleared = 0;
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(cacheRoot);
|
||||
} catch (err) {
|
||||
logHeal('quarantine-clear-skipped', {
|
||||
reason: 'cache-unreadable',
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!REVISION_DIR_RE.test(entry)) continue;
|
||||
const target = path.join(cacheRoot, entry);
|
||||
try {
|
||||
const exitCode = runXattr(target);
|
||||
// Non-zero usually means "no such xattr" — nothing to clear, fine.
|
||||
logHeal('quarantine-clear', { target, exitCode });
|
||||
cleared++;
|
||||
} catch (err) {
|
||||
logHeal('quarantine-clear', {
|
||||
target,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
return cleared > 0;
|
||||
}
|
||||
|
||||
// ─── Bounded forced reinstall (E1) ───────────────────────────────────────
|
||||
|
||||
export const XPROTECT_REINSTALL_TIMEOUT_MS = 120_000;
|
||||
|
||||
export interface ReinstallResult {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
exitCode?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `bunx playwright install --force chromium` from the gstack install
|
||||
* root, bounded at ~120s. The child gets its own process group (detached)
|
||||
* so a timeout kills the WHOLE tree (bunx → playwright CLI → download
|
||||
* workers), never leaving a zombie download saturating the network.
|
||||
*/
|
||||
export function runBoundedChromiumReinstall(
|
||||
installRoot: string,
|
||||
timeoutMs: number = XPROTECT_REINSTALL_TIMEOUT_MS,
|
||||
): Promise<ReinstallResult> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn('bunx', ['playwright', 'install', '--force', 'chromium'], {
|
||||
cwd: installRoot,
|
||||
detached: true, // own process group → group-kill on timeout
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch (err) {
|
||||
resolve({ ok: false, reason: `spawn-error: ${err instanceof Error ? err.message : String(err)}` });
|
||||
return;
|
||||
}
|
||||
let stderrTail = '';
|
||||
child.stderr?.on('data', (d: Buffer) => {
|
||||
stderrTail = (stderrTail + String(d)).slice(-2000);
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
if (child.pid) process.kill(-child.pid, 'SIGKILL'); // whole group
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException)?.code !== 'ESRCH') {
|
||||
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
||||
}
|
||||
}
|
||||
resolve({ ok: false, reason: 'timeout' });
|
||||
}, timeoutMs);
|
||||
child.on('error', (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({ ok: false, reason: `spawn-error: ${err.message}` });
|
||||
});
|
||||
child.on('exit', (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (code === 0) {
|
||||
resolve({ ok: true, exitCode: code });
|
||||
} else {
|
||||
resolve({
|
||||
ok: false,
|
||||
reason: `install-exit-${code}${stderrTail ? `: ${stderrTail.slice(-300)}` : ''}`,
|
||||
exitCode: code,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── One-shot orchestration (F4) ─────────────────────────────────────────
|
||||
|
||||
let healAttempted = false;
|
||||
|
||||
/** Test seam only — production never resets the one-shot guard. */
|
||||
export function resetXProtectHealForTests(): void {
|
||||
healAttempted = false;
|
||||
}
|
||||
|
||||
export interface XProtectHealDeps {
|
||||
platform?: NodeJS.Platform;
|
||||
executablePath?: () => string;
|
||||
clearQuarantine?: (execPath: string) => boolean;
|
||||
installRoot?: (expectedRevision: string) => string | null;
|
||||
runReinstall?: (installRoot: string) => Promise<ReinstallResult>;
|
||||
verifyInstalled?: (execPath: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt the XProtect self-heal for a classified launch failure.
|
||||
*
|
||||
* Returns true when the heal completed AND the revision dir the embedded
|
||||
* playwright-core expects exists on disk — the caller should retry the
|
||||
* launch exactly once. Returns false when the error doesn't match the
|
||||
* signature, the launch used a custom executable, the one-shot guard
|
||||
* already fired, or any heal step failed (the caller then surfaces the
|
||||
* original error + manual guidance).
|
||||
*/
|
||||
export async function maybeHealXProtectKill(
|
||||
err: unknown,
|
||||
opts: { usesCustomExecutable?: boolean } = {},
|
||||
deps: XProtectHealDeps = {},
|
||||
): Promise<boolean> {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (!isXProtectKillSignature(message, deps.platform ?? process.platform)) return false;
|
||||
if (opts.usesCustomExecutable) {
|
||||
// A GSTACK_CHROMIUM_PATH bundle belongs to the wrapper/embedder — never
|
||||
// quarantine-clear or reinstall over it (probePoisonedChromiumBundle's
|
||||
// scope contract).
|
||||
logHeal('skip', { reason: 'custom-executable' });
|
||||
return false;
|
||||
}
|
||||
if (healAttempted) {
|
||||
logHeal('skip', { reason: 'already-attempted-this-process' });
|
||||
return false;
|
||||
}
|
||||
healAttempted = true; // F4: at most one heal per process, even on failure
|
||||
logHeal('classified', { signature: 'xprotect-kill' });
|
||||
|
||||
const execPath = (deps.executablePath ?? (() => chromium.executablePath()))();
|
||||
(deps.clearQuarantine ?? clearQuarantineOnPlaywrightCache)(execPath);
|
||||
|
||||
const revision = expectedChromiumRevision(execPath);
|
||||
if (!revision) {
|
||||
logHeal('reinstall-skipped', { reason: 'no-revision-in-path', execPath });
|
||||
return false;
|
||||
}
|
||||
const root = (deps.installRoot ?? findGstackInstallRoot)(revision);
|
||||
if (!root) {
|
||||
// No install root pins our revision — a cwd-resolved install would heal
|
||||
// to the WRONG revision (ENG-OV3), so surface guidance instead.
|
||||
logHeal('reinstall-skipped', { reason: 'no-install-root', revision });
|
||||
return false;
|
||||
}
|
||||
|
||||
logHeal('reinstall-start', { installRoot: root, revision, timeoutMs: XPROTECT_REINSTALL_TIMEOUT_MS });
|
||||
const result = await (deps.runReinstall ?? runBoundedChromiumReinstall)(root);
|
||||
if (!result.ok) {
|
||||
logHeal('reinstall-failed', { reason: result.reason });
|
||||
return false;
|
||||
}
|
||||
|
||||
// F9/ENG-OV3: assert the revision dir the embedded playwright-core
|
||||
// EXPECTS exists post-heal — install exit 0 alone can mean "installed the
|
||||
// wrong revision" when resolution went sideways.
|
||||
const verify = deps.verifyInstalled ?? ((p: string) => fs.existsSync(p));
|
||||
if (!verify(execPath)) {
|
||||
logHeal('verify-failed', { expected: execPath });
|
||||
return false;
|
||||
}
|
||||
logHeal('reinstall-ok', { installRoot: root, revision });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Original launch error + manual remediation, for classified failures the
|
||||
* heal could not fix (offline, timeout, one-shot spent, no install root).
|
||||
*/
|
||||
export function buildXProtectGuidance(originalMessage: string): string {
|
||||
return (
|
||||
`${originalMessage}\n` +
|
||||
'[browse] This launch failure matches the macOS XProtect kill signature (#2554): ' +
|
||||
"the OS killed Playwright's Chromium at spawn. Automatic self-heal did not complete. " +
|
||||
'Fix manually: run `bunx playwright install chromium` from your gstack install ' +
|
||||
'(the directory whose node_modules pins playwright — ~/.claude/skills/gstack for ' +
|
||||
'global installs), then retry.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a Playwright launch call with the XProtect self-heal: on a classified
|
||||
* failure, heal once and retry the launch once. On a classified failure the
|
||||
* heal could not fix, throw the ORIGINAL error text augmented with manual
|
||||
* guidance. Unclassified failures pass through untouched.
|
||||
*/
|
||||
export async function launchWithXProtectHeal<T>(
|
||||
doLaunch: () => Promise<T>,
|
||||
opts: { usesCustomExecutable?: boolean } = {},
|
||||
deps: XProtectHealDeps = {},
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await doLaunch();
|
||||
} catch (err) {
|
||||
const healed = await maybeHealXProtectKill(err, opts, deps);
|
||||
if (healed) {
|
||||
logHeal('retry-launch', {});
|
||||
try {
|
||||
return await doLaunch();
|
||||
} catch (retryErr) {
|
||||
// The heal ran but the retry died too. Without this wrap the second
|
||||
// error propagated raw and the manual-remediation guidance was lost
|
||||
// exactly when the automatic path had just proven insufficient.
|
||||
const retryMessage = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
||||
if (isXProtectKillSignature(retryMessage, deps.platform ?? process.platform)) {
|
||||
throw new Error(buildXProtectGuidance(retryMessage), { cause: retryErr });
|
||||
}
|
||||
throw retryErr;
|
||||
}
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (isXProtectKillSignature(message, deps.platform ?? process.platform)) {
|
||||
throw new Error(buildXProtectGuidance(message), { cause: err });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -382,3 +382,42 @@ describe.skipIf(SKIP_SPAWN)('spawnSkill: lifecycle', () => {
|
||||
expect(result.stdout.length).toBeLessThanOrEqual(1024 * 1024);
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
describe('subprocess capture goes through temp files, not pipes', () => {
|
||||
// Tripwire. Capturing a child's output through `stdout: 'pipe'` is lossy
|
||||
// here: under a loaded parent, the first piped spawn in the process
|
||||
// intermittently yields an empty stderr even though the child wrote it and
|
||||
// exited 0. Neither draining before awaiting exit nor a manual getReader()
|
||||
// loop avoids it — both were measured losing the same bytes. It flaked
|
||||
// `$B skill test` (a dropped stderr left only bun's banner) and would blank
|
||||
// a skill's JSON result on `$B skill run` while still reporting success.
|
||||
//
|
||||
// runToFiles() points the child's fds at temp files instead, so the kernel
|
||||
// has flushed everything by the time the child exits. This test fails if a
|
||||
// refactor reintroduces pipe capture in this module.
|
||||
//
|
||||
// Comments are stripped first, so the module's own prose — which names the
|
||||
// banned pattern in order to explain it — doesn't trip checks meant for code.
|
||||
const src = fs.readFileSync(
|
||||
path.join(import.meta.dir, '..', 'src', 'browser-skill-commands.ts'), 'utf-8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/^\s*\/\/.*$/gm, '');
|
||||
|
||||
it("does not spawn with stdout/stderr: 'pipe'", () => {
|
||||
expect(src).not.toMatch(/std(out|err):\s*'pipe'/);
|
||||
});
|
||||
|
||||
it('does not read child output via Response(proc.stdout/stderr) or getReader', () => {
|
||||
expect(src).not.toMatch(/new Response\(\s*proc\.(stdout|stderr)/);
|
||||
expect(src).not.toMatch(/proc\.(stdout|stderr)[\s\S]{0,40}getReader\(/);
|
||||
});
|
||||
|
||||
it('every spawn site routes through runToFiles', () => {
|
||||
// The structural invariant: runToFiles owns the module's only Bun.spawn,
|
||||
// so any present or future spawn site inherits the file-based capture.
|
||||
// Counted rather than name-checked so adding a spawn site that bypasses
|
||||
// the helper fails here instead of silently reintroducing the bug.
|
||||
expect(src.match(/Bun\.spawn\(/g) ?? []).toHaveLength(1);
|
||||
expect((src.match(/await runToFiles\(/g) ?? []).length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,7 +85,17 @@ describe('browser-skills E2E — bundled hackernews-frontpage', () => {
|
||||
// It takes ~1s. Run it last so other assertions are quick.
|
||||
test('$B skill test hackernews-frontpage runs script.test.ts and reports pass', async () => {
|
||||
const result = await handleSkillCommand(['test', 'hackernews-frontpage'], { port: 0 });
|
||||
// bun test prints summary to stderr; handleSkillCommand returns stderr || stdout
|
||||
expect(result).toMatch(/13 pass|0 fail|tests passed/);
|
||||
// `bun test` splits its report across streams: the version banner goes to
|
||||
// stdout, the pass/fail summary to stderr. handleSkillCommand must return
|
||||
// both, so assert on each stream's half.
|
||||
//
|
||||
// This used to flake under full-suite load: capturing the child through
|
||||
// pipes dropped stderr on the first piped spawn in the process, so the
|
||||
// result was just the banner. The old `13 pass|0 fail|tests passed` regex
|
||||
// also had a `tests passed` alternative that matched a synthetic fallback
|
||||
// string, which would have passed vacuously on an empty capture.
|
||||
expect(result).toMatch(/bun test v/); // stdout half
|
||||
expect(result).toMatch(/\b0 fail\b/); // stderr half
|
||||
expect(result).toMatch(/Ran \d+ tests/);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* #2219 IRON RULE regression tests (E5): an alive daemon pid is NEVER
|
||||
* auto-killed. Killing a live daemon loses the session's tabs, cookies, and
|
||||
* logins — strictly worse than a slow command. Only an explicit
|
||||
* --force-restart may replace a live daemon.
|
||||
*
|
||||
* Integration legs follow the busy-daemon-recovery.test.ts pattern: a fake
|
||||
* HTTP daemon + a live `sleep` child standing in for the daemon PID, wired
|
||||
* through BROWSE_STATE_FILE. Unit legs pin the pure decision function.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, afterEach } from 'bun:test';
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as http from 'http';
|
||||
import { isProcessAlive } from '../src/error-handling';
|
||||
import { decideDaemonRestart, HEALTH_PROBE_TOTAL_BUDGET_MS } from '../src/cli';
|
||||
|
||||
// ─── Unit: the pure restart decision (decision 9 / F10) ──────────────────
|
||||
|
||||
describe('decideDaemonRestart (pure)', () => {
|
||||
test('healthy after probe → retry against the SAME daemon', () => {
|
||||
expect(decideDaemonRestart({ pidAlive: true, healthyAfterProbe: true, forceRestart: false }))
|
||||
.toBe('retry-command');
|
||||
// Even with --force-restart in hand, a healthy daemon is retried, not killed.
|
||||
expect(decideDaemonRestart({ pidAlive: true, healthyAfterProbe: true, forceRestart: true }))
|
||||
.toBe('retry-command');
|
||||
});
|
||||
|
||||
test('IRON RULE: alive + unhealthy + no flag → report busy, never kill', () => {
|
||||
expect(decideDaemonRestart({ pidAlive: true, healthyAfterProbe: false, forceRestart: false }))
|
||||
.toBe('report-busy');
|
||||
});
|
||||
|
||||
test('alive + unhealthy + explicit --force-restart → force-restart', () => {
|
||||
expect(decideDaemonRestart({ pidAlive: true, healthyAfterProbe: false, forceRestart: true }))
|
||||
.toBe('force-restart');
|
||||
});
|
||||
|
||||
test('dead pid → restart, with or without the flag', () => {
|
||||
expect(decideDaemonRestart({ pidAlive: false, healthyAfterProbe: false, forceRestart: false }))
|
||||
.toBe('restart-dead');
|
||||
expect(decideDaemonRestart({ pidAlive: false, healthyAfterProbe: false, forceRestart: true }))
|
||||
.toBe('restart-dead');
|
||||
});
|
||||
|
||||
test('probe budget is ~8s (F10) — long enough for heavy-page busy windows', () => {
|
||||
expect(HEALTH_PROBE_TOTAL_BUDGET_MS).toBeGreaterThanOrEqual(7_000);
|
||||
expect(HEALTH_PROBE_TOTAL_BUDGET_MS).toBeLessThanOrEqual(10_000);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Integration: real spawned CLI vs fake daemons ───────────────────────
|
||||
|
||||
/** A daemon whose /health always answers healthy but never serves /command. */
|
||||
async function startHealthyDaemon(): Promise<{ port: number; close: () => Promise<void> }> {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'healthy' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('ok');
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === 'string') throw new Error('fake daemon: bad address');
|
||||
return { port: addr.port, close: () => new Promise((r) => server.close(() => r())) };
|
||||
}
|
||||
|
||||
/** A WEDGED daemon: alive socket, but /health always answers unhealthy. */
|
||||
async function startWedgedDaemon(): Promise<{ port: number; close: () => Promise<void> }> {
|
||||
const server = http.createServer((req, res) => {
|
||||
res.writeHead(503, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'wedged' }));
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === 'string') throw new Error('fake daemon: bad address');
|
||||
return { port: addr.port, close: () => new Promise((r) => server.close(() => r())) };
|
||||
}
|
||||
|
||||
function runCli(args: string[], env: Record<string, string>, timeoutMs = 30_000):
|
||||
Promise<{ code: number; stdout: string; stderr: string }> {
|
||||
const cliPath = path.resolve(import.meta.dir, '../src/cli.ts');
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('bun', ['run', cliPath, ...args], { timeout: timeoutMs, env });
|
||||
let stdout = ''; let stderr = '';
|
||||
proc.stdout.on('data', (d) => stdout += d.toString());
|
||||
proc.stderr.on('data', (d) => stderr += d.toString());
|
||||
proc.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
function baseEnv(stateFile: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(process.env)) {
|
||||
if (v !== undefined) env[k] = v;
|
||||
}
|
||||
env.BROWSE_STATE_FILE = stateFile;
|
||||
return env;
|
||||
}
|
||||
|
||||
let pidChild: ChildProcess | null = null;
|
||||
afterEach(() => { pidChild?.kill('SIGKILL'); pidChild = null; });
|
||||
|
||||
describe('#2219 iron rule (CLI integration)', () => {
|
||||
test('healthy daemon SURVIVES `browse connect` — refused with guidance, no kill', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-iron-'));
|
||||
const stateFile = path.join(tmpDir, 'browse.json');
|
||||
const daemon = await startHealthyDaemon();
|
||||
try {
|
||||
pidChild = spawn('sleep', ['60'], { stdio: 'ignore' });
|
||||
const daemonPid = pidChild.pid!;
|
||||
const stateContent = {
|
||||
pid: daemonPid,
|
||||
port: daemon.port,
|
||||
token: 'iron-rule-token',
|
||||
startedAt: new Date().toISOString(),
|
||||
serverPath: '',
|
||||
mode: 'launched' as const,
|
||||
};
|
||||
fs.writeFileSync(stateFile, JSON.stringify(stateContent, null, 2));
|
||||
|
||||
const result = await runCli(['connect'], baseEnv(stateFile));
|
||||
|
||||
expect(result.code).not.toBe(0);
|
||||
expect(result.stderr).toContain('healthy daemon is already running');
|
||||
expect(result.stderr).toContain('--force-restart');
|
||||
// THE IRON RULE: the daemon process was not killed.
|
||||
expect(isProcessAlive(daemonPid)).toBe(true);
|
||||
// And the state file was not clobbered.
|
||||
expect(JSON.parse(fs.readFileSync(stateFile, 'utf-8'))).toEqual(stateContent);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test('wedged-alive daemon + plain command → busy report + nonzero exit, NO kill', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-iron-'));
|
||||
const stateFile = path.join(tmpDir, 'browse.json');
|
||||
const daemon = await startWedgedDaemon();
|
||||
try {
|
||||
pidChild = spawn('sleep', ['120'], { stdio: 'ignore' });
|
||||
const daemonPid = pidChild.pid!;
|
||||
fs.writeFileSync(stateFile, JSON.stringify({
|
||||
pid: daemonPid,
|
||||
port: daemon.port,
|
||||
token: 'iron-rule-token',
|
||||
startedAt: new Date().toISOString(),
|
||||
serverPath: '',
|
||||
mode: 'launched' as const,
|
||||
}, null, 2));
|
||||
|
||||
const result = await runCli(['status'], baseEnv(stateFile));
|
||||
|
||||
expect(result.code).not.toBe(0);
|
||||
expect(result.stderr).toContain('Daemon busy');
|
||||
expect(result.stderr).toContain('--force-restart');
|
||||
// Never killed, never restarted.
|
||||
expect(isProcessAlive(daemonPid)).toBe(true);
|
||||
expect(result.stderr).not.toContain('Restarting');
|
||||
} finally {
|
||||
await daemon.close();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 45_000);
|
||||
|
||||
test('wedged-alive daemon + --force-restart IS killed (explicit consent path)', async () => {
|
||||
// Unix lanes only: the consent path must boot a REAL replacement daemon
|
||||
// to answer the command, which the secretless/browserless Windows lane
|
||||
// cannot do (no Chromium install), and the teardown relies on setsid
|
||||
// process-group semantics Windows lacks. The Windows-relevant half of
|
||||
// the iron rule — busy → refusal, never an implicit kill — runs above.
|
||||
if (process.platform === 'win32') return;
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-iron-'));
|
||||
const stateFile = path.join(tmpDir, 'browse.json');
|
||||
const daemon = await startWedgedDaemon();
|
||||
try {
|
||||
pidChild = spawn('sleep', ['120'], { stdio: 'ignore' });
|
||||
const daemonPid = pidChild.pid!;
|
||||
fs.writeFileSync(stateFile, JSON.stringify({
|
||||
pid: daemonPid,
|
||||
port: daemon.port,
|
||||
token: 'iron-rule-token',
|
||||
startedAt: new Date().toISOString(),
|
||||
serverPath: '',
|
||||
mode: 'launched' as const,
|
||||
}, null, 2));
|
||||
|
||||
// `status` with --force-restart: the wedged "daemon" must be killed and
|
||||
// a REAL daemon started in its place.
|
||||
const result = await runCli(['--force-restart', 'status'], baseEnv(stateFile), 60_000);
|
||||
|
||||
// The wedged pid was killed — the explicit consent path.
|
||||
expect(isProcessAlive(daemonPid)).toBe(false);
|
||||
expect(result.stderr).toContain('--force-restart');
|
||||
// The replacement daemon answered the command.
|
||||
expect(result.code).toBe(0);
|
||||
} finally {
|
||||
// Kill the REAL daemon's whole PROCESS GROUP, not just its pid.
|
||||
// startServer spawns the daemon detached (setsid — its own group
|
||||
// leader), so a bare SIGKILL on the pid orphans its Chromium child,
|
||||
// which then squats memory for the REST of the suite (~100 files) —
|
||||
// enough pressure on a loaded box for the OS to kill a LATER test's
|
||||
// in-process Chromium, whose disconnect handler process.exit(1)s the
|
||||
// whole bun run mid-suite with no summary.
|
||||
try {
|
||||
const newState = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
||||
if (newState?.pid && isProcessAlive(newState.pid)) {
|
||||
try {
|
||||
process.kill(-newState.pid, 'SIGKILL'); // group: daemon + Chromium
|
||||
} catch {
|
||||
process.kill(newState.pid, 'SIGKILL'); // fallback: pid only
|
||||
}
|
||||
}
|
||||
} catch { /* state file gone — nothing started */ }
|
||||
await daemon.close();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 90_000);
|
||||
});
|
||||
@@ -70,6 +70,18 @@ describe('CDP allowlist (T2: deny-default)', () => {
|
||||
expect(isCdpMethodAllowed('Page.captureScreenshot')).toBe(true);
|
||||
});
|
||||
|
||||
it('Emulation.setEmulatedMedia is allowed, tab-scoped, trusted (#2419)', () => {
|
||||
// Media type/feature override (prefers-color-scheme, prefers-reduced-motion,
|
||||
// prefers-contrast, forced-colors) so a11y and dark-mode CSS branches are
|
||||
// testable via $B cdp. Returns an empty result — no page content, so
|
||||
// trusted output is correct.
|
||||
expect(isCdpMethodAllowed('Emulation.setEmulatedMedia')).toBe(true);
|
||||
const e = lookupCdpMethod('Emulation.setEmulatedMedia');
|
||||
expect(e).not.toBeNull();
|
||||
expect(e!.scope).toBe('tab');
|
||||
expect(e!.output).toBe('trusted');
|
||||
});
|
||||
|
||||
it('untrusted-output methods cover the read-everything-attacker-controlled cases', () => {
|
||||
// Anything that reads attacker-controlled strings (DOM/AX/CSS selectors)
|
||||
// should be tagged untrusted so the envelope wraps the result.
|
||||
|
||||
@@ -18,6 +18,12 @@ import { startTestServer } from './test-server';
|
||||
import { BrowserManager } from '../src/browser-manager';
|
||||
|
||||
const TMP_HOME = path.join(os.tmpdir(), `gstack-cdp-e2e-${process.pid}-${Date.now()}`);
|
||||
// Shard runs execute many test files in ONE bun process: a module-scope env
|
||||
// mutation without restore leaks into every LATER file in the shard. This
|
||||
// exact leak once pointed a sibling test's GSTACK_HOME at our temp dir,
|
||||
// which then got baked into artifacts that outlived it (dangling symlinks
|
||||
// into a deleted render dir). Save + restore in afterAll.
|
||||
const ORIGINAL_GSTACK_HOME = process.env.GSTACK_HOME;
|
||||
process.env.GSTACK_HOME = TMP_HOME;
|
||||
process.env.GSTACK_TELEMETRY_OFF = '1'; // don't pollute analytics during tests
|
||||
|
||||
@@ -36,6 +42,8 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (ORIGINAL_GSTACK_HOME === undefined) delete process.env.GSTACK_HOME;
|
||||
else process.env.GSTACK_HOME = ORIGINAL_GSTACK_HOME;
|
||||
try { await bm.cleanup?.(); } catch {}
|
||||
try { testServer.server.stop(); } catch {}
|
||||
await fs.rm(TMP_HOME, { recursive: true, force: true });
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* #2461 daemon crash log + F6 log hygiene needles.
|
||||
*
|
||||
* The detached daemon's stdout/stderr now land in <stateDir>/browse-daemon.log
|
||||
* (both spawn paths) instead of 'ignore'. That makes crashes diagnosable —
|
||||
* and makes it load-bearing that NOTHING secret or page-derived reaches the
|
||||
* daemon's console streams:
|
||||
*
|
||||
* - No console.* call anywhere in src/ may pass a token VALUE (AUTH_TOKEN,
|
||||
* state.token, attachToken, INTERNAL_TOKEN, setup keys). Names like
|
||||
* tokenInfo.clientId are fine — the needle targets expressions whose
|
||||
* value IS a token.
|
||||
* - The page-content carrier modules (tab-session, buffers,
|
||||
* content-security, activity) stay console-free, so raw page-derived
|
||||
* strings can't be echoed into the log unsanitized.
|
||||
*
|
||||
* Source-level, same style as windows-spawn-hide.test.ts.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const SRC_DIR = path.join(import.meta.dir, '../src');
|
||||
const SRC = (f: string) => fs.readFileSync(path.join(SRC_DIR, f), 'utf-8');
|
||||
|
||||
describe('#2461 daemon log wiring', () => {
|
||||
test('both daemon spawn paths capture stdout/stderr to browse-daemon.log', () => {
|
||||
const cli = SRC('cli.ts');
|
||||
// Unix path: fd from openDaemonLogSink wired into stdio.
|
||||
expect(cli).toContain("stdio: ['ignore', daemonLogFd, daemonLogFd]");
|
||||
expect(cli).toMatch(/openDaemonLogSink/);
|
||||
// Windows path: the fd must be opened INSIDE the node -e launcher (an fd
|
||||
// opened in cli.ts wouldn't cross the spawn boundary).
|
||||
expect(cli).toContain("stdio:['ignore',logFd,logFd]");
|
||||
expect(cli).toContain('browse-daemon.log');
|
||||
// The old fully-discarded wiring must not come back on either daemon path.
|
||||
expect(cli).not.toContain("stdio:['ignore','ignore','ignore']");
|
||||
});
|
||||
|
||||
test('log sink is append-mode (accumulates across respawns)', () => {
|
||||
const cli = SRC('cli.ts');
|
||||
// Both spawn paths open through the single daemonLogPath() source (M4),
|
||||
// which itself must build from the state dir.
|
||||
expect(cli).toMatch(/openSync\(daemonLogPath\(\), 'a'\)/);
|
||||
expect(cli).toMatch(/path\.join\(config\.stateDir, 'browse-daemon\.log'\)/);
|
||||
expect(cli).toMatch(/openSync\(\$\{daemonLogPathStr\},'a'\)/);
|
||||
});
|
||||
|
||||
test('append-mode log is growth-bounded: rotated at 10MB before daemon start', () => {
|
||||
const cli = SRC('cli.ts');
|
||||
expect(cli).toMatch(/DAEMON_LOG_MAX_BYTES = 10 \* 1024 \* 1024/);
|
||||
expect(cli).toMatch(/rotateDaemonLogIfOversized\(\);/);
|
||||
// Single generation: rename to .1, matching the repo's 10MB conventions.
|
||||
expect(cli).toMatch(/renameSync\(p, `\$\{p\}\.1`\)/);
|
||||
});
|
||||
|
||||
test('rotation behavior: oversized rotates to a single .1 generation, small/missing are no-ops', () => {
|
||||
const os = require('os');
|
||||
const { rotateDaemonLogIfOversized } = require('../src/cli');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-daemon-log-'));
|
||||
try {
|
||||
const p = path.join(tmp, 'browse-daemon.log');
|
||||
// Missing log: no throw (first launch).
|
||||
rotateDaemonLogIfOversized(p, 1024);
|
||||
// Under the cap: untouched, no generation created.
|
||||
fs.writeFileSync(p, 'x'.repeat(10));
|
||||
rotateDaemonLogIfOversized(p, 1024);
|
||||
expect(fs.existsSync(p)).toBe(true);
|
||||
expect(fs.existsSync(`${p}.1`)).toBe(false);
|
||||
// Over the cap: rotated out of the way so the daemon starts fresh.
|
||||
fs.writeFileSync(p, 'y'.repeat(2048));
|
||||
rotateDaemonLogIfOversized(p, 1024);
|
||||
expect(fs.existsSync(p)).toBe(false);
|
||||
expect(fs.readFileSync(`${p}.1`, 'utf-8')).toContain('y');
|
||||
// Single generation: the next rotation REPLACES .1 (bounded at ~2x cap
|
||||
// total, never a .2).
|
||||
fs.writeFileSync(p, 'z'.repeat(2048));
|
||||
rotateDaemonLogIfOversized(p, 1024);
|
||||
expect(fs.readFileSync(`${p}.1`, 'utf-8')).toContain('z');
|
||||
expect(fs.existsSync(`${p}.2`)).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('bun-polyfill routes Windows spawns through cross-spawn (ENOENT + cmd.exe injection fix)', () => {
|
||||
const polyfill = SRC('bun-polyfill.cjs');
|
||||
expect(polyfill).toContain("require('cross-spawn')");
|
||||
expect(polyfill).toMatch(/process\.platform === 'win32' \? crossSpawn\.sync : nodeSpawnSync/);
|
||||
expect(polyfill).toMatch(/process\.platform === 'win32' \? crossSpawn : nodeSpawn/);
|
||||
// The rejected-for-cause alternative must not creep back in: shell:true
|
||||
// on Windows routes through cmd.exe and does NOT neutralize & | ^ % < >.
|
||||
// (Strip comments — the header documents WHY shell:true was rejected.)
|
||||
const code = polyfill.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
expect(code).not.toMatch(/shell:\s*true/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('F6 log hygiene: nothing secret or page-derived reaches daemon console', () => {
|
||||
const files = fs.readdirSync(SRC_DIR).filter((f) => f.endsWith('.ts') || f.endsWith('.cjs'));
|
||||
|
||||
test('no console.* call passes a token value', () => {
|
||||
const offenders: string[] = [];
|
||||
for (const file of files) {
|
||||
const content = SRC(file);
|
||||
for (const [idx, line] of content.split('\n').entries()) {
|
||||
if (!/console\.(log|error|warn|info)\(/.test(line)) continue;
|
||||
// Interpolated token values: ${...token} / ${...Token} — the
|
||||
// expression ENDS in token, i.e. the value IS the token. Names like
|
||||
// ${tokenInfo.clientId} don't match.
|
||||
if (/\$\{[^}]*[tT]oken\s*\}/.test(line)) {
|
||||
offenders.push(`${file}:${idx + 1}: ${line.trim().slice(0, 120)}`);
|
||||
continue;
|
||||
}
|
||||
// Bare token args: console.log('x', token) / (..., authToken)
|
||||
if (/console\.(log|error|warn|info)\([^)]*[^a-zA-Z_.][tT]oken\s*[,)]/.test(line)) {
|
||||
offenders.push(`${file}:${idx + 1}: ${line.trim().slice(0, 120)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
test('page-content carrier modules are console-free', () => {
|
||||
// Page-derived strings flow through these modules. Keeping them
|
||||
// console-free guarantees raw page content can't be echoed into
|
||||
// browse-daemon.log without passing an egress sanitizer first.
|
||||
for (const file of ['tab-session.ts', 'buffers.ts', 'content-security.ts', 'activity.ts']) {
|
||||
const content = SRC(file);
|
||||
const calls = content.match(/console\.(log|error|warn|info)\(/g) || [];
|
||||
expect({ file, count: calls.length }).toEqual({ file, count: 0 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Static tripwire for #2220: every Playwright launch site must disable
|
||||
* Playwright's process-level signal handlers (handleSIGINT / handleSIGTERM /
|
||||
* handleSIGHUP), and server.ts must own the SIGHUP cleanup those flags
|
||||
* remove.
|
||||
*
|
||||
* WHY handleSIGTERM:false is correct here: server.ts DELIBERATELY ignores
|
||||
* SIGTERM in normal headless mode (the process.on('SIGTERM') handler —
|
||||
* Claude Code's Bash sandbox fires SIGTERM when the parent shell exits
|
||||
* between tool invocations, and the daemon must survive it). Playwright's
|
||||
* default handleSIGTERM:true registers its OWN handler that closes Chromium
|
||||
* on the same signal — so the daemon survived but its browser died out from
|
||||
* under it. With the flag false, the daemon's signal policy is the only
|
||||
* signal policy: the signals server.ts honors route through activeShutdown,
|
||||
* which closes Chromium itself.
|
||||
*
|
||||
* WHY server.ts needs a SIGHUP handler (ENG-OV4): before #2220 the daemon
|
||||
* had NO process-level SIGHUP handler — Playwright's default handleSIGHUP
|
||||
* was the only thing closing Chromium on hangup. Flipping the flag without
|
||||
* adding a handler would leak a live Chromium on every hangup.
|
||||
*
|
||||
* Source-level, same style as windows-spawn-hide.test.ts: cheap,
|
||||
* deterministic, runs on every platform.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const SRC = (f: string) => fs.readFileSync(path.join(import.meta.dir, '../src', f), 'utf-8');
|
||||
|
||||
/** Every occurrence of `needle` must carry all three handleSIG* flags within
|
||||
* the next `window` chars (the launch options object). */
|
||||
function expectSignalFlagsNearEvery(src: string, needle: string, window = 1200): number {
|
||||
let idx = src.indexOf(needle);
|
||||
expect(idx).toBeGreaterThanOrEqual(0);
|
||||
let count = 0;
|
||||
while (idx !== -1) {
|
||||
const slice = src.slice(idx, idx + window);
|
||||
expect(slice).toMatch(/handleSIGINT:\s*false/);
|
||||
expect(slice).toMatch(/handleSIGTERM:\s*false/);
|
||||
expect(slice).toMatch(/handleSIGHUP:\s*false/);
|
||||
count++;
|
||||
idx = src.indexOf(needle, idx + needle.length);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
describe('Playwright launch sites disable signal handlers (#2220)', () => {
|
||||
test('all chromium.launch / launchPersistentContext sites carry the three flags', () => {
|
||||
const src = SRC('browser-manager.ts');
|
||||
const launchCount = expectSignalFlagsNearEvery(src, 'chromium.launch({');
|
||||
const persistentCount = expectSignalFlagsNearEvery(src, 'chromium.launchPersistentContext(');
|
||||
// Three launch sites today: headless launch(), headed launchHeaded(),
|
||||
// and the handoff relaunch. A NEW launch site must carry the flags too —
|
||||
// bump this only after adding them.
|
||||
expect(launchCount + persistentCount).toBe(3);
|
||||
});
|
||||
|
||||
test('server.ts owns SIGHUP cleanup now that Playwright does not (ENG-OV4)', () => {
|
||||
const src = SRC('server.ts');
|
||||
// The SIGHUP handler must route to the same shutdown path Chromium
|
||||
// cleanup uses (activeShutdown), like SIGINT does.
|
||||
expect(src).toMatch(/process\.on\('SIGHUP',\s*\(\)\s*=>\s*activeShutdown\?\.\(\)\)/);
|
||||
});
|
||||
|
||||
test('the deliberate headless SIGTERM-ignore still exists (the reason handleSIGTERM:false is safe)', () => {
|
||||
const src = SRC('server.ts');
|
||||
// If this handler ever disappears, revisit handleSIGTERM:false — the
|
||||
// flag is correct BECAUSE server.ts owns SIGTERM policy.
|
||||
expect(src).toContain("process.on('SIGTERM'");
|
||||
expect(src).toContain('Received SIGTERM (ignoring');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* #2160/#1989: playwright-core is bun-patched to pass windowsHide at its two
|
||||
* Windows-visible child_process sites — the browser launch spawn (Node
|
||||
* defaults windowsHide to FALSE for spawn, so browser children could flash a
|
||||
* console window) and the force-kill taskkill spawnSync. This is the repo's
|
||||
* first patchedDependencies entry; these static checks pin the three-legged
|
||||
* coherence (patch file ↔ package.json ↔ installed tree) so a playwright
|
||||
* bump that forgets to re-target the patch fails CI instead of silently
|
||||
* dropping it. NOTE: bumping playwright (c25-style) REQUIRES regenerating
|
||||
* this patch against the new version — see the revert pairing in the wave
|
||||
* plan (reverting the bump means dropping the patch too).
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..', '..');
|
||||
|
||||
function installedPlaywrightCoreVersion(): string {
|
||||
const pkg = JSON.parse(fs.readFileSync(
|
||||
path.join(ROOT, 'node_modules', 'playwright-core', 'package.json'), 'utf-8',
|
||||
));
|
||||
return pkg.version as string;
|
||||
}
|
||||
|
||||
describe('playwright-core windowsHide patch (#2160, #1989)', () => {
|
||||
test('package.json declares the patch for the installed version', () => {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8'));
|
||||
const version = installedPlaywrightCoreVersion();
|
||||
const key = `playwright-core@${version}`;
|
||||
expect(pkg.patchedDependencies).toBeDefined();
|
||||
// Version-keyed on purpose: if playwright is bumped without re-targeting
|
||||
// the patch, this fails with the exact key that needs regenerating.
|
||||
expect(pkg.patchedDependencies[key]).toBe(`patches/playwright-core@${version}.patch`);
|
||||
});
|
||||
|
||||
test('the patch file exists and carries both windowsHide sites', () => {
|
||||
const version = installedPlaywrightCoreVersion();
|
||||
const patchPath = path.join(ROOT, 'patches', `playwright-core@${version}.patch`);
|
||||
expect(fs.existsSync(patchPath)).toBe(true);
|
||||
const patch = fs.readFileSync(patchPath, 'utf-8');
|
||||
// Launch spawnOptions site.
|
||||
expect(patch).toContain('+ windowsHide: true,');
|
||||
// taskkill force-kill site.
|
||||
expect(patch).toContain('shell: true, windowsHide: true');
|
||||
});
|
||||
|
||||
test('bun.lock is coherent: the lockfile records the patched dependency', () => {
|
||||
const lock = fs.readFileSync(path.join(ROOT, 'bun.lock'), 'utf-8');
|
||||
const version = installedPlaywrightCoreVersion();
|
||||
expect(lock).toContain(`patches/playwright-core@${version}.patch`);
|
||||
});
|
||||
|
||||
test('the INSTALLED tree actually has the patch applied (bun install ran it)', () => {
|
||||
const bundle = fs.readFileSync(
|
||||
path.join(ROOT, 'node_modules', 'playwright-core', 'lib', 'coreBundle.js'), 'utf-8',
|
||||
);
|
||||
expect(bundle).toContain('gstack patch (#2160/#1989)');
|
||||
expect(bundle).toContain('shell: true, windowsHide: true');
|
||||
});
|
||||
});
|
||||
@@ -54,16 +54,24 @@ describe('process liveness probe (Windows terminal-agent leak)', () => {
|
||||
expect(isProcessAlive(2147483646)).toBe(false);
|
||||
});
|
||||
|
||||
test('3. isProcessAlive spawns NO subprocess on POSIX (signal-0 path)', () => {
|
||||
test('2b. EPERM means ALIVE: an unsignalable-but-existing PID is not dead (T2)', () => {
|
||||
// signal-0 to a process we lack permission over throws EPERM — the
|
||||
// process EXISTS, we just can't signal it. Treating EPERM as "dead" is
|
||||
// the false negative that leaked agents. PID 1 (launchd/init) on POSIX
|
||||
// and PID 4 (System) on Windows always exist and are either signalable
|
||||
// or EPERM — both must read as alive.
|
||||
expect(isProcessAlive(process.platform === 'win32' ? 4 : 1)).toBe(true);
|
||||
});
|
||||
|
||||
test('3. isProcessAlive spawns NO subprocess on ANY platform (signal-0, #1952)', () => {
|
||||
// The heart of the bug: a liveness probe that forks is slow enough to
|
||||
// time out, and a timed-out probe silently answers "dead". Signal 0
|
||||
// cannot time out because it never leaves the process.
|
||||
//
|
||||
// Merged design note: on win32 the helper DOES keep a single hardened
|
||||
// tasklist probe (windowsHide, bounded timeout, quoted-CSV PID match)
|
||||
// because Bun's process.kill(pid, 0) throws ESRCH for live Windows PIDs
|
||||
// in compiled binaries. The POSIX path stays subprocess-free.
|
||||
if (process.platform === 'win32') return;
|
||||
// cannot time out because it never leaves the process. Node maps
|
||||
// process.kill(pid, 0) to an OpenProcess existence check on Windows —
|
||||
// and the Windows daemon runs under Node (server-node.mjs +
|
||||
// bun-polyfill), so the POSIX idiom is portable and the win32 tasklist
|
||||
// branch is GONE (it caused both the false negatives above and the
|
||||
// per-tick console flash of #1952).
|
||||
const origSpawn = (Bun as any).spawn;
|
||||
const origSpawnSync = (Bun as any).spawnSync;
|
||||
const spawns: string[] = [];
|
||||
@@ -79,16 +87,14 @@ describe('process liveness probe (Windows terminal-agent leak)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('4. no source file probes liveness via tasklist outside the central helper', () => {
|
||||
// Static tripwire: ad-hoc tasklist existence checks scattered across src/
|
||||
// resurrect the false-negative class (each call site re-invents the
|
||||
// timeout/parse handling and gets it subtly wrong). The ONE sanctioned
|
||||
// site is error-handling.ts's isProcessAlive win32 branch — centralized,
|
||||
// windowsHide, bounded timeout, quoted-CSV `"${pid}"` match. Every other
|
||||
// file must route through the helper.
|
||||
test('4. no source file probes liveness via tasklist — signal-0 is the only probe (#1952)', () => {
|
||||
// Static tripwire: a tasklist existence check ANYWHERE in src/
|
||||
// resurrects both the false-negative class (#2414: a timed-out spawnSync
|
||||
// still returns, with partial stdout, so a live process reads as dead)
|
||||
// and the per-tick console flash (#1952). isProcessAlive uses
|
||||
// process.kill(pid, 0) on every platform; nothing gets an exemption.
|
||||
const offenders: string[] = [];
|
||||
for (const { file, content } of readAllSourceFiles()) {
|
||||
if (file === 'error-handling.ts') continue; // the canonical helper
|
||||
const code = stripComments(content);
|
||||
// `PID eq` is the existence-probe form specifically. Other tasklist
|
||||
// uses (e.g. IMAGENAME filters for browser detection) are unaffected.
|
||||
|
||||
@@ -10,18 +10,12 @@
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
THRESHOLDS,
|
||||
combineVerdict,
|
||||
generateCanary,
|
||||
injectCanary,
|
||||
checkCanaryInStructure,
|
||||
writeSessionState,
|
||||
readSessionState,
|
||||
getStatus,
|
||||
extractDomain,
|
||||
type LayerSignal,
|
||||
} from '../src/security';
|
||||
@@ -244,57 +238,13 @@ describe('canary', () => {
|
||||
// ─── Attack log + rotation ───────────────────────────────────
|
||||
|
||||
|
||||
// ─── Session state (cross-process, atomic) ───────────────────
|
||||
|
||||
describe('session state', () => {
|
||||
test('write + read round-trip', () => {
|
||||
const state = {
|
||||
sessionId: 'test-session-123',
|
||||
canary: 'CANARY-TEST',
|
||||
warnedDomains: ['example.com'],
|
||||
classifierStatus: { testsavant: 'ok' as const },
|
||||
lastUpdated: '2026-04-19T12:34:56Z',
|
||||
};
|
||||
writeSessionState(state);
|
||||
const got = readSessionState();
|
||||
expect(got).not.toBeNull();
|
||||
expect(got!.sessionId).toBe('test-session-123');
|
||||
expect(got!.canary).toBe('CANARY-TEST');
|
||||
expect(got!.warnedDomains).toEqual(['example.com']);
|
||||
});
|
||||
|
||||
test('tolerates stale transcript field from pre-rip on-disk state', () => {
|
||||
// SessionState is a disk format. Files written before the Haiku
|
||||
// transcript layer was removed carry classifierStatus.transcript —
|
||||
// getStatus must read them fine, not require transcript for
|
||||
// 'protected', and never leak the stale key into /health.
|
||||
const stateFile = path.join(os.homedir(), '.gstack', 'security', 'session-state.json');
|
||||
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
||||
fs.writeFileSync(stateFile, JSON.stringify({
|
||||
sessionId: 'legacy-session',
|
||||
canary: 'CANARY-LEGACY',
|
||||
warnedDomains: [],
|
||||
classifierStatus: { testsavant: 'ok', transcript: 'degraded' },
|
||||
lastUpdated: '2026-04-19T12:34:56Z',
|
||||
}));
|
||||
const s = getStatus();
|
||||
expect(s.status).toBe('protected');
|
||||
expect('transcript' in s.layers).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Status reporting for shield icon ────────────────────────
|
||||
|
||||
describe('getStatus', () => {
|
||||
test('returns a valid SecurityStatus shape', () => {
|
||||
const s = getStatus();
|
||||
expect(['protected', 'degraded', 'inactive']).toContain(s.status);
|
||||
expect(s.layers).toBeDefined();
|
||||
expect(['ok', 'degraded', 'off']).toContain(s.layers.testsavant);
|
||||
expect(['ok', 'off']).toContain(s.layers.canary);
|
||||
expect(s.lastUpdated).toBeTruthy();
|
||||
});
|
||||
});
|
||||
// NOTE (#2557): the session-state + getStatus tests that lived here wrote
|
||||
// REAL fixture data into ~/.gstack/security/session-state.json — after which
|
||||
// /health reported a false-green 'protected' indefinitely. The surfaces they
|
||||
// covered (SessionState, read/writeSessionState, getStatus, the /health
|
||||
// security field, the sidepanel SEC shield) were dead since the PTY terminal
|
||||
// rewrite and are now removed. server-security-surface.test.ts pins the
|
||||
// removal + the live L4 wiring.
|
||||
|
||||
// ─── URL domain extraction ───────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* #2557 / ENG-OV9: pins the dead-shield removal AND the live L4 wiring.
|
||||
*
|
||||
* The removed surface: /health's `security` field read getStatus(), whose
|
||||
* only data source (~/.gstack/security/session-state.json) lost its only
|
||||
* writer when sidebar-agent.ts was ripped — so /health reported a permanent
|
||||
* 'inactive' or, wherever an old state file survived, a stale FALSE-GREEN
|
||||
* 'protected' ("no threats detected" when the real state was "not
|
||||
* measured"). Same fail-open class as #2026.
|
||||
*
|
||||
* The kept surface (ENG-OV9): security.ts is NOT dead — server.ts's
|
||||
* /pty-inject-scan path is the live L4 consumer (sidecar scan + URL
|
||||
* blocklist + datamark envelope), and security.ts's pure combiner/canary
|
||||
* exports stay. This test pins both directions so a future "cleanup" can't
|
||||
* silently take the live half, and a future re-feed of /health.security
|
||||
* from LIVE signals (isSidecarAvailable, content filters) must update this
|
||||
* test deliberately rather than resurrect the state-file path.
|
||||
*
|
||||
* Source-level, same style as windows-spawn-hide.test.ts.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const SRC = (f: string) => fs.readFileSync(path.join(import.meta.dir, '../src', f), 'utf-8');
|
||||
|
||||
describe('#2557: dead shield surface stays dead', () => {
|
||||
test('/health carries no security field and server.ts does not import getStatus', () => {
|
||||
const server = SRC('server.ts');
|
||||
expect(server).not.toMatch(/security:\s*getSecurityStatus\(\)/);
|
||||
expect(server).not.toMatch(/getStatus as getSecurityStatus/);
|
||||
// The SECURITY session-state file must not be read anywhere in src/ —
|
||||
// that file has no writer, so any reader is a false-signal feed.
|
||||
// (session-persist.ts's per-project <stateDir>/session-state.json is a
|
||||
// different, live file — only the ~/.gstack/security/ one is dead.)
|
||||
for (const f of fs.readdirSync(path.join(import.meta.dir, '../src')).filter((x) => x.endsWith('.ts'))) {
|
||||
const code = SRC(f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '').replace(/^\s*\*.*$/gm, '');
|
||||
const refs = /security[/'",\s][^\n]{0,80}session-state\.json/.test(code);
|
||||
expect({ file: f, refs }).toEqual({ file: f, refs: false });
|
||||
}
|
||||
});
|
||||
|
||||
test('security.ts no longer exports the unfed status surface', () => {
|
||||
const security = SRC('security.ts');
|
||||
expect(security).not.toMatch(/export function getStatus/);
|
||||
expect(security).not.toMatch(/export function (read|write)SessionState/);
|
||||
expect(security).not.toMatch(/export interface SessionState/);
|
||||
expect(security).not.toMatch(/export interface StatusDetail/);
|
||||
});
|
||||
|
||||
test('the sidepanel shield markup is gone', () => {
|
||||
const html = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel.html'), 'utf-8');
|
||||
const css = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel.css'), 'utf-8');
|
||||
expect(html).not.toContain('security-shield');
|
||||
expect(css).not.toMatch(/\.security-shield\s*\{/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ENG-OV9: the LIVE L4 path is untouched', () => {
|
||||
test('server.ts still consumes the sidecar on the inject-scan path', () => {
|
||||
const server = SRC('server.ts');
|
||||
expect(server).toContain("from './security-sidecar-client'");
|
||||
expect(server).toMatch(/isSidecarAvailable/);
|
||||
expect(server).toMatch(/scanWithSidecar\(/);
|
||||
});
|
||||
|
||||
test('security.ts keeps the pure combiner + canary exports', () => {
|
||||
const security = SRC('security.ts');
|
||||
expect(security).toMatch(/export const THRESHOLDS/);
|
||||
expect(security).toMatch(/export function combineVerdict/);
|
||||
expect(security).toMatch(/export function generateCanary/);
|
||||
expect(security).toMatch(/export function injectCanary/);
|
||||
expect(security).toMatch(/export function checkCanaryInStructure/);
|
||||
expect(security).toMatch(/export function extractDomain/);
|
||||
});
|
||||
|
||||
test('/health stays liveness-only: no token in any mode (regression wall from v1.63)', () => {
|
||||
const server = SRC('server.ts');
|
||||
// The /health handler block must not interpolate a token.
|
||||
const healthIdx = server.indexOf("url.pathname === '/health'");
|
||||
expect(healthIdx).toBeGreaterThan(0);
|
||||
const healthBlock = server.slice(healthIdx, healthIdx + 1500);
|
||||
expect(healthBlock).not.toMatch(/token:\s*[^n]/i);
|
||||
});
|
||||
});
|
||||
@@ -315,5 +315,11 @@ describe('applyStealth — persistent context (headed + handoff parity)', () =>
|
||||
await ctx.close();
|
||||
fs.rmSync(userDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}, 45000);
|
||||
// ^ 45s: this is the one HEADED persistent-context launch in the free
|
||||
// suite. A cold headed launch on macOS runs 8-25s — worse on the first
|
||||
// launch of a freshly downloaded Chromium (XProtect scans the new bundle,
|
||||
// the #2554 class) and under shard concurrency. bun's 5s default made this
|
||||
// the suite's most reliable false-negative: it timed out on the pre-wave
|
||||
// baseline run of main too, and passes at 15/15 with an honest budget.
|
||||
});
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* #2254: `browse stop` against a daemon that isn't running must report
|
||||
* success and exit 0 — WITHOUT starting a daemon just to stop it. The old
|
||||
* flow routed stop through ensureServer(), which booted a fresh daemon +
|
||||
* Chromium (multi-second, resource churn) and then shut it down — or
|
||||
* crash-restarted on a stale state file.
|
||||
*
|
||||
* Integration pattern follows busy-daemon-recovery.test.ts: a scratch
|
||||
* BROWSE_STATE_FILE + a real spawned CLI.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawn } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as net from 'net';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { isProcessAlive } from '../src/error-handling';
|
||||
|
||||
function runCli(args: string[], env: Record<string, string>, timeoutMs = 30_000):
|
||||
Promise<{ code: number; stdout: string; stderr: string }> {
|
||||
const cliPath = path.resolve(import.meta.dir, '../src/cli.ts');
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('bun', ['run', cliPath, ...args], { timeout: timeoutMs, env });
|
||||
let stdout = ''; let stderr = '';
|
||||
proc.stdout.on('data', (d) => stdout += d.toString());
|
||||
proc.stderr.on('data', (d) => stderr += d.toString());
|
||||
proc.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
function baseEnv(stateFile: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(process.env)) {
|
||||
if (v !== undefined) env[k] = v;
|
||||
}
|
||||
env.BROWSE_STATE_FILE = stateFile;
|
||||
return env;
|
||||
}
|
||||
|
||||
/** Grab a port that is definitely closed (bind, read, release). */
|
||||
async function closedPort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const srv = net.createServer();
|
||||
srv.once('error', reject);
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const addr = srv.address();
|
||||
if (!addr || typeof addr === 'string') { reject(new Error('bad address')); return; }
|
||||
const port = addr.port;
|
||||
srv.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('#2254 stop on a dead daemon', () => {
|
||||
test('no daemon state at all → exit 0, nothing spawned', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-stop-'));
|
||||
const stateFile = path.join(tmpDir, 'browse.json');
|
||||
try {
|
||||
const result = await runCli(['stop'], baseEnv(stateFile));
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain('nothing to stop');
|
||||
// The load-bearing half: NO daemon was started to serve the stop —
|
||||
// a spawned daemon would have written the state file.
|
||||
expect(fs.existsSync(stateFile)).toBe(false);
|
||||
expect(result.stderr).not.toContain('Starting server');
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test('stale state (dead pid + closed port) → exit 0, state cleaned, nothing spawned', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-stop-'));
|
||||
const stateFile = path.join(tmpDir, 'browse.json');
|
||||
try {
|
||||
// A pid that is certainly not alive and a port nothing listens on.
|
||||
const port = await closedPort();
|
||||
fs.writeFileSync(stateFile, JSON.stringify({
|
||||
pid: 2147483646,
|
||||
port,
|
||||
token: 'stale-token',
|
||||
startedAt: new Date().toISOString(),
|
||||
serverPath: '',
|
||||
mode: 'launched' as const,
|
||||
}, null, 2));
|
||||
|
||||
const result = await runCli(['stop'], baseEnv(stateFile));
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain('nothing to stop');
|
||||
// Stale state cleaned, and no daemon spawned to replace it.
|
||||
expect(fs.existsSync(stateFile)).toBe(false);
|
||||
expect(result.stderr).not.toContain('Starting server');
|
||||
expect(result.stderr).not.toContain('Restarting');
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('stop --force-restart on a LIVE daemon', () => {
|
||||
test('kills it directly — never boots a fresh daemon just to stop it', async () => {
|
||||
// A live-but-BUSY daemon: the pid is alive but nothing answers /health.
|
||||
// Pre-fix, stop --force-restart fell through to ensureServer(), whose
|
||||
// force-restart path killed the daemon and then STARTED a fresh one
|
||||
// (daemon + Chromium) so sendCommand('stop') could stop it again —
|
||||
// exactly what gstack-upgrade Step 4.8 triggers on a stale-busy daemon.
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-stop-force-'));
|
||||
const stateFile = path.join(tmpDir, 'browse.json');
|
||||
// Portable long-lived child standing in for the wedged daemon process.
|
||||
const wedged = spawn('bun', ['-e', 'await Bun.sleep(300000)'], { stdio: 'ignore' });
|
||||
try {
|
||||
const port = await closedPort();
|
||||
fs.writeFileSync(stateFile, JSON.stringify({
|
||||
pid: wedged.pid,
|
||||
port,
|
||||
token: 'busy-token',
|
||||
startedAt: new Date().toISOString(),
|
||||
serverPath: '',
|
||||
mode: 'launched' as const,
|
||||
}, null, 2));
|
||||
|
||||
const result = await runCli(['stop', '--force-restart'], baseEnv(stateFile));
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain('Daemon stopped (forced');
|
||||
// The load-bearing half: NO fresh daemon was booted to serve the stop.
|
||||
// A spawned daemon would have re-written the state file.
|
||||
expect(fs.existsSync(stateFile)).toBe(false);
|
||||
expect(result.stderr).not.toContain('Starting server');
|
||||
expect(result.stdout + result.stderr).not.toContain('Restarting');
|
||||
// And the live pid is actually gone.
|
||||
const deadline = Date.now() + 3000;
|
||||
while (Date.now() < deadline && isProcessAlive(wedged.pid!)) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
expect(isProcessAlive(wedged.pid!)).toBe(false);
|
||||
} finally {
|
||||
try { wedged.kill('SIGKILL'); } catch { /* already gone */ }
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* #2314: the terminal-agent must allocate its port from the SAME fixed
|
||||
* 10000-49151 scan range the main server uses (port-allocator.ts,
|
||||
* decision 8) — never `port: 0`. Binding 0 drew from the OS ephemeral range
|
||||
* (49152-65535 on macOS), where the weeks-lived agent squatted ports that
|
||||
* short-lived `app.listen(0)` test servers expected to receive, absorbing
|
||||
* their traffic as phantom 404s across every Node test suite on the machine.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as net from 'net';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
findAvailablePort,
|
||||
RANDOM_PORT_MIN,
|
||||
RANDOM_PORT_MAX,
|
||||
} from '../src/port-allocator';
|
||||
|
||||
const AGENT_TS = path.resolve(import.meta.dir, '..', 'src', 'terminal-agent.ts');
|
||||
const SERVER_TS = path.resolve(import.meta.dir, '..', 'src', 'server.ts');
|
||||
|
||||
describe('shared port allocator (#2314)', () => {
|
||||
test('allocates inside the fixed scan range, never the ephemeral range', async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const port = await findAvailablePort();
|
||||
expect(port).toBeGreaterThanOrEqual(RANDOM_PORT_MIN);
|
||||
expect(port).toBeLessThan(RANDOM_PORT_MAX);
|
||||
// The load-bearing property: the WHOLE range sits below the ephemeral
|
||||
// floor (49152). The original 60000 cap left ~22% of picks inside the
|
||||
// pool this allocator exists to avoid.
|
||||
expect(RANDOM_PORT_MAX).toBeLessThan(49152);
|
||||
expect(RANDOM_PORT_MIN).toBeGreaterThanOrEqual(1024);
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit free port is honored', async () => {
|
||||
// Find a free port by binding 0, then ask the allocator for exactly it.
|
||||
const free = await new Promise<number>((resolve, reject) => {
|
||||
const srv = net.createServer();
|
||||
srv.once('error', reject);
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const p = (srv.address() as net.AddressInfo).port;
|
||||
srv.close(() => resolve(p));
|
||||
});
|
||||
});
|
||||
expect(await findAvailablePort(free)).toBe(free);
|
||||
});
|
||||
|
||||
test('explicit occupied port throws an actionable error', async () => {
|
||||
const srv = net.createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
srv.once('error', reject);
|
||||
srv.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
const occupied = (srv.address() as net.AddressInfo).port;
|
||||
try {
|
||||
await expect(findAvailablePort(occupied)).rejects.toThrow(/in use/);
|
||||
} finally {
|
||||
await new Promise<void>((r) => srv.close(() => r()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('terminal-agent uses the shared allocator (static tripwire)', () => {
|
||||
test('terminal-agent.ts never binds port: 0', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
// Strip comments so the explanatory history above the bind doesn't trip.
|
||||
const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
expect(code).not.toMatch(/port:\s*0\b/);
|
||||
expect(src).toContain("from './port-allocator'");
|
||||
expect(src).toContain('findAvailablePort');
|
||||
});
|
||||
|
||||
test('server.ts routes findPort through the same allocator', () => {
|
||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
||||
expect(src).toContain("from './port-allocator'");
|
||||
expect(src).toMatch(/findAvailablePort\(BROWSE_PORT\)/);
|
||||
});
|
||||
});
|
||||
@@ -39,8 +39,9 @@ describe('windowsHide on Windows-reachable spawns (#1835)', () => {
|
||||
});
|
||||
|
||||
test('Windows-only process probes pass windowsHide', () => {
|
||||
// tasklist in isProcessAlive — runs in polling loops.
|
||||
expectHideNearEvery(SRC('error-handling.ts'), "'tasklist'");
|
||||
// isProcessAlive no longer spawns anything (signal-0 on every platform,
|
||||
// #1952) — process-liveness-windows.test.ts pins that it stays
|
||||
// subprocess-free, which is stronger than hiding a window.
|
||||
// powershell DPAPI + tasklist in cookie import.
|
||||
const cookie = SRC('cookie-import-browser.ts');
|
||||
expectHideNearEvery(cookie, "'powershell'");
|
||||
@@ -61,4 +62,69 @@ describe('windowsHide on Windows-reachable spawns (#1835)', () => {
|
||||
// spawn's options object carries the full env wiring before the flag.
|
||||
expectHideNearEvery(SRC('terminal-agent-control.ts'), '(Bun as any).spawn(', 700);
|
||||
});
|
||||
|
||||
test('SWEEP: every direct child_process call in src/ passes windowsHide (#2160, #2415)', () => {
|
||||
// Full-census tripwire: a NEW child_process call site without windowsHide
|
||||
// fails CI. Each exemption carries a reason — an interactive console
|
||||
// child must NOT get CREATE_NO_WINDOW.
|
||||
const EXEMPT: Array<{ file: string; needle: string; reason: string }> = [
|
||||
{
|
||||
file: 'domain-skill-commands.ts',
|
||||
needle: 'spawnSync(editor',
|
||||
reason: "interactive $EDITOR with stdio:'inherit' — windowsHide would detach a console editor into an invisible console",
|
||||
},
|
||||
];
|
||||
|
||||
const srcDir = path.join(import.meta.dir, '../src');
|
||||
const offenders: string[] = [];
|
||||
for (const file of fs.readdirSync(srcDir).filter((f) => f.endsWith('.ts'))) {
|
||||
const raw = fs.readFileSync(path.join(srcDir, file), 'utf-8');
|
||||
if (!raw.includes('child_process')) continue;
|
||||
// Strip comments so documented history doesn't trip the census.
|
||||
const code = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
|
||||
// Collect the callable names this file binds to child_process:
|
||||
// import { spawn as nodeSpawn } from 'child_process'
|
||||
// const { execSync } = await import('child_process') / require(...)
|
||||
// import * as cp from 'child_process' → cp.<fn>( pattern
|
||||
const names = new Set<string>();
|
||||
const namespaces = new Set<string>();
|
||||
const importRe = /import\s*\{([^}]*)\}\s*from\s*['"](?:node:)?child_process['"]/g;
|
||||
const dynRe = /(?:const|let|var)\s*\{([^}]*)\}\s*=\s*(?:await\s+import\(|require\()['"](?:node:)?child_process['"]\)/g;
|
||||
const nsRe = /import\s*\*\s*as\s*(\w+)\s*from\s*['"](?:node:)?child_process['"]/g;
|
||||
for (const m of code.matchAll(importRe)) {
|
||||
for (const part of m[1].split(',')) {
|
||||
const alias = part.split(/\s+as\s+/).map((s) => s.trim()).filter(Boolean);
|
||||
const name = alias[alias.length - 1];
|
||||
if (name && /^(spawn|spawnSync|exec|execSync|execFile|execFileSync|nodeSpawn|cpSpawn)/.test(alias[0].trim())) names.add(name);
|
||||
}
|
||||
}
|
||||
for (const m of code.matchAll(dynRe)) {
|
||||
for (const part of m[1].split(',')) {
|
||||
const alias = part.split(':').map((s) => s.trim()).filter(Boolean);
|
||||
const name = alias[alias.length - 1];
|
||||
if (name && /^(spawn|spawnSync|exec|execSync|execFile|execFileSync)/.test(alias[0].trim())) names.add(name);
|
||||
}
|
||||
}
|
||||
for (const m of code.matchAll(nsRe)) namespaces.add(m[1]);
|
||||
|
||||
const patterns: RegExp[] = [];
|
||||
for (const n of names) patterns.push(new RegExp(`(?<![.\\w'"\`])${n}\\(`, 'g'));
|
||||
for (const ns of namespaces) {
|
||||
patterns.push(new RegExp(`(?<![\\w'"\`])${ns}\\.(?:spawn|spawnSync|exec|execSync|execFile|execFileSync)\\(`, 'g'));
|
||||
}
|
||||
|
||||
for (const re of patterns) {
|
||||
for (const m of code.matchAll(re)) {
|
||||
const slice = code.slice(m.index!, m.index! + 700);
|
||||
const exempt = EXEMPT.some((e) => e.file === file && slice.startsWith(e.needle));
|
||||
if (exempt) continue;
|
||||
if (!/windowsHide:\s*true/.test(slice)) {
|
||||
offenders.push(`${file}: ${slice.split('\n')[0].slice(0, 100)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* XProtect launch-kill self-heal (P0 #2554) — unit tests.
|
||||
*
|
||||
* F9: the classifier is tested with POSITIVE signatures (sourced from the
|
||||
* #2554 report + Playwright's launch-error format) AND NEGATIVES (missing
|
||||
* executable, EPERM/EACCES, sandbox denial, plain crash) so a generic launch
|
||||
* failure can never trigger a pointless reinstall.
|
||||
*
|
||||
* F4: the one-shot guard is pinned — at most one heal attempt per process,
|
||||
* even when the heal fails.
|
||||
*
|
||||
* ENG-OV3/F9: the post-heal verification target is REGISTRY-derived (the
|
||||
* revision playwright-core's browsers.json expects), not disk-derived, and
|
||||
* the install-root finder rejects roots pinning a different revision.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { chromium } from 'playwright';
|
||||
import {
|
||||
isXProtectKillSignature,
|
||||
findPlaywrightRevisionDir,
|
||||
expectedChromiumRevision,
|
||||
findGstackInstallRoot,
|
||||
clearQuarantineOnPlaywrightCache,
|
||||
maybeHealXProtectKill,
|
||||
launchWithXProtectHeal,
|
||||
resetXProtectHealForTests,
|
||||
buildXProtectGuidance,
|
||||
runBoundedChromiumReinstall,
|
||||
} from '../src/xprotect-heal';
|
||||
|
||||
const REPO_ROOT = path.resolve(import.meta.dir, '..', '..');
|
||||
|
||||
// ─── Fixtures: POSITIVE signatures (real Playwright error shapes for an
|
||||
// OS-level SIGKILL at spawn — what xprotectd does per the #2554 report) ────
|
||||
|
||||
const SIGKILL_BROWSER_CLOSED = `browserType.launch: Browser closed.
|
||||
==================== Browser output: ====================
|
||||
<launched> pid=48213
|
||||
[pid=48213] <process did exit: exitCode=null, signal=SIGKILL>
|
||||
[pid=48213] starting temporary directories cleanup
|
||||
=========================== logs ===========================`;
|
||||
|
||||
const SIGKILL_PERSISTENT_CONTEXT = `browserType.launchPersistentContext: Target page, context or browser has been closed
|
||||
Browser logs:
|
||||
<launched> pid=9021
|
||||
[pid=9021] <process did exit: exitCode=null, signal=SIGKILL>`;
|
||||
|
||||
// The #2554 report's visible symptom: the kill surfaces as a launch timeout
|
||||
// where the process DID spawn (<launched>) but never became ready.
|
||||
const LAUNCH_TIMEOUT_AFTER_SPAWN = `browserType.launch: Timeout 180000ms exceeded.
|
||||
=========================== logs ===========================
|
||||
<launched> pid=51677
|
||||
============================================================`;
|
||||
|
||||
// ─── Fixtures: NEGATIVE signatures (F9) ──────────────────────────────────
|
||||
|
||||
const MISSING_EXECUTABLE = `browserType.launch: Executable doesn't exist at /Users/dev/Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-mac-arm64/headless_shell
|
||||
╔═══════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download browsers:║
|
||||
║ bunx playwright install ║
|
||||
╚═══════════════════════════════════════════════════════╝`;
|
||||
|
||||
const SPAWN_EACCES = `browserType.launch: spawn /Users/dev/Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing EACCES`;
|
||||
|
||||
const EPERM_FAILURE = `browserType.launch: Browser closed.
|
||||
==================== Browser output: ====================
|
||||
Error: EPERM: operation not permitted, open '/Users/dev/Library/Caches/ms-playwright/.links/lock'`;
|
||||
|
||||
const SANDBOX_DENIAL = `browserType.launch: Browser closed.
|
||||
==================== Browser output: ====================
|
||||
<launched> pid=7211
|
||||
[pid=7211][err] Failed to move to new namespace: PID namespaces supported, Network namespace supported, but failed: errno = Operation not permitted
|
||||
[pid=7211] <process did exit: exitCode=1, signal=null>`;
|
||||
|
||||
const PLAIN_CRASH_EXIT_1 = `browserType.launch: Browser closed.
|
||||
==================== Browser output: ====================
|
||||
<launched> pid=3300
|
||||
[pid=3300] <process did exit: exitCode=1, signal=null>`;
|
||||
|
||||
// ─── Classifier ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('isXProtectKillSignature — positives (darwin)', () => {
|
||||
it('classifies SIGKILL in a Browser closed error', () => {
|
||||
expect(isXProtectKillSignature(SIGKILL_BROWSER_CLOSED, 'darwin')).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies SIGKILL in a launchPersistentContext error', () => {
|
||||
expect(isXProtectKillSignature(SIGKILL_PERSISTENT_CONTEXT, 'darwin')).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies a launch timeout where the process spawned (<launched>)', () => {
|
||||
expect(isXProtectKillSignature(LAUNCH_TIMEOUT_AFTER_SPAWN, 'darwin')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isXProtectKillSignature — negatives (F9)', () => {
|
||||
it('rejects a missing executable', () => {
|
||||
expect(isXProtectKillSignature(MISSING_EXECUTABLE, 'darwin')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects spawn EACCES', () => {
|
||||
expect(isXProtectKillSignature(SPAWN_EACCES, 'darwin')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects EPERM failures', () => {
|
||||
expect(isXProtectKillSignature(EPERM_FAILURE, 'darwin')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects Linux sandbox denials even with a <launched> marker', () => {
|
||||
expect(isXProtectKillSignature(SANDBOX_DENIAL, 'darwin')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a plain crash (exitCode=1, no signal)', () => {
|
||||
expect(isXProtectKillSignature(PLAIN_CRASH_EXIT_1, 'darwin')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a bare timeout with no <launched> marker (process never spawned)', () => {
|
||||
expect(isXProtectKillSignature('browserType.launch: Timeout 180000ms exceeded.', 'darwin')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty messages', () => {
|
||||
expect(isXProtectKillSignature('', 'darwin')).toBe(false);
|
||||
});
|
||||
|
||||
it('is platform-gated: the SIGKILL signature on linux/win32 is NOT XProtect', () => {
|
||||
expect(isXProtectKillSignature(SIGKILL_BROWSER_CLOSED, 'linux')).toBe(false);
|
||||
expect(isXProtectKillSignature(SIGKILL_BROWSER_CLOSED, 'win32')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cache path helpers ──────────────────────────────────────────────────
|
||||
|
||||
describe('findPlaywrightRevisionDir', () => {
|
||||
it('finds the revision dir for the headed bundle layout', () => {
|
||||
const p = '/Users/dev/Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing';
|
||||
expect(findPlaywrightRevisionDir(p)).toBe('/Users/dev/Library/Caches/ms-playwright/chromium-1234');
|
||||
});
|
||||
|
||||
it('finds the revision dir for the headless shell layout', () => {
|
||||
const p = '/Users/dev/Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-mac-arm64/headless_shell';
|
||||
expect(findPlaywrightRevisionDir(p)).toBe('/Users/dev/Library/Caches/ms-playwright/chromium_headless_shell-1234');
|
||||
});
|
||||
|
||||
it('returns null outside the Playwright cache layout', () => {
|
||||
expect(findPlaywrightRevisionDir('/Applications/GStack Browser.app/Contents/MacOS/Chromium')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expectedChromiumRevision — registry-derived expectation (F9/ENG-OV3)', () => {
|
||||
it('matches the revision playwright-core browsers.json declares for chromium', () => {
|
||||
const browsersJson = JSON.parse(fs.readFileSync(
|
||||
path.join(REPO_ROOT, 'node_modules', 'playwright-core', 'browsers.json'), 'utf-8',
|
||||
));
|
||||
const registryRevision = browsersJson.browsers.find((b: { name: string }) => b.name === 'chromium').revision;
|
||||
// chromium.executablePath() is computed from the embedded registry (not
|
||||
// read from disk) — the heal's post-install verification target is
|
||||
// therefore the revision dir playwright-core EXPECTS, which is exactly
|
||||
// what a wrong-revision heal would fail.
|
||||
expect(expectedChromiumRevision(chromium.executablePath())).toBe(registryRevision);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findGstackInstallRoot (ENG-OV3: revision-matched roots only)', () => {
|
||||
let tmpRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xprotect-root-'));
|
||||
const pwCore = path.join(tmpRoot, 'node_modules', 'playwright-core');
|
||||
fs.mkdirSync(pwCore, { recursive: true });
|
||||
fs.writeFileSync(path.join(pwCore, 'browsers.json'), JSON.stringify({
|
||||
browsers: [{ name: 'chromium', revision: '1234' }],
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('accepts a root whose pinned playwright-core expects the same revision', () => {
|
||||
expect(findGstackInstallRoot('1234', [tmpRoot])).toBe(tmpRoot);
|
||||
});
|
||||
|
||||
it('rejects a root pinning a DIFFERENT revision (wrong-revision heal guard)', () => {
|
||||
expect(findGstackInstallRoot('9999', [tmpRoot])).toBe(null);
|
||||
});
|
||||
|
||||
it('rejects roots without node_modules/playwright-core', () => {
|
||||
const bare = fs.mkdtempSync(path.join(os.tmpdir(), 'xprotect-bare-'));
|
||||
try {
|
||||
expect(findGstackInstallRoot('1234', [bare])).toBe(null);
|
||||
} finally {
|
||||
fs.rmSync(bare, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves the dev checkout by default (its node_modules pins our revision)', () => {
|
||||
const browsersJson = JSON.parse(fs.readFileSync(
|
||||
path.join(REPO_ROOT, 'node_modules', 'playwright-core', 'browsers.json'), 'utf-8',
|
||||
));
|
||||
const registryRevision = browsersJson.browsers.find((b: { name: string }) => b.name === 'chromium').revision;
|
||||
const root = findGstackInstallRoot(registryRevision);
|
||||
expect(root).not.toBe(null);
|
||||
expect(fs.existsSync(path.join(root!, 'node_modules', 'playwright-core', 'browsers.json'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Quarantine-clear scope contract ─────────────────────────────────────
|
||||
|
||||
describe('clearQuarantineOnPlaywrightCache', () => {
|
||||
let tmpCache: string;
|
||||
let execPath: string;
|
||||
const savedCustomPath = process.env.GSTACK_CHROMIUM_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpCache = fs.mkdtempSync(path.join(os.tmpdir(), 'xprotect-cache-'));
|
||||
for (const dir of ['chromium-1234', 'chromium_headless_shell-1234', 'firefox-5678', 'webkit-2222']) {
|
||||
fs.mkdirSync(path.join(tmpCache, dir), { recursive: true });
|
||||
}
|
||||
execPath = path.join(tmpCache, 'chromium-1234', 'chrome-mac-arm64', 'App.app', 'Contents', 'MacOS', 'chromium');
|
||||
delete process.env.GSTACK_CHROMIUM_PATH;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpCache, { recursive: true, force: true });
|
||||
if (savedCustomPath === undefined) delete process.env.GSTACK_CHROMIUM_PATH;
|
||||
else process.env.GSTACK_CHROMIUM_PATH = savedCustomPath;
|
||||
});
|
||||
|
||||
it('clears every chromium* revision dir, never firefox/webkit', () => {
|
||||
const cleared: string[] = [];
|
||||
const ok = clearQuarantineOnPlaywrightCache(execPath, (target) => {
|
||||
cleared.push(path.basename(target));
|
||||
return 0;
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(cleared.sort()).toEqual(['chromium-1234', 'chromium_headless_shell-1234']);
|
||||
});
|
||||
|
||||
it('NEVER touches a GSTACK_CHROMIUM_PATH bundle (embedder scope contract)', () => {
|
||||
process.env.GSTACK_CHROMIUM_PATH = execPath;
|
||||
const cleared: string[] = [];
|
||||
const ok = clearQuarantineOnPlaywrightCache(execPath, (target) => {
|
||||
cleared.push(target);
|
||||
return 0;
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(cleared).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips executables outside the Playwright cache layout', () => {
|
||||
const cleared: string[] = [];
|
||||
const ok = clearQuarantineOnPlaywrightCache('/Applications/Foo.app/Contents/MacOS/foo', (target) => {
|
||||
cleared.push(target);
|
||||
return 0;
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(cleared).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── One-shot heal orchestration (F4) ────────────────────────────────────
|
||||
|
||||
function makeDeps(counters: { installs: number; quarantines: number }, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
platform: 'darwin' as NodeJS.Platform,
|
||||
executablePath: () => '/tmp/ms-playwright/chromium-1234/chrome-mac-arm64/App.app/Contents/MacOS/chromium',
|
||||
clearQuarantine: () => { counters.quarantines++; return true; },
|
||||
installRoot: () => '/tmp/fake-gstack-root',
|
||||
runReinstall: async () => { counters.installs++; return { ok: true }; },
|
||||
verifyInstalled: () => true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('maybeHealXProtectKill', () => {
|
||||
beforeEach(() => resetXProtectHealForTests());
|
||||
|
||||
it('heals a classified failure: quarantine-clear + reinstall + verify', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
const healed = await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, makeDeps(counters));
|
||||
expect(healed).toBe(true);
|
||||
expect(counters.quarantines).toBe(1);
|
||||
expect(counters.installs).toBe(1);
|
||||
});
|
||||
|
||||
it('F4: runs AT MOST ONCE per process, even across distinct errors', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, makeDeps(counters))).toBe(true);
|
||||
expect(await maybeHealXProtectKill(new Error(LAUNCH_TIMEOUT_AFTER_SPAWN), {}, makeDeps(counters))).toBe(false);
|
||||
expect(counters.installs).toBe(1);
|
||||
});
|
||||
|
||||
it('F4: a FAILED heal also consumes the one-shot (no reinstall loops)', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
const failing = makeDeps(counters, { runReinstall: async () => { counters.installs++; return { ok: false, reason: 'timeout' }; } });
|
||||
expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, failing)).toBe(false);
|
||||
expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, makeDeps(counters))).toBe(false);
|
||||
expect(counters.installs).toBe(1);
|
||||
});
|
||||
|
||||
it('an unclassified error does NOT consume the one-shot', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
expect(await maybeHealXProtectKill(new Error(MISSING_EXECUTABLE), {}, makeDeps(counters))).toBe(false);
|
||||
expect(counters.installs).toBe(0);
|
||||
// Guard not consumed — a real signature afterwards still heals.
|
||||
expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, makeDeps(counters))).toBe(true);
|
||||
});
|
||||
|
||||
it('never heals over a custom executable (GSTACK_CHROMIUM_PATH scope)', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
const healed = await maybeHealXProtectKill(
|
||||
new Error(SIGKILL_BROWSER_CLOSED),
|
||||
{ usesCustomExecutable: true },
|
||||
makeDeps(counters),
|
||||
);
|
||||
expect(healed).toBe(false);
|
||||
expect(counters.quarantines).toBe(0);
|
||||
expect(counters.installs).toBe(0);
|
||||
});
|
||||
|
||||
it('fails the heal when no install root pins our revision (ENG-OV3)', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
const deps = makeDeps(counters, { installRoot: () => null });
|
||||
expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, deps)).toBe(false);
|
||||
expect(counters.installs).toBe(0);
|
||||
});
|
||||
|
||||
it('fails the heal when post-install verification misses the expected revision dir (F9)', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
const deps = makeDeps(counters, { verifyInstalled: () => false });
|
||||
expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, deps)).toBe(false);
|
||||
expect(counters.installs).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Launch wrapper ──────────────────────────────────────────────────────
|
||||
|
||||
describe('launchWithXProtectHeal', () => {
|
||||
beforeEach(() => resetXProtectHealForTests());
|
||||
|
||||
it('retries the launch exactly once after a successful heal', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
let attempts = 0;
|
||||
const result = await launchWithXProtectHeal(async () => {
|
||||
attempts++;
|
||||
if (attempts === 1) throw new Error(SIGKILL_BROWSER_CLOSED);
|
||||
return 'browser';
|
||||
}, {}, makeDeps(counters));
|
||||
expect(result).toBe('browser');
|
||||
expect(attempts).toBe(2);
|
||||
expect(counters.installs).toBe(1);
|
||||
});
|
||||
|
||||
it('surfaces the ORIGINAL error + manual guidance when the heal fails (E1)', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
const deps = makeDeps(counters, { runReinstall: async () => ({ ok: false, reason: 'timeout' }) });
|
||||
let thrown: Error | null = null;
|
||||
try {
|
||||
await launchWithXProtectHeal(async () => { throw new Error(SIGKILL_BROWSER_CLOSED); }, {}, deps);
|
||||
} catch (err) {
|
||||
thrown = err as Error;
|
||||
}
|
||||
expect(thrown).not.toBe(null);
|
||||
// Original launch error text preserved…
|
||||
expect(thrown!.message).toContain('signal=SIGKILL');
|
||||
// …plus the manual remediation.
|
||||
expect(thrown!.message).toContain('bunx playwright install chromium');
|
||||
});
|
||||
|
||||
it('passes unclassified failures through untouched', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
let thrown: Error | null = null;
|
||||
try {
|
||||
await launchWithXProtectHeal(async () => { throw new Error(MISSING_EXECUTABLE); }, {}, makeDeps(counters));
|
||||
} catch (err) {
|
||||
thrown = err as Error;
|
||||
}
|
||||
expect(thrown!.message).toBe(MISSING_EXECUTABLE);
|
||||
expect(counters.installs).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildXProtectGuidance', () => {
|
||||
it('carries both the original message and the manual command', () => {
|
||||
const out = buildXProtectGuidance('original launch error');
|
||||
expect(out).toContain('original launch error');
|
||||
expect(out).toContain('bunx playwright install chromium');
|
||||
expect(out).toContain('#2554');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── runBoundedChromiumReinstall (T3) — previously zero coverage ──────────
|
||||
//
|
||||
// Exercised end-to-end against a STUB `bunx` on a prepended PATH: real spawn,
|
||||
// real process group, real timer — only the binary is fake. Shell stubs
|
||||
// don't exist on Windows, and the group-kill path is POSIX (`kill(-pid)`),
|
||||
// so the suite is Unix-only like the shape it tests.
|
||||
describe.skipIf(process.platform === 'win32')('runBoundedChromiumReinstall', () => {
|
||||
let stubDir: string;
|
||||
let savedPath: string | undefined;
|
||||
|
||||
function installStubBunx(script: string): void {
|
||||
const stub = path.join(stubDir, 'bunx');
|
||||
fs.writeFileSync(stub, `#!/bin/sh\n${script}\n`, { mode: 0o755 });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stubDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xprotect-stub-'));
|
||||
savedPath = process.env.PATH;
|
||||
process.env.PATH = `${stubDir}${path.delimiter}${process.env.PATH ?? ''}`;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.PATH = savedPath;
|
||||
fs.rmSync(stubDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('resolves ok on exit 0', async () => {
|
||||
installStubBunx('exit 0');
|
||||
const result = await runBoundedChromiumReinstall(stubDir, 10_000);
|
||||
expect(result).toEqual({ ok: true, exitCode: 0 });
|
||||
});
|
||||
|
||||
it('reports install-exit-N with the stderr tail on a nonzero exit', async () => {
|
||||
installStubBunx('echo "download failed: mirror unreachable" >&2\nexit 7');
|
||||
const result = await runBoundedChromiumReinstall(stubDir, 10_000);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.exitCode).toBe(7);
|
||||
expect(result.reason).toStartWith('install-exit-7');
|
||||
expect(result.reason).toContain('download failed: mirror unreachable');
|
||||
});
|
||||
|
||||
it('group-kills a hung install at timeoutMs and reports timeout', async () => {
|
||||
// The stub spawns its own child (like bunx → playwright CLI → download
|
||||
// workers) and sleeps well past the bound; the detached process group
|
||||
// must take BOTH down, and the result must arrive at ~timeoutMs, not
|
||||
// after the sleep.
|
||||
installStubBunx('sleep 30 &\nsleep 30');
|
||||
const started = Date.now();
|
||||
const result = await runBoundedChromiumReinstall(stubDir, 500);
|
||||
const elapsed = Date.now() - started;
|
||||
expect(result).toEqual({ ok: false, reason: 'timeout' });
|
||||
expect(elapsed).toBeLessThan(5_000); // resolved at the bound, not the sleep
|
||||
});
|
||||
|
||||
it('reports spawn-error when the binary cannot be executed', async () => {
|
||||
// No stub installed and PATH reduced to the empty stub dir only.
|
||||
process.env.PATH = stubDir;
|
||||
const result = await runBoundedChromiumReinstall(stubDir, 10_000);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toStartWith('spawn-error:');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user