Repository navigation
fix(env): share global Compose env references and refuse an oversized environment - #412
Merged
Merged
Conversation
… environment Since #357, each service got its own HACK_ENV_* reference for every key, global values included, so the Compose process environment grew with services times keys. Twenty services and one 64 KiB global secret made `hack up` fail with E_UNEXPECTED: E2BIG from posix_spawn on macOS. - A key whose winning scope for a service is global now uses one shared reference for every such service. A key that a service scope sets keeps its own reference. The choice follows the scope metadata, never the values, so the override file stays value-free. - Before it writes the override or starts Compose, Hack measures the environment the docker compose child would get against the platform limit (macOS 1 MiB ARG_MAX; Linux a quarter of the stack limit and 128 KiB MAX_ARG_STRLEN). An oversized environment now fails with the new closed code E_ENV_TOO_LARGE, which names the limit, gives byte counts only and says how to reduce the environment. - Adoption still accepts a generation saved with per-service references. - docs/env.md and the hack-diagnose error-code reference describe both. Refs HACK-1317 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
…tadata The managed adoption fixture rebuilt the expected Compose env override without scope metadata, so it expected one reference per service and refused the shared global references the writer now produces (HACK-1317). The oracle now reads the same resolved scope metadata as the writer. Refs HACK-1317 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolve the adoption projection conflict with #397: keep the shared and per-service reference forms and take next's HACK-1318 comment. Refs HACK-1317 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
roodboi
marked this pull request as ready for review
October 22, 2026 18:41
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)
roodboi
added a commit
that referenced
this pull request
Oct 23, 2026
…-size follow-ups (#426) ## Summary Follow-ups to #407 (`.hack/.gitignore`) and #412 (Compose env size), both merged into `next` as `5c9cc82e`. Refs HACK-1372. ## Root cause and fix **#407: read-only `.hack/.gitignore`** 1. **Interactive `hack init` was silent.** When the user answered "yes" to "Overwrite scaffold files?", `hack init` called `ensureHackDirGitignore` and ignored its `readOnly` result. An outdated 0444 file was left unchanged with no message. Both init call sites now go through `ensureInitHackDirGitignore`, which prints the fixed `HACK_DIR_GITIGNORE_READ_ONLY_WARNING` (the same text that doctor and the env writes print). Init still succeeds. `--auto` refuses an existing `.hack/`, so in practice only the interactive path can reach this case. 2. **Root and read-only policy.** The writability check used only `access(W_OK)`, which always succeeds for root. Under `sudo` or in a root container, a 0444 file was therefore replaced. **Decision: mode bits.** A file with no write permission bit (`mode & 0o222 === 0`) is read- only for every user, root included. When some write bit is set, `access(2)` still decides, so a file that only another user can write stays read-only. Both `ensureHackDirGitignore` and `inspectHackDirGitignore` (used by doctor) use the mode they just read. They accept an optional `checkWriteAccess` probe so a test can model root. - **No root `skipIf` guards added.** With this policy, the existing 0444 tests in `hack-gitignore`, `doctor-generated- files`, `doctor-fix-noninteractive`, `project-env-config` and `project-env-hydrate` expect the same results under root as for a normal user. A `test.skipIf(process.getuid?.() === 0)` guard would only drop coverage. The new injected-probe test shows the root behavior on a non-root host. 3. **Doctor probe location documented.** The doctor git probe runs in the project root. So a non-git root whose `.hack` links into another repository reports "Skipped (not a git checkout)", and writes are still refused. This PR documents that behavior in the `inspectHackDirGitignoreForDoctor` JSDoc and in `docs/env.md`, and a new test pins it. It does not keep `unsafe` for this case. `docs/env.md` also records the init warning and the mode-bit rule, and corrects "`hack init` does not repair an existing project" (it does when you confirm the overwrite). **#412: Compose env size** 4. **Legacy preflight test.** No test covered the `assertComposeSpawnEnvFits({ env: resolved.envForCompose })` call on the legacy `hack.env.json` path in `resolveComposeEnvOverrides`. A new CLI test fills that gap. That path passes each value to Compose once, so it uses six 200 KiB values (1.2 MiB) to exceed the macOS 1 MiB `ARG_MAX`. Each value is also over Linux's `MAX_ARG_STRLEN`. 5. **Mutation M7.** A new projection test writes the earlier per-service reference form. It expects adoption to resolve that form and to deliver the per-service names and values to the hash probe. The comparisons are flags only, so a failure prints no value. 6. **Value guard documented precisely.** The renderer JSDoc and `docs/env.md` said the choice between a shared and an own reference never depends on values. That was not exact. A safety guard compares each service value with the global value. When the guard fires, the override text shows that the service's value differs from the global value. It never shows the value or its length. **The guard stays.** Removing it would give a container the global value when the scopes and the values disagree. Scopes and values from one `resolveProjectEnvConfig` result come from the same traversal (`resolveEffectiveStoredEntries`), so the guard never fires for real configs. A new test checks this with layered default and local files, where a later global value overrides an earlier service value. ## Tests and mutation proof Each mutation was applied alone with `mise x bun@1.4.2 -- bun test <file>`, then restored. | Mutation | Caught by | | --- | --- | | M1: init warning removed (`project.ts`) | `interactive hack init that overwrites the scaffold reports a read-only outdated .gitignore…` | | M2: mode-bit check removed (`project-env-config.ts`) | `a 0444 .gitignore is read-only even when access(2) grants write, as it does for root` | | M3: injected probe ignored (`project-env-config.ts`) | `a .gitignore with a write bit defers to access(2)…` | | M4: doctor probe runs in `projectDir`, not the root | new `a non-git project root whose .hack links into another repository is skipped…` and the existing HACK-1363 unsafe test | | M5: legacy preflight removed (`project.ts` `resolveComposeEnvOverrides`) | new `a legacy hack.env.json environment over the platform limit fails with E_ENV_TOO_LARGE…` | | M7: `[shared, perService]` → `[shared]` (`native-compose-adoption-projection.ts`) | new `a generation saved with per-service references … still resolves` | | M8: metadata scope always `global` (`resolveMetadata`) | new `with metadata and values from one resolution the value guard never fires…` | `tests/helpers/interactive-init-runner.ts` now also accepts `none` (no `--with`) and an `overwrite` argument. The existing `onboarding-entry-parity` callers are unchanged and pass. All new tests use temporary directories, a private HOME (`privateCliEnv`), a PATH without agent CLIs, and the existing stub `docker`. They make no Docker socket, keychain or launchd calls. Values are synthetic, built at runtime, and checked only with boolean includes or equality flags. ## Evidence - Bun 1.4.2 (`mise x bun@1.4.2`), pinned. - Local (this Mac, under load): `bun run --cwd packages/cli typecheck` is clean. Root `bun run check` is clean (`privacy-check: ok`; only the existing complexity warnings). Biome is clean on every changed file, tests included. - Focused tests: 166 pass, 0 fail, across 11 files (`onboarding-entry-parity`, `project-env-config`, `project-env-hydrate`, `doctor-fix-noninteractive`, `doctor-generated-files`, `hack-gitignore`, `compose-env-shared-references`, `native-compose-adoption-projection`, `compose-env-spawn-size-cli`, `spawn-env-limit`, `compose-env-at-rest-cli`). The docs-reading tests (`project-task-catalog`, `skill-references`, `env-command-matrix`) also pass. - gitleaks 8.30.1 (`gitleaks dir` on a `git log -p origin/next..HEAD` export): no leaks. - The CLI surface, consumer agent guidance and `package.json` scripts did not change. `docs:cli-reference` produces no diff. No plugin regeneration or task-catalog re-pin was needed. ## Gaps - **M3 full suite not run yet (open gate).** Two things block it: - The M3 has about 57.7 GiB free, which is below the 58 GiB floor. - The shared `m3-ci` lock directory has existed since 12:37. The G51 run took the lock at 12:37 and its suite finished, failed, at about 13:20. That run never removed the lock or its `G51` worktree, and no process holds them now. The lock looks stale, but I did not create it, so I did not remove it. Several units are waiting on this lock. I queued `~/.hack-v5-private/m3-ci/G55-run.sh` on the M3 at `8baaccfd`. It waits for the lock and starts only with at least 60 GiB free. It writes `G55-full.log`, `G55-suite.log` and `G55-fails.txt`. On every exit it removes its worktree and releases the lock. If it cannot get the lock within 24 hours, it stops. This PR does not have its result yet. - I did not run the root behavior as real root. The injected probe models `access(2)` granting write, which is what root sees. - The legacy preflight test relies on the macOS or Linux limits. On another platform (256 KiB limit) it would still exceed the limit. Refs HACK-1372 🤖 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.
Problem
A project with 20 services and one 64 KiB global secret fails
hack upwithE_UNEXPECTED: E2BIG ... posix_spawn dockerbefore Docker starts. Projects with 4 or 12services work.
Root cause
#357 keeps decrypted Compose env values off disk:
.hack/.internal/compose.env.override.ymlholds only
${HACK_ENV_*}references, and Hack passes the values todocker composethrough its process environment.
renderComposeEnvReferenceOverride(insrc/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_MAXon macOS. The spawn failure then reached the top-level handler as anunclassified error.
Fix
global, thatservice uses one
composeEnvGlobalReferenceName({ key })reference, shared with everyother 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), neverthe 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>_KEYshape, sowithoutComposeEnvReferencesstill strips it from non-Compose children. Its digest input (
\0global\0KEY) cannotequal a per-service input, including one for a service named
global.src/lib/spawn-env-limit.tsmeasures theenvironment that the
docker composechild would get: the liveprocess.envplus theCompose env, with the
NAME=valuestrings, their NULs and pointer slots. It checks thattotal 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 filecannot be read), plus
MAX_ARG_STRLEN= 128 KiB for one string. Other platforms get256 KiB.
resolveModernComposeEnvOverridesand the legacy contract path run the checkbefore they write the override or start Compose. This covers
up,restart,runandexec. An oversized environment fails with the new codeE_ENV_TOO_LARGE. Its messagenames the limit (
ARG_MAXorMAX_ARG_STRLEN) and says how to reduce the environment.The
--jsondetail is{limit, bytes, limitBytes}: byte counts only, with no names andno values.
LegacyComposeAdoptionProjection.resolveaccepts three formsof 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/env.mdreplaces 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_CODESand insrc/agents/error-code-reference.ts, and theplugin's
hack-diagnose/references/error-codes.mdwas regenerated withbun run generate:agent-plugins. The CLI surface andpackage.jsonscripts did notchange, 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.tsis the regression test. It runs the sourceCLI against a stub
dockerwith a private HOME, an isolated Docker client config and atemp project, and makes no Docker socket, keychain or launchd calls.
up --detach --jsonsucceeds. The stub seesexactly 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.
E_ENV_TOO_LARGE, exit 1,ARG_MAXon macOS orMAX_ARG_STRLENon Linux. Nodocker composeruns and nooverride is written.
tests/compose-env-shared-references.test.tscovers the renderer: sharing, aservice-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.tscovers measurement, the per-platform limits, the/proc/self/limitsparser, the boundaries, the closed error's content and the inheritedenvironment.
tests/native-compose-adoption-projection.test.tsnow expects the shared form from thecurrent writer.
Mutation proof. Each mutation was applied alone, and the named tests failed:
project.tsE_ENV_TOO_LARGEtestproject.tsMAX_ARG_STRLENcheck removedv3 value-free env override binds…(generation test)real canonical legacy writers…(projection test)Pre-fix reproduction on macOS: with sharing disabled and the preflight removed, the
20-service CLI test exits 1 (the E2BIG path).
Evidence
mise x bun@1.4.2), pinned.bun run --cwd packages/cli typecheckand rootbun run check(includingprivacy:check) are clean, with only existing complexitywarnings. Focused files pass: 168 tests across 13 files, plus
native-compose-adoption-generation.test.tsat 114/114 when run alone. During onecombined run that also included the generation file, that file had several tests fail
at about 20 s each under host load. They pass alone.
origin/next..HEADreports no leaks.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 hostedtestjob, which runs the full suite, passed at this exact head (see the rework section). An M3 full-suite run at40c5581cis still an open gate.Rework after the first CI run
Hosted CI at
78be3539failedtestand docker-e2e shards 3 and 4 with the sameclosed error, "Adoption managed fixture source check failed; values omitted."
(
tests/e2e/scenarios/native-compose-adoption-managed-inputs.ts) rebuilt theexpected 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.
f8f51a6c). The oracle now reads the same resolved scope metadata as thewriter (
resolveProjectEnvConfig(...).effectiveMetadata) and passes it aseffectiveScopes.effectiveScopesremoved from the writer inproject.ts, themanaged fixture uses real writer owners…test fails; restored, it passes.next(40c5581c). fix(adoption): report caller-deadline expiry apart from binding drift and refuse publishing a literal-bound override #397 changed the same adoption projection block.The resolution keeps the shared and per-service reference forms and takes fix(adoption): report caller-deadline expiry apart from binding drift and refuse publishing a literal-bound override #397's
HACK-1318 comment. fix(adoption): report caller-deadline expiry apart from binding drift and refuse publishing a literal-bound override #397's literal-override classifier accepts the shared names,
because they keep the
${NAME}reference shape.40c5581c: all 16 checks pass, includingtestand docker-e2eshards 1–4 (the
native-compose-adoption-managed-worktreesandnative-compose-adoption-local-worktreesscenarios).bun run check(privacy-check: ok) are clean.Focused files pass, 73 tests across 10 files, including both fixture tests.
generate:agent-pluginsshows no drift. gitleaks 8.30.1 overorigin/next..HEADreports no leaks.native-compose-adoption-generation.test.tsrun alone on this Mac at loadaverage 113: its first test timed out at 60 s and the rest of the file
cascaded. The hosted
testjob passed it at this head.Gaps
/proc/self/limits. A container runtime with a differentstack limit for
dockerthan forhack, or a kernel with non-4 KiB pages(
MAX_ARG_STRLENscales with page size), could still give a different real limit. Thecheck is conservative on
MAX_ARG_STRLEN(it assumes 4 KiB pages).could still exceed it in theory.
plain global name used for
${KEY}interpolation). This is bounded, but it is not onecopy.
40c5581c(see Evidence).Refs HACK-1317
🤖 Generated with Claude Code