Repository navigation
fix(doctor): refuse symlinked .hack/.internal instead of deleting through it, and support adopted projects - #392
Merged
Merged
Conversation
…ead of deleting through it Doctor's Compose env at-rest check, its --fix removal and the stale-aside sweep now apply the writer's no-follow ownership check to .hack and .hack/.internal (and the override itself) before they list, read, move or unlink anything. A link reports a closed, value-free refusal and nothing behind it changes. Before, a symlinked .hack/.internal made doctor report an outside file as unverifiable and --fix deleted it. Refs HACK-1316 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs HACK-1316 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… as native input An active or pending `hack config adopt` makes ordinary project discovery refuse the project as native input, so `hack doctor` stopped at its preflight with E_NATIVE_PROJECT_UNSUPPORTED, every project check that reached discovery failed, and the --fix plan threw. Doctor now selects the adopted root first, reports it with a closed project message, runs the checks that do not read adoption-owned inputs (env key, tracked generated files, env values at rest), lists the others as not checked, and plans --fix without touching the adoption. Ordinary native input is still refused, and the Compose-backend features check is unchanged for legacy projects. Refs HACK-1311 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e native-input refusal `hack doctor --fix` in an active adopted project with tracked generated files ran `git rm --cached` and then called the legacy `.hack/.gitignore` writer, which refuses an adopted project as native input. The run exited 1 with E_NATIVE_PROJECT_UNSUPPORTED after changing the Git index, and the later fix steps never ran. For an adopted project the step now changes only the Git index (and adds a committed `.hack.secret.key` to the root `.gitignore`), leaves the adoption's `.hack/.gitignore` unchanged with a closed note, and the plan says so. Regression tests run the whole `--fix` flow in an adopted fixture with Docker reachable and `--migrate-env-config`, so they also cover the legacy-context guards of the later steps; another test covers the env override writer's refusal before mkdir (HACK-1316). Refs HACK-1311, HACK-1316 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolve doctor.ts against #395: keep the extracted runProjectChecks and add the new 'hack gitignore' check to its legacy set; untrack keeps the adopted branch and uses the no-follow ensureHackDirGitignoreForDoctor otherwise. The new --fix gitignore step skips adopted projects (it otherwise hit the HACK-1311 native-input refusal), and adopted doctor reports 'hack gitignore' as not applicable, with a test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolve the env-secrets threat model row: keep next's HACK-1318 adoption recovery refusal text and this branch's HACK-1316 no-follow sentence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ecked adopted checks Doctor already lists 'hack gitignore' as not checked for an adopted Compose project; the reference listed every other skipped check but this one. Refs HACK-1311 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
roodboi
marked this pull request as ready for review
October 22, 2026 23:40
roodboi
added a commit
that referenced
this pull request
Oct 23, 2026
…ding; test shared host stop waits (#432) ## Summary Follow-ups to #392 (doctor in adopted projects, symlinked `.hack`) and #389 (shared host stop waits), merged into `next` at `4e0bfa55`. - **Deep start directory.** `hack doctor` no longer throws `E_NATIVE_PROJECT_UNSUPPORTED` when it starts more than 64 directories deep. It now uses ordinary project discovery. - **Adopted-project skips.** The 12 checks that doctor does not run for an adopted project now use doctor's skip wording. They keep status `ok`. - **New tests.** A pending adoption, `doctor --fix` with a symlinked `.hack`, a census that fails during the stop wait, and the length of the KILL-confirm wait. - **Docs.** The shared-host test row for a running PID with a new birth now describes what production does. ## Root cause 1. **Skip convention.** #392 reported the 12 checks as `ok` with `Not checked: …`. Every other doctor skip uses `Skipped (…)`. 2. **Deep start directory.** `preflightDoctorProject` called `selectLegacyComposeAdoptedRoot` without a catch. With no `--project`, that selector walks up at most 64 directories. If it reaches no root in that walk, it throws `unsupported()` (`E_NATIVE_PROJECT_UNSUPPORTED`). From a start directory more than 64 levels deep, every doctor run and every `--fix` step that looks for an adoption threw. Ordinary discovery has no such limit. 3. **Test gaps.** No test covered the pending-adoption warning or `--fix` in a pending adoption. There was a CLI regression for a symlinked `.hack/.internal`, but none for a symlinked `.hack`. In #389, mutants M7 (the old loop, which stops waiting on a failed census) and M10 (`awaitExit(0)` after KILL) survived. 4. **Docs.** The row "running PID with a new birth" said that release sends KILL. That is true only at the simulated signal boundary. In production, `signalVerifiedProcessTree` checks the birth time again before it sends a signal, so a PID with a different birth time gets no signal. ## Fix - **Skip convention: keep `ok` and use the `Skipped (…)` wording.** The new message is `Skipped (adopted Compose project: its inputs belong to 'hack config adopt')`. Doctor already uses two kinds of skip: - `warn` when the skip points at something to fix, such as `Skipped (no .hack/ found)` or `Skipped (docker runtime unavailable)`; - `ok` when the skip is expected in a healthy setup, such as `Skipped (not a git checkout)`. These 12 checks never apply to an adopted project, so `warn` would add the same 12 warnings to every healthy adoption in the summary and in `counts.warn`, and real warnings would be harder to see. Problems that belong to the adoption still show up in the `project` check (`warn` for a pending adoption) or in the adopted commands. The reasoning is in a comment in `src/lib/doctor-adopted-project.ts`, and in `docs/reference/native-compose-adoption.md`. - **Deep start directory: a closed result.** `findDoctorAdoptedProject` catches only the selector's search-limit refusal and returns `null`, meaning no adoption was found. Ordinary discovery then runs. With no `--project`, `E_NATIVE_PROJECT_UNSUPPORTED` from this selector can only mean the search limit. Unsafe adoption state throws `E_CONFIG_INVALID`, and that still stops doctor. `preflightDoctorProject` moved from `src/commands/doctor.ts` to `src/lib/doctor-adopted-project.ts` so the tests can call it. Its behavior did not change. - **Docs.** In `docs/reference/shared-host-lifecycle.md`, the row now says that release passes the member to the KILL step and waits the whole `SHARED_HOST_KILL_CONFIRM_MS`. The simulated boundary records the request, but in production no signal reaches that PID. There are new rows for the two new tests. ## Tests (every new test proven by mutation) The private env is set by a runner script: `HOME`, `HACK_HOME` and `DOCKER_CONFIG` point to a temp directory, `HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1`, and `DOCKER_HOST` points to a socket that does not exist. `doctor-fix-noninteractive` runs without `HACK_HOME`, because its existing daemon test finds `~/.hack` through its own private `HOME`. The pending-adoption tests use a new fixture helper, `leaveRetainedSourceBindOperationPending`. It runs a retained `start` whose callback reports failure, so the store keeps the pending journal. No engine command runs, and nothing sleeps. | Test | Mutation | Result | | --- | --- | --- | | `doctor-adopted-project`: the existing adopted `doctor --json` test now also checks the exact skip text and that there are 12 skipped checks | D1: status `warn` | killed | | same | D2: the old `Not checked: …` text | killed | | `doctor-adopted-project`: "a start directory deeper than the adoption search bound gets ordinary discovery instead of a throw". It uses 70 nested directories, first checks that the selector itself throws, then expects `findDoctorAdoptedProject`, `preflightDoctorProject` and the `--fix` plan to return without an error | D3: no catch (the #392 code) | killed: `E_NATIVE_PROJECT_UNSUPPORTED` | | `doctor-adopted-project`: "unsafe adoption state still refuses doctor". The state root is a regular file, and the test expects `E_CONFIG_INVALID` with no canary text and no path in it | D4: catch every error | killed | | `doctor-adopted-project`: pending adoption. `doctor --json` gives a `warn` `project` check with the exact message and `ok: true`. The 12 checks are skipped, the `--fix` plan is checked from the root and from a subdirectory, the adoption is still pending, the receipt is unchanged, and no container or project command runs | D5: pending reported like active | killed | | same, plus `doctor-fix-adopted-project`: real `runCli doctor --fix --no-interactive --migrate-env-config` with a pending adoption and a committed `.hack/.env.state.json` (real `git init`). It checks exit 0, the plan line, the untrack note, the env-migration skip, the closing note, an empty `git ls-files`, no `.hack/.gitignore`, the adoption still pending, the receipt unchanged, and no engine command or project `run` call | D6: pending no longer selected | killed (both) | | `doctor-fix-adopted-project` pending case | D6b: the untrack step treats a pending adoption as legacy | killed: exit 1 from the legacy writer's refusal | | `doctor-fix-noninteractive`: "doctor --fix leaves an override behind a symlinked .hack untouched". The whole `.hack` links to an outside directory that holds a file with the override's name. The test expects that file unchanged, both directory listings unchanged, `.hack` still a link, and the notes do not mention the file | D7: the ownership check covers only `.internal` | killed: ENOENT (the outside file was deleted) | | `shared-host-lifecycle`: "a census that fails during the TERM wait keeps the wait going". The first three polls fail and poll 4 shows the helper gone. The test expects `stopped`, 4 polls and TERM only | M7: the old loop | killed: `stop-unconfirmed` | | `shared-host-lifecycle`: "after KILL, release keeps polling for the whole kill-confirm wait". A 100 ms grace; the helper leaves the census only 80% of the way into `SHARED_HOST_KILL_CONFIRM_MS` after KILL. The test expects `stopped`, TERM then KILL, and a return at or after 80% of that wait | M10: `awaitExit(0)` | killed | | Existing "running with another birth time" test, which now also expects the return at or after `SHARED_HOST_KILL_CONFIRM_MS` after KILL | M10, and M10b: `awaitExit(SHARED_HOST_KILL_CONFIRM_MS / 2)` | both killed (both tests) | For each mutation, the test was run alone and failed for the reason shown. The file was restored afterwards. ## Evidence - **Toolchain.** Bun 1.4.2 (`mise x bun@1.4.2`), `bun install --frozen-lockfile`. `bun.lock` is unchanged. - **Local checks (M5):** - `bun run --cwd packages/cli typecheck`: 0 errors. - Root `bun run check`: exit 0, including `privacy-check: ok`. - Biome on the 7 changed source and test files: clean. - gitleaks 8.30.1, `gitleaks dir` over a `git log -p origin/next..HEAD` export: no leaks. - **Focused tests:** 179 pass, 0 fail, across 10 files: `doctor-adopted-project`, `doctor-fix-adopted-project`, `shared-host-lifecycle`, `compose-env-at-rest`, `doctor- command`, `doctor-json`, `doctor-generated-files`, `doctor-env-key`, `native-compose-adoption-source-bind-generation` and `native-compose-adoption-source-bind-fixture`. `doctor-fix-noninteractive` passed 11 of 11. `shared-host-lifecycle` also passed 3 more times in a row, 38 of 38 each. - **Hosted CI at `1345ce5a`:** all 16 checks pass (run 39876007802). These include the full-suite `test` job (42 min, ubuntu), `linux-process-lifetime` (which runs `tests/shared-host-lifecycle.test.ts`), all four `docker-e2e` shards and `secret-scan`. - **Full suite on the M3: not run.** I queued a runner for `1345ce5a` at 20:04. It would take the shared CI lock and start only with at least 60 GiB free. It waited about 52 minutes. During that time, someone else had held the lock directory since 17:39, two other units were queued ahead of mine, and free space was 55.1 to 56.6 GiB, under the 58 GiB floor. I stopped my runner before it took the lock. It created no worktree, ref or lock, and I did not touch the existing lock. ## Gaps - **No M3 full suite.** The shared lock was blocked and free space was under the floor (see Evidence). The hosted full suite ran on ubuntu, not on macOS. The focused tests ran on macOS. - **Search limit is unchanged.** Doctor does not search past the selector's 64 parent directories. An adopted root that is more than 64 levels above the start directory falls through to ordinary discovery, which refuses it with the existing closed native-input error. - **Other commands still throw.** The adopted-command selector has the same throw for other commands, for example the native Compose command path and `native-compose-open`. This PR does not change them; it is reported as a finding. - **No CLI or tooling regeneration.** There are no CLI surface, agent guidance or `package.json` script changes, so nothing needed to be regenerated or re-pinned. Refs HACK-1374 🤖 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
Two
hack doctorfixes..hackor.hack/.internal. Before this fix,doctor --fixdeleted a file outside the project.hack config adopt) project correctly, anddoctor --fixcompletes in one. Before this fix, doctor refused the project as native input, the--fixplan threw, and--fixwith tracked generated files failed after it had already changed the Git index.Rework after review (commit
ff7f11e1)The verifier found one blocking defect in the first version (
ffbc4ffd):.hack.secret.key),hack doctor --fix --no-interactiveexited 1 withE_NATIVE_PROJECT_UNSUPPORTED. It did this aftergit rm --cachedhad already run, so.hack/.gitignorewas not written and the later fix steps (hackd, Docker networks, Caddy and the rest) never ran.maybeUntrackGeneratedFilesselected the adopted project, rangit rm --cached, and then calledensureHackDirGitignore. That writer callsassertLegacyProjectDirectory, which throwsProjectInputSelectionError("native")for an active adoption (src/lib/project-input-selection.ts)..hack.secret.keyto the root.gitignore, which is not an adoption input. It leaves the adoption's.hack/.gitignoreunchanged and says so in a closed note:Removed N path(s) from the git index. Left .hack/.gitignore unchanged: an adopted Compose project's .hack/ belongs to 'hack config adopt'.The plan line names the same change. Legacy projects behave exactly as before.docs/reference/native-compose-adoption.mdnow matches the code:--fixleaves.hack/.gitignoreunchanged and adds a committed key to the root.gitignore.findDoctorLegacyProjectContextguards on the later--fixsteps had no regression test. The new test runs the whole--fixflow with Docker reachable and--migrate-env-config, so the lifecycle, incomplete-runtime and env-migration steps run against the adopted project.mkdir(HACK-1316) had no test. A new unit test covers it.Update: merge with
nextat5c9cc82e(commits0d6aa587,51244fc9)0d6aa587mergesorigin/nextat5c9cc82e(fix(native): give storage proofs their own budget outside the start phase #375, fix(project): leave read-only .hack/.gitignore alone and report symlinked .hack in doctor #407, fix(env): share global Compose env references and refuse an oversized environment #412) with no conflicts. fix(project): leave read-only .hack/.gitignore alone and report symlinked .hack in doctor #407 changes the same.hack/.gitignorecode in doctor: the repair helper now returns{ changed, readOnly }and the untrack step can leave a read-only file alone. After the merge, the adopted branch of the untrack step still skips the.hack/.gitignorewriter, and only the legacy branch uses fix(project): leave read-only .hack/.gitignore alone and report symlinked .hack in doctor #407's read-only handling.51244fc9is a one-line docs fix.docs/reference/native-compose-adoption.mdlisted every check that doctor skips for an adopted project except the.hack/.gitignorecheck. The code already skips it.The verifier's repro, rerun on the merged head. It is the verifier's own scratch test:
retainedSourceBindFixture, a realgit init,git add -f .hack.secret.key, adopt (prepare and publish), thenrunCli doctor --fix --no-interactive, with shellrunmocked. The output has noE_NATIVE_PROJECT_UNSUPPORTED.git ls-filesis empty,.hack/.gitignoreis not created, the closed note above is printed, the adoption is stillactive, and the later steps run (the globaldaemon startcall and the closingRe-run: hack doctornote).Why that run still exits 1. Doctor's exit code comes from the checks that run before
--fix. This is existing behavior and is the same for legacy projects. The verifier's harness makes every non-Gitexecfail, sodocker daemonis an error, and the committed key is an error in thegenerated filesandenv keychecks. The PR'sdoctor-fix-adopted-projecttest makes Docker reachable. Its.hack/.env.state.jsoncase exits 0. Its key case exits 1 only because of the committed-key error.Mutations rerun on the merged head, each killing its test for the intended reason:
.hack/.gitignoreagain (ffbc4ffdbehavior)doctor-fix-adopted-project0/2, withE_NATIVE_PROJECT_UNSUPPORTEDin the outputfindDoctorLegacyProjectContextno longer skips an adopted project (survived the first review)doctor-fix-adopted-project0/2, withE_NATIVE_PROJECT_UNSUPPORTEDmkdirrefusal removed (survived the first review)compose-env-at-rest15/16; "the writer refuses a symlinked .hack before creating .internal behind it" failsRoot cause
HACK-1316: symlinked
.hack/.internalsrc/lib/compose-env-at-rest.tschecked that.hack/.internalwas a real directory, but only insidereadReplaceablePrivateFile, and it never checked.hack. When that read refused,inspectEntrycalledlstat(path)to choose betweenabsentandunverifiable. Thatlstatfollowed the.internallink, found the outsidecompose.env.override.yml, and classified it asunverifiable. WithincludeUnverifiable, the--fixremoval then ranlstat,renameandunlinkthrough the same link, which deleted the outside file. The stale-aside sweep ranreaddiron the linked directory before any symlink check, so it could also unlink.remove-shaped files in the outside directory.HACK-1311: adopted projects
An active or pending adoption makes
inspectProjectInputsAtRootclassify the root asnative. Doctor's first step,assertLegacyProjectDiscovery, therefore threwE_NATIVE_PROJECT_UNSUPPORTED. Every project check andbuildDoctorRemediationPlanLinescallfindProjectContext, which throws the same refusal. Every--fixstep that writes legacy project files does too.Fix
HACK-1316 (
src/lib/compose-env-at-rest.ts)One no-follow ownership check now covers both
.hackand.hack/.internal. The writer and every read, removal and sweep use it.A new
refusedstate covers these cases:.hackor.hack/.internal;Doctor reports it with a closed, value-free message. A
refusedoverride is never eligible for removal.Removal returns before the sweep when the directories are not real. It checks the directory identities again before the move and before each aside unlink. The aside listing runs only on the held directory.
The writer refuses a linked
.hackor.internalbeforemkdir, so it creates nothing through the link.HACK-1311 (
src/commands/doctor.ts,src/lib/doctor-adopted-project.ts)selectLegacyComposeAdoptedRoot, the selector that adoptedps,logsand the other adopted commands use. The legacy-discovery refusal runs only when no adoption is found.projectcheck names the adopted root. A pending adoption is a warning that asks for explicit recovery.--fixfor an adopted project:runProjectChecks, to stay under the complexity limit. The fix(native): keep Compose-only projects on the Compose backend with closed refusals #365 compose backend features check and the fix(env): keep decrypted Compose env values off disk #357 env-at-rest adopted fallback are unchanged.There are no CLI surface changes and no agent-guidance changes.
Tests
doctor-fix-adopted-project(new), 2 cases: a committed.hack.secret.keyand a committed.hack/.env.state.jsonrunCli doctor --fix --no-interactive --migrate-env-configin an adopted fixture with a realgit init. Docker is reachable through the mock, and Git runs only on the fixture with no global or system config. Each case checks:- no
- the exact plan line and step note;
- the closing notes, which prove every later step ran;
- the root
- the receipt is unchanged, the adoption is still active, and no stub engine command or project
Exit code: 0 for the env state file. For the key it is 1, because theE_NATIVE_PROJECTand no canary or key text;git ls-filesis empty and the file is unchanged on disk;.gitignoregets the key entry only in the key case, and.hack/.gitignoreis not created;runcall happens.env keycheck reports the committed key as an error before--fixruns. That is existing doctor behavior.ffbc4ffdbehavior (always write.hack/.gitignore): 0/2 pass, exit 1.findDoctorLegacyProjectContextno longer skipping adopted projects: 0/2 pass, exit 1.compose-env-at-rest: "the writer refuses a symlinked .hack before creating .internal behind it" (new).internalgets nothing created behind it, and the refusal is closed.mkdirrefusal removed: fails (this mutation survived before).doctor-fix-noninteractive: "doctor --fix leaves a file behind a symlinked .hack/.internal untouched (HACK-1316)"runCli doctor --fix: the outside file survives, and the plan and notes do not name it.origin/next's helper: fails with ENOENT, because the outside file was deleted.compose-env-at-rest: symlinked.hackand.hack/.internal(2 cases)refusedstate, closed message, no repair planned, no asides removed with a stale-aged clock, writer refusal, outside contents byte-identical..hack: the.hackcase fails.inspectEntryalwaysunverifiable: the symlinked-override case fails.doctor-adopted-project:doctor --jsonand the--fixplan in an adopted projectrunProjectChecksdisabled: fails. Preflight forced back to legacy discovery: fails.All tests use temporary directories only. They do not touch the real HOME,
~/.hack, the Docker socket, the keychain or launchd.Evidence
mise x bun@1.4.2),bun install --frozen-lockfile.bun.lockis unchanged.51244fc9(M5):bun run --cwd packages/cli typecheck: 0 errors.bun run check: exit 0, includingprivacy-check: ok(34 existing warnings in other files).gitleaks dirover agit log -p origin/next..HEADexport: no leaks.51244fc9, each file run on its own with--timeout 120000(the M5 was under heavy load), all passing:compose-env-at-rest16,doctor-fix-noninteractive10,doctor-adopted-project2,doctor-fix-adopted-project2native-compose-backend-features13,native-compose-backend-features-cli15,compose-env-at-rest-cli9doctor-command28,doctor-json3,doctor-generated-files11,doctor-env-key31,doctor-utils8,doctor-worktree6hack-gitignore14,project-env-config58,project-env-hydrate18,compose-env-shared-references6--fixand at-rest files also pass together in one process (41/41), which checks that the scoped module mocks do not interfere.doctor-fix-adopted-projectwith Bun's default 5 s timeout failed in a fixture hook. It passes with the longer timeout. This is load, not a code change.91dae070waited about 6.5 hours and never got the shared CI lock. I stopped it and queued a runner for51244fc9, which waited about 50 minutes. During that time the lock directory had existed since 12:37 with no suite process running on the M3, several other units were also waiting, and free space was about 57.6 GiB, under the 58 GiB floor. I did not touch the lock, since I could not prove who owned it. I stopped my runner. It created no worktree or ref.51244fc9(run 39839971608): the full-suitetestjob (39 min), all fourdocker-e2eshards, candidate core, config compiler, runtime state models, and secret scan. Earlier heads0a94d881,a2470049and91dae070also passed.Remaining gaps
testjob ran at51244fc9on ubuntu, not on macOS..hacknow refused by the writer. A Compose command that renders the env override now refuses a symlinked.hack, not only a symlinked.internal. This matches the existing.hack/.envrule. A project that relied on writing through a linked.hackwill see a closed refusal.--fixdoes not add the untracked generated files under.hack/to any ignore file. Only the key gets a root.gitignoreentry. The note asks the user to keep them out of later commits.--fixwith a committed key exits 1. The exit code reflects the checks that ran before the fix, for legacy and adopted projects alike. The fix itself completes and removes the key from the Git index, which is what the tracked-key checks read.HACK_RUNTIME_BACKEND=native, native doctor still skips project checks for an adopted project. This is unchanged.Refs HACK-1316
Refs HACK-1311
🤖 Generated with Claude Code