Skip to content

fix(installer): refuse an uninstall that cannot finish, before it destroys anything - #679

Open
filip131311 wants to merge 1 commit into
mainfrom
filip/uninstall-preflight
Open

fix(installer): refuse an uninstall that cannot finish, before it destroys anything#679
filip131311 wants to merge 1 commit into
mainfrom
filip/uninstall-preflight

Conversation

@filip131311

Copy link
Copy Markdown
Collaborator

Fixes #622.

argent uninstall prunes the workspace first and removes the package last. On the layout the issue reports — sudo npm i -g @swmansion/argent, npm prefix /usr/local, root-owned node_modules — the removal dies on EACCES after the pruning is already done.

Reproduced

On a Linux VM matching the report exactly:

◇  Pruned Argent Content ──────────────────────────────────╮
│  + Removed 15 Argent skill entries from .agents/skills    │
│  + Removed 15 Argent skills from skills-lock.json         │
╰──────────────────────────────────────────────────────────╯
●  Running: npm uninstall -g @swmansion/argent
npm ERR! code EACCES
npm ERR! syscall rename
   … 30 more lines of arborist/reify internals …
■  global uninstall failed: Error: Command failed: npm uninstall -g @swmansion/argent

workspace:  ./package.json          ← every skill + lockfile gone
package:    /usr/local/bin/argent   ← still installed, still runs

And it exits 0. A failed, destructive, half-finished uninstall reports success to the shell, so argent uninstall -y && … carries on as though the package were gone. That is not in the issue; I found it while reproducing. It reads as an oversight rather than a contract — the sibling tool-server failure in the same function throws (uninstall.ts:709), and nothing in this repo chains argent uninstall.

The fix

Ask the environment before touching anything. npm doesn't unlink the package directory, it renames it aside within its parent (@swmansion/argent@swmansion/.argent-p3dt2fHx), so one access(W_OK) on that parent decides the outcome. Measured on the reported layout: false unelevated, true as root.

The probe is biased hard toward silence. Windows (where access(W_OK) reflects only the read-only attribute and would answer "writable" for a directory you cannot touch), any non-npm global manager, a linked install, and every inconclusive stat all return "unknown", which callers treat exactly like "writable". A wrong "blocked" refuses an uninstall that works, which is worse than the bug being fixed.

It also derives the install path logically from the prefix instead of realpathing the bin. Under npm link the realpath lands in the source checkout, and probing that checkout's parent — a directory npm never renames — would produce a false block whenever the checkout happened to be read-only.

Blocking applies only where the removal is already consented to (-y, --global, or the coexistence prompt). A bare interactive run still asks, still defaults to no, and pruning-while-keeping-the-package remains a supported outcome — the prompt just says up front that the removal needs elevation.

Why not simply reorder

Removing the package first is the obvious fix and it is unsafe. The prune reads SKILLS_DIR / RULES_DIR / AGENTS_DIR off the running package at prune time (getBundledSkillNames[] on a missing dir, uninstall.ts:182; removeBundledContent → empty, :112-114). When the running copy is the global install, npm renames that tree away and the entire prune becomes a silent no-op that orphans every installed skill, rule and agent. Making the reorder safe needs snapshotting the manifests and a signature change — a much larger, riskier change than this.

Two things that fall out of the same bug

  • Exit code. A failed removal now exits 1, and a failed global no longer silently skips a local removal the user also asked for.
  • Classification without parsing. execShellCommandSync inherits stdio, so npm's stderr goes to the terminal and the caught error carries only "Command failed: …" — there is nothing to regex. A removal that fails anyway is classified by re-running the probe. That also means no stdio change was needed, so update is untouched and npm's live output is unchanged.

The remedy line

sudo HOME="$HOME" argent uninstall --global, deliberately not sudo -E. Ubuntu 25.10 ships sudo-rs, which does not implement -E at all:

$ sudo -E bash -c 'echo HOME=$HOME'
warning: preserving the entire environment is not supported, `-E` is ignored
HOME=/root

A reset HOME would make the global-scope cleanup clean root's home and silently miss the user's own ~/.claude, ~/.cursor and friends. The sudo VAR=value form assigns the variable directly and survives env_reset on both sudo implementations. Windows gets an Administrator-terminal hint instead.

Re-running argent rather than npm directly matters too: removing the package by hand strands every MCP entry, skill and rule pointing at a binary that is gone, with argent no longer around to clean them up.

Verified live, end to end

Same VM, same workspace, fix grafted into the installed package:

■  Cannot remove the global @swmansion/argent package:
   /usr/local/lib/node_modules/@swmansion is not writable by this user,
   so the removal would fail partway through.
●  Re-run it with elevated permissions: sudo HOME="$HOME" argent uninstall --global
●  Nothing was changed by this run: argent is still installed, so its
   configuration was left in place.
└  @swmansion/argent was not removed.
EXIT CODE: 1

workspace after:  all 16 skills still present   ← the whole point

Then the suggested command, from that same state:

$ sudo HOME="$HOME" argent uninstall --global -y
◆  Removed global package.
└  argent has been removed.
EXIT: 0

workspace:  ./package.json only        package: gone from PATH

Tests

uninstall-permissions.test.ts covers the probe (blocked / writable / linked-install / not-on-PATH / missing dir / non-npm manager / Windows). uninstall-preflight.test.ts covers the flow: workspace byte-identical and no package-manager call on a blocked run, its own telemetry code, the remedy text, fail-open on "unknown" and "writable", --local unaffected by an unwritable global prefix, exit 1 on failure, and permission-vs-other-failure classification.

Both halves are mutation-verified — disabling the gate fails 4 tests, reverting the exit code fails 4 more.

Two existing-suite changes were required: uninstall.test.ts:148 drives exactly the path that now exits 1 (it becomes the regression guard for the reported bug), and its probe is pinned so the suite's result cannot depend on whether the machine running it happens to have a root-owned /usr/local/lib/node_modules/@swmansion — green on nvm, red on a stock Linux box.

Also fixes the e2e global-uninstall phase, which asserted the config was gone regardless of whether the uninstall actually ran (00-install.sh:165) and would hard-fail when the uninstall correctly refuses.

Not in scope

  • update has the same unmapped-EACCES gap. Not destructive in the same way (nothing is pruned first), so it belongs in its own change.
  • The two adjacent cleanup findings from the issue are split out as uninstall --local leaves the .zed/settings.json it created and an empty .kiro/ #678. The third — dangling .claude/skills symlinks — does not reproduce on 0.18.0 (skills are real directories under .agents/skills), so I did not claim it.
  • uninstall trusts the exit code, unlike install/update which decide from disk. A post-removal isGloballyInstalled() check would catch "npm exited 0 but the package is still there". Left alone here.

…troys anything

`argent uninstall` pruned the workspace first and ran `npm uninstall -g` last.
On the common `sudo npm i -g @swmansion/argent` layout the removal dies on
EACCES, so the user was left with no configuration, a package that is still
installed, ~35 lines of raw npm stack — and an exit code of 0 saying it all
went fine.

Ask the environment before touching anything. npm does not unlink the package
directory, it renames it aside within its parent, so a single access(W_OK) on
that parent decides the outcome; measured against the reported layout it reads
false unelevated and true as root.

The probe is biased hard toward silence: Windows, any non-npm global manager, a
linked install and every inconclusive stat return "unknown", which callers treat
exactly like "writable". A wrong "blocked" would refuse an uninstall that works,
which is worse than the bug being fixed. It also derives the install path
logically from the prefix rather than realpathing the bin, so an `npm link`
checkout is never mistaken for the directory npm mutates.

Blocking only applies where the removal is already consented to (-y, --global,
or the coexistence prompt). A bare interactive run still asks, still defaults to
no, and pruning-while-keeping-the-package stays a supported outcome — it just
says up front that the removal needs elevation.

Reordering (package first, then prune) looks like the obvious fix and is not
safe: the prune reads SKILLS_DIR/RULES_DIR/AGENTS_DIR off the running package at
prune time, so removing that package first turns the whole prune into a silent
no-op that orphans every installed skill, rule and agent.

Two things fall out of the same bug:

- A failed removal now exits 1. It returned 0 while the sibling tool-server
  failure in the same function threw, so this was an inconsistency rather than a
  contract; nothing in the repo chains `argent uninstall`.
- A removal that fails anyway is classified by re-running the probe, not by
  parsing output — execShellCommandSync inherits stdio, so npm's stderr goes
  straight to the terminal and never reaches the catch. That also means no
  stdio change was needed, and `update` is untouched.

The remedy line is `sudo HOME="$HOME" argent uninstall --global`, not `sudo -E`:
Ubuntu 25.10 ships sudo-rs, which does not implement -E at all and leaves HOME
as /root, which would clean root's home and silently miss the user's own config.
The VAR=value form survives env_reset on both implementations. Windows gets an
Administrator-terminal hint instead.

Also stops the e2e global-uninstall phase from hard-failing when the uninstall
correctly refuses: it asserted the config was gone regardless of whether the
uninstall ran.

Verified end to end on a Linux VM matching the report (npm prefix /usr/local,
root-owned): the blocked run leaves all 16 staged skills in place and exits 1,
and the suggested command then removes the package and cleans the workspace.

Fixes #622
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.

argent uninstall removes workspace configs before npm uninstall -g fails unelevated, leaving a half-uninstalled install

1 participant