Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions cmd/waired/claude_picker_write.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}
99 changes: 99 additions & 0 deletions internal/integration/claudecode/retiredcache.go
Original file line number Diff line number Diff line change
@@ -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
}
99 changes: 99 additions & 0 deletions internal/integration/claudecode/retiredcache_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
Loading