Repository navigation
fix(env): keep decrypted Compose env values off disk - #357
Merged
Merged
Conversation
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>
This comment has been minimized.
This comment has been minimized.
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
marked this pull request as ready for review
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)
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.
Root cause
On the Compose backend,
resolveModernComposeEnvOverridesinsrc/commands/project.tsrendered every resolved env value into.hack/.internal/compose.env.override.yml. That included values decrypted fromsecure: v1:entries. It wrote the file withwriteTextFileIfChanged(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 afterdown.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-hashlabel is computed by Compose with the realHACK_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 rawdocker composeand no values. That failed docker-e2e shards 3/4 and 4/4 (run 39293621453).Rework 2 (head
879e0b56)A verifier checked
3970f175and reported six defects. Commits5695f824,bebeb89cand7345de3falready claimed fixes for most of them. This rework checked each claim with a mutation, merged currentnext, and closed what was still open.879e0b56${HACK_ENV_*}as blank)5695f824privateInterpolationEnvin the probe env, the HACK-1273 adoption test fails. With the saved-hash path disabled, it also fails.hack downskips the scrub when the down-before hook fails or Compose down throwsbebeb89c: the scrub runs before hooks, the remote target and Compose downfinallyblock, "down … even when its down-before hook fails" fails.bebeb89c(move aside, classify the moved entry, link back without overwriting)doctor --fixremoval untesteddoctor-fix-noninteractive(frombebeb89c)--fixstep removed, that test fails.hack.env.jsonpath where no service gets an override) was still untested. This rework adds a test for it.PATHincludes the hostPATHPATHis the stubbinplus/usr/bin:/binonlybebeb89c. This rework also states the move-aside window and the aside left by a crash, and describeshack downby what it runs beforedocs/env.md, threat model row and gap 4New in this rework:
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/.envand the Compose client environment.c33c3c97).tests/env-secret-sentinel-scan.test.tsfrom fix(secrets): converge concurrent key provisioning and add sentinel scan controls #349 still allowed plaintext incompose.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 thedocker composeclient env. Withnext'ssrc/commands/project.ts, the scan fails.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
SECRET_TOKEN: "${HACK_ENV_<12-hex digest of service+key>_SECRET_TOKEN}". The values reachdocker composethrough its process environment. No Compose command writes a value to disk, so a failed, interrupted or killedupleaves nothing behind (renderComposeEnvReferenceOverrideinsrc/lib/compose-managed-env.ts). The path stays the same because Compose records it in each container'sconfig_fileslabel.writeComposeEnvOverride(src/lib/compose-env-at-rest.ts) writes the file withreplacePrivateFileAtomic. 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.composeEnv.inspectLegacyComposeRuntimeConfigpasses them only to the Compose child environment of itsconfig --hashprobe (privateInterpolationEnvoncreateNativeComposeProbe). 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.bootstrapOriginalnow starts the original stack with the same Compose process env that Hack supplies (managedAdoptionFixtureComposeEnv).writeGeneratedalso checks that every reference value matches what the writer returned.hack downnow removes the file first, before hooks, remote dispatch or Compose. A failed down-before hook or a remote target no longer keeps it.hack doctorreports the file by counts only.hack doctor --fixremoves it.removePlaintextComposeEnvOverridemoves 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).$now reaches the container exactly as written.Tests
The CLI tests use
privateCliEnvand a stubdocker. TheirPATHis now the stub plus/usr/bin:/bin, sodoctor --jsonno longer runs the host'smutagenorbrew. No real HOME, Docker socket or keychain is used. Every value is a synthetic canary.New in this rework:
native-compose-adoption-generation"v3 value-free env override binds the engine config hash computed with the values"${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 savedwithLeasepasses without a key. A changed label refuses. No value orHACK_ENV_name reaches the manifest, the report or the docker argv.savedfrom 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"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)doctor-fix-noninteractive"doctor --fix removes a literal Compose env override and keeps a reference-only one"--fixremoves the literal file, keeps the reference file and prints no value--fixstepcompose-env-at-rest"removal keeps a value-free override that replaced the inspected literal file"compose-env-at-rest"removal sweeps an aside left by a crashed removal once it is stale" (rework 2)env-secret-sentinel-scan(rework 2, tightened from #349)compose.env.override.yml. The file exists with${HACK_ENV_references, mode 0600, git-ignored. The values reach thedocker composeclient envnext'ssrc/commands/project.ts, which writes the literal fileEarlier tests are kept: up/down delivery through the env with only references at rest, a failed
up, anupinterrupted 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 asnext).tests/cli-spawn-env-isolation.test.ts7, 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'stestjob is the evidence for the whole file.secret-scancovers it.Hosted CI:
7345de3f(run 39310729568):testpassed. docker-e2e shards 1/4 to 3/4 passed. Shard 4/4 failed only innative-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-worktreespassed.879e0b56(run 39439748941), at the time of writing: docker-e2e shards 2/4, 3/4 and 4/4 passed, includingnative-config-process-policyin shard 4/4.secret-scan,runtime-images,linux-process-lifetime,Runtime state models,Candidate core (ubuntu-latest)and three of the fourConfig compilerjobs passed.test, shard 1/4,Candidate core (macos-latest)andConfig compiler (macos-15-intel)were still pending.Remaining gaps
7345de3ffailed innative-config-process-policy. That scenario uses the native backend, not the Compose env override, and the same shard passed onbebeb89c. The exact-head gate is the CI run on879e0b56, which includes the merge withnext. I did not dispatch any workflow.docs/env.mdand threat-model gap 4..hack/.internal/.compose.env.override.yml.<uuid>.removecan hold literal values. It is swept only by a later removal (a no-override Compose command,hack downorhack doctor --fix) once it is more than 60 s old.hack doctordoes not report it, and anupthat renders the override does not sweep it. This is documented.docker composeclient process carries oneHACK_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 singleNAME=valuestring over 128 KiB (MAX_ARG_STRLEN) now fails withE2BIG, where the old file path worked. This is documented. A shared name per key for globally inherited values is not done.serviceEnvalready merges global values into every service, so sharing names would need scope metadata in the renderer and the adoption projection.docker composeuse. Adocker compose -f .hack/.internal/compose.env.override.yml …run outside Hack gets blank values with only per-variable warnings. This is documented indocs/env.md.docker inspect(gap 9).<private-workdir>/hack1124-next5-rerun-20261019-1204/). It confirms the original defect on published next.5:cleanup-project.txtlists a 71,130-byte, 0600compose.env.override.ymlafterdown. That file is no longer at<project>/.hack/.internal/(checked withstatonly; no content was read).Refs HACK-1273
Rework (independent verification follow-up)
Head
ddb6efd0on top of879e0b56:bfccb534(fix),300beace(test fixture), and two merges ofnext(375e17ccup to8c790004, which resolved thecompose-managed-envimport conflict insrc/commands/project.ts;ddb6efd0up toa388ce27, which auto-mergednative-compose-ownership.tsand keptprivateInterpolationEnv). 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), andpreparerefuses 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, thenhack 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 adoptandhack config adopt --dry-runnow refuse a literal override before any store, probe or engine call. The message tells the user to runhack upfirst. The preview reports the field codegenerated_env_override_holds_values. Recovery and rollback still act on existing generations.removePlaintextComposeEnvOverride, which backsdoctor --fix,downand 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 returnsadoptionBound: true, and the scrub warns without values.hack down,hack config adopt --rollback,hack up, thenhack config adopt --stop.--fixkeeps the file and prints the same step.tests/native-compose-adoption-generation.test.tsuses the verifier's repro with a literal-form prepare and publish that model an earlier version. In it, removal returnsadoptionBound: trueand the inode is unchanged, the doctor check warns with the rollback step, andloadActive()still loads. After rollback, the same removal deletes the file. The CLI test checks thatconfig adoptwith[],--dry-run,--stopand--dry-run --jsonrefuses with no state root and no engine command.tests/compose-env-at-rest.test.tschecks 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 childrenwithoutComposeEnvReferences(src/lib/compose-managed-env.ts) now stripsHACK_ENV_<12 hex>[_KEY]names beforewithDependencyStartupProgressspawnsdocker inspect/logs/events, and beforecanReuseRunningDependencyCachespawnsdocker inspect.docker composeinvocations keep the full env. The newtests/compose-env-reference-least-privilege.test.tsrecords each stubdockerchild's environment and checks that noHACK_ENV_*key or canary is present whileGLOBAL_FLAGis. The wording indocs/security/env-secrets.md, threat-model row 78 anddocs/env.mdnow says this.Findings
hack doctorcounts.compose.env.override.yml.<uuid>.removefiles and warns without values.--fixdeletes 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.docs/env.mdhas a new "Upgrading: managed values that contain$" subsection. See the release note below.execwarning:resolveExecTargetReadynow passes the Compose env tocompose ps.tests/project-exec-command.test.tsasserts that thepsenv equals theexecenv.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 nexthack 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 activehack config adoptgeneration made by an earlier version keeps its literal override until it is rolled back, run throughhack upand adopted again.Mutation proof (each fix reverted alone; the named test fails, then passes when restored)
removePlaintextComposeEnvOverrideconfig adoptapply literal refusalwithDependencyStartupProgresscanReuseRunningDependencyCacheenvon the exec readinesscompose pspsEnvsassertion)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 asnext). Biome on the changed files: clean.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.bun run testonbfccb534(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.300beacefixed 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 unchangedorigin/next(8c790004) on this host, so they are not caused by this PR.origin/next..HEAD, 9 commits): no leaks.Remaining gaps
LegacyComposeAdoptionProjection.resolvestill 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 becausefindProjectContextrefuses native inputs. Only the compose-env check and its fix step have the adopted-root fallback. This behavior predates this PR.🤖 Generated with Claude Code