community wave: 6 PRs + hardening (v0.18.1.0) (#1028)

* fix: extend tilde-in-assignment fix to design resolver + 4 skill templates

PR #993 fixed the Claude Code permission prompt for `scripts/resolvers/browse.ts`
and `gstack-upgrade/SKILL.md.tmpl`. Same bug lives in three more places that
weren't on the contributor's branch:

- `scripts/resolvers/design.ts` (3 spots: D=, B=, and _DESIGN_DIR=)
- `design-shotgun/SKILL.md.tmpl` (_DESIGN_DIR=)
- `plan-design-review/SKILL.md.tmpl` (_DESIGN_DIR=)
- `design-consultation/SKILL.md.tmpl` (_DESIGN_DIR=)
- `design-review/SKILL.md.tmpl` (REPORT_DIR=)

Replaces bare `~/` with quoted `"$HOME/..."` in the source-of-truth files, then
regenerates. `grep -rEn '^[A-Za-z_]+=~/' --include="SKILL.md" .` now returns zero
hits across all hosts (claude, codex, cursor, gbrain, hermes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(openclaw): make native skills codex-friendly (#864)

Normalizes YAML frontmatter on the 4 hand-authored OpenClaw skills so stricter
parsers like Codex can load them. Codex CLI was rejecting these files with
"mapping values are not allowed in this context" on colons inside unquoted
description scalars.

- Drops non-standard `version` and `metadata` fields
- Rewrites descriptions into simple "Use when..." form (no inline colons)
- Adds a regression test enforcing strict frontmatter (name + description only)

Verified live: Codex CLI now loads the skills without errors. Observed during
/codex outside-voice run on the eval-community-prs plan review — Codex stderr
tripped on these exact files, which was real-world confirmation the fix is needed.

Dropped the connect-chrome changes from the original PR (the symlink removal is
out of scope for this fix; keeping connect-chrome -> open-gstack-browser).

Co-Authored-By: Cathryn Lavery <cathrynlavery@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(browse): server persists across Claude Code Bash calls

The browse server was dying between Bash tool invocations in Claude Code
because:

1. SIGTERM: The Claude Code sandbox sends SIGTERM to all child processes
   when a Bash command completes. The server received this and called
   shutdown(), deleting the state file and exiting.

2. Parent watchdog: The server polls BROWSE_PARENT_PID every 15s. When
   the parent Bash shell exits (killed by sandbox), the watchdog detected
   it and called shutdown().

Both mechanisms made it impossible to use the browse tool across multiple
Bash calls — every new `$B` invocation started a fresh server with no
cookies, no page state, and no tabs.

Fix:
- SIGTERM handler: log and ignore instead of shutdown. Explicit shutdown
  is still available via the /stop command or SIGINT (Ctrl+C).
- Parent watchdog: log once and continue instead of shutdown. The existing
  idle timeout (30 min) handles eventual cleanup.

The /stop command and SIGINT still work for intentional shutdown. Windows
behavior is unchanged (uses taskkill /F which bypasses signal handlers).

Tested: browse server survives across 5+ separate Bash tool calls in
Claude Code, maintaining cookies, page state, and navigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(browse): gate #994 SIGTERM-ignore to normal mode only

PR #994 made browse persist across Claude Code Bash calls by ignoring SIGTERM
and parent-PID death, relying on the 30-min idle timeout for eventual cleanup.

Codex outside-voice review caught that the idle timeout doesn't apply in two
modes: headed mode (/open-gstack-browser) and tunnel mode (/pair-agent). Both
early-return from idleCheckInterval. Combined with #994's ignore-SIGTERM, those
sessions would leak forever after the user disconnects — a real resource leak on
shared machines where multiple /pair-agent sessions come and go.

Fix: gate SIGTERM-ignore and parent-PID-watchdog-ignore to normal (headless) mode
only. Headed + tunnel modes respect both signals and shutdown cleanly. Idle
timeout behavior unchanged.

Also documents the deliberate contract change for future contributors — don't
re-add global SIGTERM shutdown thinking it's missing; it's intentionally scoped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: keep cookie picker alive after cli exits

Fixes garrytan/gstack#985

* fix: add opencode setup support

* feat(browse): add Windows browser path detection and DPAPI cookie decryption

- Extend BrowserPlatform to include win32
- Add windowsDataDir to BrowserInfo; populate for Chrome, Edge, Brave, Chromium
- getBaseDir('win32') → ~/AppData/Local
- findBrowserMatch checks Network/Cookies first on Windows (Chrome 80+)
- Add getWindowsAesKey() reading os_crypt.encrypted_key from Local State JSON
- Add dpapiDecrypt() via PowerShell ProtectedData.Unprotect (stdin/stdout)
- decryptCookieValue branches on platform: AES-256-GCM (Windows) vs AES-128-CBC (mac/linux)
- Fix hardcoded /tmp → TEMP_DIR from platform.ts in openDbFromCopy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(browse): Windows cookie import — profile discovery, v20 detection, CDP fallback

Three bugs fixed in cookie-import-browser.ts:
- listProfiles() and findInstalledBrowsers() now check Network/Cookies on Windows
  (Chrome 80+ moved cookies from profile/Cookies to profile/Network/Cookies)
- openDb() always uses copy-then-read on Windows (Chrome holds exclusive locks)
- decryptCookieValue() detects v20 App-Bound Encryption with specific error code

Added CDP-based extraction fallback (importCookiesViaCdp) for v20 cookies:
- Launches Chrome headless with --remote-debugging-port on the real profile
- Extracts cookies via Network.getAllCookies over CDP WebSocket
- Requires Chrome to be closed (v20 keys are path-bound to user-data-dir)
- Both cookie picker UI and CLI direct-import paths auto-fall back to CDP

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(browse): document CDP debug port security + log Chrome version on v20 fallback

Follow-up to #892 per Codex outside-voice review. Two small additions to the
Windows v20 App-Bound Encryption CDP fallback:

1. Inline comment documenting the deliberate security posture of the
   --remote-debugging-port. Chrome binds it to 127.0.0.1 by default, so the
   threat model is local-user-only (which is no worse than baseline — local
   attackers can already read the cookie DB). Random port 9222-9321 is for
   collision avoidance, not security. Chrome is always killed in finally.

2. One-time Chrome version log on CDP entry via /json/version. When Chrome
   inevitably changes v20 key format or /json/list shape in a future major
   version, logs will show exactly which version users are hitting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: v0.18.1.0 — community wave (6 PRs + hardening)

VERSION bump + users-first CHANGELOG entry for the wave:
- #993 tilde-in-assignment fix (byliu-labs)
- #994 browse server persists across Bash calls (joelgreen)
- #996 cookie picker alive after cli exits (voidborne-d)
- #864 OpenClaw skills codex-friendly (cathrynlavery)
- #982 OpenCode native setup (breakneo)
- #892 Windows cookie import + DPAPI + v20 CDP fallback (msr-hickory)

Plus 3 follow-up hardening commits we own:
- Extended tilde fix to design resolver + 4 more skill templates
- Gated #994 SIGTERM-ignore to normal mode only (headed/tunnel preserve shutdown)
- Documented CDP debug port security + log Chrome version on v20 fallback

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: review pass — package.json version, import dedup, error context, stale help

Findings from /review on the wave PR:

- [P1] package.json version was 0.18.0.1 but VERSION is 0.18.1.0, failing
  test/gen-skill-docs.test.ts:177 "package.json version matches VERSION file".
  Bumped package.json to 0.18.1.0.
- [P2] Duplicate import of cookie-picker-routes in browse/src/server.ts
  (handleCookiePickerRoute at line 20 + hasActivePicker at line 792). Merged
  into single import at top.
- [P2] cookie-import-browser.ts:494 generic rethrow loses underlying error.
  Now preserves the message so "ENOENT" vs "JSON parse error" vs "permission
  denied" are distinguishable in user output.
- [P3] setup:46 "Missing value for --host" error message listed an incomplete
  set of hosts (missing factory, openclaw, hermes, gbrain). Aligned with the
  "Unknown value" error on line 94.

Kept as-is (not real issues):
- cookie-import-browser.ts:869 empty catch on Chrome version fetch is the
  correct pattern for best-effort diagnostics (per slop-scan philosophy in
  CLAUDE.md — fire-and-forget failures shouldn't throw).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(watchdog): invert test 3 to match merged #994 behavior

main #1025 added browse/test/watchdog.test.ts with test 3 expecting the old
"watchdog kills server when parent dies" behavior. The merge with this
branch's #994 inverted that semantic — the server now STAYS ALIVE on parent
death in normal headless mode (multi-step QA across Claude Code Bash calls
depends on this).

Changes:
- Renamed test 3 from "watchdog fires when parent dies" to "server STAYS ALIVE
  when parent dies (#994)".
- Replaced 25s shutdown poll with 20s observation window asserting the server
  remains alive after the watchdog tick.
- Updated docstring to document all 3 watchdog invariants (env-var disable,
  headed-mode disable, headless persists) and note tunnel-mode coverage gap.

Verification: bun test browse/test/watchdog.test.ts → 3 pass, 0 fail (22.7s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): switch apt mirror to Hetzner to bypass Ubicloud → archive.ubuntu.com timeouts

Both build attempts of `.github/docker/Dockerfile.ci` failed at
`apt-get update` with persistent connection timeouts to archive.ubuntu.com:80
and security.ubuntu.com:80 — 90+ seconds of "connection timed out" against
every Ubuntu IP. Not a transient blip; this PR doesn't touch the Dockerfile,
and a re-run reproduced the same failure across all 9 mirror IPs.

Root cause: Ubicloud runners (Hetzner FSN1-DC21 per runner output) have
unreliable HTTP-port-80 routing to Ubuntu's official archive endpoints.

Fix:
- Rewrite /etc/apt/sources.list.d/ubuntu.sources (deb822 format in 24.04)
  to use https://mirror.hetzner.com/ubuntu/packages instead. Hetzner's
  mirror is publicly accessible from any cloud (not Hetzner-only despite
  the name) and route-local for Ubicloud's actual host. Solves both
  reliability and latency.
- Add a 3-attempt retry loop around both `apt-get update` calls as
  belt-and-suspenders. Even Hetzner's mirror can have brief blips, and the
  retry costs nothing when the first attempt succeeds.

Verification: the workflow will rebuild on push. Local `docker build` not
practical for a 12-step image with bun + claude + playwright deps + a 10-min
cold install. Trusting CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): use HTTP for Hetzner apt mirror (base image lacks ca-certificates)

Previous commit switched to https://mirror.hetzner.com/... which proved the
mirror is reachable and routes correctly (no more 90s timeouts), but exposed
a chicken-and-egg: ubuntu:24.04 ships without ca-certificates, and that's
exactly the package we're installing. Result: "No system certificates
available. Try installing ca-certificates."

Fix: use http:// for the Hetzner mirror. Apt's security model verifies
package integrity via GPG-signed Release files, not TLS, so HTTP here is
no weaker than the upstream defaults (Ubuntu's official sources also
default to HTTP for the same reason).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Cathryn Lavery <cathrynlavery@users.noreply.github.com>
Co-authored-by: Joel Green <thejoelgreen@gmail.com>
Co-authored-by: d 🔹 <258577966+voidborne-d@users.noreply.github.com>
Co-authored-by: Break <breakneo@gmail.com>
Co-authored-by: Michael Spitzer-Rubenstein <msr.ext@hickory.ai>
This commit is contained in:
Garry Tan
2026-04-17 00:45:13 -07:00
committed by GitHub
parent b3eaffce07
commit 1211b6b40b
30 changed files with 864 additions and 113 deletions

View File

@@ -1,7 +1,7 @@
/**
* Chromium browser cookie import — read and decrypt cookies from real browsers
*
* Supports macOS and Linux Chromium-based browsers.
* Supports macOS, Linux, and Windows Chromium-based browsers.
* Pure logic module — no Playwright dependency, no HTTP concerns.
*
* Decryption pipeline:
@@ -40,6 +40,7 @@ import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { TEMP_DIR } from './platform';
// ─── Types ──────────────────────────────────────────────────────
@@ -50,6 +51,7 @@ export interface BrowserInfo {
aliases: string[];
linuxDataDir?: string;
linuxApplication?: string;
windowsDataDir?: string;
}
export interface ProfileEntry {
@@ -91,7 +93,7 @@ export class CookieImportError extends Error {
}
}
type BrowserPlatform = 'darwin' | 'linux';
type BrowserPlatform = 'darwin' | 'linux' | 'win32';
interface BrowserMatch {
browser: BrowserInfo;
@@ -104,11 +106,11 @@ interface BrowserMatch {
const BROWSER_REGISTRY: BrowserInfo[] = [
{ name: 'Comet', dataDir: 'Comet/', keychainService: 'Comet Safe Storage', aliases: ['comet', 'perplexity'] },
{ name: 'Chrome', dataDir: 'Google/Chrome/', keychainService: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome', 'google-chrome-stable'], linuxDataDir: 'google-chrome/', linuxApplication: 'chrome' },
{ name: 'Chromium', dataDir: 'chromium/', keychainService: 'Chromium Safe Storage', aliases: ['chromium'], linuxDataDir: 'chromium/', linuxApplication: 'chromium' },
{ name: 'Chrome', dataDir: 'Google/Chrome/', keychainService: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome', 'google-chrome-stable'], linuxDataDir: 'google-chrome/', linuxApplication: 'chrome', windowsDataDir: 'Google/Chrome/User Data/' },
{ name: 'Chromium', dataDir: 'chromium/', keychainService: 'Chromium Safe Storage', aliases: ['chromium'], linuxDataDir: 'chromium/', linuxApplication: 'chromium', windowsDataDir: 'Chromium/User Data/' },
{ name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] },
{ name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'], linuxDataDir: 'BraveSoftware/Brave-Browser/', linuxApplication: 'brave' },
{ name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'], linuxDataDir: 'microsoft-edge/', linuxApplication: 'microsoft-edge' },
{ name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'], linuxDataDir: 'BraveSoftware/Brave-Browser/', linuxApplication: 'brave', windowsDataDir: 'BraveSoftware/Brave-Browser/User Data/' },
{ name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'], linuxDataDir: 'microsoft-edge/', linuxApplication: 'microsoft-edge', windowsDataDir: 'Microsoft/Edge/User Data/' },
];
// ─── Key Cache ──────────────────────────────────────────────────
@@ -133,10 +135,12 @@ export function findInstalledBrowsers(): BrowserInfo[] {
const browserDir = path.join(getBaseDir(platform), dataDir);
try {
const entries = fs.readdirSync(browserDir, { withFileTypes: true });
if (entries.some(e =>
e.isDirectory() && e.name.startsWith('Profile ') &&
fs.existsSync(path.join(browserDir, e.name, 'Cookies'))
)) return true;
if (entries.some(e => {
if (!e.isDirectory() || !e.name.startsWith('Profile ')) return false;
const profileDir = path.join(browserDir, e.name);
return fs.existsSync(path.join(profileDir, 'Cookies'))
|| (platform === 'win32' && fs.existsSync(path.join(profileDir, 'Network', 'Cookies')));
})) return true;
} catch {}
}
return false;
@@ -174,8 +178,11 @@ export function listProfiles(browserName: string): ProfileEntry[] {
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name !== 'Default' && !entry.name.startsWith('Profile ')) continue;
const cookiePath = path.join(browserDir, entry.name, 'Cookies');
if (!fs.existsSync(cookiePath)) continue;
// Chrome 80+ on Windows stores cookies under Network/Cookies
const cookieCandidates = platform === 'win32'
? [path.join(browserDir, entry.name, 'Network', 'Cookies'), path.join(browserDir, entry.name, 'Cookies')]
: [path.join(browserDir, entry.name, 'Cookies')];
if (!cookieCandidates.some(p => fs.existsSync(p))) continue;
// Avoid duplicates if the same profile appears on multiple platforms
if (profiles.some(p => p.name === entry.name)) continue;
@@ -268,7 +275,7 @@ export async function importCookies(
for (const row of rows) {
try {
const value = decryptCookieValue(row, derivedKeys);
const value = decryptCookieValue(row, derivedKeys, match.platform);
const cookie = toPlaywrightCookie(row, value);
cookies.push(cookie);
domainCounts[row.host_key] = (domainCounts[row.host_key] || 0) + 1;
@@ -310,7 +317,8 @@ function validateProfile(profile: string): void {
}
function getHostPlatform(): BrowserPlatform | null {
if (process.platform === 'darwin' || process.platform === 'linux') return process.platform;
const p = process.platform;
if (p === 'darwin' || p === 'linux' || p === 'win32') return p as BrowserPlatform;
return null;
}
@@ -318,20 +326,22 @@ function getSearchPlatforms(): BrowserPlatform[] {
const current = getHostPlatform();
const order: BrowserPlatform[] = [];
if (current) order.push(current);
for (const platform of ['darwin', 'linux'] as BrowserPlatform[]) {
for (const platform of ['darwin', 'linux', 'win32'] as BrowserPlatform[]) {
if (!order.includes(platform)) order.push(platform);
}
return order;
}
function getDataDirForPlatform(browser: BrowserInfo, platform: BrowserPlatform): string | null {
return platform === 'darwin' ? browser.dataDir : browser.linuxDataDir || null;
if (platform === 'darwin') return browser.dataDir;
if (platform === 'linux') return browser.linuxDataDir || null;
return browser.windowsDataDir || null;
}
function getBaseDir(platform: BrowserPlatform): string {
return platform === 'darwin'
? path.join(os.homedir(), 'Library', 'Application Support')
: path.join(os.homedir(), '.config');
if (platform === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support');
if (platform === 'win32') return path.join(os.homedir(), 'AppData', 'Local');
return path.join(os.homedir(), '.config');
}
function findBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch | null {
@@ -339,12 +349,18 @@ function findBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch |
for (const platform of getSearchPlatforms()) {
const dataDir = getDataDirForPlatform(browser, platform);
if (!dataDir) continue;
const dbPath = path.join(getBaseDir(platform), dataDir, profile, 'Cookies');
try {
if (fs.existsSync(dbPath)) {
return { browser, platform, dbPath };
}
} catch {}
const baseProfile = path.join(getBaseDir(platform), dataDir, profile);
// Chrome 80+ on Windows stores cookies under Network/Cookies; fall back to Cookies
const candidates = platform === 'win32'
? [path.join(baseProfile, 'Network', 'Cookies'), path.join(baseProfile, 'Cookies')]
: [path.join(baseProfile, 'Cookies')];
for (const dbPath of candidates) {
try {
if (fs.existsSync(dbPath)) {
return { browser, platform, dbPath };
}
} catch {}
}
}
return null;
}
@@ -369,6 +385,13 @@ function getBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch {
// ─── Internal: SQLite Access ────────────────────────────────────
function openDb(dbPath: string, browserName: string): Database {
// On Windows, Chrome holds exclusive WAL locks even when we open readonly.
// The readonly open may "succeed" but return empty results because the WAL
// (where all actual data lives) can't be replayed. Always use the copy
// approach on Windows so we can open read-write and process the WAL.
if (process.platform === 'win32') {
return openDbFromCopy(dbPath, browserName);
}
try {
return new Database(dbPath, { readonly: true });
} catch (err: any) {
@@ -439,6 +462,11 @@ async function getDerivedKeys(match: BrowserMatch): Promise<Map<string, Buffer>>
]);
}
if (match.platform === 'win32') {
const key = await getWindowsAesKey(match.browser);
return new Map([['v10', key]]);
}
const keys = new Map<string, Buffer>();
keys.set('v10', getCachedDerivedKey('linux:v10', 'peanuts', 1));
@@ -452,6 +480,84 @@ async function getDerivedKeys(match: BrowserMatch): Promise<Map<string, Buffer>>
return keys;
}
async function getWindowsAesKey(browser: BrowserInfo): Promise<Buffer> {
const cacheKey = `win32:${browser.keychainService}`;
const cached = keyCache.get(cacheKey);
if (cached) return cached;
const platform = 'win32' as const;
const dataDir = getDataDirForPlatform(browser, platform);
if (!dataDir) throw new CookieImportError(`No Windows data dir for ${browser.name}`, 'not_installed');
const localStatePath = path.join(getBaseDir(platform), dataDir, 'Local State');
let localState: any;
try {
localState = JSON.parse(fs.readFileSync(localStatePath, 'utf-8'));
} catch (err) {
const reason = err instanceof Error ? `: ${err.message}` : '';
throw new CookieImportError(
`Cannot read Local State for ${browser.name} at ${localStatePath}${reason}`,
'keychain_error',
);
}
const encryptedKeyB64: string = localState?.os_crypt?.encrypted_key;
if (!encryptedKeyB64) {
throw new CookieImportError(
`No encrypted key in Local State for ${browser.name}`,
'keychain_not_found',
);
}
// The stored value is base64(b"DPAPI" + dpapi_encrypted_bytes) — strip the 5-byte prefix
const encryptedKey = Buffer.from(encryptedKeyB64, 'base64').slice(5);
const key = await dpapiDecrypt(encryptedKey);
keyCache.set(cacheKey, key);
return key;
}
async function dpapiDecrypt(encryptedBytes: Buffer): Promise<Buffer> {
const script = [
'Add-Type -AssemblyName System.Security',
'$stdin = [Console]::In.ReadToEnd().Trim()',
'$bytes = [System.Convert]::FromBase64String($stdin)',
'$dec = [System.Security.Cryptography.ProtectedData]::Unprotect($bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)',
'Write-Output ([System.Convert]::ToBase64String($dec))',
].join('; ');
const proc = Bun.spawn(['powershell', '-NoProfile', '-Command', script], {
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
});
proc.stdin.write(encryptedBytes.toString('base64'));
proc.stdin.end();
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => {
proc.kill();
reject(new CookieImportError('DPAPI decryption timed out', 'keychain_timeout', 'retry'));
}, 10_000),
);
try {
const exitCode = await Promise.race([proc.exited, timeout]);
const stdout = await new Response(proc.stdout).text();
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
throw new CookieImportError(`DPAPI decryption failed: ${stderr.trim()}`, 'keychain_error');
}
return Buffer.from(stdout.trim(), 'base64');
} catch (err) {
if (err instanceof CookieImportError) throw err;
throw new CookieImportError(
`DPAPI decryption failed: ${(err as Error).message}`,
'keychain_error',
);
}
}
async function getMacKeychainPassword(service: string): Promise<string> {
// Use async Bun.spawn with timeout to avoid blocking the event loop.
// macOS may show an Allow/Deny dialog that blocks until the user responds.
@@ -566,7 +672,7 @@ interface RawCookie {
samesite: number;
}
function decryptCookieValue(row: RawCookie, keys: Map<string, Buffer>): string {
function decryptCookieValue(row: RawCookie, keys: Map<string, Buffer>, platform: BrowserPlatform): string {
// Prefer unencrypted value if present
if (row.value && row.value.length > 0) return row.value;
@@ -574,9 +680,28 @@ function decryptCookieValue(row: RawCookie, keys: Map<string, Buffer>): string {
if (ev.length === 0) return '';
const prefix = ev.slice(0, 3).toString('utf-8');
// Chrome 127+ on Windows uses App-Bound Encryption (v20) — cannot be decrypted
// outside the Chrome process. Caller should fall back to CDP extraction.
if (prefix === 'v20') throw new CookieImportError(
'Cookie uses App-Bound Encryption (v20). Use CDP extraction instead.',
'v20_encryption',
);
const key = keys.get(prefix);
if (!key) throw new Error(`No decryption key available for ${prefix} cookies`);
if (platform === 'win32' && prefix === 'v10') {
// Windows: AES-256-GCM — structure: v10(3) + nonce(12) + ciphertext + tag(16)
const nonce = ev.slice(3, 15);
const tag = ev.slice(ev.length - 16);
const ciphertext = ev.slice(15, ev.length - 16);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce) as crypto.DecipherGCM;
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf-8');
}
// macOS / Linux: AES-128-CBC — structure: v10/v11(3) + ciphertext
const ciphertext = ev.slice(3);
const iv = Buffer.alloc(16, 0x20); // 16 space characters
const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
@@ -624,3 +749,284 @@ function mapSameSite(value: number): 'Strict' | 'Lax' | 'None' {
default: return 'Lax';
}
}
// ─── CDP-based Cookie Extraction (Windows v20 fallback) ────────
// When App-Bound Encryption (v20) is detected, we launch Chrome headless
// with remote debugging and extract cookies via the DevTools Protocol.
// This only works when Chrome is NOT already running (profile lock).
const CHROME_PATHS_WIN = [
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
];
const EDGE_PATHS_WIN = [
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
];
function findBrowserExe(browserName: string): string | null {
const candidates = browserName.toLowerCase().includes('edge') ? EDGE_PATHS_WIN : CHROME_PATHS_WIN;
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
return null;
}
function isBrowserRunning(browserName: string): Promise<boolean> {
const exe = browserName.toLowerCase().includes('edge') ? 'msedge.exe' : 'chrome.exe';
return new Promise((resolve) => {
const proc = Bun.spawn(['tasklist', '/FI', `IMAGENAME eq ${exe}`, '/NH'], {
stdout: 'pipe', stderr: 'pipe',
});
proc.exited.then(async () => {
const out = await new Response(proc.stdout).text();
resolve(out.toLowerCase().includes(exe));
}).catch(() => resolve(false));
});
}
/**
* Extract cookies via Chrome DevTools Protocol. Launches Chrome headless with
* remote debugging on the user's real profile directory. Requires Chrome to be
* closed first (profile lock).
*
* v20 App-Bound Encryption binds decryption keys to the original user-data-dir
* path, so a temp copy of the profile won't work — Chrome silently discards
* cookies it can't decrypt. We must use the real profile.
*/
export async function importCookiesViaCdp(
browserName: string,
domains: string[],
profile = 'Default',
): Promise<ImportResult> {
if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} };
if (process.platform !== 'win32') {
throw new CookieImportError('CDP extraction is only needed on Windows', 'not_supported');
}
const browser = resolveBrowser(browserName);
const exePath = findBrowserExe(browser.name);
if (!exePath) {
throw new CookieImportError(
`Cannot find ${browser.name} executable. Install it or use /connect-chrome.`,
'not_installed',
);
}
if (await isBrowserRunning(browser.name)) {
throw new CookieImportError(
`${browser.name} is running. Close it first so we can launch headless with your profile, or use /connect-chrome to control your real browser directly.`,
'browser_running',
'retry',
);
}
// Must use the real user data dir — v20 ABE keys are path-bound
const dataDir = getDataDirForPlatform(browser, 'win32');
if (!dataDir) throw new CookieImportError(`No Windows data dir for ${browser.name}`, 'not_installed');
const userDataDir = path.join(getBaseDir('win32'), dataDir);
// Launch Chrome headless with remote debugging on the real profile.
//
// Security posture of the debug port:
// - Chrome binds --remote-debugging-port to 127.0.0.1 by default. We rely
// on that — the port is NOT exposed to the network. Any local process
// running as the same user could connect and read cookies, but if an
// attacker already has local-user access they can read the cookie DB
// directly. Threat model: no worse than baseline.
// - Port is randomized in [9222, 9321] to avoid collisions with other
// Chrome-based tools the user may have open. Not cryptographic.
// - Chrome is always killed in the finally block below (even on crash).
//
// Debugging note: if this path starts failing after a Chrome update,
// check the Chrome version logged below — Chrome's ABE key format (v20)
// or /json/list shape can change between major versions.
const debugPort = 9222 + Math.floor(Math.random() * 100);
const chromeProc = Bun.spawn([
exePath,
`--remote-debugging-port=${debugPort}`,
`--user-data-dir=${userDataDir}`,
`--profile-directory=${profile}`,
'--headless=new',
'--no-first-run',
'--disable-background-networking',
'--disable-default-apps',
'--disable-extensions',
'--disable-sync',
'--no-default-browser-check',
], { stdout: 'pipe', stderr: 'pipe' });
// Wait for Chrome to start, then find a page target's WebSocket URL.
// Network.getAllCookies is only available on page targets, not browser.
let wsUrl: string | null = null;
const startTime = Date.now();
let loggedVersion = false;
while (Date.now() - startTime < 15_000) {
try {
// One-time version log for future diagnostics when Chrome changes v20 format.
if (!loggedVersion) {
try {
const versionResp = await fetch(`http://127.0.0.1:${debugPort}/json/version`);
if (versionResp.ok) {
const v = await versionResp.json() as { Browser?: string };
console.log(`[cookie-import] CDP fallback: ${browser.name} ${v.Browser || 'unknown version'}`);
loggedVersion = true;
}
} catch {}
}
const resp = await fetch(`http://127.0.0.1:${debugPort}/json/list`);
if (resp.ok) {
const targets = await resp.json() as Array<{ type: string; webSocketDebuggerUrl?: string }>;
const page = targets.find(t => t.type === 'page');
if (page?.webSocketDebuggerUrl) {
wsUrl = page.webSocketDebuggerUrl;
break;
}
}
} catch {
// Not ready yet
}
await new Promise(r => setTimeout(r, 300));
}
if (!wsUrl) {
chromeProc.kill();
throw new CookieImportError(
`${browser.name} headless did not start within 15s`,
'cdp_timeout',
'retry',
);
}
try {
// Connect via CDP WebSocket
const cookies = await extractCookiesViaCdp(wsUrl, domains);
const domainCounts: Record<string, number> = {};
for (const c of cookies) {
domainCounts[c.domain] = (domainCounts[c.domain] || 0) + 1;
}
return { cookies, count: cookies.length, failed: 0, domainCounts };
} finally {
chromeProc.kill();
}
}
async function extractCookiesViaCdp(wsUrl: string, domains: string[]): Promise<PlaywrightCookie[]> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl);
let msgId = 1;
const timeout = setTimeout(() => {
ws.close();
reject(new CookieImportError('CDP cookie extraction timed out', 'cdp_timeout'));
}, 10_000);
ws.onopen = () => {
// Enable Network domain first, then request all cookies
ws.send(JSON.stringify({ id: msgId++, method: 'Network.enable' }));
};
ws.onmessage = (event) => {
const data = JSON.parse(String(event.data));
// After Network.enable succeeds, request all cookies
if (data.id === 1 && !data.error) {
ws.send(JSON.stringify({ id: msgId, method: 'Network.getAllCookies' }));
return;
}
if (data.id === msgId && data.result?.cookies) {
clearTimeout(timeout);
ws.close();
// Normalize domain matching: domains like ".example.com" match "example.com" and vice versa
const domainSet = new Set<string>();
for (const d of domains) {
domainSet.add(d);
domainSet.add(d.startsWith('.') ? d.slice(1) : '.' + d);
}
const matched: PlaywrightCookie[] = [];
for (const c of data.result.cookies as CdpCookie[]) {
if (!domainSet.has(c.domain)) continue;
matched.push({
name: c.name,
value: c.value,
domain: c.domain,
path: c.path || '/',
expires: c.expires === -1 ? -1 : c.expires,
secure: c.secure,
httpOnly: c.httpOnly,
sameSite: cdpSameSite(c.sameSite),
});
}
resolve(matched);
} else if (data.id === msgId && data.error) {
clearTimeout(timeout);
ws.close();
reject(new CookieImportError(
`CDP error: ${data.error.message}`,
'cdp_error',
));
}
};
ws.onerror = (err) => {
clearTimeout(timeout);
reject(new CookieImportError(
`CDP WebSocket error: ${(err as any).message || 'unknown'}`,
'cdp_error',
));
};
});
}
interface CdpCookie {
name: string;
value: string;
domain: string;
path: string;
expires: number;
size: number;
httpOnly: boolean;
secure: boolean;
session: boolean;
sameSite: string;
}
function cdpSameSite(value: string): 'Strict' | 'Lax' | 'None' {
switch (value) {
case 'Strict': return 'Strict';
case 'Lax': return 'Lax';
case 'None': return 'None';
default: return 'Lax';
}
}
/**
* Check if a browser's cookie DB contains v20 (App-Bound) encrypted cookies.
* Quick check — reads a small sample, no decryption attempted.
*/
export function hasV20Cookies(browserName: string, profile = 'Default'): boolean {
if (process.platform !== 'win32') return false;
try {
const browser = resolveBrowser(browserName);
const match = getBrowserMatch(browser, profile);
const db = openDb(match.dbPath, browser.name);
try {
const rows = db.query('SELECT encrypted_value FROM cookies LIMIT 10').all() as Array<{ encrypted_value: Buffer | Uint8Array }>;
return rows.some(row => {
const ev = Buffer.from(row.encrypted_value);
return ev.length >= 3 && ev.slice(0, 3).toString('utf-8') === 'v20';
});
} finally {
db.close();
}
} catch {
return false;
}
}

View File

@@ -19,7 +19,7 @@
import * as crypto from 'crypto';
import type { BrowserManager } from './browser-manager';
import { findInstalledBrowsers, listProfiles, listDomains, importCookies, CookieImportError, type PlaywrightCookie } from './cookie-import-browser';
import { findInstalledBrowsers, listProfiles, listDomains, importCookies, importCookiesViaCdp, hasV20Cookies, CookieImportError, type PlaywrightCookie } from './cookie-import-browser';
import { getCookiePickerHTML } from './cookie-picker-ui';
// ─── Auth State ─────────────────────────────────────────────────
@@ -40,6 +40,23 @@ export function generatePickerCode(): string {
return code;
}
/** Return true while the picker still has a live code or session. */
export function hasActivePicker(): boolean {
const now = Date.now();
for (const [code, expiry] of pendingCodes) {
if (expiry > now) return true;
pendingCodes.delete(code);
}
for (const [session, expiry] of validSessions) {
if (expiry > now) return true;
validSessions.delete(session);
}
return false;
}
/** Extract session ID from the gstack_picker cookie. */
function getSessionFromCookie(req: Request): string | null {
const cookie = req.headers.get('cookie');
@@ -217,7 +234,25 @@ export async function handleCookiePickerRoute(
}
// Decrypt cookies from the browser DB
const result = await importCookies(browser, domains, profile || 'Default');
const selectedProfile = profile || 'Default';
let result = await importCookies(browser, domains, selectedProfile);
// If all cookies failed and v20 encryption is detected, try CDP extraction
if (result.cookies.length === 0 && result.failed > 0 && hasV20Cookies(browser, selectedProfile)) {
console.log(`[cookie-picker] v20 App-Bound Encryption detected, trying CDP extraction...`);
try {
result = await importCookiesViaCdp(browser, domains, selectedProfile);
} catch (cdpErr: any) {
console.log(`[cookie-picker] CDP fallback failed: ${cdpErr.message}`);
return jsonResponse({
imported: 0,
failed: result.failed,
domainCounts: {},
message: `Cookies use App-Bound Encryption (v20). Close ${browser}, retry, or use /connect-chrome to browse with your real browser directly.`,
code: 'v20_encryption',
}, { port });
}
}
if (result.cookies.length === 0) {
return jsonResponse({

View File

@@ -17,7 +17,7 @@ import { BrowserManager } from './browser-manager';
import { handleReadCommand } from './read-commands';
import { handleWriteCommand } from './write-commands';
import { handleMetaCommand } from './meta-commands';
import { handleCookiePickerRoute } from './cookie-picker-routes';
import { handleCookiePickerRoute, hasActivePicker } from './cookie-picker-routes';
import { sanitizeExtensionUrl } from './sidebar-utils';
import { COMMAND_DESCRIPTIONS, PAGE_CONTENT_COMMANDS, wrapUntrustedContent } from './commands';
import {
@@ -765,14 +765,37 @@ const idleCheckInterval = setInterval(() => {
// also checks BROWSE_HEADED in case a future launcher forgets.
// Cleanup happens via browser disconnect event or $B disconnect.
const BROWSE_PARENT_PID = parseInt(process.env.BROWSE_PARENT_PID || '0', 10);
// Outer gate: if the spawner explicitly marks this as headed (env var set at
// launch time), skip registering the watchdog entirely. Cheaper than entering
// the closure every 15s. The CLI's connect path sets BROWSE_HEADED=1 + PID=0,
// so this branch is the normal path for /open-gstack-browser.
const IS_HEADED_WATCHDOG = process.env.BROWSE_HEADED === '1';
if (BROWSE_PARENT_PID > 0 && !IS_HEADED_WATCHDOG) {
let parentGone = false;
setInterval(() => {
try {
process.kill(BROWSE_PARENT_PID, 0); // signal 0 = existence check only, no signal sent
} catch {
console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited, shutting down`);
shutdown();
// Parent exited. Resolution order:
// 1. Active cookie picker (one-time code or session live)? Stay alive
// regardless of mode — tearing down the server mid-import leaves the
// picker UI with a stale "Failed to fetch" error.
// 2. Headed / tunnel mode? Shutdown. The idle timeout doesn't apply in
// these modes (see idleCheckInterval above — both early-return), so
// ignoring parent death here would leak orphan daemons after
// /pair-agent or /open-gstack-browser sessions.
// 3. Normal (headless) mode? Stay alive. Claude Code's Bash tool kills
// the parent shell between invocations. The idle timeout (30 min)
// handles eventual cleanup.
if (hasActivePicker()) return;
const headed = browserManager.getConnectionMode() === 'headed';
if (headed || tunnelActive) {
console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited in ${headed ? 'headed' : 'tunnel'} mode, shutting down`);
shutdown();
} else if (!parentGone) {
parentGone = true;
console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited (server stays alive, idle timeout will clean up)`);
}
}
}, 15_000);
} else if (IS_HEADED_WATCHDOG) {
@@ -1241,11 +1264,36 @@ async function shutdown(exitCode: number = 0) {
}
// Handle signals
//
// Node passes the signal name (e.g. 'SIGTERM') as the first arg to listeners.
// Wrap so shutdown() receives no args — otherwise the string gets passed as
// exitCode and process.exit() coerces it to NaN, exiting with code 1 instead of 0.
process.on('SIGTERM', () => shutdown());
// Wrap calls to shutdown() so it receives no args — otherwise the string gets
// passed as exitCode and process.exit() coerces it to NaN, exiting with code 1
// instead of 0. (Caught in v0.18.1.0 #1025.)
//
// SIGINT (Ctrl+C): user intentionally stopping → shutdown.
process.on('SIGINT', () => shutdown());
// 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
// alive across $B calls. Idle timeout (30 min) handles eventual cleanup.
// - Headed / tunnel mode: idle timeout doesn't apply in these modes. Respect
// SIGTERM so external tooling (systemd, supervisord, CI) can shut cleanly
// without waiting forever. Ctrl+C and /stop still work either way.
// - Active cookie picker: never tear down mid-import regardless of mode —
// would strand the picker UI with "Failed to fetch."
process.on('SIGTERM', () => {
if (hasActivePicker()) {
console.log('[browse] Received SIGTERM but cookie picker is active, ignoring to avoid stranding the picker UI');
return;
}
const headed = browserManager.getConnectionMode() === 'headed';
if (headed || tunnelActive) {
console.log(`[browse] Received SIGTERM in ${headed ? 'headed' : 'tunnel'} mode, shutting down`);
shutdown();
} else {
console.log('[browse] Received SIGTERM (ignoring — use /stop or Ctrl+C for intentional shutdown)');
}
});
// Windows: taskkill /F bypasses SIGTERM, but 'exit' fires for some shutdown paths.
// Defense-in-depth — primary cleanup is the CLI's stale-state detection via health check.
if (process.platform === 'win32') {

View File

@@ -7,7 +7,7 @@
import type { TabSession } from './tab-session';
import type { BrowserManager } from './browser-manager';
import { findInstalledBrowsers, importCookies, listSupportedBrowserNames } from './cookie-import-browser';
import { findInstalledBrowsers, importCookies, importCookiesViaCdp, hasV20Cookies, listSupportedBrowserNames } from './cookie-import-browser';
import { generatePickerCode } from './cookie-picker-routes';
import { validateNavigationUrl } from './url-validation';
import { validateOutputPath } from './path-security';
@@ -504,7 +504,11 @@ export async function handleWriteCommand(
throw new Error(`--domain "${domain}" does not match current page domain "${pageHostname}". Navigate to the target site first.`);
}
const browser = browserArg || 'comet';
const result = await importCookies(browser, [domain], profile);
let result = await importCookies(browser, [domain], profile);
// If all cookies failed and v20 is detected, try CDP extraction
if (result.cookies.length === 0 && result.failed > 0 && hasV20Cookies(browser, profile)) {
result = await importCookiesViaCdp(browser, [domain], profile);
}
if (result.cookies.length > 0) {
await page.context().addCookies(result.cookies);
bm.trackCookieImportDomains([domain]);

View File

@@ -7,7 +7,7 @@
*/
import { describe, test, expect } from 'bun:test';
import { handleCookiePickerRoute, generatePickerCode } from '../src/cookie-picker-routes';
import { handleCookiePickerRoute, generatePickerCode, hasActivePicker } from '../src/cookie-picker-routes';
// ─── Mock BrowserManager ──────────────────────────────────────
@@ -284,6 +284,57 @@ describe('cookie-picker-routes', () => {
});
});
describe('active picker tracking', () => {
test('one-time codes keep the picker active until consumed', async () => {
const realNow = Date.now;
Date.now = () => realNow() + 3_700_000;
try {
expect(hasActivePicker()).toBe(false); // clears any stale state from prior tests
} finally {
Date.now = realNow;
}
const { bm } = mockBrowserManager();
const code = generatePickerCode();
expect(hasActivePicker()).toBe(true);
const res = await handleCookiePickerRoute(
makeUrl(`/cookie-picker?code=${code}`),
new Request('http://127.0.0.1:9470', { method: 'GET' }),
bm,
'test-token',
);
expect(res.status).toBe(302);
expect(hasActivePicker()).toBe(true); // session is now active
});
test('picker becomes inactive after an invalid session probe clears expired state', async () => {
const { bm } = mockBrowserManager();
const session = await getSessionCookie(bm, 'test-token');
expect(hasActivePicker()).toBe(true);
const realNow = Date.now;
Date.now = () => realNow() + 3_700_000;
try {
const res = await handleCookiePickerRoute(
makeUrl('/cookie-picker'),
new Request('http://127.0.0.1:9470', {
method: 'GET',
headers: { 'Cookie': `gstack_picker=${session}` },
}),
bm,
'test-token',
);
expect(res.status).toBe(403);
expect(hasActivePicker()).toBe(false);
} finally {
Date.now = realNow;
}
});
});
describe('session cookie auth', () => {
test('valid session cookie grants HTML access', async () => {
const { bm } = mockBrowserManager();

View File

@@ -5,16 +5,28 @@ import * as fs from 'fs';
import * as os from 'os';
// End-to-end regression tests for the parent-process watchdog in server.ts.
// Proves three invariants that the v0.18.1.0 fix depends on:
// The watchdog has layered behavior since v0.18.1.0 (#1025) and v0.18.2.0
// (community wave #994 + our mode-gating follow-up):
//
// 1. BROWSE_PARENT_PID=0 disables the watchdog (opt-in used by CI and pair-agent).
// 2. BROWSE_HEADED=1 disables the watchdog (server-side defense-in-depth).
// 3. Default headless mode still kills the server when its parent dies
// (the original orphan-prevention must keep working).
// 1. BROWSE_PARENT_PID=0 disables the watchdog entirely (opt-in for CI + pair-agent).
// 2. BROWSE_HEADED=1 disables the watchdog entirely (server-side defense for headed
// mode, where the user controls window lifecycle).
// 3. Default headless mode + parent dies: server STAYS ALIVE. The original
// "kill on parent death" was inverted by #994 because Claude Code's Bash
// sandbox kills the parent shell between every tool invocation, and #994
// makes browse persist across $B calls. Idle timeout (30 min) handles
// eventual cleanup.
//
// Each test spawns the real server.ts, not a mock. Tests 1 and 2 verify the
// code path via stdout log line (fast). Test 3 waits for the watchdog's 15s
// poll cycle to actually fire (slow — ~25s).
// Tunnel mode coverage (parent dies → shutdown because idle timeout doesn't
// apply) is not covered by an automated test here — tunnelActive is a runtime
// variable set by /pair-agent's tunnel-create flow, not an env var, so faking
// it would require invasive test-only hooks. The mode check is documented
// inline at the watchdog and SIGTERM handlers, and would regress visibly for
// /pair-agent users (server lingers after disconnect).
//
// Each test spawns the real server.ts. Tests 1 and 2 verify behavior via
// stdout log line (fast). Test 3 waits for the watchdog poll cycle to confirm
// the server REMAINS alive after parent death (slow — ~20s observation window).
const ROOT = path.resolve(import.meta.dir, '..');
const SERVER_SCRIPT = path.join(ROOT, 'src', 'server.ts');
@@ -117,7 +129,7 @@ describe('parent-process watchdog (v0.18.1.0)', () => {
expect(out).not.toContain('Parent process 999999 exited');
}, 15_000);
test('default headless mode: watchdog fires when parent dies', async () => {
test('default headless mode: server STAYS ALIVE when parent dies (#994)', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'watchdog-default-'));
// Spawn a real, short-lived "parent" that the watchdog will poll.
@@ -133,15 +145,13 @@ describe('parent-process watchdog (v0.18.1.0)', () => {
expect(isProcessAlive(serverPid)).toBe(true);
// Kill the parent. The watchdog polls every 15s, so first tick after
// parent death lands within ~15s, plus shutdown() cleanup time.
// parent death lands within ~15s. Pre-#994 the server would shutdown
// here. Post-#994 the server logs the parent exit and stays alive.
parentProc.kill('SIGKILL');
// Poll for up to 25s for the server to exit.
const deadline = Date.now() + 25_000;
while (Date.now() < deadline) {
if (!isProcessAlive(serverPid)) break;
await Bun.sleep(500);
}
expect(isProcessAlive(serverPid)).toBe(false);
// Wait long enough for at least one watchdog tick (15s) plus margin.
// Server should still be alive — that's the whole point of #994.
await Bun.sleep(20_000);
expect(isProcessAlive(serverPid)).toBe(true);
}, 45_000);
});