From 4dfd83bf8c5c36561ea82c893c07a84d927adb89 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 8 Oct 2026 08:43:32 -0700 Subject: [PATCH] v1.91.60.0 fix(windows): setup degrades instead of dying on a CSO failure, detects Smart App Control, Consolas terminal font (#3079) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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 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: '. 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 Co-authored-by: Claude Fable 5 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .github/workflows/free-tests.yml | 4 + .github/workflows/make-pdf-gate.yml | 2 + .github/workflows/windows-setup-e2e.yml | 62 +++++++++- .gitignore | 2 + CHANGELOG.md | 43 +++++++ README.md | 9 +- TODOS.md | 38 +++++- VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-doctor | 76 +++++++++++- bin/gstack-global-discover.ts | 18 ++- bin/gstack-launch-probe.sh | 72 ++++++++++++ browse/src/cli.ts | 6 + browse/src/find-browse.ts | 11 +- browse/test/sidebar-ux.test.ts | 21 ++++ docs/troubleshooting.md | 73 +++++++++++- extension/sidepanel-terminal.js | 8 +- extension/sidepanel.css | 4 +- make-pdf/src/cli.ts | 2 + package.json | 2 +- scripts/build-cso.sh | 63 +++++++++- scripts/build.sh | 14 ++- setup | 110 ++++++++++++++++-- test/binary-version-probe.test.ts | 61 ++++++++++ test/build-cso-nonfatal.test.ts | 76 ++++++++++++ test/build-cso-result-record.test.ts | 129 +++++++++++++++++++++ test/cso-windows-launcher.test.ts | 2 +- test/gstack-doctor.test.ts | 67 ++++++++++- test/gstack-launch-probe.test.ts | 75 ++++++++++++ test/setup-cso-degrade.test.ts | 147 ++++++++++++++++++++++++ test/setup-cso-windows-probe.test.ts | 6 +- test/setup-windows-launch-hint.test.ts | 85 ++++++++++++++ 32 files changed, 1245 insertions(+), 47 deletions(-) create mode 100644 bin/gstack-launch-probe.sh create mode 100644 test/binary-version-probe.test.ts create mode 100644 test/build-cso-nonfatal.test.ts create mode 100644 test/build-cso-result-record.test.ts create mode 100644 test/gstack-launch-probe.test.ts create mode 100644 test/setup-cso-degrade.test.ts create mode 100644 test/setup-windows-launch-hint.test.ts diff --git a/.github/workflows/free-tests.yml b/.github/workflows/free-tests.yml index 19a444876..9cfb037ab 100644 --- a/.github/workflows/free-tests.yml +++ b/.github/workflows/free-tests.yml @@ -87,6 +87,8 @@ jobs: env: GSTACK_CSO_MACOS_TESTS: "1" - name: Build browser lifecycle prerequisites + env: + GSTACK_STRICT_BUILD: '1' run: | bunx playwright install chromium bun run build @@ -117,6 +119,8 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile --ignore-scripts - name: Build browser prerequisites + env: + GSTACK_STRICT_BUILD: '1' run: | bunx playwright install chromium bun run build diff --git a/.github/workflows/make-pdf-gate.yml b/.github/workflows/make-pdf-gate.yml index 23ae86b66..b17182787 100644 --- a/.github/workflows/make-pdf-gate.yml +++ b/.github/workflows/make-pdf-gate.yml @@ -80,6 +80,8 @@ jobs: - name: Build binaries run: bun run build + env: + GSTACK_STRICT_BUILD: '1' - name: ad-hoc codesign (Apple Silicon) if: matrix.os == 'macos-latest' 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/.gitignore b/.gitignore index 7cea0df12..e4a01db37 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,8 @@ bin/.gstack-cso-stage.* bin/.gstack-cso-build.lock/ bin/.gstack-cso-generation.lock bin/.gstack-cso-generation +bin/.gstack-cso-build-result +bin/.gstack-cso-build.log lib/cso/images/gstack-cso-verifier lib/cso/images/gstack-cso-preparation .gstack/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cf4c0dbd..22d663dd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,48 @@ # Changelog +## [1.91.60.0] - 2026-10-08 + +**Windows setup finishes when the optional /cso helper fails to build, and it tells you when Smart App Control blocks gstack's binaries.** + +On Windows, `./setup` stopped completely when the native /cso helper failed to build or publish (#3071), even though /cso is optional and every other skill was ready. On Windows 11 with Smart App Control on, setup reported success while Windows refused to start all five binaries gstack compiles (`browse`, `find-browse`, `design`, `pdf`, `gstack-global-discover`). Git Bash shows that as `Permission denied`, so `/qa`, `/browse`, `/design-*` and `/make-pdf` failed later with a misleading error (#2595, #2124). And the sidebar terminal rendered every character double-width on Windows (#2287). + +### The numbers that matter + +Measured on Linux (4 vCPU) at f67c478 (before) and on this branch (after). The Windows-only paths are covered by stubbed tests here and by the `windows-setup-e2e` job on `windows-latest`. Nobody has run this on a machine with Smart App Control on. + +| Check | Before | After | +|---|---|---| +| `scripts/build.sh` with a CSO publish failure injected | exit 86; no browse server bundle, no build stamp | exit 0; bundle and stamp written; failure recorded with stage and log | +| `./setup --host codex` with the same failure | stops (exit 86) | finishes; prints `CSO publish step failed (exit 86) ...; previous CSO kept: ` | +| Same failure with `GSTACK_STRICT_BUILD=1` | exit 86 | exit 1 with the reason (CI keeps catching regressions) | +| `browse --version` | started a browse server, then exited 1 | prints the build hash, exits 0, starts nothing | +| `pdf --version` / `gstack-global-discover --version` | exit 1 (usage) | exit 0 | +| Compiled binaries setup and `gstack-doctor` launch-check | 0 of 5 | 5 of 5 | + +### What this means for you + +Run `/gstack-upgrade` (or `git pull && ./setup`), then run `~/.claude/skills/gstack/bin/gstack-doctor`: its new `binaries` and `cso` rows show whether every compiled binary starts and whether /cso's helper is installed. On Windows with Smart App Control on, setup now lists the blocked binaries and the skills that need them, and points to the two workarounds that work today: run gstack inside WSL, or turn Smart App Control off. Signed binaries are the real fix and are tracked in `TODOS.md`. + +### Itemized changes + +#### Fixed +- A failed /cso helper build or publish no longer stops `scripts/build.sh` or `./setup`. setup says which step failed (`probe`, `build` or `publish`), where the log is (`bin/.gstack-cso-build.log`), what still works, and the retry command (`bun run build:cso && ./setup`). When a failed publish restores the earlier helper, setup says `previous CSO kept: ` and /cso keeps using it. `GSTACK_STRICT_BUILD=1` makes the failure fatal; the free-tests, make-pdf-gate and windows-setup-e2e builds set it. (#3071) +- `scripts/build-cso.sh` clears its EXIT trap before it hands the stage to the native publisher, so on Git Bash the parent can no longer delete the staged files while the publisher runs. If the publisher cannot start, the parent cleans up and records why. Whether this was the cause of #3071's vanishing files is unproven. (#3071) +- The Windows /cso toolchain probe and build prefer PowerShell 7 (`pwsh`) and fall back to Windows PowerShell 5.1. A probe that fails for another reason keeps its exit status and first output line (`windows-toolchain-probe`) instead of being labeled a missing Visual Studio install. (#3071) +- `browse`, `find-browse`, `pdf` and `gstack-global-discover` answer `--version` before doing anything else, like `design` already did. +- The sidebar terminal and the sidepanel's monospace text list Consolas and the Cascadia faces before the CJK fallbacks, so Windows no longer renders `C l a u d e`. (#2287) + +#### Added +- setup on Windows runs each compiled binary's `--version` after the build. Binaries that cannot start are reported as blocked by Smart App Control or another application-control policy, with the skills that need them, the shell setup ran from, whether WSL is installed, how to undo it, and a new troubleshooting section. A binary that starts but fails is reported as a build problem. setup prints the raw Windows error beside its classification because that text is unverified. (#2595, #2124) +- `gstack-doctor`: the `browse bundle` row says `blocked at launch` instead of passing, a new `binaries` row covers the other four compiled binaries, and a new `cso` row reads the build-result record. +- README documents Smart App Control as a known issue and /cso as optional on Windows; `docs/troubleshooting.md` gains a Windows section and an entry for CSO build and publish failures. + +#### For contributors +- `bin/gstack-launch-probe.sh` is the shared launch classifier (`native`, `blocked`, `broken`, `missing`) for setup and the doctor. `bin/.gstack-cso-build-result` is the CSO outcome record that `scripts/build-cso.sh` writes; setup removes it with the helper when a prerequisite is missing, so it never writes into a source checkout that needed no build. They are pinned by `test/gstack-launch-probe.test.ts`, `test/build-cso-result-record.test.ts`, `test/build-cso-nonfatal.test.ts`, `test/setup-cso-degrade.test.ts`, `test/setup-windows-launch-hint.test.ts`, `test/binary-version-probe.test.ts`, and new cases in `test/gstack-doctor.test.ts` and `browse/test/sidebar-ux.test.ts`. +- `windows-setup-e2e` checks that all five binaries answer `--version`, and adds two forced CSO failures: fatal under `GSTACK_STRICT_BUILD=1`, and a completed setup with a doctor `cso` warning under `GSTACK_STRICT_BUILD=0`. +- Deliberately not done: a `bun run` shim for blocked binaries. PowerShell callers could not use it, and it would load the calling project's `.env` and `bunfig.toml`. +- Thanks to @dviolante for the #3071 report and its diagnosis, including the PowerShell 5.1 crash and the `pwsh` fix. Thanks to @tomfluff for the font fix (#2287, landed with original authorship). The Smart App Control diagnosis came from @knetics9000 (#2595, #2596), @smartjelic-sys (#2595), @pstilwell90 (#2124), @cko32002 (#2265) and @salluexez (#2127). + ## [1.91.59.0] - 2026-10-08 **/autoplan's phase guard accepts a plan under a symlinked directory, and a failed snapshot init no longer blocks a corrected re-run.** diff --git a/README.md b/README.md index e2c99f362..8ae9541f0 100644 --- a/README.md +++ b/README.md @@ -664,8 +664,9 @@ Data is stored in [Supabase](https://supabase.com) (open source Firebase alterna (on other hosts, `./setup --status` in your gstack checkout ends with the doctor's absolute path). Without starting a skill or spending anything, it prints one row per check (install, state root, Bun, hooks, Codex and its cached -model probe, artifacts sync, the browse bundle, Claude Code, your largest -session journal and recent /autoplan guard codes), each `ok`, `warn`, +model probe, artifacts sync, the browse bundle, the other compiled binaries, +the /cso native helper, Claude Code, your largest session journal and recent +/autoplan guard codes), each `ok`, `warn`, `not configured` or `fail` with the command that fixes it. It exits non-zero only on `fail`. `--live` also runs the paid Codex model check (one short call). Paste its output into bug reports. @@ -706,7 +707,9 @@ types into that element; bare `browse type ` types into whatever has focus **Codex says "Skipped loading skill(s) due to invalid SKILL.md"?** Your Codex skill descriptions are stale. `${CODEX_HOME:-~/.codex}/skills/gstack` is a runtime directory, not the checkout: `./setup --status` shows the Codex row's source checkout. Fix: `cd && git pull && ./setup --host codex` — for a repo-local install, run it from inside the project. -**Windows users:** gstack works on Windows 11 via Git Bash or WSL. Aside is macOS-only, so on Windows (and Linux) the browser skills, `/make-pdf`, and `/diagram` always use gstack's bundled browser. Node.js is required in addition to Bun — Bun has a known bug with Playwright's pipe transport on Windows ([bun#4253](https://github.com/oven-sh/bun/issues/4253)). The browse server automatically falls back to Node.js. Make sure both `bun` and `node` are on your PATH. Native `/cso` additionally requires Windows PowerShell and Visual Studio 2022 Build Tools with the Desktop development with C++ workload; setup leaves that skill explicitly unavailable when they are absent. +**Windows users:** gstack works on Windows 11 via Git Bash or WSL. Aside is macOS-only, so on Windows (and Linux) the browser skills, `/make-pdf`, and `/diagram` always use gstack's bundled browser. Node.js is required in addition to Bun — Bun has a known bug with Playwright's pipe transport on Windows ([bun#4253](https://github.com/oven-sh/bun/issues/4253)). The browse server automatically falls back to Node.js. Make sure both `bun` and `node` are on your PATH. Native `/cso` additionally requires PowerShell (PowerShell 7 `pwsh` is preferred; Windows PowerShell 5.1 is the fallback) and Visual Studio 2022 Build Tools with the Desktop development with C++ workload; setup leaves that skill explicitly unavailable when they are absent. /cso is optional: if its native helper fails to build or publish, setup still finishes, says which step failed with the log path and retry command, and keeps an earlier helper when it has one (`GSTACK_STRICT_BUILD=1` makes that failure fatal, as CI does). + +**Known issue: Windows Smart App Control** ([#2595](https://github.com/garrytan/gstack/issues/2595)). gstack's compiled binaries (`browse`, `find-browse`, `design`, `pdf`, `gstack-global-discover`) are built on your machine and unsigned, so Windows 11 with Smart App Control on refuses to start them; Git Bash shows `Permission denied`. setup detects this, names the blocked binaries and the skills that need them, and `gstack-doctor` reports them as `blocked`. Today's workarounds are running gstack inside WSL, or turning Smart App Control off. Details: [troubleshooting](docs/troubleshooting.md#windows-smart-app-control). The sidebar terminal uses Consolas on Windows, so its text no longer renders spaced out. On Windows without Developer Mode (MSYS2 / Git Bash), `setup` falls back to file copies instead of symlinks because `ln -snf` produces frozen copies that don't refresh on `git pull`. **Re-run `cd ~/.claude/skills/gstack && ./setup` after every `git pull`** so your skill files match the repo. `setup` prints a one-line note reminding you. Unix and WSL keep symlinks and don't need the re-run. diff --git a/TODOS.md b/TODOS.md index 672ffa7a5..1e78351ad 100644 --- a/TODOS.md +++ b/TODOS.md @@ -286,6 +286,30 @@ From the approved 2026-10 test/eval/CI audit plan; the audit PR files these. paid PR profile from the same touchfile closure so one command answers "what does my diff need?". **Effort:** M. **Priority:** P3. +### P2: Windows follow-ups from the October fix wave (PR C1) + +- **Sign the Windows binaries so Smart App Control allows them (#2595, #2124)** — + `browse`, `find-browse`, `design`, `pdf` and `gstack-global-discover` are + compiled on the user's machine with `bun build --compile`, so they are + unsigned and never gain reputation; Smart App Control blocks all five. PR C1 + only detects and explains the block (setup hint, gstack-doctor rows, + troubleshooting section). The fix is Authenticode-signed release artifacts + (for example Azure Trusted Signing) that setup downloads and verifies instead + of compiling, or a signed launcher. **Needs:** Garry's code-signing account + and certificate decision; a Windows 11 machine with Smart App Control on to + verify. **Effort:** L. **Priority:** P2. +- **Find what deletes the staged CSO files during publish on #3071's machine** — + PR C1 made the failure non-fatal, cleared the parent's EXIT trap before the + native publisher `exec`, and records the outcome, but the root cause is + unproven (one reporter; `windows-latest` CI publishes fine). Ask for + `bin/.gstack-cso-build.log`, `bash -x scripts/build-cso.sh` and a Process + Monitor trace of the stage directory. **Effort:** M (needs the reporter's + machine). **Priority:** P3. +- **Verify the Smart App Control error text on a real SAC machine** — the + launch probe (`bin/gstack-launch-probe.sh`) classifies exit 126/127 and + application-control messages as blocked from reporters' captures; nobody has + run it on an enforcing machine. **Effort:** S. **Priority:** P3. + ### P2/P3: severe fix wave follow-ups (filed 2026-10-03, v1.91.19.0) Deferred from the approved severe fix wave (docs/designs/SEVERE_FIX_WAVE_2026_10.md), each with its reason. @@ -296,11 +320,15 @@ Deferred from the approved severe fix wave (docs/designs/SEVERE_FIX_WAVE_2026_10 and `--no-env-file` (verified with 1.3.2), so D0's protection against a project's `.env`/`bunfig.toml` is silently off there. Setup should refuse or warn below the floor. **Effort:** S. **Priority:** P2. -- **Windows Node CLI lane for Smart App Control (E3; #2596, #2595, #2124)** — - a `dist/browse` wrapper would shadow `browse.exe` for every Windows user; - launcher precedence (browse vs browse.exe vs browse.cmd under Git Bash and - cmd) and the node-missing message need a Windows SAC machine to verify. - **Effort:** M. **Priority:** P2. +- ~~**Windows Node CLI lane for Smart App Control (E3; #2596, #2595, #2124)**~~ — + superseded by the fix wave's PR C1: setup and gstack-doctor now detect + SAC-blocked binaries, and the fix wave decided against a launcher or shim + (it cannot be called from PowerShell, and a `bun run` shim would load the + caller's project `.env`/`bunfig.toml`). Signing is the follow-up below. + Original note: — a `dist/browse` wrapper would shadow `browse.exe` for every + Windows user; launcher precedence (browse vs browse.exe vs browse.cmd under + Git Bash and cmd) and the node-missing message need a Windows SAC machine to + verify. **Effort:** M. **Priority:** P2. - **C8 router wording behind a routing panel** — disabled skills now leave the router, but the "When in doubt, invoke the skill" wording change (Tier 3) was not run through a routing eval panel. **Effort:** S. **Priority:** P3. diff --git a/VERSION b/VERSION index db62d22ce..6837f2329 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.59.0 +1.91.60.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index cd91c5392..fb752c3d5 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.59.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.60.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-doctor b/bin/gstack-doctor index fc7141d99..065c96bc0 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,50 @@ 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 ;; + 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 (it names any missing build prerequisite)"; fi ;; + esac +} + check_claude() { local override cmd raw ver rc a local args=() @@ -376,6 +434,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 +447,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-global-discover.ts b/bin/gstack-global-discover.ts index 8e66abbde..03cc1a572 100644 --- a/bin/gstack-global-discover.ts +++ b/bin/gstack-global-discover.ts @@ -10,7 +10,7 @@ */ import { existsSync, readdirSync, statSync, readFileSync, openSync, readSync, closeSync } from "fs"; -import { join, basename } from "path"; +import { join, basename, dirname } from "path"; import { execSync } from "child_process"; import { homedir } from "os"; import { canonicalRemote } from "../lib/remote-identity"; @@ -50,12 +50,24 @@ function printUsage(): void { --since Time window: e.g. 7d, 14d, 30d, 24h --format Output format: json (default) or summary --help Show this help + --version Print the gstack version and exit (no scan) Examples: gstack-global-discover --since 7d gstack-global-discover --since 14d --format summary`); } +function readGstackVersion(): string { + // The compiled binary and this source file both live in bin/, one level below VERSION. + for (const dir of [dirname(process.execPath), import.meta.dir]) { + try { + const version = readFileSync(join(dir, "..", "VERSION"), "utf8").trim(); + if (version) return version; + } catch {} + } + return "unknown"; +} + function parseArgs(): { since: string; format: "json" | "summary" } { const args = process.argv.slice(2); let since = ""; @@ -65,6 +77,10 @@ function parseArgs(): { since: string; format: "json" | "summary" } { if (args[i] === "--help" || args[i] === "-h") { printUsage(); process.exit(0); + } else if (args[i] === "--version") { + // setup's and gstack-doctor's launch probe (#2595): exits before any scan. + console.log(readGstackVersion()); + process.exit(0); } else if (args[i] === "--since" && args[i + 1]) { since = args[++i]; } else if (args[i] === "--format" && args[i + 1]) { 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/browse/src/cli.ts b/browse/src/cli.ts index d4ddee1d8..cbb55ad47 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -1715,6 +1715,12 @@ export function withLoopbackNoProxy(env: Record): st } async function main() { + // setup's and gstack-doctor's launch probe: proves the binary starts, with no + // server, flags or network (Smart App Control detection, #2595). + if (process.argv[2] === '--version') { + console.log(readVersionHash() ?? 'unknown'); + process.exit(0); + } process.env.NO_PROXY = process.env.no_proxy = withLoopbackNoProxy(process.env); const rawArgs = process.argv.slice(2); diff --git a/browse/src/find-browse.ts b/browse/src/find-browse.ts index 77f7526ec..cdfcaef92 100644 --- a/browse/src/find-browse.ts +++ b/browse/src/find-browse.ts @@ -5,8 +5,8 @@ * Outputs the absolute path to the browse binary on stdout, or exits 1 if not found. */ -import { accessSync, constants } from 'fs'; -import { join } from 'path'; +import { accessSync, constants, readFileSync } from 'fs'; +import { dirname, join } from 'path'; import { homedir } from 'os'; // ─── Binary Discovery ─────────────────────────────────────────── @@ -91,6 +91,13 @@ export function locateBinary(): string | null { // ─── Main ─────────────────────────────────────────────────────── function main() { + // setup's and gstack-doctor's launch probe (#2595): no git, no discovery. + if (process.argv[2] === '--version') { + let version = 'unknown'; + try { version = readFileSync(join(dirname(process.execPath), '.version'), 'utf8').trim() || version; } catch {} + console.log(version); + process.exit(0); + } const bin = locateBinary(); if (!bin) { process.stderr.write('ERROR: browse binary not found. Run: cd && ./setup\n'); diff --git a/browse/test/sidebar-ux.test.ts b/browse/test/sidebar-ux.test.ts index 65c55fc30..c92c6b857 100644 --- a/browse/test/sidebar-ux.test.ts +++ b/browse/test/sidebar-ux.test.ts @@ -983,3 +983,24 @@ describe('cookie import button (sidebar)', () => { expect(js).toContain('cookie-picker'); }); }); + +// #2287 (@tomfluff): xterm sizes its cell from the first font that resolves. +// On Windows none of the Mac/Linux monospace fonts exist, so Malgun Gothic won +// and every Latin glyph sat in a double-wide cell. Consolas ships with every +// Windows install and must come before the CJK fallbacks in both stacks. +describe('sidebar mono font stacks put a Windows Latin monospace before the CJK fallbacks (#2287)', () => { + const stacks: Record = { + 'sidepanel-terminal.js fontFamily': fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel-terminal.js'), 'utf-8').match(/fontFamily:\s*'([^']+)'/)?.[1], + 'sidepanel.css --font-mono': fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.css'), 'utf-8').match(/--font-mono:\s*([^;]+);/)?.[1], + }; + for (const [name, stack] of Object.entries(stacks)) { + test(name, () => { + expect(stack).toBeDefined(); + const fonts = stack!.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '')); + const consolas = fonts.indexOf('Consolas'); + expect(consolas).toBeGreaterThanOrEqual(0); + for (const cjk of ['Noto Sans Mono CJK KR', 'Malgun Gothic']) expect(fonts.indexOf(cjk)).toBeGreaterThan(consolas); + expect(fonts.at(-1)).toBe('monospace'); + }); + } +}); diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4761a8775..03ca88966 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -26,8 +26,9 @@ section has a stable link anchor; the reason codes and anchors come from Run `~/.claude/skills/gstack/bin/gstack-doctor` (other hosts: `./setup --status` in your gstack checkout prints the doctor's absolute path). It prints one row each for the install, state root, Bun, hooks, Codex, the cached Codex model -check, artifacts sync, the browse bundle, Claude Code, the largest session -journal and recent /autoplan guard codes. Each row is `ok`, `warn`, +check, artifacts sync, the browse bundle, the other compiled binaries, the +/cso native helper, Claude Code, the largest session journal and recent +/autoplan guard codes. Each row is `ok`, `warn`, `not configured` or `fail` with its fix; only `fail` exits non-zero. It makes no paid call: the Codex rows report the cached model check and its age, and `--live` runs that check once. Paste its output into bug reports. @@ -762,6 +763,35 @@ Visual Studio". **Fix.** Fix the printed compiler error, then re-run `./setup`. + +### `CSO unavailable: the native helper's step failed (...)` / `CSO publish step failed (...) for ; previous CSO kept: ` + +**Meaning.** The /cso native helper is optional. Its build prerequisites were +present, but the `build` step (compiling the helper) or the `publish` step +(swapping the new helper into `bin/` under a lock) failed. setup used to stop +here (#3071); now it finishes everything else and says which step failed. When +an earlier helper was installed and the failed publish restored it, setup +prints `previous CSO kept` and /cso keeps using that helper; otherwise /cso +reports `not assessed`. `interrupted` means the step was killed before it could +record a result. The outcome is in `bin/.gstack-cso-build-result`, and +`gstack-doctor`'s `cso` row reads the same record. The build output is in +`bin/.gstack-cso-build.log`. + +**Fix.** Read the log, fix what it reports, then retry from your gstack checkout: + +```bash +bun run build:cso && ./setup +``` + +On Windows, setup and the build use PowerShell 7 (`pwsh`) when it is installed +and fall back to Windows PowerShell 5.1. The root cause of the staged files +vanishing during publish in #3071 is still unknown; attach the log there if you +hit it. CI sets `GSTACK_STRICT_BUILD=1`, which makes these failures fatal so +build regressions cannot hide. + +**Expected result.** setup prints no CSO line, and the doctor's `cso` row is +`ok`. + ### `Docker found at , but native Windows Docker transport is not supported yet; static assessment only.` / `docker.exe at is outside the trusted install locations (...)` @@ -927,6 +957,45 @@ killed. --- +## Windows + + +### `Windows blocked compiled gstack binaries at launch (Smart App Control or another application-control policy, #2595)` / `bash: .../browse.exe: Permission denied` + +**Meaning.** Known issue (#2595, #2124). gstack compiles five binaries on your +machine with Bun: `browse`, `find-browse`, `design`, `pdf` and +`gstack-global-discover`. They are unsigned, and a binary built on one machine +never earns the reputation Smart App Control accepts instead of a signature, so +Windows 11 with Smart App Control on refuses to start them. Git Bash reports +that as `Permission denied`, which looks like a file-permission problem but is +code integrity (PowerShell says `An Application Control policy has blocked this +file`). setup now runs each binary's `--version` and names the blocked ones and +the skills that need them: the gstack browser fallback (`/browse`, `/qa`, +`/qa-only`, `/design-review`, `/canary`, `/benchmark`, `/pair-agent`, +`/scrape`, `/make-pdf`), the design binary (`/design-consultation`, +`/design-shotgun`, `/design-html`, `/plan-design-review`) and `/retro global`. +Every other skill works. `gstack-doctor` shows the same state in its +`browse bundle` and `binaries` rows. The exact Windows error text has not been +verified on a Smart App Control machine; setup prints the raw first line beside +its classification. + +**Fix.** There is no per-file allowlist for Smart App Control. Today's options: + +- Run gstack inside WSL (`wsl --install`, then install gstack in the Linux + distro). WSL runs gstack's Linux build, which Smart App Control does not check. +- Or turn Smart App Control off in Windows Security > App & browser control > + Smart App Control settings. On Windows 11 with the April 2026 update you can + turn it back on later without reinstalling Windows; on older builds turning it + off is permanent. gstack's binaries stay blocked whenever it is on. + +Signed release binaries are the real fix and are tracked in `TODOS.md`. After +Windows allows the binaries, re-run `./setup`; the message goes away. + +**Expected result.** setup prints no "Windows blocked" line, and +`gstack-doctor` shows `ok` for `browse bundle` and `binaries`. + +--- + ## Memory and gbrain Every message below is also in [docs/gbrain-sync-errors.md](gbrain-sync-errors.md) diff --git a/extension/sidepanel-terminal.js b/extension/sidepanel-terminal.js index 9ffb03115..56982e58f 100644 --- a/extension/sidepanel-terminal.js +++ b/extension/sidepanel-terminal.js @@ -391,7 +391,13 @@ function ensureXterm() { if (term) return; term = new Terminal({ - fontFamily: '"JetBrains Mono", "SF Mono", Menlo, "Noto Sans Mono CJK KR", "Malgun Gothic", monospace', + // Windows Latin monospace (Consolas, always present; Cascadia ships with Terminal/VS) + // MUST precede the CJK fallbacks: xterm sizes the character cell from the first + // resolved font's advance width, and Malgun Gothic (the Windows Korean UI font, the + // first that resolves here when the Mac/Linux fonts are absent) advances Latin glyphs + // in a full-width CJK cell — doubling every cell into spaced-out text. The CJK fonts + // stay last for actual CJK glyph coverage. + fontFamily: '"JetBrains Mono", "SF Mono", "Cascadia Mono", "Cascadia Code", Consolas, Menlo, "DejaVu Sans Mono", "Noto Sans Mono CJK KR", "Malgun Gothic", monospace', fontSize: 13, theme: { background: '#0a0a0a', foreground: '#e5e5e5' }, cursorBlink: true, diff --git a/extension/sidepanel.css b/extension/sidepanel.css index 3833857c1..c7dbd6431 100644 --- a/extension/sidepanel.css +++ b/extension/sidepanel.css @@ -38,7 +38,9 @@ /* Typography */ --font-system: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; - --font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', 'Noto Sans Mono CJK KR', 'Malgun Gothic', monospace; + /* Consolas (always on Windows) before the CJK fallbacks — else Latin text resolves to + Malgun Gothic and renders full-width/spaced-out. Same fix as the xterm fontFamily. */ + --font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Mono', 'Cascadia Code', Consolas, 'DejaVu Sans Mono', 'Noto Sans Mono CJK KR', 'Malgun Gothic', monospace; /* Radius */ --radius-sm: 4px; diff --git a/make-pdf/src/cli.ts b/make-pdf/src/cli.ts index ec6d3b7d2..ab7fdbc84 100644 --- a/make-pdf/src/cli.ts +++ b/make-pdf/src/cli.ts @@ -40,6 +40,8 @@ export function parseArgs(argv: string[]): ParsedArgs { printUsage(); process.exit(ExitCode.Success); } + // setup's and gstack-doctor's launch probe (#2595): the version command needs no browser. + if (args[0] === "--version") return { command: "version", positional: [], flags: {} }; // First non-flag arg is the command. let command = ""; diff --git a/package.json b/package.json index 669769dec..6156f4bfa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.59", + "version": "1.91.60", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/scripts/build-cso.sh b/scripts/build-cso.sh index e0d1084d6..b670f76a3 100644 --- a/scripts/build-cso.sh +++ b/scripts/build-cso.sh @@ -15,6 +15,12 @@ CSO_FINAL_CORE="$CSO_BUILD_ROOT/bin/gstack-cso-core$CSO_EXE" CSO_FINAL_LAUNCHER="$CSO_BUILD_ROOT/bin/gstack-cso-launcher$CSO_EXE" CSO_FINAL_WATCHDOG="$CSO_BUILD_ROOT/bin/gstack-cso-watchdog" CSO_FINAL_GENERATION="$CSO_BUILD_ROOT/bin/.gstack-cso-generation" +# Build-result record read by setup's summary and gstack-doctor (#3071). +CSO_RESULT_FILE="$CSO_BUILD_ROOT/bin/.gstack-cso-build-result" +CSO_RESULT_RECORDING=0 +CSO_RESULT_STAGE=build +CSO_REVISION="" +CSO_PREVIOUS_INSTALLED="" CSO_STAGE="" CSO_PUBLISHING=0 CSO_COMMITTED=0 @@ -38,6 +44,25 @@ cso_checkpoint() { if [ "${GSTACK_CSO_BUILD_TEST_KILL_AFTER:-}" = "$1" ]; then kill -KILL "$$" fi + if [ "${GSTACK_CSO_BUILD_TEST_REMOVE_LOCKER_AFTER:-}" = "$1" ]; then + rm -f "$CSO_STAGE_LOCKER" + fi +} + +cso_record_field() { sed -n "s/^$1=//p" "$CSO_RESULT_FILE" 2>/dev/null | head -1; } + +# One record per run: result (ok/failed), stage (build/publish), reason, this +# checkout's revision, the revision of the CSO generation still installed, and +# whether that launcher is usable. A SIGKILL leaves the last "interrupted" entry. +cso_write_result() { + launcher=no + if cso_valid_artifact "$CSO_FINAL_LAUNCHER" && cso_valid_artifact "$CSO_FINAL_CORE"; then launcher=yes; fi + installed="" + if [ "$1" = ok ]; then installed=$CSO_REVISION + elif [ "$launcher" = yes ]; then installed=${CSO_PREVIOUS_INSTALLED:-an earlier build}; fi + # Written in place: publication's signal tests count on mv moving artifacts only. + printf 'result=%s\nstage=%s\nreason=%s\nrevision=%s\ninstalled=%s\nlauncher=%s\ndiagnostic=%s\n' \ + "$1" "$2" "$3" "$CSO_REVISION" "$installed" "$launcher" "${GSTACK_CSO_BUILD_LOG:-the scripts/build-cso.sh output}" > "$CSO_RESULT_FILE" || true } cso_restore_previous() { @@ -89,6 +114,10 @@ cso_cleanup() { fi if [ "$cleanup_stage" -eq 1 ] && [ -n "$CSO_STAGE" ] && [ ! -f "$CSO_STAGE/.retain-recovery" ]; then rm -rf "$CSO_STAGE"; fi if [ -n "$CSO_EVALUATION_UNIT" ]; then rm -rf "$CSO_EVALUATION_UNIT"; fi + if [ "$CSO_RESULT_RECORDING" -eq 1 ]; then + if [ "$status" -eq 0 ] && [ "$CSO_COMMITTED" -eq 1 ]; then cso_write_result ok publish committed + else cso_write_result failed "$CSO_RESULT_STAGE" "exit $status"; fi + fi exit "$status" } trap 'cso_cleanup $?' EXIT @@ -281,8 +310,18 @@ cso_publish_locked() { CSO_COMMITTED=1 } +cso_start_result() { + CSO_RESULT_RECORDING=1 + CSO_RESULT_STAGE=$1 + CSO_REVISION="$(head -1 "$CSO_BUILD_ROOT/VERSION" 2>/dev/null | tr -cd '0-9A-Za-z.+-')" + revision_sha="$(git -C "$CSO_BUILD_ROOT" rev-parse --short=12 HEAD 2>/dev/null | tr -cd '0-9a-f')" + CSO_REVISION="${CSO_REVISION:-unknown}${revision_sha:+ ($revision_sha)}" + CSO_PREVIOUS_INSTALLED="$(cso_record_field installed)" +} + if [ "${1:-}" = __publish_locked ];then shift + cso_start_result publish cso_publish_locked "$@" exit 0 fi @@ -291,6 +330,8 @@ if [ "${1:-}" = --evaluation-candidate ];then exit 0 fi [ "$#" -eq 0 ] || { echo 'Usage: build-cso.sh [--evaluation-candidate --output ]' >&2;exit 64; } +cso_start_result build +cso_write_result failed build interrupted # Distribution builds embed only the committed catalog, never an evaluation one. if [ -f lib/cso/runtime-catalog.json ] && grep -q '"revision": *"cso-eval-' lib/cso/runtime-catalog.json;then echo 'lib/cso/runtime-catalog.json is an evaluation-only catalog; distribution builds refuse it. Restore the committed catalog.' >&2 @@ -339,12 +380,14 @@ CSO_CORE_SHA256="$(cso_sha256 "$CSO_STAGE_CORE")" printf '%s\n' "$CSO_CORE_SHA256" > "$CSO_STAGE_GENERATION" if [ -n "$CSO_EXE" ];then - if ! command -v powershell.exe >/dev/null 2>&1||! command -v cygpath >/dev/null 2>&1;then - echo 'CSO Windows build requires Git Bash and Windows PowerShell with MSVC Build Tools.' >&2;exit 1 + # PowerShell 7 first: Windows PowerShell 5.1 can crash outright (#3071). + CSO_POWERSHELL="$(command -v pwsh 2>/dev/null || command -v powershell.exe 2>/dev/null || true)" + if [ -z "$CSO_POWERSHELL" ]||! command -v cygpath >/dev/null 2>&1;then + echo 'CSO Windows build requires Git Bash and PowerShell (pwsh or Windows PowerShell) with MSVC Build Tools.' >&2;exit 1 fi CSO_WINDOWS_GIT="$(type -P git 2>/dev/null || true)" [ -n "$CSO_WINDOWS_GIT" ] && [ -f "$CSO_WINDOWS_GIT" ] || { echo 'CSO Windows build requires the Git for Windows git.exe selected by Git Bash.' >&2;exit 1; } - powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass \ + "$CSO_POWERSHELL" -NoProfile -NonInteractive -ExecutionPolicy Bypass \ -File "$(cygpath -w "$CSO_BUILD_ROOT/scripts/build-cso-windows.ps1")" \ -RepoRoot "$(cygpath -w "$CSO_BUILD_ROOT")" \ -OutputPath "$(cygpath -w "$CSO_STAGE_LAUNCHER")" \ @@ -376,4 +419,18 @@ if [ -n "$CSO_EXE" ];then else case "${BASH:-}" in /*) CSO_PUBLISH_SHELL=$BASH;; *) echo 'CSO publication requires an absolute Bash executable.' >&2;exit 1;;esac fi +cso_valid_artifact "$CSO_STAGE_LOCKER" || { echo 'Staged CSO publisher lock is not one executable regular file.' >&2; exit 1; } +CSO_RESULT_STAGE=publish +cso_write_result failed publish interrupted +cso_checkpoint before-exec +# From here the locked child owns the stage: its own EXIT trap restores and +# cleans it. Git Bash's exec of a native program may not replace this shell, +# so this trap must not be armed under the running child (#3071). If the exec +# itself fails, clean up here. +set +e +shopt -s execfail +trap - EXIT exec "$CSO_STAGE_LOCKER" "$CSO_BUILD_ROOT/bin" "$CSO_PUBLISH_SHELL" "$CSO_BUILD_ROOT/scripts/build-cso.sh" __publish_locked "$CSO_STAGE" +cso_exec_status=$? +echo "CSO publication could not start $CSO_STAGE_LOCKER (exit $cso_exec_status)." >&2 +cso_cleanup "$cso_exec_status" diff --git a/scripts/build.sh b/scripts/build.sh index bd34f55d3..00788b51d 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -10,7 +10,9 @@ BUILD_STAMP="$ROOT/browse/dist/.build-complete" BUILD_STAMP_TMP="$BUILD_STAMP.tmp.$$" # Setup trusts this stamp as proof that the selected multi-binary build completed. -# Ordinary/direct builds include CSO and remain strict. Setup may explicitly omit +# CSO is optional: a failed CSO build or publish is reported and recorded in +# bin/.gstack-cso-build-result, and the rest of the build still completes (#3071). +# GSTACK_STRICT_BUILD=1 (CI) makes a CSO failure fatal. Setup may explicitly omit # CSO after its host capability probe, while still publishing the general build. # Invalidate before touching output so an interrupted build cannot hide staleness. rm -f "$BUILD_STAMP" "$BUILD_STAMP_TMP" @@ -47,7 +49,15 @@ if [ "${GSTACK_SETUP_RUNNING:-0}" = "1" ] && [ "${GSTACK_SETUP_SKIP_CSO_BUILD:-0 bin/gstack-cso-core bin/gstack-cso-core.exe bin/gstack-cso-watchdog \ bin/.gstack-cso-generation bin/.gstack-cso-generation.lock else - BUN_CMD="$BUN_CMD" bash scripts/build-cso.sh + CSO_BUILD_LOG_FILE="$ROOT/bin/.gstack-cso-build.log" + if ! (set -o pipefail; GSTACK_CSO_BUILD_LOG="$CSO_BUILD_LOG_FILE" BUN_CMD="$BUN_CMD" bash scripts/build-cso.sh 2>&1 | tee "$CSO_BUILD_LOG_FILE"); then + cso_stage="$(sed -n 's/^stage=//p' bin/.gstack-cso-build-result 2>/dev/null | head -1)" + if [ "${GSTACK_STRICT_BUILD:-0}" = "1" ]; then + echo "CSO ${cso_stage:-build} failed and GSTACK_STRICT_BUILD=1 makes it fatal. Log: $CSO_BUILD_LOG_FILE" >&2 + exit 1 + fi + echo "CSO unavailable: the ${cso_stage:-build} step failed; the rest of the build continues. Log: $CSO_BUILD_LOG_FILE. Retry: cd $ROOT && bun run build:cso" >&2 + fi fi bash browse/scripts/build-node-server.sh bash scripts/write-version-files.sh browse/dist/.version design/dist/.version make-pdf/dist/.version diff --git a/setup b/setup index adfe5ece0..eb538e214 100755 --- a/setup +++ b/setup @@ -1563,10 +1563,11 @@ _cso_unavailable() { # CSO has a native startup boundary. Its extra toolchain is optional for setup: # when unavailable, install every other skill and leave /cso visibly fail-closed. -# A successful probe is only a prerequisite check; any later source build failure -# still aborts setup and therefore cannot be mistaken for a missing local tool. +# A successful probe is only a prerequisite check. A later build or publish +# failure is recorded by scripts/build-cso.sh and reported by stage, not as a +# missing tool; only GSTACK_STRICT_BUILD=1 makes it abort setup (#3071). probe_cso_build_prerequisites() { - local _help _flag _platform _compiler _tmp _ps_script _repo_root _git_path _probe_out + local _help _flag _platform _compiler _tmp _ps _ps_script _repo_root _git_path _probe_out _probe_rc CSO_BUILD_AVAILABLE=1 CSO_FAIL_REASON="" CSO_PROBE_DETAIL="" @@ -1585,7 +1586,9 @@ probe_cso_build_prerequisites() { _platform="$(uname -s)" if [ "$IS_WINDOWS" -eq 1 ]; then - if ! command -v powershell.exe >/dev/null 2>&1 || ! command -v cygpath >/dev/null 2>&1; then + # #3071: PowerShell 7 first; Windows PowerShell 5.1 can crash outright. + _ps="$(command -v pwsh 2>/dev/null || command -v powershell.exe 2>/dev/null || true)" + if [ -z "$_ps" ] || ! command -v cygpath >/dev/null 2>&1; then _cso_unavailable "windows-shell-toolchain" return 0 fi @@ -1598,13 +1601,20 @@ probe_cso_build_prerequisites() { fi # E2 (#3015): a probe that found MSVC but could not compile is not a # missing toolchain; keep the probe's own first diagnostic for the summary. - if ! _probe_out="$(powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass \ - -File "$_ps_script" -RepoRoot "$_repo_root" -GitExePath "$(cygpath -aw "$_git_path")" -CheckOnly 2>&1)"; then + # #3071: neither is a probe that failed for another reason (a crashed + # PowerShell); keep its exit status and first output line. + _probe_rc=0 + _probe_out="$("$_ps" -NoProfile -NonInteractive -ExecutionPolicy Bypass \ + -File "$_ps_script" -RepoRoot "$_repo_root" -GitExePath "$(cygpath -aw "$_git_path")" -CheckOnly 2>&1)" || _probe_rc=$? + if [ "$_probe_rc" -ne 0 ]; then case "$_probe_out" in *"compiler probe failed"*|*"compiler probe was not produced"*) _cso_unavailable "windows-msvc-compile" CSO_PROBE_DETAIL="$(printf '%s\n' "$_probe_out" | grep -m1 -E 'error [A-Z]*[0-9]+|probe (failed|was not produced)' | tr -d '\r' | head -c 200)" ;; - *) _cso_unavailable "windows-msvc-toolchain" ;; + *"requires Visual Studio 2022"*) _cso_unavailable "windows-msvc-toolchain" ;; + *) + _cso_unavailable "windows-toolchain-probe" + CSO_PROBE_DETAIL="${_ps##*/} exited $_probe_rc: $(printf '%s\n' "$_probe_out" | tr -d '\r' | grep -m1 -v '^[[:space:]]*$' | head -c 200)" ;; esac fi return 0 @@ -1688,8 +1698,11 @@ _EXE="" if [ "$IS_WINDOWS" -eq 1 ]; then _EXE=".exe"; fi # Never leave a trusted helper from an earlier toolchain/version available when # this setup run cannot reproduce it. The /cso skill then reports not assessed. +# An earlier build's result record no longer describes the removed helper; +# setup never writes into the source checkout when nothing is built. if [ "$CSO_BUILD_AVAILABLE" -eq 0 ]; then - rm -f "$SOURCE_GSTACK_DIR/bin/gstack-cso-launcher" \ + rm -f "$SOURCE_GSTACK_DIR/bin/.gstack-cso-build-result" \ + "$SOURCE_GSTACK_DIR/bin/gstack-cso-launcher" \ "$SOURCE_GSTACK_DIR/bin/gstack-cso-launcher.exe" \ "$SOURCE_GSTACK_DIR/bin/gstack-cso-core" \ "$SOURCE_GSTACK_DIR/bin/gstack-cso-core.exe" \ @@ -1827,11 +1840,20 @@ if [ "$NEEDS_BUILD" -eq 1 ]; then fi fi +# A failed CSO build or publish leaves an incomplete set; the summary reports +# it from the build-result record. Fatal only under GSTACK_STRICT_BUILD=1 (#3071). +_CSO_READY=0 if [ "$CSO_BUILD_AVAILABLE" -eq 1 ]; then if [ ! -x "$SOURCE_GSTACK_DIR/bin/gstack-cso-launcher$_EXE" ] || [ ! -x "$SOURCE_GSTACK_DIR/bin/gstack-cso-core$_EXE" ] || [ ! -f "$SOURCE_GSTACK_DIR/bin/.gstack-cso-generation" ] || { [ "$IS_WINDOWS" -eq 0 ] && [ ! -x "$SOURCE_GSTACK_DIR/bin/gstack-cso-watchdog" ]; } || { [ "$IS_WINDOWS" -eq 1 ] && [ ! -f "$SOURCE_GSTACK_DIR/bin/.gstack-cso-generation.lock" ]; }; then - echo "gstack setup failed: CSO build completed without its required native artifact set" >&2 - exit 1 + if [ "${GSTACK_STRICT_BUILD:-0}" = "1" ]; then + echo "gstack setup failed: CSO build completed without its required native artifact set (GSTACK_STRICT_BUILD=1)" >&2 + exit 1 + fi + else + _CSO_READY=1 fi +fi +if [ "$_CSO_READY" -eq 1 ]; then # Evaluation-only helpers embed an unqualified cso-eval-* catalog for the # private evaluator. Setup never installs one, whether marked or copied bare. if [ -e "$SOURCE_GSTACK_DIR/bin/.gstack-cso-evaluation" ] || "$SOURCE_GSTACK_DIR/bin/gstack-cso-launcher$_EXE" --version 2>/dev/null | grep -q '"evaluationOnly": *true'; then @@ -4388,15 +4410,20 @@ fi _setup_print_summary # ─── CSO native-helper summary ──────────────────────────────────────────────── +# A missing prerequisite comes from this run's probe. A build or publish +# failure comes from bin/.gstack-cso-build-result, which scripts/build-cso.sh +# writes and gstack-doctor also reads. +_cso_field() { sed -n "s/^$1=//p" "$SOURCE_GSTACK_DIR/bin/.gstack-cso-build-result" 2>/dev/null | head -1; } if [ "$CSO_BUILD_AVAILABLE" -eq 0 ]; then case "$CSO_FAIL_REASON" in bun-compile-flags) _CSO_PREREQ="upgrade Bun to a release supporting all four --no-compile-autoload-* build flags" ;; c-compiler) _CSO_PREREQ="install a C compiler (cc, Clang, or GCC)" ;; static-c-toolchain) _CSO_PREREQ="install a C toolchain capable of static linking" ;; macos-codesign|macos-native-toolchain) _CSO_PREREQ="install the macOS compiler and codesign command-line tools" ;; - windows-shell-toolchain) _CSO_PREREQ="run setup from Git Bash with Windows PowerShell available" ;; + windows-shell-toolchain) _CSO_PREREQ="run setup from Git Bash with PowerShell (pwsh or Windows PowerShell) available" ;; windows-msvc-toolchain) _CSO_PREREQ="install Visual Studio 2022 Build Tools with Desktop development with C++" ;; windows-msvc-compile) _CSO_PREREQ="fix the MSVC compile error its toolchain probe reported (${CSO_PROBE_DETAIL:-see scripts/build-cso-windows.ps1 -CheckOnly}); Visual Studio is already installed" ;; + windows-toolchain-probe) _CSO_PREREQ="fix the PowerShell toolchain probe failure (${CSO_PROBE_DETAIL:-no output}); run scripts/build-cso-windows.ps1 -CheckOnly from pwsh to see it" ;; windows-git) _CSO_PREREQ="install Git for Windows and run setup from its Git Bash" ;; unsupported-platform) _CSO_PREREQ="use a supported macOS, Linux, or Windows host" ;; skipped-by-request) _CSO_PREREQ="re-run without GSTACK_SETUP_SKIP_CSO_BUILD=1" ;; @@ -4404,8 +4431,67 @@ if [ "$CSO_BUILD_AVAILABLE" -eq 0 ]; then esac log "" log "CSO unavailable: its native helper was not built ($CSO_FAIL_REASON)." - log " /cso will report not assessed and the install prerequisite; it will not use repository tooling." + log " Failed stage: probe. /cso will report not assessed and the install prerequisite; it will not use repository tooling." log " Everything else is installed and works. To enable /cso, $_CSO_PREREQ, then re-run ./setup." +elif [ "$(_cso_field result)" = "failed" ]; then + _CSO_STAGE="$(_cso_field stage)" + _CSO_INSTALLED="$(_cso_field installed)" + _CSO_WHAT="failed ($(_cso_field reason))" + [ "$(_cso_field reason)" != "interrupted" ] || _CSO_WHAT="was interrupted" + log "" + if [ -x "$SOURCE_GSTACK_DIR/bin/gstack-cso-launcher$_EXE" ] && [ -x "$SOURCE_GSTACK_DIR/bin/gstack-cso-core$_EXE" ] && [ -n "$_CSO_INSTALLED" ]; then + log "CSO $_CSO_STAGE step $_CSO_WHAT for $(_cso_field revision); previous CSO kept: $_CSO_INSTALLED." + log " /cso keeps using that helper until a rebuild succeeds." + else + log "CSO unavailable: the native helper's $_CSO_STAGE step $_CSO_WHAT, so /cso will report not assessed." + fi + log " Diagnostic: $(_cso_field diagnostic)" + log " Everything else is installed and works. Retry: cd $SOURCE_GSTACK_DIR && bun run build:cso && ./setup" + 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 < { for (const t of tmps.splice(0)) fs.rmSync(t, { recursive: true, force: true }); }); + +function runVersion(entry: string) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-version-probe-')); + tmps.push(tmp); + const home = path.join(tmp, 'home'); + fs.mkdirSync(home); + const r = spawnSync(process.execPath, ['run', path.join(ROOT, entry), '--version'], { + cwd: tmp, + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + BROWSE_STATE_FILE: path.join(tmp, 'state', '.gstack', 'browse.json'), + GSTACK_STATE_ROOT: path.join(tmp, 'gstack-state'), + HTTPS_PROXY: 'http://127.0.0.1:9', + HTTP_PROXY: 'http://127.0.0.1:9', + }, + encoding: 'utf8', + timeout: 30_000, + }); + return { ...r, tmp, home }; +} + +describe('compiled-binary entries answer --version early (#2595)', () => { + for (const entry of ['browse/src/cli.ts', 'browse/src/find-browse.ts', 'design/src/cli.ts', 'make-pdf/src/cli.ts', 'bin/gstack-global-discover.ts']) { + test(`${entry} --version exits 0 with one line and no side effects`, () => { + const r = runVersion(entry); + expect(r.status).toBe(0); + expect(r.stdout.trim().split('\n')).toHaveLength(1); + expect(r.stdout.trim()).not.toBe(''); + expect(r.stderr).not.toContain('Starting server'); + expect(r.stderr).not.toMatch(/Unknown|Usage/); + expect(fs.existsSync(path.join(r.tmp, 'state'))).toBe(false); + expect(fs.existsSync(path.join(r.tmp, 'gstack-state'))).toBe(false); + // `bun run` itself may create its runtime cache under the home; the entry writes nothing. + expect(fs.readdirSync(r.home).filter(name => name !== '.bun' && name !== 'AppData')).toEqual([]); + }); + } + + test('find-browse --version does not search for browse', () => { + expect(runVersion('browse/src/find-browse.ts').stdout).not.toContain('browse/dist/browse'); + }); + + test('gstack-global-discover --version prints the checkout VERSION', () => { + const version = fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf8').trim(); + expect(runVersion('bin/gstack-global-discover.ts').stdout.trim()).toBe(version); + }); +}); diff --git a/test/build-cso-nonfatal.test.ts b/test/build-cso-nonfatal.test.ts new file mode 100644 index 000000000..ceeea31fe --- /dev/null +++ b/test/build-cso-nonfatal.test.ts @@ -0,0 +1,76 @@ +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'; + +// #3071: CSO is optional, so a failed CSO build or publish must not abort +// scripts/build.sh before the browse server bundle and the completion stamp. +// GSTACK_STRICT_BUILD=1 (CI) keeps it fatal. Runs build.sh's real CSO block +// with scripts/build-cso.sh replaced by a stub that fails at a chosen stage. +const ROOT = path.resolve(import.meta.dir, '..'); +const BUILD = fs.readFileSync(path.join(ROOT, 'scripts/build.sh'), 'utf8'); +const START = 'if [ "${GSTACK_SETUP_RUNNING:-0}" = "1" ] && [ "${GSTACK_SETUP_SKIP_CSO_BUILD:-0}" = "1" ]; then'; +const END = '\nbash browse/scripts/build-node-server.sh'; +const block = BUILD.slice(BUILD.indexOf(START), BUILD.indexOf(END)); +const tmps: string[] = []; +afterEach(() => { for (const t of tmps.splice(0)) fs.rmSync(t, { recursive: true, force: true }); }); + +function run(strict: string | undefined, status = 1) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-build-cso-nonfatal-')); + tmps.push(dir); + fs.mkdirSync(path.join(dir, 'bin')); + fs.mkdirSync(path.join(dir, 'scripts')); + fs.writeFileSync(path.join(dir, 'scripts/build-cso.sh'), [ + 'echo "stub CSO output (log=$GSTACK_CSO_BUILD_LOG)"', + 'echo "Staged CSO core is not one executable regular file." >&2', + "printf 'result=failed\\nstage=publish\\n' > bin/.gstack-cso-build-result", + `exit ${status}`, '', + ].join('\n')); + const env: Record = { PATH: process.env.PATH ?? '' }; + if (strict !== undefined) env.GSTACK_STRICT_BUILD = strict; + const r = runBashScript(`set -e\nROOT=${JSON.stringify(dir)}\nBUN_CMD=bun\n${block}\necho BUILD-CONTINUED`, { cwd: dir, env, timeout: 15_000 }); + return { ...r, dir, log: path.join(dir, 'bin/.gstack-cso-build.log') }; +} + +// The block runs under any bash; Windows paths in the messages would only add +// quoting noise, and the Windows lane covers this in windows-setup-e2e. +describe.skipIf(process.platform === 'win32')('scripts/build.sh: CSO failure is non-fatal unless GSTACK_STRICT_BUILD=1 (#3071)', () => { + test('a CSO publish failure is reported with its stage, log and retry, and the build continues', () => { + const r = run(undefined); + expect(r.status).toBe(0); + expect(r.stdout).toContain('BUILD-CONTINUED'); + expect(r.stderr).toContain('CSO unavailable: the publish step failed; the rest of the build continues.'); + expect(r.stderr).toContain(`Log: ${r.log}`); + expect(r.stderr).toContain(`Retry: cd ${r.dir} && bun run build:cso`); + const log = fs.readFileSync(r.log, 'utf8'); + expect(log).toContain(`stub CSO output (log=${r.log})`); + expect(log).toContain('Staged CSO core is not one executable regular file.'); + }); + + test('GSTACK_STRICT_BUILD=0 is the same non-fatal path', () => { + expect(run('0').status).toBe(0); + }); + + test('GSTACK_STRICT_BUILD=1 makes the same failure fatal', () => { + const r = run('1'); + expect(r.status).toBe(1); + expect(r.stdout).not.toContain('BUILD-CONTINUED'); + expect(r.stderr).toContain('CSO publish failed and GSTACK_STRICT_BUILD=1 makes it fatal.'); + }); + + test('a successful CSO build prints no unavailable line', () => { + const r = run('1', 0); + expect(r.status).toBe(0); + expect(r.stderr).not.toContain('CSO unavailable'); + }); + +}); + +describe('scripts/build.sh: CSO step order (#3071)', () => { + test('the server bundle and stamp still come after the CSO step', () => { + const cso = BUILD.indexOf('bash scripts/build-cso.sh'); + expect(BUILD.indexOf('bash browse/scripts/build-node-server.sh')).toBeGreaterThan(cso); + expect(BUILD.indexOf('mv -f "$BUILD_STAMP_TMP" "$BUILD_STAMP"')).toBeGreaterThan(cso); + }); +}); diff --git a/test/build-cso-result-record.test.ts b/test/build-cso-result-record.test.ts new file mode 100644 index 000000000..86a81821b --- /dev/null +++ b/test/build-cso-result-record.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { runBashScript } from './helpers/bash-script'; + +// #3071: scripts/build-cso.sh records each run's outcome in +// bin/.gstack-cso-build-result for setup's summary and gstack-doctor, and the +// parent clears its EXIT trap before exec'ing the native publisher so only the +// locked child cleans the stage. The real script runs; only compilation and +// the publisher lock are substituted (same seam as test/cso-distribution.test.ts). +const ROOT = resolve(import.meta.dir, '..'); +const temps: string[] = []; +afterEach(() => { for (const dir of temps.splice(0)) rmSync(dir, { recursive: true, force: true }); }); +const quote = (s: string) => "'" + s.replaceAll("'", "'\\''") + "'"; + +function fixture() { + const dir = mkdtempSync(join(tmpdir(), 'gstack-cso-result-')); temps.push(dir); + for (const sub of ['scripts', 'bin', 'lib/cso']) mkdirSync(join(dir, sub), { recursive: true }); + copyFileSync(join(ROOT, 'scripts/build-cso.sh'), join(dir, 'scripts/build-cso.sh')); + writeFileSync(join(dir, 'VERSION'), '9.9.9.9\n'); + writeFileSync(join(dir, 'publish lock'), '#!/bin/sh\nset -eu\nshift\nexport GSTACK_CSO_PUBLISH_LOCKED=1\nexec "$@"\n'); + chmodSync(join(dir, 'publish lock'), 0o755); + const compiler = [ + '#!/bin/sh', 'set -eu', + 'count_file="$CSO_FIXTURE/compiler.count"; count=0', + '[ ! -f "$count_file" ] || count=$(cat "$count_file")', + 'count=$((count+1)); printf "%s\\n" "$count" > "$count_file"', + 'if [ "${CSO_FAIL_COMPILER_N:-0}" = "$count" ]; then echo "fixture compiler failed" >&2; exit 42; fi', + 'output=""', + 'while [ "$#" -gt 0 ]; do case "$1" in --outfile|-o) shift; output="$1" ;; esac; shift; done', + 'if [ -n "$output" ]; then', + ' case "$output" in', + ' *gstack-cso-publish-lock*) cp "$CSO_FIXTURE/publish lock" "$output" ;;', + ' *) printf "#!/bin/sh\\nprintf NEW\\\\n\\n" > "$output" ;;', + ' esac', + ' chmod +x "$output"', + 'fi', '', + ].join('\n'); + writeFileSync(join(dir, 'compiler'), compiler); chmodSync(join(dir, 'compiler'), 0o755); + return dir; +} + +function seedOld(dir: string, installed = '9.9.9.8 (0123456789ab)') { + for (const name of ['gstack-cso-launcher', 'gstack-cso-core', 'gstack-cso-watchdog']) { + writeFileSync(join(dir, 'bin', name), '#!/bin/sh\nprintf OLD\\\\n\n'); chmodSync(join(dir, 'bin', name), 0o755); + } + writeFileSync(join(dir, 'bin', '.gstack-cso-generation'), '0'.repeat(64) + '\n'); + writeFileSync(join(dir, 'bin', '.gstack-cso-build-result'), `result=ok\nstage=publish\nreason=committed\nrevision=${installed}\ninstalled=${installed}\nlauncher=yes\ndiagnostic=old\n`); +} + +function build(dir: string, env = '') { + return runBashScript(`${env} GIT_CEILING_DIRECTORIES=${quote(dirname(dir))} CSO_FIXTURE=${quote(dir)} BUN_CMD=${quote(join(dir, 'compiler'))} CSO_CC=${quote(join(dir, 'compiler'))} bash ${quote(join(dir, 'scripts/build-cso.sh'))}`, { timeout: 15_000 }); +} + +function record(dir: string): Record { + const out: Record = {}; + for (const line of readFileSync(join(dir, 'bin', '.gstack-cso-build-result'), 'utf8').trim().split('\n')) { + const at = line.indexOf('='); + out[line.slice(0, at)] = line.slice(at + 1); + } + return out; +} +const stages = (dir: string) => readdirSync(join(dir, 'bin')).filter(name => name.startsWith('.gstack-cso-stage.')); +const launcherOutput = (dir: string) => readFileSync(join(dir, 'bin', 'gstack-cso-launcher'), 'utf8'); + +describe.skipIf(process.platform === 'win32')('build-cso.sh build-result record (#3071)', () => { + test('a successful build records ok with this revision installed', () => { + const dir = fixture(); + const r = build(dir, `GSTACK_CSO_BUILD_LOG=${quote(join(dir, 'cso.log'))}`); + expect(r.status, r.stderr).toBe(0); + expect(record(dir)).toMatchObject({ result: 'ok', stage: 'publish', revision: '9.9.9.9', installed: '9.9.9.9', launcher: 'yes', diagnostic: join(dir, 'cso.log') }); + expect(stages(dir)).toEqual([]); + }); + + test('a fresh build failure records the build stage with no usable launcher', () => { + const dir = fixture(); + const r = build(dir, 'CSO_FAIL_COMPILER_N=1'); + expect(r.status).toBe(42); + expect(record(dir)).toMatchObject({ result: 'failed', stage: 'build', reason: 'exit 42', installed: '', launcher: 'no' }); + expect(stages(dir)).toEqual([]); + }); + + test('a failed upgrade keeps the previous generation and names its revision', () => { + const dir = fixture(); seedOld(dir); + const r = build(dir, 'GSTACK_CSO_BUILD_TESTING=1 GSTACK_CSO_BUILD_TEST_FAIL_AFTER=publish-core'); + expect(r.status).toBe(86); + expect(launcherOutput(dir)).toContain('OLD'); + expect(record(dir)).toMatchObject({ result: 'failed', stage: 'publish', reason: 'exit 86', revision: '9.9.9.9', installed: '9.9.9.8 (0123456789ab)', launcher: 'yes' }); + expect(stages(dir)).toEqual([]); + }); + + test('a publish killed mid-flight leaves an interrupted publish record', () => { + const dir = fixture(); seedOld(dir); + const r = build(dir, 'GSTACK_CSO_BUILD_TESTING=1 GSTACK_CSO_BUILD_TEST_KILL_AFTER=publish-core'); + expect(r.status).not.toBe(0); + expect(record(dir)).toMatchObject({ result: 'failed', stage: 'publish', reason: 'interrupted' }); + expect(existsSync(join(dir, 'bin', 'gstack-cso-launcher'))).toBe(false); + }); + + test('a missing publisher at exec time cleans the stage and keeps the old generation', () => { + const dir = fixture(); seedOld(dir); + const r = build(dir, 'GSTACK_CSO_BUILD_TESTING=1 GSTACK_CSO_BUILD_TEST_REMOVE_LOCKER_AFTER=before-exec'); + expect(r.status).toBe(127); + expect(r.stderr).toContain('CSO publication could not start'); + expect(stages(dir)).toEqual([]); + expect(launcherOutput(dir)).toContain('OLD'); + expect(record(dir)).toMatchObject({ result: 'failed', stage: 'publish', reason: 'exit 127', installed: '9.9.9.8 (0123456789ab)', launcher: 'yes' }); + }); +}); + +describe('build-cso.sh publisher handoff (#3071)', () => { + const source = readFileSync(join(ROOT, 'scripts/build-cso.sh'), 'utf8'); + test('the parent checks the publisher, then clears its EXIT trap, then execs', () => { + const exec = source.indexOf('exec "$CSO_STAGE_LOCKER"'); + const clear = source.lastIndexOf('trap - EXIT', exec); + const check = source.lastIndexOf('cso_valid_artifact "$CSO_STAGE_LOCKER"', clear); + expect(exec).toBeGreaterThan(0); + expect(clear).toBeGreaterThan(check); + expect(check).toBeGreaterThan(0); + expect(source.slice(check, exec)).toContain('shopt -s execfail'); + expect(source.slice(exec)).toContain('cso_cleanup "$cso_exec_status"'); + }); + + test('the Windows build prefers PowerShell 7 and falls back to Windows PowerShell', () => { + expect(source).toContain('CSO_POWERSHELL="$(command -v pwsh 2>/dev/null || command -v powershell.exe 2>/dev/null || true)"'); + expect(source).not.toMatch(/^\s*powershell\.exe /m); + }); +}); diff --git a/test/cso-windows-launcher.test.ts b/test/cso-windows-launcher.test.ts index e0f10eb5e..c32324cbd 100644 --- a/test/cso-windows-launcher.test.ts +++ b/test/cso-windows-launcher.test.ts @@ -141,7 +141,7 @@ describe('CSO native Windows build contract', () => { test('Windows builds use MSVC with a static CRT and no Bun-hosted public launcher', () => { const build = fs.readFileSync(path.join(ROOT, 'scripts/build-cso.sh'), 'utf8'); const msvc = fs.readFileSync(path.join(ROOT, 'scripts/build-cso-windows.ps1'), 'utf8'); - expect(build).toContain('powershell.exe -NoProfile -NonInteractive'); + expect(build).toContain('"$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")"'); diff --git a/test/gstack-doctor.test.ts b/test/gstack-doctor.test.ts index cd19a717f..0665406b6 100644 --- a/test/gstack-doctor.test.ts +++ b/test/gstack-doctor.test.ts @@ -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,62 @@ 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(); + const none = doctor(f).row('cso'); + expect(none.state).toBe('not configured'); + expect(none.detail).toBe('native helper not built; /cso reports not assessed'); + expect(none.fix).toContain('./setup (it names any missing build prerequisite)'); + 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-cso-degrade.test.ts b/test/setup-cso-degrade.test.ts new file mode 100644 index 000000000..6d0430b2b --- /dev/null +++ b/test/setup-cso-degrade.test.ts @@ -0,0 +1,147 @@ +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'; + +// #3071: an optional CSO failure no longer aborts setup. The prerequisite +// probe prefers PowerShell 7 and keeps an unknown failure's own output; the +// summary reads bin/.gstack-cso-build-result and names the failed stage, the +// diagnostic and the retry; only GSTACK_STRICT_BUILD=1 makes an incomplete CSO +// set fatal. Runs setup's real blocks with tools stubbed. +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 between = (from: string, to: string) => { + const start = setup.indexOf(from); + const end = setup.indexOf(to, start); + if (start < 0 || end < 0) throw new Error(`missing setup block ${from}`); + return setup.slice(start, end); +}; +const SUMMARY_END = '#cso-build-or-publish-failed"\nfi\n'; +const SUMMARY = between('# ─── CSO native-helper summary', SUMMARY_END) + SUMMARY_END; +const GATE = between('_CSO_READY=0\n', 'if [ "$_CSO_READY" -eq 1 ]; then'); +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 }); + +function probe(tools: Record) { + const dir = temp('gstack-cso-probe-'); + for (const [name, body] of Object.entries(tools)) stub(dir, name, body); + stub(dir, 'cygpath', 'shift; echo "$1"'); + return runBashScript([ + 'IS_WINDOWS=1', `SOURCE_GSTACK_DIR="${ROOT}"`, + 'bun_cmd() { echo "--no-compile-autoload-dotenv --no-compile-autoload-bunfig --no-compile-autoload-tsconfig --no-compile-autoload-package-json"; }', + fn('_cso_unavailable'), fn('probe_cso_build_prerequisites'), + 'probe_cso_build_prerequisites', + 'printf "available=%s\\nreason=%s\\ndetail=%s\\n" "$CSO_BUILD_AVAILABLE" "$CSO_FAIL_REASON" "$CSO_PROBE_DETAIL"', + ].join('\n'), { env: { ...process.env, PATH: `${dir}:${process.env.PATH}` }, timeout: 30_000 }); +} + +function summary(record: string | null, opts: { launcher?: boolean; probe?: [reason: string, detail: string] } = {}) { + const dir = temp('gstack-cso-summary-'); + fs.mkdirSync(path.join(dir, 'bin')); + if (record !== null) fs.writeFileSync(path.join(dir, 'bin/.gstack-cso-build-result'), record); + if (opts.launcher) for (const name of ['gstack-cso-launcher', 'gstack-cso-core']) stub(path.join(dir, 'bin'), name, 'echo OLD'); + const probe = opts.probe ? `CSO_BUILD_AVAILABLE=0\nCSO_FAIL_REASON='${opts.probe[0]}'\nCSO_PROBE_DETAIL='${opts.probe[1]}'` : 'CSO_BUILD_AVAILABLE=1'; + const r = runBashScript(['QUIET=0', '_EXE=""', `SOURCE_GSTACK_DIR="${dir}"`, probe, 'log() { echo "$@"; }', SUMMARY].join('\n'), { timeout: 15_000 }); + expect(r.status).toBe(0); + return { out: r.stdout, dir }; +} + +describe.skipIf(process.platform === 'win32')('setup: Windows CSO probe uses PowerShell 7 and keeps unknown failures (#3071)', () => { + test('pwsh is preferred over Windows PowerShell 5.1', () => { + const r = probe({ pwsh: 'exit 0', 'powershell.exe': 'echo "5.1 must not run" >&2; exit 1' }); + expect(r.stdout).toContain('available=1\n'); + }); + + test('a crashed probe keeps its exit status and output instead of blaming the MSVC toolchain', () => { + const r = probe({ pwsh: 'echo "Fatal error. 0xC0000005"; exit 139' }); + expect(r.stdout).toContain('reason=windows-toolchain-probe\n'); + expect(r.stdout).toContain('detail=pwsh exited 139: Fatal error. 0xC0000005\n'); + }); + + test('the missing Visual Studio message still maps to the install instruction', () => { + const r = probe({ pwsh: "echo 'CSO Windows build requires Visual Studio 2022 Build Tools with the Desktop development with C++ workload.' >&2; exit 1" }); + expect(r.stdout).toContain('reason=windows-msvc-toolchain\n'); + }); + + test.skipIf(Bun.which('pwsh') !== null)('without pwsh the probe falls back to powershell.exe', () => { + const r = probe({ 'powershell.exe': 'echo "Native CSO Windows compiler probe failed (2)." >&2; exit 1' }); + expect(r.stdout).toContain('reason=windows-msvc-compile\n'); + }); +}); + +describe.skipIf(process.platform === 'win32')('setup: CSO summary reads the build-result record per stage (#3071)', () => { + const failed = (stage: string, reason: string, installed = '') => + `result=failed\nstage=${stage}\nreason=${reason}\nrevision=1.91.99.0 (abcdef123456)\ninstalled=${installed}\nlauncher=${installed ? 'yes' : 'no'}\ndiagnostic=/src/bin/.gstack-cso-build.log\n`; + + test('a probe failure keeps the prerequisite instruction and names the probe stage', () => { + const { out } = summary(null, { probe: ['windows-toolchain-probe', 'pwsh exited 139: Fatal error'] }); + expect(out).toContain('CSO unavailable: its native helper was not built (windows-toolchain-probe).'); + expect(out).toContain('Failed stage: probe.'); + expect(out).toContain('fix the PowerShell toolchain probe failure (pwsh exited 139: Fatal error)'); + }); + + test('a fresh build failure names the stage, the log and the retry, never a prerequisite install', () => { + const { out, dir } = summary(failed('build', 'exit 42')); + expect(out).toContain("CSO unavailable: the native helper's build step failed (exit 42), so /cso will report not assessed."); + expect(out).toContain('Diagnostic: /src/bin/.gstack-cso-build.log'); + expect(out).toContain('Everything else is installed and works.'); + expect(out).toContain(`Retry: cd ${dir} && bun run build:cso && ./setup`); + expect(out).toContain('troubleshooting.md#cso-build-or-publish-failed'); + expect(out).not.toContain('To enable /cso'); + expect(out).not.toMatch(/prerequisite|\binstall (?:a|the|Visual|Git|Bun)\b/i); + }); + + test('a failed publish that kept the earlier helper says previous CSO kept', () => { + const { out } = summary(failed('publish', 'exit 1', '1.91.34.0 (f67c478f05d8)'), { launcher: true }); + expect(out).toContain('CSO publish step failed (exit 1) for 1.91.99.0 (abcdef123456); previous CSO kept: 1.91.34.0 (f67c478f05d8).'); + expect(out).not.toContain('CSO unavailable'); + }); + + test('a recorded old helper whose files are gone is reported unavailable', () => { + expect(summary(failed('publish', 'exit 1', '1.91.34.0'), { launcher: false }).out).toContain("CSO unavailable: the native helper's publish step failed (exit 1)"); + }); + + test('an interrupted publish says so', () => { + expect(summary(failed('publish', 'interrupted')).out).toContain("publish step was interrupted"); + }); + + test('a successful build or a legacy install without a record prints nothing', () => { + expect(summary('result=ok\nstage=publish\nreason=committed\nrevision=1\ninstalled=1\nlauncher=yes\ndiagnostic=x\n').out).toBe(''); + expect(summary(null).out).toBe(''); + }); +}); + +test('a probe failure removes a stale build record and writes nothing into the source checkout', () => { + const block = between('if [ "$CSO_BUILD_AVAILABLE" -eq 0 ]; then\n rm -f', '\nfi\n'); + expect(block).toContain('rm -f "$SOURCE_GSTACK_DIR/bin/.gstack-cso-build-result"'); + expect(block).not.toContain('>'); +}); + +describe.skipIf(process.platform === 'win32')('setup: an incomplete CSO set is fatal only under GSTACK_STRICT_BUILD=1 (#3071)', () => { + function gate(strict?: string) { + const dir = temp('gstack-cso-gate-'); + fs.mkdirSync(path.join(dir, 'bin')); + const env: Record = { PATH: process.env.PATH ?? '' }; + if (strict !== undefined) env.GSTACK_STRICT_BUILD = strict; + return runBashScript(['set -e', 'CSO_BUILD_AVAILABLE=1', 'IS_WINDOWS=0', '_EXE=""', `SOURCE_GSTACK_DIR="${dir}"`, GATE, 'echo "ready=$_CSO_READY"'].join('\n'), { env, timeout: 15_000 }); + } + test('by default setup continues without CSO', () => { + const r = gate(); + expect(r.status).toBe(0); + expect(r.stdout).toContain('ready=0'); + }); + test('GSTACK_STRICT_BUILD=1 stops setup', () => { + const r = gate('1'); + expect(r.status).toBe(1); + expect(r.stderr).toContain('CSO build completed without its required native artifact set (GSTACK_STRICT_BUILD=1)'); + }); +}); diff --git a/test/setup-cso-windows-probe.test.ts b/test/setup-cso-windows-probe.test.ts index 20635bdcb..554887706 100644 --- a/test/setup-cso-windows-probe.test.ts +++ b/test/setup-cso-windows-probe.test.ts @@ -28,7 +28,11 @@ afterEach(() => { for (const t of tmps.splice(0)) fs.rmSync(t, { recursive: true function probe(powershellOutput: string) { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cso-win-')); tmps.push(tmp); - fs.writeFileSync(path.join(tmp, 'powershell.exe'), `#!/bin/sh\ncat <<'OUT' >&2\n${powershellOutput}\nOUT\nexit 1\n`, { mode: 0o755 }); + // setup resolves pwsh before powershell.exe (#3071); stub both so a runner's + // real PowerShell 7 never answers the probe. + for (const name of ['pwsh', 'powershell.exe']) { + fs.writeFileSync(path.join(tmp, name), `#!/bin/sh\ncat <<'OUT' >&2\n${powershellOutput}\nOUT\nexit 1\n`, { mode: 0o755 }); + } fs.writeFileSync(path.join(tmp, 'cygpath'), '#!/bin/sh\nshift; echo "$1"\n', { mode: 0o755 }); return runBashScript([ 'IS_WINDOWS=1', `SOURCE_GSTACK_DIR="${ROOT}"`, 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(''); + }); +});