From 5db35edbdc09a63611036379a3013dd085f65811 Mon Sep 17 00:00:00 2001 From: leet-c1 <264029741+leet-c1@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:44:29 +0000 Subject: [PATCH 1/5] Delete leftover files from the cache dir when it is refreshed A release that renames or drops a cache file left the old file in ~/.c1i/cache forever. Instead of a cleanup rule per file, list the files the cache is supposed to hold and delete anything else there whenever the OpenAPI cache is rewritten (at most once per 24h). Subdirectories are skipped, and so is a cache dir that is a symlink, whose target may hold files c1i doesn't own. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++ README.md | 2 + cmd/cache.go | 31 +++++++++++++ cmd/cache_test.go | 104 ++++++++++++++++++++++++++++++++++++++++++++ cmd/docs_openapi.go | 4 +- 5 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 cmd/cache.go create mode 100644 cmd/cache_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 0386ef0..97c4a4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- **Old cache files stayed on disk forever.** A cache file renamed or dropped + by a release was never removed. Refreshing the OpenAPI spec now deletes any + file in `~/.c1i/cache/` that the running version doesn't use. Subdirectories + and a symlinked cache dir are left alone. - **`requests create grant --duration` documented a format the server refuses.** Its help and the README suggested `24h` or `7d`, which the API rejects with `invalid google.protobuf.Duration value` (exit 2). They now say diff --git a/README.md b/README.md index 020dc58..582dd03 100644 --- a/README.md +++ b/README.md @@ -873,6 +873,8 @@ c1i docs guide register-mcp-server `docs guide` is embedded static content (no network call), unlike `docs search` / `docs page` which hit the C1 documentation site. Guides ship in two families: registering and operating MCP servers (`register-mcp-server`, `assign-toolset-everyone`, `test-mcp-gateway`, `delegate-entitlement-provisioning`) and everyday app/access-request workflows (`configure-new-app`, `request-access`, `inspect-and-approve-task`). Run `c1i docs guide` with no argument for the full, current list. +`~/.c1i/cache/` belongs to c1i: when `docs openapi`, `docs endpoints` or `docs endpoint` refreshes the spec, any file there that the running version doesn't use (such as one left by an older release) is deleted. Don't keep your own files in it. + `docs skill` is kept as an alias of `docs agents` for backward compatibility; both print identical output. diff --git a/cmd/cache.go b/cmd/cache.go new file mode 100644 index 0000000..20aa260 --- /dev/null +++ b/cmd/cache.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "os" + "path/filepath" +) + +// cacheFiles is every file c1i keeps in ~/.c1i/cache. Anything else there is +// left over from an older release and is deleted when the cache is written, so +// renaming a cache file needs no cleanup rule of its own. Add a new cache file +// here or it will be deleted on every refresh. +var cacheFiles = map[string]bool{cacheFileName: true} + +// pruneCacheDir is best-effort: cleanup must never fail the command. It skips +// subdirectories, and a symlinked dir (Lstat sees a link, not a dir) whose +// target may hold files c1i doesn't own. +func pruneCacheDir(dir string) { + if info, err := os.Lstat(dir); err != nil || !info.IsDir() { + return + } + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, e := range entries { + if e.IsDir() || cacheFiles[e.Name()] { + continue + } + _ = os.Remove(filepath.Join(dir, e.Name())) + } +} diff --git a/cmd/cache_test.go b/cmd/cache_test.go new file mode 100644 index 0000000..aa2512a --- /dev/null +++ b/cmd/cache_test.go @@ -0,0 +1,104 @@ +package cmd + +import ( + "bytes" + "context" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +type stubRoundTripper func(*http.Request) (*http.Response, error) + +func (f stubRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +// refreshOpenAPICache runs `docs openapi` against an expired cache so it +// fetches a stub spec and rewrites the cache. +func refreshOpenAPICache(t *testing.T, cacheDir string) { + t.Helper() + cachePath := filepath.Join(cacheDir, cacheFileName) + if err := os.WriteFile(cachePath, []byte(stubOpenAPISpec), 0o600); err != nil { + t.Fatal(err) + } + expired := time.Now().Add(-2 * cacheMaxAge) + if err := os.Chtimes(cachePath, expired, expired); err != nil { + t.Fatal(err) + } + + orig := http.DefaultClient.Transport + http.DefaultClient.Transport = stubRoundTripper(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(stubOpenAPISpec)), + Request: r, + }, nil + }) + t.Cleanup(func() { http.DefaultClient.Transport = orig }) + + docsOpenapiCmd.SetOut(&bytes.Buffer{}) + docsOpenapiCmd.SetContext(context.Background()) + if err := docsOpenapiCmd.RunE(docsOpenapiCmd, nil); err != nil { + t.Fatalf("docs openapi: %v", err) + } + info, err := os.Stat(cachePath) + if err != nil || time.Since(info.ModTime()) > time.Minute { + t.Fatalf("cache was not rewritten: %v", err) + } +} + +func TestCacheWritePrunesLeftoverFiles(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + cacheDir := filepath.Join(home, cacheDirName, "cache") + if err := os.MkdirAll(filepath.Join(cacheDir, "subdir"), 0o700); err != nil { + t.Fatal(err) + } + for _, name := range []string{"old-openapi.yaml", ".tmp-leftover"} { + if err := os.WriteFile(filepath.Join(cacheDir, name), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + + refreshOpenAPICache(t, cacheDir) + + entries, err := os.ReadDir(cacheDir) + if err != nil { + t.Fatal(err) + } + var got []string + for _, e := range entries { + got = append(got, e.Name()) + } + want := []string{cacheFileName, "subdir"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("cache dir holds %v, want %v", got, want) + } +} + +// A cache dir the user redirected elsewhere may hold files c1i doesn't own. +func TestCachePruneSkipsSymlinkedCacheDir(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + target := t.TempDir() + stray := filepath.Join(target, "not-ours.txt") + if err := os.WriteFile(stray, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(home, cacheDirName), 0o700); err != nil { + t.Fatal(err) + } + cacheDir := filepath.Join(home, cacheDirName, "cache") + if err := os.Symlink(target, cacheDir); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + refreshOpenAPICache(t, cacheDir) + + if _, err := os.Stat(stray); err != nil { + t.Errorf("file behind a symlinked cache dir was removed: %v", err) + } +} diff --git a/cmd/docs_openapi.go b/cmd/docs_openapi.go index e534d2e..f819b70 100644 --- a/cmd/docs_openapi.go +++ b/cmd/docs_openapi.go @@ -207,7 +207,9 @@ func fetchOpenAPISpec(cmd *cobra.Command) ([]byte, error) { } _ = os.MkdirAll(filepath.Dir(cachePath), 0o700) - _ = os.WriteFile(cachePath, data, 0o644) // #nosec G306 -- cached OpenAPI spec is public C1 API documentation, not sensitive + if os.WriteFile(cachePath, data, 0o644) == nil { // #nosec G306 -- cached OpenAPI spec is public C1 API documentation, not sensitive + pruneCacheDir(filepath.Dir(cachePath)) + } return data, nil } From 42abaf9423c0e779a4d5dd3ca145a93292f01a7c Mon Sep 17 00:00:00 2001 From: leet-c1 <264029741+leet-c1@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:46:19 +0000 Subject: [PATCH 2/5] Skip the cache sweep when the cache path is relative With HOME unset the cache path resolves against the working directory, so the sweep would delete files there. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++-- cmd/cache.go | 8 ++++++-- cmd/cache_test.go | 20 ++++++++++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97c4a4f..ca05b20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,9 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **Old cache files stayed on disk forever.** A cache file renamed or dropped by a release was never removed. Refreshing the OpenAPI spec now deletes any - file in `~/.c1i/cache/` that the running version doesn't use. Subdirectories - and a symlinked cache dir are left alone. + file in `~/.c1i/cache/` that the running version doesn't use. It leaves + subdirectories alone, and skips the sweep when the cache dir is a symlink or, + with no home dir, a path relative to the working directory. - **`requests create grant --duration` documented a format the server refuses.** Its help and the README suggested `24h` or `7d`, which the API rejects with `invalid google.protobuf.Duration value` (exit 2). They now say diff --git a/cmd/cache.go b/cmd/cache.go index 20aa260..1af3f3b 100644 --- a/cmd/cache.go +++ b/cmd/cache.go @@ -12,9 +12,13 @@ import ( var cacheFiles = map[string]bool{cacheFileName: true} // pruneCacheDir is best-effort: cleanup must never fail the command. It skips -// subdirectories, and a symlinked dir (Lstat sees a link, not a dir) whose -// target may hold files c1i doesn't own. +// subdirectories, a symlinked dir (Lstat sees a link, not a dir) whose target +// may hold files c1i doesn't own, and a relative dir, which is what an unset +// HOME yields and would sweep the working directory instead. func pruneCacheDir(dir string) { + if !filepath.IsAbs(dir) { + return + } if info, err := os.Lstat(dir); err != nil || !info.IsDir() { return } diff --git a/cmd/cache_test.go b/cmd/cache_test.go index aa2512a..77e32bd 100644 --- a/cmd/cache_test.go +++ b/cmd/cache_test.go @@ -102,3 +102,23 @@ func TestCachePruneSkipsSymlinkedCacheDir(t *testing.T) { t.Errorf("file behind a symlinked cache dir was removed: %v", err) } } + +// With no home dir the cache path is relative, so it lands in the working dir. +func TestCachePruneSkipsRelativeCacheDir(t *testing.T) { + t.Setenv("HOME", "") + t.Chdir(t.TempDir()) + cacheDir := filepath.Join(cacheDirName, "cache") + if err := os.MkdirAll(cacheDir, 0o700); err != nil { + t.Fatal(err) + } + stray := filepath.Join(cacheDir, "not-ours.txt") + if err := os.WriteFile(stray, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + + refreshOpenAPICache(t, cacheDir) + + if _, err := os.Stat(stray); err != nil { + t.Errorf("file in a working-dir-relative cache dir was removed: %v", err) + } +} From d741a22d1aa6980e6378371e2c139a3f1d17d369 Mon Sep 17 00:00:00 2001 From: leet-c1 <264029741+leet-c1@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:49:27 +0000 Subject: [PATCH 3/5] Document the cache sweep for agents and skip its relative-path test on Windows Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++-- cmd/agents.md | 3 +++ cmd/cache_test.go | 4 ++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 582dd03..8885274 100644 --- a/README.md +++ b/README.md @@ -873,11 +873,11 @@ c1i docs guide register-mcp-server `docs guide` is embedded static content (no network call), unlike `docs search` / `docs page` which hit the C1 documentation site. Guides ship in two families: registering and operating MCP servers (`register-mcp-server`, `assign-toolset-everyone`, `test-mcp-gateway`, `delegate-entitlement-provisioning`) and everyday app/access-request workflows (`configure-new-app`, `request-access`, `inspect-and-approve-task`). Run `c1i docs guide` with no argument for the full, current list. -`~/.c1i/cache/` belongs to c1i: when `docs openapi`, `docs endpoints` or `docs endpoint` refreshes the spec, any file there that the running version doesn't use (such as one left by an older release) is deleted. Don't keep your own files in it. - `docs skill` is kept as an alias of `docs agents` for backward compatibility; both print identical output. +`~/.c1i/cache/` belongs to c1i: when `docs openapi`, `docs endpoints` or `docs endpoint` refreshes the spec, any file there that the running version doesn't use (such as one left by an older release) is deleted. Don't keep your own files in it. + ## Output Conventions - **List commands** (`users list`, `apps list`, etc.) output NDJSON (one JSON object per line). diff --git a/cmd/agents.md b/cmd/agents.md index 67179d8..310d16d 100644 --- a/cmd/agents.md +++ b/cmd/agents.md @@ -121,6 +121,9 @@ threshold, so it always returns up to 10 plausible hits even for a nonsense query: never read a result as proof a concept exists, or an unexpected result as proof it is absent. `docs endpoints --filter` DOES have a true no-match. +Don't store files in `~/.c1i/cache/`: a spec refresh deletes any file there +that the running version doesn't use. + ## Choosing a command Prefer a first-class command (`users get`, `mcp servers register`, `grants diff --git a/cmd/cache_test.go b/cmd/cache_test.go index 77e32bd..6dd83cc 100644 --- a/cmd/cache_test.go +++ b/cmd/cache_test.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -105,6 +106,9 @@ func TestCachePruneSkipsSymlinkedCacheDir(t *testing.T) { // With no home dir the cache path is relative, so it lands in the working dir. func TestCachePruneSkipsRelativeCacheDir(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("an empty HOME doesn't unset the home dir on Windows") + } t.Setenv("HOME", "") t.Chdir(t.TempDir()) cacheDir := filepath.Join(cacheDirName, "cache") From b43dd5227efff5ad23b23c222fb4b853a41a209e Mon Sep 17 00:00:00 2001 From: leet-c1 <264029741+leet-c1@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:21:16 +0000 Subject: [PATCH 4/5] Test that the cache sweep keeps link targets, subdirectory files and siblings Plant a symlink entry, a file inside a subdirectory, and state beside the cache dir. The sweep must remove only the link. Also restore the docs openapi command's output and context after each test. Co-Authored-By: Claude Opus 5.5 --- cmd/cache_test.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/cmd/cache_test.go b/cmd/cache_test.go index dba5f54..a568dab 100644 --- a/cmd/cache_test.go +++ b/cmd/cache_test.go @@ -40,8 +40,13 @@ func refreshOpenAPICache(t *testing.T, cacheDir string) { }) t.Cleanup(func() { http.DefaultClient.Transport = orig }) + prevCtx := docsOpenapiCmd.Context() docsOpenapiCmd.SetOut(&bytes.Buffer{}) docsOpenapiCmd.SetContext(context.Background()) + t.Cleanup(func() { + docsOpenapiCmd.SetOut(nil) + docsOpenapiCmd.SetContext(prevCtx) + }) if err := docsOpenapiCmd.RunE(docsOpenapiCmd, nil); err != nil { t.Fatalf("docs openapi: %v", err) } @@ -63,6 +68,19 @@ func TestCacheWritePrunesLeftoverFiles(t *testing.T) { t.Fatal(err) } } + // None of these is a cache file, so all must survive: a link's target, a + // file inside a subdirectory, and state beside the cache dir. + outside := filepath.Join(t.TempDir(), "outside.txt") + inner := filepath.Join(cacheDir, "subdir", "inner.txt") + sibling := filepath.Join(home, cacheDirName, "other-state.json") + for _, p := range []string{outside, inner, sibling} { + if err := os.WriteFile(p, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink(outside, filepath.Join(cacheDir, "link")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } refreshOpenAPICache(t, cacheDir) @@ -78,6 +96,11 @@ func TestCacheWritePrunesLeftoverFiles(t *testing.T) { if strings.Join(got, ",") != strings.Join(want, ",") { t.Errorf("cache dir holds %v, want %v", got, want) } + for _, p := range []string{outside, inner, sibling} { + if b, err := os.ReadFile(p); err != nil || string(b) != "keep" { + t.Errorf("%s was removed or changed: %q, %v", p, b, err) + } + } } // A cache dir the user redirected elsewhere may hold files c1i doesn't own. From cd726a9f1bab3cb8799b45ed7bab365ad5918f25 Mon Sep 17 00:00:00 2001 From: leet-c1 <264029741+leet-c1@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:23:57 +0000 Subject: [PATCH 5/5] Keep the baseline sweep assertions when symlinks are unavailable Co-Authored-By: Claude Opus 5.5 --- cmd/cache_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/cache_test.go b/cmd/cache_test.go index a568dab..21a0b72 100644 --- a/cmd/cache_test.go +++ b/cmd/cache_test.go @@ -79,7 +79,7 @@ func TestCacheWritePrunesLeftoverFiles(t *testing.T) { } } if err := os.Symlink(outside, filepath.Join(cacheDir, "link")); err != nil { - t.Skipf("symlinks unavailable: %v", err) + t.Logf("symlinks unavailable, link entry not covered: %v", err) } refreshOpenAPICache(t, cacheDir)