Read the structured Claude account gate through the auth policy's accessor

The gate resolved the active account from the account-service snapshot's
runtime map; the auth policy resolves it with
getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }). Those are
two sources and two resolution rules, and they disagree on a legacy settings
blob that carries the selection only in the flat activeClaudeManagedAccountId:
the accessor falls through to it, a direct read of the runtime map does not. The
gate would then refuse a launch the policy would have run under host-1 — and in
the mirror case a session could be admitted under a policy computed from a
different account than the gate approved.

Read the same settings through the same accessor so agreement is structural
rather than coincidental, and drop the controller accessor that existed only to
reach the snapshot.

No behaviour change for any state both already agreed on; Codex is untouched.
This commit is contained in:
Merge Sim
2026-09-03 14:17:48 -07:00
parent 4e287f5486
commit 488b56d62f
5 changed files with 103 additions and 63 deletions
@@ -1,11 +1,15 @@
import { describe, expect, it } from 'vitest'
import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types'
import { structuredClaudeMatchesActiveManagedAccount } from './claude-structured-managed-account-support'
import { getSelectedClaudeAccountIdForTarget } from '../claude-accounts/runtime-selection'
import {
structuredClaudeMatchesActiveManagedAccount,
type ClaudeManagedAccountGateSettings
} from './claude-structured-managed-account-support'
function account(id: string, managedAuthRuntime: 'host' | 'wsl') {
return {
id,
email: `${id}@example.com`,
managedAuthPath: `/managed/${id}`,
managedAuthRuntime,
authMethod: 'subscription-oauth' as const,
createdAt: 0,
@@ -14,37 +18,34 @@ function account(id: string, managedAuthRuntime: 'host' | 'wsl') {
}
}
function state(overrides: Partial<ClaudeRateLimitAccountsState>): ClaudeRateLimitAccountsState {
return { accounts: [], activeAccountId: null, ...overrides }
function settings(
overrides: Partial<ClaudeManagedAccountGateSettings>
): ClaudeManagedAccountGateSettings {
return { claudeManagedAccounts: [], activeClaudeManagedAccountId: null, ...overrides }
}
describe('structuredClaudeMatchesActiveManagedAccount', () => {
it('allows an unmanaged install, where nothing claims an identity', () => {
expect(structuredClaudeMatchesActiveManagedAccount(state({}))).toBe(true)
expect(structuredClaudeMatchesActiveManagedAccount(settings({}))).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: {} }
settings({
claudeManagedAccounts: [account('host-1', 'host')],
activeClaudeManagedAccountIdsByRuntime: { 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' } }
settings({
claudeManagedAccounts: [account('wsl-1', 'wsl')],
activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } }
})
)
).toBe(false)
@@ -53,30 +54,55 @@ describe('structuredClaudeMatchesActiveManagedAccount', () => {
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: {} }
settings({
claudeManagedAccounts: [account('wsl-1', 'wsl')],
activeClaudeManagedAccountIdsByRuntime: { host: 'wsl-1', wsl: {} }
})
)
).toBe(false)
expect(
structuredClaudeMatchesActiveManagedAccount(
state({
accounts: [account('host-1', 'host')],
activeAccountIdsByRuntime: { host: 'gone', wsl: {} }
settings({
claudeManagedAccounts: [account('host-1', 'host')],
activeClaudeManagedAccountIdsByRuntime: { host: 'gone', wsl: {} }
})
)
).toBe(false)
})
it('fails closed when the account state cannot be read at all', () => {
it('fails closed when the settings 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', () => {
it('fails closed when managed accounts exist but no host account is selected', () => {
expect(
structuredClaudeMatchesActiveManagedAccount(state({ accounts: [account('host-1', 'host')] }))
structuredClaudeMatchesActiveManagedAccount(
settings({ claudeManagedAccounts: [account('host-1', 'host')] })
)
).toBe(false)
})
/** The gate and the auth policy must resolve the SAME account. A legacy settings blob carries the
* selection only in the flat `activeClaudeManagedAccountId`, which is where the accessor's
* fall-through lives — reading the runtime map directly silently disagrees with the policy. */
it('resolves the same account as the auth policy on a legacy flat selection', () => {
const legacy = settings({
claudeManagedAccounts: [account('host-1', 'host')],
activeClaudeManagedAccountId: 'host-1'
})
expect(getSelectedClaudeAccountIdForTarget(legacy, { runtime: 'host' })).toBe('host-1')
expect(structuredClaudeMatchesActiveManagedAccount(legacy)).toBe(true)
})
it('agrees with the auth policy that a legacy flat WSL selection is refused', () => {
const legacy = settings({
claudeManagedAccounts: [account('wsl-1', 'wsl')],
activeClaudeManagedAccountId: 'wsl-1'
})
expect(getSelectedClaudeAccountIdForTarget(legacy, { runtime: 'host' })).toBe('wsl-1')
expect(structuredClaudeMatchesActiveManagedAccount(legacy)).toBe(false)
})
})
@@ -1,4 +1,12 @@
import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types'
import type { GlobalSettings } from '../../shared/global-settings-types'
import { getSelectedClaudeAccountIdForTarget } from '../claude-accounts/runtime-selection'
export type ClaudeManagedAccountGateSettings = Pick<
GlobalSettings,
| 'claudeManagedAccounts'
| 'activeClaudeManagedAccountId'
| 'activeClaudeManagedAccountIdsByRuntime'
>
/**
* A structured Claude session launches against the ambient Claude config, which the account service
@@ -8,22 +16,27 @@ import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-
* 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.
* Reads the selection through the same accessor the auth policy uses. Resolving it any other way
* lets the two disagree, and a session admitted by this gate would then run under a policy computed
* from a different account than the one approved here.
*
* Unknown answers refuse: an install with no managed accounts claims no identity and is fine, but a
* selection this cannot resolve is not evidence that the ambient identity is right.
*/
export function structuredClaudeMatchesActiveManagedAccount(
accounts: ClaudeRateLimitAccountsState | null | undefined
settings: ClaudeManagedAccountGateSettings | null | undefined
): boolean {
if (!accounts) {
const accounts = settings?.claudeManagedAccounts
if (!settings || !Array.isArray(accounts)) {
return false
}
if (accounts.accounts.length === 0) {
if (accounts.length === 0) {
return true
}
const activeHostId = accounts.activeAccountIdsByRuntime?.host ?? accounts.activeAccountId ?? null
const activeHostId = getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' })
if (!activeHostId) {
return false
}
const active = accounts.accounts.find((candidate) => candidate.id === activeHostId)
const active = accounts.find((candidate) => candidate.id === activeHostId)
return active ? active.managedAuthRuntime !== 'wsl' : false
}
@@ -4,7 +4,10 @@ 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 {
structuredClaudeMatchesActiveManagedAccount,
type ClaudeManagedAccountGateSettings
} 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'
@@ -46,18 +49,28 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca
return { transcriptPath }
}
private readClaudeAccountGateSettings(): ClaudeManagedAccountGateSettings | null {
try {
return this.requireStore().getSettings()
} catch {
return null
}
}
async getStructuredAgentSessionCreateSupport(
worktreeSelector: string,
agent: 'claude' | 'codex'
): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> {
const accountState = agent === 'claude' ? this.accounts.readClaudeManagedAccounts() : null
// Same settings the auth policy reads, so the gate and the policy cannot resolve different
// accounts. Unreadable settings fail closed below.
const accountSettings = agent === 'claude' ? this.readClaudeAccountGateSettings() : 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)) {
if (agent === 'claude' && !structuredClaudeMatchesActiveManagedAccount(accountSettings)) {
return { supported: false, reason: 'wsl' }
}
return { supported: true }
@@ -2,7 +2,7 @@ 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'
import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
@@ -15,6 +15,7 @@ function managedAccount(id: string, managedAuthRuntime: 'host' | 'wsl') {
return {
id,
email: `${id}@example.com`,
managedAuthPath: `/managed/${id}`,
managedAuthRuntime,
authMethod: 'subscription-oauth' as const,
createdAt: 0,
@@ -23,27 +24,23 @@ function managedAccount(id: string, managedAuthRuntime: 'host' | 'wsl') {
}
}
const WSL_ONLY: ClaudeRateLimitAccountsState = {
accounts: [managedAccount('wsl-1', 'wsl')],
activeAccountId: null,
activeAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } }
const WSL_ONLY: ClaudeManagedAccountGateSettings = {
claudeManagedAccounts: [managedAccount('wsl-1', 'wsl')],
activeClaudeManagedAccountId: null,
activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } }
}
const HOST_SELECTED: ClaudeRateLimitAccountsState = {
accounts: [managedAccount('host-1', 'host')],
activeAccountId: 'host-1',
activeAccountIdsByRuntime: { host: 'host-1', wsl: {} }
const HOST_SELECTED: ClaudeManagedAccountGateSettings = {
claudeManagedAccounts: [managedAccount('host-1', 'host')],
activeClaudeManagedAccountId: 'host-1',
activeClaudeManagedAccountIdsByRuntime: { 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)
}
function runtimeWithAccounts(claude: ClaudeManagedAccountGateSettings | null): OrcaRuntimeService {
// No store at all is the unreadable-settings case the gate must fail closed on.
const runtime = claude
? new OrcaRuntimeService({ getSettings: () => claude } as never)
: new OrcaRuntimeService()
const internal = runtime as unknown as {
resolveStructuredAgentSessionLocation: (selector: string) => Promise<unknown>
ensureStructuredAgentSessionHost: () => Promise<void>
@@ -58,15 +58,6 @@ 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 {