From a3be41978dcbac72f03806df1a03bf8d97932d79 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Thu, 3 Sep 2026 17:23:15 -0700 Subject: [PATCH] Support structured Claude when accounts are registered but none is selected Registered-but-deselected Claude accounts were refused, which is behaviourally identical to having no accounts at all: the auth policy does not strip, ambient auth is the truth, and the UI names no host identity. A user who deselected their accounts silently got legacy chat with nothing explaining why. Nothing selected for the host runtime is two states the settings cannot tell apart after the fact, because pruneInvalidClaudeRuntimeSelection empties the host slot and persists null in the second one: honest deselection -> ambient auth, UI names nothing -> SUPPORTED the WSL-only steady state -> ambient auth, UI names the WSL account -> REFUSED The presence of any WSL-bound account in the list decides. Simplifying this to "none active -> supported" re-opens the auth-identity misrepresentation, so the tests fail loudly on exactly that: five of them, across the unit rule and the createSupport path. --- ...structured-managed-account-support.test.ts | 37 +++++++++++++++++-- ...aude-structured-managed-account-support.ts | 6 ++- ...ime-structured-claude-account-gate.test.ts | 15 ++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) 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(