fix(install): never follow a symlink when replacing the Hermes plugin

`rm -rf "$dest/"` follows a symlink and empties its target. basename ignores a
trailing slash, so HERMES_PLUGIN_DIR=".../agency-agents-router/" passed every
check, and a symlinked destination (our own --link install, or a user's folder)
had its target's contents deleted. #903's ownership check didn't cover it: the
check reads through the link and the rm still follows it.

- Strip all trailing slashes from the destination before any check.
- A symlinked destination is removed with `rm -f` (the link itself), never
  `rm -rf`.
- test-install-hermes-destination.sh: two new cases (trailing slash on a
  symlink to a folder with a matching plugin.yaml; re-install over a --link
  install). Both fail before this commit and pass after.

Found by a destructive-path matrix (25 scenarios, throwaway container, fake HOME
with sentinel files): main fails 10 (deletes unrelated dirs, files and symlink
targets at the plugin path); the series without this commit fails 2 (H8/H9);
with it, 25/25.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Sitarzewski
2026-09-29 10:56:23 -05:00
co-authored by Claude Opus 5.5
parent d2d2101c45
commit d801ff90ad
2 changed files with 36 additions and 1 deletions
+9 -1
View File
@@ -1390,6 +1390,9 @@ install_hermes() {
local src="$INTEGRATIONS/hermes/agency-agents-router"
local hermes_home; hermes_home="$(hermes_home_dir)"
local dest; dest="$(resolve_dest hermes "${hermes_home}/plugins/agency-agents-router")"
# Strip trailing slashes first: basename ignores them, but `rm -rf link/`
# follows a symlink and empties its target instead of removing the link.
while [[ "$dest" == */ && "$dest" != "/" ]]; do dest="${dest%/}"; done
# HERMES_PLUGIN_DIR is ambiguous: its name invites setting it to the plugins
# parent (~/.hermes/plugins) rather than the full plugin path. Always target
# the agency-agents-router subdir so we never rm -rf a shared plugins dir that
@@ -1417,7 +1420,12 @@ install_hermes() {
return 1
fi
fi
rm -rf "$dest"
# A symlink (e.g. from an earlier --link install) is replaced, never followed.
if [[ -L "$dest" ]]; then
rm -f -- "$dest"
else
rm -rf -- "$dest"
fi
if $USE_LINK; then
ln -s "$src" "$dest"
else
@@ -57,3 +57,30 @@ HOME="$fresh_home" bash "$repo/scripts/install.sh" --no-interactive --tool herme
exit 1
}
echo 'PASS: fresh Hermes installation works normally'
# A trailing slash must not turn "replace the plugin" into "empty whatever a
# symlink points at": `rm -rf link/` follows the link and deletes the target's
# contents. Seen on real installs via HERMES_PLUGIN_DIR=".../agency-agents-router/".
link_home="$tmp/link-home"
link_plugin="$link_home/.hermes/plugins/agency-agents-router"
personal="$tmp/personal"
mkdir -p "$personal" "$(dirname "$link_plugin")"
printf 'name: agency-agents-router\n' > "$personal/plugin.yaml"
printf 'personal content\n' > "$personal/personal.txt"
ln -s "$personal" "$link_plugin"
HOME="$link_home" HERMES_PLUGIN_DIR="$link_plugin/" bash "$repo/scripts/install.sh" --no-interactive --tool hermes --no-convert > "$tmp/slash-output" 2>&1 || true
[[ -f "$personal/personal.txt" ]] || {
echo 'FAIL: a trailing slash deleted the contents of a symlinked directory' >&2
exit 1
}
echo 'PASS: a trailing slash on a symlinked destination leaves its target intact'
relink_home="$tmp/relink-home"
HOME="$relink_home" bash "$repo/scripts/install.sh" --no-interactive --tool hermes --no-convert --link > "$tmp/link-output" 2>&1
HOME="$relink_home" HERMES_PLUGIN_DIR="$relink_home/.hermes/plugins/agency-agents-router/" \
bash "$repo/scripts/install.sh" --no-interactive --tool hermes --no-convert > "$tmp/relink-output" 2>&1
[[ -f "$repo/integrations/hermes/agency-agents-router/plugin.yaml" ]] || {
echo 'FAIL: re-installing over a --link install deleted the plugin source in the clone' >&2
exit 1
}
echo 'PASS: re-installing over a --link install keeps the clone intact'