Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Showing 6 changed files with 609 additions and 148 deletions.
| @@ -151,9 +151,19 @@ const modelsTimeout = 8 * time.Second | ||
| 151 | 151 | // marked. It exits zero even when it has nothing good to say — a non-zero exit |
| 152 | 152 | // makes the client discard the whole listing, including the part that was |
| 153 | 153 | // 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) { | |
| 155 | 161 | 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.") | |
| 157 | 167 | return |
| 158 | 168 | } |
| 159 | 169 | fmt.Fprintf(w, "You are logged in to %s.\n", client.BaseURL) |
| @@ -161,7 +161,7 @@ func TestModelsOutputParsesTheWayTheClientParsesIt(t *testing.T) { | ||
| 161 | 161 | // No endpoint is reachable here, so this exercises the fallback: the one |
| 162 | 162 | // model we were configured with, and a login state that is still true. |
| 163 | 163 | var out bytes.Buffer |
| 164 | - printModels(&out, testClient(t)) | |
| 164 | + printModels(&out, testClient(t), false) | |
| 165 | 165 | |
| 166 | 166 | loggedIn, models, def := parseLikeT3(out.String()) |
| 167 | 167 | if !loggedIn { |
| @@ -174,7 +174,7 @@ func TestModelsOutputParsesTheWayTheClientParsesIt(t *testing.T) { | ||
| 174 | 174 | |
| 175 | 175 | func TestLoggedOutSaysSoRatherThanFailing(t *testing.T) { |
| 176 | 176 | var out bytes.Buffer |
| 177 | - printModels(&out, noCredentialClient(t)) | |
| 177 | + printModels(&out, noCredentialClient(t), false) | |
| 178 | 178 | |
| 179 | 179 | if loggedIn, _, _ := parseLikeT3(out.String()); loggedIn { |
| 180 | 180 | t.Errorf("a client with no credential reported itself logged in:\n%s", out.String()) |
| @@ -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 | + } | |
| @@ -1,11 +1,17 @@ | ||
| 1 | 1 | package main |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "bytes" | |
| 4 | 5 | "context" |
| 6 | + "errors" | |
| 7 | + "io" | |
| 8 | + "net/http" | |
| 5 | 9 | "os" |
| 6 | 10 | "path/filepath" |
| 11 | + "strings" | |
| 7 | 12 | "testing" |
| 8 | 13 | |
| 14 | + "github.com/johannSo/codey/internal/config" | |
| 9 | 15 | "github.com/johannSo/codey/internal/llm" |
| 10 | 16 | ) |
| 11 | 17 | |
| @@ -27,34 +33,12 @@ func fakeHome(t *testing.T) string { | ||
| 27 | 33 | return home |
| 28 | 34 | } |
| 29 | 35 | |
| 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 | - | |
| 52 | 36 | // tripwireHome writes a credentials file that is malformed JSON: a |
| 53 | 37 | // 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. | |
| 58 | 42 | func tripwireHome(t *testing.T) string { |
| 59 | 43 | t.Helper() |
| 60 | 44 | home := t.TempDir() |
| @@ -68,54 +52,121 @@ func tripwireHome(t *testing.T) string { | ||
| 68 | 52 | return home |
| 69 | 53 | } |
| 70 | 54 | |
| 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}, | |
| 77 | 85 | } |
| 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 | + }) | |
| 80 | 93 | } |
| 81 | 94 | } |
| 82 | 95 | |
| 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)) | |
| 92 | 98 | 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}, | |
| 96 | 122 | } |
| 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 | + }) | |
| 99 | 150 | } |
| 100 | 151 | } |
| 101 | 152 | |
| 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) | |
| 106 | 158 | if err != nil { |
| 107 | - t.Fatalf("resolveClaudeCode: %v", err) | |
| 159 | + t.Fatalf("resolveClaudeCode read the file despite an explicit credential: %v", err) | |
| 108 | 160 | } |
| 109 | 161 | 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") | |
| 111 | 163 | } |
| 112 | 164 | } |
| 113 | 165 | |
| 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. | |
| 115 | 168 | 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) | |
| 119 | 170 | if err != nil { |
| 120 | 171 | t.Fatalf("a missing file is not an error, got %v", err) |
| 121 | 172 | } |
| @@ -123,3 +174,208 @@ func TestClaudeCodeMissingFileIsNotAnError(t *testing.T) { | ||
| 123 | 174 | t.Error("no file means no source") |
| 124 | 175 | } |
| 125 | 176 | } |
| 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 | + } | |
| @@ -89,7 +89,8 @@ func run() error { | ||
| 89 | 89 | noWeb := flag.Bool("no-web", false, "do not offer web search, whatever the config says") |
| 90 | 90 | noSkills := flag.Bool("no-skills", false, "skip discovering skills in ~/"+skills.Dirname+", ./"+skills.Dirname+" and Claude Code's own") |
| 91 | 91 | 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") | |
| 93 | 94 | noHooks := flag.Bool("no-hooks", false, "do not run the hooks in the config, and do not report agent state to herdr") |
| 94 | 95 | noDiagnostics := flag.Bool("no-diagnostics", false, "do not check an edited file with the language server or linter installed for it") |
| 95 | 96 | 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 { | ||
| 125 | 126 | // still starts, and the wizard can write a good one over it. |
| 126 | 127 | cfg, _, cfgErr := config.Load() |
| 127 | 128 | |
| 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. | |
| 132 | 131 | opts := []llm.Option{ |
| 133 | 132 | llm.WithModel(config.Or(*model, cfg.Model)), |
| 134 | 133 | llm.WithBaseURL(*baseURL), |
| 135 | 134 | llm.WithAPIKey(*apiKey), |
| 136 | 135 | llm.WithAuthToken(*authToken), |
| 137 | 136 | } |
| 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 | |
| 143 | 137 | |
| 144 | 138 | home, err := os.UserHomeDir() |
| 145 | 139 | if err != nil { |
| 146 | 140 | // No home directory is not fatal: an explicit credential still works. |
| 147 | 141 | home = "" |
| 148 | 142 | } |
| 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 | |
| 179 | 152 | } |
| 180 | 153 | // The login-and-models probe answers here, where a borrowed Claude Code |
| 181 | 154 | // credential already counts as being logged in, and before the check |
| 182 | 155 | // 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. | |
| 184 | 159 | if inv.Kind == invModels { |
| 185 | - printModels(os.Stdout, client) | |
| 160 | + printModels(os.Stdout, b.client, ccAskable) | |
| 186 | 161 | return nil |
| 187 | 162 | } |
| 188 | 163 | |
| 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 | |
| 191 | 203 | } |
| 192 | 204 | |
| 193 | 205 | effortSetting := config.Or(*effort, cfg.Effort) |
| @@ -780,6 +792,12 @@ func run() error { | ||
| 780 | 792 | // nothing on a machine with no checker installed for anything in |
| 781 | 793 | // the tree, whatever the file says. |
| 782 | 794 | 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, | |
| 783 | 801 | CopyClaudeSkills: func() (int, error) { |
| 784 | 802 | copied, problems, err := skills.CopyClaude(home, root) |
| 785 | 803 | for _, p := range problems { |
| @@ -938,47 +956,6 @@ func beginTurn(parent context.Context) (context.Context, func()) { | ||
| 938 | 956 | } |
| 939 | 957 | } |
| 940 | 958 | |
| 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 | - | |
| 982 | 959 | // interactive reports whether both ends of the terminal are a real one. A |
| 983 | 960 | // piped stdin or a redirected stdout means the full-screen UI would be wrong, |
| 984 | 961 | // and the line-based REPL still works. |
| @@ -88,7 +88,7 @@ func (c *Client) Clone(model string) *Client { | ||
| 88 | 88 | // authenticate with. It is one value rather than three copies of a string |
| 89 | 89 | // because ListModels, CreateMessage and StreamMessage all reach it, and a |
| 90 | 90 | // 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") | |
| 92 | 92 | |
| 93 | 93 | // Option configures a Client. |
| 94 | 94 | type Option func(*Client) |