From 488b56d62ff295e8f79f5126e4926ff6f8948555 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Thu, 3 Sep 2026 14:17:48 -0700 Subject: [PATCH 1/2] 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 { From 1d7a3938a15e64b9ed199dd639217539bf2b2af3 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Thu, 3 Sep 2026 14:30:46 -0700 Subject: [PATCH 2/2] Run the managed-account gate on every Claude acquisition, not just create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createSupport gates the create path, but a session's account state can change while it lives. A reacquire after an unexpected child exit re-resolves the launch and re-derives auth, with nothing re-checking the gate — so a session created while supported could come back up in the refused shape. With the strip predicate keyed on there being an active non-WSL account, the WSL-only user's normalized steady state (accounts exist, none active) does not strip, and that reacquire reaches the child with ambient auth while the UI names the account. Gate at resolveLaunch, the one choke point every acquisition passes through, refusing with the pre-spawn error the caller already handles. Same predicate as create-time, now sharing one settings reader so the two cannot drift. Claude only; Codex resolves its account on a different path and is untouched. The runtime class that wires this does not typecheck its own `this` calls — a missing hookup compiles clean — so the wiring is pinned behaviourally rather than trusted to the compiler. --- ...laude-structured-launch-resolution.test.ts | 78 +++++++++++++++++++ .../claude-structured-launch-resolution.ts | 18 +++++ ...aude-structured-managed-account-support.ts | 11 +++ .../runtime/orca-runtime-get-worktree-ps.ts | 4 + ...lve-recovered-structured-tui-transcript.ts | 17 ++-- ...time-structured-claude-gate-wiring.test.ts | 56 +++++++++++++ .../structured-agent-session-runtime.ts | 5 ++ .../structured-claude-runtime-adapter.ts | 7 +- 8 files changed, 184 insertions(+), 12 deletions(-) create mode 100644 src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 653c2518407..fe1dea65e55 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, @@ -58,6 +60,39 @@ function resolverFor(value: AgentSessionRecord | null, resolveEnv?: () => Record }) } +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 }) @@ -292,4 +327,47 @@ 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', + 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 41db7a403ed..72ce138799e 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -5,6 +5,11 @@ import { agentSessionProviderHandleChainHead } from '../../shared/agent-session- import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { applyClaudeEnvPatch } from '../claude-accounts/environment' +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' @@ -118,6 +123,8 @@ export type ClaudeStructuredLaunchResolverDeps = { | Promise | undefined> | Record | undefined + /** Account state for the managed-account gate; null when it cannot be read, which refuses. */ + readManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null } export function claudeSessionIdForOrcaSession(sessionId: string): string { @@ -150,6 +157,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.ts b/src/main/native-chat/claude-structured-managed-account-support.ts index f5834c31ba3..cd3afa473ca 100644 --- a/src/main/native-chat/claude-structured-managed-account-support.ts +++ b/src/main/native-chat/claude-structured-managed-account-support.ts @@ -40,3 +40,14 @@ export function structuredClaudeMatchesActiveManagedAccount( 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 21109f0b05b..4b3273f47cf 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, @@ -156,6 +157,9 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent resolveTuiAgentLaunchEnv('codex', this.requireStore().getSettings().agentDefaultEnv), resolveClaudeLaunchEnv: () => resolveTuiAgentLaunchEnv('claude', this.requireStore().getSettings().agentDefaultEnv), + // 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 a9b804c498d..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 @@ -5,8 +5,8 @@ import { agentSessionOwnerBindingsEqual } from '../../shared/claimed-agent-pty-o import { resolvePinnedCodexRolloutProof } from '../codex/codex-tui-rollout-proof' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { - structuredClaudeMatchesActiveManagedAccount, - type ClaudeManagedAccountGateSettings + 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' @@ -49,21 +49,16 @@ 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' }> { // 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 accountSettings = + agent === 'claude' + ? readClaudeManagedAccountGateSettings(() => this.requireStore().getSettings()) + : null const location = await this.resolveStructuredAgentSessionLocation(worktreeSelector) await this.ensureStructuredAgentSessionHost() if (getStructuredAgentSessionHost()?.supportsCreate(location, agent)) { 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/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index b0cd548371e..1282de38318 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' @@ -68,6 +69,7 @@ export type StructuredAgentSessionRuntimeDeps = { resolveLaunchEnv?: () => Promise resolveLaunchEnvOverlay?: () => Promise> | Record resolveClaudeLaunchEnv?: () => Promise> | Record + readClaudeManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null resolveEnvironment?: () => Promise resolveCodexOverrides?: () => NodeJS.ProcessEnv onError?: (input: { scope: string; error: unknown }) => void @@ -185,6 +187,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 38994be84d9..d95a0fb01aa 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -13,6 +13,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 = { @@ -20,6 +21,7 @@ export type StructuredClaudeRuntimeAdapterDeps = { resolveWorkspacePath: (workspaceId: string) => Promise resolveClaudeCommand?: () => string resolveClaudeLaunchEnv?: () => Promise> | Record + readClaudeManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] readProcessStartTime?: ClaudeStructuredSessionAdapterDeps['readProcessStartTime'] onUnexpectedExit: (event: StructuredAgentSessionLifecycleEvent) => void @@ -34,7 +36,10 @@ export function createStructuredClaudeRuntimeAdapter( store, resolveWorkspacePath: deps.resolveWorkspacePath, resolveCommand: deps.resolveClaudeCommand ?? resolveClaudeCommand, - ...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {}) + ...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {}), + ...(deps.readClaudeManagedAccountGate + ? { readManagedAccountGate: deps.readClaudeManagedAccountGate } + : {}) }), persistHandle: async ({ sessionId, providerSessionId, leafUuid, fence }) => { const currentFence = store.getRecord(sessionId)?.lease.runtimeFence ?? fence