Vectrum
Sign In

harness: borrow the Claude Code login when nothing else is set

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>
Jjonicommitted 1 parent 28d36f5commit89953c1

Showing 5 changed files with 213 additions and 7 deletions.

+102−0cmd/harness/auth_test.goaddedView File
@@ -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+ }
+51−7cmd/harness/main.goView File
@@ -16,6 +16,7 @@ import (
1616
1717 "harness/internal/agent"
1818 "harness/internal/approval"
19+ "harness/internal/claudecode"
1920 "harness/internal/llm"
2021 "harness/internal/metrics"
2122 "harness/internal/skills"
@@ -55,6 +56,7 @@ func run() error {
5556 yolo := flag.Bool("yolo", false, "skip every approval prompt. Only against a throwaway clone or inside a container")
5657 noTUI := flag.Bool("no-tui", false, "use the plain line-based REPL instead of the full-screen TUI")
5758 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)
5860 flag.Parse()
5961
6062 root, err := filepath.Abs(*workdir)
@@ -62,12 +64,30 @@ func run() error {
6264 return err
6365 }
6466
65- client := llm.New(
67+ opts := []llm.Option{
6668 llm.WithModel(*model),
6769 llm.WithBaseURL(*baseURL),
6870 llm.WithAPIKey(*apiKey),
6971 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+ }
7191 if !client.HasCredentials() {
7292 return errNoCredentials
7393 }
@@ -100,14 +120,12 @@ func run() error {
100120 // to this endpoint, not to Anthropic.
101121 notice("sending requests to %s", client.BaseURL)
102122 }
123+ if authNote != "" {
124+ notice("%s", authNote)
125+ }
103126
104127 var found []skills.Skill
105128 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- }
111129 var problems []error
112130 found, problems = skills.Discover(home, root)
113131 for _, p := range problems {
@@ -297,8 +315,34 @@ var errNoCredentials = errors.New(`no credentials. Supply one of:
297315 export ANTHROPIC_API_KEY=sk-ant-... (sent as x-api-key)
298316 export ANTHROPIC_AUTH_TOKEN=... (sent as Authorization: Bearer)
299317 -api-key / -auth-token (flags override the environment)
318+ log in with Claude Code (borrowed automatically, Anthropic endpoint only)
300319 To use a non-Anthropic endpoint, set ANTHROPIC_BASE_URL or -base-url as well.`)
301320
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+
302346 // interactive reports whether both ends of the terminal are a real one. A
303347 // piped stdin or a redirected stdout means the full-screen UI would be wrong,
304348 // and the line-based REPL still works.
+22−0internal/claudecode/claudecode.goView File
@@ -8,6 +8,7 @@
88 package claudecode
99
1010 import (
11+ "context"
1112 "encoding/json"
1213 "errors"
1314 "fmt"
@@ -91,3 +92,24 @@ func (c *Credentials) Expired(now time.Time) bool {
9192 func (c *Credentials) RefreshExpired(now time.Time) bool {
9293 return !now.Before(c.RefreshExpiresAt)
9394 }
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+ }
+31−0internal/claudecode/claudecode_test.goView File
@@ -1,6 +1,7 @@
11 package claudecode
22
33 import (
4+ "context"
45 "errors"
56 "os"
67 "path/filepath"
@@ -122,3 +123,33 @@ func TestRefreshExpiredIsTerminal(t *testing.T) {
122123 t.Error("a refresh token past its expiry is expired")
123124 }
124125 }
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+ }
+7−0internal/llm/client.goView File
@@ -136,6 +136,13 @@ func (c *Client) HasCredentials() bool {
136136 return c.apiKey != "" || c.authToken != "" || c.tokenSource != nil
137137 }
138138
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+
139146 // APIError is a non-2xx response.
140147 type APIError struct {
141148 StatusCode int