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);