claude-code: undo a home removal through the mesh
mesh/merge-gate pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered

A removal kept its copy but nothing could put it back without a shell on
the machine. claude_code_home_restore puts a kept copy back when nothing is
at its path and the copy matches the digests recorded at removal;
claude_code_home_removed lists what is kept.
This commit is contained in:
jochen
2026-10-07 19:44:07 +02:00
parent 03728728c3
commit a0deb90741
5 changed files with 304 additions and 23 deletions
+5 -1
View File
@@ -78,7 +78,11 @@ operator account's own `~/.claude` on those nodes):
removing it is the person's act, and this tool is that act made explicit. `why` is required; what the
mesh placed is refused (its `_unregister` owns it), and so is a symbolic link. A copy is kept first in
the module's state, `removed-from-home/<date>/<time>-<kind>-<name>/`, with a `removal.json` note, and
the removal and its reason are appended to `removed-from-home/removed.log`; the answer names the copy.
the removal and its reason are appended to `removed-from-home/removed.log`; the answer names the copy;
- `claude_code_home_removed`: every kept removal, newest first, with its `keptAt` and whether it was put back;
- `claude_code_home_restore` (`kept`): puts a removal's copy back at its path — refused when something is
there now, or when the copy is not, file by file, what its `removal.json` digests say was removed.
Logged to `removed.log` and the journal; the copy stays, marked with a `restored.json`.
A new session takes a change; a running one at `/reload-plugins`.
@@ -329,6 +329,13 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool {
}
return answer, nil
}},
stdio.Tool{Name: "claude_code_home_removed",
Description: "Every item removed from this node's home through claude_code_home_remove whose copy the module keeps, newest first: what it was, where it was, why it went, its keptAt, and whether it was put back.",
Run: func(map[string]any) (any, error) { return KeptRemovals(p) }},
stdio.Tool{Name: "claude_code_home_restore",
Description: "Undo a removal made with claude_code_home_remove: put the kept copy back at the path it was removed from. Refused when something is at that path now, or when the copy is not exactly what was removed (checked file by file against the digests its removal.json recorded). Logged as the removal was; the copy stays.",
Input: map[string]any{"kept": str("the keptAt the removal answered, as claude_code_home_removed lists it")},
Run: func(a map[string]any) (any, error) { return RestoreHome(p, strArg(a, "kept"), time.Now()) }},
)
return out
}
+180 -21
View File
@@ -15,6 +15,7 @@ import (
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
@@ -176,14 +177,22 @@ func ShowHome(p Paths, kind, name string) (map[string]any, error) {
// Removal is what the removed-items log keeps of one removal, and the copy's own note.
type Removal struct {
At string `json:"at"`
Node string `json:"node"`
Kind string `json:"kind"`
Name string `json:"name"`
Path string `json:"path"`
Why string `json:"why"`
Kept string `json:"keptAt"`
Files []string `json:"files"`
Action string `json:"action"`
At string `json:"at"`
Node string `json:"node"`
Kind string `json:"kind"`
Name string `json:"name"`
Path string `json:"path"`
Why string `json:"why,omitempty"`
Kept string `json:"keptAt"`
Files map[string]KeptFile `json:"files"`
}
// KeptFile is one file of a kept copy as it was in the home: its digest, checked before it is put back, and
// its mode.
type KeptFile struct {
Digest string `json:"sha256"`
Mode string `json:"mode"`
}
// RemoveHome removes one item the person made in the home, on their word and for the reason given: it keeps
@@ -229,7 +238,7 @@ func RemoveHome(p Paths, kind, name, why string, now time.Time) (map[string]any,
if !folder {
copyRoot = filepath.Dir(copyRoot)
}
names := make([]string, 0, len(files))
names := map[string]KeptFile{}
for in, content := range files {
to := filepath.Join(copyRoot, filepath.FromSlash(in))
if err := os.MkdirAll(filepath.Dir(to), 0o700); err != nil {
@@ -241,9 +250,9 @@ func RemoveHome(p Paths, kind, name, why string, now time.Time) (map[string]any,
if back, err := os.ReadFile(to); err != nil || !bytes.Equal(back, []byte(content)) {
return nil, fmt.Errorf("the copy of %s does not read back the same, nothing removed", in)
}
names = append(names, in)
names[in] = KeptFile{Digest: digest(content), Mode: fmt.Sprintf("%04o", modes[in])}
}
r := Removal{At: now.Format(time.RFC3339), Node: p.Node, Kind: kind, Name: name, Path: full, Why: why,
r := Removal{Action: "removed", At: now.Format(time.RFC3339), Node: p.Node, Kind: kind, Name: name, Path: full, Why: why,
Kept: filepath.Join(kept, filepath.FromSlash(rel)), Files: names}
note, _ := indented(r)
if err := os.WriteFile(filepath.Join(kept, "removal.json"), note, 0o600); err != nil {
@@ -269,16 +278,7 @@ func RemoveHome(p Paths, kind, name, why string, now time.Time) (map[string]any,
return nil, fmt.Errorf("%s could not be removed (the copy is at %s): %w", full, kept, err)
}
line, _ := json.Marshal(r)
logged := true
if f, err := os.OpenFile(p.removedLog(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600); err == nil {
_, werr := f.Write(append(line, '\n'))
if cerr := f.Close(); werr != nil || cerr != nil {
logged = false
}
} else {
logged = false
}
logged := logRemoval(p, r)
say("removed %s from the home on the person's word, kept at %s: %s", full, r.Kept, why)
answer := map[string]any{"removed": full, "kind": kind, "name": name, "why": why, "keptAt": r.Kept,
"undo": "copy " + r.Kept + " back to " + full, "log": p.removedLog()}
@@ -287,3 +287,162 @@ func RemoveHome(p Paths, kind, name, why string, now time.Time) (map[string]any,
}
return answer, nil
}
// logRemoval appends one line to the removed-items log, and answers whether it could.
func logRemoval(p Paths, r Removal) bool {
line, _ := json.Marshal(r)
f, err := os.OpenFile(p.removedLog(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return false
}
_, werr := f.Write(append(line, '\n'))
return f.Close() == nil && werr == nil
}
// ---- putting a removal back ---------------------------------------------------------------------------
// Kept is one removal whose copy the module keeps, as the list of removals shows it.
type Kept struct {
Removal
Restored string `json:"restored,omitempty"`
}
// KeptRemovals lists every removal the module keeps a copy of, newest first, and whether it was put back.
func KeptRemovals(p Paths) ([]Kept, error) {
root := filepath.Join(p.State, RemovedDir)
notes, _ := filepath.Glob(filepath.Join(root, "*", "*", "removal.json"))
out := []Kept{}
for _, note := range notes {
var k Kept
if !readJSON(note, &k.Removal) {
continue
}
var back Removal
if readJSON(filepath.Join(filepath.Dir(note), "restored.json"), &back) {
k.Restored = back.At
}
out = append(out, k)
}
sort.Slice(out, func(i, j int) bool {
if out[i].At != out[j].At {
return out[i].At > out[j].At
}
return out[i].Kept > out[j].Kept
})
return out, nil
}
// removalFolder finds the folder of one removal from what a removal answered as keptAt (or the folder
// itself): <state>/removed-from-home/<date>/<removal>, and nothing outside it.
func removalFolder(p Paths, kept string) (string, error) {
root := filepath.Join(p.State, RemovedDir)
rel, err := filepath.Rel(root, filepath.Clean(kept))
parts := strings.Split(filepath.ToSlash(rel), "/")
if err != nil || kept == "" || len(parts) < 2 || parts[0] == ".." || parts[0] == "." {
return "", fmt.Errorf("%q is not a copy this module kept: give the keptAt a removal answered, as claude_code_home_removed lists it", kept)
}
return filepath.Join(root, parts[0], parts[1]), nil
}
// RestoreHome puts a removed item back where it was: only when nothing is at that path now, and only when the
// kept copy is exactly what was removed, file by file against the digests its note recorded. Logged as the
// removal was; the copy stays, marked as put back.
func RestoreHome(p Paths, kept string, now time.Time) (map[string]any, error) {
folder, err := removalFolder(p, kept)
if err != nil {
return nil, err
}
var r Removal
if !readJSON(filepath.Join(folder, "removal.json"), &r) {
return nil, fmt.Errorf("%s holds no readable removal.json: nothing to check the copy against, nothing restored", folder)
}
// Where it goes is worked out again from its kind and name, never taken from the note alone.
rel, isFolder, err := HomeItemPath(r.Kind, r.Name)
if err != nil {
return nil, err
}
dir := filepath.Join(p.Home, ".claude")
full := filepath.Join(dir, filepath.FromSlash(rel))
if r.Path != full || r.Kept != filepath.Join(folder, filepath.FromSlash(rel)) {
return nil, fmt.Errorf("%s/removal.json does not describe the copy beside it, nothing restored", folder)
}
// The copy's own integrity: the same files, each with the digest recorded when it was removed.
files, _, err := readItem(folder, rel, isFolder)
if err != nil {
return nil, fmt.Errorf("the kept copy cannot be read, nothing restored: %w", err)
}
if len(files) != len(r.Files) {
return nil, fmt.Errorf("the kept copy holds %d file(s), its note %d: nothing restored", len(files), len(r.Files))
}
for in, content := range files {
want, ok := r.Files[in]
if !ok || digest(content) != want.Digest {
return nil, fmt.Errorf("the kept copy's %s is not what was removed: nothing restored", in)
}
}
// Nothing may be in the way: whatever is there now is the person's, or the mesh's.
if why := linkedParent(dir, rel+"/x"); why != "" {
return nil, errors.New(why + ", nothing restored")
}
if _, err := os.Lstat(full); err == nil {
return nil, fmt.Errorf("%s exists now, nothing restored: look at it with claude_code_home_show first", full)
} else if !os.IsNotExist(err) {
return nil, err
}
base := full
if !isFolder {
base = filepath.Dir(full)
}
written := []string{}
undo := func() {
for _, w := range written {
_ = os.Remove(w)
}
if isFolder {
_ = os.RemoveAll(full)
}
}
for in, content := range files {
to := filepath.Join(base, filepath.FromSlash(in))
if !isFolder {
to = full
}
mode := os.FileMode(0o644)
var m uint32
if _, err := fmt.Sscanf(r.Files[in].Mode, "%o", &m); err == nil && m != 0 {
mode = os.FileMode(m).Perm()
}
if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil {
undo()
return nil, fmt.Errorf("%s could not be restored: %w", full, err)
}
f, err := os.OpenFile(to, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
undo()
return nil, fmt.Errorf("%s could not be restored: %w", full, err)
}
_, werr := f.WriteString(content)
if cerr := f.Close(); werr != nil || cerr != nil {
undo()
return nil, fmt.Errorf("%s could not be restored", to)
}
_ = os.Chmod(to, mode) // the umask may have taken bits the file had
written = append(written, to)
}
back := Removal{Action: "restored", At: now.UTC().Format(time.RFC3339), Node: p.Node, Kind: r.Kind, Name: r.Name,
Path: full, Why: "undoes the removal of " + r.At + ": " + r.Why, Kept: r.Kept, Files: r.Files}
note, _ := indented(back)
_ = os.WriteFile(filepath.Join(folder, "restored.json"), note, 0o600)
logged := logRemoval(p, back)
say("restored %s from %s, undoing its removal of %s", full, r.Kept, r.At)
answer := map[string]any{"restored": full, "kind": r.Kind, "name": r.Name, "from": r.Kept, "removedAt": r.At,
"log": p.removedLog()}
if !logged {
answer["log"] = "the log could not be written; the restore is noted in " + filepath.Join(folder, "restored.json")
}
return answer, nil
}
@@ -208,3 +208,112 @@ func TestASymbolicLinkIsLeftAlone(t *testing.T) {
t.Fatal("what the link points at was removed")
}
}
func TestARemovalIsListedAndPutBack(t *testing.T) {
p, _ := node(t, "laptop")
dir := filepath.Join(p.Home, ".claude")
_ = os.MkdirAll(filepath.Join(dir, "rules"), 0o755)
_ = os.MkdirAll(filepath.Join(dir, "skills", "old", "scripts"), 0o755)
writeFile(t, filepath.Join(dir, "rules", "hal-era.md"), "# old rule\n")
_ = os.Chmod(filepath.Join(dir, "rules", "hal-era.md"), 0o644)
writeFile(t, filepath.Join(dir, "skills", "old", "SKILL.md"), "---\nname: old\n---\n")
writeFile(t, filepath.Join(dir, "skills", "old", "scripts", "a.sh"), "#!/bin/sh\n")
_ = os.Chmod(filepath.Join(dir, "skills", "old", "scripts", "a.sh"), 0o755)
rule, err := RemoveHome(p, KindInstructions, "hal-era", "HAL is retired", at)
if err != nil {
t.Fatal(err)
}
skill, err := RemoveHome(p, KindSkill, "old", "superseded", at.Add(time.Minute))
if err != nil {
t.Fatal(err)
}
kept, err := KeptRemovals(p)
if err != nil || len(kept) != 2 || kept[0].Name != "old" || kept[1].Kept != rule["keptAt"] || kept[1].Restored != "" {
t.Fatalf("%+v %v", kept, err)
}
back, err := RestoreHome(p, rule["keptAt"].(string), at.Add(time.Hour))
if err != nil {
t.Fatal(err)
}
full := filepath.Join(dir, "rules", "hal-era.md")
if raw, _ := os.ReadFile(full); string(raw) != "# old rule\n" || back["restored"] != full {
t.Fatalf("%v: %q", back, raw)
}
if info, _ := os.Stat(full); info.Mode().Perm() != 0o644 {
t.Fatalf("restored as %v, removed as 0644", info.Mode().Perm())
}
if _, err := RestoreHome(p, skill["keptAt"].(string), at.Add(time.Hour)); err != nil {
t.Fatal(err)
}
if info, err := os.Stat(filepath.Join(dir, "skills", "old", "scripts", "a.sh")); err != nil || info.Mode().Perm()&0o100 == 0 {
t.Fatalf("the skill's script came back without its executable bit: %v", err)
}
// Listed as put back; logged with the removals.
kept, _ = KeptRemovals(p)
for _, k := range kept {
if k.Restored == "" {
t.Errorf("%s is not listed as put back", k.Kept)
}
}
raw, _ := os.ReadFile(p.removedLog())
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
var last Removal
if len(lines) != 4 || json.Unmarshal([]byte(lines[2]), &last) != nil || last.Action != "restored" || last.Path != full {
t.Fatalf("%d lines, %+v", len(lines), last)
}
// Something at the path now: refused, and left as it is.
if _, err := RestoreHome(p, rule["keptAt"].(string), at); err == nil {
t.Fatal("restored over what is there now")
}
if raw, _ := os.ReadFile(full); string(raw) != "# old rule\n" {
t.Fatal("what was there was changed")
}
}
func TestACopyThatChangedIsNotPutBack(t *testing.T) {
p, _ := node(t, "laptop")
dir := filepath.Join(p.Home, ".claude")
_ = os.MkdirAll(filepath.Join(dir, "rules"), 0o755)
_ = os.MkdirAll(filepath.Join(dir, "skills", "s"), 0o755)
writeFile(t, filepath.Join(dir, "rules", "r.md"), "original")
writeFile(t, filepath.Join(dir, "skills", "s", "SKILL.md"), "x")
rule, _ := RemoveHome(p, KindInstructions, "r", "why", at)
skill, _ := RemoveHome(p, KindSkill, "s", "why", at)
writeFile(t, rule["keptAt"].(string), "tampered")
if _, err := RestoreHome(p, rule["keptAt"].(string), at); err == nil {
t.Fatal("a changed copy was put back")
}
if _, err := os.Stat(filepath.Join(dir, "rules", "r.md")); !os.IsNotExist(err) {
t.Fatal("something was written from a changed copy")
}
// A file added to a kept skill: not what was removed.
writeFile(t, filepath.Join(skill["keptAt"].(string), "extra.md"), "x")
if _, err := RestoreHome(p, skill["keptAt"].(string), at); err == nil {
t.Fatal("a copy with an extra file was put back")
}
if _, err := os.Stat(filepath.Join(dir, "skills", "s")); !os.IsNotExist(err) {
t.Fatal("the skill's folder was made from a changed copy")
}
// A note pointing elsewhere than its own kind and name: refused.
note := filepath.Join(filepath.Dir(filepath.Dir(rule["keptAt"].(string))), "removal.json")
var r Removal
_ = readJSON(note, &r)
writeFile(t, rule["keptAt"].(string), "original")
r.Path = "/etc/passwd"
raw, _ := json.Marshal(r)
writeFile(t, note, string(raw))
if _, err := RestoreHome(p, rule["keptAt"].(string), at); err == nil {
t.Fatal("a note naming another path was followed")
}
// Nothing outside the module's kept copies.
for _, kept := range []string{"", "/etc/passwd", filepath.Join(p.State, RemovedDir), filepath.Join(p.State, RemovedDir, "..", "config.json")} {
if _, err := RestoreHome(p, kept, at); err == nil {
t.Errorf("%q was taken as a kept copy", kept)
}
}
}
+3 -1
View File
@@ -56,7 +56,9 @@
"claude_code_config_status",
"claude_code_config_import",
"claude_code_home_show",
"claude_code_home_remove"
"claude_code_home_remove",
"claude_code_home_removed",
"claude_code_home_restore"
],
"data": {
"own": [