Launch Target Resolution¶
Issues: #1266 (the defects this closes) · #585 (the npm hang this must not regress)
Status: Implemented. amplihack_utils::launch_target::resolve is the one
resolver; bootstrap::ensure_tool_available, launcher_core::get_claude_cli_path,
and the fleet reasoner all read through it.
Scope: crates/amplihack-utils — launch_target, claude_native · crates/amplihack-cli — bootstrap, launcher, commands/launch · crates/amplihack-launcher — launcher_core
Verification snapshot. Measured on the dev VM (Linux x86_64) against
@anthropic-ai/claude-code. The native binary is 338,860,336 B at 2.1.238 and 342,563,120 B at 2.1.239 — the size tracks the release, so it is quoted with a version or not at all. The copilot shim is 1185 B, the unrepaired claude stub is ~500 B, a warmnpm showis ~0.3 s, and a host launch settles at 0.45 s.npm showcorrected the design's earlier 351 ms estimate.Code comments say "~339 MB", which is 2.1.238's 338,860,336 B in decimal MB. An earlier draft of this page rendered the same quantity as "327 MiB" — that is 2.1.239's 342,563,120 B in binary MiB, i.e. a different release and a different unit presented as the same number. Prose below says "hundreds of megabytes" where the point is the order of magnitude; exact figures appear only here, where they are measured and labelled.
Two figures remain design estimates, not observations: the per-resolution timings (151 ms → 116 ms → 0.14 ms) and the 60.0 s drain overrun against the 10 s budget. The workspace has no benchmark harness, so neither is reproducible from a
cargoinvocation; both are labelled where they are used below. Sizes and versions drift with every upstream release — read the absolute numbers as of the stated version, and the ratios as the durable claim.
Overview¶
Every amplihack claude launch has to answer three questions:
- Which binary will we execute?
- Is that binary usable?
- Do we need to install or upgrade anything first?
amplihack_utils::launch_target::resolve answers all three. One function, one
answer, one binary. The version that gets compared against the registry is the
version of the binary that gets executed, and nothing else in the repository
independently resolves a launch path for these purposes.
That single-resolver rule is the whole design. The rest of this document is its consequences.
The resolution contract¶
resolve¶
use amplihack_utils::launch_target::{resolve, TargetSource};
let resolution = resolve("claude");
match resolution.target {
Some(target) => println!("{} @ {} ({:?})", target.path.display(), target.version, target.source),
None => eprintln!("{}", resolution.rejection_report("claude", "@anthropic-ai/claude-code")),
}
// Output on a healthy host:
// /usr/bin/claude @ 2.1.238 (Path)
pub struct LaunchTarget {
pub path: PathBuf,
pub version: String,
pub source: TargetSource,
}
pub enum TargetSource {
/// AMPLIHACK_CLAUDE_BINARY_PATH / CLAUDE_BINARY_PATH.
/// `user_supplied` is false when amplihack set the variable itself —
/// see "The override is also set programmatically" below.
ExplicitOverride { user_supplied: bool },
Path, // found on $PATH
AmplihackPrefix, // ~/.npm-global/bin — the prefix amplihack installs into
FallbackDir, // ~/.cargo/bin, ~/.local/bin
}
pub enum Rejection {
NotAbsolute, // a relative path — see "Every candidate is absolute"
Missing, // no such path, or a dangling symlink
NotAFile, // resolves to a directory or other non-regular file
NotExecutable, // no executable bit for this user
PlaceholderStub, // `--version` failed AND the file has the placeholder's shape
Unreadable, // `--version` failed and the file could not be read to say why
ProbeFailed, // `--version` ran but exited non-zero
ProbeTimedOut, // `--version` exceeded the per-candidate budget
UnparseableVersion, // `--version` succeeded but emitted no semver
NotProbed, // never examined — see "Stopping early is recorded"
// The binary RAN and died. Never evidence of a missing install (issue #1424).
KilledBySignal { signal: i32, stderr_tail: String },
// The host would not run it, or took it down for want of resources.
ResourceExhausted { detail: String },
}
pub struct Resolution {
pub target: Option<LaunchTarget>,
pub rejected: Vec<(PathBuf, Rejection)>,
}
/// Where `AMPLIHACK_<TOOL>_BINARY_PATH` came from. There is deliberately no
/// `Default`: the safe-looking default is `User`, the *strict* arm, so a caller
/// that forgot to thread the value would turn a preference into a hard launch
/// failure. Making every call site name the origin is the point (issue #1276).
pub enum OverrideOrigin {
/// The variable came from the caller's environment. A failing override is a
/// hard error — the user named a binary, and amplihack must not quietly run
/// a different one.
User,
/// amplihack set the variable itself, as a preference.
/// `configure_preferred_rustyclawd_binary` is the only producer. A failing
/// override warns and the search continues, so a broken `rustyclawd` on
/// `$PATH` cannot turn a working `amplihack rustyclawd` into a failed launch.
AmplihackSupplied,
}
pub fn resolve(tool: &str, override_origin: OverrideOrigin) -> Resolution;
/// Same answer, ignoring the memo, and refreshes it. For callers that just
/// changed the filesystem — i.e. installed something. See "One probe per
/// process" below.
pub fn resolve_uncached(tool: &str, override_origin: OverrideOrigin) -> Resolution;
impl Resolution {
/// Human-readable account of what happened, and the remedy.
///
/// `tool` and `package` are parameters because this is the error path for
/// EVERY tool. See "The report speaks about the tool it was asked about".
pub fn rejection_report(&self, tool: &str, package: &str) -> String;
}
/// `~/.npm-global/bin` — the one directory amplihack owns, spelled once.
///
/// Takes `home` rather than reading `$HOME`, so it stays pure. Every consumer
/// of the concept calls this: `candidate_paths`, `binary_finder`'s
/// `install_fallback_dirs`, the launch command's `is_already_reachable`, and
/// `bootstrap`'s `npm_prefix_dir` (which returns this path's parent, because
/// npm's `--prefix` takes the prefix). See "Child process PATH".
pub fn amplihack_prefix_bin(home: &Path) -> PathBuf;
Resolution carries the rejection list because the error path needs it. A bare
Option<LaunchTarget> can say "nothing worked" but cannot say what it tried
and why each one failed, which is exactly the information the user needs when
a launch cannot proceed.
Candidate order¶
Candidates are examined in this order and the first healthy one wins:
AMPLIHACK_CLAUDE_BINARY_PATH, thenCLAUDE_BINARY_PATH(explicit override)- Each
$PATHentry, in$PATHorder ~/.npm-global/bin— amplihack's own npm prefix- The remaining install fallback directories:
~/.cargo/bin,~/.local/bin
An explicit override that exists but fails the health gate is an error, not a silent demotion. If you point amplihack at a specific binary and that binary is broken, amplihack tells you so rather than quietly launching a different one.
Only absolute directories become candidates¶
candidate_paths discards every $PATH entry that is not absolute. This is not
hygiene; it closes a live path to arbitrary execution.
POSIX defines an empty $PATH element as the current directory, and
std::env::split_paths faithfully yields an empty PathBuf for one. A stray
trailing or doubled colon — PATH=/usr/bin: — therefore produces the bare
relative candidate claude, which execvp resolves out of the current working
directory. If a file of that name in a cloned repository prints a parseable
semver, it becomes the LaunchTarget and gets executed. Worse, its parent is
the empty path, so the child-PATH promotion below would put the current
directory at the front of the agent's PATH — and thus of every subagent
and every shell-out — turning one stray colon into cwd-first resolution of
git, node, and sh.
Filtering on Path::is_absolute removes the candidate and the promotion in one
step. no_global_path_mutation.rs asserts that no relative and no empty
directory ever reaches either the candidate list or prepend_path.
The override is also set programmatically¶
AMPLIHACK_CLAUDE_BINARY_PATH is not exclusively a user-facing variable.
configure_preferred_rustyclawd_binary
(crates/amplihack-cli/src/commands/rustyclawd.rs) sets it in-process
whenever amplihack rustyclawd finds a rustyclawd or claude-code binary,
then delegates to the ordinary claude launch path.
So the "a broken override is a hard error" rule can fire on a value amplihack set
itself. The binary it selects has passed only is_executable_file — existence
plus the executable bit — which is strictly weaker than the health gate. A
rustyclawd that exists but cannot answer --version would turn a previously
working amplihack rustyclawd into a hard failure it never had.
The two origins are therefore treated differently:
| Override origin | When it fails the health gate |
|---|---|
| Set in the user's environment | Hard error. The user named this exact binary; quietly launching a different one is worse than failing. |
Set programmatically by configure_preferred_rustyclawd_binary |
Warn, drop the candidate, continue down the list. This is a preference, not an instruction. |
resolve records the origin in
TargetSource::ExplicitOverride { user_supplied }. The internally-set case is
marked via an in-process flag, not a second environment variable — the child
must not inherit it, or a nested amplihack invocation would silently downgrade
a genuine user override.
Callers that need user_supplied to be true should set the variable in the
environment before the process starts. Callers expressing a preference should go
through the internal path.
The health gate¶
A candidate becomes a LaunchTarget only if all of the following hold:
| Check | Rejection when it fails |
|---|---|
| Path is absolute | Rejection::NotAbsolute |
| Path exists | Rejection::Missing |
Path resolves to a regular file — fs::metadata, following symlinks |
Rejection::NotAFile |
| File is executable | Rejection::NotExecutable |
--version exits 0 within the per-candidate budget |
Rejection::ProbeFailed / Rejection::PlaceholderStub / Rejection::Unreadable / Rejection::ProbeTimedOut / Rejection::KilledBySignal / Rejection::ResourceExhausted |
--version output contains a parseable semver |
Rejection::UnparseableVersion |
Rejection::NotProbed is not in this table on purpose: it is not a verdict on
the candidate at all — see "Stopping early is recorded".
A candidate that ran is not a candidate that is missing¶
Issue #1424. output.status.success() is false for three unrelated events, and
only one of them is repaired by an install:
| What happened | Verdict | decide_install |
|---|---|---|
| Exited non-zero | ProbeFailed / PlaceholderStub |
InstallMissing |
| Killed by a signal | KilledBySignal { signal, stderr_tail } |
Abstain |
EAGAIN/ENOMEM on spawn, or a death with the host's out-of-resources fingerprint on stderr |
ResourceExhausted { detail } |
Abstain |
A Copilot native binary that launched and then aborted on SIGABRT — after
failing to spawn a thread with EAGAIN — was reported as a missing platform
package, which sent the reader after an installation problem that did not
exist. A binary that executed is present and executable by construction, so
nothing downstream may say otherwise:
Rejection::is_about_the_machineis the single predicatedecide_installreads for inconclusive evidence. Adding a machine-shaped verdict means adding it here, not to amatches!at the call site.Resolution::install_would_helpgates thenpm install -gremedy inrejection_report. An install remedy under a verdict that says the binary ran is a confident instruction to fix something that is not broken.Resolution::resource_failureis what a caller reports instead of the probe budgets, which name the wrong number just as confidently.
The stderr_tail is sanitised the way display_untrusted_path sanitises a
path, and folded to a single line: a child's stderr is attacker-influenced text
that lands in amplihack's own diagnosis, and a newline in it would forge a row
in the report.
The first four rows are cheap_reject, and they are the only pre-probe
checks. Absoluteness is a fact about the path; the other three are filesystem
facts that hold for any tool on any platform: it is there, it is a regular file,
you may run it. Nothing about the file's contents is judged before the probe —
see "The shape check is a label, never a gate".
Every candidate is absolute¶
Rejection::NotAbsolute is checked first, before any filesystem call, and
it is the one rule that is about the shape of the path rather than the state of
the disk.
A relative candidate is not a candidate, because two different subsystems would
disagree about which file it names. cheap_reject stats it against the
process current directory; execvp resolves a name containing no separator
against the child's $PATH. Neither of those files is the one that was named.
Relative candidates arrive from two places:
$PATH. POSIX reads an empty element as the current directory, and trailing or doubled colons are ordinary in hand-edited shell profiles.split_paths("/usr/bin:")yields["/usr/bin", ""], and joining""withclaudegives the bare nameclaude. If a./claudein the current directory prints parseable semver, it becomes the selectedLaunchTarget.- The override variables.
CLAUDE_BINARY_PATH=claudeis spelled by a user and never passes through the$PATHseam at all.
The invariant is enforced in cheap_reject — the one funnel every producer
passes through, including whatever producer is added next — rather than at each
producer, or in the push closure that builds the candidate list. Filtering in
push would make a relative override vanish from the list, and the user would
get a silently different binary, which is precisely what the source match in
resolve_from_candidates exists to prevent.
Enforcing it at the funnel gives the right behaviour for free, with no new branching:
| Source of the relative candidate | Result |
|---|---|
ExplicitOverride { user_supplied: true } |
Hard error naming the path — "not an absolute path — name the binary by full path" |
ExplicitOverride { user_supplied: false } |
Warn and fall through to the next candidate — it is a preference, not an instruction |
Path / AmplihackPrefix / FallbackDir |
Rejected before probe_version spawns anything |
path_dirs keeps its own is_absolute filter as a cheap pre-filter and as the
pure, mutation-free seam the ratchet test pins. Two sibling $PATH walks —
binary_finder::search_path_dirs and docker_detector::which_docker_in — carry
the same filter, because they are separate funnels with separate callers. The
ratchet in tests/no_global_path_mutation.rs scans the crate by shape, not by
filename: the first version of it named one file, and that is exactly how the
second funnel stayed open through the first fix.
The file-type check must follow symlinks. On every npm-installed host,
~/.npm-global/bin/claude is a symlink into the package directory:
ls -l ~/.npm-global/bin/claude
# lrwxrwxrwx 1 you you 60 ... claude -> ../lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe
Using symlink_metadata — or any is_file() derived from it — rejects every
npm-installed claude on every host, including the one amplihack installs itself.
Use fs::metadata, which follows the link. A dangling symlink then surfaces as
Rejection::Missing, which is the correct answer for it.
Health is a filter, never an annotation. There is no such thing as a
LaunchTarget with version: "unknown". A binary whose version probe fails,
times out, or returns something unparseable is not a degraded candidate — it is
not a candidate. amplihack will not execute it. The type carries this: there is
no constructor for LaunchTarget that admits an unknown version, so "launch
something we could not identify" is not a state the program can represent.
The health gate is a correctness control, not a security boundary¶
probe → select → spawn is TOCTOU by construction. The file that answered
--version is not provably the file that gets executed a few milliseconds
later, and nothing here tries to make it so. The gate exists to stop amplihack
from launching things that do not work — a broken install, a placeholder, a
binary that hangs. It is not an integrity check and must not be relied on as
one. The module says so at its head so that a future reader does not mistake
the check for a defence, and a reviewer does not relitigate the absence of one.
The shape check is a label, never a gate¶
claude_native::has_placeholder_shape answers one question: is this a small
file — under STUB_MAX_LEN, 4 KiB — that does not begin with a native
executable magic number (\x7fELF, a Mach-O magic, or MZ)? The test is the absence of a magic
number, not the presence of any particular text.
That distinction is load-bearing. The placeholder shipped by
@anthropic-ai/claude-code has no shebang. It is a 500-byte file whose first
bytes are:
and file reports it as ASCII text. A check written to look for #! would
miss the exact stub this exists to name — do not write one.
It may only ever relabel a candidate whose probe has already failed. It is
called from label_failed_probe, on the ProbeFailed path, and from nowhere
else. It lives in claude_native, beside is_materialized and
MIN_NATIVE_BINARY_LEN, because it encodes what a claude install looks like.
This was previously a pre-probe rejection in cheap_reject, applied to every
tool, and it broke amplihack copilot. @github/copilot installs
~/.npm-global/bin/copilot as a 1185-byte #!/usr/bin/env node loader —
small, no native magic, and perfectly healthy. It was rejected before it was
ever run, so decide_install(None, _) answered InstallMissing, amplihack
reinstalled @github/copilot on every launch, re-resolved, saw the same shim,
and hard-failed. @openai/codex ships the same shape.
The rule that prevents a recurrence is structural, not a special case: a
classifier that can only rename an already-failed rejection cannot produce a
false rejection for any tool, present or future. An if tool == "claude" would
have fixed copilot and left the next tool to rediscover it.
What survives is the diagnosis the fast path was written for. An incomplete
@anthropic-ai/claude-code install still reports "incomplete install — this is
the small placeholder the npm package ships" rather than a bare "--version
failed". The cost is one execve that returns ENOEXEC, on an install that was
already broken.
Two contract tests hold the boundary: cheap_reject's body may not mention
has_placeholder_shape or STUB_MAX_LEN, and launch_target.rs's production
code may not contain a tool name or an npm scope.
Probe budget¶
| Budget | Value |
|---|---|
Per-candidate --version timeout |
3 s |
| Total probe budget across all candidates | 10 s |
MAX_PROBE_CANDIDATES |
8 |
Probing stops at the first healthy candidate, so the common case is one
subprocess. The total budget exists because a single hung or hostile binary
early in $PATH must not be able to stall a launch: eight candidates at the
per-candidate timeout would otherwise be 24 seconds of foreground hang.
The bound covers the whole subprocess, output drain included. Waiting for
the child and then joining the reader threads unconditionally is not a timeout:
if the child exits promptly but a grandchild inherits its stdout pipe, the
drain thread never sees EOF and the join has no ceiling at all. Against a
shim that runs sleep 60 & and then exits, the drain overruns to 60.0 s
against a 10 s budget (a design estimate — no test in the workspace builds
that shim, so the figure is the sleep's own duration, reasoned rather than
timed; the unboundedness it illustrates is structural, not numeric),
and against a daemon it never returns. run_capped_output_with_timeout
therefore bounds the joins by whatever is left of the timeout and, when that
runs out, abandons the reader threads rather than waiting on them — the child's
exit status is already known and authoritative, and a version probe needs one
semver line, not a complete transcript. The detached threads exit when the pipe
closes or when the process does.
The timeout kills the child, not its process group, so a candidate that forks can still leave a grandchild running after amplihack has moved on. That is a stray process, not a hang: the drain is abandoned and resolution returns on budget regardless. Killing the group would mean putting the probe in its own session, which buys tidiness at the cost of signal-delivery semantics the launch path does not otherwise need. The stray process is accepted, and named here so it is not rediscovered as a leak.
This is what makes the resolution memo's global lock defensible: it is held
across the probe and it is one mutex for all tools, so the wait it can impose on
another thread has to be bounded. It is, by TOTAL_PROBE_BUDGET.
The 3 s figure is deliberately larger than binary_finder's 500 ms
VERSION_DETECTION_TIMEOUT. That constant gates an advisory annotation, where a
false negative costs nothing. This one gates a launch, where a false rejection
degrades the user's session.
One probe per process¶
A single launch asks "which binary?" at least twice — the update notice, then
the install decision — and the probe runs against a binary of hundreds of megabytes. That costs
an estimated ~151 ms per resolution, of which 0.15 ms is building the
candidate list and the rest is claude --version. Asking twice bought nothing.
resolve therefore memoizes, and the memo is keyed by tool and validated
against the candidate list it was computed from. Every input the health gate
reads is in that list, so any environment change that could change the answer —
PATH, HOME, AMPLIHACK_CLAUDE_BINARY_PATH,
mark_override_amplihack_supplied — produces a different list and misses the
memo instead of returning a stale answer.
What the memo cannot see is the filesystem changing underneath it, which is
exactly what an install does. Both post-install call sites in bootstrap.rs
call resolve_uncached, which re-probes and leaves the memo holding the fresh
answer. Nothing else should need it.
| Before | After | |
|---|---|---|
| First resolution in a process | 151 ms | 116 ms |
| Every later one | 151 ms | 0.14 ms |
These four numbers are design estimates, not measurements. The workspace has
no benchmark harness, so nothing reproduces them from a cargo invocation.
What the code does guarantee independently of the timings is the shape: the
second and every later resolution in a process does no subprocess work at all,
so the win is a removed claude --version exec per extra resolution rather than
a percentage. Read the ratio, not the milliseconds.
The first-resolution improvement is a separate fix on the same path:
binary_finder's child wait polled on a 10→100 ms backoff, so a 110 ms
--version was noticed at the 150 ms tick. The drain threads already know when
the child's pipes hit EOF, so the wait now sleeps on that instead — with the
same backoff retained as the fallback, because a child can close its stdio and
keep running, and a grandchild can hold the pipes open past its parent's exit.
try_wait remains the authority; EOF is only a hint about when to ask.
The install decision¶
decide_install is a pure function over the resolved target and the latest
published version. It is the entire fix for the reinstall-on-every-launch defect,
and it is unit-testable without touching npm.
pub enum InstallDecision {
UseExisting,
InstallMissing,
UpgradeOwned,
/// Nothing healthy resolved, but the evidence is inconclusive rather than
/// absent: a candidate TIMED OUT rather than answering, or resolution
/// stopped before examining every candidate. Neither is worth ~339 MB.
Abstain,
/// A user-supplied override is broken, and it names a file outside
/// `amplihack_prefix_bin` — so no install amplihack can perform would
/// change the answer. Conclusive, unlike `Abstain`; the conclusion is that
/// installing is futile.
BrokenOverride,
}
pub fn decide_install(
resolution: &Resolution,
latest: Option<&str>,
amplihack_bin: Option<&Path>,
) -> InstallDecision;
It takes the whole Resolution, not just its target, because the rejection list
is the difference between "nothing is installed" and "we could not tell".
It takes amplihack_bin — the one directory an install writes — because
"conclusive" and "an install fixes it" are not the same claim, and the override
exit below is where they come apart.
| Resolved target | Latest version from registry | Decision |
|---|---|---|
None, and some candidate was ProbeTimedOut, NotProbed, KilledBySignal or ResourceExhausted — i.e. Rejection::is_about_the_machine |
any | Abstain |
None, halted on a user override inside amplihack_bin |
any | InstallMissing |
None, halted on a user override outside amplihack_bin |
any | BrokenOverride |
None, every rejection conclusive |
any | InstallMissing |
Healthy, source is Path / FallbackDir / ExplicitOverride |
any | UseExisting |
Healthy, source is AmplihackPrefix |
None (query failed or timed out) |
UseExisting |
Healthy, source is AmplihackPrefix, version equals latest |
Some |
UseExisting |
Healthy, source is AmplihackPrefix, version differs from latest |
Some |
UpgradeOwned |
Two rules are load-bearing:
amplihack never upgrades a binary it does not own. If the binary that will
actually be executed lives outside ~/.npm-global, amplihack does not write
anything. Installing into its own prefix would not change what gets launched, so
the "upgrade" would be several hundred megabytes of download with no effect on
the next launch — and the launch after that would decide identically, forever.
Ownership is carried by TargetSource and nothing else: a candidate is
AmplihackPrefix only when it was found in amplihack's own prefix directory,
and decide_install writes only for that source. A prefix directory reached
under some other spelling (a symlink, a trailing slash) is tagged Path and
therefore left alone, so the failure mode is amplihack declines to upgrade —
never amplihack writes outside its prefix.
A failed registry query never triggers an install. latest == None means the
network was unavailable or slow, not that the local install is stale. A network
blip must not cause a reinstall.
Inconclusive evidence never triggers an install either. The same rule, on
the resolution axis. A 3 s --version timeout on a loaded box is the same class
of transient as a network blip, and it used to be indistinguishable from
"nothing is installed" — so it bought a reinstall of hundreds of megabytes. Abstain says so
instead: ensure_tool_available reports which candidate stopped responding, and
tells the user to re-run or to set {TOOL}_BINARY_PATH. One candidate timing
out is enough, because the binary that would have answered may be the one that
hung.
Stopping early is recorded¶
Resolution is bounded twice — MAX_PROBE_CANDIDATES probes and
TOTAL_PROBE_BUDGET in total — and hitting either bound stops the walk with
candidates still unexamined. Those candidates are pushed onto rejected as
Rejection::NotProbed, which is not a claim about the file: it says only that
resolution never looked.
They have to be there. decide_install reads the rejection list as evidence,
and a None target over a list of conclusive rejections means "there is no
working binary", which buys an install. A truncated walk means nothing of the
sort — the binary that would have answered may be the one past the cap — so
dropping the unexamined candidates made "we stopped looking" indistinguishable
from "nothing is there" and bought an install of hundreds of megabytes that resolves identically
next launch. That is issue #1266's loop, reached through the funnel built to
close it. NotProbed maps to Abstain for the same reason ProbeTimedOut
does.
One early exit deliberately does not record NotProbed: a user-supplied
ExplicitOverride that fails the health gate returns immediately, leaving the
remaining candidates unconsulted on purpose — consulting them is the silent
substitution this module exists to prevent. That is a conclusion, not a
truncation: the evidence is conclusive for the question that was asked, and
decide_install should read it that way and repair the install. Recording
NotProbed there would flip it to Abstain and turn a repairable broken
override into a hard error.
Conclusive is not the same as repairable¶
Reading that exit as "install" and stopping there re-creates #1266's loop with a different first candidate, so the resolution carries the path out with it:
pub struct Resolution {
pub target: Option<LaunchTarget>,
pub rejected: Vec<(PathBuf, Rejection)>,
/// Set when resolution stopped early on a broken **user-supplied** override.
pub halted_on_user_override: Option<PathBuf>,
}
An install writes exactly one directory, amplihack_prefix_bin. So:
CLAUDE_BINARY_PATH=~/.npm-global/bin/claudepointing at the 500-byte placeholder is repairable — that is the case the exit was written for, and refusing it would break the demonstrated repair path.CLAUDE_BINARY_PATH=/opt/vendor/bin/claudewith a typo, a directory, or a binary that fails--versionis not. AnsweringInstallMissingspends hundreds of megabytes, re-resolves to the same broken override, fails anyway, and decides identically on the next launch. Forever.
BrokenOverride is the second case. ensure_tool_available reports which file
the user named, why it was rejected, and that installing cannot repair a path
amplihack does not write — instead of buying the install first and failing after.
The path is carried rather than inferred because Resolution.rejected is
Vec<(PathBuf, Rejection)> and drops TargetSource, so a pure decide_install
otherwise cannot tell "nothing is installed anywhere" from "the one binary the
user named is broken".
Unexamined candidates are summarised in rejection_report, not listed one
per row. They say nothing about the file, only that resolution stopped, and a
cap hit on a long $PATH would otherwise bury the rows that do say something
under dozens of identical ones.
The registry query is skipped entirely when it cannot change the decision: with
no healthy target, or with a target amplihack does not own, decide_install
reaches its answer without reading latest.
The registry side is asked twice per launch for the same reason the resolution
was — once by the advisory notice, once by the install decision — and each ask
is an npm show subprocess (~0.3 s warm on the dev VM, up to the 3 s
NPM_TIMEOUT on a slow registry). get_latest_version memoizes per package,
including a failed query. Caching the failure is deliberate: the two callers
must agree about it — one saying "unknown" while the other says "1.2.3" is the
class of disagreement this document exists to remove — and decide_install
already reads unknown as "never install", so it is the safe direction as well
as the fast one.
get_installed_version (tool_update_check/version.rs) ran npm list -g under
npm's ambient prefix — which is not necessarily the prefix amplihack installs
to, and not necessarily where the launched binary lives. It is deleted, not
merely demoted.
The design as frozen kept it for the advisory "update available" notice. Running
the fix against the dev VM showed why that is not good enough: with the healthy
2.1.238 binary selected for launch, the notice still read the ambient prefix
and printed update available: @anthropic-ai/claude-code 2.1.237 → 2.1.238 —
telling the user to upgrade to the version they were already running. A notice
that names a different binary than the one being launched is the same defect as
installing one, only quieter. maybe_print_npm_update_notice now takes its
installed-version side from launch_target::resolve(tool).target, and a grep
test enforces that no install decision consults npm's ambient prefix.
Installing claude's native binary¶
@anthropic-ai/claude-code ships a small placeholder at bin/claude.exe and
materializes the real platform-native binary through its postinstall
script (node install.cjs), which copies the binary out of a platform-specific
optionalDependencies package.
Two independent npm flags each prevent that from happening:
| Flag | Mechanism | Result |
|---|---|---|
--ignore-scripts |
install.cjs never runs |
placeholder stub survives |
--omit=optional |
the platform package is never fetched, so install.cjs resolves nothing |
placeholder stub survives |
amplihack keeps both flags on every npm invocation and materializes the binary explicitly instead, in three steps:
- Base install —
run_npm_installwith-g --prefix <prefix> --omit=optional <package> --ignore-scripts. Byte-for-byte the same invocation used for every other package. - Platform package — one explicit, exactly-named install of the single
@anthropic-ai/claude-code-<platform>package for this host, pinned to the base install's version, still with both protective flags. - Materialize — run the vendor's
install.cjsfrom the installed package directory withnode.
Step 3 needs Node. amplihack locates it with BinaryFinder::find("node"); if
Node is absent it warns and skips the step. amplihack does not download or
manage a Node runtime for this. Node is a stated prerequisite (see
Prerequisites), and a launcher that silently provisions a
language runtime is a far larger promise than this feature makes. Without Node
the placeholder survives, the health gate rejects it, and resolution falls
through to whatever else on the host is healthy.
This mirrors what install_npm_package already does for @github/copilot, and
it is why run_npm_install needed no change at all: copilot's argv is unchanged
by construction, so #585
cannot regress. Installing exactly one platform package by exact name also cannot
reproduce #585's failure mode, which was npm reifying optional dependencies for
every platform.
Success is verified by outcome, not exit code¶
install.cjs exits 0 on most of its failure paths — unsupported platform, a
release channel with no native binaries, and a failed require.resolve all
return 0. Its exit code is not a success signal and amplihack ignores it.
Success means is_materialized returns true for the resulting file: larger than
1 MiB and carrying a native executable magic number.
This is the one place in the design where validation is genuinely load-bearing rather than defense-in-depth. Everywhere else, validation is a safety net around an install that is expected to work.
is_materialized checks completeness, not authenticity. Magic bytes and a
length say "a native binary landed here", not "the right native binary landed
here". verify_node_archive_sha256, a few hundred lines away in the same file,
does check a manifest SHA-256 and fails closed — so the asymmetry is visible and
deserves an explanation rather than silence.
The two cases have different backstops. The Node archive arrives over a raw
curl to a URL with no integrity metadata attached, so if amplihack does not
check the digest, nothing does. The claude platform package arrives through
npm install, which verifies the tarball against the integrity hash in the
registry metadata and fails the install if it does not match. Re-hashing the
extracted file afterwards would be checking npm's work against a digest
amplihack would have to obtain from the same registry — a second read of the
same source of truth, not an independent one. The check that would add real
value is signature verification against a publisher key, which npm provenance
supplies and which is out of scope here.
Every failure path in the three steps warns and returns. None of them fail the launch: if materialization does not happen, the health gate rejects the stub and resolution falls through to whatever else on the host is healthy.
Platform selection¶
Returns the candidate platform packages in preference order — a slice rather than a single value, so a musl/glibc misdetection can be corrected by one bounded retry with the alternate. An empty slice means "no known package for this platform", which skips the step non-fatally, exactly as the copilot path already behaves.
Every element is a &'static str. That is a security control, not a style
choice: no runtime-derived string can reach npm's argv. The one runtime value
that does — the version read out of the installed package.json and pinned onto
the platform install — is validated against an anchored ^\d+\.\d+\.\d+$ regex
and rejected before use — anchored, digit-bounded
(^\d{1,9}\.\d{1,9}\.\d{1,9}$), and length-capped at 64 characters.
The containment check is anchored to the prefix, not to the package¶
install.cjs is canonicalized and asserted to live under the canonicalized
npm prefix root — the directory amplihack itself created — before it is
executed. A failed check warns and skips, like every other step here.
The anchor matters more than the check. The obvious spelling is to canonicalize
<prefix>/lib/node_modules/@anthropic-ai/, then assert the script path starts
with it. That is circular: every component of that boundary below the prefix is
package-derived, so if @anthropic-ai is itself a symlink to /tmp/evil, the
boundary canonicalizes to /tmp/evil, the script canonicalizes to
/tmp/evil/claude-code/install.cjs, starts_with returns true, and
amplihack executes attacker-controlled JS with the user's privileges. A path
cannot be allowed to define the boundary it is being checked against.
npm_prefix_dir() is the only link in the chain amplihack creates rather than
reads out of a package, so it is the only sound anchor. Containment is checked
against it and against nothing derived from the package name.
This is verified behaviourally, not by reading the source: a test builds a
temporary prefix, symlinks @anthropic-ai out of the tree, and asserts the
refusal. The distinction is load-bearing — the source-scanning contract test in
claude_install_contract.rs passes against the vulnerable spelling too, because
the vulnerable spelling also contains the word canonicalize. The source scan
is kept as a ratchet against the check being deleted; the behavioural test is
what proves it works.
The narrow --ignore-scripts exception described below is conditional on this
check. Without it, "under a prefix amplihack owns" is an assumption rather than
an assertion, and the exception is not defensible.
musl is detected with a zero-spawn filesystem probe for /lib/ld-musl-* and
/usr/lib/ld-musl-*, matching what the vendor's own install.cjs does when it
reads process.report.getReport().header.glibcVersionRuntime instead of
shelling out to ldd. Ambiguity defaults to glibc, and a wrong guess only
reorders the candidate list.
Child process PATH¶
augment_claude_launch_env prepends the directory of the resolved target to
the child's PATH. ~/.npm-global/bin is prepended only when the resolved
target actually lives there.
One spelling of the prefix. ~/.npm-global/bin used to be re-derived by
string literal in four places, and is_already_reachable compares by value — so
moving the prefix would have broken nothing at compile time and simply made
claude unreachable in the child, with no symptom. amplihack_prefix_bin(home)
is now the single owner and every consumer calls it, including bootstrap's
npm_prefix_dir, which returns its parent because npm's --prefix takes the
prefix.
Nothing relative reaches prepend_path. A relative resolved target's parent
is the empty path, is_already_reachable("") matches the empty $PATH
element that produced it, and prepending the empty path writes a leading colon —
the current directory at the front of the child's PATH for the agent, every
subagent and every shell-out. cheap_reject now removes relative candidates at
the resolution funnel (see "Every candidate is absolute"), so resolved is
already absolute by the time it arrives here; the absoluteness filter at this
site asserts that invariant rather than establishing it, because resolved is a
bare &Path from a caller this module does not control.
Prepending moves an entry to the front; it never adds one. The directory
is promoted only if it is already on PATH, or if it is amplihack's own npm
prefix (which amplihack owns, and which is routinely missing from a shell PATH
captured before the first install). Otherwise a
CLAUDE_BINARY_PATH=/tmp/x/claude would put /tmp/x ahead of /usr/bin for
the child and every subagent and shell-out in that session, so git, node
and sh would resolve from there too. Setting the variable already grants
control of the binary amplihack execs — that is what it is for — but it is not
consent to redirect every other binary in the session.
This matters because agents re-exec. A session launched by absolute path from
/usr/bin/claude will still resolve bare claude from its own PATH when it
spawns a subagent or shells out. Unconditionally putting an amplihack-writable
directory ahead of the system directories means any stub in that directory
shadows the working install for the entire session — and on a host where
~/.npm-global/bin is already first on the user's PATH, for every other shell
on the machine too.
When resolution finds no healthy target, nothing is prepended.
Promotion still reorders — the limit of the rule, stated¶
is_already_reachable refuses to add a directory to the child's PATH. It
does not refuse to promote one. CLAUDE_BINARY_PATH=/opt/tools/claude, where
/opt/tools is already the last entry on your PATH, moves that directory ahead
of /usr/bin for the child — so git, node, and sh resolve from there too,
for the agent and everything it spawns.
The same objection that motivates the add-refusal applies, in weaker form, to
promotion. It is accepted rather than fixed, because the reorder is what the
feature needs: an agent that shells out to bare claude has to reach the binary
amplihack selected, and that requires its directory to win the PATH search.
Two exemptions are worth naming explicitly, because they are the sharpest edges in this design:
~/.npm-global/binis exempt from the reachability check — and it is precisely the directory that holds the stub this whole document is about. Exempting it looks backwards until you note that amplihack owns that prefix, installs into it, and that it is routinely absent from aPATHcaptured before the first install or inside tmux, ssh, or a container. Without the exemption, the binary amplihack just installed would be unreachable to the session it installed it for. The stub is not the hazard here — the health gate has already rejected it, and the directory is only promoted when the resolved target lives there, which a stub never is.- The override case reorders for every binary, not just the named one. The clean fix is a single-symlink shim directory containing only the selected binary, which is a larger change than this work and is out of scope.
If you need amplihack to use a specific binary without reordering anything else,
put that binary alone in a directory and point CLAUDE_BINARY_PATH at it.
When the launch cannot proceed¶
If the binary amplihack was about to execute fails the health gate, amplihack
does not execute it. It falls back to the next healthy candidate in the
resolution order — which includes the fallback directories, not just $PATH. If there is no healthy candidate at all, the launch fails with an
error built from Resolution::rejection_report(tool, package):
error: no usable claude binary was found
/home/you/.npm-global/bin/claude incomplete install — 500-byte placeholder,
the native binary was never materialized
/home/you/.local/bin/claude --version did not complete within 3s
Remedy: install the CLI so its native binary is materialized:
npm install -g @anthropic-ai/claude-code
then run `amplihack claude` again.
The error names the real cause and states a remedy. Asserted properties, enforced by test:
- names the actual cause (incomplete install / native binary not materialized)
- states a remedy
- does not surface a bare
Exec format error (os error 8) - does not mention CPU architecture or platform mismatch
The old failure mode was to launch the stub, get Exec format error (os error 8)
from the kernel, and hand the user a message that sent them hunting for a
CPU-architecture problem that did not exist. enrich_spawn_error translates the
raw OS error through the rejection report so the message describes the thing that
actually went wrong.
Error text carries paths, rejection reasons, and the remedy — never the environment, never the full argv.
The report speaks about the tool it was asked about¶
rejection_report and enrich_spawn_error take the tool name and the npm
package as parameters. They are the error path for every tool, and they used
to hardcode claude's: a copilot user whose launch failed was told "No usable
claude binary was found" and handed npm install -g @anthropic-ai/claude-code.
There are also two headlines, because there are two failures:
- Nothing resolved. "No usable
<tool>binary was found", followed by every candidate and why each was rejected. The list is the story. - Something resolved and would not run. "amplihack selected
<path>(version X) for<tool>, and it could not be run." This is the spawn-failure path, which by construction runs only after a target was resolved — so the binary that failed is precisely the one missing from the rejection list, and the "nothing was found" headline over a list that does not contain it (and is usually empty) was simply false.
SEC-3: what reaches the terminal¶
Probe stdout is whatever an arbitrary candidate binary chose to print, and a
candidate path can itself be planted. Both go through one shared strip_ansi
(binary_finder; there must not be a second copy) before rendering.
Every renderer of these strings strips, not just rejection_report.
enrich_spawn_error formats the selected binary's path into its headline —
same provenance, same planted-filename hazard, and it runs on the failure path,
at the exact moment the user is being told what command to run next. A newline
in a path forges a plausible extra cause: line; ESC ]52 writes the user's
clipboard. The crate's SEC-WS2-02 rule is unqualified, and the two tests that
hold rejection_report to it — rejection_report_strips_ansi_from_candidate_paths
and a_newline_in_a_candidate_path_cannot_forge_a_report_row — are mirrored for
enrich_spawn_error.
Stripping removes:
- CSI —
ESC [… final byte in0x40..=0x7e - String sequences —
ESC ](OSC),ESC P(DCS),ESC X,ESC ^,ESC _, each up toBELorST. OSC 52 writes the user's clipboard and OSC 0 rewrites the window title; a CSI-only implementation let both through. - Two-byte escapes —
ESCplus one final byte, which coversESC c(RIS, a full terminal reset)
Every remaining C0 control (and DEL) becomes a single space, except tab.
A space and not a deletion, deliberately: deleting them splices 1.2.3\n4.5.6
into 1.2.34.5.6, which the semver regex reads as 1.2.34 — a version that was
never printed. The practical case is LF and CR: the report renders
"\n {path}\n {reason}\n", so a $PATH entry containing a newline could
otherwise forge convincing extra rows and make a rejected candidate read as a
healthy one.
Configuration reference¶
| Variable | Effect |
|---|---|
AMPLIHACK_CLAUDE_BINARY_PATH |
Explicit binary to use. Must pass the health gate; a broken override set in your environment is an error, not a fallback. amplihack also sets this variable internally for amplihack rustyclawd; that case warns and falls through instead. See The override is also set programmatically. |
CLAUDE_BINARY_PATH |
Same, checked second (parity with the Python implementation). |
RUSTYCLAWD_PATH |
Read only by amplihack rustyclawd. Names a preferred rustyclawd binary; if it is an executable file, configure_preferred_rustyclawd_binary sets AMPLIHACK_CLAUDE_BINARY_PATH to it and the ordinary claude launch path takes over. It is checked with is_executable_file alone, so a binary named here that then fails the health gate warns and falls through — it does not hard-fail the launch. |
There is no environment variable that disables the health gate. A binary that cannot report its version is not launched.
Verifying behavior on your own host¶
Run amplihack claude twice and compare:
amplihack claude --version
# 📦 Installing claude via npm package @anthropic-ai/claude-code...
# 📦 Installing platform binary @anthropic-ai/claude-code-linux-x64...
# 2.1.238 (Claude Code)
amplihack claude --version
# 2.1.238 (Claude Code)
The second run performs no npm work at all.
Is the native binary actually materialized?¶
The materialization target is bin/claude.exe inside the package directory.
Inspect that file, not the bin/ entry that points at it:
CC=~/.npm-global/lib/node_modules/@anthropic-ai/claude-code
ls -l "$CC/bin/claude.exe"
# -rwxr-xr-x 1 you you 338860336 ... claude.exe
file "$CC/bin/claude.exe"
# ELF 64-bit LSB pie executable, x86-64, ...
A ~500-byte claude.exe that file reports as ASCII text is the stub. Its
first line is echo "Error: claude native binary not installed." >&2. If you see
it, resolution rejects it and says so.
~/.npm-global/bin/claude is a symlink, so inspecting it without
dereferencing describes the link, not the binary — 60 bytes and lrwxrwxrwx,
which tells you nothing about materialization:
ls -l ~/.npm-global/bin/claude
# lrwxrwxrwx 1 you you 60 ... claude -> ../lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe
file ~/.npm-global/bin/claude
# symbolic link to ../lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe
To go through the symlink, dereference explicitly:
ls -lL ~/.npm-global/bin/claude # or: stat -Lc '%s' ~/.npm-global/bin/claude
file -L ~/.npm-global/bin/claude
Note that cli.js is not the materialization target and its size says
nothing about whether the native binary exists.
To confirm amplihack does not degrade a working install it does not own, record size, version, and inode before and after a launch:
stat -Lc '%i %s' /usr/bin/claude && /usr/bin/claude --version
amplihack claude --version >/dev/null
stat -Lc '%i %s' /usr/bin/claude && /usr/bin/claude --version
# identical before and after
-L dereferences, so this reports the real binary even when the path on $PATH
is a symlink — which it usually is.
Design notes¶
Why one resolver¶
Before this design there were three independent resolutions in a single launch:
the version check read whatever npm list -g reported under npm's ambient
prefix, the install wrote into ~/.npm-global via --prefix, and the exec
picked the first binary found on $PATH. On any host where those three
disagree — which is every host where npm's global prefix is not the directory
claude is served from — the version check compares a version that has nothing
to do with the binary being run, concludes an upgrade is needed, installs
somewhere that is never selected, and reaches the same conclusion on the next
launch. Forever.
Adding a health gate to that arrangement does not fix it; it just makes the useless reinstall survivable. The fix is that check, install, and launch resolve through one function.
What this replaces¶
ensure_claude_cli (crates/amplihack-utils/src/claude_cli.rs) is the
pre-existing second resolver-and-installer. The single-resolver rule means it is
deleted, and the ClaudeCliError variants that only it constructed go with it.
check_claude_version in the same module is deleted on the same rule. It had no
callers, but a dead duplicate is not inert: it was a second answer to "what
version is installed" (its own <binary> --version probe) and a second answer to
"what version is published" (its own npm view query), competing with this
module and with tool_update_check. Those two questions disagreeing is issue
1266. The surviving implementations memoize, bound the subprocess, and sanitize¶
registry output before believing it; that copy did none of the three, so it was a working example of the bug for whoever grepped for "version check" next.
With both gone, ClaudeCliError and VersionStatus are unconstructible and are
deleted too, leaving get_claude_cli_path — one line of delegation to
[resolve] — as the module's entire surface. That last line went as well, and
with it the claude_cli module: it had no callers. The one consumer that might
have used it, launcher_core, deliberately wrote its own private resolver
instead, because it needs [resolve]'s rejection report on failure and the
shim flattened that to None. A zero-caller public alias for [resolve] is a
second name for the single resolver, which is the thing this page exists to
prevent — callers use [resolve] directly.
ClaudeCliError, VersionStatus, and the claude_cli module itself were
pub, so that is a public API change. The two types, the removed variants,
and the module are enumerated in the PR body rather than here, so this page does
not carry a list that drifts.
What #585 was actually about¶
#585 was
amplihack copilot hanging while npm reified platform-specific optional
dependencies for every platform. The fix was --os/--cpu, since evolved into
--omit=optional plus an explicit single-platform follow-up install. Postinstall
scripts appear nowhere in #585's diagnosis or its remedy.
--ignore-scripts is asserted by a contract test that lives in
tests/issue_585_copilot_npm_hang.rs because that is where the npm-flag
assertions ended up, not because it was part of #585's fix. It is a generic
supply-chain protection and it is retained unchanged, for every package, on every
invocation.
The threat model, stated honestly¶
The residual delta introduced by materializing claude's native binary is exactly one named script, at an absolute path, under a prefix amplihack owns, for one exactly-matched package name — run immediately before amplihack executes that same package's native binary.
Declining to run a package's own postinstall while planning to exec its native binary seconds later is not a coherent security posture. The postinstall is strictly less privileged than what immediately follows it.
This is deliberately narrower than a script allowlist would be. An allowlist
re-enables arbitrary lifecycle scripts for a class of packages; this re-enables
one script for one exact package name. Exact string equality is enforced by
test, with negative cases for near-miss spellings such as
@anthropic-ai/claude-code-evil and claude-code.
Environment variables are not a security boundary¶
Anyone who can set AMPLIHACK_* in this process's environment can already
execute code as this user. The override variables are a usability affordance, and
the health gate is a correctness control that stops amplihack from running things
that do not work. Neither is an integrity control and neither should be described
as one.
Related documentation¶
- System Prompt Append — the other half of the launch path: how amplihack's routing contract reaches the agent
amplihack copilot— Subprocess-Safe Defaults — the sibling flag-injection design in the same launch path- Copilot CLI — the copilot install path this design mirrors
- Prerequisites — npm and Node requirements
- Security Recommendations — repository-wide security posture