mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
Merge commit '1d7a3938a1' into brennanb2025/claude-sdk-ready-mainsync
# Conflicts: # src/main/claude/claude-structured-launch-resolution.ts # src/main/runtime/orca-runtime-get-worktree-ps.ts # src/main/runtime/structured-agent-session-runtime.ts # src/main/runtime/structured-claude-runtime-adapter.ts
This commit is contained in:
@@ -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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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> | 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' &&
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const installed = vi.hoisted(() => ({ deps: null as Record<string, unknown> | 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<string, unknown>) => {
|
||||
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<string, unknown> = 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()
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> | ClaudeStructuredAuthPolicy
|
||||
readClaudeManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null
|
||||
resolveEnvironment?: () => Promise<NodeJS.ProcessEnv>
|
||||
resolveCodexOverrides?: () => NodeJS.ProcessEnv
|
||||
onError?: (input: { scope: string; error: unknown }) => void
|
||||
@@ -200,6 +202,9 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise<Install
|
||||
? { resolveClaudeLaunchEnv: deps.resolveClaudeLaunchEnv }
|
||||
: {}),
|
||||
resolveClaudeAuthPolicy: deps.resolveClaudeAuthPolicy,
|
||||
...(deps.readClaudeManagedAccountGate
|
||||
? { readClaudeManagedAccountGate: deps.readClaudeManagedAccountGate }
|
||||
: {}),
|
||||
onUnexpectedExit: (event) => {
|
||||
recoveryChain = recoveryChain.then(async () => {
|
||||
try {
|
||||
|
||||
@@ -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> | 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
|
||||
|
||||
Reference in New Issue
Block a user