From 488b56d62ff295e8f79f5126e4926ff6f8948555 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Thu, 3 Sep 2026 14:17:48 -0700 Subject: [PATCH] Read the structured Claude account gate through the auth policy's accessor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...structured-managed-account-support.test.ts | 76 +++++++++++++------ ...aude-structured-managed-account-support.ts | 29 +++++-- ...lve-recovered-structured-tui-transcript.ts | 19 ++++- ...ime-structured-claude-account-gate.test.ts | 33 ++++---- .../runtime/runtime-account-controller.ts | 9 --- 5 files changed, 103 insertions(+), 63 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 1c1a3cad560..86054b9aab2 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 @@ -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 { - return { accounts: [], activeAccountId: null, ...overrides } +function settings( + overrides: Partial +): 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) + }) }) 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 8486cfa1cf7..f5834c31ba3 100644 --- a/src/main/native-chat/claude-structured-managed-account-support.ts +++ b/src/main/native-chat/claude-structured-managed-account-support.ts @@ -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 } diff --git a/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts b/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts index b6443961435..a9b804c498d 100644 --- a/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts +++ b/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts @@ -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 } 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 e6a17b33591..4ac7bea17bd 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 @@ -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 ensureStructuredAgentSessionHost: () => Promise diff --git a/src/main/runtime/runtime-account-controller.ts b/src/main/runtime/runtime-account-controller.ts index f4c59852d96..45d3ade97ff 100644 --- a/src/main/runtime/runtime-account-controller.ts +++ b/src/main/runtime/runtime-account-controller.ts @@ -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 {