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 aa3d55907ef..f647579d03e 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 @@ -90,12 +90,43 @@ describe('structuredClaudeMatchesActiveManagedAccount', () => { expect(structuredClaudeMatchesActiveManagedAccount(undefined)).toBe(false) }) - it('fails closed when managed accounts exist but no host account is selected', () => { + /** The four states this gate exists to tell apart, pinned together so a change to one is visible + * against the others. */ + it.each([ + ['no managed accounts', [], null, true], + ['accounts present, none active, no WSL account', [account('host-1', 'host')], null, true], + ['host account selected', [account('host-1', 'host')], 'host-1', true], + ['WSL-only, normalized to no host selection', [account('wsl-1', 'wsl')], null, false] + ] as const)('resolves %s', (_name, claudeManagedAccounts, activeId, expected) => { expect( structuredClaudeMatchesActiveManagedAccount( - settings({ claudeManagedAccounts: [account('host-1', 'host')] }) + settings({ + claudeManagedAccounts: [...claudeManagedAccounts], + activeClaudeManagedAccountIdsByRuntime: { host: activeId, wsl: {} } + }) ) - ).toBe(false) + ).toBe(expected) + }) + + /** 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 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 a4709c0a629..dccf6216bda 100644 --- a/src/main/native-chat/claude-structured-managed-account-support.ts +++ b/src/main/native-chat/claude-structured-managed-account-support.ts @@ -39,7 +39,11 @@ export function structuredClaudeMatchesActiveManagedAccount( } const activeHostId = getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) if (!activeHostId) { - return false + // 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 !accounts.some((candidate) => candidate.managedAuthRuntime === 'wsl') } const active = accounts.find((candidate) => candidate.id === activeHostId) return active ? active.managedAuthRuntime !== 'wsl' : false 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 4ac7bea17bd..64b451e9ea9 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 @@ -30,6 +30,14 @@ const WSL_ONLY: ClaudeManagedAccountGateSettings = { 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', @@ -72,6 +80,13 @@ describe('structured Claude managed-account gate', () => { ).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(