Skip to content

fix(ai): inject Claude Code identity for Anthropic OAuth model access#1981

Open
AndyMik90 wants to merge 2 commits intodevelopfrom
fix/claude-code
Open

fix(ai): inject Claude Code identity for Anthropic OAuth model access#1981
AndyMik90 wants to merge 2 commits intodevelopfrom
fix/claude-code

Conversation

@AndyMik90
Copy link
Owner

@AndyMik90 AndyMik90 commented Mar 23, 2026

Summary

Anthropic's API now requires a Claude Code identity system prompt as the first system block for OAuth tokens to access Opus/Sonnet models. Without it, consumer OAuth tokens (from Claude Max subscriptions) are restricted to Haiku only, returning a generic 400 invalid_request_error.

Problem

Since ~January 2026, Anthropic enforces server-side validation on OAuth token requests. The API checks that the first system block is an exact match of "You are Claude Code, Anthropic's official CLI for Claude." as a separate {type: "text"} entry. Combining it with other text in a single block is rejected.

This caused all autonomous tasks (spec creation, planning, coding, QA) to fail with 400 errors when using OAuth accounts.

Solution

Adds a fetch interceptor (createOAuthSystemPromptFetch()) to the Anthropic provider factory that:

  1. Intercepts outgoing POST requests to the Anthropic API
  2. Parses the request body and prepends the identity as a separate first system block
  3. Is idempotent — won't double-add if already present
  4. Only activates for OAuth tokens (sk-ant-oa* prefix) — API key users are unaffected
  5. Handles edge cases: missing system array, parse failures (passthrough)

Changes

  • apps/desktop/src/main/ai/providers/factory.ts — Added createOAuthSystemPromptFetch() and wired it into the OAuth provider path

Testing

  • Verified via curl: OAuth token + separate identity block → 200 OK with Opus 4.6
  • Verified: combined identity in single block → 400 (this is the bug we fix)
  • Verified: API keys unaffected (no interceptor applied)
  • TypeScript compiles cleanly
  • Biome lint passes

Summary by CodeRabbit

  • Bug Fixes
    • Ensure Anthropic OAuth requests always include the required system identity prompt so AI responses remain consistent.
    • Deduplicate Claude profiles by config location, remap the active profile when needed, preserve existing profile IDs and default state, and persist changes to avoid duplicate profiles appearing.

Anthropic's API requires the Claude Code identity system prompt as a
separate first system block for OAuth tokens to access Opus/Sonnet models.
Without it, consumer OAuth tokens are restricted to Haiku only (400 error).

Adds a fetch interceptor for Anthropic OAuth providers that prepends the
identity block to all API requests. The interceptor is idempotent and only
activates for OAuth tokens (sk-ant-oa* prefix).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions github-actions bot added area/frontend This is frontend only bug Something isn't working size/S Small (10-99 lines) labels Mar 23, 2026
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 23, 2026

📝 Walkthrough

Walkthrough

Added an Anthropic OAuth fetch interceptor that enforces an exact CLAUDE_CODE_IDENTITY system block in outgoing messages, and implemented profile deduplication in the Claude profile manager that removes duplicate profiles by configDir and adjusts saveProfile() to merge on configDir when appropriate.

Changes

Cohort / File(s) Summary
Anthropic OAuth System Prompt Interceptor
apps/desktop/src/main/ai/providers/factory.ts
Added createOAuthSystemPromptFetch() interceptor that parses POST JSON bodies with messages/system, ensures the first system block equals CLAUDE_CODE_IDENTITY (prepends or creates as needed), and wires the interceptor into the Anthropic OAuth provider when an OAuth token is detected.
Claude Profile Deduplication & Save Behavior
apps/desktop/src/main/claude-profile-manager.ts
Run deduplicateProfiles() during initialization to remove duplicate profiles sharing the same configDir and remap activeProfileId if needed. Updated saveProfile() to update existing profiles by configDir when IDs differ, preserving original id and merging isDefault state; persists and emits warnings when changes occur.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant Interceptor as OAuth Fetch Interceptor
    participant Anthropic as Anthropic API

    Client->>Interceptor: POST /v1/... with JSON body (messages)
    activate Interceptor
    Interceptor->>Interceptor: Attempt JSON parse
    alt parse fails
        Interceptor->>Anthropic: Forward original request
    else parse succeeds
        alt no system array
            Interceptor->>Interceptor: Add system = [{type:'text', text: CLAUDE_CODE_IDENTITY}]
        else system exists but first entry != CLAUDE_CODE_IDENTITY
            Interceptor->>Interceptor: Prepend CLAUDE_CODE_IDENTITY block to system
        else first system already CLAUDE_CODE_IDENTITY
            Interceptor->>Interceptor: Leave messages unchanged
        end
        Interceptor->>Anthropic: Send modified request
    end
    deactivate Interceptor
    Anthropic-->>Client: Response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hopped through code, so spry and neat,
I tucked a Claude block where messages meet,
I pruned twin profiles, one by one,
Kept the true path and saved the run,
Hooray — neat hops, a job well done! 🥕

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and specifically summarizes the main change: injecting a Claude Code identity system prompt for Anthropic OAuth model access, which is the primary feature added across the files.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/claude-code

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request resolves a critical issue preventing Anthropic OAuth token users from accessing advanced models like Opus and Sonnet. Anthropic's API now strictly requires a specific 'Claude Code' identity system prompt as the very first system block for OAuth requests. This change introduces an intelligent fetch interceptor that automatically injects this necessary prompt into outgoing Anthropic API requests when an OAuth token is detected, ensuring seamless access to the full range of models without manual intervention from the user.

Highlights

  • Problem Addressed: Fixed 400 errors for Anthropic OAuth users attempting to access Opus/Sonnet models, which occurred due to a new Anthropic API requirement for a specific system prompt.
  • Solution Implemented: Introduced a fetch interceptor (createOAuthSystemPromptFetch) that automatically prepends the required 'Claude Code' identity system prompt to Anthropic API requests when an OAuth token is used.
  • Scope and Idempotency: The interceptor is activated exclusively for OAuth tokens, is designed to be idempotent to prevent duplicate additions, and includes logic to handle edge cases such as missing system arrays or JSON parse failures.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request adds a fetch interceptor to inject the required Claude Code identity system prompt for Anthropic OAuth requests, which is a good solution to the problem described. The implementation is mostly correct, but I've found a potential bug where it doesn't handle the case where the system prompt is a string, which is valid according to the Anthropic API. I've suggested a refactoring to make the logic more robust and handle this case correctly.

Comment on lines +67 to +84
if (body.system && Array.isArray(body.system)) {
// Check if identity block already present
const hasIdentity = body.system.length > 0
&& body.system[0]?.type === 'text'
&& body.system[0]?.text === CLAUDE_CODE_IDENTITY;

if (!hasIdentity) {
body.system = [
{ type: 'text', text: CLAUDE_CODE_IDENTITY },
...body.system,
];
init = { ...init, body: JSON.stringify(body) };
}
} else if (body.system === undefined && body.messages) {
// No system prompt at all — add identity block
body.system = [{ type: 'text', text: CLAUDE_CODE_IDENTITY }];
init = { ...init, body: JSON.stringify(body) };
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation doesn't handle the case where body.system is a string, which is a valid type for the Anthropic API. If body.system is a string, the identity prompt will not be injected, and the API call may fail for OAuth users.

The logic can be simplified and made more robust by first normalizing the system prompt (whether it's a string or an array) into an array of blocks, and then performing the identity check and injection. This ensures all valid formats for the system prompt are handled correctly.

        if (body.system) {
          const systemBlocks = Array.isArray(body.system)
            ? body.system
            : [{ type: 'text', text: body.system }];

          const hasIdentity = systemBlocks.length > 0 &&
            systemBlocks[0]?.type === 'text' &&
            systemBlocks[0]?.text === CLAUDE_CODE_IDENTITY;

          if (!hasIdentity) {
            body.system = [
              { type: 'text', text: CLAUDE_CODE_IDENTITY },
              ...systemBlocks,
            ];
            init = { ...init, body: JSON.stringify(body) };
          }
        } else if (body.messages) {
          // No system prompt at all — add identity block
          body.system = [{ type: 'text', text: CLAUDE_CODE_IDENTITY }];
          init = { ...init, body: JSON.stringify(body) };
        }

@github-actions
Copy link
Contributor

github-actions bot commented Mar 23, 2026

Coverage Report for apps/desktop

Status Category Percentage Covered / Total
🔵 Lines 23.32% (🎯 22%) 12411 / 53220
🔵 Statements 22.98% (🎯 22%) 12958 / 56377
🔵 Functions 20.33% (🎯 19%) 2225 / 10940
🔵 Branches 18.06% (🎯 17%) 7090 / 39251
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
apps/desktop/src/main/claude-profile-manager.ts 0.6% 0% 0% 0.63% 70-1031, 1044-1080
apps/desktop/src/main/ai/providers/factory.ts 68.96% 58.46% 75% 68.42% 62-90, 111-119, 131-136, 215-216, 275
Generated in workflow #7835 for commit a81f600 by the Vitest Coverage Report Action

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/desktop/src/main/ai/providers/factory.ts`:
- Around line 67-84: The interceptor currently only handles body.system when
it's an array or undefined, so string system prompts are ignored; update the
logic around body.system handling to detect when body.system is a string and
convert it into an array that prepends the CLAUDE_CODE_IDENTITY block (e.g.,
replace string with [{ type: 'text', text: CLAUDE_CODE_IDENTITY }, { type:
'text', text: body.system }]) and then set init = { ...init, body:
JSON.stringify(body) } just like the other branches; ensure you reference and
update the same variables (body.system, CLAUDE_CODE_IDENTITY, init) and preserve
existing messages if present.
- Line 65: The current one-line parse of init.body can throw for
non-string/ArrayBuffer types; update the body parsing where const body =
JSON.parse(...) is assigned to first detect init.body's runtime type: if it's a
string parse directly; if it's an ArrayBuffer decode with TextDecoder; if it's a
Blob call blob.text(); if it's URLSearchParams call toString(); if it's FormData
convert via Object.fromEntries(formData) then JSON.stringify that result (or
extract entries into an object) and then JSON.parse; if it's a ReadableStream or
other unsupported type, read it to a string (e.g., via Response/stream handling)
or fall back to safe empty/object handling and log a warning. Ensure all
branches produce a string or object suitable for JSON.parse and keep references
to init.body and the const body assignment location so the fix targets that line
in factory.ts.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 41d93d8d-7824-4e88-9068-f053a2b5aab5

📥 Commits

Reviewing files that changed from the base of the PR and between cba7a02 and 2e1772a.

📒 Files selected for processing (1)
  • apps/desktop/src/main/ai/providers/factory.ts

return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
if (init?.method?.toUpperCase() === 'POST' && init.body) {
try {
const body = JSON.parse(typeof init.body === 'string' ? init.body : new TextDecoder().decode(init.body as ArrayBuffer));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Body type handling may throw on non-string/ArrayBuffer types.

init.body can be Blob, FormData, URLSearchParams, ReadableStream, or other types. Casting directly to ArrayBuffer will fail for these. While the Vercel AI SDK likely only sends strings, a defensive check avoids surprises.

🛡️ Proposed fix for safer body parsing
-        const body = JSON.parse(typeof init.body === 'string' ? init.body : new TextDecoder().decode(init.body as ArrayBuffer));
+        let bodyStr: string;
+        if (typeof init.body === 'string') {
+          bodyStr = init.body;
+        } else if (init.body instanceof ArrayBuffer || ArrayBuffer.isView(init.body)) {
+          bodyStr = new TextDecoder().decode(init.body);
+        } else {
+          // Unsupported body type — pass through unchanged
+          return globalThis.fetch(input, init);
+        }
+        const body = JSON.parse(bodyStr);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/ai/providers/factory.ts` at line 65, The current
one-line parse of init.body can throw for non-string/ArrayBuffer types; update
the body parsing where const body = JSON.parse(...) is assigned to first detect
init.body's runtime type: if it's a string parse directly; if it's an
ArrayBuffer decode with TextDecoder; if it's a Blob call blob.text(); if it's
URLSearchParams call toString(); if it's FormData convert via
Object.fromEntries(formData) then JSON.stringify that result (or extract entries
into an object) and then JSON.parse; if it's a ReadableStream or other
unsupported type, read it to a string (e.g., via Response/stream handling) or
fall back to safe empty/object handling and log a warning. Ensure all branches
produce a string or object suitable for JSON.parse and keep references to
init.body and the const body assignment location so the fix targets that line in
factory.ts.

Comment on lines +67 to +84
if (body.system && Array.isArray(body.system)) {
// Check if identity block already present
const hasIdentity = body.system.length > 0
&& body.system[0]?.type === 'text'
&& body.system[0]?.text === CLAUDE_CODE_IDENTITY;

if (!hasIdentity) {
body.system = [
{ type: 'text', text: CLAUDE_CODE_IDENTITY },
...body.system,
];
init = { ...init, body: JSON.stringify(body) };
}
} else if (body.system === undefined && body.messages) {
// No system prompt at all — add identity block
body.system = [{ type: 'text', text: CLAUDE_CODE_IDENTITY }];
init = { ...init, body: JSON.stringify(body) };
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing handling for string system prompts.

The Anthropic API accepts system as either a string or an array of content blocks. This interceptor only handles the array case (line 67) and undefined case (line 80). If body.system is a string (e.g., "You are a helpful assistant"), neither branch triggers and the identity block won't be prepended—OAuth users would silently get 400 errors or be restricted to Haiku.

🐛 Proposed fix to handle string system prompts
         if (body.system && Array.isArray(body.system)) {
           // Check if identity block already present
           const hasIdentity = body.system.length > 0
             && body.system[0]?.type === 'text'
             && body.system[0]?.text === CLAUDE_CODE_IDENTITY;

           if (!hasIdentity) {
             body.system = [
               { type: 'text', text: CLAUDE_CODE_IDENTITY },
               ...body.system,
             ];
             init = { ...init, body: JSON.stringify(body) };
           }
+        } else if (typeof body.system === 'string') {
+          // Convert string system to array with identity prepended
+          body.system = [
+            { type: 'text', text: CLAUDE_CODE_IDENTITY },
+            { type: 'text', text: body.system },
+          ];
+          init = { ...init, body: JSON.stringify(body) };
         } else if (body.system === undefined && body.messages) {
           // No system prompt at all — add identity block
           body.system = [{ type: 'text', text: CLAUDE_CODE_IDENTITY }];
           init = { ...init, body: JSON.stringify(body) };
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (body.system && Array.isArray(body.system)) {
// Check if identity block already present
const hasIdentity = body.system.length > 0
&& body.system[0]?.type === 'text'
&& body.system[0]?.text === CLAUDE_CODE_IDENTITY;
if (!hasIdentity) {
body.system = [
{ type: 'text', text: CLAUDE_CODE_IDENTITY },
...body.system,
];
init = { ...init, body: JSON.stringify(body) };
}
} else if (body.system === undefined && body.messages) {
// No system prompt at all — add identity block
body.system = [{ type: 'text', text: CLAUDE_CODE_IDENTITY }];
init = { ...init, body: JSON.stringify(body) };
}
if (body.system && Array.isArray(body.system)) {
// Check if identity block already present
const hasIdentity = body.system.length > 0
&& body.system[0]?.type === 'text'
&& body.system[0]?.text === CLAUDE_CODE_IDENTITY;
if (!hasIdentity) {
body.system = [
{ type: 'text', text: CLAUDE_CODE_IDENTITY },
...body.system,
];
init = { ...init, body: JSON.stringify(body) };
}
} else if (typeof body.system === 'string') {
// Convert string system to array with identity prepended
body.system = [
{ type: 'text', text: CLAUDE_CODE_IDENTITY },
{ type: 'text', text: body.system },
];
init = { ...init, body: JSON.stringify(body) };
} else if (body.system === undefined && body.messages) {
// No system prompt at all — add identity block
body.system = [{ type: 'text', text: CLAUDE_CODE_IDENTITY }];
init = { ...init, body: JSON.stringify(body) };
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/ai/providers/factory.ts` around lines 67 - 84, The
interceptor currently only handles body.system when it's an array or undefined,
so string system prompts are ignored; update the logic around body.system
handling to detect when body.system is a string and convert it into an array
that prepends the CLAUDE_CODE_IDENTITY block (e.g., replace string with [{ type:
'text', text: CLAUDE_CODE_IDENTITY }, { type: 'text', text: body.system }]) and
then set init = { ...init, body: JSON.stringify(body) } just like the other
branches; ensure you reference and update the same variables (body.system,
CLAUDE_CODE_IDENTITY, init) and preserve existing messages if present.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

// No system prompt at all — add identity block
body.system = [{ type: 'text', text: CLAUDE_CODE_IDENTITY }];
init = { ...init, body: JSON.stringify(body) };
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String system prompt silently skips identity injection

Medium Severity

The Anthropic API accepts system as either a string or an array of content blocks. The conditional only handles body.system when it's an array (Array.isArray) or undefined, but when body.system is a string, it's truthy (so the first branch is skipped) and not === undefined (so the second branch is also skipped). The identity block is silently not injected, meaning OAuth users would still get 400 errors for any request where the SDK sends system as a plain string.

Fix in Cursor Fix in Web

…startup

saveProfile() matched by ID only, so when the UI sent a profile with a
new generated ID but the same configDir, it was added as a duplicate.
This caused the profile store to grow unboundedly (5 accounts → 17 entries).

Two fixes:
1. saveProfile() now checks configDir for existing entries before adding.
   If a profile with the same configDir exists, it updates that entry
   instead of creating a duplicate.
2. deduplicateProfiles() runs on startup to clean any existing duplicates,
   keeping the first (oldest/default) entry per configDir.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions github-actions bot added size/M Medium (100-499 lines) and removed size/S Small (10-99 lines) labels Mar 23, 2026
Comment on lines +67 to +72
if (body.system && Array.isArray(body.system)) {
// Check if identity block already present
const hasIdentity = body.system.length > 0
&& body.system[0]?.type === 'text'
&& body.system[0]?.text === CLAUDE_CODE_IDENTITY;

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The identity injection logic does not handle cases where body.system is a string, causing the injection to be silently skipped.
Severity: MEDIUM

Suggested Fix

Add a new conditional branch or modify the existing one to handle the case where body.system is a string. Convert the string to an array containing that string, and then prepend the identity block as planned.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: apps/desktop/src/main/ai/providers/factory.ts#L67-L72

Potential issue: The fetch interceptor logic for identity injection only handles cases
where `body.system` is an array or is `undefined`. If the request body contains a
`system` property that is a string, neither conditional branch is executed. This results
in the identity block not being prepended to the system prompt, silently bypassing the
intended functionality of the PR. While the Vercel AI SDK may typically send an array,
the code should defensively handle the string case to ensure robustness.

Did we get this right? 👍 / 👎 to inform future reviews.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/desktop/src/main/claude-profile-manager.ts`:
- Around line 186-189: The current duplicate-collapse behavior (the reverse-loop
that splices this.data.profiles at duplicateIndices and the saveProfile(...)
path) discards persisted fields like usage, rateLimitEvents, oauthToken,
tokenCreatedAt, subscription metadata, and lastUsedAt; replace the blind
deletion/overwrite with a deterministic merge routine (e.g., add a private
mergeDuplicateProfiles(existing: ClaudeProfile, incoming: ClaudeProfile)) and
call it from both the initialization duplicate-handling loop and saveProfile;
the merge should prefer incoming values when present but fall back to existing
for oauthToken/tokenCreatedAt, choose the most-recent usage by lastUpdated,
concatenate rateLimitEvents arrays, and preserve
subscriptionType/rateLimitTier/lastUsedAt when incoming is undefined so no
persisted state is lost.
- Around line 159-173: The deduplication uses raw configDir strings so paths
like ~/foo vs /home/user/foo, or differing separators/case on Windows, bypass
dedupe; in deduplicateProfiles() canonicalize each profile.configDir before
using it as the Map key (expand leading ~ to homedir, normalize separators with
path.normalize, resolve relative segments with path.resolve and, if available,
follow symlinks with fs.realpathSync), and on Windows compare case-insensitively
(toLowerCase the canonical path); apply the same canonicalization in
saveProfile() (the referenced block around lines 425-429) so both startup
cleanup and saveProfile() use identical normalized keys for deduplication.
- Around line 178-193: The dedupe logic only remaps activeProfileId but must
remap all references to removed profile IDs; create an idRemap (oldId ->
retainedId) when you detect duplicates (using the existing seen map and
duplicateIndices), then iterate and replace entries in
this.data.migratedProfileIds and this.data.accountPriorityOrder (and any other
arrays of profile IDs) by mapping removed IDs to the retained ID; update the
arrays in-place (or filter/map to new arrays) before splicing out duplicates and
calling this.save(); reference ClaudeProfileManager, the seen map,
duplicateIndices, removedIds, migratedProfileIds and accountPriorityOrder when
making the changes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 912b0b03-7b81-4442-9323-532f546e0f9f

📥 Commits

Reviewing files that changed from the base of the PR and between 2e1772a and a81f600.

📒 Files selected for processing (1)
  • apps/desktop/src/main/claude-profile-manager.ts

Comment on lines +159 to +173
private deduplicateProfiles(): void {
const seen = new Map<string, number>(); // configDir -> index of first occurrence
const duplicateIndices: number[] = [];
const removedIds: string[] = [];

for (let i = 0; i < this.data.profiles.length; i++) {
const configDir = this.data.profiles[i].configDir;
if (!configDir) continue;

if (seen.has(configDir)) {
duplicateIndices.push(i);
removedIds.push(this.data.profiles[i].id);
} else {
seen.set(configDir, i);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Canonicalize configDir before using it as the dedupe key.

These comparisons use raw strings. Mixed ~/expanded paths and Windows separator or casing differences can still resolve to the same directory, so the same profile can slip past both startup cleanup and saveProfile() deduplication.

Also applies to: 425-429

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/claude-profile-manager.ts` around lines 159 - 173, The
deduplication uses raw configDir strings so paths like ~/foo vs /home/user/foo,
or differing separators/case on Windows, bypass dedupe; in deduplicateProfiles()
canonicalize each profile.configDir before using it as the Map key (expand
leading ~ to homedir, normalize separators with path.normalize, resolve relative
segments with path.resolve and, if available, follow symlinks with
fs.realpathSync), and on Windows compare case-insensitively (toLowerCase the
canonical path); apply the same canonicalization in saveProfile() (the
referenced block around lines 425-429) so both startup cleanup and saveProfile()
use identical normalized keys for deduplication.

Comment on lines +178 to +193
// Remap activeProfileId if it points to a duplicate being removed
if (this.data.activeProfileId && removedIds.includes(this.data.activeProfileId)) {
const activeConfigDir = this.data.profiles.find(p => p.id === this.data.activeProfileId)?.configDir;
if (activeConfigDir && seen.has(activeConfigDir)) {
this.data.activeProfileId = this.data.profiles[seen.get(activeConfigDir)!].id;
}
}

// Remove duplicates (iterate in reverse to preserve indices)
for (let i = duplicateIndices.length - 1; i >= 0; i--) {
this.data.profiles.splice(duplicateIndices[i], 1);
}

console.warn(`[ClaudeProfileManager] Deduplicated profiles: removed ${duplicateIndices.length} duplicates, ${this.data.profiles.length} remaining`);
this.save();
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Remap all profile-ID indexes, not just activeProfileId.

After a duplicate is removed, migratedProfileIds and accountPriorityOrder can still point at the deleted ID. apps/desktop/src/main/index.ts:569-593 compares remainingMigratedIds with activeProfile.id, so an active migrated profile will stop triggering re-auth once its duplicate ID is collapsed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/claude-profile-manager.ts` around lines 178 - 193, The
dedupe logic only remaps activeProfileId but must remap all references to
removed profile IDs; create an idRemap (oldId -> retainedId) when you detect
duplicates (using the existing seen map and duplicateIndices), then iterate and
replace entries in this.data.migratedProfileIds and
this.data.accountPriorityOrder (and any other arrays of profile IDs) by mapping
removed IDs to the retained ID; update the arrays in-place (or filter/map to new
arrays) before splicing out duplicates and calling this.save(); reference
ClaudeProfileManager, the seen map, duplicateIndices, removedIds,
migratedProfileIds and accountPriorityOrder when making the changes.

Comment on lines +186 to +189
// Remove duplicates (iterate in reverse to preserve indices)
for (let i = duplicateIndices.length - 1; i >= 0; i--) {
this.data.profiles.splice(duplicateIndices[i], 1);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Both duplicate-collapse paths can discard persisted profile state.

Initialization deletes the later row outright, and saveProfile() overwrites the survivor with the incoming object while only preserving id and isDefault. That can drop usage, rateLimitEvents, oauthToken, subscription metadata, or lastUsedAt, which changes auth and availability scoring after restart.

💡 Suggested direction
-    for (let i = duplicateIndices.length - 1; i >= 0; i--) {
-      this.data.profiles.splice(duplicateIndices[i], 1);
-    }
+    for (let i = duplicateIndices.length - 1; i >= 0; i--) {
+      const duplicateIndex = duplicateIndices[i];
+      const duplicate = this.data.profiles[duplicateIndex];
+      const keptIndex = seen.get(duplicate.configDir!)!;
+      this.data.profiles[keptIndex] = this.mergeDuplicateProfiles(
+        this.data.profiles[keptIndex],
+        duplicate
+      );
+      this.data.profiles.splice(duplicateIndex, 1);
+    }
@@
-        profile.id = existingId;
-        profile.isDefault = existingIsDefault || profile.isDefault;
-        this.data.profiles[indexByConfigDir] = profile;
+        this.data.profiles[indexByConfigDir] = this.mergeDuplicateProfiles(
+          this.data.profiles[indexByConfigDir],
+          profile
+        );
+        this.data.profiles[indexByConfigDir].id = existingId;
+        this.data.profiles[indexByConfigDir].isDefault = existingIsDefault || profile.isDefault;
         this.save();
-        return profile;
+        return this.data.profiles[indexByConfigDir];
private mergeDuplicateProfiles(existing: ClaudeProfile, incoming: ClaudeProfile): ClaudeProfile {
  return {
    ...existing,
    ...incoming,
    oauthToken: incoming.oauthToken ?? existing.oauthToken,
    tokenCreatedAt: incoming.tokenCreatedAt ?? existing.tokenCreatedAt,
    usage:
      !existing.usage ||
      (incoming.usage?.lastUpdated && incoming.usage.lastUpdated > existing.usage.lastUpdated)
        ? incoming.usage ?? existing.usage
        : existing.usage,
    rateLimitEvents: [
      ...(existing.rateLimitEvents ?? []),
      ...(incoming.rateLimitEvents ?? []),
    ],
    subscriptionType: incoming.subscriptionType ?? existing.subscriptionType,
    rateLimitTier: incoming.rateLimitTier ?? existing.rateLimitTier,
    lastUsedAt: incoming.lastUsedAt ?? existing.lastUsedAt,
  };
}

Also applies to: 431-444

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/claude-profile-manager.ts` around lines 186 - 189, The
current duplicate-collapse behavior (the reverse-loop that splices
this.data.profiles at duplicateIndices and the saveProfile(...) path) discards
persisted fields like usage, rateLimitEvents, oauthToken, tokenCreatedAt,
subscription metadata, and lastUsedAt; replace the blind deletion/overwrite with
a deterministic merge routine (e.g., add a private
mergeDuplicateProfiles(existing: ClaudeProfile, incoming: ClaudeProfile)) and
call it from both the initialization duplicate-handling loop and saveProfile;
the merge should prefer incoming values when present but fall back to existing
for oauthToken/tokenCreatedAt, choose the most-recent usage by lastUpdated,
concatenate rateLimitEvents arrays, and preserve
subscriptionType/rateLimitTier/lastUsedAt when incoming is undefined so no
persisted state is lost.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/frontend This is frontend only bug Something isn't working size/M Medium (100-499 lines)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant