Skip to content

Let the agent hook scenario inherit its environment; write down what mise does - #3016

Merged
jeremy merged 1 commit into
mainfrom
prek-hook-inherit-env
Aug 1, 2026
Merged

Let the agent hook scenario inherit its environment; write down what mise does#3016
jeremy merged 1 commit into
mainfrom
prek-hook-inherit-env

Conversation

@jeremy

@jeremy jeremy commented Aug 1, 2026

Copy link
Copy Markdown
Member

Two follow-ups to the toolchain-resolution fix that landed 2026-07-31. Neither changes the hook's behaviour — its 43 executable lines are byte-identical to master; the only hook edit is a comment.

1. The agent test scenario inherits its environment, and the forwarding list is deleted

The agent scenario rebuilt its environment under env -i and forwarded a hand-maintained list of the MISE_* variables that can relocate mise's install tree. expected_ruby_path is derived in the invoking environment while the probe ran under env -i, so any variable missing from that list made the two disagree and failed a working hook.

The list fell behind twice — MISE_INSTALLS_DIR, then MISE_SHARED_INSTALL_DIRS — and mise publishes no authoritative set to copy: mise settings --all reports none of the path settings. The shipped file admitted as much in a comment ("bounded by measurement, not enumerated"). That caveat was the smell: the list defended a boundary that need not exist.

So run_hook_inheriting() inherits the invoking environment and overrides only PATH, __MISE_ORIG_PATH and PROBE_ENV_KEYS — which is what an agent session actually does. The two now agree by construction under any store configuration, and both past bugs become structurally impossible rather than individually fixed.

The [env] keys are unset, because a mise-activated project shell already exports them and running the suite that way is normal — inheriting them would let those assertions pass on ambient state. That scrub is the one place env -i's hazard is deliberately reintroduced, so it gets its own negative control below.

Both remaining lists are derived from the same source as the thing they serve, so neither can fall behind it:

list derived from serves
scrub (-u $env_keys) .mise.toml [env] the [env] assertions, read from .mise.toml
store configuration inherited wholesale expected_ruby_path, derived in that same environment

_mise_dirs stays, unchanged, for the terminal scenario; its rationale is rewritten, since it was written around the agent scenario's path assertion.

2. The mechanism comment understated what mise does

Both files said mise exec "substitutes the install dirs at the shims entry's position". That is the visible consequence, not the mechanism. Asked directly (mise exec -- sh -c 'echo $PATH') rather than inferred from which binary won:

PATH in PATH mise hands the child ruby
/usr/bin : shims : /bin /usr/bin : «installs» : shims : /bin /usr/bin/ruby
installs/ruby/4/bin : /usr/bin : shims /usr/bin : «installs» : shims /usr/bin/ruby
/usr/bin : /bin «installs» : /usr/bin : /bin project ruby
decoy : /usr/bin : shims decoy : /usr/bin : «installs» : shims decoy

mise strips the install directories it recognises out of the inherited PATH, then inserts the resolved ones immediately before the shims entry — keeping that entry — or prepends them when there is no shims entry at all. Row 2 is the striking one: the leading installs/ruby/4/bin simply vanishes. Row 4 shows foreign entries are never touched.

Measured on mise 2026.7.10 (the project's min_version floor) and 2026.7.16, on both platforms, identically. An earlier draft of this PR said mise removed the shims entry too; review caught it and direct measurement settled it.

This is why a globally activated installs/ruby/4/bin at PATH #13, nine places ahead of /usr/bin at #22, still lost to /usr/bin/ruby — the most counterintuitive fact in the investigation, and exactly the confusion behind "mise exec -- ruby -v prints 3.4.10 but the hook still fails". It was written down nowhere. Now it is, in both files.

3. A fourth scenario: an unrelated ruby earlier on PATH

A decoy ruby ahead of /usr/bin, in a PATH otherwise the agent shape. Not a different mise code path — I claimed it was, and review was right that it isn't: /usr/bin/ruby is already foreign and mise never strips it, so the agent scenario always had a foreign competitor. What the decoy adds is determinism. The agent scenario's competitor is whatever ruby the platform happens to ship — 2.6.10 on macOS, 3.4.10 on Arch, and eventually nothing at all, since Apple is removing it; on a host without one that scenario would quietly have nothing left to beat and would pass whatever the hook did. The decoy always has a competitor, and fails as DECOY_RUBY rather than as a version string that might coincide with the project's.

Kept as its own scenario rather than folded into the agent PATH: with a decoy winning, a broken hook would fail by version everywhere, and the suite would lose the Arch-specific evidence that it fails there by path alone (/usr/bin/ruby is also 3.4.10 on Arch). Both properties are worth holding.

4. Two activation states, and a permanent negative control

Review flagged that dropping __MISE_DIFF while keeping __MISE_SESSION/MISE_SHELL/__MISE_EXE leaves a combination no real shell produces, built from undocumented mise internals. Rather than pick one interpretation, run_hook_inheriting takes an activation mode and the agent and decoy scenarios each run under both — keep (drop __MISE_DIFF only, the faithful model) and bare (drop all four). The property asserted is that the hook resolves correctly either way, which stays checked if a future mise changes what these mean.

And the negative control is now in the suite, not a measurement someone took once by hand: it strips the two shims-prepend lines from the hook and requires the decoy to win — asserted positively, by exact path and exact version, in both activation modes. Positively, because "not the expected path" is also satisfied by an empty field from a mutant that never executed, which is the same liveness hole the control exists to close. Failure to mutate is fatal rather than a skip.

It earned its place twice over: it caught a real bug in itself on the first run (it was writing over its own input file), and review caught the != liveness hole before merge. Sensitivity verified both directions — pointed at the unmutated hook it goes red, and with the probe output forced empty it goes red.

This matters because the file has form: env -i twice stripped PWD, so two shipped assertions could never fail. Inheriting the environment trades that hazard for a new one — inherited mise state repairing a broken hook — and this control is what keeps it honest.

Assertion count 17 → 28 (20 → 34 for launchpad, which has two [env] keys).

5. The forwarding list could not have been completed even in principle

Found while chasing my own review of this change, and it is the strongest argument for deleting the list rather than extending it a third time. shared_install_dirs is a genuine mise setting, so a config file relocates the install tree exactly as an environment variable does — and the deleted list named no config-dir variable at all. So it had a third latent hole waiting.

Demonstrated, same working hook, same environment, a fresh empty local store for each run, with MISE_CONFIG_DIR pointing at a config declaring shared_install_dirs:

  • old harness — expected …/sharedinstalls/ruby/3.4.10/bin/ruby, got …/storeX/installs/ruby/3.4.10/bin/rubyfails a working hook
  • new harness — expected and got …/sharedinstalls/ruby/3.4.10/bin/rubypasses

By contrast data_dir, shims_dir and installs_dir report Unknown setting and a config file setting them is ignored with a warning — they are environment-only. (That is the Unknown setting vs Setting [x] is not set distinction; misreading it once produced a confidently wrong rebuttal.)

Verification

All on macOS and Arch, at this exact head; hook and test sha256-identical across all five repos.

  1. shellcheck clean on both files, all five repos.

  2. Suite green: 28/28 (34/34 launchpad), both platforms.

  3. Negative control — the gate for change 1. Strip only the two prepend lines. The new configuration is one that had never been run: broken hook, inherited environment, modelled PATH, __MISE_ORIG_PATH supplied, __MISE_DIFF removed. The hazard was that inherited mise shell state might reconstitute install directories and mask the break. It does not — run from a fully mise-activated shell on both platforms (macOS: __MISE_DIFF/__MISE_SESSION/MISE_SHELL set and 9 install dirs ahead of /usr/bin; Arch likewise):

    • macOS — agent fails by version and path (2.6.10, /usr/bin/ruby); decoy fails by both.
    • Arch — agent fails by path alone (/usr/bin/ruby, also 3.4.10, so the version assertion passes); decoy fails by both.

    All four inheriting scenarios (agent, agent-bare, decoy, decoy-bare) fail correctly. This control is now also permanently in the suite — see section 4.

  4. Control that the [env] scrub earns its place. A hook variant execing the probe bare, so nothing exports [env], run from a mise-activated shell where PROMETHEUS_EXPORTER_URL is already set: (a) with the scrub → <UNSET>, the assertion fails; (b) without it → the ambient value, the assertion passes on state the hook never exported. Both platforms. The hazard is real and the scrub blocks it.

  5. Whole suite with MISE_DATA_DIR at an empty store: green, with agent and decoy resolving out of that store because they inherit it. Repeated with MISE_SHARED_INSTALL_DIRS at a populated store, an empty local store and an empty MISE_CONFIG_DIR — the fixture that exposed the second bug: green with no variable forwarding at all. Method kept honest with a made-up-name control: MISE_TOTALLY_MADE_UP_DIRS leaves mise which ruby unresolved.

  6. End-to-end in a real agent shell (shims below /usr/bin): the hook with its final exec swapped for a probe resolves mise which ruby, not /usr/bin/ruby. Plus the real thing — a staged Ruby file so rubocop actually runs: Passed under the merged hook; under the stripped one it reproduces the original report exactly, bin/rubocop's #!/usr/bin/env ruby landing on 2.6.10 and dying in Bundler.

  7. Inert where already correct: the hook's executable content is byte-identical to master, and the GUI and terminal scenarios are unchanged and green on both platforms.

Note on adoption

Git runs .githooks/pre-commit from the working tree, so a branch created before 2026-07-31 executes the pre-fix file no matter what master says. The remedy is to pick up master (git rebase origin/master), not --no-verify. The same lag applies to this PR — but it only affects the test suite, which you run from whatever branch you are on.

Copilot AI review requested due to automatic review settings August 1, 2026 17:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI review requested due to automatic review settings August 1, 2026 17:33
@jeremy
jeremy force-pushed the prek-hook-inherit-env branch from bf908f1 to 4166936 Compare August 1, 2026 17:33
…mise does

Two follow-ups to the toolchain-resolution fix. Neither changes the hook's
behaviour: its 43 executable lines are byte-identical to master.

The agent scenario rebuilt its environment under `env -i` and forwarded a
hand-maintained list of the MISE_* variables that can relocate mise's install
tree -- expected_ruby_path is derived in the invoking environment, so any
variable missing from that list made the two disagree and failed a working
hook. The list fell behind twice (MISE_INSTALLS_DIR, then
MISE_SHARED_INSTALL_DIRS), and it could not have been completed even in
principle: shared_install_dirs is a real mise setting, so a config FILE
relocates the install tree too, and the list named no config-dir variable at
all. Measured -- with MISE_CONFIG_DIR pointing at a config declaring
shared_install_dirs, the old harness fails a working hook and this one passes.
So delete the list. run_hook_inheriting() inherits the invoking environment and
overrides only PATH, __MISE_ORIG_PATH and PROBE_ENV_KEYS, which is what an
agent session actually does; the two now agree by construction under any store
configuration. The [env] keys are unset so those assertions cannot pass on
ambient state from a mise-activated shell.

Both files described `mise exec` as substituting install dirs at the shims
entry's position. Measured on 2026.7.10 (the floor) and 2026.7.16, on macOS and
Arch, mise strips the install directories it recognises out of the inherited
PATH, then inserts the resolved ones immediately BEFORE the shims entry --
keeping that entry -- or prepends them when there is none. Which is why a
globally activated installs/ruby/4/bin at PATH #13 still loses to /usr/bin/ruby
at #22. Recorded in both files with the measured rewrites.

Adds a decoy scenario: an unrelated ruby ahead of /usr/bin. Not a different
mise code path -- /usr/bin/ruby is already foreign and never stripped -- but a
deterministic competitor that does not depend on what ruby the platform ships,
failing unmistakably as DECOY_RUBY.

Adds an in-suite negative control. The agent and decoy scenarios run under two
activation states (mise's session internals retained, and fully scrubbed), and
a mutated hook with the two shims-prepend lines removed must resolve the decoy,
asserted by exact path and version rather than merely differing from expected --
this file has twice shipped assertions that could not fail. Failure to mutate
is fatal rather than a skip.
@jeremy
jeremy force-pushed the prek-hook-inherit-env branch from 4166936 to 3ebbb38 Compare August 1, 2026 17:38
Copilot stopped reviewing on behalf of jeremy due to an error August 1, 2026 17:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@jeremy
jeremy merged commit a9f3963 into main Aug 1, 2026
13 checks passed
@jeremy
jeremy deleted the prek-hook-inherit-env branch August 1, 2026 18:29
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.

2 participants