Logged into Claude Code means ./harness starts with no arguments. Two gates. An explicit credential is never overridden, and the file is read only when the endpoint is Anthropic's: a credential we found on disk is not one the user chose to send, and forwarding it to a -base-url someone else runs would leak it because a file happened to exist. No refresh yet — an expired token says to run claude. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing 5 changed files with 213 additions and 7 deletions.
| @@ -0,0 +1,102 @@ | ||
| 1 | + package main | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "context" | |
| 5 | + "os" | |
| 6 | + "path/filepath" | |
| 7 | + "testing" | |
| 8 | + | |
| 9 | + "harness/internal/llm" | |
| 10 | + ) | |
| 11 | + | |
| 12 | + // fakeHome writes a credentials file with a token valid for an hour. | |
| 13 | + func fakeHome(t *testing.T) string { | |
| 14 | + t.Helper() | |
| 15 | + home := t.TempDir() | |
| 16 | + dir := filepath.Join(home, ".claude") | |
| 17 | + if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 18 | + t.Fatal(err) | |
| 19 | + } | |
| 20 | + // expiresAt is 2035, comfortably in the future. | |
| 21 | + body := `{"claudeAiOauth":{"accessToken":"file-token","refreshToken":"r", | |
| 22 | + "expiresAt":2058430000000,"refreshTokenExpiresAt":2058430000000, | |
| 23 | + "scopes":["user:inference"],"subscriptionType":"team"}}` | |
| 24 | + if err := os.WriteFile(filepath.Join(dir, ".credentials.json"), []byte(body), 0o600); err != nil { | |
| 25 | + t.Fatal(err) | |
| 26 | + } | |
| 27 | + return home | |
| 28 | + } | |
| 29 | + | |
| 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(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 | + // An explicit credential wins. We never override what the user typed. | |
| 53 | + func TestClaudeCodeIsSkippedWhenACredentialExists(t *testing.T) { | |
| 54 | + c := llm.New(llm.WithBaseURL(llm.DefaultBaseURL), llm.WithAPIKey("sk-ant-explicit")) | |
| 55 | + src, _, err := resolveClaudeCode(c, fakeHome(t), false) | |
| 56 | + if err != nil { | |
| 57 | + t.Fatalf("resolveClaudeCode: %v", err) | |
| 58 | + } | |
| 59 | + if src != nil { | |
| 60 | + t.Error("an explicit credential must not be replaced by the file") | |
| 61 | + } | |
| 62 | + } | |
| 63 | + | |
| 64 | + // The security property: a credential we found is never sent to an endpoint | |
| 65 | + // the user pointed us at. "We did not send it" is not observable from outside, | |
| 66 | + // so it is a test rather than a comment. | |
| 67 | + func TestClaudeCodeIsNeverSentToANonDefaultEndpoint(t *testing.T) { | |
| 68 | + c := llm.New(llm.WithBaseURL("http://gateway.example")) | |
| 69 | + c.ClearCredentialsForTest() | |
| 70 | + src, _, err := resolveClaudeCode(c, fakeHome(t), false) | |
| 71 | + if err != nil { | |
| 72 | + t.Fatalf("resolveClaudeCode: %v", err) | |
| 73 | + } | |
| 74 | + if src != nil { | |
| 75 | + t.Fatal("a discovered credential must never go to a non-default endpoint") | |
| 76 | + } | |
| 77 | + } | |
| 78 | + | |
| 79 | + func TestClaudeCodeCanBeDisabled(t *testing.T) { | |
| 80 | + c := llm.New(llm.WithBaseURL(llm.DefaultBaseURL)) | |
| 81 | + c.ClearCredentialsForTest() | |
| 82 | + src, _, err := resolveClaudeCode(c, fakeHome(t), true) | |
| 83 | + if err != nil { | |
| 84 | + t.Fatalf("resolveClaudeCode: %v", err) | |
| 85 | + } | |
| 86 | + if src != nil { | |
| 87 | + t.Error("-no-claude-code-auth must skip discovery entirely") | |
| 88 | + } | |
| 89 | + } | |
| 90 | + | |
| 91 | + // Not logged in is not an error. It falls through to errNoCredentials. | |
| 92 | + func TestClaudeCodeMissingFileIsNotAnError(t *testing.T) { | |
| 93 | + c := llm.New(llm.WithBaseURL(llm.DefaultBaseURL)) | |
| 94 | + c.ClearCredentialsForTest() | |
| 95 | + src, _, err := resolveClaudeCode(c, t.TempDir(), false) | |
| 96 | + if err != nil { | |
| 97 | + t.Fatalf("a missing file is not an error, got %v", err) | |
| 98 | + } | |
| 99 | + if src != nil { | |
| 100 | + t.Error("no file means no source") | |
| 101 | + } | |
| 102 | + } | |
| @@ -16,6 +16,7 @@ import ( | ||
| 16 | 16 | |
| 17 | 17 | "harness/internal/agent" |
| 18 | 18 | "harness/internal/approval" |
| 19 | + "harness/internal/claudecode" | |
| 19 | 20 | "harness/internal/llm" |
| 20 | 21 | "harness/internal/metrics" |
| 21 | 22 | "harness/internal/skills" |
| @@ -55,6 +56,7 @@ func run() error { | ||
| 55 | 56 | yolo := flag.Bool("yolo", false, "skip every approval prompt. Only against a throwaway clone or inside a container") |
| 56 | 57 | noTUI := flag.Bool("no-tui", false, "use the plain line-based REPL instead of the full-screen TUI") |
| 57 | 58 | noSkills := flag.Bool("no-skills", false, "skip discovering skills in ~/"+skills.Dirname+" and ./"+skills.Dirname) |
| 59 | + noClaudeCodeAuth := flag.Bool("no-claude-code-auth", false, "do not fall back to the OAuth login in ~/"+claudecode.Filename) | |
| 58 | 60 | flag.Parse() |
| 59 | 61 | |
| 60 | 62 | root, err := filepath.Abs(*workdir) |
| @@ -62,12 +64,30 @@ func run() error { | ||
| 62 | 64 | return err |
| 63 | 65 | } |
| 64 | 66 | |
| 65 | - client := llm.New( | |
| 67 | + opts := []llm.Option{ | |
| 66 | 68 | llm.WithModel(*model), |
| 67 | 69 | llm.WithBaseURL(*baseURL), |
| 68 | 70 | llm.WithAPIKey(*apiKey), |
| 69 | 71 | llm.WithAuthToken(*authToken), |
| 70 | - ) | |
| 72 | + } | |
| 73 | + client := llm.New(opts...) | |
| 74 | + | |
| 75 | + home, err := os.UserHomeDir() | |
| 76 | + if err != nil { | |
| 77 | + // No home directory is not fatal: an explicit credential still works. | |
| 78 | + home = "" | |
| 79 | + } | |
| 80 | + var authNote string | |
| 81 | + if home != "" { | |
| 82 | + src, note, err := resolveClaudeCode(client, home, *noClaudeCodeAuth) | |
| 83 | + if err != nil { | |
| 84 | + return err | |
| 85 | + } | |
| 86 | + if src != nil { | |
| 87 | + client = llm.New(append(opts, llm.WithTokenSource(src))...) | |
| 88 | + authNote = note | |
| 89 | + } | |
| 90 | + } | |
| 71 | 91 | if !client.HasCredentials() { |
| 72 | 92 | return errNoCredentials |
| 73 | 93 | } |
| @@ -100,14 +120,12 @@ func run() error { | ||
| 100 | 120 | // to this endpoint, not to Anthropic. |
| 101 | 121 | notice("sending requests to %s", client.BaseURL) |
| 102 | 122 | } |
| 123 | + if authNote != "" { | |
| 124 | + notice("%s", authNote) | |
| 125 | + } | |
| 103 | 126 | |
| 104 | 127 | var found []skills.Skill |
| 105 | 128 | if !*noSkills { |
| 106 | - home, err := os.UserHomeDir() | |
| 107 | - if err != nil { | |
| 108 | - // No home directory is not fatal: project skills still work. | |
| 109 | - home = "" | |
| 110 | - } | |
| 111 | 129 | var problems []error |
| 112 | 130 | found, problems = skills.Discover(home, root) |
| 113 | 131 | for _, p := range problems { |
| @@ -297,8 +315,34 @@ var errNoCredentials = errors.New(`no credentials. Supply one of: | ||
| 297 | 315 | export ANTHROPIC_API_KEY=sk-ant-... (sent as x-api-key) |
| 298 | 316 | export ANTHROPIC_AUTH_TOKEN=... (sent as Authorization: Bearer) |
| 299 | 317 | -api-key / -auth-token (flags override the environment) |
| 318 | + log in with Claude Code (borrowed automatically, Anthropic endpoint only) | |
| 300 | 319 | To use a non-Anthropic endpoint, set ANTHROPIC_BASE_URL or -base-url as well.`) |
| 301 | 320 | |
| 321 | + // resolveClaudeCode returns a token source borrowed from Claude Code's login, | |
| 322 | + // or nil when one should not be used. The second return is a line for the | |
| 323 | + // user: a borrowed credential is worth announcing, the same way a non-default | |
| 324 | + // endpoint is. | |
| 325 | + // | |
| 326 | + // Two conditions gate it. The client must have no credential already, because | |
| 327 | + // we never override what the user typed. And the endpoint must be Anthropic's, | |
| 328 | + // because a credential we found on disk is not one the user chose to send — | |
| 329 | + // forwarding it to a -base-url someone else operates would leak it to a third | |
| 330 | + // party because a file happened to exist. | |
| 331 | + func resolveClaudeCode(client *llm.Client, home string, disabled bool) (func(context.Context) (string, error), string, error) { | |
| 332 | + if disabled || client.HasCredentials() || !client.IsDefaultEndpoint() { | |
| 333 | + return nil, "", nil | |
| 334 | + } | |
| 335 | + creds, err := claudecode.Load(home) | |
| 336 | + if errors.Is(err, claudecode.ErrNotFound) { | |
| 337 | + return nil, "", nil | |
| 338 | + } | |
| 339 | + if err != nil { | |
| 340 | + return nil, "", err | |
| 341 | + } | |
| 342 | + note := fmt.Sprintf("using your Claude Code login (%s plan) from ~/%s", creds.SubscriptionType, claudecode.Filename) | |
| 343 | + return claudecode.NewSource(creds).Token, note, nil | |
| 344 | + } | |
| 345 | + | |
| 302 | 346 | // interactive reports whether both ends of the terminal are a real one. A |
| 303 | 347 | // piped stdin or a redirected stdout means the full-screen UI would be wrong, |
| 304 | 348 | // and the line-based REPL still works. |
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | package claudecode |
| 9 | 9 | |
| 10 | 10 | import ( |
| 11 | + "context" | |
| 11 | 12 | "encoding/json" |
| 12 | 13 | "errors" |
| 13 | 14 | "fmt" |
| @@ -91,3 +92,24 @@ func (c *Credentials) Expired(now time.Time) bool { | ||
| 91 | 92 | func (c *Credentials) RefreshExpired(now time.Time) bool { |
| 92 | 93 | return !now.Before(c.RefreshExpiresAt) |
| 93 | 94 | } |
| 95 | + | |
| 96 | + // ErrExpired means the stored access token has lapsed. Task 7 may replace this | |
| 97 | + // with a refresh; until then it is the whole story. | |
| 98 | + var ErrExpired = errors.New("claudecode: your Claude Code login has expired, run `claude` to refresh it") | |
| 99 | + | |
| 100 | + // Source hands out the access token for as long as it is valid. It satisfies | |
| 101 | + // the shape llm.WithTokenSource wants. | |
| 102 | + type Source struct { | |
| 103 | + creds *Credentials | |
| 104 | + } | |
| 105 | + | |
| 106 | + // NewSource wraps credentials in a token source. | |
| 107 | + func NewSource(c *Credentials) *Source { return &Source{creds: c} } | |
| 108 | + | |
| 109 | + // Token returns the access token, or ErrExpired once it has lapsed. | |
| 110 | + func (s *Source) Token(context.Context) (string, error) { | |
| 111 | + if s.creds.Expired(time.Now()) { | |
| 112 | + return "", ErrExpired | |
| 113 | + } | |
| 114 | + return s.creds.AccessToken, nil | |
| 115 | + } | |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | package claudecode |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "context" | |
| 4 | 5 | "errors" |
| 5 | 6 | "os" |
| 6 | 7 | "path/filepath" |
| @@ -122,3 +123,33 @@ func TestRefreshExpiredIsTerminal(t *testing.T) { | ||
| 122 | 123 | t.Error("a refresh token past its expiry is expired") |
| 123 | 124 | } |
| 124 | 125 | } |
| 126 | + | |
| 127 | + func TestSourceReturnsTheAccessToken(t *testing.T) { | |
| 128 | + s := NewSource(&Credentials{ | |
| 129 | + AccessToken: "tok", | |
| 130 | + ExpiresAt: time.Now().Add(time.Hour), | |
| 131 | + }) | |
| 132 | + got, err := s.Token(context.Background()) | |
| 133 | + if err != nil { | |
| 134 | + t.Fatalf("Token: %v", err) | |
| 135 | + } | |
| 136 | + if got != "tok" { | |
| 137 | + t.Errorf("Token = %q, want tok", got) | |
| 138 | + } | |
| 139 | + } | |
| 140 | + | |
| 141 | + // Until refresh lands, an expired token is a clear instruction rather than a | |
| 142 | + // 401 from the API. | |
| 143 | + func TestSourceExpiredTokenSaysWhatToDo(t *testing.T) { | |
| 144 | + s := NewSource(&Credentials{ | |
| 145 | + AccessToken: "tok", | |
| 146 | + ExpiresAt: time.Now().Add(-time.Hour), | |
| 147 | + }) | |
| 148 | + _, err := s.Token(context.Background()) | |
| 149 | + if !errors.Is(err, ErrExpired) { | |
| 150 | + t.Fatalf("err = %v, want ErrExpired", err) | |
| 151 | + } | |
| 152 | + if !strings.Contains(err.Error(), "claude") { | |
| 153 | + t.Errorf("the error should say to run claude, got %q", err) | |
| 154 | + } | |
| 155 | + } | |
| @@ -136,6 +136,13 @@ func (c *Client) HasCredentials() bool { | ||
| 136 | 136 | return c.apiKey != "" || c.authToken != "" || c.tokenSource != nil |
| 137 | 137 | } |
| 138 | 138 | |
| 139 | + // ClearCredentialsForTest drops any credential read from the environment, so a | |
| 140 | + // test exercising credential precedence is not at the mercy of the developer's | |
| 141 | + // own shell. | |
| 142 | + func (c *Client) ClearCredentialsForTest() { | |
| 143 | + c.apiKey, c.authToken, c.tokenSource = "", "", nil | |
| 144 | + } | |
| 145 | + | |
| 139 | 146 | // APIError is a non-2xx response. |
| 140 | 147 | type APIError struct { |
| 141 | 148 | StatusCode int |