diff --git a/zz_error_paths_test.go b/zz_error_paths_test.go new file mode 100644 index 0000000..3d36ecb --- /dev/null +++ b/zz_error_paths_test.go @@ -0,0 +1,441 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//go:build !no_skillinject +// +build !no_skillinject + +// Targets the hardest-to-reach disk error branches by leveraging +// permission denial (chmod 0500), un-stat-able paths, and config-file +// targets that are not regular files. +// +// - mergePluginAllowList: parent-dir mkdir failure surfaces error +// (config path under a non-writable ancestor that's a regular file) +// - mergePluginAllowList: writeFileAtomic backup failure (bakPath +// points into a non-writable dir while live file is in a writable +// sibling — we wedge by chmod 0500 on the parent) +// - mergePluginAllowList: read plugin config returns non-IsNotExist +// error (config path is a directory, not a file) +// - mergePluginAllowList: writeFileAtomic for the live config swap +// fails (parent chmod 0500) +// - mergePluginAllowList: post-swap verifyOnDiskResult rollback path +// (covered by directly invoking verifyOnDiskResult + writeFileAtomic +// in a unit-level integration that mirrors the rollback machinery) +// - writeFile: mkdir failure (parent is a regular file) +// - writeMarker: ReadFile non-IsNotExist error (path is a dir) +// - SetEnabled: mkdir failure when parent is a file +// - writeCache: mkdir failure when parent is a file +// - get(): http.NewRequestWithContext fails on a control-character URL +// +// We skip permission-based tests when running as root since chmod can't +// restrict root. + +package skillinject + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func skipIfRoot(t *testing.T) { + t.Helper() + if os.Geteuid() == 0 { + t.Skip("permission-based test requires non-root euid") + } +} + +// writeFile: parent is a REGULAR FILE → MkdirAll returns ENOTDIR. We +// pick a path whose dirname is an existing regular file, which makes +// os.MkdirAll fail with "not a directory" without needing chmod tricks. +func TestWriteFile_MkdirFailsWhenParentIsFile(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // Create a regular file, then ask writeFile to put a child under it. + parentAsFile := filepath.Join(dir, "iam-a-file") + if err := os.WriteFile(parentAsFile, []byte("x"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + err := writeFile(filepath.Join(parentAsFile, "child"), []byte("body")) + if err == nil { + t.Fatal("expected mkdir error when parent is a regular file") + } +} + +// writeMarker: passing a path whose dirname is a regular file causes +// the post-read mkdir to fail (and is well-defined cross-platform). +func TestWriteMarker_MkdirFails(t *testing.T) { + t.Parallel() + dir := t.TempDir() + parentAsFile := filepath.Join(dir, "x") + if err := os.WriteFile(parentAsFile, []byte(""), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + // Path is under a regular file → ReadFile returns ENOTDIR (a + // non-IsNotExist error), so writeMarker returns immediately. + err := writeMarker(filepath.Join(parentAsFile, "AGENT.md"), "ref", "abc123") + if err == nil { + t.Fatal("expected error reading or making path under a file") + } +} + +// mergePluginAllowList: parent of config path is a regular file → +// mkdir fails inside merge. Note: the function first tries to ReadFile +// the configPath, which will return ENOTDIR (path traversal hit a +// non-dir); since that's not IsNotExist, merge returns +// "read plugin config" error early. +func TestMergePluginAllowList_ReadPluginConfigErrors(t *testing.T) { + t.Parallel() + dir := t.TempDir() + parentAsFile := filepath.Join(dir, "iam-a-file") + if err := os.WriteFile(parentAsFile, []byte("x"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + cfgPath := filepath.Join(parentAsFile, "openclaw.json") + err := mergePluginAllowList(cfgPath, "plugins.allow", "plugins.entries", "pilot") + if err == nil { + t.Fatal("expected error reading config path under a file") + } + if !strings.Contains(err.Error(), "read plugin config") { + t.Errorf("error not wrapped as expected: %v", err) + } +} + +// mergePluginAllowList: config exists but parent dir becomes read-only +// AFTER the read. Hard to do without monkey patching; instead, we +// exercise the writeFileAtomic-fails branch by making the temp file +// path uncreatable. We chmod the parent dir to 0o500 (read+exec only) +// — file writes inside will fail, the backup write fails first. +func TestMergePluginAllowList_BackupWriteFailsWhenDirReadOnly(t *testing.T) { + t.Parallel() + skipIfRoot(t) + if runtime.GOOS == "windows" { + t.Skip("chmod-based test not applicable on windows") + } + dir := t.TempDir() + // Seed config inside `dir`. + cfgPath := filepath.Join(dir, "openclaw.json") + if err := os.WriteFile(cfgPath, []byte(`{"x": 1}`), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + // Make the directory read-only (no write/create). + if err := os.Chmod(dir, 0o500); err != nil { + t.Fatalf("chmod: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(dir, 0o700) }) + + err := mergePluginAllowList(cfgPath, "plugins.allow", "plugins.entries", "pilot") + if err == nil { + t.Fatal("expected merge error when parent dir is read-only") + } + // Should mention either backup or write — both are reasonable failure points. + msg := err.Error() + if !strings.Contains(msg, "backup") && !strings.Contains(msg, "write plugin config") { + t.Errorf("error not about backup/write: %v", err) + } +} + +// SetEnabled: parent of ~/.pilot is a regular file → mkdir fails. +func TestSetEnabled_MkdirFails(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // Put a regular file at the slot where the ~/.pilot dir would live. + pilotSlot := filepath.Join(dir, ".pilot") + if err := os.WriteFile(pilotSlot, []byte("nope"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + // Now SetEnabled wants to create ~/.pilot/ but it's a file. + // os.MkdirAll on an existing regular file returns an error. + err := SetEnabled(dir, false) + if err == nil { + t.Fatal("expected SetEnabled to fail when ~/.pilot is a regular file") + } +} + +// writeCache: parent of the cache slot is a regular file → mkdir fails +// inside writeCache. Verifies the err-return branch. +func TestWriteCache_MkdirFails(t *testing.T) { + t.Parallel() + home := t.TempDir() + // Block the cache root by putting a regular file at .pilot. + if err := os.WriteFile(filepath.Join(home, ".pilot"), []byte(""), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + err := writeCache(home, "some/relative.json", []byte("x")) + if err == nil { + t.Fatal("expected mkdir error in writeCache") + } +} + +// get(): URL with embedded control character makes +// http.NewRequestWithContext fail before any IO happens. Covers the +// `if err != nil { return nil, err }` branch in get(). +func TestGet_NewRequestFails(t *testing.T) { + t.Parallel() + f := newFetcher(Config{}) + // A control-char (0x7f DEL) in the URL is rejected by net/url + // inside NewRequestWithContext. + _, err := f.get(context.Background(), "http://example.com/\x7fbad") + if err == nil { + t.Fatal("expected NewRequestWithContext error") + } +} + +// reconcilePluginAllowList: when merge fails on a malformed config, +// already covered. Here we cover the symmetric case where merge fails +// AT the disk level (parent read-only) and the error surfaces through +// the Outcome. This exercises the line that records the err.Error() +// into Outcome.Err. +func TestReconcilePluginAllowList_DiskErrorSurfacesAsOutcome(t *testing.T) { + t.Parallel() + skipIfRoot(t) + if runtime.GOOS == "windows" { + t.Skip("chmod-based test not applicable on windows") + } + home := t.TempDir() + openclawDir := filepath.Join(home, ".openclaw") + if err := os.MkdirAll(openclawDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + cfgPath := filepath.Join(openclawDir, "openclaw.json") + // Seed in a Drifted state so the merge path is taken. + if err := os.WriteFile(cfgPath, []byte(`{"unrelated": true}`), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + // Lock the dir so the backup + swap fail. + if err := os.Chmod(openclawDir, 0o500); err != nil { + t.Fatalf("chmod: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(openclawDir, 0o700) }) + + p := &ManifestPlugin{ + ID: "pilot", + AllowList: &ManifestPluginAllowList{ + ConfigPath: "~/.openclaw/openclaw.json", + AllowListJsonPath: "plugins.allow", + EntriesJsonPath: "plugins.entries", + }, + } + out := reconcilePluginAllowList(p, home) + if out.Action != ActionError { + t.Errorf("action = %v; want error", out.Action) + } + if out.Err == "" { + t.Errorf("expected non-empty Err: %+v", out) + } +} + +// Direct unit on rollback machinery: writeFileAtomic + verifyOnDiskResult +// together — this exercises the same combo used in the rollback branch +// of mergePluginAllowList (writeFileAtomic from in-memory originalBytes). +// We seed a valid file, run verify (passes), then overwrite with garbage +// and verify (fails), then writeFileAtomic the original bytes back +// (recovers the file), then verify (passes). Mirrors the rollback step +// in mergePluginAllowList without monkey-patching. +func TestRollbackMachinery_RestoreFromInMemorySnapshot(t *testing.T) { + t.Parallel() + dir := t.TempDir() + cfgPath := filepath.Join(dir, "openclaw.json") + originalBytes := []byte(`{ + "plugins": { + "allow": ["pilot"], + "entries": { "pilot": { "enabled": true } } + } +} +`) + if err := os.WriteFile(cfgPath, originalBytes, 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + originalTopKeys := map[string]struct{}{"plugins": {}} + // Step 1: verify passes. + if err := verifyOnDiskResult(cfgPath, originalTopKeys, "pilot", "plugins.allow", "plugins.entries"); err != nil { + t.Fatalf("baseline verify: %v", err) + } + // Step 2: corrupt; verify fails. + if err := os.WriteFile(cfgPath, []byte("scrambled garbage"), 0o644); err != nil { + t.Fatalf("corrupt: %v", err) + } + if err := verifyOnDiskResult(cfgPath, originalTopKeys, "pilot", "plugins.allow", "plugins.entries"); err == nil { + t.Fatal("verify should fail on corrupted bytes") + } + // Step 3: rollback via writeFileAtomic (same call merge() uses). + if err := writeFileAtomic(cfgPath, originalBytes, 0o644); err != nil { + t.Fatalf("rollback writeFileAtomic: %v", err) + } + // Step 4: post-rollback verify passes again. + if err := verifyOnDiskResult(cfgPath, originalTopKeys, "pilot", "plugins.allow", "plugins.entries"); err != nil { + t.Errorf("post-rollback verify: %v", err) + } + got, _ := os.ReadFile(cfgPath) + if string(got) != string(originalBytes) { + t.Errorf("rollback file content differs from original") + } +} + +// Round-out reconcilePluginFiles: helper-fetch returns 404 → error +// outcome (one of two cov0 lines in reconcilePluginFiles, the other +// being the unreachable writeFile failure). +func TestReconcilePluginFiles_FetchErrorSurfaces(t *testing.T) { + t.Parallel() + home := t.TempDir() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Always 404 — every fetchRepoFile call errors out. + http.NotFound(w, r) + })) + defer srv.Close() + f := newFetcher(Config{RepoBaseURL: srv.URL + "/"}) + + p := &ManifestPlugin{ + ID: "pilot", + InstallPath: "~/.openclaw/extensions/pilot", + Files: []ManifestPluginFile{ + {Name: "openclaw.plugin.json", Src: "plugin/openclaw.plugin.json"}, + }, + } + outs := reconcilePluginFiles(f, context.Background(), p, home) + if len(outs) != 1 { + t.Fatalf("expected 1 outcome; got %d", len(outs)) + } + if outs[0].Action != ActionError { + t.Errorf("action = %v; want error on 404 fetch", outs[0].Action) + } + if outs[0].Err == "" { + t.Errorf("expected non-empty Err") + } +} + +// Tick: heartbeat template fetch fails (404) → marker outcome carries +// ActionError, doesn't block other tools. +func TestTick_HeartbeatTemplateFetchFails(t *testing.T) { + t.Parallel() + home := t.TempDir() + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/") + switch path { + case "inject-manifest.json": + _, _ = w.Write([]byte(`{"version":1,"entrypoint":"pilotctl","tools":[ + {"name":"claude-code","rootDir":"~/.claude","skillsDir":"~/.claude/skills", + "heartbeatPath":"~/.claude/CLAUDE.md","heartbeatTemplate":"heartbeats/MISSING.md"} +]}`)) + case "skills/pilotctl/SKILL.md": + _, _ = w.Write([]byte("# skill body")) + default: + http.NotFound(w, r) + } + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + cfg := Config{ + Home: home, + ManifestURL: srv.URL + "/inject-manifest.json", + RepoBaseURL: srv.URL + "/", + } + rep, err := Tick(context.Background(), cfg) + if err != nil { + t.Fatalf("Tick: %v", err) + } + // Should have one marker error outcome (the heartbeat fetch failed). + var markerErr bool + for _, o := range rep.Outcomes { + if o.Kind == KindMarker && o.Action == ActionError { + markerErr = true + } + } + if !markerErr { + t.Errorf("expected marker error outcome on failed heartbeat fetch: %+v", rep.Outcomes) + } +} + +// Tick: entrypoint SKILL.md fetch fails → Tick returns an error. +func TestTick_EntrypointSkillFetchFails(t *testing.T) { + t.Parallel() + home := t.TempDir() + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "inject-manifest.json") { + _, _ = w.Write([]byte(`{"version":1,"entrypoint":"pilotctl","tools":[ + {"name":"claude-code","rootDir":"~/.claude","skillsDir":"~/.claude/skills"} +]}`)) + return + } + // Every other fetch (incl. skills/pilotctl/SKILL.md) 404s. + http.NotFound(w, r) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + cfg := Config{ + Home: home, + ManifestURL: srv.URL + "/inject-manifest.json", + RepoBaseURL: srv.URL + "/", + } + _, err := Tick(context.Background(), cfg) + if err == nil { + t.Fatal("expected Tick error when entrypoint SKILL.md 404s") + } + if !strings.Contains(err.Error(), "fetch entrypoint") { + t.Errorf("error not wrapped: %v", err) + } +} + +// Tick with a manifest that points heartbeatTemplate at a template +// containing an Execute-time error (referencing an unknown field). +// Covers the renderHeartbeat error branch inside Tick. +func TestTick_HeartbeatTemplateRenderError(t *testing.T) { + t.Parallel() + home := t.TempDir() + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/") + switch path { + case "inject-manifest.json": + _, _ = w.Write([]byte(`{"version":1,"entrypoint":"pilotctl","tools":[ + {"name":"claude-code","rootDir":"~/.claude","skillsDir":"~/.claude/skills", + "heartbeatPath":"~/.claude/CLAUDE.md","heartbeatTemplate":"heartbeats/bad.md"} +]}`)) + case "skills/pilotctl/SKILL.md": + _, _ = w.Write([]byte("# skill")) + case "heartbeats/bad.md": + // Field doesn't exist on heartbeatVars → Execute error. + _, _ = w.Write([]byte(`{{.NonExistent}}`)) + default: + http.NotFound(w, r) + } + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + cfg := Config{ + Home: home, + ManifestURL: srv.URL + "/inject-manifest.json", + RepoBaseURL: srv.URL + "/", + } + rep, err := Tick(context.Background(), cfg) + if err != nil { + t.Fatalf("Tick: %v", err) + } + var sawErr bool + for _, o := range rep.Outcomes { + if o.Kind == KindMarker && o.Action == ActionError { + sawErr = true + } + } + if !sawErr { + t.Errorf("expected marker render error outcome: %+v", rep.Outcomes) + } +} diff --git a/zz_extra_branches_test.go b/zz_extra_branches_test.go new file mode 100644 index 0000000..0c5c027 --- /dev/null +++ b/zz_extra_branches_test.go @@ -0,0 +1,365 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//go:build !no_skillinject +// +build !no_skillinject + +// Sweep over small uncovered branches across: +// - manifest.go: fetchManifest validation errors (version, missing +// entrypoint, no tools); fetcher repoBase auto-slash suffix; +// renderHeartbeat execute-time error; writeCache mkdir error. +// - state.go: classifyPluginFile happy paths (identical & drifted). +// - skillinject.go: logTick nil-report + error paths; reconcile +// branch where allowlist merge surfaces an error via Outcome. +// - skillinject.go: Tick disabled-by-config short-circuit returns +// a Disabled report without any disk/network IO. +// - skillinject.go: skillTargetPath for "flat" SkillNaming. +// - reconcile.go: writeFile + writeHelper mkdir/rename happy paths +// are already covered, so cover the noop-when-identical-and-mode-ok +// and write the rare case where helper exists with wrong mode but +// identical content (state=Drifted, mode rewritten). +// - config.go: SetEnabled handles a pre-existing config that's +// unparseable garbage (json.Unmarshal error is swallowed; new +// write succeeds). +// - plugin_allowlist.go: walkObject with empty path; verifyOnDiskResult +// when file disappeared between write and read-back. +// +// Every test isolates to t.TempDir() and uses no network. + +package skillinject + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// fetchManifest: HTTP 200 but body is a manifest with version != 1. +func TestFetchManifest_RejectsWrongVersion(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"version": 99, "entrypoint": "x", "tools": [{}]}`)) + })) + defer srv.Close() + f := newFetcher(Config{ManifestURL: srv.URL + "/m.json"}) + _, err := f.fetchManifest(context.Background()) + if err == nil || !strings.Contains(err.Error(), "unsupported manifest version") { + t.Errorf("expected version error; got %v", err) + } +} + +// fetchManifest: missing entrypoint. +func TestFetchManifest_RejectsMissingEntrypoint(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"version": 1, "tools": [{"name":"x"}]}`)) + })) + defer srv.Close() + f := newFetcher(Config{ManifestURL: srv.URL + "/m.json"}) + _, err := f.fetchManifest(context.Background()) + if err == nil || !strings.Contains(err.Error(), "entrypoint") { + t.Errorf("expected entrypoint error; got %v", err) + } +} + +// fetchManifest: no tools. +func TestFetchManifest_RejectsEmptyTools(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"version": 1, "entrypoint":"x", "tools": []}`)) + })) + defer srv.Close() + f := newFetcher(Config{ManifestURL: srv.URL + "/m.json"}) + _, err := f.fetchManifest(context.Background()) + if err == nil || !strings.Contains(err.Error(), "no tools") { + t.Errorf("expected no-tools error; got %v", err) + } +} + +// fetchManifest: HTTP non-200 → wrapped error from get(). +func TestFetchManifest_HTTPError(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer srv.Close() + f := newFetcher(Config{ManifestURL: srv.URL + "/m.json"}) + _, err := f.fetchManifest(context.Background()) + if err == nil { + t.Fatal("expected HTTP error") + } +} + +// newFetcher: RepoBaseURL without trailing slash gets one appended. +func TestNewFetcher_RepoBaseGetsSlash(t *testing.T) { + t.Parallel() + f := newFetcher(Config{RepoBaseURL: "https://example.org/repo"}) + if !strings.HasSuffix(f.repoBase, "/") { + t.Errorf("repoBase = %q; want trailing /", f.repoBase) + } +} + +// renderHeartbeat: template with a field that doesn't exist on +// heartbeatVars triggers Execute error. +func TestRenderHeartbeat_ExecuteError(t *testing.T) { + t.Parallel() + // {{.DoesNotExist}} fails at Execute time because the field is + // missing on heartbeatVars. + _, err := renderHeartbeat([]byte(`{{.DoesNotExist}}`), heartbeatVars{EntrypointPath: "/x"}) + if err == nil { + t.Fatal("expected execute error on unknown field") + } + if !strings.Contains(err.Error(), "render heartbeat template") { + t.Errorf("error not wrapped properly: %v", err) + } +} + +// classifyPluginFile happy paths: identical hash, drifted hash. +func TestClassifyPluginFile_AllStates(t *testing.T) { + t.Parallel() + dir := t.TempDir() + p := filepath.Join(dir, "x.json") + + // Absent. + if got := classifyPluginFile(p, "anyhash"); got != StateAbsent { + t.Errorf("missing file classify=%s; want Absent", got) + } + // Identical. + body := []byte("hello") + if err := os.WriteFile(p, body, 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + wantHash := sha256Hex(body) + if got := classifyPluginFile(p, wantHash); got != StateIdentical { + t.Errorf("matching content classify=%s; want Identical", got) + } + // Drifted. + if got := classifyPluginFile(p, "deadbeef"); got != StateDrifted { + t.Errorf("different hash classify=%s; want Drifted", got) + } +} + +// logTick: nil report short-circuits; logTick(nil, nil) must not panic. +func TestLogTick_NilReportIsSafe(t *testing.T) { + t.Parallel() + logTick(nil, nil) // covers `if r == nil { return }` +} + +// logTick: error path logs via slog and returns; no panic. +func TestLogTick_ErrorIsSafe(t *testing.T) { + t.Parallel() + logTick(nil, context.Canceled) +} + +// logTick: non-nil report goes through the Info branch. +func TestLogTick_NonNilReportLogs(t *testing.T) { + t.Parallel() + r := &Report{ + Outcomes: []Outcome{ + {Action: ActionNoop}, {Action: ActionCreate}, {Action: ActionRewrite}, {Action: ActionError}, + }, + Skipped: []string{"a", "b"}, + } + logTick(r, nil) +} + +// Tick: opt-out flag set → returns Disabled report, no IO performed. +func TestTick_DisabledReturnsEarly(t *testing.T) { + t.Parallel() + home := t.TempDir() + if err := SetEnabled(home, false); err != nil { + t.Fatalf("SetEnabled(false): %v", err) + } + cfg := Config{ + Home: home, + // Bogus URL — would error if we got past the IsEnabled gate. + ManifestURL: "http://127.0.0.1:1/m.json", + RepoBaseURL: "http://127.0.0.1:1/", + } + rep, err := Tick(context.Background(), cfg) + if err != nil { + t.Fatalf("expected no error on disabled tick, got %v", err) + } + if rep == nil || !rep.Disabled { + t.Errorf("expected Disabled=true; got %+v", rep) + } + if len(rep.Outcomes) != 0 || len(rep.Skipped) != 0 { + t.Errorf("expected empty report on disabled tick: %+v", rep) + } +} + +// skillTargetPath: "flat" SkillNaming yields a single-file path. +func TestSkillTargetPath_FlatNaming(t *testing.T) { + t.Parallel() + mt := ManifestTool{ + Name: "openhands", + SkillsDir: "~/.openhands/microagents", + SkillNaming: "flat", + } + got := skillTargetPath(mt, "pilotctl", "/home/u") + want := "/home/u/.openhands/microagents/pilotctl.md" + if got != want { + t.Errorf("got %q; want %q", got, want) + } + // Verify the default "directory" naming still hits the SKILL.md path. + mt2 := ManifestTool{Name: "claude", SkillsDir: "~/.claude/skills"} + if got := skillTargetPath(mt2, "pilotctl", "/h"); !strings.HasSuffix(got, "/pilotctl/SKILL.md") { + t.Errorf("default naming got %q; want SKILL.md suffix", got) + } +} + +// writeHelper: identical content + correct mode → noop, no rewrite. +func TestWriteHelper_IdenticalContentAndModeNoop(t *testing.T) { + t.Parallel() + dir := t.TempDir() + p := filepath.Join(dir, "h") + content := []byte("body") + if err := os.WriteFile(p, content, 0o755); err != nil { + t.Fatalf("seed: %v", err) + } + st, err := writeHelper(p, content, 0o755) + if err != nil { + t.Fatalf("writeHelper: %v", err) + } + if st != StateIdentical { + t.Errorf("state = %s; want Identical", st) + } +} + +// writeHelper: identical content but wrong mode → Drifted; rewrite restores mode. +func TestWriteHelper_ModeDriftTriggersRewrite(t *testing.T) { + t.Parallel() + dir := t.TempDir() + p := filepath.Join(dir, "h") + content := []byte("body") + if err := os.WriteFile(p, content, 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + st, err := writeHelper(p, content, 0o755) + if err != nil { + t.Fatalf("writeHelper: %v", err) + } + if st != StateDrifted { + t.Errorf("state = %s; want Drifted", st) + } + finfo, _ := os.Stat(p) + if finfo.Mode().Perm() != 0o755 { + t.Errorf("mode after rewrite = %o; want 0755", finfo.Mode().Perm()) + } +} + +// SetEnabled: existing config is unparseable garbage; SetEnabled +// silently overwrites with a fresh map (preserves nothing — the +// json.Unmarshal error is swallowed by design). +func TestSetEnabled_HandlesPreviouslyGarbageConfig(t *testing.T) { + t.Parallel() + home := t.TempDir() + cfgDir := filepath.Join(home, ".pilot") + if err := os.MkdirAll(cfgDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(cfgDir, "config.json"), []byte("not json at all"), 0o600); err != nil { + t.Fatalf("seed garbage: %v", err) + } + if err := SetEnabled(home, false); err != nil { + t.Fatalf("SetEnabled: %v", err) + } + if IsEnabled(home) { + t.Errorf("after SetEnabled(false) over garbage, IsEnabled=true; want false") + } +} + +// verifyOnDiskResult: file disappears between write and read → error. +// (Exercises the err-branch in verifyOnDiskResult.) +func TestVerifyOnDiskResult_MissingFileErrors(t *testing.T) { + t.Parallel() + dir := t.TempDir() + p := filepath.Join(dir, "does-not-exist.json") + err := verifyOnDiskResult(p, map[string]struct{}{}, "pilot", "plugins.allow", "plugins.entries") + if err == nil { + t.Fatal("expected error reading missing file") + } + if !strings.Contains(err.Error(), "read-back") { + t.Errorf("error not wrapped properly: %v", err) + } +} + +// reconcilePluginAllowList: noop branch — file already in identical state. +// Verifies that the function early-returns with Action=Noop and never +// calls mergePluginAllowList. This covers the rare cov0 line: +// `if o.Action == ActionNoop { return o }`. +func TestReconcilePluginAllowList_IdenticalNoop(t *testing.T) { + t.Parallel() + home := t.TempDir() + openclawDir := filepath.Join(home, ".openclaw") + if err := os.MkdirAll(openclawDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + cfgPath := filepath.Join(openclawDir, "openclaw.json") + // Seed in the desired state. + body := []byte(`{ + "plugins": { + "allow": ["pilot"], + "entries": { "pilot": { "enabled": true } } + } +} +`) + if err := os.WriteFile(cfgPath, body, 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + + p := &ManifestPlugin{ + ID: "pilot", + AllowList: &ManifestPluginAllowList{ + ConfigPath: "~/.openclaw/openclaw.json", + AllowListJsonPath: "plugins.allow", + EntriesJsonPath: "plugins.entries", + }, + } + out := reconcilePluginAllowList(p, home) + if out.Action != ActionNoop { + t.Errorf("action = %v; want noop on identical state (got outcome %+v)", out.Action, out) + } + // File must be byte-identical (no write happened). + got, _ := os.ReadFile(cfgPath) + if string(got) != string(body) { + t.Errorf("file was mutated despite noop classification:\nwant=%q\ngot =%q", body, got) + } +} + +// reconcilePluginAllowList: merge failure surfaces as Outcome.Action=Error +// with Err populated. Trigger by writing malformed JSON into the config +// at a state Drift will mis-classify as needing rewrite (note: +// classifyPluginAllowList returns Drifted on parse failure, then merge +// itself refuses to overwrite and returns an error — caller records it). +func TestReconcilePluginAllowList_MergeErrorSurfaced(t *testing.T) { + t.Parallel() + home := t.TempDir() + openclawDir := filepath.Join(home, ".openclaw") + if err := os.MkdirAll(openclawDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + cfgPath := filepath.Join(openclawDir, "openclaw.json") + if err := os.WriteFile(cfgPath, []byte("not json"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + + p := &ManifestPlugin{ + ID: "pilot", + AllowList: &ManifestPluginAllowList{ + ConfigPath: "~/.openclaw/openclaw.json", + AllowListJsonPath: "plugins.allow", + EntriesJsonPath: "plugins.entries", + }, + } + out := reconcilePluginAllowList(p, home) + if out.Action != ActionError { + t.Errorf("action = %v; want error", out.Action) + } + if out.Err == "" { + t.Errorf("expected non-empty Err on merge failure: %+v", out) + } +} diff --git a/zz_micro_branches_test.go b/zz_micro_branches_test.go new file mode 100644 index 0000000..689b5c5 --- /dev/null +++ b/zz_micro_branches_test.go @@ -0,0 +1,319 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//go:build !no_skillinject +// +build !no_skillinject + +// Micro-branch coverage: +// - verifyMarshalRoundTrip: catches missing-entries-enabled (the +// "allow has our id but entries..enabled is missing or not true") +// - walkObject: edge cases — empty path returns nil,""; create=false +// on already-present nested map returns the deepest level +// - allowListContains: raw not-ok branches (key missing, value not array) +// - entryEnabled: parent[leaf] not a map branch +// - ensureAllowListEntry / ensureEntryEnabled: parent==nil branch +// (we can hit this by passing an empty jsonPath, which makes +// walkObject return empty leaf — but walkObject only returns nil +// parent when create=false AND an intermediate is non-map. So we +// route through walkObject with create=false to get nil.) +// - classifyPluginAllowList: non-IsNotExist ReadFile error (path is dir) +// - writeHelper: ReadFile non-IsNotExist error (path is a directory) +// - Run: cancel-fast loop exits cleanly without firing a tick beat +// (covers the ctx.Done branch inside the for-select) +// +// All tests are pure unit tests on package-private helpers. + +package skillinject + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/TeoSlayer/pilotprotocol/pkg/coreapi" +) + +// verifyMarshalRoundTrip: bytes have our id in allow but the entry +// for our id is missing the enabled=true assertion. +func TestVerifyMarshalRoundTrip_MissingEntryEnabled(t *testing.T) { + t.Parallel() + originalKeys := map[string]struct{}{"plugins": {}} + // id is in allow, but entries. has enabled=false. + buf := []byte(`{"plugins":{"allow":["pilot"],"entries":{"pilot":{"enabled":false}}}}`) + err := verifyMarshalRoundTrip(buf, originalKeys, "pilot", "plugins.allow", "plugins.entries") + if err == nil { + t.Fatal("expected error: enabled=false should fail verify") + } + if !strings.Contains(err.Error(), "enabled") { + t.Errorf("wrong error: %v", err) + } +} + +// walkObject: empty path → nil, "". This is a tiny defensive branch. +// We pass jsonPath="" which strings.Split returns ["", ...wait]. Actually +// strings.Split("", ".") returns [""], len 1 — so the loop doesn't run +// and we return the root with leaf="". Not the cov0 line. To hit cov0 +// (len(parts)==0), we'd need an impossible input — that branch is dead +// code, but let's exercise the path that returns leaf="" with the root. +func TestWalkObject_EmptyPathReturnsRoot(t *testing.T) { + t.Parallel() + obj := map[string]any{"x": 1} + parent, leaf := walkObject(obj, "", false) + if parent == nil { + t.Errorf("expected non-nil parent for empty path") + } + if leaf != "" { + t.Errorf("leaf = %q; want empty", leaf) + } +} + +// walkObject create=false on a path where an intermediate IS a map → +// returns the deepest container + the leaf. Already covered by other +// tests, but pin the exact semantics here. +func TestWalkObject_ExistingNestedPathNoCreate(t *testing.T) { + t.Parallel() + obj := map[string]any{ + "a": map[string]any{ + "b": map[string]any{ + "c": "value", + }, + }, + } + parent, leaf := walkObject(obj, "a.b.c", false) + if parent == nil || leaf != "c" { + t.Fatalf("got (parent=%v, leaf=%q); want non-nil and c", parent, leaf) + } + if parent["c"] != "value" { + t.Errorf("walkObject returned wrong container: %v", parent) + } +} + +// allowListContains: parent map doesn't have the leaf key → false. +func TestAllowListContains_LeafKeyMissing(t *testing.T) { + t.Parallel() + obj := map[string]any{"plugins": map[string]any{}} + if allowListContains(obj, "plugins.allow", "pilot") { + t.Error("expected false when leaf key absent") + } +} + +// allowListContains: leaf key exists but value isn't a JSON array +// (e.g. it's a string). Returns false. +func TestAllowListContains_LeafNotArray(t *testing.T) { + t.Parallel() + obj := map[string]any{ + "plugins": map[string]any{ + "allow": "not-an-array", + }, + } + if allowListContains(obj, "plugins.allow", "pilot") { + t.Error("expected false when leaf isn't an array") + } +} + +// entryEnabled: parent[leaf] is not a map (e.g. it's an int) → false. +func TestEntryEnabled_LeafNotMap(t *testing.T) { + t.Parallel() + obj := map[string]any{ + "plugins": map[string]any{ + "entries": "not-a-map", + }, + } + if entryEnabled(obj, "plugins.entries", "pilot") { + t.Error("expected false when entries isn't a map") + } +} + +// classifyPluginAllowList: config path is a directory (not a file) → +// ReadFile returns a non-IsNotExist error. Function returns Drifted. +func TestClassifyPluginAllowList_PathIsDirectory(t *testing.T) { + t.Parallel() + dir := t.TempDir() // dir itself, not a file + state := classifyPluginAllowList(dir, "plugins.allow", "plugins.entries", "pilot") + if state != StateDrifted { + t.Errorf("classify=%s; want Drifted on read-as-file-but-is-dir", state) + } +} + +// classifyPluginAllowList: file exists but contents are unparseable +// JSON. Should return Drifted (so daemon retries) without erroring. +func TestClassifyPluginAllowList_UnparseableContent(t *testing.T) { + t.Parallel() + dir := t.TempDir() + p := filepath.Join(dir, "openclaw.json") + if err := os.WriteFile(p, []byte("not json"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + state := classifyPluginAllowList(p, "plugins.allow", "plugins.entries", "pilot") + if state != StateDrifted { + t.Errorf("classify=%s; want Drifted on parse failure", state) + } +} + +// writeHelper: target path IS a directory → ReadFile returns a +// non-IsNotExist error. The function returns that error. +func TestWriteHelper_TargetIsDirectoryReturnsError(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // Treat the directory itself as the helper destination. + _, err := writeHelper(dir, []byte("body"), 0o755) + if err == nil { + t.Fatal("expected error when target path is a directory") + } +} + +// Run: cancel context immediately → first Tick fires (returns error +// due to bad manifest URL), Run exits via ctx.Done branch without +// firing additional ticks. Race-detector verifies no goroutine leak. +func TestRun_ContextCancellationExits(t *testing.T) { + t.Parallel() + // Use a tiny interval to make ticker very active. + ctx, cancel := context.WithCancel(context.Background()) + + // Fake server that always 404s, making the first Tick fail fast. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer srv.Close() + + cfg := Config{ + Home: t.TempDir(), + Interval: 5 * time.Millisecond, + ManifestURL: srv.URL + "/m.json", + RepoBaseURL: srv.URL + "/", + } + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + Run(ctx, cfg) + }() + // Let Run fire one or two ticker beats, then cancel. + time.Sleep(25 * time.Millisecond) + cancel() + + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Run did not exit after context cancel") + } +} + +// Run: zero-interval uses default. Cover the `cfg.Interval <= 0` +// guard. We can't observe the default directly without a long wait, +// but we can confirm Run honors immediate cancellation regardless. +func TestRun_ZeroIntervalDefaults(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer srv.Close() + cfg := Config{ + Home: t.TempDir(), + Interval: 0, + ManifestURL: srv.URL + "/m.json", + RepoBaseURL: srv.URL + "/", + } + cancel() // cancel immediately + done := make(chan struct{}) + go func() { + Run(ctx, cfg) + close(done) + }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Run did not exit after immediate cancel with zero interval") + } +} + +// mergePluginAllowList: existing file contains literal JSON `null` → +// json.Unmarshal succeeds and produces obj=nil; the code path that +// re-initializes `obj = map[string]any{}` after the unmarshal must +// fire. Result: merge succeeds and the file ends up with our managed +// keys (because original was "null", the merge effectively starts +// fresh). +func TestMergePluginAllowList_NullJSONReinitializes(t *testing.T) { + t.Parallel() + dir := t.TempDir() + cfg := filepath.Join(dir, "openclaw.json") + if err := os.WriteFile(cfg, []byte("null"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + if err := mergePluginAllowList(cfg, "plugins.allow", "plugins.entries", "pilot"); err != nil { + t.Fatalf("merge: %v", err) + } + // Result: file is now a valid JSON object with our managed keys. + body, _ := os.ReadFile(cfg) + if !strings.Contains(string(body), `"pilot"`) { + t.Errorf("expected pilot id in merged file: %s", body) + } +} + +// Stop ctx-deadline path: Start the service, then Stop with a +// context that's already cancelled BEFORE the worker goroutine exits. +// This forces the `case <-ctx.Done()` branch of Stop's select. +func TestService_StopCtxDeadline(t *testing.T) { + t.Parallel() + // Long interval + slow tick so the worker goroutine is still + // running when we call Stop with an already-cancelled context. + cfg := Config{ + Home: t.TempDir(), + Interval: 1 * time.Hour, // never fires during this test + ManifestURL: "http://127.0.0.1:1/m.json", + RepoBaseURL: "http://127.0.0.1:1/", + } + s := NewService(cfg) + startCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := s.Start(startCtx, coreapi.Deps{}); err != nil { + t.Fatalf("Start: %v", err) + } + // Hold the worker goroutine in its ticker loop by NOT cancelling + // startCtx. Pass Stop a context that's already cancelled. + stopCtx, stopCancel := context.WithCancel(context.Background()) + stopCancel() + err := s.Stop(stopCtx) + if err == nil { + t.Error("expected ctx.Err() from Stop's deadline branch") + } + // Clean up the worker goroutine via the start-ctx cancel (deferred). +} + +// Service.Start + immediate Stop: the service spawns a goroutine that +// runs Tick once and the ticker loop. Stop must wait for `done`. This +// covers the success branch of Stop's select. +func TestService_StartStopGracefully(t *testing.T) { + t.Parallel() + // Point cfg at an unreachable manifest URL so Tick errors quickly. + cfg := Config{ + Home: t.TempDir(), + Interval: 100 * time.Millisecond, + ManifestURL: "http://127.0.0.1:1/m.json", + RepoBaseURL: "http://127.0.0.1:1/", + } + s := NewService(cfg) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // Cover the Start path via a small fake of coreapi.Deps. We need + // only a non-nil Events; nil works because Start passes it straight + // to RecoverPlugin which tolerates nil events. + if err := s.Start(ctx, coreapi.Deps{}); err != nil { + t.Fatalf("Start: %v", err) + } + // Stop with a generous deadline. + stopCtx, stopCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer stopCancel() + if err := s.Stop(stopCtx); err != nil { + t.Errorf("Stop: %v", err) + } +} diff --git a/zz_uninstall_paths_test.go b/zz_uninstall_paths_test.go new file mode 100644 index 0000000..99dd1bf --- /dev/null +++ b/zz_uninstall_paths_test.go @@ -0,0 +1,604 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//go:build !no_skillinject +// +build !no_skillinject + +// Additional uninstall.go coverage: +// - loadManifestForUninstall offline / cached-manifest fallback paths +// - loadManifestForUninstall total failure (network unreachable + no cache) +// - removePluginAllowListEntry path matrix: +// * missing config file with orphan .pilot-bak cleanup +// * .pilot-bak restore preferred path (RemovalRestored) +// * inverse-merge path (RemovalMerged) +// * inverse-merge over an id we never added (RemovalNoop + orphan bak cleanup) +// * unparseable live config (RemovalError, refuses to write) +// - stripMarkerFile noop when no marker present +// - removeOwnedFile noop on already-missing path +// - Uninstall error surfacing when loadManifestForUninstall fails completely +// +// All file I/O uses t.TempDir(). + +package skillinject + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// loadManifestForUninstall: network OK → returns (manifest, offline=false). +// Spin up a tiny fake repo locally, point Config at it, ensure the +// "happy" branch is taken (no cache touched). +func TestLoadManifestForUninstall_NetworkPath(t *testing.T) { + t.Parallel() + home := t.TempDir() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "inject-manifest.json") { + m := Manifest{ + Version: 1, + Entrypoint: "pilotctl", + Tools: []ManifestTool{{ + Name: "claude-code", RootDir: "~/.claude", SkillsDir: "~/.claude/skills", + }}, + } + _ = json.NewEncoder(w).Encode(m) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + cfg := Config{ + Home: home, + ManifestURL: srv.URL + "/inject-manifest.json", + RepoBaseURL: srv.URL + "/", + } + m, offline, err := loadManifestForUninstall(context.Background(), cfg, home) + if err != nil { + t.Fatalf("loadManifestForUninstall: %v", err) + } + if offline { + t.Errorf("offline=true; want false when network reachable") + } + if m == nil || m.Entrypoint != "pilotctl" { + t.Errorf("unexpected manifest: %+v", m) + } +} + +// loadManifestForUninstall: network DOWN, cached manifest on disk → +// returns (manifest, offline=true). +func TestLoadManifestForUninstall_CachedFallback(t *testing.T) { + t.Parallel() + home := t.TempDir() + + // Seed cached manifest. + cacheRel := filepath.Join(cacheDir(home), manifestCacheRel) + if err := os.MkdirAll(filepath.Dir(cacheRel), 0o755); err != nil { + t.Fatalf("mkdir cache: %v", err) + } + m := Manifest{Version: 1, Entrypoint: "pilotctl", Tools: []ManifestTool{{Name: "x"}}} + b, _ := json.Marshal(m) + if err := os.WriteFile(cacheRel, b, 0o644); err != nil { + t.Fatalf("seed cache: %v", err) + } + + cfg := Config{ + Home: home, + ManifestURL: "http://127.0.0.1:1/inject-manifest.json", // unreachable + RepoBaseURL: "http://127.0.0.1:1/", + } + got, offline, err := loadManifestForUninstall(context.Background(), cfg, home) + if err != nil { + t.Fatalf("loadManifestForUninstall: %v", err) + } + if !offline { + t.Errorf("offline=false; want true (used cached fallback)") + } + if got == nil || got.Entrypoint != "pilotctl" { + t.Errorf("manifest not restored from cache: %+v", got) + } +} + +// loadManifestForUninstall: network DOWN, cache MISSING → returns error. +func TestLoadManifestForUninstall_NoCache(t *testing.T) { + t.Parallel() + home := t.TempDir() + cfg := Config{ + Home: home, + ManifestURL: "http://127.0.0.1:1/inject-manifest.json", + RepoBaseURL: "http://127.0.0.1:1/", + } + _, _, err := loadManifestForUninstall(context.Background(), cfg, home) + if err == nil { + t.Fatal("expected error when network is down and no cache") + } + if !strings.Contains(err.Error(), "no cached manifest") { + t.Errorf("error doesn't mention missing cache: %v", err) + } +} + +// loadManifestForUninstall: cached file is unparseable garbage → error. +func TestLoadManifestForUninstall_CachedGarbage(t *testing.T) { + t.Parallel() + home := t.TempDir() + cacheRel := filepath.Join(cacheDir(home), manifestCacheRel) + if err := os.MkdirAll(filepath.Dir(cacheRel), 0o755); err != nil { + t.Fatalf("mkdir cache: %v", err) + } + if err := os.WriteFile(cacheRel, []byte("not json {{{"), 0o644); err != nil { + t.Fatalf("seed garbage: %v", err) + } + cfg := Config{ + Home: home, + ManifestURL: "http://127.0.0.1:1/inject-manifest.json", + RepoBaseURL: "http://127.0.0.1:1/", + } + _, _, err := loadManifestForUninstall(context.Background(), cfg, home) + if err == nil || !strings.Contains(err.Error(), "parse cached manifest") { + t.Errorf("expected parse error; got %v", err) + } +} + +// Uninstall surfaces an error when loadManifestForUninstall fails +// completely (network unreachable + no cache). Empty report returned. +func TestUninstall_PropagatesManifestLoadFailure(t *testing.T) { + t.Parallel() + home := t.TempDir() + cfg := Config{ + Home: home, + ManifestURL: "http://127.0.0.1:1/inject-manifest.json", + RepoBaseURL: "http://127.0.0.1:1/", + } + rep, err := Uninstall(context.Background(), cfg) + if err == nil { + t.Fatal("expected error when manifest unfetchable + uncached") + } + if rep == nil { + t.Fatal("expected non-nil empty report alongside error") + } + if len(rep.Removals) != 0 { + t.Errorf("expected no removals on early failure; got %d", len(rep.Removals)) + } +} + +// removePluginAllowListEntry: config file gone, no .pilot-bak → noop. +func TestRemovePluginAllowListEntry_ConfigMissing(t *testing.T) { + t.Parallel() + dir := t.TempDir() + cfgPath := filepath.Join(dir, "openclaw.json") + + p := &ManifestPlugin{ + ID: "pilot", + AllowList: &ManifestPluginAllowList{ + ConfigPath: cfgPath, + AllowListJsonPath: "plugins.allow", + EntriesJsonPath: "plugins.entries", + }, + } + r := removePluginAllowListEntry(p, cfgPath) + if r.Action != RemovalNoop { + t.Errorf("action = %v, want noop; %+v", r.Action, r) + } +} + +// removePluginAllowListEntry: config gone but orphan .pilot-bak present +// → still noop, AND the orphan backup is cleaned up. +func TestRemovePluginAllowListEntry_OrphanBakCleanup(t *testing.T) { + t.Parallel() + dir := t.TempDir() + cfgPath := filepath.Join(dir, "openclaw.json") + if err := os.WriteFile(cfgPath+BackupSuffix, []byte("{}"), 0o600); err != nil { + t.Fatalf("seed orphan bak: %v", err) + } + + p := &ManifestPlugin{ + ID: "pilot", + AllowList: &ManifestPluginAllowList{ + ConfigPath: cfgPath, + AllowListJsonPath: "plugins.allow", + EntriesJsonPath: "plugins.entries", + }, + } + r := removePluginAllowListEntry(p, cfgPath) + if r.Action != RemovalNoop { + t.Errorf("action = %v, want noop", r.Action) + } + if _, err := os.Stat(cfgPath + BackupSuffix); err == nil { + t.Errorf("orphan .pilot-bak should have been removed") + } +} + +// removePluginAllowListEntry: .pilot-bak is the pre-install snapshot and +// the live file has our id merged in → RESTORE from backup, return +// RemovalRestored, delete the .pilot-bak. +func TestRemovePluginAllowListEntry_RestoreFromBackup(t *testing.T) { + t.Parallel() + dir := t.TempDir() + cfgPath := filepath.Join(dir, "openclaw.json") + + // Backup: original config without pilot. + bak := map[string]any{ + "gateway": map[string]any{"mode": "auto"}, + "plugins": map[string]any{ + "allow": []any{"other-plugin"}, + "entries": map[string]any{"other-plugin": map[string]any{"enabled": true}}, + }, + } + bakBytes, _ := json.MarshalIndent(bak, "", " ") + bakBytes = append(bakBytes, '\n') + if err := os.WriteFile(cfgPath+BackupSuffix, bakBytes, 0o600); err != nil { + t.Fatalf("seed bak: %v", err) + } + + // Live: config WITH pilot merged in. + live := map[string]any{ + "gateway": map[string]any{"mode": "auto"}, + "plugins": map[string]any{ + "allow": []any{"other-plugin", "pilot"}, + "entries": map[string]any{ + "other-plugin": map[string]any{"enabled": true}, + "pilot": map[string]any{"enabled": true}, + }, + }, + } + liveBytes, _ := json.MarshalIndent(live, "", " ") + if err := os.WriteFile(cfgPath, liveBytes, 0o644); err != nil { + t.Fatalf("seed live: %v", err) + } + + p := &ManifestPlugin{ + ID: "pilot", + AllowList: &ManifestPluginAllowList{ + ConfigPath: cfgPath, + AllowListJsonPath: "plugins.allow", + EntriesJsonPath: "plugins.entries", + }, + } + r := removePluginAllowListEntry(p, cfgPath) + if r.Action != RemovalRestored { + t.Errorf("action = %v, want RemovalRestored; %+v", r.Action, r) + } + // Live file should now equal the backup bytes byte-for-byte. + got, _ := os.ReadFile(cfgPath) + if string(got) != string(bakBytes) { + t.Errorf("file content not byte-restored:\nwant=%q\ngot =%q", bakBytes, got) + } + // Backup should have been removed after restore. + if _, err := os.Stat(cfgPath + BackupSuffix); err == nil { + t.Errorf(".pilot-bak should be removed after restore") + } +} + +// removePluginAllowListEntry: .pilot-bak somehow itself contains our id +// (invalid as a "pre-install" snapshot) → restore is skipped, falls +// through to inverse-merge path. End state: id removed, action=Merged. +func TestRemovePluginAllowListEntry_BakContainsOurIdFallsToInverseMerge(t *testing.T) { + t.Parallel() + dir := t.TempDir() + cfgPath := filepath.Join(dir, "openclaw.json") + + // Bogus "backup" that actually contains pilot. + bogus := map[string]any{ + "plugins": map[string]any{ + "allow": []any{"pilot"}, + "entries": map[string]any{"pilot": map[string]any{"enabled": true}}, + }, + } + bb, _ := json.MarshalIndent(bogus, "", " ") + if err := os.WriteFile(cfgPath+BackupSuffix, bb, 0o600); err != nil { + t.Fatalf("seed bogus bak: %v", err) + } + // Live file with pilot. + live := map[string]any{ + "plugins": map[string]any{ + "allow": []any{"user-x", "pilot"}, + "entries": map[string]any{"user-x": map[string]any{"enabled": true}, "pilot": map[string]any{"enabled": true}}, + }, + } + lb, _ := json.MarshalIndent(live, "", " ") + if err := os.WriteFile(cfgPath, lb, 0o644); err != nil { + t.Fatalf("seed live: %v", err) + } + + p := &ManifestPlugin{ + ID: "pilot", + AllowList: &ManifestPluginAllowList{ + ConfigPath: cfgPath, + AllowListJsonPath: "plugins.allow", + EntriesJsonPath: "plugins.entries", + }, + } + r := removePluginAllowListEntry(p, cfgPath) + if r.Action != RemovalMerged { + t.Errorf("action = %v, want RemovalMerged (inverse-merge fallback)", r.Action) + } + // User entry preserved. + got, _ := os.ReadFile(cfgPath) + var obj map[string]any + _ = json.Unmarshal(got, &obj) + plugins := obj["plugins"].(map[string]any) + entries := plugins["entries"].(map[string]any) + if _, ok := entries["pilot"]; ok { + t.Errorf("pilot entry not removed: %+v", entries) + } + if _, ok := entries["user-x"]; !ok { + t.Errorf("user-x entry lost: %+v", entries) + } +} + +// removePluginAllowListEntry: config parseable, no backup, our id not +// present → noop (nothing to remove). Pre-existing orphan .pilot-bak +// (if any) gets cleaned up too. +func TestRemovePluginAllowListEntry_NoIdPresentNoop(t *testing.T) { + t.Parallel() + dir := t.TempDir() + cfgPath := filepath.Join(dir, "openclaw.json") + live := map[string]any{ + "plugins": map[string]any{ + "allow": []any{"user-a"}, + "entries": map[string]any{"user-a": map[string]any{"enabled": true}}, + }, + } + lb, _ := json.MarshalIndent(live, "", " ") + if err := os.WriteFile(cfgPath, lb, 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + // Orphan backup that is UNPARSEABLE — the restore preference check + // then bails out (json.Unmarshal returns error), forcing the path + // through the inverse-merge logic, which finds no id and noops. + if err := os.WriteFile(cfgPath+BackupSuffix, []byte("garbage{{"), 0o600); err != nil { + t.Fatalf("seed orphan bak: %v", err) + } + + p := &ManifestPlugin{ + ID: "pilot", + AllowList: &ManifestPluginAllowList{ + ConfigPath: cfgPath, + AllowListJsonPath: "plugins.allow", + EntriesJsonPath: "plugins.entries", + }, + } + r := removePluginAllowListEntry(p, cfgPath) + if r.Action != RemovalNoop { + t.Errorf("action = %v, want noop (id not present)", r.Action) + } + if _, err := os.Stat(cfgPath + BackupSuffix); err == nil { + t.Errorf("orphan .pilot-bak should have been removed") + } +} + +// removePluginAllowListEntry: live config is malformed JSON → RemovalError; +// the file must NOT be overwritten. +func TestRemovePluginAllowListEntry_MalformedConfigRefuses(t *testing.T) { + t.Parallel() + dir := t.TempDir() + cfgPath := filepath.Join(dir, "openclaw.json") + garbage := []byte("not json { ]") + if err := os.WriteFile(cfgPath, garbage, 0o644); err != nil { + t.Fatalf("seed garbage: %v", err) + } + + p := &ManifestPlugin{ + ID: "pilot", + AllowList: &ManifestPluginAllowList{ + ConfigPath: cfgPath, + AllowListJsonPath: "plugins.allow", + EntriesJsonPath: "plugins.entries", + }, + } + r := removePluginAllowListEntry(p, cfgPath) + if r.Action != RemovalError { + t.Errorf("action = %v, want RemovalError on malformed config", r.Action) + } + got, _ := os.ReadFile(cfgPath) + if string(got) != string(garbage) { + t.Errorf("malformed config was overwritten:\nwant=%q\ngot =%q", garbage, got) + } +} + +// stripMarkerFile: file exists but contains no marker block → noop, +// content untouched. +func TestStripMarkerFile_NoMarkerNoop(t *testing.T) { + t.Parallel() + dir := t.TempDir() + p := filepath.Join(dir, "AGENT.md") + content := []byte("# Plain file\n\nno marker here.\n") + if err := os.WriteFile(p, content, 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + r := stripMarkerFile("my-tool", p) + if r.Action != RemovalNoop { + t.Errorf("action = %v, want noop", r.Action) + } + got, _ := os.ReadFile(p) + if string(got) != string(content) { + t.Errorf("file mutated despite noop: %q", got) + } +} + +// stripMarkerFile: file missing → noop with no error. +func TestStripMarkerFile_MissingFileNoop(t *testing.T) { + t.Parallel() + dir := t.TempDir() + r := stripMarkerFile("my-tool", filepath.Join(dir, "does-not-exist.md")) + if r.Action != RemovalNoop { + t.Errorf("action = %v, want noop on missing file", r.Action) + } +} + +// removeOwnedFile: file already missing → noop. +func TestRemoveOwnedFile_MissingNoop(t *testing.T) { + t.Parallel() + dir := t.TempDir() + r := removeOwnedFile("tool", KindHelper, filepath.Join(dir, "gone")) + if r.Action != RemovalNoop { + t.Errorf("action = %v, want noop", r.Action) + } +} + +// removeOwnedFile: file exists, removable → deleted. +func TestRemoveOwnedFile_Deletes(t *testing.T) { + t.Parallel() + dir := t.TempDir() + p := filepath.Join(dir, "x") + if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + r := removeOwnedFile("tool", KindHelper, p) + if r.Action != RemovalDeleted { + t.Errorf("action = %v, want deleted", r.Action) + } + if _, err := os.Stat(p); err == nil { + t.Errorf("file should be gone") + } +} + +// stripMarkerFile: path is a directory (not a file) → ReadFile returns +// a non-IsNotExist error. Reports RemovalError. +func TestStripMarkerFile_PathIsDirectoryErrors(t *testing.T) { + t.Parallel() + dir := t.TempDir() // directory itself + r := stripMarkerFile("tool", dir) + if r.Action != RemovalError { + t.Errorf("action = %v, want RemovalError on dir-as-path", r.Action) + } + if r.Err == "" { + t.Errorf("expected non-empty Err on error") + } +} + +// removeOwnedFile: path is a non-empty directory → os.Remove returns +// an error. Reports RemovalError. +func TestRemoveOwnedFile_NonEmptyDirErrors(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // Create child file so os.Remove(dir) fails with "directory not empty". + if err := os.WriteFile(filepath.Join(dir, "child"), []byte("x"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + r := removeOwnedFile("tool", KindHelper, dir) + if r.Action != RemovalError { + t.Errorf("action = %v, want RemovalError on non-empty dir", r.Action) + } + if r.Err == "" { + t.Errorf("expected non-empty Err") + } +} + +// removePluginAllowListEntry: cfgPath is a directory (non-IsNotExist +// read error) → RemovalError. +func TestRemovePluginAllowListEntry_PathIsDirectory(t *testing.T) { + t.Parallel() + dir := t.TempDir() + p := &ManifestPlugin{ + ID: "pilot", + AllowList: &ManifestPluginAllowList{ + ConfigPath: dir, // directory, not a file + AllowListJsonPath: "plugins.allow", + EntriesJsonPath: "plugins.entries", + }, + } + r := removePluginAllowListEntry(p, dir) + if r.Action != RemovalError { + t.Errorf("action = %v, want RemovalError on dir-as-config", r.Action) + } +} + +// Uninstall: openclaw end-to-end "restore from backup" path. +// Seed a live openclaw.json + a pre-install .pilot-bak, run Uninstall, +// verify the live file is restored byte-for-byte AND the report carries +// a RemovalRestored row. +func TestUninstall_OpenClawRestoredFromBackup(t *testing.T) { + t.Parallel() + home := t.TempDir() + openclawDir := filepath.Join(home, ".openclaw") + if err := os.MkdirAll(openclawDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + configPath := filepath.Join(openclawDir, "openclaw.json") + + // Pre-install snapshot (the .pilot-bak): NO pilot id. + bak := map[string]any{ + "plugins": map[string]any{ + "allow": []any{"user-a"}, + "entries": map[string]any{"user-a": map[string]any{"enabled": true}}, + }, + "user_setting": "preserve-me", + } + bakBytes, _ := json.MarshalIndent(bak, "", " ") + bakBytes = append(bakBytes, '\n') + if err := os.WriteFile(configPath+BackupSuffix, bakBytes, 0o600); err != nil { + t.Fatalf("seed bak: %v", err) + } + // Live: has pilot merged in. + live := map[string]any{ + "plugins": map[string]any{ + "allow": []any{"user-a", "pilot"}, + "entries": map[string]any{"user-a": map[string]any{"enabled": true}, "pilot": map[string]any{"enabled": true}}, + }, + "user_setting": "preserve-me", + } + liveBytes, _ := json.MarshalIndent(live, "", " ") + if err := os.WriteFile(configPath, liveBytes, 0o644); err != nil { + t.Fatalf("seed live: %v", err) + } + + // Seed a minimal cached manifest so Uninstall can run with no network. + m := Manifest{ + Version: 1, + Entrypoint: "pilotctl", + Tools: []ManifestTool{{ + Name: "openclaw", RootDir: "~/.openclaw", SkillsDir: "~/.openclaw/skills", + Plugin: &ManifestPlugin{ + ID: "pilot", + InstallPath: "~/.openclaw/extensions/pilot", + AllowList: &ManifestPluginAllowList{ + ConfigPath: "~/.openclaw/openclaw.json", + AllowListJsonPath: "plugins.allow", + EntriesJsonPath: "plugins.entries", + }, + }, + }}, + } + cacheRel := filepath.Join(cacheDir(home), manifestCacheRel) + if err := os.MkdirAll(filepath.Dir(cacheRel), 0o755); err != nil { + t.Fatalf("mkdir cache: %v", err) + } + mb, _ := json.Marshal(m) + if err := os.WriteFile(cacheRel, mb, 0o644); err != nil { + t.Fatalf("seed cache: %v", err) + } + + cfg := Config{ + Home: home, + ManifestURL: "http://127.0.0.1:1/inject-manifest.json", // unreachable + RepoBaseURL: "http://127.0.0.1:1/", + } + rep, err := Uninstall(context.Background(), cfg) + if err != nil { + t.Fatalf("Uninstall: %v", err) + } + if !rep.ManifestOffline { + t.Errorf("ManifestOffline=false; want true (cache used)") + } + var sawRestored bool + for _, rm := range rep.Removals { + if rm.Action == RemovalRestored && rm.Kind == KindPluginAllowList { + sawRestored = true + } + } + if !sawRestored { + t.Errorf("expected a RemovalRestored row in report: %+v", rep.Removals) + } + got, _ := os.ReadFile(configPath) + if string(got) != string(bakBytes) { + t.Errorf("live file not byte-restored from backup:\nwant=%q\ngot =%q", bakBytes, got) + } +}