From 50dbe7fd07f3d9c42c2ae67ce3302343bdb97aad Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:50:00 +0700 Subject: [PATCH] Port upstream 0.65.0: respect read-only claude-swap adapters --- .../src-tauri/src/commands/claude_accounts.rs | 75 +++++++++++++++++-- .../ClaudeSwapAccountsSection.test.tsx | 25 +++++++ rust/src/providers/claude/claude_swap.rs | 3 + .../providers/claude/claude_swap/parser.rs | 45 +++++++++++ .../claude/claude_swap/projection.rs | 40 +++++++++- 5 files changed, 178 insertions(+), 10 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs b/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs index 317cdc9f5e..da3d6b90e4 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs @@ -191,15 +191,22 @@ impl ClaudeSwapMutationOutcome { fn validate_claude_swap_operation( operation: ClaudeSwapAccountOperation, + list: &ClaudeSwapAccountList, account: &ClaudeSwapAccountRow, ) -> Result<(), String> { let expected_action = match operation { ClaudeSwapAccountOperation::Switch => ClaudeSwapAccountAction::Switch, ClaudeSwapAccountOperation::Reauthenticate => ClaudeSwapAccountAction::Reauthenticate, }; - if claude_swap::action_for_account(account) == Some(expected_action) { + if claude_swap::action_for_account(list, account) == Some(expected_action) { return Ok(()); } + if !list.supports_account_switching { + return Err( + "This claude-swap adapter is read-only and does not support account switching." + .to_string(), + ); + } match operation { ClaudeSwapAccountOperation::Switch if account.is_active => { Err("That claude-swap account is already active.".to_string()) @@ -230,7 +237,7 @@ fn run_claude_swap_operation( let before = claude_swap::read_account_list(&config.executable_path) .map_err(|error| error.to_string())?; let account = account_row_for_slot(&before, slot)?; - validate_claude_swap_operation(operation, account)?; + validate_claude_swap_operation(operation, &before, account)?; let result = claude_swap::switch_account(&config.executable_path, slot) .map_err(|error| error.to_string())?; @@ -413,10 +420,15 @@ pub async fn claude_account_switch( mod tests { use super::*; - fn account_row(active: bool, status: &str) -> ClaudeSwapAccountRow { + fn account_list( + active: bool, + status: &str, + supports_account_switching: bool, + ) -> ClaudeSwapAccountList { let raw = serde_json::json!({ "schemaVersion": 1, "activeAccountNumber": if active { serde_json::json!(1) } else { serde_json::Value::Null }, + "supportsAccountSwitching": supports_account_switching, "accounts": [{ "number": 1, "email": "test@example.com", @@ -426,6 +438,10 @@ mod tests { }); codexbar::providers::claude::claude_swap::parse_account_list(&raw.to_string()) .expect("fixture should parse") + } + + fn account_row(active: bool, status: &str) -> ClaudeSwapAccountRow { + account_list(active, status, true) .accounts .into_iter() .next() @@ -434,18 +450,63 @@ mod tests { #[test] fn operation_validation_uses_the_projected_action_state() { - let switchable = account_row(false, "ok"); + let switchable = account_list(false, "ok", true); assert!( - validate_claude_swap_operation(ClaudeSwapAccountOperation::Switch, &switchable).is_ok() + validate_claude_swap_operation( + ClaudeSwapAccountOperation::Switch, + &switchable, + &switchable.accounts[0] + ) + .is_ok() ); - let blocked = account_row(false, "no_credentials"); + let blocked = account_list(false, "no_credentials", true); assert_eq!( - validate_claude_swap_operation(ClaudeSwapAccountOperation::Switch, &blocked), + validate_claude_swap_operation( + ClaudeSwapAccountOperation::Switch, + &blocked, + &blocked.accounts[0] + ), Err("That claude-swap account is not available for switching.".to_string()) ); } + #[test] + fn read_only_adapter_rejects_switch_and_reauthenticate() { + let expected = Err( + "This claude-swap adapter is read-only and does not support account switching." + .to_string(), + ); + let inactive = account_list(false, "ok", false); + assert_eq!( + validate_claude_swap_operation( + ClaudeSwapAccountOperation::Switch, + &inactive, + &inactive.accounts[0] + ), + expected + ); + let foreign = account_list(true, "foreign_credential", false); + assert_eq!( + validate_claude_swap_operation( + ClaudeSwapAccountOperation::Reauthenticate, + &foreign, + &foreign.accounts[0] + ), + expected + ); + // The same row is accepted when the adapter supports switching. + let switching = account_list(true, "foreign_credential", true); + assert!( + validate_claude_swap_operation( + ClaudeSwapAccountOperation::Reauthenticate, + &switching, + &switching.accounts[0] + ) + .is_ok() + ); + } + #[test] fn reauthentication_confirmation_requires_a_live_ok_status() { assert!(reauthentication_is_repaired(&account_row(true, "ok"))); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.test.tsx index 9e1a0643fe..31af2ad1f6 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.test.tsx @@ -117,6 +117,31 @@ describe("ClaudeSwapAccountsSection", () => { expect(screen.getByRole("status").textContent).toBe("ClaudeSwapSwitched"); }); + it("renders no action button when the adapter is read-only", async () => { + mocks.getSettingsSnapshot.mockResolvedValue({ + claudeSwapEnabled: true, + claudeSwapExecutablePath: "~/bin/cswap", + }); + // Read-only adapters project `action: null` for every row, including + // inactive rows that would otherwise be switchable. + mocks.claudeSwapAccountsList.mockResolvedValue( + enabledState([ + active, + { ...switchable, action: null }, + { ...active, id: "claude-swap:4", slot: 4, label: "foreign@example.com", status: "foreign_credential", action: null }, + ]), + ); + render(); + await screen.findByText("work@example.com"); + + expect(screen.getByText("personal@example.com")).toBeTruthy(); + expect(screen.getAllByText("TokenAccountActive").length).toBeGreaterThan(0); + expect(screen.queryByText("ClaudeSwapSwitchButton")).toBeNull(); + expect(screen.queryByText("ClaudeSwapReauthenticateButton")).toBeNull(); + expect(mocks.claudeSwapAccountSwitch).not.toHaveBeenCalled(); + expect(mocks.claudeSwapAccountReauthenticate).not.toHaveBeenCalled(); + }); + it("surfaces adapter errors without offering a switch", async () => { mocks.getSettingsSnapshot.mockResolvedValue({ claudeSwapEnabled: true, diff --git a/rust/src/providers/claude/claude_swap.rs b/rust/src/providers/claude/claude_swap.rs index 4512b2be48..4fc13b9e63 100644 --- a/rust/src/providers/claude/claude_swap.rs +++ b/rust/src/providers/claude/claude_swap.rs @@ -181,6 +181,9 @@ pub struct ClaudeSwapAccountRow { pub struct ClaudeSwapAccountList { pub active_account_number: Option, pub accounts: Vec, + /// Additive adapter capability (`supportsAccountSwitching`). Absent means + /// `true` for schema-v1 compatibility; read-only adapters report `false`. + pub supports_account_switching: bool, } #[derive(Debug, Clone, PartialEq)] diff --git a/rust/src/providers/claude/claude_swap/parser.rs b/rust/src/providers/claude/claude_swap/parser.rs index 22c1df7c21..5ff813871c 100644 --- a/rust/src/providers/claude/claude_swap/parser.rs +++ b/rust/src/providers/claude/claude_swap/parser.rs @@ -256,6 +256,15 @@ pub fn parse_account_list(raw: &str) -> Result true, + Some(Value::Bool(supported)) => *supported, + Some(_) => { + return Err(ClaudeSwapError::MalformedShape( + "supportsAccountSwitching is not a boolean".to_string(), + )); + } + }; let active_account_number = match active_field { Value::Null => None, Value::Number(_) => Some(non_negative_slot(active_field).ok_or_else(|| { @@ -303,6 +312,7 @@ pub fn parse_account_list(raw: &str) -> Result Option { - if row.is_active { +/// Action a row offers within `list`. Read-only adapters +/// (`supports_account_switching == false`) offer none: usage, details and the +/// active marker still render, but switching and re-authentication do not. +pub fn action_for_account( + list: &ClaudeSwapAccountList, + row: &ClaudeSwapAccountRow, +) -> Option { + if !list.supports_account_switching { + None + } else if row.is_active { (row.usage_status == ClaudeSwapUsageStatus::ForeignCredential) .then_some(ClaudeSwapAccountAction::Reauthenticate) } else if row.usage_status.can_switch_to() { @@ -263,7 +271,7 @@ pub fn project_accounts( } else { label }; - let action = action_for_account(row); + let action = action_for_account(list, row); ClaudeSwapAccount { id: format!("claude-swap:{}", row.number), slot: row.number, @@ -363,6 +371,32 @@ mod tests { ); } + #[test] + fn read_only_adapter_suppresses_actions_but_keeps_usage_and_active_marker() { + let mut list = list_fixture(); + assert!(list.supports_account_switching); + list.supports_account_switching = false; + list.accounts[1].usage_status = ClaudeSwapUsageStatus::ForeignCredential; + + let projected = project_accounts(&list, false); + assert_eq!(projected.len(), 3); + assert!(projected.iter().all(|account| account.action.is_none())); + // Active marker, identity and usage remain. + assert!(projected[0].is_active); + assert_eq!(projected[0].slot, 2); + assert_eq!(projected[0].status, "foreign_credential"); + let inactive = projected.iter().find(|a| a.slot == 1).unwrap(); + assert_eq!(inactive.email.as_deref(), Some("same@example.com")); + assert_eq!(inactive.five_hour.as_ref().unwrap().used_percent, 10.0); + } + + #[test] + fn supporting_adapter_keeps_row_actions() { + let projected = project_accounts(&list_fixture(), false); + let actionable = projected.iter().find(|a| a.slot == 1).unwrap(); + assert_eq!(actionable.action, Some(ClaudeSwapAccountAction::Switch)); + } + #[test] fn hiding_personal_info_collapses_to_ordinals() { let projected = project_accounts(&list_fixture(), true);