Repository navigation
fix(env): name unreclaimable layer locks and the re-include cause - #453
Merged
Merged
Conversation
- An env layer lock that Hack can never reclaim (a malformed or untrusted receipt, a reused PID, a dead owner behind a leftover `<lock>.recovery` guard) made every set/add/unset/hydrate refuse with "Retry when it finishes." forever. The timeout now names the lock path and the recovery step, and keeps the registry's "recovery guard is busy" detail with both paths. Closed, one line, value-free. - The unignored-key refusal no longer says Hack "could not add" the entry when the root .gitignore already lists the key and a later rule re-includes it; it names that rule as the cause. - A later `git init` is out of scope for env writes (Hack runs no code then). `hack doctor`'s env key check is the backstop: it flags the unignored key, and the next --secret write repairs the entry. Documented in docs/env.md and covered end to end. - Tests: paused-unset compare-and-swap conflict, unreclaimable lock receipts, recovery guard (set and hydrate), re-include message, and the git-init doctor scenario. Each was proven by mutation. Refs HACK-1386 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
…passes The spawn-env isolation guard counts any whole-object use of process.env as a copy of the runner's environment, so withPrivateEnvironment's Object.assign(process.env, overrides) failed it in hosted CI. Set each override by key and restore it with the shared restoreEnv helper; the values were already private. Refs HACK-1386 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolve the src/commands/doctor.ts docblock conflict with #448 to the PR side: keep the extended checkProjectEnvKey docblock and its export. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 24, 2026
roodboi
marked this pull request as ready for review
October 24, 2026 05:48
roodboi
added a commit
that referenced
this pull request
Oct 24, 2026
…urate gitignore entry, no-TTY Tailscale test) (#479) The six small CLI follow-ups in HACK-1426, from the verifiers of #466, #441, #453, #460 and #462. ## Root cause 1. **Tailscale guard had no test.** The `canPrompt()` guard in front of "Turn on Tailscale?" in `setupTailscale` (`src/commands/ssh.ts`) had no case in `tests/no-tty-prompt-sites-cli.test.ts`. If you changed it to `if (false)`, every ssh case still passed. 2. **Cold cache in the first case.** Each run of that file uses a fresh private HOME. Bun keeps its runtime transpiler cache under HOME (`~/Library/Caches/bun/@t@` on macOS), so the first spawn started cold inside its 10 s kill bound. On a loaded host that first case took 6.3 s and was SIGKILLed. 3. **`hack up` dropped the error code.** For a missing project, `runUpCommand` caught `MissingProjectContextError` and, without `--json`, logged only its message. `down`, `restart`, `ps`, `logs` and `open` let the error reach the CLI's closed-error renderer, which prints `E_PROJECT_NOT_FOUND: ...`. 4. **Indented `.gitignore` entry counted as present.** `ensureGitignoreEntry` (`src/lib/fs.ts`) compared trimmed lines. Git keeps leading whitespace as part of the pattern, so ` .hack.secret.key` does not ignore the key. Hack counted that line as the entry, wrote nothing, then found the key not ignored. It then refused with the "a later rule re-includes the key" message, which names the wrong cause. 5. **ACL test could leave mode 0444.** In `tests/git-info-exclude.test.ts`, the macOS ACL test ran `chmod 0444`, the ACL grant and `access(W_OK)` before the `try` whose `finally` restores mode 0644. If any of those steps failed, the file kept mode 0444. 6. **Paired fixture lost a case.** #462 gave the paired `unsearchable` fixture a `.hack/.internal` directory. After that, nothing covered a `.hack` at mode 000 with no `.hack/.internal`, which is the most common real layout. ## Fix 1. New paired no-TTY case: `ssh --tailscale` runs against a logging `tailscale` stand-in placed first on PATH. The stand-in reports `{"BackendState":"Stopped"}`. The case expects exit 1, the `Run \`tailscale up\`, then re-run \`hack ssh\`.` hint, no prompt, and no `tailscale up` call. 2. `beforeAll` now runs one warm-up child (`index.ts --help`) under the file's private HOME. It has its own named budget: `WARM_UP_KILL_AFTER_MS` = 90 s, and the hook bound is that plus 10 s. A kill fails the file with a clear message. The cases keep the 10 s bound. 3. `runUpCommand` now handles `MissingProjectContextError` itself only under `--json`. Without `--json` it rethrows, so `hack up` prints `ERROR: E_PROJECT_NOT_FOUND: No .hack/ (or legacy .dev/) found. Run: hack init` (console logger), the same as the other lifecycle commands. `docs/cli.md` already promised this. 4. `ensureGitignoreEntry` now reads each line the way git does: it drops a trailing CR and trailing spaces, and keeps leading whitespace. An indented line no longer counts as the entry, so Hack appends the exact entry, git ignores the key, and the secret write goes ahead. The "re- included" refusal now appears only when the exact entry is present and git still does not ignore the key, which is what its message says. Tabs are not trimmed, which matches git's `trim_trailing_spaces`. 5. The ACL test's mode change, ACL grant and `access` probe now run inside the `try`. 6. New `unsearchable-no-internal` fixture (never adopted, no `.hack/.internal`). Its `.hack` at mode 000 is the first case in the paired CLI/MCP loop. The proof-matrix row in `docs/reference/ax-target-result-contract.md` now names this case. ## Tests and mutation evidence For each mutation I applied it, ran the focused tests, and restored the file. - **`if (!canPrompt())` changed to `if (false)` in `setupTailscale`:** the new case fails. The child was SIGKILLed at the 10 s bound with the prompt open. The other 3 ssh cases pass. The stand-in recorded no `up` call. - **`up`'s non-JSON branch reverted:** 2 tests fail, `up -p <empty dir> without --json …` and `up from a working directory without a project …`. The same new loop over `down`, `restart`, `ps`, `logs` and `open` still passes. - **`line.trim() === entryLine` restored in `ensureGitignoreEntry`:** 2 tests fail, the CLI case "an indented entry … is completed by an exact entry instead of being refused as re-included" and the unit case "ensureGitignoreEntry reads each line as git does …". - **Exact comparison, `line === entryLine`:** the unit case fails on the trailing-space and CRLF inputs. - **EACCES/EPERM no longer mapped to `permission` in the adoption marker's `classifyStateRootFailure`:** the paired loop fails on its first case, `unsearchable-no- internal` with `.hack` at 000. It gets `adoption_state_unsafe` / `E_CONFIG_INVALID` where `input_permission_denied` / `E_PERMISSION` is expected. - **Warm-up removed (timing, idle host):** the first case took 1.10 s without the warm-up and 0.65 s with it. The later cases are unchanged. ## Validation - Focused tests: 6 files (`no-tty-prompt-sites-cli`, `missing-project-error-code`, `project-env- key-ignore`, `project-target-inspect`, `git-info-exclude`, `doctor-generated-files`), 121 pass and 0 fail. `project-up-command` also passes. Runs used a private HOME, HACK_HOME and DOCKER_CONFIG, and `HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1`, as a non-root user on macOS. - `bun run --cwd packages/cli typecheck`: 0 errors. - Root `bun run check`: `privacy-check: ok`. `@hack/cli:check` shows the 34 warnings that were already there. Biome finds no issues in the changed files. - `bun run docs:cli-reference`: no diff. Only an error line changed, not the CLI surface. No consumer agent guidance changed. - gitleaks 8.30.1 (`gitleaks dir` on the exported `git log -p origin/next..HEAD`): no leaks found. - Full suite: the hosted CI `test` job (all shards) at this PR head. ## Gaps - I did not reproduce item 2 under load. The evidence is that the warm-up moves the cold start out of the first case: on an idle host, 1.10 s became 0.65 s. The reported failure was 6.3 s at load about 60. - Item 5 is a structural move with unchanged assertions. No mutation tells the old and new placement apart without failing the ACL setup itself. - As in #462, no mutation tells the no-`.internal` case apart from the `.hack` 000 case with `.internal`. Both get EACCES from the same state-root `lstat`. The new case is live (it fails first under the classifier mutation) but not independent. - Locally, `tests/native-compose-adoption-search-depth.test.ts` "a pending adoption more than 64 directories above …" fails in its setup with `E_LEGACY_ADOPTION_DEADLINE`. It fails the same way with `origin/next`'s `src/`, so this change did not cause it. Hosted CI decides. - Not run as root. The mode-dependent cases are skipped as root, as before. Refs HACK-1426 🤖 Generated with [Claude Code](https://claude.com/claude-code)
roodboi
added a commit
that referenced
this pull request
Oct 24, 2026
…out hard links (#468) Refs HACK-1377 This PR fixes the env write refusals that were still raw on `next` after #402 (HACK-1330). All five were pre-existing; none are regressions. Each one now prints a single line with an `E_*` code and no value, stack, errno or staging path. `--json` reports the same code. ## Root causes and fixes 1. **Filesystems without hard links (ExFAT, FAT).** Hack publishes lock receipts (`withProjectsRegistryLock`) and the first project key (`publishPrivateFileExclusive`) with `link(2)`. ExFAT returns ENOTSUP (Linux FAT returns EPERM), so the key could never be created. On current `next` the problem is wider: since #435, every `env set` on ExFAT fails at the per- layer lock, including a non-secret one. The output was a raw `ENOTSUP … link '<…>.owner' -> '<…>.lock'` with a stack. **Fix:** a new `src/lib/exclusive-publish.ts`. When `link` reports that hard links are unsupported (ENOTSUP, EOPNOTSUPP or EPERM), it falls back to an exclusive placeholder. Hack creates an empty `O_CREAT|O_EXCL|O_NOFOLLOW` file at the target, then renames the complete, fsynced sibling over it. The no-overwrite and no-follow guarantees still hold. An existing entry or symlink gets EEXIST. The rename replaces only the empty file this call created, and that is checked again just before the rename. Readers see nothing, an empty file or the complete file, never a partial one. If the rename fails, the reservation is removed. The registry lock now accepts that the rename already used up its staging name. Since the fallback keeps every guarantee, no `E_UNSUPPORTED_FILESYSTEM` refusal was needed. 2. **Hydrate with a dangling `.hack/.internal` symlink** reached `mkdir -p` and got a raw EEXIST (`E_UNEXPECTED` under `--json`). **Fix:** `ensureDirForWrite` maps EEXIST to a closed `not_a_directory_entry` refusal. The hydrate lock now uses the new `ensureRealDirectoryForWrite`. It refuses a dangling or live symlink, or a file, at `.internal` as `E_PATH_CONFLICT` before anything is created through it. 3. **A very long `--env` name** (215 characters) produced lock and staging file names over NAME_MAX, so set, unset and hydrate printed a raw ENAMETOOLONG with the staging path. **Fix:** `parseEnvConfigSelection` caps overlay names at 128 characters (`ENV_CONFIG_NAME_MAX_LENGTH`). The longest derived name adds 68 bytes. The CLI refuses longer names up front with the fixed `Invalid --env value.` message (`E_USAGE`), and the daemon route uses the same parser. ENAMETOOLONG is also classified as a write failure (`name_too_long`, `E_PATH_CONFLICT`) for deep project paths. 4. **A stale key lock.** A reused PID is never reclaimed, so `ProjectsRegistryLockTimeoutError` escaped with a stack. **Fix:** a new `ProjectEnvKeyLockTimeoutError` with a new code, `E_LOCKED`. It names the lock and the remedy, and also the `.recovery` guard when that guard is what is held. `ProjectsRegistryLockTimeoutError` gains a `recoveryBusy` field. Its message text is unchanged, because #453 (G69) classifies the per-layer lock by that text. 5. **A `.hack/.gitignore` that is a directory** printed a line with no code, and `--json` gave `E_UNEXPECTED`. **Fix:** `HackDirGitignoreRefusalError` is now a `HackCliError` with `E_PATH_CONFLICT`, which is what `docs/env.md` already said. Its wording is unchanged. 6. **Test gaps** are now covered: the ENOENT, EPERM and EROFS (and EACCES) entries in `isProjectEnvKeyLockUnavailable`; the code and message of every `AtomicWriteRefusal`; behavioural `not_regular_file` and `not_writable` refusals; and the staging EISDIR to `not_regular_file` mapping. `E_LOCKED` is added to `HACK_ERROR_CODES` and the error-code guide, and the plugin reference is regenerated. `docs/env.md` documents the overlay-name limit, `E_LOCKED`, the new `E_PATH_CONFLICT` cases and the behaviour without hard links. This PR does not touch the per-layer lock message, the unset CAS test, the doctor env-key check or the gitignore-refusal wording. #453 (G69, HACK-1386) owns those. The env-layer lock picks up the fallback and the ENAMETOOLONG mapping only through the shared helpers. `git merge-tree` of this head with the current #453 head is clean. ## Tests The new tests are in `tests/project-env-write-refusal-gaps.test.ts`. `tests/fixtures/staging- errno-preload.ts` gains a `link` injection step. - **(1)** With `link(2)` injected as ENOTSUP or EPERM, in and out of git: the first `--secret` creates a 0600 key, a second set adopts it, the lock is released and nothing is left behind. Concurrent sets of three keys under ENOTSUP keep every key. Unit tests cover the fallback: it publishes and uses up its source, never replaces an existing file, never follows or replaces a symlink, and removes its reservation when the rename fails. A real ExFAT test is opt-in on macOS with `HACK_TEST_EXFAT=1`, because it attaches a disk image; the image is detached in `finally`. - **(2)** Hydrate with a dangling or live `.internal` symlink under all three loggers and `--json`; unit tests for `ensureDirForWrite` and `ensureRealDirectoryForWrite`. - **(3)** The limit boundary (128 accepted, 129 refused); set, unset and hydrate `--json` with a 215-character name; ENAMETOOLONG injected at layer staging and at the layer lock. - **(4)** A key lock held by a live unrelated PID gives `E_LOCKED`. Tests also cover `recoveryBusy` propagation and both messages. - **(5)** A `.hack/.gitignore` directory under all three loggers and `hydrate --json`. - **(6)** As listed above. Mutation proof: each mutation below was applied alone, its focused tests were run, and the source was restored. All 21 were killed. | Mutation | Result | | --- | --- | | M1 no fallback on unsupported link / M1b drop EPERM from the fallback codes | 5 fail / 3 fail | | M2 lock `unlink`s the consumed candidate / M3 failed rename keeps its reservation / M3b placeholder path replaces an existing file | 2 / 1 / 2 fail | | M4 `ensureDirForWrite` raw EEXIST / M5 hydrate lock uses plain `ensureDirForWrite` | 1 / 1 fail | | M6 no overlay-name limit / M6b off by one (`<`) / M7 ENAMETOOLONG unclassified | 2 / 2 / 1 fail | | M8 key lock timeout left raw / M9 `recoveryBusy` dropped | 1 / 1 fail | | M10 `.hack/.gitignore` refusal back to `E_UNEXPECTED` | 1 fail | | M11a/b/c drop ENOENT / EPERM / EROFS from `isProjectEnvKeyLockUnavailable` | 1 fail each | | M12a–d wrong code for `not_regular_file`, `not_writable`, `owner_not_preserved`, `group_not_preserved` | 2 / 2 / 1 / 1 fail | | M13 staging EISDIR mapped to `not_a_directory` | 1 fail | ## Evidence - **Real ExFAT, macOS** (`hdiutil create -fs ExFAT`, detached in a trap; private HOME, HACK_HOME and DOCKER_CONFIG; keychain fallback disabled; layer file written first): - On `origin/next` (7d9c89f): raw `ENOTSUP … link '…hack.env.default.yaml.lock.<uuid>.owner'` plus a stack, exit 1, in a plain directory, a git checkout and a linked worktree. - At this head: every case exits 0. A plain directory gets a checkout-local key and `env get` round-trips the value. A git checkout runs a second `--secret` set and a non-secret set. In a linked worktree, the key is written to the shared location in `.git`. No `.tmp`, `.owner` or `.lock` file is left behind. - Six concurrent first `--secret` sets on ExFAT, in a plain directory and in a git checkout, all exit 0, and all six values round-trip. - The opt-in `HACK_TEST_EXFAT=1` test passes (85 expects). - **Items (2) to (5), before and after, with the built `dist/hack`:** - (2) Before: raw `EEXIST … mkdir` with a stack, and `E_UNEXPECTED`. After: `E_PATH_CONFLICT: Could not create directory <…>/.hack/.internal: that path is a symlink or not a directory. Replace it with a regular directory, then retry.` - (3) Before: raw `ENAMETOOLONG … open '<…>.owner'`. After: the fixed `Invalid --env value.` line; `--json` gives `E_USAGE`. A 128-character name still writes its layer. - (4) Before: `Timed out waiting for project env key lock` with a stack. After: `E_LOCKED: … remove <…>/.git/.hack.secret.key.lock, then retry.` No key is created, and the retry succeeds once the lock is removed. - (5) Before: no code, and `E_UNEXPECTED`. After: `E_PATH_CONFLICT: Refused .hack/.gitignore access: …`. - **Focused tests:** - `project-env-write-refusal-gaps`: 28 pass, 1 skip (the opt-in ExFAT test). - `project-env-write-refusals`: 74/74 with `--timeout 120000`. - Nine related files with `--timeout 120000` (`projects-registry-lock`, `projects-registry- identity`, `project-env-layer-lock`, `project-env-key-ignore`, `project-env-worktree-key`, `private-env-file`, `env-selection-parity`, `env-key-argument-redaction-cli`, `focused- skills`): 113/113. - **Gates:** - `bun run check` (includes `privacy:check`): exit 0. - `bun run --cwd packages/cli typecheck`: 0 errors. - Biome on the changed tests: clean. - gitleaks 8.30.1: `gitleaks dir` on the `git log -p origin/next..HEAD` export finds no leaks. - `bun run generate:agent-plugins` and `bun run docs:cli-reference` produce no further diff. - **Full suite:** the hosted CI `test` job at this PR head. ## Gaps - **Interrupted fallback publish.** Without hard links, a process killed between the reservation and the rename leaves an empty `.hack.secret.key` or lock file. Hack refuses it and never adopts or reclaims it, so it must be removed by hand. `docs/env.md` says so. ExFAT and FAT also do not store Unix permissions, so mode 0600 does not limit the key there; this is documented as well. - **ExFAT test is macOS-only and opt-in.** Hosted CI covers the fallback only through the injected ENOTSUP and EPERM cases. Linux FAT (EPERM) is covered only by injection. - **Help text after a usage error.** A too-long `--env` prints the same one-line `ERROR` on stderr as any other invalid `--env`, plus the existing usage help on stdout. That is the pre- existing usage-error convention, and this PR leaves it unchanged. - **Local test runs were affected by host load.** This host ran at a load average of 50 to 290. Default-timeout runs of `project-env-worktree-key`, `project-env-key-ignore` and `project-env- write-refusals` failed with 5-second hook timeouts. The same `project-env-worktree-key` file also fails on an untouched `origin/next` export under the same load (8 of 14). With a 120-second timeout, all of these files pass. Hosted CI is the full-suite evidence. - **New error code.** `E_LOCKED` is new. #453 leaves the per-layer lock refusal without a code. If it wants one, it can reuse `E_LOCKED` once both PRs land. 🤖 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.
Follow-ups to #433 (HACK-1375) and #435 (HACK-1376), both already on
next.Root cause
unsetProjectEnvValuepassesexpectedExisting: textto the layer write. If you delete that line, every lock, hydrate and config test still passes, because only the set path had a paused-writer conflict test.withEnvLayerLockreuses the projects-registry owner protocol. That protocol never removes a malformed receipt, a receipt it does not trust (another uid, or writable by group or others), a receipt whose PID now belongs to a live process, or a dead owner behind a leftover<lock>.recoveryguard. In each of these cases everyenv set/add/unset/hydrateon the layer waited 10 s and then printedAnother hack env command is writing <file>; … Retry when it finishes.No retry could ever succeed, and the message did not name the lock file.git init. In a non-git directory, the first--secretcreates the key and writes its root.gitignoreentry. Outside git, an existing key deliberately triggers no.gitignorewrite. So if.gitignoreis deleted and the directory then getsgit init, the key is not ignored, and Hack is not running at that moment..gitignorealready contains.hack.secret.keyfollowed by!.hack.secret.key.Fix
src/lib/env-layer-lock.ts:EnvLayerLockTimeoutErrornow has areason(held|recovery_guard) and thelockPath. The message is still one value-free line with no stack:held:Another hack env command is writing <file>; gave up after waiting 10s for its lock at <lock>. Nothing was written. Retry when it finishes. If no hack env command is running, Hack cannot reclaim that lock itself: remove <lock> and retry.recovery_guard: when the registry reports a dead owner behind a busy guard, the message keeps the registry'srecovery guard is busy (interrupted recovery requires offline inspection)detail and names both<lock>.recoveryand<lock>as the files to remove.src/lib/projects-registry-lock.tsis unchanged; the classification reads the registry's existing timeout detail. That file belongs to G73 / HACK-1377.src/lib/project-env-config.ts:projectEnvKeyUnignoredErrornow takes acause.unwritablekeeps the existing message.reincludedsays the root.gitignorelists the key but a later rule in that file re-includes it, and asks the user to remove that rule. Once the entry is present, no deeper ignore file can apply to a key at the project root, so a later rule in that same file is the only possible cause.src/commands/doctor.ts: exportscheckProjectEnvKeyso the real doctor check can be tested end to end. It also moves a misplaced docblock onto that function. The check's behavior is unchanged.docs/env.md: documents unreclaimable locks and the recovery step, the re-include cause, and thegit initdecision. It also adds anenv-key-unignoredexample to the doctor table.Decision on a later
git init(item 3)Watching for
git initis out of scope for env writes, because Hack runs no code when it happens. Outside git, an existing key still needs no entry, and Hack does not recreate a root.gitignorethe user deleted. That behavior is unchanged and covered by the existing test. The guarantee comes back at the next Hack touchpoint:hack doctor's env key check reportsenv-key-unignored(warn) with a remedy. This check already existed; the new test proves it catches this exact sequence.--secretset or hydrate in the new repository adds the entry before it uses the key, or it refuses (fix(env): create the project key only after its root .gitignore entry #433 behavior).Tests (each proven by mutation)
project-env-layer-lock: an unset refuses with a conflict when a writer that takes no lock changed the layer after its read. AspyOnpausesresolvePrimaryLocalProjectDir, the one awaited call between the local unset's read and its write. The test edits the layer, resumes, and expectsAtomicWriteConflictErrorwith the edit kept.expectedExisting: text,from the unset: 1 failproject-env-layer-lock: a layer lock Hack cannot reclaim… uses three receipts: malformed, group-writable with an exited PID, and PID 1 standing in for a reused PID. Set and unset each refuse with the exact message, the receipt is left untouched, and removing the named lock lets the retry succeed. The existing held-lock test asserts the new message too.project-env-layer-lock: a dead owner behind an interrupted recovery guard… covers a set (300 ms wait) and a hydrate (default 10 s): both give therecovery_guardmessage with both paths, write nothing, and leave the lock and guard in place. After both are removed, the set succeeds.held: 1 failproject-env-key-ignore: the re-include test now expects thereincludedmessage, does not contain "could not add it", and checks that.gitignorewas not rewritten.unwritablefor the post-entry probe: 1 failproject-env-key-ignore: a key whose entry was removed before a later git init… runs the exact reported sequence through the source CLI, then the realcheckProjectEnvKey: statuswarn,env-key-unignored, and no key bytes or absolute root in the output. The next--secretset repairs the entry and doctor returnsok.inspectLocalKeyIgnoredfrom doctor: 1 failEvery fixture uses a private HOME, HACK_HOME, global config and Docker config,
HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1, and git without global or system config. The env layer is written before any--secret. The in-process doctor call swaps in the same private environment and restores it afterwards.Evidence
git init,git check-ignoreexits 1 andgit statuslists.hack.secret.key. (4) gave the "could not add it" message. After the fix, the same script prints the messages above, and removing the named lock makes the retry exit 0.tests/project-env-layer-lock.test.ts12/12 andtests/project-env-key-ignore.test.ts8/8 pass locally.bun run check(includesprivacy:check) exit 0.bun run --cwd packages/cli typecheckexit 0. gitleaks 8.30.1 (gitleaks diron thegit log -p origin/next..HEADexport): no leaks.testat this PR head (see checks).Gaps
project-env-write-refusals,project-env-stable-ciphertextandproject-env-worktree-keyfailed withbeforeEach/afterEach hook timed outwhile the host load average was 51–60 on 18 cores. None of them asserts a message this PR changes. Hosted CI is the evidence for them.ClosedCliErrorwith noE_*code (as before). Adding a lock-busy code would change the error-code registry and its generated docs, so it is left for a separate decision.recovery_guarddepends on the registry's existing timeout text. If G73 rewords that text, the guard test fails in the merge train rather than silently falling back toheld.heldmessage cannot tell a live writer from an unreclaimable lock without parsing the receipt (code G73 is editing), so it covers both cases: retry, or remove the lock when no hack env command is running.spyOnon an ESM namespace export (worktree-local-config.ts), in process and restored infinally.Round 2: merge with current next
While this PR was being verified,
nextmoved from 7d9c89f to 5208ee5 (#404, #446, #430, #439, #421, #448, #452, #405), and the PR becameCONFLICTING.origin/next(5208ee5) into the branch. The only conflict was thecheckProjectEnvKeydocblock insrc/commands/doctor.ts, which fix(doctor): name explicit recovery rollback for a literal override a pending adoption pins #448 also moved. It is resolved to this PR's side: the extended docblock plusexport async function checkProjectEnvKey. Compared with next,doctor.tsnow differs only in those lines.bun run check(includesprivacy:check) exit 0.bun run --cwd packages/cli typecheckexit 0. Focused testsproject-env-layer-lock,project-env-key-ignoreandcli-spawn-env-isolationpass 27/27 (--timeout 120000) under test: fail and contain process-state leaks between test files #405's new preloads. gitleaks 8.30.1 (gitleaks diron thegit log -p origin/next..HEADexport): no leaks.testpassed in 38m45s, plus docker-e2e 1-4/4, Candidate core (ubuntu), Config compiler (ubuntu x64/arm, macos-15-intel), Runtime state models, linux-process-lifetime, runtime-images and secret-scan. The twomacos-latestjobs were still queued for a runner after about an hour. The earlier green run 39978726682 was at ed877f5, before the base moved, so it does not count for this head.Refs HACK-1386
🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need.