diff --git a/CHANGELOG.md b/CHANGELOG.md index cef694d9..ab66fcfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ Versions follow [Semantic Versioning](https://semver.org/). ### Changed +- **Windows usage documented, and the native test suite made honest (Windows report items 1 to 6, 10).** `docs/platform-support.md` gains the standard way to drive WSL2 from a Windows session (`ops/diagnostics/wsl-run.sh`: script files and pmctl arguments reach WSL without re-quoting, with `--pmctl-command` and the command's own exit status), where to edit, which permission rules to allow, what a native gate run needs (measured: about 17 minutes, five gates on one branch), and the native test status in four groups. `docs/review-model.md` states why a targeted pass is judged against the whole current subject. Test fixes: cases that need symlinks, POSIX mode bits, special file names or a PATH of links now skip with a reason instead of failing; two tests no longer use `grep -Fi`, which aborts in the GNU grep 3.0 of some Git for Windows installs; path assertions accept the `C:/` spelling the product records; the relocated-host fixture copies `jq-lf.sh` as a real checkout has it. +- **`pmctl gate mutations --preview` gives the mutation checklist before a gate (issue 9 of the Windows report).** qa-tester asks, per test, whether it would fail if the code were wrong in a plausible way, and a weak test used to surface only inside a gate round. The command lists the test files the change touches (`--base` defaults to `HEAD`, so uncommitted and untracked files count) and the mutation classes qa-tester uses: off-by-one or inverted output, first/last element, dropped exit status, removed guard, flipped operator, skipped or reordered call, empty input treated as valid. It is a checklist, not a prediction: it runs no mutants and asks for no test per boundary. +- **`gate.reviewer_executor` in `~/.pm-dispatch/config` pins the PR-gate reviewer (issue 13 of the Windows report).** `gate.reviewer_executor = codex` (or `claude`) makes `pr-gate` use that executor when `--executor` is absent or `auto`, and refuse a different `--executor` with "gate.reviewer_executor pins the reviewer to codex, so --executor claude is refused". A project that fixes "claude implements, codex reviews" can now say so once, and a used-up reviewer quota can no longer move the review to the implementing model unnoticed. `auto` or no key keeps the old behaviour. +- **A reviewer whose executor quota is used up fails the gate at once and says when to rerun (issue 8 of the Windows report).** Codex answers a used-up plan with "You've hit your usage limit ... try again at 9:15 PM". The gate filed that under the retryable "transport failure" and spent a retry round re-hitting it, with no hint when the quota returns. `gate_reviewer_quota_exhausted_signature` (next to the sandbox-unavailable one) now recognises the executor's usage-limit line. The adapter writes that line to its per-run `.stderr` and event stream, not to the reviewer's dispatch log, so the scan follows the `trace:` path the adapter's banner prints in that log to those two files; and it runs only for a reviewer that failed or wrote no result, because a successful review of quota-handling code may quote the same words. The parallel path records it as unrecoverable and reports "executor quota exhausted: (rerun the gate unchanged after the reset it names; the reviewer executor is not switched automatically)". The sequential path still reports a dispatch failure as before. +- **`pmctl gate wait` and `gate status` name a published result when the sentinel key is gone.** When the sentinel key was lost (session cleanup, a new login) `gate wait` said only "indeterminate: sentinel key absent", and `gate status` showed "starting" and "result: not written yet" for a gate whose phase events recorded `gate end` and `publish end result=`. `gate wait` still exits 3 and never treats the result as verified; it now adds that a published result exists, that no sentinel authenticates it, and the `pmctl gate verify --cd ` command to check it. `gate status` reports the end per the phase events and the result path with the same caveat. New `pmctl gate result ` prints the published result's frontmatter verdicts, its Gate Conclusion and Must-Fix sections and the file path, and says whether `gate verify` accepts it, so nobody has to find `.gate-results/gate-*.md` by hand. It shows verify's three answers (`artifact_valid`, `subject_current`, `policy_applicable`) on separate lines, so a valid result for a tree that has since changed is not read as plainly accepted. It also lists each reviewer's verdict as the result's section headings state it, because the frontmatter words qa, security and risk verdicts differently (`pass`, `needs-tests`) and an `advise` would otherwise read as a `pass`. +- **The prompt-context hook no longer fails on every prompt when a native Windows host pipes the payload in.** Claude Code and PowerShell start Git Bash with an anonymous Windows pipe as stdin; there `/dev/stdin` cannot be opened, so `payload=$(/.pm-dispatch/context.db`, which is built and refreshed by `_ctx_index_tree`. Reading it takes under 0.1 s, but building it for 1000 cards took 301 s on native Windows, and about 150 ms per file of that were three processes: `wc -c | tr -d ' '` for the size and `date` for the time. The size now comes from the one batched `stat` pass (`_ctx_batch_stats`) and the clock is read once per run, so `files.indexed_at` and `index_meta.built_at` are the time the run started. A cold build is 36 s to 24 s at 100 cards and 100 s to 42 s at 300 cards (one run each, noisy). Against a clean export of `main`, the `files`, `file_chunks`, symbol count and `index_meta` rows of a 40-file fixture (empty file, no trailing newline, a quote and Chinese characters in file names) are identical. A new case counts `wc`/`tr`/`date` starts and fails when the old calls come back. A symlinked file keeps the size of its target, as `wc -c <` always gave (the gate review found that `stat` without `-L` returns the length of the link text; a second new case covers it and runs on Linux and WSL only). Not done and recorded in BACKLOG CC-687: the line-by-line chunker (about 120 ms per file), the loops of a refresh with nothing changed (4 to 5 s at 1000 files), and Linux and WSL timings. - **The memory injection hook no longer starts processes per card (CC-686, issue #738).** `guard-inject-memory.sh` runs on every prompt and used to start three `awk` and one `tr` for each ordinary card in `MEMORY.md`, so on native Windows (a process costs tens of milliseconds) its time grew by about 0.23 s per card and passed the host's 30 s limit at roughly 130 cards; the output is discarded when the limit is hit. One `awk` (`memory_index_snapshot` in `runtime/lib/memory.sh`) now reads the index and opens each linked card itself, the payload and the resolution are each read by one `jq`, the episode log is read once instead of twice, and the age bucket no longer needs a command substitution per ranked card. **What it prints is unchanged**: compared with the previous hook on the same fixtures, the output and the usage table are identical for pinned cards (also quoted, after a preamble line), duplicate keys, fields after the closing `---`, a missing card, link-less lines, ASCII case folding, multi-line prompts, and fourteen kinds of last episode record. Measured on native Windows with synthetic cards, one run each: 10 cards 4.6 s to about 2.1 s, 50 cards 12.4 to 13.2 s to about 2.2 s, 100 cards 25.1 s to about 2.9 s, 200 cards 48.2 s to about 3.5 to 3.7 s; 1000 cards about 7 s before and about 8 s after the check that a linked card is a regular file was put back (the old hook was not run at that size). The fixed cost of about 2 s is not touched (the memory directory resolution, 0.8 s of it, computes the project key twice, and the usage store is still read and written synchronously), and the time still grows with the number of cards, only about fifty times more slowly; whether to keep a metadata index is left open in CC-686. New suite `tests/shell/test-memory-hook.sh`. - **The working-tree fingerprint hashes its files in one call instead of one process per file (CC-685, issue #736).** A gate computes the fingerprint of the reviewed tree three times that its phase events show (the subject snapshot, and before and after the pre-flight tests) and, by the call sites, twice more after the reviewers (the pre-flight freshness check and the final assurance snapshot; confirmed by the phase events of the final gate), and each one started a `sha256sum` per tracked file. On native Windows, where a process costs tens of milliseconds, that was 118 to 127 seconds for the 564 files of this repository (78 to 85 seconds inside a gate), about 85% of it the per-file digests. `_gate_subject_tree_fingerprint` now takes the digests of the regular files with one `xargs -0 sha256sum -z --`, keeps them for that call only, and quotes paths without a subshell: 105 seconds became 5 on the same tree, 6 seconds became 1 under WSL. **The fingerprint is bit-for-bit the same** (the same value on native Windows and on Linux for the same tree), so a result that was recorded before this change is still judged current. A file the batch cannot carry (a name with a bracket, backslash or newline, exactly `@`, `*` or `-`), a failed batch, or a `sha256sum` without `-z` falls back to the old one-at-a-time hashing, which still reports a read error with status 2. `PM_GATE_SUBJECT_BATCH_DIGESTS=0` turns the batch off. The first version did not engage inside a real detached gate: xargs refused to run ("environment is too large for exec") because a detached gate's environment is tens of kilobytes, and the fingerprint silently fell back to one process per file, so the gate stayed as slow as before while a standalone call took 5 seconds. xargs now runs under a minimal environment (`env -i PATH`), and each fingerprint records `digests batched=N per-file=M` as a phase event, so `pmctl gate status` shows a batch that fell back. Measured with gates that stop after their pre-flight, same machine, one run each: the subject snapshot went from 72 to 88 seconds to 8, and the two pre-flight fingerprints from 72 to 85 seconds to 5 to 7. On Linux the saving is a few seconds per fingerprint. A whole gate with reviewers, measured once on the same machine in the same mode: 15 min 50 s against 32 min 11 s for the earlier gate of this change. The batch accounts for about 5 minutes of that (the snapshot, the four other fingerprints, and the stretch after the reviewers that had no event); the rest is run-to-run variance in model time and test time, not measured. diff --git a/README.md b/README.md index a32286c7..6c8c4b11 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,8 @@ router and [`cli/commands.tsv`](cli/commands.tsv). - `gate status` — Show where a detached gate is: state, phase, last progress, log and result. [experimental; JSON: false; mutating: false] - `gate cancel` — Stop the verified gate producer process tree, then cancel only its recorded child runs; partial termination stays indeterminate. [experimental; JSON: false; mutating: true] - `gate reconcile` — Reconcile a gate operation from trusted child terminal claims. [experimental; JSON: false; mutating: true] +- `gate result` — Print a gate's published result: verdicts, conclusion and the result file path, with whether gate verify accepts it. [experimental; JSON: false; mutating: false] +- `gate mutations` — Before a gate: list the test files this change touches and the mutation classes qa-tester asks of each test. [experimental; JSON: false; mutating: false] - `gate stats` — Aggregate operational cost of past PR-gate runs from on-disk artifacts. [experimental; JSON: true; mutating: false] - `context index` — Build the repository context index. [experimental; JSON: false; mutating: true] - `context update` — Update the repository context index. [experimental; JSON: false; mutating: true] diff --git a/cli/commands.tsv b/cli/commands.tsv index bae2b0d9..f35f5a59 100644 --- a/cli/commands.tsv +++ b/cli/commands.tsv @@ -46,6 +46,8 @@ gate wait Wait for a detached gate run. pmctl gate wait [options] expe gate status Show where a detached gate is: state, phase, last progress, log and result. pmctl gate status [--cd ] experimental false false --cd pmctl gate status gate-123 --cd . gate cancel Stop the verified gate producer process tree, then cancel only its recorded child runs; partial termination stays indeterminate. pmctl gate cancel --cd [--grace ] experimental false true --cd, --grace pmctl gate cancel op-20260724T000000Z-abcdef --cd . gate reconcile Reconcile a gate operation from trusted child terminal claims. pmctl gate reconcile --cd experimental false true --cd pmctl gate reconcile op-20260724T000000Z-abcdef --cd . +gate result Print a gate's published result: verdicts, conclusion and the result file path, with whether gate verify accepts it. pmctl gate result [--cd ] experimental false false --cd pmctl gate result gate-123 --cd . +gate mutations Before a gate: list the test files this change touches and the mutation classes qa-tester asks of each test. pmctl gate mutations --preview [--cd ] [--base ] experimental false false --preview, --cd, --base pmctl gate mutations --preview --cd . gate stats Aggregate operational cost of past PR-gate runs from on-disk artifacts. pmctl gate stats [--since ] [--json] [--cd ] experimental true false --since, --json, --cd pmctl gate stats --since 2026-08-01 --json context index Build the repository context index. pmctl context index [] [--source ] [--budget-ms ] experimental false true --source, --budget-ms pmctl context index . --source repo context update Update the repository context index. pmctl context update [] [] experimental false true none pmctl context update . README.md diff --git a/cli/pmctl b/cli/pmctl index 3f3c622b..718d9e0f 100755 --- a/cli/pmctl +++ b/cli/pmctl @@ -425,6 +425,18 @@ case "$cmd/$sub" in fi pmctl_gate_wait "$REPO_ROOT" "$@" ;; + gate/result) + if ! declare -F pmctl_gate_result >/dev/null; then + pmctl_die "gate result unavailable" + fi + pmctl_gate_result "$REPO_ROOT" "$@" + ;; + gate/mutations) + if ! declare -F pmctl_gate_mutations >/dev/null; then + pmctl_die "gate mutations unavailable" + fi + pmctl_gate_mutations "$REPO_ROOT" "$@" + ;; gate/status) if ! declare -F pmctl_gate_status >/dev/null; then pmctl_die "gate status unavailable" diff --git a/docs/architecture/script-variable-consumers.tsv b/docs/architecture/script-variable-consumers.tsv index 31d6e8cf..b364e720 100644 --- a/docs/architecture/script-variable-consumers.tsv +++ b/docs/architecture/script-variable-consumers.tsv @@ -71,6 +71,7 @@ CODEX_GATE_STUB_* CODEX_GATE_STUB_PROTOCOL_MUTATION_ON_RETRY tests/shell/test-pr CODEX_GATE_STUB_* CODEX_GATE_STUB_QA_ABORT_AFTER_CHECKPOINT tests/shell/test-pr-gate.sh test CODEX_GATE_STUB_* CODEX_GATE_STUB_QA_RUN_COMMAND tests/shell/test-pr-gate.sh test CODEX_GATE_STUB_* CODEX_GATE_STUB_QA_TAMPER_EVIDENCE tests/shell/test-pr-gate.sh test +CODEX_GATE_STUB_* CODEX_GATE_STUB_QUOTE_QUOTA tests/shell/test-pr-gate.sh test CODEX_GATE_STUB_* CODEX_GATE_STUB_RESULT_VERSION tests/shell/test-pr-gate.sh test CODEX_GATE_STUB_* CODEX_GATE_STUB_SYNTHESIS_EXTRA_FINAL tests/shell/test-pr-gate.sh test CODEX_GATE_STUB_* CODEX_GATE_STUB_SYNTHESIS_FINAL tests/shell/test-pr-gate.sh test @@ -152,6 +153,7 @@ MSYS2_ARG_CONV_EXCL MSYS2_ARG_CONV_EXCL hosts/claude/bin/install-guards.sh produ MSYS2_ARG_CONV_EXCL MSYS2_ARG_CONV_EXCL hosts/claude/bin/uninstall-guards.sh production MSYS2_ARG_CONV_EXCL MSYS2_ARG_CONV_EXCL hosts/codex/bin/install.sh production MSYS2_ARG_CONV_EXCL MSYS2_ARG_CONV_EXCL hosts/codex/lib/hook-paths.sh production +MSYS2_ARG_CONV_EXCL MSYS2_ARG_CONV_EXCL ops/diagnostics/wsl-run.sh production MSYS2_ARG_CONV_EXCL MSYS2_ARG_CONV_EXCL runtime/lib/gate-assurance.sh production MSYS2_ARG_CONV_EXCL MSYS2_ARG_CONV_EXCL runtime/lib/gate-policy.sh production MSYS2_ARG_CONV_EXCL MSYS2_ARG_CONV_EXCL runtime/lib/gate-protocol.sh production @@ -231,6 +233,7 @@ PATH PATH ops/diagnostics/gate-subprocess-census.sh production PATH PATH ops/diagnostics/run-ci-lints.sh production PATH PATH ops/diagnostics/run-tests-in-wsl.sh production PATH PATH ops/diagnostics/windows-acceptance.sh production +PATH PATH ops/diagnostics/wsl-run.sh production PATH PATH ops/release/clean-install-smoke.sh production PATH PATH ops/release/release-verify.sh production PATH PATH ops/release/upgrade-smoke-v0.10-v0.11.sh production @@ -273,6 +276,8 @@ PM_CFG_* PM_CFG_DEFAULT_MODEL adapters/grok/dispatch.sh production PM_CFG_* PM_CFG_DEFAULT_MODEL adapters/opencode/dispatch.sh production PM_CFG_* PM_CFG_DEFAULT_MODEL runtime/lib/pmctl-config.sh production PM_CFG_* PM_CFG_DEFAULT_MODEL runtime/lib/pmctl-dispatch.sh production +PM_CFG_* PM_CFG_GATE_REVIEWER_EXECUTOR runtime/bin/pr-gate.sh production +PM_CFG_* PM_CFG_GATE_REVIEWER_EXECUTOR runtime/lib/pmctl-config.sh production PM_CFG_* PM_CFG_LEGACY_MEMORY_DIR runtime/lib/pmctl-config.sh production PM_CFG_* PM_CFG_LEGACY_MEMORY_DIR runtime/lib/pmctl-memory-config.sh production PM_CFG_* PM_CFG_LEGACY_MEMORY_DIR_INVALID runtime/lib/pmctl-config.sh production diff --git a/docs/platform-support.md b/docs/platform-support.md index a1d956a8..0fa7d255 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -415,6 +415,81 @@ things to know: CC-641); the state directory may contain such characters (`pmctl worktree` handles them), the repository may not. +### Driving WSL2 from a native Windows session + +When the repository, `pmctl` and the gate live in WSL2 (the supported setup for release sign-off) and the +session you type in is on Windows, three things go wrong in practice. Each has a standard answer. + +- **Do not put multi-line or quoted text inside `wsl.exe -e bash -lc '...'`.** The text passes through the Windows + command line, Git Bash's argument rewriting and WSL's bash, and does not survive: an apostrophe ends the string + (`unexpected EOF`), a heredoc loses its backslashes, `\n` becomes a real newline, `$` is expanded by the wrong + shell. Write the commands to a file and run the file. `ops/diagnostics/wsl-run.sh` does the translation: + + ```bash + bash ops/diagnostics/wsl-run.sh [--cd ] script [args...] # run a file with bash -l, args kept whole + bash ops/diagnostics/wsl-run.sh [--cd ] pmctl gate status --cd /home/me/repo # pmctl, args kept whole + ``` + + The script's Windows path (an 8.3 short one such as `C:\Users\LIENCH~1\...` included) is translated with + `wslpath`, a script with CRLF line endings is refused by name, and the command's own exit status is the helper's. + `--pmctl-command ` runs a different program in `pmctl` mode (a WSL path, or a name on its PATH). +- **Edit files where the repository lives.** A repository cloned inside WSL (under its own home directory) is edited from WSL. A + Windows tool that edits `\\wsl.localhost\...` must read before it writes and disagrees with WSL's git about what + is dirty. Use Windows only to send commands and read results; keep the Windows checkout and the WSL checkout in + step through git, never by editing the same file from both sides. +- **Permission prompts match a command's prefix.** A rule such as `Bash(pmctl:*)` matches only a command that starts + with `pmctl`; a variable assignment, `command -v`, a subshell or a leading `cd ... &&` makes every call a manual + approval. Call `pmctl ...` bare, and from native Windows call `bash ops/diagnostics/wsl-run.sh pmctl ...`. Rules + worth allowing in advance: `Bash(pmctl:*)` and `Bash(bash ops/diagnostics/wsl-run.sh pmctl:*)`. Do not allow + `wsl-run.sh script` in advance: it runs whatever file it is given. + +### The PR gate on native Windows (measured 2026-11-11) + +`pmctl gate run` runs to a verdict in native Git Bash. On one machine (Windows 11, Git for Windows 2.42, bash 5.2, +codex-cli 0.165.0, jq 1.8.1 from winget) a `full`-tier, parallel, five-reviewer gate on this repository took about +17 minutes from launch to result, `gate result` and `gate verify` read it, and verify reported all three axes. Five +gate runs on one branch produced three NO-GO reviews with real findings, one refusal before review (a targeted pass +below the policy floor, see [review-model.md](review-model.md)) and one GO whose `subject_current` axis was `pass`. +It remains **experimental**: the native run needs the setup below, and it is not release sign-off. + +- A state root only you can write, as a POSIX path: `export PM_DISPATCH_STATE_ROOT="$HOME/.pm-gate-state"` after the + `icacls` step in the list above. Without it the first command stops with `unsafe state root rejected`. +- Reviewers launched by `--executor codex` use the codex CLI on PATH; the reviewer is never switched. To make that a + rule rather than a habit, put `gate.reviewer_executor = codex` in `~/.pm-dispatch/config`: a different + `--executor` is then refused. +- A quota that is used up shows as `executor quota exhausted: ` and the + gate stops without a retry; rerun it unchanged after the reset. +- `pmctl gate wait` can return `indeterminate: sentinel key absent` (exit 3) when the per-user sentinel key is gone. + That is a statement about authentication, not about the run: `pmctl gate result ` prints the published + result with the three verify axes, and `pmctl gate verify --cd ` decides whether to trust it. +- Before a gate, `pmctl gate mutations --preview` lists the test files the change touches and the mutation classes + qa-tester asks of each test. + +### Native Windows test status (Git Bash, measured 2026-11-11) + +Run with a scratch directory only you can write (`TMPDIR`, see above). Of 125 `tests/shell` scripts, 85 passed in +the first full run with a 240 s limit per script; the rest fall into these groups. What is listed as skipped is +skipped with a stated reason, not silently. + +- **Skipped on purpose (capability).** Real symlinks, POSIX mode bits and enforced permissions, file names with a + quote, backslash or newline, and a PATH built from links to the real tools (native Git Bash's `ln -s` copies the + `.exe` without its DLLs, which then fails to load). The cases use the `th_require_*` probes in + `tests/lib/test-harness.sh`; they run in WSL2. +- **Slow, not wrong.** Process starts cost tens of milliseconds, so these exceed 240 s and some 600 s here: + `test-dispatch-cancel`, `test-dispatch-lifecycle`, `test-doctor`, `test-gate-lifecycle` and `test-guards` as whole + files, `test-host-write-codex`, `test-install`, `test-pmctl-context`, `test-pmctl-dispatch`, `test-pmctl-gate`, + `test-pmctl-ship`, `test-pmctl-task`, `test-state-store`, the `test-pr-gate*` files and + `test-script-domain-inventory`. Run them with `ops/diagnostics/run-tests-in-wsl.sh`, or by `--filter` natively. +- **Fixed in this tree.** `grep -Fi` aborts with a core dump in the GNU grep 3.0 of some Git for Windows installs + (two tests used it); a hook that read its payload from `/dev/stdin` failed on every prompt when a Windows host + piped it in; several assertions compared the POSIX spelling of a path (`/c/Users/...`) with the `C:/Users/...` form + the product records. +- **Open (native Windows only; cause not fixed).** `test-pmctl-memory` resolve/config/stats cases (the memory + directory is reported `unavailable`), `test-pmctl-pm` `prepare` and `run` cases, and two `test-pmctl-ship-finish` + assessment cases fail natively but pass in WSL2 (measured 2026-11-11), so they are specific to native Windows. The + likely cause is path spelling (`C:/...` versus `/c/...`) in the tests' expectations or in the project key; that is + unconfirmed. Treat WSL2 as the reference result for these files. + --- ## Verify the install diff --git a/docs/review-model.md b/docs/review-model.md index eac511b5..4d4abd43 100644 --- a/docs/review-model.md +++ b/docs/review-model.md @@ -136,7 +136,7 @@ reviewer `Agent` calls from a handover block, was retired (`pr-gate-handover_v1` is no Agent-spawn route for the gate. **When this applies.** Only when the gate **cannot be run to a verdict in your environment** because -of a documented platform limit (today: native Windows, see +of a documented platform limit (native Windows without the setup the platform page describes, see [platform-support.md](platform-support.md#windows-git-bash-experimental-prefer-wsl2-for-release-sign-off)). If Linux or WSL2 is available to you, run the gate there instead. It does **not** apply when the gate returned NO-GO, when it returned an indeterminate result or its evidence could not be read, or when it @@ -260,6 +260,20 @@ to required coverage without automatically converting every bounded change to even when its content overlaps a prior gate. Tier and coverage selection bases are recorded independently. +**A remediation pass is judged against the whole current subject, not against the fix.** The +reviewers a targeted pass must include come from the signals of the diff the gate reviews +(base to head), and that diff is the whole branch. A branch that carries `large-change`, +`cross-boundary` or `input-execution-path` signals therefore needs all five reviewers even when the +fix after a NO-GO touched only tests, and `--reviewers qa-tester` alone is refused before any +reviewer runs (exit 3, "requested gate assurance is below the canonical generic policy floor"). +The refusal prints the full set to pass; use it. This is deliberate: a reviewer set derived from +the fix alone would let a risky change go unreviewed once an earlier round had passed it. Two +ways to make the re-review cheaper stay inside the policy: keep a branch small enough that its +own signals need fewer reviewers, or take a scope-bound `--policy-override` that records the +user's approval of the omission (the PM cannot grant it). Measured on native Windows +(2026-11-11): the full five-reviewer targeted pass took about 17 minutes, the same as the +initial one. + The generic consumer preserves an explicit coverage choice only when it keeps all risk-derived required reviewers (or a scope-bound override authorizes an omission). The maintainer diff --git a/hosts/claude/hooks/inject-context.sh b/hosts/claude/hooks/inject-context.sh index 006ec178..0f551eb3 100755 --- a/hosts/claude/hooks/inject-context.sh +++ b/hosts/claude/hooks/inject-context.sh @@ -26,7 +26,15 @@ command -v sqlite3 >/dev/null 2>&1 || exit 0 # call, which is slow for a large paste) and handed to jq on stdin (no temp file). A closed stdin is checked first, # without a process: the substitution's own pipe would take file descriptor 0 and `/dev/stdin` would read that. { : 3<&0; } 2>/dev/null || exit 0 -payload=$(/dev/null; then + payload=$('` passes the text through the Windows command line, the Git Bash argument +# rewriting and WSL's bash, and the text does not survive it: an apostrophe ends the string, a heredoc loses its +# backslashes, `\n` becomes a real newline, `$` is expanded by the wrong shell. Anything longer than one plain +# command is safer as a file, and a file is what this helper runs: it translates the Windows path of the script to +# the WSL path (`/mnt/c/...`, short 8.3 names such as C:\Users\LIENCH~1 included) and runs it with bash, so the +# text is never re-quoted. +# +# Usage: +# ops/diagnostics/wsl-run.sh [--distro NAME] [--cd ] script [args...] +# ops/diagnostics/wsl-run.sh [--distro NAME] [--cd ] [--pmctl-command ] pmctl +# +# script run (a Windows or POSIX path of a file on this machine) with `bash -l` +# pmctl run the pmctl on the WSL PATH, with plain arguments (no shell text; each is passed as is) +# --cd working directory inside WSL (default: the WSL home directory) +# --pmctl-command the program that `pmctl` mode runs inside WSL: a name on its PATH or a WSL path (default: pmctl) +# --distro NAME WSL distribution (default: the default one) +# +# Edit files where the repository lives. A repository cloned in WSL (~/github/...) is edited from WSL; a Windows +# tool editing \\wsl.localhost\... has to read before it writes and disagrees with WSL's git about the state. Use +# this helper to send commands and read results, not to edit across the boundary. +# +# Exit status: the script's or pmctl's own, or 2 for a usage error, 3 when WSL cannot be reached. +set -euo pipefail + +die() { printf 'wsl-run: %s\n' "$*" >&2; exit "${2:-2}"; } + +distro="" wdir="" pmctl_cmd="pmctl" +while [[ $# -gt 0 ]]; do + case "$1" in + --distro) [[ $# -ge 2 && -n "$2" ]] || die "--distro needs a name"; distro="$2"; shift 2 ;; + --cd) [[ $# -ge 2 && -n "$2" ]] || die "--cd needs a WSL directory"; wdir="$2"; shift 2 ;; + --pmctl-command) [[ $# -ge 2 && -n "$2" ]] || die "--pmctl-command needs a command (a name on the WSL PATH or a WSL path)"; pmctl_cmd="$2"; shift 2 ;; + -h|--help) sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) break ;; + esac +done +[[ $# -ge 1 ]] || die "usage: wsl-run.sh [--distro NAME] [--cd ] script [args...] | pmctl " +mode="$1"; shift +# Usage errors and unreadable input come first, so they are reported the same on a host without WSL. +need_wsl() { command -v wsl.exe >/dev/null 2>&1 || die "wsl.exe not found; install WSL2 first" 3; } + +# MSYS rewrites anything that looks like a path in a native program's argument; turn that off for wsl.exe. +wsl() { + if [[ -n "$distro" ]]; then + MSYS2_ARG_CONV_EXCL='*' wsl.exe -d "$distro" "$@" + else + MSYS2_ARG_CONV_EXCL='*' wsl.exe "$@" + fi +} + +# `--cd` goes to wsl.exe itself: it changes directory before starting the command, with no shell text involved. +cd_args=() +[[ -z "$wdir" ]] || cd_args=(--cd "$wdir") + +case "$mode" in + script) + [[ $# -ge 1 ]] || die "script needs a file" + file="$1"; shift + [[ -f "$file" ]] || die "no such file: $file" + # The file's own bytes are what runs; a CRLF copy of a script (autocrlf) would not, so refuse it by name. + # (grep is no test for this: Git Bash's grep drops a line's trailing CR before it matches.) + if ! cmp -s <(tr -d '\r' < "$file") "$file"; then + die "$file has CRLF line endings; bash would read them as part of the commands (write it with LF)" + fi + need_wsl + if command -v cygpath >/dev/null 2>&1; then + win_path="$(cygpath -w "$file")" + else + win_path="$file" + fi + wsl_path="$(wsl -e wslpath -a -u "$win_path" | tr -d '\r')" || die "wslpath could not translate $win_path" 3 + [[ -n "$wsl_path" ]] || die "wslpath returned nothing for $win_path" 3 + wsl "${cd_args[@]}" -e bash -l "$wsl_path" "$@" + ;; + pmctl) + [[ $# -ge 1 ]] || die "pmctl needs arguments" + need_wsl + # `bash -lc 'exec "$@"' _ args...` keeps each argument whole and passes the command's own exit status + # on; the only shell text is this fixed line. + wsl "${cd_args[@]}" -e bash -lc 'exec "$@"' _ "$pmctl_cmd" "$@" + ;; + *) die "unknown mode: $mode (expected script or pmctl)" ;; +esac diff --git a/rerun1.txt b/rerun1.txt new file mode 100644 index 00000000..e69de29b diff --git a/runtime/bin/pr-gate.sh b/runtime/bin/pr-gate.sh index 8bf6254a..1a00f9ba 100755 --- a/runtime/bin/pr-gate.sh +++ b/runtime/bin/pr-gate.sh @@ -655,6 +655,25 @@ if [[ -n "$DISPATCH_ISOLATION" ]]; then _validate_isolation_level "$DISPATCH_ISOLATION" "$ISOLATION_POLICY_FILE" || exit 2 fi +# ~/.pm-dispatch/config `gate.reviewer_executor = codex|claude` pins the reviewer: it is used when --executor is +# absent or auto, and a different --executor is refused, so the review is never moved to another model by accident +# (for example while the pinned executor's quota is used up). +if [[ -r "$PR_GATE_LIB_DIR/pmctl-config.sh" ]]; then + # shellcheck source=runtime/lib/pmctl-config.sh + . "$PR_GATE_LIB_DIR/pmctl-config.sh" + pm_config_load + case "${PM_CFG_GATE_REVIEWER_EXECUTOR:-}" in + codex|claude) + if [[ "$EXECUTOR_OPTION" == auto ]]; then + EXECUTOR_OPTION="$PM_CFG_GATE_REVIEWER_EXECUTOR" + elif [[ "$EXECUTOR_OPTION" != "$PM_CFG_GATE_REVIEWER_EXECUTOR" ]]; then + printf 'Error: gate.reviewer_executor pins the reviewer to %s, so --executor %s is refused. Change the config to change the reviewer.\n' \ + "$PM_CFG_GATE_REVIEWER_EXECUTOR" "$EXECUTOR_OPTION" >&2 + exit 2 + fi + ;; + esac +fi EXECUTOR="$(resolve_executor_at "$PR_GATE_EXECUTOR_ROOT" "$EXECUTOR_OPTION")" || exit 2 # Reviewer briefs instruct the dispatched session to call `pmctl guard check` @@ -3481,6 +3500,16 @@ RBRIEF_EOF PROTOCOL_INVALID_REASONS+=("executor sandbox unavailable: ${_sandbox_line} (see ${_dispatch_log})") gate_protocol_attempt_record reviewer "$r" 1 unrecoverable \ "executor sandbox unavailable: ${_sandbox_line}" "$rf" || exit 2 + elif { [[ " ${FAILED_REVIEWERS[*]:-} " == *" $r "* || ! -s "$rf" ]] \ + && _quota_line="$(gate_reviewer_quota_exhausted_signature "$_dispatch_log" || true)" \ + && [[ -n "$_quota_line" ]]; }; then + # The executor's quota is used up (a reviewer that produced a result is never classified here: it may only quote + # such text): a retry now re-hits it. Name the executor's own reset hint, and say + # that the reviewer stays the same (rerun unchanged after the reset; do not switch executor). + PROTOCOL_INVALID_OUTPUTS+=("$r") + PROTOCOL_INVALID_REASONS+=("executor quota exhausted: ${_quota_line} (rerun the gate unchanged after the reset it names; the reviewer executor is not switched automatically; see ${_dispatch_log})") + gate_protocol_attempt_record reviewer "$r" 1 unrecoverable \ + "executor quota exhausted: ${_quota_line}" "$rf" || exit 2 elif [[ " ${FAILED_REVIEWERS[*]:-} " == *" $r "* ]]; then PROTOCOL_INVALID_OUTPUTS+=("$r") PROTOCOL_INVALID_REASONS+=("transport failure") diff --git a/runtime/lib/gate-protocol.sh b/runtime/lib/gate-protocol.sh index 92ea67a5..4988e600 100644 --- a/runtime/lib/gate-protocol.sh +++ b/runtime/lib/gate-protocol.sh @@ -85,6 +85,43 @@ gate_reviewer_sandbox_unavailable_signature() { return 1 } +# An executor whose plan quota is used up (Codex: "You've hit your usage limit ... try again at 9:15 PM") fails every +# reviewer the same way until the quota resets. A retry minutes later re-hits it, and the role split forbids moving the +# review to another executor, so the useful report is the executor's own reset hint and nothing else. +# +# The adapter writes the executor's own stderr and event stream to per-run files next to its trace, not to the +# reviewer's dispatch log; the log only carries the adapter's banner, whose `trace: .jsonl` line names them. +# gate_reviewer_quota_evidence_files +# Prints, one per line, the files that can hold the executor's usage-limit text for the reviewer that wrote +# : the log itself, then the `.stderr` and `.jsonl` of the trace its banner names (when readable). +gate_reviewer_quota_evidence_files() { + local log="$1" trace + [[ -n "$log" && -r "$log" ]] || return 0 + printf '%s\n' "$log" + trace="$(sed -n 's/^[[:space:]]*trace:[[:space:]]*//p' -- "$log" 2>/dev/null | head -1 | tr -d '\r')" + [[ "$trace" == *.jsonl ]] || return 0 + [[ -r "${trace%.jsonl}.stderr" ]] && printf '%s\n' "${trace%.jsonl}.stderr" + [[ -r "$trace" ]] && printf '%s\n' "$trace" + return 0 +} + +# gate_reviewer_quota_exhausted_signature +# If the dispatch log or the files it points to hold a quota-exhausted signature, print its first matching line +# (it normally carries the reset time) on stdout and return 0. Otherwise print nothing and return 1. Read-only. +# Call it only for a reviewer that failed or wrote no result: a successful review may quote such text. +gate_reviewer_quota_exhausted_signature() { + local log="$1" file line + while IFS= read -r file; do + [[ -n "$file" ]] || continue + line="$(grep -m1 -i -E "hit your usage limit|usage_limit_reached|insufficient_quota|exceeded your current quota|quota (exceeded|exhausted)" -- "$file" 2>/dev/null | tr -d '\r' | cut -c1-300)" || line="" + if [[ -n "$line" ]]; then + printf '%s\n' "$line" + return 0 + fi + done < <(gate_reviewer_quota_evidence_files "$log") + return 1 +} + # gate_protocol_attempt_record # Append one gate_protocol_attempt_v1 JSON line to $PROTOCOL_RECOVERY_PATH. # An empty is recorded as JSON null. Returns non-zero only if the diff --git a/runtime/lib/pmctl-config.sh b/runtime/lib/pmctl-config.sh index 28836d41..a03142a2 100644 --- a/runtime/lib/pmctl-config.sh +++ b/runtime/lib/pmctl-config.sh @@ -14,6 +14,9 @@ # PM_CFG_MEMORY_CONFIG_STATUS — none|matched|matched-invalid|legacy-global # PM_CFG_LEGACY_MEMORY_DIR — deprecated machine-wide dispatch.memory_dir # PM_CFG_USAGE_LOG_PATH — dispatch.usage_log_path (absolute path to log-usage.sh), or "" +# PM_CFG_GATE_REVIEWER_EXECUTOR — gate.reviewer_executor (codex|claude|auto), or "". A value other than auto +# pins the PR-gate reviewer: pr-gate uses it when --executor is not given and refuses a +# different --executor (the reviewer is never switched silently). # # Config file: ${PM_DISPATCH_CONFIG_FILE:-~/.pm-dispatch/config} # Format: key = value lines; # comments; unknown keys silently ignored. @@ -29,6 +32,7 @@ PM_CFG_MEMORY_CONFIG_STATUS="none" PM_CFG_LEGACY_MEMORY_DIR="" PM_CFG_LEGACY_MEMORY_DIR_INVALID=0 PM_CFG_USAGE_LOG_PATH="" +PM_CFG_GATE_REVIEWER_EXECUTOR="" # Resolve the same stable identity used by project state. Git worktrees share # their primary checkout's key; non-git diagnostic fixtures fall back to a @@ -70,6 +74,7 @@ pm_config_load() { PM_CFG_LEGACY_MEMORY_DIR="" PM_CFG_LEGACY_MEMORY_DIR_INVALID=0 PM_CFG_USAGE_LOG_PATH="" + PM_CFG_GATE_REVIEWER_EXECUTOR="" [[ -r "$_cfg_path" ]] || return 0 @@ -147,6 +152,13 @@ pm_config_load() { printf 'pm-dispatch: config: warning: malformed project memory key in %s:%d: %s\n' "$_cfg_path" "$_line_no" "$_key" >&2 fi ;; + gate.reviewer_executor) + if [[ "$_value" =~ ^(codex|claude|auto)$ ]]; then + PM_CFG_GATE_REVIEWER_EXECUTOR="$_value" + else + printf 'pm-dispatch: config: warning: malformed value for gate.reviewer_executor in %s:%d (expected codex, claude or auto); ignoring\n' "$_cfg_path" "$_line_no" >&2 + fi + ;; dispatch.usage_log_path) if [[ "$_value" == /* ]]; then PM_CFG_USAGE_LOG_PATH="$_value" diff --git a/runtime/lib/pmctl-gate.sh b/runtime/lib/pmctl-gate.sh index 35fbf5f7..3d20ab2f 100644 --- a/runtime/lib/pmctl-gate.sh +++ b/runtime/lib/pmctl-gate.sh @@ -1019,6 +1019,7 @@ pmctl_gate_wait() { # never created. There is no authenticated # completion signal without it — never treat this as success. printf 'pmctl gate wait: indeterminate: sentinel key absent; completion is unverified for %s (exit=3)\n' "$gate_id" >&2 + _pmctl_gate_wait_note_published_result "$repo_root" "$work_dir" "$gate_id" return 3 fi _key_nonce="$(cat "$_key_file" 2>/dev/null)" || _key_nonce="" @@ -1283,13 +1284,55 @@ _pmctl_gate_status_sentinel() { done <"$sentinel" } +# What a gate's own phase events record about its end, independent of the authenticated sentinel: +# _pmctl_gate_recorded_outcome exit -> "exit=N" from the last `gate end` event +# _pmctl_gate_recorded_outcome result -> the path in the last `publish end` event's result= +# Nothing is printed (status 0) when the file, jq or a matching event is missing or unreadable. This is evidence +# that a gate ended and where it published, never a verdict: the sentinel key can be gone (session cleanup, a +# new login) while a complete, verifiable result sits in the run directory. +_pmctl_gate_recorded_outcome() { + local events="${1:-}" which="${2:-}" out="" + [[ -n "$events" && -r "$events" ]] || return 0 + command -v jq >/dev/null 2>&1 || return 0 + case "$which" in + exit) + out="$(jq -rs 'map(select(.phase == "gate" and .event == "end")) | last | .detail // empty' "$events" 2>/dev/null)" || out="" + ;; + result) + out="$(jq -rs 'map(select(.phase == "publish" and .event == "end")) | last | .detail // empty + | sub("^result="; "")' "$events" 2>/dev/null)" || out="" + ;; + *) return 0 ;; + esac + printf '%s' "${out%$'\r'}" +} + +# One stderr line for `gate wait` when the sentinel key is gone but the run directory holds a published result: +# where it is and how to check it. It never changes the exit status (still 3, completion unauthenticated). +_pmctl_gate_wait_note_published_result() { + local repo_root="$1" work_dir="$2" gate_id="$3" run_dir published + run_dir="$(_pmctl_gate_run_dir_of "$repo_root" "$work_dir" "$gate_id")" + [[ -n "$run_dir" && -d "$run_dir" ]] || return 0 + _pmctl_gate_phase_ensure_loaded "$repo_root" || true + published="$(_pmctl_gate_recorded_outcome "$run_dir/${GATE_PHASE_EVENTS_NAME:-gate-phase-events.jsonl}" result)" + [[ -n "$published" && -s "$published" ]] || return 0 + # shellcheck disable=SC2016 # literal markdown backticks in the format string + printf 'pmctl gate wait: the run directory holds a published result for %s: %s; it is not authenticated by a sentinel, so check it with `pmctl gate verify %q --cd %q` before relying on it\n' \ + "$gate_id" "$published" "$published" "$work_dir" >&2 +} + # state: and heartbeat: lines. Reads final_state and ready from the caller's scope. A gate that is gone # without a terminal sentinel is not classified here (cancelled or indeterminate is `gate wait`'s call, # which also reads the operation store); this only reports what the supervisor's identity shows. _pmctl_gate_status_state() { - local run_dir="$1" state heartbeat="" id_file vrc=0 + local run_dir="$1" state heartbeat="" id_file vrc=0 recorded_exit="" + recorded_exit="$(_pmctl_gate_recorded_outcome "${run_dir:+$run_dir/$GATE_PHASE_EVENTS_NAME}" exit)" if [[ -n "$final_state" ]]; then state="finished: ${final_state} (exit ${final_exit:-?}); read it with pmctl gate wait, which also verifies the result" + elif [[ -n "$recorded_exit" ]]; then + # No authenticated sentinel (its key is gone or never matched), but the phase events record that the gate + # ended. That is evidence of the end, not of the verdict: say so rather than "starting". + state="ended per its phase events (${recorded_exit}), but no authenticated sentinel confirms it; the result below is not verified until pmctl gate verify accepts it" elif [[ -z "$ready" || ! -f "$ready" ]]; then state="starting: the supervisor has not reported ready" else @@ -1360,6 +1403,16 @@ _pmctl_gate_status_where() { printf 'result: %s\n' "$final_result" return 0 fi + # No sentinel, but the gate's own `publish end` event names a non-empty result: show it, as unverified. The + # `result=` line of the plan names where the result was planned, which can differ from where it was published. + local published + published="$(_pmctl_gate_recorded_outcome "$events" result)" + if [[ -n "$published" && -s "$published" && -n "$(_pmctl_gate_recorded_outcome "$events" exit)" ]]; then + # shellcheck disable=SC2016 # literal markdown backticks in the format string + printf 'result: %s (published per the phase events; no authenticated sentinel, so not verified: run `pmctl gate verify %q --cd %q`)\n' \ + "$published" "$published" "${work_dir:-}" + return 0 + fi while IFS= read -r line; do [[ "$line" != result=* ]] || pending="${line#result=}" done <<<"$context" @@ -1466,6 +1519,158 @@ pmctl_gate_status() { return 0 } +# pmctl gate result [--cd ] +# Prints where a gate's published result is and what it says (frontmatter verdicts, then the "Gate Conclusion" +# and "Must-Fix Order" sections), so nobody has to find `.gate-results/gate-*.md` by hand. Whether the file is +# authentic is a separate question: `pmctl gate verify` answers it, and the last line says what it answered. +pmctl_gate_result() { + local repo_root="${1:-}" + shift || true + local gate_id="" work_dir="" + while [[ $# -gt 0 ]]; do + case "$1" in + --cd) + [[ $# -ge 2 && -n "$2" ]] || { printf 'pmctl gate result: missing value for --cd\n' >&2; return 2; } + work_dir="$2" + shift 2 + ;; + --*) printf 'pmctl gate result: unknown option %s\n' "$1" >&2; return 2 ;; + *) + [[ -z "$gate_id" ]] || { printf 'pmctl gate result: unexpected argument %s\n' "$1" >&2; return 2; } + gate_id="$1" + shift + ;; + esac + done + [[ -n "$gate_id" ]] || { printf 'pmctl gate result: is required\n' >&2; return 2; } + # The argument is a gate id, or the path of a result file (then there is no run directory to look in). + local run_dir result="" newest="" f from_file=false + if [[ -f "$gate_id" ]]; then + from_file=true + result="$gate_id" + else + pm_identifier_gate_is_valid "$gate_id" || { printf 'pmctl gate result: invalid gate_id %q\n' "$gate_id" >&2; return 2; } + fi + if [[ -z "$work_dir" ]]; then + work_dir="$(_pmctl_gate_default_cd)" + fi + if declare -F _portable_canonical_path >/dev/null 2>&1; then + work_dir="$(_portable_canonical_path "$work_dir")" + fi + + if [[ "$from_file" == false ]]; then + _pmctl_gate_phase_ensure_loaded "$repo_root" || true + run_dir="$(_pmctl_gate_run_dir_of "$repo_root" "$work_dir" "$gate_id")" + if [[ -z "$run_dir" || ! -d "$run_dir" ]]; then + printf 'pmctl gate result: no gate %s is known under --cd %s\n' "$gate_id" "$work_dir" >&2 + return 3 + fi + result="$(_pmctl_gate_recorded_outcome "$run_dir/${GATE_PHASE_EVENTS_NAME:-gate-phase-events.jsonl}" result)" + if [[ -z "$result" || ! -s "$result" ]]; then + result="" + for f in "$run_dir"/.gate-results/gate-*.md; do + [[ -s "$f" ]] || continue + if [[ -z "$newest" || "$f" -nt "$newest" ]]; then newest="$f"; fi + done + result="$newest" + fi + if [[ -z "$result" ]]; then + printf 'pmctl gate result: gate %s has no published result yet; see pmctl gate status %s --cd %q\n' "$gate_id" "$gate_id" "$work_dir" >&2 + return 3 + fi + printf 'gate: %s\n' "$gate_id" + fi + + printf 'result: %s\n' "$result" + # The frontmatter, then the two sections a reader needs; the sections are cut at the next "## " heading. + awk ' + NR == 1 && $0 == "---" { fm = 1; print; next } + fm == 1 { print; if ($0 == "---") fm = 2; next } + /^## (Gate Conclusion|Must-Fix Order)[[:space:]]*$/ { keep = 1; print ""; print; next } + /^## / { keep = 0 } + keep { print } + ' "$result" | tr -d '\r' + # The frontmatter writes qa, security and risk verdicts in its own words (pass, needs-tests, block) while the + # section headings keep the reviewer's own (approve, advise, block-soft, block). Print the headings' verdicts + # too, so an `advise` is never read as the `pass` the frontmatter shows for it. + awk ' + /^## [A-Za-z][A-Za-z-]* -- (approve|advise|block-soft|block|pass|needs-tests|skipped)[[:space:]]*\r?$/ { + name = $2; verdict = $4; sub(/\r$/, "", verdict) + if (!(name in seen)) { seen[name] = 1; order[++n] = name; v[name] = verdict } + } + END { + if (n == 0) exit + print "" + print "reviewer verdicts as the result sections state them (the frontmatter above may word them differently):" + for (i = 1; i <= n; i++) printf " %s: %s\n", order[i], v[order[i]] + } + ' "$result" + # Whether the file is authentic (artifact_valid), still describes the tree as it is now (subject_current) and + # permits the consumer's use (policy_applicable) are three answers, not one: a valid result can be stale, and an + # unqualified "accepted" would hide that. Relay verify's own lines for the three. + local vrc=0 vout="" + vout="$(pmctl_gate_verify "$repo_root" "$result" --cd "$work_dir" 2>&1)" || vrc=$? + printf 'verify: pmctl gate verify exit %s\n' "$vrc" + printf '%s\n' "$vout" | tr -d '\r' | grep -E '^(assurance|artifact_valid|subject_current|policy_applicable)' | sed 's/^/ /' || true + if [[ "$vrc" -ne 0 ]]; then + # shellcheck disable=SC2016 # literal backticks in the message. + printf 'verify: the result is not accepted; run `pmctl gate verify %q --cd %q` for the reason\n' "$result" "$work_dir" + fi + return 0 +} + +# pmctl gate mutations --preview [--cd ] [--base ] +# Before a gate: the test files this change touches, and the mutation classes qa-tester asks of each test ("would +# this test fail if the code were wrong in this way?"). qa-tester judges this per test; nothing here predicts its +# verdict or replaces running the tests. It only gives the implementer the same checklist first, so a weak test is +# found before a gate round instead of in it. --base defaults to HEAD, which lists uncommitted and untracked files. +pmctl_gate_mutations() { + local repo_root="${1:-}" + shift || true + local preview=false work_dir="" base="HEAD" + while [[ $# -gt 0 ]]; do + case "$1" in + --preview) preview=true; shift ;; + --cd) + [[ $# -ge 2 && -n "$2" ]] || { printf 'pmctl gate mutations: missing value for --cd\n' >&2; return 2; } + work_dir="$2"; shift 2 ;; + --base) + [[ $# -ge 2 && -n "$2" ]] || { printf 'pmctl gate mutations: missing value for --base\n' >&2; return 2; } + base="$2"; shift 2 ;; + *) printf 'pmctl gate mutations: unknown argument %s\n' "$1" >&2; return 2 ;; + esac + done + [[ "$preview" == true ]] || { printf 'pmctl gate mutations: usage: pmctl gate mutations --preview [--cd ] [--base ]\n' >&2; return 2; } + [[ -n "$work_dir" ]] || work_dir="$(_pmctl_gate_default_cd)" + git -C "$work_dir" rev-parse --git-dir >/dev/null 2>&1 || { printf 'pmctl gate mutations: %s is not a git repository\n' "$work_dir" >&2; return 2; } + git -C "$work_dir" rev-parse --verify --quiet "$base^{commit}" >/dev/null || { printf 'pmctl gate mutations: unknown base %s\n' "$base" >&2; return 2; } + + local changed tests + # NUL-delimited: a non-ASCII file name is listed as it is, not as git's quoted octal spelling (which no test pattern matches). + changed="$( { git -C "$work_dir" diff -z --name-only "$base" -- 2>/dev/null; git -C "$work_dir" ls-files -z --others --exclude-standard 2>/dev/null; } | tr '\0' '\n' | LC_ALL=C sort -u)" + tests="$(printf '%s\n' "$changed" | grep -E '(^|/)(tests?|spec|__tests__)/|(^|/)test[-_][^/]*$|[-_.]tests?\.[A-Za-z]+$|\.spec\.[A-Za-z]+$' || true)" + + printf 'mutation preview for %s (base %s)\n' "$work_dir" "$base" + if [[ -z "$tests" ]]; then + printf 'test files touched: none\n' + else + printf 'test files touched:\n' + printf '%s\n' "$tests" | sed 's/^/ /' + fi + printf '%s\n' \ + 'for each non-trivial test, ask whether it would fail if the code under test were changed like this:' \ + ' - the return value or output is off by one, rounded, truncated or inverted' \ + ' - the first or last element (or the only element) is used where all are expected' \ + ' - an exit status or error is dropped, swallowed or turned into success' \ + ' - a validation, guard or permission check is removed' \ + ' - a comparison or boundary operator is flipped (< for <=, == for !=)' \ + ' - a call is skipped, repeated or made in the wrong order' \ + ' - a default, empty input or missing value is treated as valid' \ + 'a test that survives such a change is nominal coverage. It counts as real only if you can name the change that breaks it.' \ + 'this list is a checklist, not a prediction of qa-tester: it does not run mutants and does not ask for a test per boundary.' + return 0 +} + # pmctl gate verify [--cd ] # [--consumer embedded|generic|maintainer|publish] [--json] # The default inspection mode preserves the historical "is this artifact diff --git a/tests/shell/test-gate-lifecycle.sh b/tests/shell/test-gate-lifecycle.sh index 00c83267..8f4be628 100755 --- a/tests/shell/test-gate-lifecycle.sh +++ b/tests/shell/test-gate-lifecycle.sh @@ -1766,6 +1766,173 @@ case_status_shows_phase_plan_log_and_pending_result() { fi } +case_status_and_wait_without_sentinel_key_name_the_published_result() { + local name="gate-lifecycle/gate status and wait name an unauthenticated published result when the sentinel key is gone" + should_run "$name" || return 0 + + local fixture="$tmp_root/cc wait key/fixture" work="$tmp_root/cc wait key/work" + mkdir -p "$work" + _mk_fixture_repo "$fixture" + local started_fifo release_fifo + started_fifo="$(mktemp -u)"; release_fifo="$(mktemp -u)" + mkfifo "$started_fifo" "$release_fifo" + _mk_fake_gate_phased "$fixture" "$started_fifo" "$release_fifo" + + local run_wrapper="$tmp_root/cc wait key/run" wait_wrapper="$tmp_root/cc wait key/wait" \ + status_wrapper="$tmp_root/cc wait key/status" key_wrapper="$tmp_root/cc wait key/key" + _run_gate_wrapper "$fixture" "$run_wrapper" + _wait_wrapper "$fixture" "$wait_wrapper" + _status_wrapper "$fixture" "$status_wrapper" + cat > "$key_wrapper" </dev/null 2>&1 || true + + # The run directory and its result, then the loss of the sentinel key (session cleanup, a new login). + local before run_dir result events + before="$("$status_wrapper" "$gate_id" --cd "$work" 2>&1)" || true + run_dir="$(printf '%s\n' "$before" | sed -n 's/^run dir: //p' | head -1)" + result="$run_dir/result.md" + events="$run_dir/gate-phase-events.jsonl" + rm -f "$("$key_wrapper" "$gate_id")" + + local status_out wait_out wait_code=0 status_code=0 + # A `gate end` event alone (the fake gate's own) names no published result: nothing may be claimed. + wait_out="$("$wait_wrapper" "$gate_id" --cd "$work" --timeout 5 2>&1)" || wait_code=$? + local no_event_ok=1 + [[ "$wait_code" -eq 3 && "$wait_out" == *"indeterminate: sentinel key absent"* && "$wait_out" != *"published result"* ]] || no_event_ok=0 + + # The gate's `publish end` event names a non-empty result file: both commands point at it, unverified. + printf '%s\n' "{\"kind\":\"gate_phase_event_v1\",\"ts\":\"2026-11-10T14:41:07+09:00\",\"epoch\":1794289267,\"phase\":\"publish\",\"event\":\"end\",\"detail\":\"result=$result\"}" >> "$events" + wait_code=0 + wait_out="$("$wait_wrapper" "$gate_id" --cd "$work" --timeout 5 2>&1)" || wait_code=$? + status_out="$("$status_wrapper" "$gate_id" --cd "$work" 2>&1)" || status_code=$? + # `gate result` finds the same file from the phase events and says whether verify accepted it. + local result_wrapper="$tmp_root/cc wait key/result" result_out result_code=0 + cat > "$result_wrapper" < "$result" + result_out="$("$result_wrapper" "$gate_id" --cd "$work" 2>&1)" || result_code=$? + local result_ok=0 + [[ "$result_code" -eq 0 && "$result_out" == *"gate: $gate_id"* && "$result_out" == *"result: $result"* \ + && "$result_out" == *"final: GO"* && "$result_out" == *"most_severe: advise"* \ + && "$result_out" == *"CONCLUSION-MARK"* && "$result_out" == *"MUSTFIX-MARK"* && "$result_out" != *"UNRELATED-MARK"* \ + && "$result_out" == *" qa-tester: advise"* && "$result_out" == *"verify: "* ]] && result_ok=1 + # Without a publish event the reader falls back to the results directory: it takes the newest non-empty result, + # and with none it reports exit 3 and prints no verdict. + local fb_ok=0 fb_out fb_code=0 fb_none_out fb_none_code=0 + grep -v '"phase":"publish"' "$events" > "$events.nopub" && mv "$events.nopub" "$events" + mkdir -p "$run_dir/.gate-results" + : > "$run_dir/.gate-results/gate-empty.md" + printf '%s\n' '---' 'final: NO-GO' '---' 'OLD-MARK' > "$run_dir/.gate-results/gate-old.md" + printf '%s\n' '---' 'final: GO' '---' 'NEW-MARK' > "$run_dir/.gate-results/gate-new.md" + touch -d '2020-01-01 00:00:00' "$run_dir/.gate-results/gate-old.md" + fb_out="$("$result_wrapper" "$gate_id" --cd "$work" 2>&1)" || fb_code=$? + rm -f "$run_dir/.gate-results/gate-old.md" "$run_dir/.gate-results/gate-new.md" + fb_none_out="$("$result_wrapper" "$gate_id" --cd "$work" 2>&1)" || fb_none_code=$? + [[ "$fb_code" -eq 0 && "$fb_out" == *"gate-new.md"* && "$fb_out" == *"final: GO"* && "$fb_out" != *"gate-old.md"* \ + && "$fb_none_code" -eq 3 && "$fb_none_out" != *"final:"* && "$fb_none_out" == *"no published result"* ]] && fb_ok=1 + # Every recovery hint quotes the paths it prints: a path with spaces stays one argument when pasted. + # The --cd value is printed in the form the command canonicalized it to (c:/... on native Windows), so only its + # tail is compared: the directory name with its space, escaped. + local quoted_result quoted_work_tail quoting_ok=0 + printf -v quoted_result '%q' "$result"; printf -v quoted_work_tail '%q' "cc wait key/work" + [[ "$wait_out" == *"pmctl gate verify $quoted_result --cd "*"$quoted_work_tail"* \ + && "$status_out" == *"pmctl gate verify $quoted_result --cd "*"$quoted_work_tail"* \ + && "$result_out" == *"pmctl gate verify $quoted_result --cd "*"$quoted_work_tail"* ]] && quoting_ok=1 + + if [[ "$no_event_ok" -eq 1 && "$result_ok" -eq 1 && "$fb_ok" -eq 1 ]] \ + && [[ "$wait_code" -eq 3 && "$wait_out" == *"indeterminate: sentinel key absent; completion is unverified"* \ + && "$wait_out" == *"published result for $gate_id: $result"* \ + && "$wait_out" == *"not authenticated by a sentinel"* && "$quoting_ok" -eq 1 ]] \ + && [[ "$status_code" -eq 0 && "$status_out" == *"state: ended per its phase events (exit=0)"* \ + && "$status_out" != *"state: starting"* \ + && "$status_out" == *"result: $result (published per the phase events; no authenticated sentinel, so not verified"* \ + && "$status_out" != *"not written yet"* ]]; then + pass "$name" + else + fail "$name" "no_event_ok=$no_event_ok result_ok=$result_ok fb_ok=$fb_ok fb=$fb_code:$fb_out none=$fb_none_code:$fb_none_out quoting_ok=$quoting_ok result=$result_code:$result_out wait=$wait_code:$wait_out status=$status_code:$status_out" + fi +} + +case_mutations_preview_lists_touched_tests_and_the_checklist() { + local name="gate-lifecycle/gate mutations --preview lists the touched test files and the mutation checklist" + should_run "$name" || return 0 + + local fixture="$tmp_root/cc-mut/fixture" repo="$tmp_root/cc-mut/repo" + _mk_fixture_repo "$fixture" + mkdir -p "$repo/src" "$repo/tests" + git -C "$repo" init -q + local g=(git -C "$repo" -c user.name=t -c user.email=t@example.com) + # History: a base commit with a test to be modified, an untouched control test and a source file; then a commit + # that adds one more test. Then, uncommitted: the first test modified, a non-test source change, an untracked test. + printf 'v1\n' > "$repo/tests/test_modified.py" + printf 'v1\n' > "$repo/tests/test_control.py" + printf 'v1\n' > "$repo/src/app.py" + git -C "$repo" add -A + "${g[@]}" commit -q -m base + printf 'v1\n' > "$repo/tests/test_in_head.py" + git -C "$repo" add -A + "${g[@]}" commit -q -m head + printf 'v2\n' > "$repo/tests/test_modified.py" + printf 'v2\n' > "$repo/src/app.py" + printf 'x\n' > "$repo/tests/test_new.py" + printf 'x\n' > "$repo/tests/test_café.py" # git quotes a non-ASCII name unless the listing is NUL-delimited + local wrapper="$tmp_root/cc-mut/mut" + cat > "$wrapper" <&1)" || code=$? + at_parent="$("$wrapper" --preview --cd "$repo" --base HEAD~1 2>&1)" || code=$? + "$wrapper" --cd "$repo" >/dev/null 2>&1 || bare_code=$? + _mut_count() { printf '%s\n' "$1" | grep -c "^ $2\$" || true; } + [[ "$code" -eq 0 && "$bare_code" -eq 2 ]] || { ok=0; detail+="[codes $code/$bare_code] "; } + # base HEAD: the modified tracked test and the untracked test, each once; no control, no head-commit test, no source + [[ "$(_mut_count "$at_head" tests/test_modified.py)" -eq 1 && "$(_mut_count "$at_head" tests/test_new.py)" -eq 1 ]] || { ok=0; detail+="[head: touched tests missing or repeated] "; } + [[ "$(_mut_count "$at_head" tests/test_café.py)" -eq 1 ]] || { ok=0; detail+="[head: non-ASCII test name lost] "; } + [[ "$(_mut_count "$at_head" tests/test_control.py)" -eq 0 && "$(_mut_count "$at_head" tests/test_in_head.py)" -eq 0 \ + && "$(_mut_count "$at_head" src/app.py)" -eq 0 ]] || { ok=0; detail+="[head: untouched or non-test file listed] "; } + # --base moves the comparison: against HEAD~1 the test added by the head commit is touched too + [[ "$(_mut_count "$at_parent" tests/test_in_head.py)" -eq 1 && "$(_mut_count "$at_parent" tests/test_modified.py)" -eq 1 \ + && "$(_mut_count "$at_parent" tests/test_new.py)" -eq 1 && "$(_mut_count "$at_parent" tests/test_control.py)" -eq 0 ]] || { ok=0; detail+="[--base ignored or wrong] "; } + [[ "$at_head" == *"an exit status or error is dropped"* && "$at_head" == *"not a prediction of qa-tester"* ]] || { ok=0; detail+="[checklist text] "; } + if [[ "$ok" -eq 1 ]]; then + pass "$name" + else + fail "$name" "$detail head=$at_head parent=$at_parent" + fi +} + case_wait_timeout_names_the_phase() { local name="gate-lifecycle/gate wait timeout says which phase the gate is in and points at gate status" should_run "$name" || return 0 @@ -1947,6 +2114,8 @@ case_phase_events_library() { case_detached_launch_returns_gate_id case_status_shows_phase_plan_log_and_pending_result +case_status_and_wait_without_sentinel_key_name_the_published_result +case_mutations_preview_lists_touched_tests_and_the_checklist case_wait_timeout_names_the_phase case_status_without_phase_events case_status_usage_and_unknown_gate diff --git a/tests/shell/test-guards.sh b/tests/shell/test-guards.sh index c4992e10..ed00e3fd 100755 --- a/tests/shell/test-guards.sh +++ b/tests/shell/test-guards.sh @@ -4199,6 +4199,52 @@ STUB rm -rf "$dir" } +ctx_inject_hook_reads_a_payload_piped_in_by_a_native_windows_host() { + # Regression (issue reported 2026-11-10): Claude Code and PowerShell start Git Bash with an anonymous Windows pipe as + # stdin. There /dev/stdin cannot be opened, so `payload=$(/dev/null 2>&1 || { skip "$name" "sqlite3 not on PATH"; return 0; } + command -v powershell.exe >/dev/null 2>&1 || { skip "$name" "powershell.exe not on PATH"; return 0; } + local dir repo args status bash_win hook_posix stdout stderr + dir="$(mktemp -d)"; repo="$dir/repo"; args="$dir/scan-args" + mkdir -p "$repo/.pm-dispatch/ctx" + git -C "$repo" init -q + bash_win="$(cygpath -w "$(type -P bash)")" + hook_posix="$CTX_HOOK" + cat > "$dir/fake-pmctl" < "$args" + printf 'knowledge_hits:\n - ref: docs/notes.md:1\n' +fi +STUB + chmod +x "$dir/fake-pmctl" + cat > "$dir/pipe.ps1" <"$dir/err")" || status=$? + stderr="$(cat "$dir/err" 2>/dev/null || true)" + if [[ "$status" == "0" && -z "$stderr" && "$stdout" == *"docs/notes.md:1"* \ + && "$(cat "$args" 2>/dev/null || true)" == "$(git -C "$repo" rev-parse --show-toplevel)|tell me about winpipeterm behavior|" ]]; then + PASS=$((PASS+1)) + else + FAIL=$((FAIL+1)); FAILED_CASES+=("$name") + printf ' FAIL %s — exit=%s stdout=%q stderr=%q scan_args=%q\n' "$name" "$status" "$stdout" "$stderr" "$(cat "$args" 2>/dev/null || true)" + fi + rm -rf "$dir" +} + ctx_inject_hook_sqlite_missing_skips_pmctl() { # Verifies sqlite3 is an optional capability gate: without it the hook never # invokes pmctl and never creates a context DB. @@ -4208,6 +4254,8 @@ ctx_inject_hook_sqlite_missing_skips_pmctl() { # 3. Assert silent exit 0, no marker, and no DB local name="ctx-inject-hook/sqlite-missing-skips-pmctl" should_run "$name" || return 0 + # Builds a PATH of links to the real tools: native Git Bash's `ln -s` copies the .exe without the DLLs next to it. + th_require_symlinks "$name" || return 0 local dir repo bin marker output status cmd dir="$(mktemp -d)"; repo="$dir/repo"; bin="$dir/bin"; marker="$dir/pmctl-called" mkdir -p "$repo/docs" "$bin" @@ -4987,6 +5035,7 @@ ctx_inject_hook_no_db_auto_builds ctx_inject_hook_marker_round_trip ctx_inject_hook_sqlite_missing_skips_pmctl ctx_inject_hook_needs_only_bash_git_jq_mkdir +ctx_inject_hook_reads_a_payload_piped_in_by_a_native_windows_host ctx_inject_hook_initial_timeout_removes_empty_db ctx_inject_hook_existing_empty_db_reenters_initial_cleanup ctx_inject_hook_invalid_timeout_preserves_phase_default diff --git a/tests/shell/test-host-manifest.sh b/tests/shell/test-host-manifest.sh index c9b587ff..ac3c1e17 100755 --- a/tests/shell/test-host-manifest.sh +++ b/tests/shell/test-host-manifest.sh @@ -567,7 +567,8 @@ for needle in \ "hook-script"; do name="host-contract.md: documents '$needle'" should_run "$name" || continue - if grep -qiF "$needle" "$CONTRACT_DOC"; then + # Case-insensitive fixed-string match in bash: `grep -Fi` aborts (core dump) in the GNU grep 3.0 of some Git for Windows installs. + if [[ "$(tr '[:upper:]' '[:lower:]' < "$CONTRACT_DOC")" == *"${needle,,}"* ]]; then pass "$name" else fail "$name" "contract doc lost its '$needle' section" diff --git a/tests/shell/test-host-write-parity.sh b/tests/shell/test-host-write-parity.sh index 353a2366..afabf6d7 100755 --- a/tests/shell/test-host-write-parity.sh +++ b/tests/shell/test-host-write-parity.sh @@ -64,6 +64,7 @@ make_relocated_opencode_fixture() { cp "$REPO_ROOT/runtime/lib/host-resolver.sh" "$root/runtime/lib/host-resolver.sh" cp "$REPO_ROOT/runtime/lib/host-write.sh" "$root/runtime/lib/host-write.sh" cp "$REPO_ROOT/runtime/lib/portable.sh" "$root/runtime/lib/portable.sh" + cp "$REPO_ROOT/runtime/lib/jq-lf.sh" "$root/runtime/lib/jq-lf.sh" cp "$REPO_ROOT/hosts/opencode/bin/install.sh" "$root/hosts/opencode/bin/install.sh" cp "$REPO_ROOT/hosts/opencode/bin/uninstall.sh" "$root/hosts/opencode/bin/uninstall.sh" } @@ -79,6 +80,7 @@ make_relocated_codex_fixture() { cp "$REPO_ROOT/runtime/lib/host-resolver.sh" "$root/runtime/lib/host-resolver.sh" cp "$REPO_ROOT/runtime/lib/host-write.sh" "$root/runtime/lib/host-write.sh" cp "$REPO_ROOT/runtime/lib/portable.sh" "$root/runtime/lib/portable.sh" + cp "$REPO_ROOT/runtime/lib/jq-lf.sh" "$root/runtime/lib/jq-lf.sh" cp "$REPO_ROOT/hosts/codex/bin/install.sh" "$root/hosts/codex/bin/install.sh" cp "$REPO_ROOT/hosts/codex/bin/uninstall.sh" "$root/hosts/codex/bin/uninstall.sh" cp "$REPO_ROOT/hosts/codex/hooks/command-guard.sh" "$root/hosts/codex/hooks/command-guard.sh" @@ -121,6 +123,12 @@ test_claude_uninstall_surface_stays_symmetric() { fi } +# The same directory in the spelling the product records: on native Windows it writes the mixed form (C:/Users/...) +# where the fixture path is the POSIX one (/c/Users/...); elsewhere the two are equal. +native_spelling() { + cygpath -m "$1" 2>/dev/null || printf '%s' "$1" +} + # Behavior: manifest modules remain runnable after their directory depth changes. # Steps: relocate both OpenCode modules in a fixture, dispatch install/uninstall, # and verify the explicit repository ABI owns and removes the resulting receipt. @@ -140,8 +148,10 @@ test_relocated_module_uses_explicit_repo_root() { HOME="$tmp_root/relocated/home" XDG_CONFIG_HOME="$xdg" TMPDIR="$tmp_root/relocated" \ host_write_install "$fixture" opencode 0 >/dev/null ) - if [[ ! -f "$receipt" ]] || [[ "$(jq -r '.repo_root' "$receipt")" != "$fixture" ]]; then - fail "$name" "relocated install did not record the dispatcher-supplied repository root" + local recorded="" + [[ ! -f "$receipt" ]] || recorded="$(jq -r '.repo_root' "$receipt")" + if [[ -z "$recorded" || ( "$recorded" != "$fixture" && "$recorded" != "$(native_spelling "$fixture")" ) ]]; then + fail "$name" "relocated install did not record the dispatcher-supplied repository root (receipt: ${recorded:-none}, fixture: $fixture)" return fi ( @@ -176,11 +186,20 @@ test_relocated_codex_module_uses_explicit_repo_root() { HOME="$tmp_root/relocated-codex/operator-home" CODEX_HOME="$codex" TMPDIR="$tmp_root/relocated-codex" \ host_write_install "$fixture" codex 0 >/dev/null ) - if ! jq -e --arg root "$fixture" ' + # The root appears in the hook commands and in AGENTS.md in the forms the platform needs: a bare path on Linux; on + # native Windows `bash ''` (a script cannot be run directly) and, where the path has a space, backslash-escaped + # in AGENTS.md. Match the root in any of its spellings and forms. + local fixture_native fixture_escaped + fixture_native="$(native_spelling "$fixture")" + printf -v fixture_escaped '%q' "$fixture" + if ! jq -e --arg root "$fixture" --arg native "$fixture_native" ' [.hooks[][]?.hooks[]?.command] | - any(startswith($root + "/hosts/codex/") or startswith($root + "/runtime/hooks/")) + any(contains($root + "/hosts/codex/") or contains($root + "/runtime/hooks/") + or contains($native + "/hosts/codex/") or contains($native + "/runtime/hooks/")) ' "$hooks" >/dev/null \ - || ! grep -Fq "$fixture/hosts/codex/bin/memory-update.sh" "$codex/AGENTS.md"; then + || ! { grep -Fq "$fixture/hosts/codex/bin/memory-update.sh" "$codex/AGENTS.md" \ + || grep -Fq "$fixture_native/hosts/codex/bin/memory-update.sh" "$codex/AGENTS.md" \ + || grep -Fq "$fixture_escaped/hosts/codex/bin/memory-update.sh" "$codex/AGENTS.md"; }; then fail "$name" "relocated Codex install did not use the dispatcher-supplied repository root" return fi diff --git a/tests/shell/test-lint-model-aliases.sh b/tests/shell/test-lint-model-aliases.sh index deaeb70f..b8953ff4 100755 --- a/tests/shell/test-lint-model-aliases.sh +++ b/tests/shell/test-lint-model-aliases.sh @@ -53,7 +53,8 @@ _run_linter_expect() { return 0 fi - if [[ "$exit_code" -ne 0 ]] && grep -Fqi -- "$expected_stderr" <<<"$out"; then + # Case-insensitive fixed-string match in bash: `grep -Fi` aborts (core dump) in the GNU grep 3.0 of some Git for Windows installs. + if [[ "$exit_code" -ne 0 && "${out,,}" == *"${expected_stderr,,}"* ]]; then pass "$name" else fail "$name" "exit=$exit_code out=$(head -1 <<<"$out")" diff --git a/tests/shell/test-pm-prep-snapshot.sh b/tests/shell/test-pm-prep-snapshot.sh index 03170312..326c5886 100755 --- a/tests/shell/test-pm-prep-snapshot.sh +++ b/tests/shell/test-pm-prep-snapshot.sh @@ -419,7 +419,8 @@ fi # 1. Create a directory with mode 500 (no write permission). # 2. Run pm-prep-snapshot.sh --out /out.md. # 3. Assert exit is non-zero and error output contains "cannot write to output path". -if should_run "error-hard-unwritable-output"; then +# The directory is made unwritable with chmod 500, which native Git Bash neither sets nor enforces. +if should_run "error-hard-unwritable-output" && th_require_perm_enforcement "error-hard-unwritable-output"; then ro="$tmp_root/unwritable" error_log="$tmp_root/unwritable-output.err" mkdir -p "$ro" @@ -520,7 +521,8 @@ fi # 2. Build a shadow dir with symlinks to all tools in that directory except jq. # 3. Run pm-prep-snapshot.sh with PATH="$FAKE_GH_DIR:$shadow_dir:remaining_path". # 4. Assert exit 0, output contains "# warn: jq command not found", recently_merged: []. -if should_run "recently-merged-no-jq-warns"; then +# The PATH without jq is built from links to the real tools; native Git Bash copies the .exe files without their DLLs. +if should_run "recently-merged-no-jq-warns" && th_require_symlinks "recently-merged-no-jq-warns"; then _jq_real="$(type -P jq 2>/dev/null || true)" _out="$tmp_root/no-jq.md" if [[ -z "$_jq_real" ]]; then diff --git a/tests/shell/test-pmctl-gate.sh b/tests/shell/test-pmctl-gate.sh index 4b7121ef..07e78d17 100755 --- a/tests/shell/test-pmctl-gate.sh +++ b/tests/shell/test-pmctl-gate.sh @@ -1388,6 +1388,34 @@ case_verify_v3_three_axes_current() { fi } +case_result_shows_the_three_verify_axes_after_repository_drift() { + # Behavior: `pmctl gate result ` prints the three answers of the real verifier on separate lines, run from a + # directory other than the reviewed repository (--cd names it). A current result passes all three; the same result + # after the producer's recorded head drifted is still an authentic artifact but no longer describes the subject, + # and the reader must say so instead of reporting one plain acceptance. + local name="gate/result: shows artifact_valid, subject_current and policy_applicable apart after repository drift" + should_run "$name" || return 0 + local result sidecar current drifted other code=0 ok=1 detail="" + result="$(_gate_verify_result_path result-axes)" + sidecar="${result}.assurance.json" + _mk_gate_result_v3_verified "$result" "$_GATE_VERIFY_REPO" + other="$(mktemp -d)" + current="$(cd "$other" && PM_DISPATCH_STATE_ROOT="$_GATE_VERIFY_STATE_ROOT" "$PMCTL" gate result "$result" --cd "$_GATE_VERIFY_REPO" 2>&1)" || code=$? + [[ "$code" -eq 0 && "$current" == *"artifact_valid: pass"* && "$current" == *"subject_current: pass"* \ + && "$current" == *"policy_applicable:"* \ + && "$current" == *"verify: pmctl gate verify exit"* ]] || { ok=0; detail+="[current: $code $current] "; } + jq --arg value "$(printf '%040d' 0)" '.subject.observed_at_finish.head_commit = $value' "$sidecar" > "${sidecar}.tmp" + mv "${sidecar}.tmp" "$sidecar" + _refresh_gate_result_v3_attestation "$result" + code=0 + drifted="$(cd "$other" && PM_DISPATCH_STATE_ROOT="$_GATE_VERIFY_STATE_ROOT" "$PMCTL" gate result "$result" --cd "$_GATE_VERIFY_REPO" 2>&1)" || code=$? + [[ "$code" -eq 0 && "$drifted" == *"artifact_valid: pass"* && "$drifted" == *"subject_current: fail"* \ + && "$drifted" != *"subject_current: pass"* && "$drifted" == *"policy_applicable:"* \ + && "$drifted" == *"producer_head_drift"* ]] || { ok=0; detail+="[drifted: $code $drifted] "; } + rm -rf "$other" + if [[ "$ok" -eq 1 ]]; then pass "$name"; else fail "$name" "$detail"; fi +} + case_verify_v3_producer_drift_reason_codes() { local name="gate/verify: v3 producer-finish drift reasons are observable" should_run "$name" || return 0 @@ -3629,6 +3657,7 @@ case_verify_v2_named_consumer_is_not_authorizing case_verify_v2_canonical_authorization case_verify_v3_three_axes_current case_verify_v3_producer_drift_reason_codes +case_result_shows_the_three_verify_axes_after_repository_drift case_verify_v3_policy_reason_codes case_verify_v3_policy_compatibility_matrix case_verify_v3_unknown_policy_consumer_fails_closed diff --git a/tests/shell/test-pmctl-memory.sh b/tests/shell/test-pmctl-memory.sh index 9662c369..ecda7708 100755 --- a/tests/shell/test-pmctl-memory.sh +++ b/tests/shell/test-pmctl-memory.sh @@ -2819,6 +2819,8 @@ case_memory_usage_tsv_tab_and_newline_round_trip() { case_memory_usage_sqlite_tab_and_newline_round_trip() { local name="memory usage sidecar: SQLite round-trips tab and newline relpaths" should_run "$name" || return 0 + # Uses file names with a tab or newline, which Windows file names cannot hold. + th_require_special_filenames "$name" || return 0 command -v sqlite3 >/dev/null 2>&1 || { fail "$name" "sqlite3 is required to execute this case's SQLite-backed assertions; a missing dependency must not be reported as a silent pass"; return 0; } local sidecar="$tmp_root/usage-sqlite/.pm-dispatch/inject-usage.sqlite3" today @@ -2837,6 +2839,8 @@ case_memory_usage_sqlite_tab_and_newline_round_trip() { case_memory_usage_sqlite_imports_legacy_tsv_history() { local name="memory usage sidecar: SQLite legacy TSV import preserves counts" should_run "$name" || return 0 + # Uses file names with a tab or newline, which Windows file names cannot hold. + th_require_special_filenames "$name" || return 0 command -v sqlite3 >/dev/null 2>&1 || { fail "$name" "sqlite3 is required to execute this case's SQLite-backed assertions; a missing dependency must not be reported as a silent pass"; return 0; } local base sqlite today @@ -2879,6 +2883,8 @@ case_memory_usage_sqlite_imports_legacy_tsv_history() { case_memory_usage_sqlite_prefix_row_survives_upgrade() { local name="memory usage sidecar: pre-fix SQLite row with raw backslash-t is not corrupted" should_run "$name" || return 0 + # Uses file names with a tab or newline, which Windows file names cannot hold. + th_require_special_filenames "$name" || return 0 command -v sqlite3 >/dev/null 2>&1 || { fail "$name" "sqlite3 is required to execute this case's SQLite-backed assertions; a missing dependency must not be reported as a silent pass"; return 0; } local base sqlite today @@ -3585,6 +3591,8 @@ case_memory_applied_scan_brief_never_blocks() { case_memory_applied_scan_brief_refuses_symlinked_targets() { local name="memory_applied_scan_brief: refuses symlinked .pm-dispatch dir or applied-usage.tsv target" should_run "$name" || return 0 + # Needs a real symlink. + th_require_symlinks "$name" || return 0 local brief_content='schema_version: 1 working_dir: /tmp @@ -3667,6 +3675,8 @@ acceptance: case_memory_applied_record_refuses_race_swap_before_open() { local name="memory_applied_record: refuses a sidecar swapped to a symlink between the write-guard check and the final open (flock and mkdir-lock paths)" should_run "$name" || return 0 + # Needs a real symlink. + th_require_symlinks "$name" || return 0 local mode for mode in flock mkdir; do @@ -4256,6 +4266,8 @@ case_memory_append_episode_skeleton_flag_rejected() { case_memory_append_episode_refuses_symlink() { local name="pmctl memory append-episode: refuses symlink target" should_run "$name" || return 0 + # Needs a real symlink. + th_require_symlinks "$name" || return 0 local repo="$tmp_root/append-symlink-repo" mdir="$tmp_root/append-symlink-memory" target="$tmp_root/external-episodes" out status=0 mkdir -p "$repo" "$mdir" git -C "$repo" init -q @@ -4308,6 +4320,8 @@ case_memory_append_episode_symlink_swap_race() { case_memory_append_episode_refuses_symlink_lock_dir() { local name="pmctl memory append-episode: refuses symlink lock directory" should_run "$name" || return 0 + # Needs a real symlink. + th_require_symlinks "$name" || return 0 local repo="$tmp_root/append-lock-repo" mdir="$tmp_root/append-lock-memory" external="$tmp_root/append-lock-external" out status=0 mkdir -p "$repo" "$mdir" "$external" git -C "$repo" init -q diff --git a/tests/shell/test-pmctl-ship-finish.sh b/tests/shell/test-pmctl-ship-finish.sh index 7a2449b1..fda4b963 100755 --- a/tests/shell/test-pmctl-ship-finish.sh +++ b/tests/shell/test-pmctl-ship-finish.sh @@ -2642,6 +2642,8 @@ case_finish_supplied_gate_result_head_moved_refuses_push() { case_finish_gh_missing_refuses_before_gate_or_push() { local name="ship finish: gh unavailable refuses before the gate even runs -- no push, no gate round spent" should_run "$name" || return 0 + # The PATH without gh is built from links to the real tools; native Git Bash copies the .exe files without their DLLs. + th_require_symlinks "$name" || return 0 local work out err status=0 work="$tmp_root/work-finish-nogh" make_work_repo "$work" "CC-9001" diff --git a/tests/shell/test-pmctl-task.sh b/tests/shell/test-pmctl-task.sh index 59024b7e..ee70bb33 100755 --- a/tests/shell/test-pmctl-task.sh +++ b/tests/shell/test-pmctl-task.sh @@ -725,6 +725,8 @@ case_task_create_event_failure_cleanup_fails() { case_task_create_write_failure_no_event() { local name="pmctl task create: projection write failure exits non-zero with no event row" should_run "$name" || return 0 + # The failure is made with chmod 555 on the tasks directory; native Git Bash neither sets nor enforces it. + th_require_perm_enforcement "$name" || return 0 # Behavior: when task_upsert cannot write the projection file, pmctl task create exits non-zero # and does NOT append a task.created event to events.jsonl. # Steps: initialize state store via a successful create; make tasks dir unwritable; attempt a diff --git a/tests/shell/test-pr-gate.sh b/tests/shell/test-pr-gate.sh index a75c2517..f7c54a7d 100755 --- a/tests/shell/test-pr-gate.sh +++ b/tests/shell/test-pr-gate.sh @@ -103,6 +103,11 @@ else . "$runner_root/runtime/lib/test-pr-gate-fixture.sh" fi +# Record which adapter was dispatched (the claude stub is a copy of this file): the directory this file sits in. +if [[ -n "${PM_TEST_ADAPTER_LOG:-}" ]]; then + _adapter_dir="$(dirname "${BASH_SOURCE[0]}")" + printf '%s\n' "$(basename "$_adapter_dir")" >> "$PM_TEST_ADAPTER_LOG" +fi # Capture raw dispatch args for isolation-forwarding tests. if [[ -n "${CODEX_GATE_CAPTURE_DISPATCH_ARGS:-}" ]]; then printf '%s\n' "$@" > "$CODEX_GATE_CAPTURE_DISPATCH_ARGS" @@ -485,6 +490,10 @@ if [[ "$effective_mode" == fail \ effective_mode=success fi +if [[ "${CODEX_GATE_STUB_QUOTE_QUOTA:-}" == 1 ]]; then + # A review that discusses quota handling quotes the wording without having hit any limit. + printf "NOTE: the reviewed change detects \"You've hit your usage limit ... try again at 9:15 PM\".\n" >&2 +fi case "$effective_mode" in fail) # Non-zero exit — caught by FAILED_REVIEWERS check. @@ -511,6 +520,16 @@ case "$effective_mode" in printf 'ERROR codex_core::tools::router: error=exec_command failed: CreateProcess { message: "Rejected(\"Failed to create unified exec process: helper_unknown_error: setup refresh had errors\")" }\n' >&2 exit 0 ;; + quota-exhausted) + # Fails like the real adapter does when the plan quota is used up: the executor's own usage-limit line goes to + # the per-run .stderr file next to the trace, and only the banner naming that trace reaches this reviewer's + # dispatch log (which is all pr-gate captures). Exits non-zero, as the real adapter does. + stub_trace="$(dirname "$brief_file")/codex-stub-$$.jsonl" + printf ' trace: %s\n' "$stub_trace" >&2 + printf "ERROR: You've hit your usage limit. Upgrade to Pro or try again at 9:15 PM.\n" > "${stub_trace%.jsonl}.stderr" + : > "$stub_trace" + exit 1 + ;; no-verdict) # Writes a non-empty output file but omits the Verdict line — simulates # malformed reviewer output (caught by reviewer structure validation). @@ -3372,6 +3391,132 @@ test_reviewer_sandbox_unavailable_skips_retry() { pass "$name" } +# Behavior: a reviewer whose executor quota is used up fails the gate at once, without a wasted retry, and the +# report carries the executor's own reset hint and says the reviewer is not switched. +test_reviewer_quota_exhausted_names_reset_and_skips_retry() { + local name="reviewer-quota-exhausted-names-reset-and-skips-retry" + should_run "$name" || return 0 + local dir="$TMP_ROOT/$name" + local home="$dir/home" repo="$dir/repo" runner="$dir/runner" + local out="$dir/out" err="$dir/err" + mkdir -p "$dir" + create_runner "$runner" + create_agents "$home" critic qa-tester architecture-reviewer security-reviewer risk-reviewer + create_repo "$repo" docs + + set +e + CODEX_GATE_STUB_MODE=quota-exhausted run_gate "$home" "$runner" "$repo" "$out" "$err" --base main --parallel + local code=$? + set -e + if [[ "$code" -eq 0 ]]; then + fail "$name" "expected non-zero exit when every reviewer hits the quota signature" + return + fi + assert_not_contains "$name" "$out" "retrying once" || return + assert_file_contains "$name" "$err" "executor quota exhausted" || return + assert_file_contains "$name" "$err" "try again at 9:15 PM" || return + assert_file_contains "$name" "$err" "not switched automatically" || return + pass "$name" +} + +# Behavior: reviewers that produced a valid result are never classified by quota wording: a review of quota-handling code +# quotes it. Contrast with the failing case above, where the same wording means the executor really stopped. +test_reviewer_quoting_quota_wording_is_accepted() { + local name="reviewer-quoting-quota-wording-is-accepted" + should_run "$name" || return 0 + local dir="$TMP_ROOT/$name" + local home="$dir/home" repo="$dir/repo" runner="$dir/runner" + local out="$dir/out" err="$dir/err" + mkdir -p "$dir" + create_runner "$runner" + create_agents "$home" critic qa-tester architecture-reviewer security-reviewer risk-reviewer + create_repo "$repo" docs + + set +e + CODEX_GATE_STUB_QUOTE_QUOTA=1 run_gate "$home" "$runner" "$repo" "$out" "$err" --base main --parallel + local code=$? + set -e + if [[ "$code" -ne 0 ]]; then + fail "$name" "a reviewer that only quoted the usage-limit wording was rejected (exit $code): $(cat "$err" 2>/dev/null | tail -5)" + return + fi + assert_not_contains "$name" "$err" "executor quota exhausted" || return + pass "$name" +} + +# Behavior: gate.reviewer_executor in the config file pins the reviewer. The gate uses it when --executor is absent +# and refuses a different --executor with a message naming the pin, so the review cannot move to another model. +test_config_pins_reviewer_executor() { + local name="config-pins-reviewer-executor" + should_run "$name" || return 0 + local dir="$TMP_ROOT/$name" + local home="$dir/home" runner="$dir/runner" + local out="$dir/out" err="$dir/err" code repo + mkdir -p "$dir" + create_runner "$runner" + create_agents "$home" critic qa-tester architecture-reviewer security-reviewer risk-reviewer + create_repo "$dir/repo-conflict" docs + + # 1. A different --executor than the pin is refused, naming the pin. + printf 'gate.reviewer_executor = codex\n' > "$dir/config" + set +e + PM_DISPATCH_CONFIG_FILE="$dir/config" run_gate "$home" "$runner" "$dir/repo-conflict" "$out" "$err" --base main --parallel --executor claude + code=$? + set -e + if [[ "$code" -ne 2 ]]; then + fail "$name" "expected exit 2 when --executor claude contradicts the pinned codex reviewer, got $code" + return + fi + assert_file_contains "$name" "$err" "pins the reviewer to codex" || return + assert_file_contains "$name" "$err" "--executor claude is refused" || return + + # 2. The pin selects the executor when --executor is absent or auto. The runner registers both a codex and a claude + # adapter, so a run succeeds either way; what shows the choice is the adapter every reviewer dispatch was routed + # to (the fixture pmctl records it). Pin claude, and every dispatch must go to claude even though automatic + # selection picks codex; remove the pin assignment and they go to codex. + printf 'gate.reviewer_executor = claude\n' > "$dir/config-claude" + local selector + for selector in omitted auto; do + repo="$dir/repo-claude-$selector" + create_repo "$repo" docs + set +e + : > "$dir/adapters-$selector" + if [[ "$selector" == auto ]]; then + PM_TEST_ADAPTER_LOG="$dir/adapters-$selector" PM_DISPATCH_CONFIG_FILE="$dir/config-claude" run_gate "$home" "$runner" "$repo" "$out" "$err" --base main --parallel --executor auto + else + PM_TEST_ADAPTER_LOG="$dir/adapters-$selector" PM_DISPATCH_CONFIG_FILE="$dir/config-claude" run_gate "$home" "$runner" "$repo" "$out" "$err" --base main --parallel + fi + code=$? + set -e + if [[ "$code" -ne 0 ]]; then + fail "$name" "the gate did not run with the config pinning claude and --executor $selector (exit $code): $(tail -3 "$err" 2>/dev/null)" + return + fi + if [[ ! -s "$dir/adapters-$selector" ]] || grep -qv '^claude$' "$dir/adapters-$selector"; then + fail "$name" "the config pinned claude but the reviewer dispatches for --executor $selector went to: $(sort -u "$dir/adapters-$selector" | tr '\n' ' ')" + return + fi + done + + # 3. Pinned to codex with --executor omitted, the briefs name codex. + repo="$dir/repo-codex" + create_repo "$repo" docs + : > "$dir/adapters-codex" + set +e + PM_TEST_ADAPTER_LOG="$dir/adapters-codex" PM_DISPATCH_CONFIG_FILE="$dir/config" run_gate "$home" "$runner" "$repo" "$out" "$err" --base main --parallel + code=$? + set -e + if [[ "$code" -ne 0 ]]; then + fail "$name" "the gate did not run with the config pinning codex and --executor omitted (exit $code): $(tail -3 "$err" 2>/dev/null)" + return + fi + if [[ ! -s "$dir/adapters-codex" ]] || grep -qv '^codex$' "$dir/adapters-codex"; then + fail "$name" "the config pinned codex but the reviewer dispatches went to: $(sort -u "$dir/adapters-codex" | tr '\n' ' ')" + return + fi + pass "$name" +} + # Behavior: sequential mode exiting 0 without writing reviewer protocol # evidence fails closed before reporting a result. # Steps: @@ -7630,6 +7775,9 @@ run_test test_reviewer_heading_only_verdict_is_accepted run_test test_reviewer_heading_and_explicit_verdict_must_agree run_test test_reviewer_no_output_aborts_gate run_test test_reviewer_sandbox_unavailable_skips_retry +run_test test_reviewer_quota_exhausted_names_reset_and_skips_retry +run_test test_reviewer_quoting_quota_wording_is_accepted +run_test test_config_pins_reviewer_executor run_test test_sequential_no_output_aborts_gate run_test test_sequential_no_final_line_aborts_gate run_test test_sequential_timeout_preserves_partial_result diff --git a/tests/shell/test-run-tests-in-wsl.sh b/tests/shell/test-run-tests-in-wsl.sh index 423910f8..de122c90 100755 --- a/tests/shell/test-run-tests-in-wsl.sh +++ b/tests/shell/test-run-tests-in-wsl.sh @@ -208,4 +208,93 @@ if should_run "run-tests-in-wsl/changed-paths"; then rm -rf "$repo" "$out" fi +# wsl-run.sh (script files and pmctl arguments sent to WSL without re-quoting): usage errors and unreadable or CRLF +# input are refused before WSL is needed, so they run on any host. The WSL round trip itself is exercised by hand. +WSL_RUN="$REPO_ROOT/ops/diagnostics/wsl-run.sh" +run_wsl_run() { + local expected="$1" needle="$2" name="$3" + shift 3 + should_run "$name" || return 0 + local output status + output="$(bash "$WSL_RUN" "$@" 2>&1)" + status=$? + if [[ "$status" -eq "$expected" && "$output" == *"$needle"* ]]; then + pass "$name" + else + fail "$name" "expected exit $expected containing '$needle'; got exit $status: $output" + fi +} +run_wsl_run 2 "usage:" "wsl-run/no-mode-is-usage-error" +run_wsl_run 2 "unknown mode" "wsl-run/unknown-mode" bogus +run_wsl_run 2 "no such file" "wsl-run/missing-script-file" script /no/such/file.sh +if should_run "wsl-run/crlf-script-is-refused"; then + _crlf_dir="$(mktemp -d)" + printf 'echo hi\r\n' > "$_crlf_dir/crlf.sh" + _out="$(bash "$WSL_RUN" script "$_crlf_dir/crlf.sh" 2>&1)"; _st=$? + if [[ "$_st" -eq 2 && "$_out" == *"CRLF line endings"* ]]; then pass "wsl-run/crlf-script-is-refused"; else fail "wsl-run/crlf-script-is-refused" "exit $_st: $_out"; fi + rm -rf "$_crlf_dir" +fi +# The refusal must not catch an ordinary LF script: with WSL absent it gets past the CRLF check and stops at +# "wsl.exe not found" (exit 3); with WSL present it runs. Exit 2 or "CRLF" would be the false refusal. +if should_run "wsl-run/lf-script-is-not-refused"; then + _lf_dir="$(mktemp -d)" + printf 'echo hi\n' > "$_lf_dir/lf.sh" + _out="$(bash "$WSL_RUN" script "$_lf_dir/lf.sh" 2>&1)"; _st=$? + if [[ "$_out" != *"CRLF"* && "$_st" -ne 2 ]]; then pass "wsl-run/lf-script-is-not-refused"; else fail "wsl-run/lf-script-is-not-refused" "exit $_st: $_out"; fi + rm -rf "$_lf_dir" +fi + +# The WSL round trip: arguments with spaces, an apostrophe, `$`, a command substitution and a backquote reach the script as +# the exact bytes given, and none of them is executed on the way (the failure `wsl.exe -e bash -lc ''` has). +# Needs a working WSL; elsewhere it is skipped with that reason. +if should_run "wsl-run/argv-reaches-the-script-unchanged"; then + if [[ "${OSTYPE:-}" != msys* && "${OSTYPE:-}" != cygwin* ]]; then + skip "wsl-run/argv-reaches-the-script-unchanged" "needs native Windows: from inside WSL the script path is already a WSL path" + elif ! command -v wsl.exe >/dev/null 2>&1 || ! wsl.exe -e true >/dev/null 2>&1; then + skip "wsl-run/argv-reaches-the-script-unchanged" "WSL is not available on this host" + else + _argv_dir="$(mktemp -d)" + # shellcheck disable=SC2016 # literal on purpose: these words must reach the program unexpanded + printf '%s\n' '#!/usr/bin/env bash' 'for a in "$@"; do printf "<%s>\n" "$a"; done' 'exit 7' > "$_argv_dir/args.sh" + _rc=0 + # shellcheck disable=SC2016 # literal on purpose: these words must reach the program unexpanded + _out="$(bash "$WSL_RUN" script "$_argv_dir/args.sh" 'a b' "it's" '$HOME' '$(echo INJECTED1)' '`echo INJECTED2`' 'x;echo INJECTED3' 2>&1)" || _rc=$? + # shellcheck disable=SC2016 # literal on purpose: these words must reach the program unexpanded + _want="$(printf '%s\n' '' "" '<$HOME>' '<$(echo INJECTED1)>' '<`echo INJECTED2`>' '')" + if [[ "$_out" == "$_want" && "$_rc" -eq 7 ]]; then + pass "wsl-run/argv-reaches-the-script-unchanged" + else + fail "wsl-run/argv-reaches-the-script-unchanged" "exit $_rc (want 7), got: $_out" + fi + rm -rf "$_argv_dir" + fi +fi + +# pmctl mode passes the arguments and the exit status through unchanged. The pmctl that runs inside WSL is a fixture (a +# script that prints each argument and exits 7), so the boundary is real and nothing else is installed; with a real +# pmctl a swallowed status would hide a gate NO-GO the same way. +if should_run "wsl-run/pmctl-mode-keeps-arguments-and-exit-status"; then + if [[ "${OSTYPE:-}" != msys* && "${OSTYPE:-}" != cygwin* ]]; then + skip "wsl-run/pmctl-mode-keeps-arguments-and-exit-status" "needs native Windows: from inside WSL the script path is already a WSL path" + elif ! command -v wsl.exe >/dev/null 2>&1 || ! wsl.exe -e true >/dev/null 2>&1; then + skip "wsl-run/pmctl-mode-keeps-arguments-and-exit-status" "WSL is not available on this host" + else + _pm_dir="$(mktemp -d)" + # shellcheck disable=SC2016 # literal on purpose: these words must reach the program unexpanded + printf '%s\n' '#!/usr/bin/env bash' 'for a in "$@"; do printf "<%s>\n" "$a"; done' 'exit 7' > "$_pm_dir/fakepmctl" + chmod +x "$_pm_dir/fakepmctl" + _pm_wsl="$(MSYS2_ARG_CONV_EXCL='*' wsl.exe -e wslpath -a -u "$(cygpath -w "$_pm_dir/fakepmctl" 2>/dev/null || printf '%s' "$_pm_dir/fakepmctl")" | tr -d '\r')" + # shellcheck disable=SC2016 # the words are literal on purpose: they must reach the program unexpanded + _out="$(bash "$WSL_RUN" --pmctl-command "$_pm_wsl" pmctl gate status 'a b' "it's" '$HOME' '$(echo INJECTED1)' 2>&1)"; _st=$? + # shellcheck disable=SC2016 # literal on purpose + _want="$(printf '%s\n' '' '' '' "" '<$HOME>' '<$(echo INJECTED1)>')" + if [[ "$_st" -eq 7 && "$_out" == "$_want" ]]; then + pass "wsl-run/pmctl-mode-keeps-arguments-and-exit-status" + else + fail "wsl-run/pmctl-mode-keeps-arguments-and-exit-status" "exit $_st (want 7), got: $_out" + fi + rm -rf "$_pm_dir" + fi +fi + th_summary diff --git a/tests/shell/test-runtime-lib-coverage.sh b/tests/shell/test-runtime-lib-coverage.sh index db9e4ac1..f2fa0724 100755 --- a/tests/shell/test-runtime-lib-coverage.sh +++ b/tests/shell/test-runtime-lib-coverage.sh @@ -483,6 +483,8 @@ test_retrieval_terms_cjk_cap_stays_interactive() { test_retrieval_terms_no_python_needed() { local name="runtime-lib-coverage/retrieval-terms-no-python-needed" output bin path should_run "$name" || return 0 + # Builds a PATH of links to the real tools: native Git Bash's `ln -s` copies the .exe without the DLLs next to it. + th_require_symlinks "$name" || return 0 bin="$tmp_root/no-python-bin" mkdir -p "$bin" for cmd in tr od awk sort bash wc grep head; do diff --git a/tests/shell/test-state-store.sh b/tests/shell/test-state-store.sh index 02c4e98e..666cc094 100755 --- a/tests/shell/test-state-store.sh +++ b/tests/shell/test-state-store.sh @@ -294,6 +294,8 @@ case_state_store_init_world_writable_rejected_when_chmod_cannot_secure() { # 3. Assert state_store_init fails loudly before writing VERSION. local name="state_store_init: world-writable root rejected when chmod cannot secure" should_run "$name" || return 0 + # These cases set 0777/0720/0702 and expect the store to see them: native Git Bash cannot hold POSIX mode bits. + th_require_mode_bits "$name" || return 0 local store rc=0 stderr_out store="$tmp_root/root-world-writable" mkdir -p "$store" @@ -324,6 +326,8 @@ case_state_store_init_world_writable_escape_hatch() { # 3. Assert state_store_init succeeds with a warning. local name="state_store_init: unsafe-root escape hatch allows world-writable root" should_run "$name" || return 0 + # These cases set 0777/0720/0702 and expect the store to see them: native Git Bash cannot hold POSIX mode bits. + th_require_mode_bits "$name" || return 0 local store rc=0 stderr_out store="$tmp_root/root-world-writable-allowed" mkdir -p "$store" @@ -356,6 +360,8 @@ case_state_store_init_group_only_writable_rejected() { # 3. Assert state_store_init fails loudly before writing VERSION. local name="state_store_init: group-only-writable root rejected" should_run "$name" || return 0 + # These cases set 0777/0720/0702 and expect the store to see them: native Git Bash cannot hold POSIX mode bits. + th_require_mode_bits "$name" || return 0 local store rc=0 stderr_out store="$tmp_root/root-group-writable" mkdir -p "$store" @@ -387,6 +393,8 @@ case_state_store_init_world_only_writable_rejected() { # 3. Assert state_store_init fails loudly before writing VERSION. local name="state_store_init: world-only-writable root rejected" should_run "$name" || return 0 + # These cases set 0777/0720/0702 and expect the store to see them: native Git Bash cannot hold POSIX mode bits. + th_require_mode_bits "$name" || return 0 local store rc=0 stderr_out store="$tmp_root/root-other-writable" mkdir -p "$store"