diff --git a/.github/workflows/windows-setup-e2e.yml b/.github/workflows/windows-setup-e2e.yml index de15bd85e..1d5f24f58 100644 --- a/.github/workflows/windows-setup-e2e.yml +++ b/.github/workflows/windows-setup-e2e.yml @@ -8,7 +8,11 @@ name: Windows Setup E2E # an isolated HOME and drives the installed runtime root's browse from an # unrelated directory: a piped two-command `chain` flow must print the page # text (the runtime root has no node_modules, so this is the checkout-bundle -# path from #3026, and the piped-stdin path from #3039). +# path from #3026, and the piped-stdin path from #3039). Every compiled +# binary must answer the side-effect-free --version that setup's Smart App +# Control probe runs (#2595). Last, two forced CSO failures: fatal under +# GSTACK_STRICT_BUILD=1, and a completed setup with the failure reported +# under GSTACK_STRICT_BUILD=0 (#3071). # # Separate from windows-free-tests.yml because that one runs a curated # unit-test subset; this one exercises the install path itself. @@ -21,6 +25,8 @@ on: paths: - 'package.json' - 'scripts/build.sh' + - 'scripts/build-cso.sh' + - 'scripts/build-cso-windows.ps1' - 'scripts/write-version-files.sh' - 'setup' - 'browse/src/cli.ts' @@ -28,7 +34,12 @@ on: - 'browse/src/server.ts' - 'browse/src/meta-commands.ts' - 'browse/scripts/build-node-server.sh' + - 'design/src/cli.ts' + - 'make-pdf/src/cli.ts' + - 'bin/gstack-global-discover.ts' - 'bin/gstack-paths' + - 'bin/gstack-doctor' + - 'bin/gstack-launch-probe.sh' - '.github/workflows/windows-setup-e2e.yml' workflow_dispatch: @@ -45,7 +56,7 @@ permissions: jobs: windows-setup: runs-on: windows-latest - timeout-minutes: 25 + timeout-minutes: 35 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -85,6 +96,7 @@ jobs: shell: bash env: GSTACK_SKIP_PLAYWRIGHT: '1' + GSTACK_STRICT_BUILD: '1' - name: Verify binaries exist (with .exe extension on Windows) run: | @@ -96,6 +108,15 @@ jobs: echo "All binaries present" shell: bash + - name: 'Every compiled binary launches with --version (Smart App Control probe, #2595)' + run: | + set -euo pipefail + . bin/gstack-launch-probe.sh + gstack_launch_probe_all "$PWD" .exe + printf 'blocked: %s\nbroken: %s\nmissing: %s\n' "$_glp_blocked" "$_glp_broken" "$_glp_missing" + test -z "$_glp_blocked$_glp_broken$_glp_missing" || (echo "a compiled binary did not answer --version" && exit 1) + shell: bash + - name: Verify find-browse resolves to the .exe variant run: | set -e @@ -127,7 +148,7 @@ jobs: set -euo pipefail E2E_HOME="$RUNNER_TEMP/gstack-e2e-home" mkdir -p "$E2E_HOME" - HOME="$E2E_HOME" USERPROFILE="$(cygpath -w "$E2E_HOME")" ./setup --host codex + HOME="$E2E_HOME" USERPROFILE="$(cygpath -w "$E2E_HOME")" GSTACK_STRICT_BUILD=1 ./setup --host codex ROOT="$E2E_HOME/.codex/skills/gstack" test -f "$ROOT/.source-path" || (echo "MISSING: $ROOT/.source-path" && exit 1) test ! -e "$ROOT/node_modules" || (echo "UNEXPECTED: $ROOT/node_modules (the journey must exercise the copy without it)" && exit 1) @@ -169,3 +190,38 @@ jobs: test "$EMPTY_CODE" -eq 1 || (echo "empty chain exited $EMPTY_CODE, expected 1" && exit 1) echo "$EMPTY_OUT" | grep -q 'no flow to run' || (echo "empty chain lacks the usage error" && exit 1) shell: bash + + - name: A forced CSO failure is fatal under GSTACK_STRICT_BUILD=1 (#3071) + run: | + set -uo pipefail + rm -f browse/dist/.build-complete + set +e + GSTACK_SKIP_PLAYWRIGHT=1 GSTACK_STRICT_BUILD=1 GSTACK_CSO_BUILD_TESTING=1 GSTACK_CSO_BUILD_TEST_FAIL_AFTER=core bun run build > strict.log 2>&1 + CODE=$? + set -e + tail -n 20 strict.log + test "$CODE" -ne 0 || (echo "strict build passed despite the injected CSO failure" && exit 1) + grep -q 'GSTACK_STRICT_BUILD=1 makes it fatal' strict.log || (echo "strict failure lacks its message" && exit 1) + shell: bash + + - name: A forced CSO publish failure still completes setup (#3071) + run: | + set -euo pipefail + E2E_HOME="$RUNNER_TEMP/gstack-e2e-cso-fail" + mkdir -p "$E2E_HOME" + rm -f browse/dist/.build-complete + set +e + OUT=$(HOME="$E2E_HOME" USERPROFILE="$(cygpath -w "$E2E_HOME")" GSTACK_SKIP_PLAYWRIGHT=1 GSTACK_STRICT_BUILD=0 \ + GSTACK_CSO_BUILD_TESTING=1 GSTACK_CSO_BUILD_TEST_FAIL_AFTER=publish-core ./setup --host codex 2>&1) + CODE=$? + set -e + echo "$OUT" | tail -n 40 + test "$CODE" -eq 0 || (echo "setup exited $CODE after an optional CSO failure" && exit 1) + echo "$OUT" | grep -qE 'CSO publish step failed \(exit 86\).*previous CSO kept|CSO unavailable: the native helper.s publish step failed' || (echo "setup did not report the CSO publish failure" && exit 1) + grep -q '^result=failed' bin/.gstack-cso-build-result + grep -q '^stage=publish' bin/.gstack-cso-build-result + test -f browse/dist/.build-complete || (echo "the build stopped at CSO" && exit 1) + DOCTOR=$(HOME="$E2E_HOME" bash bin/gstack-doctor 2>&1 || true) + echo "$DOCTOR" + echo "$DOCTOR" | grep -qE '^warn +cso +' || (echo "doctor did not report the CSO failure" && exit 1) + shell: bash diff --git a/bin/gstack-doctor b/bin/gstack-doctor index fc7141d99..6606a90dc 100755 --- a/bin/gstack-doctor +++ b/bin/gstack-doctor @@ -6,7 +6,7 @@ # gstack-doctor --live also run the paid Codex model probe (one short call) # # Rows: install root, state root, bun, hooks, codex, codex probe, artifacts -# sync, browse bundle, claude code, session journal, guard log. Each row is +# sync, browse bundle, binaries, cso, claude code, session journal, guard log. Each row is # `ok`, `warn`, `not configured` or `fail`; a row that is not ok carries a fix # line, and every gstack helper in a fix line is an absolute path under this # install root. Exit 1 when any row is `fail`, 2 on a usage error, else 0. @@ -15,8 +15,9 @@ # install registry rows behind `./setup --status`, bin/gstack-bun-version.sh, # bin/gstack-hook-check, bin/gstack-codex-probe's subcommands (the paid # `probe-model` only with --live; otherwise the cached result and its age), -# the status file bin/gstack-brain-sync writes, and setup's browse build -# outputs. The sync row prints only a clamped status code and counts, never +# the status file bin/gstack-brain-sync writes, setup's browse build +# outputs, setup's launch probe (bin/gstack-launch-probe.sh, which runs each +# compiled binary's side-effect-free --version) and the CSO build-result record. The sync row prints only a clamped status code and counts, never # the status message or file paths: the message can quote a matched secret. # Bash 3.2 plus POSIX tools; it still reports when bun or python3 is missing. @@ -272,13 +273,26 @@ check_sync() { fi } +# launch_fix — the fix line for a binary the OS refuses to start. +launch_fix() { + if [ -n "$EXE" ]; then + echo "Windows Smart App Control (or another application-control policy) blocks unsigned local builds (#2595): run gstack inside WSL or turn Smart App Control off, then cd $SRC && ./setup ($DOCS#windows-smart-app-control)" + else + echo "the OS refused to execute it (permissions or a noexec mount under $ROOT); fix that, then cd $SRC && ./setup" + fi +} + check_browse() { - local exe="" missing="" f - case "${OSTYPE:-}" in msys*|cygwin*|win32*) exe=".exe" ;; esac - if [ ! -x "$ROOT/browse/dist/browse$exe" ]; then + local missing="" f + if [ ! -x "$ROOT/browse/dist/browse$EXE" ]; then row warn "browse bundle" "not built; the gstack browser fallback for /browse, /qa, /qa-only, /design-review, /make-pdf, /pair-agent is unavailable (the Aside browser is unaffected)" "cd $SRC && ./setup" return fi + gstack_launch_probe "$ROOT/browse/dist/browse$EXE" + case "$_glp_state" in + blocked) row warn "browse bundle" "built, but blocked at launch (${_glp_detail:-no output}); the gstack browser fallback is unavailable (the Aside browser is unaffected)" "$(launch_fix)"; return ;; + broken) row warn "browse bundle" "built, but browse --version failed (${_glp_detail})" "cd $SRC && bun run build"; return ;; + esac for f in server-node.mjs .build-complete; do [ -e "$ROOT/browse/dist/$f" ] || missing="${missing:+$missing, }$f"; done if [ -n "$missing" ]; then row warn "browse bundle" "incomplete build (missing browse/dist: $missing)" "cd $SRC && ./setup" @@ -287,6 +301,51 @@ check_browse() { fi } +# check_binaries — the other compiled binaries, by launch state (browse has its own row). +check_binaries() { + local rel detail blocked="" broken="" missing="" skills="" n=0 + for rel in browse/dist/find-browse design/dist/design make-pdf/dist/pdf bin/gstack-global-discover; do + gstack_launch_probe "$ROOT/$rel$EXE" + detail="${rel##*/}" + case "$_glp_state" in + native) n=$((n + 1)) ;; + blocked) blocked="${blocked:+$blocked, }$detail (${_glp_detail:-no output})"; skills="${skills:+$skills, }$(gstack_launch_probe_skills "$rel")" ;; + broken) broken="${broken:+$broken, }$detail (${_glp_detail})" ;; + missing) missing="${missing:+$missing, }$detail" ;; + esac + done + if [ -n "$blocked" ]; then + row warn binaries "blocked at launch: $blocked; unavailable: $skills" "$(launch_fix)" + elif [ -n "$broken" ]; then + row warn binaries "launched but failed: $broken" "cd $SRC && bun run build" + elif [ -n "$missing" ]; then + row warn binaries "not built: $missing" "cd $SRC && ./setup" + else + row ok binaries "find-browse, design, pdf, gstack-global-discover launch ($n of 4)" + fi +} + +# check_cso — from the build-result record setup and scripts/build-cso.sh write. +check_cso() { + local file="$SRC/bin/.gstack-cso-build-result" result stage reason installed diag usable=0 + [ -x "$SRC/bin/gstack-cso-launcher$EXE" ] && [ -x "$SRC/bin/gstack-cso-core$EXE" ] && usable=1 + cso_field() { sed -n "s/^$1=//p" "$file" 2>/dev/null | head -1 | clamp ' -~' 200; } + result=$(cso_field result) stage=$(cso_field stage) reason=$(cso_field reason) installed=$(cso_field installed) diag=$(cso_field diagnostic) + case "$result" in + ok) if [ "$usable" -eq 1 ]; then row ok cso "native helper $installed" + else row warn cso "the last build succeeded but the native helper is missing; /cso reports not assessed" "cd $SRC && ./setup"; fi ;; + unavailable) row "not configured" cso "unavailable: a build prerequisite is missing ($reason${diag:+: $diag}); /cso reports not assessed" "cd $SRC && ./setup names the prerequisite to install" ;; + failed) + if [ "$usable" -eq 1 ] && [ -n "$installed" ]; then + row warn cso "$stage step failed ($reason) for $(cso_field revision); previous CSO kept: $installed" "cd $SRC && bun run build:cso && ./setup (log: ${diag:-none}; $DOCS#cso-build-or-publish-failed)" + else + row warn cso "unavailable: the $stage step failed ($reason); /cso reports not assessed" "cd $SRC && bun run build:cso && ./setup (log: ${diag:-none}; $DOCS#cso-build-or-publish-failed)" + fi ;; + *) if [ "$usable" -eq 1 ]; then row ok cso "native helper built (no build record yet)" + else row "not configured" cso "native helper not built; /cso reports not assessed" "cd $SRC && ./setup"; fi ;; + esac +} + check_claude() { local override cmd raw ver rc a local args=() @@ -376,6 +435,10 @@ STATE_ROOT="$_gstack_sr_root" GSTACK_STATE_ROOT="$STATE_ROOT" . "$BIN/gstack-codex-status.sh" 2>/dev/null || { echo "gstack-doctor: $BIN/gstack-codex-status.sh is missing; reinstall with cd $SRC && ./setup" >&2; exit 1; } +. "$BIN/gstack-launch-probe.sh" 2>/dev/null || { echo "gstack-doctor: $BIN/gstack-launch-probe.sh is missing; reinstall with cd $SRC && ./setup" >&2; exit 1; } +EXE="" +case "${OSTYPE:-}" in msys*|cygwin*|win32*) EXE=".exe" ;; esac + echo "gstack doctor $(head -1 "$ROOT/VERSION" 2>/dev/null | clamp '0-9A-Za-z.+-' 40)" check_install check_state_root @@ -385,6 +448,8 @@ check_codex [ "$CODEX_READY" -eq 1 ] && check_codex_probe check_sync check_browse +check_binaries +check_cso check_claude check_journal check_guard_log diff --git a/bin/gstack-launch-probe.sh b/bin/gstack-launch-probe.sh new file mode 100644 index 000000000..098587025 --- /dev/null +++ b/bin/gstack-launch-probe.sh @@ -0,0 +1,72 @@ +# shellcheck shell=bash +# gstack-launch-probe.sh — sourced by setup and bin/gstack-doctor. +# +# Windows Smart App Control (and other application-control policies) blocks +# unsigned executables at launch, and Git Bash reports that as a plain +# "Permission denied" (#2595, #2124). `[ -x ]` cannot see it, so this runs +# each compiled binary's side-effect-free `--version` and classifies it: +# +# native launched and exited 0 +# blocked could not launch: spawn error, permission denied (exit 126/127), +# or an application-control message in its output +# broken launched but exited non-zero or timed out: a build problem +# missing not built +# +# The exact Windows error text is unverified on a real SAC machine, so callers +# show the raw first output line ($_glp_detail) beside the classification. +# Bash 3.2 plus POSIX tools. + +GSTACK_COMPILED_BINARIES="browse/dist/browse browse/dist/find-browse design/dist/design make-pdf/dist/pdf bin/gstack-global-discover" + +# gstack_launch_probe PATH — sets _glp_state and _glp_detail. +gstack_launch_probe() { + local out rc + _glp_state=missing + _glp_detail="" + [ -e "$1" ] || return 0 + if command -v timeout >/dev/null 2>&1; then + out=$(timeout 20 "$1" --version &1) + else + out=$("$1" --version &1) + fi + rc=$? + _glp_detail=$(printf '%s\n' "$out" | tr -d '\r' | grep -v '^[[:space:]]*$' | head -1 | cut -c1-200) + case "$out" in + *[Aa]pplication\ [Cc]ontrol*|*"Smart App Control"*|*"blocked by group policy"*|*"has blocked this file"*) + _glp_state=blocked; return 0 ;; + esac + case "$rc" in + 0) _glp_state=native ;; + 126|127) _glp_state=blocked ;; + 124) _glp_state=broken; _glp_detail="timed out after 20s${_glp_detail:+: $_glp_detail}" ;; + *) _glp_state=broken; _glp_detail="exit $rc${_glp_detail:+: $_glp_detail}" ;; + esac +} + +# gstack_launch_probe_skills REL — the skills that stop working without it. +gstack_launch_probe_skills() { + case "$1" in + browse/dist/browse|browse/dist/find-browse) printf '%s' "/browse, /qa, /qa-only, /design-review, /canary, /benchmark, /pair-agent, /scrape, /make-pdf" ;; + design/dist/design) printf '%s' "/design-consultation, /design-shotgun, /design-html, /plan-design-review" ;; + make-pdf/dist/pdf) printf '%s' "/make-pdf" ;; + bin/gstack-global-discover) printf '%s' "/retro global" ;; + esac +} + +# gstack_launch_probe_all ROOT EXE — probes every compiled binary under ROOT +# (EXE is ".exe" on Windows). Sets _glp_blocked and _glp_broken to +# newline-separated "reldetail" rows, and _glp_missing to a list. +gstack_launch_probe_all() { + local rel + _glp_blocked="" _glp_broken="" _glp_missing="" + for rel in $GSTACK_COMPILED_BINARIES; do + gstack_launch_probe "$1/$rel$2" + case "$_glp_state" in + blocked) _glp_blocked="$_glp_blocked$rel $_glp_detail +" ;; + broken) _glp_broken="$_glp_broken$rel $_glp_detail +" ;; + missing) _glp_missing="${_glp_missing:+$_glp_missing, }$rel" ;; + esac + done +} diff --git a/setup b/setup index 7d1873106..7a17debad 100755 --- a/setup +++ b/setup @@ -4454,6 +4454,50 @@ elif [ "$_CSO_RESULT" = "failed" ]; then log " Details: https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#cso-build-or-publish-failed" fi +# ─── Windows launch probe (#2595, #2124) ────────────────────────────────────── +# Smart App Control blocks unsigned executables at launch, which `[ -x ]` cannot +# see and Git Bash reports as "Permission denied". Probe each compiled binary. +_windows_launch_summary() { + local rel detail shell wsl + . "$SOURCE_GSTACK_DIR/bin/gstack-launch-probe.sh" || return 0 + gstack_launch_probe_all "$SOURCE_GSTACK_DIR" ".exe" + if [ -n "$_glp_blocked" ]; then + case "$(uname -s)" in + MINGW*|MSYS*) shell="Git Bash" ;; + CYGWIN*) shell="Cygwin" ;; + *) shell="$(uname -s)" ;; + esac + if command -v wsl.exe >/dev/null 2>&1; then wsl="WSL is installed"; else wsl="WSL is not installed (wsl --install)"; fi + log "" + log "Windows blocked compiled gstack binaries at launch (Smart App Control or another application-control policy, #2595):" + while IFS=' ' read -r rel detail; do + [ -n "$rel" ] || continue + log " $rel.exe: ${detail:-no output} -> $(gstack_launch_probe_skills "$rel")" + done < App & browser control." + log " To undo: once Windows allows the binaries, re-run ./setup and this message goes away." + log " Details: https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#windows-smart-app-control" + fi + if [ -n "$_glp_broken" ]; then + log "" + log "Compiled gstack binaries launched but failed (a build problem, not Smart App Control):" + while IFS=' ' read -r rel detail; do + [ -n "$rel" ] || continue + log " $rel.exe: $detail -> $(gstack_launch_probe_skills "$rel")" + done < rel + EXE); const REPO = path.resolve(import.meta.dir, '..'); const COPIED = [ 'setup', 'VERSION', 'bin/gstack-doctor', 'bin/gstack-codex-status.sh', 'bin/gstack-state-root.sh', 'bin/gstack-install-registry.sh', 'bin/gstack-render-claude.sh', 'bin/gstack-bun-version.sh', 'bin/gstack-hook-check', 'bin/gstack-config', + 'bin/gstack-launch-probe.sh', ]; const bases: string[] = []; afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); }); @@ -79,7 +82,10 @@ function makeFixture(): Fixture { ]); const hooks = spawnSync('bash', [path.join(root, 'bin/gstack-hook-check'), '--list', root], { encoding: 'utf8', timeout: 20_000 }).stdout.trim().split('\n'); for (const hook of hooks) write(path.join(root, hook), '#!/bin/sh\nexit 0\n', 0o755); - for (const rel of [BROWSE_BIN, 'browse/dist/server-node.mjs', 'browse/dist/.build-complete']) write(path.join(root, rel), 'built\n', 0o755); + // The doctor launches each compiled binary's --version (#2595), so the + // stand-ins answer it like the real binaries do. + for (const rel of [BROWSE_BIN, ...OTHER_BINS]) write(path.join(root, rel), '#!/bin/sh\necho built\n', 0o755); + for (const rel of ['browse/dist/server-node.mjs', 'browse/dist/.build-complete']) write(path.join(root, rel), 'built\n', 0o755); const version = fs.readFileSync(path.join(root, 'VERSION'), 'utf8').trim(); write(path.join(state, 'installs.tsv'), ['claude', 'global', '-', path.join(home, '.claude/skills'), root, root, version, 'false', 'committed', '2026-10-07T00:00:00Z'].join('\t') + '\n'); @@ -360,3 +366,61 @@ describe('bug-report template and README (DX-2, DX-12)', () => { expect(section).toContain('`~/.claude/skills/gstack/bin/gstack-doctor`'); }); }); + +// #2595/#2124 and #3071: compiled binaries by launch state, and CSO from the +// build-result record. A blocked binary is simulated by the application-control +// message reporters captured (#2595), which works on every OS. +describe('gstack-doctor launch and CSO rows', () => { + const BLOCKED = `#!/bin/sh\necho "Program 'x.exe' failed to run: An Application Control policy has blocked this file" >&2\nexit 1\n`; + const record = (f: Fixture, text: string) => write(path.join(f.root, 'bin/.gstack-cso-build-result'), text); + const cso = (f: Fixture) => { for (const name of ['gstack-cso-launcher', 'gstack-cso-core']) write(path.join(f.root, 'bin', name + EXE), '#!/bin/sh\necho cso\n', 0o755); }; + + test('healthy binaries are ok; a blocked browse says blocked, not "not built"', () => { + const f = makeFixture(); + let r = doctor(f); + expect(r.row('binaries').state).toBe('ok'); + expect(r.row('binaries').detail).toContain('(4 of 4)'); + write(path.join(f.root, BROWSE_BIN), BLOCKED, 0o755); + r = doctor(f); + expect(r.row('browse bundle').state).toBe('warn'); + expect(r.row('browse bundle').detail).toContain('built, but blocked at launch (Program'); + expect(r.row('browse bundle').detail).not.toContain('not built'); + expect(r.row('browse bundle').fix).toContain(process.platform === 'win32' ? 'Smart App Control' : 'the OS refused to execute it'); + expect(r.status).toBe(0); + }); + + test('blocked, broken and missing binaries are told apart', () => { + const f = makeFixture(); + write(path.join(f.root, OTHER_BINS[1]), BLOCKED, 0o755); + let r = doctor(f); + expect(r.row('binaries').detail).toContain('blocked at launch: design'); + expect(r.row('binaries').detail).toContain('unavailable: /design-consultation'); + write(path.join(f.root, OTHER_BINS[1]), '#!/bin/sh\necho ok\n', 0o755); + write(path.join(f.root, OTHER_BINS[2]), '#!/bin/sh\necho "usage" >&2\nexit 2\n', 0o755); + r = doctor(f); + expect(r.row('binaries').detail).toBe('launched but failed: pdf (exit 2: usage)'); + expect(r.row('binaries').fix).toBe(`cd ${bashPath(f.root)} && bun run build`); + fs.rmSync(path.join(f.root, OTHER_BINS[2])); + r = doctor(f); + expect(r.row('binaries').detail).toBe('not built: pdf'); + }); + + test('the CSO row reads the build-result record', () => { + const f = makeFixture(); + expect(doctor(f).row('cso').state).toBe('not configured'); + record(f, 'result=unavailable\nstage=probe\nreason=windows-msvc-toolchain\nrevision=1\ninstalled=\nlauncher=no\ndiagnostic=\n'); + expect(doctor(f).row('cso').detail).toContain('unavailable: a build prerequisite is missing (windows-msvc-toolchain)'); + record(f, 'result=failed\nstage=build\nreason=exit 42\nrevision=1.2 (abc)\ninstalled=\nlauncher=no\ndiagnostic=/x/cso.log\n'); + let r = doctor(f); + expect(r.row('cso').state).toBe('warn'); + expect(r.row('cso').detail).toBe('unavailable: the build step failed (exit 42); /cso reports not assessed'); + expect(r.row('cso').fix).toContain('bun run build:cso && ./setup (log: /x/cso.log;'); + cso(f); + record(f, 'result=failed\nstage=publish\nreason=exit 1\nrevision=1.2 (abc)\ninstalled=1.1 (def)\nlauncher=yes\ndiagnostic=/x/cso.log\n'); + expect(doctor(f).row('cso').detail).toBe('publish step failed (exit 1) for 1.2 (abc); previous CSO kept: 1.1 (def)'); + record(f, 'result=ok\nstage=publish\nreason=committed\nrevision=1.2 (abc)\ninstalled=1.2 (abc)\nlauncher=yes\ndiagnostic=x\n'); + r = doctor(f); + expect(r.row('cso')).toEqual({ state: 'ok', detail: 'native helper 1.2 (abc)', fix: '' }); + expect(r.status).toBe(0); + }); +}); diff --git a/test/gstack-launch-probe.test.ts b/test/gstack-launch-probe.test.ts new file mode 100644 index 000000000..fcf76bc36 --- /dev/null +++ b/test/gstack-launch-probe.test.ts @@ -0,0 +1,75 @@ +import { afterEach, 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 { runBashScript } from './helpers/bash-script'; + +// #2595/#2124: Smart App Control blocks unsigned binaries at launch and Git +// Bash reports it as "Permission denied" (exit 126). bin/gstack-launch-probe.sh +// runs each binary's --version and tells blocked from broken from healthy. +// The exact Windows text is unverified (no SAC machine); these cases use the +// shapes reporters captured in #2595 and #2124. +const ROOT = path.resolve(import.meta.dir, '..'); +const PROBE = path.join(ROOT, 'bin/gstack-launch-probe.sh'); +const tmps: string[] = []; +afterEach(() => { for (const t of tmps.splice(0)) fs.rmSync(t, { recursive: true, force: true }); }); + +function fakeBinary(body: string | null, mode = 0o755) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-launch-probe-')); + tmps.push(dir); + const file = path.join(dir, 'tool'); + if (body !== null) fs.writeFileSync(file, `#!/bin/sh\n${body}\n`, { mode }); + return file; +} + +function probe(file: string) { + const r = runBashScript(`. ${JSON.stringify(PROBE)}\ngstack_launch_probe ${JSON.stringify(file)}\nprintf 'state=%s\\ndetail=%s\\n' "$_glp_state" "$_glp_detail"`, { timeout: 30_000 }); + expect(r.status).toBe(0); + return { state: r.stdout.match(/^state=(.*)$/m)![1], detail: r.stdout.match(/^detail=(.*)$/m)![1] }; +} + +// POSIX execute bits stand in for a refused launch; Windows has no such bit, +// and a real SAC block cannot be produced in CI. +describe.skipIf(process.platform === 'win32')('gstack-launch-probe.sh classification', () => { + test('a binary that answers --version is native', () => { + expect(probe(fakeBinary('[ "$1" = --version ] && echo 1.2.3'))).toEqual({ state: 'native', detail: '1.2.3' }); + }); + + test('a binary the OS refuses to execute is blocked (Git Bash "Permission denied", exit 126)', () => { + const r = probe(fakeBinary('echo never', 0o644)); + expect(r.state).toBe('blocked'); + expect(r.detail).toContain('Permission denied'); + }); + + test('an application-control message is blocked even with another exit code', () => { + const r = probe(fakeBinary("echo \"Program 'browse.exe' failed to run: An Application Control policy has blocked this file\" >&2; exit 1")); + expect(r.state).toBe('blocked'); + expect(r.detail).toContain('An Application Control policy has blocked this file'); + }); + + test('a binary that launches and exits non-zero is broken, not blocked', () => { + expect(probe(fakeBinary('echo "Unknown argument: --version" >&2; exit 3'))).toEqual({ state: 'broken', detail: 'exit 3: Unknown argument: --version' }); + }); + + test('an absent binary is missing', () => { + expect(probe(fakeBinary(null)).state).toBe('missing'); + }); + + test('probe_all groups blocked, broken and missing binaries by repo path', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-launch-root-')); + tmps.push(root); + const write = (rel: string, body: string, mode = 0o755) => { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), `#!/bin/sh\n${body}\n`, { mode }); + }; + write('browse/dist/browse.exe', 'echo x', 0o644); + write('browse/dist/find-browse.exe', 'echo ok'); + write('design/dist/design.exe', 'echo ok'); + write('make-pdf/dist/pdf.exe', 'exit 2'); + const r = runBashScript(`. ${JSON.stringify(PROBE)}\ngstack_launch_probe_all ${JSON.stringify(root)} .exe\nprintf 'blocked=[%s]\\nbroken=[%s]\\nmissing=[%s]\\n' "$_glp_blocked" "$_glp_broken" "$_glp_missing"`, { timeout: 30_000 }); + expect(r.stdout).toContain('blocked=[browse/dist/browse\t'); + expect(r.stdout).toContain('broken=[make-pdf/dist/pdf\texit 2'); + expect(r.stdout).toContain('missing=[bin/gstack-global-discover]'); + expect(r.stdout).not.toContain('design/dist/design'); + }); +}); diff --git a/test/setup-windows-launch-hint.test.ts b/test/setup-windows-launch-hint.test.ts new file mode 100644 index 000000000..47c9e46fb --- /dev/null +++ b/test/setup-windows-launch-hint.test.ts @@ -0,0 +1,85 @@ +import { afterEach, 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 { runBashScript } from './helpers/bash-script'; + +// #2595/#2124: on Windows, setup launches each compiled binary's --version +// after the build and explains a Smart App Control block: the blocked +// binaries, the skills that need them, #2595, the shell and WSL status, how to +// undo it and the troubleshooting link. Runs setup's real function against +// stand-in binaries; a real SAC block cannot be produced in CI. +const ROOT = path.resolve(import.meta.dir, '..'); +const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf8'); +const fn = (name: string) => { + const start = setup.indexOf(`\n${name}() {`); + const end = setup.indexOf('\n}\n', start); + if (start < 0 || end < 0) throw new Error(`missing setup function ${name}`); + return setup.slice(start + 1, end + 3); +}; +const tmps: string[] = []; +afterEach(() => { for (const t of tmps.splice(0)) fs.rmSync(t, { recursive: true, force: true }); }); +const temp = (prefix: string) => { const d = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); tmps.push(d); return d; }; +const stub = (dir: string, name: string, body: string) => fs.writeFileSync(path.join(dir, name), `#!/bin/sh\n${body}\n`, { mode: 0o755 }); + +test('setup runs the launch summary only on Windows, after the CSO summary', () => { + const call = setup.indexOf('if [ "$IS_WINDOWS" -eq 1 ]; then\n _windows_launch_summary\nfi'); + expect(call).toBeGreaterThan(setup.indexOf('# ─── CSO native-helper summary')); +}); + +describe.skipIf(process.platform === 'win32')('setup: Windows Smart App Control hint (#2595)', () => { + function run(binaries: Record, wsl: boolean) { + const src = temp('gstack-sac-src-'); + const tools = temp('gstack-sac-tools-'); + fs.mkdirSync(path.join(src, 'bin')); + fs.copyFileSync(path.join(ROOT, 'bin/gstack-launch-probe.sh'), path.join(src, 'bin/gstack-launch-probe.sh')); + for (const [rel, body] of Object.entries(binaries)) { + fs.mkdirSync(path.dirname(path.join(src, rel)), { recursive: true }); + fs.writeFileSync(path.join(src, rel), `#!/bin/sh\n${body}\n`, { mode: body === 'BLOCKED' ? 0o644 : 0o755 }); + } + stub(tools, 'uname', 'echo MINGW64_NT-10.0-26200'); + if (wsl) stub(tools, 'wsl.exe', 'exit 0'); + const r = runBashScript(['QUIET=0', `SOURCE_GSTACK_DIR="${src}"`, 'log() { echo "$@"; }', fn('_windows_launch_summary'), '_windows_launch_summary'].join('\n'), + { env: { ...process.env, PATH: `${tools}:${process.env.PATH}` }, timeout: 60_000 }); + expect(r.status).toBe(0); + return { out: r.stdout, src }; + } + const healthy = 'echo 1.0'; + + test('blocked binaries are named with their skills, #2595, the shell, WSL status, undo and the docs link', () => { + const { out } = run({ + 'browse/dist/browse.exe': 'BLOCKED', + 'browse/dist/find-browse.exe': healthy, + 'design/dist/design.exe': healthy, + 'make-pdf/dist/pdf.exe': "echo \"Program 'pdf.exe' failed to run: An Application Control policy has blocked this file\" >&2; exit 1", + 'bin/gstack-global-discover.exe': healthy, + }, false); + expect(out).toContain('Windows blocked compiled gstack binaries at launch (Smart App Control or another application-control policy, #2595):'); + expect(out).toMatch(/browse\/dist\/browse\.exe: .*Permission denied.* -> \/browse, \/qa, /); + expect(out).toContain('make-pdf/dist/pdf.exe: Program \'pdf.exe\' failed to run: An Application Control policy has blocked this file -> /make-pdf'); + expect(out).toContain('code integrity, not file permissions'); + expect(out).toContain('You ran setup from Git Bash, where the block applies; WSL is not installed (wsl --install)'); + expect(out).toContain('To undo: once Windows allows the binaries, re-run ./setup'); + expect(out).toContain('troubleshooting.md#windows-smart-app-control'); + expect(out).not.toContain('design/dist/design.exe'); + }); + + test('WSL presence is reported', () => { + expect(run({ 'browse/dist/browse.exe': 'BLOCKED' }, true).out).toContain('WSL is installed, and gstack inside WSL is not affected'); + }); + + test('a binary that launches and fails is a build problem, not Smart App Control', () => { + const { out, src } = run({ + 'browse/dist/browse.exe': healthy, 'browse/dist/find-browse.exe': healthy, 'design/dist/design.exe': healthy, + 'make-pdf/dist/pdf.exe': healthy, 'bin/gstack-global-discover.exe': 'echo "Unknown argument: --version" >&2; exit 1', + }, false); + expect(out).not.toContain('Smart App Control or another'); + expect(out).toContain('bin/gstack-global-discover.exe: exit 1: Unknown argument: --version -> /retro global'); + expect(out).toContain(`Rebuild: cd ${src} && bun run build`); + }); + + test('healthy binaries print nothing', () => { + const all = Object.fromEntries(['browse/dist/browse.exe', 'browse/dist/find-browse.exe', 'design/dist/design.exe', 'make-pdf/dist/pdf.exe', 'bin/gstack-global-discover.exe'].map(rel => [rel, healthy])); + expect(run(all, false).out).toBe(''); + }); +});