Skip to content

fix(env): name unreclaimable layer locks and the re-include cause - #453

Merged
roodboi merged 3 commits into
nextfrom
wf/g69-env-lock-key-followups
Oct 24, 2026
Merged

roodboi merged 3 commits into
nextfrom
wf/g69-env-lock-key-followups

Conversation

@roodboi

@roodboi roodboi commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Follow-ups to #433 (HACK-1375) and #435 (HACK-1376), both already on next.

Root cause

  1. The unset compare-and-swap was not tested. unsetProjectEnvValue passes expectedExisting: text to 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.
  2. Some layer locks can never be reclaimed, and the message did not say so. withEnvLayerLock reuses 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>.recovery guard. In each of these cases every env set/add/unset/hydrate on the layer waited 10 s and then printed Another hack env command is writing <file>; … Retry when it finishes. No retry could ever succeed, and the message did not name the lock file.
  3. A key can become unignored after a later git init. In a non-git directory, the first --secret creates the key and writes its root .gitignore entry. Outside git, an existing key deliberately triggers no .gitignore write. So if .gitignore is deleted and the directory then gets git init, the key is not ignored, and Hack is not running at that moment.
  4. The re-include refusal named the wrong cause. The ignore probe that runs after the entry is in place produced the same "Hack could not add it to <.gitignore>" message as a failed write. That is wrong when .gitignore already contains .hack.secret.key followed by !.hack.secret.key.

Fix

  • src/lib/env-layer-lock.ts: EnvLayerLockTimeoutError now has a reason (held | recovery_guard) and the lockPath. 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's recovery guard is busy (interrupted recovery requires offline inspection) detail and names both <lock>.recovery and <lock> as the files to remove.
    • The lock helper still never removes anything itself. src/lib/projects-registry-lock.ts is unchanged; the classification reads the registry's existing timeout detail. That file belongs to G73 / HACK-1377.
  • src/lib/project-env-config.ts: projectEnvKeyUnignoredError now takes a cause. unwritable keeps the existing message. reincluded says the root .gitignore lists 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: exports checkProjectEnvKey so 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 the git init decision. It also adds an env-key-unignored example to the doctor table.

Decision on a later git init (item 3)

Watching for git init is 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 .gitignore the user deleted. That behavior is unchanged and covered by the existing test. The guarantee comes back at the next Hack touchpoint:

Tests (each proven by mutation)

Test Mutation that makes it fail
project-env-layer-lock: an unset refuses with a conflict when a writer that takes no lock changed the layer after its read. A spyOn pauses resolvePrimaryLocalProjectDir, the one awaited call between the local unset's read and its write. The test edits the layer, resumes, and expects AtomicWriteConflictError with the edit kept. delete expectedExisting: text, from the unset: 1 fail
project-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. restore the old message: 2 fail
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 the recovery_guard message with both paths, write nothing, and leave the lock and guard in place. After both are removed, the set succeeds. classify the guard case as held: 1 fail
project-env-key-ignore: the re-include test now expects the reincluded message, does not contain "could not add it", and checks that .gitignore was not rewritten. send unwritable for the post-entry probe: 1 fail
project-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 real checkProjectEnvKey: status warn, env-key-unignored, and no key bytes or absolute root in the output. The next --secret set repairs the entry and doctor returns ok. drop inspectLocalKeyIgnored from doctor: 1 fail

Every 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

  • Manual repro before the fix: (2) dead owner plus guard, and a malformed receipt, gave the old "Retry when it finishes." message. (3) after git init, git check-ignore exits 1 and git status lists .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.
  • Focused: tests/project-env-layer-lock.test.ts 12/12 and tests/project-env-key-ignore.test.ts 8/8 pass locally.
  • bun run check (includes privacy:check) exit 0. bun run --cwd packages/cli typecheck exit 0. gitleaks 8.30.1 (gitleaks dir on the git log -p origin/next..HEAD export): no leaks.
  • The CLI surface and consumer agent guidance did not change, so no reference or plugin regeneration was needed.
  • Full suite: hosted CI test at this PR head (see checks).

Gaps

  • In a wider local run of 11 related env/doctor files, 13 tests in project-env-write-refusals, project-env-stable-ciphertext and project-env-worktree-key failed with beforeEach/afterEach hook timed out while 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.
  • The refusal is still a ClosedCliError with no E_* 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.
  • Classifying recovery_guard depends 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 to held.
  • The held message 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.
  • The unset CAS test uses spyOn on an ESM namespace export (worktree-local-config.ts), in process and restored in finally.

Round 2: merge with current next

While this PR was being verified, next moved from 7d9c89f to 5208ee5 (#404, #446, #430, #439, #421, #448, #452, #405), and the PR became CONFLICTING.

  • Merge commit 7ac7cbb merges origin/next (5208ee5) into the branch. The only conflict was the checkProjectEnvKey docblock in src/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 plus export async function checkProjectEnvKey. Compared with next, doctor.ts now differs only in those lines.
  • No other code changed in this round. The diff against next is still the same 6 files.
  • Gates on the merged head: bun run check (includes privacy:check) exit 0. bun run --cwd packages/cli typecheck exit 0. Focused tests project-env-layer-lock, project-env-key-ignore and cli-spawn-env-isolation pass 27/27 (--timeout 120000) under test: fail and contain process-state leaks between test files #405's new preloads. gitleaks 8.30.1 (gitleaks dir on the git log -p origin/next..HEAD export): no leaks.
  • Full suite: hosted CI run 40056707145 at 7ac7cbb (base 5208ee5): test passed 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 two macos-latest jobs 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


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

- 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>
@linear-code

linear-code Bot commented Oct 23, 2026

Copy link
Copy Markdown

HACK-1386

@blacksmith-sh

This comment has been minimized.

roodboi and others added 2 commits October 23, 2026 11:29
…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>
@roodboi
roodboi marked this pull request as ready for review October 24, 2026 05:48
@roodboi
roodboi merged commit 5a2387c into next Oct 24, 2026
16 checks passed
@roodboi
roodboi deleted the wf/g69-env-lock-key-followups branch 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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant