Repository navigation
fix(env): retry a private env read that races an atomic replace instead of refusing it as a symlink - #480
Merged
Conversation
…ad of refusing it as a symlink An unlocked read of a `.hack/.env*` file (the preflight in `upsertDotEnvValue`/`removeDotEnvKey`, and every resolver) compared the inode seen by `lstat` with the inode it then opened. A concurrent writer's atomic rename landing between the two made the reader exit 1 with the "symlink or not a regular file" refusal, although the path only ever held regular files. `readReplaceablePrivateFile` now starts the read again when both entries are regular files (at most `REPLACEABLE_READ_ATTEMPTS`), and still refuses a symlink or non-regular entry at once and a path replaced on every attempt. This was the cause of the intermittent `project-env-layer-lock` legacy dotenv failure; #435's layer lock was already in place, so no update was lost. Also fix two test-only flakes from HACK-1422: - native-runtime-client: wait for the keeper's completion text, not for the file's existence; the keeper now opens the empty-file window every run. - xattr-owner: give the in-process durable store cases a named 30 s budget instead of Bun's 5 s default; they wait on no timer. Refs HACK-1422 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
roodboi
added a commit
that referenced
this pull request
Oct 24, 2026
…using adopted Compose (HACK-1421) (#477) The docker e2e scenario `native-compose-adoption-job-worktrees` failed once on `next` (run 40055366376, job 120757063141, docker-e2e shard 4/4, commit d803ad6). At `phase=alpha-start-7`, `hack up` exited with `code=E_CONFIG_INVALID ordered-stage=unavailable` after about 2.8 s. Passing runs of the same up take 7 to 11 s. ## Root cause The task suggested that #452 had started mapping input access faults to `E_CONFIG_INVALID`. That is not the case. #452 only added a value-free diagnostic and changed one message. `ProjectInputAccessError` already returned `E_CONFIG_INVALID` before it. The scenario also has no concurrent writer, so I found no half-written `.hack` input to blame. The cause I found is a Docker read race in the retained-resource binding proof (`src/lib/native- compose-adoption-binding.ts`). I found it by reading the source. The CI log does not show it directly (see Gaps). - While an adopted `up` runs its ordered job plan, it keeps calling `assertFresh()`. Each call runs `readInputs()` and then `inspectResources()`. That function reads each owned bridge's `.Containers` keys with `docker network inspect`. - Docker lists an endpoint that is not joined to a sandbox as `ep-<endpoint id>`. Moby's `buildContainerAttachments` (daemon/network.go) does this, and it happens for a moment on every container start (endpoint created, sandbox not yet joined) and exit (sandbox left, endpoint not yet deleted). A container inspect waits for the container lock, but a network inspect does not, so it can see this key. The native Compose owner already handles this key (#272, #362). The adoption binding did not. - `networkRow` and `pluralNetworkRows` accepted only 64-hex container IDs, so the key failed at once with `E_LEGACY_COMPOSE_BINDING_IDENTITY`. The snapshot loop retries only when running state changes, so this refusal was never retried. Inside the job plan the error carries no ordered-refusal tag, so the store returns `E_LEGACY_ADOPTION_STATE` and the command reports `E_CONFIG_INVALID` with no `legacy_adoption_refusal` detail. That is exactly what the CI line shows. - At alpha-start-7 the DB is already healthy, so the seed job starts within the first seconds of the up, during the polling. This fits the early 2.8 s refusal. The window is only milliseconds long, so the failure is rare (once in the last 100 `ci.yml` runs). ## Fix - A new helper, `bridgeMembers()`, splits a bridge's keys into container IDs and a `transient` flag. A key that exactly matches `ep-<64 hex>` is never attributed, admitted or saved. Any other non-ID key, or a duplicate, still refuses identity at once. - If the accepted loop read has a placeholder, the existing snapshot loop throws that observation away and reads again. It keeps the same aggregate probe deadline and output allowance. A placeholder that is still there at the deadline refuses with the probe deadline error. The first bridge read is only an identity read and is always followed by a loop read, so a placeholder there costs no extra scan. - `docs/reference/native-compose-adoption.md` now describes this rule next to the existing note that "delayed endpoint removal can still refuse". - **Diagnostic:** the scenario's `cli-result` line now ends with `refusal=<token>`, for example `refusal=legacy-state`. The token comes from a closed list. It is set only when the envelope message exactly matches one of the fixed product messages: the `LegacyComposeAdoptedGenerationError` codes, the adopted-command refusal and cancel messages, the adoption marker kinds, and the shared input-access guard. Any other message gives `other`. The message text is never printed. `E_PERMISSION` is now also a known code. The task asked to surface #462's errno diagnostic in the scenario output. That is not possible: #462 is still open, and its diagnostic is internal and never reaches the CLI envelope. The new token does tell an input-access or marker fault apart from an adoption-store refusal. ## Tests and mutation evidence New tests in `tests/native-compose-adoption-binding.test.ts`. The fake `docker` adds an `ep-<hex>` key to a chosen network inspect read: - placeholder on the identity read (no extra scan) and on the accepted loop read (exactly one extra scan): the binding equals a clean acquisition. - placeholder on one of two owned bridges: both bridges are read again. - a placeholder on every read uses up the shared deadline and refuses with `E_LEGACY_COMPOSE_BINDING_PROBE`. - a malformed key that looks like a placeholder (`ep-not-an-endpoint`) still refuses identity after one read. Before the fix, the three placeholder cases and the deadline case failed with `E_LEGACY_COMPOSE_BINDING_IDENTITY`. That is the CI symptom. I applied each mutation, ran the tests, and restored the file: | Mutation | Tests that failed | | --- | --- | | Drop `transient ||` from the loop condition | 3: loop-read placeholder, two-bridge, persistent | | Accept only IDs in `bridgeMembers` (the old behavior) | 4: all placeholder cases and the deadline case | | Loosen the pattern to `/^ep-/` | 1: the malformed-key test | | Drop the plural `transient ||=` | 1: the two-bridge test | | Diagnostics: map `legacy-state` to the CHANGED message | 1: the new token test | | Diagnostics: print the raw message instead of a token | 4: every test that checks for leaked text | | Diagnostics: match by prefix instead of exact text | 1: the new token test (suffix case) | ## Validation - `tests/native-compose-adoption-binding.test.ts`: 104 pass, 0 fail. I ran it with `--timeout 60000`, a private HOME, HACK_HOME and DOCKER_CONFIG, and `HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1`. The run excluded one existing test, "moving job snapshot counterexample". It times out at its own 30 s limit on this Mac (load average 50 to 90), and it fails the same way with the unmodified `origin/next` source. - `tests/native-compose-adoption-job-diagnostics.test.ts`: 8 pass. The adoption execution, jobs, probe and mount-format files also pass. `tests/native-compose-adoption-job-worktrees.test.ts` has 1 or 2 process-timing failures on this loaded Mac ("Synthetic owned child did not become ready" in 2 s). They fail the same way with the original diagnostics file. - `bun run --cwd packages/cli typecheck`: 0 errors. - Root `bun run check`: `privacy-check: ok`. `@hack/cli:check` shows the 34 existing warnings. In the binding module, the same 4 complexity warnings existed before; `inspectResources` goes from 30 to 31. - `docs:cli-reference` and `generate:agent-plugins` are not needed: the CLI surface and the consumer agent guidance did not change. - gitleaks 8.30.1 (`gitleaks dir` on the exported `git log -p origin/next..HEAD`): no leaks found. - I ran no Docker e2e on this Mac. Hosted CI at head `3d26b784` (run 40068851447): all four docker-e2e shards and the aggregate `docker-e2e` gate pass. `native-compose-adoption-job- worktrees` passes in shard 2 (98.3 s), and its `cli-result` lines now end with `refusal=`. The Config compiler, Candidate core (ubuntu), runtime-images, linux-process-lifetime, Runtime state models and secret-scan checks also pass. - Hosted `test` is **red**: shard 3/4 failed one test in `tests/project-env-layer-lock.test.ts`, "concurrent legacy dotenv sets and removals of different keys all apply". A concurrent `upsertDotEnvValue` read the env file during a replacement and refused it as "a symlink or not a regular file". This PR does not touch that code. HACK-1422 already tracks this as an intermittent failure. It is not on this workflow's list of flakes that may be rerun, so I did not rerun it. Shards 1, 2 and 4 pass. ## Gaps - The CI log does not name the cause. The scenario printed only the code and ordered detail, no artifacts were kept, and the CLI gives no finer stage. The `ep-` race is the only path I found that matches all the evidence. But it is inferred from the source and Moby's code, not observed. If the failure comes back, the new `refusal=` token will show whether it is an adoption-store refusal (`legacy-state`) or something else. - `legacy-state` is still shared by many store sites. A recurrence with `legacy-state` after this fix would point to another transient read, such as ID-keyed delayed endpoint removal, which the doc already lists as able to refuse. - I did not reproduce the window against a real daemon, by design (no local Docker e2e). Refs HACK-1421 ## Round 2 (current head c9ce3f9) - Merged origin/next 6e4bcf2 into the branch (no conflicts; the diff against next is unchanged). - Hosted CI run 40102249794 at c9ce3f9: every check passed, including all four `test` shards and the aggregate `test`, and all four docker-e2e shards. The earlier red `test` at 3d26b78 (run 40068851447) was HACK-1422's legacy dotenv concurrency test, which this PR does not touch; its fix is #480. - Independent verification: next's binding module fails 4 of the 5 new placeholder tests (3 with E_LEGACY_COMPOSE_BINDING_IDENTITY, the CI symptom); the PR head passes them. Mutations of the binding and diagnostics code were killed. - Remaining follow-ups (hand-copied diagnostic strings, a load-sensitive two-bridge test, a pre- existing untested duplicate-key check) are tracked separately. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ier deadline The same-birth root replacement case still timed out at 30 s on a host at load average 45-95 on 18 CPUs. Its cost is a fixed amount of store file work (about 19k lstat, 13k realpath and 4.5k open calls in its second `up`, the same on every run), so its wall time follows host load. A larger budget alone was not enough: the fake carrier's fixed 60 s deadline then expired first and the case failed with the storage witness refusal instead of the asserted E_NATIVE_COMPOSE_UNCERTAIN. The fake carrier's lifetime is now twice the store case budget, so a slow host can only fail a case as a clean Bun timeout. The budget is 180 s, a hang guard rather than a speed assertion. Refs HACK-1422 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
roodboi
marked this pull request as ready for review
October 24, 2026 18:05
roodboi
added a commit
that referenced
this pull request
Oct 24, 2026
… or non-ASCII task programs (HACK-1438, HACK-1461) (#490) Refs HACK-1438 ## Root cause The task-catalog denylist (`isInlineCommandArgv` in `src/lib/task-catalog-recipe.ts`) did not model the `git send-email` options that run a command. On `next` (1281184), every one of these was accepted: - `--to-cmd`, `--cc-cmd`, `--header-cmd` and `--sendmail-cmd`, in both the `--opt cmd` and `--opt=cmd` forms. - `--smtp-server=/abs/path`. send-email runs an absolute path given here as a sendmail program. A second reason it was open: `git send-email` is a Perl script that reads its options with Getopt::Long's defaults (`pass_through` only). Getopt::Long's grammar is wider than Git's parse- options. It accepts `--`, `-` or `+` before a long name, ignores letter case, and accepts any unique prefix. So even a rule written for `--to-cmd` would miss `-to-cmd=cmd`, `+to-cmd=cmd` and `--CC-CMD=cmd`. I confirmed each spelling against real Git 2.56 in a scratch repo. Each one ran a marker command: `--to-cmd X`, `--to-cmd=X`, `--cc-cmd=X`, `--header-cmd=X`, `--sendmail- cmd=X`, `--smtp-server=/abs`, `--cc-cm=X`, `--hea=X`, `--sendm=X`, `--to-cm=X`, `--CC-CMD=X`, `-cc-cmd=X`, `-to-cmd=X`, `-header-cmd X`, `-sendmail-cmd=X`, `+to-cmd=X`, `-smtp-server=/abs`, and `git Send-Email --cc-cmd=X`. The last one works because APFS is case-insensitive, so Git finds the `git-send-email` file for `Send-Email`. The `-c sendemail.ccCmd=…` / `toCmd` / `headerCmd` / `sendmailCmd` / `smtpServer` config forms also ran, including identity-scoped `sendemail.<id>.ccCmd`. They were already refused, because `git -c` and `--config-env` are refused whatever the key. This PR adds tests for them. ## Fix (`src/lib/task-catalog-recipe.ts`) - **New `subcommandGetoptLong` table** on the evaluator, plus a `getoptLongSelects` matcher. The matcher strips a `--`, `-` or `+` prefix, takes the name up to `=`, and lowercases it. It refuses the argument when the name is a prefix of an inline option. The one exception is a name that exactly matches another option (`to`, `cc`, `h`), because Getopt::Long takes an exact name as that option. `git send-email` gets `to-cmd`, `cc-cmd`, `header-cmd`, `sendmail- cmd` and `smtp-server`. - **Subcommand scoping.** The table is scoped to the `send-email` operand the same way `subcommandInline` is, so other commands are not refused (`git rev-list --header`, `git status -s`, `git format-patch --to=…`, `git log --cc`). The dashed form (`git-send-email`, `/usr/lib/git-core/git-send-email`) goes through the existing dashed-builtin mapping. - **Case-insensitive lookup.** Both subcommand-keyed tables now look up operands lowercased, because of the case-insensitive filesystem point above. - **Sweep additions**, each refused the same way and each confirmed against real Git where it is installed (see the sweep below): - `grep -O` / `--open-files-in-pager` (scoped to `grep`) - `daemon --access-hook` (scoped to `daemon`) - `instaweb -d` / `--httpd` (scoped to `instaweb`) - `svn --authors-prog` (scoped to `svn`) - the `remote-ext` word - `ext::` URLs (new `extUrls` flag) - the `archimport` word ## Tests (`tests/project-task-catalog.test.ts`) - **`HACK-1438: git send-email options that run a command`** (gap case). - Refused: every spelling listed above, plus the dashed, absolute-path, behind-`--git-dir` and mixed-case forms. Also refused: the `-c` config forms in any key case, identity-scoped and `smtpServer` keys, `--config-env`, and `-c sendemail.ccCmd=… -c alias.se=send-email se`, which only the `-c` rule catches. - Accepted: `--to=… --cc …`, `--suppress-cc=all`, `--sender=…`, `--no-header-cmd`, `--smtp- server-port=587`, `--no-to`, `-h`, and the same option names under other subcommands. - **`HACK-1438: Git porcelain options that run a command`** (gap case). Covers the sweep additions, with scoped accepted controls: `git grep -n`, `git grep --only-matching --or`, `git branch -d`, `git svn fetch --authors-file=…`, `git daemon --export-all`, `git instaweb --stop`, an `https://` remote and `git add extension.txt`. - **`HACK-1438: documented send-email and grep fail-closed collateral stays refused`.** Covers `send-email -s`, `--smtp`, `--smtp-server=mail`, `git grep -o`, and a path operand `Grep` scoping `-O`. ## Evidence - **Before and after.** I probed `isInlineCommandArgv` directly with 24 argvs that should be refused. On `next`, 22 of them were accepted; only the 2 `-c`/`--config-env` forms were refused. After the change, all 24 are refused, and the probed controls (`--to=…`, `--cc=…`, `git log --cc`, `git format-patch --to=…`) are still accepted. - **Mutation testing.** I applied 25 mutations one at a time, ran `-t HACK-1438` after each, and then restored the source. Every mutation fails at least one new test: | Mutation | Failing tests | | --- | --- | | Drop the Getopt::Long check | 2 | | Accept only the `--` prefix | 2 | | Drop the `+` prefix | 1 | | No abbreviations | 2 | | Case- sensitive option name | 1 | | Drop the exact list | 1 | | Case-sensitive subcommand lookup | 2 | | Unscoped (every `git` argv) | 1 | | Drop each of `to-cmd`, `cc-cmd`, `header-cmd`, `sendmail-cmd` | 1 each | | Drop `smtp-server` | 2 | | Drop `git -c` | 1 | | Drop the `grep` scope | 2 | | Drop `--open-files-in-pager` | 1 | | Drop `daemon` | 1 | | Drop `instaweb -d` | 1 | | Drop `instaweb --httpd` | 1 | | Drop `svn` | 1 | | Drop the `remote- ext` word | 1 | | Drop the `archimport` word | 1 | | Drop `ext::` URLs | 1 | | Case- sensitive `ext::` | 1 | | Unscoped `subcommandInline` | 1 | - **Focused tests.** `bun test tests/project-task-catalog.test.ts` reported 40 pass, 0 fail. It ran with a private HOME, HACK_HOME and DOCKER_CONFIG and `HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1`, and with `--timeout 60000`. The run with the default 5 s timeout hit 3 timeouts in slow existing domain tests (tracked pins, stale service definition, edit during read) while the host load average was about 106. None of the timeouts were in the changed tests. - **Gates.** Root `bun run check` passed, including `privacy-check: ok`, with only existing warnings and none in the changed files. `bunx biome check` on the changed files was clean. `bun run --cwd packages/cli typecheck` reported 0 errors. gitleaks 8.30.1 over `origin/next..HEAD` found no leaks. - **Not regenerated.** The CLI surface and consumer agent guidance did not change, so the CLI reference and plugin bundles were not regenerated. - **Full suite.** The hosted CI `test` job at this head. ## Git porcelain sweep Sources: `--git-completion-helper-all` for every command that `git --list- cmds=builtins,main,others,nohelpers` lists on Git 2.56, plus the script sources on `git/git` master (`git-send-email.perl`, `git-svn.perl`, `perl/Git/SVN.pm`, `git-instaweb.sh`, `git-p4.py`, `git-cvsimport.perl`, `git-cvsexportcommit.perl`, `git-archimport.perl`, `git- cvsserver.perl`, `git-web--browse.sh`, `git-mergetool.sh`, `git-difftool--helper.sh`, `git- submodule.sh`, `daemon.c`, `builtin/grep.c`, `imap-send.c`). Version history (2.19 and later) comes from the existing table and #463/#475. | Option or form | Runs | Status | | --- | --- | --- | | `send-email --to-cmd/--cc-cmd/--header- cmd/--sendmail-cmd` | shell command | **added** (Getopt::Long spellings) | | `send-email --smtp- server=/abs` | sendmail program | **added** (any value, fail-closed) | | `-c`/`--config-env` `sendemail.*Cmd`, `sendemail.smtpServer`, `core.sshCommand`, `core.pager`, `core.fsmonitor`, ... | config | already refused (`-c`/`--config-env` refused whatever the key) | | `grep -O<pager>` / `--open-files-in-pager=<pager>` | shell (`use_shell`) | **added**, scoped to `grep` | | `daemon --access-hook=<cmd>` | shell (`use_shell`) | **added**, scoped to `daemon` | | `instaweb -d/--httpd=<cmd>` | the daemon command line | **added**, scoped to `instaweb` | | `svn --authors-prog=<cmd>` | backticks in `Git::SVN` | **added**, scoped to `svn` (git-svn is not installed here; read from source) | | `git remote-ext <remote> <url>`, `ext::<cmd>` URLs | the URL as a command | **added**. Git's default `protocol.ext.allow=never` blocks it, but config can allow it. `EXT::` is also blocked by Git ("cannot handle remote-EXT as a builtin"). | | `git archimport <archive/branch>` | operand pasted into `tla abrowse … $limit \|` | **added**. `git archimport 'x/y;cmd #'` ran `cmd` even without tla. | | `--upload-pack`, `--receive-pack`, `--exec` (`clone`, `fetch`, `pull`, `ls-remote`, `push`, `archive`, `fetch-pack`, `send-pack`), `clone -u`, `rebase --exec/-x`, `difftool --extcmd/-x`, `submodule foreach`, `bisect run`, `filter-branch`, `rebase--helper --add-exec-commands` | shell | already modeled. `ls-remote` has no `-u` (its `--exec` is a hidden alias and is covered). | | `rebase--interactive --cmd` (2.20–2.33) | shell | in #475 (HACK-1423), not on `next` yet | | `column --command` | config lookup only | not a command | | `for-each-ref --shell`, `cat-file --batch-command` | quoting / stdin protocol | not a command | | `difftool/mergetool -t/--tool`, `web--browse -b`, `instaweb -b` | name must be a known tool or a configured `*.cmd` | config only | | `git hook run`, `clone/init --template` (template hooks run during clone), `--exec-path`, `merge/rebase/pull -s <name>` (`git-merge-<name>` on `PATH`), `transport::address` URLs (`git-remote-<transport>`) | a program file, not a string | not modeled (documented gap) | | `git config <key> <cmd>` writes (`core.pager`, `sendemail.ccCmd`, `include.path`, ...) | config read later | not modeled (existing documented gap) | | `cvsimport -d :ext:host` (runs `$CVS_RSH host cvs server`), `p4`, `cvsserver`, `cvsexportcommit`, `imap-send` (`imap.tunnel`) | env or config | not modeled. `cvsexportcommit` rev-parses its commit operands before it uses them in shell strings. | ## Gaps - **Repository aliases.** A repository alias still hides a subcommand from its scoped flags (`git se --cc-cmd cmd` with `alias.se = send-email`). This is the existing documented limit and is out of scope. - **Fail-closed collateral, documented.** - Any prefix of a refused send-email option is refused, even one Getopt::Long finds ambiguous and passes on to `format-patch` (`send-email -s`, `-t`, `--smtp`). - `--smtp-server` is refused whatever its value. - `git grep -o` is refused, because flags are case-folded; use `--only-matching`. - Subcommand names now match in any letter case at any operand, so a path named `Grep` scopes `-O`. The same applies to the existing `clone -u` and `rebase --add-exec-commands`. - **Interaction with #475 (HACK-1423, still a draft).** #475 changes `subcommandInline` to count only after the subcommand's operand. When it is rebased onto this change (or this onto it), it should apply the same after-the-operand rule to `subcommandGetoptLong`, and keep the lowercased lookup from `scopedEntries`. The two PRs touch the same lines in `evaluatesCode` and the docs paragraph, so expect a textual conflict. - **Not modeled.** Config writes and program-file runners (table above) are not modeled. ## Round 2: merged with `next`, one scoping rule (HACK-1423 + HACK-1438) The verifier found that this PR conflicted with `next` after #475 (HACK-1423) and #480 landed. The conflict was not only textual. #475 made subcommand-scoped Git flags count only **after** the operand that names the subcommand. This PR counted them at **any** operand. Its collateral test asserted that `git log -o -- Grep` is refused, which contradicts #475. **Rule chosen: #475's.** A scoped flag counts only after the operand that names its subcommand, because Git reads a subcommand's options only after the subcommand's name. This PR's lowercased subcommand lookup is kept, so `git Send-Email --cc-cmd=cmd` is still refused. What changed (merge commit 6cf5296, then a clean merge of 78487f7 in 0556dd9): - **`src/lib/task-catalog-recipe.ts`.** `scopedFlags` (from #475) and `scopedEntries` (from this PR) are now one helper, `scopedEntries({ table, positional, at })`. It returns the table entries for operands **before** argument `at`, and it looks up each operand lowercased. Both `subcommandInline` and `subcommandGetoptLong` use it, so the send-email Getopt::Long options follow the same after-the-subcommand rule as `clone -u`. #475's `--cmd` inline flag and its comments are kept. - **Tests.** The collateral test now asserts `git log -- Grep -o` is refused and `git log -o -- Grep` passes. New accepted controls where the flag comes before the subcommand word: `git rev- list --header send-email`, `git log -s -- Send-Email`, `git diff -Oorderfile -- grep`, `git push -d origin instaweb`. Every send-email spelling after `send-email` is still refused. New cases kill the two mutations that survived round 1: `git send-email --to=… -- p` and `git send-email --to=… -` pass (`--` and `-` name no option), `git push --repo=ext::cmd` is refused (`ext::` after `=`), and `git add docs/ext::notes.md` passes (`ext::` only at the start of an argument or after `=`). - **Docs (`docs/reference/ax-task-catalog.md`).** The contract table keeps both #475's rows and this PR's rows. The scoping row now lists `git rev-list --header send-email`, `git push -d origin instaweb` and `git log -o -- Grep` as accepted, and `git log -- Grep -o` as refused. The limitations paragraph uses the single after-the-subcommand rule, says the lookup ignores letter case, and drops the round-1 text ("matched at any operand ... a path named `Grep` scopes those flags to it"). The "Interaction with #475" gap and the "any operand" collateral bullet above are now resolved by this round. ### Evidence (round 2) - **Focused tests.** `bun test tests/project-task-catalog.test.ts`: 42 pass, 0 fail. Run with a private HOME, HACK_HOME and DOCKER_CONFIG and `HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1`. - **Mutation testing.** I applied each mutation alone, ran the focused file, and restored the source: | Mutation | Failing tests | | --- | --- | | Scoped flags count at any operand (drop the `at` check) | 5 | | Case-sensitive subcommand lookup | 2 | | `subcommandGetoptLong` counts at any operand | 1 | | `subcommandInline` counts at any operand | 4 | | Allow an empty Getopt::Long name (`--`, `-`) | 1 (survived round 1) | | `ext::` matched only at the start of an argument | 1 (survived round 1) | | `ext::` matched anywhere in an argument | 1 | | Drop `h` from the send-email exact list | 1 | | Getopt::Long without the one-dash prefix | 2 | | `index > at` instead of `index >= at` | 0, an equivalent mutation: the subcommand word itself cannot match a flag | - **Gates.** Root `bun run check` exit 0, with `privacy-check: ok`. `bun run --cwd packages/cli typecheck` exit 0, with 0 errors. gitleaks 8.30.1 `dir` over a `git log -p origin/next..HEAD` export (next = 78487f7): no leaks. The CLI surface and consumer agent guidance did not change, so nothing was regenerated. - **Full suite.** Hosted CI at 0556dd9, which contains `next` 78487f7. ## Round 3: Unicode case folding no longer bypasses the denylist (HACK-1461), merged with `next` Refs HACK-1461 The round-2 verifier found that APFS (and NTFS) look up names with Unicode case folding, but the denylist folded names with JavaScript's `toLowerCase()`. So `git ſend-email --to-cmd=cmd` (U+017F long s), `git-ſend-email`, `git inſtaweb --httpd=cmd`, `git ſvn clone --authors- prog=cmd` and `git filter-branch --tree-filter cmd` (U+FB01 fi ligature) were accepted. HACK-1461 records that two of them ran a marker command live on macOS. What changed (7483db3, on top of the merge of `next` cd35034 in 84d0379): - **`src/lib/task-catalog-recipe.ts`.** - A new `foldName` replaces `toLowerCase()` everywhere the denylist compares a name: program basenames (`programName`), Git helper porcelains (`gitSubcommand`), the subcommand-scoped tables (`scopedEntries`), the `words` check, first-operand `subcommands`, and Python REPL/trampoline modules (`isListedModule`). - `foldName` over-folds on purpose (fail-closed). It applies NFKC (`fi` becomes `fi`, `ſ` becomes `s`, the Kelvin sign becomes `K`), removes default-ignorable code points such as a zero-width space, then upper-cases before lower-casing (so `ß` becomes `ss` and dotless `ı` becomes `i`). - Option names keep ASCII-only folding. Git's parse-options and Getopt::Long compare options bytewise apart from ASCII letter case, and option values are left unfolded. - Two outright refusals in `evaluatesCode`, so the result does not depend on any one filesystem's folding table: - A listed program whose basename contains any non-ASCII character is refused whatever follows (`ſh scripts/x.sh`, `git-ſtatus`). - A Git subcommand (the first operand) that contains any non-ASCII character is refused (`git ſtatus`, `git --work-tree dir pack-refſ`). - Non-ASCII operands and values (`git commit -m 'café'`, `git log --author=Zoë`) and unlisted programs (`cat ſend-email`) still pass. - **Tests (`tests/project-task-catalog.test.ts`).** New test `HACK-1461: names that a case- insensitive filesystem folds to a listed name are refused`: - 18 refused spellings covering the long s, the fi ligature, the Kelvin sign (`Ksh -c`, `awK`), dotless i (`git ınstaweb`, `ıex`), folding after the first operand (`git log filter- branch`, `git log bıſect`, `git help ſend-email --to-cmd=…`, `git log filter-branch`, `python3 -m code`) and the non-ASCII refusals. - 8 accepted controls, plus the ASCII `filter-branch` and `send-email --to-cmd` refusals. - The collateral comment no longer claims a subcommand counts "at any operand". It now says the name is case-folded and scopes its flags from any operand that names it. - **Docs (`docs/reference/ax-task-catalog.md`).** - The refusal list describes the Unicode folding and the non-ASCII rule, and the contract table has a new row for it. - The limitations paragraph documents `help.autocorrect` next to the repository-alias limit. With autocorrect set to run the correction, `git send-emai --to-cmd=cmd` runs `send-email`, but the catalog sees an unknown subcommand and does not scope the flag to it. ### Evidence (round 3) - **Focused tests.** `bun test tests/project-task-catalog.test.ts`: 43 pass, 0 fail, run with `HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1`. The file's fixtures use their own private HOME, HACK_HOME and registry. - **Mutation testing.** I applied each mutation alone, then ran the HACK-1461 test (it failed every time) and a pure `isInlineCommandArgv` probe over the same 30 cases, then restored the source. | Mutation | Cases that flip to accepted | | --- | --- | | `foldName` is plain `toLowerCase()` (the pre-fix folding) | 7: `ıex`, `git log filter-branch`, `git log bıſect`, `git help ſend-email --to-cmd=id`, `git log filter-branch`, `python3 -m code`, `ſh scripts/x.sh` | | No non-ASCII listed-program refusal | 2: `ſh scripts/x.sh`, `git-ſtatus` | | No non-ASCII Git subcommand refusal | 2: `git ſtatus`, `git --work-tree dir pack-refſ` | | No upper-case step | 2: `ıex`, `git log bıſect` | | NFC instead of NFKC | 1: `python3 -m code` | | Default-ignorables kept | 1: `git log filter-branch` | | `words` and `scopedEntries` back on `toLowerCase()` | 4 | The Kelvin cases do not flip under the `toLowerCase()` mutation, because JavaScript already lowercases U+212A to `k`. They stay in the test as regression cases. - **Live macOS check (built CLI).** - Setup: I ran `bun run build`. In a scratch project with a private HOME, HACK_HOME and DOCKER_CONFIG, `.hack/tasks.json` held the HACK-1461 argv, each with a `touch <marker>` command. I then ran `./dist/hack project task list --json --path=<scratch>`. - At this head, every Unicode recipe (long s, dashed long s, fi ligature, dotless i, Kelvin) is refused as `inline_command`, as is the ASCII `send-email --to-cmd` control. The benign `lint` control gets past the inline check and is refused later as `input_path_invalid` (the scratch project is not a Git repository). - A CLI built the same way from the round-2 `task-catalog-recipe.ts` (0556dd9) let the long-s, dashed, fi and dotless-i recipes through the inline check; they too stopped only at `input_path_invalid`. - The marker file was never created. The catalog has no execution path. - **Gates.** - Root `bun run check`: exit 0, with `privacy-check: ok`. Its 34 warnings were there before this change, and none is in the changed files. - `bun run --cwd packages/cli typecheck`: 0 errors. - gitleaks 8.30.1 `dir` over a `git log -p origin/next..HEAD` export: no leaks. - The CLI surface and the consumer agent guidance did not change, so nothing was regenerated. - **Merge.** `next` cd35034 is merged in 84d0379 with no conflicts, so the branch is no longer behind. - **Full suite.** Hosted CI run 40134680598 (pull_request, headSha 7483db3) passed every check: the `test` aggregate and test shards 1/4 to 4/4, docker-e2e shards 1/4 to 4/4, secret-scan, Candidate core on Ubuntu and macOS, the Config compiler jobs, Runtime state models, runtime- images and linux-process-lifetime. While CI ran, `next` moved to 191382d (#489, #497). Neither touches this PR's files, and `git merge-tree` merges it cleanly, so GitHub shows BEHIND, not a conflict. ### Gaps (round 3) - I did not repeat the direct `git ſend-email --to-cmd` execution in this round, because the agent harness blocks a private-HOME `git` run outside its worktree. HACK-1461 already records that the bypass executes. This round shows that the catalog now refuses those argv. - `help.autocorrect` and repository aliases are documented limits. They are not detected, because the check does not read Git config. ### Round 4 (312de30): capital sharp s and non-ASCII programs The G109 verifier found a HIGH gap: `ẞh` (U+1E9E, capital sharp s) resolves to `/usr/bin/ssh` on the case-insensitive macOS filesystem, but `foldName` did not fold it, because JavaScript lowercases U+1E9E to `ß`, not `ss`. - **Folding.** `foldName` now runs NFKC, strips default-ignorables, then applies lower, upper, lower. The upper step maps `ß` to `SS`, so `ẞh` and `ßh` fold to `ssh`. - **argv[0] rule.** A recipe whose program (argv[0]) has any non-ASCII character in its file name is now refused, listed or not (`ẞh`, `/usr/bin/ẞh`, `./scripts/ünïcode.sh`, `café`). A case-insensitive filesystem may run such a name as an ASCII program that `foldName` does not predict, so the check fails closed. Reviewed scripts must use ASCII file names. Later arguments are still checked through `foldName` against the listed programs (`cat ẞh` is refused). - **Tests.** `tests/project-task-catalog.test.ts` adds `ẞh host id`, `/usr/bin/ẞh -o ProxyCommand=id x`, `cat ẞh`, `./scripts/ünïcode.sh` and `café --version` as refused cases. The accepted controls did not change. 43 pass. - **Mutations.** Restoring the old fold fails the `cat ẞh` case. Removing the argv[0] rule fails the HACK-1461 test. - **Docs.** `docs/reference/ax-task-catalog.md` documents the `ẞ` fold, the argv[0] refusal and the ASCII-name requirement. - **Gates.** Biome is clean on the changed files (complexity is back under the limit), `packages/cli` typecheck has 0 errors, `privacy:check` is ok, and gitleaks found no leaks in the commit patch. Hosted CI at 312de30 is the full-suite evidence. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes the three intermittent Bun test failures in HACK-1422. One of them is a real product bug. It is not the lost update the ticket suspected: #435's layer lock already covers the legacy dotenv path. It is a false refusal: a concurrent
hack env set/unseton a.hack/.env*file can exit 1 with the misleading "symlink or not a regular file" message. No data is lost.1. Legacy dotenv concurrency (
tests/project-env-layer-lock.test.ts): product fixRoot cause.
upsertDotEnvValueandremoveDotEnvKeyread the env file once without the layer lock as a preflight. The preflight refuses a symlinked file before the lock creates.internal/, andremoveuses it to skip the lock on a no-op. Every env resolver also reads without the lock.readReplaceablePrivateFiledoeslstat, then a no-followopen, and refuses if the two inodes differ. A lock-holding writer's atomicrenamethat lands between those two calls therefore failed the reader withPRIVATE_ENV_FILE_REFUSAL, although the path only ever held regular files. The failing stacks were allreadPrivateEnvFile<-upsertDotEnvValue:526/removeDotEnvKey:556, which are the two unlocked preflights. On base under load the whole file failed 3 of 5 runs, each time with that refusal.Fix (
src/lib/private-file.ts). When thelstatentry and the opened entry are both regular files but differ, the read starts again, at mostREPLACEABLE_READ_ATTEMPTS(8) times. That returns the previous or the new contents, which is whatreplacePrivateFileAtomicalready promises readers. The other refusals behave as before:ELOOP) or non-regular entry is still refused at once, on any attempt.Tests (
tests/private-env-file.test.ts). These interceptopenso the replacement lands exactly betweenlstatandopen. They are deterministic and need no load.REPLACEABLE_READ_ATTEMPTSopens, and no lock directory is created.Mutation proof.
2.
tests/native-runtime-client.test.ts, "authored * finishes while a descendant retains stdout and stderr": test fixRoot cause. The completion wait used
waitForFile, which only waits for the file to exist. The keeper'swriteFileSynccreates the file before it writesfinished, so the finaltoBe("finished")could read"".Fix. A new
waitForFileText(path, expected, budget = HANDSHAKE_BUDGET_MS)waits, with a bound, for the exact text. The assertion is unchanged.Mutation proof. The keeper now always opens the empty-file window: it writes
'', sleepsTORN_HANDSHAKE_GAP_MS, then writesfinished, the same pattern as the file's existing pid handshake. With the old existence wait, all 6 cases fail withExpected "finished", Received "". With the content wait, all pass.3.
tests/native-compose-storage-witness-xattr-owner.test.ts, "same-birth root replacement while awaiting the final reader…": named budgetCause: Bun's 5 s default timeout under load, not an unbounded wait. The case runs entirely in process against a fake carrier. The carrier deadline is 60 s, and no timer or poll is involved. On this host (load average about 40 to 50 on 18 cores) the case alone took 7.3 s wall time with 5.3 s of CPU, 4 s of it system time from the durable store's file work. With the default timeout it failed in both runs here (standalone and whole file) at about 5002 ms. A whole-file run also timed out 4 other in-process store cases at 5 s.
Fix. A named
STORE_CASE_BUDGET_MS = 30_000, documented with this reason, applied to the named case and to the 3 other test sites observed timing out (command storage carriers use the %s named storage budget…,post-workload marker loss…,unknown enrolled helper %s blocks replay…). Biome reformatted those 4 cases into the multi-linetest(name, fn, budget)shape, so most of that file's diff is indentation.git diff -wshows +34/−9.Evidence
All local runs used a private
HOME,HACK_HOMEandDOCKER_CONFIG, withHACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1, on Bun 1.4.2 (pinned). Each loop ran 30 times in a row with 8 extra busy-loop processes, stopped by anEXIT/INT/TERM/HUPtrap, on a host already at load average about 40 to 50 on 18 CPUs.same-birth root replacement…(-t)tests/private-env-file.test.ts(whole file, includes the new race tests)finishes while a descendant retains…(6 cases)tests/project-env-layer-lock.test.ts(whole file, includes the legacy dotenv case)Before the fix, the same loop on
project-env-layer-lock.test.tsfailed 3 of 5 runs. Two earlier runtime loops were discarded and rerun in full on the committed tree: in one, I staged files mid-run, which tripped the checkout write guard while the 6 cases passed; in the other, the host stalled for about 7 h during a run, so the wall-clock drain assertion saw a 25264 s gap.Gates at the PR head:
gitleaks dirover agit log -p origin/next..HEADexport: no leaks.bun run check: passed, warnings only.bun run --cwd packages/cli typecheck: 0 errors.compose-env-at-rest*,project-env-config,project-env-write-refusals,secret-store*,shared-host-lifecycle. The exception is onehack-env.test.tscase that also fails on base whenHACK_HOMEis set (see Gaps).The full-suite evidence is hosted CI at this exact head (
8ff17289):testand all 4 test shards passed, and so did every other check (docker-e2e ×4, candidate core, config compiler, secret-scan and the others).Gaps
readReplaceablePrivateFilecaller: env files, project env layers, the secret store, compose env at rest and shared host lifecycle. Only the env-file path has a dedicated race test. The others share the same code path.tests/hack-env.test.ts"resolveHackEnv skips encrypted backend access…" fails wheneverHACK_HOMEis set. It writes its global config to$HOME/.hackand does not clearHACK_HOME/HACK_GLOBAL_CONFIG_PATH. This is pre-existing on base, CI does not setHACK_HOME, and it is outside this unit.Round 2: xattr-owner budget no longer races the fake carrier deadline
Verifier defect. At
8ff17289the same-birth case did not pass 30 runs in a row on a host at load average 45 to 95 on 18 CPUs (28/30, 13/30, 14/30, each failure a clean 30 s timeout). Raising the budget alone was not enough. The fake carrier's fixedDate.now() + 60_000deadline then expired first, and the case failed with the storage witness refusal andexchangedfalse instead ofE_NATIVE_COMPOSE_UNCERTAIN.Why the case is slow. Its cost is a fixed amount of store file work, not a wait. With the calls counted through spies, the case's second
upalone makes about 19klstat, 13krealpathand 4.5kopencalls, the same count on every run whatever the wall time. Most come from about 1.3k storecheck()calls, each rechecking about 7 held directories. Wall time therefore follows host load. A deep privateTMPDIRmakes eachrealpathmore expensive, which helps explain why local runs under a scratchTMPDIRwere slower than hosted CI.Fix (
tests/native-compose-storage-witness-xattr-owner.test.tsonly):FAKE_CARRIER_LIFETIME_MS = 2 * STORE_CASE_BUDGET_MSreplaces the fixed 60 s infake(). Every carrier is captured inside a case, so its deadline is always later than that case's Bun budget. A slow host can now fail a case only as a clean timeout, never with a misleading refusal.STORE_CASE_BUDGET_MSgoes from 30 s to 180 s. It is a hang guard, not a speed assertion: no case it covers waits on a timer.Mutation proof.
expect(exchanged).toBe(true), receivedfalse. That is exactly the verifier's failure mode.E_NATIVE_COMPOSE_UNCERTAINthrown fromactive().Evidence (Bun 1.4.2; private
HOME,HACK_HOME,DOCKER_CONFIGand a deep scratchTMPDIR;HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1; 8 extra busy loops stopped by anEXIT/INT/TERM/HUPtrap):same-birth root replacement…(-t), 30 runs in a rowIn run 12 of that loop the case passed, but the file run exited 1. The checkout write guard's
afterAllhook (tests/helpers/checkout-write-guard.ts) timed out after 5 s runninggit statusunder load ("killed 1 dangling process"). That hook is outside this unit; see Findings.Merge.
next(1281184b) is merged in; the merge was clean.Gates at the new head
34e6af28:gitleaks dirover agit log -p origin/next..HEADexport: no leaks.bun run check: passed (privacy-check ok, turbo 2/2).bun run --cwd packages/cli typecheck: 0 errors.34e6af28(run 40110572468, attempt 1): all 20 checks passed. That includestestand test shards 1 to 4 (the full-suite evidence), docker-e2e and shards 1 to 4, Candidate core ×2, Config compiler ×4, Runtime state models, linux-process-lifetime, runtime-images and secret-scan.Remaining gap (unchanged from round 1, now measured). A local run of the whole xattr-owner file at the new head, at load average about 20 to 60, timed out 17 other cases. All 17 were timeouts, at Bun's 5 s default or at the file's existing
30_000literals. TheE_NATIVE_COMPOSE_STATEerrors in that log are knock-on effects after those timeouts. Those cases do the same store work, so they share the same exposure to a loaded host. Hosted CI passes them.Findings (not fixed here)
upwith one storage witness makes about 1.3k full storecheck()calls: about 19klstat, 13krealpathand 4.5kopen. That is several seconds of system time on a loaded host, and it grows with the depth of the project path. Expected: rechecks that do not grow with every proof step. Actual: about 7 held directories rechecked about 1.3k times perup.installCheckoutWriteGuard'safterAllruns a synchronousgit statusunder Bun's default 5 s hook timeout. Under load it timed out once in 30 runs and failed a file whose cases all passed. Expected: the guard never fails a passing file for being slow. Actual:a beforeEach/afterEach hook timed out.Refs HACK-1422
🤖 Generated with Claude Code