Skip to content

fix(env): keep decrypted Compose env values off disk - #357

Merged
roodboi merged 12 commits into
nextfrom
wf/g5-compose-env-at-rest-b17
Oct 21, 2026
Merged

roodboi merged 12 commits into
nextfrom
wf/g5-compose-env-at-rest-b17

Conversation

@roodboi

@roodboi roodboi commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Root cause

On the Compose backend, resolveModernComposeEnvOverrides in src/commands/project.ts rendered every resolved env value into .hack/.internal/compose.env.override.yml. That included values decrypted from secure: v1: entries. It wrote the file with writeTextFileIfChanged (Bun.write, which uses the umask mode and follows a symlink) and never removed it. Up, down, a failed up and an interrupted up all left the plaintext on disk. A rerun on a real project saw this: a 71 KB, 0600 file covering 14 services was still there after down.

The first version of this PR moved the values out of the file. Review found that this broke legacy Compose adoption. Each container's com.docker.compose.config-hash label is computed by Compose with the real HACK_ENV_* values. The adoption probe (inspectLegacyComposeRuntimeConfig) recomputed the hash from the files with no values, so every reference resolved blank. The hashes differed and adoption refused every stack with managed env that this version started. The docker e2e adoption harness also started its original stack with raw docker compose and no values. That failed docker-e2e shards 3/4 and 4/4 (run 39293621453).

Rework 2 (head 879e0b56)

A verifier checked 3970f175 and reported six defects. Commits 5695f824, bebeb89c and 7345de3f already claimed fixes for most of them. This rework checked each claim with a mutation, merged current next, and closed what was still open.

Verifier defect Status at 879e0b56 Evidence
1. Adoption refuses every stack this version starts (probe hashes ${HACK_ENV_*} as blank) Fixed in 5695f824 Without privateInterpolationEnv in the probe env, the HACK-1273 adoption test fails. With the saved-hash path disabled, it also fails.
2. hack down skips the scrub when the down-before hook fails or Compose down throws Fixed in bebeb89c: the scrub runs before hooks, the remote target and Compose down With the scrub moved back to after the finally block, "down … even when its down-before hook fails" fails.
3. TOCTOU: inspect, then unlink by path Fixed in bebeb89c (move aside, classify the moved entry, link back without overwriting) With a plain unlink by path, "removal keeps a value-free override that replaced the inspected literal file" fails. The remaining small window is now documented (see Remaining gaps).
4a. doctor --fix removal untested Covered by doctor-fix-noninteractive (from bebeb89c) With the --fix step removed, that test fails.
4b. No-override scrubs untested 2 of 3 were covered. The third (legacy hack.env.json path where no service gets an override) was still untested. This rework adds a test for it. With each of the three scrub calls removed in turn, a different test fails.
5. CLI test PATH includes the host PATH Fixed: PATH is the stub bin plus /usr/bin:/bin only Code reading
6. Docs overstate or omit points Fixed in bebeb89c. This rework also states the move-aside window and the aside left by a crash, and describes hack down by what it runs before docs/env.md, threat model row and gap 4

New in this rework:

  • Merge with next (cb2d6226). fix(secrets): converge concurrent key provisioning and add sentinel scan controls #349 had made the literal override private (0600, atomic, writePrivateEnvFileIfChanged). This branch keeps its reference writer instead. The resolution drops the unused import, folds fix(secrets): converge concurrent key provisioning and add sentinel scan controls #349's threat-model row into the HACK-1273 row, and rewrites open gap 4 to cover .hack/.env and the Compose client environment.
  • Sentinel scan (c33c3c97). tests/env-secret-sentinel-scan.test.ts from fix(secrets): converge concurrent key provisioning and add sentinel scan controls #349 still allowed plaintext in compose.env.override.yml. The file loses that exception. The scan now requires the override to exist with ${HACK_ENV_ references, to be 0600 and git-ignored, and requires the values to reach the docker compose client env. With next's src/commands/project.ts, the scan fails.
  • Legacy no-override scrub test and stale-aside sweep test (879e0b56). The stale-aside sweep (removeStaleAsides) had no test. It now takes an injectable clock (a test seam, no authority). The test proves that a fresh aside is kept, a stale one is removed, and a directory or a non-matching name is left alone. If the sweep becomes a no-op, the test fails. It also fails if the sweep ignores the aside's age.

Fix

  • The override holds only Compose interpolation references, such as SECRET_TOKEN: "${HACK_ENV_<12-hex digest of service+key>_SECRET_TOKEN}". The values reach docker compose through its process environment. No Compose command writes a value to disk, so a failed, interrupted or killed up leaves nothing behind (renderComposeEnvReferenceOverride in src/lib/compose-managed-env.ts). The path stays the same because Compose records it in each container's config_files label.
  • writeComposeEnvOverride (src/lib/compose-env-at-rest.ts) writes the file with replacePrivateFileAtomic. The file gets mode 0600 and a symlink at the path is refused. Text that holds a literal value is refused before anything is written.
  • Adoption config hash. The projection already resolves the managed values at preview and preparation. It now also returns the reference values as a non-enumerable, never-serialized composeEnv. inspectLegacyComposeRuntimeConfig passes them only to the Compose child environment of its config --hash probe (privateInterpolationEnv on createNativeComposeProbe). A saved-generation recheck runs without a key or values by design. When the bound files include the env override, that recheck compares each container's engine label with the hash proven at preparation (meta.runtimeConfig). The Compose copy, the generated override bytes and the managed env revision stay pinned by the existing saved owners. A recreated container therefore still refuses.
  • e2e harness. bootstrapOriginal now starts the original stack with the same Compose process env that Hack supplies (managedAdoptionFixtureComposeEnv). writeGenerated also checks that every reference value matches what the writer returned.
  • Cleanup of files written by earlier versions:
    • The next Compose command that renders the override replaces the file.
    • A Compose command that renders no override removes the file, but only if it holds literal values.
    • hack down now removes the file first, before hooks, remote dispatch or Compose. A failed down-before hook or a remote target no longer keeps it.
    • hack doctor reports the file by counts only. hack doctor --fix removes it.
  • Race-safe removal. removePlaintextComposeEnvOverride moves the entry aside with one atomic rename, then classifies the entry it moved. If a concurrent Compose command had already replaced the file with the reference form, that file is linked back without overwriting anything (a newer file wins). A value-free file is never deleted, but the path is absent between the move and the link-back. A crash between the move and the unlink leaves an aside file. A later removal deletes it once it is more than 60 s old (the clock can be injected for tests).
  • Behavior change: a value that contains $ now reaches the container exactly as written.

Tests

The CLI tests use privateCliEnv and a stub docker. Their PATH is now the stub plus /usr/bin:/bin, so doctor --json no longer runs the host's mutagen or brew. No real HOME, Docker socket or keychain is used. Every value is a synthetic canary.

New in this rework:

Test Proves Mutation that makes it fail
native-compose-adoption-generation "v3 value-free env override binds the engine config hash computed with the values" The stub engine hashes every ${NAME} reference with its value from the Compose env, as Compose does. The label is computed with the values and differs from the hash without them. Preview passes, prepare saves the label hash, and a saved withLease passes without a key. A changed label refuses. No value or HACK_ENV_ name reaches the manifest, the report or the docker argv. Dropping the values at preview, or at prepare; dropping saved from the recheck; ignoring the label in saved mode (4 separate mutations, each fails)
compose-env-at-rest-cli "down … even when its down-before hook fails" A failed hook (exit 17) still removes the literal file The scrub moved back to after the hooks (the previous placement)
compose-env-at-rest-cli "up that renders no override …" (env config without values; no env config; legacy contract, no value for these services, new in rework 2) All three no-override paths remove a literal file: the modern null override, the legacy empty contract, and the legacy contract whose vars apply to no running service Removing each of the three scrub calls (each fails exactly one test)
doctor-fix-noninteractive "doctor --fix removes a literal Compose env override and keeps a reference-only one" --fix removes the literal file, keeps the reference file and prints no value Removing the --fix step
compose-env-at-rest "removal keeps a value-free override that replaced the inspected literal file" A concurrent rewrite between inspection and removal is kept, with mode 0600 and no aside left Unlinking by path without the move-aside (the 3970f17 behavior)
compose-env-at-rest "removal sweeps an aside left by a crashed removal once it is stale" (rework 2) A fresh aside is kept, a stale one is removed, and a directory or a non-matching name is left alone Making the sweep a no-op; ignoring the aside's age
env-secret-sentinel-scan (rework 2, tightened from #349) No sentinel value exists in compose.env.override.yml. The file exists with ${HACK_ENV_ references, mode 0600, git-ignored. The values reach the docker compose client env Running it with next's src/commands/project.ts, which writes the literal file

Earlier tests are kept: up/down delivery through the env with only references at rest, a failed up, an up interrupted by SIGTERM, doctor detection by counts, and the renderer, classifier and writer unit tests.

Local gates on 879e0b56 (macOS, shared host with load average 13 to 50):

  • bun run --cwd packages/cli typecheck: exit 0. bun run typecheck: exit 0.
  • bun run --cwd packages/cli check: exit 0, 0 errors, 34 warnings (the same count as next).
  • Biome on the changed files: clean.
  • Each file run in its own bun process, all green: compose-env-at-rest 10, compose-env-at-rest-cli 9, doctor-fix-noninteractive 4, tests/cli-spawn-env-isolation.test.ts 7, env-secret-sentinel-scan 6, compose-env-staleness 11, compose-managed-env 2, doctor-command 28, doctor-json 3, doctor-utils 8, native-compose-adoption-projection 17, native-compose-adoption-managed-fixture 3, native-compose-adoption-local-fixture 3, native-compose-adoption-local 42, native-compose-ownership 65, project-exec 5, project-run 10, project-up-startup-state 67, run-exec-branch-default 4, secret-key-provisioning-convergence 2.
  • native-compose-adoption-generation: the six env-related cases (HACK-1273, the v3 saved, v3 drift and managed cases) pass. They were run with the file's 20 s per-test bound raised locally to 180 s, and that change was not committed. At the default bound on this host, every case timed out, including on unchanged cases, because another agent was running the same file at the same time and load average reached 40 to 50. The full file was not run green locally. CI's test job is the evidence for the whole file.
  • gitleaks is not installed on this host. CI secret-scan covers it.

Hosted CI:

  • On 7345de3f (run 39310729568): test passed. docker-e2e shards 1/4 to 3/4 passed. Shard 4/4 failed only in native-config-process-policy ("Native --profile must succeed", a live-endpoint-membership topology refusal on the native backend). In the same shard, native-compose-adoption-local-worktrees passed.
  • On 879e0b56 (run 39439748941), at the time of writing: docker-e2e shards 2/4, 3/4 and 4/4 passed, including native-config-process-policy in shard 4/4. secret-scan, runtime-images, linux-process-lifetime, Runtime state models, Candidate core (ubuntu-latest) and three of the four Config compiler jobs passed. test, shard 1/4, Candidate core (macos-latest) and Config compiler (macos-15-intel) were still pending.

Remaining gaps

  • Docker e2e was run in CI only. Shard 4/4 on 7345de3f failed in native-config-process-policy. That scenario uses the native backend, not the Compose env override, and the same shard passed on bebeb89c. The exact-head gate is the CI run on 879e0b56, which includes the merge with next. I did not dispatch any workflow.
  • Transient absence during a raced removal. When a removal moves aside a file that a concurrent Hack command has just rewritten value-free, the path is absent until the link-back. A Compose process that opens it in that moment fails and must be retried. POSIX has no "unlink if still this inode", so the design accepts this window. It is documented in docs/env.md and threat-model gap 4.
  • Aside left by a crash. If Hack is killed between the move and the unlink, .hack/.internal/.compose.env.override.yml.<uuid>.remove can hold literal values. It is swept only by a later removal (a no-override Compose command, hack down or hack doctor --fix) once it is more than 60 s old. hack doctor does not report it, and an up that renders the override does not sweep it. This is documented.
  • Adoption test budget. The HACK-1273 adoption case takes about 16 s alone at load average 14, and the file's per-test bound is 20 s. Like the existing v3 cases, it can time out on a saturated host.
  • Compose client env size. The docker compose client process carries one HACK_ENV_* copy of each value per service, so its environment grows with services × keys. On macOS the total limit is about 1 MiB. On Linux, a single NAME=value string over 128 KiB (MAX_ARG_STRLEN) now fails with E2BIG, where the old file path worked. This is documented. A shared name per key for globally inherited values is not done. serviceEnv already merges global values into every service, so sharing names would need scope metadata in the renderer and the adoption projection.
  • Direct docker compose use. A docker compose -f .hack/.internal/compose.env.override.yml … run outside Hack gets blank values with only per-variable warnings. This is documented in docs/env.md.
  • Saved adoption recheck. The recheck now trusts the preparation-time config hash plus the pinned file bytes and env revision. It no longer recomputes the hash with Compose. A change in Compose's own hashing would therefore not be noticed on that path.
  • Older plaintext elsewhere. Plaintext that an earlier version wrote can survive in filesystem snapshots or backups. Rotate the affected secrets if that exposure matters. Running containers still expose their env through docker inspect (gap 9).
  • No real-engine check here. This branch was not run on the real-project host or on a real local Docker engine. The rerun evidence was read without changes (<private-workdir>/hack1124-next5-rerun-20261019-1204/). It confirms the original defect on published next.5: cleanup-project.txt lists a 71,130-byte, 0600 compose.env.override.yml after down. That file is no longer at <project>/.hack/.internal/ (checked with stat only; no content was read).

Refs HACK-1273

Rework (independent verification follow-up)

Head ddb6efd0 on top of 879e0b56: bfccb534 (fix), 300beace (test fixture), and two merges of next (375e17cc up to 8c790004, which resolved the compose-managed-env import conflict in src/commands/project.ts; ddb6efd0 up to a388ce27, which auto-merged native-compose-ownership.ts and kept privateInterpolationEnv). Nothing was rebased or force-pushed.

Defect 1 (medium): adopted generations bound to a literal override

Choice: refuse and guard; no in-place migration. A saved adoption pins the override's SHA-256, inode, size, mode, mtime and ctime (generatedProof), and prepare refuses while an adoption is active (E_LEGACY_ADOPTION_BUSY). Migrating in place would mean either rewriting a pinned file, which breaks the proof, or adding a new manifest-rewrite path that weakens it. The existing proof-preserving route already migrates: rollback, then hack up (which writes references; the adoption already refuses values containing $, so the config hash and the container IDs do not change), then adopt again.

  • hack config adopt and hack config adopt --dry-run now refuse a literal override before any store, probe or engine call. The message tells the user to run hack up first. The preview reports the field code generated_env_override_holds_values. Recovery and rollback still act on existing generations.
  • removePlaintextComposeEnvOverride, which backs doctor --fix, down and the no-override scrubs, keeps the file while the adoption state is prepared, active or pending. It also keeps the file when that state cannot be read safely. It returns adoptionBound: true, and the scrub warns without values.
  • The doctor "compose env at rest" check now reaches an adopted project. Before, ordinary discovery refused the project as native, so the check errored. It reports the pinned file with the next step: hack down, hack config adopt --rollback, hack up, then hack config adopt --stop. --fix keeps the file and prints the same step.
  • Tests cover both directions. tests/native-compose-adoption-generation.test.ts uses the verifier's repro with a literal-form prepare and publish that model an earlier version. In it, removal returns adoptionBound: true and the inode is unchanged, the doctor check warns with the rollback step, and loadActive() still loads. After rollback, the same removal deletes the file. The CLI test checks that config adopt with [], --dry-run, --stop and --dry-run --json refuses with no state root and no engine command. tests/compose-env-at-rest.test.ts checks that an unreadable adoption state keeps the file and that removing that state frees it.

Defect 2 (low): HACK_ENV_* values reached non-Compose docker children

withoutComposeEnvReferences (src/lib/compose-managed-env.ts) now strips HACK_ENV_<12 hex>[_KEY] names before withDependencyStartupProgress spawns docker inspect/logs/events, and before canReuseRunningDependencyCache spawns docker inspect. docker compose invocations keep the full env. The new tests/compose-env-reference-least-privilege.test.ts records each stub docker child's environment and checks that no HACK_ENV_* key or canary is present while GLOBAL_FLAG is. The wording in docs/security/env-secrets.md, threat-model row 78 and docs/env.md now says this.

Findings

  • Leftover asides: hack doctor counts .compose.env.override.yml.<uuid>.remove files and warns without values. --fix deletes stale ones, older than 60 s. A Compose command that renders the override also sweeps stale ones, after its symlink checks, on a best-effort basis. The docs and threat-model gap 4 are updated.
  • Migration note: docs/env.md has a new "Upgrading: managed values that contain $" subsection. See the release note below.
  • exec warning: resolveExecTargetReady now passes the Compose env to compose ps. tests/project-exec-command.test.ts asserts that the ps env equals the exec env.

Release / migration note

Managed values that contain $ change meaning. Earlier versions wrote values literally into the override, so Compose interpolated $VAR, ${VAR} and $$. Values now reach containers exactly as stored. After upgrading, the affected services' containers are recreated on the next hack up. A value that relied on interpolation must store the expanded value. A value escaped as $$ must store a single $. Separately, a project with an active hack config adopt generation made by an earlier version keeps its literal override until it is rolled back, run through hack up and adopted again.

Mutation proof (each fix reverted alone; the named test fails, then passes when restored)

Fix reverted Test that failed
Adoption guard in removePlaintextComposeEnvOverride unit "an override that an adoption state may pin…" and the real-adoption case "…keeps it loadable until rollback frees it"
Preview literal refusal "v3 saved adoption consumes…" and "CLI adoption and its preview refuse a literal env override…"
config adopt apply literal refusal "CLI adoption and its preview refuse a literal env override…"
Doctor adopted-root fallback the real-adoption case (doctor check assertion)
Strip in withDependencyStartupProgress "dependency progress observers get the Compose env without…"
Strip in canReuseRunningDependencyCache "the dependency cache reuse probe gets the Compose env without…"
env on the exec readiness compose ps "exec reaches the running service…" (psEnvs assertion)
Stale-aside sweep in the writer "a rendering write sweeps stale removal asides…"
Aside counting in the doctor report "doctor reports leftover removal asides by count…"

Gates on ddb6efd0 (macOS, shared host, load average 13 to 130 during the runs)

  • bun run --cwd packages/cli typecheck: exit 0. bun run --cwd packages/cli check: 0 errors, 34 warnings (the same as next). Biome on the changed files: clean.
  • Focused files, each in its own bun process, all green: compose-env-at-rest 13, compose-env-at-rest-cli 9, compose-env-reference-least-privilege 2, project-exec-command 5, doctor-fix-noninteractive 4, env-secret-sentinel-scan 6, cli-spawn-env-isolation 7, compose-managed-env 2, native-compose-adoption-projection 17, project-run-command 10, project-up-startup-state 67, dependency-startup-progress 6, native-compose-adoption-publication-diagnostics 6, doctor-command 28, native-compose-ownership 67.
  • native-compose-adoption-generation, with the 20 s bound raised locally (not committed): 110 of 114 pass. The env-related subset passes 10 of 10 (all HACK-1273 cases, the v3 cases, v4 typed locals, CLI backend refusal and dry-run). The 4 failures are v7 start/restart/source-drift and v5 retained dispatch. Each timed out at its own explicit 30 s bound. They use neither the env override nor the preview.
  • Full bun run test on bfccb534 (before the merges): 7045 pass, 291 fail, mostly cascades after timeouts under load. One was real: the v4 typed-locals fixture adopted over a literal override, which the new preview refusal now rejects. 300beace fixed it by switching that fixture to the reference form. Rerun serially with --timeout 60000: 50 of the 57 other affected files pass. The remaining 7 native files (authored-project-recovery, authored-project-start, compose-down-command, retained-storage-command, storage-activation-command, storage-fence-cost, warm-up-command) fail the same way on unchanged origin/next (8c790004) on this host, so they are not caused by this PR.
  • gitleaks 8.30.1 (origin/next..HEAD, 9 commits): no leaks.

Remaining gaps

  • The library LegacyComposeAdoptionProjection.resolve still matches the literal form. Only the command boundary (config-adopt.ts) and the preview refuse it, and the tests use the literal match to model an earlier version's generation. A generation that an earlier version prepared but did not publish can still be published after upgrading.
  • buildDoctorRemediationPlanLines, like most doctor project checks, still throws in an adopted project because findProjectContext refuses native inputs. Only the compose-env check and its fix step have the adopted-root fallback. This behavior predates this PR.
  • The adoption regression cases take 20 to 50 s each on this host under load, so they can hit the file's 20 s per-test bound, as the existing v3 cases do. They were verified locally with the bound raised, and that change was not committed.

🤖 Generated with Claude Code

roodboi and others added 2 commits October 19, 2026 13:14
The Compose backend wrote every resolved (decrypted) env value into
.hack/.internal/compose.env.override.yml and left it there after up, down,
failed or interrupted runs.

The override now holds only ${HACK_ENV_<digest>_<KEY>} references and the
values reach docker compose through its process environment, so no Compose
command writes a value to disk. The file keeps its path (Compose records it
in each container's config_files label), is written atomically with mode
0600 and refuses a symlink or literal text. Files left by earlier versions
are replaced by the next Compose command, removed by down or by a command
that renders no override, and reported by a new "compose env at rest"
doctor check with an authorized --fix removal. Legacy adoption accepts the
reference form as well as the earlier literal bytes.

Refs HACK-1273

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Update the env guide, architecture notes and env/secrets threat model for
the reference-only Compose env override, its doctor check and the
remaining client-environment gap. Align run, up and adoption tests with
values arriving through the Compose process environment.

Refs HACK-1273

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Oct 19, 2026

Copy link
Copy Markdown

HACK-1273

@blacksmith-sh

This comment has been minimized.

roodboi and others added 10 commits October 19, 2026 14:40
The env override now holds only ${HACK_ENV_*} references, while each
container's config-hash label was computed by Compose with the resolved
values. The adoption config-hash probe ran without them, so every stack
with managed env that this version started was refused.

Preview and preparation now pass the reference values (already resolved
by the projection) to the probe's Compose environment only. A saved
recheck has no key or values: it binds each container's label to the
hash proven at preparation, with the Compose copy, generated override
bytes and env revision still pinned.

The docker e2e adoption harness now starts the original stack with the
same Compose process env that Hack supplies.

Refs HACK-1273

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Removal moves the file aside atomically and classifies what it moved;
  a value-free file a concurrent command wrote is linked back.
- hack down removes a literal override before hooks, remote dispatch or
  Compose, so a failed down-before hook no longer keeps it.
- Tests for the doctor --fix step, the no-override scrubs and the
  down-hook path; the CLI tests no longer search the host PATH.
- Docs: direct docker compose use gets blank values; Linux 128 KiB
  per-string limit; services x keys growth.

Refs HACK-1273

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test runs doctor --fix twice and timed out at the 5 s default on
the hosted runner.

Refs HACK-1273

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolves the overlap with #349, which made the literal Compose env
override private (0600, atomic). This branch removes the values from
that file instead, so its reference writer is kept, the unused
writePrivateEnvFileIfChanged import is dropped, the threat-model row for
the private literal file is folded into the HACK-1273 row, and open gap 4
now covers .hack/.env plus the Compose client environment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…scan

The sentinel scan from #349 still allowed plaintext in
.hack/.internal/compose.env.override.yml. That file now holds only
${HACK_ENV_...} references (HACK-1273), so it loses its exception: any
value in it fails the scan. The scan now requires the override to exist
with references, to be private and git-ignored, and requires the values
to reach the docker compose client environment. Running the scan with
next's src/commands/project.ts fails it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two removal paths had no test: the scrub in the legacy hack.env.json path
when no service gets an override, and the sweep of an aside left by a
removal that crashed between its move and its unlink. Each now has a test
that fails when that path is removed. The sweep takes an injectable clock
so the one-minute stale rule can be tested.

The docs now say what the move-aside removal does not guarantee: the path
is absent for a moment while a concurrently written value-free file is put
back, and a crash leaves an aside that hack doctor does not report.
hack down is described by what it runs before (hooks, remote target,
Compose down) instead of 'before anything else'.

Refs HACK-1273

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ence values

A legacy adoption pins the generated env override's bytes and inode, and
adopted commands dispatch before the down scrub. Removing an earlier
version's literal override therefore broke the adoption, and a new
adoption could still bind a literal file for its whole life.

- `hack config adopt` and its preview refuse a literal override and say
  to run `hack up` first, which rewrites it with references.
- Removal (doctor --fix, down and the no-override scrubs) keeps an
  override while a prepared, active or pending adoption exists. Doctor
  reports it with the supported next step: rollback, `hack up`, adopt.
  The doctor check and its fix step now reach an adopted project, whose
  inputs ordinary discovery refuses.
- Doctor reports leftover removal asides by count; --fix and a rendering
  Compose command delete stale ones.
- `HACK_ENV_*` values reach only the `docker compose` invocations that
  read the override; dependency progress and cache reuse `docker`
  children get the env without them.
- The exec readiness `compose ps` gets the Compose env, so Compose no
  longer warns about unset references.
- docs/env.md gains an upgrade note for managed values that contain `$`.

Refs HACK-1273

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolves the compose-managed-env import conflict in src/commands/project.ts
(keep the reference renderer, add resolveComposeProjectName from next).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…erride

Preview now refuses a literal env override, so the fresh-adoption fixture
binds the reference form that current writers produce.

Refs HACK-1273

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi
roodboi marked this pull request as ready for review October 21, 2026 19:36
@roodboi
roodboi merged commit ec223cd into next Oct 21, 2026
17 checks passed
@roodboi
roodboi deleted the wf/g5-compose-env-at-rest-b17 branch October 21, 2026 19:36
roodboi added a commit that referenced this pull request Oct 22, 2026
… environment (#412)

## Problem

A project with 20 services and one 64 KiB global secret fails `hack up` with `E_UNEXPECTED:
E2BIG ... posix_spawn docker` before Docker starts. Projects with 4 or 12 services work.

## Root cause

#357 keeps decrypted Compose env values off disk: `.hack/.internal/compose.env.override.yml`
holds only `${HACK_ENV_*}` references, and Hack passes the values to `docker compose`
through its process environment. `renderComposeEnvReferenceOverride` (in
`src/lib/compose-managed-env.ts`) gave every service its own reference for every key,
including keys that come from the global scope. The Compose child environment therefore
grew with services times keys: 20 × 64 KiB ≈ 1.25 MiB, plus the plain global copy, is over
the 1 MiB `ARG_MAX` on macOS. The spawn failure then reached the top-level handler as an
unclassified error.

## Fix

- **Shared global references.** When a key's winning scope for a service is `global`, that
  service uses one `composeEnvGlobalReferenceName({ key })` reference, shared with every
  other such service. A key that a service scope sets keeps its own per-service reference.
  The choice uses the resolved config's `effectiveMetadata` (names and scopes only), never
  the values, so the override text stays value-free. As a guard, a key that the metadata
  marks global but whose value differs from the global value keeps its own reference. The
  shared name keeps the `HACK_ENV_<12 hex>_KEY` shape, so `withoutComposeEnvReferences`
  still strips it from non-Compose children. Its digest input (`\0global\0KEY`) cannot
  equal a per-service input, including one for a service named `global`.
- **Preflight with a closed error.** The new `src/lib/spawn-env-limit.ts` measures the
  environment that the `docker compose` child would get: the live `process.env` plus the
  Compose env, with the `NAME=value` strings, their NULs and pointer slots. It checks that
  total against the platform limit and keeps 64 KiB for argv. The limits are 1 MiB on
  macOS. On Linux the limit is the kernel's formula, `max(min(stack/4, 6 MiB), 128 KiB)`,
  with the stack soft limit read from `/proc/self/limits` (8 MiB is assumed when that file
  cannot be read), plus `MAX_ARG_STRLEN` = 128 KiB for one string. Other platforms get
  256 KiB. `resolveModernComposeEnvOverrides` and the legacy contract path run the check
  before they write the override or start Compose. This covers `up`, `restart`, `run` and
  `exec`. An oversized environment fails with the new code `E_ENV_TOO_LARGE`. Its message
  names the limit (`ARG_MAX` or `MAX_ARG_STRLEN`) and says how to reduce the environment.
  The `--json` detail is `{limit, bytes, limitBytes}`: byte counts only, with no names and
  no values.
- **Adoption compatibility.** `LegacyComposeAdoptionProjection.resolve` accepts three forms
  of a saved generation: the shared form, the earlier per-service reference form, and the
  literal form that only tests still model. A generation saved before this change still
  loads.
- **Docs and guidance.** `docs/env.md` replaces the "grows with services times keys"
  paragraph with the shared-reference behavior, the limits and the new code. The new code
  is registered in `HACK_ERROR_CODES` and in `src/agents/error-code-reference.ts`, and the
  plugin's `hack-diagnose/references/error-codes.md` was regenerated with
  `bun run generate:agent-plugins`. The CLI surface and `package.json` scripts did not
  change, so no CLI reference or task-catalog re-pin was needed.

Values stay off disk and out of argv and logs. The new code paths handle only byte counts, names
and scopes.

## Tests

- `tests/compose-env-spawn-size-cli.test.ts` is the regression test. It runs the source
  CLI against a stub `docker` with a private HOME, an isolated Docker client config and a
  temp project, and makes no Docker socket, keychain or launchd calls.
  - **20 services, 64 KiB global secret:** `up --detach --json` succeeds. The stub sees
    exactly 2 variables that carry the value (the shared reference and the plain global
    name), and the whole environment is under 256 KiB. All 20 services reference the same
    name, and the file, argv and output hold no value. The test assembles the values at
    runtime and checks them only with boolean includes, so a failing assertion never
    prints one.
  - **Oversized environment:** three 200 KiB global values give `E_ENV_TOO_LARGE`, exit 1,
    `ARG_MAX` on macOS or `MAX_ARG_STRLEN` on Linux. No `docker compose` runs and no
    override is written.
- `tests/compose-env-shared-references.test.ts` covers the renderer: sharing, a
  service-scope override (including one restated with the global value), the fallback
  service, value-independence, the metadata/value guard, the form without metadata, and
  name separation.
- `tests/spawn-env-limit.test.ts` covers measurement, the per-platform limits, the
  `/proc/self/limits` parser, the boundaries, the closed error's content and the inherited
  environment.
- `tests/native-compose-adoption-projection.test.ts` now expects the shared form from the
  current writer.

**Mutation proof.** Each mutation was applied alone, and the named tests failed:

| Mutation | Caught by |
| --- | --- |
| sharing disabled | CLI 20-service test + 2 renderer tests |
| preflight removed from `project.ts` | CLI `E_ENV_TOO_LARGE` test |
| metadata not passed in `project.ts` | CLI 20-service test |
| scope check ignored | renderer "service-scoped keys" |
| value guard removed | renderer "disagrees" |
| pointer bytes dropped | measure test |
| `MAX_ARG_STRLEN` check removed | 2 limit tests |
| per-service form no longer accepted in adoption | `v3 value-free env override binds…` (generation test) |
| shared form not accepted in adoption | `real canonical legacy writers…` (projection test) |
| inherited env ignored | 2 limit tests |

Pre-fix reproduction on macOS: with sharing disabled and the preflight removed, the 20-service
CLI test exits 1 (the E2BIG path).

## Evidence

- Bun 1.4.2 (`mise x bun@1.4.2`), pinned.
- Local (this Mac, which was under load): `bun run --cwd packages/cli typecheck` and root
  `bun run check` (including `privacy:check`) are clean, with only existing complexity
  warnings. Focused files pass: 168 tests across 13 files, plus
  `native-compose-adoption-generation.test.ts` at 114/114 when run alone. During one
  combined run that also included the generation file, that file had several tests fail
  at about 20 s each under host load. They pass alone.
- gitleaks 8.30.1 over `origin/next..HEAD` reports no leaks.
- Full suite on the M3 at `40c5581c`: not completed. The run got the M3 CI lock at 11:34, but
  the M3 had only 56 GiB free, which is below the 58 GiB floor. I stopped it during the suite
  and removed its worktree. The M3 disk guard now holds the lock until space recovers. The
  hosted `test` job, which runs the full suite, passed at this exact head (see the rework
  section). An M3 full-suite run at `40c5581c` is still an open gate.

## Rework after the first CI run

Hosted CI at `78be3539` failed `test` and docker-e2e shards 3 and 4 with the same closed error,
"Adoption managed fixture source check failed; values omitted."

- **Cause.** The managed adoption fixture
  (`tests/e2e/scenarios/native-compose-adoption-managed-inputs.ts`) rebuilt the
  expected Compose env override without scope metadata. It therefore expected one
  reference per service and refused the shared global references that the writer now
  produces. The product code was right; the fixture oracle was stale. My local focused
  set had not included the fixture tests.
- **Fix (`f8f51a6c`).** The oracle now reads the same resolved scope metadata as the
  writer (`resolveProjectEnvConfig(...).effectiveMetadata`) and passes it as
  `effectiveScopes`.
- **Mutation proof.** With `effectiveScopes` removed from the writer in `project.ts`, the
  `managed fixture uses real writer owners…` test fails; restored, it passes.
- **Merge of `next` (`40c5581c`).** #397 changed the same adoption projection block.
  The resolution keeps the shared and per-service reference forms and takes #397's
  HACK-1318 comment. #397's literal-override classifier accepts the shared names,
  because they keep the `${NAME}` reference shape.
- **Evidence after the rework.**
  - Hosted CI at `40c5581c`: all 16 checks pass, including `test` and docker-e2e
    shards 1–4 (the `native-compose-adoption-managed-worktrees` and
    `native-compose-adoption-local-worktrees` scenarios).
  - Local: CLI typecheck and root `bun run check` (`privacy-check: ok`) are clean.
    Focused files pass, 73 tests across 10 files, including both fixture tests.
    `generate:agent-plugins` shows no drift. gitleaks 8.30.1 over
    `origin/next..HEAD` reports no leaks.
  - `native-compose-adoption-generation.test.ts` run alone on this Mac at load
    average 113: its first test timed out at 60 s and the rest of the file
    cascaded. The hosted `test` job passed it at this head.

## Gaps

- On Linux the preflight trusts `/proc/self/limits`. A container runtime with a different
  stack limit for `docker` than for `hack`, or a kernel with non-4 KiB pages
  (`MAX_ARG_STRLEN` scales with page size), could still give a different real limit. The
  check is conservative on `MAX_ARG_STRLEN` (it assumes 4 KiB pages).
- The 64 KiB argv reserve is an estimate. A command line with thousands of service names
  could still exceed it in theory.
- Values still appear twice in the Compose environment (the shared reference and the
  plain global name used for `${KEY}` interpolation). This is bounded, but it is not one
  copy.
- The CLI regression test has no real-Docker run. Hosted CI runs it on macOS and Linux.
- No M3 full-suite run at `40c5581c` (see Evidence).

Refs HACK-1317

🤖 Generated with [Claude Code](https://claude.com/claude-code)
roodboi added a commit that referenced this pull request Oct 22, 2026
…ough it, and support adopted projects (#392)

## Summary

Two `hack doctor` fixes.

- **HACK-1316:** doctor no longer follows a symlinked `.hack` or `.hack/.internal`. Before this
  fix, `doctor --fix` deleted a file outside the project.
- **HACK-1311:** doctor now reports an adopted (`hack config adopt`) project correctly, and
  `doctor --fix` completes in one. Before this fix, doctor refused the project as native input,
  the `--fix` plan threw, and `--fix` with 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`):

- **Defect.** In an active adopted project whose generated files are tracked by Git (for example
  a committed `.hack.secret.key`), `hack doctor --fix --no-interactive` exited 1 with
  `E_NATIVE_PROJECT_UNSUPPORTED`. It did this after `git rm --cached` had already run, so
  `.hack/.gitignore` was not written and the later fix steps (hackd, Docker networks, Caddy and
  the rest) never ran.
- **Root cause.** `maybeUntrackGeneratedFiles` selected the adopted project, ran `git rm
  --cached`, and then called `ensureHackDirGitignore`. That writer calls
  `assertLegacyProjectDirectory`, which throws `ProjectInputSelectionError("native")` for an
  active adoption (`src/lib/project-input-selection.ts`).
- **Fix.** For an adopted project, the step now changes only the Git index. It still adds a
  committed `.hack.secret.key` to the root `.gitignore`, which is not an adoption input. It
  leaves the adoption's `.hack/.gitignore` unchanged 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.** `docs/reference/native-compose-adoption.md` now matches the code: `--fix` leaves
  `.hack/.gitignore` unchanged and adds a committed key to the root `.gitignore`.
- **Test gaps closed.**
  - The `findDoctorLegacyProjectContext` guards on the later `--fix` steps had no regression
  test. The new test runs the whole `--fix` flow with Docker reachable and `--migrate-env-
  config`, so the lifecycle, incomplete-runtime and env-migration steps run against the adopted
  project.
  - The writer's refusal before `mkdir` (HACK-1316) had no test. A new unit test covers it.

## Update: merge with `next` at `5c9cc82e` (commits `0d6aa587`, `51244fc9`)

- `0d6aa587` merges `origin/next` at `5c9cc82e` (#375, #407, #412) with no conflicts. #407
  changes the same `.hack/.gitignore` code 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/.gitignore` writer, and only the
  legacy branch uses #407's read-only handling.
- `51244fc9` is a one-line docs fix. `docs/reference/native-compose-adoption.md` listed every
  check that doctor skips for an adopted project except the `.hack/.gitignore` check. The code
  already skips it.
- **The verifier's repro, rerun on the merged head.** It is the verifier's own scratch test:
  `retainedSourceBindFixture`, a real `git init`, `git add -f .hack.secret.key`, adopt (prepare
  and publish), then `runCli doctor --fix --no-interactive`, with shell `run` mocked. The output
  has no `E_NATIVE_PROJECT_UNSUPPORTED`. `git ls-files` is empty, `.hack/.gitignore` is not
  created, the closed note above is printed, the adoption is still `active`, and the later steps
  run (the global `daemon start` call and the closing `Re-run: hack doctor` note).
- **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-Git `exec` fail, so `docker daemon` is an error, and the committed key is an
  error in the `generated files` and `env key` checks. The PR's `doctor-fix-adopted-project`
  test makes Docker reachable. Its `.hack/.env.state.json` case 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:

| Mutation | Result |
  | --- | --- |
  | Adopted untrack step writes `.hack/.gitignore` again (`ffbc4ffd` behavior) | `doctor-fix-
  adopted-project` 0/2, with `E_NATIVE_PROJECT_UNSUPPORTED` in the output |
  | `findDoctorLegacyProjectContext` no longer skips an adopted project (survived the first
  review) | `doctor-fix-adopted-project` 0/2, with `E_NATIVE_PROJECT_UNSUPPORTED` |
  | Writer's pre-`mkdir` refusal removed (survived the first review) | `compose-env-at-rest`
  15/16; "the writer refuses a symlinked .hack before creating .internal behind it" fails |

## Root cause

### HACK-1316: symlinked `.hack/.internal`

`src/lib/compose-env-at-rest.ts` checked that `.hack/.internal` was a real directory, but only
inside `readReplaceablePrivateFile`, and it never checked `.hack`. When that read refused,
`inspectEntry` called `lstat(path)` to choose between `absent` and `unverifiable`. That `lstat`
followed the `.internal` link, found the outside `compose.env.override.yml`, and classified it
as `unverifiable`. With `includeUnverifiable`, the `--fix` removal then ran `lstat`, `rename`
and `unlink` through the same link, which deleted the outside file. The stale-aside sweep ran
`readdir` on 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 `inspectProjectInputsAtRoot` classify the root as `native`.
Doctor's first step, `assertLegacyProjectDiscovery`, therefore threw
`E_NATIVE_PROJECT_UNSUPPORTED`. Every project check and `buildDoctorRemediationPlanLines` call
`findProjectContext`, which throws the same refusal. Every `--fix` step 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 `.hack` and `.hack/.internal`. The writer and
  every read, removal and sweep use it.
- A new `refused` state covers these cases:
  - a symlinked or non-directory `.hack` or `.hack/.internal`;
  - a symlinked or non-regular override.

Doctor reports it with a closed, value-free message. A `refused` override 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 `.hack` or `.internal` before `mkdir`, so it creates nothing
  through the link.

### HACK-1311 (`src/commands/doctor.ts`, `src/lib/doctor-adopted-project.ts`)

- Doctor selects an active or pending adoption first, with `selectLegacyComposeAdoptedRoot`, the
  selector that adopted `ps`, `logs` and the other adopted commands use. The legacy-discovery
  refusal runs only when no adoption is found.
- **Checks for an adopted project:**
  - The `project` check names the adopted root. A pending adoption is a warning that asks for
  explicit recovery.
  - The env key, tracked generated files and Compose env at rest checks run against the adopted
  root.
  - Each check that reads adoption-owned inputs is listed as not checked.
  - Agent integrations use the adopted root, and sessions mux uses global settings.
- **`--fix` for an adopted project:**
  - The plan says it leaves the adoption's inputs, retained containers and state alone, and it
  skips env migration.
  - Lifecycle, incomplete-runtime and env-migration repairs treat the project as having no
  legacy project.
  - Untracking generated files changes only the Git index (see the rework above).
- The legacy project-check block moved unchanged into `runProjectChecks`, to stay under the
  complexity limit. The #365 compose backend features check and the #357 env-at-rest adopted
  fallback are unchanged.

There are no CLI surface changes and no agent-guidance changes.

## Tests

| Test | Proves | Mutation that kills it |
| --- | --- | --- |
| `doctor-fix-adopted-project` (new), 2 cases: a committed `.hack.secret.key` and a committed `.hack/.env.state.json` | Real `runCli doctor --fix --no-interactive --migrate-env-config` in an adopted fixture with a real `git init`. Docker is reachable through the mock, and Git runs only on the fixture with no global or system config. Each case checks:<ul><li>no `E_NATIVE_PROJECT` and no canary or key text;</li><li>the exact plan line and step note;</li><li>the closing notes, which prove every later step ran;</li><li>`git ls-files` is empty and the file is unchanged on disk;</li><li>the root `.gitignore` gets the key entry only in the key case, and `.hack/.gitignore` is not created;</li><li>the receipt is unchanged, the adoption is still active, and no stub engine command or project `run` call happens.</li></ul>Exit code: 0 for the env state file. For the key it is 1, because the `env key` check reports the committed key as an error before `--fix` runs. That is existing doctor behavior. | <ul><li>`ffbc4ffd` behavior (always write `.hack/.gitignore`): 0/2 pass, exit 1.</li><li>`findDoctorLegacyProjectContext` no longer skipping adopted projects: 0/2 pass, exit 1.</li><li>Plan line reverted to the legacy wording: 0/2 pass.</li></ul> |
| `compose-env-at-rest`: "the writer refuses a symlinked .hack before creating .internal behind it" (new) | A link to an outside directory without `.internal` gets nothing created behind it, and the refusal is closed. | Writer's pre-`mkdir` refusal removed: fails (this mutation survived before). |
| `doctor-fix-noninteractive`: "doctor --fix leaves a file behind a symlinked .hack/.internal untouched (HACK-1316)" | Real `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 `.hack` and `.hack/.internal` (2 cases) | `refused` state, closed message, no repair planned, no asides removed with a stale-aged clock, writer refusal, outside contents byte-identical. | Old helper: both fail. Directory list without `.hack`: the `.hack` case fails. `inspectEntry` always `unverifiable`: the symlinked-override case fails. |
| `doctor-adopted-project`: `doctor --json` and the `--fix` plan in an adopted project | Closed project message, the checks that run, the not-applicable list, the exact plan, the receipt unchanged, no container start or stop. | Adopted branch of `runProjectChecks` disabled: 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

- **Toolchain.** Bun 1.4.2 (`mise x bun@1.4.2`), `bun install --frozen-lockfile`. `bun.lock` is
  unchanged.
- **Local gates on the current head `51244fc9` (M5):**
  - `bun run --cwd packages/cli typecheck`: 0 errors.
  - Root `bun run check`: exit 0, including `privacy-check: ok` (34 existing warnings in other
  files).
  - 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 at `51244fc9`, each file run on its own with `--timeout 120000` (the M5 was
  under heavy load), all passing:**
  - `compose-env-at-rest` 16, `doctor-fix-noninteractive` 10, `doctor-adopted-project` 2,
  `doctor-fix-adopted-project` 2
  - `native-compose-backend-features` 13, `native-compose-backend-features-cli` 15, `compose-
  env-at-rest-cli` 9
  - `doctor-command` 28, `doctor-json` 3, `doctor-generated-files` 11, `doctor-env-key` 31,
  `doctor-utils` 8, `doctor-worktree` 6
  - From #407 and #412, which touch the same code: `hack-gitignore` 14, `project-env-config` 58,
  `project-env-hydrate` 18, `compose-env-shared-references` 6
- The five doctor `--fix` and at-rest files also pass together in one process (41/41), which
  checks that the scoped module mocks do not interfere.
- Under the same load, `doctor-fix-adopted-project` with Bun's default 5 s timeout failed in a
  fixture hook. It passes with the longer timeout. This is load, not a code change.
- **Full suite on the M3: not run.** The runner for `91dae070` waited about 6.5 hours and never
  got the shared CI lock. I stopped it and queued a runner for `51244fc9`, 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.
- **Hosted CI:** all 16 checks pass on the current head `51244fc9` (run 39839971608): the full-
  suite `test` job (39 min), all four `docker-e2e` shards, candidate core, config compiler,
  runtime state models, and secret scan. Earlier heads `0a94d881`, `a2470049` and `91dae070`
  also passed.

## Remaining gaps

- **No M3 full suite.** It did not run because the shared lock was blocked and free space was
  under the floor (see Evidence). The hosted full-suite `test` job ran at `51244fc9` on ubuntu,
  not on macOS.
- **Symlinked `.hack` now 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/.env` rule. A project that relied on writing through a linked `.hack` will see
  a closed refusal.
- **Small race window remains.** Directory identities are checked again before each move and
  unlink, not held as open descriptors. A same-user swap in that window remains, as already
  documented for the move-aside window.
- **Other generated files can be committed again.** For an adopted project, `--fix` does not add
  the untracked generated files under `.hack/` to any ignore file. Only the key gets a root
  `.gitignore` entry. The note asks the user to keep them out of later commits.
- **`--fix` with 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.
- **Pending adoption not tested.** The pending-adoption warning has no test: the fixture has no
  cheap pending state.
- **Native backend skips adopted project checks.** With `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](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