Do not offer structured Claude under a WSL-only managed account

Structured Claude 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 on Windows a
structured 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.

That was unreachable only because nothing offered structured Claude on win32.
Enabling it makes it reachable, so gate it here rather than patching the auth
layer: refuse the structured path when the active managed Claude account is
WSL-bound, and let the terminal-backed path — which resolves the account per
runtime — handle that account shape.

The answer rides the agentSession.createSupport seam the renderer already
consumes, so no new capability and no renderer knowledge of account internals.
A create the host declines becomes the definitive refusal the launch fallback
already turns into a legacy native chat tab, with no error toast.

Unknown answers refuse. An install with no managed accounts claims no identity
and is fine, but an active selection that cannot be resolved — or account state
that cannot be read at all — is not evidence that the ambient identity is right.

Claude only. Codex resolves its account through a different path and its
createSupport answer is untouched, as is every Codex routing decision.
This commit is contained in:
Merge Sim
2026-09-03 14:06:54 -07:00
parent 9eb3aed2b3
commit 4e287f5486
5 changed files with 228 additions and 0 deletions
@@ -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>): 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)
})
})
@@ -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
}
@@ -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 {
@@ -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<unknown>
ensureStructuredAgentSessionHost: () => Promise<void>
}
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 })
})
})
@@ -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 {