mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-13 08:29:04 +02:00
Five hardening items in the browse CLI and its tests: - probeHealthWithBackoff's advertised ~8s budget could really run ~10s: the final 2s probe could start 1ms before the deadline, and every call site had JUST run a failed probe yet the loop re-probed immediately. Iterations now start with the sleep and each probe's timeout clamps to the remaining budget (isServerHealthy takes an injectable timeout). - browse-daemon.log is append-mode across every respawn with no size cap, so a crash-respawn loop fills the disk. The path is now built in one place (daemonLogPath — the Unix fd path and the Windows launcher string had two spellings) and daemon start rotates a >10MB log to browse-daemon.log.1, single generation, matching the repo's 10MB rotation convention. Rotation is exported + injectable and behaviorally unit-tested. - The two "healthy daemon already running" refusal blocks in connect had already drifted (one lost the tabs/cookies/logins explainer) — extracted refuseHeadedOverLiveDaemon as the single source. - process-liveness: pinned the EPERM-means-alive contract (PID 1 on POSIX, PID 4 on Windows — signalable-or-EPERM, both alive). A probe that reads EPERM as dead is the false negative that leaked agents. - runBoundedChromiumReinstall had zero coverage: now exercised end-to-end against a stub bunx on a prepended PATH — exit 0, install-exit-N with stderr tail, the detached group-kill timeout path (child of the child dies too), and spawn-error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
136 lines
6.3 KiB
TypeScript
136 lines
6.3 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');
|
|
// 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 });
|
|
}
|
|
});
|
|
});
|