From 2eb1fe7269a41e08a61c2d8a9f61021eba3b1a00 Mon Sep 17 00:00:00 2001 From: Kirill Korikov Date: Tue, 22 Sep 2026 16:54:21 +0400 Subject: [PATCH 1/2] Prune and refresh managed starter files during sync --- README.md | 2 +- docs/setup.md | 13 ++ internal/app/starter_manifest.go | 323 ++++++++++++++++++++++++++ internal/app/starter_manifest_test.go | 317 +++++++++++++++++++++++++ internal/app/sync.go | 6 + memory/README.md | 5 + 6 files changed, 665 insertions(+), 1 deletion(-) create mode 100644 internal/app/starter_manifest.go create mode 100644 internal/app/starter_manifest_test.go diff --git a/README.md b/README.md index c152f26..9e4ab96 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ rem dream --apply # collapse exact-duplicate re rem search "quota preferences" # semantic search over captured memory ``` -Candidates are inert until you promote them into durable instructions — consolidation is report-first by design, because automatically rewriting memory is how agents quietly corrupt their own instructions. Design notes in [docs/memory.md](docs/memory.md). +Candidates are inert until you promote them into durable instructions — consolidation is report-first by design, because automatically rewriting memory is how agents quietly corrupt their own instructions. `sync` keeps the managed memory code layer current and removes files a release no longer ships; anything you edited yourself is reported and left alone. Design notes in [docs/memory.md](docs/memory.md). ## Roles diff --git a/docs/setup.md b/docs/setup.md index ee77c07..938f85b 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -55,6 +55,19 @@ dotagents view --no-open --secure-cookie --addr 127.0.0.1:8765 tailscale serve --bg --set-path /dotagents http://127.0.0.1:8765 ``` +## Managed starter files + +`setup` copies the starter content into the config root once. The memory code layer under `memory/hooks/` and `memory/lib/` stays managed afterwards: on every `sync`, dotagents + +- scaffolds files that are missing, +- refreshes files it wrote that you have not modified, so an upgraded CLI does not leave an old hook layer behind, +- removes files a release stopped shipping, +- and reports, without touching, anything you edited yourself. + +Ownership is tracked in `.dotagents-starter.json` at the config root (commit it alongside `dotagents.yaml`). Only content dotagents wrote is ever refreshed or removed, so a customized layer is safe. + +Everything else in the starter set — `AGENTS.md`, `dotagents.yaml`, `agents/*.md`, `skills/` — is your content: dotagents only creates those when they are missing. + ## Memory tier Choose during setup or reconfigure later: diff --git a/internal/app/starter_manifest.go b/internal/app/starter_manifest.go new file mode 100644 index 0000000..62887e1 --- /dev/null +++ b/internal/app/starter_manifest.go @@ -0,0 +1,323 @@ +package app + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + + starter "github.com/yourconscience/dotagents" +) + +// starterManifestName records which starter files this config root received +// from dotagents and what they looked like when it wrote them. It is committed +// with the config root so every machine tracks the same baseline. +const starterManifestName = ".dotagents-starter.json" + +const starterManifestVersion = 1 + +// starterManagedPrefixes are the starter paths dotagents owns as code. It +// refreshes them while they are unmodified and removes them once a release +// stops shipping them. Everything else in the starter set (AGENTS.md, +// dotagents.yaml, agents/*.md, skills/) is user content: dotagents only ever +// creates those when missing. +var starterManagedPrefixes = []string{"memory/hooks/", "memory/lib/"} + +// legacyStarterHashes lists hashes of managed starter files that earlier +// dotagents releases wrote. It bridges config roots created before the manifest +// existed: a managed file is refreshed or removed only while its content +// matches one of these hashes or the manifest baseline, so dotagents never +// overwrites or deletes content it did not write itself. +// +// The manifest takes over for every release after v0.9.0, so this table is a +// one-time bridge and does not need to grow with each release. +var legacyStarterHashes = map[string][]string{ + // Refreshed when they still match the v0.8.0 content. + "memory/hooks/common.sh": {"f13b590485d585d2d3d73a87feffc885737bf33202daf558c37d22c4c3127ca3"}, + "memory/hooks/session-end.sh": {"6d1cb6e961d3b2a4179d7efbae41de0882a9eec6a8f322acb9f76f39917fafc8"}, + "memory/hooks/stop.sh": {"6d98219c8a2e6d019e2001917a30664f04cdedb0c3eda91e7e0e2db696814f3c"}, + "memory/hooks/sync-memory-to-vault.sh": {"7d6689ad7a1cf6188d19e8cd71879f0e2a752cbe033827d6daf6928327b99d26"}, + "memory/hooks/sync-vault-to-memory.sh": {"3b9df7dd08e9a9d45977a7d0b2e44b01e7a680162127e23ad5eea0f1399e9c81"}, + "memory/lib/basic_memory.py": {"effd29bac6e8d75bc70b150e84833e3157eda6928a6f974a62ca54e3142a0900"}, + "memory/lib/sync.py": {"5bb300805c1cfc7f673150e4c8bfbcdaf65db882720033f46c34df2ec78523ea"}, + // Removed when they still match the content a past release shipped. + "memory/hooks/README-codex-omp.md": {"3cdc9cf3d1403650bb44cb117b79d2ed2bbe656f8914206860cdbdf60ccc5ead"}, + "memory/hooks/omp-memory.ts": {"3da7a801ef62593ba53f3e8a6095a1cce5cc125e1d971d8995a6b04d936eda08"}, + "memory/lib/amp_digest.py": {"a2fca5d854bc89dbde8f8aa93c6ede1f85cbc5f8b751cc36d5f745ec99d04840"}, + "memory/lib/factory_digest.py": {"deae3192f371d0f863f1d84b9aa46ce71011dd548b29c83c5d60eb6b7a015e4e"}, + "memory/lib/hermes_digest.py": {"fa6c061d85fc829689142a55e08abcc12aadaffae529907b33bb94bcd326b20b"}, +} + +type starterManifest struct { + Version int `json:"version"` + Files map[string]string `json:"files"` +} + +// starterChanges reports what a reconcile pass did, so sync can show it. +type starterChanges struct { + Scaffolded []string + Updated []string + Removed []string + KeptModified []string +} + +func (c starterChanges) empty() bool { + return len(c.Scaffolded)+len(c.Updated)+len(c.Removed)+len(c.KeptModified) == 0 +} + +func (c starterChanges) report(out io.Writer) { + if c.empty() { + return + } + fmt.Fprintf(out, "starter files: updated %d, removed %d, scaffolded %d, kept modified %d\n", + len(c.Updated), len(c.Removed), len(c.Scaffolded), len(c.KeptModified)) + printStarterList(out, "updated", c.Updated) + printStarterList(out, "removed", c.Removed) + printStarterList(out, "scaffolded", c.Scaffolded) + printStarterList(out, "kept (modified by you)", c.KeptModified) +} + +func printStarterList(out io.Writer, label string, paths []string) { + if len(paths) == 0 { + return + } + fmt.Fprintf(out, " %s: %s\n", label, strings.Join(paths, ", ")) +} + +func fileHash(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +func isManagedStarterPath(path string) bool { + for _, prefix := range starterManagedPrefixes { + if strings.HasPrefix(path, prefix) { + return true + } + } + return false +} + +func hashIn(hashes []string, want string) bool { + for _, hash := range hashes { + if hash == want { + return true + } + } + return false +} + +func starterManifestPath(root string) string { + return filepath.Join(root, starterManifestName) +} + +func loadStarterManifest(root string) (starterManifest, bool, error) { + data, err := os.ReadFile(starterManifestPath(root)) + if errors.Is(err, fs.ErrNotExist) { + return starterManifest{Version: starterManifestVersion, Files: map[string]string{}}, false, nil + } + if err != nil { + return starterManifest{}, false, fmt.Errorf("read %s: %w", starterManifestPath(root), err) + } + var manifest starterManifest + if err := json.Unmarshal(data, &manifest); err != nil { + return starterManifest{}, false, fmt.Errorf("parse %s: %w", starterManifestPath(root), err) + } + if manifest.Files == nil { + manifest.Files = map[string]string{} + } + manifest.Version = starterManifestVersion + return manifest, true, nil +} + +func saveStarterManifest(root string, manifest starterManifest) error { + manifest.Version = starterManifestVersion + if manifest.Files == nil { + manifest.Files = map[string]string{} + } + data, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return fmt.Errorf("encode %s: %w", starterManifestName, err) + } + data = append(data, '\n') + path := starterManifestPath(root) + if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, data) { + return nil + } + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + return nil +} + +// shippedStarterFiles returns the managed starter files this release ships. +func shippedStarterFiles() (map[string][]byte, error) { + files := map[string][]byte{} + err := fs.WalkDir(starter.StarterAssets, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !isManagedStarterPath(path) { + return nil + } + data, err := starter.StarterAssets.ReadFile(path) + if err != nil { + return fmt.Errorf("read starter %s: %w", path, err) + } + files[path] = data + return nil + }) + if err != nil { + return nil, err + } + return files, nil +} + +func writeStarterFile(root, path string, data []byte) error { + target := filepath.Join(root, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return fmt.Errorf("create %s: %w", filepath.Dir(target), err) + } + mode := fs.FileMode(0o644) + if strings.HasPrefix(path, "memory/hooks/") { + mode = 0o755 + } + if err := os.WriteFile(target, data, mode); err != nil { + return fmt.Errorf("write starter %s: %w", target, err) + } + return nil +} + +// reconcileStarterFiles keeps the managed starter code layer in step with the +// running release: missing files are scaffolded, files dotagents wrote and the +// user did not touch are refreshed to the shipped version, files a release +// stopped shipping are removed, and anything the user modified is reported and +// left alone. +func reconcileStarterFiles(root string, streams setupIO, confirm bool) (starterChanges, error) { + shipped, err := shippedStarterFiles() + if err != nil { + return starterChanges{}, err + } + return reconcileStarterSet(root, shipped, legacyStarterHashes, streams, confirm) +} + +func reconcileStarterSet(root string, shipped map[string][]byte, legacy map[string][]string, streams setupIO, confirm bool) (starterChanges, error) { + var changes starterChanges + manifest, _, err := loadStarterManifest(root) + if err != nil { + return changes, err + } + + next := make(map[string]string, len(shipped)) + for _, path := range sortedKeysNative(shipped) { + data := shipped[path] + shippedHash := fileHash(data) + target := filepath.Join(root, filepath.FromSlash(path)) + disk, err := os.ReadFile(target) + switch { + case errors.Is(err, fs.ErrNotExist): + if err := writeStarterFile(root, path, data); err != nil { + return changes, err + } + changes.Scaffolded = append(changes.Scaffolded, path) + next[path] = shippedHash + case err != nil: + return changes, fmt.Errorf("read %s: %w", target, err) + default: + diskHash := fileHash(disk) + recorded, tracked := manifest.Files[path] + switch { + case diskHash == shippedHash: + next[path] = shippedHash + case (tracked && diskHash == recorded) || hashIn(legacy[path], diskHash): + // Untouched content dotagents wrote in this or an earlier + // release: safe to refresh. + if err := writeStarterFile(root, path, data); err != nil { + return changes, err + } + changes.Updated = append(changes.Updated, path) + next[path] = shippedHash + default: + // Content dotagents did not write: keep the file and keep + // reporting it, so a customized layer is never clobbered. + changes.KeptModified = append(changes.KeptModified, path) + if tracked { + next[path] = recorded + } + } + } + } + + // Managed files this release no longer ships. + for _, path := range sortedKeysNative(manifest.Files) { + if _, stillShipped := shipped[path]; stillShipped { + continue + } + removed, kept, err := retireStarterFile(root, path, manifest.Files[path], legacy[path], streams, confirm) + if err != nil { + return changes, err + } + switch { + case removed: + changes.Removed = append(changes.Removed, path) + case kept: + changes.KeptModified = append(changes.KeptModified, path) + next[path] = manifest.Files[path] + } + } + for _, path := range sortedKeysNative(legacy) { + if _, stillShipped := shipped[path]; stillShipped { + continue + } + if _, tracked := manifest.Files[path]; tracked { + continue + } + removed, kept, err := retireStarterFile(root, path, "", legacy[path], streams, confirm) + if err != nil { + return changes, err + } + switch { + case removed: + changes.Removed = append(changes.Removed, path) + case kept: + changes.KeptModified = append(changes.KeptModified, path) + } + } + + manifest.Files = next + if err := saveStarterManifest(root, manifest); err != nil { + return changes, err + } + return changes, nil +} + +// retireStarterFile removes a managed file that is no longer shipped while its +// content is still one dotagents wrote, and keeps it otherwise. +func retireStarterFile(root, path, recorded string, legacy []string, streams setupIO, confirm bool) (bool, bool, error) { + target := filepath.Join(root, filepath.FromSlash(path)) + disk, err := os.ReadFile(target) + if errors.Is(err, fs.ErrNotExist) { + return false, false, nil + } + if err != nil { + return false, false, fmt.Errorf("read %s: %w", target, err) + } + diskHash := fileHash(disk) + if diskHash != recorded && !hashIn(legacy, diskHash) { + return false, true, nil + } + if confirm && !promptYesNoDefaultNo(streams, fmt.Sprintf("remove retired starter file %s?", path)) { + return false, true, nil + } + if err := os.Remove(target); err != nil { + return false, false, fmt.Errorf("remove %s: %w", path, err) + } + return true, false, nil +} diff --git a/internal/app/starter_manifest_test.go b/internal/app/starter_manifest_test.go new file mode 100644 index 0000000..f3e5d2a --- /dev/null +++ b/internal/app/starter_manifest_test.go @@ -0,0 +1,317 @@ +package app + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func testStarterSet() (map[string][]byte, map[string][]string) { + shipped := map[string][]byte{ + "memory/lib/keep.py": []byte("shipped keep v2\n"), + "memory/hooks/keep.sh": []byte("shipped hook v2\n"), + } + legacy := map[string][]string{ + "memory/lib/keep.py": {fileHash([]byte("shipped keep v1\n"))}, + "memory/lib/retired.py": {fileHash([]byte("retired v1\n"))}, + } + return shipped, legacy +} + +func readStarter(t *testing.T, root, path string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(path))) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(data) +} + +func writeStarter(t *testing.T, root, path, content string) { + t.Helper() + target := filepath.Join(root, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestReconcileStarterSetScaffoldsAndRecordsManifest(t *testing.T) { + root := t.TempDir() + shipped, legacy := testStarterSet() + + changes, err := reconcileStarterSet(root, shipped, legacy, setupIO{}, false) + if err != nil { + t.Fatal(err) + } + if len(changes.Scaffolded) != 2 || len(changes.Updated) != 0 || len(changes.Removed) != 0 || len(changes.KeptModified) != 0 { + t.Fatalf("changes = %#v, want 2 scaffolded and nothing else", changes) + } + if got := readStarter(t, root, "memory/lib/keep.py"); got != "shipped keep v2\n" { + t.Fatalf("scaffolded content = %q", got) + } + + manifest, exists, err := loadStarterManifest(root) + if err != nil || !exists { + t.Fatalf("manifest exists = %v, err = %v", exists, err) + } + if manifest.Files["memory/lib/keep.py"] != fileHash([]byte("shipped keep v2\n")) { + t.Fatalf("manifest did not record the shipped hash: %#v", manifest.Files) + } + + // A second run is a no-op and leaves the manifest bytes untouched. + before, err := os.ReadFile(starterManifestPath(root)) + if err != nil { + t.Fatal(err) + } + changes, err = reconcileStarterSet(root, shipped, legacy, setupIO{}, false) + if err != nil { + t.Fatal(err) + } + if !changes.empty() { + t.Fatalf("second run changes = %#v, want none", changes) + } + after, err := os.ReadFile(starterManifestPath(root)) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) { + t.Fatal("manifest was rewritten on an unchanged run") + } +} + +func TestReconcileStarterSetRefreshesManifestBaselineAndKeepsUserEdits(t *testing.T) { + root := t.TempDir() + shipped, legacy := testStarterSet() + + // Recorded as a previous release wrote it: unmodified, so it refreshes. + writeStarter(t, root, "memory/lib/keep.py", "shipped keep v1\n") + // Hand-edited by the user: kept and reported. + writeStarter(t, root, "memory/hooks/keep.sh", "my own hook\n") + if err := saveStarterManifest(root, starterManifest{ + Version: starterManifestVersion, + Files: map[string]string{ + "memory/lib/keep.py": fileHash([]byte("shipped keep v1\n")), + "memory/hooks/keep.sh": fileHash([]byte("shipped hook v1\n")), + }, + }); err != nil { + t.Fatal(err) + } + + changes, err := reconcileStarterSet(root, shipped, legacy, setupIO{}, false) + if err != nil { + t.Fatal(err) + } + if len(changes.Updated) != 1 || changes.Updated[0] != "memory/lib/keep.py" { + t.Fatalf("updated = %#v, want only memory/lib/keep.py", changes.Updated) + } + if len(changes.KeptModified) != 1 || changes.KeptModified[0] != "memory/hooks/keep.sh" { + t.Fatalf("kept = %#v, want only memory/hooks/keep.sh", changes.KeptModified) + } + if got := readStarter(t, root, "memory/lib/keep.py"); got != "shipped keep v2\n" { + t.Fatalf("unmodified file was not refreshed: %q", got) + } + if got := readStarter(t, root, "memory/hooks/keep.sh"); got != "my own hook\n" { + t.Fatalf("modified file was overwritten: %q", got) + } +} + +func TestReconcileStarterSetRefreshesContentOlderReleasesShipped(t *testing.T) { + root := t.TempDir() + shipped, legacy := testStarterSet() + + // No manifest yet: this root predates the manifest, and the content is + // exactly what an earlier release wrote, so the upgrade may refresh it. + writeStarter(t, root, "memory/lib/keep.py", "shipped keep v1\n") + + changes, err := reconcileStarterSet(root, shipped, legacy, setupIO{}, false) + if err != nil { + t.Fatal(err) + } + if len(changes.Updated) != 1 || changes.Updated[0] != "memory/lib/keep.py" { + t.Fatalf("updated = %#v, want the pre-manifest file", changes.Updated) + } + if got := readStarter(t, root, "memory/lib/keep.py"); got != "shipped keep v2\n" { + t.Fatalf("content = %q, want the shipped version", got) + } +} + +func TestReconcileStarterSetNeverTouchesUnrecognizedManagedContent(t *testing.T) { + root := t.TempDir() + shipped, legacy := testStarterSet() + + // Neither a recorded baseline nor a known earlier release: dotagents must + // not overwrite it, and must keep reporting it. + writeStarter(t, root, "memory/lib/keep.py", "def my_own_helper():\n pass\n") + + changes, err := reconcileStarterSet(root, shipped, legacy, setupIO{}, false) + if err != nil { + t.Fatal(err) + } + if len(changes.Updated) != 0 { + t.Fatalf("updated = %#v, want no refresh of unrecognized content", changes.Updated) + } + if len(changes.KeptModified) != 1 || changes.KeptModified[0] != "memory/lib/keep.py" { + t.Fatalf("kept = %#v, want the unrecognized file reported", changes.KeptModified) + } + if got := readStarter(t, root, "memory/lib/keep.py"); got != "def my_own_helper():\n pass\n" { + t.Fatalf("unrecognized content was overwritten: %q", got) + } + + // The manifest must not claim ownership of it. + manifest, _, err := loadStarterManifest(root) + if err != nil { + t.Fatal(err) + } + if _, ok := manifest.Files["memory/lib/keep.py"]; ok { + t.Fatal("manifest recorded a file dotagents never wrote") + } +} + +func TestReconcileStarterSetRemovesRetiredFilesOnlyWhenUnmodified(t *testing.T) { + root := t.TempDir() + shipped, legacy := testStarterSet() + + writeStarter(t, root, "memory/lib/retired.py", "retired v1\n") + writeStarter(t, root, "memory/lib/mine.py", "hand written by the user\n") + + changes, err := reconcileStarterSet(root, shipped, legacy, setupIO{}, false) + if err != nil { + t.Fatal(err) + } + if len(changes.Removed) != 1 || changes.Removed[0] != "memory/lib/retired.py" { + t.Fatalf("removed = %#v, want only the unmodified retired file", changes.Removed) + } + if _, err := os.Lstat(filepath.Join(root, "memory", "lib", "retired.py")); !os.IsNotExist(err) { + t.Fatalf("retired file still present: %v", err) + } + if got := readStarter(t, root, "memory/lib/mine.py"); got != "hand written by the user\n" { + t.Fatalf("unrelated user file was touched: %q", got) + } +} + +func TestReconcileStarterSetRemovesManifestTrackedFilesNoLongerShipped(t *testing.T) { + root := t.TempDir() + shipped, legacy := testStarterSet() + + writeStarter(t, root, "memory/hooks/old.sh", "old hook\n") + writeStarter(t, root, "memory/hooks/edited.sh", "edited old hook\n") + if err := saveStarterManifest(root, starterManifest{ + Version: starterManifestVersion, + Files: map[string]string{ + "memory/hooks/old.sh": fileHash([]byte("old hook\n")), + "memory/hooks/edited.sh": fileHash([]byte("old hook\n")), + }, + }); err != nil { + t.Fatal(err) + } + + changes, err := reconcileStarterSet(root, shipped, legacy, setupIO{}, false) + if err != nil { + t.Fatal(err) + } + if len(changes.Removed) != 1 || changes.Removed[0] != "memory/hooks/old.sh" { + t.Fatalf("removed = %#v, want only the untouched tracked file", changes.Removed) + } + if len(changes.KeptModified) != 1 || changes.KeptModified[0] != "memory/hooks/edited.sh" { + t.Fatalf("kept = %#v, want the edited tracked file", changes.KeptModified) + } + if got := readStarter(t, root, "memory/hooks/edited.sh"); got != "edited old hook\n" { + t.Fatalf("edited tracked file was removed or rewritten: %q", got) + } +} + +func TestReconcileStarterSetConfirmationKeepsRetiredFiles(t *testing.T) { + root := t.TempDir() + shipped, legacy := testStarterSet() + writeStarter(t, root, "memory/lib/retired.py", "retired v1\n") + + streams := setupIO{in: strings.NewReader("n\n"), out: &bytes.Buffer{}} + changes, err := reconcileStarterSet(root, shipped, legacy, streams, true) + if err != nil { + t.Fatal(err) + } + if len(changes.Removed) != 0 || len(changes.KeptModified) != 1 { + t.Fatalf("changes = %#v, want the declined removal kept", changes) + } + if got := readStarter(t, root, "memory/lib/retired.py"); got != "retired v1\n" { + t.Fatalf("declined removal still deleted the file: %q", got) + } +} + +func TestShippedStarterFilesAreManagedPathsOnly(t *testing.T) { + shipped, err := shippedStarterFiles() + if err != nil { + t.Fatal(err) + } + if len(shipped) == 0 { + t.Fatal("no managed starter files found in the embedded assets") + } + for path := range shipped { + if !isManagedStarterPath(path) { + t.Fatalf("unmanaged path in the managed set: %s", path) + } + } + // User content must never be treated as managed code. + for _, path := range []string{"AGENTS.md", "dotagents.yaml", "agents/architect.md", "skills/dotagents/SKILL.md"} { + if isManagedStarterPath(path) { + t.Fatalf("%s must not be managed", path) + } + } + // Files this release stopped shipping must be gone from the shipped set. + for _, path := range []string{"memory/lib/amp_digest.py", "memory/lib/factory_digest.py", "memory/lib/hermes_digest.py", "memory/hooks/omp-memory.ts"} { + if _, ok := shipped[path]; ok { + t.Fatalf("%s is still shipped", path) + } + } + // Every legacy hash must describe a managed path, so the bridge cannot be + // pointed at user content by mistake. + for path := range legacyStarterHashes { + if !isManagedStarterPath(path) { + t.Fatalf("legacy hash for unmanaged path: %s", path) + } + } +} + +func TestRunSyncReconcilesManagedStarterFiles(t *testing.T) { + home := t.TempDir() + repoRoot := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("DOTAGENTS_HOME", repoRoot) + + writeSyncTestFile(t, filepath.Join(repoRoot, "dotagents.yaml"), []byte(`version: 1 +agents: + - name: hermes + enabled: true + skill_root: ~/.hermes/skills +`)) + writeSyncTestFile(t, filepath.Join(home, ".hermes", "config.yaml"), []byte("{}\n")) + // Hand-edited managed file: sync must leave it alone. + writeSyncTestFile(t, filepath.Join(repoRoot, "memory", "hooks", "common.sh"), []byte("#!/bin/sh\n# my own tweak\n")) + + if err := runSync(runOptions{Agents: agentHermes}); err != nil { + t.Fatal(err) + } + + if got := readStarter(t, repoRoot, "memory/hooks/common.sh"); got != "#!/bin/sh\n# my own tweak\n" { + t.Fatalf("user-edited managed file was overwritten by sync: %q", got) + } + manifest, exists, err := loadStarterManifest(repoRoot) + if err != nil || !exists { + t.Fatalf("sync did not write a manifest: exists=%v err=%v", exists, err) + } + shipped, err := shippedStarterFiles() + if err != nil { + t.Fatal(err) + } + if manifest.Files["memory/lib/basic_memory.py"] != fileHash(shipped["memory/lib/basic_memory.py"]) { + t.Fatalf("manifest did not record the shipped hash for a scaffolded file: %#v", manifest.Files) + } + if _, ok := manifest.Files["memory/hooks/common.sh"]; ok { + t.Fatal("manifest claimed a file dotagents never wrote") + } +} diff --git a/internal/app/sync.go b/internal/app/sync.go index b6eb44c..c7a819d 100644 --- a/internal/app/sync.go +++ b/internal/app/sync.go @@ -70,6 +70,12 @@ func runSync(opts runOptions) error { return err } + starterChanges, err := reconcileStarterFiles(repoRoot, setupStreams(opts), opts.ConfirmRemovals) + if err != nil { + return err + } + starterChanges.report(os.Stdout) + toolInstalls, err := installMemoryTools(repoRoot) if err != nil { return err diff --git a/memory/README.md b/memory/README.md index 5132558..4fcc61a 100644 --- a/memory/README.md +++ b/memory/README.md @@ -70,3 +70,8 @@ The CLI only manages hooks exposed by the harness registry: - `tools/rem/`, `tools/knowledge-sync/` — shipped Go package sources - `tools/memsearch/` — optional memsearch parity guidance - `tests/` — dependency-free Python behavior tests + +`memory/hooks/` and `memory/lib/` are managed in a deployed config root: +`dotagents sync` refreshes them while they are unmodified, removes files a +release stopped shipping, and reports (without touching) anything you edited. +Ownership is recorded in `.dotagents-starter.json` at the config root. From f7716eedaeb337a95bad6f53eaa921e4f470f1d9 Mon Sep 17 00:00:00 2001 From: Kirill Korikov Date: Tue, 22 Sep 2026 16:58:16 +0400 Subject: [PATCH 2/2] Reject unsafe manifest paths and report to the configured stream --- internal/app/starter_manifest.go | 24 ++++++++++++++ internal/app/starter_manifest_test.go | 48 +++++++++++++++++++++++++++ internal/app/sync.go | 5 +-- 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/internal/app/starter_manifest.go b/internal/app/starter_manifest.go index 62887e1..4e9c7a3 100644 --- a/internal/app/starter_manifest.go +++ b/internal/app/starter_manifest.go @@ -105,6 +105,22 @@ func isManagedStarterPath(path string) bool { return false } +// isSafeStarterManifestPath reports whether a manifest entry is a plain relative +// path inside the managed layer. Entries that are absolute, contain a `.` or +// `..` segment, or name user content are rejected, so a hand-edited manifest +// cannot make dotagents read or remove a file outside the config root. +func isSafeStarterManifestPath(path string) bool { + if path == "" || filepath.IsAbs(path) || strings.ContainsRune(path, '\\') { + return false + } + for _, segment := range strings.Split(path, "/") { + if segment == "" || segment == "." || segment == ".." { + return false + } + } + return isManagedStarterPath(path) +} + func hashIn(hashes []string, want string) bool { for _, hash := range hashes { if hash == want { @@ -130,6 +146,14 @@ func loadStarterManifest(root string) (starterManifest, bool, error) { if err := json.Unmarshal(data, &manifest); err != nil { return starterManifest{}, false, fmt.Errorf("parse %s: %w", starterManifestPath(root), err) } + // The manifest is a committed file, so it can be edited or corrupted by + // hand: drop anything that is not a plain managed path before it is used + // for reads, writes, or removals. + for path := range manifest.Files { + if !isSafeStarterManifestPath(path) { + delete(manifest.Files, path) + } + } if manifest.Files == nil { manifest.Files = map[string]string{} } diff --git a/internal/app/starter_manifest_test.go b/internal/app/starter_manifest_test.go index f3e5d2a..54c5e02 100644 --- a/internal/app/starter_manifest_test.go +++ b/internal/app/starter_manifest_test.go @@ -315,3 +315,51 @@ agents: t.Fatal("manifest claimed a file dotagents never wrote") } } + +func TestReconcileStarterSetIgnoresUnsafeManifestEntries(t *testing.T) { + root := t.TempDir() + shipped, legacy := testStarterSet() + + // A hand-edited manifest must not be able to reach outside the config root. + outside := filepath.Join(filepath.Dir(root), "outside.txt") + if err := os.WriteFile(outside, []byte("victim\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := saveStarterManifest(root, starterManifest{ + Version: starterManifestVersion, + Files: map[string]string{ + "../outside.txt": fileHash([]byte("victim\n")), + "memory/lib/../../outside.txt": fileHash([]byte("victim\n")), + "/etc/hosts": fileHash([]byte("victim\n")), + "AGENTS.md": fileHash([]byte("victim\n")), + }, + }); err != nil { + t.Fatal(err) + } + + changes, err := reconcileStarterSet(root, shipped, legacy, setupIO{}, false) + if err != nil { + t.Fatal(err) + } + if len(changes.Removed) != 0 { + t.Fatalf("removed = %#v, want nothing removed from an unsafe manifest", changes.Removed) + } + if _, err := os.Stat(outside); err != nil { + t.Fatalf("file outside the config root was removed: %v", err) + } + + manifest, _, err := loadStarterManifest(root) + if err != nil { + t.Fatal(err) + } + for path := range manifest.Files { + if !isSafeStarterManifestPath(path) { + t.Fatalf("unsafe entry survived: %s", path) + } + } + for _, unsafe := range []string{"../outside.txt", "memory/lib/../../outside.txt", "/etc/hosts", "AGENTS.md"} { + if _, ok := manifest.Files[unsafe]; ok { + t.Fatalf("unsafe entry %q was kept", unsafe) + } + } +} diff --git a/internal/app/sync.go b/internal/app/sync.go index c7a819d..8dded1e 100644 --- a/internal/app/sync.go +++ b/internal/app/sync.go @@ -70,11 +70,12 @@ func runSync(opts runOptions) error { return err } - starterChanges, err := reconcileStarterFiles(repoRoot, setupStreams(opts), opts.ConfirmRemovals) + streams := setupStreams(opts) + starterChanges, err := reconcileStarterFiles(repoRoot, streams, opts.ConfirmRemovals) if err != nil { return err } - starterChanges.report(os.Stdout) + starterChanges.report(streams.out) toolInstalls, err := installMemoryTools(repoRoot) if err != nil {