Skip to content

Commit 7b05740

Browse files
author
ak2k
committed
fix(claude): stop macOS Keychain corrupting OAuth credentials on refresh
Claude credentials are persisted to the macOS login Keychain with `security add-generic-password -w <json>`. credentialPayload wrote the JSON with json.MarshalIndent (multi-line). macOS `security find-generic-password -w` hex-encodes any generic-password value containing a newline on read, so the stored credential came back as an ASCII hex string like "7b0a2020..." that fails to JSON-parse ("invalid character 'b' after top-level value"). Because the daemon rewrites the credential after every OAuth token refresh, each Claude account self-corrupted within its access-token lifetime: reads then fail, the account is skipped, usage fetches 401, it scores headroom=0, and routing returns "no non-exhausted claude accounts available". Fix: - credentialPayload writes compact json.Marshal (single line). json.Marshal escapes any in-string newline to \n, so the value can never contain a raw newline and `security` returns it as text. - parseCredentialPayload heals already-hex-encoded blobs on read via healHexEncodedCredential (gated on the "7b"/"5b" ASCII-hex prefix; the hex decode + json.Unmarshal are the real gate, so it is a no-op on ordinary JSON, which starts with the byte '{' not the ASCII "7b"). Adds unit tests for the compact-write invariant, hex heal round-trip, no-op on plain JSON, and fail-closed handling of malformed input.
1 parent 46607df commit 7b05740

2 files changed

Lines changed: 101 additions & 3 deletions

File tree

internal/agents/claude/store.go

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,7 @@ func readCredentialFile(instancePath string) (*CredentialInfo, bool) {
592592
}
593593

594594
func parseCredentialPayload(body []byte) (*CredentialInfo, error) {
595+
body = healHexEncodedCredential(body)
595596
var raw struct {
596597
ClaudeAIOAuth *CredentialInfo `json:"claudeAiOauth"`
597598
}
@@ -601,14 +602,48 @@ func parseCredentialPayload(body []byte) (*CredentialInfo, error) {
601602
return raw.ClaudeAIOAuth, nil
602603
}
603604

605+
// healHexEncodedCredential recovers credentials that macOS `security
606+
// find-generic-password -w` returned hex-encoded. `security` hex-encodes a
607+
// generic-password value on read whenever it contains a newline, so a
608+
// credential previously written with indented (multi-line) JSON round-trips as
609+
// a hex string like "7b0a2020..." that fails to JSON-parse. Detect that shape
610+
// (credential JSON always starts with '{' or '['; its hex form starts with the
611+
// ASCII "7b" or "5b") and decode it back. credentialPayload now writes compact
612+
// JSON so new writes avoid this, but this keeps already-written blobs readable.
613+
//
614+
// hex.DecodeString plus the subsequent json.Unmarshal are the real gate: a body
615+
// that only coincidentally starts with "7b"/"5b" but isn't valid hex-of-JSON
616+
// fails to decode (or decodes to non-JSON) and is returned unchanged, so this
617+
// is a no-op on ordinary JSON.
618+
func healHexEncodedCredential(body []byte) []byte {
619+
trimmed := bytes.TrimSpace(body)
620+
if len(trimmed) < 2 || len(trimmed)%2 != 0 {
621+
return body
622+
}
623+
// `security` emits lowercase hex; hex.DecodeString accepts either case, so
624+
// match either and let the decode be the real gate.
625+
prefix := bytes.ToLower(trimmed[:2])
626+
if !bytes.Equal(prefix, []byte("7b")) && !bytes.Equal(prefix, []byte("5b")) {
627+
return body
628+
}
629+
decoded, err := hex.DecodeString(string(trimmed))
630+
if err != nil {
631+
return body
632+
}
633+
return decoded
634+
}
635+
604636
func credentialPayload(credential CredentialInfo) ([]byte, error) {
605-
body, err := json.MarshalIndent(map[string]CredentialInfo{
637+
// Compact (single-line) JSON: macOS `security` hex-encodes multi-line
638+
// keychain values on read, which breaks parseCredentialPayload. json.Marshal
639+
// escapes any in-string newline to \n, so the output is always single-line.
640+
// See healHexEncodedCredential.
641+
body, err := json.Marshal(map[string]CredentialInfo{
606642
"claudeAiOauth": credential,
607-
}, "", " ")
643+
})
608644
if err != nil {
609645
return nil, err
610646
}
611-
body = append(body, '\n')
612647
return body, nil
613648
}
614649

internal/agents/claude/store_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package claude
22

33
import (
4+
"bytes"
45
"context"
6+
"encoding/hex"
57
"encoding/json"
68
"net/http"
79
"net/http/httptest"
@@ -91,6 +93,67 @@ func TestClaudeConfigDirFallsBackWhenAliasMissing(t *testing.T) {
9193
}
9294
}
9395

96+
func TestCredentialPayloadIsSingleLine(t *testing.T) {
97+
// macOS `security find-generic-password -w` hex-encodes any keychain value
98+
// containing a newline on read, so the persisted payload must be one line.
99+
body, err := credentialPayload(CredentialInfo{AccessToken: "tok", RefreshToken: "refresh", SubscriptionType: "pro"})
100+
if err != nil {
101+
t.Fatal(err)
102+
}
103+
if bytes.ContainsRune(body, '\n') {
104+
t.Fatalf("credentialPayload must be single-line (no newline); got %q", body)
105+
}
106+
cred, err := parseCredentialPayload(body)
107+
if err != nil || cred == nil {
108+
t.Fatalf("round-trip parse: cred=%v err=%v", cred, err)
109+
}
110+
if cred.AccessToken != "tok" || cred.RefreshToken != "refresh" {
111+
t.Fatalf("round-trip credential = %+v, want accessToken=tok refreshToken=refresh", cred)
112+
}
113+
}
114+
115+
func TestParseCredentialPayloadHealsHexEncoded(t *testing.T) {
116+
// A credential written by an older build (indented JSON) that macOS
117+
// `security -w` returned hex-encoded on read must still be readable.
118+
indented := []byte("{\n \"claudeAiOauth\": {\n \"accessToken\": \"tok\",\n \"refreshToken\": \"refresh\"\n }\n}")
119+
hexed := []byte(hex.EncodeToString(indented))
120+
cred, err := parseCredentialPayload(hexed)
121+
if err != nil {
122+
t.Fatalf("heal+parse hex-encoded credential: %v", err)
123+
}
124+
if cred == nil || cred.AccessToken != "tok" || cred.RefreshToken != "refresh" {
125+
t.Fatalf("healed credential = %+v, want accessToken=tok refreshToken=refresh", cred)
126+
}
127+
}
128+
129+
func TestHealHexEncodedCredentialNoOpOnPlainJSON(t *testing.T) {
130+
// Real credential JSON starts with the byte '{' (not the ASCII "7b"), so the
131+
// heal must never mutate it.
132+
for name, in := range map[string][]byte{
133+
"compact": []byte(`{"claudeAiOauth":{"accessToken":"tok"}}`),
134+
"indented": []byte("{\n \"claudeAiOauth\": {}\n}"),
135+
"leading-space": []byte(` {"claudeAiOauth":{}}`),
136+
} {
137+
if got := healHexEncodedCredential(in); !bytes.Equal(got, in) {
138+
t.Fatalf("%s: heal mutated plain JSON: %q -> %q", name, in, got)
139+
}
140+
}
141+
}
142+
143+
func TestHealHexEncodedCredentialGuardsMalformed(t *testing.T) {
144+
// Inputs that pass the "7b"/"5b" prefix filter but are not valid hex must be
145+
// returned unchanged (fail-closed; downstream json.Unmarshal reports it).
146+
for _, in := range [][]byte{
147+
[]byte("7"), // too short
148+
[]byte("7b0"), // odd length
149+
[]byte("7bzz"), // non-hex body
150+
} {
151+
if got := healHexEncodedCredential(in); !bytes.Equal(got, in) {
152+
t.Fatalf("guard: %q was mutated to %q", in, got)
153+
}
154+
}
155+
}
156+
94157
func TestReadCredentialFile(t *testing.T) {
95158
dir := t.TempDir()
96159
if err := os.WriteFile(filepath.Join(dir, ".credentials.json"), []byte(`{"claudeAiOauth":{"accessToken":"tok","refreshToken":"refresh","subscriptionType":"pro"}}`), 0o600); err != nil {

0 commit comments

Comments
 (0)