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.
This commit is contained in:
Merge Sim
2026-09-03 17:23:15 -07:00
parent 3b38af0c21
commit a3be41978d
3 changed files with 54 additions and 4 deletions
@@ -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<typeof account>[]) =>
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
@@ -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
@@ -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(