Files
gstack/browse/test/dotenv-isolation.test.ts
Garry Tanandcapy-ai[bot] 39b06e22af v1.91.250.0 fix(tests): remove three free-tier fixture races (GSTA-107 daily flaky pass) (#3474)
* fix(tests): remove three free-tier fixture races: atomic result files in dotenv-isolation and claude-code-runner, per-fixture Git repo in cso-git-hardening (GSTA-107)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

The new dotenv-isolation test spawns Bun with emptyBunConfig() instead of a
literal --config=/dev/null, which the Windows lane cannot open; the runner's
timeout case asserts the descendant published a pid before asking whether it
is running, so a missed readiness window names itself.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(tests): cso-git-hardening fixtures turn git auto-maintenance off

seedRepo commits with fixtureGit's replacement env, which never sees the
shard backstop, so each commit started a detached git maintenance run that
creates and deletes .git/objects/maintenance.lock under the tests' own .git
reads and the afterEach rmSync (docs/TESTING_INTERNALS.md, Free-suite flake
rules). Found by /ship's documentation audit; git 2.55.0 trace2 shows the
maintenance spawn with the keys unset and none with them set.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-26 17:13:33 +00:00

145 lines
7.6 KiB
TypeScript

/**
* D0: a project's .env and bunfig.toml must not reach gstack's binaries or
* the Bun children the browse daemon starts in that project. Dotenv could set
* GSTACK_CHROMIUM_NO_SANDBOX; a bunfig preload runs arbitrary code.
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { emptyBunConfig } from '../src/bun-child-config';
import { spawnTerminalAgent } from '../src/terminal-agent-control';
const ROOT = path.resolve(import.meta.dir, '..', '..');
const CLI = path.join(ROOT, 'browse', 'src', 'cli.ts');
let project: string;
let marker: string;
function seedHostileProject(dir: string): void {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, '.env'), 'GSTACK_CHROMIUM_NO_SANDBOX=1\n');
fs.writeFileSync(path.join(dir, 'preload.ts'), `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ran');\n`);
fs.writeFileSync(path.join(dir, 'bunfig.toml'), 'preload = ["./preload.ts"]\n');
}
// The probe publishes its result by rename, so a poller never sees the file
// half-written (Free Tests 39902683183 and 40322888041: a 50 ms poll read the
// empty file writeFileSync had just created, "JSON Parse error: Unexpected EOF").
function probeSource(out: string): string {
return `const fs = require('node:fs'); const tmp = ${JSON.stringify(out)} + '.tmp'; fs.writeFileSync(tmp, JSON.stringify({ sandbox: process.env.GSTACK_CHROMIUM_NO_SANDBOX ?? null })); fs.renameSync(tmp, ${JSON.stringify(out)});\n`;
}
/** The probe's complete result: waits for the file to exist and to hold content, not only to exist. */
async function waitForFile(file: string): Promise<string> {
const deadline = Date.now() + 20_000;
let content = '';
while (Date.now() < deadline) {
try { content = fs.readFileSync(file, 'utf-8'); } catch {}
if (content) return content;
await Bun.sleep(50);
}
return fs.readFileSync(file, 'utf-8');
}
beforeEach(() => {
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-dotenv-'));
project = path.join(scratch, 'project');
marker = path.join(scratch, 'preload-ran');
seedHostileProject(project);
});
afterEach(() => {
fs.rmSync(path.dirname(project), { recursive: true, force: true });
});
describe('D0: project .env and bunfig.toml are ignored', () => {
test('waitForFile returns a result that is still being written only once it holds content', async () => {
const out = path.join(path.dirname(project), 'late.json');
fs.writeFileSync(out, '');
setTimeout(() => fs.writeFileSync(out, '{"sandbox":null}'), 150);
expect(JSON.parse(await waitForFile(out))).toEqual({ sandbox: null });
const atomic = path.join(path.dirname(project), 'atomic.json');
fs.writeFileSync(path.join(path.dirname(project), 'atomic-probe.ts'), probeSource(atomic));
const run = Bun.spawnSync([process.execPath, '--no-env-file', `--config=${emptyBunConfig()}`, path.join(path.dirname(project), 'atomic-probe.ts')], { cwd: project, env: { PATH: process.env.PATH ?? '' }, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
expect(run.exitCode, run.stderr.toString()).toBe(0);
expect(JSON.parse(fs.readFileSync(atomic, 'utf-8'))).toEqual({ sandbox: null });
expect(fs.existsSync(atomic + '.tmp')).toBe(false);
});
test('every compiled binary in scripts/build.sh turns dotenv and bunfig autoload off, and the flags work', () => {
const lines = fs.readFileSync(path.join(ROOT, 'scripts', 'build.sh'), 'utf-8').split('\n').filter(l => /build --compile/.test(l));
expect(lines.length).toBeGreaterThanOrEqual(5);
for (const line of lines) {
expect(line).toContain('--no-compile-autoload-dotenv');
expect(line).toContain('--no-compile-autoload-bunfig');
}
const out = path.join(path.dirname(project), 'probe.json');
const src = path.join(path.dirname(project), 'probe.ts');
const bin = path.join(path.dirname(project), process.platform === 'win32' ? 'probe-bin.exe' : 'probe-bin');
fs.writeFileSync(src, probeSource(out));
const build = Bun.spawnSync([process.execPath, 'build', '--compile', '--no-compile-autoload-dotenv', '--no-compile-autoload-bunfig', src, '--outfile', bin], { stdout: 'pipe', stderr: 'pipe', timeout: 60_000 });
expect(build.exitCode, build.stderr.toString()).toBe(0);
const run = Bun.spawnSync([bin], { cwd: project, env: { PATH: process.env.PATH ?? '' }, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
expect(run.exitCode, run.stderr.toString()).toBe(0);
expect(JSON.parse(fs.readFileSync(out, 'utf-8'))).toEqual({ sandbox: null });
expect(fs.existsSync(marker)).toBe(false);
}, 90_000);
// The agent's owner identity comes from `ps`, which Windows runners lack.
test.skipIf(process.platform === 'win32')('the terminal agent spawned in the project sees neither the .env value nor the bunfig preload', async () => {
const stateDir = path.join(path.dirname(project), 'state');
fs.mkdirSync(stateDir, { recursive: true });
const out = path.join(stateDir, 'agent-env.json');
const script = path.join(stateDir, 'agent-probe.ts');
fs.writeFileSync(script, probeSource(out));
const saved = process.env.GSTACK_CHROMIUM_NO_SANDBOX;
delete process.env.GSTACK_CHROMIUM_NO_SANDBOX;
try {
const pid = spawnTerminalAgent({ stateFile: path.join(stateDir, 'browse.json'), serverPort: 1, ownerPid: process.pid, cwd: project, scriptPath: script });
expect(pid).toBeGreaterThan(0);
expect(JSON.parse(await waitForFile(out))).toEqual({ sandbox: null });
expect(fs.existsSync(marker)).toBe(false);
} finally {
if (saved !== undefined) process.env.GSTACK_CHROMIUM_NO_SANDBOX = saved;
}
}, 30_000);
test.skipIf(process.platform === 'win32')('the browse server spawned from the project sees neither the .env value nor the bunfig preload', async () => {
const stateFile = path.join(project, '.gstack', 'browse.json');
const stub = path.join(path.dirname(project), 'stub-server.ts');
fs.writeFileSync(stub, `
import * as fs from 'node:fs';
const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => Response.json({ status: 'healthy' }) });
fs.writeFileSync(process.env.BROWSE_STATE_FILE!, JSON.stringify({
pid: process.pid, port: server.port, token: 'dotenv-test', mode: 'launched',
sandbox: process.env.GSTACK_CHROMIUM_NO_SANDBOX ?? null,
}));
setTimeout(() => server.stop(true), 5000);
`);
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && !/^(BROWSE_|GSTACK_|CHROMIUM_PROFILE$|CLAUDE_PLUGIN_DATA$)/.test(key)) env[key] = value;
}
Object.assign(env, {
HOME: path.dirname(project),
GSTACK_HOME: path.join(path.dirname(project), '.gstack'),
BROWSE_STATE_FILE: stateFile,
BROWSE_SERVER_SCRIPT: stub,
BROWSE_PARENT_PID: '0',
BROWSE_START_TIMEOUT: '20000',
});
// The shipped CLI is compiled without dotenv autoload; --no-env-file gives
// the source CLI the same starting environment.
const cli = Bun.spawn([process.execPath, '--no-env-file', '--config=/dev/null', CLI, 'status'], { cwd: project, env, stdout: 'pipe', stderr: 'pipe' });
const timer = setTimeout(() => cli.kill('SIGKILL'), 40_000);
const [code, stderr] = await Promise.all([cli.exited, new Response(cli.stderr).text()]);
clearTimeout(timer);
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
try { process.kill(state.pid, 'SIGKILL'); } catch {}
expect(code, stderr).toBe(0);
expect(state.sandbox).toBeNull();
expect(fs.existsSync(marker)).toBe(false);
}, 60_000);
});