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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-08 06:30:48 +00:00
1 parent 7afe2751e8
commit 1fa01610ca
7 files changed
+471 -10

No files matched your search

+59 -3
View File
@@ -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
+71 -6
View File
@@ -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
+72
View File
@@ -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 </dev/null 2>&1)
else
out=$("$1" --version </dev/null 2>&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 "rel<TAB>detail" 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
}
+44
View File
@@ -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 <<EOF
$_glp_blocked
EOF
log " This is code integrity, not file permissions: the binaries are compiled on this machine and unsigned."
log " Everything else is installed. You ran setup from $shell, where the block applies; $wsl, and gstack inside WSL is not affected."
log " Workarounds: install gstack inside WSL, or turn Smart App Control off in Windows Security > 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 <<EOF
$_glp_broken
EOF
log " Rebuild: cd $SOURCE_GSTACK_DIR && bun run build"
fi
}
if [ "$IS_WINDOWS" -eq 1 ]; then
_windows_launch_summary
fi
# ─── Chromium bootstrap summary (best-effort browser, see # 2) ───────────────
# Printed LAST so it is the thing the user sees, after every skill registered.
# The skills that drive Aside first and use the bundled browser only as fallback
+65 -1
View File
@@ -16,12 +16,15 @@ import * as path from 'path';
// setup builds browse/dist/browse.exe on Windows (Git Bash), browse/dist/browse elsewhere.
const BROWSE_BIN = process.platform === 'win32' ? 'browse/dist/browse.exe' : 'browse/dist/browse';
const EXE = process.platform === 'win32' ? '.exe' : '';
const OTHER_BINS = ['browse/dist/find-browse', 'design/dist/design', 'make-pdf/dist/pdf', 'bin/gstack-global-discover'].map(rel => 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);
});
});
+75
View File
@@ -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');
});
});
+85
View File
@@ -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<string, string>, 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('');
});
});