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 index 1c1a3cad560..86054b9aab2 100644 --- a/src/main/native-chat/claude-structured-managed-account-support.test.ts +++ b/src/main/native-chat/claude-structured-managed-account-support.test.ts @@ -1,11 +1,15 @@ import { describe, expect, it } from 'vitest' -import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' -import { structuredClaudeMatchesActiveManagedAccount } from './claude-structured-managed-account-support' +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, @@ -14,37 +18,34 @@ function account(id: string, managedAuthRuntime: 'host' | 'wsl') { } } -function state(overrides: Partial): ClaudeRateLimitAccountsState { - return { accounts: [], activeAccountId: null, ...overrides } +function settings( + overrides: Partial +): ClaudeManagedAccountGateSettings { + return { claudeManagedAccounts: [], activeClaudeManagedAccountId: null, ...overrides } } describe('structuredClaudeMatchesActiveManagedAccount', () => { it('allows an unmanaged install, where nothing claims an identity', () => { - expect(structuredClaudeMatchesActiveManagedAccount(state({}))).toBe(true) + expect(structuredClaudeMatchesActiveManagedAccount(settings({}))).toBe(true) }) it('allows a selected host account, which the runtime syncs into the ambient config', () => { expect( structuredClaudeMatchesActiveManagedAccount( - state({ - accounts: [account('host-1', 'host')], - activeAccountIdsByRuntime: { host: 'host-1', wsl: {} } + settings({ + claudeManagedAccounts: [account('host-1', 'host')], + activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} } }) ) ).toBe(true) - expect( - structuredClaudeMatchesActiveManagedAccount( - state({ accounts: [account('host-1', 'host')], activeAccountId: 'host-1' }) - ) - ).toBe(true) }) it('refuses a WSL-only managed account, which never reaches the ambient config', () => { expect( structuredClaudeMatchesActiveManagedAccount( - state({ - accounts: [account('wsl-1', 'wsl')], - activeAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } + settings({ + claudeManagedAccounts: [account('wsl-1', 'wsl')], + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } }) ) ).toBe(false) @@ -53,30 +54,55 @@ describe('structuredClaudeMatchesActiveManagedAccount', () => { it('refuses when a host selection names an account that is WSL-bound or missing', () => { expect( structuredClaudeMatchesActiveManagedAccount( - state({ - accounts: [account('wsl-1', 'wsl')], - activeAccountIdsByRuntime: { host: 'wsl-1', wsl: {} } + settings({ + claudeManagedAccounts: [account('wsl-1', 'wsl')], + activeClaudeManagedAccountIdsByRuntime: { host: 'wsl-1', wsl: {} } }) ) ).toBe(false) expect( structuredClaudeMatchesActiveManagedAccount( - state({ - accounts: [account('host-1', 'host')], - activeAccountIdsByRuntime: { host: 'gone', wsl: {} } + settings({ + claudeManagedAccounts: [account('host-1', 'host')], + activeClaudeManagedAccountIdsByRuntime: { host: 'gone', wsl: {} } }) ) ).toBe(false) }) - it('fails closed when the account state cannot be read at all', () => { + it('fails closed when the settings cannot be read at all', () => { expect(structuredClaudeMatchesActiveManagedAccount(null)).toBe(false) expect(structuredClaudeMatchesActiveManagedAccount(undefined)).toBe(false) }) - it('fails closed when managed accounts exist but none is active', () => { + it('fails closed when managed accounts exist but no host account is selected', () => { expect( - structuredClaudeMatchesActiveManagedAccount(state({ accounts: [account('host-1', 'host')] })) + structuredClaudeMatchesActiveManagedAccount( + settings({ claudeManagedAccounts: [account('host-1', 'host')] }) + ) ).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 index 8486cfa1cf7..f5834c31ba3 100644 --- a/src/main/native-chat/claude-structured-managed-account-support.ts +++ b/src/main/native-chat/claude-structured-managed-account-support.ts @@ -1,4 +1,12 @@ -import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' +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 @@ -8,22 +16,27 @@ import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account- * another. Refuse the structured path there and let the terminal-backed one, which resolves the * account per runtime, handle that account shape. * - * Unknown answers refuse: an install with no managed accounts claims no identity and is fine, but - * an active selection this cannot resolve is not evidence that the ambient identity is right. + * 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: an install with no managed accounts claims no identity and is fine, but a + * selection this cannot resolve is not evidence that the ambient identity is right. */ export function structuredClaudeMatchesActiveManagedAccount( - accounts: ClaudeRateLimitAccountsState | null | undefined + settings: ClaudeManagedAccountGateSettings | null | undefined ): boolean { - if (!accounts) { + const accounts = settings?.claudeManagedAccounts + if (!settings || !Array.isArray(accounts)) { return false } - if (accounts.accounts.length === 0) { + if (accounts.length === 0) { return true } - const activeHostId = accounts.activeAccountIdsByRuntime?.host ?? accounts.activeAccountId ?? null + const activeHostId = getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) if (!activeHostId) { return false } - const active = accounts.accounts.find((candidate) => candidate.id === activeHostId) + const active = accounts.find((candidate) => candidate.id === activeHostId) return active ? active.managedAuthRuntime !== 'wsl' : false } diff --git a/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts b/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts index b6443961435..a9b804c498d 100644 --- a/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts +++ b/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts @@ -4,7 +4,10 @@ import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-a import { agentSessionOwnerBindingsEqual } from '../../shared/claimed-agent-pty-owner-snapshot' import { resolvePinnedCodexRolloutProof } from '../codex/codex-tui-rollout-proof' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import { structuredClaudeMatchesActiveManagedAccount } from '../native-chat/claude-structured-managed-account-support' +import { + structuredClaudeMatchesActiveManagedAccount, + type ClaudeManagedAccountGateSettings +} from '../native-chat/claude-structured-managed-account-support' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' @@ -46,18 +49,28 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca return { transcriptPath } } + private readClaudeAccountGateSettings(): ClaudeManagedAccountGateSettings | null { + try { + return this.requireStore().getSettings() + } catch { + return null + } + } + async getStructuredAgentSessionCreateSupport( worktreeSelector: string, agent: 'claude' | 'codex' ): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> { - const accountState = agent === 'claude' ? this.accounts.readClaudeManagedAccounts() : null + // Same settings the auth policy reads, so the gate and the policy cannot resolve different + // accounts. Unreadable settings fail closed below. + const accountSettings = agent === 'claude' ? this.readClaudeAccountGateSettings() : null const location = await this.resolveStructuredAgentSessionLocation(worktreeSelector) await this.ensureStructuredAgentSessionHost() if (getStructuredAgentSessionHost()?.supportsCreate(location, agent)) { // Claude only: Codex resolves its account through 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 (agent === 'claude' && !structuredClaudeMatchesActiveManagedAccount(accountState)) { + if (agent === 'claude' && !structuredClaudeMatchesActiveManagedAccount(accountSettings)) { return { supported: false, reason: 'wsl' } } return { supported: true } 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 index e6a17b33591..4ac7bea17bd 100644 --- a/src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts +++ b/src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts @@ -2,7 +2,7 @@ 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 { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' +import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -15,6 +15,7 @@ function managedAccount(id: string, managedAuthRuntime: 'host' | 'wsl') { return { id, email: `${id}@example.com`, + managedAuthPath: `/managed/${id}`, managedAuthRuntime, authMethod: 'subscription-oauth' as const, createdAt: 0, @@ -23,27 +24,23 @@ function managedAccount(id: string, managedAuthRuntime: 'host' | 'wsl') { } } -const WSL_ONLY: ClaudeRateLimitAccountsState = { - accounts: [managedAccount('wsl-1', 'wsl')], - activeAccountId: null, - activeAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } +const WSL_ONLY: ClaudeManagedAccountGateSettings = { + claudeManagedAccounts: [managedAccount('wsl-1', 'wsl')], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } } -const HOST_SELECTED: ClaudeRateLimitAccountsState = { - accounts: [managedAccount('host-1', 'host')], - activeAccountId: 'host-1', - activeAccountIdsByRuntime: { host: 'host-1', wsl: {} } +const HOST_SELECTED: ClaudeManagedAccountGateSettings = { + claudeManagedAccounts: [managedAccount('host-1', 'host')], + activeClaudeManagedAccountId: 'host-1', + activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} } } -function runtimeWithAccounts(claude: ClaudeRateLimitAccountsState | null): OrcaRuntimeService { - const runtime = new OrcaRuntimeService() - if (claude) { - runtime.setAccountServices({ - claudeAccounts: { listAccounts: () => claude }, - codexAccounts: { listAccounts: () => ({ accounts: [], activeAccountId: null }) }, - rateLimits: { getState: () => ({}) } - } as never) - } +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 diff --git a/src/main/runtime/runtime-account-controller.ts b/src/main/runtime/runtime-account-controller.ts index f4c59852d96..45d3ade97ff 100644 --- a/src/main/runtime/runtime-account-controller.ts +++ b/src/main/runtime/runtime-account-controller.ts @@ -58,15 +58,6 @@ export class RuntimeAccountController { return this.services?.claudeAccounts.getRuntimeConfigDir(target) ?? null } - /** Null when the account state cannot be read, so gates can fail closed instead of throwing. */ - readClaudeManagedAccounts(): ClaudeRateLimitAccountsState | null { - try { - return this.services?.claudeAccounts.listAccounts() ?? null - } catch { - return null - } - } - getSnapshot(): AccountsSnapshot { const { claudeAccounts, codexAccounts, rateLimits } = this.requireServices() return {