Skip to content

fix(install): make the installer's account of itself true (0.8.2, PR-1)#86

Merged
temrjan merged 1 commit into
mainfrom
fix/0.8.2-installer-honesty
Jul 22, 2026
Merged

fix(install): make the installer's account of itself true (0.8.2, PR-1)#86
temrjan merged 1 commit into
mainfrom
fix/0.8.2-installer-honesty

Conversation

@temrjan

@temrjan temrjan commented Jul 22, 2026

Copy link
Copy Markdown
Member

Where this came from

0.8.1 shipped, and then two things happened: a fresh session installed the wallet from the public page with no prior context (a real first-user probe), and a read-only audit went through install.sh looking for anything that would force an 0.8.3.

The install worked — one command, exit 0, no intervention, the cosign wall gone. What both found was a class of defect underneath: the script asserts things it does not do. Nothing here breaks an installation; all of it misinforms the person running one.

Detection: two states, keyed on whether cosign runs

The probe now decides, and command -v is gone.

It answers a different question — "is there a file by that name" — and answers it differently per shell: for a present-but-non-executable file /bin/sh says no, bash says yes. We ship as curl … | sh, so keying on it meant the most common breakage (a download that lost its +x) printed "cosign is not installed" about a file sitting in the user's PATH, and the branch written for exactly that case was unreachable on the delivery path. That is what the tester hit: a truncated 18 MB cosign, reported as absent.

There is also no exit code that separates "missing" from "broken". Measured on executables the kernel refuses:

content, with +x exit
random garbage 2
truncated ELF 126
near-text 127

Any single-code rule misfiles one of them. Both states lead to the same action anyway — install by digest, provenance unchecked — so the script stops pretending to distinguish them and names both possibilities in one message. works + verify fails still refuses, fail-closed, untouched.

Three unguarded writes

Every network call was checked; the filesystem writes were not.

  • Unwritable profile killed the run after the shim was fetched, chmod +x'd and installed — a failed install that had in fact succeeded. Now non-fatal: shim stays, manual PATH line printed, exit 0.
  • Failed chmod left ~/.local/bin/rustok.rustok-tmp behind — the exact leftover the temp-file-then-atomic-mv pattern exists to prevent. Now cleaned up.
  • Failed mkdir surfaced as a raw shell error after the image had been pulled. Now fails in the installer's own voice.

⚠️ Worth flagging for review: the first version of this fix was itself a lie. if ! { …; } >>"$prof" does not reliably catch a failed redirection, so it printed "added … to PATH" over a file the shell had just refused to open. Success is now read back from the file's contents. The test caught it.

Also true now where it was not

  • PATH block gated on the profile's contents, not this session's PATH — a directory exported by a parent shell is gone in the next terminal, and gating on it would cost that user rustok everywhere.
  • fish / csh get their own syntax and an untouched config, instead of a ~/.profile they never read plus "open a new shell", which was false for them.
  • Replacing an existing rustok is announced (the tester's stale symlink was overwritten silently).
  • Header example URL uses the tag namespace that exists: wallet-tui-vX.Y.Z → 200, the bare vX.Y.Z it showed → 404.
  • Refusal links an absolute URL — over a pipe there is no local docs/ tree to open.
  • Unset HOME fails in our voice, not the shell's.

Tests

Ten new cases, every one proven red before the fix, and all under sh — the delivery path, not the shell that happened to make the old bug visible. That distinction is the point: the previous suite tested the no-+x case under bash, which is precisely why it encoded the defect as expected behaviour and stayed green.

Filesystem failures are now injectable at all (break_tool), which they were not — the reason three unguarded writes passed a green suite.

Mutation controls, each verified to apply before being trusted:

mutation result
restore 3-state detector with separate messages sh-unrunnable case → red
break the profile-content gate its case → red
remove the replacement notice its case → red
change the mkdir wording its case → red

Honest note: reintroducing command -v in front of the probe leaves the suite green — under two states with one message it changes nothing observable. What guards the regression is the unified message, not the absence of that call.

Gates

All nine locally: ruff · mypy · pytest 188 · shellcheck ×2 · shim 101/101 · install 28/28 · bash -n + sh -n · hadolint. shellcheck/hadolint containerised.

Scope note: no version bump here — that lands with PR-2 so the six version points move once. The RPC-URL-in-podman inspect leak is text-only in 0.8.2 by the Captain's ratification and is recorded as deferred in the spec, with its test location.

A first-time install of 0.8.1 by someone with no context, plus a read-only
audit, found the same class of defect throughout: the script asserts things it
does not do. None of it broke an install; all of it misinformed the person
running one.

Detection is now two states, keyed on whether cosign RUNS.
`command -v` answers a different question and answers it differently per shell:
for a present-but-non-executable file /bin/sh says no while bash says yes. Since
we ship as `curl … | sh`, keying on it meant the most common breakage — a
download that lost its +x bit — printed "cosign is not installed" about a file
sitting in the user's PATH, and the branch written for that case was unreachable
on the delivery path. Nor is there a code that separates "missing" from
"broken": an executable the kernel refuses exits 2, 126 or 127 depending on its
bytes (measured: garbage 2, truncated ELF 126, near-text 127). Both states lead
to the same action, so the script stops pretending to tell them apart and names
both possibilities in one message.

Three filesystem writes were unguarded, unlike every network call around them:
- an unwritable profile killed the run AFTER the shim was fetched, made
  executable and installed — reporting a failed install that had succeeded. It
  is now non-fatal: the shim stays, the manual PATH line is printed, exit 0.
- a failed chmod left ~/.local/bin/rustok.rustok-tmp behind, the exact leftover
  the temp-file-then-atomic-mv dance exists to prevent. It now cleans up.
- a failed mkdir surfaced as a raw shell error after the image had already been
  pulled. It now fails in the installer's own voice.

Success is now reported from the outcome, not from the write's status: a failed
redirection on a compound command does not reliably return non-zero, and the
first version of this fix cheerfully printed "added … to PATH" over a file the
shell had just refused to open. The script reads the file back instead.

Also true now where it was not:
- the PATH block is gated on the profile's contents, not on this session's PATH
  (a directory exported by a parent shell is gone in the next terminal, and
  skipping the edit for that user would cost them `rustok` everywhere);
- fish and csh get their own syntax and an untouched config, instead of a
  ~/.profile they never read plus "open a new shell", which was simply false;
- replacing an existing `rustok` is announced;
- the header's example URL uses the tag namespace that exists (`wallet-tui-vX.Y.Z`
  resolves; the bare `vX.Y.Z` it showed 404s);
- the refusal links an absolute URL — over a pipe there is no local docs/ tree;
- an unset HOME fails in our voice, not the shell's.

Tests: ten new cases, every one proven red first, all under `sh` — the delivery
path, not the shell that happened to make the old bug visible. Mutation controls
included: restoring the three-state detector with separate messages turns the
sh-unrunnable case red; breaking the profile-content gate, the replacement
notice or the mkdir wording each turns its own case red. Filesystem failures are
now injectable at all, which they were not — the reason three unguarded writes
shipped through a green suite.

Spec: .claude/specs/2026-07-22-0.8.2-honesty-batch.md. Version bump and the shim
fixes land in PR-2; the RPC-in-`podman inspect` leak is text-only in 0.8.2 by
ratification and recorded there as deferred.
@temrjan
temrjan merged commit 4e2474d into main Jul 22, 2026
9 checks passed
@temrjan
temrjan deleted the fix/0.8.2-installer-honesty branch July 22, 2026 11:08
temrjan added a commit that referenced this pull request Jul 22, 2026
> Stacked on #86 — base retargets to `main` once that merges.

## The one that matters: the purge gate ran after the teardown

`uninstall --purge-keys` through a pipe or an agent **refused correctly
— after** deregistering the clients, stopping the wallets, deleting the
secrets, removing the PATH block, deleting the shim itself and wiping
the config dir.

The keys survived. The installation did not, and the user was left
without the command that manages them. A gate that fires after the
damage is not a gate.

The tty gate and the volume listing now run **first**, before a single
mutation. Deletion itself stays last — the data-safe order is unchanged,
only the gate moved. Acceptance is the tester's
`probe-03-purgekeys-order.sh`: **4/4**, was 1/4.

## Teardown no longer reports work it failed to do

| before | now |
|---|---|
| `stop … \|\| true` + unconditional "stopped N running wallet(s)" |
counts what actually stopped, names what did not — and a wallet still
running is exactly what makes a later volume removal fail |
| `volume rm … && echo` — a refusal printed **nothing** and exited **0**
| each surviving volume named, command fails |
| docker password files claimed removal whether any existed or the
removal worked | reports what actually happened |

## The RPC guarantee is stated as it is

`--help` promised a keyed URL *"stays out of argv, agent configs and
`podman inspect`"*. That holds **only after `connect`** has stored it as
a secret. Before that — and on every docker run — the value is forwarded
by name and the engine records it in container metadata, readable by
anything running as the user.

All three cases are now spelled out, and `start` **warns at the moment
it takes the exposed road**. Closing the leak in code is deferred to the
next release by ratification; the text stops overclaiming today.

⚠️ Landmine worth a reviewer's eye: `wallet_run_args` emits argv on
**stdout**, so the warning had to go to stderr — a `say` there would
have become an argument to `podman run`. There is a test asserting both
the warning text and that argv still carries only `-e NAME`.

## Quoting, at the point the words are built

The password mount was a word-split fragment carrying a real filesystem
path. A `$HOME` or `$XDG_CONFIG_HOME` containing a space tore it into
three argv tokens — corrupting `init`, `start`, and **permanently
freezing a broken argv into the client's MCP config** on `connect`. The
helper is gone; the three call sites append quoted words.

The **101 byte-exact argv tests passed unchanged** through this
refactor, which is what made it safe to do at all.

## Diagnostics stopped writing

`doctor` and `status` created the engine config as a side effect of
being run. They no longer persist anything, and `doctor` no longer calls
the engine "pinned" when nothing has pinned it. Both now distinguish
*"no wallet has ever been created"* from *"one exists but is not
running"* and point a fresh install at `rustok init` — previously six
green `ok:` lines told a new user nothing about the next step.

`--help` also stops advertising a "leftovers" sweep; what doctor really
checks is stray containers, so that is what it says.

## `update` says what it is not doing

One stderr line at pull time: it pulls by **mutable tag** and does
**not** re-verify the signature — the installer is the verified path.
Same "tell the truth at the moment the guarantee drops" pattern as the
start warning. Deep verify-on-update stays in the backlog.

## Versions

0.8.2 across the six points, plus the `SKILL.md` body tags and the
install-URL tag. The shim's own axis goes **0.3.0 → 0.3.1** — its
commands changed behaviour, and that axis has no guard test, so it is
called out explicitly.

## Tests

13 new cases, **each proven red first**. Two pre-existing tests were
updated because the contract deliberately changed, and I am flagging
that rather than burying it:

- engine pinning is asserted through a **state-changing** command
instead of `status` (diagnostics no longer pin);
- the empty-machine `status` text is the new wording.

Also fixed two of my own test defects mid-flight: an assertion that
checked for a prefix already present in earlier output (it could not
fail), and a `--help` grep that missed the overclaim because the promise
is split across a line break.

## Gates

All nine locally: ruff · mypy · pytest 188 · shellcheck ×2 · shim
**114/114** · install **28/28** · `bash -n` + `sh -n` · hadolint.

Deferred and recorded in the spec with test locations: the RPC leak in
code, per-subcommand `--help` (12 commands, plus `connect` swallowing
`--help` as an agent name), and an early exit for `update` with no
registrations — that last one needs the client-discovery loop duplicated
before the pull, which is surgery, not a tweak.
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