From 3fd9476fbc717cd7d1c805424a092375cd5b7b7a Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 2 Sep 2026 21:07:39 -0400 Subject: [PATCH] feat(agents-vault): add directory-only symlink helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enforces the rails the vault depends on: only directories are linked (agent editing tools refuse to write through a symlinked file), a missing target is refused rather than turned into a dangling link, and adopting a populated live directory copies without clobbering. Also fixes slug sanitization in _agents_repo_slug to apply the same [^a-z0-9._-] → - mapping to the fallback (no-remote) branch, ensuring local project slugs are filesystem-safe and won't leak special chars like spaces or exclamation marks. --- functions/_agents_repo_ensure_symlink.fish | 71 ++++++++++++++++++++++ functions/_agents_repo_slug.fish | 5 +- tests/test-agents-vault.fish | 54 ++++++++++++++++ 3 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 functions/_agents_repo_ensure_symlink.fish diff --git a/functions/_agents_repo_ensure_symlink.fish b/functions/_agents_repo_ensure_symlink.fish new file mode 100644 index 0000000..82d6ac3 --- /dev/null +++ b/functions/_agents_repo_ensure_symlink.fish @@ -0,0 +1,71 @@ +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later + +# SYNOPSIS +# _agents_repo_ensure_symlink +# +# DESCRIPTION +# Idempotently makes a symlink pointing at the directory . +# +# Only directories are ever linked. The agent file-editing tools resolve a +# symlinked directory transparently but refuse to write through a +# symlinked file, so linking a file would silently break every later edit; +# a non-directory target is refused outright. +# +# A missing target is refused rather than linked, because a dangling +# memory/ symlink makes agent memory writes fail -- strictly worse than +# having no backup at all. +# +# When is an existing real directory, its contents are copied into +# without clobbering (cp -n) before the directory is replaced by +# the link, so adopting a populated live directory never overwrites the +# copy already in the vault. +# +# ARGUMENTS +# link Path that should become the symlink +# target Existing directory the link should point at +# +# EXIT STATUS +# 0 Link is correct (created, repinned, or already right) +# 1 Refused (non-directory target, missing target, non-directory link) or +# a copy, remove, or link operation failed +# +# RETURNS +# A single "→ ..." progress line on stdout when something changed; +# nothing at all when the link was already correct. +# +# EXAMPLE +# _agents_repo_ensure_symlink ~/.claude/projects/-home-u-proj/memory \ +# ~/.local/share/agent-vault/projects/host-user-proj/claude/memory +function _agents_repo_ensure_symlink --argument-names link target + test -n "$link" -a -n "$target"; or return 1 + + if test -e "$target"; and not test -d "$target" + echo "_agents_repo_ensure_symlink: refusing non-directory target: $target" >&2 + return 1 + end + if not test -d "$target" + echo "_agents_repo_ensure_symlink: target does not exist: $target" >&2 + return 1 + end + + if test -L "$link" + set -l cur (path resolve "$link") + set -l want (path resolve "$target") + test "$cur" = "$want"; and return 0 + rm -f "$link"; or return 1 + else if test -d "$link" + set -l contents (command ls -A "$link" 2>/dev/null) + if test (count $contents) -gt 0 + command cp -rn "$link/." "$target/"; or return 1 + end + rm -rf "$link"; or return 1 + else if test -e "$link" + echo "_agents_repo_ensure_symlink: refusing to replace non-directory: $link" >&2 + return 1 + end + + mkdir -p (path dirname "$link"); or return 1 + ln -s "$target" "$link"; or return 1 + echo "→ Linked "(path basename "$link")" → $target" +end diff --git a/functions/_agents_repo_slug.fish b/functions/_agents_repo_slug.fish index 6825d2a..57a4724 100644 --- a/functions/_agents_repo_slug.fish +++ b/functions/_agents_repo_slug.fish @@ -18,7 +18,8 @@ # git@git.rootiest.dev:rootiest/fish-config.git # ssh://git@git.rootiest.dev:22/rootiest/fish-config.git # -# With no remote the slug is local--<8 hex of sha256(realpath)>. +# With no remote the slug is local--<8 hex of sha256(realpath)>, +# where the basename is lowercased and mapped the same way as the remote form. # That key is machine-dependent by construction and is best-effort only; # agents-vault --adopt rebinds such an entry by hand. # @@ -63,7 +64,7 @@ function _agents_repo_slug --argument-names dir end set -l rp (path resolve "$dir") - set -l base (string lower -- (path basename "$rp")) + set -l base (string lower -- (path basename "$rp") | string replace -ra '[^a-z0-9._-]' '-') set -l digest (printf '%s' "$rp" | sha256sum | string split -f1 ' ') printf 'local-%s-%s\n' "$base" (string sub -l 8 -- "$digest") end diff --git a/tests/test-agents-vault.fish b/tests/test-agents-vault.fish index 8de37ba..c59589b 100644 --- a/tests/test-agents-vault.fish +++ b/tests/test-agents-vault.fish @@ -82,6 +82,60 @@ set -l r3 (new_repo) git -C $r3 remote add upstream https://git.rootiest.dev/rootiest/fish-config.git check "falls back to first remote" $want (_agents_repo_slug $r3) +# Slug sanitization test: special chars in fallback (local-) branch. +set -l dirt (mktemp -d); set -ga TMPDIRS $dirt +mkdir -p "$dirt/projects/My Project!" +git -C "$dirt/projects/My Project!" init -q +git -C "$dirt/projects/My Project!" config user.email t@t +git -C "$dirt/projects/My Project!" config user.name t +set -l slug_dirty (_agents_repo_slug "$dirt/projects/My Project!") +set -l has_bad_chars (string match -q '*[ !]*' -- "$slug_dirty"; and echo true; or echo false) +check "sanitizes special chars in local slug" false "$has_bad_chars" + +# ──────────────────────── ensure_symlink rails ───────────────────────── +echo "" +echo "== _agents_repo_ensure_symlink ==" + +set -l w (mktemp -d); set -ga TMPDIRS $w +mkdir -p $w/target $w/live + +# Fresh link onto an empty live parent. +_agents_repo_ensure_symlink $w/live/memory $w/target >/dev/null +check "creates the link" true (test -L $w/live/memory; and echo true; or echo false) +check "link resolves to target" (path resolve $w/target) (path resolve $w/live/memory) + +# Idempotent: a second run prints nothing. +set -l second (_agents_repo_ensure_symlink $w/live/memory $w/target) +check "idempotent, silent" "" "$second" + +# Refuses a non-directory target (a symlinked FILE breaks agent edits). +touch $w/afile +check "refuses file target" 1 (_agents_repo_ensure_symlink $w/live/f2 $w/afile 2>/dev/null; echo $status) + +# Refuses to create a dangling link when the target is missing. +check "refuses missing target" 1 (_agents_repo_ensure_symlink $w/live/f3 $w/nope 2>/dev/null; echo $status) +check "no dangling link left" false (test -L $w/live/f3; and echo true; or echo false) + +# Non-destructive adoption: content on both sides, nothing overwritten. +set -l a (mktemp -d); set -ga TMPDIRS $a +mkdir -p $a/vault $a/live/memory +echo vault-version >$a/vault/shared.md +echo vault-only >$a/vault/vaultonly.md +echo live-version >$a/live/memory/shared.md +echo live-only >$a/live/memory/liveonly.md +_agents_repo_ensure_symlink $a/live/memory $a/vault >/dev/null +check "adoption keeps vault copy" vault-version (cat $a/vault/shared.md) +check "adoption imports live-only file" live-only (cat $a/vault/liveonly.md) +check "adoption keeps vault-only file" vault-only (cat $a/vault/vaultonly.md) +check "adoption replaced dir with link" true (test -L $a/live/memory; and echo true; or echo false) + +# Repins a link that points somewhere else. +set -l p (mktemp -d); set -ga TMPDIRS $p +mkdir -p $p/one $p/two +ln -s $p/one $p/link +_agents_repo_ensure_symlink $p/link $p/two >/dev/null +check "repins a wrong link" (path resolve $p/two) (path resolve $p/link) + cleanup echo "" echo (math $TESTS_RUN - $TESTS_FAILED)"/$TESTS_RUN passed"