diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 74fa62041cc..d4a649f8326 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -5,6 +5,8 @@ import { describe, expect, it } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { CLAUDE_DEFAULT_SETTING_SOURCES, CLAUDE_STRUCTURED_BASE_OPTIONS, @@ -63,6 +65,39 @@ function resolverFor( }) } +function managedAccount(id: string, managedAuthRuntime: 'host' | 'wsl') { + return { + id, + email: `${id}@example.com`, + managedAuthPath: `/managed/${id}`, + managedAuthRuntime, + authMethod: 'subscription-oauth' as const, + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } +} + +const HOST_SELECTED: ClaudeManagedAccountGateSettings = { + claudeManagedAccounts: [managedAccount('host-1', 'host')], + activeClaudeManagedAccountId: 'host-1', + activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} } +} + +/** The normalized steady state of a Windows user whose only Claude account is WSL-managed: the + * prune drops the WSL account out of the host slot and persists that. */ +const WSL_ONLY_NORMALIZED: ClaudeManagedAccountGateSettings = { + claudeManagedAccounts: [managedAccount('wsl-1', 'wsl')], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } +} + +const RESUMABLE = record({ + providerHandleChain: [ + { handle: { provider: 'claude', sessionId: 'provider-current', leafUuid: 'leaf-current' } } + ] as AgentSessionRecord['providerHandleChain'] +}) + describe('claude structured launch resolution', () => { it('pre-mints a stable provider id and pins interactive setting sources', async () => { const first = await resolverFor(record())({ identity: IDENTITY }) @@ -301,4 +336,48 @@ describe('claude structured launch resolution', () => { }) ).rejects.toThrow(/CLAUDE_CONFIG_DIR/) }) + + /** The account state can change while a session lives, and a reacquire after an unexpected child + * exit re-resolves the launch. Without the gate here, that reacquire spawns under whatever the + * account state has become. */ + describe('managed-account gate on every acquisition', () => { + function resolverWithGate(read: () => ClaudeManagedAccountGateSettings | null) { + return createClaudeStructuredLaunchResolver({ + store: { getRecord: () => RESUMABLE } as unknown as AgentSessionRecordStore, + resolveWorkspacePath: async (id) => `/repos/${id}`, + resolveCommand: () => '/usr/local/bin/claude', + resolveAuthPolicy: () => ({ stripAuthEnv: false }), + readManagedAccountGate: read + }) + } + + it('refuses a reacquire once the account state becomes the refused shape', async () => { + let gate: ClaudeManagedAccountGateSettings | null = HOST_SELECTED + const resolve = resolverWithGate(() => gate) + + // Created while supported: the launch resolves and would spawn. + await expect(resolve({ identity: identityAt('leaf-current') })).resolves.toMatchObject({ + providerSessionId: 'provider-current' + }) + + gate = WSL_ONLY_NORMALIZED + + // Reacquire after the account state changed: refused before anything spawns. + await expect(resolve({ identity: identityAt('leaf-current') })).rejects.toBeInstanceOf( + AgentSessionPreSpawnError + ) + }) + + it('fails closed when the account state cannot be read', async () => { + await expect( + resolverWithGate(() => null)({ identity: identityAt('leaf-current') }) + ).rejects.toBeInstanceOf(AgentSessionPreSpawnError) + }) + + it('keeps resolving when no gate is wired, so other embedders are unaffected', async () => { + await expect( + resolverFor(RESUMABLE)({ identity: identityAt('leaf-current') }) + ).resolves.toMatchObject({ providerSessionId: 'provider-current' }) + }) + }) }) diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index cec785a062f..4f28f14ad65 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -15,6 +15,11 @@ import { CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS, whenClaudeAuthSwitchSettles } from '../claude-accounts/live-pty-gate' +import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + structuredClaudeMatchesActiveManagedAccount, + type ClaudeManagedAccountGateSettings +} from '../native-chat/claude-structured-managed-account-support' import { resolveClaudeCommand } from '../codex-cli/command' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' @@ -137,6 +142,8 @@ export type ClaudeStructuredLaunchResolverDeps = { resolveAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy /** How long an in-flight account switch may hold a launch before it is refused. */ authSwitchSettleTimeoutMs?: number + /** Account state for the managed-account gate; null when it cannot be read, which refuses. */ + readManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null } /** @@ -186,6 +193,17 @@ export function createClaudeStructuredLaunchResolver( if (record.accountHome.variable !== 'CLAUDE_CONFIG_DIR') { throw new Error(`claude sessions pin CLAUDE_CONFIG_DIR, not ${record.accountHome.variable}`) } + // Every acquisition, not just the first: the account state can change under a live session, and + // a reacquire after an unexpected exit would otherwise spawn under whatever it has become. + // Codex has no gate here — it resolves its account on a different path. + if ( + deps.readManagedAccountGate && + !structuredClaudeMatchesActiveManagedAccount(deps.readManagedAccountGate()) + ) { + throw new AgentSessionPreSpawnError( + 'structured Claude is not offered under the active managed Claude account' + ) + } const head = agentSessionProviderHandleChainHead(record.providerHandleChain) if ( head?.handle.provider === 'claude' && 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..cd3afa473ca 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,38 @@ 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 } + +/** Reads the gate's settings, answering null when they cannot be read so callers refuse. */ +export function readClaudeManagedAccountGateSettings( + getSettings: () => ClaudeManagedAccountGateSettings +): ClaudeManagedAccountGateSettings | null { + try { + return getSettings() + } catch { + return null + } +} diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 3a3a8fdfe30..0fd2a0e6a7a 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -15,6 +15,7 @@ import type { Repo } from '../../shared/repo-types' import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment' import { ensureStructuredAgentSessionHost as installStructuredAgentSessionHost } from './structured-agent-session-runtime' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' +import { readClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { resolveTuiAgentLaunchArgs, @@ -159,6 +160,9 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent resolveTuiAgentLaunchEnv('claude', this.requireStore().getSettings().agentDefaultEnv), resolveClaudeAuthPolicy: () => claudeStructuredAuthPolicyForSettings(this.requireStore().getSettings()), + // Same gate and same settings as agentSession.createSupport, re-read on every acquisition. + readClaudeManagedAccountGate: () => + readClaudeManagedAccountGateSettings(() => this.requireStore().getSettings()), handoffTransport: this.createStructuredAgentSessionHandoffTransport() }) } 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..9ae2ea6566a 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 { + readClaudeManagedAccountGateSettings, + 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' @@ -50,14 +53,19 @@ 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 + // 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' + ? readClaudeManagedAccountGateSettings(() => this.requireStore().getSettings()) + : 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/orca-runtime-structured-claude-gate-wiring.test.ts b/src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts new file mode 100644 index 00000000000..3c188d39efd --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest' + +const installed = vi.hoisted(() => ({ deps: null as Record | null })) + +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') } +})) + +vi.mock('./structured-agent-session-runtime', () => ({ + ensureStructuredAgentSessionHost: vi.fn(async (deps: Record) => { + installed.deps = deps + }) +})) + +import { OrcaRuntimeService } from './orca-runtime' +import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' + +const SETTINGS = { + claudeManagedAccounts: [], + activeClaudeManagedAccountId: null, + agentDefaultEnv: {}, + agentDefaultArgs: {} +} as unknown as ClaudeManagedAccountGateSettings + +function readGate(): (() => unknown) | undefined { + const deps: Record = installed.deps ?? {} + const read = deps['readClaudeManagedAccountGate'] + return typeof read === 'function' ? (read as () => unknown) : undefined +} + +/** The runtime class this wiring lives on does not typecheck its own `this` calls, so a broken or + * missing gate hookup compiles clean. Pin it behaviourally instead. */ +describe('structured Claude managed-account gate wiring', () => { + it('hands the host a gate reader that resolves the live settings', async () => { + installed.deps = null + const runtime = new OrcaRuntimeService({ getSettings: () => SETTINGS } as never) + + await runtime.ensureStructuredAgentSessionHost() + + const read = readGate() + expect(typeof read).toBe('function') + expect(read?.()).toBe(SETTINGS) + }) + + it('answers null instead of throwing when the settings cannot be read', async () => { + installed.deps = null + const runtime = new OrcaRuntimeService() + + await runtime.ensureStructuredAgentSessionHost() + + expect(readGate()?.()).toBeNull() + }) +}) 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 { diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 60fa824d5f8..983bf4462ab 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -21,6 +21,7 @@ import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/st import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { AgentSessionRecordStore } from './agent-session-record-store' import { agentSessionStorePath } from './agent-session-record-store-file' import { stopOrphanAgentSessionChildren } from './agent-session-orphan-child-reaper' @@ -72,6 +73,7 @@ export type StructuredAgentSessionRuntimeDeps = { /** Required. The one production wiring lives in a `@ts-nocheck` file, so this is * also asserted at install time — an absent policy must not degrade to a guess. */ resolveClaudeAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy + readClaudeManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null resolveEnvironment?: () => Promise resolveCodexOverrides?: () => NodeJS.ProcessEnv onError?: (input: { scope: string; error: unknown }) => void @@ -200,6 +202,9 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { recoveryChain = recoveryChain.then(async () => { try { diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 6a71cd1cd77..398288562b9 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -14,6 +14,7 @@ import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import { recordAgentSessionProviderHandle } from './agent-session-provider-handle-transition' +import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import type { AgentSessionRecordStore } from './agent-session-record-store' export type StructuredClaudeRuntimeAdapterDeps = { @@ -24,6 +25,7 @@ export type StructuredClaudeRuntimeAdapterDeps = { /** Managed-account auth state for a Claude launch, mirroring the terminal preflight. * Required: an absent policy is what silently under-strips. */ resolveClaudeAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy + readClaudeManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] readProcessStartTime?: ClaudeStructuredSessionAdapterDeps['readProcessStartTime'] onUnexpectedExit: (event: StructuredAgentSessionLifecycleEvent) => void @@ -39,7 +41,10 @@ export function createStructuredClaudeRuntimeAdapter( resolveWorkspacePath: deps.resolveWorkspacePath, resolveCommand: deps.resolveClaudeCommand ?? resolveClaudeCommand, ...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {}), - resolveAuthPolicy: deps.resolveClaudeAuthPolicy + resolveAuthPolicy: deps.resolveClaudeAuthPolicy, + ...(deps.readClaudeManagedAccountGate + ? { readManagedAccountGate: deps.readClaudeManagedAccountGate } + : {}) }), persistHandle: async ({ sessionId, providerSessionId, leafUuid, fence }) => { const currentFence = store.getRecord(sessionId)?.lease.runtimeFence ?? fence