Vectrum
Sign In

auth: the Claude Code login, only when asked for

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

Showing 6 changed files with 609 additions and 148 deletions.

+12−2cmd/codey/agentcmd.goView File
@@ -151,9 +151,19 @@ const modelsTimeout = 8 * time.Second
151151 // marked. It exits zero even when it has nothing good to say — a non-zero exit
152152 // makes the client discard the whole listing, including the part that was
153153 // true.
154- func printModels(w io.Writer, client *llm.Client) {
154+ //
155+ // The client is the one startup built, so a Claude Code login counts as being
156+ // logged in exactly when the session would use it: when the config says
157+ // "claude_code_auth": "on". claudeCode says there is such a login on this
158+ // machine going unused, which is worth naming in the logged-out line — the
159+ // probe runs with no flags, so the config is the way to turn it on here.
160+ func printModels(w io.Writer, client *llm.Client, claudeCode bool) {
155161 if !client.HasCredentials() {
156- fmt.Fprintln(w, "You are not logged in. Set ANTHROPIC_API_KEY, or log in with Claude Code.")
162+ if claudeCode {
163+ fmt.Fprintln(w, `You are not logged in. Set ANTHROPIC_API_KEY, or set "claude_code_auth": "on" in Codey's config to use your Claude Code login.`)
164+ return
165+ }
166+ fmt.Fprintln(w, "You are not logged in. Set ANTHROPIC_API_KEY.")
157167 return
158168 }
159169 fmt.Fprintf(w, "You are logged in to %s.\n", client.BaseURL)
+2−2cmd/codey/agentcmd_test.goView File
@@ -161,7 +161,7 @@ func TestModelsOutputParsesTheWayTheClientParsesIt(t *testing.T) {
161161 // No endpoint is reachable here, so this exercises the fallback: the one
162162 // model we were configured with, and a login state that is still true.
163163 var out bytes.Buffer
164- printModels(&out, testClient(t))
164+ printModels(&out, testClient(t), false)
165165
166166 loggedIn, models, def := parseLikeT3(out.String())
167167 if !loggedIn {
@@ -174,7 +174,7 @@ func TestModelsOutputParsesTheWayTheClientParsesIt(t *testing.T) {
174174
175175 func TestLoggedOutSaysSoRatherThanFailing(t *testing.T) {
176176 var out bytes.Buffer
177- printModels(&out, noCredentialClient(t))
177+ printModels(&out, noCredentialClient(t), false)
178178
179179 if loggedIn, _, _ := parseLikeT3(out.String()); loggedIn {
180180 t.Errorf("a client with no credential reported itself logged in:\n%s", out.String())
+218−0cmd/codey/auth.goaddedView File
@@ -0,0 +1,218 @@
1+ package main
2+
3+ // Where the credential comes from.
4+ //
5+ // An API key or a gateway token is the ordinary answer, and nothing here
6+ // touches one: the llm package reads them from the flags and the environment.
7+ // What this file decides is the other answer — the OAuth login Claude Code
8+ // keeps in ~/.claude/.credentials.json — and the rule for it is that it is
9+ // borrowed only when someone asked. That login is issued to Claude Code, and
10+ // using it from another client is outside Anthropic's terms for it. It used to
11+ // be borrowed silently whenever nothing else was set; that was tolerable on
12+ // the one machine Codey was written on and is not for a binary other people
13+ // install, where "a file happened to exist" is not consent.
14+
15+ import (
16+ "bufio"
17+ "errors"
18+ "fmt"
19+ "io"
20+ "path/filepath"
21+ "strings"
22+
23+ "github.com/johannSo/codey/internal/claudecode"
24+ "github.com/johannSo/codey/internal/config"
25+ "github.com/johannSo/codey/internal/llm"
26+ )
27+
28+ // claudeCodeAllowed is whether this run may borrow Claude Code's login, read
29+ // in order of how deliberate each source is: -no-claude-code-auth, then
30+ // -claude-code-auth, then the config, then off.
31+ //
32+ // The refusal outranks the request so that a launch line which has collected
33+ // both — a wrapper script that adds one, a settings box that adds the other —
34+ // comes out on the side that sends nothing it was not sure about. And the
35+ // default is off, so that only a person can turn it on.
36+ func claudeCodeAllowed(refuse, request bool, cfg config.Config) bool {
37+ switch {
38+ case refuse:
39+ return false
40+ case request:
41+ return true
42+ }
43+ return cfg.ClaudeCodeAuthEnabled()
44+ }
45+
46+ // claudeCodeAskable reports whether the question of borrowing Claude Code's
47+ // login arises at all for this client: nothing else was supplied, the
48+ // endpoint is Anthropic's, and there is a login on disk to borrow. Anywhere
49+ // else the answer would change nothing, and a question whose answer changes
50+ // nothing is one the wizard should not ask and an error should not suggest.
51+ //
52+ // It looks for the file without reading it. Whether what is in there is a
53+ // usable token is a question for after someone has said yes.
54+ func claudeCodeAskable(client *llm.Client, home string) bool {
55+ return !client.HasCredentials() && client.IsDefaultEndpoint() && claudecode.Present(home)
56+ }
57+
58+ // resolveClaudeCode returns a token source borrowed from Claude Code's login,
59+ // or nil when one should not be used. The second return is a line for the
60+ // user: a borrowed credential is worth announcing, the same way a non-default
61+ // endpoint is.
62+ //
63+ // Three conditions gate it. It has to have been asked for, which is what
64+ // allowed carries — see claudeCodeAllowed. The client must have no credential
65+ // already, because we never override what the user typed. And the endpoint
66+ // must be Anthropic's, because a credential we found on disk is not one the
67+ // user chose to send — forwarding it to a -base-url someone else operates
68+ // would leak it to a third party because a file happened to exist. All three
69+ // are checked before the file is opened.
70+ func resolveClaudeCode(client *llm.Client, home string, allowed bool) (*claudecode.Source, string, error) {
71+ if !allowed || client.HasCredentials() || !client.IsDefaultEndpoint() {
72+ return nil, "", nil
73+ }
74+ creds, err := claudecode.Load(home)
75+ if errors.Is(err, claudecode.ErrNotFound) {
76+ return nil, "", nil
77+ }
78+ if err != nil {
79+ return nil, "", err
80+ }
81+ note := fmt.Sprintf("using your Claude Code login (%s plan) from ~/%s", creds.SubscriptionType, claudecode.Filename)
82+ // home is handed on so a rotated refresh token can be written back. That
83+ // is the only circumstance in which this process writes to Claude Code's
84+ // file, and not writing then is what would break its login.
85+ //
86+ // The refresh client is deliberately not Codey's: that one allows
87+ // ten minutes for a model to answer, which is the wrong budget for a token
88+ // exchange running under a lock. NewSource supplies a short-timeout client.
89+ return claudecode.NewSource(creds, nil, home), note, nil
90+ }
91+
92+ // borrowed is the client startup ends up with, and what it took to get it.
93+ type borrowed struct {
94+ client *llm.Client
95+ // opts is the list the client was built from, kept so a second client on
96+ // another model can be built the same way.
97+ opts []llm.Option
98+ // source is set only when the credential is Claude Code's, so its
99+ // write-back failure hook can be wired once there is somewhere to report.
100+ source *claudecode.Source
101+ note string
102+ }
103+
104+ // borrowClaudeCode builds the client from opts, and rebuilds it on Claude
105+ // Code's login when that is allowed and nothing else supplied a credential.
106+ //
107+ // The options are a slice rather than a client because the rebuild has to
108+ // replay them: replaying the same list with one more option is how the rebuilt
109+ // client is guaranteed to be the one that passed the endpoint check. It is
110+ // the one place both the session and the `codey models` probe get their
111+ // client from, so the probe cannot report a login the session would refuse.
112+ func borrowClaudeCode(opts []llm.Option, home string, allowed bool) (borrowed, error) {
113+ b := borrowed{client: llm.New(opts...), opts: opts}
114+ if home == "" {
115+ // No home directory is not fatal: an explicit credential still works.
116+ return b, nil
117+ }
118+ src, note, err := resolveClaudeCode(b.client, home, allowed)
119+ if err != nil || src == nil {
120+ return b, err
121+ }
122+ b.opts = append(append([]llm.Option(nil), opts...), llm.WithTokenSource(src.Token))
123+ b.client = llm.New(b.opts...)
124+ // Not expected to ever trigger: resolveClaudeCode already checked the
125+ // endpoint before we agreed to borrow this credential, but that check ran
126+ // against the client we had before this rebuild. This is a guard against
127+ // a future refactor reordering things so the two clients quietly diverge,
128+ // not a condition anyone should see fire.
129+ if !b.client.IsDefaultEndpoint() {
130+ return borrowed{}, errors.New("internal: discovered credential on a non-default endpoint")
131+ }
132+ b.source, b.note = src, note
133+ return b, nil
134+ }
135+
136+ // shouldAskClaudeCode is whether startup, having found no credential, stops to
137+ // ask once about the Claude Code login instead of only failing.
138+ //
139+ // It exists for the people who relied on the silent fallback: after upgrading,
140+ // their next start would otherwise end in "no credentials" for no reason they
141+ // can see. So it asks — but only of a person at a terminal, since a -p run, a
142+ // pipe and a protocol client have nobody to answer; only when the config has
143+ // never recorded an answer, so it is asked once rather than on every start;
144+ // only when the config was read, since the answer is saved over it; and never
145+ // when -no-claude-code-auth already said no.
146+ func shouldAskClaudeCode(cfg config.Config, cfgErr error, askable, refused, tty bool) bool {
147+ return askable && tty && !refused && cfgErr == nil && cfg.ClaudeCodeAuth == ""
148+ }
149+
150+ // askClaudeCode is that question, on the plain terminal before anything takes
151+ // the screen over. The answer is saved either way: yes so the next start does
152+ // not ask again, and no for the same reason — someone who said no should be
153+ // told how to get a key, not asked the same thing every morning. Anything but
154+ // a yes is a no, an empty line included, because the cautious answer is the
155+ // one enter should give.
156+ func askClaudeCode(in io.Reader, out io.Writer, cfg config.Config, save func(config.Config) error) (bool, config.Config) {
157+ fmt.Fprintf(out, "codey: no API key is set, but Claude Code is logged in on this machine (~/%s).\n", claudecode.Filename)
158+ fmt.Fprintln(out, "codey: that login is issued to Claude Code; using it from another client is outside Anthropic's terms for it.")
159+ fmt.Fprint(out, "codey: use it for Codey on this machine? [y/N] ")
160+
161+ line, _ := bufio.NewReader(in).ReadString('\n')
162+ yes := false
163+ switch strings.ToLower(strings.TrimSpace(line)) {
164+ case "y", "yes":
165+ yes = true
166+ }
167+
168+ cfg.ClaudeCodeAuth = config.ClaudeCodeAuthOff
169+ if yes {
170+ cfg.ClaudeCodeAuth = config.ClaudeCodeAuthOn
171+ }
172+ if save != nil {
173+ if err := save(cfg); err != nil {
174+ // The answer still holds for this run; only remembering it failed,
175+ // and the next start will ask again, which is the honest outcome.
176+ fmt.Fprintf(out, "codey: could not save the answer: %v\n", err)
177+ }
178+ }
179+ return yes, cfg
180+ }
181+
182+ // apiKeysURL is where an Anthropic API key is created.
183+ const apiKeysURL = "https://console.anthropic.com/settings/keys"
184+
185+ // noCredentialsError says how to get a credential, because "unauthorized"
186+ // against a gateway that ignores the header is a confusing failure and "no
187+ // credentials" on its own is a dead end.
188+ //
189+ // The Claude Code login is mentioned only where it could be borrowed — there
190+ // is a login on disk and the endpoint is Anthropic's — and with the terms
191+ // attached, because it is a way to start that is yours to choose on your own
192+ // machine rather than the first thing to try.
193+ func noCredentialsError(claudeCode bool, cfgPath string) error {
194+ var b strings.Builder
195+ b.WriteString("no credentials. Set one of:\n")
196+ b.WriteString(" export ANTHROPIC_API_KEY=sk-ant-... create one at " + apiKeysURL + "\n")
197+ b.WriteString(" -base-url URL with ANTHROPIC_AUTH_TOKEN for a gateway (or -api-key / -auth-token)")
198+ if claudeCode {
199+ b.WriteString("\nClaude Code is logged in here: -claude-code-auth, or \"claude_code_auth\": \"on\" in " + cfgPath + ", borrows that login.")
200+ b.WriteString("\nIt is issued to Claude Code; using it from another client is outside Anthropic's terms for it.")
201+ }
202+ return errors.New(b.String())
203+ }
204+
205+ // configPathLabel is where the config lives, with the home directory spelled
206+ // as ~ so the message reads the way the README does.
207+ func configPathLabel(home string) string {
208+ p, err := config.Path()
209+ if err != nil {
210+ return "Codey's config"
211+ }
212+ if home != "" {
213+ if rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, "..") {
214+ return "~/" + filepath.ToSlash(rel)
215+ }
216+ }
217+ return p
218+ }
+314−58cmd/codey/auth_test.goView File
@@ -1,11 +1,17 @@
11 package main
22
33 import (
4+ "bytes"
45 "context"
6+ "errors"
7+ "io"
8+ "net/http"
59 "os"
610 "path/filepath"
11+ "strings"
712 "testing"
813
14+ "github.com/johannSo/codey/internal/config"
915 "github.com/johannSo/codey/internal/llm"
1016 )
1117
@@ -27,34 +33,12 @@ func fakeHome(t *testing.T) string {
2733 return home
2834 }
2935
30- func TestClaudeCodeFillsInAMissingCredential(t *testing.T) {
31- c := llm.New(llm.WithBaseURL(llm.DefaultBaseURL))
32- c.ClearCredentialsForTest()
33- src, note, err := resolveClaudeCode(c, fakeHome(t), false)
34- if err != nil {
35- t.Fatalf("resolveClaudeCode: %v", err)
36- }
37- if src == nil {
38- t.Fatal("expected a token source")
39- }
40- tok, err := src.Token(context.Background())
41- if err != nil {
42- t.Fatalf("token: %v", err)
43- }
44- if tok != "file-token" {
45- t.Errorf("token = %q, want file-token", tok)
46- }
47- if note == "" {
48- t.Error("using a discovered credential should be announced")
49- }
50- }
51-
5236 // tripwireHome writes a credentials file that is malformed JSON: a
5337 // deliberate landmine, not a fixture. If resolveClaudeCode ever reads it
54- // before deciding the endpoint disqualifies discovery, claudecode.Load
55- // returns a parse error and the test surfaces that loudly, rather than
56- // passing on the unobservable (and here false) assumption that the file
57- // went untouched.
38+ // when it should not have — the login was not asked for, or the endpoint
39+ // disqualifies it — claudecode.Load returns a parse error and the test
40+ // surfaces that loudly, rather than passing on the unobservable (and here
41+ // false) assumption that the file went untouched.
5842 func tripwireHome(t *testing.T) string {
5943 t.Helper()
6044 home := t.TempDir()
@@ -68,54 +52,121 @@ func tripwireHome(t *testing.T) string {
6852 return home
6953 }
7054
71- // An explicit credential wins. We never override what the user typed.
72- func TestClaudeCodeIsSkippedWhenACredentialExists(t *testing.T) {
73- c := llm.New(llm.WithBaseURL(llm.DefaultBaseURL), llm.WithAPIKey("sk-ant-explicit"))
74- src, _, err := resolveClaudeCode(c, fakeHome(t), false)
75- if err != nil {
76- t.Fatalf("resolveClaudeCode: %v", err)
55+ // cleanEnv keeps the developer's own shell out of a test about where the
56+ // credential comes from: an exported ANTHROPIC_API_KEY would otherwise be
57+ // the credential every one of these tests found.
58+ func cleanEnv(t *testing.T) {
59+ t.Helper()
60+ t.Setenv("ANTHROPIC_API_KEY", "")
61+ t.Setenv("ANTHROPIC_AUTH_TOKEN", "")
62+ t.Setenv("ANTHROPIC_BASE_URL", "")
63+ }
64+
65+ // The order the switches are read in. The refusal outranks the request, and
66+ // with neither flag the config decides, and with no config the answer is no.
67+ func TestClaudeCodeAuthPrecedence(t *testing.T) {
68+ on := config.Config{ClaudeCodeAuth: config.ClaudeCodeAuthOn}
69+ off := config.Config{ClaudeCodeAuth: config.ClaudeCodeAuthOff}
70+ cases := []struct {
71+ name string
72+ refuse, request bool
73+ cfg config.Config
74+ want bool
75+ }{
76+ {"nothing said: off", false, false, config.Config{}, false},
77+ {"config on", false, false, on, true},
78+ {"config off", false, false, off, false},
79+ {"flag on, no config", false, true, config.Config{}, true},
80+ {"flag on beats config off", false, true, off, true},
81+ {"-no beats config on", true, false, on, false},
82+ {"-no beats the flag", true, true, config.Config{}, false},
83+ {"-no beats the flag and config on", true, true, on, false},
84+ {"an unknown word is not on", false, false, config.Config{ClaudeCodeAuth: "yes"}, false},
7785 }
78- if src != nil {
79- t.Error("an explicit credential must not be replaced by the file")
86+ for _, tc := range cases {
87+ t.Run(tc.name, func(t *testing.T) {
88+ if got := claudeCodeAllowed(tc.refuse, tc.request, tc.cfg); got != tc.want {
89+ t.Errorf("claudeCodeAllowed(-no=%v, -claude-code-auth=%v, %q) = %v, want %v",
90+ tc.refuse, tc.request, tc.cfg.ClaudeCodeAuth, got, tc.want)
91+ }
92+ })
8093 }
8194 }
8295
83- // The security property: a credential we found is never sent to an endpoint
84- // the user pointed us at. "We did not send it" is not observable from outside,
85- // so it is a test rather than a comment. The home directory here contains
86- // malformed JSON rather than a valid credential: if resolveClaudeCode ever
87- // reads the file before rejecting the non-default endpoint, claudecode.Load
88- // fails to parse it and err comes back non-nil, which the assertion below
89- // catches. A nil err is what proves the file was never opened.
90- func TestClaudeCodeIsNeverSentToANonDefaultEndpoint(t *testing.T) {
91- c := llm.New(llm.WithBaseURL("http://gateway.example"))
96+ func noCredentials(baseURL string) *llm.Client {
97+ c := llm.New(llm.WithBaseURL(baseURL))
9298 c.ClearCredentialsForTest()
93- src, _, err := resolveClaudeCode(c, tripwireHome(t), false)
94- if err != nil {
95- t.Fatalf("resolveClaudeCode read the credentials file (err = %v); it must not touch disk once the endpoint disqualifies discovery", err)
99+ return c
100+ }
101+
102+ // The gate, end to end: what the flags and the config say goes through
103+ // claudeCodeAllowed into resolveClaudeCode, as it does in run. Every case
104+ // that must not borrow is run against the tripwire, so "it did not use the
105+ // login" also proves "it did not open the file".
106+ func TestClaudeCodeIsBorrowedOnlyWhenAskedFor(t *testing.T) {
107+ on := config.Config{ClaudeCodeAuth: config.ClaudeCodeAuthOn}
108+ cases := []struct {
109+ name string
110+ refuse, request bool
111+ cfg config.Config
112+ base string
113+ borrow bool
114+ }{
115+ {"off by default", false, false, config.Config{}, llm.DefaultBaseURL, false},
116+ {"off when the config says off", false, false, config.Config{ClaudeCodeAuth: config.ClaudeCodeAuthOff}, llm.DefaultBaseURL, false},
117+ {"on with the config", false, false, on, llm.DefaultBaseURL, true},
118+ {"on with the flag", false, true, config.Config{}, llm.DefaultBaseURL, true},
119+ {"off with -no-claude-code-auth", true, false, on, llm.DefaultBaseURL, false},
120+ {"never to a gateway, config on", false, false, on, "http://gateway.example", false},
121+ {"never to a gateway, flag on", false, true, config.Config{}, "http://gateway.example", false},
96122 }
97- if src != nil {
98- t.Fatal("a discovered credential must never go to a non-default endpoint")
123+ for _, tc := range cases {
124+ t.Run(tc.name, func(t *testing.T) {
125+ home := tripwireHome(t)
126+ if tc.borrow {
127+ home = fakeHome(t)
128+ }
129+ src, note, err := resolveClaudeCode(noCredentials(tc.base), home, claudeCodeAllowed(tc.refuse, tc.request, tc.cfg))
130+ if err != nil {
131+ t.Fatalf("resolveClaudeCode read the credentials file (err = %v); it must not touch disk unless the login was asked for and may be sent", err)
132+ }
133+ if got := src != nil; got != tc.borrow {
134+ t.Fatalf("borrowed = %v, want %v", got, tc.borrow)
135+ }
136+ if !tc.borrow {
137+ return
138+ }
139+ tok, err := src.Token(context.Background())
140+ if err != nil {
141+ t.Fatalf("token: %v", err)
142+ }
143+ if tok != "file-token" {
144+ t.Errorf("token = %q, want file-token", tok)
145+ }
146+ if note == "" {
147+ t.Error("using a borrowed credential should be announced")
148+ }
149+ })
99150 }
100151 }
101152
102- func TestClaudeCodeCanBeDisabled(t *testing.T) {
103- c := llm.New(llm.WithBaseURL(llm.DefaultBaseURL))
104- c.ClearCredentialsForTest()
105- src, _, err := resolveClaudeCode(c, fakeHome(t), true)
153+ // An explicit credential wins, even over a login that was asked for. We never
154+ // override what the user typed.
155+ func TestClaudeCodeIsSkippedWhenACredentialExists(t *testing.T) {
156+ c := llm.New(llm.WithBaseURL(llm.DefaultBaseURL), llm.WithAPIKey("sk-ant-explicit"))
157+ src, _, err := resolveClaudeCode(c, tripwireHome(t), true)
106158 if err != nil {
107- t.Fatalf("resolveClaudeCode: %v", err)
159+ t.Fatalf("resolveClaudeCode read the file despite an explicit credential: %v", err)
108160 }
109161 if src != nil {
110- t.Error("-no-claude-code-auth must skip discovery entirely")
162+ t.Error("an explicit credential must not be replaced by the file")
111163 }
112164 }
113165
114- // Not logged in is not an error. It falls through to errNoCredentials.
166+ // Asked for and not logged in is not an error. It falls through to the
167+ // no-credentials message.
115168 func TestClaudeCodeMissingFileIsNotAnError(t *testing.T) {
116- c := llm.New(llm.WithBaseURL(llm.DefaultBaseURL))
117- c.ClearCredentialsForTest()
118- src, _, err := resolveClaudeCode(c, t.TempDir(), false)
169+ src, _, err := resolveClaudeCode(noCredentials(llm.DefaultBaseURL), t.TempDir(), true)
119170 if err != nil {
120171 t.Fatalf("a missing file is not an error, got %v", err)
121172 }
@@ -123,3 +174,208 @@ func TestClaudeCodeMissingFileIsNotAnError(t *testing.T) {
123174 t.Error("no file means no source")
124175 }
125176 }
177+
178+ // A broken file the user asked us to use is worth stopping for, with the
179+ // path, rather than a "no credentials" that hides why.
180+ func TestClaudeCodeBrokenFileIsAnErrorOnceAskedFor(t *testing.T) {
181+ _, _, err := resolveClaudeCode(noCredentials(llm.DefaultBaseURL), tripwireHome(t), true)
182+ if err == nil || !strings.Contains(err.Error(), ".credentials.json") {
183+ t.Errorf("err = %v, want a parse error naming the file", err)
184+ }
185+ }
186+
187+ // borrowClaudeCode is what run builds its client with. A borrowed login has
188+ // to end up on the client and in the options a second client is built from;
189+ // one that was not asked for leaves both alone.
190+ func TestBorrowClaudeCodeRebuildsTheClient(t *testing.T) {
191+ cleanEnv(t)
192+ opts := []llm.Option{llm.WithModel("codey-test-model")}
193+
194+ b, err := borrowClaudeCode(opts, tripwireHome(t), false)
195+ if err != nil {
196+ t.Fatalf("not asked for: %v", err)
197+ }
198+ if b.client.HasCredentials() || b.source != nil || len(b.opts) != len(opts) {
199+ t.Fatalf("a login nobody asked for was borrowed: %+v", b)
200+ }
201+
202+ b, err = borrowClaudeCode(opts, fakeHome(t), true)
203+ if err != nil {
204+ t.Fatal(err)
205+ }
206+ if !b.client.HasCredentials() || b.source == nil || b.note == "" {
207+ t.Fatalf("asked for, but not borrowed: %+v", b)
208+ }
209+ if len(b.opts) != len(opts)+1 {
210+ t.Errorf("the options a second client is built from do not carry the login: %d, want %d", len(b.opts), len(opts)+1)
211+ }
212+ if again := llm.New(b.opts...); !again.HasCredentials() || again.Model() != "codey-test-model" {
213+ t.Error("a client rebuilt from the options differs from the one in use")
214+ }
215+ }
216+
217+ // The question only arises where its answer changes something.
218+ func TestClaudeCodeAskable(t *testing.T) {
219+ cases := []struct {
220+ name string
221+ client *llm.Client
222+ home string
223+ want bool
224+ }{
225+ {"no key, Anthropic, logged in", noCredentials(llm.DefaultBaseURL), fakeHome(t), true},
226+ {"an API key is set", llm.New(llm.WithBaseURL(llm.DefaultBaseURL), llm.WithAPIKey("k")), fakeHome(t), false},
227+ {"a gateway", noCredentials("http://gateway.example"), fakeHome(t), false},
228+ {"Claude Code not logged in", noCredentials(llm.DefaultBaseURL), t.TempDir(), false},
229+ {"no home directory", noCredentials(llm.DefaultBaseURL), "", false},
230+ }
231+ for _, tc := range cases {
232+ if got := claudeCodeAskable(tc.client, tc.home); got != tc.want {
233+ t.Errorf("%s: askable = %v, want %v", tc.name, got, tc.want)
234+ }
235+ }
236+ }
237+
238+ // The message says how to get a key, and mentions the Claude Code login —
239+ // with the terms — only on a machine where it could be borrowed.
240+ func TestNoCredentialsSaysHowToGetOne(t *testing.T) {
241+ plain := noCredentialsError(false, "~/.config/codey/config.json").Error()
242+ for _, want := range []string{"ANTHROPIC_API_KEY", apiKeysURL, "-base-url", "ANTHROPIC_AUTH_TOKEN"} {
243+ if !strings.Contains(plain, want) {
244+ t.Errorf("the message does not mention %q:\n%s", want, plain)
245+ }
246+ }
247+ if strings.Contains(plain, "Claude Code") {
248+ t.Errorf("the message offers a Claude Code login there is none of:\n%s", plain)
249+ }
250+
251+ cc := noCredentialsError(true, "~/.config/codey/config.json").Error()
252+ for _, want := range []string{"-claude-code-auth", `"claude_code_auth": "on"`, "~/.config/codey/config.json", "terms"} {
253+ if !strings.Contains(cc, want) {
254+ t.Errorf("the message does not mention %q:\n%s", want, cc)
255+ }
256+ }
257+ if n := strings.Count(cc, "\n") + 1; n > 6 {
258+ t.Errorf("the message is %d lines; keep it short:\n%s", n, cc)
259+ }
260+ }
261+
262+ // Asked once, at a terminal, of someone the config has never recorded an
263+ // answer for — and nowhere else.
264+ func TestShouldAskClaudeCode(t *testing.T) {
265+ unset := config.Config{}
266+ cases := []struct {
267+ name string
268+ cfg config.Config
269+ cfgErr error
270+ askable bool
271+ refused bool
272+ tty bool
273+ want bool
274+ }{
275+ {"upgrading, at a terminal", unset, nil, true, false, true, true},
276+ {"-p or a pipe", unset, nil, true, false, false, false},
277+ {"already answered on", config.Config{ClaudeCodeAuth: config.ClaudeCodeAuthOn}, nil, true, false, true, false},
278+ {"already answered off", config.Config{ClaudeCodeAuth: config.ClaudeCodeAuthOff}, nil, true, false, true, false},
279+ {"-no-claude-code-auth", unset, nil, true, true, true, false},
280+ {"nothing to borrow", unset, nil, false, false, true, false},
281+ {"an unreadable config", unset, errors.New("bad json"), true, false, true, false},
282+ }
283+ for _, tc := range cases {
284+ if got := shouldAskClaudeCode(tc.cfg, tc.cfgErr, tc.askable, tc.refused, tc.tty); got != tc.want {
285+ t.Errorf("%s: ask = %v, want %v", tc.name, got, tc.want)
286+ }
287+ }
288+ }
289+
290+ // The answer is saved either way, so it is asked once; enter is no; and the
291+ // terms are on screen before the question is.
292+ func TestAskClaudeCodeSavesTheAnswer(t *testing.T) {
293+ cases := []struct {
294+ in string
295+ yes bool
296+ save string
297+ }{
298+ {"y\n", true, config.ClaudeCodeAuthOn},
299+ {"YES\n", true, config.ClaudeCodeAuthOn},
300+ {"\n", false, config.ClaudeCodeAuthOff},
301+ {"n\n", false, config.ClaudeCodeAuthOff},
302+ {"", false, config.ClaudeCodeAuthOff}, // ctrl-d
303+ }
304+ for _, tc := range cases {
305+ var out bytes.Buffer
306+ var saved *config.Config
307+ yes, cfg := askClaudeCode(strings.NewReader(tc.in), &out, config.Config{Model: "kept"}, func(c config.Config) error {
308+ saved = &c
309+ return nil
310+ })
311+ if yes != tc.yes {
312+ t.Errorf("%q: yes = %v, want %v", tc.in, yes, tc.yes)
313+ }
314+ if saved == nil || saved.ClaudeCodeAuth != tc.save || cfg.ClaudeCodeAuth != tc.save {
315+ t.Errorf("%q: saved %+v, want claude_code_auth %q", tc.in, saved, tc.save)
316+ }
317+ if saved != nil && saved.Model != "kept" {
318+ t.Errorf("%q: the rest of the config was not kept: %+v", tc.in, saved)
319+ }
320+ if !strings.Contains(out.String(), "terms") {
321+ t.Errorf("the question does not carry the terms caveat:\n%s", out.String())
322+ }
323+ }
324+ }
325+
326+ // refusingTransport answers every request with a 500 and never touches the
327+ // network, so a client on Anthropic's default endpoint can be exercised.
328+ type refusingTransport struct{}
329+
330+ func (refusingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
331+ return &http.Response{
332+ StatusCode: http.StatusInternalServerError,
333+ Body: io.NopCloser(strings.NewReader(`{}`)),
334+ Header: http.Header{},
335+ Request: req,
336+ }, nil
337+ }
338+
339+ // `codey models` reads its client off the same path run does, so a Claude
340+ // Code login counts as logged in exactly when the config turned it on — and
341+ // not merely because the file is there.
342+ func TestModelsProbeFollowsTheOptIn(t *testing.T) {
343+ cleanEnv(t)
344+ home := fakeHome(t)
345+
346+ probe := func(cfg config.Config) string {
347+ rt := refusingTransport{}
348+ opts := []llm.Option{
349+ llm.WithModel("codey-test-model"),
350+ llm.WithHTTPClient(&http.Client{Transport: rt}),
351+ }
352+ askable := claudeCodeAskable(llm.New(opts...), home)
353+ b, err := borrowClaudeCode(opts, home, claudeCodeAllowed(false, false, cfg))
354+ if err != nil {
355+ t.Fatal(err)
356+ }
357+ var out bytes.Buffer
358+ printModels(&out, b.client, askable)
359+ return out.String()
360+ }
361+
362+ out := probe(config.Config{})
363+ if loggedIn, _, _ := parseLikeT3(out); loggedIn {
364+ t.Errorf("a Claude Code login nobody turned on counted as logged in:\n%s", out)
365+ }
366+ if !t3LoggedOut.MatchString(out) {
367+ t.Errorf("the client would read this as unknown rather than logged out:\n%s", out)
368+ }
369+ if !strings.Contains(out, `"claude_code_auth": "on"`) {
370+ t.Errorf("the logged-out line does not say how to turn the login on:\n%s", out)
371+ }
372+
373+ out = probe(config.Config{ClaudeCodeAuth: config.ClaudeCodeAuthOn})
374+ loggedIn, models, def := parseLikeT3(out)
375+ if !loggedIn {
376+ t.Errorf("config on, and the client would read this as logged out:\n%s", out)
377+ }
378+ if len(models) != 1 || def != "codey-test-model" {
379+ t.Errorf("models = %v, default = %q, from:\n%s", models, def, out)
380+ }
381+ }
+62−85cmd/codey/main.goView File
@@ -89,7 +89,8 @@ func run() error {
8989 noWeb := flag.Bool("no-web", false, "do not offer web search, whatever the config says")
9090 noSkills := flag.Bool("no-skills", false, "skip discovering skills in ~/"+skills.Dirname+", ./"+skills.Dirname+" and Claude Code's own")
9191 noAgents := flag.Bool("no-agents", false, "do not offer the subagent tools, so the agent works alone")
92- noClaudeCodeAuth := flag.Bool("no-claude-code-auth", false, "do not fall back to the OAuth login in ~/"+claudecode.Filename)
92+ claudeCodeAuth := flag.Bool("claude-code-auth", false, "with no API key or token set, borrow the OAuth login in ~/"+claudecode.Filename+" for this run — your own machine only; it is issued to Claude Code (config: \"claude_code_auth\": \"on\")")
93+ noClaudeCodeAuth := flag.Bool("no-claude-code-auth", false, "never borrow the OAuth login in ~/"+claudecode.Filename+", whatever -claude-code-auth or the config say")
9394 noHooks := flag.Bool("no-hooks", false, "do not run the hooks in the config, and do not report agent state to herdr")
9495 noDiagnostics := flag.Bool("no-diagnostics", false, "do not check an edited file with the language server or linter installed for it")
9596 noMCP := flag.Bool("no-mcp", false, "do not start the MCP servers in the config, so their tools are not offered")
@@ -125,69 +126,80 @@ func run() error {
125126 // still starts, and the wizard can write a good one over it.
126127 cfg, _, cfgErr := config.Load()
127128
128- // Held as a slice because the Claude Code fallback below may have to
129- // rebuild the client with one more option, and replaying the same list is
130- // how the rebuilt client is guaranteed to be the one that passed the
131- // endpoint check.
129+ // Held as a slice because the Claude Code login may have to rebuild the
130+ // client with one more option — see borrowClaudeCode in auth.go.
132131 opts := []llm.Option{
133132 llm.WithModel(config.Or(*model, cfg.Model)),
134133 llm.WithBaseURL(*baseURL),
135134 llm.WithAPIKey(*apiKey),
136135 llm.WithAuthToken(*authToken),
137136 }
138- client := llm.New(opts...)
139- // clientOpts is the list the live client was actually built from, kept so
140- // a second client on another model can be built the same way. The Claude
141- // Code branch below is the only thing that adds to it.
142- clientOpts := opts
143137
144138 home, err := os.UserHomeDir()
145139 if err != nil {
146140 // No home directory is not fatal: an explicit credential still works.
147141 home = ""
148142 }
149- var authNote string
150- // systemPrefix carries the Claude Code identity block. It is set only when
151- // the credential was borrowed from Claude Code's own login: an OAuth
152- // request that omits it is rejected (as a 429, not a 401 — it reads like a
153- // quota problem and is not one).
154- var systemPrefix string
155- // ccSource is kept so its write-back failure hook can be wired once the
156- // places a warning can go — the notice channel, the event log — exist.
157- var ccSource *claudecode.Source
158- if home != "" {
159- src, note, err := resolveClaudeCode(client, home, *noClaudeCodeAuth)
160- if err != nil {
161- return err
162- }
163- if src != nil {
164- ccSource = src
165- clientOpts = append(opts, llm.WithTokenSource(src.Token))
166- client = llm.New(clientOpts...)
167- // Not expected to ever trigger: resolveClaudeCode already checked
168- // the endpoint before we agreed to borrow this credential, but
169- // that check ran against the client we had before this rebuild.
170- // This is a guard against a future refactor reordering things so
171- // the two clients quietly diverge, not a condition anyone should
172- // see fire.
173- if !client.IsDefaultEndpoint() {
174- return errors.New("internal: discovered credential on a non-default endpoint")
175- }
176- authNote = note
177- systemPrefix = agent.ClaudeCodeIdentity
178- }
143+ // Whether borrowing Claude Code's login is a question for this run at all,
144+ // decided before anything is borrowed: the wizard asks it, and the errors
145+ // mention it, only where the answer would change something.
146+ ccAskable := claudeCodeAskable(llm.New(opts...), home)
147+ // The login is borrowed only when asked for, by flag or config; see
148+ // auth.go for why that is not the default.
149+ b, err := borrowClaudeCode(opts, home, claudeCodeAllowed(*noClaudeCodeAuth, *claudeCodeAuth, cfg))
150+ if err != nil {
151+ return err
179152 }
180153 // The login-and-models probe answers here, where a borrowed Claude Code
181154 // credential already counts as being logged in, and before the check
182155 // below: "not logged in" is the useful answer to that question, not an
183- // error exit that tells the asker nothing.
156+ // error exit that tells the asker nothing. It reads the same flags and
157+ // config the session does, so it cannot report a login the agent run
158+ // would then refuse to use.
184159 if inv.Kind == invModels {
185- printModels(os.Stdout, client)
160+ printModels(os.Stdout, b.client, ccAskable)
186161 return nil
187162 }
188163
189- if !client.HasCredentials() {
190- return errNoCredentials
164+ // Someone upgrading who relied on the old silent fallback is asked once,
165+ // at a terminal, rather than met with an error they have no context for.
166+ tty := inv.Kind == invNormal && *prompt == "" && interactive()
167+ if !b.client.HasCredentials() && shouldAskClaudeCode(cfg, cfgErr, ccAskable, *noClaudeCodeAuth, tty) {
168+ var yes bool
169+ yes, cfg = askClaudeCode(os.Stdin, os.Stderr, cfg, config.Save)
170+ if yes {
171+ if b, err = borrowClaudeCode(opts, home, true); err != nil {
172+ return err
173+ }
174+ }
175+ }
176+ if !b.client.HasCredentials() {
177+ return noCredentialsError(ccAskable, configPathLabel(home))
178+ }
179+
180+ client := b.client
181+ // clientOpts is the list the live client was actually built from, kept so
182+ // a second client on another model can be built the same way.
183+ clientOpts := b.opts
184+ authNote := b.note
185+ // ccSource is kept so its write-back failure hook can be wired once the
186+ // places a warning can go — the notice channel, the event log — exist.
187+ ccSource := b.source
188+ // systemPrefix carries the Claude Code identity block. It is set only when
189+ // the credential was borrowed from Claude Code's own login: an OAuth
190+ // request that omits it is rejected (as a 429, not a 401 — it reads like a
191+ // quota problem and is not one).
192+ var systemPrefix string
193+ // ccInForce is what this session is running on, for the settings panel.
194+ // It is on or nothing: a run where the question arose and the login was
195+ // not borrowed has no credential and has already stopped above, and where
196+ // it did not arise — a key was given, or the endpoint is not Anthropic's —
197+ // the panel reads the file rather than claim a restart would change
198+ // anything.
199+ var ccInForce string
200+ if ccSource != nil {
201+ systemPrefix = agent.ClaudeCodeIdentity
202+ ccInForce = config.ClaudeCodeAuthOn
191203 }
192204
193205 effortSetting := config.Or(*effort, cfg.Effort)
@@ -780,6 +792,12 @@ func run() error {
780792 // nothing on a machine with no checker installed for anything in
781793 // the tree, whatever the file says.
782794 Diagnostics: diagMode,
795+ // Whether to ask about borrowing Claude Code's login: only where
796+ // nothing else supplied a credential and there is a login to
797+ // borrow. And what this session is actually running on, so the
798+ // panel knows when a change waits for the next start.
799+ OfferClaudeCodeAuth: ccAskable,
800+ ClaudeCodeAuth: ccInForce,
783801 CopyClaudeSkills: func() (int, error) {
784802 copied, problems, err := skills.CopyClaude(home, root)
785803 for _, p := range problems {
@@ -938,47 +956,6 @@ func beginTurn(parent context.Context) (context.Context, func()) {
938956 }
939957 }
940958
941- // errNoCredentials spells out every way to supply one, because "unauthorized"
942- // against a gateway that ignores the header is a confusing failure.
943- var errNoCredentials = errors.New(`no credentials. Supply one of:
944- export ANTHROPIC_API_KEY=sk-ant-... (sent as x-api-key)
945- export ANTHROPIC_AUTH_TOKEN=... (sent as Authorization: Bearer)
946- -api-key / -auth-token (flags override the environment)
947- log in with Claude Code (borrowed automatically, Anthropic endpoint only)
948- To use a non-Anthropic endpoint, set ANTHROPIC_BASE_URL or -base-url as well.`)
949-
950- // resolveClaudeCode returns a token source borrowed from Claude Code's login,
951- // or nil when one should not be used. The second return is a line for the
952- // user: a borrowed credential is worth announcing, the same way a non-default
953- // endpoint is.
954- //
955- // Two conditions gate it. The client must have no credential already, because
956- // we never override what the user typed. And the endpoint must be Anthropic's,
957- // because a credential we found on disk is not one the user chose to send —
958- // forwarding it to a -base-url someone else operates would leak it to a third
959- // party because a file happened to exist.
960- func resolveClaudeCode(client *llm.Client, home string, disabled bool) (*claudecode.Source, string, error) {
961- if disabled || client.HasCredentials() || !client.IsDefaultEndpoint() {
962- return nil, "", nil
963- }
964- creds, err := claudecode.Load(home)
965- if errors.Is(err, claudecode.ErrNotFound) {
966- return nil, "", nil
967- }
968- if err != nil {
969- return nil, "", err
970- }
971- note := fmt.Sprintf("using your Claude Code login (%s plan) from ~/%s", creds.SubscriptionType, claudecode.Filename)
972- // home is handed on so a rotated refresh token can be written back. That
973- // is the only circumstance in which this process writes to Claude Code's
974- // file, and not writing then is what would break its login.
975- //
976- // The refresh client is deliberately not Codey's: that one allows
977- // ten minutes for a model to answer, which is the wrong budget for a token
978- // exchange running under a lock. NewSource supplies a short-timeout client.
979- return claudecode.NewSource(creds, nil, home), note, nil
980- }
981-
982959 // interactive reports whether both ends of the terminal are a real one. A
983960 // piped stdin or a redirected stdout means the full-screen UI would be wrong,
984961 // and the line-based REPL still works.
+1−1internal/llm/client.goView File
@@ -88,7 +88,7 @@ func (c *Client) Clone(model string) *Client {
8888 // authenticate with. It is one value rather than three copies of a string
8989 // because ListModels, CreateMessage and StreamMessage all reach it, and a
9090 // caller comparing them should not have to hope they were typed alike.
91- var errNoCredentials = errors.New("llm: no credentials: set ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, or log in with Claude Code")
91+ var errNoCredentials = errors.New("llm: no credentials: set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN")
9292
9393 // Option configures a Client.
9494 type Option func(*Client)