Files
gstack/test/cso-windows-launcher.test.ts
4dfd83bf8c v1.91.60.0 fix(windows): setup degrades instead of dying on a CSO failure, detects Smart App Control, Consolas terminal font (#3079)
* Fix spaced-out terminal text on Windows: put a Latin monospace before the CJK fallbacks

On Windows none of the Mac/Linux monospace fonts in the stack are
installed, so the first font that resolves is Malgun Gothic (the Windows
Korean UI font). xterm.js sizes its character cell from that font's
advance width, and Malgun Gothic advances Latin glyphs in a full-width
CJK cell — doubling every cell and rendering the whole terminal (and the
sidepanel mono UI) as spaced-out text.

Insert Consolas (present on every Windows install) and the Cascadia
faces ahead of the CJK fonts in both the xterm fontFamily and the
--font-mono CSS var. The CJK fonts stay last for actual CJK coverage.
No bundled font, no user install needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKj8Nr8T4GvryTcWBJRMyC

* fix(windows): every compiled binary answers --version before any side effect

browse --version started a server and exited 1, pdf printed usage and
exited 1, gstack-global-discover rejected the flag, and find-browse ran
discovery. A launch probe for Smart App Control (#2595) needs one
side-effect-free command that exits 0 on a healthy build, so browse,
find-browse, make-pdf and gstack-global-discover now answer --version
first (design already did).

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

* test(extension): pin Consolas ahead of the CJK fallbacks in both mono stacks (#2287)

Regression test for @tomfluff's fix: the xterm fontFamily and --font-mono
must list Consolas before Noto Sans Mono CJK KR and Malgun Gothic, or
Windows renders the sidebar terminal double-width.

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

* fix(cso): record each CSO build's outcome and stop arming the parent trap across the native publisher exec (#3071)

scripts/build-cso.sh now writes bin/.gstack-cso-build-result: result,
failed stage (build or publish), reason, this revision, the revision of
the generation still installed, whether its launcher is usable, and the
diagnostic log. An 'interrupted' entry is written before each stage so a
killed run is visible. A failed publish that restores the old generation
records it as still installed.

Before exec'ing the native publisher, the parent re-checks the locker,
clears its EXIT trap and enables execfail: the locked child's own trap
owns the stage. On Git Bash an exec of a native program may not replace
the shell, so the parent's trap could otherwise delete the stage under
the running child (the vanishing files in #3071; root cause unproven).
If the exec itself fails the parent cleans the stage and records it.

The Windows build prefers PowerShell 7 (pwsh) over Windows PowerShell
5.1, which crashed outright on the reporter's machine.

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

* fix(setup): an optional CSO build or publish failure no longer aborts setup (#3071)

CSO was optional at probe time but fatal at build time: scripts/build.sh
ran build-cso.sh under set -e before the browse server bundle and the
completion stamp, and setup exited when the CSO artifact set was
incomplete. A failed CSO step now prints 'CSO unavailable: the <stage>
step failed' with its log (bin/.gstack-cso-build.log) and retry, and the
build and setup finish. GSTACK_STRICT_BUILD=1 keeps it fatal; the free-tests,
make-pdf-gate and windows-setup-e2e builds set it, so regressions still
fail CI.

setup's summary reads the build-result record instead of the pre-build
availability flag: probe failures keep their prerequisite instruction;
build and publish failures name the stage, the diagnostic and
'bun run build:cso && ./setup', and a restored earlier helper is
reported as 'previous CSO kept: <revision>'. setup records probe
failures in the same file.

The Windows prerequisite probe prefers pwsh, and a probe that fails for
any reason other than a missing toolchain or a compile error keeps its
exit status and first output line (windows-toolchain-probe) instead of
being labeled windows-msvc-toolchain. Reported and diagnosed by
@dviolante.

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

* feat(windows): detect Smart App Control-blocked binaries in setup and gstack-doctor (#2595, #2124)

Smart App Control refuses the five unsigned binaries gstack compiles
locally, and Git Bash reports it as 'Permission denied', so setup's
[ -x ] gate passed and skills failed later. bin/gstack-launch-probe.sh
runs each binary's side-effect-free --version and classifies it:
native, blocked (spawn error, exit 126/127, or an application-control
message), broken (launched, exited non-zero) or missing. It shows the
raw first output line, since the exact Windows text is unverified.

On Windows, setup names each blocked binary and the skills that need it,
cites #2595, says which shell ran setup and whether WSL is installed,
gives today's workarounds (WSL, or turning Smart App Control off), how to
undo, and the new troubleshooting section. gstack-doctor's browse row now
says 'blocked at launch' instead of passing, a binaries row covers the
other four, and a cso row reads the build-result record.

windows-setup-e2e builds with GSTACK_STRICT_BUILD=1, checks all five
binaries answer --version, and adds two forced CSO failures: fatal under
strict mode, and a completed setup that reports the failure and a doctor
cso warning under GSTACK_STRICT_BUILD=0.

No shim or launcher: it could not be called from PowerShell and would
load the caller's project .env/bunfig. Signing is the follow-up in
TODOS.md. Diagnosis credit: @knetics9000 (#2595, #2596), @smartjelic-sys
(#2595), @pstilwell90 (#2124), @cko32002 (#2265), @salluexez (#2127).

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

* docs: Windows Smart App Control known issue, optional CSO failures, doctor rows

README's Windows section says /cso failures no longer stop setup
(GSTACK_STRICT_BUILD=1 keeps them fatal), documents Smart App Control as
a known issue with today's workarounds (WSL, or turning it off) and the
Consolas fix. troubleshooting.md gains a Windows section for blocked
binaries and an entry for CSO build/publish failures. TODOS.md replaces
the Node CLI lane item with Windows code signing (needs a signing
account), the #3071 root-cause follow-up and SAC text verification.

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

* fix(setup): never write the CSO build record into a source checkout that needed no build

Setup wrote bin/.gstack-cso-build-result for every probe-stage outcome,
so a run that built nothing still changed the source checkout's bin/
(caught by setup-codex-scope's unchanged-source checks). A missing
prerequisite now comes from the run's own probe, as before, and setup
only removes a stale record alongside the removed helper.
scripts/build-cso.sh remains the only writer. The doctor's cso row
reports 'native helper not built' with no record.

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

* test(extension): move the Consolas font-stack check into sidebar-ux (#2287)

The standalone file read extension sources without importing product
code, which the test-of-test ratchet rejects; sidebar-ux already owns
the sidepanel.css structural checks.

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

* v1.91.43.0 fix(windows): setup degrades instead of dying on a CSO failure, detects Smart App Control, Consolas terminal font

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

---------

Co-authored-by: Yotam <yotsurfing@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-08 15:43:32 +00:00

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('"$CSO_POWERSHELL" -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);
});