Files
gstack/browse/test/daemon-log-hygiene.test.ts
T
c4e2233832 fix(browse): capture daemon stdout/stderr to browse-daemon.log + Windows polyfill spawn fixes (re-derived from #2461)
The detached daemon's stdout/stderr were wired to 'ignore' on every
platform, so every console.error('[browse] FATAL: ...') from a Chromium
crash, uncaughtException, or unhandledRejection was discarded at the OS
level — a crash-and-respawn looked identical to every other dropped
session, with nothing on disk recording why. Both spawn paths now redirect
to <stateDir>/browse-daemon.log (append mode, accumulates across respawns):
the Unix path via an fd from openDaemonLogSink(), the Windows path by
opening the fd INSIDE the node -e launcher string (an fd opened in cli.ts
would not cross the spawn boundary). Unwritable state dir falls back to
'ignore' rather than failing the launch.

Capturing daemon output is what surfaced the PR's second fix, still valid
on current main: bun-polyfill.cjs's Bun.spawn/spawnSync called Node's
child_process with a bare command name, which Windows can't resolve without
PATHEXT lookup ("spawn bun ENOENT" from the terminal-agent respawn path).
Routed through cross-spawn on win32 (now a direct dependency; already in
the tree transitively via @modelcontextprotocol/sdk) — the PR verified
empirically that shell:true does NOT neutralize cmd.exe metacharacters
reachable via `$B skill run` arg passthrough, and that Node refuses .cmd
spawns without a shell (CVE-2024-27980), so cross-spawn's combined PATHEXT
resolution + argument escaping is the only correct shape. The PR's third
fix (resolveDisconnectCause throwing "browser?.process is not a function")
already landed on main via the #2085 typeof guard — not re-applied.

F6 log hygiene (daemon-log-hygiene.test.ts): needle tests pin the log
wiring on both spawn paths (and that stdio 'ignore','ignore','ignore'
never returns), that bun-polyfill stays on cross-spawn with no shell:true,
that NO console.* call in src/ passes a token value (interpolated or bare
arg), and that the page-content carrier modules (tab-session, buffers,
content-security, activity) stay console-free — so neither AUTH_TOKEN nor
unsanitized page-derived strings can reach browse-daemon.log.

Tests: daemon-log-hygiene + bun-polyfill + windows-spawn-hide +
cli-setsid-daemonize 21 pass; stop-dead-daemon + busy-daemon-iron-rule
(exercises a REAL daemon boot through the new log-fd wiring) 10 pass.

Re-derived from PR #2461 by @phuttimatebenchanakatkul.

Co-authored-by: phuttimatebenchanakatkul <phuttimatebenchanakatkul@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:59:07 -07:00

96 lines
4.5 KiB
TypeScript

/**
* #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');
expect(cli).toMatch(/openSync\(path\.join\(config\.stateDir, 'browse-daemon\.log'\), 'a'\)/);
expect(cli).toMatch(/openSync\(\$\{daemonLogPathStr\},'a'\)/);
});
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 });
}
});
});