Skip to content

Commit 6c958bb

Browse files
committed
fix(codex): serialize enrichment writes and sync plan into OAuth attributes
- Persist List-path subscription enrichment under the store mutex with a re-read+merge of only the subscription keys, so a token refresh/login Save racing the enrichment can no longer have its fresh access/refresh tokens rolled back by a stale-read rename. - In the management Codex OAuth flow, mirror the enriched plan_type and expiry into record.Attributes before saving, so a Free/Plus/Team account added via the management flow is registered with the correct model catalog instead of defaulting to Pro until a later file rescan. Test: persist merges subscription fields without clobbering on-disk tokens.
1 parent b388a44 commit 6c958bb

3 files changed

Lines changed: 89 additions & 1 deletion

File tree

internal/api/handlers/management/auth_files.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2299,6 +2299,19 @@ func (h *Handler) RequestCodexToken(c *gin.Context) {
22992299
Storage: tokenStorage,
23002300
Metadata: metadata,
23012301
}
2302+
// Mirror the enriched subscription fields into attributes the runtime
2303+
// reads (sdk/cliproxy/service.go selects the Codex model catalog from
2304+
// Attributes["plan_type"]); without this a Free/Plus/Team account added
2305+
// through this flow would be registered as the default plan.
2306+
if record.Attributes == nil {
2307+
record.Attributes = make(map[string]string)
2308+
}
2309+
if planType, ok := metadata["plan_type"].(string); ok && strings.TrimSpace(planType) != "" {
2310+
record.Attributes["plan_type"] = strings.TrimSpace(planType)
2311+
}
2312+
if expiry, ok := metadata["subscription_active_until"].(string); ok && strings.TrimSpace(expiry) != "" {
2313+
record.Attributes["subscription_active_until"] = strings.TrimSpace(expiry)
2314+
}
23022315
savedPath, errSave := h.saveTokenRecord(ctx, record)
23032316
if errSave != nil {
23042317
SetOAuthSessionError(state, "Failed to save authentication tokens")

sdk/auth/filestore.go

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ func (s *FileTokenStore) readAuthFile(ctx context.Context, path, baseDir string)
296296
changed, _ := codex.EnrichSubscriptionMetadata(enrichCtx, metadata, http.DefaultClient)
297297
cancelEnrich()
298298
if changed {
299-
_ = writeAuthMetadataFile(path, metadata)
299+
s.persistCodexSubscriptionFields(path, metadata)
300300
}
301301
}
302302
info, errStat = os.Stat(path)
@@ -343,6 +343,43 @@ func (s *FileTokenStore) readAuthFile(ctx context.Context, path, baseDir string)
343343
return auth, nil
344344
}
345345

346+
// codexSubscriptionMetadataKeys are the only keys the List-path enrichment is
347+
// allowed to write back, so a concurrent token Save is never rolled back.
348+
var codexSubscriptionMetadataKeys = []string{
349+
"plan_type",
350+
"subscription_active_until",
351+
"subscription_expired",
352+
"chatgpt_account_id",
353+
"account_id",
354+
"chatgpt_subscription_active_until",
355+
"chatgpt_subscription_last_checked",
356+
}
357+
358+
// persistCodexSubscriptionFields writes the enriched subscription fields back
359+
// to disk under the store mutex (the same lock Save uses). It re-reads the
360+
// current file and merges only the subscription keys, so a token refresh/login
361+
// Save racing the enrichment cannot have its fresh access/refresh tokens
362+
// clobbered by a stale read taken before the network call.
363+
func (s *FileTokenStore) persistCodexSubscriptionFields(path string, enriched map[string]any) {
364+
s.mu.Lock()
365+
defer s.mu.Unlock()
366+
367+
data, errRead := os.ReadFile(path)
368+
if errRead != nil || len(data) == 0 {
369+
return
370+
}
371+
current := make(map[string]any)
372+
if errUnmarshal := json.Unmarshal(data, &current); errUnmarshal != nil {
373+
return
374+
}
375+
for _, key := range codexSubscriptionMetadataKeys {
376+
if value, ok := enriched[key]; ok {
377+
current[key] = value
378+
}
379+
}
380+
_ = writeAuthMetadataFile(path, current)
381+
}
382+
346383
func writeAuthMetadataFile(path string, metadata map[string]any) error {
347384
raw, errMarshal := json.Marshal(metadata)
348385
if errMarshal != nil {

sdk/auth/filestore_metadata_write_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,41 @@ func TestList_CopiesCodexPlanTypeIntoAttributes(t *testing.T) {
9898
t.Fatalf("Attributes[subscription_active_until] = %q, want %s", got, future)
9999
}
100100
}
101+
102+
func TestPersistCodexSubscriptionFields_DoesNotClobberTokens(t *testing.T) {
103+
dir := t.TempDir()
104+
path := filepath.Join(dir, "codex.json")
105+
// Simulate the latest on-disk state written by a concurrent token Save.
106+
if err := os.WriteFile(path, []byte(`{"type":"codex","access_token":"fresh","refresh_token":"fresh-r"}`), 0o600); err != nil {
107+
t.Fatalf("seed: %v", err)
108+
}
109+
110+
store := NewFileTokenStore()
111+
store.SetBaseDir(dir)
112+
// Enrichment built from a STALE read (old tokens) plus new subscription info.
113+
enriched := map[string]any{
114+
"access_token": "stale",
115+
"refresh_token": "stale-r",
116+
"plan_type": "plus",
117+
"subscription_active_until": "2030-01-01T00:00:00Z",
118+
"subscription_expired": false,
119+
}
120+
store.persistCodexSubscriptionFields(path, enriched)
121+
122+
raw, _ := os.ReadFile(path)
123+
var got map[string]any
124+
if err := json.Unmarshal(raw, &got); err != nil {
125+
t.Fatalf("unmarshal %q: %v", string(raw), err)
126+
}
127+
// Tokens must remain the fresh on-disk values, not be rolled back.
128+
if got["access_token"] != "fresh" || got["refresh_token"] != "fresh-r" {
129+
t.Fatalf("tokens were clobbered: %s", string(raw))
130+
}
131+
// Subscription fields must be written.
132+
if got["plan_type"] != "plus" {
133+
t.Fatalf("plan_type not persisted: %s", string(raw))
134+
}
135+
if got["subscription_active_until"] != "2030-01-01T00:00:00Z" {
136+
t.Fatalf("expiry not persisted: %s", string(raw))
137+
}
138+
}

0 commit comments

Comments
 (0)