Skip to content

fix(env): share global Compose env references and refuse an oversized environment - #412

Merged
roodboi merged 3 commits into
nextfrom
wf/g45-compose-env-e2big
Oct 22, 2026
Merged

roodboi merged 3 commits into
nextfrom
wf/g45-compose-env-e2big

Conversation

@roodboi

@roodboi roodboi commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

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). 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.
  • 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

… 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>
@linear-code

linear-code Bot commented Oct 22, 2026

Copy link
Copy Markdown

HACK-1317

@blacksmith-sh

This comment has been minimized.

roodboi and others added 2 commits October 22, 2026 09:05
…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
roodboi marked this pull request as ready for review October 22, 2026 18:41
@roodboi
roodboi merged commit 5c9cc82 into next Oct 22, 2026
17 checks passed
@roodboi
roodboi deleted the wf/g45-compose-env-e2big branch October 22, 2026 18:41
roodboi added a commit that referenced this pull request Oct 22, 2026
Brings in #375, #407 and #412. #407 changes the doctor .hack/.gitignore
repair helper and the untrack step; this branch's adopted-project branch of
that step still skips the .hack/.gitignore writer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant