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 new file mode 100644 index 00000000000..1c1a3cad560 --- /dev/null +++ b/src/main/native-chat/claude-structured-managed-account-support.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' +import { structuredClaudeMatchesActiveManagedAccount } from './claude-structured-managed-account-support' + +function account(id: string, managedAuthRuntime: 'host' | 'wsl') { + return { + id, + email: `${id}@example.com`, + managedAuthRuntime, + authMethod: 'subscription-oauth' as const, + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } +} + +function state(overrides: Partial): ClaudeRateLimitAccountsState { + return { accounts: [], activeAccountId: null, ...overrides } +} + +describe('structuredClaudeMatchesActiveManagedAccount', () => { + it('allows an unmanaged install, where nothing claims an identity', () => { + expect(structuredClaudeMatchesActiveManagedAccount(state({}))).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: {} } + }) + ) + ).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' } } + }) + ) + ).toBe(false) + }) + + 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: {} } + }) + ) + ).toBe(false) + expect( + structuredClaudeMatchesActiveManagedAccount( + state({ + accounts: [account('host-1', 'host')], + activeAccountIdsByRuntime: { host: 'gone', wsl: {} } + }) + ) + ).toBe(false) + }) + + it('fails closed when the account state 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', () => { + expect( + structuredClaudeMatchesActiveManagedAccount(state({ accounts: [account('host-1', 'host')] })) + ).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 new file mode 100644 index 00000000000..8486cfa1cf7 --- /dev/null +++ b/src/main/native-chat/claude-structured-managed-account-support.ts @@ -0,0 +1,29 @@ +import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' + +/** + * 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. + * + * 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. + */ +export function structuredClaudeMatchesActiveManagedAccount( + accounts: ClaudeRateLimitAccountsState | null | undefined +): boolean { + if (!accounts) { + return false + } + if (accounts.accounts.length === 0) { + return true + } + const activeHostId = accounts.activeAccountIdsByRuntime?.host ?? accounts.activeAccountId ?? null + if (!activeHostId) { + return false + } + const active = accounts.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 ebaa8bf614c..b6443961435 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,6 +4,7 @@ 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 { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' @@ -49,9 +50,16 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca worktreeSelector: string, agent: 'claude' | 'codex' ): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> { + const accountState = agent === 'claude' ? this.accounts.readClaudeManagedAccounts() : 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)) { + return { supported: false, reason: 'wsl' } + } return { supported: true } } return { 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 new file mode 100644 index 00000000000..e6a17b33591 --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts @@ -0,0 +1,100 @@ +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' + +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`, + managedAuthRuntime, + authMethod: 'subscription-oauth' as const, + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } +} + +const WSL_ONLY: ClaudeRateLimitAccountsState = { + accounts: [managedAccount('wsl-1', 'wsl')], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } +} + +const HOST_SELECTED: ClaudeRateLimitAccountsState = { + accounts: [managedAccount('host-1', 'host')], + activeAccountId: 'host-1', + activeAccountIdsByRuntime: { 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) + } + 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('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/runtime-account-controller.ts b/src/main/runtime/runtime-account-controller.ts index 45d3ade97ff..f4c59852d96 100644 --- a/src/main/runtime/runtime-account-controller.ts +++ b/src/main/runtime/runtime-account-controller.ts @@ -58,6 +58,15 @@ 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 {