From 17a3de070ec90e79d7a942a13fa7146341167546 Mon Sep 17 00:00:00 2001 From: gen16k Date: Sun, 6 Sep 2026 04:52:11 +0900 Subject: [PATCH] The picker cache the old rows came from is taken away, not just stopped being written (#1185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found on sv-mag while checking #1185 on a real host, and reproduced against the real client before fixing. Before #1185 the Waired rows reached the /model picker by waired writing Claude Code's own discovery cache and setting the flag that makes the picker read it. #1185 stopped writing both. It did not take either away — the flag is scrubbed at the next root `waired claude enable`, and the file was not removed at all. So a host that upgrades and has not re-run enable as root carries a stale cache that Claude Code still reads. Measured on 2.1.261: the picker shows BOTH, the three old rows under their old names and the new ones beside them. sv-mag is in exactly that state today — a cache written on 2026-09-03 naming claude-waired-auto, anthropic-waired-local and claude-waired-peer. The per-user picker write takes it away now. That is the half that needs no elevation and runs on every `claude` launch through the SessionStart hook, so it closes the window without waiting for anybody to re-run enable; by the time the root half scrubs the flag there is nothing left to read. Ownership as everywhere else: only a document that names THIS gateway and whose every row is a Waired id. A cache describing some other gateway is somebody else's — and Claude Code ignores it anyway, since it compares baseUrl against the live ANTHROPIC_BASE_URL by exact string. Absent, unreadable, unparseable or foreign are all left alone without an error, because this runs inside a hook on every launch. Verified on sv-mag with a build of this branch: the file was there before the write and gone after it, and the rows in settings.json are the new ones. Refs #1185 Refs waired-ai/waired#1313 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HJUUSmAfbRdjER1seDdjXm Signed-off-by: gen16k --- cmd/waired/claude_picker_write.go | 30 ++++++ .../integration/claudecode/retiredcache.go | 99 +++++++++++++++++++ .../claudecode/retiredcache_test.go | 99 +++++++++++++++++++ 3 files changed, 228 insertions(+) create mode 100644 internal/integration/claudecode/retiredcache.go create mode 100644 internal/integration/claudecode/retiredcache_test.go diff --git a/cmd/waired/claude_picker_write.go b/cmd/waired/claude_picker_write.go index 51031391b..857ddf61f 100644 --- a/cmd/waired/claude_picker_write.go +++ b/cmd/waired/claude_picker_write.go @@ -77,6 +77,10 @@ func newClaudePickerCmd() *cobra.Command { fmt.Fprintf(stdout, "Wrote Claude Code /model rows: %s\n", path) return nil case "remove": + if _, err := claudecode.RemoveRetiredCache( + claudecode.ClaudeConfigDir(), home, baseURLFromManagedSettings()); err != nil { + fmt.Fprintf(stderr, "warning: %v\n", err) + } _, err := claudecode.RemovePickerLineup(claudecode.SettingsPath(home)) return err default: @@ -130,6 +134,23 @@ func writePickerRows(home, baseURL string, peerEntries int) (path string, change } path = claudecode.SettingsPath(home) changed, err = claudecode.WritePickerLineup(path, pickerRows(defaultMgmtAddr, peerEntries)) + if err != nil { + return path, changed, err + } + // The upgrade path. Until waired-agent#1185 the rows reached the picker + // through Claude Code's own discovery cache, which waired wrote and + // nothing removed. The flag that makes the picker read it is scrubbed by + // the next root `waired claude enable`, but until that happens the + // picker shows the stale rows AND these ones — measured on 2.1.261, + // 2026-09-06, three old names beside the new ones. This half needs no + // elevation and runs on every launch, so it is the half that closes the + // window. + if gone, err := claudecode.RemoveRetiredCache( + claudecode.ClaudeConfigDir(), home, baseURL); err != nil { + fmt.Fprintf(stderr, "warning: %v\n", err) + } else if gone { + changed = true + } return path, changed, err } @@ -213,3 +234,12 @@ func invokerPickerHome(action string) (home string, ok bool) { } return home, true } + +// baseURLFromManagedSettings is the live ANTHROPIC_BASE_URL, which is what a +// retired cache has to name to be one of ours. Read here rather than passed +// in: `_picker remove` runs from `waired claude disable`, which has no base +// URL to hand it, and the file is the same one the reader compares against. +func baseURLFromManagedSettings() string { + _, _, baseURL := claudemanaged.View() + return baseURL +} diff --git a/internal/integration/claudecode/retiredcache.go b/internal/integration/claudecode/retiredcache.go new file mode 100644 index 000000000..486b3a327 --- /dev/null +++ b/internal/integration/claudecode/retiredcache.go @@ -0,0 +1,99 @@ +package claudecode + +// Taking away the picker cache a previous waired wrote (waired-agent#1185's +// upgrade path). +// +// Before #1185 the Waired rows reached the /model picker by waired writing +// Claude Code's own discovery cache, ~/.claude/cache/gateway-models.json, and +// setting CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY so the picker read it. +// The rows come from the `modelPicker` setting now, and the flag is scrubbed +// — but only by a root `waired claude enable`, and nothing removed the file +// at all. +// +// Measured on Claude Code 2.1.261 (2026-09-06): with the flag still set and +// the stale file still on disk, the picker shows BOTH — the three old rows +// under their old names and the new ones beside them. On a host that upgrades +// and does not immediately re-run enable as root, that is what the operator +// sees. +// +// So the per-user picker write takes the file away, which is the half that +// needs no elevation and runs on every `claude` launch through the +// SessionStart hook. The root half (scrubbing the flag) still happens at the +// next enable; by then there is nothing left for it to read. +// +// Ownership, as everywhere else: only a document that names THIS gateway and +// whose every row is a Waired id. A cache describing some other gateway is +// somebody else's — and Claude Code ignores it anyway, since it compares +// baseUrl against the live ANTHROPIC_BASE_URL by exact string. + +import ( + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" +) + +// retiredCacheFile is the name Claude Code gives its discovery cache. +const retiredCacheFile = "gateway-models.json" + +// claudeConfigDirEnv relocates the whole ~/.claude tree, this file included. +const claudeConfigDirEnv = "CLAUDE_CONFIG_DIR" + +// ClaudeConfigDir reports CLAUDE_CONFIG_DIR, or "" when it is unset. +func ClaudeConfigDir() string { return os.Getenv(claudeConfigDirEnv) } + +// RetiredCachePath is where the cache lives for this user. +func RetiredCachePath(configDir, home string) string { + root := configDir + if root == "" { + root = filepath.Join(home, ".claude") + } + return filepath.Join(root, "cache", retiredCacheFile) +} + +// retiredCacheDoc is the shape waired used to write. Only the two fields that +// decide ownership are decoded; anything else Claude Code has since added +// rides along and is irrelevant to the question. +type retiredCacheDoc struct { + BaseURL string `json:"baseUrl"` + Models []struct { + ID string `json:"id"` + } `json:"models"` +} + +// RemoveRetiredCache deletes the pre-#1185 picker cache when it is one waired +// wrote for this gateway, and reports whether it did. +// +// Absent, unreadable, unparseable, or describing a different gateway: left +// alone, no error. This runs inside a SessionStart hook on every launch, so a +// surprise here would be a failure on a path whose whole job is best-effort. +func RemoveRetiredCache(configDir, home, baseURL string) (removed bool, err error) { + if baseURL == "" { + return false, nil + } + path := RetiredCachePath(configDir, home) + b, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + if err != nil { + return false, nil + } + var doc retiredCacheDoc + if json.Unmarshal(b, &doc) != nil { + return false, nil + } + if doc.BaseURL != baseURL || len(doc.Models) == 0 { + return false, nil + } + for _, m := range doc.Models { + if !IsWairedModelID(m.ID) { + return false, nil + } + } + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return false, err + } + return true, nil +} diff --git a/internal/integration/claudecode/retiredcache_test.go b/internal/integration/claudecode/retiredcache_test.go new file mode 100644 index 000000000..cfd453052 --- /dev/null +++ b/internal/integration/claudecode/retiredcache_test.go @@ -0,0 +1,99 @@ +package claudecode + +import ( + "os" + "path/filepath" + "testing" +) + +const liveBaseURL = "http://127.0.0.1:9472" + +func seedRetiredCache(t *testing.T, body string) (home, path string) { + t.Helper() + home = t.TempDir() + path = RetiredCachePath("", home) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return home, path +} + +// TestRemoveRetiredCache is the upgrade path for waired-agent#1185. Until it, +// the Waired rows reached the picker through Claude Code's own discovery +// cache; a host that upgrades still has that file, and while the discovery +// flag survives (it is scrubbed only by a root enable) the picker shows the +// stale rows beside the new ones — measured on 2.1.261, 2026-09-06. +func TestRemoveRetiredCache(t *testing.T) { + // What a pre-#1185 waired actually wrote, copied off a real host. + ours := `{"baseUrl":"` + liveBaseURL + `","fetchedAt":1788458345856,"models":[` + + `{"id":"claude-waired-auto","display_name":"Waired — 200k (any of your devices)"},` + + `{"id":"anthropic-waired-local","display_name":"Waired local (this device)"},` + + `{"id":"claude-waired-peer","display_name":"Waired peer (another device)"}]}` + + t.Run("ours is taken away", func(t *testing.T) { + home, path := seedRetiredCache(t, ours) + removed, err := RemoveRetiredCache("", home, liveBaseURL) + if err != nil || !removed { + t.Fatalf("RemoveRetiredCache = (%v, %v), want (true, nil)", removed, err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("the file is still there (stat err = %v)", err) + } + }) + + // Everything below is left alone, and none of it is an error: this runs + // inside a SessionStart hook on every `claude` launch. + for _, tc := range []struct { + name string + body string + base string + }{ + {"a cache naming a different gateway", ours, "http://127.0.0.1:9999"}, + {"a cache holding a model that is not ours", + `{"baseUrl":"` + liveBaseURL + `","models":[{"id":"claude-opus-4-8"}]}`, liveBaseURL}, + {"one waired id among somebody else's", + `{"baseUrl":"` + liveBaseURL + `","models":[{"id":"waired"},{"id":"claude-opus-4-8"}]}`, liveBaseURL}, + {"an empty model list", `{"baseUrl":"` + liveBaseURL + `","models":[]}`, liveBaseURL}, + {"not JSON", `{`, liveBaseURL}, + {"no live base URL to compare against", ours, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + home, path := seedRetiredCache(t, tc.body) + removed, err := RemoveRetiredCache("", home, tc.base) + if err != nil || removed { + t.Fatalf("RemoveRetiredCache = (%v, %v), want (false, nil)", removed, err) + } + if _, err := os.Stat(path); err != nil { + t.Errorf("the file was deleted anyway: %v", err) + } + }) + } + + t.Run("no file at all", func(t *testing.T) { + removed, err := RemoveRetiredCache("", t.TempDir(), liveBaseURL) + if err != nil || removed { + t.Errorf("RemoveRetiredCache = (%v, %v), want (false, nil)", removed, err) + } + }) + + // CLAUDE_CONFIG_DIR relocates the whole tree, this file with it. + t.Run("under CLAUDE_CONFIG_DIR", func(t *testing.T) { + cfg := t.TempDir() + path := RetiredCachePath(cfg, "/home/ignored") + if want := filepath.Join(cfg, "cache", "gateway-models.json"); path != want { + t.Fatalf("path = %q, want %q", path, want) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(ours), 0o600); err != nil { + t.Fatal(err) + } + if removed, err := RemoveRetiredCache(cfg, "/home/ignored", liveBaseURL); err != nil || !removed { + t.Fatalf("RemoveRetiredCache = (%v, %v), want (true, nil)", removed, err) + } + }) +}