mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 06:54:50 +01:00
* 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>
145 lines
7.6 KiB
TypeScript
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);
|
|
});
|