mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 23:14:33 +01:00
* fix(make-pdf,design): real TOC page numbers and anchors; design models move to gpt-5.5 + gpt-image-2 (#2903, #2807) * ci: pin every action to a SHA, Scorecard, checksummed Bun, scoped Codex CLI in the CI image, macOS named-regressions job (#1948, #1706, #1997) * fix(cso,browse): Windows/macOS lease identity, retention skips bad runs, truthful 'not assessed' reports, env.kv false positive, Windows browse spawn fixes (#2894, #2912, #2048, #2637, #2805) * fix(outside-voice): one Codex model precedence for probe and dispatch, bounded timeouts with KILL escalation, probe cache identity, guarded mktemp, skills hidden in nested Codex (#2776, #2914, #2787, #2881, #2847) * feat(setup): install registry, ./setup --status, staged activation, per-scope migrations, host tiers and conformance kit, OpenCode/Hermes fixes, Copilot CLI (experimental), disabled_skills, slimmer CLAUDE.md (#1925, #2879, #2347, #2338, #2626, #2629, #2825, #2015, #393, #1206, #2096) Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com> Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com> Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com> * fix(preamble): skills start in worktree-isolated Claude Code sessions — one literal skill-start line, context-recovery helper, no eval in skill blocks, $N token tripwire, honest plan-mode precedence (#2763, #2896, #2851) * fix(setup): per-install renders for renamed, vendored and custom-config installs, space-safe alias roots, per-host /cso launcher path (#1882, #2906) * test: free tests never write the real HOME (home-write tripwire), no orphaned browse daemons, symlinked-TMPDIR (macOS) fixes, gbrain-on-PATH isolation (#2895, #2816, #2829, #2841, #2982, #2911, #2909, #2908, #2910) * chore: integrate wave follow-ups — regenerate all hosts, README model docs, TODOS, touchfiles, duration seeds, Bun 1.3 telemetry test fix, replay fixtures pinned to their recorded bytes * v1.91.14.0: release metadata * test(browse): group-kill cleanup falls back to the leader when macOS returns EPERM for a live group * ci: Codex login steps run under bash (the CI container's default sh rejects pipefail) * fix: full-suite repairs — private state roots for 7 real-HOME writers, truthful home-guard attribution with --attribute-home, gstack-paths shim in ship-hook fixtures, Copilot in team-init probes, test-of-test detector, cookie review fixture pinned to the approved excerpt * fix(windows): LF for Dockerfiles, normalized home-guard exclusions, path.relative fixture containment, ship push test spawn timeouts * test(design-detect): widen the whole-scan budget test's timing margins so a loaded macOS runner can't time out the single-batch call * fix(evals): qa protocol names defined for the judge, async PTY judge so overlapping sessions keep their cleanup deadline, bws setup cases use the standard JUDGE_MS tier * test(pty): the non-blocking judge case is POSIX-only (its fake judge is a shebang script, like the PTY harness it covers) * test(evals): ship-coverage-audit names absolute skill paths, uses Read, and skips the Step 7 artifact nothing consumes (removes the mechanism behind 3 of 4 historical timeouts) * test: deflake terminal-agent concurrent startup (write the placeholder record before spawning, like the product) and qa-evidence resumed pipe (barrier on a full high-water mark instead of a sleep) * fix(design): daemon log stream handles open errors instead of crashing the daemon; shutdown test awaits the stubbed exit * test(evals): skillify checks the product's resolved state root (no GSTACK_HOME bypass), negative cases check every root; tpa shims live outside the agent's cwd and answer consistently * test(evals): coverage-audit detector splits parallel columns at │ rails and [+] groups; coverage prompts name absolute skill paths; TODOs from the gate eval census * fix(plan-eng-review): scope gate always sends the menu (no headless 'Scope pending' exit); PTY harness ends idle turn-ends and API-error panels promptly with a precise diagnostic * test(evals): one position-bound coverage-read rule credits read-only && chains (labels, context ranges, git displays in either order) and still rejects forged output * fix(ship): documentation section has one saved source inserted by cat (never retyped); spawned-marker evidence is the child output; refreshed codex/factory ship goldens * test(docsync): the byte-exact insert positive is POSIX-only (the bash-command observer refuses backslash paths by design) * test(evals): plan-design-with-ui focus-title rule accepts the comma-less 'dimensions or focus' wording (replayed on the captured CI run, negative controls kept) * fix(qa-callers): exploratory preflight says the scope/method Reads come after exploratory.md; design-review scan check credits an executed scan proven by one engine run over the changed files --------- Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com> Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com> Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>
505 lines
35 KiB
TypeScript
505 lines
35 KiB
TypeScript
import { afterAll, beforeAll, 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 { createHash } from 'node:crypto';
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { identityStartedAtMs, processIdentitySource } from '../lib/cso/process-identity';
|
|
import { createPrecisionLossCandidate } from './helpers/cso-ntfs-fixture';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const windows = process.platform === 'win32';
|
|
const required = process.env.GSTACK_CSO_WINDOWS_TESTS === '1';
|
|
if (required && !windows) throw new Error('GSTACK_CSO_WINDOWS_TESTS=1 requires native Windows; emulation does not qualify the launcher.');
|
|
let temporary = '', invocationCwd = '', launcher = '', core = '', marker = '', preload = '', publisher = '', buildStage = '';
|
|
beforeAll(() => {
|
|
if (!windows) return;
|
|
const installed = path.join(ROOT, 'bin', 'gstack-cso-launcher.exe');
|
|
if (!fs.existsSync(installed)) throw new Error('Native CSO launcher is missing; run bun run build:cso with MSVC first.');
|
|
temporary = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'CSO launcher 空間 '));
|
|
launcher = path.join(temporary, 'gstack-cso-launcher.exe');
|
|
core = path.join(temporary, 'gstack-cso-core.exe');
|
|
invocationCwd = path.join(temporary, 'audited cwd');
|
|
fs.mkdirSync(invocationCwd);
|
|
marker = path.join(temporary, 'preload-ran');
|
|
preload = path.join(temporary, 'preload.ts');
|
|
fs.writeFileSync(preload, `import {writeFileSync} from 'node:fs'; writeFileSync(${JSON.stringify(marker)}, 'startup injection');`);
|
|
const source = path.join(temporary, 'core-fixture.ts');
|
|
fs.writeFileSync(source, `import {existsSync,writeFileSync} from 'node:fs';
|
|
const hold=process.argv.indexOf('--hold-until');
|
|
if(hold>=0){writeFileSync(process.argv[hold+1],String(process.pid));while(!existsSync(process.argv[hold+2]))await Bun.sleep(10);writeFileSync(process.argv[hold+3],'exited');}
|
|
process.stdout.write(JSON.stringify({argv:process.argv.slice(2),env:process.env,cwd:process.cwd()}));
|
|
if(process.argv.includes('--exit-23'))process.exit(23);`);
|
|
const compiled = spawnSync(process.execPath, ['build', '--compile', '--no-compile-autoload-dotenv', '--no-compile-autoload-bunfig', '--no-compile-autoload-tsconfig', '--no-compile-autoload-package-json', source, '--outfile', core], { encoding: 'utf8', timeout: 60_000 });
|
|
expect(compiled.status).toBe(0);
|
|
const digest = createHash('sha256').update(fs.readFileSync(core)).digest('hex');
|
|
buildStage = fs.mkdtempSync(path.join(ROOT, 'bin', '.gstack-cso-stage.windows-launcher-test.'));
|
|
const stagedLauncher = path.join(buildStage, 'gstack-cso-launcher.exe');
|
|
const stagedPublisher = path.join(buildStage, 'gstack-cso-publish-lock.exe');
|
|
const native = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
|
|
'-File', path.join(ROOT, 'scripts', 'build-cso-windows.ps1'), '-RepoRoot', ROOT,
|
|
'-OutputPath', stagedLauncher, '-LockOutputPath', stagedPublisher, '-CoreSha256', digest, '-GitExePath', Bun.which('git')!],
|
|
{ encoding: 'utf8', timeout: 60_000 });
|
|
if (native.status !== 0) throw new Error(`Fixture launcher compilation failed: ${native.stdout}${native.stderr}`);
|
|
fs.copyFileSync(stagedLauncher, launcher);
|
|
publisher = path.join(temporary, 'gstack-cso-publish-lock.exe');
|
|
fs.copyFileSync(stagedPublisher, publisher);
|
|
fs.writeFileSync(path.join(temporary, '.gstack-cso-generation.lock'), '');
|
|
fs.writeFileSync(path.join(temporary, '.gstack-cso-generation'), `${digest}\n`);
|
|
fs.rmSync(buildStage, { recursive: true, force: true }); buildStage = '';
|
|
}, 140_000);
|
|
afterAll(() => {
|
|
if (buildStage) fs.rmSync(buildStage, { recursive: true, force: true });
|
|
if (temporary) fs.rmSync(temporary, { recursive: true, force: true });
|
|
});
|
|
|
|
function launchEnvironment(extra: Record<string, string> = {}) {
|
|
return { ...process.env, HOME: temporary, GSTACK_HOME: path.join(temporary, 'state'), PATH: temporary,
|
|
NODE_OPTIONS: '--require=hostile', RUBYOPT: '-rhostile', PYTHONPATH: temporary,
|
|
GSTACK_CSO_SECRET_CANARY: 'must-not-cross-startup', ...extra };
|
|
}
|
|
|
|
function launch(args: string[], extra: Record<string, string> = {}, file = launcher) {
|
|
return spawnSync(file, args, {
|
|
cwd: invocationCwd, encoding: 'utf8', timeout: 30_000,
|
|
env: launchEnvironment(extra),
|
|
});
|
|
}
|
|
|
|
async function waitForFile(file: string, timeout = 10_000) {
|
|
const deadline = Date.now() + timeout;
|
|
while (!fs.existsSync(file) && Date.now() < deadline) await Bun.sleep(10);
|
|
expect(fs.existsSync(file)).toBe(true);
|
|
}
|
|
|
|
function publishProbe() {
|
|
return spawnSync(publisher, [temporary, process.execPath, '--version'], { encoding: 'utf8', timeout: 10_000 });
|
|
}
|
|
|
|
function pathIdentity(value: string) {
|
|
const stat = fs.statSync(value, { bigint: true });
|
|
return { device: stat.dev, file: stat.ino };
|
|
}
|
|
|
|
function expectSuccessfulProcess(result: ReturnType<typeof spawnSync>, label: string) {
|
|
if (result.status === 0) return;
|
|
const bounded = (value: unknown) => String(value ?? '').slice(0, 8192);
|
|
throw new Error(`${label} failed: ${JSON.stringify({
|
|
status: result.status,
|
|
signal: result.signal,
|
|
error: result.error?.message,
|
|
stdout: bounded(result.stdout),
|
|
stderr: bounded(result.stderr),
|
|
})}`);
|
|
}
|
|
|
|
// Diagnostic snapshot of every lease artifact under a CSO state root. A failed
|
|
// native lease command embeds it in the assertion and keeps a copy outside the
|
|
// fixture so the exact owner/decision records survive cleanup.
|
|
function leaseRecords(root: string): string {
|
|
const records: unknown[] = [];
|
|
for (const leases of filesNamed(root, '.mutation-lock').map(lock => path.join(path.dirname(lock), '.mutation-lock-leases'))) {
|
|
let names: string[] = [];
|
|
try { names = fs.readdirSync(leases).sort(); } catch { continue; }
|
|
for (const name of names) {
|
|
const file = path.join(leases, name);
|
|
try {
|
|
const stat = fs.lstatSync(file, { bigint: true }), text = stat.size <= 4096n ? fs.readFileSync(file, 'utf8') : '';
|
|
let pidAlive: boolean | undefined;
|
|
try { const value = JSON.parse(text), pid = value.ownerPid ?? value.pid; if (Number.isInteger(pid)) { try { process.kill(pid, 0); pidAlive = true; } catch (error: any) { pidAlive = error?.code === 'EPERM'; } } } catch {}
|
|
records.push({ file: path.relative(root, file), dev: String(stat.dev), ino: String(stat.ino), nlink: String(stat.nlink), size: String(stat.size), text, pidAlive });
|
|
} catch (error: any) { records.push({ file: path.relative(root, file), error: error?.code ?? String(error) }); }
|
|
}
|
|
}
|
|
const serialized = JSON.stringify(records), keep = path.join(process.env.RUNNER_TEMP ?? os.tmpdir(), 'cso-lease-diagnostics');
|
|
try { fs.mkdirSync(keep, { recursive: true }); fs.writeFileSync(path.join(keep, `${Date.now()}-${process.pid}.json`), serialized); } catch {}
|
|
return serialized.slice(0, 16_384);
|
|
}
|
|
|
|
function expectLeaseCommand(result: ReturnType<typeof spawnSync>, label: string, root: string) {
|
|
try { expectSuccessfulProcess(result, label); }
|
|
catch (error: any) { throw new Error(`${error.message}\nlease records: ${leaseRecords(root)}`); }
|
|
}
|
|
|
|
function filesNamed(root: string, name: string): string[] {
|
|
const found: string[] = [];
|
|
const walk = (directory: string) => {
|
|
let entries: fs.Dirent[];
|
|
try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { return; }
|
|
for (const entry of entries) {
|
|
const candidate = path.join(directory, entry.name);
|
|
if (entry.isSymbolicLink()) continue;
|
|
if (entry.isDirectory()) walk(candidate);
|
|
else if (entry.isFile() && entry.name === name) found.push(candidate);
|
|
}
|
|
};
|
|
walk(root);
|
|
return found;
|
|
}
|
|
|
|
describe('CSO native Windows build contract', () => {
|
|
test('Windows builds use MSVC with a static CRT and no Bun-hosted public launcher', () => {
|
|
const build = fs.readFileSync(path.join(ROOT, 'scripts/build-cso.sh'), 'utf8');
|
|
const msvc = fs.readFileSync(path.join(ROOT, 'scripts/build-cso-windows.ps1'), 'utf8');
|
|
expect(build).toContain('powershell.exe -NoProfile -NonInteractive');
|
|
expect(build).toContain('build-cso-windows.ps1');
|
|
expect(build).toContain('-OutputPath "$(cygpath -w "$CSO_STAGE_LAUNCHER")"');
|
|
expect(build).toContain('-LockOutputPath "$(cygpath -w "$CSO_STAGE_LOCKER")"');
|
|
expect(build).toContain('-CoreSha256 "$CSO_CORE_SHA256"');
|
|
expect(build).toContain('-GitExePath "$(cygpath -aw "$CSO_WINDOWS_GIT")"');
|
|
expect(build).toContain('CSO_PUBLISH_SHELL="$(cygpath -aw /usr/bin/bash.exe)"');
|
|
expect(build).not.toContain('launcher-windows.ts');
|
|
expect(fs.existsSync(path.join(ROOT, 'lib/cso/launcher-windows.ts'))).toBe(false);
|
|
expect(msvc).toContain('Launch-VsDevShell.ps1');
|
|
expect(msvc).toContain('[switch]$CheckOnly');
|
|
expect(msvc).toContain('Normal CSO Windows builds require staged outputs and a lowercase SHA-256 core binding.');
|
|
expect(msvc).toContain("[System.IO.FileAttributes]::ReparsePoint");
|
|
expect(msvc).not.toContain("Join-Path $RepoRoot 'bin\\gstack-cso-launcher.exe'");
|
|
expect(msvc).toContain("Set-Content -LiteralPath $source");
|
|
expect(msvc).toContain('-Arch amd64 -HostArch amd64 -SkipAutomaticLocation');
|
|
expect(msvc).toContain('StartsWith($installationPrefix');
|
|
expect(msvc).toContain('/MT');
|
|
expect(msvc).toContain('/W4 /WX');
|
|
expect(msvc).toContain('GSTACK_CSO_CORE_SHA256');
|
|
expect(msvc).toContain('GSTACK_CSO_GIT_PATH');
|
|
expect(msvc).toContain('/FI$binding');
|
|
expect(msvc).toContain('if ($LASTEXITCODE -ne 0)');
|
|
const processSource=fs.readFileSync(path.join(ROOT,'lib','cso','process.ts'),'utf8').replace(/\s+/g,'');
|
|
expect(processSource).toContain("includeNullPath=process.platform==='win32'?'/dev/null':nullPath");
|
|
const launcherSource = fs.readFileSync(path.join(ROOT, 'lib/cso/launcher-windows.c'), 'utf8');
|
|
expect(launcherSource).toContain('.gstack-cso-generation.lock');
|
|
expect(launcherSource).toContain('.gstack-cso-generation');
|
|
expect(launcherSource).toContain('GSTACK_CSO_CORE_SHA256');
|
|
expect(launcherSource).toContain('BCryptOpenAlgorithmProvider');
|
|
expect(launcherSource).toContain('MS_PRIMITIVE_PROVIDER');
|
|
expect(launcherSource).toContain('sha256_handle(pinned_core');
|
|
expect(launcherSource).toContain('#pragma comment(lib, "bcrypt.lib")');
|
|
expect(launcherSource).toContain('PROC_THREAD_ATTRIBUTE_HANDLE_LIST');
|
|
expect(launcherSource).toContain('joined_path');
|
|
const childWait = launcherSource.indexOf('WaitForSingleObject(child.hProcess, INFINITE)');
|
|
expect(childWait).toBeGreaterThan(0);
|
|
expect(launcherSource.lastIndexOf('CloseHandle(pinned_core)')).toBeGreaterThan(childWait);
|
|
expect(launcherSource.lastIndexOf('CloseHandle(generation_gate)')).toBeGreaterThan(childWait);
|
|
const publisherSource = fs.readFileSync(path.join(ROOT, 'lib/cso/publish-lock.c'), 'utf8');
|
|
expect(publisherSource).toContain('joined_path');
|
|
expect(publisherSource).toContain('GENERIC_READ | GENERIC_WRITE, 0');
|
|
});
|
|
|
|
test('Windows CI runs the actual launcher tests rather than marking the platform supported from a source check', () => {
|
|
const workflow = Bun.YAML.parse(fs.readFileSync(path.join(ROOT, '.github/workflows/free-tests.yml'), 'utf8')) as any;
|
|
const job = workflow.jobs['cso-windows-launcher'];
|
|
expect(job['runs-on']).toBe('windows-latest');
|
|
expect(job.steps.some((s: any) => s.run === 'bun run build:cso' && s.shell === 'bash')).toBe(true);
|
|
const smoke = job.steps.find((s: any) => s.run === 'bun run test:cso:windows');
|
|
expect(smoke.env.GSTACK_CSO_WINDOWS_TESTS).toBe('1');
|
|
expect(smoke['continue-on-error']).not.toBe(true);
|
|
});
|
|
|
|
// Each PowerShell invocation has its own deadline. A serial loop placed all
|
|
// five cold starts under Bun's default five-second timeout.
|
|
const pathEscapeCases = [
|
|
['launcher outside stage', (stage: string, outside: string) => [path.join(outside, 'gstack-cso-launcher.exe'), path.join(stage, 'gstack-cso-publish-lock.exe')]],
|
|
['launcher in stage parent', (stage: string) => [path.join(stage, '..', 'gstack-cso-launcher.exe'), path.join(stage, 'gstack-cso-publish-lock.exe')]],
|
|
['wrong launcher name', (stage: string) => [path.join(stage, 'wrong.exe'), path.join(stage, 'gstack-cso-publish-lock.exe')]],
|
|
['lock outside stage', (stage: string, outside: string) => [path.join(stage, 'gstack-cso-launcher.exe'), path.join(outside, 'gstack-cso-publish-lock.exe')]],
|
|
['wrong lock name', (stage: string) => [path.join(stage, 'gstack-cso-launcher.exe'), path.join(stage, 'wrong-lock.exe')]],
|
|
] as const;
|
|
test.skipIf(!windows).each(pathEscapeCases)('Windows build rejects %s before invoking MSVC',(_name, outputs)=>{
|
|
const script=path.join(ROOT,'scripts/build-cso-windows.ps1'),outside=fs.mkdtempSync(path.join(os.tmpdir(),'cso-bin-evil-'));
|
|
const stage=fs.mkdtempSync(path.join(ROOT,'bin','.gstack-cso-stage.path-test.'));
|
|
const [output,lock]=outputs(stage,outside),digest='a'.repeat(64);
|
|
try{
|
|
const result=spawnSync('powershell.exe',['-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',script,'-RepoRoot',ROOT,'-OutputPath',output,'-LockOutputPath',lock,'-CoreSha256',digest,'-GitExePath',Bun.which('git')!],{encoding:'utf8',timeout:30_000});
|
|
expect(result.error).toBeUndefined();expect(result.signal).toBeNull();expect(result.status).not.toBeNull();
|
|
expect(result.status).not.toBe(0);expect(`${result.stdout}${result.stderr}`).toContain('direct, non-reparse staging directory');
|
|
}finally{fs.rmSync(stage,{recursive:true,force:true});fs.rmSync(outside,{recursive:true,force:true});}
|
|
},35_000);
|
|
});
|
|
|
|
(windows ? describe : describe.skip)('CSO native Windows startup', () => {
|
|
test('BUN_OPTIONS cannot execute a preload before the environment is scrubbed', () => {
|
|
const result = launch(['--version'], { BUN_OPTIONS: `--preload=${preload}` });
|
|
expect(result.status).toBe(0);
|
|
expect(fs.existsSync(marker)).toBe(false);
|
|
const value = JSON.parse(result.stdout);
|
|
expect(value.argv).toEqual(['--version']);
|
|
expect(value.env.BUN_OPTIONS).toBeUndefined();
|
|
expect(value.env.GSTACK_CSO_SECRET_CANARY).toBeUndefined();
|
|
expect(value.env.NODE_OPTIONS).toBeUndefined();
|
|
expect(value.env.GSTACK_HOME).toBe(path.join(temporary, 'state'));
|
|
expect(value.env.PATH).not.toBe(temporary);
|
|
expect(value.env.SystemRoot.toLowerCase()).toBe(process.env.SystemRoot!.toLowerCase());
|
|
expect(pathIdentity(value.cwd)).toEqual(pathIdentity(temporary));
|
|
expect(pathIdentity(value.cwd)).not.toEqual(pathIdentity(invocationCwd));
|
|
});
|
|
|
|
test('BUN_BE_BUN cannot turn the public command into the Bun runtime', () => {
|
|
const result = launch(['--version'], { BUN_BE_BUN: '1' });
|
|
expect(result.status).toBe(0);
|
|
const value = JSON.parse(result.stdout);
|
|
expect(value.argv).toEqual(['--version']);
|
|
expect(value.env.BUN_BE_BUN).toBeUndefined();
|
|
});
|
|
|
|
test('preserves Unicode, empty, quoted, trailing-backslash and shell-looking arguments', () => {
|
|
const args = ['', '空間', 'with spaces', 'a"b', 'C:\\directory with spaces\\', '\\"', '& whoami', '%PATH%', 'line\nbreak'];
|
|
const result = launch(args);
|
|
expect(result.status).toBe(0);
|
|
expect(JSON.parse(result.stdout).argv).toEqual(args);
|
|
});
|
|
|
|
test('forwards child exit status and fails closed on oversized inherited input', () => {
|
|
expect(launch(['--exit-23']).status).toBe(23);
|
|
const result = launch([], { GSTACK_HOME: 'x'.repeat(8193) });
|
|
expect(result.status).toBe(69);
|
|
expect(result.stdout).toBe('');
|
|
expect(result.stderr).toContain('environment input is too large');
|
|
});
|
|
|
|
test('resolves launcher installation aliases before locating its sibling core', () => {
|
|
const alias = path.join(temporary, 'alias');
|
|
fs.symlinkSync(temporary, alias, 'junction');
|
|
const result = launch(['--version'], {}, path.join(alias, 'gstack-cso-launcher.exe'));
|
|
expect(result.status).toBe(0);
|
|
expect(JSON.parse(result.stdout).argv).toEqual(['--version']);
|
|
fs.unlinkSync(alias);
|
|
});
|
|
|
|
test('rejects a launcher/manifest generation mismatch before executing the core', () => {
|
|
const mismatched = path.join(temporary, 'mismatched generation');
|
|
fs.mkdirSync(mismatched);
|
|
fs.copyFileSync(launcher, path.join(mismatched, 'gstack-cso-launcher.exe'));
|
|
fs.copyFileSync(core, path.join(mismatched, 'gstack-cso-core.exe'));
|
|
fs.writeFileSync(path.join(mismatched, '.gstack-cso-generation.lock'), '');
|
|
fs.writeFileSync(path.join(mismatched, '.gstack-cso-generation'), `${'0'.repeat(64)}\n`);
|
|
const result = launch(['--version'], {}, path.join(mismatched, 'gstack-cso-launcher.exe'));
|
|
expect(result.status).toBe(69);
|
|
expect(result.stdout).toBe('');
|
|
expect(result.stderr).toContain('generations do not match');
|
|
});
|
|
|
|
test('rejects changed core bytes even when the generation manifest is unchanged', () => {
|
|
const tampered = path.join(temporary, 'tampered core');
|
|
fs.mkdirSync(tampered);
|
|
const tamperedLauncher = path.join(tampered, 'gstack-cso-launcher.exe');
|
|
const tamperedCore = path.join(tampered, 'gstack-cso-core.exe');
|
|
fs.copyFileSync(launcher, tamperedLauncher);
|
|
fs.copyFileSync(core, tamperedCore);
|
|
fs.appendFileSync(tamperedCore, Buffer.from([0]));
|
|
fs.writeFileSync(path.join(tampered, '.gstack-cso-generation.lock'), '');
|
|
fs.copyFileSync(path.join(temporary, '.gstack-cso-generation'), path.join(tampered, '.gstack-cso-generation'));
|
|
const result = launch(['--version'], {}, tamperedLauncher);
|
|
expect(result.status).toBe(69);
|
|
expect(result.stdout).toBe('');
|
|
expect(result.stderr).toContain('digest does not match');
|
|
});
|
|
|
|
test('the inherited generation gate survives launcher termination until the core exits', async () => {
|
|
const ready = path.join(temporary, 'held-core.ready'), release = path.join(temporary, 'held-core.release'), exited = path.join(temporary, 'held-core.exited');
|
|
const running = spawn(launcher, ['--hold-until', ready, release, exited], { cwd: invocationCwd, env: launchEnvironment(), stdio: 'ignore' });
|
|
try {
|
|
await waitForFile(ready);
|
|
const launcherExited = new Promise<void>(resolveExit => running.once('exit', () => resolveExit()));
|
|
running.kill('SIGKILL');
|
|
await launcherExited;
|
|
expect(publishProbe().status).toBe(73);
|
|
fs.writeFileSync(release, 'release');
|
|
const deadline = Date.now() + 10_000; let result = publishProbe();
|
|
while (result.status === 73 && Date.now() < deadline) { await Bun.sleep(20); result = publishProbe(); }
|
|
expect(result.status).toBe(0);
|
|
} finally {
|
|
fs.writeFileSync(release, 'release');
|
|
if (!running.killed) running.kill('SIGKILL');
|
|
const deadline = Date.now() + 5_000;
|
|
while (!fs.existsSync(exited) && Date.now() < deadline) await Bun.sleep(10);
|
|
}
|
|
}, 30_000);
|
|
|
|
test('a missing core never falls back to a PATH executable or Bun', () => {
|
|
const missing = path.join(temporary, 'missing core');
|
|
fs.mkdirSync(missing);
|
|
const copy = path.join(missing, 'gstack-cso-launcher.exe');
|
|
fs.copyFileSync(launcher, copy);
|
|
fs.writeFileSync(path.join(missing, '.gstack-cso-generation.lock'), '');
|
|
fs.copyFileSync(path.join(temporary, '.gstack-cso-generation'), path.join(missing, '.gstack-cso-generation'));
|
|
const result = launch(['--version'], {}, copy);
|
|
expect(result.status).toBe(69);
|
|
expect(result.stdout).toBe('');
|
|
expect(result.stderr).toContain('trusted compiled helper is missing');
|
|
});
|
|
|
|
test('the actual helper finds trusted Git and stores state under USERPROFILE without HOME', () => {
|
|
const repository=path.join(temporary,'actual repository'),profile=path.join(temporary,'profile');
|
|
fs.mkdirSync(repository);fs.mkdirSync(profile);
|
|
const git='C:\\Program Files\\Git\\cmd\\git.exe',gitEnv={...process.env,HOME:profile};
|
|
for(const args of [['init','-q'],['config','user.email','fixture@example.test'],['config','user.name','Fixture']] as string[][]){const result=spawnSync(git,args,{cwd:repository,encoding:'utf8',env:gitEnv,timeout:10_000});expect(result.status).toBe(0);}
|
|
fs.writeFileSync(path.join(repository,'app.js'),'console.log("safe")\n');
|
|
for(const args of [['add','app.js'],['commit','-qm','fixture']] as string[][]){const result=spawnSync(git,args,{cwd:repository,encoding:'utf8',env:gitEnv,timeout:10_000});expect(result.status).toBe(0);}
|
|
const actual=path.join(ROOT,'bin','gstack-cso-launcher.exe'),env={...process.env,HOME:'',GSTACK_HOME:'',CLAUDE_PLUGIN_ROOT:'',CLAUDE_PLUGIN_DATA:'',USERPROFILE:profile,PATH:temporary,NODE_OPTIONS:'--require=hostile'};
|
|
const doctor=spawnSync(actual,['doctor','--repo',repository],{cwd:repository,encoding:'utf8',env,timeout:30_000});expect(doctor.status).toBe(0);expect(JSON.parse(doctor.stdout).downloads).toBe(false);
|
|
const started=spawnSync(actual,['start','--repo',repository,'--offline'],{cwd:repository,encoding:'utf8',env,timeout:30_000});expectSuccessfulProcess(started,'gstack-cso start');expect(JSON.parse(started.stdout).schemaVersion).toBe(3);expect(fs.existsSync(path.join(profile,'.gstack','security','cso'))).toBe(true);
|
|
}, 120_000);
|
|
|
|
test('NTFS high file IDs survive repeated native commands and ambiguous old decisions remain blocked', () => {
|
|
const repository=path.join(temporary,'lease lifecycle repository'),profile=path.join(temporary,'lease lifecycle profile');
|
|
fs.mkdirSync(repository);fs.mkdirSync(profile);
|
|
const git='C:\\Program Files\\Git\\cmd\\git.exe',gitEnv={...process.env,HOME:profile};
|
|
for(const args of [['init','-q'],['config','user.email','fixture@example.test'],['config','user.name','Fixture']] as string[][]){const result=spawnSync(git,args,{cwd:repository,encoding:'utf8',env:gitEnv,timeout:10_000});expect(result.status).toBe(0);}
|
|
fs.writeFileSync(path.join(repository,'app.js'),'console.log("fixture")\n');
|
|
for(const args of [['add','app.js'],['commit','-qm','fixture']] as string[][]){const result=spawnSync(git,args,{cwd:repository,encoding:'utf8',env:gitEnv,timeout:10_000});expect(result.status).toBe(0);}
|
|
const actual=path.join(ROOT,'bin','gstack-cso-launcher.exe'),env={...process.env,HOME:'',GSTACK_HOME:'',CLAUDE_PLUGIN_ROOT:'',CLAUDE_PLUGIN_DATA:'',USERPROFILE:profile,PATH:temporary};
|
|
const command=(args:string[])=>spawnSync(actual,args,{cwd:repository,encoding:'utf8',env,timeout:30_000}),submission=path.join(profile,'submission.json');
|
|
fs.writeFileSync(submission,'{}\n');
|
|
for(let i=0;i<3;i++){
|
|
const started=command(['start','--repo',repository,'--offline']);expectSuccessfulProcess(started,'gstack-cso start');
|
|
const run=JSON.parse(started.stdout),dir=path.join(profile,'.gstack','security','cso',run.repoId,run.runId),leases=path.join(dir,'.mutation-lock-leases');
|
|
const initialized=command(['resume',run.runId]);expectSuccessfulProcess(initialized,'gstack-cso initialize mutation lease');
|
|
expect(fs.realpathSync(leases).startsWith(fs.realpathSync(profile)+path.sep)).toBe(true);
|
|
if(i===0){
|
|
const churn=path.join(leases,'churn');let inode=0n;
|
|
for(let attempt=0;attempt<1024;attempt++){
|
|
fs.writeFileSync(churn,'x');inode=fs.lstatSync(churn,{bigint:true}).ino;fs.unlinkSync(churn);
|
|
if(inode>BigInt(Number.MAX_SAFE_INTEGER))break;
|
|
}
|
|
expect(inode).toBeGreaterThan(BigInt(Number.MAX_SAFE_INTEGER));
|
|
}
|
|
const inspected=command(['inspect',run.runId]);expectSuccessfulProcess(inspected,'gstack-cso inspect');
|
|
expect(JSON.parse(inspected.stdout).report.status).toBe('running');
|
|
const read=command(['read',run.runId,'app.js']);expectSuccessfulProcess(read,'gstack-cso read');expect(read.stdout).toContain('fixture');
|
|
const history=command(['history',run.runId]);expectSuccessfulProcess(history,'gstack-cso history');
|
|
const submitted=command(['submit',run.runId,submission]);expectSuccessfulProcess(submitted,'gstack-cso submit');
|
|
const resumed=command(['resume',run.runId]);expectSuccessfulProcess(resumed,'gstack-cso resume');
|
|
const finished=command(['finish',run.runId]);expectSuccessfulProcess(finished,'gstack-cso finish');
|
|
expect(JSON.parse(finished.stdout).completeness).not.toBe('complete');
|
|
const report=JSON.parse(fs.readFileSync(path.join(dir,'report.json'),'utf8'));
|
|
expect(report.coverage.some((entry:any)=>entry.status==='not_assessed')).toBe(true);
|
|
expect(fs.readdirSync(leases)).toEqual([]);
|
|
if(i!==2)continue;
|
|
const token='d'.repeat(32),candidate=path.join(leases,`${token}.json`),decision=path.join(leases,`${token}.decision`);
|
|
for(const rounded of [false,true]){
|
|
const selected=createPrecisionLossCandidate(candidate,JSON.stringify({pid:2147483647,token,createdAt:0})+'\n');
|
|
const stat=fs.lstatSync(candidate,{bigint:true}),record={schemaVersion:1,token,kind:'ticket',ticket:'0000000000000001',candidateDev:String(stat.dev),candidateIno:rounded?String(Number(stat.ino)):String(stat.ino),ownerPid:2147483647,ownerCreatedAt:0,publisherPid:2147483647,createdAt:0};
|
|
expect(stat.ino).toBe(selected);
|
|
expect(stat.ino).toBeGreaterThan(BigInt(Number.MAX_SAFE_INTEGER));
|
|
expect(String(Number(stat.ino))).not.toBe(String(stat.ino));
|
|
fs.writeFileSync(decision,JSON.stringify(record)+'\n');
|
|
const result=command(['resume',run.runId]);expect(result.status).not.toBe(0);
|
|
if(rounded){
|
|
expect(record.candidateIno).not.toBe(String(stat.ino));expect(result.stderr).toContain('UNSAFE_PATH');
|
|
expect(fs.existsSync(candidate)).toBe(true);expect(fs.existsSync(decision)).toBe(true);
|
|
fs.unlinkSync(decision);fs.unlinkSync(candidate);
|
|
}else{
|
|
expect(result.stderr).toContain('INVALID_SCHEMA');
|
|
expect(fs.existsSync(candidate)).toBe(false);expect(fs.existsSync(decision)).toBe(false);
|
|
}
|
|
}
|
|
const next=command(['start','--repo',repository,'--offline']);expectSuccessfulProcess(next,'gstack-cso start after legacy state');
|
|
expect(JSON.parse(next.stdout).runId).not.toBe(run.runId);
|
|
}
|
|
}, 180_000);
|
|
|
|
test('#2894 the win32 process identity source reads real process start times', async () => {
|
|
const own = processIdentitySource.read(process.pid), started = identityStartedAtMs(own ?? '');
|
|
expect(own).toMatch(/^win32:\d+$/);
|
|
expect(processIdentitySource.read(process.pid)).toBe(own!);
|
|
expect(started!).toBeLessThanOrEqual(Date.now());
|
|
expect(started!).toBeGreaterThan(Date.now() - 3_600_000);
|
|
await Bun.sleep(20);
|
|
const child = spawn(process.execPath, ['-e', 'setTimeout(()=>{},30000)'], { stdio: 'ignore' });
|
|
try {
|
|
const identity = processIdentitySource.read(child.pid!);
|
|
expect(identity).toMatch(/^win32:\d+$/);
|
|
expect(identity).not.toBe(own!);
|
|
expect(identityStartedAtMs(identity!)!).toBeGreaterThanOrEqual(started!);
|
|
} finally { child.kill(); }
|
|
expect(processIdentitySource.read(2147483647)).toBeUndefined();
|
|
});
|
|
|
|
test('#2894 lease sequence: repeated commands, a legacy record elsewhere, and recycled or dead owners never brick start', async () => {
|
|
const repository=path.join(temporary,'issue 2894 repository'),other=path.join(temporary,'issue 2894 other repository'),profile=path.join(temporary,'issue 2894 profile');
|
|
for(const directory of [repository,other,profile])fs.mkdirSync(directory);
|
|
const git='C:\\Program Files\\Git\\cmd\\git.exe',gitEnv={...process.env,HOME:profile};
|
|
for(const repo of [repository,other]){
|
|
for(const args of [['init','-q'],['config','user.email','fixture@example.test'],['config','user.name','Fixture']] as string[][]){const result=spawnSync(git,args,{cwd:repo,encoding:'utf8',env:gitEnv,timeout:10_000});expect(result.status).toBe(0);}
|
|
fs.writeFileSync(path.join(repo,'app.js'),'console.log("fixture")\n');fs.writeFileSync(path.join(repo,'other.js'),'console.log("other")\n');
|
|
for(const args of [['add','app.js','other.js'],['commit','-qm','fixture']] as string[][]){const result=spawnSync(git,args,{cwd:repo,encoding:'utf8',env:gitEnv,timeout:10_000});expect(result.status).toBe(0);}
|
|
}
|
|
const actual=path.join(ROOT,'bin','gstack-cso-launcher.exe'),env={...process.env,HOME:'',GSTACK_HOME:'',CLAUDE_PLUGIN_ROOT:'',CLAUDE_PLUGIN_DATA:'',USERPROFILE:profile,PATH:temporary},state=path.join(profile,'.gstack','security','cso');
|
|
const command=(args:string[],cwd=repository)=>spawnSync(actual,args,{cwd,encoding:'utf8',env,timeout:30_000});
|
|
const step=(args:string[],label:string,cwd=repository)=>{const result=command(args,cwd);expectLeaseCommand(result,label,state);return result;};
|
|
const started=JSON.parse(step(['start','--repo',repository,'--offline'],'start').stdout),dir=path.join(state,started.repoId,started.runId),leases=path.join(dir,'.mutation-lock-leases');
|
|
for(const [args,label] of [[['resume',started.runId],'resume'],[['inspect',started.runId],'inspect'],[['read',started.runId,'app.js'],'read app.js'],[['read',started.runId,'other.js'],'read other.js'],[['history',started.runId],'history'],[['inspect',started.runId],'inspect again']] as [string[],string][])step(args,label);
|
|
expect(fs.readdirSync(leases)).toEqual([]);
|
|
// A record left by gstack <= 1.88.0 in another repository's run (lossy NTFS file ID) stays
|
|
// blocked for that run, but start for any repository still succeeds.
|
|
const otherRun=JSON.parse(step(['start','--repo',other,'--offline'],'start other',other).stdout),otherLeases=path.join(state,otherRun.repoId,otherRun.runId,'.mutation-lock-leases');
|
|
step(['resume',otherRun.runId],'resume other',other);
|
|
const legacy='d'.repeat(32),legacyCandidate=path.join(otherLeases,`${legacy}.json`),legacyDecision=path.join(otherLeases,`${legacy}.decision`);
|
|
createPrecisionLossCandidate(legacyCandidate,JSON.stringify({pid:2147483647,token:legacy,createdAt:1})+'\n');
|
|
const legacyStat=fs.lstatSync(legacyCandidate,{bigint:true});
|
|
fs.writeFileSync(legacyDecision,JSON.stringify({schemaVersion:1,token:legacy,kind:'ticket',ticket:'0000000000000001',candidateDev:String(legacyStat.dev),candidateIno:String(Number(legacyStat.ino)),ownerPid:2147483647,ownerCreatedAt:1,publisherPid:2147483647,createdAt:1})+'\n');
|
|
const next=JSON.parse(step(['start','--repo',repository,'--offline'],'start with a legacy record in another repository').stdout);
|
|
expect(next.runId).not.toBe(started.runId);
|
|
const blocked=command(['resume',otherRun.runId],other);expect(blocked.status).not.toBe(0);expect(blocked.stderr).toContain('UNSAFE_PATH');
|
|
expect(fs.existsSync(legacyCandidate)).toBe(true);expect(fs.existsSync(legacyDecision)).toBe(true);
|
|
// A recycled PID: the recorded owner is gone and an unrelated process that started after the
|
|
// lease was written now holds that PID. A plain dead PID is reclaimed the same way.
|
|
const nextLeases=path.join(state,next.repoId,next.runId,'.mutation-lock-leases');
|
|
step(['resume',next.runId],'resume next');
|
|
const createdAt=Date.now();await Bun.sleep(2_500);
|
|
const holder=spawn(process.execPath,['-e','setTimeout(()=>{},60000)'],{stdio:'ignore'});
|
|
try{
|
|
for(const [token,pid] of [['e'.repeat(32),holder.pid!],['f'.repeat(32),2147483647]] as [string,number][]){
|
|
const candidate=path.join(nextLeases,`${token}.json`),decision=path.join(nextLeases,`${token}.decision`);
|
|
fs.writeFileSync(candidate,JSON.stringify({pid,token,createdAt})+'\n');
|
|
const stat=fs.lstatSync(candidate,{bigint:true});
|
|
fs.writeFileSync(decision,JSON.stringify({schemaVersion:1,token,kind:'ticket',ticket:'0000000000000001',candidateDev:String(stat.dev),candidateIno:String(stat.ino),ownerPid:pid,ownerCreatedAt:createdAt,publisherPid:pid,createdAt})+'\n');
|
|
step(['inspect',next.runId],`inspect after ${pid===2147483647?'dead':'recycled'} owner`);
|
|
expect(fs.existsSync(candidate)).toBe(false);expect(fs.existsSync(decision)).toBe(false);
|
|
}
|
|
expect(fs.readdirSync(nextLeases)).toEqual([]);
|
|
}finally{holder.kill();}
|
|
}, 180_000);
|
|
|
|
test('the actual helper rejects source mutation during snapshot capture without certifying a report', async () => {
|
|
const repository=path.join(temporary,'racing repository'),profile=path.join(temporary,'race profile'),padding=path.join(repository,'padding'),target=path.join(repository,'zzzz-race-target.js');
|
|
fs.mkdirSync(repository);fs.mkdirSync(profile);fs.mkdirSync(padding);
|
|
const git='C:\\Program Files\\Git\\cmd\\git.exe',gitEnv={...process.env,HOME:profile};
|
|
for(const args of [['init','-q'],['config','user.email','fixture@example.test'],['config','user.name','Fixture']] as string[][]){const result=spawnSync(git,args,{cwd:repository,encoding:'utf8',env:gitEnv,timeout:10_000});expect(result.status).toBe(0);}
|
|
const sourceBytes=128*1024,stable=Buffer.alloc(sourceBytes,0x61),changed=Buffer.alloc(sourceBytes,0x62);
|
|
fs.writeFileSync(target,stable);
|
|
for(let index=0;index<192;index++)fs.writeFileSync(path.join(padding,`${String(index).padStart(4,'0')}.js`),stable);
|
|
for(const args of [['add',path.basename(target)],['commit','-qm','fixture']] as string[][]){const result=spawnSync(git,args,{cwd:repository,encoding:'utf8',env:gitEnv,timeout:30_000});expect(result.status).toBe(0);}
|
|
|
|
const actual=path.join(ROOT,'bin','gstack-cso-launcher.exe'),env={...process.env,HOME:'',GSTACK_HOME:'',CLAUDE_PLUGIN_ROOT:'',CLAUDE_PLUGIN_DATA:'',USERPROFILE:profile,PATH:temporary},state=path.join(profile,'.gstack','security','cso');
|
|
const child=spawn(actual,['start','--repo',repository,'--offline'],{cwd:repository,env,stdio:['ignore','pipe','pipe']});
|
|
let stdout='',stderr='',closed=false;
|
|
child.stdout.on('data',chunk=>{stdout=(stdout+String(chunk)).slice(-8192);});
|
|
child.stderr.on('data',chunk=>{stderr=(stderr+String(chunk)).slice(-8192);});
|
|
const terminal=new Promise<{code:number|null;error?:string}>(resolve=>{
|
|
child.once('error',error=>{closed=true;resolve({code:null,error:error.message});});
|
|
child.once('close',code=>{closed=true;resolve({code});});
|
|
});
|
|
const markerDeadline=Date.now()+30_000;let mutations=0,outcome:{code:number|null;error?:string}|undefined;
|
|
try{
|
|
while(!closed&&!filesNamed(state,'history-status.json').length&&Date.now()<markerDeadline)await Bun.sleep(2);
|
|
if(!filesNamed(state,'history-status.json').length)throw new Error('gstack-cso exited or timed out before reaching the bounded snapshot mutation point');
|
|
while(!closed&&Date.now()<markerDeadline+15_000){
|
|
try{fs.writeFileSync(target,changed);mutations++;}catch(error:any){if(!['EBUSY','EACCES','EPERM'].includes(error?.code))throw error;}
|
|
await Bun.sleep(1);
|
|
}
|
|
if(!closed)throw new Error('gstack-cso did not finish after the injected snapshot mutation');
|
|
outcome=await terminal;
|
|
}finally{
|
|
if(!closed){spawnSync('taskkill',['/PID',String(child.pid),'/T','/F'],{encoding:'utf8',timeout:10_000});await Promise.race([terminal,Bun.sleep(10_000)]);}
|
|
}
|
|
expect(mutations).toBeGreaterThan(0);
|
|
expect(outcome?.error).toBeUndefined();
|
|
expect(outcome?.code).not.toBe(0);
|
|
expect(stderr).toContain('SNAPSHOT_RACE');
|
|
expect(stdout).toBe('');
|
|
expect(filesNamed(state,'snapshot.json')).toEqual([]);
|
|
expect(filesNamed(state,'report.json')).toEqual([]);
|
|
}, 90_000);
|
|
});
|