Vectrum
Sign In

config: claude_code_auth, off unless it says on

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Jjonicommitted 1 parent 219352ecommit8d9f14c

Showing 4 changed files with 111 additions and 0 deletions.

+12−0internal/claudecode/claudecode.goView File
@@ -49,6 +49,18 @@ type stored struct {
4949 } `json:"claudeAiOauth"`
5050 }
5151
52+ // Present reports whether Claude Code has a credentials file in home, without
53+ // reading it. It is what decides whether a question about borrowing the login
54+ // is worth asking, and asking is all it is for: whether the file holds a
55+ // usable token is Load's business, and only once someone has said yes.
56+ func Present(home string) bool {
57+ if home == "" {
58+ return false
59+ }
60+ _, err := os.Stat(filepath.Join(home, Filename))
61+ return err == nil
62+ }
63+
5264 // Load reads and parses the credential store. home is passed in rather than
5365 // looked up so tests can point it at a fixture, matching skills.Discover.
5466 func Load(home string) (*Credentials, error) {
+15−0internal/claudecode/claudecode_test.goView File
@@ -72,6 +72,21 @@ func TestLoadMissingFileIsErrNotFound(t *testing.T) {
7272 }
7373 }
7474
75+ // Present only looks. A file it cannot parse is still a file, because the
76+ // question it answers is whether there is a login to ask about, and the
77+ // answer to a broken one is Load's error once someone has said yes.
78+ func TestPresentOnlyChecksTheFileIsThere(t *testing.T) {
79+ if Present(t.TempDir()) {
80+ t.Error("an empty home has no Claude Code login")
81+ }
82+ if Present("") {
83+ t.Error("no home directory has no Claude Code login")
84+ }
85+ if !Present(write(t, "{not json")) {
86+ t.Error("a credentials file is present whatever is in it")
87+ }
88+ }
89+
7590 // A file that exists but does not parse is a broken install, and saying so
7691 // with the path beats falling through to "no credentials".
7792 func TestLoadMalformedJSONNamesThePath(t *testing.T) {
+29−0internal/config/config.goView File
@@ -118,6 +118,20 @@ const (
118118 DiagnosticsOff = "off"
119119 )
120120
121+ // Claude Code login settings, as written to the file.
122+ //
123+ // Empty means off, and it has to: the login in ~/.claude/.credentials.json is
124+ // issued to Claude Code, and using it from another client is outside
125+ // Anthropic's terms for it. Borrowing it is therefore something you ask for on
126+ // your own machine, never something a fresh install does because a file
127+ // happened to be there. An empty value is also how the startup path tells
128+ // "never asked" apart from an explicit "off", which is what decides whether
129+ // someone upgrading from the silent fallback is asked once or simply told.
130+ const (
131+ ClaudeCodeAuthOn = "on"
132+ ClaudeCodeAuthOff = "off"
133+ )
134+
121135 // DefaultSearchCommand is the command WebSearchCustom runs when nothing else
122136 // is configured: ddgs, the DuckDuckGo CLI, which needs no account and no API
123137 // key. {{query}} and {{count}} are substituted as whole argv elements, never
@@ -165,6 +179,10 @@ type Config struct {
165179 // agent edits is handed to whatever language server or linter this
166180 // machine has for it, and what they say handed back. Empty means on.
167181 Diagnostics string `json:"diagnostics,omitempty"`
182+ // ClaudeCodeAuth is ClaudeCodeAuthOn or ClaudeCodeAuthOff: whether a run
183+ // with no API key or token of its own may borrow Claude Code's OAuth
184+ // login. Empty means off, and means nobody has been asked yet.
185+ ClaudeCodeAuth string `json:"claude_code_auth,omitempty"`
168186 // Hooks maps a lifecycle event name to the shell commands run when it
169187 // happens; the names are the ones in hooks.All. It is the one setting
170188 // here the wizard never asks about and never writes: a hook is a command
@@ -299,6 +317,11 @@ func ValidClaudeSkills(mode string) bool { return contains(ClaudeSkillsModes, mo
299317 // "off" turns it off.
300318 func (c Config) DiagnosticsEnabled() bool { return c.Diagnostics != DiagnosticsOff }
301319
320+ // ClaudeCodeAuthEnabled reports whether the config asks for Claude Code's
321+ // login to be borrowed. Only an explicit "on" does: this is the one setting
322+ // where the cautious reading is also the one Anthropic's terms require.
323+ func (c Config) ClaudeCodeAuthEnabled() bool { return c.ClaudeCodeAuth == ClaudeCodeAuthOn }
324+
302325 // AutoCompactEnabled reports whether the conversation should fold itself when
303326 // the window fills. Only an explicit "off" turns it off.
304327 func (c Config) AutoCompactEnabled() bool { return c.AutoCompact != AutoCompactOff }
@@ -435,6 +458,12 @@ func sanitize(c Config) Config {
435458 if c.Diagnostics != DiagnosticsOn && c.Diagnostics != DiagnosticsOff {
436459 c.Diagnostics = ""
437460 }
461+ // An unrecognised word is not an "on". It is dropped to empty rather than
462+ // to "off", so the one-time question at startup still gets asked: a typo
463+ // is not an answer.
464+ if c.ClaudeCodeAuth != ClaudeCodeAuthOn && c.ClaudeCodeAuth != ClaudeCodeAuthOff {
465+ c.ClaudeCodeAuth = ""
466+ }
438467 c.SearchCommand = strings.TrimSpace(c.SearchCommand)
439468 // Anything other than the one word means the marker was not written by a
440469 // wizard that finished, so it does not count as one.
+55−0internal/config/config_test.goView File
@@ -531,3 +531,58 @@ func TestEqualSeesMCPServers(t *testing.T) {
531531 t.Error("no args should equal an empty args list")
532532 }
533533 }
534+
535+ // The Claude Code login is borrowed only on an explicit "on". A config written
536+ // before the setting existed, and one with a word this build does not know,
537+ // both read as off — and both read as never asked, which is what lets the
538+ // startup path ask someone upgrading from the silent fallback exactly once.
539+ func TestClaudeCodeAuthRoundTripsAndDefaultsToOff(t *testing.T) {
540+ isolate(t)
541+ for _, v := range []string{ClaudeCodeAuthOn, ClaudeCodeAuthOff} {
542+ if err := Save(Config{ClaudeCodeAuth: v}); err != nil {
543+ t.Fatal(err)
544+ }
545+ got, _, err := Load()
546+ if err != nil {
547+ t.Fatal(err)
548+ }
549+ if got.ClaudeCodeAuth != v {
550+ t.Errorf("%q did not survive a round trip: got %q", v, got.ClaudeCodeAuth)
551+ }
552+ if got.ClaudeCodeAuthEnabled() != (v == ClaudeCodeAuthOn) {
553+ t.Errorf("%q: enabled = %v", v, got.ClaudeCodeAuthEnabled())
554+ }
555+ }
556+
557+ if err := Save(Config{ClaudeCodeAuth: "yes"}); err != nil {
558+ t.Fatal(err)
559+ }
560+ got, _, err := Load()
561+ if err != nil {
562+ t.Fatal(err)
563+ }
564+ if got.ClaudeCodeAuth != "" {
565+ t.Errorf("an unrecognised value survived: %q", got.ClaudeCodeAuth)
566+ }
567+ if got.ClaudeCodeAuthEnabled() {
568+ t.Error("an unrecognised value turned the Claude Code login on")
569+ }
570+ if (Config{}).ClaudeCodeAuthEnabled() {
571+ t.Error("an empty config borrows the Claude Code login; it must be off until asked for")
572+ }
573+ }
574+
575+ // The key is spelled the way the README and the error message spell it.
576+ func TestClaudeCodeAuthKeyName(t *testing.T) {
577+ path := isolate(t)
578+ if err := Save(Config{ClaudeCodeAuth: ClaudeCodeAuthOn}); err != nil {
579+ t.Fatal(err)
580+ }
581+ raw, err := os.ReadFile(path)
582+ if err != nil {
583+ t.Fatal(err)
584+ }
585+ if !strings.Contains(string(raw), `"claude_code_auth": "on"`) {
586+ t.Errorf("the file does not say \"claude_code_auth\": \"on\":\n%s", raw)
587+ }
588+ }