--- id: "007" title: npm Wrapper Binary Resolution and Diagnosability status: complete amends: specs/003-npm-distribution.md blocked_by: [] blocks: [] --- # npm Wrapper Binary Resolution and Diagnosability ## Overview On 2026-07-31 Windows Defender quarantined the installed `agent-mail` binary as `Program:Win32/Wacapew.A!ml` — a machine-learning heuristic that fires on unsigned PyInstaller executables. The CLI had been installed and verified hours earlier; the detection named two live processes, so the removal happened under a running agent. Every subsequent invocation returned: ```json { "error": "agent-mail binary not found", "hint": "Try reinstalling: npm install -g agent-mail" } ``` Three separate defects made that eleven-word answer expensive. The message does not say what the wrapper looked for or what it found, so it is indistinguishable from a failed install, a partial download, an unsupported platform, or a version mismatch — and the actual cause, a third party deleting the file after a successful install, is not a possibility a reader would even consider. The hint names the **unscoped** package, which npm rejected for this project and which resolves to somebody else's package if it resolves at all. And the resolution itself takes the first directory entry matching `agent-mail-*`, so an upgrade that leaves the previous binary behind silently runs whichever name sorts first. That last one is the familiar shape here: not an error, but a plausible wrong answer. It is the same family as specs 004 and 005, and it is the one to fix even though nobody has reported it yet, because a wrapper that runs the wrong version reports the wrong schema from `describe` and there is no signal anywhere that it happened. Ships as `0.1.8`. > **Completion rule:** This spec is not complete until all acceptance criteria are verified through the testing approach below, including the quarantine scenario reproduced by deleting the binary from a real installed package and reading the resulting error. Build-only verification is insufficient. The agent must iterate until verification passes. ## Goals - A missing or unusable binary produces an error that names what was expected, what was present, and what to do — enough to diagnose without reading the wrapper source. - Removal-after-install (antivirus quarantine, manual cleanup, a failed upgrade) is a named, recognizable cause rather than something the reader must infer. - The wrapper runs the binary matching its own version, or refuses — never a different version chosen by directory order. - Every install hint names the package that actually exists. ## Requirements ### Functional Requirements - **FR1**: The wrapper resolves the binary whose name matches its own package version and the host platform. When that file is present it is used and nothing else is considered. - **FR2**: When the exact-version binary is absent but exactly one platform-compatible `agent-mail-*` binary is present, the wrapper uses it. This keeps a hand-placed local build working for development and parity testing. - **FR3**: When the exact-version binary is absent and more than one platform-compatible candidate is present, the wrapper exits non-zero with a JSON error naming the expected binary and every candidate found, rather than choosing one. - **FR4**: When no platform-compatible binary is present, the wrapper exits non-zero with a JSON error carrying the expected binary name, the directory searched, the entries actually found there, and remediation guidance that includes removal-after-install as a likely cause. - **FR5**: Every install or reinstall hint the wrapper or the postinstall script emits names the published scoped package. - **FR6**: On a platform with no supported binary, the wrapper reports the unsupported platform and the `pipx install agent-mail-cli` fallback rather than a generic not-found error. ### Non-Functional Requirements - **NFR1**: All wrapper errors remain single JSON documents on stderr with an `error` key and a non-zero exit, matching spec 001 NFR2. - **NFR2**: The wrapper stays dependency-free Node, runs on Node 16+, and adds no install-time or runtime network calls (spec 003 TC2, TC4). - **NFR3**: Resolution adds no measurable startup cost — one directory read, as today. ### Technical Constraints - **TC1**: The expected binary name is `agent-mail--` where `` is the wrapper's own `package.json` version and `` comes from the platform map already used by the postinstall script. Wrapper and postinstall derive it from one shared source so they cannot disagree about what to download versus what to run. - **TC2**: Platform compatibility means the filename ends with `.exe` on `win32` and does not end with `.exe` elsewhere — the existing rule, retained. - **TC3**: The error payload's key set is `error`, `expected`, `searched`, `found`, and `hint`. `found` lists binary candidates only, never the wrapper's own `.js` files. - **TC4**: No change to the download logic, the release-asset naming, the publish workflow, or the smoke workflow. - **TC5**: The fix ships as version `0.1.8` through the existing GitHub Release and npm channels. ### Requirement Traceability | Requirement | Acceptance Criteria | |---|---| | FR1 | AC1 | | FR2 | AC2 | | FR3 | AC3 | | FR4 | AC4, AC5 | | FR5 | AC6 | | FR6 | AC7 | | NFR1 | AC4, AC8 | | NFR2 | AC8, AC9 | | TC1 | AC1, AC9 | | TC2 | AC2, AC7 | | TC3 | AC4 | | TC4 | AC9 | | TC5 | AC10 | ## Reported Shape The quarantine case — an install that succeeded, then lost its binary: ```json { "error": "agent-mail binary not found", "expected": "agent-mail-0.1.8-windows-x64.exe", "searched": "C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@juanjofuchs\\agent-mail\\bin", "found": [], "hint": "The package is installed but its binary is missing. Antivirus quarantine is a known cause on Windows (the binary is an unsigned PyInstaller executable and can be flagged as Program:Win32/Wacapew.A!ml) — check your antivirus quarantine or exclusion list. Otherwise reinstall: npm install -g @juanjofuchs/agent-mail" } ``` The ambiguous case — an upgrade that left the old binary behind: ```json { "error": "Cannot choose an agent-mail binary", "expected": "agent-mail-0.1.8-linux-x64", "searched": "/usr/lib/node_modules/@juanjofuchs/agent-mail/bin", "found": ["agent-mail-0.1.6-linux-x64", "agent-mail-0.1.7-linux-x64"], "hint": "The binary matching this wrapper's version is missing and several others are present, so no choice is safe. Remove the stale binaries or reinstall: npm install -g @juanjofuchs/agent-mail" } ``` ## Key Decisions ### The version match is the answer, not a preference Taking the first `agent-mail-*` entry made the running version a function of directory order. It works right up until an upgrade leaves the previous binary in place, and then `describe` reports an older schema while the package claims the new version — a wrong answer with no error and no signal. Matching the wrapper's own version makes the pairing explicit, and it costs nothing: the postinstall script already writes exactly that name. ### One unambiguous fallback, and no more Removing the fallback entirely would break the development and parity path, where a hand-built binary sits in `npm/bin` under whatever name the local build produced. Keeping the fallback *unconditional* would preserve the coin flip. One compatible candidate is unambiguous, so it is safe to run; two or more is a genuine choice, and a tool in this family should refuse a choice it cannot make correctly rather than pick and hope. ### The error carries the evidence, because the reader is usually an agent `describe` is this project's product wedge on the theory that agents learn tools at runtime from structured output. The failure path deserves the same treatment. `expected` plus `found` plus `searched` is the whole diagnosis: an empty `found` against a real directory means the binary was removed after install, a populated `found` with the wrong versions means an upgrade problem, and a missing directory means a broken install. Compressing all three into "binary not found" throws away the distinction and forces whoever hits it to read the wrapper source — which, on the incident that produced this spec, is exactly what happened. ### Quarantine is named explicitly, not left to inference Antivirus removal is invisible by construction: the install succeeded, the package is present, and the file is simply gone with no log the user is likely to read. Nobody reasons their way to "a third party deleted this" from a not-found error, so the hint names it, names the actual detection string, and points at the quarantine list. This is knowledge the tool has and the reader does not — which is the only kind of thing a hint should carry. ### Wrapper and postinstall share one name derivation They already encode the same format in two places. They agreed by luck; a version-aware wrapper makes disagreement a hard failure instead of a cosmetic inconsistency, so the derivation moves to one exported helper the postinstall script already provides. ### Spec 003 is amended, not superseded Spec 003 remains the npm distribution contract. This spec owns the resolution and error behavior and folds the resulting rules back into 003's implementation tasks. ## Implementation Tasks - [x] Derive the expected binary name in the wrapper from the shared platform map and the wrapper's own version. - [x] Resolve exact version first, then a single compatible candidate, and refuse an ambiguous set. - [x] Emit the documented error payload for the not-found, ambiguous, and unsupported-platform cases. - [x] Replace every unscoped install hint with the published scoped package name. - [x] Add hermetic tests that run the wrapper against fixture bin directories for each resolution case. - [x] Amend spec 003 with the resolution rule and its acceptance criteria. - [x] Bump the package version to `0.1.8`; the npm package version is set by the publish workflow. - [x] Add the `0.1.8` CHANGELOG entry. - [x] Release `v0.1.8` after JJ's approval and verify the published artifacts. ## Verification Record - [x] `ruff check src/ tests/` passed; `pytest` passed 63 tests with the PyInstaller binary and npm wrapper both available, 0 skipped. - [x] The ten new wrapper tests run node against throwaway package trees, so the repo's real `npm/bin` — which the parity suite depends on — is never mutated. - [x] The parity suite exercised the FR2 fallback for real: `npm/package.json` reads `0.1.4` locally while its `bin/` holds a `0.1.7` binary, so every npm-runner test resolved through the single-compatible-candidate path. - [x] `python -m build` produced the `0.1.8` wheel and sdist; `twine check` passed on both. - [x] CI passed on `main` for Python 3.10-3.13 plus lint and build (run `30702521828`). - [x] Release workflow `30702560796` succeeded; GitHub Release `v0.1.8` carries the wheel, the sdist, and the four platform binaries. PyPI and npm both serve `0.1.8` (npm publish run `30702625599`). - [x] `npm-smoke.yml` passed on Windows x64, Linux x64, macOS x64, and macOS arm64. The first dispatch failed with `ETARGET` 31 seconds after publish — registry propagation, not a code failure — and passed on re-run once `npm view --prefer-online` confirmed `0.1.8` was being served. - [x] AC10 verified against the globally installed `0.1.8`: removing the binary from the real package directory reproduced the documented payload with `expected` = `agent-mail-0.1.8-windows-x64.exe`, `found` = `[]`, the resolved `searched` path, and the quarantine hint. Restoring the file returned the CLI to service. ## Acceptance Criteria Each criterion names its validation method. `integration` criteria are automated tests owning their own fixture bin directories; `manual` criteria need live published artifacts. ### Resolution - [x] **AC1** (`integration`): With the exact-version binary present alongside binaries for other versions, the wrapper runs the exact-version one. - [x] **AC2** (`integration`): With no exact-version binary and exactly one platform-compatible binary present, the wrapper runs it. - [x] **AC3** (`integration`): With no exact-version binary and two platform-compatible binaries present, the wrapper exits non-zero with a JSON error listing both in `found` and runs neither. - [x] **AC7** (`integration`): A binary for the wrong platform — a non-`.exe` file on Windows, or an `.exe` elsewhere — is not treated as a candidate. ### Diagnosability - [x] **AC4** (`integration`): With an empty bin directory, the wrapper exits non-zero with a JSON error on stderr carrying `error`, `expected`, `searched`, `found`, and `hint`, where `expected` is the version-and-platform-correct filename and `found` is empty. - [x] **AC5** (`integration`): The not-found hint names antivirus quarantine as a cause and gives the scoped reinstall command. - [x] **AC6** (`integration`): No error path emitted by the wrapper or the postinstall script contains the unscoped package name as an install target. - [x] **AC8** (`integration`): Every wrapper error path writes exactly one JSON document to stderr, writes nothing to stdout, and exits non-zero. ### Regression and release - [x] **AC9** (`integration`): `node --check` passes on both npm scripts, the existing parity and behavior suites still pass through the `npm` runner, and the publish and smoke workflows are unchanged. - [x] **AC10** (`manual`): GitHub Release `v0.1.8` carries the six artifacts required by spec 002 AC3, npm serves `0.1.8`, the smoke workflow passes on all four platforms, and deleting the binary from the freshly installed package reproduces the documented not-found payload. ## Testing Approach ### Validation steps 1. Copy the wrapper into a temporary directory with a fixture `bin/` and run it with `node`. The wrapper resolves relative to its own location, so a copy is a complete, hermetic harness — no mutation of the repo's real `npm/bin`, which the parity suite depends on. 2. Assert the error paths by running the wrapper end-to-end: they never spawn, so the JSON is fully observable. 3. Assert *which* binary gets chosen through the exported resolver rather than by spawning. A fixture file is not executable, and a spawn failure would not reveal which candidate was selected — the exact question these criteria are about. 4. Reproduce the quarantine case on a real install by removing the binary, then restore it. ### Test cases | bin/ contents (on Windows) | Expected | |---|---| | `agent-mail--windows-x64.exe` plus an older `.exe` | Runs the self-version binary | | One `.exe` of a different version | Runs it | | Two `.exe` files, neither self-version | Error, both listed in `found`, nothing run | | Empty | Not-found error with `found: []` and the quarantine hint | | Only a non-`.exe` file | Not-found error; the Linux binary is not a candidate | ### Release validation ```bash gh run watch gh release view v0.1.8 npm view @juanjofuchs/agent-mail@0.1.8 version ``` A same-day `npx` check needs `npx --min-release-age=0`, per spec 004. ## Out of Scope - Code signing the release binaries, and any Microsoft false-positive submission. Both are real follow-ups to the same incident but neither is a change to this repository's code; track them outside this spec. - Changing the distribution format away from PyInstaller binaries, or making the npm package fall back to PyPI at runtime. - Verifying binary integrity by checksum or signature at install or run time. - Re-downloading a missing binary automatically at runtime. A CLI that silently reaches the network mid-invocation is a worse failure than one that explains itself, and it would fight the antivirus rather than report it. - Detecting or querying antivirus state programmatically. - Removing stale binaries during upgrade — npm owns the package directory's lifecycle. - Any change to the Python CLI's behavior, the mailbox, or `watch`. ## References - spec 001: [`specs/001-agent-mail-cli.md`](001-agent-mail-cli.md) — the JSON-error contract these payloads follow. - spec 002: [`specs/002-packaging.md`](002-packaging.md) — produces the binaries and their release names. - spec 003: [`specs/003-npm-distribution.md`](003-npm-distribution.md) — the npm distribution contract this spec amends. - spec 005: [`specs/005-storage-transparency.md`](005-storage-transparency.md) — the sibling "report what this invocation actually resolved" fix, applied there to the mailbox path. - Implementation: [`npm/bin/agent-mail.js`](../npm/bin/agent-mail.js), [`npm/scripts/postinstall.js`](../npm/scripts/postinstall.js). - Incident: Windows Defender detection `Program:Win32/Wacapew.A!ml` on `agent-mail-0.1.7-windows-x64.exe`, 2026-07-31 19:09 local, recorded in a private planning note outside this repo.