From 6b7ec0a086a09f768b887154618eba37796900cf Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 17:24:03 -0400 Subject: [PATCH 1/5] Revert "Revert Claude per-account folders (#24434, #26405) before release (#26638)" This reverts commit 9ca9b4e36b9f61012d490a0642c52b980f3634f2. --- .github/workflows/pr.yml | 1 + .../claude-account-windows-spawn-repro.mjs | 342 --------- ...de-pty-windows-pty-teardown-patch.test.mjs | 4 +- src/cli/handlers/account-list-format.ts | 7 +- src/cli/handlers/account.test.ts | 238 +++--- src/cli/handlers/account.ts | 120 ++- .../daemon-bash-rcfile.txt | 24 + .../daemon-fish-init.txt | 32 +- .../daemon-zsh-zshenv.txt | 24 + .../local-bash-rcfile.txt | 24 + .../local-fish-init.txt | 32 +- .../local-zsh-zshenv.txt | 24 + .../relay-zsh-zshenv.txt | 24 + .../agent-auth-restart-preservation.test.ts | 100 +-- src/main/agent-auth-restart-preservation.ts | 20 +- .../claude-account-folder.test.ts | 106 +++ .../claude-accounts/claude-account-folder.ts | 153 ++++ .../claude-account-registration.ts | 330 +++----- .../claude-account-selection.ts | 272 ++++--- ...-account-service-account-selection.test.ts | 488 ------------ ...claude-account-service-add-account.test.ts | 373 --------- .../claude-account-service-api-parity.test.ts | 90 --- ...account-service-config-dir-capture.test.ts | 250 ------ ...account-service-credential-capture.test.ts | 150 ---- ...aude-account-service-login-process.test.ts | 684 ----------------- ...de-account-service-reauth-rollback.test.ts | 420 ----------- .../claude-account-service-test-harness.ts | 41 - .../claude-account-service.test.ts | 349 +++++++++ .../claude-accounts/claude-auth-capture.ts | 176 ----- .../claude-command-process.test.ts | 75 ++ .../claude-accounts/claude-command-process.ts | 38 +- ...ude-credential-persistence-ratchet.test.ts | 281 +++++++ .../claude-login-completion.oracle.test.ts | 192 ----- .../claude-accounts/claude-login-session.ts | 121 +-- .../claude-managed-auth-storage.ts | 285 ------- .../claude-profile-installed-router.ts | 6 +- .../claude-profile-paths.test.ts | 21 +- .../claude-accounts/claude-profile-paths.ts | 40 +- .../claude-profile-provisioning.test.ts | 78 +- .../claude-profile-provisioning.ts | 26 +- .../claude-profile-router.test.ts | 249 +++++- .../claude-accounts/claude-profile-router.ts | 116 ++- .../claude-profile-setup.test.ts | 20 +- .../claude-accounts/claude-profile-setup.ts | 13 +- .../claude-profile-shell-composition.test.ts | 42 +- .../claude-profile-wsl-paths.ts | 9 + .../claude-profile-wsl-router.test.ts | 121 ++- .../claude-profile-wsl-router.ts | 57 +- .../claude-windows-interactive-login.test.ts | 148 ---- src/main/claude-accounts/environment.ts | 27 +- src/main/claude-accounts/keychain.test.ts | 288 +------ src/main/claude-accounts/keychain.ts | 107 +-- .../claude-accounts/live-pty-gate.test.ts | 142 ---- src/main/claude-accounts/live-pty-gate.ts | 174 ----- src/main/claude-accounts/managed-auth-path.ts | 112 --- .../claude-accounts/oauth-refresh.test.ts | 186 ----- src/main/claude-accounts/oauth-refresh.ts | 170 ----- .../runtime-auth-path-materialization.test.ts | 58 -- ...ime-auth-service-account-switching.test.ts | 509 ------------- ...time-auth-service-deselect-restore.test.ts | 611 --------------- ...me-auth-service-keychain-snapshots.test.ts | 712 ------------------ ...untime-auth-service-launch-refresh.test.ts | 248 ------ ...ntime-auth-service-materialization.test.ts | 490 ------------ ...ime-auth-service-readback-identity.test.ts | 607 --------------- ...service-shared-credential-failures.test.ts | 85 --- ...me-auth-service-shared-credentials.test.ts | 292 ------- ...e-auth-service-snapshot-validation.test.ts | 550 -------------- .../runtime-auth-service-test-harness.ts | 277 ------- .../runtime-auth-service-wsl-runtime.test.ts | 312 -------- .../runtime-auth-service.test.ts | 96 +++ .../claude-accounts/runtime-auth-service.ts | 234 +++--- ...runtime-auth-shared-credentials-fixture.ts | 51 -- .../runtime-auth-credential-identity.ts | 209 ----- .../runtime-auth-credential-matching.ts | 152 ---- .../runtime-auth/runtime-auth-file-storage.ts | 82 -- .../runtime-auth-keychain-snapshots.ts | 135 ---- .../runtime-auth-managed-credentials.ts | 152 ---- .../runtime-auth/runtime-auth-preparation.ts | 119 --- .../runtime-auth/runtime-auth-readback.ts | 163 ---- .../runtime-auth-runtime-state.ts | 238 ------ .../runtime-auth-snapshot-capture.ts | 119 --- .../runtime-auth-snapshot-restore.ts | 239 ------ .../runtime-auth/runtime-auth-state.ts | 19 - .../runtime-auth/runtime-auth-sync.ts | 289 ------- .../runtime-auth/runtime-auth-types.ts | 46 +- .../claude-accounts/runtime-paths.test.ts | 6 - src/main/claude-accounts/runtime-paths.ts | 2 - src/main/claude-accounts/service.ts | 212 ++---- .../shared-credential-fields.test.ts | 137 ---- .../shared-credential-fields.ts | 166 ---- .../shared-credential-reconciliation.test.ts | 129 ---- src/main/claude/claude-model-catalog-probe.ts | 1 - .../claude-stream-json-connection.test.ts | 83 -- .../claude/claude-stream-json-connection.ts | 20 - .../claude-structured-auth-parity.test.ts | 139 +--- ...-structured-effort-default-at-rest.test.ts | 2 + ...-structured-launch-account-routing.test.ts | 14 +- .../claude/claude-structured-launch-home.ts | 2 +- ...laude-structured-launch-resolution.test.ts | 109 +-- .../claude-structured-launch-resolution.ts | 80 +- .../claude-structured-session-acquisition.ts | 16 +- ...n-foreground-confirmation-protocol.test.ts | 2 +- .../daemon-init-provider-installation.test.ts | 20 - src/main/daemon/daemon-protocol-version.ts | 11 +- src/main/daemon/daemon-provider-init.ts | 31 - src/main/daemon/daemon-provider-state.ts | 14 +- .../pty-subprocess/shell-launch-plan.ts | 8 +- src/main/ipc/claude-accounts.ts | 14 +- .../pty-listener-teardown-and-orphans.test.ts | 5 - .../ipc/pty-spawn-env-terminal-basics.test.ts | 48 +- .../ipc/pty/ipc/claude-old-terminal.test.ts | 36 + src/main/ipc/pty/ipc/claude-old-terminal.ts | 23 + src/main/ipc/pty/ipc/spawn-commit.ts | 4 - src/main/ipc/pty/ipc/spawn-env.ts | 5 - src/main/ipc/pty/ipc/spawn-preflight.ts | 5 - src/main/ipc/pty/provider/bind-listeners.ts | 2 - src/main/ipc/pty/provider/liveness.ts | 2 - src/main/ipc/pty/provider/local-configure.ts | 2 - src/main/ipc/pty/provider/state-cleanup.ts | 3 - src/main/ipc/pty/register-handlers.ts | 3 + src/main/ipc/pty/runtime/spawn-commit.ts | 4 - .../spawn-preflight-requested-shell.test.ts | 1 + src/main/ipc/pty/runtime/spawn-preflight.ts | 8 - .../macos-keychain/generic-password.test.ts | 24 +- src/main/macos-keychain/generic-password.ts | 39 - .../structured-agent-session-adapter.ts | 2 + .../structured-agent-session-failure-text.ts | 2 + ...ent-session-pre-spawn-first-answer.test.ts | 16 + ...structured-managed-account-support.test.ts | 136 ---- ...aude-structured-managed-account-support.ts | 68 -- ...tured-agent-session-create-support.test.ts | 80 +- ...structured-agent-session-create-support.ts | 33 +- ...tence-worktree-lineage-and-backups.test.ts | 61 -- .../leasing-ssh-ptys/ssh-target-state.ts | 37 - .../normalize-loaded-profile-state.ts | 2 - .../profile-state-direct-flush.test.ts | 10 - .../loading-store/ssh-profile-operations.ts | 33 +- .../loading-store/store-domain-composition.ts | 2 +- .../pane-alias-normalization.ts | 24 - .../local-pty-windows-spawn-environment.ts | 8 +- src/main/providers/windows-shell-args.test.ts | 16 +- src/main/providers/windows-shell-args.ts | 14 +- .../rate-limits/claude-active-usage-fetch.ts | 286 ++----- .../rate-limits/claude-cli-usage-fetch.ts | 117 --- .../claude-fetcher-cli-fallback.test.ts | 490 ------------ .../claude-fetcher-fable-usage.test.ts | 362 --------- ...laude-fetcher-keychain-credentials.test.ts | 491 ------------ ...aude-fetcher-managed-account-usage.test.ts | 425 ----------- .../claude-fetcher-test-harness.ts | 67 -- src/main/rate-limits/claude-fetcher.ts | 6 +- .../claude-managed-account-credentials.ts | 127 ---- .../claude-managed-account-usage.ts | 140 ++-- .../rate-limits/claude-managed-usage-panel.ts | 113 --- .../rate-limits/claude-oauth-credentials.ts | 175 ++--- src/main/rate-limits/claude-oauth-recovery.ts | 172 ----- ...=> claude-oauth-usage-extra-usage.test.ts} | 108 +-- .../rate-limits/claude-profile-usage.test.ts | 271 +++++++ .../rate-limits/claude-pty-reset-parser.ts | 307 -------- .../rate-limits/claude-pty-stop-markers.ts | 14 - .../rate-limits/claude-pty-usage-parser.ts | 166 ---- src/main/rate-limits/claude-pty.test.ts | 554 -------------- src/main/rate-limits/claude-pty.ts | 354 --------- .../claude-usage-error-classification.test.ts | 40 +- .../claude-usage-error-classification.ts | 17 - .../claude-usage-refresh-plan.test.ts | 32 - .../rate-limits/claude-usage-refresh-plan.ts | 47 -- src/main/rate-limits/claude-usage-result.ts | 114 +-- .../rate-limits/hidden-pty-cleanup.test.ts | 135 ---- src/main/rate-limits/hidden-pty-cleanup.ts | 58 -- .../service-account-target-selection.test.ts | 4 +- .../service-inactive-account-previews.test.ts | 16 +- .../service-live-claude-usage.test.ts | 107 +-- .../service/service-account-refresh.ts | 11 - .../service/service-fetch-policy.ts | 9 +- ...structured-agent-session-create-support.ts | 3 +- .../runtime/orca-runtime-get-worktree-ps.ts | 2 - ...ime-structured-claude-account-gate.test.ts | 112 --- ...time-structured-claude-gate-wiring.test.ts | 64 -- src/main/runtime/rpc/methods/accounts.test.ts | 57 +- src/main/runtime/rpc/methods/accounts.ts | 60 +- .../runtime/runtime-account-controller.ts | 21 +- .../runtime-service-command-surface.ts | 8 +- ...-agent-create-support-opencode-rig.test.ts | 3 +- .../structured-agent-launch-support.ts | 10 +- .../structured-agent-runtime-registrations.ts | 7 - .../structured-agent-session-runtime.ts | 3 - .../structured-claude-runtime-adapter.ts | 5 - ...ll-wrapper-generated-file-snapshot.test.ts | 2 + .../startup/main-process-account-services.ts | 1 - .../startup/main-process-ready-foundation.ts | 20 - .../main-process-ready-identity-write.test.ts | 9 - .../startup/main-window-core-services.test.ts | 1 - src/main/startup/main-window-core-services.ts | 8 +- src/preload/api/pty-api.ts | 2 + .../pty-bridge-stream-and-serialization.ts | 2 + .../src/app-shell/use-app-shell-services.ts | 2 + .../native-chat/NativeChatNoticeRow.tsx | 18 + .../NativeChatStructuredSession.tsx | 65 +- .../agent-session-failure-words-text.ts | 10 + .../native-chat-claude-sign-in.test.tsx | 116 +++ .../native-chat/native-chat-claude-sign-in.ts | 77 ++ .../native-chat-structured-session-notices.ts | 30 +- .../settings/accounts-pane-account-actions.ts | 31 +- .../accounts-pane-claude-section.test.tsx | 156 ++++ .../settings/accounts-pane-claude-section.tsx | 65 +- .../accounts-pane-removal-dialogs.tsx | 4 +- .../status-bar/ClaudeSwitcherMenu.tsx | 98 ++- .../status-bar/InlineProviderUsage.tsx | 8 +- .../status-bar/StatusBarProviderSegment.tsx | 7 +- .../status-bar/StatusBarSurface.tsx | 4 +- .../status-bar/claude-status-sign-in.test.tsx | 242 ++++++ .../status-bar/inline-usage-bars.test.tsx | 16 + .../status-bar/status-bar-claude-accounts.ts | 22 +- .../status-bar-runtime-groups.test.ts | 46 ++ .../status-bar/status-bar-runtime-targets.ts | 5 + .../src/components/status-bar/tooltip.test.ts | 41 +- .../components/status-bar/usage-error-copy.ts | 35 +- .../usage-provider-settings-target.test.ts | 36 +- .../usage-provider-settings-target.ts | 18 + .../status-bar/usage-roster-row-state.test.ts | 2 +- .../status-bar/use-status-bar-controller.ts | 8 +- .../terminal-pane/ClaudeOldTerminalBanner.tsx | 143 ++++ .../terminal-pane/CodexOldTerminalDialog.tsx | 54 -- .../terminal-pane/CodexSharedServerBanner.tsx | 150 +--- .../terminal-pane/OldTerminalDialog.tsx | 45 ++ .../terminal-pane/PaneTopWarningBanner.tsx | 96 +++ .../TerminalPaneRuntimePortals.tsx | 7 + .../claude-old-terminal-banner.test.tsx | 154 ++++ .../terminal-pane/open-terminal-beside-tab.ts | 29 + .../src/i18n/en-runtime-required.json | 2 + src/renderer/src/i18n/locales/en.json | 62 +- src/renderer/src/i18n/locales/es.json | 17 +- src/renderer/src/i18n/locales/fr.json | 17 +- src/renderer/src/i18n/locales/ja.json | 17 +- src/renderer/src/i18n/locales/ko.json | 17 +- src/renderer/src/i18n/locales/zh.json | 17 +- .../lib/claude-account-sign-in-notice.test.ts | 111 +++ .../src/lib/claude-account-sign-in-notice.ts | 63 ++ .../src/lib/claude-account-sign-in.ts | 37 + .../store/slices/ui-notice-dismissals.test.ts | 13 + .../slices/ui/ui-slice-contract-contextual.ts | 2 + .../slices/ui/ui-slice-hydration-actions.ts | 1 + .../store/slices/ui/ui-slice-trust-actions.ts | 9 + .../src/web/preload-api/web-terminal-api.ts | 1 + src/shared/agent-session-failure-copy.ts | 7 + src/shared/agent-session-failure-words.ts | 2 + src/shared/agent-session-failure.ts | 2 + src/shared/agent-session-refusal-details.ts | 4 + .../agent-session-refusal-reason-words.ts | 2 + ...claude-profile-activation-contract.test.ts | 18 + src/shared/claude-profile-routing.ts | 17 +- src/shared/claude-shell-function.test.ts | 101 ++- src/shared/claude-shell-function.ts | 54 +- src/shared/constants.ts | 1 - src/shared/global-settings-types.ts | 2 + .../local-build-compatibility-contract.json | 4 +- .../local-build-compatibility-contract.ts | 4 +- src/shared/managed-account-types.ts | 21 + src/shared/persisted-state-types.ts | 2 - src/shared/persisted-ui-state-types.ts | 2 + src/shared/protocol-version.ts | 3 + src/shared/rate-limit-types.ts | 1 - src/shared/rpc-contract/accounts-params.ts | 13 + src/shared/rpc-contract/client-ui-params.ts | 1 + .../rpc-params-catalog.generated.ts | 5 + ...ctured-agent-session-dispatch-rejection.ts | 4 + src/shared/wsl-login-shell-command.ts | 5 + 267 files changed, 5493 insertions(+), 19855 deletions(-) delete mode 100644 config/scripts/claude-account-windows-spawn-repro.mjs create mode 100644 src/main/claude-accounts/claude-account-folder.test.ts create mode 100644 src/main/claude-accounts/claude-account-folder.ts delete mode 100644 src/main/claude-accounts/claude-account-service-account-selection.test.ts delete mode 100644 src/main/claude-accounts/claude-account-service-add-account.test.ts delete mode 100644 src/main/claude-accounts/claude-account-service-api-parity.test.ts delete mode 100644 src/main/claude-accounts/claude-account-service-config-dir-capture.test.ts delete mode 100644 src/main/claude-accounts/claude-account-service-credential-capture.test.ts delete mode 100644 src/main/claude-accounts/claude-account-service-login-process.test.ts delete mode 100644 src/main/claude-accounts/claude-account-service-reauth-rollback.test.ts delete mode 100644 src/main/claude-accounts/claude-account-service-test-harness.ts create mode 100644 src/main/claude-accounts/claude-account-service.test.ts delete mode 100644 src/main/claude-accounts/claude-auth-capture.ts create mode 100644 src/main/claude-accounts/claude-command-process.test.ts create mode 100644 src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts delete mode 100644 src/main/claude-accounts/claude-login-completion.oracle.test.ts delete mode 100644 src/main/claude-accounts/claude-managed-auth-storage.ts delete mode 100644 src/main/claude-accounts/claude-windows-interactive-login.test.ts delete mode 100644 src/main/claude-accounts/live-pty-gate.test.ts delete mode 100644 src/main/claude-accounts/live-pty-gate.ts delete mode 100644 src/main/claude-accounts/managed-auth-path.ts delete mode 100644 src/main/claude-accounts/oauth-refresh.test.ts delete mode 100644 src/main/claude-accounts/oauth-refresh.ts delete mode 100644 src/main/claude-accounts/runtime-auth-path-materialization.test.ts delete mode 100644 src/main/claude-accounts/runtime-auth-service-account-switching.test.ts delete mode 100644 src/main/claude-accounts/runtime-auth-service-deselect-restore.test.ts delete mode 100644 src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts delete mode 100644 src/main/claude-accounts/runtime-auth-service-launch-refresh.test.ts delete mode 100644 src/main/claude-accounts/runtime-auth-service-materialization.test.ts delete mode 100644 src/main/claude-accounts/runtime-auth-service-readback-identity.test.ts delete mode 100644 src/main/claude-accounts/runtime-auth-service-shared-credential-failures.test.ts delete mode 100644 src/main/claude-accounts/runtime-auth-service-shared-credentials.test.ts delete mode 100644 src/main/claude-accounts/runtime-auth-service-snapshot-validation.test.ts delete mode 100644 src/main/claude-accounts/runtime-auth-service-test-harness.ts delete mode 100644 src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts create mode 100644 src/main/claude-accounts/runtime-auth-service.test.ts delete mode 100644 src/main/claude-accounts/runtime-auth-shared-credentials-fixture.ts delete mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-credential-identity.ts delete mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-credential-matching.ts delete mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-file-storage.ts delete mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-keychain-snapshots.ts delete mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts delete mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts delete mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts delete mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-runtime-state.ts delete mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-capture.ts delete mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-restore.ts delete mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-state.ts delete mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts delete mode 100644 src/main/claude-accounts/shared-credential-fields.test.ts delete mode 100644 src/main/claude-accounts/shared-credential-fields.ts delete mode 100644 src/main/claude-accounts/shared-credential-reconciliation.test.ts create mode 100644 src/main/ipc/pty/ipc/claude-old-terminal.test.ts create mode 100644 src/main/ipc/pty/ipc/claude-old-terminal.ts delete mode 100644 src/main/native-chat/claude-structured-managed-account-support.test.ts delete mode 100644 src/main/native-chat/claude-structured-managed-account-support.ts delete mode 100644 src/main/rate-limits/claude-cli-usage-fetch.ts delete mode 100644 src/main/rate-limits/claude-fetcher-cli-fallback.test.ts delete mode 100644 src/main/rate-limits/claude-fetcher-fable-usage.test.ts delete mode 100644 src/main/rate-limits/claude-fetcher-keychain-credentials.test.ts delete mode 100644 src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts delete mode 100644 src/main/rate-limits/claude-fetcher-test-harness.ts delete mode 100644 src/main/rate-limits/claude-managed-account-credentials.ts delete mode 100644 src/main/rate-limits/claude-managed-usage-panel.ts delete mode 100644 src/main/rate-limits/claude-oauth-recovery.ts rename src/main/rate-limits/{claude-fetcher-extra-usage.test.ts => claude-oauth-usage-extra-usage.test.ts} (66%) create mode 100644 src/main/rate-limits/claude-profile-usage.test.ts delete mode 100644 src/main/rate-limits/claude-pty-reset-parser.ts delete mode 100644 src/main/rate-limits/claude-pty-stop-markers.ts delete mode 100644 src/main/rate-limits/claude-pty-usage-parser.ts delete mode 100644 src/main/rate-limits/claude-pty.test.ts delete mode 100644 src/main/rate-limits/claude-pty.ts delete mode 100644 src/main/rate-limits/claude-usage-refresh-plan.test.ts delete mode 100644 src/main/rate-limits/claude-usage-refresh-plan.ts delete mode 100644 src/main/rate-limits/hidden-pty-cleanup.test.ts delete mode 100644 src/main/rate-limits/hidden-pty-cleanup.ts delete mode 100644 src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts delete mode 100644 src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-claude-sign-in.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-claude-sign-in.ts create mode 100644 src/renderer/src/components/settings/accounts-pane-claude-section.test.tsx create mode 100644 src/renderer/src/components/status-bar/claude-status-sign-in.test.tsx create mode 100644 src/renderer/src/components/terminal-pane/ClaudeOldTerminalBanner.tsx delete mode 100644 src/renderer/src/components/terminal-pane/CodexOldTerminalDialog.tsx create mode 100644 src/renderer/src/components/terminal-pane/OldTerminalDialog.tsx create mode 100644 src/renderer/src/components/terminal-pane/PaneTopWarningBanner.tsx create mode 100644 src/renderer/src/components/terminal-pane/claude-old-terminal-banner.test.tsx create mode 100644 src/renderer/src/components/terminal-pane/open-terminal-beside-tab.ts create mode 100644 src/renderer/src/lib/claude-account-sign-in-notice.test.ts create mode 100644 src/renderer/src/lib/claude-account-sign-in-notice.ts create mode 100644 src/renderer/src/lib/claude-account-sign-in.ts create mode 100644 src/shared/claude-profile-activation-contract.test.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 6f957ce9fe3a..0d27ea922046 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1275,6 +1275,7 @@ jobs: src/main/wsl/wsl-runner.test.ts src/main/wsl/wsl-guest-environment.test.ts src/main/wsl/wsl-executable-path.win32.test.ts + src/main/claude-accounts/claude-profile-history-windows.test.ts src/main/wsl/wsl-w1-w3-contract.test.ts src/shared/source-scan/source-tree-scan.test.ts src/main/cli/wsl-cli-powershell-boundary.test.ts diff --git a/config/scripts/claude-account-windows-spawn-repro.mjs b/config/scripts/claude-account-windows-spawn-repro.mjs deleted file mode 100644 index eaf04762e5ba..000000000000 --- a/config/scripts/claude-account-windows-spawn-repro.mjs +++ /dev/null @@ -1,342 +0,0 @@ -import { spawn } from 'node:child_process' -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { buildWindowsCommandInvocation } from '../../src/main/claude-accounts/windows-command-invocation.ts' - -const strategy = process.argv[2] -if (!['baseline', 'candidate', 'explicit-cmd'].includes(strategy)) { - throw new Error( - 'Usage: node config/scripts/claude-account-windows-spawn-repro.mjs ' - ) -} -if (process.platform !== 'win32') { - throw new Error('This reproduction requires a physical Windows host.') -} - -const expectedArgs = [ - '', - 'two words', - 'amp&ersand', - 'pipe|value', - 'lessvalue', - 'caret^value', - 'trailing\\', - 'two-trailing\\\\', - '(parentheses)', - '100%', - '%ORCA_ARG_TRAP%', - 'bang!value', - '한글-λ' -] -const tempRoot = await mkdtemp(join(tmpdir(), 'orca-claude-spawn-')) -const reportedDir = join(tempRoot, 'Profile with spaces 한글') -const reportedCapturePath = join(reportedDir, 'capture.json') -const reportedPidPath = join(reportedDir, 'pids.json') -const reportedShimPath = join(reportedDir, 'claude fixture.cmd') -const reportedFixturePath = join(reportedDir, 'capture-child.cjs') -const fixtureDir = join(tempRoot, 'Profile space & ^ (paren) %ORCA_PATH_TRAP% !bang! 한글') -const capturePath = join(fixtureDir, 'capture.json') -const pidPath = join(fixtureDir, 'pids.json') -const shimPath = join(fixtureDir, 'claude fixture.cmd') -const fixturePath = join(fixtureDir, 'capture-child.cjs') -const fixtureEnv = { - ...process.env, - CLAUDE_CONFIG_DIR: join(fixtureDir, 'config space & ^ (paren) %ORCA_ENV_LITERAL% !bang! 한글'), - ORCA_ARG_TRAP: 'EXPANDED_ARG', - ORCA_PATH_TRAP: 'EXPANDED_PATH', - ORCA_FIXTURE_CAPTURE: capturePath, - ORCA_FIXTURE_PIDS: pidPath, - ORCA_FIXTURE_NODE: process.execPath -} -const reportedEnv = { - ...fixtureEnv, - CLAUDE_CONFIG_DIR: join(reportedDir, 'config with spaces 한글'), - ORCA_FIXTURE_CAPTURE: reportedCapturePath, - ORCA_FIXTURE_PIDS: reportedPidPath -} - -function quoteForCandidate(value) { - return `"${value.replace(/"/g, '""')}"` -} - -function launch(args, command = shimPath, env = fixtureEnv) { - if (strategy === 'baseline') { - return spawn(command, args, { cwd: tempRoot, env, shell: true, windowsHide: true }) - } - if (strategy === 'candidate') { - return spawn(quoteForCandidate(command), args, { - cwd: tempRoot, - env, - shell: true, - windowsHide: true - }) - } - const invocation = buildWindowsCommandInvocation(command, args) - return spawn(invocation.command, invocation.args, { - cwd: tempRoot, - env, - shell: false, - windowsVerbatimArguments: invocation.windowsVerbatimArguments, - windowsHide: true - }) -} - -function collect(child) { - return new Promise((resolve) => { - let stdout = '' - let stderr = '' - child.stdout?.on('data', (chunk) => (stdout += chunk.toString())) - child.stderr?.on('data', (chunk) => (stderr += chunk.toString())) - child.on('error', (error) => resolve({ code: null, stdout, stderr, error: error.message })) - child.on('close', (code) => resolve({ code, stdout, stderr, error: null })) - }) -} - -async function waitForFile(path, timeoutMs = 5_000) { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - try { - return JSON.parse(await readFile(path, 'utf8')) - } catch { - await new Promise((resolve) => setTimeout(resolve, 25)) - } - } - throw new Error(`Timed out waiting for fixture output: ${path}`) -} - -async function taskExists(pid) { - const result = await collect( - spawn('tasklist.exe', ['/fi', `PID eq ${pid}`, '/fo', 'csv', '/nh'], { - windowsHide: true - }) - ) - if (result.error || result.code !== 0) { - throw new Error(`tasklist failed for PID ${pid}: ${result.error ?? result.stderr}`) - } - return result.stdout.includes(`"${pid}"`) -} - -async function killTree(pid) { - const result = await collect( - spawn('taskkill.exe', ['/pid', String(pid), '/t', '/f'], { windowsHide: true }) - ) - if (result.error || result.code !== 0) { - throw new Error(`taskkill failed for PID ${pid}: ${result.error ?? result.stderr}`) - } -} - -async function waitForTreeExit(pids, timeoutMs = 5_000) { - const deadline = Date.now() + timeoutMs - let alive = {} - do { - alive = Object.fromEntries( - await Promise.all( - Object.entries(pids).map(async ([name, pid]) => [name, await taskExists(pid)]) - ) - ) - if (!Object.values(alive).some(Boolean)) { - return alive - } - await new Promise((resolve) => setTimeout(resolve, 50)) - } while (Date.now() < deadline) - return alive -} - -const results = { - strategy, - reportedPath: null, - pathMatrix: {}, - argvMatrix: {}, - hostilePathAndArgv: null, - error: null, - cancellation: null -} -const fixtureSource = - `const { spawn } = require('node:child_process')\n` + - `const { writeFileSync } = require('node:fs')\n` + - `if (process.argv[2] === '--exit-error') { process.stderr.write('fixture error: 한글 & ^ % !\\n'); process.exit(23) }\n` + - `if (process.argv[2] === '--linger') {\n` + - ` const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { windowsHide: true })\n` + - ` writeFileSync(process.env.ORCA_FIXTURE_PIDS, JSON.stringify({ child: process.pid, grandchild: grandchild.pid }))\n` + - ` setInterval(() => {}, 1000)\n` + - `} else {\n` + - ` writeFileSync(process.env.ORCA_FIXTURE_CAPTURE, JSON.stringify({ argv: process.argv.slice(2), configDir: process.env.CLAUDE_CONFIG_DIR }))\n` + - `}\n` -const shimSource = '@echo off\r\n"%ORCA_FIXTURE_NODE%" "%~dp0capture-child.cjs" %*\r\n' -let lingeringShellPid = null -try { - await mkdir(fixtureDir, { recursive: true }) - await mkdir(reportedDir, { recursive: true }) - await writeFile(fixturePath, fixtureSource, 'utf8') - await writeFile(shimPath, shimSource, 'utf8') - await writeFile(reportedFixturePath, fixtureSource, 'utf8') - await writeFile(reportedShimPath, shimSource, 'utf8') - - const reportedArgs = ['auth', 'status', '--json'] - const reportedRun = await collect(launch(reportedArgs, reportedShimPath, reportedEnv)) - let reportedCapture = null - try { - reportedCapture = await waitForFile(reportedCapturePath, 1_000) - } catch {} - results.reportedPath = { - ...reportedRun, - actual: reportedCapture, - expected: { argv: reportedArgs, configDir: reportedEnv.CLAUDE_CONFIG_DIR }, - pass: - reportedRun.code === 0 && - JSON.stringify(reportedCapture) === - JSON.stringify({ argv: reportedArgs, configDir: reportedEnv.CLAUDE_CONFIG_DIR }) - } - - for (const [name, segment] of Object.entries({ - spaces: 'profile space', - ampersand: 'profile&name', - caret: 'profile^name', - parentheses: 'profile(name)', - percent: 'profile%ORCA_PATH_TRAP%', - bang: 'profile!name', - unicode: 'profile-한글-λ' - })) { - const directory = join(tempRoot, segment) - const captureFile = join(directory, 'capture.json') - const command = join(directory, 'claude fixture.cmd') - await mkdir(directory, { recursive: true }) - await writeFile(join(directory, 'capture-child.cjs'), fixtureSource, 'utf8') - await writeFile(command, shimSource, 'utf8') - const env = { - ...fixtureEnv, - CLAUDE_CONFIG_DIR: join(directory, 'config'), - ORCA_FIXTURE_CAPTURE: captureFile - } - const run = await collect(launch(reportedArgs, command, env)) - let actual = null - try { - actual = await waitForFile(captureFile, 500) - } catch {} - results.pathMatrix[name] = { - code: run.code, - stderr: run.stderr.trim(), - actual: actual?.argv ?? null, - pass: run.code === 0 && JSON.stringify(actual?.argv) === JSON.stringify(reportedArgs) - } - } - - for (const [name, value] of Object.entries({ - empty: '', - spaces: 'two words', - ampersand: 'amp&ersand', - pipe: 'pipe|value', - lessThan: 'lessvalue', - caret: 'caret^value', - trailingBackslash: 'trailing\\', - twoTrailingBackslashes: 'two-trailing\\\\', - parentheses: '(parentheses)', - percent: '%ORCA_ARG_TRAP%', - bang: 'bang!value', - unicode: '한글-λ' - })) { - const args = ['prefix', value, 'suffix'] - const captureFile = join(reportedDir, `capture-${name}.json`) - const env = { ...reportedEnv, ORCA_FIXTURE_CAPTURE: captureFile } - const run = await collect(launch(args, reportedShimPath, env)) - let actual = null - try { - actual = await waitForFile(captureFile, 500) - } catch {} - results.argvMatrix[name] = { - code: run.code, - stderr: run.stderr.trim(), - actual: actual?.argv ?? null, - pass: run.code === 0 && JSON.stringify(actual?.argv) === JSON.stringify(args) - } - } - - const argvRun = await collect(launch(expectedArgs)) - let capture = null - try { - capture = await waitForFile(capturePath, 1_000) - } catch {} - results.hostilePathAndArgv = { - ...argvRun, - actual: capture, - expected: { argv: expectedArgs, configDir: fixtureEnv.CLAUDE_CONFIG_DIR }, - pass: - argvRun.code === 0 && - JSON.stringify(capture) === - JSON.stringify({ argv: expectedArgs, configDir: fixtureEnv.CLAUDE_CONFIG_DIR }) - } - - results.error = await collect(launch(['--exit-error'], reportedShimPath, reportedEnv)) - results.error.pass = - results.error.code === 23 && results.error.stderr.includes('fixture error: 한글 & ^ % !') - - const lingering = launch(['--linger'], reportedShimPath, reportedEnv) - lingeringShellPid = lingering.pid - const lingeringResult = collect(lingering) - try { - const pids = await waitForFile(reportedPidPath) - await killTree(lingering.pid) - const alive = await waitForTreeExit({ - shell: lingering.pid, - child: pids.child, - grandchild: pids.grandchild - }) - results.cancellation = { - shell: lingering.pid, - ...pids, - alive, - pass: !Object.values(alive).some(Boolean) - } - } catch (error) { - if (await taskExists(lingering.pid)) { - await killTree(lingering.pid) - } - const launchResult = await Promise.race([ - lingeringResult, - new Promise((resolve) => - setTimeout( - () => resolve({ code: null, error: 'fixture did not exit after cleanup' }), - 5_000 - ) - ) - ]) - results.cancellation = { - shell: lingering.pid, - launchResult, - pass: false, - error: error instanceof Error ? error.message : String(error) - } - } -} finally { - if (lingeringShellPid && (await taskExists(lingeringShellPid))) { - await killTree(lingeringShellPid) - } - try { - let pids - try { - pids = JSON.parse(await readFile(reportedPidPath, 'utf8')) - } catch { - pids = JSON.parse(await readFile(pidPath, 'utf8')) - } - for (const pid of [pids.child, pids.grandchild]) { - if (await taskExists(pid)) { - await killTree(pid) - } - } - } catch {} - await rm(tempRoot, { recursive: true, force: true }) -} - -console.log(JSON.stringify(results, null, 2)) -process.exitCode = - results.reportedPath?.pass && - Object.values(results.pathMatrix).every((result) => result.pass) && - Object.values(results.argvMatrix).every((result) => result.pass) && - results.hostilePathAndArgv?.pass && - results.error?.pass && - results.cancellation?.pass - ? 0 - : 1 diff --git a/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs b/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs index d9ee133a70e3..fc0894d58f6b 100644 --- a/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs +++ b/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs @@ -10,8 +10,8 @@ // Those numbers are the `!useConptyDll` branch, which is the branch a RELAY runs. Every desktop // site that opens a terminal pane sets `useConptyDll: true` and takes the other branch, where // upstream already destroys the input socket. The hidden rate-limit probe -// (`src/main/rate-limits/claude-pty.ts`) does omit the option and so does run this hunk, but no -// user-visible pane does. The divergence pinned below is about which +// (`src/main/rate-limits/codex-pty-rate-limit-probe.ts`) does omit the option and so does run this +// hunk, but no user-visible pane does. The divergence pinned below is about which // branch each host runs for terminals -- not about a regression in the panes users open. import { createRequire } from 'node:module' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' diff --git a/src/cli/handlers/account-list-format.ts b/src/cli/handlers/account-list-format.ts index 66da6d4b1b71..59df0c6b3a48 100644 --- a/src/cli/handlers/account-list-format.ts +++ b/src/cli/handlers/account-list-format.ts @@ -3,7 +3,7 @@ import type { ManagedDataAccountsState } from '../../shared/managed-account-type // Why: Claude and Codex managed-account summaries both carry id+email+active id, // so one formatter renders either provider's block. type AccountsBlock = { - accounts: readonly { id: string; email: string }[] + accounts: readonly { id: string; email: string; needsSignIn?: true }[] activeAccountId: string | null activeAccountIdsByRuntime?: { host: string | null @@ -35,7 +35,10 @@ export function formatAccountsBlock(label: string, block: AccountsBlock): string ...Object.values(block.activeAccountIdsByRuntime?.wsl ?? {}) ]) const lines = block.accounts.map( - (account) => ` ${account.email}${activeAccountIds.has(account.id) ? ' (active)' : ''}` + (account) => + ` ${account.email}${activeAccountIds.has(account.id) ? ' (active)' : ''}${ + account.needsSignIn ? ' (sign in again in Orca Settings > AI Provider Accounts)' : '' + }` ) return `Managed ${label} accounts (${block.accounts.length}):\n${lines.join('\n')}` } diff --git a/src/cli/handlers/account.test.ts b/src/cli/handlers/account.test.ts index 8b2fe9d5ae7e..e19fd4a8ad29 100644 --- a/src/cli/handlers/account.test.ts +++ b/src/cli/handlers/account.test.ts @@ -1,6 +1,5 @@ import type * as NodeCliCommandResolutionModule from '../../shared/node-cli-command-resolution' import { EventEmitter } from 'node:events' -import { createHash } from 'node:crypto' import { existsSync } from 'node:fs' import { delimiter } from 'node:path' import type * as NodeFs from 'node:fs' @@ -63,7 +62,10 @@ import { WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR, WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL } from '../../shared/windows-batch-spawn' -import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { + ACCOUNT_IMPORT_RUNTIME_CAPABILITY, + CLAUDE_SIGN_IN_RUNTIME_CAPABILITY +} from '../../shared/protocol-version' function successfulChild(): EventEmitter { const child = new EventEmitter() @@ -131,8 +133,14 @@ describe('account CLI handlers', () => { ok: true, result: method === 'status.get' - ? { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] } - : accountState(method.includes('Claude') ? 'claude@example.com' : 'codex@example.com'), + ? { + capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY, CLAUDE_SIGN_IN_RUNTIME_CAPABILITY] + } + : method === 'accounts.beginClaudeSignIn' + ? { accountId: 'draft', configDir: '/fake/final-profile', runtime: 'host' } + : accountState( + method.includes('Claude') ? 'claude@example.com' : 'codex@example.com' + ), _meta: { runtimeId: 'test-runtime' } }) ) @@ -306,37 +314,17 @@ describe('account CLI handlers', () => { expect(pathValues).toContain(`${nodeBin}${delimiter}${effectivePathBefore}`) }) - it('removes scoped Claude credentials and restores the legacy Keychain item', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) - readKeychainMock.mockResolvedValue('legacy-credentials') - + it('runs login in the final profile and never reads, writes or cleans up Keychain credentials', async () => { await ACCOUNT_HANDLERS['account add'](context('claude')) - - const configDir = spawnMock.mock.calls[0]?.[2].env.CLAUDE_CONFIG_DIR - expect(deleteKeychainMock).toHaveBeenCalledWith(configDir) - expect(writeKeychainMock).toHaveBeenCalledWith('legacy-credentials') - expect(callMock).toHaveBeenCalledWith('accounts.addClaudeFromConfigDir', { - configDir, - previousLegacyCredentialsSha256: createHash('sha256') - .update('legacy-credentials') - .digest('hex') - }) - expect(existsSync(configDir)).toBe(false) - }) - - it('attributes a Claude account added through the WSL bridge to the cwd distro', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - - await ACCOUNT_HANDLERS['account add']( - context('claude', false, String.raw`\\wsl.localhost\Ubuntu-22.04\home\user\project`) + expect(spawnMock.mock.calls[0]?.[2].env.CLAUDE_CONFIG_DIR).toBe('/fake/final-profile') + expect(callMock).toHaveBeenCalledWith( + 'accounts.finishClaudeSignIn', + { accountId: 'draft', runtime: 'host', wslDistro: undefined }, + { timeoutMs: 300000 } ) - - const configDir = spawnMock.mock.calls[0]?.[2].env.CLAUDE_CONFIG_DIR - expect(callMock).toHaveBeenCalledWith('accounts.addClaudeFromConfigDir', { - configDir, - runtime: 'wsl', - wslDistro: 'Ubuntu-22.04' - }) + expect(readKeychainMock).not.toHaveBeenCalled() + expect(writeKeychainMock).not.toHaveBeenCalled() + expect(deleteKeychainMock).not.toHaveBeenCalled() }) it('attributes a Codex account added through the WSL bridge to the cwd distro', async () => { @@ -364,8 +352,9 @@ describe('account CLI handlers', () => { ) expect(callMock).toHaveBeenCalledWith( - 'accounts.addClaudeFromConfigDir', - expect.objectContaining({ runtime: 'wsl', wslDistro: 'Debian' }) + 'accounts.beginClaudeSignIn', + expect.objectContaining({ runtime: 'wsl', wslDistro: 'Debian' }), + { timeoutMs: 300000 } ) }) @@ -375,10 +364,14 @@ describe('account CLI handlers', () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) process.env.ORCA_CLI_WSL_DISTRO = 'Ubuntu Work' await ACCOUNT_HANDLERS['account add'](context(agent, false, String.raw`C:\work with spaces`)) - expect(callMock).toHaveBeenCalledWith( - agent === 'claude' ? 'accounts.addClaudeFromConfigDir' : 'accounts.addCodexFromHome', - expect.objectContaining({ runtime: 'wsl', wslDistro: 'Ubuntu Work' }) - ) + expect( + callMock.mock.calls.some( + ([method, params]) => + method === + (agent === 'claude' ? 'accounts.beginClaudeSignIn' : 'accounts.addCodexFromHome') && + params.wslDistro === 'Ubuntu Work' + ) + ).toBe(true) } ) @@ -470,62 +463,6 @@ describe('account CLI handlers', () => { exitSpy.mockRestore() }) - it('waits for in-flight cleanup when a second signal arrives', async () => { - // Why: a boolean latch lets the second signal's process.exit fire while the - // first cleanup is still inside a Keychain call, stranding the credentials. - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) - readKeychainMock.mockResolvedValue('legacy-credentials') - let releaseKeychainDelete: (() => void) | undefined - deleteKeychainMock.mockImplementationOnce( - () => - new Promise((resolvePromise) => { - releaseKeychainDelete = () => resolvePromise() - }) - ) - const child = Object.assign(new EventEmitter(), { kill: vi.fn() }) - let configDir = '' - spawnMock.mockImplementation((_command, _args, options: { env: Record }) => { - configDir = options.env.CLAUDE_CONFIG_DIR - return child - }) - const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) - const sigintBefore = process.listeners('SIGINT') - const sigtermBefore = process.listeners('SIGTERM') - - const pending = ACCOUNT_HANDLERS['account add'](context('claude')).catch(() => {}) - await vi.waitFor(() => expect(configDir).not.toBe('')) - - const onSigint = newSignalListener('SIGINT', sigintBefore) - const onSigterm = newSignalListener('SIGTERM', sigtermBefore) - // Why: `rawListeners` exposes the `once` wrapper, so this fails if the handler - // is registered with `once` — where a second Ctrl-C falls through to Node's - // default and kills the process mid-cleanup. - expect(process.rawListeners('SIGINT')).toContain(onSigint) - - onSigint('SIGINT') - await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGINT')) - child.emit('exit', 1) - child.emit('close', 1) - await vi.waitFor(() => expect(deleteKeychainMock).toHaveBeenCalledWith(configDir)) - onSigterm('SIGTERM') - await new Promise((resolvePromise) => { - setImmediate(resolvePromise) - }) - - expect(exitSpy).not.toHaveBeenCalled() - expect(writeKeychainMock).not.toHaveBeenCalled() - expect(existsSync(configDir)).toBe(true) - - releaseKeychainDelete?.() - await vi.waitFor(() => expect(exitSpy).toHaveBeenCalled()) - expect(writeKeychainMock).toHaveBeenCalledWith('legacy-credentials') - expect(existsSync(configDir)).toBe(false) - expect(child.kill).toHaveBeenCalledOnce() - - await pending - exitSpy.mockRestore() - }) - it('warns that the account may already be registered when interrupted mid-RPC', async () => { // Why: the runtime finishes the add independently of this process, so an // interrupt after sign-in cannot honestly be reported as "not added". @@ -540,7 +477,9 @@ describe('account CLI handlers', () => { ? Promise.resolve({ id: 'test', ok: true, - result: { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] }, + result: { + capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY, CLAUDE_SIGN_IN_RUNTIME_CAPABILITY] + }, _meta: { runtimeId: 'test-runtime' } }) : method === 'accounts.list' @@ -569,32 +508,6 @@ describe('account CLI handlers', () => { exitSpy.mockRestore() }) - it('stays armed for signals until post-success cleanup finishes', async () => { - // Why: detaching the handlers before cleanup leaves the multi-second Keychain - // calls covered only by Node's default handling, which kills mid-cleanup. - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) - readKeychainMock.mockResolvedValue('legacy-credentials') - let releaseKeychainDelete: (() => void) | undefined - deleteKeychainMock.mockImplementationOnce( - () => - new Promise((resolvePromise) => { - releaseKeychainDelete = () => resolvePromise() - }) - ) - - const listenersBefore = process.listeners('SIGINT') - - const pending = ACCOUNT_HANDLERS['account add'](context('claude')) - await vi.waitFor(() => expect(deleteKeychainMock).toHaveBeenCalled()) - - // Why: cleanup is still in flight here, so this add's guard must still be installed. - const handler = newSignalListener('SIGINT', listenersBefore) - - releaseKeychainDelete?.() - await pending - expect(process.listeners('SIGINT')).not.toContain(handler) - }) - it('fails before the login when the runtime is unreachable', async () => { // Why: discovering a dead runtime after sign-in wastes a full OAuth round trip. callMock.mockRejectedValue(new Error('runtime not running')) @@ -606,22 +519,40 @@ describe('account CLI handlers', () => { expect(spawnMock).not.toHaveBeenCalled() }) - it('fails before login when the running runtime predates account imports', async () => { + it('still adds Codex through an older host that advertises only the retired import capability', async () => { callMock.mockResolvedValue({ id: 'test', ok: true, - result: { capabilities: [] }, + result: { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] }, _meta: { runtimeId: 'test-runtime' } }) + spawnMock.mockImplementation(() => { + throw new Error('stop after the capability gate') + }) - await expect(ACCOUNT_HANDLERS['account add'](context('codex'))).rejects.toThrow( - 'runtime is too old' - ) - expect(callMock).toHaveBeenCalledOnce() - expect(callMock).toHaveBeenCalledWith('status.get') - expect(spawnMock).not.toHaveBeenCalled() + await expect(ACCOUNT_HANDLERS['account add'](context('codex'))).rejects.toThrow() + expect(spawnMock).toHaveBeenCalled() }) + it.each(['claude', 'codex'] as const)( + 'refuses %s login before spawning when its capability is absent', + async (agent) => { + callMock.mockResolvedValue({ + id: 'test', + ok: true, + result: { capabilities: [] }, + _meta: { runtimeId: 'test-runtime' } + }) + + await expect(ACCOUNT_HANDLERS['account add'](context(agent))).rejects.toThrow( + 'The running Orca runtime is too old to add accounts from the CLI.' + ) + expect(callMock).toHaveBeenCalledOnce() + expect(callMock).toHaveBeenCalledWith('status.get') + expect(spawnMock).not.toHaveBeenCalled() + } + ) + it.each(['environment', 'pairing-code'])( 'rejects --%s instead of silently ignoring it', async (flag) => { @@ -667,7 +598,9 @@ describe('account CLI handlers', () => { ? Promise.resolve({ id: 'test', ok: true, - result: { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] }, + result: { + capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY, CLAUDE_SIGN_IN_RUNTIME_CAPABILITY] + }, _meta: { runtimeId: 'test-runtime' } }) : method === 'accounts.list' @@ -699,15 +632,14 @@ describe('account CLI handlers', () => { expect(logSpy).not.toHaveBeenCalled() }) - it('fails a successful Claude add when Keychain cleanup fails', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) - readKeychainMock.mockResolvedValue('legacy-credentials') - deleteKeychainMock.mockRejectedValueOnce(new Error('Keychain denied cleanup')) - - await expect(ACCOUNT_HANDLERS['account add'](context('claude'))).rejects.toThrow( - 'Failed to clean up Claude login artifacts' - ) - expect(writeKeychainMock).toHaveBeenCalledWith('legacy-credentials') + it('does not touch a locked Keychain while the CLI signs in', async () => { + readKeychainMock.mockRejectedValue(new Error('locked')) + deleteKeychainMock.mockRejectedValue(new Error('locked')) + writeKeychainMock.mockRejectedValue(new Error('locked')) + await ACCOUNT_HANDLERS['account add'](context('claude')) + expect(readKeychainMock).not.toHaveBeenCalled() + expect(deleteKeychainMock).not.toHaveBeenCalled() + expect(writeKeychainMock).not.toHaveBeenCalled() }) it('rejects `--agent` with no value instead of defaulting to Claude', async () => { @@ -741,6 +673,32 @@ describe('account CLI handlers', () => { expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('claude@example.com (active)')) }) + it('tells the user which saved Claude accounts need a fresh sign-in', async () => { + callMock.mockResolvedValue({ + id: 'test', + ok: true, + result: { + claude: { + accounts: [ + { id: 'old', email: 'old@example.com', needsSignIn: true }, + { id: 'ok', email: 'ok@example.com' } + ], + activeAccountId: 'old' + }, + codex: { accounts: [], activeAccountId: null } + }, + _meta: { runtimeId: 'test-runtime' } + }) + + await ACCOUNT_HANDLERS['account list']({ ...context('claude'), flags: new Map() }) + + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining( + ' old@example.com (active) (sign in again in Orca Settings > AI Provider Accounts)\n ok@example.com\n' + ) + ) + }) + it('lists accounts without forcing a provider usage refresh', async () => { // Why: the forced lane bypasses the poll throttle and costs one serial // round-trip per managed account, and this output shows no usage numbers. diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts index d4081c3c5da1..db2cd31c8b8a 100644 --- a/src/cli/handlers/account.ts +++ b/src/cli/handlers/account.ts @@ -1,5 +1,4 @@ import { spawn } from 'node:child_process' -import { createHash } from 'node:crypto' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' @@ -9,10 +8,10 @@ import { RuntimeClientError } from '../runtime-client' import { stripElectronRunAsNode } from '../runtime/launch' import { rejectRemoteSelectionFlags } from '../remote-selection-flag-rejection' import { - deleteActiveClaudeKeychainCredentialsStrict, - readActiveClaudeKeychainCredentialsStrict, - writeActiveClaudeKeychainCredentials -} from '../../main/claude-accounts/keychain' + buildWslExecArgs, + buildWslLoginShellCommand, + quotePosixShell +} from '../../shared/wsl-login-shell-command' import { getVersionManagerBinPaths, resolveCliCommand, @@ -24,9 +23,13 @@ import { WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL } from '../../shared/windows-batch-spawn' import { stdioForWindowsInteractiveChild } from '../../shared/windows-console-input' -import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { + ACCOUNT_IMPORT_RUNTIME_CAPABILITY, + CLAUDE_SIGN_IN_RUNTIME_CAPABILITY +} from '../../shared/protocol-version' import type { RuntimeStatus } from '../../shared/runtime-types' import type { + ClaudeAccountSignIn, ClaudeRateLimitAccountsState, CodexRateLimitAccountsState, ManagedDataAccountsState @@ -140,79 +143,53 @@ async function runAgentLoginInTerminal( }) } -async function cleanupClaudeLoginArtifacts( - configDir: string, - legacyCredentials: string | null, - restoreLegacyCredentials: boolean -): Promise { - const errors: unknown[] = [] - if (process.platform === 'darwin') { - try { - await deleteActiveClaudeKeychainCredentialsStrict(configDir) - } catch (error) { - errors.push(error) - } - if (restoreLegacyCredentials) { - try { - await (legacyCredentials - ? writeActiveClaudeKeychainCredentials(legacyCredentials) - : deleteActiveClaudeKeychainCredentialsStrict()) - } catch (error) { - errors.push(error) - } - } - } - try { - rmSync(configDir, { recursive: true, force: true }) - } catch (error) { - errors.push(error) - } - if (errors.length > 0) { - throw new AggregateError(errors, 'Failed to clean up Claude login artifacts.') - } -} - -/** Logs into a Claude account in a temp config dir, then registers it with the local runtime. */ +/** Signs in to a new account folder the host created, then registers it (superset AddAccountDialog). */ async function addClaudeAccount({ client, cwd, json }: HandlerContext): Promise { - const configDir = mkdtempSync(join(tmpdir(), 'orca-account-add-claude-')) + const { result: signIn } = await client.call( + 'accounts.beginClaudeSignIn', + getWslAccountTarget(cwd) ?? {}, + { timeoutMs: 300_000 } + ) const session: InteractiveLoginSession = { child: null, registering: false, terminationPromise: null } - let legacyCredentials: string | null = null - let restoreLegacyCredentials = false const result = await withInteractiveLoginCleanup( session, + // Why always: the host keeps a folder that became an account, and deletes an abandoned one. + // Never thrown: an older running Orca lacks the method, and the add itself already finished. async () => { - await cleanupClaudeLoginArtifacts(configDir, legacyCredentials, restoreLegacyCredentials) + const { accountId, runtime, wslDistro } = signIn + await client + .call('accounts.cancelClaudeSignIn', { accountId, runtime, wslDistro }) + .catch((error: unknown) => console.warn('[account] Could not clean up sign-in:', error)) }, async () => { - if (process.platform === 'darwin') { - legacyCredentials = await readActiveClaudeKeychainCredentialsStrict() - restoreLegacyCredentials = true + if (signIn.runtime === 'wsl' && signIn.wslDistro) { + const login = `exec env CLAUDE_CONFIG_DIR=${quotePosixShell(signIn.configDir)} claude auth login` + await runAgentLoginInTerminal( + 'wsl.exe', + buildWslExecArgs(signIn.wslDistro, ['/bin/sh', '-c', buildWslLoginShellCommand(login)]), + {}, + json, + session + ) + } else { + await runAgentLoginInTerminal( + 'claude', + ['auth', 'login'], + { CLAUDE_CONFIG_DIR: signIn.configDir }, + json, + session + ) } - await runAgentLoginInTerminal( - 'claude', - ['auth', 'login', '--claudeai'], - { - CLAUDE_CONFIG_DIR: configDir - }, - json, - session - ) session.registering = true - return client.call('accounts.addClaudeFromConfigDir', { - configDir, - ...getWslAccountTarget(cwd), - ...(process.platform === 'darwin' - ? { - previousLegacyCredentialsSha256: legacyCredentials - ? createHash('sha256').update(legacyCredentials).digest('hex') - : null - } - : {}) - }) + return client.call( + 'accounts.finishClaudeSignIn', + { accountId: signIn.accountId, runtime: signIn.runtime, wslDistro: signIn.wslDistro }, + { timeoutMs: 300_000 } + ) } ) printResult(result, json, (state) => formatAccountsBlock('Claude', state)) @@ -265,9 +242,14 @@ function rejectAccountRemoteSelectionFlags(ctx: HandlerContext, command: string) ) } -async function assertAccountImportSupported({ client }: HandlerContext): Promise { +async function assertAccountImportSupported( + { client }: HandlerContext, + agent: 'claude' | 'codex' +): Promise { const status = await client.call('status.get') - if (!status.result.capabilities?.includes(ACCOUNT_IMPORT_RUNTIME_CAPABILITY)) { + const capability = + agent === 'claude' ? CLAUDE_SIGN_IN_RUNTIME_CAPABILITY : ACCOUNT_IMPORT_RUNTIME_CAPABILITY + if (!status.result.capabilities?.includes(capability)) { throw new RuntimeClientError( 'incompatible_runtime', 'The running Orca runtime is too old to add accounts from the CLI. Update or restart Orca and try again.' @@ -300,7 +282,7 @@ export const ACCOUNT_HANDLERS: Record = { return } // Why: fail on runtime version skew before burning a full OAuth round trip. - await assertAccountImportSupported(ctx) + await assertAccountImportSupported(ctx, agent) await ctx.client.call('accounts.list', { refreshUsage: false }) await (agent === 'claude' ? addClaudeAccount(ctx) : addCodexAccount(ctx)) }, diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt index c8de61a9fdac..eb280f1900bf 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -138,6 +138,30 @@ if [[ -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then } fi unset __orca_codex_binary +__orca_claude_binary="$(unalias claude 2>/dev/null || :; command -v claude 2>/dev/null || :)" +if [[ -n "${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "${__orca_claude_binary:-}" && -x "${__orca_claude_binary}" ]]; then + function claude { + local __orca_claude_home __orca_claude_pointer="${ORCA_CLAUDE_PROFILE_POINTER:-}" + # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. + case "$__orca_claude_pointer" in '~/'*) __orca_claude_pointer="${HOME:-}/${__orca_claude_pointer#??}" ;; esac + __orca_claude_home="$(cat "$__orca_claude_pointer" 2>/dev/null || :)" + if [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ "$CLAUDE_CONFIG_DIR" != "${ORCA_CLAUDE_INJECTED_CONFIG_DIR:-}" ]; then + command claude "$@"; return + fi + # Why: Orca's value replaced the user's own at spawn, so System default restores theirs. + if [ -z "$__orca_claude_home" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ -n "${ORCA_CLAUDE_USER_CONFIG_DIR:-}" ]; then + ( unset ORCA_CLAUDE_INJECTED_CONFIG_DIR; export CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR"; command claude "$@" ); return + fi + if [ -z "$__orca_claude_home" ]; then + ( unset CLAUDE_CONFIG_DIR ORCA_CLAUDE_INJECTED_CONFIG_DIR; command claude "$@" ); return + fi + case "$__orca_claude_home" in /*|[A-Za-z]:*) ;; *) return 1 ;; esac + # Why -m 700: matches the folder Orca's setup creates; a credentials folder stays private. + [ -d "$__orca_claude_home" ] || mkdir -p -m 700 -- "$__orca_claude_home" 2>/dev/null + ( unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN AWS_BEARER_TOKEN_BEDROCK; case "${ANTHROPIC_CUSTOM_HEADERS:-}" in *[aA][uU][tT][hH][oO][rR][iI][zZ][aA][tT][iI][oO][nN]*|*[xX]-[aA][pP][iI]-[kK][eE][yY]*|*[aA][pP][iI]-[kK][eE][yY]*|*[bB][eE][aA][rR][eE][rR]*) unset ANTHROPIC_CUSTOM_HEADERS ;; esac; export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) + } +fi +unset __orca_claude_binary # Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent # status when the foreground command exits — mirrors the zsh daemon wrapper. diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt index ff24125e2c00..42fb21032ecc 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt @@ -17,4 +17,34 @@ if test "$__orca_codex_type" = file command codex $argv end end -set -e __orca_codex_type \ No newline at end of file +set -e __orca_codex_type +set -l __orca_claude_type (type -t claude 2>/dev/null) +if test -n "$ORCA_CLAUDE_PROFILE_POINTER"; and test "$__orca_claude_type" = file + function claude + # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. + set -l pointer (string replace -r '^~/' "$HOME/" -- "$ORCA_CLAUDE_PROFILE_POINTER") + set -l profile (cat "$pointer" 2>/dev/null) + if test -n "$CLAUDE_CONFIG_DIR"; and test "$CLAUDE_CONFIG_DIR" != "$ORCA_CLAUDE_INJECTED_CONFIG_DIR" + command claude $argv + return $status + end + if test -z "$profile"; and test -n "$CLAUDE_CONFIG_DIR"; and test -n "$ORCA_CLAUDE_USER_CONFIG_DIR" + env -u ORCA_CLAUDE_INJECTED_CONFIG_DIR CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR" claude $argv + return $status + end + if test -z "$profile" + env -u CLAUDE_CONFIG_DIR -u ORCA_CLAUDE_INJECTED_CONFIG_DIR claude $argv + return $status + end + if not string match -qr '^(/|[A-Za-z]:)' -- "$profile" + return 1 + end + test -d "$profile"; or mkdir -p -m 700 -- "$profile" 2>/dev/null + set -l headers + if string match -irq 'authorization|x-api-key|api-key|bearer' -- "$ANTHROPIC_CUSTOM_HEADERS" + set headers -u ANTHROPIC_CUSTOM_HEADERS + end + env $headers -u ANTHROPIC_API_KEY -u ANTHROPIC_AUTH_TOKEN -u CLAUDE_CODE_OAUTH_TOKEN -u AWS_BEARER_TOKEN_BEDROCK CLAUDE_CONFIG_DIR="$profile" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$profile" claude $argv + end +end +set -e __orca_claude_type diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index 18850b4a0efc..22c221bb9434 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -220,6 +220,30 @@ __orca_deferred_init() { } fi unset __orca_codex_binary + __orca_claude_binary="$(unalias claude 2>/dev/null || :; command -v claude 2>/dev/null || :)" + if [[ -n "${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "${__orca_claude_binary:-}" && -x "${__orca_claude_binary}" ]]; then + function claude { + local __orca_claude_home __orca_claude_pointer="${ORCA_CLAUDE_PROFILE_POINTER:-}" + # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. + case "$__orca_claude_pointer" in '~/'*) __orca_claude_pointer="${HOME:-}/${__orca_claude_pointer#??}" ;; esac + __orca_claude_home="$(cat "$__orca_claude_pointer" 2>/dev/null || :)" + if [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ "$CLAUDE_CONFIG_DIR" != "${ORCA_CLAUDE_INJECTED_CONFIG_DIR:-}" ]; then + command claude "$@"; return + fi + # Why: Orca's value replaced the user's own at spawn, so System default restores theirs. + if [ -z "$__orca_claude_home" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ -n "${ORCA_CLAUDE_USER_CONFIG_DIR:-}" ]; then + ( unset ORCA_CLAUDE_INJECTED_CONFIG_DIR; export CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR"; command claude "$@" ); return + fi + if [ -z "$__orca_claude_home" ]; then + ( unset CLAUDE_CONFIG_DIR ORCA_CLAUDE_INJECTED_CONFIG_DIR; command claude "$@" ); return + fi + case "$__orca_claude_home" in /*|[A-Za-z]:*) ;; *) return 1 ;; esac + # Why -m 700: matches the folder Orca's setup creates; a credentials folder stays private. + [ -d "$__orca_claude_home" ] || mkdir -p -m 700 -- "$__orca_claude_home" 2>/dev/null + ( unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN AWS_BEARER_TOKEN_BEDROCK; case "${ANTHROPIC_CUSTOM_HEADERS:-}" in *[aA][uU][tT][hH][oO][rR][iI][zZ][aA][tT][iI][oO][nN]*|*[xX]-[aA][pP][iI]-[kK][eE][yY]*|*[aA][pP][iI]-[kK][eE][yY]*|*[bB][eE][aA][rR][eE][rR]*) unset ANTHROPIC_CUSTOM_HEADERS ;; esac; export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) + } + fi + unset __orca_claude_binary if [[ -n "${_orca_histfile:-}" ]]; then HISTFILE="$_orca_histfile" fi diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt index 05f3da594e03..a43c7bf2c396 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt @@ -148,6 +148,30 @@ if [[ -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then } fi unset __orca_codex_binary +__orca_claude_binary="$(unalias claude 2>/dev/null || :; command -v claude 2>/dev/null || :)" +if [[ -n "${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "${__orca_claude_binary:-}" && -x "${__orca_claude_binary}" ]]; then + function claude { + local __orca_claude_home __orca_claude_pointer="${ORCA_CLAUDE_PROFILE_POINTER:-}" + # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. + case "$__orca_claude_pointer" in '~/'*) __orca_claude_pointer="${HOME:-}/${__orca_claude_pointer#??}" ;; esac + __orca_claude_home="$(cat "$__orca_claude_pointer" 2>/dev/null || :)" + if [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ "$CLAUDE_CONFIG_DIR" != "${ORCA_CLAUDE_INJECTED_CONFIG_DIR:-}" ]; then + command claude "$@"; return + fi + # Why: Orca's value replaced the user's own at spawn, so System default restores theirs. + if [ -z "$__orca_claude_home" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ -n "${ORCA_CLAUDE_USER_CONFIG_DIR:-}" ]; then + ( unset ORCA_CLAUDE_INJECTED_CONFIG_DIR; export CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR"; command claude "$@" ); return + fi + if [ -z "$__orca_claude_home" ]; then + ( unset CLAUDE_CONFIG_DIR ORCA_CLAUDE_INJECTED_CONFIG_DIR; command claude "$@" ); return + fi + case "$__orca_claude_home" in /*|[A-Za-z]:*) ;; *) return 1 ;; esac + # Why -m 700: matches the folder Orca's setup creates; a credentials folder stays private. + [ -d "$__orca_claude_home" ] || mkdir -p -m 700 -- "$__orca_claude_home" 2>/dev/null + ( unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN AWS_BEARER_TOKEN_BEDROCK; case "${ANTHROPIC_CUSTOM_HEADERS:-}" in *[aA][uU][tT][hH][oO][rR][iI][zZ][aA][tT][iI][oO][nN]*|*[xX]-[aA][pP][iI]-[kK][eE][yY]*|*[aA][pP][iI]-[kK][eE][yY]*|*[bB][eE][aA][rR][eE][rR]*) unset ANTHROPIC_CUSTOM_HEADERS ;; esac; export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) + } +fi +unset __orca_claude_binary # Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent # status when the foreground command (e.g. an interrupted Claude/Codex CLI) diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt index ff24125e2c00..42fb21032ecc 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt @@ -17,4 +17,34 @@ if test "$__orca_codex_type" = file command codex $argv end end -set -e __orca_codex_type \ No newline at end of file +set -e __orca_codex_type +set -l __orca_claude_type (type -t claude 2>/dev/null) +if test -n "$ORCA_CLAUDE_PROFILE_POINTER"; and test "$__orca_claude_type" = file + function claude + # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. + set -l pointer (string replace -r '^~/' "$HOME/" -- "$ORCA_CLAUDE_PROFILE_POINTER") + set -l profile (cat "$pointer" 2>/dev/null) + if test -n "$CLAUDE_CONFIG_DIR"; and test "$CLAUDE_CONFIG_DIR" != "$ORCA_CLAUDE_INJECTED_CONFIG_DIR" + command claude $argv + return $status + end + if test -z "$profile"; and test -n "$CLAUDE_CONFIG_DIR"; and test -n "$ORCA_CLAUDE_USER_CONFIG_DIR" + env -u ORCA_CLAUDE_INJECTED_CONFIG_DIR CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR" claude $argv + return $status + end + if test -z "$profile" + env -u CLAUDE_CONFIG_DIR -u ORCA_CLAUDE_INJECTED_CONFIG_DIR claude $argv + return $status + end + if not string match -qr '^(/|[A-Za-z]:)' -- "$profile" + return 1 + end + test -d "$profile"; or mkdir -p -m 700 -- "$profile" 2>/dev/null + set -l headers + if string match -irq 'authorization|x-api-key|api-key|bearer' -- "$ANTHROPIC_CUSTOM_HEADERS" + set headers -u ANTHROPIC_CUSTOM_HEADERS + end + env $headers -u ANTHROPIC_API_KEY -u ANTHROPIC_AUTH_TOKEN -u CLAUDE_CODE_OAUTH_TOKEN -u AWS_BEARER_TOKEN_BEDROCK CLAUDE_CONFIG_DIR="$profile" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$profile" claude $argv + end +end +set -e __orca_claude_type diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index b3c8a92869ca..e6e492c48bcf 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -227,6 +227,30 @@ __orca_deferred_init() { } fi unset __orca_codex_binary + __orca_claude_binary="$(unalias claude 2>/dev/null || :; command -v claude 2>/dev/null || :)" + if [[ -n "${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "${__orca_claude_binary:-}" && -x "${__orca_claude_binary}" ]]; then + function claude { + local __orca_claude_home __orca_claude_pointer="${ORCA_CLAUDE_PROFILE_POINTER:-}" + # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. + case "$__orca_claude_pointer" in '~/'*) __orca_claude_pointer="${HOME:-}/${__orca_claude_pointer#??}" ;; esac + __orca_claude_home="$(cat "$__orca_claude_pointer" 2>/dev/null || :)" + if [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ "$CLAUDE_CONFIG_DIR" != "${ORCA_CLAUDE_INJECTED_CONFIG_DIR:-}" ]; then + command claude "$@"; return + fi + # Why: Orca's value replaced the user's own at spawn, so System default restores theirs. + if [ -z "$__orca_claude_home" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ -n "${ORCA_CLAUDE_USER_CONFIG_DIR:-}" ]; then + ( unset ORCA_CLAUDE_INJECTED_CONFIG_DIR; export CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR"; command claude "$@" ); return + fi + if [ -z "$__orca_claude_home" ]; then + ( unset CLAUDE_CONFIG_DIR ORCA_CLAUDE_INJECTED_CONFIG_DIR; command claude "$@" ); return + fi + case "$__orca_claude_home" in /*|[A-Za-z]:*) ;; *) return 1 ;; esac + # Why -m 700: matches the folder Orca's setup creates; a credentials folder stays private. + [ -d "$__orca_claude_home" ] || mkdir -p -m 700 -- "$__orca_claude_home" 2>/dev/null + ( unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN AWS_BEARER_TOKEN_BEDROCK; case "${ANTHROPIC_CUSTOM_HEADERS:-}" in *[aA][uU][tT][hH][oO][rR][iI][zZ][aA][tT][iI][oO][nN]*|*[xX]-[aA][pP][iI]-[kK][eE][yY]*|*[aA][pP][iI]-[kK][eE][yY]*|*[bB][eE][aA][rR][eE][rR]*) unset ANTHROPIC_CUSTOM_HEADERS ;; esac; export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) + } + fi + unset __orca_claude_binary if [[ -n "${_orca_histfile:-}" ]]; then HISTFILE="$_orca_histfile" fi diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index 21dce8480e63..1fcf476a1a0b 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -190,6 +190,30 @@ __orca_deferred_init() { } fi unset __orca_codex_binary + __orca_claude_binary="$(unalias claude 2>/dev/null || :; command -v claude 2>/dev/null || :)" + if [[ -n "${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "${__orca_claude_binary:-}" && -x "${__orca_claude_binary}" ]]; then + function claude { + local __orca_claude_home __orca_claude_pointer="${ORCA_CLAUDE_PROFILE_POINTER:-}" + # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. + case "$__orca_claude_pointer" in '~/'*) __orca_claude_pointer="${HOME:-}/${__orca_claude_pointer#??}" ;; esac + __orca_claude_home="$(cat "$__orca_claude_pointer" 2>/dev/null || :)" + if [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ "$CLAUDE_CONFIG_DIR" != "${ORCA_CLAUDE_INJECTED_CONFIG_DIR:-}" ]; then + command claude "$@"; return + fi + # Why: Orca's value replaced the user's own at spawn, so System default restores theirs. + if [ -z "$__orca_claude_home" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ -n "${ORCA_CLAUDE_USER_CONFIG_DIR:-}" ]; then + ( unset ORCA_CLAUDE_INJECTED_CONFIG_DIR; export CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR"; command claude "$@" ); return + fi + if [ -z "$__orca_claude_home" ]; then + ( unset CLAUDE_CONFIG_DIR ORCA_CLAUDE_INJECTED_CONFIG_DIR; command claude "$@" ); return + fi + case "$__orca_claude_home" in /*|[A-Za-z]:*) ;; *) return 1 ;; esac + # Why -m 700: matches the folder Orca's setup creates; a credentials folder stays private. + [ -d "$__orca_claude_home" ] || mkdir -p -m 700 -- "$__orca_claude_home" 2>/dev/null + ( unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN AWS_BEARER_TOKEN_BEDROCK; case "${ANTHROPIC_CUSTOM_HEADERS:-}" in *[aA][uU][tT][hH][oO][rR][iI][zZ][aA][tT][iI][oO][nN]*|*[xX]-[aA][pP][iI]-[kK][eE][yY]*|*[aA][pP][iI]-[kK][eE][yY]*|*[bB][eE][aA][rR][eE][rR]*) unset ANTHROPIC_CUSTOM_HEADERS ;; esac; export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) + } + fi + unset __orca_claude_binary if [[ -n "${_orca_histfile:-}" ]]; then HISTFILE="$_orca_histfile" fi diff --git a/src/main/agent-auth-restart-preservation.test.ts b/src/main/agent-auth-restart-preservation.test.ts index 0b0ff8ca6225..b5056e836066 100644 --- a/src/main/agent-auth-restart-preservation.test.ts +++ b/src/main/agent-auth-restart-preservation.test.ts @@ -8,7 +8,7 @@ describe('preserveAgentAuthBeforeRestart', () => { vi.restoreAllMocks() }) - it('syncs Codex then Claude before flushing the store', async () => { + it('syncs Codex and flushes the store', async () => { const calls: string[] = [] await preserveAgentAuthBeforeRestart({ @@ -17,11 +17,6 @@ describe('preserveAgentAuthBeforeRestart', () => { calls.push('codex') }) }, - claudeRuntimeAuth: { - syncForCurrentSelection: vi.fn(async () => { - calls.push('claude') - }) - }, store: { flushPendingOrThrowAsync: vi.fn(async () => { calls.push('flush') @@ -29,7 +24,7 @@ describe('preserveAgentAuthBeforeRestart', () => { } }) - expect(calls).toEqual(['codex', 'claude', 'flush']) + expect(calls).toEqual(['codex', 'flush']) }) it('drains retained WSL Codex auth before flushing the store', async () => { @@ -106,27 +101,6 @@ describe('preserveAgentAuthBeforeRestart', () => { expect(JSON.stringify(warn.mock.calls)).not.toContain('token-secret') }) - it('does not start Claude after host Codex exhausts the lifecycle budget', async () => { - vi.useFakeTimers() - const startedAt = Date.now() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const syncClaude = vi.fn(async () => {}) - - await preserveAgentAuthBeforeRestart({ - codexRuntimeHome: { - syncForCurrentSelection: vi.fn(() => { - vi.setSystemTime(startedAt + 2_000) - }) - }, - claudeRuntimeAuth: { syncForCurrentSelection: syncClaude } - }) - - expect(syncClaude).not.toHaveBeenCalled() - expect(warn).toHaveBeenCalledWith( - '[agent-auth-restart] Claude auth preservation exceeded 0ms; continuing restart/update' - ) - }) - it('checkpoints admitted state without waiting for ongoing edits when auth services are missing', async () => { const flushPendingOrThrowAsync = vi.fn() @@ -148,83 +122,15 @@ describe('preserveAgentAuthBeforeRestart', () => { throw new Error('codex-token-secret') }) }, - claudeRuntimeAuth: { - syncForCurrentSelection: vi.fn(async () => { - throw new Error('claude-token-secret') - }) - }, store: { flushPendingOrThrowAsync } }) ).resolves.toBeUndefined() expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) - expect(warn).toHaveBeenCalledTimes(2) + expect(warn).toHaveBeenCalledTimes(1) expect(JSON.stringify(warn.mock.calls)).not.toContain('token-secret') }) - it('releases the lifecycle path on timeout without canceling in-flight sync', async () => { - vi.useFakeTimers() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const calls: string[] = [] - let finishClaude!: () => void - - const preservation = preserveAgentAuthBeforeRestart({ - claudeRuntimeAuth: { - syncForCurrentSelection: vi.fn(async () => { - calls.push('claude-start') - await new Promise((resolve) => { - finishClaude = resolve - }) - calls.push('claude-finish') - }) - }, - store: { - flushPendingOrThrowAsync: vi.fn(async () => { - calls.push('flush') - }) - } - }) - - await vi.advanceTimersByTimeAsync(2_000) - await preservation - - expect(calls).toEqual(['claude-start', 'flush']) - expect(warn).toHaveBeenCalledWith( - '[agent-auth-restart] Claude auth preservation exceeded 2000ms; continuing restart/update' - ) - - finishClaude() - await Promise.resolve() - - expect(calls).toEqual(['claude-start', 'flush', 'claude-finish']) - }) - - it('shares the original lifecycle timeout between Claude and store preservation', async () => { - vi.useFakeTimers() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - let settled = false - - const preservation = preserveAgentAuthBeforeRestart({ - claudeRuntimeAuth: { - syncForCurrentSelection: vi.fn(() => new Promise(() => {})) - }, - store: { - flushPendingOrThrowAsync: vi.fn(() => new Promise(() => {})) - } - }).then(() => { - settled = true - }) - - await vi.advanceTimersByTimeAsync(2_000) - await vi.runOnlyPendingTimersAsync() - await preservation - - expect(settled).toBe(true) - expect(warn).toHaveBeenCalledWith( - '[agent-auth-restart] Store persistence exceeded 0ms; continuing restart/update' - ) - }) - it('bounds a store flush that never settles', async () => { vi.useFakeTimers() const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/src/main/agent-auth-restart-preservation.ts b/src/main/agent-auth-restart-preservation.ts index d00641088cc2..a41be9a01833 100644 --- a/src/main/agent-auth-restart-preservation.ts +++ b/src/main/agent-auth-restart-preservation.ts @@ -1,4 +1,3 @@ -import type { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' import type { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service' import type { Store } from './persistence' @@ -6,23 +5,17 @@ const AUTH_PRESERVATION_TIMEOUT_MS = 2_000 type CodexRuntimeAuthSync = Pick & Partial> -type ClaudeRuntimeAuthSync = Pick type ShutdownStore = Pick -type AuthPreservationStep = - | 'Codex auth preservation' - | 'Claude auth preservation' - | 'Store persistence' +type AuthPreservationStep = 'Codex auth preservation' | 'Store persistence' export type AgentAuthRestartPreservationOptions = { codexRuntimeHome?: CodexRuntimeAuthSync | null - claudeRuntimeAuth?: ClaudeRuntimeAuthSync | null store?: ShutdownStore | null } export async function preserveAgentAuthBeforeRestart({ codexRuntimeHome, - claudeRuntimeAuth, store }: AgentAuthRestartPreservationOptions): Promise { const startedAt = Date.now() @@ -30,17 +23,6 @@ export async function preserveAgentAuthBeforeRestart({ // Why: the drain owns guest-process timeouts; a shared 2s cutoff can relaunch before promotion. const wslCodexPreservation = runWslCodexPreservationStep(codexRuntimeHome) - const claudeRemainingMs = remainingLifecycleTime(startedAt) - if (claudeRuntimeAuth && claudeRemainingMs > 0) { - await runWithinLifecycleTimeout( - 'Claude auth preservation', - () => claudeRuntimeAuth.syncForCurrentSelection(), - claudeRemainingMs - ) - } else if (claudeRuntimeAuth) { - logStepTimeout('Claude auth preservation', 0) - } - const storePreservation = store ? runWithinLifecycleTimeout( 'Store persistence', diff --git a/src/main/claude-accounts/claude-account-folder.test.ts b/src/main/claude-accounts/claude-account-folder.test.ts new file mode 100644 index 000000000000..5374afed213a --- /dev/null +++ b/src/main/claude-accounts/claude-account-folder.test.ts @@ -0,0 +1,106 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as Os from 'node:os' + +const keychain = vi.hoisted(() => ({ items: new Map(), userHome: '' })) +vi.mock('node:os', async (original) => ({ + ...(await original()), + homedir: () => keychain.userHome +})) +vi.mock('../macos-keychain/generic-password', () => ({ + execSecurityCommand: async (args: string[]) => { + const service = args[2] + const count = keychain.items.get(service) ?? 0 + if (count === 0) { + throw new Error('The specified item could not be found in the keychain.') + } + keychain.items.set(service, count - 1) + return { stdout: '', stderr: '' } + } +})) + +import { readClaudeFolderLogin, removeClaudeAccountFolder } from './claude-account-folder' +import { claudeKeychainService } from './keychain' + +const roots: string[] = [] +afterEach(() => { + keychain.items.clear() + roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) +}) + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'claude-account-folder-')) + roots.push(root) + keychain.userHome = root + const dataRoot = join(root, 'data') + const home = join(dataRoot, 'claude-profiles', 'a', 'home') + mkdirSync(home, { recursive: true }) + return { root, dataRoot, home } +} + +describe('claude account folder', () => { + it("reads the folder's login and treats a signed-out state file as no login", () => { + const { home } = fixture() + const stateFile = join(home, '.claude.json') + expect(readClaudeFolderLogin(stateFile)).toBeNull() + writeFileSync(stateFile, JSON.stringify({ oauthAccount: { emailAddress: ' a@example.test ' } })) + expect(readClaudeFolderLogin(stateFile)).toEqual({ + email: 'a@example.test', + organizationUuid: null, + organizationName: null + }) + writeFileSync(stateFile, JSON.stringify({ numStartups: 2 })) + expect(readClaudeFolderLogin(stateFile)).toBeNull() + }) + + it('reuses a recent answer within maxAgeMs even though the file changed', () => { + const { home } = fixture() + const stateFile = join(home, '.claude.json') + writeFileSync(stateFile, JSON.stringify({ oauthAccount: { emailAddress: 'a@example.test' } })) + expect(readClaudeFolderLogin(stateFile, 5_000)?.email).toBe('a@example.test') + writeFileSync(stateFile, JSON.stringify({ oauthAccount: { emailAddress: 'b@example.test!' } })) + expect(readClaudeFolderLogin(stateFile, 5_000)?.email).toBe('a@example.test') + expect(readClaudeFolderLogin(stateFile)?.email).toBe('b@example.test!') + }) + + // A real symlink needs Developer Mode on Windows (EPERM otherwise). + it.skipIf(process.platform === 'win32')( + 'unlinks shared history before deleting, and leaves the older layout alone', + async () => { + const { root, dataRoot, home } = fixture() + const history = join(root, '.claude', 'projects') + mkdirSync(history, { recursive: true }) + writeFileSync(join(history, 'chat.jsonl'), '{}') + symlinkSync(history, join(home, 'projects')) + const older = join(dataRoot, 'claude-accounts', 'a') + mkdirSync(older, { recursive: true }) + + await removeClaudeAccountFolder(dataRoot, 'a') + expect(existsSync(join(dataRoot, 'claude-profiles', 'a'))).toBe(false) + expect(existsSync(join(history, 'chat.jsonl'))).toBe(true) + expect(existsSync(older)).toBe(true) + } + ) + + it.skipIf(process.platform !== 'darwin')( + 'deletes every Keychain item Claude may have keyed to the folder', + async () => { + const { dataRoot, home } = fixture() + const rest = home.slice(keychain.userHome.length) + const spellings = [home, `${home}/`, `~${rest}`, `$HOME${rest}/`] + for (const spelling of spellings) { + keychain.items.set(claudeKeychainService(spelling), 2) + } + const unrelated = claudeKeychainService(join(keychain.userHome, 'other')) + keychain.items.set(unrelated, 1) + + await removeClaudeAccountFolder(dataRoot, 'a') + for (const spelling of spellings) { + expect(keychain.items.get(claudeKeychainService(spelling))).toBe(0) + } + expect(keychain.items.get(unrelated)).toBe(1) + } + ) +}) diff --git a/src/main/claude-accounts/claude-account-folder.ts b/src/main/claude-accounts/claude-account-folder.ts new file mode 100644 index 000000000000..7e4bfe8e3efd --- /dev/null +++ b/src/main/claude-accounts/claude-account-folder.ts @@ -0,0 +1,153 @@ +import { + existsSync, + lstatSync, + readdirSync, + rmdirSync, + rmSync, + statSync, + unlinkSync +} from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import { z } from 'zod' +import { resolveClaudeGlobalConfigFile } from '../claude/claude-folder-trust-file' +import { execSecurityCommand } from '../macos-keychain/generic-password' +import { describeClaudeProfile, readClaudeProfileObject } from './claude-profile-paths' +import { claudeConfigDirSpellings, claudeKeychainService } from './keychain' + +export type ClaudeFolderLogin = { + email: string + organizationUuid: string | null + organizationName: string | null +} + +const text = z + .string() + .transform((value) => value.trim() || null) + .nullish() + .catch(null) +const oauthAccount = z.object({ + emailAddress: text, + organizationUuid: text, + organizationName: text +}) + +/** Claude's state file for a config folder; undefined is `~/.claude`, whose file sits in home. */ +export function claudeStateFile(configDir: string | undefined, userHome = homedir()): string { + return resolveClaudeGlobalConfigFile({ + env: { CLAUDE_CONFIG_DIR: configDir }, + homeDir: userHome, + style: process.platform === 'win32' ? 'win32' : 'posix', + exists: existsSync + }) +} + +// Why memoized: a state file grows with history and the account list is read on every refresh. +const logins = new Map< + string, + { mtimeMs: number; size: number; readAt: number; login: ClaudeFolderLogin | null } +>() + +/** The login parsed Claude state names; null before Claude finishes a sign-in. */ +export function claudeStateLogin(state: Record): ClaudeFolderLogin | null { + const parsed = oauthAccount.safeParse(state.oauthAccount) + const email = parsed.success ? parsed.data.emailAddress : null + return parsed.success && email + ? { + email, + organizationUuid: parsed.data.organizationUuid ?? null, + organizationName: parsed.data.organizationName ?? null + } + : null +} + +/** + * The login a Claude state file names; null when signed out (superset U/profiles.ts:121-137). + * `maxAgeMs` reuses a recent answer without a stat, for a file Claude rewrites constantly. + */ +export function readClaudeFolderLogin(stateFile: string, maxAgeMs = 0): ClaudeFolderLogin | null { + const recent = logins.get(stateFile) + if (recent && Date.now() - recent.readAt < maxAgeMs) { + return recent.login + } + let stat: { mtimeMs: number; size: number } + try { + stat = statSync(stateFile) + } catch { + logins.delete(stateFile) + return null + } + if (recent?.mtimeMs === stat.mtimeMs && recent.size === stat.size) { + recent.readAt = Date.now() + return recent.login + } + const read = readClaudeProfileObject(stateFile) + const login = read.kind === 'present' ? claudeStateLogin(read.value) : null + logins.set(stateFile, { mtimeMs: stat.mtimeMs, size: stat.size, readAt: Date.now(), login }) + return login +} + +/** + * Deletes a host account's folder and, on macOS, the Keychain items Claude keyed to it + * (superset profile-remove.ts). Only `claude-profiles/` is reachable from an id, so an + * older Orca's `claude-accounts/` folders are never touched. + */ +export async function removeClaudeAccountFolder( + dataRoot: string, + accountId: string +): Promise { + const { home } = describeClaudeProfile(dataRoot, accountId, { + executionHostId: 'local', + runtime: 'host' + }) + if (process.platform === 'darwin') { + for (const spelling of claudeConfigDirSpellings(home, homedir())) { + // Why a loop: items are filed per Keychain account name, and each delete removes one. + for (let attempt = 0; attempt < 4; attempt += 1) { + const deleted = await execSecurityCommand([ + 'delete-generic-password', + '-s', + claudeKeychainService(spelling) + ]).then( + () => true, + () => false + ) + if (!deleted) { + break + } + } + } + } + const folder = dirname(home) + unlinkLinks(folder) + rmSync(folder, { recursive: true, force: true }) +} + +// Why first: history is linked into ~/.claude, and a recursive delete that followed a Windows +// junction would delete the user's own history. +function unlinkLinks(dir: string): void { + let names: string[] + try { + names = readdirSync(dir) + } catch { + return + } + for (const name of names) { + const path = join(dir, name) + // Why no throw: an entry may vanish between the listing and the stat. + const stat = lstatSync(path, { throwIfNoEntry: false }) + if (!stat) { + continue + } + if (stat.isSymbolicLink()) { + try { + unlinkSync(path) + } catch { + // A Windows directory link is removed as a directory. + rmdirSync(path) + } + } else if (stat.isDirectory()) { + unlinkLinks(path) + } + } +} diff --git a/src/main/claude-accounts/claude-account-registration.ts b/src/main/claude-accounts/claude-account-registration.ts index bebc26be6804..13ec68d7dee8 100644 --- a/src/main/claude-accounts/claude-account-registration.ts +++ b/src/main/claude-accounts/claude-account-registration.ts @@ -1,250 +1,152 @@ import { randomUUID } from 'node:crypto' import type { - ClaudeManagedAccount, - ClaudeRateLimitAccountsState + ClaudeAccountSignIn, + ClaudeRateLimitAccountsState, + ClaudeSignInRequest } from '../../shared/managed-account-types' -import type { Store } from '../persistence' -import type { RateLimitService } from '../rate-limits/service' -import { findDuplicateClaudeAccount } from './claude-duplicate-account' -import type { CapturedClaudeAuth } from './claude-auth-capture' +import { claudeStateFile, readClaudeFolderLogin } from './claude-account-folder' import type { - ClaudeManagedAuthLocation, - ClaudeManagedAuthSnapshot, - ClaudeManagedAuthTarget -} from './claude-managed-auth-storage' -import type { ClaudeRuntimeAuthService } from './runtime-auth-service' + ClaudeAccountRuntime, + ClaudeAccountSelection, + ClaudeAccountStore, + ClaudeAccountUsage +} from './claude-account-selection' +import type { ClaudeCommandConfig } from './claude-command-process' +import { findDuplicateClaudeAccount } from './claude-duplicate-account' import { getClaudeSelectionTargetForAccount, - normalizeClaudeRuntimeSelection + normalizeClaudeAccountSelectionTarget, + type ClaudeAccountSelectionTarget } from './runtime-selection' -import type { ClaudeAccountSelection } from './claude-account-selection' -type ClaudeAccountRegistrationDependencies = { - store: Store - rateLimits: RateLimitService - runtimeAuth: ClaudeRuntimeAuthService - selection: ClaudeAccountSelection - createManagedAuth: ( - accountId: string, - target?: ClaudeManagedAuthTarget - ) => Promise - assertManagedAuth: (path: string, accountId: string) => Promise - removeManagedAuth: (accountId: string, path: string) => Promise - writeManagedAuth: (accountId: string, path: string, captured: CapturedClaudeAuth) => Promise - writeCredentials: (accountId: string, path: string, value: string) => Promise - writeOauth: (accountId: string, path: string, value: unknown) => Promise - readSnapshot: (accountId: string, path: string) => Promise - restoreCredentials: ( - accountId: string, - path: string, - snapshot: ClaudeManagedAuthSnapshot - ) => Promise - restoreOauth: ( - accountId: string, - path: string, - snapshot: ClaudeManagedAuthSnapshot - ) => Promise - login: (location: ClaudeManagedAuthLocation) => Promise - captureExisting: ( - configDir: string, - previousLegacyCredentialsSha256?: string | null - ) => Promise -} - -class DuplicateClaudeAccountError extends Error {} +export const CLAUDE_SIGN_IN_NOT_FINISHED_MESSAGE = + 'Claude has not finished signing in to this account yet. Finish the sign-in in the terminal, then try again.' +const CLAUDE_LOGIN_WITHOUT_ACCOUNT_MESSAGE = + 'Claude login completed, but Orca could not resolve the account email.' +/** + * Sign-in runs `claude auth login` against the account's own folder: hidden from Settings, in + * the user's terminal from the CLI. A folder becomes an account only once it holds a login + * (superset U/profiles.ts:132,178), so an abandoned sign-in never shows as a row. + */ export class ClaudeAccountRegistration { - constructor(private readonly dependencies: ClaudeAccountRegistrationDependencies) {} - - async add(target?: ClaudeManagedAuthTarget): Promise { - const accountId = randomUUID() - const location = await this.dependencies.createManagedAuth(accountId, target) - const previousSettings = this.dependencies.store.getSettings() - try { - const captured = await this.dependencies.login(location) - return await this.persist(accountId, location, previousSettings, captured) - } catch (error) { - await this.cleanupFailedAdd(accountId, location.managedAuthPath, previousSettings, error) - throw error + constructor( + private readonly deps: { + store: ClaudeAccountStore + rateLimits: ClaudeAccountUsage + runtimeAuth: ClaudeAccountRuntime + selection: ClaudeAccountSelection } + ) {} + + /** A new folder, or a saved account's own folder when `accountId` is given. */ + async begin(request: ClaudeSignInRequest): Promise { + const { accountId, target, folder } = await this.prepare(request) + return { accountId, configDir: folder.configDir, ...target } } - async addFromConfigDir( - configDir: string, - options?: ClaudeManagedAuthTarget & { previousLegacyCredentialsSha256?: string | null } + /** Runs `login` into the account's folder, then saves it; a failed new folder is deleted. */ + async signIn( + request: ClaudeSignInRequest, + login: (folder: ClaudeCommandConfig) => Promise ): Promise { - const accountId = randomUUID() - const location = await this.dependencies.createManagedAuth(accountId, options) - const previousSettings = this.dependencies.store.getSettings() + const { accountId, target, folder } = await this.prepare(request) + const signIn = { accountId, ...target } try { - const captured = await this.dependencies.captureExisting( - configDir, - options?.previousLegacyCredentialsSha256 - ) - return await this.persist(accountId, location, previousSettings, captured) + await login({ + windowsPath: folder.readPath, + linuxPath: target.runtime === 'wsl' ? folder.configDir : null, + wslDistro: target.runtime === 'wsl' ? (target.wslDistro ?? null) : null + }) + return await this.finish(signIn, CLAUDE_LOGIN_WITHOUT_ACCOUNT_MESSAGE) } catch (error) { - await this.cleanupFailedAdd(accountId, location.managedAuthPath, previousSettings, error) + // Why caught: the sign-in's own failure is what the user needs to see. + await this.cancel(signIn).catch((cleanupError: unknown) => + console.warn( + '[claude-accounts] Could not delete an unfinished sign-in folder:', + cleanupError + ) + ) throw error } } - async reauthenticate(accountId: string): Promise { - const account = this.dependencies.selection.requireAccount(accountId) - const managedAuthPath = await this.dependencies.assertManagedAuth( - account.managedAuthPath, - accountId - ) - const previousSettings = this.dependencies.store.getSettings() - const previousAuth = await this.dependencies.readSnapshot(accountId, managedAuthPath) - const captured = await this.dependencies.login({ - managedAuthPath, - managedAuthRuntime: account.managedAuthRuntime ?? 'host', - wslDistro: account.wslDistro ?? null, - wslLinuxAuthPath: account.wslLinuxAuthPath ?? null - }) - if (!captured.identity.email) { - throw new Error('Claude login completed, but Orca could not resolve the account email.') - } + private async prepare(request: ClaudeSignInRequest) { + const saved = request.accountId ? this.deps.selection.requireAccount(request.accountId) : null + const target = saved ? getClaudeSelectionTargetForAccount(saved) : signInTarget(request) + const accountId = saved?.id ?? randomUUID() + const folder = await this.deps.runtimeAuth.prepareAccountFolder(accountId, target) + return { accountId, target, folder } + } - const settings = this.dependencies.store.getSettings() - const now = Date.now() - const nextAccounts = settings.claudeManagedAccounts.map((entry) => - entry.id === accountId - ? { - ...entry, - email: captured.identity.email!, - organizationUuid: captured.identity.organizationUuid, - organizationName: captured.identity.organizationName, - updatedAt: now, - lastAuthenticatedAt: now - } - : entry - ) - let wroteCredentials = false - try { - await this.dependencies.writeOauth(accountId, managedAuthPath, captured.oauthAccount) - await this.dependencies.writeCredentials(accountId, managedAuthPath, captured.credentialsJson) - wroteCredentials = true - this.dependencies.store.updateSettings({ claudeManagedAccounts: nextAccounts }) - this.dependencies.runtimeAuth.clearLastWrittenCredentialsJson(accountId) - this.dependencies.rateLimits.evictInactiveClaudeCache(accountId) - const target = getClaudeSelectionTargetForAccount(account) - await this.dependencies.selection.syncRuntimeAuth(target) - await this.dependencies.rateLimits.refreshForClaudeAccountChange(undefined, target) - return this.dependencies.selection.snapshot() - } catch (error) { - await this.rollbackReauthentication( - accountId, - managedAuthPath, - previousAuth, - previousSettings, - nextAccounts, - wroteCredentials - ) - throw error + async cancel(signIn: Omit): Promise { + if (!this.deps.selection.findAccount(signIn.accountId)) { + await this.deps.runtimeAuth.removeAccountFolder(signIn.accountId, signInTarget(signIn)) } } - private async persist( - accountId: string, - location: ClaudeManagedAuthLocation, - previousSettings: ReturnType, - captured: CapturedClaudeAuth + async finish( + signIn: Omit, + notSignedInMessage = CLAUDE_SIGN_IN_NOT_FINISHED_MESSAGE ): Promise { - if (!captured.identity.email) { - throw new Error('Claude login completed, but Orca could not resolve the account email.') + const { store, rateLimits, runtimeAuth, selection } = this.deps + const saved = selection.findAccount(signIn.accountId) + const target = saved ? getClaudeSelectionTargetForAccount(saved) : signInTarget(signIn) + // Why set up again: onboarding is merged only into a state file the login just created. + const folder = await runtimeAuth.prepareAccountFolder(signIn.accountId, target) + const login = readClaudeFolderLogin(claudeStateFile(folder.readPath)) + if (!login) { + throw new Error(notSignedInMessage) } + const accounts = store.getSettings().claudeManagedAccounts + const scope = { + managedAuthRuntime: target.runtime ?? 'host', + wslDistro: target.runtime === 'wsl' ? (target.wslDistro ?? null) : null + } as const if ( - findDuplicateClaudeAccount(previousSettings.claudeManagedAccounts, { - email: captured.identity.email, - organizationUuid: captured.identity.organizationUuid, - managedAuthRuntime: location.managedAuthRuntime, - wslDistro: location.wslDistro + !saved && + findDuplicateClaudeAccount(accounts, { + email: login.email, + organizationUuid: login.organizationUuid, + ...scope }) ) { - throw new DuplicateClaudeAccountError('This Claude account is already added.') + await runtimeAuth.removeAccountFolder(signIn.accountId, target) + throw new Error('This Claude account is already added.') } - await this.dependencies.writeManagedAuth(accountId, location.managedAuthPath, captured) const now = Date.now() - const account: ClaudeManagedAccount = { - id: accountId, - email: captured.identity.email, - managedAuthPath: location.managedAuthPath, - managedAuthRuntime: location.managedAuthRuntime, - wslDistro: location.wslDistro, - wslLinuxAuthPath: location.wslLinuxAuthPath, - authMethod: 'subscription-oauth', - organizationUuid: captured.identity.organizationUuid, - organizationName: captured.identity.organizationName, - createdAt: now, - updatedAt: now, - lastAuthenticatedAt: now - } - const selection = normalizeClaudeRuntimeSelection(previousSettings) - this.dependencies.store.updateSettings({ - claudeManagedAccounts: [...previousSettings.claudeManagedAccounts, account], - activeClaudeManagedAccountId: selection.host, - activeClaudeManagedAccountIdsByRuntime: selection + selection.saveSettings({ + claudeManagedAccounts: [ + ...accounts.filter((entry) => entry.id !== signIn.accountId), + { + id: signIn.accountId, + email: login.email, + organizationUuid: login.organizationUuid, + organizationName: login.organizationName, + managedAuthPath: folder.readPath, + ...scope, + wslLinuxAuthPath: target.runtime === 'wsl' ? folder.configDir : null, + authMethod: 'subscription-oauth', + createdAt: saved?.createdAt ?? now, + updatedAt: now, + lastAuthenticatedAt: now + } + ] }) - this.dependencies.runtimeAuth.clearLastWrittenCredentialsJson(accountId) - this.dependencies.rateLimits.evictInactiveClaudeCache(accountId) - return this.dependencies.selection.snapshot() - } - - private async cleanupFailedAdd( - accountId: string, - managedAuthPath: string, - previousSettings: ReturnType, - error: unknown - ): Promise { - if (error instanceof DuplicateClaudeAccountError) { - await this.dependencies.removeManagedAuth(accountId, managedAuthPath) - return - } - this.dependencies.selection.restoreSettings(previousSettings) - try { - await this.dependencies.runtimeAuth.forceMaterializeCurrentSelectionForRollback() - } catch (rollbackError) { - console.warn('[claude-accounts] Rollback rematerialization failed:', rollbackError) - } - await this.dependencies.removeManagedAuth(accountId, managedAuthPath) + rateLimits.evictInactiveClaudeCache(signIn.accountId) + // Why publish: a selected account's terminals follow its folder only once it exists. + await runtimeAuth.syncForCurrentSelection(target) + selection.refreshUsage(undefined, target) + return selection.list() } +} - private async rollbackReauthentication( - accountId: string, - path: string, - snapshot: ClaudeManagedAuthSnapshot, - previousSettings: ReturnType, - nextAccounts: ClaudeManagedAccount[], - wroteCredentials: boolean - ): Promise { - let restoredCredentials = false - try { - await this.dependencies.restoreCredentials(accountId, path, snapshot) - restoredCredentials = true - } catch (rollbackError) { - console.warn( - '[claude-accounts] Failed to restore managed credentials during rollback:', - rollbackError - ) - } - if (restoredCredentials || !wroteCredentials) { - try { - await this.dependencies.restoreOauth(accountId, path, snapshot) - } catch (rollbackError) { - console.warn( - '[claude-accounts] Failed to restore managed oauth metadata during rollback:', - rollbackError - ) - } - } - if (restoredCredentials) { - this.dependencies.selection.restoreSettings(previousSettings) - await this.dependencies.runtimeAuth.forceMaterializeCurrentSelectionForRollback() - } else if (wroteCredentials) { - this.dependencies.store.updateSettings({ claudeManagedAccounts: nextAccounts }) - } else { - this.dependencies.selection.restoreSettings(previousSettings) - } +function signInTarget(request: ClaudeAccountSelectionTarget): ClaudeAccountSelectionTarget { + const target = normalizeClaudeAccountSelectionTarget(request) + if (target.runtime === 'wsl' && !target.wslDistro) { + throw new Error('Choose a WSL distro before signing in.') } + return target.runtime === 'wsl' + ? { runtime: 'wsl', wslDistro: target.wslDistro } + : { runtime: 'host' } } diff --git a/src/main/claude-accounts/claude-account-selection.ts b/src/main/claude-accounts/claude-account-selection.ts index d81f2346eb70..c0b089e902cf 100644 --- a/src/main/claude-accounts/claude-account-selection.ts +++ b/src/main/claude-accounts/claude-account-selection.ts @@ -3,9 +3,10 @@ import type { ClaudeManagedAccountSummary, ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' -import type { Store } from '../persistence' +import type { GlobalSettings } from '../../shared/global-settings-types' import type { RateLimitService } from '../rate-limits/service' -import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from './live-pty-gate' +import { claudeStateFile, readClaudeFolderLogin } from './claude-account-folder' +import { savedWslClaudeAccountHome } from './claude-profile-wsl-paths' import type { ClaudeRuntimeAuthService } from './runtime-auth-service' import { getClaudeSelectionTargetForAccount, @@ -18,59 +19,137 @@ import { type ClaudeAccountSelectionTarget } from './runtime-selection' +export const CLAUDE_ACCOUNT_NEEDS_SIGN_IN_MESSAGE = + 'Sign in to this Claude account again before selecting it.' + +export const CLAUDE_ACCOUNT_FOLDER_IN_USE_MESSAGE = + "Orca couldn't delete this Claude account's folder. Quit any Claude running in this account, then remove it again." + +export type ClaudeAccountSettings = Pick< + GlobalSettings, + | 'claudeManagedAccounts' + | 'activeClaudeManagedAccountId' + | 'activeClaudeManagedAccountIdsByRuntime' +> + +export type ClaudeAccountStore = { + getSettings: () => ClaudeAccountSettings + updateSettings: ( + patch: Partial, + options?: { notifyListeners?: boolean } + ) => unknown +} + +export type ClaudeAccountRuntime = Pick< + ClaudeRuntimeAuthService, + 'syncForCurrentSelection' | 'publishAll' | 'removeAccountFolder' | 'prepareAccountFolder' +> & { + router: Pick< + ClaudeRuntimeAuthService['router'], + 'accountHome' | 'userConfigDir' | 'copiedLoginIntoSystemDefault' + > +} + +export type ClaudeAccountUsage = Pick< + RateLimitService, + 'evictInactiveClaudeCache' | 'refreshForClaudeAccountChange' +> + export class ClaudeAccountSelection { constructor( - private readonly store: Store, - private readonly rateLimits: RateLimitService, - private readonly runtimeAuth: ClaudeRuntimeAuthService, - private readonly removeManagedAuth: (accountId: string, path: string) => Promise + private readonly store: ClaudeAccountStore, + private readonly rateLimits: ClaudeAccountUsage, + private readonly runtimeAuth: ClaudeAccountRuntime ) {} + /** Each row is labelled with the login its folder holds (superset U/profiles.ts:121-137). */ list(): ClaudeRateLimitAccountsState { - this.normalizeActiveSelection() - return this.snapshot() + this.pruneSelection() + const settings = this.store.getSettings() + const selection = normalizeClaudeRuntimeSelection(settings) + const accounts = settings.claudeManagedAccounts + .map((account) => this.summarize(account)) + .sort((a, b) => b.updatedAt - a.updatedAt) + const userConfigDir = this.runtimeAuth.router.userConfigDir() + // Why only with accounts, and at most every 5 s: the personal state file is large and every + // running Claude rewrites it, so a list would otherwise re-parse it on main each time. + const systemDefault = + accounts.length > 0 ? readClaudeFolderLogin(claudeStateFile(userConfigDir), 5_000) : null + return { + accounts, + activeAccountId: selection.host, + activeAccountIdsByRuntime: selection, + ...(systemDefault ? { systemDefaultEmail: systemDefault.email } : {}), + ...(systemDefault && this.runtimeAuth.router.copiedLoginIntoSystemDefault() + ? { systemDefaultMayBeCopied: true } + : {}) + } + } + + private summarize(account: ClaudeManagedAccount): ClaudeManagedAccountSummary { + const summary: ClaudeManagedAccountSummary = { + id: account.id, + email: account.email, + managedAuthRuntime: account.managedAuthRuntime ?? 'host', + wslDistro: account.wslDistro ?? null, + authMethod: account.authMethod ?? 'unknown', + organizationUuid: account.organizationUuid ?? null, + organizationName: account.organizationName ?? null, + createdAt: account.createdAt, + updatedAt: account.updatedAt, + lastAuthenticatedAt: account.lastAuthenticatedAt + } + if (account.managedAuthRuntime === 'wsl') { + // Why no read: listing must not start a stopped distro. Only an older Orca's folder layout + // is known to hold no login; a launch refuses a missing folder with its own reason. + return savedWslClaudeAccountHome(account) ? summary : { ...summary, needsSignIn: true } + } + const login = readClaudeFolderLogin( + claudeStateFile(this.runtimeAuth.router.accountHome(account.id)) + ) + return login + ? { + ...summary, + email: login.email, + organizationUuid: login.organizationUuid, + organizationName: login.organizationName + } + : { ...summary, needsSignIn: true } } async remove(accountId: string): Promise { const account = this.requireAccount(accountId) const settings = this.store.getSettings() - const nextAccounts = settings.claudeManagedAccounts.filter((entry) => entry.id !== accountId) + const target = getClaudeSelectionTargetForAccount(account) + const wasSelected = getSelectedClaudeAccountIdForTarget(settings, target) === accountId const nextSelection = removeClaudeAccountIdFromSelection( normalizeClaudeRuntimeSelection(settings), accountId ) - const nextActiveId = - settings.activeClaudeManagedAccountId === accountId ? null : nextSelection.host - const target = getClaudeSelectionTargetForAccount(account) - const wasSelected = getSelectedClaudeAccountIdForTarget(settings, target) === accountId + this.saveSettings({ + activeClaudeManagedAccountId: + settings.activeClaudeManagedAccountId === accountId ? null : nextSelection.host, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) + // Why before the delete: no new launch may pick the folder while it is being removed. + await this.runtimeAuth.syncForCurrentSelection(target) try { - if (wasSelected) { - this.store.updateSettings({ - activeClaudeManagedAccountId: nextActiveId, - activeClaudeManagedAccountIdsByRuntime: nextSelection - }) - await this.syncRuntimeAuth(target) - this.store.updateSettings({ claudeManagedAccounts: nextAccounts }) - } else { - this.store.updateSettings({ - claudeManagedAccounts: nextAccounts, - activeClaudeManagedAccountId: nextActiveId, - activeClaudeManagedAccountIdsByRuntime: nextSelection - }) - await this.syncRuntimeAuth(target) - } - await this.removeManagedAuth(accountId, account.managedAuthPath) - this.rateLimits.evictInactiveClaudeCache(accountId) - await this.rateLimits.refreshForClaudeAccountChange( - wasSelected ? accountId : undefined, - target - ) - return this.snapshot() + await this.runtimeAuth.removeAccountFolder(accountId, target) } catch (error) { - this.restoreSettings(settings) - await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback() - throw error + // Why keep the row: the folder (on Windows, with its history) is still there to retry. + console.warn('[claude-accounts] Could not delete the account folder:', error) + throw new Error(CLAUDE_ACCOUNT_FOLDER_IN_USE_MESSAGE) } + this.saveSettings({ + claudeManagedAccounts: this.store + .getSettings() + .claudeManagedAccounts.filter((entry) => entry.id !== accountId) + }) + // Why again: the last account's removal also removes the which-account file. + await this.runtimeAuth.syncForCurrentSelection(target) + this.rateLimits.evictInactiveClaudeCache(accountId) + this.refreshUsage(wasSelected ? accountId : undefined, target) + return this.list() } async select( @@ -81,111 +160,90 @@ export class ClaudeAccountSelection { if (accountId !== null) { const account = this.requireAccount(accountId) const accountTarget = getClaudeSelectionTargetForAccount(account) - const requestedTarget = normalizeClaudeAccountSelectionTarget(target ?? accountTarget) - const normalizedAccountTarget = normalizeClaudeAccountSelectionTarget(accountTarget) + const requested = normalizeClaudeAccountSelectionTarget(target ?? accountTarget) + const owned = normalizeClaudeAccountSelectionTarget(accountTarget) if ( - requestedTarget.runtime !== normalizedAccountTarget.runtime || - (requestedTarget.wslDistro !== null && - requestedTarget.wslDistro !== normalizedAccountTarget.wslDistro) + requested.runtime !== owned.runtime || + (requested.wslDistro !== null && requested.wslDistro !== owned.wslDistro) ) { throw new Error('That Claude account belongs to a different runtime.') } + // Why on the host: the CLI and paired clients select without the renderer's check. + if (this.summarize(account).needsSignIn) { + throw new Error(CLAUDE_ACCOUNT_NEEDS_SIGN_IN_MESSAGE) + } effectiveTarget = accountTarget } - const previousSettings = this.store.getSettings() - const outgoingAccountId = getSelectedClaudeAccountIdForTarget(previousSettings, effectiveTarget) + const previous = this.store.getSettings() + const outgoingAccountId = getSelectedClaudeAccountIdForTarget(previous, effectiveTarget) const nextSelection = setSelectedClaudeAccountIdForTarget( - normalizeClaudeRuntimeSelection(previousSettings), + normalizeClaudeRuntimeSelection(previous), accountId, effectiveTarget ) - this.store.updateSettings({ + this.saveSettings({ activeClaudeManagedAccountId: effectiveTarget?.runtime === 'wsl' ? nextSelection.host : accountId, activeClaudeManagedAccountIdsByRuntime: nextSelection }) try { - await this.syncRuntimeAuth(effectiveTarget) - await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId, effectiveTarget) - return this.snapshot() + await this.runtimeAuth.syncForCurrentSelection(effectiveTarget) } catch (error) { - this.restoreSettings(previousSettings) - await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback() + this.saveSettings({ + activeClaudeManagedAccountId: previous.activeClaudeManagedAccountId, + activeClaudeManagedAccountIdsByRuntime: previous.activeClaudeManagedAccountIdsByRuntime + }) + await this.runtimeAuth.publishAll().catch((rollbackError: unknown) => { + console.warn('[claude-accounts] Rollback republish failed:', rollbackError) + }) throw error } + this.refreshUsage(outgoingAccountId, effectiveTarget) + return this.list() } - snapshot(): ClaudeRateLimitAccountsState { - const settings = this.store.getSettings() - return { - accounts: settings.claudeManagedAccounts - .map(toClaudeAccountSummary) - .sort((a, b) => b.updatedAt - a.updatedAt), - activeAccountId: normalizeClaudeRuntimeSelection(settings).host, - activeAccountIdsByRuntime: normalizeClaudeRuntimeSelection(settings) - } + /** Bookkeeping: a usage failure never undoes the account change that triggered it. */ + refreshUsage( + outgoingAccountId: string | null | undefined, + target: ClaudeAccountSelectionTarget | undefined + ): void { + void this.rateLimits + .refreshForClaudeAccountChange(outgoingAccountId ?? undefined, target) + .catch((error: unknown) => + console.warn('[claude-accounts] Usage unavailable after account change:', error) + ) + } + + findAccount(accountId: string): ClaudeManagedAccount | undefined { + return this.store.getSettings().claudeManagedAccounts.find((entry) => entry.id === accountId) } requireAccount(accountId: string): ClaudeManagedAccount { - const account = this.store - .getSettings() - .claudeManagedAccounts.find((entry) => entry.id === accountId) + const account = this.findAccount(accountId) if (!account) { throw new Error('That Claude account no longer exists.') } return account } - restoreSettings(settings: ReturnType): void { - this.store.updateSettings({ - claudeManagedAccounts: settings.claudeManagedAccounts, - activeClaudeManagedAccountId: settings.activeClaudeManagedAccountId, - activeClaudeManagedAccountIdsByRuntime: settings.activeClaudeManagedAccountIdsByRuntime - }) + // Why notify: every window's switcher and Settings read the selection from settings, including + // selections made by the CLI, a paired client or another window. + saveSettings(patch: Partial): void { + this.store.updateSettings(patch, { notifyListeners: true }) } - async syncRuntimeAuth( - target?: ClaudeAccountSelectionTarget, - operation?: () => Promise - ): Promise { - beginClaudeAuthSwitch() - try { - await (operation ? operation() : this.runtimeAuth.syncForCurrentSelection(target)) - } finally { - endClaudeAuthSwitch() - } - } - - private normalizeActiveSelection(): void { + private pruneSelection(): void { const settings = this.store.getSettings() - const currentSelection = normalizeClaudeRuntimeSelection(settings) - const nextSelection = pruneInvalidClaudeRuntimeSelection( - currentSelection, - settings.claudeManagedAccounts - ) + const current = normalizeClaudeRuntimeSelection(settings) + const next = pruneInvalidClaudeRuntimeSelection(current, settings.claudeManagedAccounts) if ( - nextSelection.host !== settings.activeClaudeManagedAccountId || - JSON.stringify(nextSelection) !== JSON.stringify(currentSelection) + next.host !== settings.activeClaudeManagedAccountId || + JSON.stringify(next) !== JSON.stringify(current) ) { this.store.updateSettings({ - activeClaudeManagedAccountId: nextSelection.host, - activeClaudeManagedAccountIdsByRuntime: nextSelection + activeClaudeManagedAccountId: next.host, + activeClaudeManagedAccountIdsByRuntime: next }) } } } - -function toClaudeAccountSummary(account: ClaudeManagedAccount): ClaudeManagedAccountSummary { - return { - id: account.id, - email: account.email, - managedAuthRuntime: account.managedAuthRuntime ?? 'host', - wslDistro: account.wslDistro ?? null, - authMethod: account.authMethod ?? 'unknown', - organizationUuid: account.organizationUuid ?? null, - organizationName: account.organizationName ?? null, - createdAt: account.createdAt, - updatedAt: account.updatedAt, - lastAuthenticatedAt: account.lastAuthenticatedAt - } -} diff --git a/src/main/claude-accounts/claude-account-service-account-selection.test.ts b/src/main/claude-accounts/claude-account-service-account-selection.test.ts deleted file mode 100644 index f055475506bc..000000000000 --- a/src/main/claude-accounts/claude-account-service-account-selection.test.ts +++ /dev/null @@ -1,488 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { - resetClaudeKeychainMocks, - restorePlatform, - setPlatform -} from './claude-account-service-test-harness' - -const CLAUDE_SERVICE_TEST_ROOT = join(tmpdir(), 'orca-claude-service-selection-test') - -vi.mock('electron', () => ({ - app: { - getPath: () => CLAUDE_SERVICE_TEST_ROOT - } -})) - -const commandMocks = vi.hoisted(() => ({ - resolveClaudeCommand: vi.fn(() => 'claude') -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: commandMocks.resolveClaudeCommand -})) - -vi.mock('./keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async () => {}), - deleteManagedClaudeKeychainCredentials: vi.fn(async () => {}), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(async () => {}), - writeManagedClaudeKeychainCredentials: vi.fn(async () => {}) -})) - -describe('ClaudeAccountService credential capture', () => { - let tempDir: string | null = null - - beforeEach(() => { - setPlatform('darwin') - tempDir = null - resetClaudeKeychainMocks() - }) - - afterEach(() => { - restorePlatform() - if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }) - } - }) - - it('refreshes rate limits without recaching a removed active account', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync(join(managedAuthPath, '.credentials.json'), '{"old":true}\n', 'utf-8') - let settings = { - claudeManagedAccounts: [ - { - id: 'account-1', - email: 'old@example.com', - managedAuthPath, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'account-1' - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - await service.removeAccount('account-1') - - expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith('account-1') - expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith('account-1', { - runtime: 'host' - }) - expect(settings).toMatchObject({ - claudeManagedAccounts: [], - activeClaudeManagedAccountId: null - }) - }) - - it('switches the active Claude account while PTYs are live', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const firstAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - const secondAuthPath = join(tempDir, 'claude-accounts', 'account-2', 'auth') - mkdirSync(firstAuthPath, { recursive: true }) - mkdirSync(secondAuthPath, { recursive: true }) - let settings = { - claudeManagedAccounts: [ - { - id: 'account-1', - email: 'first@example.com', - managedAuthPath: firstAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'second@example.com', - managedAuthPath: secondAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeClaudeManagedAccountId: 'account-1', - activeClaudeManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const { markClaudePtyExited, markClaudePtySpawned } = await import('./live-pty-gate') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - markClaudePtySpawned('live-claude-pty') - try { - await service.selectAccount('account-2') - } finally { - markClaudePtyExited('live-claude-pty') - } - - expect(settings.activeClaudeManagedAccountId).toBe('account-2') - expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({ - host: 'account-2', - wsl: {} - }) - expect(runtimeAuth.syncForCurrentSelection).toHaveBeenCalledWith({ runtime: 'host' }) - expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith('account-1', { - runtime: 'host' - }) - }) - - it('restores the previous selection when a Claude account switch fails', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const firstAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - const secondAuthPath = join(tempDir, 'claude-accounts', 'account-2', 'auth') - mkdirSync(firstAuthPath, { recursive: true }) - mkdirSync(secondAuthPath, { recursive: true }) - let settings = { - claudeManagedAccounts: [ - { - id: 'account-1', - email: 'first@example.com', - managedAuthPath: firstAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'second@example.com', - managedAuthPath: secondAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeClaudeManagedAccountId: 'account-1', - activeClaudeManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - syncForCurrentSelection: vi.fn(async () => { - throw new Error('runtime sync failed') - }), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - await expect(service.selectAccount('account-2')).rejects.toThrow('runtime sync failed') - - expect(settings.activeClaudeManagedAccountId).toBe('account-1') - expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({ - host: 'account-1', - wsl: {} - }) - expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled() - expect(rateLimits.refreshForClaudeAccountChange).not.toHaveBeenCalled() - }) - - it('selects a WSL account without changing the Windows active account', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') - const wslAuthPath = join(tempDir, 'claude-accounts', 'wsl-account', 'auth') - mkdirSync(hostAuthPath, { recursive: true }) - mkdirSync(wslAuthPath, { recursive: true }) - let settings = { - claudeManagedAccounts: [ - { - id: 'host-account', - email: 'host@example.com', - managedAuthPath: hostAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'wsl-account', - email: 'wsl@example.com', - managedAuthPath: wslAuthPath, - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/jin/.local/share/orca/claude-accounts/wsl-account/auth', - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'host-account', - activeClaudeManagedAccountIdsByRuntime: { host: 'host-account', wsl: { Ubuntu: null } } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - const snapshot = await service.selectAccountForTarget('wsl-account', { - runtime: 'wsl', - wslDistro: 'Ubuntu' - }) - - expect(settings.activeClaudeManagedAccountId).toBe('host-account') - expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({ - host: 'host-account', - wsl: { Ubuntu: 'wsl-account' } - }) - expect(snapshot.activeAccountIdsByRuntime).toEqual({ - host: 'host-account', - wsl: { Ubuntu: 'wsl-account' } - }) - expect(runtimeAuth.syncForCurrentSelection).toHaveBeenCalledWith({ - runtime: 'wsl', - wslDistro: 'Ubuntu' - }) - expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith(null, { - runtime: 'wsl', - wslDistro: 'Ubuntu' - }) - }) - - it('rejects selecting a WSL account for the Windows target', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const wslAuthPath = join(tempDir, 'claude-accounts', 'wsl-account', 'auth') - mkdirSync(wslAuthPath, { recursive: true }) - const settings = { - claudeManagedAccounts: [ - { - id: 'wsl-account', - email: 'wsl@example.com', - managedAuthPath: wslAuthPath, - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/jin/.local/share/orca/claude-accounts/wsl-account/auth', - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn() - } - const runtimeAuth = { - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - await expect( - service.selectAccountForTarget('wsl-account', { runtime: 'host' }) - ).rejects.toThrow('different runtime') - expect(runtimeAuth.syncForCurrentSelection).not.toHaveBeenCalled() - expect(rateLimits.refreshForClaudeAccountChange).not.toHaveBeenCalled() - }) - - it('removes a WSL account without clearing the Windows active account', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') - const wslAuthPath = join(tempDir, 'claude-accounts', 'wsl-account', 'auth') - mkdirSync(hostAuthPath, { recursive: true }) - mkdirSync(wslAuthPath, { recursive: true }) - writeFileSync(join(wslAuthPath, '.orca-managed-claude-auth'), 'wsl-account\n', 'utf-8') - let settings = { - claudeManagedAccounts: [ - { - id: 'host-account', - email: 'host@example.com', - managedAuthPath: hostAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'wsl-account', - email: 'wsl@example.com', - managedAuthPath: wslAuthPath, - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/jin/.local/share/orca/claude-accounts/wsl-account/auth', - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'host-account', - activeClaudeManagedAccountIdsByRuntime: { - host: 'host-account', - wsl: { Ubuntu: 'wsl-account' } - } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - await service.removeAccount('wsl-account') - - expect(settings.activeClaudeManagedAccountId).toBe('host-account') - expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({ - host: 'host-account', - wsl: { Ubuntu: null } - }) - expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith('wsl-account') - expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith('wsl-account', { - runtime: 'wsl', - wslDistro: 'Ubuntu' - }) - }) -}) diff --git a/src/main/claude-accounts/claude-account-service-add-account.test.ts b/src/main/claude-accounts/claude-account-service-add-account.test.ts deleted file mode 100644 index 3e17f4c00ebb..000000000000 --- a/src/main/claude-accounts/claude-account-service-add-account.test.ts +++ /dev/null @@ -1,373 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { - resetClaudeKeychainMocks, - restorePlatform, - setPlatform -} from './claude-account-service-test-harness' - -const CLAUDE_SERVICE_TEST_ROOT = join(tmpdir(), 'orca-claude-service-add-test') - -vi.mock('electron', () => ({ - app: { - getPath: () => CLAUDE_SERVICE_TEST_ROOT - } -})) - -const commandMocks = vi.hoisted(() => ({ - resolveClaudeCommand: vi.fn(() => 'claude') -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: commandMocks.resolveClaudeCommand -})) - -vi.mock('./keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async () => {}), - deleteManagedClaudeKeychainCredentials: vi.fn(async () => {}), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(async () => {}), - writeManagedClaudeKeychainCredentials: vi.fn(async () => {}) -})) - -describe('ClaudeAccountService credential capture', () => { - let tempDir: string | null = null - - beforeEach(() => { - setPlatform('darwin') - tempDir = null - resetClaudeKeychainMocks() - }) - - afterEach(() => { - restorePlatform() - if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }) - } - }) - - it('adds an account without switching the active Claude auth while PTYs are live', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') - mkdirSync(hostAuthPath, { recursive: true }) - let settings = { - claudeManagedAccounts: [ - { - id: 'host-account', - email: 'host@example.com', - managedAuthPath: hostAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'host-account', - activeClaudeManagedAccountIdsByRuntime: { host: 'host-account', wsl: { Ubuntu: null } } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const { markClaudePtyExited, markClaudePtySpawned } = await import('./live-pty-gate') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - ;( - service as unknown as { - runClaudeLoginAndCapture(): Promise<{ - credentialsJson: string - oauthAccount: unknown - identity: { email: string; organizationUuid: null; organizationName: null } - }> - } - ).runClaudeLoginAndCapture = vi.fn(async () => ({ - credentialsJson: '{"new":true}\n', - oauthAccount: { newOauth: true }, - identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } - })) - - markClaudePtySpawned('live-claude-pty') - try { - await service.addAccount({ runtime: 'host' }) - } finally { - markClaudePtyExited('live-claude-pty') - } - - expect(settings.claudeManagedAccounts).toHaveLength(2) - expect(settings.claudeManagedAccounts[1].email).toBe('new@example.com') - expect(settings.activeClaudeManagedAccountId).toBe('host-account') - expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({ - host: 'host-account', - wsl: { Ubuntu: null } - }) - expect(runtimeAuth.syncForCurrentSelection).not.toHaveBeenCalled() - expect(rateLimits.refreshForClaudeAccountChange).not.toHaveBeenCalled() - expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith( - settings.claudeManagedAccounts[1].id - ) - }) - - it('reports the original add failure and still removes managed auth when rollback rematerialization fails', async () => { - // Why: this is the desktop add path. Previously the rollback's rematerialize - // was unguarded, so when it threw it replaced the real add error and skipped - // safeRemoveManagedAuth, leaking the throwaway auth dir. - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') - mkdirSync(hostAuthPath, { recursive: true }) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - let settings = { - claudeManagedAccounts: [ - { - id: 'host-account', - email: 'host@example.com', - managedAuthPath: hostAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'host-account', - activeClaudeManagedAccountIdsByRuntime: { host: 'host-account', wsl: { Ubuntu: null } } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => { - throw new Error('rematerialize failed') - }) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - ;( - service as unknown as { - runClaudeLoginAndCapture(): Promise<{ - credentialsJson: string - oauthAccount: unknown - identity: { email: string; organizationUuid: null; organizationName: null } - }> - } - ).runClaudeLoginAndCapture = vi.fn(async () => ({ - credentialsJson: '{"new":true}\n', - oauthAccount: null, - identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } - })) - ;(service as unknown as { writeManagedAuth(): Promise }).writeManagedAuth = vi.fn( - async () => { - throw new Error('managed auth write failed') - } - ) - - await expect(service.addAccount({ runtime: 'host' })).rejects.toThrow( - 'managed auth write failed' - ) - - expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled() - expect(settings.claudeManagedAccounts).toHaveLength(1) - // Why: the throwaway account directory must be gone even though rollback threw. - expect(readdirSync(join(tempDir, 'claude-accounts'))).toEqual(['host-account']) - warn.mockRestore() - }) - - it('rejects adding a Claude account whose identity already exists', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const existingAuthPath = join(tempDir, 'claude-accounts', 'existing-account', 'auth') - mkdirSync(existingAuthPath, { recursive: true }) - const existingMarkerPath = join(existingAuthPath, '.orca-managed-claude-auth') - writeFileSync(existingMarkerPath, 'existing-account\n', 'utf-8') - let settings = { - claudeManagedAccounts: [ - { - id: 'existing-account', - email: 'new@example.com', - managedAuthPath: existingAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'existing-account', - activeClaudeManagedAccountIdsByRuntime: { host: 'existing-account', wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - ;( - service as unknown as { - runClaudeLoginAndCapture(): Promise<{ - credentialsJson: string - oauthAccount: unknown - identity: { email: string; organizationUuid: string | null; organizationName: null } - }> - } - ).runClaudeLoginAndCapture = vi.fn(async () => ({ - credentialsJson: '{"new":true}\n', - oauthAccount: { newOauth: true }, - identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } - })) - - await expect(service.addAccount({ runtime: 'host' })).rejects.toThrow( - 'This Claude account is already added.' - ) - - expect(settings.claudeManagedAccounts).toHaveLength(1) - expect(readFileSync(existingMarkerPath, 'utf-8')).toBe('existing-account\n') - // The guard fires before credentials/settings change, so rollback I/O - // would only add latency and could mask the duplicate error. - expect(store.updateSettings).not.toHaveBeenCalled() - expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).not.toHaveBeenCalled() - // The rejected add's throwaway managed-auth dir must be cleaned up, leaving - // only the pre-existing account's dir behind. - expect(readdirSync(join(tempDir, 'claude-accounts')).sort()).toEqual(['existing-account']) - }) - - it('adds a Claude account with the same email under a different organization', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const existingAuthPath = join(tempDir, 'claude-accounts', 'existing-account', 'auth') - mkdirSync(existingAuthPath, { recursive: true }) - writeFileSync( - join(existingAuthPath, '.orca-managed-claude-auth'), - 'existing-account\n', - 'utf-8' - ) - let settings = { - claudeManagedAccounts: [ - { - id: 'existing-account', - email: 'new@example.com', - managedAuthPath: existingAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: 'org-A', - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'existing-account', - activeClaudeManagedAccountIdsByRuntime: { host: 'existing-account', wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - ;( - service as unknown as { - runClaudeLoginAndCapture(): Promise<{ - credentialsJson: string - oauthAccount: unknown - identity: { email: string; organizationUuid: string | null; organizationName: null } - }> - } - ).runClaudeLoginAndCapture = vi.fn(async () => ({ - credentialsJson: '{"new":true}\n', - oauthAccount: { newOauth: true }, - identity: { email: 'new@example.com', organizationUuid: 'org-B', organizationName: null } - })) - - await service.addAccount({ runtime: 'host' }) - - expect(settings.claudeManagedAccounts).toHaveLength(2) - expect(settings.claudeManagedAccounts[1].email).toBe('new@example.com') - expect(settings.claudeManagedAccounts[1].organizationUuid).toBe('org-B') - }) -}) diff --git a/src/main/claude-accounts/claude-account-service-api-parity.test.ts b/src/main/claude-accounts/claude-account-service-api-parity.test.ts deleted file mode 100644 index 09ec7813ffe8..000000000000 --- a/src/main/claude-accounts/claude-account-service-api-parity.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' -import { ClaudeAccountService } from './service' -import type { ClaudeAccountAddTarget } from './service' - -vi.mock('electron', () => ({ app: { getPath: () => '/tmp/orca-claude-api-parity' } })) - -function createService(): ClaudeAccountService { - const settings = { - claudeManagedAccounts: [], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } - } - return new ClaudeAccountService( - { getSettings: () => settings, updateSettings: vi.fn() } as never, - {} as never, - { getRuntimeConfigDir: () => '/tmp/claude' } as never - ) -} - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void - return { promise: new Promise((done) => (resolve = done)), resolve } -} - -describe('ClaudeAccountService mutation serialization', () => { - it('serializes mutations within one service', async () => { - const service = createService() - const first = deferred() - const calls: string[] = [] - const registration = ( - service as unknown as { - registration: { - add(target?: ClaudeAccountAddTarget): Promise - } - } - ).registration - registration.add = vi.fn(async (target) => { - calls.push(target?.wslDistro ?? 'host') - if (calls.length === 1) { - await first.promise - } - return { - accounts: [], - activeAccountId: null, - activeAccountIdsByRuntime: { host: null, wsl: {} } - } - }) - - const firstAdd = service.addAccount({ runtime: 'wsl', wslDistro: 'one' }) - const secondAdd = service.addAccount({ runtime: 'wsl', wslDistro: 'two' }) - await vi.waitFor(() => expect(calls).toEqual(['one'])) - first.resolve() - await Promise.all([firstAdd, secondAdd]) - expect(calls).toEqual(['one', 'two']) - }) - - it('isolates mutation and cancellation state between runtime hosts', async () => { - const firstService = createService() - const secondService = createService() - const first = deferred() - const firstAdd = vi.fn(async () => { - await first.promise - return { - accounts: [], - activeAccountId: null, - activeAccountIdsByRuntime: { host: null, wsl: {} } - } - }) - const secondAdd = vi.fn(async () => ({ - accounts: [], - activeAccountId: null, - activeAccountIdsByRuntime: { host: null, wsl: {} } - })) - ;(firstService as unknown as { registration: { add: typeof firstAdd } }).registration.add = - firstAdd - ;(secondService as unknown as { registration: { add: typeof secondAdd } }).registration.add = - secondAdd - ;( - firstService as unknown as { cancelPendingClaudeLogin: () => boolean } - ).cancelPendingClaudeLogin = vi.fn(() => true) - - const pendingFirst = firstService.addAccount() - await expect(secondService.addAccount()).resolves.toMatchObject({ accounts: [] }) - expect(firstService.cancelPendingLogin()).toBe(true) - expect(secondService.cancelPendingLogin()).toBe(false) - first.resolve() - await pendingFirst - }) -}) diff --git a/src/main/claude-accounts/claude-account-service-config-dir-capture.test.ts b/src/main/claude-accounts/claude-account-service-config-dir-capture.test.ts deleted file mode 100644 index 8ed94bebc6eb..000000000000 --- a/src/main/claude-accounts/claude-account-service-config-dir-capture.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createHash } from 'node:crypto' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import type { ClaudeManagedAccount } from '../../shared/managed-account-types' -import { - readActiveClaudeKeychainCredentialsStrict, - writeManagedClaudeKeychainCredentials -} from './keychain' -import { restorePlatform, setPlatform } from './claude-account-service-test-harness' - -const CLAUDE_SERVICE_TEST_ROOT = join(tmpdir(), 'orca-claude-service-config-dir-test') - -vi.mock('electron', () => ({ - app: { - getPath: () => CLAUDE_SERVICE_TEST_ROOT - } -})) - -const commandMocks = vi.hoisted(() => ({ - resolveClaudeCommand: vi.fn(() => 'claude') -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: commandMocks.resolveClaudeCommand -})) - -vi.mock('./keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async () => {}), - deleteManagedClaudeKeychainCredentials: vi.fn(async () => {}), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(async () => {}), - writeManagedClaudeKeychainCredentials: vi.fn(async () => {}) -})) - -describe('ClaudeAccountService.addAccountFromConfigDir', () => { - const managedRoot = CLAUDE_SERVICE_TEST_ROOT - let sourceDir: string | null = null - - beforeEach(() => { - setPlatform('linux') - rmSync(managedRoot, { recursive: true, force: true }) - sourceDir = null - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockReset() - vi.mocked(writeManagedClaudeKeychainCredentials).mockReset().mockResolvedValue() - }) - - afterEach(() => { - restorePlatform() - rmSync(managedRoot, { recursive: true, force: true }) - if (sourceDir) { - rmSync(sourceDir, { recursive: true, force: true }) - } - }) - - function makeDeps() { - let settings = { - claudeManagedAccounts: [] as ClaudeManagedAccount[], - activeClaudeManagedAccountId: null as string | null, - activeClaudeManagedAccountIdsByRuntime: { host: null as string | null, wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const rateLimits = { evictInactiveClaudeCache: vi.fn() } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - return { store, rateLimits, runtimeAuth, getSettings: () => settings } - } - - it('registers a managed account by capturing an authenticated config dir', async () => { - sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-')) - writeFileSync( - join(sourceDir, '.credentials.json'), - '{"claudeAiOauth":{"accessToken":"tok"}}\n', - 'utf-8' - ) - writeFileSync( - join(sourceDir, '.claude.json'), - JSON.stringify({ oauthAccount: { emailAddress: 'new@example.com' } }), - 'utf-8' - ) - - const deps = makeDeps() - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - deps.store as never, - deps.rateLimits as never, - deps.runtimeAuth as never - ) - // Why: avoid spawning a real `claude auth status` subprocess in the test. - ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( - async () => '{"email":"new@example.com"}' - ) - - const result = await service.addAccountFromConfigDir(sourceDir) - - const accounts = deps.getSettings().claudeManagedAccounts - expect(accounts).toHaveLength(1) - expect(accounts[0].email).toBe('new@example.com') - expect(result.accounts[0]?.email).toBe('new@example.com') - expect(readFileSync(join(accounts[0].managedAuthPath, '.credentials.json'), 'utf-8')).toBe( - '{"claudeAiOauth":{"accessToken":"tok"}}\n' - ) - expect(deps.runtimeAuth.clearLastWrittenCredentialsJson).toHaveBeenCalledWith(accounts[0].id) - }) - - it('captures only the config-scoped macOS Keychain credential', async () => { - setPlatform('darwin') - sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-keychain-')) - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (configDir) => - configDir ? '{"claudeAiOauth":{"accessToken":"scoped"}}' : 'legacy-credentials' - ) - const deps = makeDeps() - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - deps.store as never, - deps.rateLimits as never, - deps.runtimeAuth as never - ) - ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( - async () => '{"email":"new@example.com"}' - ) - - await service.addAccountFromConfigDir(sourceDir) - - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(sourceDir) - expect(writeManagedClaudeKeychainCredentials).toHaveBeenCalledWith( - expect.any(String), - '{"claudeAiOauth":{"accessToken":"scoped"}}' - ) - }) - - it('does not mistake an unchanged legacy Keychain credential for the temp login', async () => { - setPlatform('darwin') - sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-keychain-empty-')) - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (configDir) => - configDir ? null : 'legacy-credentials' - ) - const deps = makeDeps() - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - deps.store as never, - deps.rateLimits as never, - deps.runtimeAuth as never - ) - ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( - async () => '{"email":"existing@example.com"}' - ) - - await expect(service.addAccountFromConfigDir(sourceDir)).rejects.toThrow( - 'no OAuth credentials were captured' - ) - expect(deps.getSettings().claudeManagedAccounts).toHaveLength(0) - }) - - it('captures a legacy Keychain credential that changed after login began', async () => { - setPlatform('darwin') - sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-keychain-legacy-')) - const previousCredentials = '{"claudeAiOauth":{"accessToken":"previous"}}' - const newCredentials = '{"claudeAiOauth":{"accessToken":"new","email":"new@example.com"}}' - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (configDir) => - configDir ? null : newCredentials - ) - const deps = makeDeps() - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - deps.store as never, - deps.rateLimits as never, - deps.runtimeAuth as never - ) - ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( - async () => '{"email":"new@example.com"}' - ) - - await service.addAccountFromConfigDir(sourceDir, { - previousLegacyCredentialsSha256: createHash('sha256') - .update(previousCredentials) - .digest('hex') - }) - - expect(writeManagedClaudeKeychainCredentials).toHaveBeenCalledWith( - expect.any(String), - newCredentials - ) - expect(deps.getSettings().claudeManagedAccounts[0]?.email).toBe('new@example.com') - }) - - it('still registers when the daemon cannot spawn `claude auth status`', async () => { - // Why: `allowFailure` covers a non-zero exit but not a spawn error, so a daemon - // started with a minimal PATH would hard-fail an add the user already signed in for. - sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-nostatus-')) - writeFileSync( - join(sourceDir, '.credentials.json'), - '{"claudeAiOauth":{"accessToken":"tok"}}\n', - 'utf-8' - ) - writeFileSync( - join(sourceDir, '.claude.json'), - JSON.stringify({ oauthAccount: { emailAddress: 'new@example.com' } }), - 'utf-8' - ) - - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const deps = makeDeps() - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - deps.store as never, - deps.rateLimits as never, - deps.runtimeAuth as never - ) - ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( - async () => { - throw Object.assign(new Error('spawn claude ENOENT'), { code: 'ENOENT' }) - } - ) - - const result = await service.addAccountFromConfigDir(sourceDir) - - expect(result.accounts[0]?.email).toBe('new@example.com') - expect(deps.getSettings().claudeManagedAccounts).toHaveLength(1) - warn.mockRestore() - }) - - it('rejects and rolls back when the config dir has no credentials', async () => { - sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-empty-')) - const deps = makeDeps() - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - deps.store as never, - deps.rateLimits as never, - deps.runtimeAuth as never - ) - - await expect(service.addAccountFromConfigDir(sourceDir)).rejects.toThrow( - /No Claude credentials found/ - ) - expect(deps.getSettings().claudeManagedAccounts).toHaveLength(0) - expect(deps.runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled() - }) -}) diff --git a/src/main/claude-accounts/claude-account-service-credential-capture.test.ts b/src/main/claude-accounts/claude-account-service-credential-capture.test.ts deleted file mode 100644 index 641ef58b5bee..000000000000 --- a/src/main/claude-accounts/claude-account-service-credential-capture.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { - readActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentialsStrict, - writeActiveClaudeKeychainCredentials -} from './keychain' -import { - createService, - resetClaudeKeychainMocks, - restorePlatform, - setPlatform -} from './claude-account-service-test-harness' - -const CLAUDE_SERVICE_TEST_ROOT = join(tmpdir(), 'orca-claude-service-capture-test') - -vi.mock('electron', () => ({ - app: { - getPath: () => CLAUDE_SERVICE_TEST_ROOT - } -})) - -const commandMocks = vi.hoisted(() => ({ - resolveClaudeCommand: vi.fn(() => 'claude') -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: commandMocks.resolveClaudeCommand -})) - -vi.mock('./keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async () => {}), - deleteManagedClaudeKeychainCredentials: vi.fn(async () => {}), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(async () => {}), - writeManagedClaudeKeychainCredentials: vi.fn(async () => {}) -})) - -async function readCapturedCredentials( - configDir: string, - previousLegacyKeychain: string | null -): Promise { - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - createService() as never, - createService() as never, - createService() as never - ) - return ( - service as unknown as { - readCapturedCredentials( - configDir: string, - previousLegacyKeychain: string | null - ): Promise - } - ).readCapturedCredentials(configDir, previousLegacyKeychain) -} - -describe('ClaudeAccountService credential capture', () => { - let tempDir: string | null = null - - beforeEach(() => { - setPlatform('darwin') - tempDir = null - resetClaudeKeychainMocks() - }) - - afterEach(() => { - restorePlatform() - if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }) - } - }) - - it('accepts scoped Keychain capture even when it matches the previous legacy item', async () => { - vi.mocked(readActiveClaudeKeychainCredentialsStrict) - .mockResolvedValueOnce('same-account') - .mockResolvedValueOnce('same-account') - - await expect(readCapturedCredentials('/tmp/claude-config', 'same-account')).resolves.toBe( - 'same-account' - ) - - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith('/tmp/claude-config') - expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled() - }) - - it('rejects unchanged legacy fallback when scoped capture is missing', async () => { - vi.mocked(readActiveClaudeKeychainCredentialsStrict) - .mockResolvedValueOnce(null) - .mockResolvedValueOnce('previous') - - await expect(readCapturedCredentials('/tmp/claude-config', 'previous')).resolves.toBeNull() - - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith( - 1, - '/tmp/claude-config' - ) - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2) - }) - - it('accepts changed legacy fallback for old Claude Code builds', async () => { - vi.mocked(readActiveClaudeKeychainCredentialsStrict) - .mockResolvedValueOnce(null) - .mockResolvedValueOnce('new-legacy') - - await expect(readCapturedCredentials('/tmp/claude-config', 'previous')).resolves.toBe( - 'new-legacy' - ) - - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith( - 1, - '/tmp/claude-config' - ) - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2) - }) - - it('falls back to captured credentials file on macOS', async () => { - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-capture-')) - writeFileSync(join(tempDir, '.credentials.json'), '{"token":"file"}\n', 'utf-8') - vi.mocked(readActiveClaudeKeychainCredentialsStrict) - .mockResolvedValueOnce(null) - .mockResolvedValueOnce('previous') - - await expect(readCapturedCredentials(tempDir, 'previous')).resolves.toBe('{"token":"file"}\n') - }) - - it('fails login capture when legacy Keychain cleanup fails', async () => { - vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue('previous-legacy') - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue('captured-scoped') - vi.mocked(writeActiveClaudeKeychainCredentials).mockRejectedValue(new Error('restore failed')) - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - createService() as never, - createService() as never, - createService() as never - ) - const testService = service as unknown as { - runClaudeCommand: () => Promise - runClaudeLoginAndCapture(): Promise<{ credentialsJson: string }> - } - testService.runClaudeCommand = vi.fn(async () => '{"account":{"email":"user@example.com"}}') - - await expect(testService.runClaudeLoginAndCapture()).rejects.toThrow('restore failed') - }) -}) diff --git a/src/main/claude-accounts/claude-account-service-login-process.test.ts b/src/main/claude-accounts/claude-account-service-login-process.test.ts deleted file mode 100644 index 8aee047e5fa5..000000000000 --- a/src/main/claude-accounts/claude-account-service-login-process.test.ts +++ /dev/null @@ -1,684 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { EventEmitter } from 'node:events' -import { writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { PassThrough } from 'node:stream' -import type { ClaudeManagedAccount } from '../../shared/managed-account-types' -import { readActiveClaudeKeychainCredentials } from './keychain' -import { getCmdExePath } from '../../shared/windows-batch-spawn' -import { - createService, - resetClaudeKeychainMocks, - restorePlatform, - setPlatform -} from './claude-account-service-test-harness' - -const CLAUDE_SERVICE_TEST_ROOT = join(tmpdir(), 'orca-claude-service-login-test') - -vi.mock('electron', () => ({ - app: { - getPath: () => CLAUDE_SERVICE_TEST_ROOT - } -})) - -const commandMocks = vi.hoisted(() => ({ - resolveClaudeCommand: vi.fn(() => 'claude') -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: commandMocks.resolveClaudeCommand -})) - -vi.mock('./keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async () => {}), - deleteManagedClaudeKeychainCredentials: vi.fn(async () => {}), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(async () => {}), - writeManagedClaudeKeychainCredentials: vi.fn(async () => {}) -})) - -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the doubles below implement every member the add-account login path reaches; this case drives the service only through addAccount. -const asServiceDouble = (double: unknown): T => double as T - -describe('ClaudeAccountService credential capture', () => { - beforeEach(() => { - setPlatform('darwin') - resetClaudeKeychainMocks() - }) - - afterEach(() => { - restorePlatform() - }) - - it('removes command listeners when Claude sign-in times out', async () => { - vi.resetModules() - vi.useFakeTimers() - const child = new EventEmitter() as EventEmitter & { - stdin: PassThrough - stdout: PassThrough - stderr: PassThrough - kill: () => void - } - child.stdin = new PassThrough() - child.stdout = new PassThrough() - child.stderr = new PassThrough() - child.kill = vi.fn() - const destroyStdin = vi.spyOn(child.stdin, 'destroy') - const spawnMock = vi.fn(() => child) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - - try { - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - createService() as never, - createService() as never, - createService() as never - ) - const commandPromise = ( - service as unknown as { - runClaudeCommand( - args: string[], - configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, - timeoutMs: number, - options?: { keepStdinOpen?: boolean } - ): Promise - } - ).runClaudeCommand( - ['login'], - { windowsPath: '/tmp/claude-auth', linuxPath: null, wslDistro: null }, - 1000, - { keepStdinOpen: true } - ) - const rejection = expect(commandPromise).rejects.toThrow( - 'Claude sign-in took too long to finish.' - ) - - await vi.advanceTimersByTimeAsync(1000) - - await rejection - expect(child.kill).toHaveBeenCalledTimes(1) - expect(destroyStdin).toHaveBeenCalledTimes(1) - expect(child.stdout.listenerCount('data')).toBe(0) - expect(child.stderr.listenerCount('data')).toBe(0) - expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - } finally { - vi.useRealTimers() - vi.doUnmock('node:child_process') - } - }) - - it('owns the complete cmd.exe command line for a resolved Windows Claude command', async () => { - setPlatform('win32') - vi.resetModules() - commandMocks.resolveClaudeCommand.mockReturnValueOnce( - 'C:\\Users\\First Last\\AppData\\Roaming\\npm\\claude.cmd' - ) - const child = new EventEmitter() as EventEmitter & { - stdout: PassThrough - stderr: PassThrough - kill: ReturnType - } - child.stdout = new PassThrough() - child.stderr = new PassThrough() - child.kill = vi.fn() - const spawnMock = vi.fn(() => { - child.stdout.write('{"email":"user@example.com"}\n') - queueMicrotask(() => child.emit('close', 0)) - return child - }) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - - try { - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - createService() as never, - createService() as never, - createService() as never - ) - await ( - service as unknown as { - runClaudeCommand( - args: string[], - configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, - timeoutMs: number - ): Promise - } - ).runClaudeCommand( - ['auth', 'status', '--json'], - { windowsPath: 'C:\\tmp\\claude-auth', linuxPath: null, wslDistro: null }, - 1000 - ) - - expect(spawnMock).toHaveBeenCalledWith( - process.env.ComSpec ?? 'cmd.exe', - [ - '/d', - '/v:off', - '/s', - '/c', - '""C:\\Users\\First Last\\AppData\\Roaming\\npm\\claude.cmd" "auth" "status" "--json""' - ], - expect.objectContaining({ shell: false, windowsVerbatimArguments: true }) - ) - } finally { - vi.doUnmock('node:child_process') - } - }) - - it('keeps WSL execution separate from Windows command resolution', async () => { - setPlatform('win32') - vi.resetModules() - commandMocks.resolveClaudeCommand.mockClear() - const child = new EventEmitter() as EventEmitter & { - stdout: PassThrough - stderr: PassThrough - kill: ReturnType - } - child.stdout = new PassThrough() - child.stderr = new PassThrough() - child.kill = vi.fn() - const spawnMock = vi.fn(() => { - child.stdout.write('{"email":"user@example.com"}\n') - queueMicrotask(() => child.emit('close', 0)) - return child - }) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - - try { - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - createService() as never, - createService() as never, - createService() as never - ) - await ( - service as unknown as { - runClaudeCommand( - args: string[], - configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, - timeoutMs: number - ): Promise - } - ).runClaudeCommand( - ['auth', 'status', '--json'], - { - windowsPath: 'C:\\tmp\\claude-auth', - linuxPath: '/home/user/.config/orca auth', - wslDistro: 'Ubuntu Test' - }, - 1000 - ) - - expect(commandMocks.resolveClaudeCommand).not.toHaveBeenCalled() - expect(spawnMock).toHaveBeenCalledWith( - 'wsl.exe', - [ - '-d', - 'Ubuntu Test', - '--exec', - 'bash', - '-lc', - "export CLAUDE_CONFIG_DIR='/home/user/.config/orca auth'; export CLAUDE_SECURESTORAGE_CONFIG_DIR='/home/user/.config/orca auth'; exec claude 'auth' 'status' '--json'" - ], - expect.objectContaining({ shell: false, windowsVerbatimArguments: false }) - ) - } finally { - vi.doUnmock('node:child_process') - } - }) - - it('pipes stdin only for the explicit Claude account login command', async () => { - setPlatform('linux') - vi.resetModules() - vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null) - const loginChild = new EventEmitter() as EventEmitter & { - stdin: PassThrough - stdout: PassThrough - stderr: PassThrough - kill: ReturnType - } - loginChild.stdin = new PassThrough() - loginChild.stdout = new PassThrough() - loginChild.stderr = new PassThrough() - loginChild.kill = vi.fn() - const statusChild = new EventEmitter() as EventEmitter & { - stdout: PassThrough - stderr: PassThrough - kill: ReturnType - } - statusChild.stdout = new PassThrough() - statusChild.stderr = new PassThrough() - statusChild.kill = vi.fn() - const spawnMock = vi.fn( - (_command: string, args: string[], options: { env: NodeJS.ProcessEnv }) => { - if (args[1] === 'login') { - writeFileSync( - join(options.env.CLAUDE_CONFIG_DIR!, '.credentials.json'), - '{"claudeAiOauth":{"email":"user@example.com","accessToken":"token"}}\n', - 'utf-8' - ) - queueMicrotask(() => loginChild.emit('close', 0)) - return loginChild - } - statusChild.stdout.write('{"email":"user@example.com"}\n') - queueMicrotask(() => statusChild.emit('close', 0)) - return statusChild - } - ) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - - try { - const { ClaudeAccountService } = await import('./service') - let settings = { - claudeManagedAccounts: [] as ClaudeManagedAccount[], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn() - } - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - await service.addAccount() - - expect(spawnMock).toHaveBeenNthCalledWith( - 1, - 'claude', - ['auth', 'login', '--claudeai'], - expect.objectContaining({ stdio: ['pipe', 'pipe', 'pipe'] }) - ) - expect(spawnMock).toHaveBeenNthCalledWith( - 2, - 'claude', - ['auth', 'status', '--json'], - expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'] }) - ) - expect(settings.claudeManagedAccounts[0]?.email).toBe('user@example.com') - } finally { - vi.doUnmock('node:child_process') - } - }) - - it('rejects immediately when Claude sign-in is denied', async () => { - vi.resetModules() - const child = new EventEmitter() as EventEmitter & { - stdin: PassThrough - stdout: PassThrough - stderr: PassThrough - kill: ReturnType - pid: number - } - child.stdin = new PassThrough() - child.stdout = new PassThrough() - child.stderr = new PassThrough() - child.kill = vi.fn() - child.pid = 4242 - const destroyStdin = vi.spyOn(child.stdin, 'destroy') - const spawnMock = vi.fn(() => child) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - // Denial must tear down the whole detached login/browser tree (process-group kill on POSIX), - // not just the direct child — otherwise the orphaned auth processes the `detached` spawn guards against leak. - const killTree = vi.spyOn(process, 'kill').mockReturnValue(true) - - try { - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - createService() as never, - createService() as never, - createService() as never - ) - const commandPromise = ( - service as unknown as { - runClaudeCommand( - args: string[], - configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, - timeoutMs: number, - options?: { keepStdinOpen?: boolean } - ): Promise - } - ).runClaudeCommand( - ['login'], - { windowsPath: '/tmp/claude-auth', linuxPath: null, wslDistro: null }, - 180_000, - { keepStdinOpen: true } - ) - - child.stderr.write('OAuth authorization failed: access_denied\n') - - await expect(commandPromise).rejects.toThrow('Claude sign-in was denied. Please try again.') - expect(killTree).toHaveBeenCalledWith(-child.pid) - expect(child.kill).not.toHaveBeenCalled() - expect(destroyStdin).toHaveBeenCalledTimes(1) - expect(child.stdout.listenerCount('data')).toBe(0) - expect(child.stderr.listenerCount('data')).toBe(0) - expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - } finally { - killTree.mockRestore() - vi.doUnmock('node:child_process') - } - }) - - it('cancels an in-flight Claude account add', async () => { - vi.resetModules() - const child = new EventEmitter() as EventEmitter & { - stdin: PassThrough - stdout: PassThrough - stderr: PassThrough - kill: ReturnType - } - child.stdin = new PassThrough() - child.stdout = new PassThrough() - child.stderr = new PassThrough() - child.kill = vi.fn() - const destroyStdin = vi.spyOn(child.stdin, 'destroy') - const spawnMock = vi.fn(() => child) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - - try { - const { ClaudeAccountService } = await import('./service') - let settings = { - claudeManagedAccounts: [], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn() - } - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - const addPromise = service.addAccount() - await vi.waitFor(() => { - expect(spawnMock).toHaveBeenCalledTimes(1) - }) - - expect(service.cancelPendingLogin()).toBe(true) - await expect(addPromise).rejects.toThrow('Claude sign-in was cancelled.') - expect(child.kill).toHaveBeenCalledTimes(1) - expect(destroyStdin).toHaveBeenCalledTimes(1) - expect(service.cancelPendingLogin()).toBe(false) - expect(settings.claudeManagedAccounts).toEqual([]) - expect(child.stdout.listenerCount('data')).toBe(0) - expect(child.stderr.listenerCount('data')).toBe(0) - expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - } finally { - vi.doUnmock('node:child_process') - } - }) - - it('honors cancel before Claude login command starts', async () => { - setPlatform('linux') - vi.resetModules() - let releaseKeychainRead: (value: string | null) => void = () => {} - vi.mocked(readActiveClaudeKeychainCredentials).mockReturnValue( - new Promise((resolve) => { - releaseKeychainRead = resolve - }) - ) - const spawnMock = vi.fn() - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - - try { - const { ClaudeAccountService } = await import('./service') - let settings = { - claudeManagedAccounts: [], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn() - } - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - const addPromise = service.addAccount() - await vi.waitFor(() => { - expect(readActiveClaudeKeychainCredentials).toHaveBeenCalled() - }) - - expect(service.cancelPendingLogin()).toBe(true) - expect(service.cancelPendingLogin()).toBe(false) - expect(spawnMock).not.toHaveBeenCalled() - releaseKeychainRead(null) - await expect(addPromise).rejects.toThrow('Claude sign-in was cancelled.') - expect(spawnMock).not.toHaveBeenCalled() - expect(service.cancelPendingLogin()).toBe(false) - expect(settings.claudeManagedAccounts).toEqual([]) - } finally { - vi.doUnmock('node:child_process') - } - }) - - it('supersedes the login a closed Settings pane abandoned instead of queueing behind it', async () => { - setPlatform('linux') - vi.resetModules() - vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null) - const children: (EventEmitter & { stdin: PassThrough; kill: ReturnType })[] = [] - // No pid: the POSIX teardown signals -pid as a process group, which must - // never reach a real group from a test double. - const spawnMock = vi.fn(() => { - const child = Object.assign(new EventEmitter(), { - stdin: new PassThrough(), - stdout: new PassThrough(), - stderr: new PassThrough(), - kill: vi.fn() - }) - children.push(child) - return child - }) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - - try { - const { ClaudeAccountService } = await import('./service') - let settings = { - claudeManagedAccounts: [], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn() - } - const service = new ClaudeAccountService( - asServiceDouble(store), - asServiceDouble(rateLimits), - asServiceDouble(runtimeAuth) - ) - - const abandoned = service.addAccount({ runtime: 'host' }) - const abandonedRejection = expect(abandoned).rejects.toThrow('Claude sign-in was cancelled.') - await vi.waitFor(() => { - expect(children.length).toBe(1) - }) - - // The user reopens Settings and clicks Add Account again. - const retry = service.addAccount({ runtime: 'host' }) - const retryRejection = expect(retry).rejects.toThrow() - - await abandonedRejection - expect(children[0].kill).toHaveBeenCalled() - // Why: the point of the fix — the second login starts now, not after the - // abandoned one's whole sign-in deadline elapses. - await vi.waitFor(() => { - expect(children.length).toBe(2) - }) - - children[1].emit('close', 1) - await retryRejection - } finally { - vi.doUnmock('node:child_process') - } - }) - - it('uses taskkill to cancel the Windows Claude login process tree', async () => { - setPlatform('win32') - vi.resetModules() - vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null) - const child = new EventEmitter() as EventEmitter & { - pid: number - stdin: PassThrough - stdout: PassThrough - stderr: PassThrough - kill: ReturnType - } - child.pid = 1234 - child.stdin = new PassThrough() - child.stdout = new PassThrough() - child.stderr = new PassThrough() - child.kill = vi.fn() - const destroyStdin = vi.spyOn(child.stdin, 'destroy') - const taskkill = new EventEmitter() - const spawnMock = vi.fn((command: string) => (command === 'taskkill.exe' ? taskkill : child)) - const cleanupInteractiveLogin = vi.fn() - let publishTerminationPid: (pid: number) => void = () => {} - const buildInteractiveLoginSpawn = vi.fn(() => ({ - command: getCmdExePath(), - args: ['/d', '/c', 'start', '', '/wait', 'claude', 'auth', 'login', '--claudeai'], - stdio: 'ignore' as const, - windowsHide: true, - cleanup: cleanupInteractiveLogin, - getTerminationPid: () => null, - waitForTerminationPid: () => - new Promise((resolve) => { - publishTerminationPid = resolve - }) - })) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - vi.doMock('../../shared/windows-interactive-login-spawn', () => ({ - buildWindowsHostInteractiveLoginSpawn: buildInteractiveLoginSpawn - })) - - try { - const { ClaudeAccountService } = await import('./service') - let settings = { - claudeManagedAccounts: [], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn() - } - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - const addPromise = service.addAccount() - await vi.waitFor(() => { - expect(buildInteractiveLoginSpawn).toHaveBeenCalledWith('claude', [ - 'auth', - 'login', - '--claudeai' - ]) - expect(spawnMock).toHaveBeenCalledWith( - getCmdExePath(), - ['/d', '/c', 'start', '', '/wait', 'claude', 'auth', 'login', '--claudeai'], - expect.objectContaining({ stdio: 'ignore', windowsHide: true }) - ) - }) - - expect(service.cancelPendingLogin()).toBe(true) - const rejection = expect(addPromise).rejects.toThrow('Claude sign-in was cancelled.') - expect(child.kill).not.toHaveBeenCalled() - expect(spawnMock).not.toHaveBeenCalledWith( - 'taskkill.exe', - expect.anything(), - expect.anything() - ) - publishTerminationPid(9876) - await vi.waitFor(() => { - expect(spawnMock).toHaveBeenCalledWith( - 'taskkill.exe', - ['/pid', '9876', '/t', '/f'], - expect.objectContaining({ stdio: 'ignore', windowsHide: true }) - ) - }) - expect(destroyStdin).not.toHaveBeenCalled() - taskkill.emit('close', 0) - await rejection - expect(destroyStdin).toHaveBeenCalledTimes(1) - expect(cleanupInteractiveLogin).toHaveBeenCalledTimes(1) - expect(service.cancelPendingLogin()).toBe(false) - } finally { - vi.doUnmock('node:child_process') - vi.doUnmock('../../shared/windows-interactive-login-spawn') - } - }) -}) diff --git a/src/main/claude-accounts/claude-account-service-reauth-rollback.test.ts b/src/main/claude-accounts/claude-account-service-reauth-rollback.test.ts deleted file mode 100644 index 2ad1eb6356d1..000000000000 --- a/src/main/claude-accounts/claude-account-service-reauth-rollback.test.ts +++ /dev/null @@ -1,420 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { - readManagedClaudeKeychainCredentials, - writeManagedClaudeKeychainCredentials -} from './keychain' -import { - resetClaudeKeychainMocks, - restorePlatform, - setPlatform -} from './claude-account-service-test-harness' - -const CLAUDE_SERVICE_TEST_ROOT = join(tmpdir(), 'orca-claude-service-reauth-test') - -vi.mock('electron', () => ({ - app: { - getPath: () => CLAUDE_SERVICE_TEST_ROOT - } -})) - -const commandMocks = vi.hoisted(() => ({ - resolveClaudeCommand: vi.fn(() => 'claude') -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: commandMocks.resolveClaudeCommand -})) - -vi.mock('./keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async () => {}), - deleteManagedClaudeKeychainCredentials: vi.fn(async () => {}), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(async () => {}), - writeManagedClaudeKeychainCredentials: vi.fn(async () => {}) -})) - -describe('ClaudeAccountService credential capture', () => { - let tempDir: string | null = null - - beforeEach(() => { - setPlatform('darwin') - tempDir = null - resetClaudeKeychainMocks() - }) - - afterEach(() => { - restorePlatform() - if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }) - } - }) - - it('restores previous managed auth when reauth materialization fails', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync(join(managedAuthPath, '.credentials.json'), '{"old":true}\n', 'utf-8') - writeFileSync(join(managedAuthPath, 'oauth-account.json'), '{"oldOauth":true}\n', 'utf-8') - let settings = { - claudeManagedAccounts: [ - { - id: 'account-1', - email: 'old@example.com', - managedAuthPath, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'account-1' - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}), - syncForCurrentSelection: vi.fn(async () => { - throw new Error('materialize failed') - }) - } - const rateLimits = { evictInactiveClaudeCache: vi.fn(), refreshForClaudeAccountChange: vi.fn() } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - ;( - service as unknown as { - runClaudeLoginAndCapture(): Promise<{ - credentialsJson: string - oauthAccount: unknown - identity: { email: string; organizationUuid: null; organizationName: null } - }> - } - ).runClaudeLoginAndCapture = vi.fn(async () => ({ - credentialsJson: '{"new":true}\n', - oauthAccount: { newOauth: true }, - identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } - })) - - await expect(service.reauthenticateAccount('account-1')).rejects.toThrow('materialize failed') - - expect(readFileSync(join(managedAuthPath, '.credentials.json'), 'utf-8')).toBe('{"old":true}\n') - expect(readFileSync(join(managedAuthPath, 'oauth-account.json'), 'utf-8')).toBe( - '{"oldOauth":true}\n' - ) - expect(store.getSettings().claudeManagedAccounts[0].email).toBe('old@example.com') - expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled() - }) - - it('restores settings without rematerializing when managed-auth rollback write fails', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync(join(managedAuthPath, 'oauth-account.json'), '{"oldOauth":true}\n', 'utf-8') - vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValue('{"old":true}\n') - vi.mocked(writeManagedClaudeKeychainCredentials) - .mockResolvedValueOnce() - .mockRejectedValueOnce(new Error('managed restore failed')) - let settings = { - claudeManagedAccounts: [ - { - id: 'account-1', - email: 'old@example.com', - managedAuthPath, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'account-1' - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}), - syncForCurrentSelection: vi.fn(async () => { - throw new Error('materialize failed') - }) - } - const rateLimits = { evictInactiveClaudeCache: vi.fn(), refreshForClaudeAccountChange: vi.fn() } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - ;( - service as unknown as { - runClaudeLoginAndCapture(): Promise<{ - credentialsJson: string - oauthAccount: unknown - identity: { email: string; organizationUuid: null; organizationName: null } - }> - } - ).runClaudeLoginAndCapture = vi.fn(async () => ({ - credentialsJson: '{"new":true}\n', - oauthAccount: { newOauth: true }, - identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } - })) - - await expect(service.reauthenticateAccount('account-1')).rejects.toThrow('materialize failed') - - expect(store.getSettings().claudeManagedAccounts[0].email).toBe('new@example.com') - expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).not.toHaveBeenCalled() - expect(warn).toHaveBeenCalledWith( - '[claude-accounts] Failed to restore managed credentials during rollback:', - expect.any(Error) - ) - warn.mockRestore() - }) - - it('restores oauth metadata when new credential write and credential rollback fail', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync(join(managedAuthPath, 'oauth-account.json'), '{"oldOauth":true}\n', 'utf-8') - vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValue('{"old":true}\n') - vi.mocked(writeManagedClaudeKeychainCredentials) - .mockRejectedValueOnce(new Error('new credentials failed')) - .mockRejectedValueOnce(new Error('credential rollback failed')) - let settings = { - claudeManagedAccounts: [ - { - id: 'account-1', - email: 'old@example.com', - managedAuthPath, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'account-1' - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}), - syncForCurrentSelection: vi.fn() - } - const rateLimits = { evictInactiveClaudeCache: vi.fn(), refreshForClaudeAccountChange: vi.fn() } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - ;( - service as unknown as { - runClaudeLoginAndCapture(): Promise<{ - credentialsJson: string - oauthAccount: unknown - identity: { email: string; organizationUuid: null; organizationName: null } - }> - } - ).runClaudeLoginAndCapture = vi.fn(async () => ({ - credentialsJson: '{"new":true}\n', - oauthAccount: { newOauth: true }, - identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } - })) - - await expect(service.reauthenticateAccount('account-1')).rejects.toThrow( - 'new credentials failed' - ) - - expect(readFileSync(join(managedAuthPath, 'oauth-account.json'), 'utf-8')).toBe( - '{"oldOauth":true}\n' - ) - expect(store.getSettings().claudeManagedAccounts[0].email).toBe('old@example.com') - expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).not.toHaveBeenCalled() - expect(warn).toHaveBeenCalledWith( - '[claude-accounts] Failed to restore managed credentials during rollback:', - expect.any(Error) - ) - warn.mockRestore() - }) - - it('restores old metadata when rollback restores credentials but oauth restore fails', async () => { - setPlatform('linux') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - const oauthPath = join(managedAuthPath, 'oauth-account.json') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync(join(managedAuthPath, '.credentials.json'), '{"old":true}\n', 'utf-8') - writeFileSync(oauthPath, '{"oldOauth":true}\n', 'utf-8') - let settings = { - claudeManagedAccounts: [ - { - id: 'account-1', - email: 'old@example.com', - managedAuthPath, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'account-1' - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}), - syncForCurrentSelection: vi.fn(async () => { - rmSync(oauthPath, { force: true }) - mkdirSync(oauthPath) - throw new Error('materialize failed') - }) - } - const rateLimits = { evictInactiveClaudeCache: vi.fn(), refreshForClaudeAccountChange: vi.fn() } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - ;( - service as unknown as { - runClaudeLoginAndCapture(): Promise<{ - credentialsJson: string - oauthAccount: unknown - identity: { email: string; organizationUuid: null; organizationName: null } - }> - } - ).runClaudeLoginAndCapture = vi.fn(async () => ({ - credentialsJson: '{"new":true}\n', - oauthAccount: { newOauth: true }, - identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } - })) - - await expect(service.reauthenticateAccount('account-1')).rejects.toThrow('materialize failed') - - expect(readFileSync(join(managedAuthPath, '.credentials.json'), 'utf-8')).toBe('{"old":true}\n') - expect(store.getSettings().claudeManagedAccounts[0].email).toBe('old@example.com') - expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled() - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - - it('evicts inactive rate-limit cache after successful reauth', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync(join(managedAuthPath, '.credentials.json'), '{"old":true}\n', 'utf-8') - writeFileSync(join(managedAuthPath, 'oauth-account.json'), '{"oldOauth":true}\n', 'utf-8') - let settings = { - claudeManagedAccounts: [ - { - id: 'account-1', - email: 'old@example.com', - managedAuthPath, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: null - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - ;( - service as unknown as { - runClaudeLoginAndCapture(): Promise<{ - credentialsJson: string - oauthAccount: unknown - identity: { email: string; organizationUuid: null; organizationName: null } - }> - } - ).runClaudeLoginAndCapture = vi.fn(async () => ({ - credentialsJson: '{"new":true}\n', - oauthAccount: { newOauth: true }, - identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } - })) - - await service.reauthenticateAccount('account-1') - - expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith('account-1') - expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith(undefined, { - runtime: 'host' - }) - expect(settings.claudeManagedAccounts[0].email).toBe('new@example.com') - }) -}) diff --git a/src/main/claude-accounts/claude-account-service-test-harness.ts b/src/main/claude-accounts/claude-account-service-test-harness.ts deleted file mode 100644 index d66d1f3ea51b..000000000000 --- a/src/main/claude-accounts/claude-account-service-test-harness.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { vi } from 'vitest' -import { - deleteActiveClaudeKeychainCredentialsStrict, - readActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentialsStrict, - readManagedClaudeKeychainCredentials, - writeActiveClaudeKeychainCredentials, - writeManagedClaudeKeychainCredentials -} from './keychain' - -// Each suite declares its own `vi.mock('./keychain', ...)` (factories are hoisted and -// cannot close over this module); this only re-arms the doubles between tests. -export function resetClaudeKeychainMocks(): void { - vi.mocked(readActiveClaudeKeychainCredentials).mockReset() - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockReset() - vi.mocked(readManagedClaudeKeychainCredentials).mockReset() - vi.mocked(deleteActiveClaudeKeychainCredentialsStrict).mockClear() - vi.mocked(writeActiveClaudeKeychainCredentials).mockReset() - vi.mocked(writeActiveClaudeKeychainCredentials).mockResolvedValue() - vi.mocked(writeManagedClaudeKeychainCredentials).mockReset() - vi.mocked(writeManagedClaudeKeychainCredentials).mockResolvedValue() -} - -const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - -export function setPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { - configurable: true, - value: platform - }) -} - -export function restorePlatform(): void { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } -} - -export function createService(): unknown { - return {} -} diff --git a/src/main/claude-accounts/claude-account-service.test.ts b/src/main/claude-accounts/claude-account-service.test.ts new file mode 100644 index 000000000000..55c4304ca626 --- /dev/null +++ b/src/main/claude-accounts/claude-account-service.test.ts @@ -0,0 +1,349 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../shared/constants' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' +import { CLAUDE_SIGN_IN_NOT_FINISHED_MESSAGE } from './claude-account-registration' +import { + CLAUDE_ACCOUNT_FOLDER_IN_USE_MESSAGE, + CLAUDE_ACCOUNT_NEEDS_SIGN_IN_MESSAGE +} from './claude-account-selection' +import type { ClaudeCommandConfig, ClaudeCommandOptions } from './claude-command-process' +import type { ClaudeAccountSelectionTarget } from './runtime-selection' +import { ClaudeAccountService } from './service' + +const roots: string[] = [] +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) + +function account(id: string, extra: Partial = {}): ClaudeManagedAccount { + return { + id, + email: `${id}@example.test`, + managedAuthPath: '', + managedAuthRuntime: 'host', + wslDistro: null, + authMethod: 'subscription-oauth', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1, + ...extra + } +} + +function fixture(accounts: ClaudeManagedAccount[] = [account('a'), account('b')]) { + const root = mkdtempSync(join(tmpdir(), 'claude-accounts-')) + roots.push(root) + const home = (id: string) => join(root, id, 'home') + // Stands in for Claude finishing `claude auth login` in a folder. + const signIn = (id: string, email: string) => { + mkdirSync(home(id), { recursive: true }) + writeFileSync( + join(home(id), '.claude.json'), + JSON.stringify({ oauthAccount: { emailAddress: email } }) + ) + } + let settings: GlobalSettings = { + ...getDefaultSettings('/tmp'), + claudeManagedAccounts: accounts, + activeClaudeManagedAccountId: 'a', + activeClaudeManagedAccountIdsByRuntime: { host: 'a', wsl: {} } + } + const runtimeAuth = { + router: { + accountHome: home, + userConfigDir: () => join(root, 'personal'), + copiedLoginIntoSystemDefault: () => existsSync(join(root, 'claude-runtime-auth')) + }, + syncForCurrentSelection: vi.fn(async (_target?: ClaudeAccountSelectionTarget) => {}), + publishAll: vi.fn(async () => {}), + prepareAccountFolder: vi.fn(async (id: string) => { + mkdirSync(home(id), { recursive: true }) + return { configDir: home(id), readPath: home(id) } + }), + removeAccountFolder: vi.fn(async (id: string) => rmSync(join(root, id), { recursive: true })), + getRuntimeConfigDir: () => '/unused' + } + // Stands in for the hidden `claude auth login`; each test decides how it ends. + const runLogin = vi.fn< + ( + args: string[], + config: ClaudeCommandConfig, + timeoutMs: number, + options?: ClaudeCommandOptions + ) => Promise + >(async () => '') + const service = new ClaudeAccountService( + { + getSettings: () => settings, + updateSettings: (patch) => { + settings = { ...settings, ...patch } + } + }, + { + evictInactiveClaudeCache: vi.fn(), + refreshForClaudeAccountChange: vi.fn().mockResolvedValue(undefined) + }, + runtimeAuth, + runLogin + ) + return { root, home, service, runtimeAuth, runLogin, signIn, settings: () => settings } +} + +// The id the last sign-in prepared a folder for. +function newId(f: ReturnType): string { + return f.runtimeAuth.prepareAccountFolder.mock.calls.at(-1)![0] +} + +describe('ClaudeAccountService', () => { + it("labels each row with its folder's login and asks a folder without one to sign in", () => { + const f = fixture([ + account('a'), + account('b'), + account('old-wsl', { + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/u/.local/share/orca/claude-accounts/old-wsl/auth' + }), + account('new-wsl', { + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/u/.local/share/orca/claude-profiles/new-wsl/home' + }) + ]) + f.signIn('a', 'now-a@example.test') + const byId = new Map(f.service.listAccounts().accounts.map((row) => [row.id, row])) + expect(byId.get('a')).toMatchObject({ email: 'now-a@example.test' }) + expect(byId.get('a')?.needsSignIn).toBeUndefined() + expect(byId.get('b')).toMatchObject({ email: 'b@example.test', needsSignIn: true }) + expect(byId.get('old-wsl')).toMatchObject({ needsSignIn: true }) + expect(byId.get('new-wsl')?.needsSignIn).toBeUndefined() + }) + + it("reports System default's login from the user's own folder", () => { + const f = fixture() + mkdirSync(join(f.root, 'personal')) + writeFileSync( + join(f.root, 'personal', '.claude.json'), + JSON.stringify({ oauthAccount: { emailAddress: 'me@example.test' } }) + ) + expect(f.service.listAccounts()).toMatchObject({ + systemDefaultEmail: 'me@example.test' + }) + expect(f.service.listAccounts()).not.toHaveProperty('userClaudeConfigDir') + expect(f.service.listAccounts().systemDefaultMayBeCopied).toBeUndefined() + mkdirSync(join(f.root, 'claude-runtime-auth')) + expect(f.service.listAccounts().systemDefaultMayBeCopied).toBe(true) + }) + + it('saves an account only once its folder holds a login', async () => { + const f = fixture() + const begun = await f.service.beginSignIn({ runtime: 'host' }) + expect(begun).toMatchObject({ + runtime: 'host', + configDir: join(f.root, begun.accountId, 'home') + }) + await expect(f.service.finishSignIn(begun)).rejects.toThrow(CLAUDE_SIGN_IN_NOT_FINISHED_MESSAGE) + expect(f.settings().claudeManagedAccounts).toHaveLength(2) + + f.signIn(begun.accountId, 'new@example.test') + await f.service.finishSignIn(begun) + expect(f.settings().claudeManagedAccounts.at(-1)).toMatchObject({ + id: begun.accountId, + email: 'new@example.test', + managedAuthPath: begun.configDir + }) + }) + + it('refuses a second account for the same login and deletes its folder', async () => { + const f = fixture() + const begun = await f.service.beginSignIn({ runtime: 'host' }) + f.signIn(begun.accountId, 'B@example.test') + await expect(f.service.finishSignIn(begun)).rejects.toThrow('already added') + expect(f.runtimeAuth.removeAccountFolder).toHaveBeenCalledWith(begun.accountId, { + runtime: 'host' + }) + expect(f.settings().claudeManagedAccounts).toHaveLength(2) + }) + + it('relabels a saved account signed in again under another login', async () => { + const f = fixture() + const begun = await f.service.beginSignIn({ accountId: 'b' }) + f.signIn('b', 'other@example.test') + await f.service.finishSignIn(begun) + expect(f.settings().claudeManagedAccounts.find((entry) => entry.id === 'b')).toMatchObject({ + email: 'other@example.test', + createdAt: 1 + }) + }) + + it('signs in with a hidden login straight into the new account folder', async () => { + const f = fixture() + f.runLogin.mockImplementationOnce(async (_args, config) => { + f.signIn(newId(f), 'new@example.test') + expect(config).toEqual({ windowsPath: f.home(newId(f)), linuxPath: null, wslDistro: null }) + return '' + }) + await f.service.addAccount({ runtime: 'host' }) + expect(f.runLogin).toHaveBeenCalledWith( + ['auth', 'login', '--claudeai'], + expect.anything(), + expect.any(Number), + expect.objectContaining({ keepStdinOpen: true }) + ) + expect(f.settings().claudeManagedAccounts.at(-1)).toMatchObject({ + id: newId(f), + email: 'new@example.test', + managedAuthPath: f.home(newId(f)) + }) + expect(f.runtimeAuth.removeAccountFolder).not.toHaveBeenCalled() + }) + + it('runs a WSL login against the guest folder', async () => { + const f = fixture() + f.runtimeAuth.prepareAccountFolder.mockImplementation(async (id: string) => { + mkdirSync(f.home(id), { recursive: true }) + return { configDir: `/home/u/claude-profiles/${id}/home`, readPath: f.home(id) } + }) + f.runLogin.mockImplementationOnce(async (_args, config) => { + f.signIn(newId(f), 'wsl@example.test') + expect(config).toEqual({ + windowsPath: f.home(newId(f)), + linuxPath: `/home/u/claude-profiles/${newId(f)}/home`, + wslDistro: 'Ubuntu' + }) + return '' + }) + await f.service.addAccount({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + expect(f.settings().claudeManagedAccounts.at(-1)).toMatchObject({ + email: 'wsl@example.test', + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: `/home/u/claude-profiles/${newId(f)}/home` + }) + }) + + it('leaves no row or folder when the hidden login is cancelled', async () => { + const f = fixture() + f.runLogin.mockImplementationOnce( + (_args, _config, _timeoutMs, options) => + new Promise((_resolve, reject) => + options?.signal?.addEventListener('abort', () => + reject(new Error('Claude sign-in was cancelled.')) + ) + ) + ) + const adding = f.service.addAccount({ runtime: 'host' }) + await vi.waitFor(() => expect(f.runLogin).toHaveBeenCalled()) + expect(f.service.cancelPendingLogin()).toBe(true) + await expect(adding).rejects.toThrow('Claude sign-in was cancelled.') + expect(f.service.cancelPendingLogin()).toBe(false) + expect(f.settings().claudeManagedAccounts).toHaveLength(2) + expect(f.runtimeAuth.removeAccountFolder).toHaveBeenCalledWith(newId(f), { runtime: 'host' }) + expect(existsSync(join(f.root, newId(f)))).toBe(false) + }) + + it('leaves no row or folder when the hidden login times out or finds no login', async () => { + const f = fixture() + f.runLogin.mockRejectedValueOnce(new Error('Claude sign-in took too long to finish.')) + await expect(f.service.addAccount({ runtime: 'host' })).rejects.toThrow('took too long') + await expect(f.service.addAccount({ runtime: 'host' })).rejects.toThrow( + 'could not resolve the account email' + ) + expect(f.settings().claudeManagedAccounts).toHaveLength(2) + expect(f.runtimeAuth.removeAccountFolder).toHaveBeenCalledTimes(2) + }) + + it('refuses a hidden login to an already added account and deletes its folder', async () => { + const f = fixture() + f.runLogin.mockImplementationOnce(async () => { + f.signIn(newId(f), 'B@example.test') + return '' + }) + await expect(f.service.addAccount({ runtime: 'host' })).rejects.toThrow( + 'This Claude account is already added.' + ) + expect(f.settings().claudeManagedAccounts).toHaveLength(2) + expect(existsSync(join(f.root, newId(f)))).toBe(false) + }) + + it('signs a saved account in again inside its own folder and keeps it on failure', async () => { + const f = fixture() + f.runLogin.mockImplementationOnce(async (_args, config) => { + expect(config.windowsPath).toBe(f.home('b')) + f.signIn('b', 'b@example.test') + return '' + }) + await f.service.reauthenticateAccount('b') + expect(f.runtimeAuth.prepareAccountFolder).toHaveBeenCalledWith('b', { runtime: 'host' }) + expect(f.settings().claudeManagedAccounts.find((entry) => entry.id === 'b')).toMatchObject({ + email: 'b@example.test', + createdAt: 1 + }) + f.runLogin.mockRejectedValueOnce(new Error('Claude sign-in was cancelled.')) + await expect(f.service.reauthenticateAccount('b')).rejects.toThrow('cancelled') + expect(f.runtimeAuth.removeAccountFolder).not.toHaveBeenCalled() + expect(f.settings().claudeManagedAccounts.map((entry) => entry.id)).toEqual(['a', 'b']) + }) + + it('clears the selection, republishes, then deletes the folder on remove', async () => { + const f = fixture() + f.signIn('a', 'a@example.test') + await f.service.removeAccount('a') + expect(f.settings().claudeManagedAccounts.map((entry) => entry.id)).toEqual(['b']) + expect(f.settings().activeClaudeManagedAccountIdsByRuntime?.host).toBeNull() + expect(f.runtimeAuth.syncForCurrentSelection).toHaveBeenCalledWith({ runtime: 'host' }) + expect(f.runtimeAuth.removeAccountFolder).toHaveBeenCalledWith('a', { runtime: 'host' }) + }) + + it("deletes a cancelled sign-in's folder but never a saved account's", async () => { + const f = fixture() + const begun = await f.service.beginSignIn({ runtime: 'host' }) + await f.service.cancelSignIn({ accountId: begun.accountId, runtime: 'host' }) + expect(f.runtimeAuth.removeAccountFolder).toHaveBeenCalledWith(begun.accountId, { + runtime: 'host' + }) + f.runtimeAuth.removeAccountFolder.mockClear() + await f.service.cancelSignIn({ accountId: 'a', runtime: 'host' }) + expect(f.runtimeAuth.removeAccountFolder).not.toHaveBeenCalled() + }) + + it('keeps the row when its folder cannot be deleted, so the user can retry', async () => { + const f = fixture() + f.runtimeAuth.removeAccountFolder.mockRejectedValueOnce( + Object.assign(new Error('resource busy'), { code: 'EBUSY' }) + ) + await expect(f.service.removeAccount('a')).rejects.toThrow(CLAUDE_ACCOUNT_FOLDER_IN_USE_MESSAGE) + expect(f.settings().claudeManagedAccounts.map((entry) => entry.id)).toEqual(['a', 'b']) + expect(f.settings().activeClaudeManagedAccountIdsByRuntime?.host).toBeNull() + }) + + it('puts the previous account back when publishing a new selection fails', async () => { + const f = fixture() + f.signIn('b', 'b@example.test') + f.runtimeAuth.syncForCurrentSelection.mockRejectedValueOnce(new Error('publish failed')) + await expect(f.service.selectAccount('b')).rejects.toThrow('publish failed') + expect(f.settings().activeClaudeManagedAccountIdsByRuntime?.host).toBe('a') + expect(f.runtimeAuth.publishAll).toHaveBeenCalled() + }) + + it('refuses to select an account whose folder holds no login', async () => { + const f = fixture() + await expect(f.service.selectAccount('b')).rejects.toThrow(CLAUDE_ACCOUNT_NEEDS_SIGN_IN_MESSAGE) + expect(f.settings().activeClaudeManagedAccountIdsByRuntime?.host).toBe('a') + f.signIn('b', 'b@example.test') + await f.service.selectAccount('b') + expect(f.settings().activeClaudeManagedAccountIdsByRuntime?.host).toBe('b') + }) + + it('refuses to select a WSL account for the host', async () => { + const f = fixture([ + account('a'), + account('w', { managedAuthRuntime: 'wsl', wslDistro: 'Ubuntu' }) + ]) + await expect(f.service.selectAccountForTarget('w', { runtime: 'host' })).rejects.toThrow( + 'That Claude account belongs to a different runtime.' + ) + }) +}) diff --git a/src/main/claude-accounts/claude-auth-capture.ts b/src/main/claude-accounts/claude-auth-capture.ts deleted file mode 100644 index 2acaa4408683..000000000000 --- a/src/main/claude-accounts/claude-auth-capture.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { createHash } from 'node:crypto' -import { existsSync, readFileSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { readActiveClaudeKeychainCredentialsStrict } from './keychain' - -export type ClaudeIdentity = { - email: string | null - organizationUuid: string | null - organizationName: string | null -} - -export type CapturedClaudeAuth = { - credentialsJson: string - oauthAccount: unknown - identity: ClaudeIdentity -} - -export type ClaudeCaptureCommand = ( - args: string[], - configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, - timeoutMs: number, - options?: { allowFailure?: boolean } -) => Promise - -const STATUS_TIMEOUT_MS = 20_000 - -export async function captureClaudeAuthFromExistingConfigDir( - configDir: string, - previousLegacyCredentialsSha256: string | null | undefined, - runCommand: ClaudeCaptureCommand -): Promise { - const trimmed = configDir.trim() - if (!trimmed) { - throw new Error('A Claude config directory path is required.') - } - const resolvedDir = resolve(trimmed) - if (process.platform !== 'darwin' && !existsSync(join(resolvedDir, '.credentials.json'))) { - throw new Error( - `No Claude credentials found in ${resolvedDir}. Run \`claude login\` into this directory first.` - ) - } - - let status = '' - try { - status = await runCommand( - ['auth', 'status', '--json'], - { windowsPath: resolvedDir, linuxPath: null, wslDistro: null }, - STATUS_TIMEOUT_MS, - { allowFailure: true } - ) - } catch (error) { - console.warn('[claude-accounts] Could not read `claude auth status`:', error) - } - const currentLegacyKeychain = await readActiveClaudeKeychainCredentialsStrict() - return captureClaudeAuthFromConfigDir( - resolvedDir, - status, - currentLegacyKeychain, - previousLegacyCredentialsSha256 - ) -} - -export async function captureClaudeAuthFromConfigDir( - configDir: string, - statusOutput: string, - previousLegacyKeychain: string | null, - previousLegacyCredentialsSha256?: string | null, - readCredentials = readCapturedClaudeCredentials -): Promise { - const credentialsJson = await readCredentials( - configDir, - previousLegacyKeychain, - previousLegacyCredentialsSha256 - ) - if (!credentialsJson) { - throw new Error('Claude login completed, but no OAuth credentials were captured.') - } - const oauthAccount = readClaudeOauthAccount(configDir) - return { - credentialsJson, - oauthAccount, - identity: resolveClaudeIdentity(statusOutput, oauthAccount, credentialsJson) - } -} - -export async function readCapturedClaudeCredentials( - configDir: string, - previousLegacyKeychain: string | null, - previousLegacyCredentialsSha256?: string | null -): Promise { - if (process.platform === 'darwin') { - const scopedCredentialsJson = await readActiveClaudeKeychainCredentialsStrict(configDir) - if (scopedCredentialsJson) { - return scopedCredentialsJson - } - const legacyCredentialsJson = await readActiveClaudeKeychainCredentialsStrict() - const legacyChanged = - previousLegacyCredentialsSha256 === undefined - ? legacyCredentialsJson !== previousLegacyKeychain - : legacyCredentialsJson !== null && - createHash('sha256').update(legacyCredentialsJson).digest('hex') !== - previousLegacyCredentialsSha256 - if (legacyCredentialsJson && legacyChanged) { - return legacyCredentialsJson - } - } - const credentialsPath = join(configDir, '.credentials.json') - return existsSync(credentialsPath) ? readFileSync(credentialsPath, 'utf-8') : null -} - -function readClaudeOauthAccount(configDir: string): unknown { - for (const configPath of [join(configDir, '.claude.json'), join(configDir, '.config.json')]) { - if (!existsSync(configPath)) { - continue - } - try { - const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as Record - if (parsed.oauthAccount) { - return parsed.oauthAccount - } - } catch { - continue - } - } - return null -} - -function resolveClaudeIdentity( - statusOutput: string, - oauthAccount: unknown, - credentialsJson: string -): ClaudeIdentity { - const status = parseJsonObject(statusOutput) - const oauth = asRecord(oauthAccount) - const credentialOauth = asRecord(parseJsonObject(credentialsJson)?.claudeAiOauth) - return { - email: normalizeField( - readString(status, 'email') ?? - readString(oauth, 'emailAddress') ?? - readString(oauth, 'email') ?? - readString(credentialOauth, 'email') - ), - organizationUuid: normalizeField( - readString(status, 'organizationUuid') ?? - readString(status, 'organizationId') ?? - readString(oauth, 'organizationUuid') ?? - readString(oauth, 'organizationId') - ), - organizationName: normalizeField( - readString(status, 'organizationName') ?? readString(oauth, 'organizationName') - ) - } -} - -function parseJsonObject(value: string): Record | null { - try { - return asRecord(JSON.parse(value) as unknown) - } catch { - return null - } -} - -function asRecord(value: unknown): Record | null { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : null -} - -function readString(value: Record | null, key: string): string | null { - return typeof value?.[key] === 'string' ? value[key] : null -} - -function normalizeField(value: string | null | undefined): string | null { - const trimmed = value?.trim() - return trimmed ? trimmed : null -} diff --git a/src/main/claude-accounts/claude-command-process.test.ts b/src/main/claude-accounts/claude-command-process.test.ts new file mode 100644 index 000000000000..514a8ed4d487 --- /dev/null +++ b/src/main/claude-accounts/claude-command-process.test.ts @@ -0,0 +1,75 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' + +type SpawnSpec = { program: string; args?: readonly string[]; env?: NodeJS.ProcessEnv } +const spawned = vi.hoisted(() => { + const specs: SpawnSpec[] = [] + return { specs } +}) + +vi.mock('../../shared/child-process/run-process', () => ({ + spawnProcess: (spec: SpawnSpec) => { + spawned.specs.push(spec) + const child = Object.assign(new EventEmitter(), { + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + pid: undefined, + kill: vi.fn() + }) + queueMicrotask(() => child.emit('close', 0)) + return child + } +})) +vi.mock('../codex-cli/command', () => ({ resolveClaudeCommand: () => 'claude' })) +vi.mock('../wsl/wsl-executable-path', () => ({ + resolveWslExecutablePath: () => 'C:\\Windows\\System32\\wsl.exe' +})) + +import { buildWslLoginShellCommand } from '../../shared/wsl-login-shell-command' +import { runClaudeCommandProcess } from './claude-command-process' + +const originalPlatform = process.platform +afterEach(() => { + spawned.specs.length = 0 + Object.defineProperty(process, 'platform', { value: originalPlatform }) +}) + +describe('runClaudeCommandProcess', () => { + it("logs in on the host with CLAUDE_CONFIG_DIR set to the account's folder", async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }) + await runClaudeCommandProcess( + ['auth', 'login', '--claudeai'], + { windowsPath: '/data/claude-profiles/a/home', linuxPath: null, wslDistro: null }, + 1000 + ) + expect(spawned.specs[0]).toMatchObject({ + program: 'claude', + args: ['auth', 'login', '--claudeai'] + }) + expect(spawned.specs[0].env?.CLAUDE_CONFIG_DIR).toBe('/data/claude-profiles/a/home') + }) + + it("logs in through the distro's login shell with --exec for a WSL account", async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + await runClaudeCommandProcess( + ['auth', 'login', '--claudeai'], + { + windowsPath: '\\\\wsl.localhost\\Ubuntu\\home\\u\\a', + linuxPath: "/home/u/it's/a", + wslDistro: 'Ubuntu' + }, + 1000 + ) + const spec = spawned.specs[0] + expect(spec.program).toBe('C:\\Windows\\System32\\wsl.exe') + expect(spec.args?.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', '/bin/sh', '-c']) + expect(spec.args?.[5]).toBe( + buildWslLoginShellCommand( + `exec env CLAUDE_CONFIG_DIR='/home/u/it'\\''s/a' claude 'auth' 'login' '--claudeai'` + ) + ) + expect(spec.env?.CLAUDE_CONFIG_DIR).toBe(process.env.CLAUDE_CONFIG_DIR) + }) +}) diff --git a/src/main/claude-accounts/claude-command-process.ts b/src/main/claude-accounts/claude-command-process.ts index 78fe109f79f8..98b867d02a78 100644 --- a/src/main/claude-accounts/claude-command-process.ts +++ b/src/main/claude-accounts/claude-command-process.ts @@ -4,7 +4,13 @@ import { buildWindowsHostInteractiveLoginSpawn, type WindowsHostInteractiveLoginSpawn } from '../../shared/windows-interactive-login-spawn' +import { + buildWslExecArgs, + buildWslLoginShellCommand, + quotePosixShell +} from '../../shared/wsl-login-shell-command' import { resolveClaudeCommand } from '../codex-cli/command' +import { resolveWslExecutablePath } from '../wsl/wsl-executable-path' import { buildWindowsCommandInvocation } from './windows-command-invocation' import { terminateClaudeProcess } from './claude-login-process-termination' @@ -19,15 +25,10 @@ export type ClaudeCommandConfig = { } export type ClaudeCommandOptions = { - allowFailure?: boolean signal?: AbortSignal keepStdinOpen?: boolean } -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'` -} - export function runClaudeCommandProcess( args: string[], configDir: ClaudeCommandConfig, @@ -149,7 +150,7 @@ export function runClaudeCommandProcess( return } settle(() => { - if (code === 0 || options?.allowFailure) { + if (code === 0) { resolvePromise(output) return } @@ -188,12 +189,9 @@ type ClaudeSpawnConfig = { windowsVerbatimArguments: boolean } +// Why only this variable: account terminals set just CLAUDE_CONFIG_DIR, so Claude keys the login the same way. function claudeConfigDirEnv(configDir: string): NodeJS.ProcessEnv { - return { - CLAUDE_CONFIG_DIR: configDir, - // Why: Claude Code 2.1.220+ hashes this for the Keychain service name. - CLAUDE_SECURESTORAGE_CONFIG_DIR: configDir - } + return { CLAUDE_CONFIG_DIR: configDir } } function resolveClaudeInvocation( @@ -214,15 +212,15 @@ function resolveClaudeInvocation( } : configDir.linuxPath && configDir.wslDistro ? { - command: 'wsl.exe', - args: [ - '-d', - configDir.wslDistro, - '--exec', - 'bash', - '-lc', - `export CLAUDE_CONFIG_DIR=${shellQuote(configDir.linuxPath)}; export CLAUDE_SECURESTORAGE_CONFIG_DIR=${shellQuote(configDir.linuxPath)}; exec claude ${args.map(shellQuote).join(' ')}` - ], + // Same login as the CLI's WSL sign-in: the distro's login shell finds an nvm/mise claude. + command: resolveWslExecutablePath(), + args: buildWslExecArgs(configDir.wslDistro, [ + '/bin/sh', + '-c', + buildWslLoginShellCommand( + `exec env CLAUDE_CONFIG_DIR=${quotePosixShell(configDir.linuxPath)} claude ${args.map(quotePosixShell).join(' ')}` + ) + ]), env: process.env, windowsVerbatimArguments: false } diff --git a/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts b/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts new file mode 100644 index 000000000000..4156b58ced4b --- /dev/null +++ b/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts @@ -0,0 +1,281 @@ +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { join, relative } from 'node:path' +import { expect, it } from 'vitest' +import { stripComments } from '../../shared/source-scan/source-tree-scan' + +const src = join(__dirname, '../..') + +/** + * Every place a Claude login could be written from: accounts, usage, launch, hooks, the CLI, the + * relay and the generated shell scripts. Plan §9 lists these; credential checks run on all of them. + */ +const credentialRoots: readonly (string | { root: string; only: RegExp })[] = [ + 'main/claude-accounts', + // Other providers there refresh their own logins; only Claude's usage is in scope. + { root: 'main/rate-limits', only: /[\\/]claude[^\\/]*\.ts$/ }, + 'main/claude', + 'main/native-chat', + 'main/ipc/pty', + 'main/daemon', + 'main/providers', + 'main/macos-keychain', + 'cli', + 'relay', + 'shared' +] + +/** + * Only noncredential profile surfaces may persist bytes, and each only with the primitives it + * uses today; a new primitive in one of them is a review, not a silent pass. + */ +const fsAllowances: Record = { + 'claude-accounts/claude-profile-history.ts': [ + 'mkdirSync', + 'renameSync', + 'rmdirSync', + 'symlinkSync' + ], + 'claude-accounts/claude-profile-paths.ts': ['mkdirSync', 'writeFileAtomically'], + // The which-account file. + 'claude-accounts/claude-profile-router.ts': ['mkdirSync', 'rmSync', 'writeFileAtomically'], + // Removing an account deletes its folder, links first. + 'claude-accounts/claude-account-folder.ts': ['rmSync', 'rmdirSync', 'unlinkSync'], + 'claude-accounts/claude-profile-prompt-history.ts': [ + 'appendFileSync', + 'linkSync', + 'openSync', + 'renameSync', + 'rmSync', + 'symlinkSync', + 'unlinkSync', + 'writeFileSync', + 'writeFileAtomically' + ], + 'claude-accounts/claude-profile-provisioning.ts': ['mkdirSync', 'rmSync', 'writeFileAtomically'], + 'claude-accounts/claude-profile-sharing.ts': [ + 'mkdirSync', + 'rmdirSync', + 'symlinkSync', + 'unlinkSync', + 'writeFileAtomically' + ], + // The guest helper answers the host on stdout. + 'claude-accounts/claude-profile-wsl-entry.ts': ['write'] +} +const mutation = + /^(?:write(?:File|Json|Sync|v|.*Credentials|.*Keychain)?$|write(?:File|Json)|appendFile|copyFile|cp(?:Sync)?$|rename(?:Sync)?$|unlink(?:Sync)?$|rm(?:dir)?(?:Sync)?$|truncate(?:Sync)?$|createWriteStream|symlink(?:Sync)?$|link(?:Sync)?$|chmod(?:Sync)?$|mkdir(?:Sync)?$|mkdtemp(?:Sync)?$|open(?:Sync)?$|delete.*Keychain|refreshClaudeOauth)/ + +function persistencePrimitives(text: string): string[] { + const found = new Set() + // Import-side names survive aliases, including destructuring and wrapped helper calls. + for (const match of text.matchAll(/\b([a-zA-Z_$][\w$]*)\s*(?=\(|as\s|[:,}])/g)) { + if (mutation.test(match[1])) { + found.add(match[1]) + } + } + return [...found] +} + +const WRITE_CALL = + /\b(?:write\w*|append\w*|copy\w*|cp(?:Sync)?|rename\w*|link\w*|symlink\w*|createWriteStream)\s*\(/g +// The one runner that takes `security` argv from its callers; each caller must spell the verb. +const SECURITY_RUNNER = 'main/macos-keychain/generic-password.ts' +// Removing an account deletes the Keychain items Claude made for its folder; nothing adds one. +const KEYCHAIN_DELETE_OWNER = 'main/claude-accounts/claude-account-folder.ts' + +/** Names bound, directly or through another such name, to the credentials file's path. */ +function credentialFileNames(text: string): Set { + const declarations: { name: string; init: string }[] = [] + const lines = text.split('\n') + lines.forEach((line, index) => { + const declared = /^(\s*)(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)/.exec(line) + if (!declared) { + return + } + const indent = declared[1].length + let end = index + 1 + while (end < lines.length && /^\s*$|^\s+/.test(lines[end])) { + const lead = /^\s*/.exec(lines[end])?.[0].length ?? 0 + if (lines[end].trim() && lead <= indent && !/^\s*[)\]}]/.test(lines[end])) { + break + } + end += 1 + } + declarations.push({ name: declared[2], init: lines.slice(index, end).join('\n') }) + }) + const names = new Set() + for (let changed = true; changed;) { + changed = false + for (const { name, init } of declarations) { + const tainted = + init.includes('.credentials.json') || + [...names].some((other) => new RegExp(`\\b${other}\\b`).test(init.replace(name, ''))) + if (tainted && !names.has(name)) { + names.add(name) + changed = true + } + } + } + return names +} + +/** Credential writes refused everywhere scanned, whatever form the call takes. */ +function credentialMutations(text: string, file = ''): string[] { + const found: string[] = [] + // A `security` launch whose verb is not spelled out could be a write, so only the runner may + // forward argv, and its callers must spell the verb. + if ( + (file !== SECURITY_RUNNER && + /\b(?:exec(?:File)?(?:Sync|Async)?|spawn(?:Sync)?|runProcess|spawnProcess)\s*\(\s*['"`]security['"`]\s*,\s*(?!\[\s*['"])/.test( + text + )) || + /\bprogram\s*:\s*['"`]security['"`]\s*,\s*args\s*:\s*(?!\[\s*['"])/.test(text) || + /(? new RegExp(`\\b${name}\\b`).test(args))) { + found.push('credentials file write through a name') + break + } + } + // Argv (`['add-generic-password', …]`) and shell (`security add-generic-password`) forms. + if ( + /add-generic-password/.test(text) || + (file !== KEYCHAIN_DELETE_OWNER && /delete-generic-password/.test(text)) + ) { + found.push('keychain write') + } + if (/\b(?:write|delete|store|save)\w*Keychain\w*\b/.test(text)) { + found.push('keychain helper') + } + if (/oauth\/token|grant_type|\brefresh_token\b/.test(text)) { + found.push('token refresh') + } + // A statement that names the credentials file together with anything that writes, copies, + // moves or opens it for writing (TS calls and generated shell alike). + for (const statement of text.split(/;|\n\s*\n/)) { + if ( + statement.includes('.credentials.json') && + /\b(?:write\w*|append\w*|copy\w*|cp(?:Sync)?|rename\w*|link\w*|symlink\w*|createWriteStream|mv|tee|install)\b|\bopen(?:Sync)?\s*\([\s\S]{0,200}?,\s*['"`](?:w|a|r\+|wx|ax)|>\s*["'$]/.test( + statement + ) + ) { + found.push('credentials file write') + } + } + return found +} + +function files(dir: string): string[] { + if (!existsSync(dir)) { + return [] + } + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const file = join(dir, entry.name) + return entry.isDirectory() + ? entry.name === 'node_modules' || entry.name === '__fixtures__' + ? [] + : files(file) + : /\.(?:ts|tsx|mts|cts|js|mjs|cjs|sh)$/.test(entry.name) && + !/\.test\.|\.spec\.|test-harness|harness\.ts|test-fixtures|test-utils/.test(entry.name) + ? [file] + : [] + }) +} + +it('keeps every Claude credential writer removed, in every form and on every launch path', () => { + const violations = credentialRoots + .flatMap((entry) => { + const { root, only } = typeof entry === 'string' ? { root: entry, only: null } : entry + const dir = join(src, root) + const found = statSync(dir, { throwIfNoEntry: false })?.isDirectory() ? files(dir) : [] + return only ? found.filter((file) => only.test(file)) : found + }) + .flatMap((file) => + credentialMutations( + stripComments(readFileSync(file, 'utf8')), + relative(src, file).replaceAll('\\', '/') + ).map((kind) => `${relative(src, file)}: ${kind}`) + ) + expect(violations).toEqual([]) +}) + +it('confines filesystem persistence in accounts and usage to named profile surfaces', () => { + const main = join(src, 'main') + const candidates = [ + ...files(__dirname), + ...files(join(main, 'rate-limits')).filter((file) => /\/claude[^/]*\.ts$/.test(file)) + ] + const violations = candidates.flatMap((file) => { + const name = relative(main, file).replaceAll('\\', '/') + const allowed = new Set(fsAllowances[name] ?? []) + return persistencePrimitives(stripComments(readFileSync(file, 'utf8'))) + .filter((primitive) => !allowed.has(primitive)) + .map((primitive) => `${name}: ${primitive}`) + }) + expect(violations).toEqual([]) + const cli = readFileSync(join(main, '../cli/handlers/account.ts'), 'utf8') + expect(cli).not.toMatch(/Keychain|orca-account-add-claude|addClaudeFromConfigDir/) + const auth = readFileSync(join(__dirname, 'runtime-auth-service.ts'), 'utf8') + expect(auth).not.toMatch(/extends ClaudeRuntimeAuth|snapshot|credentialsJson|readBack/i) +}) + +it.each([ + "import { writeFile as save } from 'node:fs/promises'; save(path, token)", + "import * as fs from 'node:fs'; const save = () => fs.writeFileSync(path, token)", + "import { writeFileAtomically as persist } from '../codex-accounts/fs-utils'; persist(path, token)", + 'fs.copyFile(source, destination)', + 'fs.symlink(credentialPath, destination)', + "const fd = openSync(join(dir, 'x'), 'w'); writeSync(fd, token)", + 'await fs.promises.cp(a, b)', + 'fs.cpSync(a, b, { recursive: true })' +])('mutation control: the filesystem census rejects %s', (source) => { + expect(persistencePrimitives(source).length).toBeGreaterThan(0) +}) + +it.each([ + 'security add-generic-password -s Claude -w token', + "await execFileAsync('security', ['add-generic-password', '-U', '-s', service, '-w', token])", + "execFileSync('security', ['delete-generic-password', '-s', 'Claude Code-credentials'])", + "import { writeKeychainPassword as persist } from '../macos-keychain/generic-password'; persist(service, user, token)", + "const fd = openSync(join(dir, '.credentials.json'), 'w'); writeSync(fd, token)", + "await fs.promises.cp(join(a, '.credentials.json'), join(b, '.credentials.json'))", + "copyFileSync(join(from, '.credentials.json'), join(home, '.credentials.json'))", + "const script = 'cp source .credentials.json'", + 'const script = `printf %s "$token" > "$HOME/.claude/.credentials.json"`', + "fetch('https://api.anthropic.com/v1/oauth/token')", + "body: JSON.stringify({ grant_type: 'refresh_token' })", + "execFile('security', [['add', 'generic', 'password'].join('-'), '-s', service])", + "spawn('security', verbArgs)", + 'execSecurityCommand(args)', + "const CREDENTIALS_FILE = '.credentials.json'\n\ncopyFileSync(join(from, CREDENTIALS_FILE), join(to, CREDENTIALS_FILE))", + "const name = '.credentials.json'\nfunction save(dir) {\n const target = join(dir, name)\n\n writeFileSync(target, token)\n}" +])('mutation control: the credential check rejects %s', (source) => { + expect(credentialMutations(source).length).toBeGreaterThan(0) +}) + +it.each([ + "const file = await readFile(join(home, '.credentials.json'), 'utf8')", + "readKeychainPassword('Claude Code-credentials', user)", + "execSecurityCommand(['find-generic-password', '-s', service, '-w'])", + "const file = join(home, '.credentials.json')\nconst raw = await readFile(file, 'utf8')" +])('the credential check leaves a read alone: %s', (source) => { + expect(credentialMutations(source)).toEqual([]) +}) + +it('lets only account removal delete a Keychain item, and nothing add one', () => { + const remove = "execSecurityCommand(['delete-generic-password', '-s', service])" + expect(credentialMutations(remove, KEYCHAIN_DELETE_OWNER)).toEqual([]) + expect(credentialMutations(remove, 'main/claude-accounts/service.ts')).not.toEqual([]) + expect( + credentialMutations( + "execSecurityCommand(['add-generic-password', '-s', service])", + KEYCHAIN_DELETE_OWNER + ) + ).not.toEqual([]) +}) diff --git a/src/main/claude-accounts/claude-login-completion.oracle.test.ts b/src/main/claude-accounts/claude-login-completion.oracle.test.ts deleted file mode 100644 index 1f2fd963183f..000000000000 --- a/src/main/claude-accounts/claude-login-completion.oracle.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { EventEmitter } from 'node:events' -import { PassThrough } from 'node:stream' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const processMocks = vi.hoisted(() => ({ - spawn: vi.fn() -})) - -vi.mock('node:child_process', () => ({ - execFileSync: vi.fn(), - spawn: processMocks.spawn -})) - -vi.mock('electron', () => ({ - app: { getPath: () => 'C:\\orca-review-11407' } -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: () => 'claude.exe' -})) - -vi.mock('./keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), - deleteManagedClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(), - writeManagedClaudeKeychainCredentials: vi.fn() -})) - -type CommandConfig = { - windowsPath: string - linuxPath: string | null - wslDistro: string | null -} - -type FakeChild = EventEmitter & { - stdin: PassThrough - stdout: PassThrough - stderr: PassThrough - kill: ReturnType - pid: number -} - -type CommandRunner = { - runClaudeCommand( - args: string[], - config: CommandConfig, - timeoutMs: number, - options?: { keepStdinOpen?: boolean; signal?: AbortSignal } - ): Promise -} - -const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - -function setPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { configurable: true, value: platform }) -} - -function createChild(): FakeChild { - const child = new EventEmitter() as FakeChild - child.stdin = new PassThrough() - child.stdout = new PassThrough() - child.stderr = new PassThrough() - child.kill = vi.fn() - child.pid = 11407 - return child -} - -async function createRunner(): Promise { - const { ClaudeAccountService } = await import('./service') - return new ClaudeAccountService({} as never, {} as never, {} as never) as unknown as CommandRunner -} - -async function flushPromiseCallbacks(): Promise { - await Promise.resolve() - await Promise.resolve() -} - -describe('native Windows Claude login completion oracle', () => { - beforeEach(() => { - vi.useFakeTimers() - processMocks.spawn.mockReset() - }) - - afterEach(() => { - vi.useRealTimers() - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - }) - - it('settles exact native Windows auth login on exit exactly once and cleans resources', async () => { - setPlatform('win32') - const child = createChild() - const destroyStdin = vi.spyOn(child.stdin, 'destroy') - const destroyStdout = vi.spyOn(child.stdout, 'destroy') - const destroyStderr = vi.spyOn(child.stderr, 'destroy') - const abortController = new AbortController() - const removeAbortListener = vi.spyOn(abortController.signal, 'removeEventListener') - processMocks.spawn.mockReturnValue(child) - const runner = await createRunner() - let completions = 0 - const command = runner - .runClaudeCommand( - ['auth', 'login', '--claudeai'], - { windowsPath: 'C:\\isolated-auth', linuxPath: null, wslDistro: null }, - 1000, - { keepStdinOpen: true, signal: abortController.signal } - ) - .then(() => { - completions += 1 - }) - - child.emit('exit', 0) - await flushPromiseCallbacks() - - try { - expect(completions).toBe(1) - expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('exit')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - expect(child.stdout.listenerCount('data')).toBe(0) - expect(child.stderr.listenerCount('data')).toBe(0) - expect(destroyStdin).toHaveBeenCalledTimes(1) - expect(destroyStdout).toHaveBeenCalledTimes(1) - expect(destroyStderr).toHaveBeenCalledTimes(1) - expect(removeAbortListener).toHaveBeenCalledTimes(1) - - child.emit('close', 0) - await vi.advanceTimersByTimeAsync(1001) - - expect(completions).toBe(1) - expect(child.kill).not.toHaveBeenCalled() - expect(destroyStdin).toHaveBeenCalledTimes(1) - expect(destroyStdout).toHaveBeenCalledTimes(1) - expect(destroyStderr).toHaveBeenCalledTimes(1) - expect(removeAbortListener).toHaveBeenCalledTimes(1) - } finally { - child.emit('close', 0) - await command - } - }) - - it.each([ - { - name: 'non-Windows auth login', - platform: 'linux' as const, - args: ['auth', 'login', '--claudeai'], - config: { windowsPath: '/isolated-auth', linuxPath: null, wslDistro: null } - }, - { - name: 'unrelated native Windows command containing login', - platform: 'win32' as const, - args: ['config', 'login'], - config: { windowsPath: 'C:\\isolated-auth', linuxPath: null, wslDistro: null } - }, - { - name: 'WSL auth login', - platform: 'win32' as const, - args: ['auth', 'login', '--claudeai'], - config: { - windowsPath: 'C:\\isolated-auth', - linuxPath: '/home/orca/isolated-auth', - wslDistro: 'Ubuntu-Oracle' - } - } - ])('preserves close completion for $name', async ({ platform, args, config }) => { - setPlatform(platform) - const child = createChild() - processMocks.spawn.mockReturnValue(child) - const runner = await createRunner() - let completions = 0 - const command = runner.runClaudeCommand(args, config, 1000).then(() => { - completions += 1 - }) - - child.emit('exit', 0) - await flushPromiseCallbacks() - - expect(completions).toBe(0) - expect(child.listenerCount('close')).toBe(1) - - child.emit('close', 0) - await command - - expect(completions).toBe(1) - expect(child.listenerCount('close')).toBe(0) - expect(child.kill).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/claude-accounts/claude-login-session.ts b/src/main/claude-accounts/claude-login-session.ts index ce9a398bb865..47160b506b22 100644 --- a/src/main/claude-accounts/claude-login-session.ts +++ b/src/main/claude-accounts/claude-login-session.ts @@ -1,19 +1,6 @@ -import { mkdtempSync, realpathSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { toWindowsWslPath } from '../wsl' -import { runWslProcess } from '../wsl/wsl-runner' -import type { CapturedClaudeAuth } from './claude-auth-capture' import type { ClaudeCommandConfig, ClaudeCommandOptions } from './claude-command-process' -import type { ClaudeManagedAuthLocation } from './claude-managed-auth-storage' -import { - deleteActiveClaudeKeychainCredentialsStrict, - readActiveClaudeKeychainCredentials, - writeActiveClaudeKeychainCredentials -} from './keychain' const LOGIN_TIMEOUT_MS = 180_000 -const STATUS_TIMEOUT_MS = 20_000 type ClaudeLoginSessionDependencies = { runCommand: ( @@ -22,19 +9,14 @@ type ClaudeLoginSessionDependencies = { timeoutMs: number, options?: ClaudeCommandOptions ) => Promise - capture: ( - configDir: string, - statusOutput: string, - previousLegacyKeychain: string | null - ) => Promise setCancel: (cancel: (() => boolean) | null) => void } +/** Runs a hidden `claude auth login` that writes its login straight into the account's folder. */ export async function runClaudeLoginSession( - location: ClaudeManagedAuthLocation, + folder: ClaudeCommandConfig, dependencies: ClaudeLoginSessionDependencies -): Promise { - const tempConfig = await createTemporaryClaudeConfigDir(location) +): Promise { const controller = new AbortController() dependencies.setCancel(() => { if (controller.signal.aborted) { @@ -43,107 +25,12 @@ export async function runClaudeLoginSession( controller.abort() return true }) - const previousLegacyKeychain = await readActiveClaudeKeychainCredentials() - let captured: CapturedClaudeAuth | null = null - let captureError: unknown = null - let cleanupError: unknown = null try { - if (controller.signal.aborted) { - throw new Error('Claude sign-in was cancelled.') - } - await dependencies.runCommand(['auth', 'login', '--claudeai'], tempConfig, LOGIN_TIMEOUT_MS, { + await dependencies.runCommand(['auth', 'login', '--claudeai'], folder, LOGIN_TIMEOUT_MS, { signal: controller.signal, keepStdinOpen: true }) - dependencies.setCancel(null) - const status = await dependencies.runCommand( - ['auth', 'status', '--json'], - tempConfig, - STATUS_TIMEOUT_MS, - { allowFailure: true } - ) - captured = await dependencies.capture(tempConfig.windowsPath, status, previousLegacyKeychain) - } catch (error) { - captureError = error } finally { - if (process.platform === 'darwin') { - try { - await deleteActiveClaudeKeychainCredentialsStrict(tempConfig.windowsPath) - } catch (error) { - console.warn('[claude-accounts] Failed to clean temporary Claude Keychain item:', error) - } - try { - await (previousLegacyKeychain - ? writeActiveClaudeKeychainCredentials(previousLegacyKeychain) - : deleteActiveClaudeKeychainCredentialsStrict()) - } catch (error) { - cleanupError = error - } - } - await removeTemporaryClaudeConfigDir(tempConfig) dependencies.setCancel(null) } - if (captureError) { - throw captureError - } - if (cleanupError) { - throw cleanupError - } - return captured! -} - -async function createTemporaryClaudeConfigDir( - location: ClaudeManagedAuthLocation -): Promise { - if (location.managedAuthRuntime !== 'wsl') { - const created = mkdtempSync(join(tmpdir(), 'orca-claude-login-')) - let windowsPath = created - try { - windowsPath = realpathSync(created) - } catch { - // Keep the mkdtemp path if the temp root cannot be resolved. - } - return { - windowsPath, - linuxPath: null, - wslDistro: null - } - } - if (!location.wslDistro) { - throw new Error('Could not resolve the active WSL distribution for Claude login.') - } - const created = await runWslProcess({ - distro: location.wslDistro, - loginPath: 'none', - shell: 'bash', - script: 'mktemp -d "${TMPDIR:-/tmp}/orca-claude-login.XXXXXX"', - timeoutMs: 5000 - }) - const linuxPath = created.stdout.replaceAll(String.fromCharCode(0), '').trim() - if (created.code !== 0 || created.timedOut || !linuxPath.startsWith('/')) { - throw new Error('Could not create a temporary WSL Claude login directory.') - } - return { - windowsPath: toWindowsWslPath(linuxPath, location.wslDistro), - linuxPath, - wslDistro: location.wslDistro - } -} - -async function removeTemporaryClaudeConfigDir(config: ClaudeCommandConfig): Promise { - if (config.linuxPath && config.wslDistro) { - try { - await runWslProcess({ - distro: config.wslDistro, - loginPath: 'none', - program: 'rm', - args: ['-rf', '--', config.linuxPath], - timeoutMs: 5000 - }) - } catch { - // Cleanup cannot mask the login result. - } - return - } - rmSync(config.windowsPath, { recursive: true, force: true }) } diff --git a/src/main/claude-accounts/claude-managed-auth-storage.ts b/src/main/claude-accounts/claude-managed-auth-storage.ts deleted file mode 100644 index b430979655c6..000000000000 --- a/src/main/claude-accounts/claude-managed-auth-storage.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' -import { join, relative, resolve, sep } from 'node:path' -import { parseWslUncPath } from '../../shared/wsl-paths' -import { toWindowsWslPath } from '../wsl' -import { runWslProcess } from '../wsl/wsl-runner' -import { stripSharedClaudeCredentialFields } from './shared-credential-fields' -import { - getClaudeManagedAccountsRoot, - readClaudeManagedAuthFile, - resolveOwnedClaudeManagedAuthPath, - writeClaudeManagedAuthFile -} from './managed-auth-path' -import { - deleteManagedClaudeKeychainCredentials, - readManagedClaudeKeychainCredentials, - writeManagedClaudeKeychainCredentials -} from './keychain' - -export type ClaudeManagedAuthLocation = { - managedAuthPath: string - managedAuthRuntime: 'host' | 'wsl' - wslDistro: string | null - wslLinuxAuthPath: string | null -} - -export type ClaudeManagedAuthSnapshot = { - credentialsJson: string | null - oauthAccountJson: string | null -} - -export type ClaudeManagedAuthTarget = { - runtime?: 'host' | 'wsl' - wslDistro?: string | null -} - -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'` -} - -export class ClaudeManagedAuthStorage { - async create( - accountId: string, - target?: ClaudeManagedAuthTarget - ): Promise { - const wslAuth = await this.tryCreateWsl(accountId, target) - if (wslAuth) { - return wslAuth - } - const managedAuthPath = join(this.getRoot(), accountId, 'auth') - mkdirSync(managedAuthPath, { recursive: true, mode: 0o700 }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), `${accountId}\n`, { - encoding: 'utf-8', - mode: 0o600 - }) - return { - managedAuthPath: await this.assertOwned(managedAuthPath, accountId), - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null - } - } - - async writeAuth( - accountId: string, - managedAuthPath: string, - captured: { credentialsJson: string; oauthAccount: unknown } - ): Promise { - await this.writeCredentials(accountId, managedAuthPath, captured.credentialsJson) - await this.writeOauthAccount(accountId, managedAuthPath, captured.oauthAccount) - } - - async writeCredentials( - accountId: string, - managedAuthPath: string, - credentialsJson: string - ): Promise { - const trustedPath = await this.assertOwned(managedAuthPath, accountId) - if (!parseWslUncPath(trustedPath)) { - credentialsJson = stripSharedClaudeCredentialFields(credentialsJson) - } - if (process.platform === 'darwin') { - await writeManagedClaudeKeychainCredentials(accountId, credentialsJson) - } else { - writeClaudeManagedAuthFile(trustedPath, '.credentials.json', credentialsJson) - } - } - - async writeOauthAccount( - accountId: string, - managedAuthPath: string, - oauthAccount: unknown - ): Promise { - const trustedPath = await this.assertOwned(managedAuthPath, accountId) - writeClaudeManagedAuthFile( - trustedPath, - 'oauth-account.json', - `${JSON.stringify(oauthAccount, null, 2)}\n` - ) - } - - async readSnapshot( - accountId: string, - managedAuthPath: string - ): Promise { - const trustedPath = await this.assertOwned(managedAuthPath, accountId) - return { - credentialsJson: - process.platform === 'darwin' - ? await readManagedClaudeKeychainCredentials(accountId) - : readClaudeManagedAuthFile(trustedPath, '.credentials.json'), - oauthAccountJson: readClaudeManagedAuthFile(trustedPath, 'oauth-account.json') - } - } - - async restoreCredentials( - accountId: string, - managedAuthPath: string, - snapshot: ClaudeManagedAuthSnapshot - ): Promise { - const trustedPath = await this.assertOwned(managedAuthPath, accountId) - if (process.platform === 'darwin') { - await (snapshot.credentialsJson !== null - ? writeManagedClaudeKeychainCredentials(accountId, snapshot.credentialsJson) - : deleteManagedClaudeKeychainCredentials(accountId)) - } else if (snapshot.credentialsJson !== null) { - writeClaudeManagedAuthFile(trustedPath, '.credentials.json', snapshot.credentialsJson) - } else { - rmSync(join(trustedPath, '.credentials.json'), { force: true }) - } - } - - async restoreOauth( - accountId: string, - managedAuthPath: string, - snapshot: ClaudeManagedAuthSnapshot - ): Promise { - const trustedPath = await this.assertOwned(managedAuthPath, accountId) - if (snapshot.oauthAccountJson !== null) { - writeClaudeManagedAuthFile(trustedPath, 'oauth-account.json', snapshot.oauthAccountJson) - } else { - rmSync(join(trustedPath, 'oauth-account.json'), { force: true }) - } - } - - async remove(accountId: string, candidatePath: string): Promise { - try { - const managedAuthPath = await this.assertOwned(candidatePath, accountId) - rmSync(resolve(managedAuthPath, '..'), { recursive: true, force: true }) - } catch (error) { - console.warn('[claude-accounts] Refusing to remove untrusted managed auth:', error) - } - await deleteManagedClaudeKeychainCredentials(accountId) - } - - async assertOwned(candidatePath: string, expectedAccountId?: string): Promise { - const wslInfo = parseWslUncPath(candidatePath) - if (wslInfo) { - return this.assertOwnedWsl(candidatePath, wslInfo, expectedAccountId) - } - this.getRoot() - const accountId = expectedAccountId ?? this.readAccountId(candidatePath) - if (!accountId || (expectedAccountId && accountId !== expectedAccountId)) { - throw new Error('Managed Claude auth directory does not exist on disk.') - } - const trustedPath = resolveOwnedClaudeManagedAuthPath(accountId, candidatePath, { - adoptLegacyMarker: true - }) - if (!trustedPath) { - throw new Error('Managed Claude auth storage is not owned by Orca.') - } - return trustedPath - } - - private async tryCreateWsl( - accountId: string, - target?: ClaudeManagedAuthTarget - ): Promise { - if (process.platform !== 'win32' || target?.runtime !== 'wsl') { - return null - } - const requestedDistro = target.wslDistro?.trim() || undefined - const info = await runWslProcess({ - distro: requestedDistro, - loginPath: 'none', - shell: 'bash', - script: 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"', - timeoutMs: 5000 - }) - const [rawDistro, rawHome] = - info.code === 0 && !info.timedOut - ? info.stdout - .replaceAll(String.fromCharCode(0), '') - .split(/\r?\n/) - .map((line) => line.trim()) - : [] - const distro = requestedDistro || rawDistro - const home = rawHome - if (!distro || !home?.startsWith('/')) { - throw new Error('Could not resolve the active WSL home directory for Claude login.') - } - const linuxPath = `${home.replace(/\/$/, '')}/.local/share/orca/claude-accounts/${accountId}/auth` - const created = await runWslProcess({ - distro, - loginPath: 'none', - shell: 'bash', - script: 'umask 077; mkdir -p "$1" && printf \'%s\\n\' "$2" > "$1/.orca-managed-claude-auth"', - args: [linuxPath, accountId], - timeoutMs: 5000 - }) - if (created.code !== 0 || created.timedOut) { - throw new Error('Could not create the managed WSL Claude auth directory.') - } - const managedAuthPath = toWindowsWslPath(linuxPath, distro) - return { - managedAuthPath: await this.assertOwned(managedAuthPath, accountId), - managedAuthRuntime: 'wsl', - wslDistro: distro, - wslLinuxAuthPath: linuxPath - } - } - - private async assertOwnedWsl( - candidatePath: string, - wslInfo: NonNullable>, - expectedAccountId?: string - ): Promise { - if ( - !wslInfo.linuxPath.includes('/.local/share/orca/claude-accounts/') || - !wslInfo.linuxPath.endsWith('/auth') - ) { - throw new Error('Managed WSL Claude auth storage is outside Orca account storage.') - } - if (process.platform !== 'win32') { - if ( - !existsSync(candidatePath) || - !existsSync(join(candidatePath, '.orca-managed-claude-auth')) - ) { - throw new Error('Managed Claude auth storage is not owned by Orca.') - } - return candidatePath - } - try { - const expected = expectedAccountId - ? `test "$(cat "$candidate_real/.orca-managed-claude-auth")" = ${shellQuote(expectedAccountId)}` - : 'test -n "$(cat "$candidate_real/.orca-managed-claude-auth")"' - const owned = await runWslProcess({ - distro: wslInfo.distro, - loginPath: 'none', - shell: 'bash', - script: [ - 'set -euo pipefail', - `candidate=${shellQuote(wslInfo.linuxPath)}`, - 'managed_root="${HOME%/}/.local/share/orca/claude-accounts"', - 'candidate_real=$(readlink -f -- "$candidate")', - 'managed_root_real=$(readlink -f -- "$managed_root")', - 'test -f "$candidate_real/.orca-managed-claude-auth"', - expected, - 'case "$candidate_real" in "$managed_root_real"/*/auth) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac' - ].join('\n'), - timeoutMs: 5000 - }) - const canonicalPath = owned.stdout.trim() - if (owned.code !== 0 || owned.timedOut || !canonicalPath) { - throw new Error('Managed Claude auth directory does not exist on disk.') - } - return toWindowsWslPath(canonicalPath, wslInfo.distro) - } catch (error) { - throw new Error('Managed WSL Claude auth storage is outside Orca account storage.', { - cause: error - }) - } - } - - private getRoot(): string { - const root = getClaudeManagedAccountsRoot() - mkdirSync(root, { recursive: true, mode: 0o700 }) - return root - } - - private readAccountId(candidatePath: string): string | null { - const relativePath = relative(resolve(this.getRoot()), resolve(candidatePath)) - const parts = relativePath.split(sep) - return parts.length === 2 && parts[1] === 'auth' ? parts[0] : null - } -} diff --git a/src/main/claude-accounts/claude-profile-installed-router.ts b/src/main/claude-accounts/claude-profile-installed-router.ts index a0c04e3c9baa..c4efc9f39e42 100644 --- a/src/main/claude-accounts/claude-profile-installed-router.ts +++ b/src/main/claude-accounts/claude-profile-installed-router.ts @@ -26,7 +26,7 @@ export function listClaudeProfileHomes(dataRoot: string): string[] { let installed: ClaudeProfileRouter | undefined -/** Installed by the host runtime only when routing is enabled; workers and child processes have none. */ +/** Installed by the desktop's account services; workers, child processes and orcad have none. */ export function installClaudeProfileRouter(router: ClaudeProfileRouter | undefined): void { installed = router } @@ -46,8 +46,8 @@ export function withClaudeProfileTerminalEnv } /** - * Account `` folders the System default readers cannot see. Step 1 links history into the - * System default on macOS/Linux, so only Windows (or a cross-filesystem refusal) adds any. + * Account `` folders the System default readers cannot see. Setup links history into the + * System default on every platform, so only a folder setup could not link (e.g. across drives) adds any. */ export function claudeProfileHistoryDirs(surface: 'projects' | 'transcripts'): string[] { return (installed?.accountHomes() ?? []).map((home) => join(home, surface)).filter(isDirectory) diff --git a/src/main/claude-accounts/claude-profile-paths.test.ts b/src/main/claude-accounts/claude-profile-paths.test.ts index f0d04f0fd734..070b486eeb20 100644 --- a/src/main/claude-accounts/claude-profile-paths.test.ts +++ b/src/main/claude-accounts/claude-profile-paths.test.ts @@ -12,7 +12,6 @@ import { join, resolve } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ app: { getPath: () => '/unused-test-path' } })) import { - assertOutsideDefaultClaudeHomes, describeClaudeProfile, prepareClaudeProfileDirectory, readClaudeProfileObject, @@ -57,10 +56,16 @@ describe('Claude profile namespace', () => { target, home: join(dir, 'claude-profiles/account-a/home') }) - prepareClaudeProfileDirectory(dir, profile, userHome) - expect( - JSON.parse(readFileSync(join(dir, 'claude-profiles/account-a/profile.json'), 'utf8')) - ).toEqual({ version: 1, accountId: 'account-a', runtime: 'wsl', distro: 'Ubuntu' }) + const markSetUp = prepareClaudeProfileDirectory(dir, profile, userHome) + const marker = join(dir, 'claude-profiles/account-a/profile.json') + expect(existsSync(marker)).toBe(false) + markSetUp() + expect(JSON.parse(readFileSync(marker, 'utf8'))).toEqual({ + version: 1, + accountId: 'account-a', + runtime: 'wsl', + distro: 'Ubuntu' + }) // The host id is the caller's view of the host, so another caller's spelling is the same profile. prepareClaudeProfileDirectory( dir, @@ -121,11 +126,9 @@ describe('Claude profile namespace', () => { it.runIf(caseInsensitive)('refuses a case-only alias of the default home', () => { const userHome = root() mkdirSync(join(userHome, '.claude')) - expect(() => assertOutsideDefaultClaudeHomes(join(userHome, '.CLAUDE'), userHome)).toThrow( - 'separate directories' - ) + const dataRoot = join(userHome, '.CLAUDE', 'orca') expect(() => - assertOutsideDefaultClaudeHomes(join(userHome, '.CLAUDE', 'nested'), userHome) + prepareClaudeProfileDirectory(dataRoot, describeClaudeProfile(dataRoot, 'a', local), userHome) ).toThrow('separate directories') }) it("reads the user's own CLAUDE_CONFIG_DIR but never Orca's injected one", () => { diff --git a/src/main/claude-accounts/claude-profile-paths.ts b/src/main/claude-accounts/claude-profile-paths.ts index 74ee5222c8e0..ece12937405c 100644 --- a/src/main/claude-accounts/claude-profile-paths.ts +++ b/src/main/claude-accounts/claude-profile-paths.ts @@ -1,6 +1,10 @@ import { lstatSync, mkdirSync, readFileSync, realpathSync } from 'node:fs' import * as hostPath from 'node:path' import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { + CLAUDE_INJECTED_CONFIG_DIR_ENV, + CLAUDE_USER_CONFIG_DIR_ENV +} from '../../shared/claude-profile-routing' import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' import type { ExecutionHostId } from '../../shared/execution-host' import { writeFileAtomically } from '../codex-accounts/fs-utils' @@ -93,23 +97,23 @@ function readOwnershipMarker(file: string): string | null { return ownershipRecord(version, accountId, runtime, distro) } -/** - * The only gate before writing into a profile: namespace, containment, no linked components, - * outside Claude's default homes, and an ownership marker beside the home. Refuses before creating anything. - */ -// Written by setup's ownership gate when setup starts, not when it completes: its absence means -// setup never started here, and its presence does not prove setup finished. +// Written when a first setup finishes, so its absence means no setup has finished here yet. export function claudeProfileMarkerPath(profile: ClaudeProfileDescriptor): string { const path = profile.target.runtime === 'wsl' ? hostPath.posix : hostPath return path.join(path.dirname(profile.home), 'profile.json') } +/** + * The only gate before writing into a profile: namespace, containment, no linked components, + * outside Claude's default homes, and an ownership marker beside the home. Refuses before creating + * anything. Returns the step that writes the marker, which setup runs last. + */ export function prepareClaudeProfileDirectory( dataRoot: string, profile: ClaudeProfileDescriptor, userHome: string, userConfigDir?: string -): void { +): () => void { let expected: ClaudeProfileDescriptor try { expected = describeClaudeProfile(dataRoot, profile.accountId, profile.target) @@ -135,12 +139,14 @@ export function prepareClaudeProfileDirectory( ) } mkdirSync(profile.home, { recursive: true, mode: 0o700 }) - if (marker === null) { - writeFileAtomically(markerPath, `${record}\n`, { mode: 0o600 }) + return () => { + if (marker === null) { + writeFileAtomically(markerPath, `${record}\n`, { mode: 0o600 }) + } } } -export function assertClaudeProfileDescendant(root: string, destination: string): void { +function assertClaudeProfileDescendant(root: string, destination: string): void { const suffix = relative(resolve(root), resolve(destination)) if (!suffix || suffix === '..' || suffix.startsWith(`..${sep}`) || isAbsolute(suffix)) { throw new ClaudeProfileSurfaceError( @@ -184,7 +190,7 @@ function canonicalPath(file: string): string { } } -export function assertDistinctClaudeProfile(profile: string, defaultHome: string): void { +function assertDistinctClaudeProfile(profile: string, defaultHome: string): void { const left = canonicalPath(profile) const right = canonicalPath(defaultHome) for (const [root, destination] of [ @@ -202,7 +208,7 @@ export function assertDistinctClaudeProfile(profile: string, defaultHome: string } /** Claude's default homes: a profile may never be, contain, or sit inside one. */ -export function assertOutsideDefaultClaudeHomes( +function assertOutsideDefaultClaudeHomes( profileHome: string, userHome: string, userConfigDir?: string @@ -214,16 +220,18 @@ export function assertOutsideDefaultClaudeHomes( } } -/** Set beside every CLAUDE_CONFIG_DIR Orca injects, so its own value never reads as the user's. */ -export const CLAUDE_INJECTED_CONFIG_DIR_ENV = 'ORCA_CLAUDE_INJECTED_CONFIG_DIR' - /** The user's own CLAUDE_CONFIG_DIR (their System default), or undefined for `~/.claude`. */ export function readUserClaudeConfigDir(env: NodeJS.ProcessEnv): string | undefined { const configDir = env.CLAUDE_CONFIG_DIR?.trim() const injected = env[CLAUDE_INJECTED_CONFIG_DIR_ENV]?.trim() - if (!configDir || (injected && resolve(injected) === resolve(configDir))) { + if (!configDir) { return undefined } + if (injected && resolve(injected) === resolve(configDir)) { + // Why: an outer Orca's injected value carries the user's own beside it. + const user = env[CLAUDE_USER_CONFIG_DIR_ENV]?.trim() + return user ? resolve(user) : undefined + } return resolve(configDir) } diff --git a/src/main/claude-accounts/claude-profile-provisioning.test.ts b/src/main/claude-accounts/claude-profile-provisioning.test.ts index 66bf0346d90d..4bc12a4738a9 100644 --- a/src/main/claude-accounts/claude-profile-provisioning.test.ts +++ b/src/main/claude-accounts/claude-profile-provisioning.test.ts @@ -13,13 +13,15 @@ vi.mock('../codex-accounts/fs-utils', async (original) => { return { ...actual, writeFileAtomically: vi.fn(actual.writeFileAtomically) } }) import { writeFileAtomically } from '../codex-accounts/fs-utils' -import { CLAUDE_PROFILE_MEMORY_IMPORT, provisionClaudeProfile } from './claude-profile-provisioning' +import { provisionClaudeProfile } from './claude-profile-provisioning' const USER_HOOK = { matcher: '', hooks: [{ type: 'command', command: 'notify-me' }] } const ORCA_HOOK = { matcher: '', hooks: [{ type: 'command', command: '"$HOME/.orca/agent-hooks/claude-hook.sh"' }] } +// What Claude writes on a finished sign-in. +const LOGIN = { emailAddress: 'p@example.com' } const roots: string[] = [] function fixture() { const root = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), 'claude-profile-setup-'))) @@ -33,13 +35,8 @@ function fixture() { const read = (file: string): Record => JSON.parse(fs.readFileSync(file, 'utf8')) return { root, userHome, profileHome, source, json, read } } -const provision = (f: { profileHome: string; userHome: string }, trustKeys?: string[]) => - provisionClaudeProfile({ - profileHome: f.profileHome, - userHome: f.userHome, - trustKeys, - platform: 'linux' - }) +const provision = (f: { profileHome: string; userHome: string }) => + provisionClaudeProfile({ profileHome: f.profileHome, userHome: f.userHome, platform: 'linux' }) afterEach(() => { vi.clearAllMocks() for (const dir of roots.splice(0)) { @@ -219,7 +216,7 @@ describe('dormant Claude profile provisioning', () => { const f = fixture() f.json(join(f.source, 'settings.json'), { model: 'a' }) f.json(join(f.userHome, '.claude.json'), { mcpServers: { a: {} }, theme: 'dark' }) - f.json(join(f.profileHome, '.claude.json'), { userID: 'p' }) + f.json(join(f.profileHome, '.claude.json'), { userID: 'p', oauthAccount: LOGIN }) await provision(f) fs.writeFileSync(join(f.source, 'settings.json'), '{bad') fs.writeFileSync(join(f.userHome, '.claude.json'), '{bad') @@ -233,17 +230,18 @@ describe('dormant Claude profile provisioning', () => { await provision(f) expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ userID: 'p', + oauthAccount: LOGIN, theme: 'dark', hasCompletedOnboarding: true }) }) - it('requires existing state, merges MCP/theme/onboarding/trust and never copies or writes credentials', async () => { + it('requires existing state, merges MCP/theme/onboarding and never copies or writes credentials', async () => { const f = fixture() fs.writeFileSync(join(f.source, '.credentials.json'), 'SOURCE_CREDENTIAL_BYTES') f.json(join(f.userHome, '.claude.json'), { mcpServers: { local: { command: 'example' } }, theme: 'dark', - oauthAccount: { email: 'source' }, + oauthAccount: { emailAddress: 'source@example.com' }, userID: 'source-id' }) await provision(f) @@ -251,18 +249,18 @@ describe('dormant Claude profile provisioning', () => { expect(fs.existsSync(join(f.profileHome, '.credentials.json'))).toBe(false) fs.writeFileSync(join(f.profileHome, '.credentials.json'), 'PROFILE_CREDENTIAL_BYTES') f.json(join(f.profileHome, '.claude.json'), { - oauthAccount: { email: 'profile' }, + oauthAccount: { emailAddress: 'profile@example.com' }, userID: 'profile-id', projects: { '/work': { allowedTools: ['Read'] } } }) - await provision(f, ['/work']) + await provision(f) expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ - oauthAccount: { email: 'profile' }, + oauthAccount: { emailAddress: 'profile@example.com' }, userID: 'profile-id', mcpServers: { local: { command: 'example' } }, theme: 'dark', hasCompletedOnboarding: true, - projects: { '/work': { allowedTools: ['Read'], hasTrustDialogAccepted: true } } + projects: { '/work': { allowedTools: ['Read'] } } }) expect(fs.readFileSync(join(f.source, '.credentials.json'), 'utf8')).toBe( 'SOURCE_CREDENTIAL_BYTES' @@ -277,44 +275,50 @@ describe('dormant Claude profile provisioning', () => { } } }) - it('still forces onboarding and trust when the personal state is unreadable', async () => { + it('leaves a folder Claude has not signed in yet to Claude’s own onboarding', async () => { const f = fixture() - fs.writeFileSync(join(f.userHome, '.claude.json'), '{"theme": "da') - f.json(join(f.profileHome, '.claude.json'), { userID: 'p' }) - const report = await provision(f, ['/work']) + f.json(join(f.userHome, '.claude.json'), { theme: 'dark', mcpServers: { a: {} } }) + // Claude writes its state file at the theme pick, before the sign-in. + f.json(join(f.profileHome, '.claude.json'), { theme: 'light' }) + expect((await provision(f)).surfaces['.claude.json']).toBe('absent') + expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ theme: 'light' }) + f.json(join(f.profileHome, '.claude.json'), { theme: 'light', oauthAccount: LOGIN }) + expect((await provision(f)).surfaces['.claude.json']).toBe('merged') expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ - userID: 'p', - hasCompletedOnboarding: true, - projects: { '/work': { hasTrustDialogAccepted: true } } + theme: 'light', + oauthAccount: LOGIN, + mcpServers: { a: {} }, + hasCompletedOnboarding: true }) - expect(report.warnings).toContainEqual( - expect.objectContaining({ surface: '.claude.json', code: 'unreadable' }) - ) }) - it('skips only folder trust when the profile projects value is malformed', async () => { + it('still forces onboarding when the personal state is unreadable', async () => { const f = fixture() - f.json(join(f.userHome, '.claude.json'), { theme: 'dark' }) - f.json(join(f.profileHome, '.claude.json'), { userID: 'p', projects: 'bad' }) - const report = await provision(f, ['/work']) - expect(report.surfaces['.claude.json']).toBe('merged') + fs.writeFileSync(join(f.userHome, '.claude.json'), '{"theme": "da') + f.json(join(f.profileHome, '.claude.json'), { userID: 'p', oauthAccount: LOGIN }) + const report = await provision(f) expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ userID: 'p', - projects: 'bad', - theme: 'dark', + oauthAccount: LOGIN, hasCompletedOnboarding: true }) + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: '.claude.json', code: 'unreadable' }) + ) }) it('skips the state write while Claude holds its lock and records nothing for it', async () => { const f = fixture() f.json(join(f.userHome, '.claude.json'), { theme: 'dark' }) - f.json(join(f.profileHome, '.claude.json'), { userID: 'p' }) + f.json(join(f.profileHome, '.claude.json'), { userID: 'p', oauthAccount: LOGIN }) fs.mkdirSync(join(f.profileHome, '.claude.json.lock')) const report = await provision(f) expect(report.surfaces['.claude.json']).toBe('failed') expect(report.warnings).toContainEqual( expect.objectContaining({ surface: '.claude.json', code: 'locked' }) ) - expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ userID: 'p' }) + expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ + userID: 'p', + oauthAccount: LOGIN + }) fs.rmdirSync(join(f.profileHome, '.claude.json.lock')) expect((await provision(f)).surfaces['.claude.json']).toBe('merged') expect(f.read(join(f.profileHome, '.claude.json')).theme).toBe('dark') @@ -372,9 +376,7 @@ describe('dormant Claude profile provisioning', () => { expect(fs.existsSync(join(f.profileHome, 'CLAUDE.md'))).toBe(false) fs.writeFileSync(join(f.source, 'CLAUDE.md'), 'personal instructions') expect((await provision(f)).surfaces['CLAUDE.md']).toBe('synced') - expect(fs.readFileSync(join(f.profileHome, 'CLAUDE.md'), 'utf8')).toBe( - CLAUDE_PROFILE_MEMORY_IMPORT - ) + expect(fs.readFileSync(join(f.profileHome, 'CLAUDE.md'), 'utf8')).toBe('@~/.claude/CLAUDE.md\n') fs.writeFileSync(join(f.source, 'CLAUDE.md'), 'edited personal instructions') expect((await provision(f)).surfaces['CLAUDE.md']).toBe('unchanged') }) @@ -388,7 +390,7 @@ describe('dormant Claude profile provisioning', () => { f.json(join(userConfigDir, 'settings.json'), { model: 'custom' }) f.json(join(userConfigDir, '.claude.json'), { theme: 'custom' }) f.json(join(f.userHome, '.claude.json'), { theme: 'home' }) - f.json(join(f.profileHome, '.claude.json'), { userID: 'p' }) + f.json(join(f.profileHome, '.claude.json'), { userID: 'p', oauthAccount: LOGIN }) await provisionClaudeProfile({ ...f, userConfigDir, platform: 'linux' }) expect(fs.realpathSync(join(f.profileHome, 'skills'))).toBe(join(userConfigDir, 'skills')) expect(fs.readFileSync(join(f.profileHome, 'CLAUDE.md'), 'utf8')).toBe('custom instructions') diff --git a/src/main/claude-accounts/claude-profile-provisioning.ts b/src/main/claude-accounts/claude-profile-provisioning.ts index 752f7fee3f38..8c72cf291a14 100644 --- a/src/main/claude-accounts/claude-profile-provisioning.ts +++ b/src/main/claude-accounts/claude-profile-provisioning.ts @@ -2,10 +2,10 @@ import { existsSync } from 'node:fs' import { join, resolve } from 'node:path' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { - applyClaudeFolderTrust, resolveClaudeGlobalConfigFile, updateClaudeGlobalConfig } from '../claude/claude-folder-trust-file' +import { claudeStateLogin } from './claude-account-folder' import { readClaudeProfileObject, resolveClaudeDefaultHome } from './claude-profile-paths' import { lstatIfPresent } from './claude-profile-prompt-history' import { @@ -39,7 +39,7 @@ export const CLAUDE_PROFILE_RESOURCE_DIRS = [ ] as const // Copied, not linked: a rename-replace save (Claude's own, or an editor's) would cut a link. export const CLAUDE_PROFILE_RESOURCE_FILES = ['CLAUDE.md', 'keybindings.json'] as const -export const CLAUDE_PROFILE_MEMORY_IMPORT = '@~/.claude/CLAUDE.md\n' +const CLAUDE_PROFILE_MEMORY_IMPORT = '@~/.claude/CLAUDE.md\n' const PRIVATE_KEYS = new Set([ 'apiKeyHelper', 'awsAuthRefresh', @@ -103,7 +103,6 @@ async function mergeState(args: { source: string target: string ledger: ClaudeProfileLedger - trustKeys: readonly string[] report: ClaudeProfileReport }): Promise { if (lstatIfPresent(args.target)?.isSymbolicLink()) { @@ -111,7 +110,7 @@ async function mergeState(args: { } const input = readClaudeProfileObject(args.source) if (input.kind === 'unavailable') { - // Why: onboarding and trust don't depend on the personal state; only its shared keys wait. + // Why: onboarding doesn't depend on the personal state; only its shared keys wait. const error = new ClaudeProfileSurfaceError('unreadable', 'Personal Claude state is unreadable') warnClaudeProfile(args.report, '.claude.json', error) } @@ -120,7 +119,15 @@ async function mergeState(args: { SHARED_STATE_KEYS.filter((key) => key in source).map((key) => [key, source[key]]) ) let written: Record = {} + let signedOut = false const outcome = await updateClaudeGlobalConfig(args.target, (current) => { + // Why oauthAccount: Claude writes it only on a finished sign-in, while it writes this file + // earlier in onboarding (theme pick). It's read under Claude's own lock, on every platform; + // credentials sit in the Keychain on macOS. A flag set before then skips Claude's sign-in. + signedOut = !claudeStateLogin(current) + if (signedOut) { + return { kind: 'unchanged' } + } const config = { ...current } written = { ...args.ledger.keys['.claude.json'] } let changed = mergeClaudeProfileKeys(config, desired, written).length > 0 @@ -135,15 +142,10 @@ async function mergeState(args: { config.hasCompletedOnboarding = true changed = true } - // Why: a malformed `projects` refuses only trust; onboarding and shared keys still apply. - const trust = args.trustKeys.length > 0 ? applyClaudeFolderTrust(config, args.trustKeys) : null - if (trust?.kind === 'changed') { - return trust - } return changed ? { kind: 'changed', config } : { kind: 'unchanged' } }) - if (outcome === 'missing-config') { - // Why: no state file means no completed login; writing one would fabricate an account. + if (outcome === 'missing-config' || (outcome === 'unchanged' && signedOut)) { + // Why: no completed login yet; writing would fabricate an account or skip Claude's sign-in. return 'absent' } if (outcome === 'locked' || outcome === 'unreadable') { @@ -163,7 +165,6 @@ export async function provisionClaudeProfile(args: { /** The user's own CLAUDE_CONFIG_DIR; `~/.claude` when unset. */ userConfigDir?: string platform?: NodeJS.Platform - trustKeys?: readonly string[] }): Promise { const platform = args.platform ?? process.platform const defaultHome = resolveClaudeDefaultHome(args.userHome, args.userConfigDir) @@ -210,7 +211,6 @@ export async function provisionClaudeProfile(args: { source: statePath(args.userConfigDir), target: statePath(args.profileHome), ledger, - trustKeys: args.trustKeys ?? [], report }) ) diff --git a/src/main/claude-accounts/claude-profile-router.test.ts b/src/main/claude-accounts/claude-profile-router.test.ts index 5391072d0bbe..3db8218f016f 100644 --- a/src/main/claude-accounts/claude-profile-router.test.ts +++ b/src/main/claude-accounts/claude-profile-router.test.ts @@ -1,4 +1,6 @@ +import { spawnSync } from 'node:child_process' import { + chmodSync, existsSync, mkdirSync, mkdtempSync, @@ -9,22 +11,44 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { ClaudeManagedAccount } from '../../shared/managed-account-types' import type { ClaudeProfileSetupReport } from './claude-profile-setup' +import { + claudeProfileMarkerPath, + describeClaudeProfile, + prepareClaudeProfileDirectory, + type ClaudeProfileDescriptor +} from './claude-profile-paths' import { CLAUDE_PROFILE_MISSING_MESSAGE, - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, - ClaudeProfileRouter, - type ClaudeProfileRouterSettings -} from './claude-profile-router' + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE +} from '../../shared/claude-profile-routing' +import { getPosixClaudeShellFunction } from '../../shared/claude-shell-function' +import { ClaudeProfileRouter, type ClaudeProfileRouterSettings } from './claude-profile-router' import { claudeProfileHistoryDirs, installClaudeProfileRouter } from './claude-profile-installed-router' +// Why: removal deletes macOS Keychain items; the test must never reach the real Keychain. +// The login shell exports what a Dock launch's own env lacks. +const shell = vi.hoisted(() => ({ hang: false })) +vi.mock('../startup/login-shell-environment', () => ({ + resolveLoginShellEnvironment: () => + shell.hang + ? new Promise(() => {}) + : Promise.resolve({ CLAUDE_CONFIG_DIR: resolve('/shell/own') }) +})) +vi.mock('../macos-keychain/generic-password', () => ({ + execSecurityCommand: async () => { + throw new Error('The specified item could not be found in the keychain.') + } +})) + const roots: string[] = [] afterEach(() => { + shell.hang = false installClaudeProfileRouter(undefined) roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) }) @@ -52,13 +76,19 @@ function fixture(env: NodeJS.ProcessEnv = {}) { agentStatusHooksEnabled: false, disabledTuiAgents: [] } - // Stands in for the worker; each setup stays pending until the test settles it. + // Stands in for the worker; like it, stays pending until settled and marks the folder last. const setup: { calls: number; outcome: ClaudeProfileSetupReport['outcome']; settle: () => void } = { calls: 0, outcome: 'prepared', settle: () => {} } - const runSetup = () => { + const runSetup = ({ profile }: { profile: ClaudeProfileDescriptor }) => { setup.calls += 1 + mkdirSync(profile.home, { recursive: true }) return new Promise((resolve) => { - setup.settle = () => resolve({ outcome: setup.outcome, warnings: [], surfaces: {} }) + setup.settle = () => { + if (setup.outcome === 'prepared') { + writeFileSync(claudeProfileMarkerPath(profile), '{}') + } + resolve({ outcome: setup.outcome, warnings: [], surfaces: {} }) + } }) } const router = new ClaudeProfileRouter({ @@ -73,13 +103,36 @@ function fixture(env: NodeJS.ProcessEnv = {}) { } describe('ClaudeProfileRouter', () => { - it('publishes the selected folder, System default as empty, and no file without accounts', () => { + it('prepares an account folder for sign-in only when setup succeeds', async () => { + const f = fixture({ CLAUDE_CONFIG_DIR: resolve('/custom/claude') }) + expect(f.router.accountHome('b')).toBe(f.home('b')) + expect(f.router.userConfigDir()).toBe(resolve('/custom/claude')) + const prepared = f.router.prepareAccount('b') + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + f.setup.settle() + await expect(prepared).resolves.toBe(f.home('b')) + f.setup.outcome = 'refused' + const refused = f.router.prepareAccount('b') + await vi.waitFor(() => expect(f.setup.calls).toBe(2)) + f.setup.settle() + await expect(refused).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + }) + + it("reads an older Orca's copy-based switching from the System default snapshot it left", () => { + const f = fixture() + expect(f.router.copiedLoginIntoSystemDefault()).toBe(false) + mkdirSync(join(f.dataRoot, 'claude-runtime-auth'), { recursive: true }) + writeFileSync(join(f.dataRoot, 'claude-runtime-auth', 'system-default-auth.json'), '{}') + expect(f.router.copiedLoginIntoSystemDefault()).toBe(true) + }) + + it('publishes the selected folder, System default as empty, and no file without accounts', async () => { const f = fixture() mkdirSync(f.home('a'), { recursive: true }) f.router.publish() expect(readFileSync(f.router.pointerPath, 'utf8')).toBe(f.home('a')) // publish returned while its setup is still running in the background. - expect(f.setup.calls).toBe(1) + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) f.settings.activeClaudeManagedAccountId = null f.router.publish() @@ -90,42 +143,108 @@ describe('ClaudeProfileRouter', () => { expect(existsSync(f.router.pointerPath)).toBe(false) }) - it('makes a launch wait only for a folder that was never set up, reusing the running setup', async () => { + it('makes a launch wait for a first setup that never ran or is still running, reusing it', async () => { const f = fixture() mkdirSync(f.home('a'), { recursive: true }) - f.router.publish() let launched = false const launch = f.router.prepareLaunch().then((prepared) => { launched = true return prepared }) - await Promise.resolve() + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + await new Promise((resolve) => setTimeout(resolve, 10)) expect(launched).toBe(false) + const second = f.router.prepareLaunch() + f.setup.outcome = 'refused' + f.setup.settle() + await expect(launch).rejects.toMatchObject({ reason: 'claudeAccountSetupFailed' }) + await expect(second).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) expect(f.setup.calls).toBe(1) + }) + + it('never makes a launch wait on, or fail with, a re-run of a set-up folder', async () => { + const f = fixture() + mkdirSync(f.home('a'), { recursive: true }) + const first = f.router.prepareLaunch() + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) f.setup.settle() - await expect(launch).resolves.toMatchObject({ configDir: f.home('a') }) + await expect(first).resolves.toMatchObject({ configDir: f.home('a') }) + // Startup or a switch re-runs setup; it stays pending, then fails. f.setup.outcome = 'refused' - const refused = f.router.prepareLaunch() + f.router.publish() + await vi.waitFor(() => expect(f.setup.calls).toBe(2)) + await expect(f.router.prepareLaunch()).resolves.toMatchObject({ configDir: f.home('a') }) f.setup.settle() - await expect(refused).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) - - // Set up once (setup's marker exists): launches stop waiting. - writeFileSync(join(f.dataRoot, 'claude-profiles', 'a', 'profile.json'), '{}') + await new Promise((resolve) => setTimeout(resolve, 10)) await expect(f.router.prepareLaunch()).resolves.toMatchObject({ configDir: f.home('a') }) expect(f.setup.calls).toBe(2) }) - it('names a never-signed-in folder without creating it, and refuses to launch it', async () => { + it('makes a launch redo a first setup that was cut off after its ownership gate', async () => { + const f = fixture() + mkdirSync(f.home('a'), { recursive: true }) + const profile = describeClaudeProfile(f.dataRoot, 'a', { + executionHostId: 'local', + runtime: 'host' + }) + // The worker timed out, or the app quit, after the gate ran. + const cutOff = new ClaudeProfileRouter({ + getSettings: () => f.settings, + dataRoot: f.dataRoot, + userHome: f.userHome, + env: {}, + runSetup: async (args) => { + prepareClaudeProfileDirectory(args.dataRoot, args.profile, args.userHome) + throw new Error('Claude account setup timed out') + } + }) + await expect(cutOff.prepareLaunch()).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + expect(existsSync(claudeProfileMarkerPath(profile))).toBe(false) + + const launch = f.router.prepareLaunch() + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + f.setup.settle() + await expect(launch).resolves.toMatchObject({ configDir: f.home('a') }) + }) + + it("launches a set-up account without waiting for the login shell's env", async () => { + shell.hang = true + const f = fixture() + mkdirSync(f.home('a'), { recursive: true }) + writeFileSync(join(f.home('a'), '..', 'profile.json'), '{}') + const router = new ClaudeProfileRouter({ + getSettings: () => f.settings, + dataRoot: f.dataRoot, + userHome: f.userHome + }) + await expect(router.prepareLaunch()).resolves.toMatchObject({ configDir: f.home('a') }) + }) + + it("sets up a pre-update account's missing folder without a login, for Claude's own first run", async () => { const f = fixture() f.settings.activeClaudeManagedAccountId = 'b' - f.router.publish() - expect(readFileSync(f.router.pointerPath, 'utf8')).toBe(f.home('b')) - expect(f.setup.calls).toBe(0) - expect(existsSync(f.home('b'))).toBe(false) + // Before its setup, a terminal still opens; its claude function creates the folder itself. expect(() => f.router.preparation()).toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) - // A terminal still opens; its claude function refuses from the pointer instead. expect(f.router.terminalEnv()).toEqual({ ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath }) + f.router.publish() + expect(readFileSync(f.router.pointerPath, 'utf8')).toBe(f.home('b')) + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + expect(existsSync(f.home('b'))).toBe(true) + // Claude's first run is clean only without an onboarding-done state file. + expect(existsSync(join(f.home('b'), '.claude.json'))).toBe(false) + const launch = f.router.prepareLaunch() + f.setup.settle() + await expect(launch).resolves.toMatchObject({ configDir: f.home('b') }) + expect(f.setup.calls).toBe(1) + }) + + it('makes a launch set up a missing folder first', async () => { + const f = fixture() + const launch = f.router.prepareLaunch() + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + f.setup.settle() + await expect(launch).resolves.toMatchObject({ configDir: f.home('a') }) }) it('injects the account with its twin, and nothing over the user’s own System default', () => { @@ -149,12 +268,92 @@ describe('ClaudeProfileRouter', () => { expect(f.router.preparation().envPatch).not.toHaveProperty('CLAUDE_CONFIG_DIR') }) + it('carries the user’s own value beside an account terminal’s injected one', () => { + const f = fixture({ CLAUDE_CONFIG_DIR: resolve('/user/own') }) + mkdirSync(f.home('a'), { recursive: true }) + expect(f.router.terminalEnv()).toEqual({ + ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath, + CLAUDE_CONFIG_DIR: f.home('a'), + ORCA_CLAUDE_INJECTED_CONFIG_DIR: f.home('a'), + ORCA_CLAUDE_USER_CONFIG_DIR: resolve('/user/own') + }) + expect(f.router.preparation().envPatch).not.toHaveProperty('ORCA_CLAUDE_USER_CONFIG_DIR') + f.settings.activeClaudeManagedAccountId = null + expect(f.router.terminalEnv()).toEqual({ ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath }) + const none = fixture() + mkdirSync(none.home('a'), { recursive: true }) + expect(none.router.terminalEnv()).not.toHaveProperty('ORCA_CLAUDE_USER_CONFIG_DIR') + }) + + // Why not win32: runs the POSIX claude function under bash. + it.skipIf(process.platform === 'win32')( + 'gives an account terminal back the inherited CLAUDE_CONFIG_DIR, or none, on System default', + () => { + for (const inherited of [{ CLAUDE_CONFIG_DIR: resolve('/user/own') }, {}]) { + const f = fixture(inherited) + mkdirSync(f.home('a'), { recursive: true }) + const bin = join(f.root, 'bin') + mkdirSync(bin) + writeFileSync(join(bin, 'claude'), '#!/bin/sh\nprintf "%s" "${CLAUDE_CONFIG_DIR-unset}"\n') + chmodSync(join(bin, 'claude'), 0o700) + const claude = (env: Record): string => + spawnSync( + '/bin/bash', + ['--norc', '--noprofile', '-c', `${getPosixClaudeShellFunction()}\nclaude`], + { env: { ...env, PATH: `${bin}:/usr/bin:/bin` }, encoding: 'utf8' } + ).stdout + // The pane opens while account A is selected, so Orca replaces the inherited value. + f.router.publish() + const pane = { ...inherited, ...f.router.terminalEnv() } + expect(claude(pane)).toBe(f.home('a')) + f.settings.activeClaudeManagedAccountId = null + f.router.publish() + expect(claude(pane)).toBe(inherited.CLAUDE_CONFIG_DIR ?? 'unset') + } + } + ) + + it('removes a folder only after its running setup, so the setup cannot bring it back', async () => { + const f = fixture() + mkdirSync(f.home('a'), { recursive: true }) + f.router.publish() + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + const removal = f.router.removeAccount('a') + await new Promise((resolve) => setTimeout(resolve, 10)) + // Setup writes into the folder before it settles, as the worker does. + mkdirSync(f.home('a'), { recursive: true }) + writeFileSync(join(f.home('a'), 'settings.json'), '{}') + f.setup.settle() + await removal + expect(existsSync(join(f.dataRoot, 'claude-profiles', 'a'))).toBe(false) + }) + + it('takes System default from the login shell, which a Dock launch does not inherit', async () => { + const f = fixture() + f.settings.activeClaudeManagedAccountId = null + const router = new ClaudeProfileRouter({ + getSettings: () => f.settings, + dataRoot: f.dataRoot, + userHome: f.userHome + }) + await expect(router.prepareLaunch()).resolves.toMatchObject({ + configDir: resolve('/shell/own'), + provenance: 'system' + }) + }) + it('treats a CLAUDE_CONFIG_DIR an outer Orca injected as not the user’s', () => { const f = fixture({ CLAUDE_CONFIG_DIR: '/outer/profile', ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/outer/profile' }) expect(f.router.systemDefaultHome()).toBe(join(f.userHome, '.claude')) + const carried = fixture({ + CLAUDE_CONFIG_DIR: '/outer/profile', + ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/outer/profile', + ORCA_CLAUDE_USER_CONFIG_DIR: '/user/own' + }) + expect(carried.router.systemDefaultHome()).toBe(resolve('/user/own')) }) // Why not win32: creating the link needs privileges there. diff --git a/src/main/claude-accounts/claude-profile-router.ts b/src/main/claude-accounts/claude-profile-router.ts index 3b12f6f7c82d..fa44e53b5630 100644 --- a/src/main/claude-accounts/claude-profile-router.ts +++ b/src/main/claude-accounts/claude-profile-router.ts @@ -2,13 +2,19 @@ import { existsSync, mkdirSync, rmSync } from 'node:fs' import { homedir } from 'node:os' import { dirname, join } from 'node:path' import { isAgentStatusHooksEnabledForAgent } from '../../shared/agent-status-hooks-setting' -import { CLAUDE_PROFILE_POINTER_ENV } from '../../shared/claude-profile-routing' +import { + CLAUDE_INJECTED_CONFIG_DIR_ENV, + CLAUDE_PROFILE_MISSING_MESSAGE, + CLAUDE_PROFILE_POINTER_ENV, + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, + CLAUDE_USER_CONFIG_DIR_ENV +} from '../../shared/claude-profile-routing' import type { GlobalSettings } from '../../shared/global-settings-types' import { probeClaudeCliVersion } from '../claude/claude-hook-event-versions' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { resolveClaudeCommand } from '../codex-cli/command' +import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { - CLAUDE_INJECTED_CONFIG_DIR_ENV, claudeProfileMarkerPath, describeClaudeProfile, type ClaudeProfileDescriptor, @@ -25,6 +31,8 @@ import { } from './runtime-selection' import { wslClaudeProfilePointer } from './claude-profile-wsl-paths' import { isDirectory, listClaudeProfileHomes } from './claude-profile-installed-router' +import { removeClaudeAccountFolder } from './claude-account-folder' +import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' export type ClaudeProfileRouterSettings = Pick< GlobalSettings, @@ -35,12 +43,6 @@ export type ClaudeProfileRouterSettings = Pick< | 'disabledTuiAgents' > -export const CLAUDE_PROFILE_SETUP_FAILED_MESSAGE = - 'The selected Claude account could not be set up. Try again or choose another account.' - -export const CLAUDE_PROFILE_MISSING_MESSAGE = - "The selected Claude account's folder is missing. Sign in to it again or choose another account." - /** * Routes this host's Claude launches to the selected account's folder. Settings own the selection; * the pointer file mirrors it for `claude` typed in an already-open terminal. @@ -48,17 +50,25 @@ export const CLAUDE_PROFILE_MISSING_MESSAGE = export class ClaudeProfileRouter { readonly pointerPath: string private readonly setups = new Map>() + private env: NodeJS.ProcessEnv + private readonly envReady: Promise constructor( private readonly args: { getSettings: () => ClaudeProfileRouterSettings dataRoot: string userHome?: string + /** Tests replace the login shell's env. */ env?: NodeJS.ProcessEnv /** Tests replace the worker. */ runSetup?: typeof runClaudeProfileSetupInWorker } ) { this.pointerPath = join(args.dataRoot, 'claude-profiles', 'selected-host') + this.env = args.env ?? process.env + // Why the login shell's: a Dock launch lacks the CLAUDE_CONFIG_DIR an rc exports. Chats share it. + this.envReady = args.env + ? Promise.resolve() + : resolveLoginShellEnvironment().then((env) => (this.env = env)) } private get userHome(): string { @@ -67,24 +77,19 @@ export class ClaudeProfileRouter { private selectedProfile(): ClaudeProfileDescriptor | null { const id = getSelectedClaudeAccountIdForTarget(this.args.getSettings(), { runtime: 'host' }) - return id - ? describeClaudeProfile(this.args.dataRoot, id, { executionHostId: 'local', runtime: 'host' }) - : null + return id ? this.describe(id) : null } /** The user's System default: their own CLAUDE_CONFIG_DIR, else ~/.claude. */ systemDefaultHome(): string { - return resolveClaudeDefaultHome( - this.userHome, - readUserClaudeConfigDir(this.args.env ?? process.env) - ) + return resolveClaudeDefaultHome(this.userHome, this.userConfigDir()) } /** Null for System default. Throws for a missing folder: falling back would run the wrong account. */ selectedHome(): string | null { const home = this.selectedProfile()?.home ?? null if (home !== null && !isDirectory(home)) { - throw new Error(CLAUDE_PROFILE_MISSING_MESSAGE) + throw claudeProfileMissing() } return home } @@ -98,26 +103,72 @@ export class ClaudeProfileRouter { const profile = this.selectedProfile() mkdirSync(dirname(this.pointerPath), { recursive: true, mode: 0o700 }) writeFileAtomically(this.pointerPath, profile?.home ?? '', { mode: 0o600 }) - // Why the existence check: setup creates the folder, and only sign-in may create an account. - if (profile && isDirectory(profile.home)) { + // Why even a missing folder: setup creates it without a login, so Claude's own first run + // signs in there (an account saved before per-account folders has none yet). + if (profile) { this.setUp(profile).catch((error: unknown) => { console.warn('[claude-profile] Account setup failed:', error) }) } } - /** Waits for setup only for a folder that was never set up; otherwise launches at once. */ + /** Waits for a first setup that never finished, running or not; otherwise launches at once. */ async prepareLaunch(): Promise { const profile = this.selectedProfile() - if (profile && isDirectory(profile.home) && !existsSync(claudeProfileMarkerPath(profile))) { + if (!profile) { + // Only System default reads the login shell's env here; setup awaits it itself. + await this.envReady + return this.preparation() + } + // Why the marker: setup writes it last, so a missing folder is set up too. A re-run of a + // set-up folder never blocks. + if (!existsSync(claudeProfileMarkerPath(profile))) { const report = await this.setUp(profile).catch(() => null) if (report?.outcome !== 'prepared') { - throw new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + throw claudeProfileSetupFailed() } } return this.preparation() } + /** An account's folder on this host, whether or not it exists yet. */ + accountHome(accountId: string): string { + return this.describe(accountId).home + } + + /** Creates and sets up an account's folder for sign-in; the login itself is Claude's. */ + async prepareAccount(accountId: string): Promise { + const profile = this.describe(accountId) + const report = await this.setUp(profile).catch(() => null) + if (report?.outcome !== 'prepared') { + throw new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + } + return profile.home + } + + /** Deletes an account's folder after any setup running for it, which would otherwise recreate it. */ + async removeAccount(accountId: string): Promise { + await this.setups.get(accountId)?.catch(() => {}) + await removeClaudeAccountFolder(this.args.dataRoot, accountId) + } + + /** True once an older Orca copied an account's login into System default: it left this snapshot. */ + copiedLoginIntoSystemDefault(): boolean { + return existsSync(join(this.args.dataRoot, 'claude-runtime-auth', 'system-default-auth.json')) + } + + /** The user's own CLAUDE_CONFIG_DIR, which wins over the selection in their terminals. */ + userConfigDir(): string | undefined { + return readUserClaudeConfigDir(this.env) + } + + private describe(accountId: string): ClaudeProfileDescriptor { + return describeClaudeProfile(this.args.dataRoot, accountId, { + executionHostId: 'local', + runtime: 'host' + }) + } + /** One setup per account at a time; a later request reuses the running one. */ private setUp(profile: ClaudeProfileDescriptor): Promise { const running = this.setups.get(profile.accountId) @@ -130,13 +181,14 @@ export class ClaudeProfileRouter { } private async runSetup(profile: ClaudeProfileDescriptor): Promise { + await this.envReady const hooks = isAgentStatusHooksEnabledForAgent(this.args.getSettings(), 'claude') const claudeVersion = hooks ? await probeClaudeCliVersion(resolveClaudeCommand()) : null const report = await (this.args.runSetup ?? runClaudeProfileSetupInWorker)({ dataRoot: this.args.dataRoot, profile, userHome: this.userHome, - userConfigDir: readUserClaudeConfigDir(this.args.env ?? process.env), + userConfigDir: this.userConfigDir(), hooks, claudeVersion: claudeVersion ?? undefined }) @@ -163,7 +215,12 @@ export class ClaudeProfileRouter { return { [CLAUDE_PROFILE_POINTER_ENV]: `~/${wslClaudeProfilePointer(this.args.dataRoot)}` } } try { - return this.launchEnv() + const env = this.launchEnv() + const userConfigDir = this.userConfigDir() + // Why: the injected value replaces the user's own; the claude function restores it on System default. + return env.CLAUDE_CONFIG_DIR && userConfigDir + ? { ...env, [CLAUDE_USER_CONFIG_DIR_ENV]: userConfigDir } + : env } catch { return { [CLAUDE_PROFILE_POINTER_ENV]: this.pointerPath } } @@ -187,3 +244,16 @@ export class ClaudeProfileRouter { return listClaudeProfileHomes(this.args.dataRoot) } } + +// Typed so a chat names the situation; a terminal reads the same message. +export function claudeProfileMissing(): AgentSessionPreSpawnError { + return new AgentSessionPreSpawnError(new Error(CLAUDE_PROFILE_MISSING_MESSAGE), { + reason: 'claudeAccountFolderMissing' + }) +} + +export function claudeProfileSetupFailed(): AgentSessionPreSpawnError { + return new AgentSessionPreSpawnError(new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE), { + reason: 'claudeAccountSetupFailed' + }) +} diff --git a/src/main/claude-accounts/claude-profile-setup.test.ts b/src/main/claude-accounts/claude-profile-setup.test.ts index 0195805eff32..0ead45be168d 100644 --- a/src/main/claude-accounts/claude-profile-setup.test.ts +++ b/src/main/claude-accounts/claude-profile-setup.test.ts @@ -48,7 +48,7 @@ function fixture() { installHooks, platform: 'linux' }) - return { root, defaultHome, dataRoot, service, setup } + return { root, defaultHome, dataRoot, service, installHooks, setup } } // Why: setup runs as 'linux', so it creates real symlinks, which Windows needs privilege for. @@ -83,6 +83,24 @@ describe('Claude account profile setup', () => { expect(existsSync(join(home, '.credentials.json'))).toBe(false) expect((await f.setup()).warnings).toEqual([]) }) + itLinks('marks the folder set up only after its last step', async () => { + const f = fixture() + const marker = join(f.dataRoot, 'claude-profiles/a/profile.json') + let markedDuringHooks: boolean | undefined + const report = await provisionClaudeAccountProfile({ + dataRoot: f.dataRoot, + profile: describeClaudeProfile(f.dataRoot, 'a', local), + userHome: state.home, + installHooks: (target) => { + markedDuringHooks = existsSync(marker) + return f.installHooks(target) + }, + platform: 'linux' + }) + expect(report.outcome).toBe('prepared') + expect(markedDuringHooks).toBe(false) + expect(existsSync(marker)).toBe(true) + }) itLinks("brings the user's later hooks into an account set up while they had none", async () => { const f = fixture() const settings = join(f.defaultHome, 'settings.json') diff --git a/src/main/claude-accounts/claude-profile-setup.ts b/src/main/claude-accounts/claude-profile-setup.ts index 1687d9cf0e8b..7d132e1c86d4 100644 --- a/src/main/claude-accounts/claude-profile-setup.ts +++ b/src/main/claude-accounts/claude-profile-setup.ts @@ -51,16 +51,21 @@ export async function provisionClaudeAccountProfile(args: { userConfigDir?: string /** Null when Orca's Claude hooks are turned off. Runs after the settings merge so its entries survive it. */ installHooks: ((target: { configDir: string }) => AgentHookInstallStatus) | null - trustKeys?: readonly string[] platform?: NodeJS.Platform }): Promise { const platform = args.platform ?? process.platform const report = createClaudeProfileReport() + let markSetUp: () => void try { if (args.profile.target.runtime === 'wsl' && platform === 'win32') { throw new ClaudeProfileSurfaceError('invalid-profile', 'WSL profiles are set up in the guest') } - prepareClaudeProfileDirectory(args.dataRoot, args.profile, args.userHome, args.userConfigDir) + markSetUp = prepareClaudeProfileDirectory( + args.dataRoot, + args.profile, + args.userHome, + args.userConfigDir + ) } catch (error) { report.surfaces.profile = 'failed' warnClaudeProfile(report, 'profile', error) @@ -70,7 +75,7 @@ export async function provisionClaudeAccountProfile(args: { const shared = { profileHome: home, userHome: args.userHome, userConfigDir: args.userConfigDir } for (const step of [ () => shareClaudeProfileHistory({ ...shared, platform }), - () => provisionClaudeProfile({ ...shared, platform, trustKeys: args.trustKeys }) + () => provisionClaudeProfile({ ...shared, platform }) ]) { try { const part = await step() @@ -92,5 +97,7 @@ export async function provisionClaudeAccountProfile(args: { recordInstalledHooks(home) return 'merged' }) + // Last: a setup cut off before here leaves no marker, so the next launch waits for a full one. + markSetUp() return { outcome: 'prepared', ...report } } diff --git a/src/main/claude-accounts/claude-profile-shell-composition.test.ts b/src/main/claude-accounts/claude-profile-shell-composition.test.ts index 4bf99fdac515..fb4ed6255e10 100644 --- a/src/main/claude-accounts/claude-profile-shell-composition.test.ts +++ b/src/main/claude-accounts/claude-profile-shell-composition.test.ts @@ -1,21 +1,13 @@ -import type * as ProfileRouting from '../../shared/claude-profile-routing' import { spawnSync } from 'node:child_process' import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, expect, it, vi } from 'vitest' -const gate = vi.hoisted(() => ({ enabled: true })) -vi.mock('../../shared/claude-profile-routing', async (original) => ({ - ...(await original()), - claudeProfileRoutingEnabled: () => gate.enabled -})) +import { afterEach, expect, it } from 'vitest' const FISH = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync) ?? '/usr/bin/fish' const roots: string[] = [] afterEach(() => { - gate.enabled = true - vi.resetModules() roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) }) function fishInit({ args }: { args: string[] | null }): string { @@ -25,9 +17,7 @@ function fishInit({ args }: { args: string[] | null }): string { } return init } -async function composed(enabled: boolean) { - gate.enabled = enabled - vi.resetModules() +async function composed() { const local = await import('../providers/local-pty-shell-ready') const daemon = await import('../daemon/shell-ready') const powershell = await import('../powershell-osc133-bootstrap') @@ -53,35 +43,19 @@ async function composed(enabled: boolean) { } } -it('keeps the composed bash, zsh, fish and PowerShell startup text dormant with the gate off', async () => { - const off = await composed(false) - for (const text of [...off.fish, off.powershell]) { - expect(text).not.toContain('claude') - } - for (const text of [...off.bash, ...off.zsh]) { - expect(text).not.toContain('ORCA_CLAUDE') - } - const on = await composed(true) +it('gives every composed shell startup and the plain fish vendor snippet the claude function', async () => { + const on = await composed() for (const text of [...on.bash, ...on.zsh]) { expect(text).toContain('ORCA_CLAUDE_INJECTED_CONFIG_DIR') } -}) - -it('gives a plain fish tab the claude function through the vendor snippet only with the gate on', async () => { - gate.enabled = false - vi.resetModules() - const off = await import('../fish-xdg-data-dirs-handoff') - expect(off.getFishVendorConfSnippet()).not.toContain('claude') - gate.enabled = true - vi.resetModules() - const on = await import('../fish-xdg-data-dirs-handoff') + const { getFishVendorConfSnippet } = await import('../fish-xdg-data-dirs-handoff') const { getFishClaudeShellFunction } = await import('../../shared/claude-shell-function') expect(getFishClaudeShellFunction()).toContain('function claude') - expect(on.getFishVendorConfSnippet()).toContain(getFishClaudeShellFunction()) + expect(getFishVendorConfSnippet()).toContain(getFishClaudeShellFunction()) }) it('starts the PowerShell claude function on its own line after the codex fragment', async () => { - const on = await composed(true) + const on = await composed() expect(on.powershell).toContain( 'Remove-Variable orcaCodexCommand -ErrorAction SilentlyContinue\n$orcaClaudeCommand' ) @@ -95,7 +69,7 @@ it.skipIf(!existsSync(FISH))( mkdirSync(join(root, 'bin')) writeFileSync(join(root, 'bin', 'claude'), '#!/bin/sh\nexit 0\n') chmodSync(join(root, 'bin', 'claude'), 0o700) - const on = await composed(true) + const on = await composed() for (const init of on.fish) { const result = spawnSync( '/usr/bin/env', diff --git a/src/main/claude-accounts/claude-profile-wsl-paths.ts b/src/main/claude-accounts/claude-profile-wsl-paths.ts index a5096c040eea..39d2bf677282 100644 --- a/src/main/claude-accounts/claude-profile-wsl-paths.ts +++ b/src/main/claude-accounts/claude-profile-wsl-paths.ts @@ -25,3 +25,12 @@ export function wslClaudeProfilePointer(hostDataRoot: string): string { const build = basename(hostDataRoot).replace(/[^\w.-]/g, '_') return `.local/share/orca/claude-profiles/selected-wsl-${build}` } + +/** A saved WSL account's guest folder; null for an older Orca's folder, which holds no login. */ +export function savedWslClaudeAccountHome(account: { + id: string + wslLinuxAuthPath?: string | null +}): string | null { + const home = account.wslLinuxAuthPath + return home?.endsWith(`/claude-profiles/${account.id}/home`) ? home : null +} diff --git a/src/main/claude-accounts/claude-profile-wsl-router.test.ts b/src/main/claude-accounts/claude-profile-wsl-router.test.ts index decde2f9d35a..00c16d881d20 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.test.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.test.ts @@ -6,6 +6,7 @@ import { mkdtempSync, readFileSync, rmSync, + symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -37,10 +38,12 @@ vi.mock('../wsl/wsl-runner', () => ({ import { CLAUDE_PROFILE_MISSING_MESSAGE, - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, - type ClaudeProfileRouterSettings -} from './claude-profile-router' + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE +} from '../../shared/claude-profile-routing' +import type { ClaudeProfileRouterSettings } from './claude-profile-router' +import { prepareClaudeProfileDirectory } from './claude-profile-paths' import { ClaudeWslProfileRouter } from './claude-profile-wsl-router' +import { wslClaudeProfile } from './claude-profile-wsl-paths' // Why skipped on Windows: the guest is Linux; these run its scripts and Node bundle as the guest. const posixHost = process.platform !== 'win32' @@ -75,12 +78,14 @@ function fixture() { const router = new ClaudeWslProfileRouter({ getSettings: () => settings, dataRoot: join(root, 'orca-dev'), + // Like the guest helper, marks the folder only once it finishes. runSetup: async () => { setup.calls += 1 await setup.gate if (setup.fail) { throw new Error('refused') } + mkdirSync(profileHome, { recursive: true }) writeFileSync(join(profileHome, '..', 'profile.json'), '{}') } }) @@ -90,14 +95,10 @@ function fixture() { } describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { - it('writes the guest pointer per build, sets up only a signed-in folder, and removes it with the last account', async () => { + it('writes the guest pointer per build, sets up even a missing folder, and removes it with the last account', async () => { const f = fixture() await f.router.publish('Ubuntu') expect(readFileSync(f.pointer, 'utf8')).toBe(f.profileHome) - expect(f.setup.calls).toBe(0) - - mkdirSync(f.profileHome, { recursive: true }) - await f.router.publish('Ubuntu') await vi.waitFor(() => expect(f.setup.calls).toBe(1)) f.wsl.Ubuntu = null @@ -109,15 +110,21 @@ describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { expect(existsSync(f.pointer)).toBe(false) }) - it('refuses a missing folder, waits for a never-set-up one, then launches at once', async () => { + it('sets up a missing folder, waits for a first setup never run or still running, then launches at once', async () => { const f = fixture() - await expect(f.router.prepareLaunch('Ubuntu')).rejects.toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) - - mkdirSync(f.profileHome, { recursive: true }) + await expect(f.router.preparation('Ubuntu')).rejects.toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) f.setup.fail = true - await expect(f.router.prepareLaunch('Ubuntu')).rejects.toThrow( - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE - ) + let release = () => {} + f.setup.gate = new Promise((resolve) => (release = resolve)) + const launch = f.router.prepareLaunch('Ubuntu') + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + const second = f.router.prepareLaunch('Ubuntu') + await new Promise((resolve) => setTimeout(resolve, 50)) + release() + await expect(launch).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + await expect(second).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + expect(f.setup.calls).toBe(1) + f.setup.fail = false const prepared = await f.router.prepareLaunch('Ubuntu') expect(prepared).toMatchObject({ @@ -131,10 +138,94 @@ describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { ORCA_CLAUDE_INJECTED_CONFIG_DIR: f.profileHome } }) + // The failed first setup left no marker, so this launch ran setup again; the next does not. + expect(f.setup.calls).toBe(2) await f.router.prepareLaunch('Ubuntu') expect(f.setup.calls).toBe(2) }) + it('makes a launch redo a first setup that was cut off after its ownership gate', async () => { + const f = fixture() + mkdirSync(f.profileHome, { recursive: true }) + // The guest helper failed, or timed out, after the gate ran. + const cutOff = new ClaudeWslProfileRouter({ + getSettings: () => f.settings, + dataRoot: join(guest.home, '..', 'orca-dev'), + runSetup: async (distro, home, accountId) => { + const { dataRoot, profile } = wslClaudeProfile(home, distro, accountId) + prepareClaudeProfileDirectory(dataRoot, profile, home) + throw new Error('timed out') + } + }) + await expect(cutOff.prepareLaunch('Ubuntu')).rejects.toThrow( + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE + ) + expect(existsSync(join(f.profileHome, '..', 'profile.json'))).toBe(false) + + await f.router.prepareLaunch('Ubuntu') + expect(f.setup.calls).toBe(1) + }) + + it('never makes a launch wait on, or fail with, a re-run of a set-up folder', async () => { + const f = fixture() + mkdirSync(f.profileHome, { recursive: true }) + writeFileSync(join(f.profileHome, '..', 'profile.json'), '{}') + f.setup.fail = true + let release = () => {} + f.setup.gate = new Promise((resolve) => (release = resolve)) + await f.router.publish('Ubuntu') + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + await expect(f.router.prepareLaunch('Ubuntu')).resolves.toMatchObject({ + configDir: f.profileHome + }) + release() + await new Promise((resolve) => setTimeout(resolve, 10)) + await expect(f.router.prepareLaunch('Ubuntu')).resolves.toMatchObject({ + configDir: f.profileHome + }) + expect(f.setup.calls).toBe(1) + }) + + it('sets up a new account folder for sign-in and deletes it without following its links', async () => { + const f = fixture() + const sharedHistory = join(guest.home, '.claude', 'projects') + mkdirSync(sharedHistory) + writeFileSync(join(sharedHistory, 'chat.jsonl'), '{}') + // The stub setup marks account a's folder. + mkdirSync(f.profileHome, { recursive: true }) + const home = await f.router.prepareAccount('Ubuntu', 'b') + expect(home).toBe(join(guest.home, '.local/share/orca/claude-profiles/b/home')) + expect(f.setup.calls).toBe(1) + mkdirSync(home, { recursive: true }) + symlinkSync(sharedHistory, join(home, 'projects')) + + await f.router.removeAccount('Ubuntu', 'b') + expect(existsSync(join(home, '..'))).toBe(false) + expect(existsSync(join(sharedHistory, 'chat.jsonl'))).toBe(true) + + f.setup.fail = true + await expect(f.router.prepareAccount('Ubuntu', 'c')).rejects.toThrow( + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE + ) + }) + + it('removes a guest folder only after its running setup, so the setup cannot bring it back', async () => { + const f = fixture() + mkdirSync(f.profileHome, { recursive: true }) + let release = () => {} + // Like the guest helper, setup recreates the folder it writes into. + f.setup.gate = new Promise((resolve) => (release = resolve)).then(() => { + mkdirSync(f.profileHome, { recursive: true }) + }) + await f.router.publish('Ubuntu') + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + const removal = f.router.removeAccount('Ubuntu', 'a') + await new Promise((resolve) => setTimeout(resolve, 50)) + release() + await removal + expect(existsSync(join(f.profileHome, '..'))).toBe(false) + }) + it('writes a missing guest pointer before a launch returns', async () => { const f = fixture() mkdirSync(f.profileHome, { recursive: true }) diff --git a/src/main/claude-accounts/claude-profile-wsl-router.ts b/src/main/claude-accounts/claude-profile-wsl-router.ts index 0fba0eb59a68..10c8273d9195 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.ts @@ -2,21 +2,21 @@ import { existsSync } from 'node:fs' import { lstat, readFile } from 'node:fs/promises' import { join, posix } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' -import { CLAUDE_PROFILE_POINTER_ENV } from '../../shared/claude-profile-routing' +import { + CLAUDE_INJECTED_CONFIG_DIR_ENV, + CLAUDE_PROFILE_POINTER_ENV, + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE +} from '../../shared/claude-profile-routing' import { WSL_CLAUDE_PROFILE_HELPER_FILENAME } from '../../shared/relay-artifacts' import { parseWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' import { getWslHomeAsync, listRunningWslDistrosAsync } from '../wsl' import { ensureWslPinnedRuntime } from '../wsl/wsl-pinned-runtime' import { relayBundleCandidates } from '../ssh/relay-bundle-paths' import { runWslProcess, type WslSpec } from '../wsl/wsl-runner' +import { claudeProfileMarkerPath, type ClaudeProfileDescriptor } from './claude-profile-paths' import { - CLAUDE_INJECTED_CONFIG_DIR_ENV, - claudeProfileMarkerPath, - type ClaudeProfileDescriptor -} from './claude-profile-paths' -import { - CLAUDE_PROFILE_MISSING_MESSAGE, - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, + claudeProfileMissing, + claudeProfileSetupFailed, type ClaudeProfileRouterSettings } from './claude-profile-router' import { wslClaudeProfile, wslClaudeProfilePointer } from './claude-profile-wsl-paths' @@ -90,22 +90,23 @@ export class ClaudeWslProfileRouter { return } await writePointer(distro, this.pointerIn(home), profile?.home ?? '') - // Why the existence check: setup creates the folder, and only sign-in may create an account. - if (profile && (await guestStat(distro, profile.home))?.isDirectory()) { + // Why even a missing folder: setup creates it without a login, for Claude's own first run. + if (profile) { this.setUp(distro, home, profile.accountId).catch((error: unknown) => { console.warn('[claude-profile] WSL account setup failed:', error) }) } } - /** Waits for setup only for a folder that was never set up; otherwise launches at once. */ + /** Waits for a first setup that never finished, running or not; otherwise launches at once. */ async prepareLaunch(distro: string): Promise { const { home, profile } = await this.resolve(distro) - await this.assertPresent(distro, profile) - if (profile && !(await guestStat(distro, claudeProfileMarkerPath(profile)))?.isFile()) { + // Why the marker: setup writes it last, so a missing folder is set up too. A re-run of a + // set-up folder never blocks. + if (profile && !(await hasMarker(distro, profile))) { await this.setUp(distro, home, profile.accountId).catch((error: unknown) => { console.warn('[claude-profile] WSL account setup failed:', error) - throw new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + throw claudeProfileSetupFailed() }) } // Why: a missing or stale guest pointer would run the pane's `claude` under another account. @@ -123,10 +124,32 @@ export class ClaudeWslProfileRouter { return this.preparationFor(distro, home, profile) } + /** Creates and sets up an account's guest folder for sign-in; the login itself is Claude's. */ + async prepareAccount(distro: string, accountId: string): Promise { + const { home } = await this.resolve(distro) + const { profile } = wslClaudeProfile(home, distro, accountId) + await this.setUp(distro, home, accountId).catch((error: unknown) => { + console.warn('[claude-profile] WSL account setup failed:', error) + throw new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + }) + return profile.home + } + + /** + * Deletes an account's guest folder after any setup running for it, which would otherwise + * recreate it. Linux `rm -r` never follows the history links. + */ + async removeAccount(distro: string, accountId: string): Promise { + await this.setups.get(accountId)?.catch(() => {}) + const { home } = await this.resolve(distro) + const folder = posix.dirname(wslClaudeProfile(home, distro, accountId).profile.home) + await runGuest(distro, { script: 'rm -rf -- "$1"', args: [folder], loginPath: 'none' }) + } + /** Falling back would run the wrong account. */ private async assertPresent(distro: string, profile: ClaudeProfileDescriptor | null) { if (profile && !(await guestStat(distro, profile.home))?.isDirectory()) { - throw new Error(CLAUDE_PROFILE_MISSING_MESSAGE) + throw claudeProfileMissing() } } @@ -172,6 +195,10 @@ async function guestStat(distro: string, linuxPath: string) { return lstat(toWindowsWslPath(linuxPath, distro)).catch(() => null) } +async function hasMarker(distro: string, profile: ClaudeProfileDescriptor): Promise { + return (await guestStat(distro, claudeProfileMarkerPath(profile)))?.isFile() ?? false +} + /** Writes in the guest only when the file differs, so a launch reads it over the share instead. */ async function writePointer(distro: string, pointer: string, home: string): Promise { // Why the timeout: a hung share must not stall startup's serialized publish; the guest write decides. diff --git a/src/main/claude-accounts/claude-windows-interactive-login.test.ts b/src/main/claude-accounts/claude-windows-interactive-login.test.ts deleted file mode 100644 index 69ca24c21a8f..000000000000 --- a/src/main/claude-accounts/claude-windows-interactive-login.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { EventEmitter } from 'node:events' -import { getCmdExePath } from '../../shared/windows-batch-spawn' -import { createService, restorePlatform, setPlatform } from './claude-account-service-test-harness' - -vi.mock('electron', () => ({ - app: { - getPath: () => '/tmp/orca-claude-windows-login-test' - } -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: vi.fn(() => 'C:\\Tools\\claude.cmd') -})) - -describe('Claude Windows host interactive login', () => { - afterEach(() => { - restorePlatform() - }) - - it('resolves a Windows auth login whose child has no piped streams', async () => { - setPlatform('win32') - vi.resetModules() - const child = new EventEmitter() as EventEmitter & { - stdout: null - stderr: null - kill: ReturnType - pid: number - } - child.stdout = null - child.stderr = null - child.kill = vi.fn() - child.pid = 4242 - const spawnMock = vi.fn(() => { - queueMicrotask(() => child.emit('exit', 0)) - return child - }) - const buildInteractiveLoginSpawn = vi.fn(() => ({ - command: getCmdExePath(), - args: [ - '/d', - '/c', - 'start', - '', - '/wait', - 'C:\\Tools\\claude.cmd', - 'auth', - 'login', - '--claudeai' - ], - stdio: 'ignore' as const, - windowsHide: true - })) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - vi.doMock('../../shared/windows-interactive-login-spawn', () => ({ - buildWindowsHostInteractiveLoginSpawn: buildInteractiveLoginSpawn - })) - - try { - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - createService() as never, - createService() as never, - createService() as never - ) - await ( - service as unknown as { - runClaudeCommand( - args: string[], - configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, - timeoutMs: number - ): Promise - } - ).runClaudeCommand( - ['auth', 'login', '--claudeai'], - { windowsPath: 'C:\\tmp\\claude-auth', linuxPath: null, wslDistro: null }, - 1000 - ) - - expect(buildInteractiveLoginSpawn).toHaveBeenCalledWith('C:\\Tools\\claude.cmd', [ - 'auth', - 'login', - '--claudeai' - ]) - expect(spawnMock).toHaveBeenCalledWith( - getCmdExePath(), - expect.arrayContaining(['start', '', '/wait']), - expect.objectContaining({ - stdio: 'ignore', - windowsHide: true - }) - ) - expect(child.kill).not.toHaveBeenCalled() - } finally { - vi.doUnmock('node:child_process') - vi.doUnmock('../../shared/windows-interactive-login-spawn') - } - }) - - it('times out a denied Windows login that stays alive without piped output', async () => { - setPlatform('win32') - vi.resetModules() - vi.useFakeTimers() - const child = new EventEmitter() as EventEmitter & { - stdout: null - stderr: null - kill: ReturnType - pid: number - } - child.stdout = null - child.stderr = null - child.kill = vi.fn() - child.pid = 0 - const spawnMock = vi.fn(() => child) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - - try { - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - createService() as never, - createService() as never, - createService() as never - ) - const login = ( - service as unknown as { - runClaudeCommand( - args: string[], - configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, - timeoutMs: number - ): Promise - } - ).runClaudeCommand( - ['auth', 'login', '--claudeai'], - { windowsPath: 'C:\\tmp\\claude-auth', linuxPath: null, wslDistro: null }, - 1000 - ) - const rejection = expect(login).rejects.toThrow('Claude sign-in took too long to finish.') - - await vi.advanceTimersByTimeAsync(3_000) - - await rejection - expect(child.kill).toHaveBeenCalledOnce() - } finally { - vi.useRealTimers() - vi.doUnmock('node:child_process') - } - }) -}) diff --git a/src/main/claude-accounts/environment.ts b/src/main/claude-accounts/environment.ts index 3fd7871c70d7..918de5250f5e 100644 --- a/src/main/claude-accounts/environment.ts +++ b/src/main/claude-accounts/environment.ts @@ -1,4 +1,9 @@ import type { ClaudeManagedAccount } from '../../shared/managed-account-types' +import { + CLAUDE_INJECTED_CONFIG_DIR_ENV, + CLAUDE_PROFILE_POINTER_ENV, + CLAUDE_USER_CONFIG_DIR_ENV +} from '../../shared/claude-profile-routing' export const CLAUDE_AUTH_ENV_VARS = [ 'ANTHROPIC_API_KEY', @@ -8,8 +13,9 @@ export const CLAUDE_AUTH_ENV_VARS = [ ] as const export type ClaudeEnvPatch = { - ORCA_CLAUDE_PROFILE_POINTER?: string - ORCA_CLAUDE_INJECTED_CONFIG_DIR?: string + [CLAUDE_PROFILE_POINTER_ENV]?: string + [CLAUDE_INJECTED_CONFIG_DIR_ENV]?: string + [CLAUDE_USER_CONFIG_DIR_ENV]?: string CLAUDE_CONFIG_DIR?: string ANTHROPIC_CUSTOM_HEADERS?: string } @@ -35,11 +41,15 @@ export function applyClaudeEnvPatch( } } - if (patch.ORCA_CLAUDE_PROFILE_POINTER) { - baseEnv.ORCA_CLAUDE_PROFILE_POINTER = patch.ORCA_CLAUDE_PROFILE_POINTER - } - if (patch.ORCA_CLAUDE_INJECTED_CONFIG_DIR) { - baseEnv.ORCA_CLAUDE_INJECTED_CONFIG_DIR = patch.ORCA_CLAUDE_INJECTED_CONFIG_DIR + for (const key of [ + CLAUDE_PROFILE_POINTER_ENV, + CLAUDE_INJECTED_CONFIG_DIR_ENV, + CLAUDE_USER_CONFIG_DIR_ENV + ] as const) { + const value = patch[key] + if (value) { + baseEnv[key] = value + } } if (patch.CLAUDE_CONFIG_DIR) { baseEnv.CLAUDE_CONFIG_DIR = patch.CLAUDE_CONFIG_DIR @@ -56,9 +66,6 @@ export function applyClaudeEnvPatch( export const CLAUDE_AUTH_ENV_CONFLICT_MESSAGE = 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' -export const CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE = - 'A Claude account switch is in progress. Try again after it finishes.' - /** * Whether a launch on the host runtime must drop inherited Anthropic auth. * diff --git a/src/main/claude-accounts/keychain.test.ts b/src/main/claude-accounts/keychain.test.ts index 3ee2c6b77593..3bd725ba068f 100644 --- a/src/main/claude-accounts/keychain.test.ts +++ b/src/main/claude-accounts/keychain.test.ts @@ -1,277 +1,21 @@ -import { createHash } from 'node:crypto' -import { execFile } from 'node:child_process' -import { mkdtempSync, realpathSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - deleteActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentialsStrict, - writeActiveClaudeKeychainCredentials, - writeActiveClaudeKeychainCredentialsForRuntime -} from './keychain' +import { afterEach, expect, it, vi } from 'vitest' -vi.mock('node:child_process', () => ({ - execFile: vi.fn() -})) +const reads = vi.hoisted(() => vi.fn(async () => null)) +vi.mock('../macos-keychain/generic-password', () => ({ readKeychainPassword: reads })) -const execFileMock = vi.mocked(execFile) -const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') -const originalUser = process.env.USER -const originalUsername = process.env.USERNAME -const TEST_USER = 'orca-test-user' -const SSO_USER = 'sso.user@example.com' -let configDir: string - -function setPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { - configurable: true, - value: platform - }) -} - -function serviceForConfigDir(configDir: string): string { - const suffix = createHash('sha256').update(configDir).digest('hex').slice(0, 8) - return `Claude Code-credentials-${suffix}` -} - -function invokeExecFileCallback( - callback: unknown, - error: Error | null, - stdout: string, - stderr: string -): void { - const execCallback = callback as (error: Error | null, stdout: string, stderr: string) => void - execCallback(error, stdout, stderr) -} - -describe('Claude Keychain credentials', () => { - beforeEach(() => { - configDir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-claude-keychain-'))) - setPlatform('darwin') - execFileMock.mockReset() - process.env.USER = TEST_USER - delete process.env.USERNAME - }) - - afterEach(() => { - rmSync(configDir, { recursive: true, force: true }) - vi.useRealTimers() - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - if (originalUser === undefined) { - delete process.env.USER - } else { - process.env.USER = originalUser - } - if (originalUsername === undefined) { - delete process.env.USERNAME - } else { - process.env.USERNAME = originalUsername - } - }) - - it('reads config-scoped Claude Code 2.1 credentials before legacy credentials', async () => { - const scopedService = serviceForConfigDir(configDir) - execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { - invokeExecFileCallback(callback, null, '{"claudeAiOauth":{"accessToken":"scoped"}}\n', '') - return null as never - }) - - await expect(readActiveClaudeKeychainCredentials(configDir)).resolves.toBe( - '{"claudeAiOauth":{"accessToken":"scoped"}}' - ) - - expect(execFileMock).toHaveBeenCalledTimes(1) - expect(execFileMock.mock.calls[0][1]).toEqual([ - 'find-generic-password', - '-s', - scopedService, - '-a', - TEST_USER, - '-w' - ]) - }) - - it('falls back to the legacy unsuffixed Claude Code credentials service', async () => { - const notFound = Object.assign(new Error('not found'), { code: 44 }) - execFileMock - .mockImplementationOnce((_file, _args, _options, callback) => { - invokeExecFileCallback(callback, notFound, '', 'could not be found') - return null as never - }) - .mockImplementationOnce((_file, _args, _options, callback) => { - invokeExecFileCallback(callback, null, 'legacy\n', '') - return null as never - }) - - await expect(readActiveClaudeKeychainCredentials(configDir)).resolves.toBe('legacy') - - expect(execFileMock.mock.calls[1][1]).toEqual([ - 'find-generic-password', - '-s', - 'Claude Code-credentials', - '-a', - TEST_USER, - '-w' - ]) - }) - - it('writes active credentials to the config-scoped Claude Code service', async () => { - const scopedService = serviceForConfigDir(configDir) - execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { - invokeExecFileCallback(callback, null, '', '') - return null as never - }) - - await writeActiveClaudeKeychainCredentials('credentials-json', configDir) - - expect(execFileMock.mock.calls[0][1]).toEqual([ - 'add-generic-password', - '-U', - '-s', - scopedService, - '-a', - TEST_USER, - '-w', - 'credentials-json' - ]) - }) - - it('writes runtime credentials to scoped and legacy services for old Claude Code compatibility', async () => { - const scopedService = serviceForConfigDir(configDir) - execFileMock.mockImplementation((_file, _args, _options, callback) => { - invokeExecFileCallback(callback, null, '', '') - return null as never - }) - - await writeActiveClaudeKeychainCredentialsForRuntime('credentials-json', configDir) +import { readActiveClaudeKeychainCredentialsStrict } from './keychain' - expect(execFileMock.mock.calls.map((call) => call[1])).toEqual([ - [ - 'add-generic-password', - '-U', - '-s', - scopedService, - '-a', - TEST_USER, - '-w', - 'credentials-json' - ], - [ - 'add-generic-password', - '-U', - '-s', - 'Claude Code-credentials', - '-a', - TEST_USER, - '-w', - 'credentials-json' - ] - ]) - }) - - it('strictly reads only the requested active credentials service', async () => { - const scopedService = serviceForConfigDir(configDir) - execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { - invokeExecFileCallback(callback, null, 'scoped\n', '') - return null as never - }) - - await expect(readActiveClaudeKeychainCredentialsStrict(configDir)).resolves.toBe('scoped') - - expect(execFileMock).toHaveBeenCalledTimes(1) - expect(execFileMock.mock.calls[0][1]).toEqual([ - 'find-generic-password', - '-s', - scopedService, - '-a', - TEST_USER, - '-w' - ]) - }) - - it('rejects when a keychain read never reports completion', async () => { - vi.useFakeTimers() - const killMock = vi.fn() - execFileMock.mockImplementationOnce(() => ({ kill: killMock }) as never) - - let settled = false - let rejected: unknown - const readPromise = readActiveClaudeKeychainCredentialsStrict(configDir).then( - (credentials) => { - settled = true - return credentials - }, - (error: unknown) => { - settled = true - rejected = error - return null - } - ) - - await vi.advanceTimersByTimeAsync(3000) - - expect(settled).toBe(true) - await readPromise - expect(rejected).toEqual( - expect.objectContaining({ message: 'security timed out after 3000ms' }) - ) - expect(killMock).toHaveBeenCalled() - }) - - it('deletes both scoped and legacy active credentials for config-dir cleanup', async () => { - const scopedService = serviceForConfigDir(configDir) - execFileMock.mockImplementation((_file, _args, _options, callback) => { - invokeExecFileCallback(callback, null, '', '') - return null as never - }) - - await deleteActiveClaudeKeychainCredentials(configDir) - - expect(execFileMock.mock.calls.map((call) => call[1])).toEqual([ - ['delete-generic-password', '-s', scopedService, '-a', TEST_USER], - ['delete-generic-password', '-s', 'Claude Code-credentials', '-a', TEST_USER] - ]) - }) - - it('looks up claude-code-user when $USER contains @ (#12857)', async () => { - process.env.USER = SSO_USER - execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { - invokeExecFileCallback(callback, null, '{"claudeAiOauth":{"accessToken":"ok"}}\n', '') - return null as never - }) - - await expect(readActiveClaudeKeychainCredentials()).resolves.toBe( - '{"claudeAiOauth":{"accessToken":"ok"}}' - ) - expect(execFileMock.mock.calls[0][1]).toEqual([ - 'find-generic-password', - '-s', - 'Claude Code-credentials', - '-a', - 'claude-code-user', - '-w' - ]) - }) - - it('cleans both Claude Code and raw $USER Keychain accounts after a failed SSO login', async () => { - process.env.USER = SSO_USER - const scopedService = serviceForConfigDir(configDir) - execFileMock.mockImplementation((_file, _args, _options, callback) => { - invokeExecFileCallback(callback, null, '', '') - return null as never - }) - - await deleteActiveClaudeKeychainCredentials(configDir) +const originalUser = process.env.USER +afterEach(() => { + process.env.USER = originalUser + reads.mockClear() +}) - expect(execFileMock.mock.calls.map((call) => call[1])).toEqual([ - ['delete-generic-password', '-s', scopedService, '-a', 'claude-code-user'], - ['delete-generic-password', '-s', scopedService, '-a', SSO_USER], - ['delete-generic-password', '-s', 'Claude Code-credentials', '-a', 'claude-code-user'], - ['delete-generic-password', '-s', 'Claude Code-credentials', '-a', SSO_USER] - ]) - }) +it('reads the claude-code-user item when $USER is not a valid Keychain account (#12857)', async () => { + process.env.USER = 'first@example.com' + await readActiveClaudeKeychainCredentialsStrict() + expect(reads).toHaveBeenCalledWith('Claude Code-credentials', 'claude-code-user') + process.env.USER = 'first.last' + await readActiveClaudeKeychainCredentialsStrict() + expect(reads).toHaveBeenLastCalledWith('Claude Code-credentials', 'first.last') }) diff --git a/src/main/claude-accounts/keychain.ts b/src/main/claude-accounts/keychain.ts index aa4dcfa64dbc..14070e6bb631 100644 --- a/src/main/claude-accounts/keychain.ts +++ b/src/main/claude-accounts/keychain.ts @@ -2,36 +2,18 @@ import { createHash } from 'node:crypto' import { lstatSync, realpathSync } from 'node:fs' import { userInfo } from 'node:os' import { basename, dirname, join } from 'node:path' -import { - deleteKeychainPassword, - readKeychainPassword, - writeKeychainPassword -} from '../macos-keychain/generic-password' +import { readKeychainPassword } from '../macos-keychain/generic-password' const ACTIVE_CLAUDE_SERVICE = 'Claude Code-credentials' -const ORCA_CLAUDE_SERVICE = 'Orca Claude Code Managed Credentials' -export async function readActiveClaudeKeychainCredentials( - configDir?: string -): Promise { - for (const service of getActiveClaudeServices(configDir)) { - const credentials = await readKeychainPassword(service, getKeychainUser()) - if (credentials) { - return credentials - } - } - return null -} - export async function readActiveClaudeKeychainCredentialsStrict( configDir?: string ): Promise { if (!configDir) { - return readKeychainPassword(getActiveClaudeService(), getKeychainUser()) + return readKeychainPassword(claudeKeychainService(), getKeychainUser()) } - // Why: macOS tmp is /var → /private/var. Claude hashes the realpath; a - // mkdtemp login dir would miss the Keychain item if we only hashed the raw path. + // Keep both lexical and canonical profile aliases scoped; never try System Default. for (const dir of claudeConfigDirKeychainAliases(configDir)) { - const credentials = await readKeychainPassword(getActiveClaudeService(dir), getKeychainUser()) + const credentials = await readKeychainPassword(claudeKeychainService(dir), getKeychainUser()) if (credentials) { return credentials } @@ -39,63 +21,6 @@ export async function readActiveClaudeKeychainCredentialsStrict( return null } -export async function writeActiveClaudeKeychainCredentials( - contents: string, - configDir?: string -): Promise { - await writeKeychainPassword(getActiveClaudeService(configDir), getKeychainUser(), contents) -} - -export async function writeActiveClaudeKeychainCredentialsForRuntime( - contents: string, - configDir: string -): Promise { - const user = getKeychainUser() - const scopedService = getActiveClaudeService(configDir) - await writeKeychainPassword(scopedService, user, contents) - if (scopedService !== ACTIVE_CLAUDE_SERVICE) { - await writeKeychainPassword(ACTIVE_CLAUDE_SERVICE, user, contents) - } -} - -export async function deleteActiveClaudeKeychainCredentials(configDir?: string): Promise { - for (const service of getActiveClaudeServices(configDir)) { - for (const account of getKeychainUsersForCleanup()) { - await deleteKeychainPassword(service, account) - } - } -} - -export async function deleteActiveClaudeKeychainCredentialsStrict( - configDir?: string -): Promise { - const dirs = configDir ? claudeConfigDirKeychainAliases(configDir) : [undefined] - for (const dir of dirs) { - for (const account of getKeychainUsersForCleanup()) { - await deleteKeychainPassword(getActiveClaudeService(dir), account, { - failOnAccessError: true - }) - } - } -} - -export async function readManagedClaudeKeychainCredentials( - accountId: string -): Promise { - return readKeychainPassword(ORCA_CLAUDE_SERVICE, accountId) -} - -export async function writeManagedClaudeKeychainCredentials( - accountId: string, - contents: string -): Promise { - await writeKeychainPassword(ORCA_CLAUDE_SERVICE, accountId, contents) -} - -export async function deleteManagedClaudeKeychainCredentials(accountId: string): Promise { - await deleteKeychainPassword(ORCA_CLAUDE_SERVICE, accountId) -} - const KEYCHAIN_ACCOUNT_PATTERN = /^[a-zA-Z0-9._-]+$/ const CLAUDE_CODE_FALLBACK_USER = 'claude-code-user' @@ -111,13 +36,7 @@ function getKeychainUser(): string { return KEYCHAIN_ACCOUNT_PATTERN.test(user) ? user : CLAUDE_CODE_FALLBACK_USER } -function getKeychainUsersForCleanup(): string[] { - const derived = getKeychainUser() - const raw = process.env.USER || process.env.USERNAME - return raw && raw !== derived ? [derived, raw] : [derived] -} - -function getActiveClaudeService(configDir?: string): string { +export function claudeKeychainService(configDir?: string): string { if (!configDir) { return ACTIVE_CLAUDE_SERVICE } @@ -173,10 +92,16 @@ export function claudeConfigDirKeychainAliases(configDir: string): string[] { return aliases } -function getActiveClaudeServices(configDir?: string): string[] { - if (!configDir) { - return [ACTIVE_CLAUDE_SERVICE] +/** Every spelling of a folder Claude may have hashed into its Keychain item name (superset U/profiles.ts). */ +export function claudeConfigDirSpellings(configDir: string, userHome: string): string[] { + const spellings = new Set() + for (const dir of claudeConfigDirKeychainAliases(configDir)) { + const trimmed = dir.replace(/[\\/]+$/, '') + const rest = trimmed.startsWith(`${userHome}/`) ? trimmed.slice(userHome.length) : null + for (const spelling of [trimmed, ...(rest ? [`~${rest}`, `$HOME${rest}`] : [])]) { + spellings.add(spelling) + spellings.add(`${spelling}/`) + } } - const scoped = claudeConfigDirKeychainAliases(configDir).map((dir) => getActiveClaudeService(dir)) - return [...new Set([...scoped, ACTIVE_CLAUDE_SERVICE])] + return [...spellings] } diff --git a/src/main/claude-accounts/live-pty-gate.test.ts b/src/main/claude-accounts/live-pty-gate.test.ts deleted file mode 100644 index 7359bbd98055..000000000000 --- a/src/main/claude-accounts/live-pty-gate.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - attachClaudeLivePtyPersistence, - beginClaudeAuthSwitch, - confirmSeededClaudeLivePtys, - endClaudeAuthSwitch, - hasLiveClaudePtys, - isClaudeAuthSwitchInProgress, - markClaudePtyExited, - markClaudePtySpawned, - onLiveClaudePtysDrained, - seedLiveClaudePtysFromPersistence -} from './live-pty-gate' - -describe('Claude live PTY gate', () => { - afterEach(() => { - markClaudePtyExited('live-claude-pty') - markClaudePtyExited('seeded-pty-1') - markClaudePtyExited('seeded-pty-2') - confirmSeededClaudeLivePtys([]) - attachClaudeLivePtyPersistence(null) - endClaudeAuthSwitch() - }) - - it('allows switching while Claude PTYs are live', () => { - markClaudePtySpawned('live-claude-pty') - - beginClaudeAuthSwitch() - - expect(isClaudeAuthSwitchInProgress()).toBe(true) - }) - - it('still rejects overlapping account switches', () => { - beginClaudeAuthSwitch() - - expect(() => beginClaudeAuthSwitch()).toThrow('already in progress') - }) - - it('counts seeded session ids as live until confirmed dead', () => { - seedLiveClaudePtysFromPersistence(['seeded-pty-1', 'seeded-pty-2']) - - expect(hasLiveClaudePtys()).toBe(true) - - confirmSeededClaudeLivePtys(['seeded-pty-1']) - - expect(hasLiveClaudePtys()).toBe(true) - - confirmSeededClaudeLivePtys([]) - - expect(hasLiveClaudePtys()).toBe(true) - - markClaudePtyExited('seeded-pty-1') - - expect(hasLiveClaudePtys()).toBe(false) - }) - - it('releases seeded ids the daemon no longer knows', () => { - const removeClaudeLivePtySessionId = vi.fn() - attachClaudeLivePtyPersistence({ - addClaudeLivePtySessionId: vi.fn(), - removeClaudeLivePtySessionId - }) - seedLiveClaudePtysFromPersistence(['seeded-pty-1', 'seeded-pty-2']) - - confirmSeededClaudeLivePtys(['seeded-pty-2']) - - expect(hasLiveClaudePtys()).toBe(true) - expect(removeClaudeLivePtySessionId).toHaveBeenCalledWith('seeded-pty-1') - expect(removeClaudeLivePtySessionId).not.toHaveBeenCalledWith('seeded-pty-2') - }) - - it('keeps a seeded id confirmed by a real spawn out of later pruning', () => { - seedLiveClaudePtysFromPersistence(['seeded-pty-1']) - markClaudePtySpawned('seeded-pty-1') - - confirmSeededClaudeLivePtys([]) - - expect(hasLiveClaudePtys()).toBe(true) - }) - - it('notifies drain listeners only when the last live Claude PTY exits', () => { - const onDrained = vi.fn() - const unsubscribe = onLiveClaudePtysDrained(onDrained) - try { - markClaudePtySpawned('live-claude-pty') - markClaudePtySpawned('seeded-pty-1') - - markClaudePtyExited('live-claude-pty') - expect(onDrained).not.toHaveBeenCalled() - - markClaudePtyExited('seeded-pty-1') - expect(onDrained).toHaveBeenCalledTimes(1) - - // Why: exits with no live PTYs left must not fire again — the drain - // signal marks the 1 -> 0 transition, not every teardown call. - markClaudePtyExited('seeded-pty-1') - expect(onDrained).toHaveBeenCalledTimes(1) - } finally { - unsubscribe() - } - }) - - it('notifies drain listeners when seed reconciliation releases the last live id', () => { - const onDrained = vi.fn() - const unsubscribe = onLiveClaudePtysDrained(onDrained) - try { - seedLiveClaudePtysFromPersistence(['seeded-pty-1']) - - confirmSeededClaudeLivePtys([]) - - expect(onDrained).toHaveBeenCalledTimes(1) - } finally { - unsubscribe() - } - }) - - it('stops notifying an unsubscribed drain listener', () => { - const onDrained = vi.fn() - const unsubscribe = onLiveClaudePtysDrained(onDrained) - unsubscribe() - - markClaudePtySpawned('live-claude-pty') - markClaudePtyExited('live-claude-pty') - - expect(onDrained).not.toHaveBeenCalled() - }) - - it('persists spawns and exits when persistence is attached', () => { - const addClaudeLivePtySessionId = vi.fn() - const removeClaudeLivePtySessionId = vi.fn() - attachClaudeLivePtyPersistence({ - addClaudeLivePtySessionId, - removeClaudeLivePtySessionId - }) - - markClaudePtySpawned('live-claude-pty') - expect(addClaudeLivePtySessionId).toHaveBeenCalledWith('live-claude-pty') - - markClaudePtyExited('live-claude-pty') - expect(removeClaudeLivePtySessionId).toHaveBeenCalledWith('live-claude-pty') - }) -}) diff --git a/src/main/claude-accounts/live-pty-gate.ts b/src/main/claude-accounts/live-pty-gate.ts deleted file mode 100644 index caab66c34308..000000000000 --- a/src/main/claude-accounts/live-pty-gate.ts +++ /dev/null @@ -1,174 +0,0 @@ -const liveClaudePtyIds = new Set() -// Why: ids restored from persistence at startup, not yet confirmed against the -// daemon. They keep the OAuth refresh gate closed so an early managed refresh -// cannot rotate the single-use refresh token out from under a Claude CLI that -// survived the app restart inside the daemon. -const seededUnconfirmedPtyIds = new Set() -let switchInProgress = false -// Woken by endClaudeAuthSwitch so a caller past the point of no return can wait the -// swap out instead of refusing. See whenClaudeAuthSwitchSettles. -const switchSettledListeners = new Set<() => void>() - -/** A managed account swap is a credential-file rewrite, not a network round trip; - * anything past this is a wedged switch, and refusing beats waiting forever. */ -export const CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS = 15_000 - -export type ClaudeLivePtyPersistence = { - addClaudeLivePtySessionId(sessionId: string): void - removeClaudeLivePtySessionId(sessionId: string): void -} - -let persistence: ClaudeLivePtyPersistence | null = null - -export function attachClaudeLivePtyPersistence(target: ClaudeLivePtyPersistence | null): void { - persistence = target -} - -// Why: a live claude defers the managed OAuth refresh ("Waiting for Claude -// session"); consumers need the 1 -> 0 transition to recover promptly instead -// of waiting out the usage-fetch failure backoff. -type LiveClaudePtyDrainListener = () => void -const drainListeners = new Set() - -export function onLiveClaudePtysDrained(listener: LiveClaudePtyDrainListener): () => void { - drainListeners.add(listener) - return () => drainListeners.delete(listener) -} - -function notifyDrainedOnTransition(hadLivePtys: boolean): void { - if (!hadLivePtys || liveClaudePtyIds.size > 0) { - return - } - for (const listener of drainListeners) { - listener() - } -} - -export function seedLiveClaudePtysFromPersistence(sessionIds: readonly string[]): void { - for (const sessionId of sessionIds) { - liveClaudePtyIds.add(sessionId) - seededUnconfirmedPtyIds.add(sessionId) - } -} - -export function hasSeededUnconfirmedClaudePtys(): boolean { - return seededUnconfirmedPtyIds.size > 0 -} - -/** - * Reconcile seeded ids against the daemon's live session list. Seeded ids the - * daemon no longer knows are dead — release them so they cannot defer OAuth - * refresh forever. Seeded ids that are still alive stay in the gate even if - * their pane never reattaches: that daemon process still owns the credentials. - */ -export function confirmSeededClaudeLivePtys(aliveSessionIds: readonly string[]): void { - const hadLivePtys = liveClaudePtyIds.size > 0 - const alive = new Set(aliveSessionIds) - for (const sessionId of seededUnconfirmedPtyIds) { - if (!alive.has(sessionId)) { - liveClaudePtyIds.delete(sessionId) - persistence?.removeClaudeLivePtySessionId(sessionId) - } - } - seededUnconfirmedPtyIds.clear() - notifyDrainedOnTransition(hadLivePtys) -} - -export function markClaudePtySpawned(ptyId: string): void { - liveClaudePtyIds.add(ptyId) - seededUnconfirmedPtyIds.delete(ptyId) - persistence?.addClaudeLivePtySessionId(ptyId) -} - -export function markClaudePtyExited(ptyId: string): void { - const hadLivePtys = liveClaudePtyIds.size > 0 - liveClaudePtyIds.delete(ptyId) - seededUnconfirmedPtyIds.delete(ptyId) - persistence?.removeClaudeLivePtySessionId(ptyId) - notifyDrainedOnTransition(hadLivePtys) -} - -/** - * Register a structured Claude child with the same gate the terminal path uses. - * - * The gate is what makes the managed OAuth refresh defer instead of rotating a - * single-use refresh token out from under a running Claude (runtime-auth-sync.ts). - * A structured session's child is as much a live Claude as a PTY's is, so it has to - * hold the gate too — otherwise a refresh mid-turn breaks its next API call while an - * identical terminal session is protected. - * - * Deliberately not persisted, unlike markClaudePtySpawned: these children are direct - * children of this process and cannot survive a restart, so seeding them back on the - * next launch would hold the gate closed for a process that is provably gone. - */ -export function markClaudeStructuredChildSpawned(childKey: string): void { - liveClaudePtyIds.add(structuredChildGateId(childKey)) -} - -export function markClaudeStructuredChildExited(childKey: string): void { - const hadLivePtys = liveClaudePtyIds.size > 0 - liveClaudePtyIds.delete(structuredChildGateId(childKey)) - notifyDrainedOnTransition(hadLivePtys) -} - -// Namespaced so a structured child can never collide with a daemon PTY session id, -// which confirmSeededClaudeLivePtys reconciles against the daemon's own list. -function structuredChildGateId(childKey: string): string { - return `claude-structured:${childKey}` -} - -export function hasLiveClaudePtys(): boolean { - return liveClaudePtyIds.size > 0 -} - -export function beginClaudeAuthSwitch(): void { - if (switchInProgress) { - throw new Error('A Claude account switch is already in progress.') - } - switchInProgress = true -} - -export function endClaudeAuthSwitch(): void { - const wasInProgress = switchInProgress - switchInProgress = false - if (!wasInProgress) { - return - } - // Each listener removes itself as it settles; Set iteration is defined over that. - for (const listener of switchSettledListeners) { - listener() - } -} - -/** - * Resolves `true` once no account switch is running, `false` if one is still running - * at the deadline. - * - * Exists for callers that have already done irreversible work — a structured acquire - * has closed the old child by the time it resolves its launch, so turning a switch - * into a refusal there strands the user with a dead session and no replacement. - * Waiting for the swap and then launching against it is the recoverable answer; - * refusing is only correct when nothing has been torn down yet. - */ -export function whenClaudeAuthSwitchSettles( - timeoutMs = CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS -): Promise { - if (!switchInProgress) { - return Promise.resolve(true) - } - return new Promise((resolve) => { - const settle = (settled: boolean): void => { - switchSettledListeners.delete(listener) - clearTimeout(timer) - resolve(settled) - } - const listener = (): void => settle(true) - switchSettledListeners.add(listener) - const timer = setTimeout(() => settle(false), timeoutMs) - timer.unref?.() - }) -} - -export function isClaudeAuthSwitchInProgress(): boolean { - return switchInProgress -} diff --git a/src/main/claude-accounts/managed-auth-path.ts b/src/main/claude-accounts/managed-auth-path.ts deleted file mode 100644 index 2914b72e0334..000000000000 --- a/src/main/claude-accounts/managed-auth-path.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { existsSync, lstatSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' -import { join, relative, resolve, sep } from 'node:path' -import { app } from 'electron' -import { writeFileAtomically } from '../codex-accounts/fs-utils' - -const MANAGED_AUTH_MARKER = '.orca-managed-claude-auth' - -export function getClaudeManagedAccountsRoot(): string { - return join(app.getPath('userData'), 'claude-accounts') -} - -export function resolveOwnedClaudeManagedAuthPath( - accountId: string, - candidatePath: string, - options: { adoptLegacyMarker?: boolean } = {} -): string | null { - const rootPath = getClaudeManagedAccountsRoot() - const resolvedCandidate = resolve(candidatePath) - if (!existsSync(resolvedCandidate) || !existsSync(rootPath)) { - return null - } - try { - if (lstatSync(resolvedCandidate).isSymbolicLink()) { - return null - } - const canonicalCandidate = realpathSync(resolvedCandidate) - const canonicalRoot = realpathSync(rootPath) - if ( - canonicalCandidate === canonicalRoot || - !canonicalCandidate.startsWith(canonicalRoot + sep) - ) { - return null - } - const relativePath = relative(canonicalRoot, canonicalCandidate) - const relativeParts = relativePath.split(sep) - const escaped = relativePath.startsWith('..') || relativePath.includes(`..${sep}`) - if ( - escaped || - relativeParts.length !== 2 || - relativeParts[0] !== accountId || - relativeParts[1] !== 'auth' - ) { - return null - } - const markerPath = join(canonicalCandidate, MANAGED_AUTH_MARKER) - const markerValid = isManagedAuthMarkerValid(markerPath, accountId) - if (!markerValid && options.adoptLegacyMarker) { - writeFileSync(markerPath, `${accountId}\n`, { encoding: 'utf-8', mode: 0o600, flag: 'wx' }) - } - if (!markerValid && !isManagedAuthMarkerValid(markerPath, accountId)) { - return null - } - return canonicalCandidate - } catch { - return null - } -} - -export function readClaudeManagedAuthFile( - managedAuthPath: string, - filename: '.credentials.json' | 'oauth-account.json' -): string | null { - const filePath = resolve(managedAuthPath, filename) - try { - if (!isOwnedChildFile(managedAuthPath, filePath)) { - return null - } - return readFileSync(filePath, 'utf-8') - } catch { - return null - } -} - -export function writeClaudeManagedAuthFile( - managedAuthPath: string, - filename: '.credentials.json' | 'oauth-account.json', - contents: string -): void { - const filePath = resolve(managedAuthPath, filename) - if (existsSync(filePath) && !isOwnedChildFile(managedAuthPath, filePath)) { - throw new Error('Managed Claude auth child file is not owned by Orca.') - } - writeFileAtomically(filePath, contents, { mode: 0o600 }) -} - -function isManagedAuthMarkerValid(markerPath: string, accountId: string): boolean { - try { - if ( - !existsSync(markerPath) || - lstatSync(markerPath).isSymbolicLink() || - !lstatSync(markerPath).isFile() - ) { - return false - } - return readFileSync(markerPath, 'utf-8').trim() === accountId - } catch { - return false - } -} - -function isOwnedChildFile(managedAuthPath: string, filePath: string): boolean { - if ( - !existsSync(filePath) || - lstatSync(filePath).isSymbolicLink() || - !lstatSync(filePath).isFile() - ) { - return false - } - const canonicalAuthPath = realpathSync(managedAuthPath) - const canonicalFilePath = realpathSync(filePath) - return canonicalFilePath.startsWith(canonicalAuthPath + sep) -} diff --git a/src/main/claude-accounts/oauth-refresh.test.ts b/src/main/claude-accounts/oauth-refresh.test.ts deleted file mode 100644 index dd5e01c245a5..000000000000 --- a/src/main/claude-accounts/oauth-refresh.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - applyRefreshedToken, - isOauthTokenExpiring, - parseClaudeOauthBlob, - readRefreshToken, - refreshClaudeOauthCredentials -} from './oauth-refresh' - -const { netFetchMock } = vi.hoisted(() => ({ - netFetchMock: vi.fn() -})) - -vi.mock('electron', () => ({ - net: { fetch: netFetchMock }, - session: { defaultSession: {} } -})) - -vi.mock('../network/proxy-settings', () => ({ - ensureElectronProxyFromEnvironment: vi.fn().mockResolvedValue({ source: 'none' }) -})) - -const NOW = 1_700_000_000_000 - -function credentials(overrides: Record = {}): string { - return JSON.stringify({ - claudeAiOauth: { - accessToken: 'old-access', - refreshToken: 'old-refresh', - expiresAt: NOW + 60 * 60 * 1000, - scopes: ['user:inference', 'user:profile'], - ...overrides - } - }) -} - -describe('parseClaudeOauthBlob', () => { - it('returns the oauth block', () => { - expect(parseClaudeOauthBlob(credentials())?.accessToken).toBe('old-access') - }) - - it('returns null for non-JSON or missing block', () => { - expect(parseClaudeOauthBlob('not json')).toBeNull() - expect(parseClaudeOauthBlob('{}')).toBeNull() - expect(parseClaudeOauthBlob('{"claudeAiOauth":[]}')).toBeNull() - }) -}) - -describe('readRefreshToken', () => { - it('reads a present token', () => { - expect(readRefreshToken(credentials())).toBe('old-refresh') - }) - - it('returns null for blank or missing tokens', () => { - expect(readRefreshToken(credentials({ refreshToken: ' ' }))).toBeNull() - expect(readRefreshToken(credentials({ refreshToken: undefined }))).toBeNull() - }) -}) - -describe('isOauthTokenExpiring', () => { - it('is false when well within validity', () => { - expect(isOauthTokenExpiring(credentials(), NOW)).toBe(false) - }) - - it('is true within the 5-minute buffer', () => { - expect(isOauthTokenExpiring(credentials({ expiresAt: NOW + 60 * 1000 }), NOW)).toBe(true) - }) - - it('is true when already expired', () => { - expect(isOauthTokenExpiring(credentials({ expiresAt: NOW - 1000 }), NOW)).toBe(true) - }) - - it('treats missing/non-numeric expiry as expiring', () => { - expect(isOauthTokenExpiring(credentials({ expiresAt: undefined }), NOW)).toBe(true) - expect(isOauthTokenExpiring(credentials({ expiresAt: 'soon' }), NOW)).toBe(true) - }) - - it('is false for credentials without an oauth block', () => { - expect(isOauthTokenExpiring('{}', NOW)).toBe(false) - }) -}) - -describe('applyRefreshedToken', () => { - it('rotates access + refresh token and recomputes expiry', () => { - const updated = applyRefreshedToken( - credentials(), - { access_token: 'new-access', expires_in: 3600, refresh_token: 'new-refresh' }, - NOW - ) - const oauth = parseClaudeOauthBlob(updated!)! - expect(oauth.accessToken).toBe('new-access') - expect(oauth.refreshToken).toBe('new-refresh') - expect(oauth.expiresAt).toBe(NOW + 3600 * 1000) - }) - - it('keeps the existing refresh token when the server does not rotate it', () => { - const updated = applyRefreshedToken( - credentials(), - { access_token: 'new-access', expires_in: 3600 }, - NOW - ) - expect(parseClaudeOauthBlob(updated!)!.refreshToken).toBe('old-refresh') - }) - - it('preserves unrelated top-level fields', () => { - const raw = JSON.stringify({ - claudeAiOauth: { accessToken: 'a', refreshToken: 'r' }, - somethingElse: { keep: true } - }) - const updated = applyRefreshedToken(raw, { access_token: 'b' }, NOW) - expect(JSON.parse(updated!).somethingElse).toEqual({ keep: true }) - }) - - it('splits scope string into scopes array', () => { - const updated = applyRefreshedToken( - credentials(), - { access_token: 'b', scope: 'user:inference user:profile' }, - NOW - ) - expect(parseClaudeOauthBlob(updated!)!.scopes).toEqual(['user:inference', 'user:profile']) - }) - - it('returns null when the response lacks an access token', () => { - expect(applyRefreshedToken(credentials(), {}, NOW)).toBeNull() - expect(applyRefreshedToken('not json', { access_token: 'b' }, NOW)).toBeNull() - }) -}) - -describe('refreshClaudeOauthCredentials', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - it('returns null without a refresh token (no network call)', async () => { - const result = await refreshClaudeOauthCredentials( - credentials({ refreshToken: undefined }), - NOW - ) - expect(result).toBeNull() - expect(netFetchMock).not.toHaveBeenCalled() - }) - - it('posts a form-urlencoded refresh grant and persists the rotation', async () => { - netFetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ - access_token: 'fresh-access', - expires_in: 3600, - refresh_token: 'fresh-refresh' - }) - }) - - const result = await refreshClaudeOauthCredentials(credentials(), NOW) - - expect(netFetchMock).toHaveBeenCalledTimes(1) - const [url, init] = netFetchMock.mock.calls[0] - expect(url).toBe('https://platform.claude.com/v1/oauth/token') - expect(init.method).toBe('POST') - expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded') - const body = new URLSearchParams(init.body) - expect(body.get('grant_type')).toBe('refresh_token') - expect(body.get('refresh_token')).toBe('old-refresh') - expect(body.get('client_id')).toBe('9d1c250a-e61b-44d9-88ed-5944d1962f5e') - - const oauth = parseClaudeOauthBlob(result!)! - expect(oauth.accessToken).toBe('fresh-access') - expect(oauth.refreshToken).toBe('fresh-refresh') - }) - - it('returns null on a non-ok response and logs the status for diagnosability', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - netFetchMock.mockResolvedValue({ ok: false, status: 429, json: async () => ({}) }) - expect(await refreshClaudeOauthCredentials(credentials(), NOW)).toBeNull() - expect(warn).toHaveBeenCalledWith(expect.stringContaining('429')) - warn.mockRestore() - }) - - it('returns null when the request throws (never rejects)', async () => { - netFetchMock.mockRejectedValue(new Error('network down')) - await expect(refreshClaudeOauthCredentials(credentials(), NOW)).resolves.toBeNull() - }) -}) diff --git a/src/main/claude-accounts/oauth-refresh.ts b/src/main/claude-accounts/oauth-refresh.ts deleted file mode 100644 index cc717ef51ee8..000000000000 --- a/src/main/claude-accounts/oauth-refresh.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { net, session } from 'electron' -import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings' - -// Why: the OAuth client id and token endpoint are the public Claude Code -// values, verified against the installed `claude` binary (2.1.177) and the -// claude-swap reference tool. Orca owns the refresh so a single-use refresh -// token is rotated and persisted atomically, instead of being scraped back -// after the CLI rotates it (the lossy path that strands stale tokens). -const OAUTH_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token' -const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e' - -// Refresh slightly ahead of expiry so a token doesn't expire mid-launch. The -// CLI uses the same 5-minute skew for its own refresh decision. -const OAUTH_EXPIRY_BUFFER_MS = 5 * 60 * 1000 -const REFRESH_TIMEOUT_MS = 10_000 - -type ClaudeOauthBlob = { - accessToken?: unknown - refreshToken?: unknown - expiresAt?: unknown - scopes?: unknown - [key: string]: unknown -} - -type ClaudeCredentials = { - claudeAiOauth?: ClaudeOauthBlob - [key: string]: unknown -} - -type TokenEndpointResponse = { - access_token?: unknown - expires_in?: unknown - refresh_token?: unknown - scope?: unknown -} - -/** - * Parse the `claudeAiOauth` object from a credentials JSON string. - * Returns null when the string is not parseable or lacks the OAuth block. - */ -export function parseClaudeOauthBlob(credentialsJson: string): ClaudeOauthBlob | null { - try { - const parsed = JSON.parse(credentialsJson) as ClaudeCredentials - const oauth = parsed?.claudeAiOauth - return oauth && typeof oauth === 'object' && !Array.isArray(oauth) ? oauth : null - } catch { - return null - } -} - -/** Read a stored refresh token, or null when absent/blank. */ -export function readRefreshToken(credentialsJson: string): string | null { - const oauth = parseClaudeOauthBlob(credentialsJson) - const token = oauth?.refreshToken - return typeof token === 'string' && token.trim() !== '' ? token.trim() : null -} - -/** - * Whether the stored access token is expired or within the refresh buffer. - * - * A missing/non-numeric `expiresAt` is treated as "needs refresh" so a blob - * with no usable expiry metadata still gets a proactive refresh attempt rather - * than being trusted indefinitely. `now` is injectable for tests. - */ -export function isOauthTokenExpiring(credentialsJson: string, now: number = Date.now()): boolean { - const oauth = parseClaudeOauthBlob(credentialsJson) - if (!oauth) { - return false - } - const expiresAt = oauth.expiresAt - if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) { - return true - } - return now + OAUTH_EXPIRY_BUFFER_MS >= expiresAt -} - -/** - * Merge a token-endpoint response into the stored credentials, returning the - * updated credentials JSON. Preserves every field the caller already had - * (including the refresh token when the server does not rotate it) and only - * overwrites what the response provides. Returns null on malformed input. - */ -export function applyRefreshedToken( - credentialsJson: string, - response: TokenEndpointResponse, - now: number = Date.now() -): string | null { - let parsed: ClaudeCredentials - try { - parsed = JSON.parse(credentialsJson) as ClaudeCredentials - } catch { - return null - } - const accessToken = response.access_token - if (typeof accessToken !== 'string' || accessToken.trim() === '') { - return null - } - const oauth: ClaudeOauthBlob = { ...parsed.claudeAiOauth } - oauth.accessToken = accessToken - if (typeof response.expires_in === 'number' && Number.isFinite(response.expires_in)) { - oauth.expiresAt = now + response.expires_in * 1000 - } - // Rotation: keep the existing refresh token unless the server issued a new - // one. Single-use refresh tokens make persisting the rotated value the whole - // point of owning refresh. - if (typeof response.refresh_token === 'string' && response.refresh_token.trim() !== '') { - oauth.refreshToken = response.refresh_token - } - if (typeof response.scope === 'string' && response.scope.trim() !== '') { - oauth.scopes = response.scope.split(' ') - } - parsed.claudeAiOauth = oauth - return JSON.stringify(parsed) -} - -/** - * Refresh the OAuth token for a stored credentials blob. - * - * Returns the updated credentials JSON (with the rotated refresh token and new - * access token) on success, or null on any failure. Never throws — callers - * treat null as "keep the existing credentials", so a transient network error - * is never worse than today's behavior. - */ -export async function refreshClaudeOauthCredentials( - credentialsJson: string, - now: number = Date.now() -): Promise { - const refreshToken = readRefreshToken(credentialsJson) - if (!refreshToken) { - return null - } - - await ensureElectronProxyFromEnvironment({ - proxySession: session.defaultSession, - probeUrl: OAUTH_TOKEN_URL - }).catch(() => {}) - - try { - // Why: the `claude` CLI posts grant_type=refresh_token as - // application/x-www-form-urlencoded with the public client id. net.fetch - // routes through Chromium's stack so the env proxy bridge above applies. - const res = await net.fetch(OAUTH_TOKEN_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - client_id: OAUTH_CLIENT_ID - }).toString(), - signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS) - }) - if (!res.ok) { - // Why: surface the status (never the token) so a throttle (429) or a - // dead refresh token (400/401 invalid_grant) is diagnosable in the - // field, instead of a silent null that looks identical to success. - // Callers keep the existing credentials on null — a transient 429 just - // means the still-valid token is reused until the next attempt. - console.warn(`[claude-oauth-refresh] token endpoint returned ${res.status}`) - return null - } - const data = (await res.json()) as TokenEndpointResponse - return applyRefreshedToken(credentialsJson, data, now) - } catch (error) { - console.warn( - '[claude-oauth-refresh] token refresh request failed:', - error instanceof Error ? error.message : error - ) - return null - } -} diff --git a/src/main/claude-accounts/runtime-auth-path-materialization.test.ts b/src/main/claude-accounts/runtime-auth-path-materialization.test.ts deleted file mode 100644 index dbccd87cee5e..000000000000 --- a/src/main/claude-accounts/runtime-auth-path-materialization.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { existsSync, mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getDefaultSettings } from '../../shared/constants' - -const testState = { - fakeHomeDir: '', - previousConfigDir: undefined as string | undefined -} - -vi.mock('electron', () => ({ app: { getPath: () => testState.fakeHomeDir } })) - -vi.mock('node:os', async () => { - // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - const actual = await vi.importActual('node:os') - return { ...actual, homedir: () => testState.fakeHomeDir } -}) - -const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - -beforeEach(() => { - testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-claude-rate-limit-path-')) - testState.previousConfigDir = process.env.CLAUDE_CONFIG_DIR - delete process.env.CLAUDE_CONFIG_DIR -}) - -afterEach(() => { - rmSync(testState.fakeHomeDir, { recursive: true, force: true }) - if (testState.previousConfigDir === undefined) { - delete process.env.CLAUDE_CONFIG_DIR - } else { - process.env.CLAUDE_CONFIG_DIR = testState.previousConfigDir - } - testState.fakeHomeDir = '' -}) - -describe('Claude runtime auth path materialization', () => { - it('does not create the config directory while preparing a background usage fetch', async () => { - const settings = { - ...getDefaultSettings(testState.fakeHomeDir), - disabledTuiAgents: ['claude'] as const, - claudeManagedAccounts: [], - activeClaudeManagedAccountId: null - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn() - } - const service = new ClaudeRuntimeAuthService(store as never) - - const preparation = await service.prepareForRateLimitFetch() - - expect(preparation.configDir).toBe(join(testState.fakeHomeDir, '.claude')) - expect(preparation.provenance).toBe('system') - expect(existsSync(preparation.configDir)).toBe(false) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-account-switching.test.ts b/src/main/claude-accounts/runtime-auth-service-account-switching.test.ts deleted file mode 100644 index 249e403b075b..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-account-switching.test.ts +++ /dev/null @@ -1,509 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createClaudeAccount, - createClaudeCredentialsJson, - createClaudeCredentialsWithoutEmail, - createElectronMock, - createKeychainMock, - createManagedClaudeAuth, - createOauthRefreshMock, - createSettings, - createStore, - readManagedCredentialsForTest, - readRuntimeOauthAccountForTest, - resetRuntimeAuthTestState, - testState -} from './runtime-auth-service-test-harness' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - -vi.mock('electron', () => createElectronMock()) - -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) - -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { - ...actual, - homedir: () => testState.fakeHomeDir - } -}) - -vi.mock('./keychain', () => createKeychainMock()) - -describe('ClaudeRuntimeAuthService', () => { - beforeEach(() => { - resetRuntimeAuthTestState() - }) - - afterEach(() => { - cleanupRuntimeAuthTestState() - }) - - it('saves a verified file refresh but refuses to overwrite unreadable keychain state', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') - const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - testState.throwScopedKeychainRead = true - testState.throwLegacyKeychainRead = true - await expect(service.syncForCurrentSelection()).rejects.toThrow('scoped keychain read failed') - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - expect(testState.scopedKeychainCredentials).toBe(originalCredentials) - expect(testState.legacyKeychainCredentials).toBe(originalCredentials) - warn.mockRestore() - }) - - it('captures a fresh system-default snapshot when re-entering managed mode', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials1 = createClaudeCredentialsJson('system1@example.com', 'system1') - const systemCredentials2 = createClaudeCredentialsJson('system2@example.com', 'system2') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials1, 'utf-8') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - writeFileSync(runtimeCredentialsPath, systemCredentials2, 'utf-8') - - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials2) - }) - - it('refreshes keychain and oauth snapshot surfaces when the credentials file is unchanged', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials1 = createClaudeCredentialsJson('system1@example.com', 'system1') - const systemCredentials2 = createClaudeCredentialsJson('system2@example.com', 'system2') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const systemOauthAccount1 = { accountUuid: 'system-account-1' } - const systemOauthAccount2 = { accountUuid: 'system-account-2' } - writeFileSync(runtimeCredentialsPath, systemCredentials1, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount1 })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = systemCredentials1 - testState.legacyKeychainCredentials = systemCredentials1 - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount2 })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = systemCredentials2 - testState.legacyKeychainCredentials = systemCredentials2 - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials1) - expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount2) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials2) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials2) - }) - - it('reads back refreshed credentials for the outgoing Claude account before switching', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1Original = createClaudeCredentialsJson('one@example.com', 'one-original') - const account1Refreshed = createClaudeCredentialsJson('one@example.com', 'one-refreshed') - const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Original - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, account1Refreshed, 'utf-8') - settings.activeClaudeManagedAccountId = 'account-2' - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Refreshed) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) - }) - - it('switches accounts without persisting unverified live runtime credentials', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1Original = createClaudeCredentialsJson('one@example.com', 'one-original', 'org-a') - const unverifiedLiveCredentials = createClaudeCredentialsWithoutEmail('one-live', 'org-b') - const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two', 'org-c') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Original - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { - email: 'one@example.com', - organizationUuid: 'org-a' - }), - createClaudeAccount('account-2', managedAuthPath2, { - email: 'two@example.com', - organizationUuid: 'org-c' - }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const { markClaudePtyExited, markClaudePtySpawned } = await import('./live-pty-gate') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - markClaudePtySpawned('live-claude-pty') - try { - writeFileSync(runtimeCredentialsPath, unverifiedLiveCredentials, 'utf-8') - settings.activeClaudeManagedAccountId = 'account-2' - - await service.syncForCurrentSelection() - } finally { - markClaudePtyExited('live-claude-pty') - } - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Original) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) - if (process.platform === 'darwin') { - expect(testState.scopedKeychainCredentials).toBe(account2Credentials) - } - }) - - it('routes refreshed Claude credentials to the matching managed account', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1Original = createClaudeCredentialsJson('one@example.com', 'one-original') - const account1Refreshed = createClaudeCredentialsJson('one@example.com', 'one-refreshed') - const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Original - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) - ], - activeClaudeManagedAccountId: 'account-2' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - // A stale account-1 Claude process refreshed the shared runtime file after - // Orca selected account-2. Persist that refresh to account-1, then restore - // the selected account in the shared Claude runtime credentials. - writeFileSync(runtimeCredentialsPath, account1Refreshed, 'utf-8') - testState.scopedKeychainCredentials = account1Refreshed - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Refreshed) - expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) - if (process.platform === 'darwin') { - expect(testState.scopedKeychainCredentials).toBe(account2Credentials) - expect(testState.legacyKeychainCredentials).toBe(account2Credentials) - } - }) - - it('rejects stale cold-start read-back for inactive matching account', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1ManagedNewer = createClaudeCredentialsJson( - 'one@example.com', - 'one-managed-newer', - null, - 5_000 - ) - const account1RuntimeStale = createClaudeCredentialsJson( - 'one@example.com', - 'one-runtime-stale', - null, - 2_000 - ) - const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two', null, 1_000) - writeFileSync(runtimeCredentialsPath, account1RuntimeStale, 'utf-8') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1ManagedNewer - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) - ], - activeClaudeManagedAccountId: 'account-2' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1ManagedNewer) - expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) - }) - - it('rejects ambiguous Claude read-back instead of choosing a managed account', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('same@example.com', 'same-original') - const refreshedCredentials = createClaudeCredentialsJson('same@example.com', 'same-refreshed') - const activeCredentials = createClaudeCredentialsJson('active@example.com', 'active') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - originalCredentials - ) - const managedAuthPath3 = createManagedClaudeAuth( - testState.userDataDir, - 'account-3', - activeCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'same@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { email: 'same@example.com' }), - createClaudeAccount('account-3', managedAuthPath3, { email: 'active@example.com' }) - ], - activeClaudeManagedAccountId: 'account-3' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - testState.scopedKeychainCredentials = refreshedCredentials - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(originalCredentials) - expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(originalCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(activeCredentials) - if (process.platform === 'darwin') { - expect(testState.scopedKeychainCredentials).toBe(activeCredentials) - expect(testState.legacyKeychainCredentials).toBe(activeCredentials) - } - }) - - it('rejects same-email read-back when another account needs organization proof', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const noOrgCredentials = createClaudeCredentialsJson('same@example.com', 'no-org') - const orgCredentials = createClaudeCredentialsJson('same@example.com', 'org', 'org-b') - const refreshedWithoutOrg = createClaudeCredentialsJson('same@example.com', 'refreshed') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - noOrgCredentials - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - orgCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'same@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { - email: 'same@example.com', - organizationUuid: 'org-b' - }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedWithoutOrg, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(noOrgCredentials) - expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(orgCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(noOrgCredentials) - }) - - it('ignores unrelated org-scoped accounts when reading back no-org credentials', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') - const account1RefreshedCredentials = createClaudeCredentialsJson( - 'one@example.com', - 'one-refreshed' - ) - const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two', 'org-b') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Credentials - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { - email: 'two@example.com', - organizationUuid: 'org-b' - }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, account1RefreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe( - account1RefreshedCredentials - ) - expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1RefreshedCredentials) - }) - - it('rejects same-email read-back with conflicting organization for no-org accounts', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const noOrgCredentials = createClaudeCredentialsJson('same@example.com', 'no-org') - const orgCredentials = createClaudeCredentialsJson('same@example.com', 'org', 'org-b') - const conflictingOrgCredentials = createClaudeCredentialsJson( - 'same@example.com', - 'conflicting-org', - 'org-c' - ) - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - noOrgCredentials - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - orgCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'same@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { - email: 'same@example.com', - organizationUuid: 'org-b' - }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, conflictingOrgCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(noOrgCredentials) - expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(orgCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(noOrgCredentials) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-deselect-restore.test.ts b/src/main/claude-accounts/runtime-auth-service-deselect-restore.test.ts deleted file mode 100644 index efd650618b41..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-deselect-restore.test.ts +++ /dev/null @@ -1,611 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createClaudeAccount, - createClaudeCredentialsJson, - createClaudeCredentialsWithoutEmail, - createElectronMock, - createKeychainMock, - createManagedClaudeAuth, - createOauthRefreshMock, - createSettings, - createStore, - readManagedCredentialsForTest, - readRuntimeOauthAccountForTest, - resetRuntimeAuthTestState, - setPlatform, - testState -} from './runtime-auth-service-test-harness' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - -vi.mock('electron', () => createElectronMock()) - -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) - -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { - ...actual, - homedir: () => testState.fakeHomeDir - } -}) - -vi.mock('./keychain', () => createKeychainMock()) - -describe('ClaudeRuntimeAuthService', () => { - beforeEach(() => { - resetRuntimeAuthTestState() - }) - - afterEach(() => { - cleanupRuntimeAuthTestState() - }) - - it('restores the system default after rejecting unverifiable managed credentials', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - const originalCredentials = createClaudeCredentialsWithoutEmail('original') - const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - }) - - it('restores system default after same-identity managed Claude refresh on deselect', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed', 'org-1') - const externalCredentials = createClaudeCredentialsJson('user@example.com', 'external', 'org-1') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { - organizationUuid: 'org-1' - }) - ] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8') - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(externalCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - }) - - it('preserves external stale Claude credentials without writing them to managed storage', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const selectedCredentials = createClaudeCredentialsJson('selected@example.com', 'selected') - const staleCredentials = createClaudeCredentialsJson('stale@example.com', 'stale') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - selectedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { email: 'selected@example.com' }) - ] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('does not persist unverifiable stale Claude credentials into another active account', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') - const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') - const staleUnverifiableCredentials = createClaudeCredentialsWithoutEmail('stale') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Credentials - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - settings.activeClaudeManagedAccountId = 'account-2' - await service.syncForCurrentSelection() - writeFileSync(runtimeCredentialsPath, staleUnverifiableCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) - }) - - it('materializes only the selected managed account into shared Claude runtime files', async () => { - setPlatform('linux') - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one-token') - const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two-token') - const account1Oauth = '{"accountUuid":"account-1","emailAddress":"one@example.com"}\n' - const account2Oauth = '{"accountUuid":"account-2","emailAddress":"two@example.com"}\n' - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Credentials, - account1Oauth - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials, - account2Oauth - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - - await service.syncForCurrentSelection() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1Credentials) - expect(readRuntimeOauthAccountForTest()).toEqual({ - accountUuid: 'account-1', - emailAddress: 'one@example.com' - }) - - settings.activeClaudeManagedAccountId = 'account-2' - await service.syncForCurrentSelection() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) - expect(readRuntimeOauthAccountForTest()).toEqual({ - accountUuid: 'account-2', - emailAddress: 'two@example.com' - }) - - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1Credentials) - expect(readRuntimeOauthAccountForTest()).toEqual({ - accountUuid: 'account-1', - emailAddress: 'one@example.com' - }) - - // Why: switching rewrites only the shared Claude runtime surface; the - // managed account files remain per-account sources of truth. - expect(readFileSync(join(managedAuthPath1, '.credentials.json'), 'utf-8')).toBe( - account1Credentials - ) - expect(readFileSync(join(managedAuthPath2, '.credentials.json'), 'utf-8')).toBe( - account2Credentials - ) - expect(readFileSync(join(managedAuthPath1, 'oauth-account.json'), 'utf-8')).toBe(account1Oauth) - expect(readFileSync(join(managedAuthPath2, 'oauth-account.json'), 'utf-8')).toBe(account2Oauth) - }) - - it('does not carry the reauth read-back skip across Claude account switches', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') - const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') - const account2RefreshedCredentials = createClaudeCredentialsJson( - 'two@example.com', - 'two-refreshed' - ) - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Credentials - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - service.clearLastWrittenCredentialsJson() - settings.activeClaudeManagedAccountId = 'account-2' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, account2RefreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe( - account2RefreshedCredentials - ) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2RefreshedCredentials) - }) - - it('does not apply inactive-account Claude reauth skip to the active account', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') - const account1RefreshedCredentials = createClaudeCredentialsJson( - 'one@example.com', - 'one-refreshed' - ) - const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Credentials - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, account1RefreshedCredentials, 'utf-8') - service.clearLastWrittenCredentialsJson('account-2') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe( - account1RefreshedCredentials - ) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1RefreshedCredentials) - }) - - it('keeps external Claude logout when deselecting managed account', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - rmSync(runtimeCredentialsPath, { force: true }) - testState.activeKeychainCredentials = null - testState.scopedKeychainCredentials = null - testState.legacyKeychainCredentials = null - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(existsSync(runtimeCredentialsPath)).toBe(false) - expect(testState.scopedKeychainCredentials).toBeNull() - expect(testState.legacyKeychainCredentials).toBeNull() - }) - - it('preserves unknown runtime auth when invalid active account has no ownership proof', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') - const systemOauthAccount = { accountUuid: 'system-account' } - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - writeFileSync( - snapshotPath, - `${JSON.stringify({ - credentialsJson: systemCredentials, - configOauthAccount: systemOauthAccount, - keychainCredentialsJson: systemCredentials, - scopedKeychainCredentialsJson: systemCredentials, - legacyKeychainCredentialsJson: systemCredentials, - capturedAt: Date.now() - })}\n`, - 'utf-8' - ) - writeFileSync(runtimeCredentialsPath, staleManagedCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'missing-account' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = staleManagedCredentials - testState.legacyKeychainCredentials = staleManagedCredentials - const settings = createSettings({ - activeClaudeManagedAccountId: 'missing-account' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - const preparation = await service.prepareForClaudeLaunch() - - expect(store.updateSettings).toHaveBeenCalledWith({ - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } - }) - expect(preparation.configDir).toBe(join(testState.fakeHomeDir, '.claude')) - expect(preparation.stripAuthEnv).toBe(false) - expect(preparation.provenance).toBe('system') - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleManagedCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'missing-account' }) - expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) - expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) - }) - - it('restores system snapshot when active account credentials are missing but runtime matches account', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') - const systemOauthAccount = { accountUuid: 'system-account' } - const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync( - snapshotPath, - `${JSON.stringify({ - credentialsJson: systemCredentials, - configOauthAccount: systemOauthAccount, - keychainCredentialsJson: systemCredentials, - scopedKeychainCredentialsJson: systemCredentials, - legacyKeychainCredentialsJson: systemCredentials, - capturedAt: Date.now() - })}\n`, - 'utf-8' - ) - writeFileSync(runtimeCredentialsPath, staleManagedCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = staleManagedCredentials - testState.legacyKeychainCredentials = staleManagedCredentials - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - const preparation = await service.prepareForClaudeLaunch() - - expect(preparation.provenance).toBe('system') - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleManagedCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'account-1' }) - expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) - expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) - }) - - it('keeps missing-managed selection until cleanup can retry after keychain failure', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') - const systemOauthAccount = { accountUuid: 'system-account' } - const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync( - snapshotPath, - `${JSON.stringify({ - credentialsJson: systemCredentials, - configOauthAccount: systemOauthAccount, - keychainCredentialsJson: systemCredentials, - scopedKeychainCredentialsJson: systemCredentials, - legacyKeychainCredentialsJson: systemCredentials, - capturedAt: Date.now() - })}\n`, - 'utf-8' - ) - writeFileSync(runtimeCredentialsPath, staleManagedCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = staleManagedCredentials - testState.legacyKeychainCredentials = staleManagedCredentials - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - testState.throwScopedKeychainWrite = true - const failedPreparation = await service.prepareForClaudeLaunch() - - expect(failedPreparation.provenance).toBe('system') - expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleManagedCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'account-1' }) - expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) - - testState.throwScopedKeychainWrite = false - const preparation = await service.prepareForClaudeLaunch() - - expect(preparation.provenance).toBe('system') - expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() - expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) - expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) - }) - - it('restores missing-managed oauth metadata when only keychain proves ownership', async () => { - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') - const systemOauthAccount = { accountUuid: 'system-account' } - const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync( - snapshotPath, - `${JSON.stringify({ - credentialsJson: systemCredentials, - configOauthAccount: systemOauthAccount, - keychainCredentialsJson: systemCredentials, - scopedKeychainCredentialsJson: systemCredentials, - legacyKeychainCredentialsJson: systemCredentials, - capturedAt: Date.now() - })}\n`, - 'utf-8' - ) - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = staleManagedCredentials - testState.legacyKeychainCredentials = staleManagedCredentials - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.prepareForClaudeLaunch() - - expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'account-1' }) - expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) - expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) - }) - - it('preserves unknown runtime auth when invalid active account has no system snapshot', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, staleManagedCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'missing-account' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = staleManagedCredentials - testState.legacyKeychainCredentials = staleManagedCredentials - const settings = createSettings({ - activeClaudeManagedAccountId: 'missing-account' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - const preparation = await service.prepareForClaudeLaunch() - - expect(preparation.provenance).toBe('system') - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleManagedCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'missing-account' }) - expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) - expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts b/src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts deleted file mode 100644 index 7a80c5be002a..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts +++ /dev/null @@ -1,712 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createClaudeAccount, - createClaudeCredentialsJson, - createElectronMock, - createKeychainMock, - createManagedClaudeAuth, - createOauthRefreshMock, - createSettings, - createStore, - expectedRuntimeConfigDir, - readManagedCredentialsForTest, - readRuntimeOauthAccountForTest, - resetRuntimeAuthTestState, - testState -} from './runtime-auth-service-test-harness' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - -vi.mock('electron', () => createElectronMock()) - -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) - -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { - ...actual, - homedir: () => testState.fakeHomeDir - } -}) - -vi.mock('./keychain', () => createKeychainMock()) - -describe('ClaudeRuntimeAuthService', () => { - beforeEach(() => { - resetRuntimeAuthTestState() - }) - - afterEach(() => { - cleanupRuntimeAuthTestState() - }) - - it('reads back refreshed active keychain credentials on macOS', async () => { - if (process.platform !== 'darwin') { - return - } - - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') - const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - testState.scopedKeychainCredentials = refreshedCredentials - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - }) - - it('reads back refreshed legacy keychain credentials on old Claude Code builds', async () => { - if (process.platform !== 'darwin') { - return - } - - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') - const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - testState.scopedKeychainCredentials = originalCredentials - testState.legacyKeychainCredentials = refreshedCredentials - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - expect(testState.scopedKeychainCredentials).toBe(refreshedCredentials) - expect(testState.legacyKeychainCredentials).toBe(refreshedCredentials) - }) - - it('rejects stale legacy keychain credentials after a fresher managed write', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', null, 1_000) - const managedCredentials = createClaudeCredentialsJson( - 'user@example.com', - 'managed-newer', - null, - 2_000 - ) - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - testState.scopedKeychainCredentials = managedCredentials - testState.legacyKeychainCredentials = staleCredentials - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(managedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) - expect(testState.scopedKeychainCredentials).toBe(managedCredentials) - expect(testState.legacyKeychainCredentials).toBe(managedCredentials) - }) - - it('uses fresher file credentials when scoped keychain is stale', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', null, 1_000) - const managedCredentials = createClaudeCredentialsJson( - 'user@example.com', - 'managed', - null, - 2_000 - ) - const refreshedCredentials = createClaudeCredentialsJson( - 'user@example.com', - 'refreshed', - null, - 3_000 - ) - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - testState.scopedKeychainCredentials = staleCredentials - testState.legacyKeychainCredentials = managedCredentials - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - expect(testState.scopedKeychainCredentials).toBe(refreshedCredentials) - expect(testState.legacyKeychainCredentials).toBe(refreshedCredentials) - }) - - it('restores system default when mismatched Claude keychain auth appears before deselect', async () => { - if (process.platform !== 'darwin') { - return - } - - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const externalKeychainCredentials = createClaudeCredentialsJson( - 'external@example.com', - 'external' - ) - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - testState.scopedKeychainCredentials = externalKeychainCredentials - testState.legacyKeychainCredentials = externalKeychainCredentials - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(externalKeychainCredentials) - expect(testState.legacyKeychainCredentials).toBe(externalKeychainCredentials) - }) - - it('restores unchanged scoped keychain while preserving external legacy keychain logout', async () => { - if (process.platform !== 'darwin') { - return - } - - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - testState.legacyKeychainCredentials = null - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBeNull() - }) - - it('preserves external scoped keychain login while restoring unchanged legacy keychain', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const externalScopedCredentials = createClaudeCredentialsJson( - 'external@example.com', - 'external' - ) - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const { readActiveClaudeKeychainCredentials } = await import('./keychain') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - expect(readActiveClaudeKeychainCredentials).toHaveBeenCalledWith(expectedRuntimeConfigDir()) - - testState.scopedKeychainCredentials = externalScopedCredentials - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(externalScopedCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('restores oauth metadata when credentials prove managed ownership', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const systemOauthAccount = { accountUuid: 'system-account' } - const externalOauthAccount = { accountUuid: 'external-account' } - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: externalOauthAccount })}\n`, - 'utf-8' - ) - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) - }) - - it('restores owned oauth metadata when external credentials change but metadata does not', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const externalCredentials = createClaudeCredentialsJson('external@example.com', 'external') - const systemOauthAccount = { accountUuid: 'system-account' } - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8') - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(externalCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) - }) - - it('restores owned oauth metadata when only keychain proves managed ownership', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const externalCredentials = createClaudeCredentialsJson('external@example.com', 'external') - const systemOauthAccount = { accountUuid: 'system-account' } - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials, - 'null\n' - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8') - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(externalCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('uses managed credentials as ownership baseline after restart with partial external changes', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const externalScopedCredentials = createClaudeCredentialsJson( - 'external@example.com', - 'external' - ) - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - writeFileSync( - snapshotPath, - `${JSON.stringify({ - credentialsJson: systemCredentials, - configOauthAccount: null, - keychainCredentialsJson: systemCredentials, - scopedKeychainCredentialsJson: systemCredentials, - legacyKeychainCredentialsJson: systemCredentials, - capturedAt: Date.now() - })}\n`, - 'utf-8' - ) - writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') - testState.scopedKeychainCredentials = externalScopedCredentials - testState.legacyKeychainCredentials = managedCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(externalScopedCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('preserves external file and scoped login while restoring unchanged legacy keychain', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const externalCredentials = createClaudeCredentialsJson('external@example.com', 'external') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8') - testState.scopedKeychainCredentials = externalCredentials - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(externalCredentials) - expect(testState.scopedKeychainCredentials).toBe(externalCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('restores legacy keychain credentials from old system-default snapshots', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - writeFileSync( - snapshotPath, - `${JSON.stringify({ - credentialsJson: systemCredentials, - configOauthAccount: null, - keychainCredentialsJson: systemCredentials, - capturedAt: Date.now() - })}\n`, - 'utf-8' - ) - writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') - testState.scopedKeychainCredentials = managedCredentials - testState.legacyKeychainCredentials = managedCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('does not recapture managed file as system default after a partial keychain write failure', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - testState.throwRuntimeKeychainWrite = true - await expect(service.syncForCurrentSelection()).rejects.toThrow('runtime keychain write failed') - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - testState.throwRuntimeKeychainWrite = false - await service.syncForCurrentSelection() - - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('restores scoped keychain after legacy runtime keychain write fails', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - testState.throwLegacyRuntimeKeychainWrite = true - await expect(service.syncForCurrentSelection()).rejects.toThrow( - 'legacy runtime keychain write failed' - ) - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('keeps managed ownership baseline when keychain restore fails and retries', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - settings.activeClaudeManagedAccountId = null - testState.throwScopedKeychainWrite = true - await expect(service.syncForCurrentSelection()).rejects.toThrow('scoped keychain write failed') - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(managedCredentials) - testState.throwScopedKeychainWrite = false - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('preserves previous keychain snapshot after restart following partial restore failure', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - let service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - settings.activeClaudeManagedAccountId = null - testState.throwScopedKeychainWrite = true - await expect(service.syncForCurrentSelection()).rejects.toThrow('scoped keychain write failed') - - testState.throwScopedKeychainWrite = false - service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('does not enter managed mode when keychain snapshot capture fails', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - testState.throwScopedKeychainRead = true - await expect(service.syncForCurrentSelection()).rejects.toThrow( - 'Cannot capture current Claude Keychain credentials' - ) - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - warn.mockRestore() - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-launch-refresh.test.ts b/src/main/claude-accounts/runtime-auth-service-launch-refresh.test.ts deleted file mode 100644 index 85d68d30e8b8..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-launch-refresh.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createClaudeAccount, - createClaudeCredentialsJson, - createElectronMock, - createKeychainMock, - createManagedClaudeAuth, - createOauthRefreshMock, - createSettings, - createStore, - readManagedCredentialsForTest, - resetRuntimeAuthTestState, - testState -} from './runtime-auth-service-test-harness' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { isOauthTokenExpiring, refreshClaudeOauthCredentials } from './oauth-refresh' - -vi.mock('electron', () => createElectronMock()) - -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) - -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { - ...actual, - homedir: () => testState.fakeHomeDir - } -}) - -vi.mock('./keychain', () => createKeychainMock()) - -describe('ClaudeRuntimeAuthService', () => { - beforeEach(() => { - resetRuntimeAuthTestState() - }) - - afterEach(() => { - cleanupRuntimeAuthTestState() - }) - - it('does not clobber fresh Claude credentials after clearLastWrittenCredentialsJson', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') - const reauthedCredentials = createClaudeCredentialsJson('user@example.com', 'reauthed') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - testState.managedKeychainCredentials.set('account-1', reauthedCredentials) - writeFileSync(join(managedAuthPath, '.credentials.json'), reauthedCredentials, 'utf-8') - service.clearLastWrittenCredentialsJson() - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(reauthedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(reauthedCredentials) - }) - - it('leaves host system-default credentials untouched before launch', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const expired = createClaudeCredentialsJson('system@example.com', 'system-expired', null, 1_000) - writeFileSync(runtimeCredentialsPath, expired, 'utf-8') - testState.scopedKeychainCredentials = expired - testState.legacyKeychainCredentials = expired - const settings = createSettings({ - activeClaudeManagedAccountId: null - }) - const store = createStore(settings) - - vi.mocked(isOauthTokenExpiring).mockReturnValue(true) - vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue( - createClaudeCredentialsJson('system@example.com', 'system-refreshed') - ) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - const preparation = await service.prepareForClaudeLaunch() - - expect(isOauthTokenExpiring).not.toHaveBeenCalled() - expect(refreshClaudeOauthCredentials).not.toHaveBeenCalled() - expect(preparation.provenance).toBe('system') - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(expired) - expect(testState.scopedKeychainCredentials).toBe(expired) - expect(testState.legacyKeychainCredentials).toBe(expired) - - vi.mocked(isOauthTokenExpiring).mockReturnValue(false) - vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(null) - }) - - it('proactively refreshes and persists an expiring account on switch-in', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1Stale = createClaudeCredentialsJson('one@example.com', 'one-stale', null, 1_000) - const account1Refreshed = createClaudeCredentialsJson( - 'one@example.com', - 'one-refreshed', - null, - 9_999_999_999_999 - ) - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Stale - ) - // Start on the system default (no active managed account), then switch in. - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }) - ], - activeClaudeManagedAccountId: null - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - // Now switch into account-1: token is expiring, so the service must refresh - // and persist the rotation before materializing. - vi.mocked(isOauthTokenExpiring).mockReturnValueOnce(true) - vi.mocked(refreshClaudeOauthCredentials).mockResolvedValueOnce(account1Refreshed) - store.updateSettings({ activeClaudeManagedAccountId: 'account-1' }) - await service.syncForCurrentSelection() - - expect(refreshClaudeOauthCredentials).toHaveBeenCalledWith(account1Stale) - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Refreshed) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1Refreshed) - }) - - it('refreshes the active account with an expired token when no Claude PTY is live', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const expired = createClaudeCredentialsJson('one@example.com', 'one-expired', null, 1_000) - const refreshedCreds = createClaudeCredentialsJson( - 'one@example.com', - 'one-refreshed', - null, - 9_999_999_999_999 - ) - const managedAuthPath1 = createManagedClaudeAuth(testState.userDataDir, 'account-1', expired) - // account-1 is ALREADY the active account (seeded), so this is a re-sync of - // the active account, not a switch-in — the path that was previously missed. - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - vi.mocked(isOauthTokenExpiring).mockReturnValue(true) - vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(refreshedCreds) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(refreshClaudeOauthCredentials).toHaveBeenCalled() - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(refreshedCreds) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCreds) - - vi.mocked(isOauthTokenExpiring).mockReturnValue(false) - vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(null) - }) - - it('does not refresh the active account while a Claude PTY is live', async () => { - const expired = createClaudeCredentialsJson('one@example.com', 'one-expired', null, 1_000) - const managedAuthPath1 = createManagedClaudeAuth(testState.userDataDir, 'account-1', expired) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - vi.mocked(isOauthTokenExpiring).mockReturnValue(true) - vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue( - createClaudeCredentialsJson('one@example.com', 'should-not-be-used', null, 9_999_999_999_999) - ) - - const { markClaudePtySpawned, markClaudePtyExited } = await import('./live-pty-gate') - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - - markClaudePtySpawned('pty-live-1') - try { - const preparation = await service.prepareForRateLimitFetch() - // A live Claude owns the credentials; refreshing here would race its - // rotation, so the proactive refresh must be skipped entirely. - expect(refreshClaudeOauthCredentials).not.toHaveBeenCalled() - expect(preparation.managedRefreshDeferredByLivePty).toBe(true) - } finally { - markClaudePtyExited('pty-live-1') - vi.mocked(isOauthTokenExpiring).mockReturnValue(false) - vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(null) - } - }) - - it('adopts a rotated-refresh-token runtime credential on cold-start read-back', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - // Same expiry on both sides (cold start), but the runtime refresh token has - // rotated — proof the CLI refreshed. Must be read back into managed storage. - const managedCredentials = createClaudeCredentialsJson( - 'one@example.com', - 'one-old', - null, - 3_000 - ) - const runtimeRotated = `${JSON.stringify({ - claudeAiOauth: { - email: 'one@example.com', - accessToken: 'one-rotated', - refreshToken: 'one-rotated-refresh', - expiresAt: 3_000 - } - })}\n` - writeFileSync(runtimeCredentialsPath, runtimeRotated, 'utf-8') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(runtimeRotated) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-materialization.test.ts b/src/main/claude-accounts/runtime-auth-service-materialization.test.ts deleted file mode 100644 index 94bdab9b4d82..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-materialization.test.ts +++ /dev/null @@ -1,490 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createClaudeAccount, - createClaudeCredentialsJson, - createElectronMock, - createKeychainMock, - createManagedClaudeAuth, - createOauthRefreshMock, - createSettings, - createStore, - expectedRuntimeConfigDir, - hostPlatform, - readRuntimeOauthAccountForTest, - resetRuntimeAuthTestState, - setPlatform, - testState -} from './runtime-auth-service-test-harness' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - chmodSync, - existsSync, - lstatSync, - mkdirSync, - readFileSync, - rmSync, - statSync, - symlinkSync, - writeFileSync -} from 'node:fs' -import { join } from 'node:path' -import type { GlobalSettings } from '../../shared/global-settings-types' - -vi.mock('electron', () => createElectronMock()) - -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) - -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { - ...actual, - homedir: () => testState.fakeHomeDir - } -}) - -vi.mock('./keychain', () => createKeychainMock()) - -describe('ClaudeRuntimeAuthService', () => { - beforeEach(() => { - resetRuntimeAuthTestState() - }) - - afterEach(() => { - cleanupRuntimeAuthTestState() - }) - - it('creates and recreates the runtime directory when materializing managed credentials', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - rmSync(expectedRuntimeConfigDir(), { recursive: true, force: true }) - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) - - rmSync(expectedRuntimeConfigDir(), { recursive: true, force: true }) - await service.prepareForClaudeLaunch() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) - expect(testState.runtimeWriteConfigDir).toBe(expectedRuntimeConfigDir()) - }) - - it('restores system default instead of materializing corrupt managed credentials', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth(testState.userDataDir, 'account-1', '{not-json') - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('restores system default instead of materializing wrong-shaped managed credentials', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth(testState.userDataDir, 'account-1', '{}\n') - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('does not materialize managed credentials from unowned auth paths', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - const unownedAuthPath = join(testState.fakeHomeDir, 'unowned-claude-auth') - mkdirSync(unownedAuthPath, { recursive: true }) - writeFileSync(join(unownedAuthPath, '.credentials.json'), managedCredentials, 'utf-8') - writeFileSync( - join(unownedAuthPath, 'oauth-account.json'), - `${JSON.stringify({ accountUuid: 'account-1' })}\n`, - 'utf-8' - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', unownedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - }) - - it('adopts canonical legacy managed auth paths without existing markers', async () => { - setPlatform('linux') - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.credentials.json'), managedCredentials, 'utf-8') - writeFileSync( - join(managedAuthPath, 'oauth-account.json'), - '{"accountUuid":"account-1"}\n', - 'utf-8' - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - const markerPath = join(managedAuthPath, '.orca-managed-claude-auth') - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) - expect(lstatSync(markerPath).isFile()).toBe(true) - expect(readFileSync(markerPath, 'utf-8')).toBe('account-1\n') - }) - - it('rejects symlinked managed credential children', async () => { - setPlatform('linux') - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const escapedCredentials = createClaudeCredentialsJson('user@example.com', 'escaped') - const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') - const escapedCredentialsPath = join(testState.fakeHomeDir, 'escaped-credentials.json') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync( - join(managedAuthPath, 'oauth-account.json'), - '{"accountUuid":"account-1"}\n', - 'utf-8' - ) - writeFileSync(escapedCredentialsPath, escapedCredentials, 'utf-8') - symlinkSync(escapedCredentialsPath, join(managedAuthPath, '.credentials.json')) - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - }) - - it('restores system auth when switching from an owned account to an unowned account', async () => { - setPlatform('linux') - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const ownedCredentials = createClaudeCredentialsJson('owned@example.com', 'owned') - const unownedCredentials = createClaudeCredentialsJson('unowned@example.com', 'unowned') - const systemOauthAccount = { accountUuid: 'system-account' } - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, - 'utf-8' - ) - const ownedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - ownedCredentials - ) - const unownedAuthPath = join(testState.fakeHomeDir, 'unowned-claude-auth') - mkdirSync(unownedAuthPath, { recursive: true }) - writeFileSync(join(unownedAuthPath, '.credentials.json'), unownedCredentials, 'utf-8') - let settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', ownedAuthPath, { email: 'owned@example.com' }), - createClaudeAccount('account-2', unownedAuthPath, { email: 'unowned@example.com' }) - ], - activeClaudeManagedAccountId: null - }) - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { - ...settings, - ...updates, - notifications: { - ...settings.notifications, - ...updates.notifications - } - } - return settings - }) - } - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - store.updateSettings({ activeClaudeManagedAccountId: 'account-1' }) - await service.syncForCurrentSelection() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(ownedCredentials) - - store.updateSettings({ activeClaudeManagedAccountId: 'account-2' }) - await service.syncForCurrentSelection() - - expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) - }) - - it('restores system auth when the previously synced account is no longer in settings', async () => { - setPlatform('linux') - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const ownedCredentials = createClaudeCredentialsJson('owned@example.com', 'owned') - const systemOauthAccount = { accountUuid: 'system-account' } - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, - 'utf-8' - ) - const ownedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - ownedCredentials - ) - const unownedAuthPath = join(testState.fakeHomeDir, 'unowned-claude-auth') - mkdirSync(unownedAuthPath, { recursive: true }) - let settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', ownedAuthPath, { email: 'owned@example.com' }) - ], - activeClaudeManagedAccountId: null - }) - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { - ...settings, - ...updates, - notifications: { - ...settings.notifications, - ...updates.notifications - } - } - return settings - }) - } - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - store.updateSettings({ activeClaudeManagedAccountId: 'account-1' }) - await service.syncForCurrentSelection() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(ownedCredentials) - - store.updateSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-2', unownedAuthPath, { email: 'unowned@example.com' }) - ], - activeClaudeManagedAccountId: 'account-2' - }) - await service.syncForCurrentSelection() - - expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) - }) - - it('restores system auth when switching from an owned account to missing credentials', async () => { - setPlatform('linux') - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') - const systemOauthAccount = { accountUuid: 'system-account' } - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, - 'utf-8' - ) - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Credentials - ) - const managedAuthPath2 = join(testState.userDataDir, 'claude-accounts', 'account-2', 'auth') - mkdirSync(managedAuthPath2, { recursive: true }) - writeFileSync(join(managedAuthPath2, '.orca-managed-claude-auth'), 'account-2\n', 'utf-8') - writeFileSync( - join(managedAuthPath2, 'oauth-account.json'), - '{"accountUuid":"account-2"}\n', - 'utf-8' - ) - let settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) - ], - activeClaudeManagedAccountId: null - }) - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { - ...settings, - ...updates, - notifications: { - ...settings.notifications, - ...updates.notifications - } - } - return settings - }) - } - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - store.updateSettings({ activeClaudeManagedAccountId: 'account-1' }) - await service.syncForCurrentSelection() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1Credentials) - - store.updateSettings({ activeClaudeManagedAccountId: 'account-2' }) - await service.syncForCurrentSelection() - - expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) - }) - - it('removes runtime credentials when deselecting with a missing system-default snapshot', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(existsSync(runtimeCredentialsPath)).toBe(false) - if (process.platform === 'darwin') { - expect(testState.scopedKeychainCredentials).toBeNull() - expect(testState.legacyKeychainCredentials).toBeNull() - } - }) - - it('preserves unreadable runtime credentials instead of overwriting unknown connector grants', async () => { - if (hostPlatform === 'win32') { - return - } - - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const rotatedCredentials = createClaudeCredentialsJson('user@example.com', 'rotated') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - testState.managedKeychainCredentials.set('account-1', rotatedCredentials) - writeFileSync(join(managedAuthPath, '.credentials.json'), rotatedCredentials, 'utf-8') - chmodSync(runtimeCredentialsPath, 0o000) - try { - await expect(service.syncForCurrentSelection()).rejects.toMatchObject({ code: 'EACCES' }) - } finally { - if (existsSync(runtimeCredentialsPath)) { - chmodSync(runtimeCredentialsPath, 0o600) - } - warn.mockRestore() - } - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) - expect(testState.scopedKeychainCredentials).toBe(managedCredentials) - }) - - it('tightens credential file permissions when unchanged content is already present', async () => { - if (hostPlatform === 'win32') { - return - } - - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - chmodSync(runtimeCredentialsPath, 0o644) - await service.syncForCurrentSelection() - - expect(statSync(runtimeCredentialsPath).mode & 0o777).toBe(0o600) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-readback-identity.test.ts b/src/main/claude-accounts/runtime-auth-service-readback-identity.test.ts deleted file mode 100644 index 2806d44e82a5..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-readback-identity.test.ts +++ /dev/null @@ -1,607 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createClaudeAccount, - createClaudeCredentialsJson, - createClaudeCredentialsWithoutEmail, - createElectronMock, - createKeychainMock, - createManagedClaudeAuth, - createOauthRefreshMock, - createSettings, - createStore, - readManagedCredentialsForTest, - resetRuntimeAuthTestState, - setPlatform, - testState -} from './runtime-auth-service-test-harness' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - -vi.mock('electron', () => createElectronMock()) - -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) - -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { - ...actual, - homedir: () => testState.fakeHomeDir - } -}) - -vi.mock('./keychain', () => createKeychainMock()) - -describe('ClaudeRuntimeAuthService', () => { - beforeEach(() => { - resetRuntimeAuthTestState() - }) - - afterEach(() => { - cleanupRuntimeAuthTestState() - }) - - it('reads back refreshed credentials when the Claude identity still matches', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') - const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - }) - - it('rejects wrong-shaped refreshed credentials during read-back', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') - const malformedRefresh = `${JSON.stringify({ - claudeAiOauth: { - email: 'user@example.com', - expiresAt: Date.now() + 120_000 - } - })}\n` - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, malformedRefresh, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(originalCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(originalCredentials) - }) - - it('reads back verified same-account credentials on first sync after restart', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson( - 'user@example.com', - 'original', - null, - 1_000 - ) - const refreshedCredentials = createClaudeCredentialsJson( - 'user@example.com', - 'refreshed', - null, - 2_000 - ) - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - }) - - it('rejects older same-account Claude credentials on first sync after restart', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const staleRuntimeCredentials = createClaudeCredentialsJson( - 'user@example.com', - 'stale', - null, - 1_000 - ) - const managedCredentials = createClaudeCredentialsJson( - 'user@example.com', - 'managed-newer', - null, - 2_000 - ) - writeFileSync(runtimeCredentialsPath, staleRuntimeCredentials, 'utf-8') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(managedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) - }) - - it('rejects runtime read-back from a different Claude identity', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected') - const staleCredentials = createClaudeCredentialsJson('other@example.com', 'stale') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - selectedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) - }) - - it('rejects runtime read-back from the same Claude email in a different organization', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected', 'org-b') - const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', 'org-a') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - selectedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-b' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) - }) - - it('rejects same-email Claude read-back using stored managed organization identity', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected', 'org-b') - const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', 'org-a') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - selectedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) - }) - - it('rejects same-email Claude read-back using stored oauth-account organization identity', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected') - const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', 'org-a') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - selectedCredentials, - '{"organizationUuid":"org-b"}\n' - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) - }) - - it('rejects no-email Claude read-back when organization identity conflicts', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected', 'org-b') - const staleCredentials = createClaudeCredentialsWithoutEmail('stale', 'org-a') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - selectedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-b' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) - }) - - it('rejects no-email refreshed credentials even when organization identity matches', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original', 'org-a') - const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed', 'org-a') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-a' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(originalCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(originalCredentials) - }) - - it('preserves rejected runtime refreshes while a Claude terminal is live on Windows', async () => { - setPlatform('win32') - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original', 'org-a') - const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed', 'org-a') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-a' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const { markClaudePtyExited, markClaudePtySpawned } = await import('./live-pty-gate') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - markClaudePtySpawned('live-claude-pty') - try { - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - } finally { - markClaudePtyExited('live-claude-pty') - } - - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - }) - - it('does not persist live runtime refreshes with conflicting organization identity', async () => { - setPlatform('win32') - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original', 'org-a') - const conflictingCredentials = createClaudeCredentialsWithoutEmail('refreshed', 'org-b') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-a' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const { markClaudePtyExited, markClaudePtySpawned } = await import('./live-pty-gate') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - markClaudePtySpawned('live-claude-pty') - try { - writeFileSync(runtimeCredentialsPath, conflictingCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(originalCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(conflictingCredentials) - } finally { - markClaudePtyExited('live-claude-pty') - } - }) - - it('rematerializes managed credentials over a wiped runtime blob while a Claude terminal is live', async () => { - setPlatform('win32') - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original', 'org-a') - // Why: Claude CLI wipes tokens in place (keeps identity fields) after an - // invalid_grant refresh — the exact blob shape this regression guards. - const parsedOriginal = JSON.parse(originalCredentials) as { - claudeAiOauth: Record - } - const wipedCredentials = `${JSON.stringify({ - claudeAiOauth: { - ...parsedOriginal.claudeAiOauth, - accessToken: '', - refreshToken: '', - expiresAt: 0 - } - })}\n` - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-a' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const { markClaudePtyExited, markClaudePtySpawned } = await import('./live-pty-gate') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - markClaudePtySpawned('live-claude-pty') - try { - writeFileSync(runtimeCredentialsPath, wipedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(originalCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(originalCredentials) - } finally { - markClaudePtyExited('live-claude-pty') - } - }) - - it('rejects unverifiable refreshed runtime credentials', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsWithoutEmail('original') - const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(originalCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(originalCredentials) - }) - - it('reads back identity-less refreshed credentials when the refresh token matches', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const refreshToken = 'same-managed-refresh-token' - const originalCredentials = createClaudeCredentialsWithoutEmail('original', null, { - expiresAt: 1_000, - refreshToken - }) - const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed', null, { - expiresAt: 2_000, - refreshToken - }) - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-from-account' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - }) - - it('reads back identity-less refreshed credentials when runtime oauth metadata matches', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const originalCredentials = createClaudeCredentialsJson( - 'user@example.com', - 'original', - 'org-a', - 1_000 - ) - const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed', null, { - expiresAt: 2_000, - refreshToken: 'rotated-refresh-token' - }) - writeFileSync(runtimeConfigPath, '{}\n', 'utf-8') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials, - '{"accountUuid":"account-uuid-1","emailAddress":"user@example.com","organizationUuid":"org-a"}\n' - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { - email: 'user@example.com', - organizationUuid: 'org-a' - }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - }) - - it('rules out other identity-less accounts with different refresh tokens', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1RefreshToken = 'account-1-refresh-token' - const account1OriginalCredentials = createClaudeCredentialsWithoutEmail('account-1', null, { - expiresAt: 1_000, - refreshToken: account1RefreshToken - }) - const account1RefreshedCredentials = createClaudeCredentialsWithoutEmail( - 'account-1-refreshed', - null, - { - expiresAt: 2_000, - refreshToken: account1RefreshToken - } - ) - const account2Credentials = createClaudeCredentialsWithoutEmail('account-2', null, { - refreshToken: 'account-2-refresh-token' - }) - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1OriginalCredentials - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1), - createClaudeAccount('account-2', managedAuthPath2, { email: 'other@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, account1RefreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe( - account1RefreshedCredentials - ) - expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1RefreshedCredentials) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-shared-credential-failures.test.ts b/src/main/claude-accounts/runtime-auth-service-shared-credential-failures.test.ts deleted file mode 100644 index 4066063c5625..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-shared-credential-failures.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createElectronMock, - createKeychainMock, - createOauthRefreshMock, - resetRuntimeAuthTestState, - testState -} from './runtime-auth-service-test-harness' -import { - createSharedCredentialRuntime, - sharedFields, - withSharedFields -} from './runtime-auth-shared-credentials-fixture' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { readFileSync, writeFileSync } from 'node:fs' - -vi.mock('electron', () => createElectronMock()) -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) -vi.mock('./keychain', () => createKeychainMock()) -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { ...actual, homedir: () => testState.fakeHomeDir } -}) - -describe('shared connector credential write failures', () => { - beforeEach(resetRuntimeAuthTestState) - afterEach(cleanupRuntimeAuthTestState) - - it('keeps the committed baseline across a partial rollback so a rotated grant can be retried', async () => { - const { service, settings, runtimePath, first } = await createSharedCredentialRuntime() - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - const rotated = { - ...sharedFields, - mcpOAuth: { figma: { accessToken: 'new-access', refreshToken: 'new-refresh' } } - } - testState.scopedKeychainCredentials = withSharedFields(first, rotated) - settings.activeClaudeManagedAccountId = 'second' - testState.throwLegacyRuntimeKeychainWrite = true - await expect(service.syncForCurrentSelection()).rejects.toThrow( - 'legacy runtime keychain write failed' - ) - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(rotated) - testState.throwLegacyRuntimeKeychainWrite = false - settings.activeClaudeManagedAccountId = 'first' - await service.forceMaterializeCurrentSelectionForRollback() - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(rotated) - expect(JSON.parse(readFileSync(runtimePath, 'utf-8')).claudeAiOauth.accessToken).toBe('first') - expect(testState.scopedKeychainCredentials).toBe(readFileSync(runtimePath, 'utf-8')) - expect(testState.legacyKeychainCredentials).toBe(testState.scopedKeychainCredentials) - }) - - it('preserves disjoint live grants when the first switch fails after writing only the scoped item', async () => { - const { service, settings, runtimePath, system } = await createSharedCredentialRuntime() - const figma = sharedFields.mcpOAuth.figma - testState.scopedKeychainCredentials = withSharedFields(system, { - ...sharedFields, - mcpOAuth: { figma } - }) - testState.legacyKeychainCredentials = withSharedFields(system, { - ...sharedFields, - mcpOAuth: { notion: { accessToken: 'notion-access', refreshToken: 'notion-refresh' } } - }) - writeFileSync(runtimePath, system) - settings.activeClaudeManagedAccountId = 'first' - testState.throwLegacyRuntimeKeychainWrite = true - await expect(service.syncForCurrentSelection()).rejects.toThrow( - 'legacy runtime keychain write failed' - ) - expect(JSON.parse(readFileSync(runtimePath, 'utf-8')).mcpOAuth).toEqual({ - figma, - notion: { accessToken: 'notion-access', refreshToken: 'notion-refresh' } - }) - testState.throwLegacyRuntimeKeychainWrite = false - await service.syncForCurrentSelection() - const runtime = JSON.parse(readFileSync(runtimePath, 'utf-8')) - expect(runtime.mcpOAuth).toEqual({ - figma, - notion: { accessToken: 'notion-access', refreshToken: 'notion-refresh' } - }) - expect(runtime.claudeAiOauth.accessToken).toBe('first') - expect(testState.scopedKeychainCredentials).toBe(readFileSync(runtimePath, 'utf-8')) - expect(testState.legacyKeychainCredentials).toBe(testState.scopedKeychainCredentials) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-shared-credentials.test.ts b/src/main/claude-accounts/runtime-auth-service-shared-credentials.test.ts deleted file mode 100644 index f3bb1f001378..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-shared-credentials.test.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createClaudeCredentialsJson, - createElectronMock, - createKeychainMock, - createOauthRefreshMock, - createStore, - readManagedCredentialsForTest, - resetRuntimeAuthTestState, - testState -} from './runtime-auth-service-test-harness' -import { - createSharedCredentialRuntime, - sharedFields, - withSharedFields -} from './runtime-auth-shared-credentials-fixture' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - -vi.mock('electron', () => createElectronMock()) -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) -vi.mock('./keychain', () => createKeychainMock()) -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { ...actual, homedir: () => testState.fakeHomeDir } -}) - -describe('shared Claude connector credentials', () => { - beforeEach(resetRuntimeAuthTestState) - afterEach(cleanupRuntimeAuthTestState) - - it.each(['scoped', 'legacy', 'file'] as const)( - 'preserves connector grants stored only in %s when there is no previous Orca write', - async (surface) => { - const { service, settings, runtimePath, system } = await createSharedCredentialRuntime() - testState.scopedKeychainCredentials = system - testState.legacyKeychainCredentials = system - writeFileSync(runtimePath, system) - if (surface === 'scoped') { - testState.scopedKeychainCredentials = withSharedFields(system) - } else if (surface === 'legacy') { - testState.legacyKeychainCredentials = withSharedFields(system) - } else { - writeFileSync(runtimePath, withSharedFields(system)) - } - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(sharedFields) - } - ) - - it.each(['darwin', 'linux', 'win32'] as const)( - 'excludes connector secrets when capturing a managed account on %s', - async (platform) => { - const { firstPath, first } = await createSharedCredentialRuntime(platform) - const { ClaudeManagedAuthStorage } = await import('./claude-managed-auth-storage') - await new ClaudeManagedAuthStorage().writeCredentials( - 'first', - firstPath, - withSharedFields(first) - ) - expect(JSON.parse(readManagedCredentialsForTest('first', firstPath) ?? '')).toEqual( - JSON.parse(first) - ) - } - ) - - it.each(['scoped', 'legacy', 'file'] as const)( - 'propagates connector revocations from the %s surface and does not resurrect frozen account grants', - async (surface) => { - const { service, settings, runtimePath, first, secondPath, second } = - await createSharedCredentialRuntime() - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - if (surface === 'scoped') { - testState.scopedKeychainCredentials = first - } else if (surface === 'legacy') { - testState.legacyKeychainCredentials = first - } else { - writeFileSync(runtimePath, first) - } - testState.managedKeychainCredentials.set('second', withSharedFields(second)) - writeFileSync(join(secondPath, '.credentials.json'), withSharedFields(second)) - settings.activeClaudeManagedAccountId = 'second' - await service.syncForCurrentSelection() - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toEqual(JSON.parse(second)) - expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toEqual(JSON.parse(second)) - expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toEqual(JSON.parse(second)) - } - ) - - it('preserves grants refreshed only in the keychain when returning to the system default', async () => { - const { service, settings, runtimePath, first, system } = await createSharedCredentialRuntime() - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - const rotated = { - ...sharedFields, - mcpOAuth: { figma: { accessToken: 'rotated', refreshToken: 'rotated' } } - } - testState.legacyKeychainCredentials = withSharedFields(first, rotated) - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(rotated) - expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toMatchObject(rotated) - expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toMatchObject(rotated) - const nextRotation = { - ...sharedFields, - mcpOAuth: { figma: { accessToken: 'rotated-again', refreshToken: 'rotated-again' } } - } - testState.scopedKeychainCredentials = withSharedFields(system, nextRotation) - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(nextRotation) - expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toMatchObject(nextRotation) - }) - - it.each(['darwin', 'linux', 'win32'] as const)( - 'keeps connector grants through account switches, syncs, restart, and deselect on %s', - async (platform) => { - const state = await createSharedCredentialRuntime(platform) - const { service, settings, runtimePath, first, second, firstPath, secondPath } = state - for (const id of ['first', 'second', 'first']) { - settings.activeClaudeManagedAccountId = id - await service.syncForCurrentSelection() - await service.syncForCurrentSelection() - const runtime = JSON.parse(readFileSync(runtimePath, 'utf-8')) - expect(runtime).toMatchObject(sharedFields) - expect(runtime.claudeAiOauth.accessToken).toBe(id) - if (platform === 'darwin') { - expect(testState.scopedKeychainCredentials).toBe(readFileSync(runtimePath, 'utf-8')) - expect(testState.legacyKeychainCredentials).toBe(testState.scopedKeychainCredentials) - } - } - expect(JSON.parse(readManagedCredentialsForTest('first', firstPath) ?? '')).toEqual( - JSON.parse(first) - ) - expect(JSON.parse(readManagedCredentialsForTest('second', secondPath) ?? '')).toEqual( - JSON.parse(second) - ) - const rotated = { - ...sharedFields, - mcpOAuth: { figma: { accessToken: 'rotated-access', refreshToken: 'rotated-refresh' } } - } - const live = withSharedFields(first, rotated) - writeFileSync(runtimePath, live) - testState.scopedKeychainCredentials = live - testState.legacyKeychainCredentials = live - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime auth uses only getSettings/updateSettings from this store mock. - const restarted = new ClaudeRuntimeAuthService(createStore(settings) as never) - await restarted.syncForCurrentSelection() - settings.activeClaudeManagedAccountId = null - await restarted.syncForCurrentSelection() - const restored = JSON.parse(readFileSync(runtimePath, 'utf-8')) - expect(restored).toMatchObject(rotated) - expect(restored.claudeAiOauth.accessToken).toBe('system') - if (platform === 'darwin') { - expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toMatchObject(rotated) - expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toMatchObject(rotated) - } - } - ) - - it.each(['scoped', 'legacy', 'file'] as const)( - 'preserves MCP rotations written only to the %s surface while adopting a Claude refresh', - async (surface) => { - const { service, settings, runtimePath, firstPath } = await createSharedCredentialRuntime() - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - const refreshed = createClaudeCredentialsJson( - 'first@example.com', - 'refreshed', - null, - Date.now() + 120_000 - ) - const rotated = { - ...sharedFields, - mcpOAuth: { figma: { accessToken: 'new-access', refreshToken: 'new-refresh' } } - } - const live = withSharedFields(refreshed, rotated) - if (surface === 'scoped') { - testState.scopedKeychainCredentials = live - } - if (surface === 'legacy') { - testState.legacyKeychainCredentials = live - } - if (surface === 'file') { - writeFileSync(runtimePath, live) - } - await service.syncForCurrentSelection() - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(rotated) - expect(JSON.parse(readManagedCredentialsForTest('first', firstPath) ?? '')).toEqual( - JSON.parse(refreshed) - ) - } - ) - - it('preserves conflicting MCP grants after restart even when the Claude account token is newer', async () => { - const { service, settings, runtimePath, firstPath } = await createSharedCredentialRuntime() - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - const refreshed = createClaudeCredentialsJson( - 'first@example.com', - 'refreshed', - null, - Date.now() + 120_000 - ) - const rotated = { - ...sharedFields, - mcpOAuth: { figma: { accessToken: 'new-access', refreshToken: 'new-refresh' } } - } - testState.legacyKeychainCredentials = withSharedFields(refreshed, rotated) - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime auth uses only getSettings/updateSettings from this store mock. - const restarted = new ClaudeRuntimeAuthService(createStore(settings) as never) - await expect(restarted.syncForCurrentSelection()).rejects.toThrow( - 'live connector credentials conflict' - ) - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(sharedFields) - expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toMatchObject(sharedFields) - expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toMatchObject(rotated) - expect(JSON.parse(readManagedCredentialsForTest('first', firstPath) ?? '')).toEqual( - JSON.parse(refreshed) - ) - }) - - it('leaves all credentials untouched when the active keychain cannot be read', async () => { - const { service, settings, runtimePath } = await createSharedCredentialRuntime() - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - const before = readFileSync(runtimePath, 'utf-8') - settings.activeClaudeManagedAccountId = 'second' - testState.throwScopedKeychainRead = true - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - await expect(service.syncForCurrentSelection()).rejects.toThrow('scoped keychain read failed') - expect(readFileSync(runtimePath, 'utf-8')).toBe(before) - expect(testState.scopedKeychainCredentials).toBe(before) - expect(testState.legacyKeychainCredentials).toBe(before) - testState.throwScopedKeychainRead = false - await service.syncForCurrentSelection() - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(sharedFields) - expect(JSON.parse(readFileSync(runtimePath, 'utf-8')).claudeAiOauth.accessToken).toBe('second') - warn.mockRestore() - }) - - it.each(['scoped', 'legacy', 'file'] as const)( - 'refuses to overwrite malformed live credentials in the %s surface', - async (surface) => { - const { service, settings, runtimePath } = await createSharedCredentialRuntime() - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - if (surface === 'scoped') { - testState.scopedKeychainCredentials = '{broken' - } else if (surface === 'legacy') { - testState.legacyKeychainCredentials = '{broken' - } else { - writeFileSync(runtimePath, '{broken') - } - const fileBefore = readFileSync(runtimePath, 'utf-8') - const scopedBefore = testState.scopedKeychainCredentials - const legacyBefore = testState.legacyKeychainCredentials - settings.activeClaudeManagedAccountId = 'second' - await expect(service.syncForCurrentSelection()).rejects.toThrow( - 'Cannot preserve malformed Claude runtime credentials' - ) - expect(readFileSync(runtimePath, 'utf-8')).toBe(fileBefore) - expect(testState.scopedKeychainCredentials).toBe(scopedBefore) - expect(testState.legacyKeychainCredentials).toBe(legacyBefore) - } - ) - - it('keeps newly authorized MCP grants when returning to a signed-out system default', async () => { - const { service, settings, runtimePath, first } = await createSharedCredentialRuntime() - // A missing system credential is a signed-out default, with no connector grants yet. - rmSync(runtimePath) - testState.scopedKeychainCredentials = null - testState.legacyKeychainCredentials = null - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - const live = withSharedFields(first) - writeFileSync(runtimePath, live) - testState.scopedKeychainCredentials = live - testState.legacyKeychainCredentials = live - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - expect(existsSync(runtimePath)).toBe(true) - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toEqual(sharedFields) - expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toEqual(sharedFields) - expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toEqual(sharedFields) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-snapshot-validation.test.ts b/src/main/claude-accounts/runtime-auth-service-snapshot-validation.test.ts deleted file mode 100644 index 1df2430b8f6a..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-snapshot-validation.test.ts +++ /dev/null @@ -1,550 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createClaudeAccount, - createClaudeCredentialsJson, - createElectronMock, - createKeychainMock, - createManagedClaudeAuth, - createOauthRefreshMock, - createSettings, - createStore, - readRuntimeOauthAccountForTest, - resetRuntimeAuthTestState, - testState -} from './runtime-auth-service-test-harness' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - -vi.mock('electron', () => createElectronMock()) - -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) - -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { - ...actual, - homedir: () => testState.fakeHomeDir - } -}) - -vi.mock('./keychain', () => createKeychainMock()) - -describe('ClaudeRuntimeAuthService', () => { - beforeEach(() => { - resetRuntimeAuthTestState() - }) - - afterEach(() => { - cleanupRuntimeAuthTestState() - }) - - it('treats corrupt system-default snapshots as missing and clears owned runtime auth', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - writeFileSync(snapshotPath, '{not-json', 'utf-8') - writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = managedCredentials - testState.legacyKeychainCredentials = managedCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(existsSync(snapshotPath)).toBe(false) - expect(existsSync(runtimeCredentialsPath)).toBe(false) - expect(readRuntimeOauthAccountForTest()).toBeNull() - expect(testState.scopedKeychainCredentials).toBeNull() - expect(testState.legacyKeychainCredentials).toBeNull() - warn.mockRestore() - }) - - it('treats wrong-shaped system-default snapshots as missing and clears owned runtime auth', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - writeFileSync( - snapshotPath, - `${JSON.stringify({ - credentialsJson: { token: 'system' }, - keychainCredentialsJson: managedCredentials, - scopedKeychainCredentialsJson: { token: 'scoped' }, - legacyKeychainCredentialsJson: managedCredentials, - capturedAt: Date.now() - })}\n`, - 'utf-8' - ) - writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = managedCredentials - testState.legacyKeychainCredentials = managedCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(existsSync(snapshotPath)).toBe(false) - expect(existsSync(runtimeCredentialsPath)).toBe(false) - expect(readRuntimeOauthAccountForTest()).toBeNull() - expect(testState.scopedKeychainCredentials).toBeNull() - expect(testState.legacyKeychainCredentials).toBeNull() - warn.mockRestore() - }) - - it('treats snapshots missing all keychain credential fields as invalid', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - writeFileSync( - snapshotPath, - `${JSON.stringify({ - credentialsJson: null, - configOauthAccount: null, - capturedAt: Date.now() - })}\n`, - 'utf-8' - ) - writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') - testState.scopedKeychainCredentials = managedCredentials - testState.legacyKeychainCredentials = managedCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(existsSync(snapshotPath)).toBe(false) - expect(existsSync(runtimeCredentialsPath)).toBe(false) - expect(testState.scopedKeychainCredentials).toBeNull() - expect(testState.legacyKeychainCredentials).toBeNull() - warn.mockRestore() - }) - - it('treats snapshots missing credentialsJson as invalid and clears missing-managed runtime auth', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') - const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - writeFileSync( - snapshotPath, - `${JSON.stringify({ - configOauthAccount: null, - keychainCredentialsJson: null, - capturedAt: Date.now() - })}\n`, - 'utf-8' - ) - writeFileSync(runtimeCredentialsPath, staleManagedCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = staleManagedCredentials - testState.legacyKeychainCredentials = staleManagedCredentials - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.prepareForClaudeLaunch() - - expect(existsSync(snapshotPath)).toBe(true) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleManagedCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'account-1' }) - expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) - expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) - warn.mockRestore() - }) - - it('clears missing-managed oauth metadata when only keychain proves ownership', async () => { - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') - const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = staleManagedCredentials - testState.legacyKeychainCredentials = staleManagedCredentials - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.prepareForClaudeLaunch() - - expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'account-1' }) - expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) - expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) - }) - - it('preserves missing-managed oauth metadata without credential ownership proof', async () => { - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const externalCredentials = createClaudeCredentialsJson('external@example.com', 'external') - const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') - const managedOauthAccount = { accountUuid: 'account-1' } - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: managedOauthAccount })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = externalCredentials - testState.legacyKeychainCredentials = externalCredentials - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.prepareForClaudeLaunch() - - expect(readRuntimeOauthAccountForTest()).toEqual(managedOauthAccount) - expect(testState.scopedKeychainCredentials).toBe(externalCredentials) - expect(testState.legacyKeychainCredentials).toBe(externalCredentials) - }) - - it('preserves invalid external runtime oauth metadata when deselecting', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync(runtimeConfigPath, `${JSON.stringify({})}\n`, 'utf-8') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials, - 'null\n' - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeConfigPath, '{not-json', 'utf-8') - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('{not-json') - }) - - it('preserves invalid runtime config while materializing managed credentials', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync(runtimeConfigPath, '{not-json', 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) - expect(testState.scopedKeychainCredentials).toBe(managedCredentials) - expect(testState.legacyKeychainCredentials).toBe(managedCredentials) - expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('{not-json') - - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('{not-json') - }) - - it('preserves non-object runtime config while materializing managed credentials', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync(runtimeConfigPath, '[]', 'utf-8') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) - expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('[]') - }) - - it('does not use skipped oauth writes as ownership proof on deselect', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const externalCredentials = createClaudeCredentialsJson('external@example.com', 'external') - const managedOauthAccount = { accountUuid: 'account-1' } - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync(runtimeConfigPath, '{not-json', 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials, - `${JSON.stringify(managedOauthAccount)}\n` - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: managedOauthAccount })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = externalCredentials - testState.legacyKeychainCredentials = externalCredentials - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(externalCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual(managedOauthAccount) - expect(testState.scopedKeychainCredentials).toBe(externalCredentials) - expect(testState.legacyKeychainCredentials).toBe(externalCredentials) - }) - - it('preserves external oauth logout when managed oauth metadata is null', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'system-account' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials, - 'null\n' - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - rmSync(runtimeCredentialsPath, { force: true }) - testState.scopedKeychainCredentials = null - testState.legacyKeychainCredentials = null - writeFileSync(runtimeConfigPath, `${JSON.stringify({})}\n`, 'utf-8') - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(existsSync(runtimeCredentialsPath)).toBe(false) - expect(readRuntimeOauthAccountForTest()).toBeNull() - expect(testState.scopedKeychainCredentials).toBeNull() - expect(testState.legacyKeychainCredentials).toBeNull() - }) - - it('restores reordered owned oauth metadata using stable json equality', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const systemOauthAccount = { accountUuid: 'system-account', emailAddress: 'system@example.com' } - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, - 'utf-8' - ) - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials, - '{"accountUuid":"account-1","emailAddress":"user@example.com"}\n' - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync( - runtimeConfigPath, - '{"oauthAccount":{"emailAddress":"user@example.com","accountUuid":"account-1"}}\n', - 'utf-8' - ) - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) - }) - - it('restores owned oauth metadata during rollback after removing the added account', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const systemOauthAccount = { accountUuid: 'system-account' } - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, - 'utf-8' - ) - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials, - '{"accountUuid":"account-1"}\n' - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - settings.activeClaudeManagedAccountId = null - settings.claudeManagedAccounts = [] - await service.forceMaterializeCurrentSelectionForRollback() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-test-harness.ts b/src/main/claude-accounts/runtime-auth-service-test-harness.ts deleted file mode 100644 index 874e7534ff1a..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-test-harness.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { vi } from 'vitest' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { getDefaultSettings } from '../../shared/constants' -import type { GlobalSettings } from '../../shared/global-settings-types' -import type { ClaudeManagedAccount } from '../../shared/managed-account-types' - -const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') -export const hostPlatform = process.platform -export const testState = { - userDataDir: '', - fakeHomeDir: '', - activeKeychainCredentials: null as string | null, - scopedKeychainCredentials: null as string | null, - legacyKeychainCredentials: null as string | null, - throwScopedKeychainRead: false, - throwLegacyKeychainRead: false, - throwRuntimeKeychainWrite: false, - throwLegacyRuntimeKeychainWrite: false, - throwScopedKeychainWrite: false, - runtimeWriteConfigDir: null as string | null, - managedKeychainCredentials: new Map() -} - -export function expectedRuntimeConfigDir(): string { - return join(testState.fakeHomeDir, '.claude') -} - -export function createElectronMock() { - return { - app: { - getPath: () => testState.userDataDir - } - } -} - -// Why: these tests exercise materialize/read-back/snapshot logic, not the -// network OAuth refresh (covered by oauth-refresh.test.ts). Default the token -// to "not expiring" so the proactive switch-in refresh never fires here and -// existing expectations hold; individual tests can override these mocks. -export function createOauthRefreshMock() { - return { - isOauthTokenExpiring: vi.fn(() => false), - refreshClaudeOauthCredentials: vi.fn(async () => null) - } -} - -export function createKeychainMock() { - return { - readActiveClaudeKeychainCredentials: vi.fn(async (configDir?: string) => { - if (configDir) { - if (configDir !== expectedRuntimeConfigDir()) { - return testState.legacyKeychainCredentials - } - return testState.scopedKeychainCredentials ?? testState.legacyKeychainCredentials - } - return testState.legacyKeychainCredentials - }), - writeActiveClaudeKeychainCredentials: vi.fn(async (contents: string, configDir?: string) => { - if (configDir) { - if (configDir !== expectedRuntimeConfigDir()) { - throw new Error(`Unexpected Claude config dir: ${configDir}`) - } - if (testState.throwScopedKeychainWrite) { - throw new Error('scoped keychain write failed') - } - testState.scopedKeychainCredentials = contents - } else { - testState.legacyKeychainCredentials = contents - } - testState.activeKeychainCredentials = contents - }), - deleteActiveClaudeKeychainCredentials: vi.fn(async () => { - testState.scopedKeychainCredentials = null - testState.legacyKeychainCredentials = null - testState.activeKeychainCredentials = null - }), - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async (configDir?: string) => { - if (configDir) { - if (configDir !== expectedRuntimeConfigDir()) { - throw new Error(`Unexpected Claude config dir: ${configDir}`) - } - testState.scopedKeychainCredentials = null - } else { - testState.legacyKeychainCredentials = null - } - testState.activeKeychainCredentials = null - }), - readActiveClaudeKeychainCredentialsStrict: vi.fn(async (configDir?: string) => - configDir - ? (() => { - if (testState.throwScopedKeychainRead) { - throw new Error('scoped keychain read failed') - } - return configDir === expectedRuntimeConfigDir() - ? testState.scopedKeychainCredentials - : null - })() - : (() => { - if (testState.throwLegacyKeychainRead) { - throw new Error('legacy keychain read failed') - } - return testState.legacyKeychainCredentials - })() - ), - writeActiveClaudeKeychainCredentialsForRuntime: vi.fn( - async (contents: string, configDir: string) => { - if (configDir !== expectedRuntimeConfigDir()) { - throw new Error(`Unexpected Claude config dir: ${configDir}`) - } - if (testState.throwRuntimeKeychainWrite) { - throw new Error('runtime keychain write failed') - } - testState.runtimeWriteConfigDir = configDir - testState.scopedKeychainCredentials = contents - if (testState.throwLegacyRuntimeKeychainWrite) { - throw new Error('legacy runtime keychain write failed') - } - testState.legacyKeychainCredentials = contents - testState.activeKeychainCredentials = contents - } - ), - readManagedClaudeKeychainCredentials: vi.fn( - async (accountId: string) => testState.managedKeychainCredentials.get(accountId) ?? null - ), - writeManagedClaudeKeychainCredentials: vi.fn(async (accountId: string, contents: string) => { - testState.managedKeychainCredentials.set(accountId, contents) - }) - } -} - -export function setPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { - configurable: true, - value: platform - }) -} - -/** Shared `beforeEach` body: fresh platform, module registry, and temp homes. */ -export function resetRuntimeAuthTestState(): void { - setPlatform('darwin') - vi.resetModules() - vi.clearAllMocks() - testState.activeKeychainCredentials = null - testState.scopedKeychainCredentials = null - testState.legacyKeychainCredentials = null - testState.throwScopedKeychainRead = false - testState.throwLegacyKeychainRead = false - testState.throwRuntimeKeychainWrite = false - testState.throwLegacyRuntimeKeychainWrite = false - testState.throwScopedKeychainWrite = false - testState.runtimeWriteConfigDir = null - testState.managedKeychainCredentials.clear() - testState.userDataDir = mkdtempSync(join(tmpdir(), 'orca-claude-runtime-')) - testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-claude-home-')) - mkdirSync(join(testState.fakeHomeDir, '.claude'), { recursive: true }) -} - -/** Shared `afterEach` body. */ -export function cleanupRuntimeAuthTestState(): void { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - rmSync(testState.userDataDir, { recursive: true, force: true }) - rmSync(testState.fakeHomeDir, { recursive: true, force: true }) -} - -export function createSettings(overrides: Partial = {}): GlobalSettings { - return { - ...getDefaultSettings(testState.fakeHomeDir), - ...overrides - } -} - -export function createStore(settings: GlobalSettings) { - return { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { - ...settings, - ...updates, - notifications: { - ...settings.notifications, - ...updates.notifications - } - } - return settings - }) - } -} - -export function createManagedClaudeAuth( - rootDir: string, - accountId: string, - credentialsJson: string, - oauthAccountJson = `{"accountUuid":"${accountId}"}\n` -): string { - const managedAuthPath = join(rootDir, 'claude-accounts', accountId, 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), `${accountId}\n`, 'utf-8') - writeFileSync(join(managedAuthPath, '.credentials.json'), credentialsJson, 'utf-8') - writeFileSync(join(managedAuthPath, 'oauth-account.json'), oauthAccountJson, 'utf-8') - testState.managedKeychainCredentials.set(accountId, credentialsJson) - return managedAuthPath -} - -export function createClaudeAccount( - id: string, - managedAuthPath: string, - overrides: Partial = {} -): ClaudeManagedAccount { - return { - id, - email: 'user@example.com', - managedAuthPath, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1, - ...overrides - } -} - -export function createClaudeCredentialsJson( - email: string, - accessToken: string, - organizationUuid: string | null = null, - expiresAt = Date.now() + 60_000 -): string { - return `${JSON.stringify({ - claudeAiOauth: { - email, - ...(organizationUuid ? { organizationUuid } : {}), - accessToken, - refreshToken: `${accessToken}-refresh`, - expiresAt - } - })}\n` -} - -export function createClaudeCredentialsWithoutEmail( - accessToken: string, - organizationUuid: string | null = null, - options: { expiresAt?: number; refreshToken?: string } = {} -): string { - return `${JSON.stringify({ - claudeAiOauth: { - ...(organizationUuid ? { organizationUuid } : {}), - accessToken, - refreshToken: options.refreshToken ?? `${accessToken}-refresh`, - expiresAt: options.expiresAt ?? Date.now() + 60_000 - } - })}\n` -} - -export function readManagedCredentialsForTest( - accountId: string, - managedAuthPath: string -): string | null { - if (process.platform === 'darwin') { - return testState.managedKeychainCredentials.get(accountId) ?? null - } - return readFileSync(join(managedAuthPath, '.credentials.json'), 'utf-8') -} - -export function readRuntimeOauthAccountForTest(): unknown { - const configPath = join(testState.fakeHomeDir, '.claude.json') - if (!existsSync(configPath)) { - return null - } - return ( - (JSON.parse(readFileSync(configPath, 'utf-8')) as Record).oauthAccount ?? null - ) -} diff --git a/src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts b/src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts deleted file mode 100644 index 61c3323b6327..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createClaudeAccount, - createClaudeCredentialsJson, - createElectronMock, - createKeychainMock, - createManagedClaudeAuth, - createOauthRefreshMock, - createSettings, - createStore, - resetRuntimeAuthTestState, - setPlatform, - testState -} from './runtime-auth-service-test-harness' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import type * as ClaudeProfileRouting from '../../shared/claude-profile-routing' - -vi.mock('electron', () => createElectronMock()) - -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) - -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { - ...actual, - homedir: () => testState.fakeHomeDir - } -}) - -vi.mock('./keychain', () => createKeychainMock()) - -describe('ClaudeRuntimeAuthService', () => { - beforeEach(() => { - resetRuntimeAuthTestState() - }) - - afterEach(() => { - cleanupRuntimeAuthTestState() - }) - - it('uses account WSL runtime for untargeted Claude preparation instead of stale terminal WSL settings', async () => { - setPlatform('win32') - vi.doMock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => null, - toWindowsWslPath: (value: string) => value - })) - const ubuntuAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'ubuntu-account', - createClaudeCredentialsJson('ubuntu@example.com', 'ubuntu-token') - ) - const settings = createSettings({ - localAccountRuntime: 'wsl', - localAccountWslDistro: 'Ubuntu', - terminalWindowsShell: 'wsl.exe', - terminalWindowsWslDistro: 'Debian', - claudeManagedAccounts: [ - createClaudeAccount('ubuntu-account', ubuntuAuthPath, { - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth' - }) - ], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'ubuntu-account' } - } - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - expect(service.getRuntimeConfigDir({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - ubuntuAuthPath - ) - const preparation = await service.prepareForClaudeLaunch() - - expect(preparation).toMatchObject({ - runtime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxConfigDir: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth', - provenance: 'managed:ubuntu-account:wsl:Ubuntu', - stripAuthEnv: true - }) - }) - - it('uses the global WSL runtime for untargeted Claude preparation under auto', async () => { - setPlatform('win32') - vi.doMock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => null, - toWindowsWslPath: (value: string) => value - })) - const ubuntuAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'ubuntu-account', - createClaudeCredentialsJson('ubuntu@example.com', 'ubuntu-token') - ) - const settings = createSettings({ - localAccountRuntime: 'auto', - localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, - claudeManagedAccounts: [ - createClaudeAccount('ubuntu-account', ubuntuAuthPath, { - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth' - }) - ], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'ubuntu-account' } - } - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - const preparation = await service.prepareForClaudeLaunch() - - expect(preparation).toMatchObject({ - runtime: 'wsl', - wslDistro: 'Ubuntu', - provenance: 'managed:ubuntu-account:wsl:Ubuntu' - }) - }) - - it('ignores a persisted WSL account-runtime pin on non-Windows hosts', async () => { - setPlatform('darwin') - vi.doMock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => null, - toWindowsWslPath: (value: string) => value - })) - const settings = createSettings({ - localAccountRuntime: 'wsl', - localAccountWslDistro: 'Ubuntu' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - const preparation = await service.prepareForClaudeLaunch() - - expect(preparation).toMatchObject({ - runtime: 'host', - wslDistro: null, - provenance: 'system', - stripAuthEnv: false - }) - }) - - it('keeps untargeted Claude preparation on host when account runtime is host', async () => { - setPlatform('win32') - vi.doMock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => null, - toWindowsWslPath: (value: string) => value - })) - const settings = createSettings({ - localAccountRuntime: 'host', - terminalWindowsShell: 'wsl.exe', - terminalWindowsWslDistro: 'Debian' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - const preparation = await service.prepareForClaudeLaunch() - - expect(preparation).toMatchObject({ - runtime: 'host', - wslDistro: null, - provenance: 'system', - stripAuthEnv: false - }) - }) - - it('clears a selected WSL managed account when its credentials are missing', async () => { - const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/account-1/auth' - }) - ], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-1' } } - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - const preparation = await service.prepareForClaudeLaunch({ - runtime: 'wsl', - wslDistro: 'Ubuntu' - }) - - expect(store.updateSettings).toHaveBeenCalledWith({ - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } - }) - expect(preparation.runtime).toBe('wsl') - expect(preparation.provenance).toBe('wsl:Ubuntu:system') - expect(preparation.stripAuthEnv).toBe(true) - }) - - it('uses the default distro selection for WSL-default Claude preparation', async () => { - const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - vi.doMock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => join(testState.userDataDir, 'wsl-home'), - toWindowsWslPath: (value: string) => value - })) - const ubuntuAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'ubuntu-account', - createClaudeCredentialsJson('ubuntu@example.com', 'ubuntu-token') - ) - const debianAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'debian-account', - createClaudeCredentialsJson('debian@example.com', 'debian-token') - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('ubuntu-account', ubuntuAuthPath, { - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth' - }), - createClaudeAccount('debian-account', debianAuthPath, { - managedAuthRuntime: 'wsl', - wslDistro: 'Debian', - wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/debian/auth' - }) - ], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'ubuntu-account', Debian: 'debian-account' } - } - }) - const store = createStore(settings) - - try { - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - const preparation = await service.prepareForClaudeLaunch({ - runtime: 'wsl', - wslDistro: null - }) - - expect(preparation).toMatchObject({ - runtime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxConfigDir: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth', - provenance: 'managed:ubuntu-account:wsl:Ubuntu', - stripAuthEnv: true - }) - } finally { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - } - }) - - it('routes a WSL distro through its guest router, and a failed guest publish never fails a select', async () => { - setPlatform('win32') - const wslRouter = { - prepareLaunch: vi.fn(async (distro: string) => ({ runtime: 'wsl', wslDistro: distro })), - publish: vi.fn(async () => { - throw new Error('distro is gone') - }), - runningDistros: vi.fn(async () => []) - } - vi.doMock('../../shared/claude-profile-routing', async (original) => ({ - ...(await original()), - claudeProfileRoutingEnabled: () => true - })) - vi.doMock('./claude-profile-wsl-router', () => ({ - ClaudeWslProfileRouter: function ClaudeWslProfileRouter() { - return wslRouter - } - })) - try { - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const store = createStore(createSettings()) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service reads only getSettings/updateSettings, which the harness store implements. - const service = new ClaudeRuntimeAuthService(store as never) - await expect( - service.prepareForClaudeLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - ).resolves.toMatchObject({ wslDistro: 'Ubuntu' }) - await expect( - service.syncForCurrentSelection({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - ).resolves.toBeUndefined() - expect(wslRouter.publish).toHaveBeenCalledWith('Ubuntu') - } finally { - vi.doUnmock('../../shared/claude-profile-routing') - vi.doUnmock('./claude-profile-wsl-router') - } - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service.test.ts b/src/main/claude-accounts/runtime-auth-service.test.ts new file mode 100644 index 000000000000..1cfc4c7d3857 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../shared/constants' +import type { GlobalSettings } from '../../shared/global-settings-types' + +const fakes = vi.hoisted(() => ({ + host: { + prepareLaunch: async () => ({ runtime: 'host' }), + publish: () => {}, + systemDefaultHome: () => '/host/.claude' + }, + wsl: { + prepareLaunch: async (distro: string) => ({ runtime: 'wsl', wslDistro: distro }), + publish: async (_distro: string): Promise => {}, + runningDistros: async (): Promise => [] + } +})) +vi.mock('../../shared/app-environment', () => ({ + getAppEnvironment: () => ({ getPath: () => '/data/orca' }) +})) +vi.mock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: (distro: string) => `/wsl/${distro}/home` +})) +vi.mock('./claude-profile-router', () => ({ + ClaudeProfileRouter: function ClaudeProfileRouter() { + return fakes.host + } +})) +vi.mock('./claude-profile-wsl-router', () => ({ + ClaudeWslProfileRouter: function ClaudeWslProfileRouter() { + return fakes.wsl + } +})) +vi.mock('./claude-profile-installed-router', () => ({ installClaudeProfileRouter: () => {} })) + +import { ClaudeRuntimeAuthService } from './runtime-auth-service' + +const platform = Object.getOwnPropertyDescriptor(process, 'platform')! +function service(platformName: NodeJS.Platform, overrides: Partial = {}) { + Object.defineProperty(process, 'platform', { configurable: true, value: platformName }) + const settings = { ...getDefaultSettings('/home/user'), ...overrides } + return new ClaudeRuntimeAuthService({ getSettings: () => settings }) +} + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) +afterEach(() => { + Object.defineProperty(process, 'platform', platform) + vi.restoreAllMocks() +}) + +describe('ClaudeRuntimeAuthService', () => { + it('keeps a stale WSL account setting on the host off Windows', async () => { + const auth = service('darwin', { localAccountRuntime: 'wsl', localAccountWslDistro: 'Ubuntu' }) + expect(auth.getRuntimeConfigDir()).toBe('/host/.claude') + await expect(auth.prepareForClaudeLaunch()).resolves.toEqual({ runtime: 'host' }) + }) + + it('routes a WSL target that names no distro to the default distro', async () => { + const auth = service('win32') + await expect(auth.prepareForClaudeLaunch({ runtime: 'wsl', wslDistro: null })).resolves.toEqual( + { runtime: 'wsl', wslDistro: 'Ubuntu' } + ) + expect(auth.getRuntimeConfigDir({ runtime: 'wsl', wslDistro: null })).toBe( + '/wsl/Ubuntu/home/.claude' + ) + }) + + it('never fails a select over a failed guest publish, and publishes one pass at a time', async () => { + const auth = service('win32') + vi.spyOn(fakes.wsl, 'publish').mockRejectedValue(new Error('distro is gone')) + await expect( + auth.syncForCurrentSelection({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + ).resolves.toBeUndefined() + + const order: string[] = [] + let release = () => {} + const gate = new Promise((resolve) => (release = resolve)) + vi.spyOn(fakes.host, 'publish').mockImplementation(() => order.push('host')) + vi.spyOn(fakes.wsl, 'runningDistros') + .mockImplementationOnce(async () => { + await gate + order.push('first guests') + return [] + }) + .mockResolvedValue([]) + const first = auth.publishAll() + const second = auth.publishAll() + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(order).toEqual(['host']) + release() + await Promise.all([first, second]) + expect(order).toEqual(['host', 'first guests', 'host']) + }) +}) diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index adbbce30da01..b15bffbc4829 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -1,164 +1,156 @@ +import { join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' -import { claudeProfileRoutingEnabled } from '../../shared/claude-profile-routing' +import { toWindowsWslPath } from '../../shared/wsl-paths' +import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime' +import type { Store } from '../persistence' +import { getDefaultWslDistro, getWslHome } from '../wsl' import { ClaudeProfileRouter } from './claude-profile-router' import { ClaudeWslProfileRouter } from './claude-profile-wsl-router' -import { - getClaudeProfileRouter, - installClaudeProfileRouter -} from './claude-profile-installed-router' -import type { Store } from '../persistence' -import { - getSelectedClaudeAccountIdForTarget, - type ClaudeAccountSelectionTarget -} from './runtime-selection' -import { ClaudeRuntimeAuthSync } from './runtime-auth/runtime-auth-sync' +import { installClaudeProfileRouter } from './claude-profile-installed-router' +import type { ClaudeAccountSelectionTarget } from './runtime-selection' import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' export type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' -function routerFor(target: ClaudeAccountSelectionTarget): ClaudeProfileRouter | undefined { - return target.runtime === 'wsl' ? undefined : getClaudeProfileRouter() -} +/** `configDir` is the CLAUDE_CONFIG_DIR value; `readPath` is where this host reads it (UNC for WSL). */ +export type ClaudeAccountFolder = { configDir: string; readPath: string } -export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { +/** Routes Claude launches on this host and its WSL distros to the selected account folder. */ +export class ClaudeRuntimeAuthService { + readonly router: ClaudeProfileRouter private readonly wslRouter?: ClaudeWslProfileRouter - - constructor(store: Store) { - super(store) - if (claudeProfileRoutingEnabled()) { - const args = { - getSettings: () => store.getSettings(), - dataRoot: getAppEnvironment().getPath('userData') - } - installClaudeProfileRouter(new ClaudeProfileRouter(args)) - this.wslRouter = process.platform === 'win32' ? new ClaudeWslProfileRouter(args) : undefined - } - this.initializeLastSyncedState() - void this.safeSyncForCurrentSelection() + private readonly dataRoot = getAppEnvironment().getPath('userData') + private mutationQueue: Promise = Promise.resolve() + + constructor(private readonly store: Pick) { + const args = { getSettings: () => store.getSettings(), dataRoot: this.dataRoot } + this.router = new ClaudeProfileRouter(args) + installClaudeProfileRouter(this.router) + this.wslRouter = process.platform === 'win32' ? new ClaudeWslProfileRouter(args) : undefined + void this.publishAll().catch((error: unknown) => { + console.warn('[claude-runtime-auth] Failed to publish the Claude account selection:', error) + }) } - async prepareForClaudeLaunch( + prepareForClaudeLaunch( target?: ClaudeAccountSelectionTarget ): Promise { - const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() - const wsl = this.wslRouteFor(effectiveTarget) - if (wsl) { - return wsl.router.prepareLaunch(wsl.distro) - } - const router = routerFor(effectiveTarget) - if (router) { - return router.prepareLaunch() - } - await this.syncForCurrentSelection(effectiveTarget) - return this.getPreparation(effectiveTarget) + const wsl = this.wslRouteFor(target) + return wsl ? wsl.router.prepareLaunch(wsl.distro) : this.router.prepareLaunch() } async prepareForRateLimitFetch( target?: ClaudeAccountSelectionTarget ): Promise { - const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() - const wsl = this.wslRouteFor(effectiveTarget) - if (wsl) { - return wsl.router.preparation(wsl.distro) - } - const router = routerFor(effectiveTarget) - if (router) { - return router.preparation() + const wsl = this.wslRouteFor(target) + try { + return await (wsl ? wsl.router.preparation(wsl.distro) : this.router.preparation()) + } catch (error) { + // Why not thrown: one usage cycle polls every provider, so one account must not stop it. + return { + configDir: '', + envPatch: {}, + stripAuthEnv: true, + provenance: 'profile:unavailable', + usageError: error instanceof Error ? error.message : String(error) + } } - await this.syncForCurrentSelection(effectiveTarget) - return this.getPreparation(effectiveTarget) } + /** Rewrites the which-account file for `target` after its selection changed. */ async syncForCurrentSelection(target?: ClaudeAccountSelectionTarget): Promise { await this.serializeMutation(async () => { - const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() - const wsl = this.wslRouteFor(effectiveTarget) - const router = routerFor(effectiveTarget) + const wsl = this.wslRouteFor(target) if (wsl) { await this.publishWsl(wsl.router, wsl.distro) - } else if (router) { - router.publish() } else { - await this.doSyncForCurrentSelection(effectiveTarget) + this.router.publish() } }) } - /** Null when the target is not a WSL distro routed by account folders. */ - private wslRouteFor( - target: ClaudeAccountSelectionTarget - ): { router: ClaudeWslProfileRouter; distro: string } | null { - const distro = - target.runtime === 'wsl' ? this.resolveWslDefaultTarget(target).wslDistro?.trim() : null - return this.wslRouter && distro ? { router: this.wslRouter, distro } : null - } - - // Why never thrown: a guest Orca cannot reach also cannot run a pane, and a deleted distro must - // not block removing its accounts. The next select, or a start while it runs, rewrites it. - private async publishWsl(router: ClaudeWslProfileRouter, distro: string): Promise { - await router.publish(distro).catch((error: unknown) => { - console.warn(`[claude-profile] Could not update the Claude account in WSL ${distro}:`, error) + /** Startup and rollback republish only running distros: neither may boot a stopped one. */ + async publishAll(): Promise { + await this.serializeMutation(async () => { + this.router.publish() + const router = this.wslRouter + if (router) { + const distros = await router.runningDistros() + await Promise.all(distros.map((distro) => this.publishWsl(router, distro))) + } }) } - /** Startup and rollback republish only running distros: neither may boot a stopped one. */ - private async publishRunningWslDistros(): Promise { - const router = this.wslRouter - if (router) { - const distros = await router.runningDistros() - await Promise.all(distros.map((distro) => this.publishWsl(router, distro))) + /** Creates and sets up an account's folder so Claude can sign in to it. */ + async prepareAccountFolder( + accountId: string, + target: ClaudeAccountSelectionTarget + ): Promise { + const wsl = this.wslRouteFor(target) + if (!wsl) { + const home = await this.router.prepareAccount(accountId) + return { configDir: home, readPath: home } } + const home = await wsl.router.prepareAccount(wsl.distro, accountId) + return { configDir: home, readPath: toWindowsWslPath(home, wsl.distro) } } - async forceMaterializeCurrentSelectionForRollback(): Promise { - await this.serializeMutation(async () => { - const router = getClaudeProfileRouter() - if (router) { - router.publish() - await this.publishRunningWslDistros() - return - } - const settings = this.store.getSettings() - if (!settings.activeClaudeManagedAccountId) { - const previousAccount = this.getActiveAccount( - settings.claudeManagedAccounts, - this.lastSyncedAccountId - ) - await this.restoreSystemDefaultSnapshot( - previousAccount ? await this.readManagedCredentials(previousAccount) : null, - previousAccount ? await this.readManagedOauthAccount(previousAccount) : undefined - ) - this.lastSyncedAccountId = null - return - } - await this.doSyncForCurrentSelection() + async removeAccountFolder( + accountId: string, + target: ClaudeAccountSelectionTarget + ): Promise { + const wsl = this.wslRouteFor(target) + if (!wsl) { + await this.router.removeAccount(accountId) + return + } + // Why never thrown: a deleted distro must not block removing its accounts. + await wsl.router.removeAccount(wsl.distro, accountId).catch((error: unknown) => { + console.warn( + `[claude-profile] Could not delete the Claude account folder in WSL ${wsl.distro}:`, + error + ) }) } + /** System default's folder: skills and plugins install there and are linked into accounts. */ getRuntimeConfigDir(target?: ClaudeAccountSelectionTarget): string { - return this.getPreparation(target).configDir + const effective = this.resolveTarget(target) + const home = + effective.runtime === 'wsl' && effective.wslDistro ? getWslHome(effective.wslDistro) : null + return home ? join(home, '.claude') : this.router.systemDefaultHome() } - private initializeLastSyncedState(): void { - const settings = this.store.getSettings() - this.lastSyncedAccountId = getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) + // Why never thrown: a guest Orca cannot reach also cannot run a pane, and a deleted distro must + // not block removing its accounts. The next select, or a start while it runs, rewrites it. + private async publishWsl(router: ClaudeWslProfileRouter, distro: string): Promise { + await router.publish(distro).catch((error: unknown) => { + console.warn(`[claude-profile] Could not update the Claude account in WSL ${distro}:`, error) + }) } - private async safeSyncForCurrentSelection(): Promise { - try { - const router = getClaudeProfileRouter() - if (!router) { - await this.syncForCurrentSelection() - return - } - // Why serialized: an account change during startup must not be overwritten by this older read. - await this.serializeMutation(async () => { - router.publish() - await this.publishRunningWslDistros() - }) - } catch (error) { - console.warn('[claude-runtime-auth] Failed to sync runtime auth state:', error) + /** Null when the target is not a WSL distro this host can route. */ + private wslRouteFor( + target?: ClaudeAccountSelectionTarget + ): { router: ClaudeWslProfileRouter; distro: string } | null { + const effective = this.resolveTarget(target) + const distro = effective.runtime === 'wsl' ? effective.wslDistro?.trim() : null + return this.wslRouter && distro ? { router: this.wslRouter, distro } : null + } + + /** A WSL target that names no distro means the default distro, as Claude launches it there. */ + private resolveTarget(target?: ClaudeAccountSelectionTarget): ClaudeAccountSelectionTarget { + const effective = + target ?? + // Why: stale cross-platform WSL pins must stay on the host off Windows. + (process.platform === 'win32' + ? resolveLocalAccountRuntimeTarget(this.store.getSettings()) + : { runtime: 'host' as const }) + if (effective.runtime !== 'wsl' || effective.wslDistro?.trim()) { + return effective } + const defaultDistro = getDefaultWslDistro() + return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : effective } private serializeMutation(fn: () => Promise): Promise { @@ -166,14 +158,4 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { this.mutationQueue = next.catch(() => {}) return next } - - // Why: re-auth/add-account write fresh managed tokens; skip the next read-back so stale runtime tokens can't overwrite them. - clearLastWrittenCredentialsJson( - accountId = this.store.getSettings().activeClaudeManagedAccountId - ): void { - if (accountId === this.store.getSettings().activeClaudeManagedAccountId) { - this.lastWrittenCredentialsJson = null - } - this.skipNextReadBackForAccountId = accountId - } } diff --git a/src/main/claude-accounts/runtime-auth-shared-credentials-fixture.ts b/src/main/claude-accounts/runtime-auth-shared-credentials-fixture.ts deleted file mode 100644 index 8cb00420dc65..000000000000 --- a/src/main/claude-accounts/runtime-auth-shared-credentials-fixture.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - createClaudeAccount, - createClaudeCredentialsJson, - createManagedClaudeAuth, - createSettings, - createStore, - setPlatform, - testState -} from './runtime-auth-service-test-harness' -import { writeFileSync } from 'node:fs' -import { join } from 'node:path' - -export const sharedFields = { - mcpOAuth: { figma: { accessToken: 'mcp-access', refreshToken: 'mcp-refresh' } }, - mcpOAuthClientConfig: { figma: { clientId: 'figma-client' } }, - mcpXaaIdp: { token: 'idp-token' }, - mcpXaaIdpConfig: { issuer: 'idp-issuer' }, - pluginSecrets: { plugin: 'secret' } -} - -export function withSharedFields( - credentials: string, - fields: Record = sharedFields -): string { - return JSON.stringify({ ...JSON.parse(credentials), ...fields }) -} - -export async function createSharedCredentialRuntime(platform: NodeJS.Platform = 'darwin') { - setPlatform(platform) - const runtimePath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const system = createClaudeCredentialsJson('system@example.com', 'system') - const first = createClaudeCredentialsJson('first@example.com', 'first') - const second = createClaudeCredentialsJson('second@example.com', 'second') - const firstPath = createManagedClaudeAuth(testState.userDataDir, 'first', first) - const secondPath = createManagedClaudeAuth(testState.userDataDir, 'second', second) - writeFileSync(runtimePath, withSharedFields(system)) - testState.scopedKeychainCredentials = withSharedFields(system) - testState.legacyKeychainCredentials = withSharedFields(system) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('first', firstPath, { email: 'first@example.com' }), - createClaudeAccount('second', secondPath, { email: 'second@example.com' }) - ] - }) - const store = createStore(settings) - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime auth uses only getSettings/updateSettings from this store mock. - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - return { service, settings, runtimePath, first, second, system, firstPath, secondPath } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-credential-identity.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-credential-identity.ts deleted file mode 100644 index d029ae0d6992..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-credential-identity.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { ClaudeRuntimeAuthFileStorage } from './runtime-auth-file-storage' -import { stripSharedClaudeCredentialFields } from '../shared-credential-fields' -import type { - ClaudeAuthIdentity, - ClaudeReadBackMatch, - ClaudeRefreshTokenComparison -} from './runtime-auth-types' - -export class ClaudeRuntimeAuthCredentialIdentity extends ClaudeRuntimeAuthFileStorage { - protected accountCredentialFieldsEqual(left: string | null, right: string | null): boolean { - if (left === right) { - return true - } - if (left === null || right === null) { - return false - } - try { - const leftAccount = this.asRecord(JSON.parse(stripSharedClaudeCredentialFields(left))) - const rightAccount = this.asRecord(JSON.parse(stripSharedClaudeCredentialFields(right))) - return ( - leftAccount !== null && - rightAccount !== null && - this.jsonValuesEqual(leftAccount, rightAccount) - ) - } catch { - return false - } - } - - protected readIdentityFromCredentials(credentialsJson: string): ClaudeAuthIdentity | null { - let parsed: Record - try { - parsed = JSON.parse(credentialsJson) as Record - } catch { - return null - } - const oauth = this.asRecord(parsed.claudeAiOauth) - return { - accountUuid: this.normalizeField( - this.readString(oauth, 'accountUuid') ?? this.readString(oauth, 'accountId') - ), - email: this.normalizeField(this.readString(oauth, 'email')), - organizationUuid: this.normalizeField( - this.readString(oauth, 'organizationUuid') ?? this.readString(oauth, 'organizationId') - ) - } - } - - protected isValidCredentialsJsonObject(credentialsJson: string): boolean { - try { - const parsed = this.asRecord(JSON.parse(credentialsJson)) - const oauth = this.asRecord(parsed?.claudeAiOauth) - return this.normalizeField(this.readString(oauth, 'accessToken')) !== null - } catch { - return false - } - } - - protected runtimeCredentialsAreFresher( - runtimeCredentialsJson: string, - managedCredentialsJson: string - ): boolean { - const runtimeFreshness = this.readFreshnessFromCredentials(runtimeCredentialsJson) - const managedFreshness = this.readFreshnessFromCredentials(managedCredentialsJson) - return ( - runtimeFreshness !== null && managedFreshness !== null && runtimeFreshness > managedFreshness - ) - } - - protected runtimeCredentialsAreOlder( - runtimeCredentialsJson: string, - managedCredentialsJson: string - ): boolean { - const runtimeFreshness = this.readFreshnessFromCredentials(runtimeCredentialsJson) - const managedFreshness = this.readFreshnessFromCredentials(managedCredentialsJson) - return ( - runtimeFreshness !== null && managedFreshness !== null && runtimeFreshness < managedFreshness - ) - } - - protected chooseFreshestReadBackCandidate( - candidates: { - credentialsJson: string - match: Extract - }[] - ): { - credentialsJson: string - match: Extract - } { - return candidates.reduce((freshest, candidate) => { - const candidateFreshness = this.readFreshnessFromCredentials(candidate.credentialsJson) - const freshestFreshness = this.readFreshnessFromCredentials(freshest.credentialsJson) - if ( - candidateFreshness !== null && - (freshestFreshness === null || candidateFreshness > freshestFreshness) - ) { - return candidate - } - return freshest - }) - } - - protected readFreshnessFromCredentials(credentialsJson: string): number | null { - let parsed: Record - try { - parsed = JSON.parse(credentialsJson) as Record - } catch { - return null - } - const oauth = this.asRecord(parsed.claudeAiOauth) - return ( - this.readNumber(oauth, 'expiresAt') ?? - this.readNumber(oauth, 'expires_at') ?? - this.readNumber(oauth, 'expiry') ?? - this.readNumber(oauth, 'expires') - ) - } - - protected compareRefreshTokens( - runtimeCredentialsJson: string, - managedCredentialsJson: string - ): ClaudeRefreshTokenComparison { - const runtimeRefreshToken = this.readRefreshTokenFromCredentials(runtimeCredentialsJson) - const managedRefreshToken = this.readRefreshTokenFromCredentials(managedCredentialsJson) - if (!runtimeRefreshToken || !managedRefreshToken) { - return 'missing' - } - return runtimeRefreshToken === managedRefreshToken ? 'same' : 'different' - } - - protected readRefreshTokenFromCredentials(credentialsJson: string): string | null { - try { - const parsed = JSON.parse(credentialsJson) as Record - const oauth = this.asRecord(parsed.claudeAiOauth) - return this.normalizeField(this.readString(oauth, 'refreshToken')) - } catch { - return null - } - } - - protected readIdentityFromOauthAccount(oauthAccount: unknown): ClaudeAuthIdentity { - const oauth = this.asRecord(oauthAccount) - return { - accountUuid: this.normalizeField( - this.readString(oauth, 'accountUuid') ?? this.readString(oauth, 'accountId') - ), - email: this.normalizeField( - this.readString(oauth, 'emailAddress') ?? this.readString(oauth, 'email') - ), - organizationUuid: this.normalizeField( - this.readString(oauth, 'organizationUuid') ?? this.readString(oauth, 'organizationId') - ) - } - } - - protected asRecord(value: unknown): Record | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - return value as Record - } - - protected readString(value: Record | null, key: string): string | null { - const candidate = value?.[key] - return typeof candidate === 'string' ? candidate : null - } - - protected readNumber(value: Record | null, key: string): number | null { - const candidate = value?.[key] - if (typeof candidate === 'number' && Number.isFinite(candidate)) { - return candidate - } - if (typeof candidate === 'string') { - const parsed = Number(candidate) - return Number.isFinite(parsed) ? parsed : null - } - return null - } - - protected normalizeField(value: string | null | undefined): string | null { - if (!value) { - return null - } - const trimmed = value.trim() - return trimmed === '' ? null : trimmed - } - - protected jsonValuesEqual(left: unknown, right: unknown): boolean { - return ( - JSON.stringify(this.sortJsonValue(left ?? null)) === - JSON.stringify(this.sortJsonValue(right ?? null)) - ) - } - - protected sortJsonValue(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map((item) => this.sortJsonValue(item)) - } - const record = this.asRecord(value) - if (!record) { - return value - } - return Object.fromEntries( - Object.entries(record) - .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) - .map(([key, nestedValue]) => [key, this.sortJsonValue(nestedValue)]) - ) - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-credential-matching.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-credential-matching.ts deleted file mode 100644 index 390ec57a8b2f..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-credential-matching.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' -import { ClaudeRuntimeAuthRuntimeState } from './runtime-auth-runtime-state' -import type { ClaudeReadBackMatch } from './runtime-auth-types' - -export class ClaudeRuntimeAuthCredentialMatching extends ClaudeRuntimeAuthRuntimeState { - protected async findManagedAccountForRuntimeCredentials( - runtimeCredentialsJson: string, - runtimeOauthAccount: unknown - ): Promise { - const matches: { account: ClaudeManagedAccount; managedCredentialsJson: string }[] = [] - let unverifiableCount = 0 - for (const account of this.store.getSettings().claudeManagedAccounts) { - const managedCredentialsJson = await this.readManagedCredentials(account) - if (!managedCredentialsJson) { - continue - } - const match = this.runtimeCredentialsMatchAccount( - runtimeCredentialsJson, - runtimeOauthAccount, - account, - managedCredentialsJson, - await this.readManagedOauthAccount(account) - ) - if (match === 'match') { - matches.push({ account, managedCredentialsJson }) - } else if (match === 'unverifiable') { - unverifiableCount += 1 - } - } - - if (matches.length === 1 && unverifiableCount === 0) { - return { kind: 'matched', ...matches[0] } - } - return { kind: matches.length === 0 && unverifiableCount === 0 ? 'none' : 'ambiguous' } - } - - protected runtimeCredentialsMatchAccount( - runtimeCredentialsJson: string, - runtimeOauthAccount: unknown, - account: ClaudeManagedAccount, - managedCredentialsJson: string, - managedOauthAccount: unknown - ): 'match' | 'mismatch' | 'unverifiable' { - const identity = this.readIdentityFromCredentials(runtimeCredentialsJson) - if (!identity) { - return 'mismatch' - } - const managedIdentity = this.readIdentityFromCredentials(managedCredentialsJson) - const managedOauthIdentity = this.readIdentityFromOauthAccount(managedOauthAccount) - const runtimeOauthIdentity = this.readIdentityFromOauthAccount(runtimeOauthAccount) - const credentialOauthConflict = - (identity.accountUuid && - runtimeOauthIdentity.accountUuid && - identity.accountUuid !== runtimeOauthIdentity.accountUuid) || - (identity.email && - runtimeOauthIdentity.email && - identity.email !== runtimeOauthIdentity.email) || - (identity.organizationUuid && - runtimeOauthIdentity.organizationUuid && - identity.organizationUuid !== runtimeOauthIdentity.organizationUuid) - if (credentialOauthConflict) { - return 'mismatch' - } - - // Why: mirrors the Codex runtime-home guard; don't persist shared runtime creds into the managed account if another login rewrote them. - const selectedOrganizationUuid = this.normalizeField( - account.organizationUuid ?? - managedIdentity?.organizationUuid ?? - managedOauthIdentity.organizationUuid - ) - const oauthAccountMatches = - Boolean(managedOauthIdentity.accountUuid) && - managedOauthIdentity.accountUuid === runtimeOauthIdentity.accountUuid && - Boolean(runtimeOauthIdentity.email || runtimeOauthIdentity.organizationUuid) - const runtimeEmail = identity.email ?? runtimeOauthIdentity.email - const runtimeOrganizationUuid = - identity.organizationUuid ?? runtimeOauthIdentity.organizationUuid - const refreshTokenComparison = this.compareRefreshTokens( - runtimeCredentialsJson, - managedCredentialsJson - ) - if (!runtimeEmail) { - if (refreshTokenComparison === 'same') { - return 'match' - } - if (identity.organizationUuid) { - if (selectedOrganizationUuid && selectedOrganizationUuid !== identity.organizationUuid) { - return 'mismatch' - } - return 'unverifiable' - } - if (oauthAccountMatches) { - return 'match' - } - if (!runtimeOrganizationUuid && refreshTokenComparison === 'different') { - return 'mismatch' - } - return 'unverifiable' - } - if (account.email && this.normalizeField(account.email) !== runtimeEmail) { - return 'mismatch' - } - if (selectedOrganizationUuid && !runtimeOrganizationUuid) { - return refreshTokenComparison === 'same' || oauthAccountMatches ? 'match' : 'unverifiable' - } - if ( - selectedOrganizationUuid && - runtimeOrganizationUuid && - selectedOrganizationUuid !== runtimeOrganizationUuid - ) { - return 'mismatch' - } - if (!selectedOrganizationUuid && runtimeOrganizationUuid) { - return refreshTokenComparison === 'same' ? 'match' : 'unverifiable' - } - - return 'match' - } - - protected liveRuntimeCredentialsCanUpdateActiveAccount( - runtimeCredentialsJson: string, - account: ClaudeManagedAccount, - managedCredentialsJson: string, - managedOauthAccount: unknown - ): boolean { - const match = this.runtimeCredentialsMatchAccount( - runtimeCredentialsJson, - this.readRuntimeOauthAccount(), - account, - managedCredentialsJson, - managedOauthAccount - ) - if (match === 'match') { - return true - } - const identity = this.readIdentityFromCredentials(runtimeCredentialsJson) - const managedIdentity = this.readIdentityFromCredentials(managedCredentialsJson) - const managedOauthIdentity = this.readIdentityFromOauthAccount(managedOauthAccount) - const runtimeOauthIdentity = this.readIdentityFromOauthAccount(this.readRuntimeOauthAccount()) - const selectedOrganizationUuid = this.normalizeField( - account.organizationUuid ?? - managedIdentity?.organizationUuid ?? - managedOauthIdentity.organizationUuid - ) - return ( - match === 'unverifiable' && - Boolean(selectedOrganizationUuid) && - (identity?.organizationUuid ?? runtimeOauthIdentity.organizationUuid) === - selectedOrganizationUuid - ) - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-file-storage.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-file-storage.ts deleted file mode 100644 index 8aa33a88c609..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-file-storage.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { chmodSync, existsSync, mkdirSync, readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { app } from 'electron' -import { writeFileAtomically } from '../../codex-accounts/fs-utils' -import { ClaudeRuntimeAuthState } from './runtime-auth-state' - -export class ClaudeRuntimeAuthFileStorage extends ClaudeRuntimeAuthState { - protected writeRuntimeCredentials(contents: string): void { - const credentialsPath = this.pathResolver.getRuntimePaths().credentialsPath - mkdirSync(dirname(credentialsPath), { recursive: true }) - // Why: skip unchanged rewrites to dodge Windows EPERM contention (#1507); re-verify the file since another Claude may have rewritten it. - if ( - this.lastWrittenCredentialsJson === contents && - this.fileContentsEqual(credentialsPath, contents) - ) { - this.ensureOwnerOnlyMode(credentialsPath) - return - } - if (this.fileContentsEqual(credentialsPath, contents)) { - this.ensureOwnerOnlyMode(credentialsPath) - this.lastWrittenCredentialsJson = contents - return - } - writeFileAtomically(credentialsPath, contents, { mode: 0o600 }) - this.lastWrittenCredentialsJson = contents - } - - protected writeJson(targetPath: string, value: unknown): void { - const serialized = `${JSON.stringify(value, null, 2)}\n` - mkdirSync(dirname(targetPath), { recursive: true }) - // Why: same Windows contention reason as writeRuntimeCredentials. - if (this.fileContentsEqual(targetPath, serialized)) { - return - } - writeFileAtomically(targetPath, serialized, { mode: 0o600 }) - } - - protected fileContentsEqual(targetPath: string, contents: string): boolean { - try { - return existsSync(targetPath) && readFileSync(targetPath, 'utf-8') === contents - } catch { - return false - } - } - - protected ensureOwnerOnlyMode(targetPath: string): void { - if (process.platform === 'win32') { - return - } - try { - chmodSync(targetPath, 0o600) - } catch { - /* Best effort: the next atomic write will set the restrictive mode. */ - } - } - - protected readJsonObject(targetPath: string): Record | null { - if (!existsSync(targetPath)) { - return {} - } - try { - const parsed = JSON.parse(readFileSync(targetPath, 'utf-8')) as unknown - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - return parsed as Record - } - } catch { - // Why: invalid config is unknown external state; return null so we don't erase user or Claude-owned settings. - return null - } - return null - } - - protected getRuntimeMetadataDir(): string { - const metadataDir = join(app.getPath('userData'), 'claude-runtime-auth') - mkdirSync(metadataDir, { recursive: true }) - return metadataDir - } - - protected getSystemDefaultSnapshotPath(): string { - return join(this.getRuntimeMetadataDir(), 'system-default-auth.json') - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-keychain-snapshots.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-keychain-snapshots.ts deleted file mode 100644 index ef4c54acab83..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-keychain-snapshots.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { - readActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentialsStrict -} from '../keychain' -import { ClaudeRuntimeAuthManagedCredentials } from './runtime-auth-managed-credentials' -import type { - ClaudeKeychainReadResult, - ClaudeKeychainSnapshotValue, - ClaudeSystemDefaultSnapshot -} from './runtime-auth-types' - -export class ClaudeRuntimeAuthKeychainSnapshots extends ClaudeRuntimeAuthManagedCredentials { - protected isSystemDefaultSnapshot(value: unknown): value is ClaudeSystemDefaultSnapshot { - const snapshot = this.asRecord(value) - return ( - snapshot !== null && - Object.hasOwn(snapshot, 'credentialsJson') && - this.isOptionalNullableString(snapshot.credentialsJson) && - this.isOptionalNullableString(snapshot.keychainCredentialsJson) && - this.isOptionalNullableString(snapshot.scopedKeychainCredentialsJson) && - this.isOptionalNullableString(snapshot.legacyKeychainCredentialsJson) && - this.isOptionalBoolean(snapshot.scopedKeychainCredentialsCaptured) && - this.isOptionalBoolean(snapshot.legacyKeychainCredentialsCaptured) && - this.hasValidKeychainSnapshotValue(snapshot, 'scoped') && - this.hasValidKeychainSnapshotValue(snapshot, 'legacy') && - (snapshot.capturedAt === undefined || typeof snapshot.capturedAt === 'number') - ) - } - - protected isOptionalNullableString(value: unknown): boolean { - return value === undefined || value === null || typeof value === 'string' - } - - protected isOptionalBoolean(value: unknown): boolean { - return value === undefined || typeof value === 'boolean' - } - - protected snapshotKeychainCredentials( - credentialsJson: string | null, - previousSnapshot: ClaudeSystemDefaultSnapshot | null | undefined, - service: 'scoped' | 'legacy', - managedCredentialsJson: string | undefined - ): string | null { - if ( - managedCredentialsJson && - this.accountCredentialFieldsEqual(credentialsJson, managedCredentialsJson) && - previousSnapshot - ) { - const previousValue = this.readKeychainSnapshotValue(previousSnapshot, service) - if (previousValue.status === 'captured') { - return previousValue.credentialsJson - } - } - return credentialsJson - } - - protected hasValidKeychainSnapshotValue( - snapshot: Record, - service: 'scoped' | 'legacy' - ): boolean { - const capturedKey = - service === 'scoped' - ? 'scopedKeychainCredentialsCaptured' - : 'legacyKeychainCredentialsCaptured' - if (snapshot[capturedKey] === false) { - return true - } - const credentialsKey = - service === 'scoped' ? 'scopedKeychainCredentialsJson' : 'legacyKeychainCredentialsJson' - return ( - Object.hasOwn(snapshot, credentialsKey) || Object.hasOwn(snapshot, 'keychainCredentialsJson') - ) - } - - protected readKeychainSnapshotValue( - snapshot: ClaudeSystemDefaultSnapshot | null, - service: 'scoped' | 'legacy' - ): ClaudeKeychainSnapshotValue { - if (!snapshot) { - return { status: 'captured', credentialsJson: null } - } - const capturedKey = - service === 'scoped' - ? 'scopedKeychainCredentialsCaptured' - : 'legacyKeychainCredentialsCaptured' - if (snapshot[capturedKey] === false) { - return { status: 'unknown' } - } - const credentialsKey = - service === 'scoped' ? 'scopedKeychainCredentialsJson' : 'legacyKeychainCredentialsJson' - if (Object.hasOwn(snapshot, credentialsKey)) { - return { - status: 'captured', - credentialsJson: snapshot[credentialsKey] ?? null - } - } - return { status: 'captured', credentialsJson: snapshot.keychainCredentialsJson } - } - - protected async readAggregateClaudeKeychainCredentialsBestEffort( - configDir: string - ): Promise { - try { - return await readActiveClaudeKeychainCredentials(configDir) - } catch (error) { - console.warn('[claude-runtime-auth] Failed to read Claude Keychain credentials:', error) - return null - } - } - - protected async readActiveClaudeKeychainCredentialsBestEffort( - configDir?: string - ): Promise { - try { - return await readActiveClaudeKeychainCredentialsStrict(configDir) - } catch (error) { - console.warn('[claude-runtime-auth] Failed to read Claude Keychain credentials:', error) - return null - } - } - - protected async readActiveClaudeKeychainCredentialsForSnapshot( - configDir?: string - ): Promise { - try { - return { - status: 'captured', - credentialsJson: await readActiveClaudeKeychainCredentialsStrict(configDir) - } - } catch (error) { - console.warn('[claude-runtime-auth] Failed to read Claude Keychain credentials:', error) - return { status: 'failed' } - } - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts deleted file mode 100644 index 3a93f63d4dd6..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { existsSync } from 'node:fs' -import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' -import { parseWslUncPath } from '../../../shared/wsl-paths' -import { toWindowsWslPath } from '../../wsl' -import { runWslProcess } from '../../wsl/wsl-runner' -import { - readClaudeManagedAuthFile, - resolveOwnedClaudeManagedAuthPath, - writeClaudeManagedAuthFile -} from '../managed-auth-path' -import { isOauthTokenExpiring, refreshClaudeOauthCredentials } from '../oauth-refresh' -import { - readManagedClaudeKeychainCredentials, - writeManagedClaudeKeychainCredentials -} from '../keychain' -import { ClaudeRuntimeAuthCredentialIdentity } from './runtime-auth-credential-identity' -import { stripSharedClaudeCredentialFields } from '../shared-credential-fields' - -const OWNERSHIP_PROBE_TIMEOUT = 'orca-wsl-ownership-probe-timeout' - -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'` -} - -export class ClaudeRuntimeAuthManagedCredentials extends ClaudeRuntimeAuthCredentialIdentity { - protected async readManagedCredentials(account: ClaudeManagedAccount): Promise { - const managedAuthPath = await this.getOwnedManagedAuthPath(account) - if (!managedAuthPath) { - return null - } - if (process.platform === 'darwin') { - const credentials = await readManagedClaudeKeychainCredentials(account.id) - return credentials === null ? null : stripSharedClaudeCredentialFields(credentials) - } - const credentials = readClaudeManagedAuthFile(managedAuthPath, '.credentials.json') - return credentials === null || account.managedAuthRuntime === 'wsl' - ? credentials - : stripSharedClaudeCredentialFields(credentials) - } - - protected async writeManagedCredentials( - account: ClaudeManagedAccount, - credentialsJson: string - ): Promise { - const managedAuthPath = await this.getOwnedManagedAuthPath(account) - if (!managedAuthPath) { - throw new Error('Managed Claude auth storage is not owned by Orca.') - } - if (account.managedAuthRuntime !== 'wsl') { - credentialsJson = stripSharedClaudeCredentialFields(credentialsJson) - } - if (process.platform === 'darwin') { - await writeManagedClaudeKeychainCredentials(account.id, credentialsJson) - return - } - writeClaudeManagedAuthFile(managedAuthPath, '.credentials.json', credentialsJson) - } - - /** - * Proactively refresh an account's OAuth token and persist the rotation to - * managed storage. Returns the refreshed credentials JSON, or null when no - * refresh happened (token valid, no refresh token, or network failure). - * - * Caller guarantees this account isn't the live/active one and runs inside the - * serialized mutation queue, so the single-use refresh token can't rotate concurrently. - */ - protected async refreshManagedAccountTokenIfNeeded( - account: ClaudeManagedAccount, - credentialsJson: string - ): Promise { - if (!isOauthTokenExpiring(credentialsJson)) { - return null - } - const refreshed = await refreshClaudeOauthCredentials(credentialsJson) - if (!refreshed || !this.isValidCredentialsJsonObject(refreshed)) { - return null - } - try { - await this.writeManagedCredentials(account, refreshed) - } catch (error) { - console.warn('[claude-runtime-auth] Failed to persist refreshed Claude token:', error) - return null - } - return refreshed - } - - protected async readManagedOauthAccount(account: ClaudeManagedAccount): Promise { - const managedAuthPath = await this.getOwnedManagedAuthPath(account) - if (!managedAuthPath) { - return null - } - try { - const contents = readClaudeManagedAuthFile(managedAuthPath, 'oauth-account.json') - return contents ? (JSON.parse(contents) as unknown) : null - } catch { - return null - } - } - - protected async getOwnedManagedAuthPath(account: ClaudeManagedAccount): Promise { - const wslInfo = parseWslUncPath(account.managedAuthPath) - if (wslInfo) { - if ( - !wslInfo.linuxPath.includes('/.local/share/orca/claude-accounts/') || - !wslInfo.linuxPath.endsWith('/auth') - ) { - return null - } - if (process.platform === 'win32') { - try { - const owned = await runWslProcess({ - distro: wslInfo.distro, - loginPath: 'none', - shell: 'bash', - script: [ - 'set -euo pipefail', - `candidate=${shellQuote(wslInfo.linuxPath)}`, - 'managed_root="${HOME%/}/.local/share/orca/claude-accounts"', - 'candidate_real=$(readlink -f -- "$candidate")', - 'managed_root_real=$(readlink -f -- "$managed_root")', - 'test -f "$candidate_real/.orca-managed-claude-auth"', - `test "$(cat "$candidate_real/.orca-managed-claude-auth")" = ${shellQuote(account.id)}`, - 'case "$candidate_real" in "$managed_root_real"/*/auth) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac' - ].join('\n'), - timeoutMs: 5000 - }) - if (owned.timedOut) { - throw new Error(OWNERSHIP_PROBE_TIMEOUT) - } - if (owned.code !== 0) { - return null - } - const canonicalLinuxPath = owned.stdout.trim() - return canonicalLinuxPath ? toWindowsWslPath(canonicalLinuxPath, wslInfo.distro) : null - } catch (error) { - // Why rethrow a timeout: null means "not owned by Orca", and the - // caller persists that -- clearing the user's account selection. A - // slow distro must not decide ownership. Swallowing it here is what - // made the previous guard dead code. - if (error instanceof Error && error.message === OWNERSHIP_PROBE_TIMEOUT) { - throw error - } - return null - } - } - return existsSync(account.managedAuthPath) ? account.managedAuthPath : null - } - return resolveOwnedClaudeManagedAuthPath(account.id, account.managedAuthPath, { - adoptLegacyMarker: true - }) - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts deleted file mode 100644 index dabcd9d472f3..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { join } from 'node:path' -import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' -import { resolveLocalAccountRuntimeTarget } from '../../../shared/local-account-runtime' -import { parseWslUncPath } from '../../../shared/wsl-paths' -import { shouldStripClaudeAuthEnvForAccount } from '../environment' -import { getDefaultWslDistro, getWslHome } from '../../wsl' -import { - getSelectedClaudeAccountIdForTarget, - normalizeClaudeAccountSelectionTarget, - type ClaudeAccountSelectionTarget -} from '../runtime-selection' -import { ClaudeRuntimeAuthSnapshotRestore } from './runtime-auth-snapshot-restore' -import type { ClaudeRuntimeAuthPreparation } from './runtime-auth-types' - -export class ClaudeRuntimeAuthPreparationService extends ClaudeRuntimeAuthSnapshotRestore { - protected getPreparation(target?: ClaudeAccountSelectionTarget): ClaudeRuntimeAuthPreparation { - const settings = this.store.getSettings() - const paths = this.pathResolver.getRuntimePaths() - const normalizedTarget = this.resolveWslDefaultTarget( - target ?? this.getDefaultAccountSelectionTarget(settings) - ) - const activeAccountId = getSelectedClaudeAccountIdForTarget(settings, normalizedTarget) - const activeAccount = this.getActiveAccount(settings.claudeManagedAccounts, activeAccountId) - if ( - normalizeClaudeAccountSelectionTarget(normalizedTarget).runtime === 'wsl' && - activeAccount?.managedAuthRuntime === 'wsl' && - activeAccount.wslLinuxAuthPath - ) { - return { - configDir: activeAccount.managedAuthPath, - runtime: 'wsl', - wslDistro: activeAccount.wslDistro ?? null, - wslLinuxConfigDir: activeAccount.wslLinuxAuthPath, - envPatch: { CLAUDE_CONFIG_DIR: activeAccount.wslLinuxAuthPath }, - stripAuthEnv: true, - provenance: `managed:${activeAccount.id}:wsl:${activeAccount.wslDistro ?? ''}` - } - } - if (normalizeClaudeAccountSelectionTarget(normalizedTarget).runtime === 'wsl') { - const distro = - normalizeClaudeAccountSelectionTarget(normalizedTarget).wslDistro ?? getDefaultWslDistro() - const wslHome = distro ? getWslHome(distro) : null - const wslHomeInfo = wslHome ? parseWslUncPath(wslHome) : null - if (distro && wslHome && wslHomeInfo) { - const windowsConfigDir = join(wslHome, '.claude') - const linuxConfigDir = `${wslHomeInfo.linuxPath.replace(/\/$/, '')}/.claude` - return { - configDir: windowsConfigDir, - runtime: 'wsl', - wslDistro: distro, - wslLinuxConfigDir: linuxConfigDir, - envPatch: {}, - stripAuthEnv: true, - provenance: `wsl:${distro}:system` - } - } - return { - configDir: paths.configDir, - runtime: 'wsl', - wslDistro: normalizeClaudeAccountSelectionTarget(normalizedTarget).wslDistro, - wslLinuxConfigDir: null, - envPatch: {}, - stripAuthEnv: true, - provenance: `wsl:${normalizeClaudeAccountSelectionTarget(normalizedTarget).wslDistro ?? '__default__'}:system` - } - } - return { - configDir: paths.configDir, - runtime: 'host', - wslDistro: null, - wslLinuxConfigDir: null, - envPatch: paths.envPatch, - stripAuthEnv: shouldStripClaudeAuthEnvForAccount( - settings.claudeManagedAccounts, - activeAccountId - ), - managedRefreshDeferredByLivePty: Boolean( - activeAccountId && - activeAccount?.managedAuthRuntime !== 'wsl' && - this.managedRefreshDeferredByLivePtyAccountId === activeAccountId - ), - provenance: - activeAccountId && activeAccount?.managedAuthRuntime !== 'wsl' - ? `managed:${activeAccountId}` - : 'system' - } - } - - protected getActiveAccount( - accounts: ClaudeManagedAccount[], - activeAccountId: string | null - ): ClaudeManagedAccount | null { - if (!activeAccountId) { - return null - } - return accounts.find((account) => account.id === activeAccountId) ?? null - } - - protected getDefaultAccountSelectionTarget( - settings = this.store.getSettings() - ): ClaudeAccountSelectionTarget { - // Why: Windows auth follows the resolved account runtime; stale cross-platform WSL pins must stay local-host. - const resolved = resolveLocalAccountRuntimeTarget(settings) - if (process.platform === 'win32' && resolved.runtime === 'wsl') { - return { runtime: 'wsl', wslDistro: resolved.wslDistro } - } - return { runtime: 'host' } - } - - protected resolveWslDefaultTarget( - target?: ClaudeAccountSelectionTarget - ): ClaudeAccountSelectionTarget { - if (target?.runtime !== 'wsl' || target.wslDistro?.trim()) { - return target ?? { runtime: 'host' } - } - const defaultDistro = getDefaultWslDistro() - return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts deleted file mode 100644 index 848ee093dfbc..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs' -import { writeActiveClaudeKeychainCredentialsForRuntime } from '../keychain' -import { ClaudeRuntimeAuthCredentialMatching } from './runtime-auth-credential-matching' -import type { - ClaudeReadBackMatch, - ClaudeReadBackResult, - ClaudeRuntimeCredentialCandidate -} from './runtime-auth-types' - -export class ClaudeRuntimeAuthReadback extends ClaudeRuntimeAuthCredentialMatching { - protected async readBackRefreshedTokens( - baselineCredentialsJson: string, - options: { updateLastWrittenCredentialsJson: boolean } - ): Promise { - try { - const candidates = - await this.readRuntimeCredentialCandidatesForReadBack(baselineCredentialsJson) - if (candidates.length === 0) { - return { status: 'unchanged' } - } - const changedCandidates = - this.lastWrittenCredentialsJson === null - ? candidates - : candidates.filter( - (candidate) => - !this.accountCredentialFieldsEqual( - candidate.credentialsJson, - this.lastWrittenCredentialsJson - ) - ) - if (changedCandidates.length === 0) { - return { status: 'unchanged' } - } - - const acceptedCandidates: { - credentialsJson: string - match: Extract - }[] = [] - const ambiguousCandidates: string[] = [] - let sawAmbiguousCandidate = false - let sawValidChangedCandidate = false - for (const runtimeContents of changedCandidates) { - if (!this.isValidCredentialsJsonObject(runtimeContents.credentialsJson)) { - continue - } - sawValidChangedCandidate = true - const match = await this.findManagedAccountForRuntimeCredentials( - runtimeContents.credentialsJson, - runtimeContents.runtimeOauthAccount - ) - if (match.kind === 'ambiguous') { - sawAmbiguousCandidate = true - ambiguousCandidates.push(runtimeContents.credentialsJson) - continue - } - if (match.kind !== 'matched') { - continue - } - // Why: on cold start we can't tell a fresh CLI refresh from stale runtime creds; adopt only when expiry or a rotated refresh token proves runtime is newer than managed. - if (this.lastWrittenCredentialsJson === null) { - const fresher = this.runtimeCredentialsAreFresher( - runtimeContents.credentialsJson, - match.managedCredentialsJson - ) - const refreshTokenRotated = - this.compareRefreshTokens( - runtimeContents.credentialsJson, - match.managedCredentialsJson - ) === 'different' - const older = this.runtimeCredentialsAreOlder( - runtimeContents.credentialsJson, - match.managedCredentialsJson - ) - if (!fresher && !(refreshTokenRotated && !older)) { - continue - } - } else if ( - this.runtimeCredentialsAreOlder( - runtimeContents.credentialsJson, - match.managedCredentialsJson - ) - ) { - continue - } - acceptedCandidates.push({ credentialsJson: runtimeContents.credentialsJson, match }) - } - if (acceptedCandidates.length === 0) { - if (sawAmbiguousCandidate) { - console.warn('[claude-runtime-auth] Refusing ambiguous Claude auth read-back') - } - return { - status: 'rejected', - runtimeCredentialsChanged: true, - hasValidChangedRuntimeCredentials: sawValidChangedCandidate, - runtimeCredentialsJson: - ambiguousCandidates.length === 1 ? ambiguousCandidates[0] : undefined - } - } - const { credentialsJson: runtimeContents, match } = - this.chooseFreshestReadBackCandidate(acceptedCandidates) - - await this.writeManagedCredentials(match.account, runtimeContents) - if (options.updateLastWrittenCredentialsJson) { - const merged = await this.mergeLiveRuntimeSharedCredentials(runtimeContents) - this.writeRuntimeCredentials(merged) - if (process.platform === 'darwin') { - const paths = this.pathResolver.getRuntimePaths() - await writeActiveClaudeKeychainCredentialsForRuntime(merged, paths.configDir) - } - this.lastWrittenSharedCredentialsJson = merged - } - return { status: 'persisted' } - } catch (error) { - // Why: read-back is best-effort; a transient fs error must not block forward sync (worst case: one more stale-token cycle). - console.warn('[claude-runtime-auth] Failed to read back refreshed tokens:', error) - return { - status: 'rejected', - runtimeCredentialsChanged: - this.runtimeCredentialsChangedSinceLastWrite(baselineCredentialsJson), - // Why: an fs error hides whether a live session's refresh is present, so err toward preserving runtime state. - hasValidChangedRuntimeCredentials: true - } - } - } - - protected async readRuntimeCredentialCandidatesForReadBack( - baselineCredentialsJson: string - ): Promise { - const paths = this.pathResolver.getRuntimePaths() - const fileCredentials = existsSync(paths.credentialsPath) - ? readFileSync(paths.credentialsPath, 'utf-8') - : null - const runtimeOauthAccount = this.readRuntimeOauthAccount() - const candidates: ClaudeRuntimeCredentialCandidate[] = [] - const pushCandidate = (credentialsJson: string | null): void => { - if ( - credentialsJson && - !candidates.some((candidate) => candidate.credentialsJson === credentialsJson) - ) { - candidates.push({ credentialsJson, runtimeOauthAccount }) - } - } - if (process.platform === 'darwin') { - const scopedKeychainCredentials = await this.readActiveClaudeKeychainCredentialsBestEffort( - paths.configDir - ) - const legacyKeychainCredentials = await this.readActiveClaudeKeychainCredentialsBestEffort() - if (this.lastWrittenCredentialsJson === null) { - pushCandidate(scopedKeychainCredentials) - pushCandidate(legacyKeychainCredentials) - pushCandidate(fileCredentials) - return candidates.filter( - (candidate) => - !this.accountCredentialFieldsEqual(candidate.credentialsJson, baselineCredentialsJson) - ) - } - pushCandidate(scopedKeychainCredentials) - pushCandidate(legacyKeychainCredentials) - } - pushCandidate(fileCredentials) - return candidates - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-runtime-state.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-runtime-state.ts deleted file mode 100644 index 40d4d45395a2..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-runtime-state.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { existsSync, readFileSync, rmSync } from 'node:fs' -import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' -import { - deleteActiveClaudeKeychainCredentialsStrict, - readActiveClaudeKeychainCredentialsStrict, - writeActiveClaudeKeychainCredentials -} from '../keychain' -import { - mergeSharedClaudeCredentialFields, - reconcileSharedClaudeCredentialFields -} from '../shared-credential-fields' -import { ClaudeRuntimeAuthKeychainSnapshots } from './runtime-auth-keychain-snapshots' -import { - RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR, - type ClaudeKeychainSnapshotValue -} from './runtime-auth-types' - -export class ClaudeRuntimeAuthRuntimeState extends ClaudeRuntimeAuthKeychainSnapshots { - protected async mergeLiveRuntimeSharedCredentials(credentialsJson: string): Promise { - const paths = this.pathResolver.getRuntimePaths() - const candidates: string[] = [] - if (process.platform === 'darwin') { - // A failed read must stop the switch before any shared tokens are overwritten. - const scoped = await readActiveClaudeKeychainCredentialsStrict(paths.configDir) - const legacy = await readActiveClaudeKeychainCredentialsStrict() - if (scoped !== null) { - candidates.push(scoped) - } - if (legacy !== null) { - candidates.push(legacy) - } - } - const file = this.readRuntimeCredentialsFile() - if (file !== null) { - candidates.push(file) - } - const shared = reconcileSharedClaudeCredentialFields( - candidates, - this.lastWrittenSharedCredentialsJson - ) - return mergeSharedClaudeCredentialFields(credentialsJson, shared) - } - - protected readRuntimeCredentialsFile(): string | null { - const credentialsPath = this.pathResolver.getRuntimePaths().credentialsPath - return existsSync(credentialsPath) ? readFileSync(credentialsPath, 'utf-8') : null - } - - protected runtimeCredentialsBelongToAccount( - credentialsJson: string | null, - account: ClaudeManagedAccount, - managedOauthAccount: unknown - ): boolean { - if (!credentialsJson) { - return false - } - const identity = this.readIdentityFromCredentials(credentialsJson) - if ( - !identity?.email || - (account.email && this.normalizeField(account.email) !== identity.email) - ) { - return false - } - const oauthIdentity = this.readIdentityFromOauthAccount(managedOauthAccount) - const selectedOrganizationUuid = this.normalizeField( - account.organizationUuid ?? oauthIdentity.organizationUuid - ) - if (selectedOrganizationUuid) { - return identity.organizationUuid === selectedOrganizationUuid - } - return !identity.organizationUuid - } - - protected clearLastWrittenRuntimeState(): void { - this.lastWrittenCredentialsJson = null - this.lastWrittenOauthAccount = null - this.hasLastWrittenOauthAccount = false - this.hasMaterializedRuntimeAuth = false - } - - protected hasUnchangedRuntimeCredentials( - previouslyWrittenCredentialsJson: string | null - ): boolean { - if (previouslyWrittenCredentialsJson === null) { - return false - } - const paths = this.pathResolver.getRuntimePaths() - const currentCredentialsJson = existsSync(paths.credentialsPath) - ? readFileSync(paths.credentialsPath, 'utf-8') - : null - return this.accountCredentialFieldsEqual( - currentCredentialsJson, - previouslyWrittenCredentialsJson - ) - } - - protected runtimeCredentialsChangedSinceLastWrite(baselineCredentialsJson: string): boolean { - const paths = this.pathResolver.getRuntimePaths() - try { - const currentCredentialsJson = existsSync(paths.credentialsPath) - ? readFileSync(paths.credentialsPath, 'utf-8') - : null - return ( - currentCredentialsJson !== null && - currentCredentialsJson !== (this.lastWrittenCredentialsJson ?? baselineCredentialsJson) - ) - } catch { - return false - } - } - - protected restoreRuntimeCredentials( - credentialsJson: string | null, - sharedCredentialsJson?: string - ): void { - const paths = this.pathResolver.getRuntimePaths() - const restored = mergeSharedClaudeCredentialFields( - credentialsJson ?? '{}', - sharedCredentialsJson ?? this.readRuntimeCredentialsFile() - ) - if (credentialsJson !== null || restored !== '{}') { - this.writeRuntimeCredentials(restored) - } else { - rmSync(paths.credentialsPath, { force: true }) - } - } - - protected restoreRuntimeOauthAccountIfOwned( - oauthAccount: unknown, - ownedOauthAccount: unknown, - options: { allowCredentialSurfaceOwnership: boolean } - ): void { - const currentOauthAccount = this.readRuntimeOauthAccount() - if (currentOauthAccount === RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR) { - return - } - if (options.allowCredentialSurfaceOwnership) { - this.writeRuntimeOauthAccount(oauthAccount) - return - } - if ( - (ownedOauthAccount === null || ownedOauthAccount === undefined) && - !options.allowCredentialSurfaceOwnership - ) { - return - } - if (!this.jsonValuesEqual(currentOauthAccount, ownedOauthAccount)) { - return - } - this.writeRuntimeOauthAccount(oauthAccount) - } - - protected async hasUnchangedActiveClaudeKeychainCredentials( - snapshotValue: ClaudeKeychainSnapshotValue, - previouslyWrittenCredentialsJson: string | null, - configDir?: string - ): Promise { - if (snapshotValue.status === 'unknown') { - return false - } - const currentCredentialsJson = - await this.readActiveClaudeKeychainCredentialsBestEffort(configDir) - return ( - previouslyWrittenCredentialsJson !== null && - this.accountCredentialFieldsEqual(currentCredentialsJson, previouslyWrittenCredentialsJson) - ) - } - - protected async restoreActiveClaudeKeychainCredentials( - credentialsJson: string | null, - configDir?: string, - sharedCredentialsJson?: string - ): Promise { - const live = - sharedCredentialsJson ?? (await readActiveClaudeKeychainCredentialsStrict(configDir)) - const restored = mergeSharedClaudeCredentialFields(credentialsJson ?? '{}', live) - await (credentialsJson !== null || restored !== '{}' - ? writeActiveClaudeKeychainCredentials(restored, configDir) - : deleteActiveClaudeKeychainCredentialsStrict(configDir)) - } - - protected async hasActiveKeychainCredentialsForAccount( - account: ClaudeManagedAccount, - managedOauthAccount: unknown, - configDir?: string - ): Promise { - const currentCredentialsJson = - await this.readActiveClaudeKeychainCredentialsBestEffort(configDir) - return this.runtimeCredentialsBelongToAccount( - currentCredentialsJson, - account, - managedOauthAccount - ) - } - - protected readRuntimeOauthAccount(): unknown { - const configPath = this.pathResolver.getRuntimePaths().configPath - if (!existsSync(configPath)) { - return null - } - try { - const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as unknown - const record = this.asRecord(parsed) - if (!record) { - return RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR - } - return record.oauthAccount ?? null - } catch { - return RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR - } - } - - protected runtimeOauthAccountMatches(managedOauthAccount: unknown): boolean { - if (managedOauthAccount === null || managedOauthAccount === undefined) { - return false - } - const currentOauthAccount = this.readRuntimeOauthAccount() - if (currentOauthAccount === RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR) { - return false - } - return this.jsonValuesEqual(currentOauthAccount, managedOauthAccount) - } - - protected writeRuntimeOauthAccount(oauthAccount: unknown): boolean { - const configPath = this.pathResolver.getRuntimePaths().configPath - const existing = this.readJsonObject(configPath) - if (existing === null) { - return false - } - if (oauthAccount === null || oauthAccount === undefined) { - delete existing.oauthAccount - } else { - existing.oauthAccount = oauthAccount - } - this.writeJson(configPath, existing) - return true - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-capture.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-capture.ts deleted file mode 100644 index e184ec4e7517..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-capture.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { existsSync, readFileSync, rmSync } from 'node:fs' -import { ClaudeRuntimeAuthReadback } from './runtime-auth-readback' -import { - RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR, - type ClaudeSystemDefaultSnapshot -} from './runtime-auth-types' - -export class ClaudeRuntimeAuthSnapshotCapture extends ClaudeRuntimeAuthReadback { - protected async captureSystemDefaultSnapshotForManagedEntry( - runtimeCredentialsJson: string | null, - managedCredentialsJson: string - ): Promise { - const snapshotPath = this.getSystemDefaultSnapshotPath() - const existingSnapshot = this.readSystemDefaultSnapshot(snapshotPath) - if (!this.accountCredentialFieldsEqual(runtimeCredentialsJson, managedCredentialsJson)) { - await this.captureSystemDefaultSnapshot({ - force: true, - previousSnapshot: existingSnapshot, - managedCredentialsJson - }) - return - } - if (existingSnapshot) { - await this.captureSystemDefaultSnapshot({ - force: true, - credentialsJsonOverride: existingSnapshot.credentialsJson, - previousSnapshot: existingSnapshot, - managedCredentialsJson - }) - return - } - await this.captureSystemDefaultSnapshot({ force: false }) - } - - protected async captureSystemDefaultSnapshot(options: { - force: boolean - credentialsJsonOverride?: string | null - previousSnapshot?: ClaudeSystemDefaultSnapshot | null - managedCredentialsJson?: string - }): Promise { - const snapshotPath = this.getSystemDefaultSnapshotPath() - if (!options.force && existsSync(snapshotPath)) { - return - } - - const paths = this.pathResolver.getRuntimePaths() - const credentialsJson = - options.credentialsJsonOverride !== undefined - ? options.credentialsJsonOverride - : existsSync(paths.credentialsPath) - ? readFileSync(paths.credentialsPath, 'utf-8') - : null - const keychainCredentialsJson = await this.readAggregateClaudeKeychainCredentialsBestEffort( - paths.configDir - ) - const scopedKeychainCredentials = - process.platform === 'darwin' - ? await this.readActiveClaudeKeychainCredentialsForSnapshot(paths.configDir) - : ({ status: 'captured', credentialsJson: null } as const) - const legacyKeychainCredentialsJson = - process.platform === 'darwin' - ? await this.readActiveClaudeKeychainCredentialsForSnapshot() - : ({ status: 'captured', credentialsJson: null } as const) - if ( - scopedKeychainCredentials.status === 'failed' || - legacyKeychainCredentialsJson.status === 'failed' - ) { - throw new Error('Cannot capture current Claude Keychain credentials') - } - const scopedKeychainCredentialsJson = - scopedKeychainCredentials.status === 'captured' - ? this.snapshotKeychainCredentials( - scopedKeychainCredentials.credentialsJson, - options.previousSnapshot, - 'scoped', - options.managedCredentialsJson - ) - : undefined - const legacyKeychainSnapshotJson = - legacyKeychainCredentialsJson.status === 'captured' - ? this.snapshotKeychainCredentials( - legacyKeychainCredentialsJson.credentialsJson, - options.previousSnapshot, - 'legacy', - options.managedCredentialsJson - ) - : undefined - const configOauthAccount = this.readRuntimeOauthAccount() - const snapshot: ClaudeSystemDefaultSnapshot = { - credentialsJson, - configOauthAccount: - configOauthAccount === RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR ? null : configOauthAccount, - keychainCredentialsJson, - scopedKeychainCredentialsJson, - legacyKeychainCredentialsJson: legacyKeychainSnapshotJson, - scopedKeychainCredentialsCaptured: scopedKeychainCredentials.status === 'captured', - legacyKeychainCredentialsCaptured: legacyKeychainCredentialsJson.status === 'captured', - capturedAt: Date.now() - } - this.writeJson(snapshotPath, snapshot) - } - - protected readSystemDefaultSnapshot(snapshotPath: string): ClaudeSystemDefaultSnapshot | null { - if (!existsSync(snapshotPath)) { - return null - } - try { - const parsed = JSON.parse(readFileSync(snapshotPath, 'utf-8')) as unknown - if (this.isSystemDefaultSnapshot(parsed)) { - return parsed - } - throw new Error('Invalid Claude system-default auth snapshot shape') - } catch (error) { - console.warn('[claude-runtime-auth] Ignoring invalid system-default auth snapshot:', error) - rmSync(snapshotPath, { force: true }) - return null - } - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-restore.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-restore.ts deleted file mode 100644 index bb2158d9464b..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-restore.ts +++ /dev/null @@ -1,239 +0,0 @@ -import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' -import { ClaudeRuntimeAuthSnapshotCapture } from './runtime-auth-snapshot-capture' -import type { ClaudeKeychainSnapshotValue } from './runtime-auth-types' - -export class ClaudeRuntimeAuthSnapshotRestore extends ClaudeRuntimeAuthSnapshotCapture { - protected async restoreSystemDefaultSnapshot( - ownedCredentialsJson?: string | null, - ownedOauthAccount?: unknown - ): Promise { - const snapshotPath = this.getSystemDefaultSnapshotPath() - const paths = this.pathResolver.getRuntimePaths() - const previouslyWrittenCredentialsJson = - this.lastWrittenCredentialsJson ?? ownedCredentialsJson ?? null - const snapshot = this.readSystemDefaultSnapshot(snapshotPath) - - const fileCredentialsOwned = this.hasUnchangedRuntimeCredentials( - previouslyWrittenCredentialsJson - ) - let hasCredentialSurfaceOwnership = fileCredentialsOwned - // Why: prove ownership before mutating anything, and restore OAuth first so a failure leaves the credential proof intact for retry. - this.lastWrittenCredentialsJson = previouslyWrittenCredentialsJson - let scopedSnapshot: ClaudeKeychainSnapshotValue | null = null - let legacySnapshot: ClaudeKeychainSnapshotValue | null = null - let scopedKeychainOwned = false - let legacyKeychainOwned = false - if (process.platform === 'darwin') { - scopedSnapshot = this.readKeychainSnapshotValue(snapshot, 'scoped') - legacySnapshot = this.readKeychainSnapshotValue(snapshot, 'legacy') - scopedKeychainOwned = await this.hasUnchangedActiveClaudeKeychainCredentials( - scopedSnapshot, - previouslyWrittenCredentialsJson, - paths.configDir - ) - legacyKeychainOwned = await this.hasUnchangedActiveClaudeKeychainCredentials( - legacySnapshot, - previouslyWrittenCredentialsJson - ) - hasCredentialSurfaceOwnership = - fileCredentialsOwned || scopedKeychainOwned || legacyKeychainOwned - } - const sharedCredentialsJson = hasCredentialSurfaceOwnership - ? await this.mergeLiveRuntimeSharedCredentials('{}') - : undefined - this.restoreRuntimeOauthAccountIfOwned( - snapshot?.configOauthAccount ?? null, - this.getOwnedRuntimeOauthBaseline(ownedOauthAccount, hasCredentialSurfaceOwnership), - { allowCredentialSurfaceOwnership: hasCredentialSurfaceOwnership } - ) - if (fileCredentialsOwned) { - this.restoreRuntimeCredentials(snapshot?.credentialsJson ?? null, sharedCredentialsJson) - } - if (process.platform === 'darwin') { - if (scopedSnapshot?.status === 'captured' && scopedKeychainOwned) { - await this.restoreActiveClaudeKeychainCredentials( - scopedSnapshot.credentialsJson, - paths.configDir, - sharedCredentialsJson - ) - } - if (legacySnapshot?.status === 'captured' && legacyKeychainOwned) { - await this.restoreActiveClaudeKeychainCredentials( - legacySnapshot.credentialsJson, - undefined, - sharedCredentialsJson - ) - } - } - this.recordRestoredSharedCredentials( - sharedCredentialsJson, - fileCredentialsOwned, - scopedSnapshot?.status === 'captured' && scopedKeychainOwned, - legacySnapshot?.status === 'captured' && legacyKeychainOwned - ) - this.lastWrittenCredentialsJson = null - this.lastWrittenOauthAccount = null - this.hasLastWrittenOauthAccount = false - this.hasMaterializedRuntimeAuth = false - } - - protected getOwnedRuntimeOauthBaseline( - ownedOauthAccount: unknown, - hasCredentialSurfaceOwnership: boolean - ): unknown { - if (this.hasLastWrittenOauthAccount) { - return this.lastWrittenOauthAccount - } - // Why: managed metadata hints identity but isn't proof Orca wrote .claude.json; use only after a credential surface proves ownership. - if (hasCredentialSurfaceOwnership && ownedOauthAccount !== undefined) { - return ownedOauthAccount - } - return null - } - - private recordRestoredSharedCredentials( - credentialsJson: string | undefined, - fileRestored: boolean, - scopedRestored: boolean, - legacyRestored: boolean - ): void { - if ( - credentialsJson !== undefined && - fileRestored && - (process.platform !== 'darwin' || (scopedRestored && legacyRestored)) - ) { - this.lastWrittenSharedCredentialsJson = credentialsJson - } - } - - protected async clearRuntimeAuthForAccount( - account: ClaudeManagedAccount, - managedOauthAccount: unknown - ): Promise { - const paths = this.pathResolver.getRuntimePaths() - const fileCredentialsOwned = this.runtimeCredentialsBelongToAccount( - this.readRuntimeCredentialsFile(), - account, - managedOauthAccount - ) - let scopedKeychainOwned = false - let legacyKeychainOwned = false - if (process.platform === 'darwin') { - scopedKeychainOwned = await this.hasActiveKeychainCredentialsForAccount( - account, - managedOauthAccount, - paths.configDir - ) - legacyKeychainOwned = await this.hasActiveKeychainCredentialsForAccount( - account, - managedOauthAccount - ) - } - const hasCredentialSurfaceOwnership = - fileCredentialsOwned || scopedKeychainOwned || legacyKeychainOwned - const sharedCredentialsJson = hasCredentialSurfaceOwnership - ? await this.mergeLiveRuntimeSharedCredentials('{}') - : undefined - this.restoreRuntimeOauthAccountIfOwned( - null, - this.getOwnedRuntimeOauthBaseline(managedOauthAccount, hasCredentialSurfaceOwnership), - { - allowCredentialSurfaceOwnership: hasCredentialSurfaceOwnership - } - ) - if (fileCredentialsOwned) { - this.restoreRuntimeCredentials(null, sharedCredentialsJson) - } - if (process.platform === 'darwin') { - if (scopedKeychainOwned) { - await this.restoreActiveClaudeKeychainCredentials( - null, - paths.configDir, - sharedCredentialsJson - ) - } - if (legacyKeychainOwned) { - await this.restoreActiveClaudeKeychainCredentials(null, undefined, sharedCredentialsJson) - } - } - this.recordRestoredSharedCredentials( - sharedCredentialsJson, - fileCredentialsOwned, - scopedKeychainOwned, - legacyKeychainOwned - ) - } - - protected async restoreSystemDefaultSnapshotForMissingManagedCredentials( - account: ClaudeManagedAccount, - managedOauthAccount: unknown - ): Promise { - const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) - if (!snapshot) { - await this.clearRuntimeAuthForAccount(account, managedOauthAccount) - this.clearLastWrittenRuntimeState() - return - } - const paths = this.pathResolver.getRuntimePaths() - const fileCredentialsOwned = this.runtimeCredentialsBelongToAccount( - this.readRuntimeCredentialsFile(), - account, - managedOauthAccount - ) - let scopedSnapshot: ClaudeKeychainSnapshotValue | null = null - let legacySnapshot: ClaudeKeychainSnapshotValue | null = null - let scopedKeychainOwned = false - let legacyKeychainOwned = false - if (process.platform === 'darwin') { - scopedSnapshot = this.readKeychainSnapshotValue(snapshot, 'scoped') - legacySnapshot = this.readKeychainSnapshotValue(snapshot, 'legacy') - scopedKeychainOwned = await this.hasActiveKeychainCredentialsForAccount( - account, - managedOauthAccount, - paths.configDir - ) - legacyKeychainOwned = await this.hasActiveKeychainCredentialsForAccount( - account, - managedOauthAccount - ) - } - const hasCredentialSurfaceOwnership = - fileCredentialsOwned || scopedKeychainOwned || legacyKeychainOwned - const sharedCredentialsJson = hasCredentialSurfaceOwnership - ? await this.mergeLiveRuntimeSharedCredentials('{}') - : undefined - this.restoreRuntimeOauthAccountIfOwned( - snapshot.configOauthAccount, - this.getOwnedRuntimeOauthBaseline(managedOauthAccount, hasCredentialSurfaceOwnership), - { - allowCredentialSurfaceOwnership: hasCredentialSurfaceOwnership - } - ) - if (fileCredentialsOwned) { - this.restoreRuntimeCredentials(snapshot.credentialsJson, sharedCredentialsJson) - } - if (process.platform === 'darwin') { - if (scopedSnapshot?.status === 'captured' && scopedKeychainOwned) { - await this.restoreActiveClaudeKeychainCredentials( - scopedSnapshot.credentialsJson, - paths.configDir, - sharedCredentialsJson - ) - } - if (legacySnapshot?.status === 'captured' && legacyKeychainOwned) { - await this.restoreActiveClaudeKeychainCredentials( - legacySnapshot.credentialsJson, - undefined, - sharedCredentialsJson - ) - } - } - this.recordRestoredSharedCredentials( - sharedCredentialsJson, - fileCredentialsOwned, - scopedSnapshot?.status === 'captured' && scopedKeychainOwned, - legacySnapshot?.status === 'captured' && legacyKeychainOwned - ) - this.clearLastWrittenRuntimeState() - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-state.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-state.ts deleted file mode 100644 index 6d6e5e47a489..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-state.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Store } from '../../persistence' -import { ClaudeRuntimePathResolver } from '../runtime-paths' - -export class ClaudeRuntimeAuthState { - protected readonly pathResolver = new ClaudeRuntimePathResolver() - protected mutationQueue: Promise = Promise.resolve() - protected lastSyncedAccountId: string | null = null - // Why: creds Orca last wrote to the shared file; a mismatch on managed→default transition means an external login overwrote it, so adopt it as the new default. - protected lastWrittenCredentialsJson: string | null = null - // Connector revocations require a baseline that reached every runtime store, not a partial file write. - protected lastWrittenSharedCredentialsJson: string | null = null - protected hasMaterializedRuntimeAuth = false - protected hasLastWrittenOauthAccount = false - protected lastWrittenOauthAccount: unknown = null - protected skipNextReadBackForAccountId: string | null = null - protected managedRefreshDeferredByLivePtyAccountId: string | null = null - - protected constructor(protected readonly store: Store) {} -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts deleted file mode 100644 index 23e9112d7ed7..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs' -import { - getSelectedClaudeAccountIdForTarget, - normalizeClaudeAccountSelectionTarget, - normalizeClaudeRuntimeSelection, - setSelectedClaudeAccountIdForTarget, - type ClaudeAccountSelectionTarget -} from '../runtime-selection' -import { hasLiveClaudePtys } from '../live-pty-gate' -import { isOauthTokenExpiring } from '../oauth-refresh' -import { writeActiveClaudeKeychainCredentialsForRuntime } from '../keychain' -import { ClaudeRuntimeAuthPreparationService } from './runtime-auth-preparation' - -export class ClaudeRuntimeAuthSync extends ClaudeRuntimeAuthPreparationService { - protected async doSyncForCurrentSelection(target?: ClaudeAccountSelectionTarget): Promise { - const settings = this.store.getSettings() - const effectiveTarget = this.resolveWslDefaultTarget(target) - const normalizedTarget = normalizeClaudeAccountSelectionTarget(effectiveTarget) - const activeAccountId = getSelectedClaudeAccountIdForTarget(settings, normalizedTarget) - const activeAccount = this.getActiveAccount(settings.claudeManagedAccounts, activeAccountId) - const previousAccount = this.getActiveAccount( - settings.claudeManagedAccounts, - this.lastSyncedAccountId - ) - this.managedRefreshDeferredByLivePtyAccountId = null - const previousManagedCredentialsJson = previousAccount - ? await this.readManagedCredentials(previousAccount) - : null - const previousManagedOauthAccount = previousAccount - ? await this.readManagedOauthAccount(previousAccount) - : null - if (previousAccount && previousAccount.id !== activeAccount?.id) { - if (previousManagedCredentialsJson) { - const outgoingReadBackResult = await this.readBackRefreshedTokens( - previousManagedCredentialsJson, - { - updateLastWrittenCredentialsJson: true - } - ) - if ( - outgoingReadBackResult.status === 'rejected' && - outgoingReadBackResult.runtimeCredentialsChanged && - hasLiveClaudePtys() - ) { - if ( - outgoingReadBackResult.runtimeCredentialsJson && - this.liveRuntimeCredentialsCanUpdateActiveAccount( - outgoingReadBackResult.runtimeCredentialsJson, - previousAccount, - previousManagedCredentialsJson, - previousManagedOauthAccount - ) - ) { - // Why: switching away while Claude is live must preserve verified token refreshes before replacing shared runtime credentials. - await this.writeManagedCredentials( - previousAccount, - outgoingReadBackResult.runtimeCredentialsJson - ) - } else { - // Why: the runtime blob may lack identity proof for a live-session refresh; skip persisting it, but still let new terminals move to the account. - console.warn( - '[claude-runtime-auth] Skipping unverified live Claude auth read-back while switching accounts' - ) - } - } - } - } - if (!activeAccount) { - if (activeAccountId) { - const nextSelection = setSelectedClaudeAccountIdForTarget( - normalizeClaudeRuntimeSelection(settings), - null, - normalizedTarget - ) - this.store.updateSettings({ - activeClaudeManagedAccountId: - normalizedTarget.runtime === 'host' ? null : settings.activeClaudeManagedAccountId, - activeClaudeManagedAccountIdsByRuntime: nextSelection - }) - } - if (normalizedTarget.runtime === 'wsl') { - return - } - if (this.lastSyncedAccountId !== null) { - await (previousAccount - ? this.restoreSystemDefaultSnapshot( - previousManagedCredentialsJson, - previousManagedOauthAccount - ) - : this.restoreSystemDefaultSnapshot(this.lastWrittenCredentialsJson, undefined)) - this.lastSyncedAccountId = null - } - return - } - - if (activeAccount.managedAuthRuntime === 'wsl') { - if (!(await this.getOwnedManagedAuthPath(activeAccount))) { - console.warn( - '[claude-runtime-auth] Active WSL managed account is not owned by Orca, restoring system default' - ) - const nextSelection = setSelectedClaudeAccountIdForTarget( - normalizeClaudeRuntimeSelection(settings), - null, - normalizedTarget - ) - this.store.updateSettings({ - activeClaudeManagedAccountId: - normalizedTarget.runtime === 'host' ? null : settings.activeClaudeManagedAccountId, - activeClaudeManagedAccountIdsByRuntime: nextSelection - }) - return - } - const credentialsJson = await this.readManagedCredentials(activeAccount) - if (!credentialsJson || !this.isValidCredentialsJsonObject(credentialsJson)) { - console.warn( - '[claude-runtime-auth] Active WSL managed account is missing or has invalid credentials, restoring system default' - ) - const nextSelection = setSelectedClaudeAccountIdForTarget( - normalizeClaudeRuntimeSelection(settings), - null, - normalizedTarget - ) - this.store.updateSettings({ - activeClaudeManagedAccountId: - normalizedTarget.runtime === 'host' ? null : settings.activeClaudeManagedAccountId, - activeClaudeManagedAccountIdsByRuntime: nextSelection - }) - return - } - // Why: WSL managed accounts are isolated by their Linux CLAUDE_CONFIG_DIR; materializing into Windows ~/.claude would mix two auth stores. - this.clearLastWrittenRuntimeState() - return - } - - if (!(await this.getOwnedManagedAuthPath(activeAccount))) { - console.warn( - '[claude-runtime-auth] Active managed account is not owned by Orca, restoring system default' - ) - if (this.lastSyncedAccountId !== null) { - if ( - previousAccount && - (previousAccount.id !== activeAccount.id || - this.hasMaterializedRuntimeAuth || - this.runtimeOauthAccountMatches(await this.readManagedOauthAccount(previousAccount))) - ) { - await this.restoreSystemDefaultSnapshotForMissingManagedCredentials( - previousAccount, - previousManagedOauthAccount - ) - } else if (!previousAccount && this.hasMaterializedRuntimeAuth) { - await this.restoreSystemDefaultSnapshot(this.lastWrittenCredentialsJson, undefined) - } - } - this.store.updateSettings({ activeClaudeManagedAccountId: null }) - this.lastSyncedAccountId = null - return - } - - let credentialsJson = await this.readManagedCredentials(activeAccount) - if (!credentialsJson || !this.isValidCredentialsJsonObject(credentialsJson)) { - console.warn( - '[claude-runtime-auth] Active managed account is missing or has invalid credentials, restoring system default' - ) - if (this.lastSyncedAccountId !== null) { - if ( - previousAccount && - (previousAccount.id !== activeAccount.id || - this.hasMaterializedRuntimeAuth || - this.runtimeOauthAccountMatches(previousManagedOauthAccount)) - ) { - await this.restoreSystemDefaultSnapshotForMissingManagedCredentials( - previousAccount, - previousManagedOauthAccount - ) - } else if (!previousAccount && this.hasMaterializedRuntimeAuth) { - await this.restoreSystemDefaultSnapshot(this.lastWrittenCredentialsJson, undefined) - } - } - this.store.updateSettings({ activeClaudeManagedAccountId: null }) - this.lastSyncedAccountId = null - return - } - - if (this.lastSyncedAccountId === null) { - const paths = this.pathResolver.getRuntimePaths() - const runtimeCredentialsJson = existsSync(paths.credentialsPath) - ? readFileSync(paths.credentialsPath, 'utf-8') - : null - await this.captureSystemDefaultSnapshotForManagedEntry( - runtimeCredentialsJson, - credentialsJson - ) - } - - // Why: the CLI writes refreshed tokens to .credentials.json; if runtime differs from our last write, preserve them to managed storage before overwriting. - if (this.lastSyncedAccountId === activeAccount.id) { - if (this.skipNextReadBackForAccountId === activeAccount.id) { - this.skipNextReadBackForAccountId = null - } else { - const readBackResult = await this.readBackRefreshedTokens(credentialsJson, { - updateLastWrittenCredentialsJson: true - }) - if (readBackResult.status === 'persisted') { - const updatedCredentialsJson = await this.readManagedCredentials(activeAccount) - if (updatedCredentialsJson && this.isValidCredentialsJsonObject(updatedCredentialsJson)) { - credentialsJson = updatedCredentialsJson - } - } else if ( - readBackResult.status === 'rejected' && - readBackResult.runtimeCredentialsChanged && - // Why: a live Claude that lost a refresh race can wipe its runtime blob (empty tokens); preserving that would log out every new session. - readBackResult.hasValidChangedRuntimeCredentials && - hasLiveClaudePtys() - ) { - if ( - readBackResult.runtimeCredentialsJson && - this.liveRuntimeCredentialsCanUpdateActiveAccount( - readBackResult.runtimeCredentialsJson, - activeAccount, - credentialsJson, - await this.readManagedOauthAccount(activeAccount) - ) - ) { - // Why: this Claude launched under the active managed account, but persistence still needs positive account proof. - await this.writeManagedCredentials(activeAccount, readBackResult.runtimeCredentialsJson) - credentialsJson = readBackResult.runtimeCredentialsJson - } else { - // Why: while Claude runs, an unknown refresh may belong to a live session; rewriting stale managed auth logs it out. - console.warn( - '[claude-runtime-auth] Preserving changed Claude runtime credentials while live Claude terminals are running' - ) - this.lastSyncedAccountId = activeAccount.id - this.hasMaterializedRuntimeAuth = true - return - } - } - } - } - - if (this.lastSyncedAccountId !== activeAccount.id) { - this.skipNextReadBackForAccountId = null - } - - // Why: rotate+persist the single-use token to managed storage before materializing (else runtime gets a stale token that fails invalid_grant); skip while a live PTY owns the creds since refreshing would double-rotate it (invalidating one copy) — read-back preserves its refresh instead. - const liveClaudePtys = hasLiveClaudePtys() - if (liveClaudePtys && isOauthTokenExpiring(credentialsJson)) { - this.managedRefreshDeferredByLivePtyAccountId = activeAccount.id - } - if (!liveClaudePtys) { - const refreshed = await this.refreshManagedAccountTokenIfNeeded( - activeAccount, - credentialsJson - ) - if (refreshed) { - credentialsJson = refreshed - } - } - - const paths = this.pathResolver.getRuntimePaths() - const runtimeCredentialsJson = await this.mergeLiveRuntimeSharedCredentials(credentialsJson) - this.writeRuntimeCredentials(runtimeCredentialsJson) - if (process.platform === 'darwin') { - // Why: Claude Code 2.1+ reads the scoped service, older builds the legacy unsuffixed one; runtime switching must satisfy both. - try { - await writeActiveClaudeKeychainCredentialsForRuntime( - runtimeCredentialsJson, - paths.configDir - ) - } catch (error) { - await this.restoreSystemDefaultSnapshot( - credentialsJson, - await this.readManagedOauthAccount(activeAccount) - ) - throw error - } - } - this.lastWrittenSharedCredentialsJson = runtimeCredentialsJson - const managedOauthAccount = await this.readManagedOauthAccount(activeAccount) - if (this.writeRuntimeOauthAccount(managedOauthAccount)) { - this.lastWrittenOauthAccount = managedOauthAccount - this.hasLastWrittenOauthAccount = true - } else { - this.lastWrittenOauthAccount = null - this.hasLastWrittenOauthAccount = false - } - this.lastSyncedAccountId = activeAccount.id - this.hasMaterializedRuntimeAuth = true - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts index 77f0394e5719..a6a099dad194 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts @@ -1,4 +1,3 @@ -import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' import type { ClaudeEnvPatch } from '../environment' export type ClaudeRuntimeAuthPreparation = { @@ -8,48 +7,7 @@ export type ClaudeRuntimeAuthPreparation = { wslLinuxConfigDir?: string | null envPatch: ClaudeEnvPatch stripAuthEnv: boolean - managedRefreshDeferredByLivePty?: boolean provenance: string + /** Usage only: why the selected account cannot be read; usage polling must not throw for it. */ + usageError?: string } - -export type ClaudeSystemDefaultSnapshot = { - credentialsJson: string | null - configOauthAccount: unknown - keychainCredentialsJson: string | null - scopedKeychainCredentialsJson?: string | null - legacyKeychainCredentialsJson?: string | null - scopedKeychainCredentialsCaptured?: boolean - legacyKeychainCredentialsCaptured?: boolean - capturedAt: number -} - -export type ClaudeAuthIdentity = { - accountUuid: string | null - email: string | null - organizationUuid: string | null -} - -export type ClaudeReadBackResult = - | { status: 'unchanged' | 'persisted' } - | { - status: 'rejected' - runtimeCredentialsChanged: boolean - hasValidChangedRuntimeCredentials: boolean - runtimeCredentialsJson?: string - } -export type ClaudeReadBackMatch = - | { kind: 'matched'; account: ClaudeManagedAccount; managedCredentialsJson: string } - | { kind: 'none' | 'ambiguous' } -export type ClaudeKeychainReadResult = - | { status: 'captured'; credentialsJson: string | null } - | { status: 'failed' } -export type ClaudeKeychainSnapshotValue = - | { status: 'captured'; credentialsJson: string | null } - | { status: 'unknown' } -export type ClaudeRefreshTokenComparison = 'same' | 'different' | 'missing' -export type ClaudeRuntimeCredentialCandidate = { - credentialsJson: string - runtimeOauthAccount: unknown -} - -export const RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR = Symbol('runtime-oauth-account-parse-error') diff --git a/src/main/claude-accounts/runtime-paths.test.ts b/src/main/claude-accounts/runtime-paths.test.ts index cd1607e5a319..aa2f168b6eac 100644 --- a/src/main/claude-accounts/runtime-paths.test.ts +++ b/src/main/claude-accounts/runtime-paths.test.ts @@ -79,12 +79,6 @@ describe('ClaudeRuntimePathResolver', () => { expect(paths.envPatch).toEqual({ CLAUDE_CONFIG_DIR: inherited }) }) - it('resolves credentials next to the config directory', () => { - const paths = new ClaudeRuntimePathResolver().getRuntimePaths() - - expect(paths.credentialsPath).toBe(join(testState.fakeHomeDir, '.claude', '.credentials.json')) - }) - it('falls back to the home config file when no colocated config exists', () => { const paths = new ClaudeRuntimePathResolver().getRuntimePaths() diff --git a/src/main/claude-accounts/runtime-paths.ts b/src/main/claude-accounts/runtime-paths.ts index d9ff5701f262..c164df91d9c6 100644 --- a/src/main/claude-accounts/runtime-paths.ts +++ b/src/main/claude-accounts/runtime-paths.ts @@ -5,7 +5,6 @@ import type { ClaudeEnvPatch } from './environment' export type ClaudeRuntimePaths = { configDir: string - credentialsPath: string configPath: string envPatch: ClaudeEnvPatch } @@ -18,7 +17,6 @@ export class ClaudeRuntimePathResolver { return { configDir, - credentialsPath: join(configDir, '.credentials.json'), configPath: this.resolveConfigPath(configDir, inheritedConfigDir), envPatch: inheritedConfigDir ? { CLAUDE_CONFIG_DIR: configDir } : {} } diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index d1c79eaf17d5..9d64480cfd4a 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -1,73 +1,39 @@ -import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' -import type { Store } from '../persistence' -import type { RateLimitService } from '../rate-limits/service' +import type { + ClaudeAccountSignIn, + ClaudeRateLimitAccountsState, + ClaudeSignInRequest +} from '../../shared/managed-account-types' import { ClaudeAccountRegistration } from './claude-account-registration' -import { ClaudeAccountSelection } from './claude-account-selection' import { - captureClaudeAuthFromConfigDir, - captureClaudeAuthFromExistingConfigDir, - readCapturedClaudeCredentials, - type CapturedClaudeAuth -} from './claude-auth-capture' -import { - runClaudeCommandProcess, - type ClaudeCommandConfig, - type ClaudeCommandOptions -} from './claude-command-process' + ClaudeAccountSelection, + type ClaudeAccountRuntime, + type ClaudeAccountStore, + type ClaudeAccountUsage +} from './claude-account-selection' +import { runClaudeCommandProcess } from './claude-command-process' import { runClaudeLoginSession } from './claude-login-session' -import { - ClaudeManagedAuthStorage, - type ClaudeManagedAuthLocation -} from './claude-managed-auth-storage' import type { ClaudeRuntimeAuthService } from './runtime-auth-service' import type { ClaudeAccountSelectionTarget } from './runtime-selection' -export type ClaudeAccountAddTarget = { - runtime?: 'host' | 'wsl' - wslDistro?: string | null -} - -export type ClaudeAccountImportOptions = ClaudeAccountAddTarget & { - previousLegacyCredentialsSha256?: string | null -} - export class ClaudeAccountService { private mutationQueue: Promise = Promise.resolve() private cancelPendingClaudeLogin: (() => boolean) | null = null - private readonly storage = new ClaudeManagedAuthStorage() private readonly selection: ClaudeAccountSelection private readonly registration: ClaudeAccountRegistration constructor( - store: Store, - rateLimits: RateLimitService, - private readonly runtimeAuth: ClaudeRuntimeAuthService + store: ClaudeAccountStore, + rateLimits: ClaudeAccountUsage, + private readonly runtimeAuth: ClaudeAccountRuntime & + Pick, + private readonly runLoginCommand = runClaudeCommandProcess ) { - this.selection = new ClaudeAccountSelection(store, rateLimits, runtimeAuth, (accountId, path) => - this.safeRemoveManagedAuth(accountId, path) - ) + this.selection = new ClaudeAccountSelection(store, rateLimits, runtimeAuth) this.registration = new ClaudeAccountRegistration({ store, rateLimits, runtimeAuth, - selection: this.selection, - createManagedAuth: (accountId, target) => this.storage.create(accountId, target), - assertManagedAuth: (path, accountId) => this.storage.assertOwned(path, accountId), - removeManagedAuth: (accountId, path) => this.safeRemoveManagedAuth(accountId, path), - writeManagedAuth: (accountId, path, captured) => - this.writeManagedAuth(accountId, path, captured), - writeCredentials: (accountId, path, value) => - this.storage.writeCredentials(accountId, path, value), - writeOauth: (accountId, path, value) => - this.storage.writeOauthAccount(accountId, path, value), - readSnapshot: (accountId, path) => this.storage.readSnapshot(accountId, path), - restoreCredentials: (accountId, path, snapshot) => - this.storage.restoreCredentials(accountId, path, snapshot), - restoreOauth: (accountId, path, snapshot) => - this.storage.restoreOauth(accountId, path, snapshot), - login: (location) => this.runClaudeLoginAndCapture(location), - captureExisting: (configDir, previousDigest) => - this.captureFromExistingConfigDir(configDir, previousDigest) + selection: this.selection }) } @@ -75,34 +41,45 @@ export class ClaudeAccountService { return this.selection.list() } - async addAccount(target?: ClaudeAccountAddTarget): Promise { - this.supersedePendingLogin() - return this.serializeMutation(() => this.registration.add(target)) + /** A hidden `claude auth login` opens the browser and writes into the new account's folder. */ + addAccount(target: ClaudeAccountSelectionTarget = {}): Promise { + return this.signInHidden({ runtime: target.runtime, wslDistro: target.wslDistro }) + } + + reauthenticateAccount(accountId: string): Promise { + return this.signInHidden({ accountId }) } - async addAccountFromConfigDir( - configDir: string, - options?: ClaudeAccountImportOptions + cancelPendingLogin(): boolean { + return this.cancelPendingClaudeLogin?.() ?? false + } + + beginSignIn(request: ClaudeSignInRequest = {}): Promise { + return this.serializeMutation(() => this.registration.begin(request)) + } + + finishSignIn( + signIn: Omit ): Promise { - return this.serializeMutation(() => this.registration.addFromConfigDir(configDir, options)) + return this.serializeMutation(() => this.registration.finish(signIn)) } - async reauthenticateAccount(accountId: string): Promise { - this.supersedePendingLogin() - return this.serializeMutation(() => this.registration.reauthenticate(accountId)) + /** Deletes the folder of a sign-in that never became an account; a saved account's is kept. */ + cancelSignIn(signIn: Omit): Promise { + return this.serializeMutation(() => this.registration.cancel(signIn)) } - async removeAccount(accountId: string): Promise { + removeAccount(accountId: string): Promise { this.supersedePendingLogin() return this.serializeMutation(() => this.selection.remove(accountId)) } - async selectAccount(accountId: string | null): Promise { + selectAccount(accountId: string | null): Promise { this.supersedePendingLogin() return this.serializeMutation(() => this.selection.select(accountId)) } - async selectAccountForTarget( + selectAccountForTarget( accountId: string | null, target?: ClaudeAccountSelectionTarget ): Promise { @@ -110,13 +87,26 @@ export class ClaudeAccountService { return this.serializeMutation(() => this.selection.select(accountId, target)) } - cancelPendingLogin(): boolean { - return this.cancelPendingClaudeLogin?.() ?? false + getRuntimeConfigDir(target?: ClaudeAccountSelectionTarget): string { + return this.runtimeAuth.getRuntimeConfigDir(target) } - // Why before the queue, not inside it: the abandoned login owns the queue slot - // every later account action waits for. Called from the four the user drives, - // never from serializeMutation, which background work also uses. + private signInHidden(request: ClaudeSignInRequest): Promise { + this.supersedePendingLogin() + return this.serializeMutation(() => + this.registration.signIn(request, (folder) => + runClaudeLoginSession(folder, { + runCommand: this.runLoginCommand, + setCancel: (cancel) => { + this.cancelPendingClaudeLogin = cancel + } + }) + ) + ) + } + + // Why before the queue, not inside it: the abandoned login owns the queue slot every later + // account action waits for. Only user-driven actions supersede it. private supersedePendingLogin(): void { if (this.cancelPendingLogin()) { console.info( @@ -125,91 +115,9 @@ export class ClaudeAccountService { } } - getRuntimeConfigDir(target?: ClaudeAccountSelectionTarget): string { - return this.runtimeAuth.getRuntimeConfigDir(target) - } - private serializeMutation(operation: () => Promise): Promise { const next = this.mutationQueue.then(operation, operation) this.mutationQueue = next.catch(() => {}) return next } - - private runClaudeLoginAndCapture( - location: ClaudeManagedAuthLocation = { - managedAuthPath: '', - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null - } - ): Promise { - return runClaudeLoginSession(location, { - runCommand: (args, config, timeoutMs, options) => - this.runClaudeCommand(args, config, timeoutMs, options), - capture: (configDir, status, previousLegacy) => - this.captureAuthFromConfigDir(configDir, status, previousLegacy), - setCancel: (cancel) => { - this.cancelPendingClaudeLogin = cancel - } - }) - } - - private runClaudeCommand( - args: string[], - configDir: ClaudeCommandConfig, - timeoutMs: number, - options?: ClaudeCommandOptions - ): Promise { - return runClaudeCommandProcess(args, configDir, timeoutMs, options) - } - - private captureFromExistingConfigDir( - configDir: string, - previousLegacyCredentialsSha256?: string | null - ): Promise { - return captureClaudeAuthFromExistingConfigDir( - configDir, - previousLegacyCredentialsSha256, - (args, config, timeoutMs, options) => this.runClaudeCommand(args, config, timeoutMs, options) - ) - } - - private captureAuthFromConfigDir( - configDir: string, - statusOutput: string, - previousLegacyKeychain: string | null, - previousLegacyCredentialsSha256?: string | null - ): Promise { - return captureClaudeAuthFromConfigDir( - configDir, - statusOutput, - previousLegacyKeychain, - previousLegacyCredentialsSha256, - (path, previous, digest) => this.readCapturedCredentials(path, previous, digest) - ) - } - - private readCapturedCredentials( - configDir: string, - previousLegacyKeychain: string | null, - previousLegacyCredentialsSha256?: string | null - ): Promise { - return readCapturedClaudeCredentials( - configDir, - previousLegacyKeychain, - previousLegacyCredentialsSha256 - ) - } - - private writeManagedAuth( - accountId: string, - managedAuthPath: string, - captured: CapturedClaudeAuth - ): Promise { - return this.storage.writeAuth(accountId, managedAuthPath, captured) - } - - private safeRemoveManagedAuth(accountId: string, path: string): Promise { - return this.storage.remove(accountId, path) - } } diff --git a/src/main/claude-accounts/shared-credential-fields.test.ts b/src/main/claude-accounts/shared-credential-fields.test.ts deleted file mode 100644 index 097e1725d128..000000000000 --- a/src/main/claude-accounts/shared-credential-fields.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - mergeSharedClaudeCredentialFields, - SHARED_CLAUDE_CREDENTIAL_KEYS -} from './shared-credential-fields' - -describe('mergeSharedClaudeCredentialFields', () => { - it('merges the live credential shared fields into the target credential', () => { - const target = JSON.stringify({ claudeAiOauth: { accessToken: 'target-token' } }) - const live = JSON.stringify({ - claudeAiOauth: { accessToken: 'live-token' }, - mcpOAuth: { conn1: 'v1' }, - pluginSecrets: { s: 1 } - }) - const result = JSON.parse(mergeSharedClaudeCredentialFields(target, live)) - expect(result.claudeAiOauth).toEqual({ accessToken: 'target-token' }) - expect(result.mcpOAuth).toEqual({ conn1: 'v1' }) - expect(result.pluginSecrets).toEqual({ s: 1 }) - }) - - it('is absence-authoritative: a shared key missing on live is not carried from the target', () => { - const target = JSON.stringify({ - claudeAiOauth: { accessToken: 'target-token' }, - mcpOAuth: { stale: 'rotated-out' } - }) - const live = JSON.stringify({ claudeAiOauth: { accessToken: 'live-token' } }) - const result = JSON.parse(mergeSharedClaudeCredentialFields(target, live)) - expect(result.mcpOAuth).toBeUndefined() - }) - - it('leaves account-scoped sibling keys on the target untouched', () => { - const target = JSON.stringify({ - claudeAiOauth: { accessToken: 'target-token' }, - trustedDeviceToken: 'target-device-token' - }) - const live = JSON.stringify({ - claudeAiOauth: { accessToken: 'live-token' }, - mcpOAuth: { conn1: 'v1' } - }) - const result = JSON.parse(mergeSharedClaudeCredentialFields(target, live)) - expect(result.trustedDeviceToken).toBe('target-device-token') - expect(result.mcpOAuth).toEqual({ conn1: 'v1' }) - }) - - it('returns the target unchanged when there is no live credential to merge from', () => { - const target = JSON.stringify({ claudeAiOauth: { accessToken: 'target-token' } }) - expect(mergeSharedClaudeCredentialFields(target, null)).toBe(target) - }) - - it('returns the target unchanged when the live value is not a JSON object (e.g. a raw managed API key)', () => { - const target = JSON.stringify({ claudeAiOauth: { accessToken: 'target-token' } }) - expect(mergeSharedClaudeCredentialFields(target, 'sk-ant-api-not-json')).toBe(target) - }) - - it('returns the target unchanged when the target itself is not a Claude OAuth credential object (managed API key)', () => { - const target = 'sk-ant-api-a-raw-managed-key' - const live = JSON.stringify({ claudeAiOauth: {}, mcpOAuth: { conn1: 'v1' } }) - expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) - }) - - it('returns the target unchanged when the target JSON is malformed', () => { - const target = '{not valid json' - const live = JSON.stringify({ claudeAiOauth: {}, mcpOAuth: { conn1: 'v1' } }) - expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) - }) - - it('returns the target unchanged when claudeAiOauth is null rather than an object', () => { - const target = JSON.stringify({ claudeAiOauth: null }) - const live = JSON.stringify({ claudeAiOauth: {}, mcpOAuth: { conn1: 'v1' } }) - expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) - }) - - it('returns the target unchanged when claudeAiOauth is a primitive rather than an object', () => { - const target = JSON.stringify({ claudeAiOauth: 'not-an-object' }) - const live = JSON.stringify({ claudeAiOauth: {}, mcpOAuth: { conn1: 'v1' } }) - expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) - }) - - it('returns the target byte-for-byte unchanged when neither side has any shared key (no-op merge)', () => { - // Why: a no-op reformat (e.g. dropped trailing newline) reads as an external refresh downstream. - const target = `${JSON.stringify({ - claudeAiOauth: { accessToken: 'target-token', refreshToken: 'target-refresh' } - })}\n` - const live = JSON.stringify({ claudeAiOauth: { accessToken: 'live-token' } }) - expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) - }) - - it('covers every documented shared key, not just mcpOAuth', () => { - // Why: pins the actual key names, so removing one from the constant fails this test. - expect(SHARED_CLAUDE_CREDENTIAL_KEYS).toEqual([ - 'mcpOAuth', - 'mcpOAuthClientConfig', - 'mcpXaaIdp', - 'mcpXaaIdpConfig', - 'pluginSecrets' - ]) - const target = JSON.stringify({ claudeAiOauth: {} }) - const liveObj: Record = { claudeAiOauth: {} } - for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) { - liveObj[key] = { present: true } - } - const result = JSON.parse(mergeSharedClaudeCredentialFields(target, JSON.stringify(liveObj))) - for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) { - expect(result[key]).toEqual({ present: true }) - } - }) - - it('returns the target byte-for-byte unchanged when an existing shared key keeps its value (interleaved order)', () => { - // Why: rebuilding via key-order iteration used to move an existing shared key to the - // end even when its value did not change, causing a spurious formatting-only rewrite. - const target = `${JSON.stringify({ - claudeAiOauth: { accessToken: 'target-token' }, - mcpOAuth: { conn1: 'v1' }, - trustedDeviceToken: 'target-device-token' - })}\n` - const live = JSON.stringify({ - claudeAiOauth: { accessToken: 'live-token' }, - mcpOAuth: { conn1: 'v1' } - }) - expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) - }) - - it('still updates an interleaved shared key in place when its live value actually differs', () => { - const target = JSON.stringify({ - claudeAiOauth: { accessToken: 'target-token' }, - mcpOAuth: { conn1: 'stale' }, - trustedDeviceToken: 'target-device-token' - }) - const live = JSON.stringify({ - claudeAiOauth: { accessToken: 'live-token' }, - mcpOAuth: { conn1: 'fresh' } - }) - const result = JSON.parse(mergeSharedClaudeCredentialFields(target, live)) - expect(result.mcpOAuth).toEqual({ conn1: 'fresh' }) - expect(result.trustedDeviceToken).toBe('target-device-token') - }) -}) diff --git a/src/main/claude-accounts/shared-credential-fields.ts b/src/main/claude-accounts/shared-credential-fields.ts deleted file mode 100644 index e0014f25c4b8..000000000000 --- a/src/main/claude-accounts/shared-credential-fields.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { isDeepStrictEqual } from 'node:util' - -export const SHARED_CLAUDE_CREDENTIAL_KEYS = [ - 'mcpOAuth', - 'mcpOAuthClientConfig', - 'mcpXaaIdp', - 'mcpXaaIdpConfig', - 'pluginSecrets' -] as const - -function parseCredentialObject(credentialsJson: string | null): Record | null { - if (!credentialsJson) { - return null - } - let parsed: unknown - try { - parsed = JSON.parse(credentialsJson) - } catch { - return null - } - return isCredentialObject(parsed) ? parsed : null -} - -function isCredentialObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -export function stripSharedClaudeCredentialFields(credentialsJson: string): string { - const credential = parseCredentialObject(credentialsJson) - if (!credential) { - return credentialsJson - } - let changed = false - for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) { - if (Object.hasOwn(credential, key)) { - delete credential[key] - changed = true - } - } - return changed ? JSON.stringify(credential) : credentialsJson -} - -// Shared connector state follows the live runtime, including revocations, rather than frozen account snapshots. -export function mergeSharedClaudeCredentialFields( - targetCredentialsJson: string, - liveCredentialsJson: string | null -): string { - const target = parseCredentialObject(targetCredentialsJson) - const live = parseCredentialObject(liveCredentialsJson) - if ( - !target || - !live || - (Object.hasOwn(target, 'claudeAiOauth') && !isCredentialObject(target.claudeAiOauth)) - ) { - return targetCredentialsJson - } - - let changed = false - const merged: Record = { ...target } - for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) { - const targetHasKey = Object.hasOwn(target, key) - if (Object.hasOwn(live, key)) { - if (!targetHasKey || JSON.stringify(live[key]) !== JSON.stringify(target[key])) { - merged[key] = live[key] - changed = true - } - } else if (targetHasKey) { - delete merged[key] - changed = true - } - } - return changed ? JSON.stringify(merged) : targetCredentialsJson -} - -type CredentialField = { present: boolean; value: unknown } - -function credentialField(record: Record, key: string): CredentialField { - const present = Object.hasOwn(record, key) - return { present, value: present ? record[key] : undefined } -} - -function resolveCredentialField( - candidates: CredentialField[], - baseline: CredentialField | null -): CredentialField { - const changes = candidates.filter((candidate) => - baseline === null ? candidate.present : !isDeepStrictEqual(candidate, baseline) - ) - const first = changes[0] - if (first && changes.some((candidate) => !isDeepStrictEqual(candidate, first))) { - throw new Error( - 'Cannot switch Claude accounts: live connector credentials conflict; existing authorizations were preserved' - ) - } - return first ?? baseline ?? { present: false, value: undefined } -} - -function resolveServerGrants( - sources: Record[], - baseline: Record | null, - key: string -): CredentialField | null { - const fields = sources.map((source) => credentialField(source, key)) - const previous = baseline === null ? null : credentialField(baseline, key) - if ( - fields.some((field) => field.present && !isCredentialObject(field.value)) || - (previous?.present && !isCredentialObject(previous.value)) - ) { - return null - } - const maps = fields.map((field) => (isCredentialObject(field.value) ? field.value : {})) - const previousMap = - previous === null ? null : isCredentialObject(previous.value) ? previous.value : {} - const names = new Set([ - ...maps.flatMap((map) => Object.keys(map)), - ...Object.keys(previousMap ?? {}) - ]) - const entries: [string, unknown][] = [] - for (const name of names) { - // Keep a server's access/refresh token pair atomic while combining independent server updates. - const grant = resolveCredentialField( - maps.map((map) => credentialField(map, name)), - previousMap === null ? null : credentialField(previousMap, name) - ) - if (grant.present) { - entries.push([name, grant.value]) - } - } - const present = - entries.length > 0 || - (fields.some((field) => field.present) && - !(baseline !== null && fields.some((field) => !field.present))) - return { present, value: present ? Object.fromEntries(entries) : undefined } -} - -export function reconcileSharedClaudeCredentialFields( - liveCredentials: string[], - lastWrittenCredentials: string | null -): string { - const sources = liveCredentials.map((credentials) => { - const parsed = parseCredentialObject(credentials) - if (!parsed) { - throw new Error('Cannot preserve malformed Claude runtime credentials') - } - return parsed - }) - if (sources.length === 0) { - return '{}' - } - const baseline = parseCredentialObject(lastWrittenCredentials) - const entries: [string, unknown][] = [] - for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) { - const field = - (key === 'mcpOAuth' || key === 'mcpOAuthClientConfig' - ? resolveServerGrants(sources, baseline, key) - : null) ?? - resolveCredentialField( - sources.map((source) => credentialField(source, key)), - baseline === null ? null : credentialField(baseline, key) - ) - if (field.present) { - entries.push([key, field.value]) - } - } - return JSON.stringify(Object.fromEntries(entries)) -} diff --git a/src/main/claude-accounts/shared-credential-reconciliation.test.ts b/src/main/claude-accounts/shared-credential-reconciliation.test.ts deleted file mode 100644 index f772b5634c99..000000000000 --- a/src/main/claude-accounts/shared-credential-reconciliation.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { reconcileSharedClaudeCredentialFields } from './shared-credential-fields' - -const original = { accessToken: 'access', refreshToken: 'refresh' } -const rotated = { accessToken: 'rotated-access', refreshToken: 'rotated-refresh' } -const baseline = JSON.stringify({ - mcpOAuth: { figma: original }, - pluginSecrets: { plugin: 'secret' } -}) - -describe('live Claude connector reconciliation', () => { - it('combines independent server grants on startup without importing account identity', () => { - const sources = [ - JSON.stringify({ claudeAiOauth: { accessToken: 'account' }, mcpOAuth: { figma: original } }), - JSON.stringify({ - mcpOAuth: { notion: rotated }, - mcpOAuthClientConfig: { notion: { clientId: 'client' } } - }), - '{}' - ] - const expected = { - mcpOAuth: { figma: original, notion: rotated }, - mcpOAuthClientConfig: { notion: { clientId: 'client' } } - } - expect(JSON.parse(reconcileSharedClaudeCredentialFields(sources, null))).toEqual(expected) - expect(JSON.parse(reconcileSharedClaudeCredentialFields(sources.toReversed(), null))).toEqual( - expected - ) - }) - - it('combines independent rotations and additions against the previous write', () => { - const sources = [ - JSON.stringify({ mcpOAuth: { figma: rotated }, pluginSecrets: { plugin: 'secret' } }), - JSON.stringify({ - mcpOAuth: { figma: original, notion: original }, - pluginSecrets: { plugin: 'new-secret' } - }), - baseline - ] - const expected = { - mcpOAuth: { figma: rotated, notion: original }, - pluginSecrets: { plugin: 'new-secret' } - } - expect(JSON.parse(reconcileSharedClaudeCredentialFields(sources, baseline))).toEqual(expected) - expect( - JSON.parse(reconcileSharedClaudeCredentialFields(sources.toReversed(), baseline)) - ).toEqual(expected) - }) - - it('keeps a revocation while another store adds an unrelated server', () => { - const sources = [ - JSON.stringify({ mcpOAuth: {}, pluginSecrets: { plugin: 'secret' } }), - JSON.stringify({ - mcpOAuth: { figma: original, notion: rotated }, - pluginSecrets: { plugin: 'secret' } - }), - baseline - ] - expect(JSON.parse(reconcileSharedClaudeCredentialFields(sources, baseline))).toEqual({ - mcpOAuth: { notion: rotated }, - pluginSecrets: { plugin: 'secret' } - }) - }) - - it.each([null, baseline])( - 'rejects conflicting server token pairs with baseline %s', - (previous) => { - const sources = [ - JSON.stringify({ mcpOAuth: { figma: rotated } }), - JSON.stringify({ - mcpOAuth: { figma: { accessToken: 'other-access', refreshToken: 'other-refresh' } } - }) - ] - expect(() => reconcileSharedClaudeCredentialFields(sources, previous)).toThrow( - 'live connector credentials conflict' - ) - expect(() => reconcileSharedClaudeCredentialFields(sources.toReversed(), previous)).toThrow( - 'live connector credentials conflict' - ) - } - ) - - it('does not recombine access and refresh tokens from different writes', () => { - const sources = [ - JSON.stringify({ mcpOAuth: { figma: { ...original, accessToken: 'new-access' } } }), - JSON.stringify({ mcpOAuth: { figma: { ...original, refreshToken: 'new-refresh' } } }) - ] - expect(() => reconcileSharedClaudeCredentialFields(sources, baseline)).toThrow( - 'live connector credentials conflict' - ) - }) - - it('does not infer MCP freshness from Claude account-token expiry', () => { - const sources = [ - JSON.stringify({ claudeAiOauth: { expiresAt: 1 }, mcpOAuth: { figma: original } }), - JSON.stringify({ claudeAiOauth: { expiresAt: 9999999999999 }, mcpOAuth: { figma: rotated } }) - ] - expect(() => reconcileSharedClaudeCredentialFields(sources, null)).toThrow( - 'live connector credentials conflict' - ) - }) - - it('rejects conflicting non-server fields instead of combining their secrets', () => { - expect(() => - reconcileSharedClaudeCredentialFields( - [ - JSON.stringify({ pluginSecrets: { plugin: 'one' } }), - JSON.stringify({ pluginSecrets: { plugin: 'two' } }) - ], - null - ) - ).toThrow('live connector credentials conflict') - }) - - it('does not resurrect the last-written grants when every live store is missing', () => { - expect(reconcileSharedClaudeCredentialFields([], baseline)).toBe('{}') - }) - - it('treats an explicit null as an authoritative field removal after a known write', () => { - expect( - JSON.parse( - reconcileSharedClaudeCredentialFields( - [JSON.stringify({ mcpOAuth: null, pluginSecrets: { plugin: 'secret' } }), baseline], - baseline - ) - ) - ).toEqual({ mcpOAuth: null, pluginSecrets: { plugin: 'secret' } }) - }) -}) diff --git a/src/main/claude/claude-model-catalog-probe.ts b/src/main/claude/claude-model-catalog-probe.ts index 7a316bd0f40c..960a78e0236c 100644 --- a/src/main/claude/claude-model-catalog-probe.ts +++ b/src/main/claude/claude-model-catalog-probe.ts @@ -16,7 +16,6 @@ export type ClaudeModelCatalogProbeDeps = Pick< ClaudeStructuredLaunchResolverDeps, 'resolveCommand' | 'resolveEnv' | 'resolveInheritedEnv' | 'resolveAuthPolicy' > & { - authSwitchSettleTimeoutMs?: number /** Test seams; production runs the one-shot listing child. */ discover?: typeof discoverModelsLocal spawnAgent?: typeof spawnSourceControlAgent diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index e26e02263031..276328b009cd 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -5,7 +5,6 @@ import { join } from 'node:path' import { providerDiagnosticOf } from '../../shared/agent-session-failure' import { afterEach, describe, expect, it, vi } from 'vitest' import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process' -import { hasLiveClaudePtys } from '../claude-accounts/live-pty-gate' import type { ProcessSpec } from '../../shared/child-process/process-spec' import { query, type CanUseTool, type Options } from '@anthropic-ai/claude-agent-sdk' import { @@ -761,85 +760,3 @@ describe('Claude stream-json connection', () => { await expect(connection.close()).resolves.toBe(true) }, 20_000) }) - -// A structured Claude child owns the account's credentials while it runs, exactly as -// a Claude PTY does. The gate is what makes runtime-auth-sync defer the managed OAuth -// refresh instead of rotating the single-use token out from under a live session, and -// structured sessions used to be invisible to it. -describe('the managed-auth live gate', () => { - it('holds while a structured child runs and releases when it ends', async () => { - // The gate is a process-wide singleton and a sibling test's release lands on its - // child's 'close' event, which can settle after that test's close() resolved. - await until(() => (hasLiveClaudePtys() ? null : true), 'a drained auth gate') - const scenario = scriptScenario([ - { emit: { type: 'system', subtype: 'init', session_id: SESSION_ID, uuid: 'init-1' } }, - { wait: HOLD_OPEN } - ]) - const connection = await open(launchFor(scenario)) - - expect(hasLiveClaudePtys()).toBe(true) - - await connection.close() - - await until(() => (hasLiveClaudePtys() ? null : true), 'the auth gate to drain') - expect(hasLiveClaudePtys()).toBe(false) - }, 30_000) - - it('releases when the child dies on its own rather than through close()', async () => { - await until(() => (hasLiveClaudePtys() ? null : true), 'a drained auth gate') - const scenario = scriptScenario([ - { emit: { type: 'system', subtype: 'init', session_id: SESSION_ID, uuid: 'init-1' } }, - { wait: HOLD_OPEN } - ]) - await open(launchFor(scenario)) - expect(hasLiveClaudePtys()).toBe(true) - - spawnedChildren.at(-1)?.kill('SIGKILL') - - await until(() => (hasLiveClaudePtys() ? null : true), 'the auth gate to drain') - expect(hasLiveClaudePtys()).toBe(false) - }, 30_000) - - // The gate entry is deliberately unpersisted, so confirmSeededClaudeLivePtys can never - // reconcile a stray one: a leak here defers the managed OAuth refresh for the life of - // the process. Entering the gate only after the release handlers are attached makes - // that unreachable regardless of what the setup in between does. - it('leaks no gate entry when setup throws between spawn and handler attachment', async () => { - await until(() => (hasLiveClaudePtys() ? null : true), 'a drained auth gate') - const scenario = scriptScenario([ - { emit: { type: 'system', subtype: 'init', session_id: SESSION_ID, uuid: 'init-1' } }, - { wait: HOLD_OPEN } - ]) - let started: SpawnedProcess | null = null - - try { - await expect( - openClaudeStreamJsonConnection(launchFor(scenario), {}, (spec) => { - const child = spawnProcess(spec) - started = child - const attach = child.stderr.on.bind(child.stderr) - // Measured attach order: the SDK binds stderr 'data' from inside query(), - // before the child is even assigned. The SECOND bind is this connection's own - // armTreeOnOutput — the first statement that runs after the child exists and - // before its 'exit'/'close' release handlers. Throwing on the first is - // vacuous: it escapes before any gate entry could have happened. - let dataAttaches = 0 - child.stderr.on = ((event: string, listener: (...args: unknown[]) => void) => { - if (event === 'data') { - dataAttaches += 1 - if (dataAttaches === 2) { - throw new Error('stderr listener attach failed') - } - } - return attach(event, listener) - }) as typeof child.stderr.on - return child - }) - ).rejects.toThrow('stderr listener attach failed') - - expect(hasLiveClaudePtys()).toBe(false) - } finally { - ;(started as SpawnedProcess | null)?.kill('SIGKILL') - } - }, 30_000) -}) diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index 8e178cb720a7..e270110fc6c2 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -1,12 +1,7 @@ -import { randomUUID } from 'node:crypto' import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' import type * as ClaudeAgentSdk from '@anthropic-ai/claude-agent-sdk' import type { CanUseTool, OnUserDialog, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk' import { spawnProcess } from '../../shared/child-process/run-process' -import { - markClaudeStructuredChildExited, - markClaudeStructuredChildSpawned -} from '../claude-accounts/live-pty-gate' import { buildClaudeChildProcessEnv } from './claude-child-process-environment' import { withoutInheritedClaudeConfigDir } from './claude-config-dir-pin' import { @@ -158,12 +153,6 @@ export async function openClaudeStreamJsonConnection( if (!child || !managed) { throw new Error('the claude agent SDK returned without spawning a child') } - // This child owns the account's credentials for as long as it runs, exactly as a - // Claude PTY does — hold the OAuth-refresh gate so a managed refresh cannot rotate - // the single-use token out from under it mid-turn. Entered below, once a release - // path exists. - const authGateKey = randomUUID() - const releaseAuthGate = (): void => markClaudeStructuredChildExited(authGateKey) let exitStatus: ExitStatus | null = null let closing = false let terminalError: Error | null = null @@ -262,7 +251,6 @@ export async function openClaudeStreamJsonConnection( managed.onExit((exit) => { exitStatus = exit - releaseAuthGate() handleUnexpectedEnd() }) child.on('error', (error) => { @@ -272,7 +260,6 @@ export async function openClaudeStreamJsonConnection( handleUnexpectedEnd(error) }) child.on('close', () => { - releaseAuthGate() handleUnexpectedEnd() }) child.stdin.on('error', (error) => { @@ -281,13 +268,6 @@ export async function openClaudeStreamJsonConnection( handleUnexpectedEnd(error) } }) - // Why here and not at spawn: a structured gate entry is deliberately unpersisted, so - // confirmSeededClaudeLivePtys can never reconcile a stray one and a leak defers the - // managed OAuth refresh for the life of the process. Entering only after 'exit' and - // 'close' are attached makes that unreachable — any later throw still leaves a - // listener that releases. Nothing between spawn and here can yield, so the child - // cannot end before the gate is entered. - markClaudeStructuredChildSpawned(authGateKey) const send: ClaudeStreamJsonConnection['send'] = (message, beforeDispatch) => { if ( diff --git a/src/main/claude/claude-structured-auth-parity.test.ts b/src/main/claude/claude-structured-auth-parity.test.ts index 227fb117914c..2a22fae853ae 100644 --- a/src/main/claude/claude-structured-auth-parity.test.ts +++ b/src/main/claude/claude-structured-auth-parity.test.ts @@ -1,20 +1,8 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from '../claude-accounts/live-pty-gate' -import { - CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, - CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE -} from '../claude-accounts/environment' +import { CLAUDE_AUTH_ENV_CONFLICT_MESSAGE } from '../claude-accounts/environment' import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution' -import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' -import { - PROVIDER_SESSION_ID, - adapterFor, - fakeClaude, - identityFor -} from './claude-structured-session-test-support' -import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION_ID = 'orca-session-auth' const IDENTITY = { sessionId: SESSION_ID } as Parameters< @@ -39,7 +27,6 @@ function record(): AgentSessionRecord { function resolverFor(options: { stripAuthEnv: boolean overlay?: Record - authSwitchSettleTimeoutMs?: number }): ReturnType { return createClaudeStructuredLaunchResolver({ resolveLaunchArgs: () => [], @@ -47,41 +34,10 @@ function resolverFor(options: { resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', resolveAuthPolicy: () => ({ stripAuthEnv: options.stripAuthEnv }), - authSwitchSettleTimeoutMs: options.authSwitchSettleTimeoutMs ?? 20, ...(options.overlay ? { resolveEnv: () => options.overlay as Record } : {}) }) } -/** - * An adapter driven by the REAL launch resolver, not the stub in the shared test - * support — the stub has no auth guard at all, so a teardown-window test built on it - * would pass whatever the guard did. - */ -function realResolverAdapter( - claude: ReturnType, - authSwitchSettleTimeoutMs: number -): ClaudeStructuredSessionAdapter { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only the chain head's handle from this partial record. - const resumable = { - ...record(), - providerHandleChain: [{ handle: claudeProviderHandle(PROVIDER_SESSION_ID, null) }] - } as unknown as AgentSessionRecord - return new ClaudeStructuredSessionAdapter({ - resolveLaunch: createClaudeStructuredLaunchResolver({ - resolveLaunchArgs: () => [], - store: { getRecord: () => resumable, pinLaunchDirectory: vi.fn() }, - resolveWorkspacePath: async (id) => `/repos/${id}`, - resolveCommand: () => '/usr/local/bin/claude', - resolveAuthPolicy: () => ({ stripAuthEnv: false }), - authSwitchSettleTimeoutMs - }), - openConnection: claude.openConnection, - readProcessStartTime: async () => 1_700_000_000_000, - now: () => 1_700_000_000_500, - persistHandle: async () => {} - }) -} - function withAmbientAuth(value: string, run: () => Promise): Promise { const restore = process.env.ANTHROPIC_API_KEY process.env.ANTHROPIC_API_KEY = value @@ -95,10 +51,6 @@ function withAmbientAuth(value: string, run: () => Promise): Promise { } describe('claude structured auth parity with the terminal preflight', () => { - afterEach(() => { - endClaudeAuthSwitch() - }) - // Task 1 — the terminal preflight refuses this at spawn-env.ts:25 and // runtime/spawn-preflight.ts:139; the structured path used to let the override win. it('refuses an explicit Anthropic auth override while a managed account is pinned', async () => { @@ -161,91 +113,4 @@ describe('claude structured auth parity with the terminal preflight', () => { expect(launch.env?.ANTHROPIC_API_KEY).toBeUndefined() }) }) - - // Task 3 — the terminal preflight guards this at four sites; the structured path had none. - it('refuses launch resolution when an account switch never settles', async () => { - beginClaudeAuthSwitch() - - await expect( - resolverFor({ stripAuthEnv: true, authSwitchSettleTimeoutMs: 20 })({ identity: IDENTITY }) - ).rejects.toMatchObject({ - message: CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, - reason: 'accountSwitchInProgress' - }) - }) - - it('waits a settling account switch out rather than refusing a resolved launch', async () => { - beginClaudeAuthSwitch() - setTimeout(() => endClaudeAuthSwitch(), 20) - - const launch = await resolverFor({ - stripAuthEnv: true, - authSwitchSettleTimeoutMs: 5_000 - })({ identity: IDENTITY }) - - expect(launch.claudeConfigDir).toBe('/home/work/.claude') - }) - - it('refuses an acquire before it tears the previous session down', async () => { - const claude = fakeClaude() - const adapter = adapterFor(claude) - beginClaudeAuthSwitch() - - await expect( - adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - ).rejects.toMatchObject({ - message: CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, - reason: 'accountSwitchInProgress' - }) - // Nothing was spawned, so the refusal must not have opened a connection. - expect(claude.connections).toHaveLength(0) - }) - - // The teardown between the entry guard and launch resolution closes the live child - // and proves its tree — seconds, not milliseconds. A switch that begins inside it - // has already cost the user their session, so refusing there produces exactly the - // outcome the entry guard advertises against: a dead chat and no replacement. - it('replaces the session when a switch begins inside the acquire teardown', async () => { - const claude = fakeClaude() - const adapter = realResolverAdapter(claude, 5_000) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - const live = claude.connections[0]! - const closeWithSwitch = live.close - live.close = async () => { - beginClaudeAuthSwitch() - setTimeout(() => endClaudeAuthSwitch(), 20) - return closeWithSwitch() - } - - await expect( - adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) - ).resolves.toMatchObject({ process: { spawnToken: 'spawn-10' } }) - expect(live.closed).toBe(true) - // The replacement child exists: the user's chat came back. - expect(claude.connections).toHaveLength(2) - expect(claude.connections[1]!.closed).toBe(false) - await adapter.closeAll() - }) - - it('still refuses a mid-teardown switch that never settles, leaving nothing half-open', async () => { - const claude = fakeClaude() - const adapter = realResolverAdapter(claude, 20) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - const live = claude.connections[0]! - const closeWithSwitch = live.close - live.close = async () => { - beginClaudeAuthSwitch() - return closeWithSwitch() - } - - await expect( - adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) - ).rejects.toMatchObject({ - message: CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, - reason: 'accountSwitchInProgress' - }) - // No replacement child was opened, so nothing is left running unowned. - expect(claude.connections).toHaveLength(1) - await adapter.closeAll() - }) }) diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index dcdad5b868bf..2d14a9e0c047 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -131,6 +131,8 @@ function restingRecord(options: Record): AgentSessionRecord { provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }, location: { wslDistro: null }, + // Why released: a chat at rest has no live claim, so its next start reads the current account. + lease: { claimStatus: 'released' }, options } as unknown as AgentSessionRecord } diff --git a/src/main/claude/claude-structured-launch-account-routing.test.ts b/src/main/claude/claude-structured-launch-account-routing.test.ts index 031559a44387..0d7babfb8f5d 100644 --- a/src/main/claude/claude-structured-launch-account-routing.test.ts +++ b/src/main/claude/claude-structured-launch-account-routing.test.ts @@ -4,8 +4,8 @@ import { join, resolve as resolvePath } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { CLAUDE_PROFILE_SETUP_FAILED_MESSAGE } from '../../shared/claude-profile-routing' import { - CLAUDE_PROFILE_MISSING_MESSAGE, ClaudeProfileRouter, type ClaudeProfileRouterSettings } from '../claude-accounts/claude-profile-router' @@ -32,7 +32,10 @@ it('launches each acquisition under the current selection, not the account it wa getSettings: () => settings, dataRoot: root, userHome: join(root, 'personal'), - env: { CLAUDE_CONFIG_DIR: resolvePath('/user/own') } + env: { CLAUDE_CONFIG_DIR: resolvePath('/user/own') }, + runSetup: async () => { + throw new Error('setup refused') + } }) installClaudeProfileRouter(router) const record = { @@ -56,7 +59,12 @@ it('launches each acquisition under the current selection, not the account it wa providerHandle: claudeProviderHandle('unused', null) } - await expect(resolve({ identity })).rejects.toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) + // A missing folder is set up first; typed when that fails, so the chat names the situation. + await expect(resolve({ identity })).rejects.toMatchObject({ + name: 'AgentSessionPreSpawnError', + reason: 'claudeAccountSetupFailed', + message: CLAUDE_PROFILE_SETUP_FAILED_MESSAGE + }) const home = join(root, 'claude-profiles', 'a', 'home') mkdirSync(home, { recursive: true }) diff --git a/src/main/claude/claude-structured-launch-home.ts b/src/main/claude/claude-structured-launch-home.ts index 0df1b2f599c1..14afad9b5c43 100644 --- a/src/main/claude/claude-structured-launch-home.ts +++ b/src/main/claude/claude-structured-launch-home.ts @@ -32,7 +32,7 @@ export async function resolveClaudeStructuredLaunchHome( } /** Whether Claude wrote a transcript for this id under the given config folder. */ -export async function claudeTranscriptExists(input: { +async function claudeTranscriptExists(input: { providerSessionId: string claudeConfigDir: string }): Promise { diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index f6d52ddb7ebe..19067d65a4c5 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -5,9 +5,6 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' -import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { claudeStructuredAuthPolicyForSettings } from '../claude-accounts/claude-structured-auth-policy' -import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { CLAUDE_DEFAULT_SETTING_SOURCES, CLAUDE_SESSION_STATE_EVENTS_ENV, @@ -18,7 +15,6 @@ import { } from './claude-structured-launch-resolution' import { claudeStructuredPermissionModeForSettings } from './claude-structured-permission-mode' import { CLAUDE_THINKING_DISPLAY_FLAG, type ClaudeCliFlag } from './claude-cli-flag-support' -import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from '../claude-accounts/live-pty-gate' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION_ID = 'orca-session-1' @@ -80,33 +76,6 @@ function resolverFor( }) } -function managedAccount(id: string, managedAuthRuntime: 'host' | 'wsl') { - return { - id, - email: `${id}@example.com`, - managedAuthPath: `/managed/${id}`, - managedAuthRuntime, - authMethod: 'subscription-oauth' as const, - createdAt: 0, - updatedAt: 0, - lastAuthenticatedAt: 0 - } -} - -const HOST_SELECTED: ClaudeManagedAccountGateSettings = { - claudeManagedAccounts: [managedAccount('host-1', 'host')], - activeClaudeManagedAccountId: 'host-1', - activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} } -} - -/** The normalized steady state of a Windows user whose only Claude account is WSL-managed: the - * prune drops the WSL account out of the host slot and persists that. */ -const WSL_ONLY_NORMALIZED: ClaudeManagedAccountGateSettings = { - claudeManagedAccounts: [managedAccount('wsl-1', 'wsl')], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } -} - const RESUMABLE = record({ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only each link's handle, so the link's other fields stay unset. providerHandleChain: [ @@ -581,66 +550,11 @@ describe('claude structured launch resolution', () => { }) ).rejects.toThrow(/CLAUDE_CONFIG_DIR/) }) - - /** The account state can change while a session lives, and a reacquire after an unexpected child - * exit re-resolves the launch. Without the gate here, that reacquire spawns under whatever the - * account state has become. */ - describe('managed-account gate on every acquisition', () => { - function resolverWithGate(read: () => ClaudeManagedAccountGateSettings | null) { - return createClaudeStructuredLaunchResolver({ - resolveLaunchArgs: () => [], - store: { getRecord: () => RESUMABLE, pinLaunchDirectory: vi.fn() }, - resolveWorkspacePath: async (id) => `/repos/${id}`, - resolveCommand: () => '/usr/local/bin/claude', - // Derived, not a literal: the gate and the policy must read the SAME account state, so a - // hardcoded value could assert a pairing production cannot produce. - resolveAuthPolicy: () => { - const settings = read() - if (!settings) { - throw new Error('the gate refuses before the auth policy is computed') - } - return claudeStructuredAuthPolicyForSettings(settings) - }, - readManagedAccountGate: read - }) - } - - it('refuses a reacquire once the account state becomes the refused shape', async () => { - let gate: ClaudeManagedAccountGateSettings | null = HOST_SELECTED - const resolve = resolverWithGate(() => gate) - - // Created while supported: the launch resolves and would spawn. - await expect(resolve({ identity: identityAt('leaf-current') })).resolves.toMatchObject({ - providerSessionId: 'provider-current' - }) - - gate = WSL_ONLY_NORMALIZED - - // Reacquire after the account state changed: refused before anything spawns, naming the - // account shape a person can change. - const refused = resolve({ identity: identityAt('leaf-current') }) - await expect(refused).rejects.toBeInstanceOf(AgentSessionPreSpawnError) - await expect(refused).rejects.toMatchObject({ reason: 'managedAccountUnsupported' }) - }) - - it('fails closed when the account state cannot be read, naming no situation', async () => { - const refused = resolverWithGate(() => null)({ identity: identityAt('leaf-current') }) - await expect(refused).rejects.toBeInstanceOf(AgentSessionPreSpawnError) - await expect(refused).rejects.toMatchObject({ reason: undefined }) - }) - - it('keeps resolving when no gate is wired, so other embedders are unaffected', async () => { - await expect( - resolverFor(RESUMABLE)({ identity: identityAt('leaf-current') }) - ).resolves.toMatchObject({ providerSessionId: 'provider-current' }) - }) - }) }) describe('readable Claude thinking', () => { const launchWith = ( cliFlags?: ClaudeStructuredLaunchResolverDeps['cliFlags'], - authSwitchSettleTimeoutMs?: number, command = '/usr/local/bin/claude', launchArgs: string[] = [] ) => @@ -652,8 +566,7 @@ describe('readable Claude thinking', () => { resolveAuthPolicy: () => ({ stripAuthEnv: false }), resolveEnv: () => ({ PROJECT_SHIM: '1', ANTHROPIC_API_KEY: 'sk-user' }), hasTranscript: async () => false, - ...(cliFlags ? { cliFlags } : {}), - ...(authSwitchSettleTimeoutMs === undefined ? {} : { authSwitchSettleTimeoutMs }) + ...(cliFlags ? { cliFlags } : {}) })({ identity: IDENTITY }) // Whether the CLI's directory holds a `node` decides if the runtime pairing puts that directory @@ -676,7 +589,7 @@ describe('readable Claude thinking', () => { if (sibling) { makeExecutable(join(binDir, process.platform === 'win32' ? 'node.cmd' : 'node')) } - const launch = await launchWith({ supports }, undefined, command) + const launch = await launchWith({ supports }, command) const asked = supports.mock.calls[0]?.[1] expect(asked).toMatchObject({ command, cwd: '/repos/workspace-1' }) const segments = (env: Record | undefined) => @@ -708,7 +621,6 @@ describe('readable Claude thinking', () => { const launch = await launchWith( { supports: async (flag) => flag === CLAUDE_THINKING_DISPLAY_FLAG }, undefined, - undefined, ['--effort', 'high', '--thinking-display', 'omitted'] ) expect(launch.options.extraArgs).toEqual({ @@ -717,21 +629,4 @@ describe('readable Claude thinking', () => { 'thinking-display': 'summarized' }) }) - - it('still rechecks an account switch that began while the probe ran', async () => { - try { - const launch = launchWith( - { - supports: async () => { - beginClaudeAuthSwitch() - return false - } - }, - 10 - ) - await expect(launch).rejects.toMatchObject({ reason: 'accountSwitchInProgress' }) - } finally { - endClaudeAuthSwitch() - } - }) }) diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 7e52332fe54e..8914481044bd 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -14,20 +14,10 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' import { CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, - CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, hasClaudeAuthEnvConflict } from '../claude-accounts/environment' import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' -import { - CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS, - whenClaudeAuthSwitchSettles -} from '../claude-accounts/live-pty-gate' import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { - hasWslBoundClaudeAccount, - structuredClaudeMatchesActiveManagedAccount, - type ClaudeManagedAccountGateSettings -} from '../native-chat/claude-structured-managed-account-support' import { claudeChildEnv, claudeProbeEnv, @@ -141,10 +131,6 @@ export type ClaudeStructuredLaunchResolverDeps = { resolveAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy /** The user's Agent Permissions setting, re-read per acquisition. Absent means prompting. */ resolvePermissionMode?: () => Promise | PermissionMode - /** How long an in-flight account switch may hold a launch before it is refused. */ - authSwitchSettleTimeoutMs?: number - /** Account state for the managed-account gate; null when it cannot be read, which refuses. */ - readManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null /** The host's chat attachment store: files a client attached live there, outside the workspace, * and the agent reads them without asking. */ attachmentDirectory?: string @@ -173,19 +159,13 @@ export type ClaudeStructuredInvocation = { * drift there heals on the next refresh. */ export async function resolveClaudeStructuredInvocation( - deps: ClaudeEnvDeps & - Pick & { - authSwitchSettleTimeoutMs?: number - }, + deps: ClaudeEnvDeps & Pick, decorateEnv: (env: Record) => Record = (env) => env, /** Already resolved by a caller that needed them earlier; read again otherwise. */ resolvedSources?: ClaudeChildEnvSources ): Promise { const sources = resolvedSources ?? (await resolveClaudeChildEnvSources(deps)) const auth = await deps.resolveAuthPolicy() - // A switch can begin while the policy and overlay resolve, exactly as it can - // during the terminal preflight's prepareClaudeAuth — recheck after the awaits. - await assertClaudeAuthSwitchSettled(deps.authSwitchSettleTimeoutMs) // Under a managed account the pinned credential is the only auth this launch may // use, so an explicit override is refused rather than silently beating the pin. if (auth.stripAuthEnv && hasClaudeAuthEnvConflict(sources.overlay)) { @@ -200,29 +180,10 @@ export async function resolveClaudeStructuredInvocation( } } -/** - * Wait a running account switch out, and refuse only if it never settles. - * - * Launch resolution is reached from `acquireClaudeSession` *after* the old child has - * been closed and proved, so a plain refusal here would leave the user with a dead - * chat and no replacement — the very harm the acquire-entry guard exists to prevent. - * The entry guard still refuses outright, because nothing has been torn down yet. - */ -export async function assertClaudeAuthSwitchSettled( - timeoutMs = CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS -): Promise { - if (!(await whenClaudeAuthSwitchSettles(timeoutMs))) { - throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE), { - reason: 'accountSwitchInProgress' - }) - } -} - export function createClaudeStructuredLaunchResolver( deps: ClaudeStructuredLaunchResolverDeps ): (input: { identity: AgentSessionJournalIdentity }) => Promise { return async ({ identity }) => { - await assertClaudeAuthSwitchSettled(deps.authSwitchSettleTimeoutMs) const record = deps.store.getRecord(identity.sessionId) if (!record) { throw new Error(`no durable agent-session record for ${identity.sessionId}`) @@ -243,18 +204,7 @@ export function createClaudeStructuredLaunchResolver( if (accountHome.variable !== pinned) { throw new Error(`claude sessions pin ${pinned}, not ${accountHome.variable}`) } - // Every acquisition, not just the first: the account state can change under a live session, and - // a reacquire after an unexpected exit would otherwise spawn under whatever it has become. - // Codex has no gate here — it resolves its account on a different path. const router = getClaudeProfileRouter() - const gate = router ? undefined : deps.readManagedAccountGate?.() - if (gate !== undefined && !structuredClaudeMatchesActiveManagedAccount(gate)) { - // Unreadable account state names no situation a person can act on, so only the log reads it. - throw new AgentSessionPreSpawnError( - 'structured Claude is not offered under the active managed Claude account', - gate && hasWslBoundClaudeAccount(gate) ? { reason: 'managedAccountUnsupported' } : {} - ) - } // A Claude record's chain holds only Claude handles; the attach admission refuses anything else. const head = agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle ?? null if ( @@ -284,21 +234,6 @@ export function createClaudeStructuredLaunchResolver( const permission = claudeStructuredPermissionOptions( (await deps.resolvePermissionMode?.()) ?? 'default' ) - // A start that failed before its first turn wrote no transcript, and `--resume` of an absent - // one exits; launch that id fresh instead. With a transcript, `--session-id` would collide. - const leafUuid = head ? claudeProviderHandleLeafUuid(head) : null - const resumes = async (claudeConfigDir: string): Promise => - head !== null && - (await claudeLaunchResumesTranscript({ - router, - leafUuid, - providerSessionId, - claudeConfigDir, - hasTranscript: deps.hasTranscript - })) - // Why: without a router the home is fixed, so check it before the recheck that must stay last. - const resumedWithoutRouter = router ? undefined : await resumes(accountHome.path) - // Last: it rechecks the account switch, which may have begun during any await above. const { command, env, account } = await resolveClaudeStructuredInvocation( deps, (base) => @@ -317,7 +252,18 @@ export function createClaudeStructuredLaunchResolver( sources ) const launchHome = await resolveClaudeStructuredLaunchHome(router, env, accountHome.path) - const resumesTranscript = resumedWithoutRouter ?? (await resumes(launchHome)) + // A start that failed before its first turn wrote no transcript, and `--resume` of an absent + // one exits; launch that id fresh instead. With a transcript, `--session-id` would collide. + const leafUuid = head ? claudeProviderHandleLeafUuid(head) : null + const resumesTranscript = + head !== null && + (await claudeLaunchResumesTranscript({ + router, + leafUuid, + providerSessionId, + claudeConfigDir: launchHome, + hasTranscript: deps.hasTranscript + })) return { pathToClaudeCodeExecutable: command, account, diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index e13c1aa0047d..418aeba798c6 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -1,10 +1,7 @@ -import { - AgentSessionPreSpawnError, - type AgentSessionAcquisition, - type StructuredAgentSessionAcquireInput +import type { + AgentSessionAcquisition, + StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE } from '../claude-accounts/environment' -import { isClaudeAuthSwitchInProgress } from '../claude-accounts/live-pty-gate' import { openClaudeStreamJsonConnection } from './claude-stream-json-connection' import { buildClaudePermissionCallbacks } from './claude-structured-inbound-control' import { resolveClaudeReplayTurn } from './claude-replay-turn-resolution' @@ -62,13 +59,6 @@ export async function acquireClaudeSession({ exits: Map callbacks: ClaudeAcquireCallbacks }): Promise { - // A managed-account switch is mid-swap of the pinned credential home; refuse here, - // before this acquisition cancels the previous attempt and closes the live session. - if (isClaudeAuthSwitchInProgress()) { - throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE), { - reason: 'accountSwitchInProgress' - }) - } const sessionId = input.identity.sessionId const prompts = new ClaudePromptRegistry() const { previous, attempt } = acquisitions.start(sessionId, prompts) diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index ad7c1edabc83..53e3d13744f7 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBe(43) + expect(PROTOCOL_VERSION).toBe(42) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) diff --git a/src/main/daemon/daemon-init-provider-installation.test.ts b/src/main/daemon/daemon-init-provider-installation.test.ts index 423ee9ee34f2..2de5fd5be84e 100644 --- a/src/main/daemon/daemon-init-provider-installation.test.ts +++ b/src/main/daemon/daemon-init-provider-installation.test.ts @@ -125,26 +125,6 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(setLocalPtyProviderMock).not.toHaveBeenCalled() }) - it('prunes seeded Claude live-PTY ids against daemon sessions after init', async () => { - const mod = await importFresh() - // Why: live-pty-gate is intentionally unmocked — import from the same fresh registry so gate state matches daemon-init's. - const gate = await import('../claude-accounts/live-pty-gate') - defaultListSessionsSessions.push({ sessionId: 'claude-alive' }) - gate.seedLiveClaudePtysFromPersistence(['claude-alive', 'claude-dead']) - try { - await mod.initDaemonPtyProvider() - - expect(gate.hasLiveClaudePtys()).toBe(true) - - gate.markClaudePtyExited('claude-alive') - // Why: proves 'claude-dead' was released by the daemon reconcile — the surviving session held the gate alone. - expect(gate.hasLiveClaudePtys()).toBe(false) - } finally { - gate.markClaudePtyExited('claude-alive') - gate.markClaudePtyExited('claude-dead') - } - }) - it('does not install a late daemon provider after startup fallback aborts the init attempt', async () => { const mod = await importFresh() let resolveEnsureRunning!: (value: { socketPath: string; tokenPath: string }) => void diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index 540b84945493..8967d0cae513 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,8 +1,9 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -// v43 drops v42's `claude` account function (reverted #24434); v41 stages long startup commands as -// sourced scripts; v40 rolled the #25130/#24636 shell-wrapper changes and the wider agent list into -// a fresh daemon; older owners stay attachable. -export const PROTOCOL_VERSION = 43 +// v42 gives every pane the `claude` function that follows the selected Claude account; v41 stages +// long startup commands as sourced scripts; v40 rolled the #25130/#24636 shell-wrapper changes and +// the wider agent list into a fresh daemon; older owners stay attachable. +export const PROTOCOL_VERSION = 42 +export const CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION = 42 // v39 gives plain fish panes Orca's codex function through XDG_DATA_DIRS. export const CODEX_FISH_SHELL_FUNCTION_DAEMON_PROTOCOL_VERSION = 39 // Why: older daemons reject `setColorQueryReplyColors` as an unknown request type. @@ -37,7 +38,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, - 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42 + 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41 ] as const export function supportsColorQueryReplyColors(protocolVersion: number): boolean { diff --git a/src/main/daemon/daemon-provider-init.ts b/src/main/daemon/daemon-provider-init.ts index 9d9a1b5c3e11..dd2d2f951368 100644 --- a/src/main/daemon/daemon-provider-init.ts +++ b/src/main/daemon/daemon-provider-init.ts @@ -1,8 +1,4 @@ import { getLocalPtyProvider, rebindLocalProviderListeners } from '../ipc/pty' -import { - confirmSeededClaudeLivePtys, - hasSeededUnconfirmedClaudePtys -} from '../claude-accounts/live-pty-gate' import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from '../startup/startup-diagnostics' import { checkDaemonHealth } from './daemon-health' import { collectPinnedDaemonVersions, pruneOldDaemonHosts } from './daemon-host-relocation' @@ -163,7 +159,6 @@ export async function initDaemonPtyProvider( if (process.platform === 'darwin' && newSpawner.getHandle()?.adopted) { void reportDaemonAdoption(runtimeDir, info.socketPath, info.tokenPath, newAdapter) } - await reconcileSeededClaudeLivePtys(routedAdapter) } // Why off the init path: this is measurement of an adopted daemon (#17696), and neither its probes nor their failure may delay or fail startup. @@ -190,29 +185,3 @@ async function reportDaemonAdoption( // Best-effort measurement only. } } - -// Why: release gate ids only for daemon-confirmed-dead sessions; keep seeds on listing failure since releasing early can rotate a live CLI's refresh token. -async function reconcileSeededClaudeLivePtys(provider: DaemonProvider): Promise { - if (!hasSeededUnconfirmedClaudePtys()) { - return - } - try { - const adapters = - provider instanceof DaemonPtyRouter || provider instanceof DegradedDaemonPtyProvider - ? provider.getAllAdapters() - : [provider] - const results = await Promise.allSettled(adapters.map((entry) => entry.listSessions())) - if (results.some((result) => result.status === 'rejected')) { - console.warn('[daemon] Keeping seeded Claude live-PTY gate — session listing failed') - return - } - confirmSeededClaudeLivePtys( - results.flatMap((result) => - result.status === 'fulfilled' ? result.value.map((session) => session.sessionId) : [] - ) - ) - } catch (error) { - // Why: gate bookkeeping must never fail daemon init; stale seeds only defer a usage refresh until next restart. - console.warn('[daemon] Failed to reconcile seeded Claude live-PTY gate:', error) - } -} diff --git a/src/main/daemon/daemon-provider-state.ts b/src/main/daemon/daemon-provider-state.ts index 30eff6691f99..7599ee9cd03c 100644 --- a/src/main/daemon/daemon-provider-state.ts +++ b/src/main/daemon/daemon-provider-state.ts @@ -3,7 +3,7 @@ import { setLocalPtyProvider } from '../ipc/pty' import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' import { getDaemonRuntimeDir as getRuntimeDir } from './daemon-launch-paths' import { parseDaemonPidFile, type ParsedDaemonPid } from './daemon-pid-file-parse' -import type { DaemonProvider } from './daemon-provider-routing' +import { getLegacyDaemonAdapters, type DaemonProvider } from './daemon-provider-routing' import { DaemonPtyRouter } from './daemon-pty-router' import { getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath } from './daemon-spawner' import type { DaemonSpawner } from './daemon-spawner' @@ -88,6 +88,18 @@ export function getDaemonProvider(): DaemonProvider | null { return adapter } +/** True for a terminal on a daemon kept alive from before `protocolVersion`; never for an SSH pane. */ +export function isTerminalFromBeforeDaemonProtocol( + ptyId: string, + protocolVersion: number +): boolean { + return adapter + ? getLegacyDaemonAdapters(adapter).some( + (legacy) => legacy.protocolVersion < protocolVersion && legacy.hasPty(ptyId) + ) + : false +} + // Why: computed from the pid record on demand (not cached at adoption) so the Settings // remedy surface always reflects the daemon actually serving terminals right now. export async function getCurrentDaemonMacTccAttributionHealth(): Promise { diff --git a/src/main/daemon/pty-subprocess/shell-launch-plan.ts b/src/main/daemon/pty-subprocess/shell-launch-plan.ts index e29fff5330ab..e3c1638ff1fe 100644 --- a/src/main/daemon/pty-subprocess/shell-launch-plan.ts +++ b/src/main/daemon/pty-subprocess/shell-launch-plan.ts @@ -34,6 +34,10 @@ import { type RecognizedAgentProcess } from '../../../shared/agent-process-recognition' import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../../shared/hermes-startup-query' +import { + CLAUDE_INJECTED_CONFIG_DIR_ENV, + CLAUDE_PROFILE_POINTER_ENV +} from '../../../shared/claude-profile-routing' import { WINDOWS_GIT_BASH_SHELL } from '../../../shared/windows-terminal-shell' import { getShellLaunchConfig, resolvePtyShellPath } from '../shell-ready' import { resolveWslSessionContext } from '../wsl-session-context' @@ -169,8 +173,8 @@ export function createPtyShellLaunchPlan( if (env.CLAUDE_CONFIG_DIR) { addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) } - if (env.ORCA_CLAUDE_PROFILE_POINTER) { - addWslEnvKeys(env, ['ORCA_CLAUDE_PROFILE_POINTER', 'ORCA_CLAUDE_INJECTED_CONFIG_DIR']) + if (env[CLAUDE_PROFILE_POINTER_ENV]) { + addWslEnvKeys(env, [CLAUDE_PROFILE_POINTER_ENV, CLAUDE_INJECTED_CONFIG_DIR_ENV]) } if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) { addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV]) diff --git a/src/main/ipc/claude-accounts.ts b/src/main/ipc/claude-accounts.ts index f88f094e46b3..181164a46468 100644 --- a/src/main/ipc/claude-accounts.ts +++ b/src/main/ipc/claude-accounts.ts @@ -1,10 +1,10 @@ import { ipcMain } from 'electron' -import type { ClaudeAccountAddTarget, ClaudeAccountService } from '../claude-accounts/service' +import type { ClaudeAccountService } from '../claude-accounts/service' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' export function registerClaudeAccountHandlers(claudeAccounts: ClaudeAccountService): void { ipcMain.handle('claudeAccounts:list', () => claudeAccounts.listAccounts()) - ipcMain.handle('claudeAccounts:add', (_event, args?: ClaudeAccountAddTarget) => + ipcMain.handle('claudeAccounts:add', (_event, args?: ClaudeAccountSelectionTarget) => claudeAccounts.addAccount(args) ) ipcMain.handle('claudeAccounts:cancelPendingLogin', () => claudeAccounts.cancelPendingLogin()) @@ -16,11 +16,9 @@ export function registerClaudeAccountHandlers(claudeAccounts: ClaudeAccountServi ) ipcMain.handle( 'claudeAccounts:select', - (_event, args: { accountId: string | null } & ClaudeAccountSelectionTarget) => { - if (!args.runtime) { - return claudeAccounts.selectAccount(args.accountId) - } - return claudeAccounts.selectAccountForTarget(args.accountId, args) - } + (_event, args: { accountId: string | null } & ClaudeAccountSelectionTarget) => + args.runtime + ? claudeAccounts.selectAccountForTarget(args.accountId, args) + : claudeAccounts.selectAccount(args.accountId) ) } diff --git a/src/main/ipc/pty-listener-teardown-and-orphans.test.ts b/src/main/ipc/pty-listener-teardown-and-orphans.test.ts index 7e4178f35d3e..470582f35615 100644 --- a/src/main/ipc/pty-listener-teardown-and-orphans.test.ts +++ b/src/main/ipc/pty-listener-teardown-and-orphans.test.ts @@ -8,7 +8,6 @@ import { } from './pty-ipc-mock-registry' import { posixOnlyIt, makeDisposable } from './pty-ipc-test-constants' import { setupPtyIpcSuite } from './pty-ipc-test-harness' -import * as livePtyGate from '../claude-accounts/live-pty-gate' import { registerPtyHandlers, setLocalPtyProvider, getLocalPtyProvider } from './pty' import { join } from 'node:path' // Why resolved rather than hardcoded: the wrapper tree is content-addressed. @@ -367,7 +366,6 @@ describe('registerPtyHandlers', () => { } spawnMock.mockReturnValue(proc) const isRecoveryReloadInFlight = vi.fn(() => true) - const markClaudePtyExitedSpy = vi.spyOn(livePtyGate, 'markClaudePtyExited') registerPtyHandlers( mainWindow as never, @@ -396,11 +394,8 @@ describe('registerPtyHandlers', () => { expect(killSpy).not.toHaveBeenCalled() expect(runtime.onPtyExit).not.toHaveBeenCalled() - expect(markClaudePtyExitedSpy).not.toHaveBeenCalled() const listed = await getLocalPtyProvider().listProcesses() expect(listed.some((info) => info.id === spawnResult.id)).toBe(true) - - markClaudePtyExitedSpy.mockRestore() }) // Why: guard against over-suppression — with no recovery reload in flight the sweep MUST still reclaim genuinely orphaned local PTYs. it('still sweeps orphaned local PTYs when no recovery reload is in flight', async () => { diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index d530a9cd1947..36c02a0f2346 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -1,14 +1,13 @@ import { withFreshOmpLaunch } from '../../shared/omp-fresh-launch' import { describe, expect, it, vi } from 'vitest' import { piBuildPtyEnvMock, spawnMock } from './pty-ipc-mock-registry' -import { TEST_CODEX_HOME, makeDisposable } from './pty-ipc-test-constants' +import { TEST_CODEX_HOME } from './pty-ipc-test-constants' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { LocalPtyProvider } from '../providers/local-pty-provider' import { __resetPersistedWindowsPathCacheForTests } from '../pty/windows-environment-path' import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader' -import { hasLiveClaudePtys, markClaudePtySpawned } from '../claude-accounts/live-pty-gate' import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' -import { registerPtyHandlers, buildPtyHostEnv, clearProviderPtyState } from './pty' +import { registerPtyHandlers, buildPtyHostEnv } from './pty' import { buildJcodeRuntimeDir, shouldInjectJcodeRuntimeDir } from '../../shared/jcode-runtime-dir' import { makePaneKey } from '../../shared/stable-pane-id' import { selectShellStartupFeatures } from '../shell-startup-features' @@ -320,49 +319,6 @@ describe('registerPtyHandlers', () => { id: expect.any(String) }) }) - it('marks local Claude launches live until the PTY is killed', async () => { - let exitCb: ((info: { exitCode: number }) => void) | undefined - spawnMock.mockReturnValue({ - onData: vi.fn(() => makeDisposable()), - onExit: vi.fn((cb: (info: { exitCode: number }) => void) => { - exitCb = cb - return makeDisposable() - }), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(() => exitCb?.({ exitCode: -1 })), - process: 'zsh', - pid: 12345 - }) - const prepareClaudeAuth = vi.fn(async () => ({ - configDir: '/tmp/claude', - envPatch: {}, - stripAuthEnv: false, - provenance: 'managed:account-1' - })) - registerPtyHandlers(mainWindow as never, undefined, undefined, undefined, prepareClaudeAuth) - - const spawnResult = (await handlers.get('pty:spawn')!(null, { - cols: 80, - rows: 24, - command: 'claude' - })) as { id: string } - - expect(prepareClaudeAuth).toHaveBeenCalledTimes(1) - expect(hasLiveClaudePtys()).toBe(true) - - await handlers.get('pty:kill')!(null, { id: spawnResult.id }) - - expect(hasLiveClaudePtys()).toBe(false) - }) - it('clears Claude live-PTY tracking from shared provider teardown', () => { - markClaudePtySpawned('ssh-claude-pty') - expect(hasLiveClaudePtys()).toBe(true) - - clearProviderPtyState('ssh-claude-pty') - - expect(hasLiveClaudePtys()).toBe(false) - }) it('defaults LANG to en_US.UTF-8 when not inherited from process.env', async () => { const env = await spawnAndGetEnv(undefined, { LANG: undefined }) expect(env.LANG).toBe('en_US.UTF-8') diff --git a/src/main/ipc/pty/ipc/claude-old-terminal.test.ts b/src/main/ipc/pty/ipc/claude-old-terminal.test.ts new file mode 100644 index 000000000000..6d8a3e16e00a --- /dev/null +++ b/src/main/ipc/pty/ipc/claude-old-terminal.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, expect, it, vi } from 'vitest' + +type Handler = (event: unknown, args: { id: unknown }) => Promise + +const mocks = vi.hoisted(() => ({ + handlers: new Map(), + olderPtyIds: new Set(), + startup: vi.fn<() => Promise | undefined>() +})) +vi.mock('../../pty-host-bindings', () => ({ + getPtyIpc: () => ({ + handle: (channel: string, handler: Handler) => mocks.handlers.set(channel, handler) + }) +})) +vi.mock('../../../daemon/daemon-provider-state', () => ({ + isTerminalFromBeforeDaemonProtocol: (id: string, protocolVersion: number) => + protocolVersion === 42 && mocks.olderPtyIds.has(id) +})) + +import { installPtyClaudeOldTerminalIpcHandler } from './claude-old-terminal' + +beforeEach(() => { + mocks.handlers.clear() + mocks.olderPtyIds = new Set(['old-1']) + installPtyClaudeOldTerminalIpcHandler({ getLocalPtyProviderStartupPromise: mocks.startup }) +}) + +it('answers true only for a pane owned by a daemon from before per-account Claude folders', async () => { + const ask = mocks.handlers.get('pty:openedBeforeClaudeAccounts')! + expect(await ask({}, { id: 'old-1' })).toBe(true) + // A pane on this build's daemon, or an SSH pane, which no local daemon owns. + expect(await ask({}, { id: 'new-1' })).toBe(false) + expect(await ask({}, { id: 'ssh:conn-1:pty-1' })).toBe(false) + expect(await ask({}, { id: 42 })).toBe(false) + expect(mocks.startup).toHaveBeenCalled() +}) diff --git a/src/main/ipc/pty/ipc/claude-old-terminal.ts b/src/main/ipc/pty/ipc/claude-old-terminal.ts new file mode 100644 index 000000000000..385800241cbd --- /dev/null +++ b/src/main/ipc/pty/ipc/claude-old-terminal.ts @@ -0,0 +1,23 @@ +import { getPtyIpc } from '../../pty-host-bindings' +import { CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION } from '../../../daemon/daemon-protocol-version' +import { isTerminalFromBeforeDaemonProtocol } from '../../../daemon/daemon-provider-state' + +type Deps = { getLocalPtyProviderStartupPromise: () => Promise | undefined } + +/** Whether a pane's daemon predates the claude function that follows the selected account. */ +export function installPtyClaudeOldTerminalIpcHandler(deps: Deps): void { + getPtyIpc().handle( + 'pty:openedBeforeClaudeAccounts', + async (_event, args: { id: string }): Promise => { + if (typeof args?.id !== 'string') { + return false + } + // Why: the pre-swap provider does not own restored daemon ids. + await deps.getLocalPtyProviderStartupPromise() + return isTerminalFromBeforeDaemonProtocol( + args.id, + CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION + ) + } + ) +} diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 24df59c1201c..ffcc5d158bb3 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -1,6 +1,5 @@ import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { agentHookServer } from '../../../agent-hooks/server' -import { markClaudePtySpawned } from '../../../claude-accounts/live-pty-gate' import { registerPty } from '../../../memory/pty-registry' import type { PtySpawnResult } from '../../../providers/types' import { clearMigrationUnsupportedPtysForPaneKey } from '../../../agent-hooks/migration-unsupported-pty-state' @@ -125,9 +124,6 @@ async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args - if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) - } if (ctx.claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) } diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index b6a50c43bbbf..4b8d71c04417 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -4,8 +4,6 @@ import { resolveLocalWindowsTerminalRuntimeOptions } from '../../../../shared/local-windows-terminal-runtime' import { isWslUncPath, toWindowsWslPath } from '../../../../shared/wsl-paths' -import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate' -import { CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE } from '../../../claude-accounts/environment' import { mintPtySessionId } from '../../../daemon/pty-session-id' import { resolveWslSessionContext } from '../../../daemon/wsl-session-context' import { LocalPtyProvider } from '../../../providers/local-pty-provider' @@ -194,9 +192,6 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis } ctx.isClaudeLaunch = !ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) - if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) - } ctx.terminalRuntimeOptions = process.platform === 'win32' && !args.connectionId ? resolveLocalWindowsTerminalRuntimeOptions({ diff --git a/src/main/ipc/pty/provider/bind-listeners.ts b/src/main/ipc/pty/provider/bind-listeners.ts index 31bacec82f7f..ec13ad0d36e2 100644 --- a/src/main/ipc/pty/provider/bind-listeners.ts +++ b/src/main/ipc/pty/provider/bind-listeners.ts @@ -1,4 +1,3 @@ -import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' import { LocalPtyProvider } from '../../../providers/local-pty-provider' import { isCurrentPtyExit, ptyOwnership } from './ownership-state' import { @@ -92,7 +91,6 @@ export function bindProviderListeners(session: PtyIpcSession): void { if (!isLocalProvider) { clearProviderPtyState(payload.id) ptyOwnership.delete(payload.id) - markClaudePtyExited(payload.id) if (syntheticExit) { session.runtime?.markPtyStopRequested(payload.id) } diff --git a/src/main/ipc/pty/provider/liveness.ts b/src/main/ipc/pty/provider/liveness.ts index 8a362b74aaf2..5c4265bdc6ea 100644 --- a/src/main/ipc/pty/provider/liveness.ts +++ b/src/main/ipc/pty/provider/liveness.ts @@ -10,7 +10,6 @@ import { isSshPtyProvenExitedOnRelayError } from '../../../providers/ssh-pty-errors' import type { IPtyProvider } from '../../../providers/types' -import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' import { ptyIncarnationById, ptyOwnership } from './ownership-state' import { getRelayPtyId } from './registry' import { @@ -179,6 +178,5 @@ export function finishPtyShutdown( store?.markSshRemotePtyLease(connectionId, getRelayPtyId(connectionId, id), 'terminated') } ptyOwnership.delete(id) - markClaudePtyExited(id) return incarnationId } diff --git a/src/main/ipc/pty/provider/local-configure.ts b/src/main/ipc/pty/provider/local-configure.ts index 7a494522ec01..eeb5fef028c5 100644 --- a/src/main/ipc/pty/provider/local-configure.ts +++ b/src/main/ipc/pty/provider/local-configure.ts @@ -10,7 +10,6 @@ import { stampWslOrchestrationCompatibilityHost } from '../../../pty/wsl-orca-env' import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' -import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' import { buildPtyHostEnv } from '../host-env/assembly' import { getCompatibleSelectedCodexHomePath, @@ -126,7 +125,6 @@ export function configureLocalPtyProvider(args: { } clearProviderPtyState(id) ptyOwnership.delete(id) - markClaudePtyExited(id) runtime?.onPtyExit(id, code, incarnationId, { providerExitObserved: true, ...(cause ? { cause } : {}) diff --git a/src/main/ipc/pty/provider/state-cleanup.ts b/src/main/ipc/pty/provider/state-cleanup.ts index f70b07dbff85..aa99d191abc0 100644 --- a/src/main/ipc/pty/provider/state-cleanup.ts +++ b/src/main/ipc/pty/provider/state-cleanup.ts @@ -1,6 +1,5 @@ import { advertisedUrlWatcher } from '../../../ports/advertised-url-watcher' import { unregisterPty } from '../../../memory/pty-registry' -import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' import { forgetCodexPaneAccount } from '../../../codex/codex-pane-account-registry' import { openCodeHookService } from '../../../opencode/hook-service' import { piTitlebarExtensionService } from '../../../pi/titlebar-extension-service' @@ -49,8 +48,6 @@ export function clearProviderPtyState( // new teardown path forgets to remove one provider's overlay/hook state. openCodeHookService.clearPty(id) piTitlebarExtensionService.clearPty(id) - // Why: SSH exit/teardown paths bypass pty.ts's local onExit but still must release Claude account-switch guards. - markClaudePtyExited(id) ptySizes.delete(id) ptyIncarnationById.delete(id) lastInputAtByPty.delete(id) diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts index 7f29e895e0bc..5e823ac535ca 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -13,6 +13,7 @@ import { finishPtyShutdown } from './provider/liveness' import type { GetSelectedCodexHomePath, PrepareClaudeAuth } from './host-env/types' import { installPtyInspectIpcHandlers } from './ipc/inspect' import { installPtyCodexSharedServerIpcHandler } from './ipc/codex-shared-server' +import { installPtyClaudeOldTerminalIpcHandler } from './ipc/claude-old-terminal' import { installPtyKillIpcHandler, stopReplacedPanePty, @@ -124,6 +125,7 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:isCodexOnSharedServer') ipcMain.removeHandler('pty:disableCodexSharedServerAutoStart') ipcMain.removeHandler('pty:stopCodexSharedServer') + ipcMain.removeHandler('pty:openedBeforeClaudeAccounts') ipcMain.removeHandler('pty:getCwd') ipcMain.removeHandler('pty:getSize') ipcMain.removeHandler('pty:getAuthoritativeBufferSnapshotCapabilities') @@ -286,5 +288,6 @@ export function registerPtyHandlers( installPtyResizeVisibilityIpc(session) installPtyInspectIpcHandlers({ getLocalPtyProviderStartupPromise }) installPtyCodexSharedServerIpcHandler({ getLocalPtyProviderStartupPromise }) + installPtyClaudeOldTerminalIpcHandler({ getLocalPtyProviderStartupPromise }) installPtyKillIpcHandler(killDeps) } diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index c38637371c36..e6e301aa8afa 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -7,7 +7,6 @@ import { recordCodexPaneAccountForSpawn, codexReattachedHomeRouteField } from '../host-env/codex-home' -import { markClaudePtySpawned } from '../../../claude-accounts/live-pty-gate' import { registerPty } from '../../../memory/pty-registry' import { rememberPaneKeyForPty } from '../pane/key-state' import { @@ -240,9 +239,6 @@ async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { if (!ctx.stablePaneOwner) { ctx.deps.runtime?.noteTerminalSpawnCommand?.(ctx.result.id, ctx.launchCommand ?? null) } - if (ctx.isClaudeLaunch && !ctx.stablePaneOwner) { - markClaudePtySpawned(ctx.result.id) - } if (args.telemetry && !ctx.stablePaneOwner) { recordPtySpawnTelemetry(args.telemetry) } diff --git a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts index cfbace932ab8..88db8879630e 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts @@ -96,6 +96,7 @@ describe('runtime pty spawn preflight: Claude account routing in a WSL pane', () new ClaudeProfileRouter({ getSettings: () => getDefaultSettings('/tmp'), dataRoot: '/data/orca', + env: {}, runSetup }) ) diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index c82b0656a377..729cf74af786 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -23,10 +23,8 @@ import { } from '../host-env/fresh-spawn-routing' import { stripRemotePaneEnvWhenHooksDisabled } from '../provider/liveness' import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate' import { CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, - CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, hasClaudeAuthEnvConflict } from '../../../claude-accounts/environment' import { @@ -70,9 +68,6 @@ export async function prepareRuntimePtySpawn( } ctx.isClaudeLaunch = !ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) - if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) - } // Why: runtime-created terminals carry no renderer-computed projectRuntime; resolve from worktreeId to honor the project's Windows runtime. // `args.shellOverride` is the per-request pick (`terminal create --shell`), read here the way // the renderer twin (ipc/spawn-preflight.ts) reads a tab's override. Without it a runtime create @@ -151,9 +146,6 @@ export async function prepareRuntimePtySpawn( ctx.isClaudeLaunch && ctx.deps.prepareClaudeAuth ? await ctx.deps.prepareClaudeAuth(ctx.codexSelectionTarget) : null - if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) - } if (ctx.claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) } diff --git a/src/main/macos-keychain/generic-password.test.ts b/src/main/macos-keychain/generic-password.test.ts index 38ec1b05a723..604fd8b00bd5 100644 --- a/src/main/macos-keychain/generic-password.test.ts +++ b/src/main/macos-keychain/generic-password.test.ts @@ -4,12 +4,7 @@ const execFileMock = vi.hoisted(() => vi.fn()) vi.mock('node:child_process', () => ({ execFile: execFileMock })) -import { - deleteKeychainPassword, - isKeychainNotFoundError, - readKeychainPassword, - writeKeychainPassword -} from './generic-password' +import { isKeychainNotFoundError, readKeychainPassword } from './generic-password' const originalPlatform = process.platform @@ -71,27 +66,10 @@ describe('readKeychainPassword', () => { it('never shells out off macOS', async () => { setPlatform('win32') await expect(readKeychainPassword('svc', 'acct')).resolves.toBeNull() - await writeKeychainPassword('svc', 'acct', 'value') - await deleteKeychainPassword('svc', 'acct') expect(execFileMock).not.toHaveBeenCalled() }) }) -describe('deleteKeychainPassword', () => { - it('swallows a missing item', async () => { - fail(Object.assign(new Error('could not be found'), { code: 44 })) - await expect(deleteKeychainPassword('svc', 'acct')).resolves.toBeUndefined() - }) - - it('surfaces an access failure only when the caller asks for it', async () => { - fail(new Error('User interaction is not allowed')) - await expect(deleteKeychainPassword('svc', 'acct')).resolves.toBeUndefined() - await expect( - deleteKeychainPassword('svc', 'acct', { failOnAccessError: true }) - ).rejects.toThrow('User interaction is not allowed') - }) -}) - describe('isKeychainNotFoundError', () => { it('recognizes the security(1) not-found signals', () => { expect(isKeychainNotFoundError({ code: 44 })).toBe(true) diff --git a/src/main/macos-keychain/generic-password.ts b/src/main/macos-keychain/generic-password.ts index 8a805edd809b..1616008f303b 100644 --- a/src/main/macos-keychain/generic-password.ts +++ b/src/main/macos-keychain/generic-password.ts @@ -79,20 +79,6 @@ export function execSecurityCommand(args: string[]): Promise { - return execSecurityCommand(args).then(undefined, (error: unknown) => { - if (options?.ignoreNotFound && isKeychainNotFoundError(error)) { - return - } - if (!options?.ignoreFailure) { - throw error - } - }) -} - export async function readKeychainPassword( service: string, account: string @@ -120,28 +106,3 @@ export async function readKeychainPassword( throw error } } - -export async function writeKeychainPassword( - service: string, - account: string, - contents: string -): Promise { - if (process.platform !== 'darwin') { - return - } - await execSecurity(['add-generic-password', '-U', '-s', service, '-a', account, '-w', contents]) -} - -export async function deleteKeychainPassword( - service: string, - account: string, - options?: { failOnAccessError?: boolean } -): Promise { - if (process.platform !== 'darwin') { - return - } - await execSecurity(['delete-generic-password', '-s', service, '-a', account], { - ignoreNotFound: true, - ignoreFailure: !options?.failOnAccessError - }) -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 3ead999cfbda..52d530c65ec0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -136,6 +136,8 @@ export type AgentSessionPreSpawnReason = Extract< | 'managedAccountUnsupported' | 'launchFolderMissing' | 'historyInOtherAccount' + | 'claudeAccountFolderMissing' + | 'claudeAccountSetupFailed' | 'agentCommandNotRunnable' > diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index fde97b1b09a9..5c66814475a4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -33,6 +33,8 @@ const TYPED_START_REFUSALS = [ 'managedAccountUnsupported', 'launchFolderMissing', 'historyInOtherAccount', + 'claudeAccountFolderMissing', + 'claudeAccountSetupFailed', 'agentCommandNotRunnable' ] as const satisfies readonly (AgentSessionFailureKind & AgentSessionRefusalReason<'agent_session_operation_invalid'>)[] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts index a0f996c80b12..0a04d6cddfa5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -2,6 +2,10 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import { + CLAUDE_PROFILE_MISSING_MESSAGE, + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE +} from '../../../shared/claude-profile-routing' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { mapRuntimeError } from '../../runtime/rpc/errors' @@ -169,6 +173,18 @@ describe('a create that fails before any process spawns', () => { 'historyInOtherAccount', "This chat's history is in another Claude account. Switch back to that account to continue it." ], + [ + 'a selected Claude account whose folder is gone', + CLAUDE_PROFILE_MISSING_MESSAGE, + 'claudeAccountFolderMissing', + CLAUDE_PROFILE_MISSING_MESSAGE + ], + [ + 'a selected Claude account that could not be set up', + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, + 'claudeAccountSetupFailed', + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE + ], [ 'a Command setting that names no runnable program', 'the claude Command setting is not a runnable program', diff --git a/src/main/native-chat/claude-structured-managed-account-support.test.ts b/src/main/native-chat/claude-structured-managed-account-support.test.ts deleted file mode 100644 index 8d6aa55783f4..000000000000 --- a/src/main/native-chat/claude-structured-managed-account-support.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getSelectedClaudeAccountIdForTarget } from '../claude-accounts/runtime-selection' -import { - structuredClaudeMatchesActiveManagedAccount, - type ClaudeManagedAccountGateSettings -} from './claude-structured-managed-account-support' - -function account(id: string, managedAuthRuntime: 'host' | 'wsl') { - return { - id, - email: `${id}@example.com`, - managedAuthPath: `/managed/${id}`, - managedAuthRuntime, - authMethod: 'subscription-oauth' as const, - createdAt: 0, - updatedAt: 0, - lastAuthenticatedAt: 0 - } -} - -function settings( - overrides: Partial -): ClaudeManagedAccountGateSettings { - return { claudeManagedAccounts: [], activeClaudeManagedAccountId: null, ...overrides } -} - -describe('structuredClaudeMatchesActiveManagedAccount', () => { - it('allows an unmanaged install, where nothing claims an identity', () => { - expect(structuredClaudeMatchesActiveManagedAccount(settings({}))).toBe(true) - }) - - it('allows a selected host account, which the runtime syncs into the ambient config', () => { - expect( - structuredClaudeMatchesActiveManagedAccount( - settings({ - claudeManagedAccounts: [account('host-1', 'host')], - activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} } - }) - ) - ).toBe(true) - }) - - it('refuses a WSL-only managed account, which never reaches the ambient config', () => { - expect( - structuredClaudeMatchesActiveManagedAccount( - settings({ - claudeManagedAccounts: [account('wsl-1', 'wsl')], - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } - }) - ) - ).toBe(false) - }) - - it('refuses when a host selection names an account that is WSL-bound or missing', () => { - expect( - structuredClaudeMatchesActiveManagedAccount( - settings({ - claudeManagedAccounts: [account('wsl-1', 'wsl')], - activeClaudeManagedAccountIdsByRuntime: { host: 'wsl-1', wsl: {} } - }) - ) - ).toBe(false) - expect( - structuredClaudeMatchesActiveManagedAccount( - settings({ - claudeManagedAccounts: [account('host-1', 'host')], - activeClaudeManagedAccountIdsByRuntime: { host: 'gone', wsl: {} } - }) - ) - ).toBe(false) - }) - - /** Absent and empty are the same answer: this user has no managed Claude accounts, so nothing - * claims an identity and the ambient path is legitimate. Only settings that cannot be READ are - * unknown. Treating a missing key as unknown strands profiles that simply never wrote it — the - * auth policy's own predicate takes `(accounts ?? [])` for exactly this reason. */ - it('treats an absent account list the same as an empty one', () => { - expect( - structuredClaudeMatchesActiveManagedAccount(settings({ claudeManagedAccounts: [] })) - ).toBe(true) - expect( - structuredClaudeMatchesActiveManagedAccount({ - activeClaudeManagedAccountId: null - } as unknown as ClaudeManagedAccountGateSettings) - ).toBe(true) - }) - - it('fails closed when the settings cannot be read at all', () => { - expect(structuredClaudeMatchesActiveManagedAccount(null)).toBe(false) - expect(structuredClaudeMatchesActiveManagedAccount(undefined)).toBe(false) - }) - - /** THE discriminator, and the whole of this rule. With nothing selected for the host runtime the - * settings alone cannot distinguish honest deselection from the WSL-only steady state, because - * `pruneInvalidClaudeRuntimeSelection` empties the host slot in the second case and persists it. - * So the presence of ANY WSL-bound account decides. Simplifying this to "none active -> - * supported" re-opens the auth-identity misrepresentation this gate exists to prevent. */ - it('splits none-active on whether a WSL-bound account exists at all', () => { - const noneActive = (accounts: ReturnType[]) => - structuredClaudeMatchesActiveManagedAccount( - settings({ - claudeManagedAccounts: accounts, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } - }) - ) - - expect(noneActive([account('host-1', 'host')])).toBe(true) - expect(noneActive([account('host-1', 'host'), account('host-2', 'host')])).toBe(true) - expect(noneActive([account('wsl-1', 'wsl')])).toBe(false) - // Mixed list still refuses: the WSL account is present and nothing is selected. - expect(noneActive([account('host-1', 'host'), account('wsl-1', 'wsl')])).toBe(false) - }) - - /** The gate and the auth policy must resolve the SAME account. A legacy settings blob carries the - * selection only in the flat `activeClaudeManagedAccountId`, which is where the accessor's - * fall-through lives — reading the runtime map directly silently disagrees with the policy. */ - it('resolves the same account as the auth policy on a legacy flat selection', () => { - const legacy = settings({ - claudeManagedAccounts: [account('host-1', 'host')], - activeClaudeManagedAccountId: 'host-1' - }) - - expect(getSelectedClaudeAccountIdForTarget(legacy, { runtime: 'host' })).toBe('host-1') - expect(structuredClaudeMatchesActiveManagedAccount(legacy)).toBe(true) - }) - - it('agrees with the auth policy that a legacy flat WSL selection is refused', () => { - const legacy = settings({ - claudeManagedAccounts: [account('wsl-1', 'wsl')], - activeClaudeManagedAccountId: 'wsl-1' - }) - - expect(getSelectedClaudeAccountIdForTarget(legacy, { runtime: 'host' })).toBe('wsl-1') - expect(structuredClaudeMatchesActiveManagedAccount(legacy)).toBe(false) - }) -}) diff --git a/src/main/native-chat/claude-structured-managed-account-support.ts b/src/main/native-chat/claude-structured-managed-account-support.ts deleted file mode 100644 index 3b42c44c443f..000000000000 --- a/src/main/native-chat/claude-structured-managed-account-support.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { GlobalSettings } from '../../shared/global-settings-types' -import { getSelectedClaudeAccountIdForTarget } from '../claude-accounts/runtime-selection' - -export type ClaudeManagedAccountGateSettings = Pick< - GlobalSettings, - | 'claudeManagedAccounts' - | 'activeClaudeManagedAccountId' - | 'activeClaudeManagedAccountIdsByRuntime' -> - -/** - * A structured Claude session launches against the ambient Claude config, which the account service - * keeps in sync with the selected HOST account. A WSL-bound managed account lives inside the distro - * and is never synced there, so such a session would authenticate as whatever the ambient identity - * happens to be while the UI names the WSL account — the user is told one identity and given - * another. Refuse the structured path there and let the terminal-backed one, which resolves the - * account per runtime, handle that account shape. - * - * Reads the selection through the same accessor the auth policy uses. Resolving it any other way - * lets the two disagree, and a session admitted by this gate would then run under a policy computed - * from a different account than the one approved here. - * - * Unknown answers refuse, and only genuinely unknown ones: settings that cannot be read at all, or - * an active selection this cannot resolve. An install with no managed accounts — the list empty or - * never written — claims no identity and is fine. - */ -export function structuredClaudeMatchesActiveManagedAccount( - settings: ClaudeManagedAccountGateSettings | null | undefined -): boolean { - if (!settings) { - return false - } - // Absent is the same answer as empty — this user has no managed Claude accounts, so nothing - // claims an identity and ambient auth is the truth. Only settings that cannot be READ are - // unknown, and those refuse above. The auth policy reads the list the same way. - const accounts = settings.claudeManagedAccounts ?? [] - if (accounts.length === 0) { - return true - } - const activeHostId = getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) - if (!activeHostId) { - // Nothing selected for the host runtime is two different states that the settings cannot tell - // apart after the fact: honest deselection, where ambient auth is the truth and the UI names no - // identity, and the WSL-only case, where the prune emptied the host slot and persisted null - // while the UI still names the WSL account. The presence of any WSL-bound account decides. - return !hasWslBoundClaudeAccount(settings) - } - const active = accounts.find((candidate) => candidate.id === activeHostId) - return active ? active.managedAuthRuntime !== 'wsl' : false -} - -/** Whether any managed Claude account lives in WSL, the account shape the gate refuses over. */ -export function hasWslBoundClaudeAccount(settings: ClaudeManagedAccountGateSettings): boolean { - return (settings.claudeManagedAccounts ?? []).some( - (candidate) => candidate.managedAuthRuntime === 'wsl' - ) -} - -/** Reads the gate's settings, answering null when they cannot be read so callers refuse. */ -export function readClaudeManagedAccountGateSettings( - getSettings: () => ClaudeManagedAccountGateSettings -): ClaudeManagedAccountGateSettings | null { - try { - return getSettings() - } catch { - return null - } -} diff --git a/src/main/native-chat/structured-agent-session-create-support.test.ts b/src/main/native-chat/structured-agent-session-create-support.test.ts index 61dfd787920b..a0b61c5ceadc 100644 --- a/src/main/native-chat/structured-agent-session-create-support.test.ts +++ b/src/main/native-chat/structured-agent-session-create-support.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' -import type { ClaudeManagedAccountGateSettings } from './claude-structured-managed-account-support' import { resolveStructuredAgentSessionCreateSupport } from './structured-agent-session-create-support' const LOCAL: AgentSessionExecutionLocation = { @@ -10,64 +9,11 @@ const LOCAL: AgentSessionExecutionLocation = { workspaceKind: 'git-worktree' } -function managedAccount(id: string, managedAuthRuntime: 'host' | 'wsl') { - return { - id, - email: `${id}@example.com`, - managedAuthPath: `/managed/${id}`, - managedAuthRuntime, - authMethod: 'subscription-oauth' as const, - createdAt: 0, - updatedAt: 0, - lastAuthenticatedAt: 0 - } -} - -const HOST_SELECTED: ClaudeManagedAccountGateSettings = { - claudeManagedAccounts: [managedAccount('host-1', 'host')], - activeClaudeManagedAccountId: 'host-1', - activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} } -} - -const WSL_ONLY: ClaudeManagedAccountGateSettings = { - claudeManagedAccounts: [managedAccount('wsl-1', 'wsl')], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } -} - -function support( - overrides: Partial[0]> = {} -) { - return resolveStructuredAgentSessionCreateSupport({ - agent: 'claude', - location: LOCAL, - adapterSupportsCreate: true, - getSettings: () => HOST_SELECTED, - ...overrides - }) -} - describe('resolveStructuredAgentSessionCreateSupport', () => { - it('supports Claude under a selected host account', () => { - expect(support()).toEqual({ supported: true }) - }) - - it('refuses Claude under a WSL-only managed account', () => { - expect(support({ getSettings: () => WSL_ONLY })).toEqual({ supported: false, reason: 'wsl' }) - }) - - it('fails closed for Claude when the settings throw', () => { + it('supports what the adapter supports', () => { expect( - support({ - getSettings: () => { - throw new Error('no store') - } - }) - ).toEqual({ supported: false, reason: 'wsl' }) - }) - - it('leaves Codex to the adapter answer under the same WSL-only account', () => { - expect(support({ agent: 'codex', getSettings: () => WSL_ONLY })).toEqual({ supported: true }) + resolveStructuredAgentSessionCreateSupport({ location: LOCAL, adapterSupportsCreate: true }) + ).toEqual({ supported: true }) }) it.each([ @@ -75,22 +21,8 @@ describe('resolveStructuredAgentSessionCreateSupport', () => { ['wsl workspace', { ...LOCAL, wslDistro: 'Ubuntu' }, 'wsl'], ['unsupported agent', LOCAL, 'agent'] ] as const)('keeps the adapter refusal reason for %s', (_name, location, reason) => { - expect(support({ adapterSupportsCreate: false, location })).toEqual({ - supported: false, - reason - }) - }) - - // Command values never change the selected chat surface. - it.each([ - ['claude', 'claude-wrapper'], - ['codex', 'codex-nightly'], - ['claude', 'npx claude'], - ['codex', 'wrapper --arg'], - ['claude', '/missing/claude'], - ['codex', './codex'] - ] as const)('supports %s when this host sets launch command %s', (agent, command) => { - const settings = { ...HOST_SELECTED, agentCmdOverrides: { [agent]: command } } - expect(support({ agent, getSettings: () => settings })).toEqual({ supported: true }) + expect( + resolveStructuredAgentSessionCreateSupport({ location, adapterSupportsCreate: false }) + ).toEqual({ supported: false, reason }) }) }) diff --git a/src/main/native-chat/structured-agent-session-create-support.ts b/src/main/native-chat/structured-agent-session-create-support.ts index dc2b40ac1e65..37a1fc033866 100644 --- a/src/main/native-chat/structured-agent-session-create-support.ts +++ b/src/main/native-chat/structured-agent-session-create-support.ts @@ -1,27 +1,16 @@ import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import { - readClaudeManagedAccountGateSettings, - structuredClaudeMatchesActiveManagedAccount, - type ClaudeManagedAccountGateSettings -} from './claude-structured-managed-account-support' export type StructuredAgentSessionCreateSupport = { supported: boolean reason?: 'agent' | 'remote' | 'wsl' } -/** - * The create-support verdict, kept out of the runtime class file because that file is `@ts-nocheck` - * — a call site there is not typechecked, so an auth-identity decision written inline would compile - * however wrong it was. The runtime hands over the two facts it owns and this decides. - */ +/** The create-support verdict: the adapter's location answer, with the reason a refusal names. */ export function resolveStructuredAgentSessionCreateSupport(input: { - agent: StructuredAgentId location: AgentSessionExecutionLocation adapterSupportsCreate: boolean - getSettings: () => ClaudeManagedAccountGateSettings }): StructuredAgentSessionCreateSupport { if (!input.adapterSupportsCreate) { return { @@ -34,26 +23,12 @@ export function resolveStructuredAgentSessionCreateSupport(input: { : 'agent' } } - // Claude only: Codex resolves its account on a different path, so its answer is untouched here. - // `wsl` is the closest existing reason — the cause is a WSL-bound account rather than a WSL - // workspace — and no client reads the field, so it stays as-is. - if ( - input.agent === 'claude' && - !structuredClaudeMatchesActiveManagedAccount( - readClaudeManagedAccountGateSettings(input.getSettings) - ) - ) { - return { supported: false, reason: 'wsl' } - } return { supported: true } } -/** Which create-support check said no: where the chat would run, the installed agent (its binary - * and version on this host), or Claude's managed-account binding. */ -export type StructuredAgentSessionCreateSupportCheck = - | 'location' - | 'installed-agent' - | 'managed-account' +/** Which create-support check said no: where the chat would run, or the installed agent (its + * binary and version on this host). */ +export type StructuredAgentSessionCreateSupportCheck = 'location' | 'installed-agent' /** One main-log line per create-support verdict that sends a launch to the terminal; names the * check and never a path or environment value. */ diff --git a/src/main/persistence-worktree-lineage-and-backups.test.ts b/src/main/persistence-worktree-lineage-and-backups.test.ts index 1f750ff1503c..a9890f661644 100644 --- a/src/main/persistence-worktree-lineage-and-backups.test.ts +++ b/src/main/persistence-worktree-lineage-and-backups.test.ts @@ -277,67 +277,6 @@ describe('Store', () => { }) }) - describe('claudeLivePtySessionIds', () => { - it('persists added ids across reloads and removes them durably', async () => { - const store = await createStore() - - store.addClaudeLivePtySessionId('claude-session-1') - store.addClaudeLivePtySessionId('claude-session-2') - store.addClaudeLivePtySessionId('claude-session-1') - - expect(store.getClaudeLivePtySessionIds()).toEqual(['claude-session-1', 'claude-session-2']) - - const reloaded = await createStore() - expect(reloaded.getClaudeLivePtySessionIds()).toEqual([ - 'claude-session-1', - 'claude-session-2' - ]) - - reloaded.removeClaudeLivePtySessionId('claude-session-1') - reloaded.flush() - - const reloadedAgain = await createStore() - expect(reloadedAgain.getClaudeLivePtySessionIds()).toEqual(['claude-session-2']) - }) - - it('drops malformed persisted entries on load', async () => { - writeDataFile({ - schemaVersion: 1, - claudeLivePtySessionIds: ['valid-id', '', 42, null, 'valid-id', 'x'.repeat(513)] - }) - - const store = await createStore() - - expect(store.getClaudeLivePtySessionIds()).toEqual(['valid-id']) - }) - - it('keeps the newest ids when an oversized persisted list is loaded', async () => { - writeDataFile({ - schemaVersion: 1, - claudeLivePtySessionIds: Array.from({ length: 205 }, (_, index) => `claude-${index}`) - }) - - const store = await createStore() - - const ids = store.getClaudeLivePtySessionIds() - expect(ids).toHaveLength(200) - expect(ids[0]).toBe('claude-5') - expect(ids[199]).toBe('claude-204') - }) - - it('caps the persisted id list', async () => { - const store = await createStore() - for (let index = 0; index < 205; index += 1) { - store.addClaudeLivePtySessionId(`claude-session-${index}`) - } - - const ids = store.getClaudeLivePtySessionIds() - expect(ids).toHaveLength(200) - expect(ids[0]).toBe('claude-session-5') - expect(ids[199]).toBe('claude-session-204') - }) - }) - describe('retired JSON backups', () => { it.each(['sync', 'async'] as const)( 'leaves legacy JSON and backups unchanged during %s SQL writes', diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts b/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts index 2f0ccdc8165f..7f72f2117aba 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts @@ -2,7 +2,6 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { RemovedSshTargetTombstone, SshTarget } from '../../../shared/ssh-types' import type { ProtectedSecretPersistence } from '../../protected-secret-persistence' import { sshPtyOwnerLeaseSecretSlot } from '../../protected-secret-persistence' -import { MAX_CLAUDE_LIVE_PTY_SESSION_IDS } from '../restoring-sessions/pane-alias-normalization' import { MAX_REMOVED_SSH_TARGET_TOMBSTONES, capRemovedSshTargetTombstones, @@ -19,7 +18,6 @@ export type SshTargetStateOperations = { state: PersistedState protectedSecrets: Pick scheduleSave: () => void - flush: () => void } export function getSshTargets(state: PersistedState): SshTarget[] { @@ -92,41 +90,6 @@ export function removeSshTarget(operations: SshTargetStateOperations, id: string operations.scheduleSave() } -export function getClaudeLivePtySessionIds(state: PersistedState): string[] { - return [...(state.claudeLivePtySessionIds ?? [])] -} - -export function addClaudeLivePtySessionId( - operations: SshTargetStateOperations, - sessionId: string -): void { - if (sessionId.length === 0 || sessionId.length > 512) { - return - } - const ids = operations.state.claudeLivePtySessionIds ?? [] - if (ids.includes(sessionId)) { - return - } - // Why: drop oldest at the cap — stale ids get pruned against the daemon at startup, so only recency matters. - operations.state.claudeLivePtySessionIds = [...ids, sessionId].slice( - -MAX_CLAUDE_LIVE_PTY_SESSION_IDS - ) - // Why: flush sync so a force-quit right after a Claude spawn still seeds the live-PTY gate next launch. - operations.flush() -} - -export function removeClaudeLivePtySessionId( - operations: SshTargetStateOperations, - sessionId: string -): void { - const ids = operations.state.claudeLivePtySessionIds ?? [] - if (!ids.includes(sessionId)) { - return - } - operations.state.claudeLivePtySessionIds = ids.filter((id) => id !== sessionId) - operations.scheduleSave() -} - export function getDeletedSshConfigAliases(state: PersistedState): string[] { return [...(state.deletedSshConfigAliases ?? [])] } diff --git a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts index 1a3ba807c5ab..7045f922de88 100644 --- a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts +++ b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts @@ -12,7 +12,6 @@ import { normalizeSshTarget } from '../leasing-ssh-ptys/ssh-normalization' import { - normalizeClaudeLivePtySessionIds, normalizeLegacyPaneKeyAliasEntries, normalizeMigrationUnsupportedPtyEntries } from '../restoring-sessions/pane-alias-normalization' @@ -102,7 +101,6 @@ export function normalizeLoadedProfileState( .map(normalizeSshRemotePtyLease) .filter((lease): lease is SshRemotePtyLease => lease !== null), sshPtyConsumerRecoveries: parsed.sshPtyConsumerRecoveries, - claudeLivePtySessionIds: normalizeClaudeLivePtySessionIds(parsed.claudeLivePtySessionIds), migrationUnsupportedPtyEntries: normalizeMigrationUnsupportedPtyEntries( parsed.migrationUnsupportedPtyEntries ), diff --git a/src/main/persistence/loading-store/profile-state-direct-flush.test.ts b/src/main/persistence/loading-store/profile-state-direct-flush.test.ts index 9cf3d1ec2d1c..9e2bed18bc01 100644 --- a/src/main/persistence/loading-store/profile-state-direct-flush.test.ts +++ b/src/main/persistence/loading-store/profile-state-direct-flush.test.ts @@ -93,16 +93,6 @@ describe('SQLite durability barriers with another selective write pending', () = }) }) - it('commits Claude live-PTY admission before returning', () => { - const state = fixture() - state.pendSession() - state.store.addClaudeLivePtySessionId('claude-session') - expect(state.read('claudeLivePtySessionIds')).toMatchObject({ - kind: 'value', - value: ['claude-session'] - }) - }) - it('commits SSH lease admission before returning', () => { const state = fixture() state.pendSession() diff --git a/src/main/persistence/loading-store/ssh-profile-operations.ts b/src/main/persistence/loading-store/ssh-profile-operations.ts index ce81cea373b8..ad12108129ef 100644 --- a/src/main/persistence/loading-store/ssh-profile-operations.ts +++ b/src/main/persistence/loading-store/ssh-profile-operations.ts @@ -1,17 +1,14 @@ import type { RemovedSshTargetTombstone, SshTarget } from '../../../shared/ssh-types' import { type SshTargetStateOperations, - addClaudeLivePtySessionId as addClaudeLivePtySessionIdOperation, addDeletedSshConfigAlias as addDeletedSshConfigAliasOperation, addRemovedSshTargetTombstone as addRemovedSshTargetTombstoneOperation, addSshTarget as addSshTargetOperation, clearDeletedSshConfigAliases as clearDeletedSshConfigAliasesOperation, - getClaudeLivePtySessionIds as getClaudeLivePtySessionIdsOperation, getDeletedSshConfigAliases as getDeletedSshConfigAliasesOperation, getRemovedSshTargetTombstones as getRemovedSshTargetTombstonesOperation, getSshTarget as getSshTargetOperation, getSshTargets as getSshTargetsOperation, - removeClaudeLivePtySessionId as removeClaudeLivePtySessionIdOperation, removeDeletedSshConfigAlias as removeDeletedSshConfigAliasOperation, removeRemovedSshTargetTombstone as removeRemovedSshTargetTombstoneOperation, releaseRemovedSshTargetTombstone as releaseRemovedSshTargetTombstoneOperation, @@ -26,22 +23,17 @@ import { allocateSshTargetGeneration as allocateSshTargetGenerationOperation } f import type { StoreRuntimeState } from './store-runtime-state' import type { WriteSchedulingOperations } from './write-scheduling' -import type { WriteFlushBarrierOperations } from './write-flush-barriers' import type { RepoLifecycleOperations } from './repo-lifecycle-operations' import { syncProjectHostSetupCompatibilityState } from './repo-lifecycle-operations' import { scheduleSave } from './write-scheduling' import { forgetSshConnectionGeneration } from '../../ssh/ssh-connection-generation' -type SshProfileOperationsRuntime = Pick< - StoreRuntimeState, - 'dirtyProfileStateDomains' | 'protectedSecrets' | 'state' -> +type SshProfileOperationsRuntime = Pick const sshProfileOperationsContext = Symbol('SshProfileOperations') type SshProfileOperationsContext = { runtime: SshProfileOperationsRuntime scheduling: WriteSchedulingOperations - flushBarriers: WriteFlushBarrierOperations repos: RepoLifecycleOperations } @@ -51,10 +43,9 @@ export class SshProfileOperations { constructor( runtime: SshProfileOperationsRuntime, scheduling: WriteSchedulingOperations, - flushBarriers: WriteFlushBarrierOperations, repos: RepoLifecycleOperations ) { - this[sshProfileOperationsContext] = { runtime, scheduling, flushBarriers, repos } + this[sshProfileOperationsContext] = { runtime, scheduling, repos } } getSshTargets(): SshTarget[] { @@ -88,18 +79,6 @@ export class SshProfileOperations { ) } - getClaudeLivePtySessionIds(): string[] { - return getClaudeLivePtySessionIdsOperation(this[sshProfileOperationsContext].runtime.state) - } - - addClaudeLivePtySessionId(sessionId: string): void { - addClaudeLivePtySessionIdOperation(getSshTargetStateOperations(this), sessionId) - } - - removeClaudeLivePtySessionId(sessionId: string): void { - removeClaudeLivePtySessionIdOperation(getSshTargetStateOperations(this), sessionId) - } - getDeletedSshConfigAliases(): string[] { return getDeletedSshConfigAliasesOperation(this[sshProfileOperationsContext].runtime.state) } @@ -148,13 +127,7 @@ export function getSshTargetStateOperations(owner: SshProfileOperations): SshTar return { state: owner[sshProfileOperationsContext].runtime.state, protectedSecrets: owner[sshProfileOperationsContext].runtime.protectedSecrets, - scheduleSave: () => scheduleSave(owner[sshProfileOperationsContext].scheduling), - flush: () => { - owner[sshProfileOperationsContext].runtime.dirtyProfileStateDomains?.add( - 'claudeLivePtySessionIds' - ) - owner[sshProfileOperationsContext].flushBarriers.flush() - } + scheduleSave: () => scheduleSave(owner[sshProfileOperationsContext].scheduling) } } diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts index 4ba2e58881ac..cf3063ad37c8 100644 --- a/src/main/persistence/loading-store/store-domain-composition.ts +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -183,7 +183,7 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { const mobileTabSelections = new MobileTabSelectionPersistence(runtime, scheduling) const sparsePresets = new SparsePresetPersistence(runtime, scheduling) const ptyBindings = new PtyBindingPersistenceOperations(runtime, sessions) - const sshProfiles = new SshProfileOperations(runtime, scheduling, flushBarriers, repos) + const sshProfiles = new SshProfileOperations(runtime, scheduling, repos) const retiredWorktreeNames = new RetiredWorktreeNamePersistence(runtime, scheduling) const sshLeases = new SshLeaseRecoveryOperations( runtime, diff --git a/src/main/persistence/restoring-sessions/pane-alias-normalization.ts b/src/main/persistence/restoring-sessions/pane-alias-normalization.ts index c7e88c480217..6b9dca94c923 100644 --- a/src/main/persistence/restoring-sessions/pane-alias-normalization.ts +++ b/src/main/persistence/restoring-sessions/pane-alias-normalization.ts @@ -40,33 +40,9 @@ export function legacyMigrationUnsupportedRowsToAliasEntries( return aliasEntries } -// Why: bounds a corrupt/bloated persisted list — the gate only needs the few Claude sessions a daemon can keep alive. -export const MAX_CLAUDE_LIVE_PTY_SESSION_IDS = 200 - // Why: bound removed-SSH-target history so remove/re-add churn can't grow the file unbounded. export const MAX_REMOVED_SSH_TARGET_TOMBSTONES = 50 -export function normalizeClaudeLivePtySessionIds(value: unknown): string[] { - if (!Array.isArray(value)) { - return [] - } - // Why: scan newest-first so the cap keeps the most recent ids, matching addClaudeLivePtySessionId's eviction policy. - const ids: string[] = [] - for (let index = value.length - 1; index >= 0; index -= 1) { - const entry = value[index] - if (typeof entry !== 'string' || entry.length === 0 || entry.length > 512) { - continue - } - if (!ids.includes(entry)) { - ids.push(entry) - } - if (ids.length >= MAX_CLAUDE_LIVE_PTY_SESSION_IDS) { - break - } - } - return ids.toReversed() -} - export function normalizeMigrationUnsupportedPtyEntries( value: unknown ): MigrationUnsupportedPtyEntry[] { diff --git a/src/main/providers/local-pty-windows-spawn-environment.ts b/src/main/providers/local-pty-windows-spawn-environment.ts index 63ad18883dc9..a1f194e9af0c 100644 --- a/src/main/providers/local-pty-windows-spawn-environment.ts +++ b/src/main/providers/local-pty-windows-spawn-environment.ts @@ -1,4 +1,8 @@ import { win32 as pathWin32 } from 'node:path' +import { + CLAUDE_INJECTED_CONFIG_DIR_ENV, + CLAUDE_PROFILE_POINTER_ENV +} from '../../shared/claude-profile-routing' import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { addWslEnvKeys } from '../wsl-env' @@ -55,8 +59,8 @@ export function finalizeWindowsLocalPtySpawnEnvironment(args: { // Why: managed WSL Claude passes a Linux CLAUDE_CONFIG_DIR through wsl.exe; non-default vars need WSLENV import. addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) } - if (env.ORCA_CLAUDE_PROFILE_POINTER) { - addWslEnvKeys(env, ['ORCA_CLAUDE_PROFILE_POINTER', 'ORCA_CLAUDE_INJECTED_CONFIG_DIR']) + if (env[CLAUDE_PROFILE_POINTER_ENV]) { + addWslEnvKeys(env, [CLAUDE_PROFILE_POINTER_ENV, CLAUDE_INJECTED_CONFIG_DIR_ENV]) } if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) { // Why: wsl.exe drops custom Windows env vars; the startup wrapper needs this imported inside WSL. diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 38352a1b6d48..e6e573263ab9 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -308,7 +308,7 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() }) - it('keeps a plain Git Bash tab a login shell and wraps one with a startup command', () => { + it('gives a plain Git Bash tab the same rcfile as one with a startup command', () => { const plain = resolveWindowsShellLaunchArgs( 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Users\\alice', @@ -322,14 +322,12 @@ describe('resolveWindowsShellLaunchArgs', () => { "codex 'fix the bug'" ) - expect(plain.shellArgs).toEqual([ - '-c', - 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i' - ]) - // Why: without a preflight, only the rcfile carries the codex --no-daemon wrapper. - expect(readFileSync(getGitBashRcfilePath(launched.shellArgs[1]), 'utf8')).toContain( - 'set -- --no-daemon "$@"' - ) + expect(plain.shellArgs).toEqual(launched.shellArgs) + // Why: only the rcfile carries the account-following claude function and codex --no-daemon wrapper. + const rcfile = readFileSync(getGitBashRcfilePath(plain.shellArgs[1]), 'utf8') + expect(rcfile).toContain('function claude {') + expect(rcfile).toContain('set -- --no-daemon "$@"') + expect(rcfile).toContain('source "$HOME/.bash_profile"') expect(launched.startupCommandDeliveredInShellArgs).toBeUndefined() }) diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index c5ceade00a44..b905a3fbb302 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -29,12 +29,9 @@ const CMD_CODEX_LAUNCH_PREFLIGHT = `if defined ORCA_CODEX_LAUNCH_PREFLIGHT call // `&&`) keeps startup working even if chcp.com is missing. const GIT_BASH_UTF8_LOGIN_COMMAND = 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i' -// Why the rcfile for a startup command: it defines the codex wrapper Orca's launches need. -function getGitBashLaunchCommand(useWrapper: boolean): string { - if (!useWrapper) { - return GIT_BASH_UTF8_LOGIN_COMMAND - } - +// Why every pane gets the rcfile: it defines the claude function that follows the selected +// account (and the codex wrapper), as bash panes elsewhere get; it sources the login chain itself. +function getGitBashLaunchCommand(): string { ensureShellReadyWrappersAt() const wrapperArgs = getBashWrapperLaunchArgs() if (!wrapperArgs) { @@ -217,10 +214,7 @@ export function resolveWindowsShellLaunchArgs( if (isWindowsGitBashShellPath(shellPath)) { return { - shellArgs: [ - '-c', - getGitBashLaunchCommand(Boolean(codexLaunchPreflightCommand) || Boolean(startupCommand)) - ], + shellArgs: ['-c', getGitBashLaunchCommand()], effectiveCwd: nativeCwd, validationCwd: nativeCwd } diff --git a/src/main/rate-limits/claude-active-usage-fetch.ts b/src/main/rate-limits/claude-active-usage-fetch.ts index f285ba13ce93..df9867efd3b4 100644 --- a/src/main/rate-limits/claude-active-usage-fetch.ts +++ b/src/main/rate-limits/claude-active-usage-fetch.ts @@ -1,248 +1,86 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types' -import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' -import { completeClaudeOAuthUsageSuccess, fetchClaudeUsageViaCli } from './claude-cli-usage-fetch' import { readClaudeOAuthCredentials, resolveClaudeOAuthCredentialReadOptions } from './claude-oauth-credentials' -import { - canRetryClaudeOAuthWithLegacyKeychain, - makeClaudeUsageClassificationError, - makeLiveClaudeUsageDeferredResult, - repairClaudeCredentialsThenRetryOAuth, - retryClaudeOAuthWithLegacyKeychain, - shouldDeferClaudeUsageForLiveSession -} from './claude-oauth-recovery' import { fetchClaudeOAuthUsage } from './claude-oauth-usage-request' -import { - classifyClaudeCredentialAbsence, - classifyClaudeOAuthUsageError -} from './claude-usage-error-classification' +import { classifyClaudeOAuthUsageError } from './claude-usage-error-classification' +import { OAuthUsageError } from './claude-oauth-usage-error' import type { ClaudeRateLimitFetchOptions } from './claude-usage-fetch-options' -import { resolveClaudeUsageRefreshPlan } from './claude-usage-refresh-plan' -import { - abortedClaudeRateLimitResult, - makeClaudeUsageResult, - metadataForClaudeUsageAttempt, - recordClaudeUsageAttempt, - warnClaudeUsageFetchFailure -} from './claude-usage-result' +import { abortedClaudeRateLimitResult, makeClaudeUsageResult } from './claude-usage-result' +import { CLAUDE_PROFILE_MISSING_MESSAGE } from '../../shared/claude-profile-routing' +/** Usage observes the account; only a user-started Claude process may refresh its login. */ export async function fetchActiveClaudeRateLimits( options?: ClaudeRateLimitFetchOptions ): Promise { + const usageError = options?.authPreparation?.usageError + if (usageError) { + return makeClaudeUsageResult('error', usageError, { + failureKind: + usageError === CLAUDE_PROFILE_MISSING_MESSAGE ? 'missing-credentials' : 'usage-unavailable', + attemptedSources: [] + }) + } if (options?.signal?.aborted) { return abortedClaudeRateLimitResult() } - const attempts = { attemptedSources: [] } - const allowCliFallback = options?.allowPtyFallback !== false - const plan = resolveClaudeUsageRefreshPlan({ - authPreparation: options?.authPreparation, - allowCliFallback - }) - - if (options?.authPreparation?.runtime === 'wsl' && !options.authPreparation.wslLinuxConfigDir) { - return makeClaudeUsageResult( - 'error', - `WSL Claude config unavailable for ${options.authPreparation.wslDistro ?? 'default distro'}`, - { - attemptedSources: [], - failureKind: 'cli-unavailable', - authProvenance: options.authPreparation.provenance - } - ) - } - - const oauthCredentials = await readClaudeOAuthCredentials( + const credentials = await readClaudeOAuthCredentials( resolveClaudeOAuthCredentialReadOptions(options?.authPreparation) ) - if (options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - - if (plan.steps.some((step) => step.source === 'oauth') && oauthCredentials.token) { - recordClaudeUsageAttempt(attempts, 'oauth') - try { - const oauthLimits = await fetchClaudeOAuthUsage(oauthCredentials.token, options?.signal) - if (options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - return await completeClaudeOAuthUsageSuccess({ - oauthLimits, - oauthCredentials, - attempts, - options - }) - } catch (error) { - warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, error) - const classification = classifyClaudeOAuthUsageError(error) - - if ( - canRetryClaudeOAuthWithLegacyKeychain({ - classification, - oauthCredentials, - authPreparation: options?.authPreparation - }) - ) { - const legacyResult = await retryClaudeOAuthWithLegacyKeychain({ - failedToken: oauthCredentials.token, - attempts, - options - }) - if (legacyResult) { - return legacyResult - } - } - - if (shouldDeferClaudeUsageForLiveSession(options?.authPreparation, classification)) { - return makeLiveClaudeUsageDeferredResult({ - attempts, - oauthCredentials, - authPreparation: options?.authPreparation - }) - } - - if (classification.shouldAttemptDelegatedRefresh && allowCliFallback) { - const repaired = await repairClaudeCredentialsThenRetryOAuth({ - options, - attempts, - oauthCredentials - }) - if (repaired) { - return repaired - } - } - - if (classification.shouldAttemptCliFallback && allowCliFallback) { - try { - return await fetchClaudeUsageViaCli({ - authPreparation: options?.authPreparation, - oauthCredentials, - attempts, - networkProxySettings: options?.networkProxySettings, - signal: options?.signal - }) - } catch (ptyError) { - warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, ptyError) - } - } - - return makeClaudeUsageClassificationError({ - error, - classification, - attempts, - oauthCredentials, - authPreparation: options?.authPreparation - }) - } + const metadata = { + credentialSource: credentials.source, + authProvenance: options?.authPreparation?.provenance ?? 'system' } - - const credentialClassification = classifyClaudeCredentialAbsence({ - hasRefreshableCredentials: oauthCredentials.hasRefreshableCredentials, - keychainUnavailable: oauthCredentials.keychainUnavailable, - managedRefreshDeferredByLivePty: options?.authPreparation?.managedRefreshDeferredByLivePty - }) - - if (shouldDeferClaudeUsageForLiveSession(options?.authPreparation, credentialClassification)) { - return makeLiveClaudeUsageDeferredResult({ - attempts, - oauthCredentials, - authPreparation: options?.authPreparation - }) - } - - if ( - oauthCredentials.hasRefreshableCredentials && - credentialClassification.shouldAttemptDelegatedRefresh && - allowCliFallback - ) { - const repaired = await repairClaudeCredentialsThenRetryOAuth({ - options, - attempts, - oauthCredentials - }) - if (repaired) { - return repaired - } - } - + // Why: System Default with no Claude login is an API-key or non-Claude user, not a signed-out account. if ( - (oauthCredentials.token || - oauthCredentials.hasRefreshableCredentials || - oauthCredentials.keychainUnavailable) && - credentialClassification.shouldAttemptCliFallback && - allowCliFallback + !credentials.token && + !credentials.unavailable && + !credentials.hasRefreshableCredentials && + metadata.authProvenance === 'system' ) { - try { - return await fetchClaudeUsageViaCli({ - authPreparation: options?.authPreparation, - oauthCredentials, - attempts, - networkProxySettings: options?.networkProxySettings, - signal: options?.signal - }) - } catch (error) { - warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, error) - return makeClaudeUsageResult( - 'error', - withMacTailscaleDnsHint(error instanceof Error ? error.message : 'Unknown error'), - { - ...metadataForClaudeUsageAttempt({ - attemptedSources: attempts.attemptedSources, - oauthCredentials, - authPreparation: options?.authPreparation, - failureKind: - credentialClassification.failureKind === 'keychain-unavailable' - ? 'keychain-unavailable' - : 'cli-unavailable' - }) - } - ) - } - } - - if (oauthCredentials.keychainUnavailable) { - return makeClaudeUsageResult('error', 'Claude Keychain credentials unavailable', { - ...metadataForClaudeUsageAttempt({ - attemptedSources: attempts.attemptedSources, - oauthCredentials, - authPreparation: options?.authPreparation, - failureKind: 'keychain-unavailable' - }) + return makeClaudeUsageResult('unavailable', 'No subscription plan — API key billing', { + ...metadata, + failureKind: 'missing-credentials', + attemptedSources: [] }) } - - if (oauthCredentials.hasRefreshableCredentials) { - return makeClaudeUsageResult('error', 'Claude OAuth access token unavailable', { - ...metadataForClaudeUsageAttempt({ - attemptedSources: attempts.attemptedSources, - oauthCredentials, - authPreparation: options?.authPreparation, - failureKind: credentialClassification.failureKind - }) - }) + if (!credentials.token) { + return makeClaudeUsageResult( + 'error', + credentials.unavailable + ? 'Claude usage is unavailable.' + : 'Sign in again to use this account.', + { + ...metadata, + failureKind: credentials.unavailable ? 'keychain-unavailable' : 'missing-credentials', + attemptedSources: [] + } + ) } - - if (allowCliFallback && plan.steps.some((step) => step.source === 'cli')) { - try { - return await fetchClaudeUsageViaCli({ - authPreparation: options?.authPreparation, - oauthCredentials, - attempts, - networkProxySettings: options?.networkProxySettings, - signal: options?.signal - }) - } catch (error) { - warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, error) - } + try { + return await fetchClaudeOAuthUsage(credentials.token, options?.signal) + } catch (error) { + const { failureKind } = classifyClaudeOAuthUsageError(error) + // Why: the 429 wait gates the poll, and both messages tell the user why usage is missing. + const retryAfterMs = error instanceof OAuthUsageError ? error.retryAfterMs : null + return makeClaudeUsageResult( + 'error', + failureKind === 'stale-token' + ? // Why: Claude refreshes its own login when it next runs; System Default has no "account". + metadata.authProvenance === 'system' + ? 'Claude usage updates the next time Claude runs.' + : 'Claude usage has expired. Start Claude in this account to refresh it.' + : (failureKind === 'rate-limited' || failureKind === 'missing-scope') && + error instanceof OAuthUsageError + ? error.message + : 'Claude usage is unavailable.', + { + ...metadata, + failureKind, + attemptedSources: ['oauth'], + ...(retryAfterMs ? { retryAtMs: Date.now() + retryAfterMs } : {}) + } + ) } - - return makeClaudeUsageResult('unavailable', 'No subscription plan — API key billing', { - ...metadataForClaudeUsageAttempt({ - attemptedSources: attempts.attemptedSources, - oauthCredentials, - authPreparation: options?.authPreparation, - failureKind: 'missing-credentials' - }) - }) } diff --git a/src/main/rate-limits/claude-cli-usage-fetch.ts b/src/main/rate-limits/claude-cli-usage-fetch.ts deleted file mode 100644 index 70d264a4e68f..000000000000 --- a/src/main/rate-limits/claude-cli-usage-fetch.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { ProviderRateLimits, UsageRateLimitFailureKind } from '../../shared/rate-limit-types' -import type { NetworkProxySettings } from '../../shared/network-proxy' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -import { fetchViaPty } from './claude-pty' -import type { ClaudeOAuthCredentialReadResult } from './claude-oauth-credentials' -import type { ClaudeRateLimitFetchOptions } from './claude-usage-fetch-options' -import { - abortedClaudeRateLimitResult, - canSupplementClaudeOAuthUsage, - isManagedClaudeAuth, - mergeClaudeUsageWindows, - metadataForClaudeUsageAttempt, - recordClaudeUsageAttempt, - type ClaudeUsageAttemptState, - warnClaudeUsageFetchFailure, - withClaudeUsageMetadata -} from './claude-usage-result' - -function classifyClaudeCliUsageFailure( - limits: ProviderRateLimits -): UsageRateLimitFailureKind | undefined { - if (!limits.error) { - return undefined - } - if (/rate limited/i.test(limits.error)) { - return 'rate-limited' - } - if (/plan usage is unavailable|usage is unavailable/i.test(limits.error)) { - return 'usage-unavailable' - } - return 'cli-unavailable' -} - -export async function fetchClaudeUsageViaCli(input: { - authPreparation?: ClaudeRuntimeAuthPreparation - oauthCredentials: ClaudeOAuthCredentialReadResult - attempts: ClaudeUsageAttemptState - networkProxySettings?: NetworkProxySettings - signal?: AbortSignal -}): Promise { - recordClaudeUsageAttempt(input.attempts, 'cli') - const limits = await fetchViaPty({ - authPreparation: input.authPreparation, - networkProxySettings: input.networkProxySettings, - signal: input.signal - }) - return withClaudeUsageMetadata( - limits, - metadataForClaudeUsageAttempt({ - attemptedSources: input.attempts.attemptedSources, - oauthCredentials: input.oauthCredentials, - authPreparation: input.authPreparation, - source: 'cli', - failureKind: classifyClaudeCliUsageFailure(limits) - }) - ) -} - -async function supplementOAuthUsageFromCli(input: { - oauthLimits: ProviderRateLimits - authPreparation?: ClaudeRuntimeAuthPreparation - oauthCredentials: ClaudeOAuthCredentialReadResult - attempts: ClaudeUsageAttemptState - allowUsagePanelSupplement: boolean - networkProxySettings?: NetworkProxySettings - signal?: AbortSignal -}): Promise { - if (input.signal?.aborted || !canSupplementClaudeOAuthUsage(input)) { - return input.oauthLimits - } - try { - return mergeClaudeUsageWindows( - input.oauthLimits, - await fetchClaudeUsageViaCli({ - authPreparation: input.authPreparation, - oauthCredentials: input.oauthCredentials, - attempts: input.attempts, - networkProxySettings: input.networkProxySettings, - signal: input.signal - }) - ) - } catch (error) { - warnClaudeUsageFetchFailure(input.authPreparation, input.oauthCredentials, error) - return input.oauthLimits - } -} - -export async function completeClaudeOAuthUsageSuccess(input: { - oauthLimits: ProviderRateLimits - oauthCredentials: ClaudeOAuthCredentialReadResult - attempts: ClaudeUsageAttemptState - options?: ClaudeRateLimitFetchOptions -}): Promise { - const limits = await supplementOAuthUsageFromCli({ - oauthLimits: input.oauthLimits, - authPreparation: input.options?.authPreparation, - oauthCredentials: input.oauthCredentials, - attempts: input.attempts, - networkProxySettings: input.options?.networkProxySettings, - allowUsagePanelSupplement: - input.options?.allowUsagePanelSupplement ?? - isManagedClaudeAuth(input.options?.authPreparation), - signal: input.options?.signal - }) - if (input.options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - return withClaudeUsageMetadata( - limits, - metadataForClaudeUsageAttempt({ - attemptedSources: input.attempts.attemptedSources, - oauthCredentials: input.oauthCredentials, - authPreparation: input.options?.authPreparation, - source: 'oauth' - }) - ) -} diff --git a/src/main/rate-limits/claude-fetcher-cli-fallback.test.ts b/src/main/rate-limits/claude-fetcher-cli-fallback.test.ts deleted file mode 100644 index 428a29106941..000000000000 --- a/src/main/rate-limits/claude-fetcher-cli-fallback.test.ts +++ /dev/null @@ -1,490 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { fetchClaudeRateLimits } from './claude-fetcher' -import { primeClaudeFetcherMocks, restorePlatform } from './claude-fetcher-test-harness' -import { fetchViaPty } from './claude-pty' -import { - readActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentialsStrict -} from '../claude-accounts/keychain' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' - -const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( - () => ({ - netFetchMock: vi.fn(), - readFileMock: vi.fn(), - resolveProxyMock: vi.fn(), - setProxyMock: vi.fn(), - appGetPathMock: vi.fn() - }) -) - -vi.mock('node:fs/promises', () => ({ - readFile: readFileMock -})) - -vi.mock('electron', () => ({ - app: { - getPath: appGetPathMock - }, - net: { - fetch: netFetchMock - }, - session: { - defaultSession: { - resolveProxy: resolveProxyMock, - setProxy: setProxyMock - } - } -})) - -vi.mock('./claude-pty', () => ({ - fetchViaPty: vi.fn() -})) - -vi.mock('../claude-accounts/keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(), - writeManagedClaudeKeychainCredentials: vi.fn() -})) - -describe('fetchClaudeRateLimits', () => { - beforeEach(() => { - primeClaudeFetcherMocks({ - netFetchMock, - readFileMock, - resolveProxyMock, - setProxyMock, - appGetPathMock - }) - }) - - afterEach(() => { - restorePlatform() - }) - - it('does not mask OAuth usage rate limits with the PTY fallback', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'expired-oauth-token', - refreshToken: 'refresh-token', - expiresAt: Date.now() - 60_000 - } - }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - error: { - type: 'rate_limit_error', - message: 'Rate limited. Please try again later.' - } - }), - { status: 429, headers: { 'retry-after': '3000' } } - ) - ) - - const before = Date.now() - const result = await fetchClaudeRateLimits({ authPreparation }) - expect(result).toMatchObject({ - provider: 'claude', - status: 'error', - error: 'Claude usage is rate limited right now.', - usageMetadata: expect.objectContaining({ failureKind: 'rate-limited' }) - }) - expect(result.usageMetadata?.retryAtMs).toBeGreaterThanOrEqual(before + 3000 * 1000) - expect(result.usageMetadata?.retryAtMs).toBeLessThanOrEqual(Date.now() + 3000 * 1000) - - expect(netFetchMock).toHaveBeenCalledWith( - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer expired-oauth-token' - }) - }) - ) - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('omits retryAtMs when a 429 has no Retry-After header', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'expired-oauth-token', - refreshToken: 'refresh-token', - expiresAt: Date.now() - 60_000 - } - }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - error: { - type: 'rate_limit_error', - message: 'Rate limited. Please try again later.' - } - }), - { status: 429 } - ) - ) - - const result = await fetchClaudeRateLimits({ authPreparation }) - expect(result.status).toBe('error') - expect(result.usageMetadata?.retryAtMs).toBeUndefined() - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('uses CLI fallback for OAuth auth failures when automatic repair is safe', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'stale-oauth-token', - refreshToken: 'refresh-token', - expiresAt: Date.now() - 60_000 - } - }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - error: { - type: 'authentication_error', - message: 'Invalid OAuth token.' - } - }), - { status: 401 } - ) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 56 }, - usageMetadata: { - source: 'cli', - attemptedSources: ['oauth', 'cli'] - } - }) - - expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) - }) - - it('re-reads credentials and retries OAuth once after CLI repair', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict) - .mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'stale-oauth-token', - refreshToken: 'refresh-token', - expiresAt: Date.now() - 60_000 - } - }) - ) - // Legacy item absent — the stale-scoped legacy fallback must not preempt - // CLI repair in this scenario. - .mockResolvedValueOnce(null) - .mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'repaired-oauth-token', - refreshToken: 'refresh-token-2', - expiresAt: Date.now() + 60_000 - } - }) - ) - netFetchMock - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - error: { - type: 'authentication_error', - message: 'Invalid OAuth token.' - } - }), - { status: 401 } - ) - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - five_hour: { utilization: 14 }, - seven_day: { utilization: 27 } - }), - { status: 200 } - ) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 14 }, - weekly: { usedPercent: 27 }, - usageMetadata: { - source: 'oauth', - attemptedSources: ['oauth', 'cli'], - credentialSource: 'scoped-keychain' - } - }) - - expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) - expect(netFetchMock).toHaveBeenNthCalledWith( - 2, - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer repaired-oauth-token' - }) - }) - ) - }) - - it('explains auth failures when a live Claude terminal owns managed refresh', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - managedRefreshDeferredByLivePty: true, - provenance: 'managed:account-1' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'stale-oauth-token', - refreshToken: 'refresh-token', - expiresAt: Date.now() - 60_000 - } - }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - error: { - type: 'authentication_error', - message: 'Invalid OAuth token.' - } - }), - { status: 401 } - ) - ) - - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: - 'Claude usage refresh is waiting for the live Claude terminal to rotate its credentials.' - }) - - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('does not start CLI fallback when live Claude owns managed refresh and no token is readable', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - managedRefreshDeferredByLivePty: true, - provenance: 'managed:account-1' - } - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: - 'Claude usage refresh is waiting for the live Claude terminal to rotate its credentials.', - usageMetadata: { - failureKind: 'deferred-by-live-session', - deferredByLiveClaudeSession: true, - attemptedSources: [] - } - }) - - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('does not start the PTY fallback when disabled for background fetches', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - refreshToken: 'refresh-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - netFetchMock.mockResolvedValueOnce(new Response('temporary failure', { status: 500 })) - - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: 'OAuth API returned 500' - }) - - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('does not start the PTY fallback for refresh-only credentials when disabled', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - refreshToken: 'refresh-token', - expiresAt: Date.now() - 60_000 - } - }) - ) - - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: 'Claude OAuth access token unavailable' - }) - - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('falls back to CLI when OAuth credentials are missing in automatic mode', async () => { - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir: '/Users/test/.claude', - envPatch: {}, - stripAuthEnv: false, - provenance: 'system' - } - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 56 }, - usageMetadata: { - source: 'cli', - attemptedSources: ['cli'], - credentialSource: 'none' - } - }) - - expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) - }) - - it('marks CLI plan usage shell results as usage unavailable', async () => { - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir: '/Users/test/.claude', - envPatch: {}, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: null, - weekly: null, - updatedAt: 1, - error: 'Claude plan usage is unavailable for this Claude CLI session.', - status: 'error' - }) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: 'Claude plan usage is unavailable for this Claude CLI session.', - usageMetadata: { - source: 'cli', - attemptedSources: ['cli'], - failureKind: 'usage-unavailable' - } - }) - }) - - it('surfaces Keychain read failures as structured usage metadata when CLI fallback is disabled', async () => { - vi.mocked(readActiveClaudeKeychainCredentials).mockRejectedValueOnce( - new Error('security timed out after 3000ms') - ) - - await expect(fetchClaudeRateLimits({ allowPtyFallback: false })).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: 'Claude Keychain credentials unavailable', - usageMetadata: { - failureKind: 'keychain-unavailable', - attemptedSources: [], - credentialSource: 'none' - } - }) - - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('uses CLI fallback when Keychain is unavailable in automatic mode', async () => { - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir: '/Users/test/.claude', - envPatch: {}, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentials).mockRejectedValueOnce( - new Error('security timed out after 3000ms') - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 56 }, - usageMetadata: { - source: 'cli', - attemptedSources: ['cli'], - credentialSource: 'none' - } - }) - - expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) - }) -}) diff --git a/src/main/rate-limits/claude-fetcher-fable-usage.test.ts b/src/main/rate-limits/claude-fetcher-fable-usage.test.ts deleted file mode 100644 index 2a84cc4f2af6..000000000000 --- a/src/main/rate-limits/claude-fetcher-fable-usage.test.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' -import { fetchClaudeRateLimits } from './claude-fetcher' -import { primeClaudeFetcherMocks, restorePlatform } from './claude-fetcher-test-harness' -import { fetchViaPty } from './claude-pty' -import { readActiveClaudeKeychainCredentialsStrict } from '../claude-accounts/keychain' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' - -const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( - () => ({ - netFetchMock: vi.fn(), - readFileMock: vi.fn(), - resolveProxyMock: vi.fn(), - setProxyMock: vi.fn(), - appGetPathMock: vi.fn() - }) -) - -vi.mock('node:fs/promises', () => ({ - readFile: readFileMock -})) - -vi.mock('electron', () => ({ - app: { - getPath: appGetPathMock - }, - net: { - fetch: netFetchMock - }, - session: { - defaultSession: { - resolveProxy: resolveProxyMock, - setProxy: setProxyMock - } - } -})) - -vi.mock('./claude-pty', () => ({ - fetchViaPty: vi.fn() -})) - -vi.mock('../claude-accounts/keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(), - writeManagedClaudeKeychainCredentials: vi.fn() -})) - -describe('fetchClaudeRateLimits', () => { - beforeEach(() => { - primeClaudeFetcherMocks({ - netFetchMock, - readFileMock, - resolveProxyMock, - setProxyMock, - appGetPathMock - }) - }) - - afterEach(() => { - restorePlatform() - }) - - it('accepts Claude Code statusline-style rate limit window fields', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - five_hour: { used_percentage: 23.5, resets_at: 1770000000 }, - seven_day: { used_percentage: 41.2, resets_at: 1770604800 }, - fable_weekly: { used_percentage: 12.3, resets_at: 1770691200 } - }), - { status: 200 } - ) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 23.5, resetsAt: 1770000000000 }, - weekly: { usedPercent: 41.2, resetsAt: 1770604800000 }, - fableWeekly: { usedPercent: 12.3, resetsAt: 1770691200000 } - }) - }) - - it('maps active Fable usage from the scoped OAuth limits array without a PTY read', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'managed:account-1' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - five_hour: { utilization: 36 }, - seven_day: { utilization: 73 }, - fable_weekly: { utilization: 12 }, - limits: [ - { kind: 'weekly_scoped', percent: 55, scope: null }, - { - kind: 'weekly_scoped', - percent: 100, - resets_at: '2026-07-17T20:00:00.099908+00:00', - is_active: true, - scope: { model: { display_name: 'Fable' } } - } - ] - }), - { status: 200 } - ) - ) - - await expect( - fetchClaudeRateLimits({ authPreparation, allowUsagePanelSupplement: true }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - fableWeekly: { - usedPercent: 100, - resetsAt: Date.parse('2026-07-17T20:00:00.099908+00:00') - }, - usageMetadata: { attemptedSources: ['oauth'] } - }) - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('surfaces inactive scoped Fable usage over the legacy OAuth fallback', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - five_hour: { utilization: 11 }, - seven_day: { utilization: 22 }, - fable_weekly: { utilization: 33 }, - limits: [ - { - kind: 'weekly_scoped', - percent: 90, - is_active: false, - scope: { model: { display_name: 'fable' } } - } - ] - }), - { status: 200 } - ) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - fableWeekly: { usedPercent: 90 } - }) - }) - - it('surfaces an inactive scoped Fable entry when no legacy Fable field exists (#8979)', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - five_hour: { utilization: 11 }, - seven_day: { utilization: 22 }, - limits: [ - { - kind: 'weekly_scoped', - percent: 64, - resets_at: '2026-07-24T20:00:00+00:00', - is_active: false, - scope: { model: { display_name: 'Fable' } } - } - ] - }), - { status: 200 } - ) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - fableWeekly: { - usedPercent: 64, - resetsAt: Date.parse('2026-07-24T20:00:00+00:00') - } - }) - }) - - it('supplements managed-account OAuth usage with Fable from the CLI usage panel', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'managed:account-1' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - five_hour: { used_percentage: 23.5, resets_at: 1770000000 }, - seven_day: { used_percentage: 41.2, resets_at: 1770604800 } - }), - { status: 200 } - ) - ) - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: { usedPercent: 91, windowMinutes: 300, resetsAt: null, resetDescription: null }, - weekly: null, - fableWeekly: { - usedPercent: 12.3, - windowMinutes: 10080, - resetsAt: null, - resetDescription: '3d 2h' - }, - updatedAt: 1, - error: null, - status: 'ok' - }) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 23.5, resetsAt: 1770000000000 }, - weekly: { usedPercent: 41.2, resetsAt: 1770604800000 }, - fableWeekly: { usedPercent: 12.3, resetDescription: '3d 2h' }, - usageMetadata: { - source: 'oauth', - attemptedSources: ['oauth', 'cli'] - } - }) - expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) - }) - - it('supplements system OAuth usage when the service explicitly allows usage-panel reads', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: null, - weekly: null, - fableWeekly: { - usedPercent: 58, - windowMinutes: 10080, - resetsAt: null, - resetDescription: '4d' - }, - updatedAt: 1, - error: null, - status: 'ok' - }) - - await expect( - fetchClaudeRateLimits({ - authPreparation, - allowPtyFallback: false, - allowUsagePanelSupplement: true - }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 }, - weekly: { usedPercent: 34 }, - fableWeekly: { usedPercent: 58, resetDescription: '4d' }, - usageMetadata: { - source: 'oauth', - attemptedSources: ['oauth', 'cli'] - } - }) - expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) - }) - - it('ignores bare Fable OAuth usage because the window length is ambiguous', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - five_hour: { utilization: 11 }, - seven_day: { utilization: 22 }, - fable: { utilization: 33 } - }), - { status: 200 } - ) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 11 }, - weekly: { usedPercent: 22 }, - fableWeekly: null - }) - }) -}) diff --git a/src/main/rate-limits/claude-fetcher-keychain-credentials.test.ts b/src/main/rate-limits/claude-fetcher-keychain-credentials.test.ts deleted file mode 100644 index 9a75d3826e5c..000000000000 --- a/src/main/rate-limits/claude-fetcher-keychain-credentials.test.ts +++ /dev/null @@ -1,491 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { join } from 'node:path' -import { fetchClaudeRateLimits } from './claude-fetcher' -import { primeClaudeFetcherMocks, restorePlatform } from './claude-fetcher-test-harness' -import { fetchViaPty } from './claude-pty' -import { - readActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentialsStrict -} from '../claude-accounts/keychain' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' - -const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( - () => ({ - netFetchMock: vi.fn(), - readFileMock: vi.fn(), - resolveProxyMock: vi.fn(), - setProxyMock: vi.fn(), - appGetPathMock: vi.fn() - }) -) - -vi.mock('node:fs/promises', () => ({ - readFile: readFileMock -})) - -vi.mock('electron', () => ({ - app: { - getPath: appGetPathMock - }, - net: { - fetch: netFetchMock - }, - session: { - defaultSession: { - resolveProxy: resolveProxyMock, - setProxy: setProxyMock - } - } -})) - -vi.mock('./claude-pty', () => ({ - fetchViaPty: vi.fn() -})) - -vi.mock('../claude-accounts/keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(), - writeManagedClaudeKeychainCredentials: vi.fn() -})) - -describe('fetchClaudeRateLimits', () => { - beforeEach(() => { - primeClaudeFetcherMocks({ - netFetchMock, - readFileMock, - resolveProxyMock, - setProxyMock, - appGetPathMock - }) - }) - - afterEach(() => { - restorePlatform() - }) - - it('does not read host credentials when WSL config resolution fails', async () => { - await expect( - fetchClaudeRateLimits({ - authPreparation: { - configDir: '/Users/test/.claude', - runtime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxConfigDir: null, - envPatch: {}, - stripAuthEnv: true, - provenance: 'wsl:Ubuntu:system' - } - }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: 'WSL Claude config unavailable for Ubuntu' - }) - - expect(readFileMock).not.toHaveBeenCalled() - expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalled() - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('reads scoped Keychain credentials when the Claude config dir is explicit', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - readFileMock.mockResolvedValue( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'file-oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 }, - weekly: { usedPercent: 34 } - }) - - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(configDir) - expect(netFetchMock).toHaveBeenCalledWith( - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer oauth-token', - 'User-Agent': 'claude-code/2.1.0' - }) - }) - ) - }) - - it('falls back to the legacy keychain token when the scoped token is rejected as stale', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) => - dir - ? JSON.stringify({ - claudeAiOauth: { accessToken: 'stale-scoped-token', refreshToken: 'refresh-1' } - }) - : JSON.stringify({ claudeAiOauth: { accessToken: 'fresh-legacy-token' } }) - ) - netFetchMock.mockImplementation(async (_url: string, init?: RequestInit) => { - const auth = (init?.headers as Record | undefined)?.Authorization - return auth === 'Bearer fresh-legacy-token' - ? new Response( - JSON.stringify({ five_hour: { utilization: 12 }, seven_day: { utilization: 34 } }), - { status: 200 } - ) - : new Response( - JSON.stringify({ error: { message: 'Invalid authentication credentials' } }), - { status: 401 } - ) - }) - - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 }, - weekly: { usedPercent: 34 } - }) - - expect(netFetchMock).toHaveBeenCalledTimes(2) - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('prefers a legacy access token over scoped refresh-only credentials', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) => - dir - ? JSON.stringify({ claudeAiOauth: { refreshToken: 'refresh-only' } }) - : JSON.stringify({ claudeAiOauth: { accessToken: 'fresh-legacy-token' } }) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 } - }) - - expect(netFetchMock).toHaveBeenCalledTimes(1) - expect(netFetchMock).toHaveBeenCalledWith( - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ Authorization: 'Bearer fresh-legacy-token' }) - }) - ) - }) - - it('does not retry with the legacy keychain for managed account credentials', async () => { - const configDir = '/Users/test/managed-account' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: true, - provenance: 'managed:account-1' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) => - dir ? JSON.stringify({ claudeAiOauth: { accessToken: 'stale-managed-token' } }) : null - ) - netFetchMock.mockResolvedValue( - new Response(JSON.stringify({ error: { message: 'Invalid authentication credentials' } }), { - status: 401 - }) - ) - - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'error' - }) - - expect(netFetchMock).toHaveBeenCalledTimes(1) - expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalledWith(undefined) - }) - - it('does not retry with the legacy keychain for WSL targets', async () => { - // Why: a WSL target's stale credentials must never be answered with the - // host user's legacy macOS Keychain account. - const configDir = '\\\\wsl$\\Ubuntu\\home\\test\\.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - runtime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxConfigDir: '/home/test/.claude', - envPatch: { CLAUDE_CONFIG_DIR: '/home/test/.claude' }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) => - dir - ? JSON.stringify({ claudeAiOauth: { accessToken: 'stale-wsl-token' } }) - : JSON.stringify({ claudeAiOauth: { accessToken: 'fresh-legacy-token' } }) - ) - netFetchMock.mockResolvedValue( - new Response(JSON.stringify({ error: { message: 'Invalid authentication credentials' } }), { - status: 401 - }) - ) - - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'error' - }) - - expect(netFetchMock).toHaveBeenCalledTimes(1) - expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalledWith(undefined) - }) - - it('skips the legacy retry when the legacy item mirrors the failed scoped token', async () => { - // Why: Claude's usage endpoint has a tight request budget; retrying the - // identical token would double the request for a guaranteed second 401. - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue( - JSON.stringify({ claudeAiOauth: { accessToken: 'mirrored-token' } }) - ) - netFetchMock.mockResolvedValue( - new Response(JSON.stringify({ error: { message: 'Invalid authentication credentials' } }), { - status: 401 - }) - ) - - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'error' - }) - - expect(netFetchMock).toHaveBeenCalledTimes(1) - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(undefined) - }) - - it('falls back to legacy Keychain credentials for host system default without an explicit config dir', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - runtime: 'host', - envPatch: {}, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict) - .mockResolvedValueOnce(null) - .mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'legacy-oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 }, - weekly: { usedPercent: 34 } - }) - - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(1, configDir) - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2, undefined) - expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled() - expect(netFetchMock).toHaveBeenCalledWith( - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer legacy-oauth-token' - }) - }) - ) - }) - - it('reads scoped Keychain credentials for host system default without an explicit config dir', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - runtime: 'host', - envPatch: {}, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'scoped-oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 }, - weekly: { usedPercent: 34 } - }) - - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(configDir) - expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled() - expect(fetchViaPty).not.toHaveBeenCalled() - expect(netFetchMock).toHaveBeenCalledWith( - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer scoped-oauth-token' - }) - }) - ) - }) - - it('falls back to the credentials file when Keychain access fails', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockRejectedValue( - new Error('Keychain locked') - ) - readFileMock.mockResolvedValue( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'file-oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok' - }) - - expect(readFileMock).toHaveBeenCalledWith( - join('/Users/test/.claude', '.credentials.json'), - 'utf-8' - ) - expect(netFetchMock).toHaveBeenCalledWith( - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer file-oauth-token' - }) - }) - ) - }) - - it('falls back to legacy Keychain when scoped credentials are unusable', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict) - .mockResolvedValueOnce('{not-json') - .mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'legacy-oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok' - }) - - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(1, configDir) - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2, undefined) - expect(netFetchMock).toHaveBeenCalledWith( - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer legacy-oauth-token' - }) - }) - ) - }) - - it('tries OAuth usage even when local credential metadata is expired', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'expired-oauth-token', - refreshToken: 'refresh-token', - expiresAt: Date.now() - 60_000 - } - }) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 } - }) - - expect(netFetchMock).toHaveBeenNthCalledWith( - 1, - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer expired-oauth-token' - }) - }) - ) - expect(readFileMock).not.toHaveBeenCalled() - expect(fetchViaPty).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts b/src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts deleted file mode 100644 index c01bcb00f6eb..000000000000 --- a/src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts +++ /dev/null @@ -1,425 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fetchManagedAccountUsage } from './claude-fetcher' -import { - primeClaudeFetcherMocks, - restorePlatform, - setPlatform -} from './claude-fetcher-test-harness' -import { fetchViaPty } from './claude-pty' -import { - deleteActiveClaudeKeychainCredentialsStrict, - readManagedClaudeKeychainCredentials, - writeActiveClaudeKeychainCredentials, - writeManagedClaudeKeychainCredentials -} from '../claude-accounts/keychain' - -const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( - () => ({ - netFetchMock: vi.fn(), - readFileMock: vi.fn(), - resolveProxyMock: vi.fn(), - setProxyMock: vi.fn(), - appGetPathMock: vi.fn() - }) -) - -vi.mock('node:fs/promises', () => ({ - readFile: readFileMock -})) - -vi.mock('electron', () => ({ - app: { - getPath: appGetPathMock - }, - net: { - fetch: netFetchMock - }, - session: { - defaultSession: { - resolveProxy: resolveProxyMock, - setProxy: setProxyMock - } - } -})) - -vi.mock('./claude-pty', () => ({ - fetchViaPty: vi.fn() -})) - -vi.mock('../claude-accounts/keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(), - writeManagedClaudeKeychainCredentials: vi.fn() -})) - -describe('fetchClaudeRateLimits', () => { - let tempDir: string | null = null - - beforeEach(() => { - tempDir = null - primeClaudeFetcherMocks({ - netFetchMock, - readFileMock, - resolveProxyMock, - setProxyMock, - appGetPathMock - }) - }) - - afterEach(() => { - restorePlatform() - if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }) - } - }) - - it('does not read inactive managed credentials from unowned auth paths', async () => { - setPlatform('linux') - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) - appGetPathMock.mockReturnValue(tempDir) - const unownedAuthPath = join(tempDir, 'unowned', 'auth') - mkdirSync(unownedAuthPath, { recursive: true }) - writeFileSync(join(unownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync( - join(unownedAuthPath, '.credentials.json'), - JSON.stringify({ - claudeAiOauth: { - accessToken: 'unowned-token', - expiresAt: Date.now() + 60_000 - } - }), - 'utf-8' - ) - - await expect( - fetchManagedAccountUsage({ id: 'account-1', managedAuthPath: unownedAuthPath }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: 'No credentials' - }) - - expect(netFetchMock).not.toHaveBeenCalled() - expect(readFileMock).not.toHaveBeenCalled() - }) - - it('supplements inactive managed account OAuth usage with Fable from its usage panel', async () => { - setPlatform('linux') - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) - appGetPathMock.mockReturnValue(tempDir) - const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(ownedAuthPath, { recursive: true }) - writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - const canonicalAuthPath = realpathSync(ownedAuthPath) - writeFileSync( - join(ownedAuthPath, '.credentials.json'), - JSON.stringify({ - claudeAiOauth: { - accessToken: 'inactive-token', - expiresAt: Date.now() + 60_000 - } - }), - 'utf-8' - ) - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: null, - weekly: null, - fableWeekly: { - usedPercent: 42, - windowMinutes: 10080, - resetsAt: null, - resetDescription: '2d' - }, - updatedAt: 1, - error: null, - status: 'ok' - }) - - await expect( - fetchManagedAccountUsage( - { id: 'account-1', managedAuthPath: ownedAuthPath }, - { allowUsagePanelSupplement: true } - ) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 }, - weekly: { usedPercent: 34 }, - fableWeekly: { usedPercent: 42, resetDescription: '2d' } - }) - expect(fetchViaPty).toHaveBeenCalledWith({ - authPreparation: expect.objectContaining({ - configDir: canonicalAuthPath, - envPatch: { CLAUDE_CONFIG_DIR: canonicalAuthPath }, - provenance: 'managed:account-1:inactive-preview', - stripAuthEnv: true - }) - }) - }) - - it('stages macOS inactive account credentials in a scoped Keychain for Fable preview', async () => { - setPlatform('darwin') - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) - appGetPathMock.mockReturnValue(tempDir) - const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - const credentialsJson = JSON.stringify({ - claudeAiOauth: { - accessToken: 'managed-keychain-token', - expiresAt: Date.now() + 60_000 - } - }) - mkdirSync(ownedAuthPath, { recursive: true }) - writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - const canonicalAuthPath = realpathSync(ownedAuthPath) - vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce(credentialsJson) - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: { - usedPercent: 12, - windowMinutes: 300, - resetsAt: null, - resetDescription: null - }, - weekly: { - usedPercent: 34, - windowMinutes: 10080, - resetsAt: null, - resetDescription: null - }, - fableWeekly: { - usedPercent: 58, - windowMinutes: 10080, - resetsAt: null, - resetDescription: '3d' - }, - updatedAt: 1, - error: null, - status: 'ok' - }) - - const result = await fetchManagedAccountUsage( - { id: 'account-1', managedAuthPath: ownedAuthPath }, - { allowUsagePanelSupplement: true } - ) - - expect(result.fableWeekly).toMatchObject({ usedPercent: 58, resetDescription: '3d' }) - expect(writeActiveClaudeKeychainCredentials).toHaveBeenCalledWith( - credentialsJson, - canonicalAuthPath - ) - expect(deleteActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(canonicalAuthPath) - }) - - it('cleans up scoped Keychain credentials when the inactive preview fails', async () => { - setPlatform('darwin') - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) - appGetPathMock.mockReturnValue(tempDir) - const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(ownedAuthPath, { recursive: true }) - writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - const canonicalAuthPath = realpathSync(ownedAuthPath) - vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce( - JSON.stringify({ claudeAiOauth: { accessToken: 'managed-keychain-token' } }) - ) - vi.mocked(fetchViaPty).mockRejectedValueOnce(new Error('preview failed')) - - await expect( - fetchManagedAccountUsage( - { id: 'account-1', managedAuthPath: ownedAuthPath }, - { allowUsagePanelSupplement: true } - ) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 }, - weekly: { usedPercent: 34 }, - fableWeekly: null - }) - - expect(deleteActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(canonicalAuthPath) - }) - - it('stages refreshed macOS inactive account credentials before Fable preview', async () => { - setPlatform('darwin') - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) - appGetPathMock.mockReturnValue(tempDir) - const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - const staleCredentialsJson = JSON.stringify({ - claudeAiOauth: { - accessToken: 'stale-access', - refreshToken: 'stale-refresh', - expiresAt: Date.now() - 60_000 - } - }) - mkdirSync(ownedAuthPath, { recursive: true }) - writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce(staleCredentialsJson) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - access_token: 'fresh-access', - expires_in: 3600, - refresh_token: 'fresh-refresh' - }), - { status: 200 } - ) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ five_hour: { utilization: 12 }, seven_day: { utilization: 34 } }), - { - status: 200 - } - ) - ) - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: { - usedPercent: 12, - windowMinutes: 300, - resetsAt: null, - resetDescription: null - }, - weekly: { - usedPercent: 34, - windowMinutes: 10080, - resetsAt: null, - resetDescription: null - }, - fableWeekly: { - usedPercent: 58, - windowMinutes: 10080, - resetsAt: null, - resetDescription: '3d' - }, - updatedAt: 1, - error: null, - status: 'ok' - }) - - const result = await fetchManagedAccountUsage( - { id: 'account-1', managedAuthPath: ownedAuthPath }, - { allowUsagePanelSupplement: true } - ) - - const stagedCredentialsJson = vi.mocked(writeActiveClaudeKeychainCredentials).mock.calls[0]?.[0] - expect(result.fableWeekly).toMatchObject({ usedPercent: 58, resetDescription: '3d' }) - expect(JSON.parse(stagedCredentialsJson ?? '{}')).toMatchObject({ - claudeAiOauth: { - accessToken: 'fresh-access', - refreshToken: 'fresh-refresh' - } - }) - expect(writeManagedClaudeKeychainCredentials).toHaveBeenCalledWith( - 'account-1', - stagedCredentialsJson - ) - }) - - it('does not merge macOS inactive Fable preview when usage windows belong to another account', async () => { - setPlatform('darwin') - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) - appGetPathMock.mockReturnValue(tempDir) - const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(ownedAuthPath, { recursive: true }) - writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'managed-keychain-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: { - usedPercent: 91, - windowMinutes: 300, - resetsAt: null, - resetDescription: null - }, - weekly: { - usedPercent: 3, - windowMinutes: 10080, - resetsAt: null, - resetDescription: null - }, - fableWeekly: { - usedPercent: 58, - windowMinutes: 10080, - resetsAt: null, - resetDescription: '3d' - }, - updatedAt: 1, - error: null, - status: 'ok' - }) - - const result = await fetchManagedAccountUsage( - { id: 'account-1', managedAuthPath: ownedAuthPath }, - { allowUsagePanelSupplement: true } - ) - - expect(result.fableWeekly).toBeNull() - }) - - it('refreshes and persists an expiring inactive account before fetching usage', async () => { - setPlatform('linux') - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) - appGetPathMock.mockReturnValue(tempDir) - const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(ownedAuthPath, { recursive: true }) - writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - const credentialsPath = join(ownedAuthPath, '.credentials.json') - writeFileSync( - credentialsPath, - JSON.stringify({ - claudeAiOauth: { - accessToken: 'stale-access', - refreshToken: 'stale-refresh', - expiresAt: Date.now() - 60_000 - } - }), - 'utf-8' - ) - - // First net.fetch call is the OAuth refresh (token endpoint); second is the - // usage fetch with the refreshed access token. - netFetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - access_token: 'fresh-access', - expires_in: 3600, - refresh_token: 'fresh-refresh' - }) - }) - netFetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({ five_hour: { utilization: 12 }, seven_day: { utilization: 34 } }) - }) - - const result = await fetchManagedAccountUsage({ - id: 'account-1', - managedAuthPath: ownedAuthPath - }) - - expect(result.status).toBe('ok') - // Rotated token persisted back to managed storage. - const persisted = JSON.parse(readFileSync(credentialsPath, 'utf-8')) - expect(persisted.claudeAiOauth.accessToken).toBe('fresh-access') - expect(persisted.claudeAiOauth.refreshToken).toBe('fresh-refresh') - // Usage fetch used the fresh access token. - const usageCall = netFetchMock.mock.calls.find(([url]) => - String(url).includes('/api/oauth/usage') - ) - expect(usageCall?.[1]?.headers?.Authorization).toBe('Bearer fresh-access') - }) -}) diff --git a/src/main/rate-limits/claude-fetcher-test-harness.ts b/src/main/rate-limits/claude-fetcher-test-harness.ts deleted file mode 100644 index f679d8388337..000000000000 --- a/src/main/rate-limits/claude-fetcher-test-harness.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { vi } from 'vitest' -import type { Mock } from 'vitest' -import { fetchViaPty } from './claude-pty' -import { - deleteActiveClaudeKeychainCredentialsStrict, - readActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentialsStrict, - readManagedClaudeKeychainCredentials, - writeActiveClaudeKeychainCredentials, - writeManagedClaudeKeychainCredentials -} from '../claude-accounts/keychain' - -/** Electron/fs mocks each claude-fetcher test file declares via its own `vi.mock` factories. */ -export type ClaudeFetcherHoistedMocks = { - netFetchMock: Mock - readFileMock: Mock - resolveProxyMock: Mock - setProxyMock: Mock - appGetPathMock: Mock -} - -const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - -export function setPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { - configurable: true, - value: platform - }) -} - -export function restorePlatform(): void { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } -} - -/** Default happy-path state: darwin, no keychain credentials, OAuth usage 12/34, PTY session 56. */ -export function primeClaudeFetcherMocks(mocks: ClaudeFetcherHoistedMocks): void { - setPlatform('darwin') - vi.clearAllMocks() - mocks.readFileMock.mockRejectedValue(new Error('missing file')) - vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null) - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue(null) - vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValue(null) - vi.mocked(writeActiveClaudeKeychainCredentials).mockResolvedValue() - vi.mocked(deleteActiveClaudeKeychainCredentialsStrict).mockResolvedValue() - vi.mocked(writeManagedClaudeKeychainCredentials).mockResolvedValue() - mocks.appGetPathMock.mockReturnValue('/tmp/orca-claude-fetcher-test') - mocks.resolveProxyMock.mockResolvedValue('DIRECT') - mocks.netFetchMock.mockResolvedValue( - new Response( - JSON.stringify({ - five_hour: { utilization: 12 }, - seven_day: { utilization: 34 } - }), - { status: 200 } - ) - ) - vi.mocked(fetchViaPty).mockResolvedValue({ - provider: 'claude', - session: { usedPercent: 56, windowMinutes: 300, resetsAt: null, resetDescription: null }, - weekly: null, - updatedAt: 1, - error: null, - status: 'ok' - }) -} diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index 42427766a26f..9b03a2eb9713 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -1,7 +1,9 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types' import { fetchActiveClaudeRateLimits } from './claude-active-usage-fetch' -import type { InactiveClaudeAccount } from './claude-managed-account-credentials' -import { fetchInactiveClaudeAccountUsage } from './claude-managed-account-usage' +import { + fetchInactiveClaudeAccountUsage, + type InactiveClaudeAccount +} from './claude-managed-account-usage' import type { ClaudeManagedAccountUsageOptions, ClaudeRateLimitFetchOptions diff --git a/src/main/rate-limits/claude-managed-account-credentials.ts b/src/main/rate-limits/claude-managed-account-credentials.ts deleted file mode 100644 index 71d505510cf1..000000000000 --- a/src/main/rate-limits/claude-managed-account-credentials.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { existsSync, lstatSync, readFileSync } from 'node:fs' -import path from 'node:path' -import { parseWslUncPath } from '../../shared/wsl-paths' -import { - deleteActiveClaudeKeychainCredentialsStrict, - readActiveClaudeKeychainCredentialsStrict, - readManagedClaudeKeychainCredentials, - writeActiveClaudeKeychainCredentials, - writeManagedClaudeKeychainCredentials -} from '../claude-accounts/keychain' -import { - readClaudeManagedAuthFile, - resolveOwnedClaudeManagedAuthPath, - writeClaudeManagedAuthFile -} from '../claude-accounts/managed-auth-path' - -export type InactiveClaudeAccount = { - id: string - managedAuthPath: string - managedAuthRuntime?: 'host' | 'wsl' - wslDistro?: string | null - wslLinuxAuthPath?: string | null -} - -export type ClaudeManagedCredentialsLocation = - | { kind: 'keychain'; accountId: string; managedAuthPath: string } - | { kind: 'file'; managedAuthPath: string } - -export function resolveClaudeManagedCredentialsLocation( - account: InactiveClaudeAccount -): ClaudeManagedCredentialsLocation | null { - if (account.managedAuthRuntime === 'wsl') { - const managedAuthPath = resolveOwnedWslClaudeManagedAuthPath(account) - return managedAuthPath ? { kind: 'file', managedAuthPath } : null - } - const managedAuthPath = resolveOwnedClaudeManagedAuthPath(account.id, account.managedAuthPath, { - adoptLegacyMarker: true - }) - if (!managedAuthPath) { - return null - } - return process.platform === 'darwin' - ? { kind: 'keychain', accountId: account.id, managedAuthPath } - : { kind: 'file', managedAuthPath } -} - -export async function readClaudeManagedCredentialsJson( - location: ClaudeManagedCredentialsLocation -): Promise { - try { - return location.kind === 'keychain' - ? await readManagedClaudeKeychainCredentials(location.accountId) - : readClaudeManagedAuthFile(location.managedAuthPath, '.credentials.json') - } catch { - return null - } -} - -export async function writeClaudeManagedCredentialsJson( - location: ClaudeManagedCredentialsLocation, - credentialsJson: string -): Promise { - if (location.kind === 'keychain') { - await writeManagedClaudeKeychainCredentials(location.accountId, credentialsJson) - } else { - writeClaudeManagedAuthFile(location.managedAuthPath, '.credentials.json', credentialsJson) - } -} - -function resolveOwnedWslClaudeManagedAuthPath(account: InactiveClaudeAccount): string | null { - if (process.platform !== 'win32') { - return null - } - const wslInfo = parseWslUncPath(account.managedAuthPath) - if (!wslInfo || (account.wslDistro && wslInfo.distro !== account.wslDistro)) { - return null - } - const linuxPath = account.wslLinuxAuthPath ?? wslInfo.linuxPath - if ( - !linuxPath.includes('/.local/share/orca/claude-accounts/') || - !linuxPath.endsWith(`/${account.id}/auth`) - ) { - return null - } - try { - const markerPath = path.join(account.managedAuthPath, '.orca-managed-claude-auth') - if ( - !existsSync(markerPath) || - lstatSync(markerPath).isSymbolicLink() || - readFileSync(markerPath, 'utf-8').trim() !== account.id - ) { - return null - } - return account.managedAuthPath - } catch { - return null - } -} - -export async function withClaudeManagedPreviewKeychainCredentials( - location: ClaudeManagedCredentialsLocation, - credentialsJson: string, - operation: () => Promise -): Promise { - if (location.kind !== 'keychain') { - return operation() - } - await writeActiveClaudeKeychainCredentials(credentialsJson, location.managedAuthPath) - try { - return await operation() - } finally { - await deleteActiveClaudeKeychainCredentialsStrict(location.managedAuthPath).catch(() => {}) - } -} - -export async function readStagedClaudeManagedPreviewCredentials( - location: ClaudeManagedCredentialsLocation -): Promise { - if (location.kind !== 'keychain') { - return null - } - try { - return await readActiveClaudeKeychainCredentialsStrict(location.managedAuthPath) - } catch { - return null - } -} diff --git a/src/main/rate-limits/claude-managed-account-usage.ts b/src/main/rate-limits/claude-managed-account-usage.ts index ddf7b3df7508..b2e419f8bb0e 100644 --- a/src/main/rate-limits/claude-managed-account-usage.ts +++ b/src/main/rate-limits/claude-managed-account-usage.ts @@ -1,104 +1,66 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types' -import { - isOauthTokenExpiring, - refreshClaudeOauthCredentials -} from '../claude-accounts/oauth-refresh' -import { - readClaudeManagedCredentialsJson, - resolveClaudeManagedCredentialsLocation, - writeClaudeManagedCredentialsJson, - type InactiveClaudeAccount -} from './claude-managed-account-credentials' -import { fetchClaudeManagedUsagePanelSupplement } from './claude-managed-usage-panel' -import { parseClaudeOAuthCredentialsJson } from './claude-oauth-credentials' -import { fetchClaudeOAuthUsage } from './claude-oauth-usage-request' +import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' +import { savedWslClaudeAccountHome } from '../claude-accounts/claude-profile-wsl-paths' +import { toWindowsWslPath } from '../../shared/wsl-paths' import type { ClaudeManagedAccountUsageOptions } from './claude-usage-fetch-options' -import { - abortedClaudeRateLimitResult, - canSupplementClaudeOAuthUsage, - mergeClaudeUsageWindows, - warnClaudeUsageFetchFailure -} from './claude-usage-result' +import { fetchActiveClaudeRateLimits } from './claude-active-usage-fetch' +import { claudeUsageUnavailable, makeClaudeUsageResult } from './claude-usage-result' +import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter' -function noClaudeManagedCredentialsResult(): ProviderRateLimits { - return { - provider: 'claude', - session: null, - weekly: null, - updatedAt: Date.now(), - error: 'No credentials', - status: 'error' - } +/** Usage reads the account's own folder; a host account's comes from its id, never a stored path. */ +export type InactiveClaudeAccount = { + id: string + managedAuthRuntime?: 'host' | 'wsl' + wslDistro?: string | null + wslLinuxAuthPath?: string | null } export async function fetchInactiveClaudeAccountUsage( account: InactiveClaudeAccount, options: ClaudeManagedAccountUsageOptions = {} ): Promise { - if (options.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - const location = resolveClaudeManagedCredentialsLocation(account) - let credentialsJson = location ? await readClaudeManagedCredentialsJson(location) : null - if (options.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - if (!location || !credentialsJson) { - return noClaudeManagedCredentialsResult() - } - - let token = parseClaudeOAuthCredentialsJson(credentialsJson, 'credentials-file').token - if (isOauthTokenExpiring(credentialsJson)) { - const refreshed = await refreshClaudeOauthCredentials(credentialsJson) - if (options.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - if (refreshed) { - try { - await writeClaudeManagedCredentialsJson(location, refreshed) - } catch { - // Keep the refreshed token for this fetch; a later poll can persist it. - } - credentialsJson = refreshed - token = parseClaudeOAuthCredentialsJson(refreshed, 'credentials-file').token - } - } - - if (!token) { - return noClaudeManagedCredentialsResult() - } - const oauthLimits = await fetchClaudeOAuthUsage(token, options.signal) - if (options.signal?.aborted) { - return abortedClaudeRateLimitResult() + const configDir = + account.managedAuthRuntime === 'wsl' + ? savedWslClaudeAccountHome(account) + : (getClaudeProfileRouter()?.accountHome(account.id) ?? null) + if (!configDir || (account.managedAuthRuntime === 'wsl' && !account.wslDistro)) { + return makeClaudeUsageResult('error', 'Sign in again to use this account.', { + failureKind: 'missing-credentials', + attemptedSources: [] + }) } + const home = + account.managedAuthRuntime === 'wsl' && account.wslDistro + ? toWindowsWslPath(configDir, account.wslDistro) + : configDir + // Why: a stopped distro parks a UNC read for minutes, and the inactive loop is sequential. if ( - !canSupplementClaudeOAuthUsage({ - oauthLimits, - authPreparation: undefined, - allowUsagePanelSupplement: options.allowUsagePanelSupplement === true - }) + account.managedAuthRuntime === 'wsl' && + (await filterPathsToRunningWslDistrosAsync([home], { requireConfirmed: true })).length === 0 ) { - return oauthLimits + return claudeUsageUnavailable() } - - try { - return mergeClaudeUsageWindows( - oauthLimits, - await fetchClaudeManagedUsagePanelSupplement({ - account, - location, - credentialsJson, - oauthLimits, - networkProxySettings: options.networkProxySettings, - signal: options.signal - }) - ) - } catch (error) { - warnClaudeUsageFetchFailure( - undefined, - parseClaudeOAuthCredentialsJson(credentialsJson, 'credentials-file'), - error - ) - return oauthLimits + const read = fetchActiveClaudeRateLimits({ + signal: options.signal, + authPreparation: { + configDir: home, + runtime: account.managedAuthRuntime, + wslDistro: account.wslDistro, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: true, + provenance: `profile:${account.id}` + } + }) + if (account.managedAuthRuntime !== 'wsl') { + return read } + const timeout = AbortSignal.timeout(INACTIVE_WSL_READ_TIMEOUT_MS) + return Promise.race([ + read, + new Promise((resolve) => + timeout.addEventListener('abort', () => resolve(claudeUsageUnavailable()), { once: true }) + ) + ]) } + +const INACTIVE_WSL_READ_TIMEOUT_MS = 10_000 diff --git a/src/main/rate-limits/claude-managed-usage-panel.ts b/src/main/rate-limits/claude-managed-usage-panel.ts deleted file mode 100644 index 15c1e90169c7..000000000000 --- a/src/main/rate-limits/claude-managed-usage-panel.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' -import type { NetworkProxySettings } from '../../shared/network-proxy' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -import { fetchViaPty } from './claude-pty' -import { - readStagedClaudeManagedPreviewCredentials, - withClaudeManagedPreviewKeychainCredentials, - writeClaudeManagedCredentialsJson, - type ClaudeManagedCredentialsLocation, - type InactiveClaudeAccount -} from './claude-managed-account-credentials' - -function getManagedUsagePanelAuthPreparation( - account: InactiveClaudeAccount, - location: ClaudeManagedCredentialsLocation -): ClaudeRuntimeAuthPreparation | null { - if (process.platform === 'win32') { - return null - } - if (account.managedAuthRuntime === 'wsl') { - if (!account.wslLinuxAuthPath || !account.wslDistro) { - return null - } - return { - configDir: location.managedAuthPath, - runtime: 'wsl', - wslDistro: account.wslDistro, - wslLinuxConfigDir: account.wslLinuxAuthPath, - envPatch: { CLAUDE_CONFIG_DIR: account.wslLinuxAuthPath }, - stripAuthEnv: true, - provenance: `managed:${account.id}:inactive-preview` - } - } - return { - configDir: location.managedAuthPath, - runtime: 'host', - wslDistro: null, - wslLinuxConfigDir: null, - envPatch: { CLAUDE_CONFIG_DIR: location.managedAuthPath }, - stripAuthEnv: true, - provenance: `managed:${account.id}:inactive-preview` - } -} - -function windowsAgree(left: RateLimitWindow | null, right: RateLimitWindow | null): boolean { - return Boolean(left && right && Math.abs(left.usedPercent - right.usedPercent) <= 1) -} - -function canTrustManagedUsagePanelSupplement( - oauthLimits: ProviderRateLimits, - cliLimits: ProviderRateLimits, - requireMatchingOAuthWindow: boolean -): boolean { - if (!requireMatchingOAuthWindow) { - return true - } - const sharedWindowMatches = [ - oauthLimits.session && cliLimits.session - ? windowsAgree(oauthLimits.session, cliLimits.session) - : null, - oauthLimits.weekly && cliLimits.weekly - ? windowsAgree(oauthLimits.weekly, cliLimits.weekly) - : null - ].filter((match): match is boolean => match !== null) - // Why: older Claude builds may ignore the scoped Keychain and expose the active account. - return sharedWindowMatches.length > 0 && sharedWindowMatches.every(Boolean) -} - -export async function fetchClaudeManagedUsagePanelSupplement(input: { - account: InactiveClaudeAccount - location: ClaudeManagedCredentialsLocation - credentialsJson: string - oauthLimits: ProviderRateLimits - networkProxySettings?: NetworkProxySettings - signal?: AbortSignal -}): Promise { - if (input.signal?.aborted) { - return null - } - const authPreparation = getManagedUsagePanelAuthPreparation(input.account, input.location) - if (!authPreparation) { - return null - } - - return withClaudeManagedPreviewKeychainCredentials( - input.location, - input.credentialsJson, - async () => { - const cliLimits = await fetchViaPty({ - authPreparation, - networkProxySettings: input.networkProxySettings, - signal: input.signal - }) - if (input.signal?.aborted) { - return null - } - if ( - !canTrustManagedUsagePanelSupplement( - input.oauthLimits, - cliLimits, - input.location.kind === 'keychain' - ) - ) { - return null - } - const refreshed = await readStagedClaudeManagedPreviewCredentials(input.location) - if (refreshed && refreshed !== input.credentialsJson) { - await writeClaudeManagedCredentialsJson(input.location, refreshed) - } - return cliLimits - } - ) -} diff --git a/src/main/rate-limits/claude-oauth-credentials.ts b/src/main/rate-limits/claude-oauth-credentials.ts index 3e14fb2909e7..7fa4d04fbb92 100644 --- a/src/main/rate-limits/claude-oauth-credentials.ts +++ b/src/main/rate-limits/claude-oauth-credentials.ts @@ -1,160 +1,137 @@ import { readFile } from 'node:fs/promises' import { homedir } from 'node:os' import path from 'node:path' -import { - readActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentialsStrict -} from '../claude-accounts/keychain' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' - -type ClaudeCredentials = { - claudeAiOauth?: { - accessToken?: string - refreshToken?: string - expiresAt?: number - } -} +import { z } from 'zod' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { readActiveClaudeKeychainCredentialsStrict } from '../claude-accounts/keychain' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth/runtime-auth-types' +import { readUserClaudeConfigDir } from '../claude-accounts/claude-profile-paths' +import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' export type ClaudeOAuthCredentialSource = | 'scoped-keychain' | 'legacy-keychain' | 'credentials-file' | 'none' - export type ClaudeOAuthCredentialReadResult = { token: string | null hasRefreshableCredentials: boolean source: ClaudeOAuthCredentialSource keychainUnavailable?: boolean + unavailable?: boolean } - type ClaudeOAuthCredentialReadOptions = { credentialsFileConfigDir?: string keychainConfigDir?: string + /** System Default only: a managed profile never reads another login's item. */ + unsuffixedKeychainFallback?: boolean } - +const schema = z.object({ + claudeAiOauth: z + .object({ + accessToken: z.string().optional(), + refreshToken: z.string().optional() + }) + .optional() +}) export function parseClaudeOAuthCredentialsJson( raw: string, source: ClaudeOAuthCredentialSource ): ClaudeOAuthCredentialReadResult { try { - const oauth = (JSON.parse(raw) as ClaudeCredentials)?.claudeAiOauth - const hasRefreshableCredentials = - typeof oauth?.refreshToken === 'string' && oauth.refreshToken.trim() !== '' - if (!oauth?.accessToken || typeof oauth.accessToken !== 'string') { - return { token: null, hasRefreshableCredentials, source } - } + const oauth = schema.parse(JSON.parse(raw)).claudeAiOauth // Why: expiresAt is not authoritative for the usage endpoint; let the server decide. - return { token: oauth.accessToken, hasRefreshableCredentials, source } + return { + token: oauth?.accessToken || null, + hasRefreshableCredentials: Boolean(oauth?.refreshToken), + source + } } catch { - return emptyClaudeOAuthCredentialReadResult() + return { ...emptyClaudeOAuthCredentialReadResult(), unavailable: true } } } - export function emptyClaudeOAuthCredentialReadResult(): ClaudeOAuthCredentialReadResult { return { token: null, hasRefreshableCredentials: false, source: 'none' } } - -function unavailableKeychainResult(): ClaudeOAuthCredentialReadResult { - return { - token: null, - hasRefreshableCredentials: false, - source: 'none', - keychainUnavailable: true - } -} - -async function readFromKeychain(configDir?: string): Promise { - if (process.platform !== 'darwin') { - return emptyClaudeOAuthCredentialReadResult() - } - - if (configDir) { - const scoped = await readClaudeCredentialsFromStrictKeychain(configDir, 'scoped-keychain') - if (scoped.token) { - return scoped - } - - const legacy = await readClaudeCredentialsFromStrictKeychain(undefined, 'legacy-keychain') - // Why: a real access token must not be shadowed by scoped refresh-only credentials. - if (legacy.token) { - return legacy - } - if (scoped.hasRefreshableCredentials) { - return scoped - } - if (legacy.hasRefreshableCredentials) { - return legacy - } - return scoped.keychainUnavailable || legacy.keychainUnavailable - ? unavailableKeychainResult() - : legacy - } - - try { - const credentials = await readActiveClaudeKeychainCredentials(configDir) - return credentials - ? parseClaudeOAuthCredentialsJson(credentials, 'legacy-keychain') - : emptyClaudeOAuthCredentialReadResult() - } catch { - return unavailableKeychainResult() - } -} - export async function readClaudeCredentialsFromStrictKeychain( configDir: string | undefined, source: ClaudeOAuthCredentialSource ): Promise { try { - const credentials = await readActiveClaudeKeychainCredentialsStrict(configDir) - return credentials - ? parseClaudeOAuthCredentialsJson(credentials, source) + const contents = await readActiveClaudeKeychainCredentialsStrict(configDir) + return contents + ? parseClaudeOAuthCredentialsJson(contents, source) : emptyClaudeOAuthCredentialReadResult() } catch { - return unavailableKeychainResult() + return { + ...emptyClaudeOAuthCredentialReadResult(), + unavailable: true, + keychainUnavailable: true + } } } - -async function readFromCredentialsFile( - configDir?: string +async function readClaudeKeychainCredentials( + options?: ClaudeOAuthCredentialReadOptions ): Promise { - const credentialPath = path.join( - configDir ?? path.join(homedir(), '.claude'), - '.credentials.json' + if (!options?.keychainConfigDir) { + return readClaudeCredentialsFromStrictKeychain(undefined, 'legacy-keychain') + } + const scoped = await readClaudeCredentialsFromStrictKeychain( + options.keychainConfigDir, + 'scoped-keychain' ) - try { - return parseClaudeOAuthCredentialsJson( - await readFile(credentialPath, 'utf-8'), - 'credentials-file' - ) - } catch { - return emptyClaudeOAuthCredentialReadResult() + if (scoped.token || !options.unsuffixedKeychainFallback) { + return scoped } + const legacy = await readClaudeCredentialsFromStrictKeychain(undefined, 'legacy-keychain') + // Why: as before profiles, a token wins over an unreadable item. + const candidates = [scoped, legacy] + return ( + candidates.find((candidate) => candidate.token) ?? + candidates.find((candidate) => candidate.unavailable) ?? + legacy + ) } - export async function readClaudeOAuthCredentials( options?: ClaudeOAuthCredentialReadOptions ): Promise { - const keychain = await readFromKeychain(options?.keychainConfigDir) - if (keychain.token || keychain.hasRefreshableCredentials) { + const keychain = + process.platform === 'darwin' + ? await readClaudeKeychainCredentials(options) + : emptyClaudeOAuthCredentialReadResult() + if (keychain.token) { return keychain } - - const file = await readFromCredentialsFile(options?.credentialsFileConfigDir) - if (file.token || file.hasRefreshableCredentials) { - return file + try { + return parseClaudeOAuthCredentialsJson( + await readFile( + path.join( + options?.credentialsFileConfigDir ?? path.join(homedir(), '.claude'), + '.credentials.json' + ), + 'utf8' + ), + 'credentials-file' + ) + } catch (error) { + return isDefinitiveAbsence(error) ? keychain : { ...keychain, unavailable: true } } - return keychain.keychainUnavailable ? keychain : emptyClaudeOAuthCredentialReadResult() } - export function resolveClaudeOAuthCredentialReadOptions( authPreparation?: ClaudeRuntimeAuthPreparation ): ClaudeOAuthCredentialReadOptions | undefined { if (!authPreparation) { return undefined } + const accountDir = authPreparation.envPatch.CLAUDE_CONFIG_DIR return { credentialsFileConfigDir: authPreparation.configDir, - keychainConfigDir: authPreparation.configDir + // Why the user's own folder, login shell first: Claude names System default's item from it. + keychainConfigDir: + accountDir ?? + getClaudeProfileRouter()?.userConfigDir() ?? + readUserClaudeConfigDir(process.env), + // An unsuffixed lookup is exclusively System default, never a fallback from an account. + unsuffixedKeychainFallback: !accountDir } } diff --git a/src/main/rate-limits/claude-oauth-recovery.ts b/src/main/rate-limits/claude-oauth-recovery.ts deleted file mode 100644 index 58c6bf91cb37..000000000000 --- a/src/main/rate-limits/claude-oauth-recovery.ts +++ /dev/null @@ -1,172 +0,0 @@ -import type { ProviderRateLimits } from '../../shared/rate-limit-types' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' -import { completeClaudeOAuthUsageSuccess, fetchClaudeUsageViaCli } from './claude-cli-usage-fetch' -import { - readClaudeCredentialsFromStrictKeychain, - readClaudeOAuthCredentials, - resolveClaudeOAuthCredentialReadOptions, - type ClaudeOAuthCredentialReadResult -} from './claude-oauth-credentials' -import { OAuthUsageError } from './claude-oauth-usage-error' -import { fetchClaudeOAuthUsage } from './claude-oauth-usage-request' -import type { ClaudeUsageErrorClassification } from './claude-usage-error-classification' -import type { ClaudeRateLimitFetchOptions } from './claude-usage-fetch-options' -import { - abortedClaudeRateLimitResult, - isManagedClaudeAuth, - makeClaudeUsageResult, - mergeClaudeUsageWindows, - metadataForClaudeUsageAttempt, - recordClaudeUsageAttempt, - type ClaudeUsageAttemptState, - warnClaudeUsageFetchFailure, - withClaudeUsageMetadata -} from './claude-usage-result' - -const LIVE_REFRESH_DEFERRED_MESSAGE = - 'Claude usage refresh is waiting for the live Claude terminal to rotate its credentials.' - -export function canRetryClaudeOAuthWithLegacyKeychain(input: { - classification: ClaudeUsageErrorClassification - oauthCredentials: ClaudeOAuthCredentialReadResult - authPreparation?: ClaudeRuntimeAuthPreparation -}): boolean { - return ( - input.classification.failureKind === 'stale-token' && - input.oauthCredentials.source === 'scoped-keychain' && - (input.authPreparation?.runtime ?? 'host') === 'host' && - !isManagedClaudeAuth(input.authPreparation) - ) -} - -export async function retryClaudeOAuthWithLegacyKeychain(input: { - failedToken: string | null - attempts: ClaudeUsageAttemptState - options?: ClaudeRateLimitFetchOptions -}): Promise { - const legacy = await readClaudeCredentialsFromStrictKeychain(undefined, 'legacy-keychain') - if (!legacy.token || legacy.token === input.failedToken) { - return null - } - if (input.options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - try { - const limits = await fetchClaudeOAuthUsage(legacy.token, input.options?.signal) - if (input.options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - return await completeClaudeOAuthUsageSuccess({ - oauthLimits: limits, - oauthCredentials: legacy, - attempts: input.attempts, - options: input.options - }) - } catch (error) { - warnClaudeUsageFetchFailure(input.options?.authPreparation, legacy, error) - return null - } -} - -export function shouldDeferClaudeUsageForLiveSession( - authPreparation: ClaudeRuntimeAuthPreparation | undefined, - classification: ClaudeUsageErrorClassification -): boolean { - return Boolean( - authPreparation?.managedRefreshDeferredByLivePty && - (classification.failureKind === 'stale-token' || - classification.failureKind === 'refreshable-credentials-without-token' || - classification.failureKind === 'deferred-by-live-session') - ) -} - -export function makeLiveClaudeUsageDeferredResult(input: { - attempts: ClaudeUsageAttemptState - oauthCredentials: ClaudeOAuthCredentialReadResult - authPreparation?: ClaudeRuntimeAuthPreparation -}): ProviderRateLimits { - return makeClaudeUsageResult('error', LIVE_REFRESH_DEFERRED_MESSAGE, { - ...metadataForClaudeUsageAttempt({ - attemptedSources: input.attempts.attemptedSources, - oauthCredentials: input.oauthCredentials, - authPreparation: input.authPreparation, - failureKind: 'deferred-by-live-session', - deferredByLiveClaudeSession: true - }) - }) -} - -export function makeClaudeUsageClassificationError(input: { - error: unknown - classification: ClaudeUsageErrorClassification - attempts: ClaudeUsageAttemptState - oauthCredentials: ClaudeOAuthCredentialReadResult - authPreparation?: ClaudeRuntimeAuthPreparation -}): ProviderRateLimits { - const message = - input.error instanceof Error ? input.error.message : String(input.error || 'Unknown error') - const retryAfterMs = input.error instanceof OAuthUsageError ? input.error.retryAfterMs : null - return makeClaudeUsageResult('error', withMacTailscaleDnsHint(message), { - ...metadataForClaudeUsageAttempt({ - attemptedSources: input.attempts.attemptedSources, - oauthCredentials: input.oauthCredentials, - authPreparation: input.authPreparation, - failureKind: input.classification.failureKind, - retryAtMs: retryAfterMs ? Date.now() + retryAfterMs : undefined - }) - }) -} - -export async function repairClaudeCredentialsThenRetryOAuth(input: { - options?: ClaudeRateLimitFetchOptions - attempts: ClaudeUsageAttemptState - oauthCredentials: ClaudeOAuthCredentialReadResult -}): Promise { - if (input.options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - let cliResult: ProviderRateLimits | null = null - try { - cliResult = await fetchClaudeUsageViaCli({ - authPreparation: input.options?.authPreparation, - oauthCredentials: input.oauthCredentials, - attempts: input.attempts, - networkProxySettings: input.options?.networkProxySettings, - signal: input.options?.signal - }) - } catch (error) { - warnClaudeUsageFetchFailure(input.options?.authPreparation, input.oauthCredentials, error) - } - - if (input.options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - const refreshed = await readClaudeOAuthCredentials( - resolveClaudeOAuthCredentialReadOptions(input.options?.authPreparation) - ) - if (input.options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - if (refreshed.token) { - recordClaudeUsageAttempt(input.attempts, 'oauth') - try { - const retry = await fetchClaudeOAuthUsage(refreshed.token, input.options?.signal) - if (input.options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - return withClaudeUsageMetadata( - mergeClaudeUsageWindows(retry, cliResult), - metadataForClaudeUsageAttempt({ - attemptedSources: input.attempts.attemptedSources, - oauthCredentials: refreshed, - authPreparation: input.options?.authPreparation, - source: 'oauth' - }) - ) - } catch (error) { - warnClaudeUsageFetchFailure(input.options?.authPreparation, refreshed, error) - } - } - return cliResult -} diff --git a/src/main/rate-limits/claude-fetcher-extra-usage.test.ts b/src/main/rate-limits/claude-oauth-usage-extra-usage.test.ts similarity index 66% rename from src/main/rate-limits/claude-fetcher-extra-usage.test.ts rename to src/main/rate-limits/claude-oauth-usage-extra-usage.test.ts index fb56512bd8c6..9911343080c4 100644 --- a/src/main/rate-limits/claude-fetcher-extra-usage.test.ts +++ b/src/main/rate-limits/claude-oauth-usage-extra-usage.test.ts @@ -1,94 +1,28 @@ -import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' -import { fetchClaudeRateLimits } from './claude-fetcher' -import { primeClaudeFetcherMocks, restorePlatform } from './claude-fetcher-test-harness' -import { readActiveClaudeKeychainCredentialsStrict } from '../claude-accounts/keychain' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fetchClaudeOAuthUsage } from './claude-oauth-usage-request' -const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( - () => ({ - netFetchMock: vi.fn(), - readFileMock: vi.fn(), - resolveProxyMock: vi.fn(), - setProxyMock: vi.fn(), - appGetPathMock: vi.fn() - }) -) - -vi.mock('node:fs/promises', () => ({ - readFile: readFileMock -})) +const { netFetchMock } = vi.hoisted(() => ({ netFetchMock: vi.fn() })) vi.mock('electron', () => ({ - app: { - getPath: appGetPathMock - }, - net: { - fetch: netFetchMock - }, - session: { - defaultSession: { - resolveProxy: resolveProxyMock, - setProxy: setProxyMock - } - } -})) - -vi.mock('./claude-pty', () => ({ - fetchViaPty: vi.fn() + net: { fetch: netFetchMock }, + session: { defaultSession: {} } })) -vi.mock('../claude-accounts/keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(), - writeManagedClaudeKeychainCredentials: vi.fn() +vi.mock('../network/proxy-settings', () => ({ + ensureElectronProxyFromEnvironment: vi.fn(async () => {}) })) -function oauthPrep(): ClaudeRuntimeAuthPreparation { - return { - configDir: '/Users/test/.claude', - envPatch: { CLAUDE_CONFIG_DIR: '/Users/test/.claude' }, - stripAuthEnv: false, - provenance: 'system' - } -} - async function fetchUsageResponse(body: string) { - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { accessToken: 'oauth-token', expiresAt: Date.now() + 60_000 } - }) - ) netFetchMock.mockResolvedValueOnce(new Response(body, { status: 200 })) - return fetchClaudeRateLimits({ authPreparation: oauthPrep() }) + return fetchClaudeOAuthUsage('oauth-token') } -describe('fetchClaudeRateLimits extra usage', () => { +describe('Claude OAuth usage extra usage', () => { beforeEach(() => { - primeClaudeFetcherMocks({ - netFetchMock, - readFileMock, - resolveProxyMock, - setProxyMock, - appGetPathMock - }) - }) - - afterEach(() => { - restorePlatform() + vi.clearAllMocks() }) it('maps the usage-credits spend object into a capped balance in major units', async () => { - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) netFetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ @@ -105,7 +39,7 @@ describe('fetchClaudeRateLimits extra usage', () => { ) ) - await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({ + await expect(fetchClaudeOAuthUsage('oauth-token')).resolves.toMatchObject({ provider: 'claude', status: 'ok', extraUsage: { @@ -122,14 +56,6 @@ describe('fetchClaudeRateLimits extra usage', () => { }) it('keeps the usage-credits cap visible without inventing an out-of-credits balance', async () => { - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) netFetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ @@ -147,7 +73,7 @@ describe('fetchClaudeRateLimits extra usage', () => { ) ) - await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({ + await expect(fetchClaudeOAuthUsage('oauth-token')).resolves.toMatchObject({ provider: 'claude', status: 'ok', extraUsage: { @@ -163,14 +89,6 @@ describe('fetchClaudeRateLimits extra usage', () => { }) it('keeps legacy extra_usage spend data without inventing a missing balance', async () => { - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) netFetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ @@ -188,7 +106,7 @@ describe('fetchClaudeRateLimits extra usage', () => { ) ) - await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({ + await expect(fetchClaudeOAuthUsage('oauth-token')).resolves.toMatchObject({ provider: 'claude', status: 'ok', extraUsage: { diff --git a/src/main/rate-limits/claude-profile-usage.test.ts b/src/main/rate-limits/claude-profile-usage.test.ts new file mode 100644 index 000000000000..36816ea655ae --- /dev/null +++ b/src/main/rate-limits/claude-profile-usage.test.ts @@ -0,0 +1,271 @@ +import type * as Keychain from '../claude-accounts/keychain' +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +const calls = vi.hoisted(() => ({ keychain: vi.fn(), usage: vi.fn(), keychainService: vi.fn() })) +vi.mock('../macos-keychain/generic-password', () => ({ + readKeychainPassword: calls.keychainService +})) +vi.mock('../claude-accounts/keychain', () => ({ + readActiveClaudeKeychainCredentialsStrict: calls.keychain +})) +const profileRouter = vi.hoisted((): { userConfigDir?: string } => ({})) +vi.mock('../claude-accounts/claude-profile-installed-router', () => ({ + getClaudeProfileRouter: () => + profileRouter.userConfigDir ? { userConfigDir: () => profileRouter.userConfigDir } : undefined +})) +vi.mock('./claude-oauth-usage-request', () => ({ fetchClaudeOAuthUsage: calls.usage })) +const runningDistros = vi.hoisted(() => ({ + filter: vi.fn(async (paths: readonly string[]) => [...paths]) +})) +vi.mock('../wsl-running-path-filter', () => ({ + filterPathsToRunningWslDistrosAsync: runningDistros.filter +})) +vi.mock('../../shared/child-process/run-process', () => ({ + spawnProcess: () => { + throw new Error('Usage must not launch a process') + }, + runProcess: () => { + throw new Error('Usage must not launch a process') + } +})) +import { fetchActiveClaudeRateLimits } from './claude-active-usage-fetch' +import { + readClaudeOAuthCredentials, + resolveClaudeOAuthCredentialReadOptions +} from './claude-oauth-credentials' +import { OAuthUsageError } from './claude-oauth-usage-error' +import { CLAUDE_PROFILE_MISSING_MESSAGE } from '../../shared/claude-profile-routing' +const roots: string[] = [] +afterEach(() => { + roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) + vi.unstubAllGlobals() + vi.clearAllMocks() +}) +function profile() { + const home = mkdtempSync(join(tmpdir(), 'claude-usage-')) + roots.push(home) + calls.keychain.mockResolvedValue(null) + const options = { + authPreparation: { + configDir: home, + envPatch: { CLAUDE_CONFIG_DIR: home }, + stripAuthEnv: true, + provenance: 'profile:fake' + } + } + return { home, options } +} +function systemDefault() { + const home = mkdtempSync(join(tmpdir(), 'claude-usage-default-')) + roots.push(home) + calls.keychain.mockResolvedValue(null) + return { + home, + options: { + authPreparation: { configDir: home, envPatch: {}, stripAuthEnv: false, provenance: 'system' } + } + } +} +it('lets the server decide a locally expired token and never refreshes it', async () => { + const f = profile() + const file = join(f.home, '.credentials.json') + writeFileSync( + file, + JSON.stringify({ + claudeAiOauth: { accessToken: 'expired-fake', refreshToken: 'must-not-be-used', expiresAt: 1 } + }) + ) + vi.stubGlobal('fetch', () => { + throw new Error('No refresh endpoint') + }) + calls.usage.mockRejectedValue(new OAuthUsageError('Invalid OAuth token.', 401, true, null)) + expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ + status: 'error', + error: 'Claude usage has expired. Start Claude in this account to refresh it.', + usageMetadata: { failureKind: 'stale-token' } + }) + expect(calls.usage.mock.calls.map(([token]) => token)).toEqual(['expired-fake']) + const system = systemDefault() + writeFileSync( + join(system.home, '.credentials.json'), + JSON.stringify({ claudeAiOauth: { accessToken: 'expired-default', expiresAt: 1 } }) + ) + calls.usage.mockResolvedValueOnce({ provider: 'claude', status: 'ok' }) + expect(await fetchActiveClaudeRateLimits(system.options)).toMatchObject({ status: 'ok' }) + expect(await fetchActiveClaudeRateLimits(system.options)).toMatchObject({ + error: 'Claude usage updates the next time Claude runs.', + usageMetadata: { failureKind: 'stale-token' } + }) +}) +it('reads only the requested scoped Keychain, with no unsuffixed fallback', async () => { + const f = profile() + await readClaudeOAuthCredentials({ credentialsFileConfigDir: f.home, keychainConfigDir: f.home }) + if (process.platform === 'darwin') { + expect(calls.keychain.mock.calls).toEqual([[f.home]]) + } else { + expect(calls.keychain).not.toHaveBeenCalled() + } +}) +it('distinguishes missing, malformed and inaccessible credential observations', async () => { + const f = profile() + expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ + usageMetadata: { failureKind: 'missing-credentials' } + }) + writeFileSync(join(f.home, '.credentials.json'), '{') + expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ + usageMetadata: { failureKind: 'keychain-unavailable' } + }) +}) +it('reports an unreadable selected account before any credential read and requests no hidden recovery', async () => { + const f = profile() + expect( + await fetchActiveClaudeRateLimits({ + authPreparation: { ...f.options.authPreparation, usageError: CLAUDE_PROFILE_MISSING_MESSAGE } + }) + ).toMatchObject({ status: 'error', usageMetadata: { failureKind: 'missing-credentials' } }) + expect(calls.keychain).not.toHaveBeenCalled() + writeFileSync( + join(f.home, '.credentials.json'), + JSON.stringify({ claudeAiOauth: { accessToken: 'fake' } }) + ) + calls.usage.mockRejectedValue(new Error('network unavailable')) + expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ status: 'error' }) + expect(calls.usage).toHaveBeenCalledTimes(1) +}) + +it('strict Keychain lookup never falls back to the unsuffixed service', async () => { + const f = profile() + calls.keychainService.mockResolvedValue(null) + const actual = await vi.importActual('../claude-accounts/keychain') + await actual.readActiveClaudeKeychainCredentialsStrict(f.home) + expect(calls.keychainService).toHaveBeenCalled() + for (const [service] of calls.keychainService.mock.calls) { + expect(service).toMatch(/^Claude Code-credentials-[0-9a-f]{8}$/) + } + calls.keychainService.mockClear() + calls.keychainService.mockRejectedValue(new Error('locked')) + await expect(actual.readActiveClaudeKeychainCredentialsStrict(f.home)).rejects.toThrow('locked') + expect(calls.keychainService).toHaveBeenCalledTimes(1) +}) +it('treats an unreadable credential file as unavailable, not a missing login', async () => { + const f = profile() + mkdirSync(join(f.home, '.credentials.json')) + expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ + usageMetadata: { failureKind: 'keychain-unavailable' } + }) +}) +it('hides Claude usage for System Default with no Claude login, instead of asking to sign in again', async () => { + const f = systemDefault() + expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ + status: 'unavailable', + usageMetadata: { failureKind: 'missing-credentials' } + }) + expect(await fetchActiveClaudeRateLimits(profile().options)).toMatchObject({ + status: 'error', + error: 'Sign in again to use this account.' + }) +}) +it("keeps a 429's Retry-After and rate-limit message so polling waits it out", async () => { + const f = profile() + writeFileSync( + join(f.home, '.credentials.json'), + JSON.stringify({ claudeAiOauth: { accessToken: 'fake' } }) + ) + const message = 'Claude usage is rate limited right now.' + calls.usage.mockRejectedValueOnce(new OAuthUsageError(message, 429, true, 3_000_000)) + const before = Date.now() + const limited = await fetchActiveClaudeRateLimits(f.options) + expect(limited).toMatchObject({ + status: 'error', + error: message, + usageMetadata: { failureKind: 'rate-limited' } + }) + expect(limited.usageMetadata?.retryAtMs).toBeGreaterThanOrEqual(before + 3_000_000) + expect(limited.usageMetadata?.retryAtMs).toBeLessThanOrEqual(Date.now() + 3_000_000) + calls.usage.mockRejectedValueOnce(new OAuthUsageError(message, 429, true, null)) + expect((await fetchActiveClaudeRateLimits(f.options)).usageMetadata?.retryAtMs).toBeUndefined() +}) +it("reads System default's own CLAUDE_CONFIG_DIR Keychain item before the unsuffixed one", async () => { + const root = mkdtempSync(join(tmpdir(), 'claude-usage-inherited-')) + roots.push(root) + const inherited = join(root, 'own-claude-config') + vi.stubEnv('CLAUDE_CONFIG_DIR', inherited) + try { + calls.keychain.mockResolvedValue(null) + await readClaudeOAuthCredentials( + resolveClaudeOAuthCredentialReadOptions({ + configDir: inherited, + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + }) + ) + expect(calls.keychain.mock.calls).toEqual( + process.platform === 'darwin' ? [[inherited], [undefined]] : [] + ) + } finally { + vi.unstubAllEnvs() + } + const managed = profile() + calls.keychain.mockClear() + await fetchActiveClaudeRateLimits(managed.options) + expect(calls.keychain.mock.calls).toEqual(process.platform === 'darwin' ? [[managed.home]] : []) +}) +it("names System default's Keychain item from the login shell's CLAUDE_CONFIG_DIR a Dock launch lacks", () => { + vi.stubEnv('CLAUDE_CONFIG_DIR', '') + profileRouter.userConfigDir = '/shell/claude-config' + try { + expect( + resolveClaudeOAuthCredentialReadOptions({ + configDir: '/shell/claude-config', + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + })?.keychainConfigDir + ).toBe('/shell/claude-config') + } finally { + profileRouter.userConfigDir = undefined + vi.unstubAllEnvs() + } +}) +it('reports a host problem as unavailable usage, never as a signed-out account', async () => { + const f = profile() + expect( + await fetchActiveClaudeRateLimits({ + authPreparation: { + ...f.options.authPreparation, + usageError: 'Could not read the home folder of WSL distro Ubuntu.' + } + }) + ).toMatchObject({ status: 'error', usageMetadata: { failureKind: 'usage-unavailable' } }) +}) +it('asks an inactive account from an older Orca to sign in again, reading nothing', async () => { + const { fetchInactiveClaudeAccountUsage } = await import('./claude-managed-account-usage') + expect( + await fetchInactiveClaudeAccountUsage({ + id: 'acct', + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/u/.local/share/orca/claude-accounts/acct/auth' + }) + ).toMatchObject({ usageMetadata: { failureKind: 'missing-credentials' } }) + expect(calls.keychain).not.toHaveBeenCalled() +}) +it('does not read an inactive WSL account through a stopped distro', async () => { + const { fetchInactiveClaudeAccountUsage } = await import('./claude-managed-account-usage') + runningDistros.filter.mockResolvedValueOnce([]) + const result = await fetchInactiveClaudeAccountUsage({ + id: 'acct', + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/u/.local/share/orca/claude-profiles/acct/home' + }) + expect(runningDistros.filter).toHaveBeenCalledWith( + ['\\\\wsl.localhost\\Ubuntu\\home\\u\\.local\\share\\orca\\claude-profiles\\acct\\home'], + { requireConfirmed: true } + ) + expect(result).toMatchObject({ usageMetadata: { failureKind: 'usage-unavailable' } }) + expect(calls.keychain).not.toHaveBeenCalled() +}) diff --git a/src/main/rate-limits/claude-pty-reset-parser.ts b/src/main/rate-limits/claude-pty-reset-parser.ts deleted file mode 100644 index 43137a8cea54..000000000000 --- a/src/main/rate-limits/claude-pty-reset-parser.ts +++ /dev/null @@ -1,307 +0,0 @@ -import type { RateLimitWindow } from '../../shared/rate-limit-types' -import { buildWallClockTimestamp } from './time-zone-wall-clock' - -const RESET_LINE_RE = /resets?\s+(?:at\s+|in\s+)?(.+)/i -const MONTH_PATTERN = - 'jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?' -const MONTH_DAY_COMPACT_RE = new RegExp( - `\\b(${MONTH_PATTERN})(\\d{1,2})(?=\\s*at\\s*\\d|\\D|$)`, - 'i' -) -const MONTH_DAY_TIME_RE = new RegExp( - `\\b(${MONTH_PATTERN})\\.?\\s+(\\d{1,2})(?:,?\\s*(?:at\\s+)?)?(\\d{1,2})(?::(\\d{2}))?\\s*(am|pm)\\b`, - 'i' -) -const WEEKDAY_TIME_RE = - /\b(sun(?:day)?|mon(?:day)?|tue(?:sday)?|wed(?:nesday)?|thu(?:rsday)?|fri(?:day)?|sat(?:urday)?)\.?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i -const TIME_ONLY_RE = /\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i -// Why: newer Codex CLIs print 24-hour reset times ("10:21 on 28 Jul") with no am/pm. -const TIME_24H_RE = /\b(\d{1,2}):(\d{2})\b/ -const DAY_MONTH_RE = new RegExp(`\\b(?:on\\s+)?(\\d{1,2})\\s+(${MONTH_PATTERN})\\b`, 'i') -const RELATIVE_RESET_RE = /^(?:\s*\d+\s*(?:d(?:ays?)?|h(?:ours?|rs?)?|m(?:in(?:ute)?s?)?)\s*)+$/i -const RELATIVE_RESET_TOKEN_RE = /(\d+)\s*(d(?:ays?)?|h(?:ours?|rs?)?|m(?:in(?:ute)?s?)?)/gi -const IANA_TIME_ZONE_RE = /\(([^()]*)\)?\s*$/ - -export type ClaudePtyResetMetadata = Pick - -const MONTH_INDEX_BY_NAME: Record = { - jan: 0, - january: 0, - feb: 1, - february: 1, - mar: 2, - march: 2, - apr: 3, - april: 3, - may: 4, - jun: 5, - june: 5, - jul: 6, - july: 6, - aug: 7, - august: 7, - sep: 8, - sept: 8, - september: 8, - oct: 9, - october: 9, - nov: 10, - november: 10, - dec: 11, - december: 11 -} - -const WEEKDAY_INDEX_BY_NAME: Record = { - sun: 0, - sunday: 0, - mon: 1, - monday: 1, - tue: 2, - tuesday: 2, - wed: 3, - wednesday: 3, - thu: 4, - thursday: 4, - fri: 5, - friday: 5, - sat: 6, - saturday: 6 -} - -export function extractClaudePtyResetMetadata( - lines: string[], - matchesLabel: (line: string) => boolean, - isSectionLabel: (line: string) => boolean -): ClaudePtyResetMetadata { - for (let i = 0; i < lines.length; i++) { - if (!matchesLabel(lines[i])) { - continue - } - for (let j = i; j < Math.min(i + 14, lines.length); j++) { - if (j > i && isSectionLabel(lines[j])) { - break - } - const m = RESET_LINE_RE.exec(lines[j]) - if (m) { - const resetDescription = normalizeResetDescription(m[1]) - return { - resetsAt: parseResetTimestamp(resetDescription), - resetDescription - } - } - } - } - return { resetsAt: null, resetDescription: null } -} - -function normalizeResetDescription(raw: string): string { - // Why: Claude's TUI occasionally drops spaces around the Fable reset date - // when copied from the PTY buffer, but the value still encodes a real reset. - return ( - raw - .trim() - // Why: PTY captures keep trailing box-border glyphs from framed status panels. - .replace(/[)\s│]+$/, '') - .replace(/\s+/g, ' ') - .replace(MONTH_DAY_COMPACT_RE, '$1 $2') - .replace(/(\d{1,2})\s*at\s*(\d{1,2}(?::\d{2})?\s*(?:am|pm))/i, '$1 at $2') - .replace(/(\d)(am|pm)\(/gi, '$1$2 (') - ) -} - -function parseResetTimestamp(resetDescription: string | null): number | null { - if (!resetDescription) { - return null - } - - return ( - parseRelativeResetTimestamp(resetDescription) ?? - parseMonthDayResetTimestamp(resetDescription) ?? - parseWeekdayResetTimestamp(resetDescription) ?? - parseTimeOnlyResetTimestamp(resetDescription) ?? - parseTwentyFourHourResetTimestamp(resetDescription) - ) -} - -function parseTwentyFourHourResetTimestamp(resetDescription: string): number | null { - const resetText = stripResetTimeZone(resetDescription) - const timeMatch = TIME_24H_RE.exec(resetText) - if (!timeMatch) { - return null - } - const hour = Number(timeMatch[1]) - const minute = Number(timeMatch[2]) - if (!isValidClockTime(hour, minute)) { - return null - } - - const dayMonthMatch = DAY_MONTH_RE.exec(resetText) - if (dayMonthMatch) { - const day = Number(dayMonthMatch[1]) - const monthIndex = MONTH_INDEX_BY_NAME[dayMonthMatch[2].toLowerCase()] - if (monthIndex === undefined || day < 1 || day > 31) { - return null - } - const now = new Date() - const timeZone = extractResetTimeZone(resetDescription) - let timestamp = buildWallClockTimestamp( - { year: now.getFullYear(), monthIndex, day, hour, minute }, - timeZone - ) - if (timestamp !== null && timestamp <= Date.now()) { - timestamp = buildWallClockTimestamp( - { year: now.getFullYear() + 1, monthIndex, day, hour, minute }, - timeZone - ) - } - return timestamp - } - - const candidate = new Date() - candidate.setHours(hour, minute, 0, 0) - if (candidate.getTime() <= Date.now()) { - candidate.setDate(candidate.getDate() + 1) - } - return candidate.getTime() -} - -function parseRelativeResetTimestamp(resetDescription: string): number | null { - if (!RELATIVE_RESET_RE.test(resetDescription)) { - return null - } - - let durationMs = 0 - for (const match of resetDescription.matchAll(RELATIVE_RESET_TOKEN_RE)) { - const amount = Number(match[1]) - const unit = match[2].toLowerCase()[0] - if (!Number.isFinite(amount)) { - continue - } - if (unit === 'd') { - durationMs += amount * 24 * 60 * 60_000 - } else if (unit === 'h') { - durationMs += amount * 60 * 60_000 - } else if (unit === 'm') { - durationMs += amount * 60_000 - } - } - return durationMs > 0 ? Date.now() + durationMs : null -} - -function parseMonthDayResetTimestamp(resetDescription: string): number | null { - const resetText = stripResetTimeZone(resetDescription) - const match = MONTH_DAY_TIME_RE.exec(resetText) - if (!match) { - return null - } - - const monthIndex = MONTH_INDEX_BY_NAME[match[1].toLowerCase()] - if (monthIndex === undefined) { - return null - } - - const day = Number(match[2]) - const hour = parseHour(match[3], match[5]) - const minute = Number(match[4] ?? 0) - if (!isValidClockTime(hour, minute)) { - return null - } - - const now = new Date() - const timeZone = extractResetTimeZone(resetDescription) - let timestamp = buildWallClockTimestamp( - { year: now.getFullYear(), monthIndex, day, hour, minute }, - timeZone - ) - if (timestamp !== null && timestamp <= Date.now()) { - timestamp = buildWallClockTimestamp( - { year: now.getFullYear() + 1, monthIndex, day, hour, minute }, - timeZone - ) - } - return timestamp -} - -function parseWeekdayResetTimestamp(resetDescription: string): number | null { - const resetText = stripResetTimeZone(resetDescription) - const match = WEEKDAY_TIME_RE.exec(resetText) - if (!match) { - return null - } - - const weekdayIndex = WEEKDAY_INDEX_BY_NAME[match[1].toLowerCase()] - if (weekdayIndex === undefined) { - return null - } - - const now = new Date() - const hour = parseHour(match[2], match[4]) - const minute = Number(match[3] ?? 0) - if (!isValidClockTime(hour, minute)) { - return null - } - - const candidate = new Date(now) - const daysUntil = (weekdayIndex - now.getDay() + 7) % 7 - candidate.setDate(now.getDate() + daysUntil) - candidate.setHours(hour, minute, 0, 0) - if (candidate.getTime() <= Date.now()) { - candidate.setDate(candidate.getDate() + 7) - } - return candidate.getTime() -} - -function parseTimeOnlyResetTimestamp(resetDescription: string): number | null { - const resetText = stripResetTimeZone(resetDescription) - const match = TIME_ONLY_RE.exec(resetText) - if (!match) { - return null - } - - const hour = parseHour(match[1], match[3]) - const minute = Number(match[2] ?? 0) - if (!isValidClockTime(hour, minute)) { - return null - } - - const candidate = new Date() - candidate.setHours(hour, minute, 0, 0) - if (candidate.getTime() <= Date.now()) { - candidate.setDate(candidate.getDate() + 1) - } - return candidate.getTime() -} - -function parseHour(hourText: string, periodText: string): number { - const hour = Number(hourText) - const period = periodText.toLowerCase() - if (hour < 1 || hour > 12) { - return Number.NaN - } - if (period === 'am') { - return hour === 12 ? 0 : hour - } - return hour === 12 ? 12 : hour + 12 -} - -function isValidClockTime(hour: number, minute: number): boolean { - return Number.isInteger(hour) && hour >= 0 && hour < 24 && minute >= 0 && minute < 60 -} - -function stripResetTimeZone(resetDescription: string): string { - return resetDescription.replace(/\s*\([^)]*\)?\s*$/, '').trim() -} - -function extractResetTimeZone(resetDescription: string): string | null { - const match = IANA_TIME_ZONE_RE.exec(resetDescription) - if (!match?.[1]) { - return null - } - const timeZone = match[1].trim() - try { - new Intl.DateTimeFormat('en-US', { timeZone }) - return timeZone - } catch { - return null - } -} diff --git a/src/main/rate-limits/claude-pty-stop-markers.ts b/src/main/rate-limits/claude-pty-stop-markers.ts deleted file mode 100644 index 2bba56c69112..000000000000 --- a/src/main/rate-limits/claude-pty-stop-markers.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Why: these substrings indicate the /usage TUI panel has finished rendering. -export const CLAUDE_USAGE_STOP_SUBSTRINGS = [ - 'Current week (all models)', - 'Current week (Opus)', - 'Current week (Sonnet only)', - 'Current week (Sonnet)', - 'Weekly limits', - 'Weekly limit', - 'Weekly usage', - '7-day', - 'Current session', - 'Failed to load usage data', - 'failed to load usage data' -] diff --git a/src/main/rate-limits/claude-pty-usage-parser.ts b/src/main/rate-limits/claude-pty-usage-parser.ts deleted file mode 100644 index 3328d9471600..000000000000 --- a/src/main/rate-limits/claude-pty-usage-parser.ts +++ /dev/null @@ -1,166 +0,0 @@ -import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' -import { extractClaudePtyResetMetadata } from './claude-pty-reset-parser' - -// Why: these patterns match the Claude CLI's /usage TUI panel output. -// "Current session" shows a percent like "62% used" or "62% left". -// Weekly labels have varied between "Current week" and "Weekly limits". -const SESSION_RE = /current\s*session/i -const WEEKLY_RE = /(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)/i -const FABLE_WORD_RE = /\bfable\b/i -const FABLE_LABEL_RE = /^\s*fable\s*$/i -// Why: derive from WEEKLY_RE so a future weekly-wording change stays in one place -// instead of silently reopening the Fable-weekly parsing gap this fix closed. -const FABLE_WEEKLY_LABEL_RE = new RegExp( - `${WEEKLY_RE.source}\\s*(?:\\([^)]*\\bfable\\b[^)]*\\)|[-:]?\\s*\\bfable\\b)`, - 'i' -) -const PERCENT_RE = /(\d{1,3})(?:\.\d+)?\s*%\s*(used|consumed|left|remaining|available)/i -const ESC = String.fromCharCode(27) -const BEL = String.fromCharCode(7) -const OSC_SEQUENCE_RE = new RegExp(`${ESC}\\][^${BEL}]*(?:${BEL}|${ESC}\\\\)`, 'g') -const CSI_SEQUENCE_RE = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, 'g') - -export function stripTerminalControlSequences(output: string): string { - return output.replace(OSC_SEQUENCE_RE, '').replace(CSI_SEQUENCE_RE, '') -} - -/** - * Extract percent-left from lines following a label match. - * Scans up to 12 lines after the label to find the associated percent. - */ -function matchesWeeklyLabel(line: string): boolean { - return WEEKLY_RE.test(line) && !FABLE_WORD_RE.test(line) -} - -function matchesFableBoundary(line: string): boolean { - return FABLE_LABEL_RE.test(line) || (FABLE_WORD_RE.test(line) && WEEKLY_RE.test(line)) -} - -function matchesFableUsageLabel(line: string): boolean { - // Why: broad Fable-weekly copy should stop nearby scans, but only a real - // Fable usage heading should produce the distinct Fable meter. - return FABLE_LABEL_RE.test(line) || FABLE_WEEKLY_LABEL_RE.test(line) -} - -function isSectionLabel(line: string): boolean { - return SESSION_RE.test(line) || matchesWeeklyLabel(line) || matchesFableBoundary(line) -} - -function extractPercentAfterLabel( - lines: string[], - matchesLabel: (line: string) => boolean -): number | null { - for (let i = 0; i < lines.length; i++) { - if (!matchesLabel(lines[i])) { - continue - } - // Scan next 12 lines for a percent - for (let j = i; j < Math.min(i + 12, lines.length); j++) { - if (j > i && isSectionLabel(lines[j])) { - break - } - const m = PERCENT_RE.exec(lines[j]) - if (m) { - const pct = Number.parseFloat(m[1]) - const word = m[2].toLowerCase() - const isUsed = word === 'used' || word === 'consumed' - return isUsed ? pct : 100 - pct - } - } - } - return null -} - -export function parseClaudePtyUsage(output: string): { - session: RateLimitWindow | null - weekly: RateLimitWindow | null - fableWeekly: RateLimitWindow | null -} { - const lines = output.split(/\r\n|\n|\r/) - - const sessionPct = extractPercentAfterLabel(lines, (line) => SESSION_RE.test(line)) - const weeklyPct = extractPercentAfterLabel(lines, matchesWeeklyLabel) - const fableWeeklyPct = extractPercentAfterLabel(lines, matchesFableUsageLabel) - const sessionReset = extractClaudePtyResetMetadata( - lines, - (line) => SESSION_RE.test(line), - isSectionLabel - ) - const weeklyReset = extractClaudePtyResetMetadata(lines, matchesWeeklyLabel, isSectionLabel) - const fableWeeklyReset = extractClaudePtyResetMetadata( - lines, - matchesFableUsageLabel, - isSectionLabel - ) - - const session: RateLimitWindow | null = - sessionPct !== null - ? { - usedPercent: Math.min(100, Math.max(0, sessionPct)), - windowMinutes: 300, - resetsAt: sessionReset.resetsAt, - resetDescription: sessionReset.resetDescription - } - : null - - const weekly: RateLimitWindow | null = - weeklyPct !== null - ? { - usedPercent: Math.min(100, Math.max(0, weeklyPct)), - windowMinutes: 10080, - resetsAt: weeklyReset.resetsAt, - resetDescription: weeklyReset.resetDescription - } - : null - - const fableWeekly: RateLimitWindow | null = - fableWeeklyPct !== null - ? { - usedPercent: Math.min(100, Math.max(0, fableWeeklyPct)), - windowMinutes: 10080, - resetsAt: fableWeeklyReset.resetsAt, - resetDescription: fableWeeklyReset.resetDescription - } - : null - - return { session, weekly, fableWeekly } -} - -const RATE_LIMITED_RE = /rate limited\.?\s+please try again later/i -const LOAD_FAILED_RE = /failed to load usage data/i -const CLAUDE_21_USAGE_TABS_RE = /settings?\s+status?\s+config\s+usage\s+stats/i -const CLAUDE_21_SESSION_STATS_RE = /total\s*cost|total\s*duration|usage:\s*\d+\s*input/i - -export function isClaude21UsagePanel(output: string): boolean { - return CLAUDE_21_USAGE_TABS_RE.test(output) || CLAUDE_21_SESSION_STATS_RE.test(output) -} - -export function describeClaudeUsageFailure(output: string): string { - if (RATE_LIMITED_RE.test(output)) { - return 'Claude usage is rate limited right now.' - } - - if (LOAD_FAILED_RE.test(output)) { - return 'Claude usage is unavailable right now.' - } - - if (CLAUDE_21_USAGE_TABS_RE.test(output) || CLAUDE_21_SESSION_STATS_RE.test(output)) { - return 'Claude plan usage is unavailable for this Claude CLI session.' - } - - // Why: parser failures are an implementation detail of Orca's PTY fallback. - // The UI should explain the user-visible outcome, not leak internal parsing - // mechanics that the user cannot act on. - return 'Claude usage is unavailable right now.' -} - -export function abortedClaudeUsageResult(): ProviderRateLimits { - return { - provider: 'claude', - session: null, - weekly: null, - updatedAt: Date.now(), - error: 'Rate-limit fetch aborted', - status: 'error' - } -} diff --git a/src/main/rate-limits/claude-pty.test.ts b/src/main/rate-limits/claude-pty.test.ts deleted file mode 100644 index f2fd976821b6..000000000000 --- a/src/main/rate-limits/claude-pty.test.ts +++ /dev/null @@ -1,554 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { resolveClaudeCommandMock, spawnMock } = vi.hoisted(() => ({ - resolveClaudeCommandMock: vi.fn(), - spawnMock: vi.fn() -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: resolveClaudeCommandMock -})) - -vi.mock('node-pty', () => ({ - spawn: spawnMock -})) - -import { fetchViaPty } from './claude-pty' -import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup' - -function makeDisposable() { - return { dispose: vi.fn() } -} - -type MockTerm = { - onData: ReturnType - onExit: ReturnType - write: ReturnType - kill: ReturnType -} - -function makeMockTerm(): MockTerm & { - emitData: (data: string) => void - emitExit: () => void -} { - let dataHandler: ((data: string) => void) | null = null - let exitHandler: (() => void) | null = null - return { - onData: vi.fn((handler: (data: string) => void) => { - dataHandler = handler - return makeDisposable() - }), - onExit: vi.fn((handler: () => void) => { - exitHandler = handler - return makeDisposable() - }), - write: vi.fn(), - kill: vi.fn(), - emitData: (data: string) => dataHandler?.(data), - emitExit: () => exitHandler?.() - } -} - -describe('fetchViaPty', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.clearAllMocks() - resolveClaudeCommandMock.mockReturnValue('claude') - }) - - it('disposes node-pty listeners before killing the hidden PTY on timeout', async () => { - const onDataDisposable = makeDisposable() - const onExitDisposable = makeDisposable() - const killMock = vi.fn() - - spawnMock.mockReturnValue({ - onData: vi.fn(() => onDataDisposable), - onExit: vi.fn(() => onExitDisposable), - write: vi.fn(), - kill: killMock - }) - - const resultPromise = fetchViaPty() - await vi.advanceTimersByTimeAsync(25_000) - await resultPromise - - expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killMock.mock.invocationCallOrder[0] - ) - expect(onExitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killMock.mock.invocationCallOrder[0] - ) - }) - - it('injects the configured proxy into the hidden PTY spawn env', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty({ - networkProxySettings: { httpProxyUrl: 'http://127.0.0.1:7890' } - }) - await vi.advanceTimersByTimeAsync(0) - - expect(spawnMock).toHaveBeenCalled() - const spawnEnv = spawnMock.mock.calls[0]?.[2]?.env as Record - expect(spawnEnv.HTTPS_PROXY).toBe('http://127.0.0.1:7890') - expect(spawnEnv.HTTP_PROXY).toBe('http://127.0.0.1:7890') - - term.emitExit() - await resultPromise - }) - - it('spawns the hidden usage PTY in a bounded non-root cwd', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - await vi.advanceTimersByTimeAsync(0) - - const spawnCwd = spawnMock.mock.calls[0]?.[2]?.cwd as string - expect(spawnCwd).toContain('rate-limit-pty-cwd') - expect(spawnCwd).not.toBe('/') - expect(spawnCwd).not.toMatch(/^[A-Za-z]:(?:[\\/])?$/) - - term.emitExit() - await resultPromise - }) - - it('leaves the spawn env proxy untouched when no proxy is configured', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - const inheritedHttpsProxy = process.env.HTTPS_PROXY - - const resultPromise = fetchViaPty() - await vi.advanceTimersByTimeAsync(0) - - const spawnEnv = spawnMock.mock.calls[0]?.[2]?.env as Record - expect(spawnEnv.HTTPS_PROXY).toBe(inheritedHttpsProxy) - - term.emitExit() - await resultPromise - }) - - it('exports the configured proxy inside the WSL launch command', async () => { - // Why: Windows-side spawn env does not cross into the distro, so the proxy - // must be exported in the bash command for the inner claude to see it. - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty({ - networkProxySettings: { httpProxyUrl: 'http://127.0.0.1:7890' }, - authPreparation: { - configDir: '/home/u/.claude', - runtime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxConfigDir: '/home/u/.claude', - envPatch: { CLAUDE_CONFIG_DIR: '/home/u/.claude' }, - stripAuthEnv: false, - provenance: 'system' - } - }) - await vi.advanceTimersByTimeAsync(0) - - const [spawnFile, spawnArgs] = spawnMock.mock.calls[0] as [string, string[]] - expect(spawnFile).toBe('wsl.exe') - const bashCommand = spawnArgs.at(-1) as string - expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"') - expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"') - expect(bashCommand).toContain("export HTTPS_PROXY='http://127.0.0.1:7890'") - expect(bashCommand).toContain('exec claude') - - term.emitExit() - await resultPromise - }) - - it('kills and unregisters the hidden PTY when the fetch signal aborts', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - const controller = new AbortController() - const killMock = term.kill - - const resultPromise = fetchViaPty({ signal: controller.signal }) - await vi.advanceTimersByTimeAsync(0) - - expect(getActiveHiddenRateLimitPtyCount()).toBe(1) - - controller.abort() - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: 'Rate-limit fetch aborted' - }) - expect(killMock).toHaveBeenCalledTimes(1) - expect(getActiveHiddenRateLimitPtyCount()).toBe(0) - }) - - it('clears the startup delay timer when the hidden PTY exits early', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - await vi.advanceTimersByTimeAsync(0) - expect(spawnMock).toHaveBeenCalled() - expect(vi.getTimerCount()).toBeGreaterThan(0) - - term.emitExit() - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'error' - }) - expect(vi.getTimerCount()).toBe(0) - }) - - it('clears pending settle timers when the hidden PTY exits after usage output starts', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - await vi.advanceTimersByTimeAsync(2_000) - term.emitData('Current session\r12% used\r') - expect(vi.getTimerCount()).toBeGreaterThan(0) - - term.emitExit() - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 12 - } - }) - expect(vi.getTimerCount()).toBe(0) - }) - - it('treats Claude 2.1 tabbed /usage session stats as rendered but unavailable', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - expect(term.write).toHaveBeenCalledWith('/usage\r') - - term.emitData(` - Settings Status Config Usage Stats - - Session - Total cost: $0.0000 - Usage: 0 input, 0 output, 0 cache read, 0 cache write - `) - - await vi.advanceTimersByTimeAsync(8_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - session: null, - weekly: null, - error: 'Claude plan usage is unavailable for this Claude CLI session.' - }) - expect(term.write).not.toHaveBeenCalledWith('\x1b[D\x1b[D') - }) - - it('keeps waiting for plan windows after the Claude 2.1 usage shell renders', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Settings Status Config Usage Stats - Session - Total cost: $0.0000 - `) - - await vi.advanceTimersByTimeAsync(1_000) - term.emitData('Current session\r12% used\rResets 4:00pm\rCurrent week (all models)\r34% used\r') - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 12, - resetDescription: '4:00pm' - }, - weekly: { - usedPercent: 34 - }, - error: null - }) - }) - - it('parses the newer Claude weekly limits wording for Fable usage', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - vi.setSystemTime(new Date(2026, 6, 3, 20, 0)) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Plan usage limits - - Current session - 18% remaining - Resets in 2h 10m - - Weekly limits - Fable - 42% consumed - Resets in 3d 2h - `) - await vi.advanceTimersByTimeAsync(2_000) - - const result = await resultPromise - - expect(result).toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 82, - resetsAt: Date.now() + 2 * 60 * 60_000 + 10 * 60_000, - resetDescription: '2h 10m' - }, - weekly: null, - fableWeekly: { - usedPercent: 42, - resetsAt: Date.now() + 3 * 24 * 60 * 60_000 + 2 * 60 * 60_000, - resetDescription: '3d 2h' - }, - error: null - }) - }) - - it('parses generic weekly and Fable weekly limits as separate windows', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Plan usage limits - - Current session - 18% remaining - Resets in 2h 10m - - Current week (all models) - 84% left - Resets in 5d 4h - - Fable - 42% consumed - Resets in 3d 2h - `) - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 82, - resetDescription: '2h 10m' - }, - weekly: { - usedPercent: 16, - resetDescription: '5d 4h' - }, - fableWeekly: { - usedPercent: 42, - resetDescription: '3d 2h' - }, - error: null - }) - }) - - it('parses Claude current-week Fable usage as a distinct weekly window', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - vi.setSystemTime(new Date(2026, 6, 3, 20, 0)) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Plan usage limits - - Current session - 8% used - Resets 3:39am - - Current week (all models) - 33% used - Resets Jul 10 at 12:59pm - - Current week (Fable) - 62% used - Resets Jul10at12:59pm(America/Los_Angeles) - `) - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 8, - resetDescription: '3:39am' - }, - weekly: { - usedPercent: 33, - resetsAt: new Date(2026, 6, 10, 12, 59).getTime(), - resetDescription: 'Jul 10 at 12:59pm' - }, - fableWeekly: { - usedPercent: 62, - resetsAt: Date.parse('2026-07-10T19:59:00.000Z'), - resetDescription: 'Jul 10 at 12:59pm (America/Los_Angeles' - }, - error: null - }) - }) - - it('does not let an incomplete Fable section consume later usage sections', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Plan usage limits - - Fable - Usage unavailable - - Current session - 12% used - - Current week (all models) - 84% left - `) - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 12 - }, - weekly: { - usedPercent: 16 - }, - fableWeekly: null, - error: null - }) - }) - - it('does not treat inline Fable weekly text as a parsed usage label', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Plan usage limits - - Current session - Fable weekly usage - 42% consumed - - Current week (all models) - 84% left - `) - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: null, - weekly: { - usedPercent: 16 - }, - fableWeekly: null, - error: null - }) - }) - - it('keeps waiting after a bare Fable heading until another usage section renders', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - let settled = false - - const resultPromise = fetchViaPty().finally(() => { - settled = true - }) - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Plan usage limits - - Fable - `) - await vi.advanceTimersByTimeAsync(2_000) - - expect(settled).toBe(false) - - term.emitData(` - 42% consumed - - Current session - 12% used - `) - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 12 - }, - fableWeekly: { - usedPercent: 42 - }, - error: null - }) - }) - - it('parses 7-day weekly labels without the old Current week heading', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Usage - - Current session - 12% used - - 7-day - 84% left - Resets Wed at 9:05 PM - `) - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 12 - }, - weekly: { - usedPercent: 16, - resetDescription: 'Wed at 9:05 PM' - }, - error: null - }) - }) -}) diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts deleted file mode 100644 index cbaedbd17f9d..000000000000 --- a/src/main/rate-limits/claude-pty.ts +++ /dev/null @@ -1,354 +0,0 @@ -import type { ProviderRateLimits } from '../../shared/rate-limit-types' -import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy' -import { resolveClaudeCommand } from '../codex-cli/command' -// Why: import from the shared module, not the codex-cli re-export, so a test that -// mocks '../codex-cli/command' does not have to restate this pure helper. -import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -import { applyClaudeEnvPatch } from '../claude-accounts/environment' -import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' -import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup' -import { - getHiddenRateLimitWslCwdSetupCommands, - resolveHiddenRateLimitPtyCwd -} from './hidden-rate-limit-pty-cwd' -import { - abortedClaudeUsageResult, - describeClaudeUsageFailure, - isClaude21UsagePanel, - parseClaudePtyUsage, - stripTerminalControlSequences -} from './claude-pty-usage-parser' -import { quoteHiddenRateLimitShellValue } from './hidden-rate-limit-shell' -import { CLAUDE_USAGE_STOP_SUBSTRINGS } from './claude-pty-stop-markers' - -const PTY_TIMEOUT_MS = 25_000 -const MAX_OUTPUT_LENGTH = 100_000 // 100KB buffer limit - -// --------------------------------------------------------------------------- -// PTY fallback — spawn interactive `claude`, send `/usage`, parse the TUI -// --------------------------------------------------------------------------- - -// Why: prompt detection is unreliable because the Claude CLI v2.x renders -// a status bar and TUI elements that push the `❯` prompt out of any -// reasonable detection window. Instead we wait a fixed 2s after spawning -// for the CLI to initialize, then send `/usage\r` directly. Command -// palette prompts ("Show plan usage limits") are auto-confirmed with Enter. -const COMMAND_PALETTE_RE = /show plan|usage limits/i -const TRUST_PROMPT_RE = /do you trust|trust the files|safety check/i -const STARTUP_DELAY_MS = 2_000 -const SETTLE_AFTER_STOP_MS = 2_000 -const SETTLE_AFTER_CLAUDE_21_USAGE_MS = 8_000 - -export async function fetchViaPty(options?: { - authPreparation?: ClaudeRuntimeAuthPreparation - networkProxySettings?: NetworkProxySettings - signal?: AbortSignal -}): Promise { - if (options?.signal?.aborted) { - return abortedClaudeUsageResult() - } - const pty = await import('node-pty') - if (options?.signal?.aborted) { - return abortedClaudeUsageResult() - } - - return new Promise((resolve) => { - let output = '' - let resolved = false - let sentUsage = false - let stopDetected = false - let claude21UsageDetected = false - let startupDelayTimer: ReturnType | null = null - let stopSettleTimer: ReturnType | null = null - let claude21UsageSettleTimer: ReturnType | null = null - - const claudeCommand = resolveClaudeCommand() - - // Why: node-pty cannot spawn .cmd/.bat batch scripts directly on Windows — - // those need cmd.exe as an interpreter. Always route through cmd.exe on win32 - // and ensure the command path is properly quoted if it contains spaces. - const isWin32 = process.platform === 'win32' - const spawnEnv = applyClaudeEnvPatch( - { ...process.env, TERM: 'xterm-256color' } as Record, - options?.authPreparation?.envPatch ?? {}, - { stripAuthEnv: options?.authPreparation?.stripAuthEnv ?? false } - ) - // Why: this hidden usage PTY spawns `claude` directly, not the user's shell - // wrapper, so without the configured proxy it would reach api.anthropic.com - // from the app's own IP — bypassing the proxy the user set for Claude and - // risking rate-limit/geo signals on the account. Falls back to {} when unset. - const proxyEnv = buildConfiguredProxyEnv(options?.networkProxySettings) - Object.assign(spawnEnv, proxyEnv) - const authPreparation = options?.authPreparation - const wslConfig = - authPreparation?.runtime === 'wsl' && - authPreparation.wslDistro && - authPreparation.wslLinuxConfigDir - ? { - distro: authPreparation.wslDistro, - linuxConfigDir: authPreparation.wslLinuxConfigDir - } - : null - const spawnFile = wslConfig ? 'wsl.exe' : isWin32 ? 'cmd.exe' : claudeCommand - const spawnArgs = wslConfig - ? [ - '-d', - wslConfig.distro, - '--exec', - 'bash', - '-lc', - // Why: Windows-side env does not cross into the distro without WSLENV, - // so export the configured proxy inside the command for the inner claude. - [ - // Why: hidden usage probes must not inherit a root-like WSL cwd; - // keep Claude discovery bounded to a tiny temp directory. - ...getHiddenRateLimitWslCwdSetupCommands(), - `export CLAUDE_CONFIG_DIR=${quoteHiddenRateLimitShellValue(wslConfig.linuxConfigDir)}`, - ...Object.entries(proxyEnv).map( - ([key, value]) => `export ${key}=${quoteHiddenRateLimitShellValue(value)}` - ), - 'exec claude' - ].join(' && ') - ] - : isWin32 - ? ['/c', `"${claudeCommand}"`] - : [] - - const term = pty.spawn(spawnFile, spawnArgs, { - name: 'xterm-256color', - cols: 120, - rows: 40, - // Why: hidden usage PTYs must not inherit the process cwd (e.g. / or a - // drive root), which can trigger unbounded file discovery. - cwd: resolveHiddenRateLimitPtyCwd(), - env: withCliRuntimeOnPath(claudeCommand, spawnEnv) - }) - const termDisposables: { dispose: () => void }[] = [registerHiddenRateLimitPty(term)] - let enterInterval: ReturnType | null = null - let timeout: ReturnType | null = null - - function clearFollowupTimers(): void { - if (startupDelayTimer) { - clearTimeout(startupDelayTimer) - startupDelayTimer = null - } - if (stopSettleTimer) { - clearTimeout(stopSettleTimer) - stopSettleTimer = null - } - if (claude21UsageSettleTimer) { - clearTimeout(claude21UsageSettleTimer) - claude21UsageSettleTimer = null - } - if (enterInterval) { - clearInterval(enterInterval) - enterInterval = null - } - } - - function settleAborted(): void { - if (resolved) { - return - } - resolved = true - if (timeout) { - clearTimeout(timeout) - timeout = null - } - clearFollowupTimers() - cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) - resolve(abortedClaudeUsageResult()) - } - - if (options?.signal) { - if (options.signal.aborted) { - settleAborted() - return - } - options.signal.addEventListener('abort', settleAborted, { once: true }) - termDisposables.push({ - dispose: () => options.signal?.removeEventListener('abort', settleAborted) - }) - } - - timeout = setTimeout(() => { - if (!resolved) { - resolved = true - clearFollowupTimers() - cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) - // Even on timeout, try to parse whatever we collected - const clean = stripTerminalControlSequences(output) - const { session, weekly, fableWeekly } = parseClaudePtyUsage(clean) - if (session || weekly || fableWeekly) { - resolve({ - provider: 'claude', - session, - weekly, - fableWeekly, - updatedAt: Date.now(), - error: null, - status: 'ok' - }) - } else { - resolve({ - provider: 'claude', - session: null, - weekly: null, - updatedAt: Date.now(), - error: withMacTailscaleDnsHint( - isClaude21UsagePanel(clean) - ? describeClaudeUsageFailure(clean) - : 'PTY timeout — /usage panel did not render', - clean - ), - status: 'error' - }) - } - } - }, PTY_TIMEOUT_MS) - - // Why: the Claude TUI may have scrollable panels or prompts. - // Sending Enter every 0.8s advances through them. - function startEnterPresses(): void { - if (enterInterval) { - return - } - enterInterval = setInterval(() => { - if (!resolved && !stopDetected) { - term.write('\r') - } - }, 800) - } - - function finalize(): void { - if (resolved) { - return - } - resolved = true - if (timeout) { - clearTimeout(timeout) - timeout = null - } - clearFollowupTimers() - cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) - - const clean = stripTerminalControlSequences(output) - const { session, weekly, fableWeekly } = parseClaudePtyUsage(clean) - - if (!session && !weekly && !fableWeekly) { - resolve({ - provider: 'claude', - session: null, - weekly: null, - updatedAt: Date.now(), - error: withMacTailscaleDnsHint(describeClaudeUsageFailure(clean), clean), - status: 'error' - }) - } else { - resolve({ - provider: 'claude', - session, - weekly, - fableWeekly, - updatedAt: Date.now(), - error: null, - status: 'ok' - }) - } - } - - // Why: wait 2s for the CLI to initialize, then send `/usage\r` - // directly without detecting the prompt character (see comment above). - startupDelayTimer = setTimeout(() => { - startupDelayTimer = null - if (resolved) { - return - } - sentUsage = true - term.write('/usage\r') - startEnterPresses() - }, STARTUP_DELAY_MS) - - const onDataDisposable = term.onData((data) => { - output += data - // Why: prevent memory exhaustion if the CLI process floods output - if (output.length > MAX_OUTPUT_LENGTH) { - output = output.slice(-MAX_OUTPUT_LENGTH) - } - - const cleanChunk = stripTerminalControlSequences(data) - - // Why: the Claude CLI may prompt for first-run setup (trust files, - // workspace directory). Auto-accept so we can reach /usage. - if (TRUST_PROMPT_RE.test(cleanChunk)) { - term.write('y\r') - return - } - - // Why: Claude CLI v2.x may show a command palette when `/usage` is - // entered, listing options like "Show plan usage limits". Auto-confirm - // by sending Enter when these prompts appear. - if (sentUsage && COMMAND_PALETTE_RE.test(cleanChunk)) { - term.write('\r') - } - - // Check if we've hit a stop substring indicating the panel rendered - if (sentUsage && !stopDetected) { - const clean = stripTerminalControlSequences(output) - if (!claude21UsageDetected && isClaude21UsagePanel(clean)) { - claude21UsageDetected = true - if (enterInterval) { - clearInterval(enterInterval) - enterInterval = null - } - // Why: Claude 2.1 may render session stats without subscription - // plan windows. Give async usage loading a grace period, then finish - // with a user-facing unavailable state instead of a false PTY timeout. - claude21UsageSettleTimer = setTimeout(finalize, SETTLE_AFTER_CLAUDE_21_USAGE_MS) - } - for (const sub of CLAUDE_USAGE_STOP_SUBSTRINGS) { - if (clean.includes(sub)) { - stopDetected = true - // Why: 2.0s settle time after detecting the stop substring - // allows the full panel to finish rendering. - stopSettleTimer = setTimeout(finalize, SETTLE_AFTER_STOP_MS) - break - } - } - } - }) - if (onDataDisposable) { - termDisposables.push(onDataDisposable) - } - - const onExitDisposable = term.onExit(() => { - cleanupHiddenRateLimitPty(term, termDisposables, { kill: false }) - clearFollowupTimers() - if (!resolved) { - resolved = true - if (timeout) { - clearTimeout(timeout) - timeout = null - } - const clean = stripTerminalControlSequences(output) - const { session, weekly, fableWeekly } = parseClaudePtyUsage(clean) - resolve({ - provider: 'claude', - session, - weekly, - fableWeekly, - updatedAt: Date.now(), - error: - session || weekly || fableWeekly - ? null - : withMacTailscaleDnsHint('CLI exited before /usage rendered', clean), - status: session || weekly || fableWeekly ? 'ok' : 'error' - }) - } - }) - if (onExitDisposable) { - termDisposables.push(onExitDisposable) - } - }) -} diff --git a/src/main/rate-limits/claude-usage-error-classification.test.ts b/src/main/rate-limits/claude-usage-error-classification.test.ts index 9371479cfba5..5e60c281674f 100644 --- a/src/main/rate-limits/claude-usage-error-classification.test.ts +++ b/src/main/rate-limits/claude-usage-error-classification.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - classifyClaudeCredentialAbsence, - classifyClaudeOAuthUsageError -} from './claude-usage-error-classification' +import { classifyClaudeOAuthUsageError } from './claude-usage-error-classification' import { OAuthUsageError } from './claude-oauth-usage-error' describe('classifyClaudeOAuthUsageError', () => { @@ -47,38 +44,3 @@ describe('classifyClaudeOAuthUsageError', () => { }) }) }) - -describe('classifyClaudeCredentialAbsence', () => { - it('classifies refresh-only credentials as repairable', () => { - expect(classifyClaudeCredentialAbsence({ hasRefreshableCredentials: true })).toMatchObject({ - failureKind: 'refreshable-credentials-without-token', - shouldAttemptDelegatedRefresh: true, - shouldAttemptCliFallback: true - }) - }) - - it('classifies Keychain read failures separately from missing credentials', () => { - expect( - classifyClaudeCredentialAbsence({ - hasRefreshableCredentials: false, - keychainUnavailable: true - }) - ).toMatchObject({ - failureKind: 'keychain-unavailable', - shouldAttemptCliFallback: true, - shouldAttemptDelegatedRefresh: false - }) - }) - - it('classifies live Claude ownership as a deferred state', () => { - expect( - classifyClaudeCredentialAbsence({ - hasRefreshableCredentials: true, - managedRefreshDeferredByLivePty: true - }) - ).toMatchObject({ - failureKind: 'deferred-by-live-session', - terminal: true - }) - }) -}) diff --git a/src/main/rate-limits/claude-usage-error-classification.ts b/src/main/rate-limits/claude-usage-error-classification.ts index 71c88c242bd8..440d1155d243 100644 --- a/src/main/rate-limits/claude-usage-error-classification.ts +++ b/src/main/rate-limits/claude-usage-error-classification.ts @@ -39,23 +39,6 @@ export function classifyClaudeOAuthUsageError(error: unknown): ClaudeUsageErrorC return fallbackOnly('unknown') } -export function classifyClaudeCredentialAbsence(input: { - hasRefreshableCredentials: boolean - keychainUnavailable?: boolean - managedRefreshDeferredByLivePty?: boolean -}): ClaudeUsageErrorClassification { - if (input.managedRefreshDeferredByLivePty) { - return terminal('deferred-by-live-session') - } - if (input.keychainUnavailable) { - return fallbackOnly('keychain-unavailable') - } - if (input.hasRefreshableCredentials) { - return recoverableAuth('refreshable-credentials-without-token') - } - return terminal('missing-credentials') -} - function recoverableAuth(failureKind: UsageRateLimitFailureKind): ClaudeUsageErrorClassification { return { failureKind, diff --git a/src/main/rate-limits/claude-usage-refresh-plan.test.ts b/src/main/rate-limits/claude-usage-refresh-plan.test.ts deleted file mode 100644 index dced9e7815c7..000000000000 --- a/src/main/rate-limits/claude-usage-refresh-plan.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { resolveClaudeUsageRefreshPlan } from './claude-usage-refresh-plan' - -describe('resolveClaudeUsageRefreshPlan', () => { - it('uses OAuth then CLI app auto ordering when CLI fallback is available', () => { - expect(resolveClaudeUsageRefreshPlan({ allowCliFallback: true }).steps).toEqual([ - { source: 'oauth', reason: 'app-auto-preferred-oauth' }, - { source: 'cli', reason: 'app-auto-fallback-cli' } - ]) - }) - - it('keeps web deferred and internal-only for the first parity pass', () => { - expect(resolveClaudeUsageRefreshPlan({ allowCliFallback: true }).webDeferred).toBe(true) - }) - - it('omits CLI fallback for unresolved WSL Claude config', () => { - expect( - resolveClaudeUsageRefreshPlan({ - allowCliFallback: true, - authPreparation: { - configDir: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.claude', - runtime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxConfigDir: null, - envPatch: {}, - stripAuthEnv: true, - provenance: 'wsl:Ubuntu:system' - } - }).steps - ).toEqual([{ source: 'oauth', reason: 'app-auto-preferred-oauth' }]) - }) -}) diff --git a/src/main/rate-limits/claude-usage-refresh-plan.ts b/src/main/rate-limits/claude-usage-refresh-plan.ts deleted file mode 100644 index 06144a15715b..000000000000 --- a/src/main/rate-limits/claude-usage-refresh-plan.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { UsageRateLimitSource } from '../../shared/rate-limit-types' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' - -export type ClaudeUsageRefreshStep = { - source: UsageRateLimitSource - reason: 'app-auto-preferred-oauth' | 'app-auto-fallback-cli' | 'future-web-fallback-deferred' -} - -export type ClaudeUsageRefreshPlanInput = { - authPreparation?: ClaudeRuntimeAuthPreparation - allowCliFallback: boolean -} - -export type ClaudeUsageRefreshPlan = { - steps: ClaudeUsageRefreshStep[] - webDeferred: boolean -} - -export function resolveClaudeUsageRefreshPlan( - input: ClaudeUsageRefreshPlanInput -): ClaudeUsageRefreshPlan { - const steps: ClaudeUsageRefreshStep[] = [ - { - source: 'oauth', - reason: 'app-auto-preferred-oauth' - } - ] - - if (input.allowCliFallback && isCliPlausiblyAvailable(input.authPreparation)) { - steps.push({ - source: 'cli', - reason: 'app-auto-fallback-cli' - }) - } - - return { - steps, - webDeferred: true - } -} - -function isCliPlausiblyAvailable(authPreparation?: ClaudeRuntimeAuthPreparation): boolean { - if (authPreparation?.runtime === 'wsl') { - return Boolean(authPreparation.wslDistro && authPreparation.wslLinuxConfigDir) - } - return true -} diff --git a/src/main/rate-limits/claude-usage-result.ts b/src/main/rate-limits/claude-usage-result.ts index a29cb38d4aad..e1863be724d3 100644 --- a/src/main/rate-limits/claude-usage-result.ts +++ b/src/main/rate-limits/claude-usage-result.ts @@ -1,16 +1,4 @@ -import type { - ProviderRateLimits, - UsageRateLimitFailureKind, - UsageRateLimitMetadata, - UsageRateLimitSource -} from '../../shared/rate-limit-types' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -import type { ClaudeOAuthCredentialReadResult } from './claude-oauth-credentials' -import { OAuthUsageError } from './claude-oauth-usage-error' - -export type ClaudeUsageAttemptState = { - attemptedSources: UsageRateLimitSource[] -} +import type { ProviderRateLimits, UsageRateLimitMetadata } from '../../shared/rate-limit-types' export function abortedClaudeRateLimitResult(): ProviderRateLimits { return { @@ -23,30 +11,6 @@ export function abortedClaudeRateLimitResult(): ProviderRateLimits { } } -export function recordClaudeUsageAttempt( - state: ClaudeUsageAttemptState, - source: UsageRateLimitSource -): UsageRateLimitSource[] { - if (!state.attemptedSources.includes(source)) { - state.attemptedSources.push(source) - } - return state.attemptedSources -} - -export function withClaudeUsageMetadata( - limits: ProviderRateLimits, - metadata: UsageRateLimitMetadata -): ProviderRateLimits { - return { - ...limits, - usageMetadata: { - ...limits.usageMetadata, - ...metadata, - attemptedSources: metadata.attemptedSources ?? limits.usageMetadata?.attemptedSources - } - } -} - export function makeClaudeUsageResult( status: ProviderRateLimits['status'], error: string | null, @@ -63,76 +27,10 @@ export function makeClaudeUsageResult( } } -export function metadataForClaudeUsageAttempt(input: { - attemptedSources: UsageRateLimitSource[] - oauthCredentials: ClaudeOAuthCredentialReadResult - authPreparation?: ClaudeRuntimeAuthPreparation - source?: UsageRateLimitSource - failureKind?: UsageRateLimitFailureKind - deferredByLiveClaudeSession?: boolean - retryAtMs?: number -}): UsageRateLimitMetadata { - return { - source: input.source, - attemptedSources: [...input.attemptedSources], - failureKind: input.failureKind, - credentialSource: input.oauthCredentials.source, - authProvenance: input.authPreparation?.provenance ?? 'system', - deferredByLiveClaudeSession: input.deferredByLiveClaudeSession, - retryAtMs: input.retryAtMs - } -} - -export function warnClaudeUsageFetchFailure( - authPreparation: ClaudeRuntimeAuthPreparation | undefined, - credentials: ClaudeOAuthCredentialReadResult, - error: unknown -): void { - console.warn('[claude-rate-limits] Claude usage refresh failed', { - provenance: authPreparation?.provenance ?? 'system', - runtime: authPreparation?.runtime ?? 'host', - wslDistro: authPreparation?.wslDistro ?? null, - hasExplicitClaudeConfigDir: Boolean(authPreparation?.envPatch.CLAUDE_CONFIG_DIR), - credentialSource: credentials.source, - keychainUnavailable: credentials.keychainUnavailable, - hasRefreshableCredentials: credentials.hasRefreshableCredentials, - status: error instanceof OAuthUsageError ? error.status : null, - message: error instanceof Error ? error.message : String(error) +// Why plain: the profile/host reason is shown on the account row, not as raw text in usage. +export function claudeUsageUnavailable(): ProviderRateLimits { + return makeClaudeUsageResult('error', 'Claude usage is unavailable right now.', { + failureKind: 'usage-unavailable', + attemptedSources: [] }) } - -export function mergeClaudeUsageWindows( - primary: ProviderRateLimits, - supplement: ProviderRateLimits | null -): ProviderRateLimits { - if (!supplement) { - return primary - } - return { - ...primary, - session: primary.session ?? supplement.session, - weekly: primary.weekly ?? supplement.weekly, - fableWeekly: primary.fableWeekly ?? supplement.fableWeekly ?? null, - // Only OAuth reports the extra-usage cap; keep it when the CLI supplements windows. - extraUsage: primary.extraUsage ?? supplement.extraUsage ?? null - } -} - -export function canSupplementClaudeOAuthUsage(input: { - oauthLimits: ProviderRateLimits - authPreparation?: ClaudeRuntimeAuthPreparation - allowUsagePanelSupplement: boolean -}): boolean { - return Boolean( - input.allowUsagePanelSupplement && - !input.authPreparation?.managedRefreshDeferredByLivePty && - !input.oauthLimits.fableWeekly && - (input.oauthLimits.session || input.oauthLimits.weekly) - ) -} - -export function isManagedClaudeAuth( - authPreparation: ClaudeRuntimeAuthPreparation | undefined -): boolean { - return authPreparation?.provenance.startsWith('managed:') === true -} diff --git a/src/main/rate-limits/hidden-pty-cleanup.test.ts b/src/main/rate-limits/hidden-pty-cleanup.test.ts deleted file mode 100644 index 43e8453fa0fd..000000000000 --- a/src/main/rate-limits/hidden-pty-cleanup.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - cleanupHiddenRateLimitPty, - getActiveHiddenRateLimitPtyCount, - registerHiddenRateLimitPty -} from './hidden-pty-cleanup' - -function setPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { - configurable: true, - value: platform - }) -} - -describe('cleanupHiddenRateLimitPty', () => { - const originalPlatform = process.platform - - afterEach(() => { - setPlatform(originalPlatform) - vi.clearAllMocks() - }) - - it('disposes listeners, kills the child, then destroys the PTY fd on POSIX', () => { - setPlatform('darwin') - const dataDisposable = { dispose: vi.fn() } - const exitDisposable = { dispose: vi.fn() } - const killMock = vi.fn() - const term = { - kill: killMock, - destroy: vi.fn() - } - - cleanupHiddenRateLimitPty(term, [dataDisposable, exitDisposable], { kill: true }) - - expect(dataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killMock.mock.invocationCallOrder[0] - ) - expect(exitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killMock.mock.invocationCallOrder[0] - ) - expect(killMock.mock.invocationCallOrder[0]).toBeLessThan( - term.destroy.mock.invocationCallOrder[0] - ) - }) - - it('releases the PTY fd without killing again after natural exit', () => { - setPlatform('darwin') - const killMock = vi.fn() - const term = { - kill: killMock, - destroy: vi.fn() - } - - cleanupHiddenRateLimitPty(term, [], { kill: false }) - - expect(killMock).not.toHaveBeenCalled() - expect(term.destroy).toHaveBeenCalledTimes(1) - }) - - it('neutralizes POSIX destroy-time SIGHUP after the intentional kill', () => { - setPlatform('linux') - const killMock = vi.fn() - const term = { - kill: killMock, - destroy: vi.fn(() => { - term.kill('SIGHUP') - }) - } - - cleanupHiddenRateLimitPty(term, [], { kill: true }) - - expect(killMock).toHaveBeenCalledTimes(1) - expect(killMock).toHaveBeenCalledWith() - }) - - it('does not destroy after an intentional Windows kill because destroy kills again', () => { - setPlatform('win32') - const term = { - kill: vi.fn(), - destroy: vi.fn(() => { - term.kill() - }) - } - - cleanupHiddenRateLimitPty(term, [], { kill: true }) - - expect(term.kill).toHaveBeenCalledTimes(1) - expect(term.destroy).not.toHaveBeenCalled() - }) - - it('destroys a Windows PTY after natural exit so ConPTY cleanup still runs', () => { - setPlatform('win32') - const term = { - kill: vi.fn(), - destroy: vi.fn(() => { - term.kill() - }) - } - - cleanupHiddenRateLimitPty(term, [], { kill: false }) - - expect(term.kill).toHaveBeenCalledTimes(1) - expect(term.destroy).toHaveBeenCalledTimes(1) - }) - - it('removes registered hidden PTYs when cleanup kills them', () => { - setPlatform('darwin') - const term = { - kill: vi.fn(), - destroy: vi.fn() - } - const registration = registerHiddenRateLimitPty(term) - - expect(getActiveHiddenRateLimitPtyCount()).toBe(1) - - cleanupHiddenRateLimitPty(term, [registration], { kill: true }) - - expect(getActiveHiddenRateLimitPtyCount()).toBe(0) - }) - - it('removes registered hidden PTYs after natural exit cleanup', () => { - setPlatform('darwin') - const term = { - kill: vi.fn(), - destroy: vi.fn() - } - const registration = registerHiddenRateLimitPty(term) - - expect(getActiveHiddenRateLimitPtyCount()).toBe(1) - - cleanupHiddenRateLimitPty(term, [registration], { kill: false }) - - expect(getActiveHiddenRateLimitPtyCount()).toBe(0) - }) -}) diff --git a/src/main/rate-limits/hidden-pty-cleanup.ts b/src/main/rate-limits/hidden-pty-cleanup.ts deleted file mode 100644 index 068b937ee844..000000000000 --- a/src/main/rate-limits/hidden-pty-cleanup.ts +++ /dev/null @@ -1,58 +0,0 @@ -type HiddenPty = { - kill: (signal?: string) => void - destroy?: () => void -} - -type Disposable = { - dispose: () => void -} - -const activeHiddenRateLimitPtys = new Set() - -export function registerHiddenRateLimitPty(term: HiddenPty): Disposable { - activeHiddenRateLimitPtys.add(term) - return { - dispose: () => { - activeHiddenRateLimitPtys.delete(term) - } - } -} - -export function getActiveHiddenRateLimitPtyCount(): number { - return activeHiddenRateLimitPtys.size -} - -export function cleanupHiddenRateLimitPty( - term: HiddenPty, - disposables: Disposable[], - options: { kill: boolean } -): void { - for (const disposable of disposables.splice(0)) { - disposable.dispose() - } - - if (options.kill) { - try { - term.kill() - } catch { - /* already exited */ - } - - // Why: node-pty WindowsTerminal.destroy() calls kill() again, which can - // close the same ConPTY handle twice after an intentional termination. - if (process.platform === 'win32') { - return - } - } - - // Why: node-pty destroy releases the master PTY fd; on POSIX, neutralize - // the post-close SIGHUP hook after exit/kill to avoid pid reuse. - if (process.platform !== 'win32') { - term.kill = () => {} - } - try { - term.destroy?.() - } catch { - /* already torn down */ - } -} diff --git a/src/main/rate-limits/service-account-target-selection.test.ts b/src/main/rate-limits/service-account-target-selection.test.ts index 27e93208c1a1..4366417dc3e7 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -551,9 +551,7 @@ describe('RateLimitService', () => { it('does not cache host Claude usage under an outgoing WSL account', async () => { const service = new RateLimitService() - service.setInactiveClaudeAccountsResolver(() => [ - { id: 'wsl-account-1', managedAuthPath: '/tmp/account-1/auth' } - ]) + service.setInactiveClaudeAccountsResolver(() => [{ id: 'wsl-account-1' }]) service.setClaudeAuthPreparationResolver(async (target) => ({ configDir: target?.runtime === 'wsl' diff --git a/src/main/rate-limits/service-inactive-account-previews.test.ts b/src/main/rate-limits/service-inactive-account-previews.test.ts index cae71c569e71..469b51e4cf1c 100644 --- a/src/main/rate-limits/service-inactive-account-previews.test.ts +++ b/src/main/rate-limits/service-inactive-account-previews.test.ts @@ -78,7 +78,7 @@ describe('RateLimitService', () => { it('aborts inactive Claude preview fetches on stop', async () => { const service = new RateLimitService() - const account = { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } + const account = { id: 'account-1' } const capturedSignals: { claude?: AbortSignal } = {} service.setInactiveClaudeAccountsResolver(() => [account]) vi.mocked(fetchManagedAccountUsage).mockImplementation( @@ -212,7 +212,7 @@ describe('RateLimitService', () => { it('allows usage-panel Fable supplements for inactive Claude account previews', async () => { const service = new RateLimitService() - const account = { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } + const account = { id: 'account-1' } service.setInactiveClaudeAccountsResolver(() => [account]) vi.mocked(fetchManagedAccountUsage).mockResolvedValueOnce(okProvider('claude', 33, Date.now())) @@ -230,9 +230,7 @@ describe('RateLimitService', () => { it('does not start overlapping inactive Claude preview fetches', async () => { const service = new RateLimitService() const accountFetch = deferred() - service.setInactiveClaudeAccountsResolver(() => [ - { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } - ]) + service.setInactiveClaudeAccountsResolver(() => [{ id: 'account-1' }]) vi.mocked(fetchManagedAccountUsage).mockReturnValueOnce(accountFetch.promise) const firstFetch = service.fetchInactiveClaudeAccountsOnOpen() @@ -401,7 +399,7 @@ describe('RateLimitService', () => { it('does not recache an inactive Claude account removed during fetch-on-open', async () => { const service = new RateLimitService() const accountFetch = deferred() - let inactiveAccounts = [{ id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }] + let inactiveAccounts = [{ id: 'account-1' }] service.setInactiveClaudeAccountsResolver(() => inactiveAccounts) service.setClaudeAuthPreparationResolver(async () => ({ configDir: '/tmp/.claude', @@ -420,7 +418,7 @@ describe('RateLimitService', () => { ]) service.evictInactiveClaudeCache('account-1') - inactiveAccounts = [{ id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }] + inactiveAccounts = [{ id: 'account-1' }] await service.refreshForClaudeAccountChange('account-1') expect(service.getState().inactiveClaudeAccounts[0]?.accountId).toBe('account-1') @@ -435,9 +433,7 @@ describe('RateLimitService', () => { it('does not overwrite inactive Claude cache from a stale same-id fetch', async () => { const service = new RateLimitService() const accountFetch = deferred() - service.setInactiveClaudeAccountsResolver(() => [ - { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } - ]) + service.setInactiveClaudeAccountsResolver(() => [{ id: 'account-1' }]) service.setClaudeAuthPreparationResolver(async () => ({ configDir: '/tmp/.claude', envPatch: {}, diff --git a/src/main/rate-limits/service-live-claude-usage.test.ts b/src/main/rate-limits/service-live-claude-usage.test.ts index 8c35ab77acc7..f6cbc7006200 100644 --- a/src/main/rate-limits/service-live-claude-usage.test.ts +++ b/src/main/rate-limits/service-live-claude-usage.test.ts @@ -14,6 +14,12 @@ import { resetRateLimitProviderMocks } from './rate-limit-service-test-harness' +const profileRouter = vi.hoisted((): { userConfigDir?: string } => ({})) +vi.mock('../claude-accounts/claude-profile-installed-router', () => ({ + getClaudeProfileRouter: () => + profileRouter.userConfigDir ? { userConfigDir: () => profileRouter.userConfigDir } : undefined +})) + vi.mock('./claude-fetcher', () => ({ fetchClaudeRateLimits: vi.fn(), fetchManagedAccountUsage: vi.fn() @@ -280,6 +286,60 @@ describe('RateLimitService', () => { } }) + it('attributes statusline posts to System Default when it inherits CLAUDE_CONFIG_DIR', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 18)) + mockFreshBackgroundProviderFetches() + vi.stubEnv('CLAUDE_CONFIG_DIR', '/own/claude-config') + const service = new RateLimitService() + service.setClaudeAuthPreparationResolver(async () => ({ + configDir: '/own/claude-config', + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + })) + await service.refresh() + service.ingestLiveClaudeRateLimits({ + configDir: '/own/claude-config', + fiveHour: { used_percentage: 44 }, + sevenDay: null + }) + expect(service.getState().claude?.session?.usedPercent).toBe(44) + } finally { + vi.unstubAllEnvs() + vi.useRealTimers() + } + }) + + it("attributes System Default posts to the login shell's CLAUDE_CONFIG_DIR a Dock launch lacks", async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 18)) + mockFreshBackgroundProviderFetches() + vi.stubEnv('CLAUDE_CONFIG_DIR', '') + profileRouter.userConfigDir = '/shell/claude-config' + const service = new RateLimitService() + service.setClaudeAuthPreparationResolver(async () => ({ + configDir: '/shell/claude-config', + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + })) + await service.refresh() + service.ingestLiveClaudeRateLimits({ + configDir: '/shell/claude-config', + fiveHour: { used_percentage: 44 }, + sevenDay: null + }) + expect(service.getState().claude?.session?.usedPercent).toBe(44) + } finally { + profileRouter.userConfigDir = undefined + vi.unstubAllEnvs() + vi.useRealTimers() + } + }) + it('keeps the other window when a statusline post carries only one', async () => { vi.useFakeTimers() try { @@ -477,51 +537,4 @@ describe('RateLimitService', () => { vi.useRealTimers() } }) - - describe('refreshAfterClaudeLivePtysDrained', () => { - function deferredClaudeResult(): ProviderRateLimits { - return { - ...errorProvider('claude', 'Waiting for Claude session'), - usageMetadata: { - failureKind: 'deferred-by-live-session', - deferredByLiveClaudeSession: true - } - } - } - - it('refetches Claude usage when the current result was deferred by a live session', async () => { - const service = new RateLimitService() - vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(deferredClaudeResult()) - await service.refresh() - expect(service.getState().claude?.usageMetadata?.deferredByLiveClaudeSession).toBe(true) - vi.mocked(fetchClaudeRateLimits).mockClear() - vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) - - await service.refreshAfterClaudeLivePtysDrained() - - expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) - expect(service.getState().claude?.status).toBe('ok') - }) - - it('does not refetch when the current Claude result was not deferred', async () => { - const service = new RateLimitService() - vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce( - errorProvider('claude', 'Token expired') - ) - await service.refresh() - vi.mocked(fetchClaudeRateLimits).mockClear() - - await service.refreshAfterClaudeLivePtysDrained() - - expect(fetchClaudeRateLimits).not.toHaveBeenCalled() - }) - - it('does not refetch when there is no Claude state yet', async () => { - const service = new RateLimitService() - - await service.refreshAfterClaudeLivePtysDrained() - - expect(fetchClaudeRateLimits).not.toHaveBeenCalled() - }) - }) }) diff --git a/src/main/rate-limits/service/service-account-refresh.ts b/src/main/rate-limits/service/service-account-refresh.ts index 6aeacd99583b..944c09be2479 100644 --- a/src/main/rate-limits/service/service-account-refresh.ts +++ b/src/main/rate-limits/service/service-account-refresh.ts @@ -191,15 +191,4 @@ export abstract class RateLimitServiceAccountRefresh extends RateLimitServiceIna await this.fetchClaudeOnly({ force: true }) return this.getState() } - - async refreshAfterClaudeLivePtysDrained(): Promise { - // Why: "Waiting for Claude session" can only recover once no live claude - // owns the credentials. Refetch on the last PTY exit instead of leaving - // the stale terminal error up until the failure backoff elapses. - if (!this.state.claude?.usageMetadata?.deferredByLiveClaudeSession) { - return - } - this.activeFailureStreakByProvider.claude = 0 - await this.fetchClaudeOnly({ force: true }) - } } diff --git a/src/main/rate-limits/service/service-fetch-policy.ts b/src/main/rate-limits/service/service-fetch-policy.ts index cfd4a6e9d58e..907f0512f007 100644 --- a/src/main/rate-limits/service/service-fetch-policy.ts +++ b/src/main/rate-limits/service/service-fetch-policy.ts @@ -10,6 +10,8 @@ import { type ProviderRateLimits } from './service-types' import { mapClaudeUsageWindow } from '../claude-usage-window' +import { readUserClaudeConfigDir } from '../../claude-accounts/claude-profile-paths' +import { getClaudeProfileRouter } from '../../claude-accounts/claude-profile-installed-router' export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchTargets { protected getMiniMaxCredentialError(message: string): ProviderRateLimits { @@ -91,7 +93,12 @@ export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchT return } this.lastClaudeAuthSnapshot = { - configDir: normalizeClaudeConfigDir(authPreparation?.envPatch.CLAUDE_CONFIG_DIR), + // Why the user's own folder: System default passes it through rather than injecting it. + configDir: normalizeClaudeConfigDir( + authPreparation?.envPatch.CLAUDE_CONFIG_DIR ?? + getClaudeProfileRouter()?.userConfigDir() ?? + readUserClaudeConfigDir(process.env) + ), provenance: authPreparation?.provenance ?? 'system' } } diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index 7fd0dec42ad8..c7e3c1d79a12 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -41,8 +41,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR agent, worktreeSelector, location: await this.resolveStructuredAgentSessionLocation(worktreeSelector), - runtime: this, - getSettings: () => this.requireStore().getSettings() + runtime: this }) } diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 1698676a3cb1..4fb79e83cd4b 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -260,8 +260,6 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis resolveAgentCommandSettings: () => this.requireStore().getSettings(), resolveCodexAccountKind: (home) => resolveStructuredCodexAccountKind(home, this.requireStore().getSettings()), - // Same gate and same settings as agentSession.createSupport, re-read on every acquisition. - getClaudeManagedAccountGateSettings: () => this.requireStore().getSettings(), resolveAgentAccountHome: (agent) => this.resolveStructuredAgentAccountHome(agent), ...(this.prepareCodexCatalogProbeHomeFn ? { prepareCodexCatalogProbeHome: this.prepareCodexCatalogProbeHomeFn } diff --git a/src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts b/src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts deleted file mode 100644 index 64b451e9ea90..000000000000 --- a/src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { OrcaRuntimeService } from './orca-runtime' -import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' -import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -function managedAccount(id: string, managedAuthRuntime: 'host' | 'wsl') { - return { - id, - email: `${id}@example.com`, - managedAuthPath: `/managed/${id}`, - managedAuthRuntime, - authMethod: 'subscription-oauth' as const, - createdAt: 0, - updatedAt: 0, - lastAuthenticatedAt: 0 - } -} - -const WSL_ONLY: ClaudeManagedAccountGateSettings = { - claudeManagedAccounts: [managedAccount('wsl-1', 'wsl')], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } -} - -/** Registered Claude accounts with none selected: ambient auth, and the UI names no host identity, - * so this must reach structured rather than silently falling back to a terminal session. */ -const ACCOUNTS_PRESENT_NONE_ACTIVE: ClaudeManagedAccountGateSettings = { - claudeManagedAccounts: [managedAccount('host-1', 'host'), managedAccount('host-2', 'host')], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } -} - -const HOST_SELECTED: ClaudeManagedAccountGateSettings = { - claudeManagedAccounts: [managedAccount('host-1', 'host')], - activeClaudeManagedAccountId: 'host-1', - activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} } -} - -function runtimeWithAccounts(claude: ClaudeManagedAccountGateSettings | null): OrcaRuntimeService { - // No store at all is the unreadable-settings case the gate must fail closed on. - const runtime = claude - ? new OrcaRuntimeService({ getSettings: () => claude } as never) - : new OrcaRuntimeService() - const internal = runtime as unknown as { - resolveStructuredAgentSessionLocation: (selector: string) => Promise - ensureStructuredAgentSessionHost: () => Promise - } - internal.resolveStructuredAgentSessionLocation = vi.fn(async () => ({ - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' as const - })) - // The adapter's own location answer is irrelevant here; pin it supported so only the account - // gate can refuse. - internal.ensureStructuredAgentSessionHost = vi.fn(async () => {}) - setStructuredAgentSessionHost({ - supportsCreate: () => true - } as unknown as StructuredAgentSessionHost) - return runtime -} - -afterEach(() => { - setStructuredAgentSessionHost(null) -}) - -describe('structured Claude managed-account gate', () => { - it('refuses Claude under a WSL-only managed account', async () => { - const runtime = runtimeWithAccounts(WSL_ONLY) - await expect( - runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'claude') - ).resolves.toMatchObject({ supported: false }) - }) - - it('supports Claude when accounts are registered but none is selected', async () => { - const runtime = runtimeWithAccounts(ACCOUNTS_PRESENT_NONE_ACTIVE) - await expect( - runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'claude') - ).resolves.toMatchObject({ supported: true }) - }) - - it('still supports Claude under a selected host managed account', async () => { - const runtime = runtimeWithAccounts(HOST_SELECTED) - await expect( - runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'claude') - ).resolves.toMatchObject({ supported: true }) - }) - - it('fails closed for Claude when the account runtime cannot be determined', async () => { - const runtime = runtimeWithAccounts(null) - await expect( - runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'claude') - ).resolves.toMatchObject({ supported: false }) - }) - - /** The gate is Claude's alone: Codex resolves its account separately and this lane must not - * change any Codex answer. */ - it('leaves Codex supported under the same WSL-only Claude account', async () => { - const runtime = runtimeWithAccounts(WSL_ONLY) - await expect( - runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'codex') - ).resolves.toMatchObject({ supported: true }) - }) -}) diff --git a/src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts b/src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts deleted file mode 100644 index 0d9b12f7c528..000000000000 --- a/src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -const installed = vi.hoisted(() => ({ deps: null as Record | null })) - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -vi.mock('./structured-agent-session-runtime', () => ({ - ensureStructuredAgentSessionHost: vi.fn(async (deps: Record) => { - installed.deps = deps - }) -})) - -import { OrcaRuntimeService } from './orca-runtime' -import { - readClaudeManagedAccountGateSettings, - type ClaudeManagedAccountGateSettings -} from '../native-chat/claude-structured-managed-account-support' - -const SETTINGS = { - claudeManagedAccounts: [], - activeClaudeManagedAccountId: null, - agentDefaultEnv: {}, - agentDefaultArgs: {} -} as unknown as ClaudeManagedAccountGateSettings - -function gateSettingsGetter(): (() => ClaudeManagedAccountGateSettings) | undefined { - const deps: Record = installed.deps ?? {} - const get = deps['getClaudeManagedAccountGateSettings'] - return typeof get === 'function' ? (get as () => ClaudeManagedAccountGateSettings) : undefined -} - -/** The runtime class this wiring lives on does not typecheck its own `this` calls, so a broken or - * missing gate hookup compiles clean. Pin it behaviourally instead. */ -describe('structured Claude managed-account gate wiring', () => { - it('hands the host a gate reader that resolves the live settings', async () => { - installed.deps = null - const runtime = new OrcaRuntimeService({ getSettings: () => SETTINGS } as never) - - await runtime.ensureStructuredAgentSessionHost() - - const get = gateSettingsGetter() - expect(typeof get).toBe('function') - expect(get?.()).toBe(SETTINGS) - }) - - /** The installer composes this getter with the fail-closed reader, which is the shape the - * resolver consumes; pin that composition end to end. */ - it('composes into a null answer instead of throwing when settings cannot be read', async () => { - installed.deps = null - const runtime = new OrcaRuntimeService() - - await runtime.ensureStructuredAgentSessionHost() - - const get = gateSettingsGetter() - expect(typeof get).toBe('function') - expect(() => get?.()).toThrow() - expect(readClaudeManagedAccountGateSettings(get!)).toBeNull() - }) -}) diff --git a/src/main/runtime/rpc/methods/accounts.test.ts b/src/main/runtime/rpc/methods/accounts.test.ts index b8473d00e014..c10d70b7ad99 100644 --- a/src/main/runtime/rpc/methods/accounts.test.ts +++ b/src/main/runtime/rpc/methods/accounts.test.ts @@ -15,20 +15,6 @@ function method(name: string) { describe('account RPC methods', () => { it.each([ - { - methodName: 'accounts.addClaudeFromConfigDir', - params: { - configDir: join(tmpdir(), 'claude-login'), - previousLegacyCredentialsSha256: 'a'.repeat(64) - }, - runtimeMethod: 'addClaudeAccountFromConfigDir', - expectedSource: join(tmpdir(), 'claude-login'), - expectedOptions: { - runtime: undefined, - wslDistro: null, - previousLegacyCredentialsSha256: 'a'.repeat(64) - } - }, { methodName: 'accounts.addCodexFromHome', params: { sourceHome: join(tmpdir(), 'codex-login') }, @@ -50,29 +36,37 @@ describe('account RPC methods', () => { }) it.each([ - ['accounts.addClaudeFromConfigDir', { configDir: join(tmpdir(), 'claude-login') }], - ['accounts.addCodexFromHome', { sourceHome: join(tmpdir(), 'codex-login') }], + ['accounts.addCodexFromHome', { sourceHome: join(tmpdir(), 'codex-login') }, /only available/], [ 'accounts.addDataFromHome', - { provider: 'opencode', sourceDataHome: join(tmpdir(), 'login'), label: 'Work' } - ] - ])('rejects paired-device calls to %s', async (methodName, params) => { + { provider: 'opencode', sourceDataHome: join(tmpdir(), 'login'), label: 'Work' }, + /only available/ + ], + ['accounts.beginClaudeSignIn', {}, /Sign in on the Orca execution host/], + ['accounts.finishClaudeSignIn', { accountId: 'a' }, /Sign in on the Orca execution host/] + ] as const)('rejects paired-device calls to %s', async (methodName, params, refusal) => { const runtime = { - addClaudeAccountFromConfigDir: vi.fn(), - addCodexAccountFromHome: vi.fn() - } as unknown as OrcaRuntimeService + addCodexAccountFromHome: vi.fn(), + addDataAccountFromHome: vi.fn(), + beginClaudeSignIn: vi.fn(), + finishClaudeSignIn: vi.fn() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: refused calls never reach the runtime; only these stubs are asserted. + const runtimeService = runtime as unknown as OrcaRuntimeService const addMethod = method(methodName) if (isStreamingMethod(addMethod)) { throw new Error(`${methodName} must be a request method`) } for (const clientKind of ['mobile', 'runtime'] as const) { - await expect(addMethod.handler(params, { runtime, clientKind })).rejects.toThrow( - /only available on the Orca host runtime/ - ) + await expect( + addMethod.handler(params, { runtime: runtimeService, clientKind }) + ).rejects.toThrow(refusal) } - expect(runtime.addClaudeAccountFromConfigDir).not.toHaveBeenCalled() expect(runtime.addCodexAccountFromHome).not.toHaveBeenCalled() + expect(runtime.addDataAccountFromHome).not.toHaveBeenCalled() + expect(runtime.beginClaudeSignIn).not.toHaveBeenCalled() + expect(runtime.finishClaudeSignIn).not.toHaveBeenCalled() }) it('keeps explicit account-list refreshes on the forced refresh lane', async () => { @@ -227,3 +221,14 @@ describe('account RPC methods', () => { await running }) }) + +it('rejects the retired Claude import even for a local socket, before touching its supplied path', async () => { + const old = method('accounts.addClaudeFromConfigDir') + if (isStreamingMethod(old)) { + throw new Error('Unexpected stream') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Rejection happens before the handler accesses its context. + await expect(old.handler({ configDir: '/never-read' }, {} as never)).rejects.toThrow( + 'Update the Orca CLI to add accounts' + ) +}) diff --git a/src/main/runtime/rpc/methods/accounts.ts b/src/main/runtime/rpc/methods/accounts.ts index 10c39aacd2f8..7c97c36ac405 100644 --- a/src/main/runtime/rpc/methods/accounts.ts +++ b/src/main/runtime/rpc/methods/accounts.ts @@ -4,6 +4,8 @@ import { SelectDataAccountParams, RemoveDataAccountParams, AccountsUnsubscribeParams, + BeginClaudeSignInParams, + FinishClaudeSignInParams, AddClaudeFromConfigDirParams, AddCodexFromHomeParams, ConsumeCodexResetCreditParams, @@ -19,15 +21,42 @@ import { // registerSubscriptionCleanup's existing-key eviction path. let accountsSubscriptionSeq = 0 -// Why: bridges the desktop ClaudeAccountService / CodexAccountService / -// RateLimitService into the WebSocket / local-socket RPC. Read + switch + -// remove for all clients; interactive add/re-auth flows spawn `claude login` -// / `codex login` PTYs that need a desktop browser, so they intentionally -// remain desktop-only. `accounts.addClaudeFromConfigDir` is the exception: it -// captures an already-authenticated CLAUDE_CONFIG_DIR (no PTY) so the local -// `orca account add` CLI can register accounts on a headless host; it is gated -// to the local runtime connection, never a mobile device token. See #1438. export const ACCOUNT_METHODS = [ + // Why local only: sign-in runs `claude auth login` in a terminal on the execution host. + defineMethod({ + name: 'accounts.beginClaudeSignIn', + permission: 'accounts-admin', + params: BeginClaudeSignInParams, + handler: async (params, { runtime, clientKind }) => { + if (clientKind !== undefined) { + throw new Error('Sign in on the Orca execution host.') + } + return runtime.beginClaudeSignIn(params) + } + }), + defineMethod({ + name: 'accounts.finishClaudeSignIn', + permission: 'accounts-admin', + params: FinishClaudeSignInParams, + handler: async (params, { runtime, clientKind }) => { + if (clientKind !== undefined) { + throw new Error('Sign in on the Orca execution host.') + } + return runtime.finishClaudeSignIn(params) + } + }), + defineMethod({ + name: 'accounts.cancelClaudeSignIn', + permission: 'accounts-admin', + params: FinishClaudeSignInParams, + handler: async (params, { runtime, clientKind }) => { + if (clientKind !== undefined) { + throw new Error('Sign in on the Orca execution host.') + } + await runtime.cancelClaudeSignIn(params) + return {} + } + }), defineMethod({ name: 'accounts.listData', permission: 'workspace', @@ -118,17 +147,10 @@ export const ACCOUNT_METHODS = [ name: 'accounts.addClaudeFromConfigDir', permission: 'accounts-admin', params: AddClaudeFromConfigDirParams, - handler: async (params, { runtime, clientKind }) => { - // Why: capturing a host filesystem path is local-socket-only; paired - // mobile and remote-runtime tokens must never read host credential paths. - if (clientKind !== undefined) { - throw new Error('Adding Claude accounts is only available on the Orca host runtime.') - } - return runtime.addClaudeAccountFromConfigDir(params.configDir, { - runtime: params.runtime, - wslDistro: params.wslDistro ?? null, - previousLegacyCredentialsSha256: params.previousLegacyCredentialsSha256 - }) + handler: async () => { + throw new Error( + 'Update the Orca CLI to add accounts. Importing Claude logins is no longer supported.' + ) } }), defineMethod({ diff --git a/src/main/runtime/runtime-account-controller.ts b/src/main/runtime/runtime-account-controller.ts index c824301e5a5f..248adf2c18f1 100644 --- a/src/main/runtime/runtime-account-controller.ts +++ b/src/main/runtime/runtime-account-controller.ts @@ -2,7 +2,9 @@ import type { ClaudeAccountService } from '../claude-accounts/service' import { hasAppEnvironment } from '../../shared/app-environment' import { getManagedDataAccountService } from '../managed-data-accounts/service' import type { + ClaudeAccountSignIn, ClaudeRateLimitAccountsState, + ClaudeSignInRequest, CodexRateLimitAccountsState, ManagedDataAccountProvider, ManagedDataAccountsState @@ -164,15 +166,18 @@ export class RuntimeAccountController { return this.requireServices().claudeAccounts.removeAccount(accountId) } - addClaudeFromConfigDir( - configDir: string, - options?: { - runtime?: 'host' | 'wsl' - wslDistro?: string | null - previousLegacyCredentialsSha256?: string | null - } + beginClaudeSignIn(request: ClaudeSignInRequest): Promise { + return this.requireServices().claudeAccounts.beginSignIn(request) + } + + finishClaudeSignIn( + signIn: Omit ): Promise { - return this.requireServices().claudeAccounts.addAccountFromConfigDir(configDir, options) + return this.requireServices().claudeAccounts.finishSignIn(signIn) + } + + cancelClaudeSignIn(signIn: Omit): Promise { + return this.requireServices().claudeAccounts.cancelSignIn(signIn) } removeCodex(accountId: string): Promise { diff --git a/src/main/runtime/runtime-service-command-surface.ts b/src/main/runtime/runtime-service-command-surface.ts index c62fc799dd40..966976e1a946 100644 --- a/src/main/runtime/runtime-service-command-surface.ts +++ b/src/main/runtime/runtime-service-command-surface.ts @@ -54,7 +54,9 @@ export type RuntimeServiceCommandSurface = { selectCodexAccountForTarget: RuntimeAccountController['selectCodexForTarget'] consumeCodexRateLimitResetCredit: RuntimeAccountController['consumeCodexResetCredit'] removeClaudeAccount: RuntimeAccountController['removeClaude'] - addClaudeAccountFromConfigDir: RuntimeAccountController['addClaudeFromConfigDir'] + beginClaudeSignIn: RuntimeAccountController['beginClaudeSignIn'] + finishClaudeSignIn: RuntimeAccountController['finishClaudeSignIn'] + cancelClaudeSignIn: RuntimeAccountController['cancelClaudeSignIn'] removeCodexAccount: RuntimeAccountController['removeCodex'] addCodexAccountFromHome: RuntimeAccountController['addCodexFromHome'] addDataAccountFromHome: RuntimeAccountController['addDataFromHome'] @@ -154,7 +156,9 @@ export function installRuntimeServiceCommandSurface( selectCodexAccountForTarget: accounts.selectCodexForTarget.bind(accounts), consumeCodexRateLimitResetCredit: accounts.consumeCodexResetCredit.bind(accounts), removeClaudeAccount: accounts.removeClaude.bind(accounts), - addClaudeAccountFromConfigDir: accounts.addClaudeFromConfigDir.bind(accounts), + beginClaudeSignIn: accounts.beginClaudeSignIn.bind(accounts), + finishClaudeSignIn: accounts.finishClaudeSignIn.bind(accounts), + cancelClaudeSignIn: accounts.cancelClaudeSignIn.bind(accounts), removeCodexAccount: accounts.removeCodex.bind(accounts), addCodexAccountFromHome: accounts.addCodexFromHome.bind(accounts), addDataAccountFromHome: accounts.addDataFromHome.bind(accounts), diff --git a/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts b/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts index aecff69f1785..97f258120726 100644 --- a/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts +++ b/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts @@ -66,8 +66,7 @@ function createSupport(settings: ReturnType) { runtime: { requireStore: () => ({ getSettings: () => settings }), resolveRuntimeFileTarget: async () => ({ worktree: { path: join(root, 'proj') } }) - }, - getSettings: () => ({ claudeManagedAccounts: [], activeClaudeManagedAccountId: null }) + } }) } diff --git a/src/main/runtime/structured-agent-launch-support.ts b/src/main/runtime/structured-agent-launch-support.ts index 14a60f024d13..5f81b2c91c5b 100644 --- a/src/main/runtime/structured-agent-launch-support.ts +++ b/src/main/runtime/structured-agent-launch-support.ts @@ -4,7 +4,6 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import { nativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell-environment' import { isTuiAgent } from '../../shared/tui-agent-config' import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' -import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { resolveStructuredAgentSessionCreateSupport, warnStructuredAgentSessionCreateUnsupported, @@ -60,13 +59,12 @@ export async function structuredAgentSupportsLaunch( /** `agentSession.createSupport` on this host: every refusal it can know before spawning. The * agent's location rule and installed-agent check from its registration, without installing the - * host, then Claude's managed-account gate. A refusal logs which check said no. */ + * host. A refusal logs which check said no. */ export async function resolveHostStructuredAgentCreateSupport(input: { agent: StructuredAgentId worktreeSelector: string location: AgentSessionExecutionLocation runtime: LaunchSupportRuntime - getSettings: () => ClaudeManagedAccountGateSettings }): Promise { const { agent, location } = input const supportsLocation = @@ -75,15 +73,13 @@ export async function resolveHostStructuredAgentCreateSupport(input: { supportsLocation && (await structuredAgentSupportsLaunch(agent, input.worktreeSelector, input.runtime)) const support = resolveStructuredAgentSessionCreateSupport({ - agent, location, - adapterSupportsCreate: supportsLaunch, - getSettings: input.getSettings + adapterSupportsCreate: supportsLaunch }) warnStructuredAgentSessionCreateUnsupported( agent, support, - !supportsLocation ? 'location' : !supportsLaunch ? 'installed-agent' : 'managed-account' + supportsLocation ? 'installed-agent' : 'location' ) return support } diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index deed5bc01341..20cc9ee2523c 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -21,7 +21,6 @@ import { import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' -import { readClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import { agentSessionAttachmentStoreRoot } from '../native-chat/agent-session-attachments/agent-session-attachment-references' import { @@ -172,12 +171,6 @@ function createClaudeAdapter( ...(deps.resolveClaudePermissionMode ? { resolveClaudePermissionMode: deps.resolveClaudePermissionMode } : {}), - ...(deps.getClaudeManagedAccountGateSettings - ? { - readClaudeManagedAccountGate: () => - readClaudeManagedAccountGateSettings(deps.getClaudeManagedAccountGateSettings!) - } - : {}), attachmentDirectory: agentSessionAttachmentStoreRoot(deps.stateDirectory), onLifecycleEvent: context.deliverLifecycle, logger: deps.logger, diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 8853d7428f20..efcdab0f22cb 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -34,7 +34,6 @@ import { import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { installAgentSessionAttachments, stopAgentSessionAttachments @@ -134,8 +133,6 @@ export type StructuredAgentSessionRuntimeDeps = { resolveAgentLaunchEnv?: (agent: string) => Record /** The settings a per-agent Command override is read from, for the same agents. */ resolveAgentCommandSettings?: () => StructuredAgentCommandSettings - /** Raw settings getter; the reader that fails closed around it is built here, in checked code. */ - getClaudeManagedAccountGateSettings?: () => ClaudeManagedAccountGateSettings resolveEnvironment?: () => Promise /** Which login-shell variables Codex and Claude children inherit; absent inherits all. */ resolveShellEnvironmentPolicy?: () => NativeChatShellEnvironmentPolicy diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 269fd8423149..d9abf1cf3885 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -15,7 +15,6 @@ import { recordAgentSessionProviderHandle, reviseAgentSessionProviderResumePoint } from './agent-session-provider-handle-transition' -import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import type { AgentSessionRecordStore } from './agent-session-record-store' import { ClaudeAtRestCommandCatalog } from '../claude/claude-at-rest-commands' import { openClaudeStreamJsonConnection } from '../claude/claude-stream-json-connection' @@ -40,7 +39,6 @@ export type StructuredClaudeRuntimeAdapterDeps = { resolveClaudeAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy /** The user's Agent Permissions setting for Claude; absent means prompting. */ resolveClaudePermissionMode?: () => Promise | PermissionMode - readClaudeManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null /** Where the host stores chat attachments; granted to the agent as a readable directory. */ attachmentDirectory?: string openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] @@ -117,9 +115,6 @@ export function createStructuredClaudeRuntimeAdapter( ...(deps.resolveClaudePermissionMode ? { resolvePermissionMode: deps.resolveClaudePermissionMode } : {}), - ...(deps.readClaudeManagedAccountGate - ? { readManagedAccountGate: deps.readClaudeManagedAccountGate } - : {}), ...(deps.attachmentDirectory ? { attachmentDirectory: deps.attachmentDirectory } : {}), ...(deps.claudeCliFlags ? { cliFlags: deps.claudeCliFlags } : {}), ...(deps.prepareVisuals ? { prepareVisuals: deps.prepareVisuals } : {}) diff --git a/src/main/shell-wrapper-generated-file-snapshot.test.ts b/src/main/shell-wrapper-generated-file-snapshot.test.ts index 9e86ebdd15ca..edc24c7a44a5 100644 --- a/src/main/shell-wrapper-generated-file-snapshot.test.ts +++ b/src/main/shell-wrapper-generated-file-snapshot.test.ts @@ -68,6 +68,8 @@ async function expectWrapperFiles(transport: string, root: string): Promise !activeIds.has(account.id)) .map((account) => ({ id: account.id, - managedAuthPath: account.managedAuthPath, managedAuthRuntime: account.managedAuthRuntime, wslDistro: account.wslDistro, wslLinuxAuthPath: account.wslLinuxAuthPath diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 0b6ac644a25e..015a7eba6544 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -24,11 +24,6 @@ import { setDefaultWslDistroOverride } from '../git/runner' import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' -import { - attachClaudeLivePtyPersistence, - onLiveClaudePtysDrained, - seedLiveClaudePtysFromPersistence -} from '../claude-accounts/live-pty-gate' import { applyAppIcon } from '../app-icon' import { shouldSuppressDevEducation, @@ -265,21 +260,6 @@ export async function initializeReadyFoundation(): Promise { } } }) - // Why: run before ClaudeRuntimeAuthService's constructor sync — a surviving daemon Claude CLI holds the single-use refresh token; early refresh rotates it out mid-session. - attachClaudeLivePtyPersistence(store) - // Why: while a live claude defers the managed OAuth refresh, usage shows - // "Waiting for Claude session"; refetch when the last live PTY exits so the - // error clears immediately instead of after the failure backoff. - onLiveClaudePtysDrained(() => { - void state.rateLimits?.refreshAfterClaudeLivePtysDrained() - }) - const persistedClaudePtyIds = store.getClaudeLivePtySessionIds() - seedLiveClaudePtysFromPersistence(persistedClaudePtyIds) - if (persistedClaudePtyIds.length > 0) { - console.log( - `[claude-live-pty] Seeded ${persistedClaudePtyIds.length} persisted Claude session id(s) into the refresh gate` - ) - } applyAppIcon(store.getSettings().appIcon) if (shouldSuppressDevEducation({ isDev: is.dev })) { suppressDevEducationForStore(store) diff --git a/src/main/startup/main-process-ready-identity-write.test.ts b/src/main/startup/main-process-ready-identity-write.test.ts index 4d2285e3b8e4..19bb07714e92 100644 --- a/src/main/startup/main-process-ready-identity-write.test.ts +++ b/src/main/startup/main-process-ready-identity-write.test.ts @@ -73,9 +73,6 @@ vi.mock('../persistence', () => ({ return {} } onSettingsChanged() {} - getClaudeLivePtySessionIds() { - return [] - } getSshTargets() { return [] } @@ -87,7 +84,6 @@ vi.mock('../persistence/profile-state/profile-state-startup-authority', () => ({ store: { getSettings: () => ({}), onSettingsChanged: () => {}, - getClaudeLivePtySessionIds: () => [], getSshTargets: () => [] } }) @@ -146,11 +142,6 @@ vi.mock('../git/runner', () => ({ vi.mock('../agent-hooks/wsl-hook-relay-manager', () => ({ wslHookRelayManager: { setManagedHookSettingsResolver: vi.fn() } })) -vi.mock('../claude-accounts/live-pty-gate', () => ({ - attachClaudeLivePtyPersistence: vi.fn(), - onLiveClaudePtysDrained: vi.fn(), - seedLiveClaudePtysFromPersistence: vi.fn() -})) vi.mock('../app-icon', () => ({ applyAppIcon: vi.fn() })) vi.mock('./dev-education-suppression', () => ({ shouldSuppressDevEducation: () => false, diff --git a/src/main/startup/main-window-core-services.test.ts b/src/main/startup/main-window-core-services.test.ts index ef986e43ab87..aedb8dfccada 100644 --- a/src/main/startup/main-window-core-services.test.ts +++ b/src/main/startup/main-window-core-services.test.ts @@ -112,7 +112,6 @@ describe('main window profile-state update preparation', () => { expect(preserveAgentAuthBeforeRestartMock).toHaveBeenCalledWith({ codexRuntimeHome: state.codexRuntimeHome, - claudeRuntimeAuth: state.claudeRuntimeAuth, store }) expect(store.writeLatestProfileStateJsonExportAsync).not.toHaveBeenCalled() diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts index 8f1cac48de6e..96c8c65c18ea 100644 --- a/src/main/startup/main-window-core-services.ts +++ b/src/main/startup/main-window-core-services.ts @@ -91,11 +91,7 @@ export function attachMainWindowCoreServices( onBeforeRelaunch: async () => { state.isQuitting = true state.desktopRelayService?.fenceAndCloseNow() - await preserveAgentAuthBeforeRestart({ - codexRuntimeHome, - claudeRuntimeAuth, - store - }) + await preserveAgentAuthBeforeRestart({ codexRuntimeHome, store }) }, onOrcaProfileAuthMutation: () => state.desktopRelayService?.authMutated(), // Sign-out is the one fence a paired phone can be told about; quit and @@ -131,7 +127,7 @@ export function attachMainWindowCoreServices( onCodexHomePtySpawned: handleCodexHomePtySpawned, onPtyExit: handlePtyExit, onBeforeUpdateQuit: async () => { - await preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }) + await preserveAgentAuthBeforeRestart({ codexRuntimeHome, store }) await store.flushPendingOrThrowAsync({ fullCheckpoint: true }) }, onBeforeUpdateQuitFailure: 'abort', diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index 229ef1a5414c..cbcf78a4abe2 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -145,6 +145,8 @@ export type PtyApi = { /** Runs the fix with the pane's own Codex; true only once verified. Local panes only. */ disableCodexSharedServerAutoStart: (id: string) => Promise stopCodexSharedServer: (id: string) => Promise + /** A local pane whose daemon predates per-account Claude folders; false for any other pane. */ + openedBeforeClaudeAccounts: (id: string) => Promise getCwd: (id: string) => Promise getSize: (id: string) => Promise<{ cols: number; rows: number } | null> listSessions: (scope?: PtySessionListScope) => Promise diff --git a/src/preload/api/pty-bridge-stream-and-serialization.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts index e0b2e7f2bf67..3d72ac5e518f 100644 --- a/src/preload/api/pty-bridge-stream-and-serialization.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -23,6 +23,8 @@ export const ptyStreamAndSerializationApi = { ipcRenderer.invoke('pty:disableCodexSharedServerAutoStart', { id }), stopCodexSharedServer: (id: string): Promise => ipcRenderer.invoke('pty:stopCodexSharedServer', { id }), + openedBeforeClaudeAccounts: (id: string): Promise => + ipcRenderer.invoke('pty:openedBeforeClaudeAccounts', { id }), getCwd: (id: string): Promise => ipcRenderer.invoke('pty:getCwd', { id }), getSize: (id: string): Promise<{ cols: number; rows: number } | null> => ipcRenderer.invoke('pty:getSize', { id }), diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index bab4c09e1e81..9f833364ab15 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -22,6 +22,7 @@ import { useTerminalViewerColorPublication } from './use-terminal-viewer-color-p import { useBrowserIdentityMigrationNotice } from '../components/browser-pane/browser-user-agent-migration-notice' import { useCodexTerminalServerIsolationNotice } from '../components/terminal-pane/codex-terminal-server-isolation-notice' import { useCodexSharedSettingsNotice } from '../components/terminal-pane/codex-shared-settings-notice' +import { useClaudeAccountSignInNotice } from '../lib/claude-account-sign-in-notice' import { useVisibleReviewRefreshReporting } from './use-visible-review-refresh-reporting' import { useVisibleHostedReviewRefresh } from './use-visible-hosted-review-refresh' import { useProfileStateSaveDelayNotice } from './use-profile-state-save-delay-notice' @@ -64,4 +65,5 @@ export function useAppShellServices(): void { useBrowserIdentityMigrationNotice() useCodexTerminalServerIsolationNotice() useCodexSharedSettingsNotice() + useClaudeAccountSignInNotice() } diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx index 1bd21e9ec013..47856cdaae84 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -15,6 +15,12 @@ import { useNativeChatOrcaStopView } from './native-chat-orca-stop-context' import { nativeChatOrcaStopRowText } from './native-chat-orca-stop-words' import { AGENT_SESSION_ORCA_STOP_PRESENTATION } from '../../../../shared/agent-session-orca-stop' import { ProviderFrameRow } from './NativeChatTranscriptChrome' +import { Button } from '@/components/ui/button' +import { + isClaudeSignInFailureKind, + nativeChatClaudeSignInLabel, + useNativeChatClaudeSignInView +} from './native-chat-claude-sign-in' const HOST_STATUS_WORDS: Record string> = { 'history-repaired': () => @@ -39,6 +45,7 @@ export function NativeChatNoticeRow({ allowFileUriLinks?: boolean }): React.JSX.Element { const orcaStopView = useNativeChatOrcaStopView() + const claudeSignIn = useNativeChatClaudeSignInView() if (block.presentation === 'compaction') { const label = translate('components.native-chat.notices.compaction', 'Context compacted') return ( @@ -123,6 +130,17 @@ export function NativeChatNoticeRow({ {Icon ?