Repository navigation
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>
This branch has not been deployed
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. The M3 rerun saw this on Omega: 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).~/.hack-v5-private/hack1124-next5-rerun-20261019-1204/). It confirms the original defect on published next.5:cleanup-omega.txtlists a 71,130-byte, 0600compose.env.override.ymlafterdown. That file is no longer at~/dev/hack-dance/omega/.hack/.internal/(checked withstatonly; no content was read).Refs HACK-1273
🤖 Generated with Claude Code