Fix Claude structured launch gating

This commit is contained in:
Merge Sim
2026-08-31 23:14:38 -07:00
parent 41f8c087eb
commit f95d4f8316
11 changed files with 201 additions and 12 deletions
@@ -1,3 +1,6 @@
import { chmodSync, mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, join } from 'node:path'
import { describe, expect, it } from 'vitest'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
@@ -30,6 +33,14 @@ function record(overrides: Partial<AgentSessionRecord> = {}): AgentSessionRecord
} as AgentSessionRecord
}
function makeExecutable(path: string): void {
mkdirSync(join(path, '..'), { recursive: true })
writeFileSync(path, '')
if (process.platform !== 'win32') {
chmodSync(path, 0o755)
}
}
function resolverFor(value: AgentSessionRecord | null, resolveEnv?: () => Record<string, string>) {
return createClaudeStructuredLaunchResolver({
store: { getRecord: () => value } as unknown as AgentSessionRecordStore,
@@ -99,6 +110,27 @@ describe('claude structured launch resolution', () => {
expect((await resolver({ identity: IDENTITY })).env?.ANTHROPIC_AUTH_TOKEN).toBe('rotated-token')
})
it('pairs a resolved Claude CLI with its sibling Node runtime', async () => {
const root = mkdtempSync(join(tmpdir(), 'orca-claude-launch-'))
const binDir = join(root, 'bin')
const claudeCommand = join(binDir, process.platform === 'win32' ? 'claude.cmd' : 'claude')
const nodeCommand = join(binDir, process.platform === 'win32' ? 'node.cmd' : 'node')
makeExecutable(claudeCommand)
makeExecutable(nodeCommand)
const launch = await createClaudeStructuredLaunchResolver({
store: { getRecord: () => record() } as unknown as AgentSessionRecordStore,
resolveWorkspacePath: async (id) => `/repos/${id}`,
resolveCommand: () => claudeCommand,
resolveEnv: () => ({
PATH: '/usr/bin',
CLAUDE_CONFIG_DIR: '/accounts/selected/home'
})
})({ identity: IDENTITY })
expect((launch.env?.PATH ?? launch.env?.Path)?.split(delimiter)[0]).toBe(binDir)
})
it('refuses other hosts, WSL, providers, and account-home variables', async () => {
await expect(
resolverFor(record({ location: { ...record().location, executionHostId: 'ssh:build' } }))({
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution'
import { resolveClaudeCommand } from '../codex-cli/command'
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
import { getSpawnArgsForWindows } from '../win32-utils'
@@ -23,6 +24,16 @@ export const CLAUDE_STRUCTURED_BASE_ARGS = [
CLAUDE_DEFAULT_SETTING_SOURCES.join(',')
]
function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record<string, string>): Record<string, string> {
const next: Record<string, string> = {}
for (const [key, value] of Object.entries(env)) {
if (value !== undefined) {
next[key] = value
}
}
return next
}
export type ClaudeStructuredLaunch = {
command: string
args: string[]
@@ -88,12 +99,15 @@ export function createClaudeStructuredLaunchResolver(
...CLAUDE_STRUCTURED_BASE_ARGS,
...providerArgs
])
const env = await deps.resolveEnv?.()
const resolvedEnv = await deps.resolveEnv?.()
const env = withCliRuntimeOnPath(command, cloneDefinedEnv(resolvedEnv ?? process.env), {
platform: process.platform
})
return {
command: spawnCmd,
args: spawnArgs,
cwd: await deps.resolveWorkspacePath(record.location.workspaceId),
...(env ? { env } : {}),
...(resolvedEnv !== undefined || env !== process.env ? { env } : {}),
claudeConfigDir: record.accountHome.path,
providerSessionId,
resumeLeafUuid: head?.handle.provider === 'claude' ? head.handle.leafUuid : null,
@@ -1,3 +1,6 @@
import { chmodSync, mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, join } from 'node:path'
import { describe, expect, it } from 'vitest'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import { CLAUDE_DEFAULT_SETTING_SOURCES } from './claude-structured-launch-resolution'
@@ -28,6 +31,14 @@ function record(overrides: Partial<AgentSessionRecord> = {}): AgentSessionRecord
} as AgentSessionRecord
}
function makeExecutable(path: string): void {
mkdirSync(join(path, '..'), { recursive: true })
writeFileSync(path, '')
if (process.platform !== 'win32') {
chmodSync(path, 0o755)
}
}
describe('Claude TUI resume launch', () => {
it('pins the workspace, account home, setting sources, and launch identity', async () => {
const build = createClaudeTuiResumeLaunchBuilder({
@@ -73,6 +84,26 @@ describe('Claude TUI resume launch', () => {
expect(launch.env.CLAUDE_CODE_SESSION_ID).toBeUndefined()
})
it('pairs the resumed Claude CLI with its sibling Node runtime', async () => {
const root = mkdtempSync(join(tmpdir(), 'orca-claude-resume-'))
const binDir = join(root, 'bin')
const claudeCommand = join(binDir, process.platform === 'win32' ? 'claude.cmd' : 'claude')
const nodeCommand = join(binDir, process.platform === 'win32' ? 'node.cmd' : 'node')
makeExecutable(claudeCommand)
makeExecutable(nodeCommand)
const build = createClaudeTuiResumeLaunchBuilder({
resolveWorkspacePath: async () => '/workspace',
resolveCommand: () => claudeCommand,
resolveEnv: () => ({ PATH: '/usr/bin' }),
inheritedEnv: {}
})
const launch = await build({ record: record(), spawnToken: 'spawn' })
expect((launch.env.PATH ?? launch.env.Path)?.split(delimiter)[0]).toBe(binDir)
})
it('uses the durable session environment instead of current account settings', async () => {
const build = createClaudeTuiResumeLaunchBuilder({
resolveWorkspacePath: async () => '/workspace',
+3 -1
View File
@@ -1,5 +1,6 @@
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle'
import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution'
import { resolveClaudeCommand } from '../codex-cli/command'
import { getSpawnArgsForWindows } from '../win32-utils'
import { buildClaudeChildProcessEnv } from './claude-child-process-environment'
@@ -58,12 +59,13 @@ export function createClaudeTuiResumeLaunchBuilder(
},
{ inheritedEnv: deps.inheritedEnv }
)
const pairedEnv = withCliRuntimeOnPath(command, env, { platform: process.platform })
return {
command: spawnCmd,
args: spawnArgs,
cwd: await deps.resolveWorkspacePath(record.location.workspaceId),
env,
env: pairedEnv,
providerSessionId: head.handle.sessionId,
resumeLeafUuid: head.handle.leafUuid
}
@@ -102,4 +102,58 @@ describe('structured agent-session create intent', () => {
path: '/configured/claude-home'
})
})
it('uses the managed Claude launch home before falling back to ~/.claude', async () => {
const prepareCodexStructuredLaunch = vi.fn()
const getRuntimeConfigDir = vi.fn(() => '/accounts/managed/claude-home')
const runtime = new OrcaRuntimeService(
{
getSettings: () => ({
agentDefaultEnv: { claude: {} }
})
} as never,
undefined,
{ prepareCodexStructuredLaunch }
)
runtime.setAccountServices({
claudeAccounts: { getRuntimeConfigDir } as never,
codexAccounts: {} as never,
rateLimits: {} as never
})
vi.spyOn(runtime, 'getStructuredAgentSessionCreateSupport').mockResolvedValue({
supported: true
})
const internal = runtime as unknown as {
resolveStructuredAgentSessionLocation: (selector: string) => Promise<{
executionHostId: string
wslDistro: null
workspaceId: string
workspaceKind: 'git-worktree'
}>
resolveRuntimeFileTarget: (selector: string) => Promise<{
worktree: { path: string }
}>
}
internal.resolveStructuredAgentSessionLocation = vi.fn(async () => ({
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree' as const
}))
internal.resolveRuntimeFileTarget = vi.fn(async () => ({
worktree: { path: '/repos/workspace-1' }
}))
const intent = await runtime.resolveStructuredAgentSessionCreateIntent({
envelope: { sessionId: 'session-1', clientOperationId: 'operation-1' },
worktree: 'id:workspace-1',
agent: 'claude'
})
expect(getRuntimeConfigDir).toHaveBeenCalledTimes(1)
expect(intent.accountHome).toEqual({
variable: 'CLAUDE_CONFIG_DIR',
path: '/accounts/managed/claude-home'
})
})
})
+8 -1
View File
@@ -21,6 +21,7 @@ import {
import { resolveNestedWorkerMaxDepth } from '../../shared/nested-worker-depth'
import { sortDirEntries } from '../../shared/file-name-sort'
import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing'
import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table'
import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extraction'
import { parseFileUriPathParts } from '../daemon/osc7-file-uri'
import type { AgentStatus } from '../../shared/agent-detection'
@@ -6598,6 +6599,8 @@ export class OrcaRuntimeService {
if (terminalDegradation) {
degradations.push(terminalDegradation)
}
const windowsProcessStartTimeAvailable =
process.platform === 'win32' && isWindowsProcessStartTimeAvailable()
return {
runtimeId: this.runtimeId,
rendererGraphEpoch: this.rendererGraphEpoch,
@@ -6613,6 +6616,7 @@ export class OrcaRuntimeService {
capabilities,
...(degradations.length > 0 ? { degradations } : {}),
worktreeCreateIdempotency: { dedupeTtlMs: WORKTREE_CREATE_RESULT_TTL_MS },
...(windowsProcessStartTimeAvailable ? { windowsProcessStartTimeAvailable } : {}),
hostPlatform: process.platform,
terminalWindowsShell: this.store?.getSettings?.().terminalWindowsShell ?? null,
floatingWorkspaceEnabled: this.store?.getSettings?.().floatingTerminalEnabled !== false,
@@ -12264,7 +12268,10 @@ export class OrcaRuntimeService {
if (input.agent === 'claude') {
return this.resolveStructuredAgentSessionIntent(
input,
async ({ launchEnv }) => launchEnv.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude')
async ({ launchEnv }) =>
launchEnv.CLAUDE_CONFIG_DIR?.trim() ||
this.accountServices?.claudeAccounts.getRuntimeConfigDir()?.trim() ||
join(homedir(), '.claude')
)
}
return this.resolveStructuredAgentSessionIntent(input, async ({ workspacePath, launchEnv }) => {
@@ -1,7 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AppState } from '@/store/types'
import type * as localPreflightContext from '@/lib/local-preflight-context'
import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime'
import {
loadWindowsTerminalCapabilities,
resetWindowsTerminalCapabilitiesForTests
} from './windows-terminal-capabilities'
import { canUseStructuredNativeChat } from './structured-native-chat-availability'
const { mockGetRendererAppPlatform } = vi.hoisted(() => ({
@@ -94,6 +98,11 @@ describe('canUseStructuredNativeChat', () => {
})
})
afterEach(() => {
resetWindowsTerminalCapabilitiesForTests()
vi.unstubAllGlobals()
})
it('allows the structured stack on a local worktree', () => {
expect(canUseStructuredNativeChat(stateFor({}), 'wt-1')).toBe(true)
})
@@ -159,6 +168,30 @@ describe('canUseStructuredNativeChat', () => {
)
})
it('allows Windows-host projects once native start-time proof is cached', async () => {
vi.stubGlobal('window', {
api: {
wsl: {
isAvailable: vi.fn().mockResolvedValue(false),
listDistros: vi.fn().mockResolvedValue([])
},
pwsh: { isAvailable: vi.fn().mockResolvedValue(false) },
gitBash: { isAvailable: vi.fn().mockResolvedValue(false) },
runtime: {
getStatus: vi.fn().mockResolvedValue({
hostPlatform: 'win32',
windowsProcessStartTimeAvailable: true
})
}
}
})
await loadWindowsTerminalCapabilities()
mockGetRendererAppPlatform.mockReturnValue('win32')
expect(canUseStructuredNativeChat(stateFor({ windowsRuntime: 'windows-host' }), 'wt-1')).toBe(
true
)
})
it('refuses a Windows folder workspace even though its key resolves no project runtime', () => {
mockGetRendererAppPlatform.mockReturnValue('win32')
const state = {
@@ -2,6 +2,7 @@ import type { AppState } from '@/store/types'
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
import { getRendererAppPlatform } from '@/lib/renderer-app-platform'
import { getCachedLocalWindowsProcessStartTimeAvailable } from './windows-terminal-capability-read'
export function canUseStructuredNativeChat(state: AppState, worktreeId: string): boolean {
if (state.settings?.experimentalStructuredNativeChat !== true) {
@@ -20,7 +21,10 @@ export function canUseStructuredNativeChat(state: AppState, worktreeId: string):
// the host advertises that proof, refuse every local Windows execution path —
// windows-host, WSL, and keys that resolve no project runtime (folder
// workspaces, floating terminal) — so create cannot fail after the click.
if (getRendererAppPlatform() === 'win32') {
if (
getRendererAppPlatform() === 'win32' &&
getCachedLocalWindowsProcessStartTimeAvailable() !== true
) {
return false
}
// Refuse WSL and repair-required runtimes even if resolution ever runs
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
import { startWindowsTerminalCapabilityReprobe } from './windows-terminal-capability-reprobe'
import {
readWindowsTerminalCapabilities,
resetWindowsTerminalCapabilityReadCacheForTests,
type WindowsTerminalCapabilityLoadTarget
} from './windows-terminal-capability-read'
@@ -307,4 +308,5 @@ export function resetWindowsTerminalCapabilitiesForTests(): void {
nextCapabilityRequestId = 0
latestCapabilityRequestIdByOwnerKey.clear()
subscribersByOwnerKey.clear()
resetWindowsTerminalCapabilityReadCacheForTests()
}
@@ -4,6 +4,16 @@ import type { WindowsTerminalCapabilities } from './windows-terminal-capabilitie
export type WindowsTerminalCapabilityLoadTarget = RuntimeClientTarget
let cachedLocalWindowsProcessStartTimeAvailable: boolean | null = null
export function getCachedLocalWindowsProcessStartTimeAvailable(): boolean | null {
return cachedLocalWindowsProcessStartTimeAvailable
}
export function resetWindowsTerminalCapabilityReadCacheForTests(): void {
cachedLocalWindowsProcessStartTimeAvailable = null
}
async function reconcileWslAvailability(
available: boolean,
distros: string[],
@@ -49,17 +59,16 @@ export async function readWindowsTerminalCapabilities(
}
if (target.kind === 'local') {
const [wslAvailable, wslDistros, pwshAvailable, gitBashAvailable, hostPlatform] =
const [wslAvailable, wslDistros, pwshAvailable, gitBashAvailable, runtimeStatus] =
await Promise.all([
window.api.wsl.isAvailable().catch(() => false),
window.api.wsl.listDistros().catch(() => []),
window.api.pwsh.isAvailable().catch(() => false),
window.api.gitBash.isAvailable().catch(() => false),
window.api.runtime
.getStatus()
.then((status) => status.hostPlatform ?? null)
.catch(() => null)
window.api.runtime.getStatus().catch(() => null)
])
cachedLocalWindowsProcessStartTimeAvailable =
runtimeStatus?.windowsProcessStartTimeAvailable ?? null
const reconciledWslAvailable = await reconcileWslAvailability(wslAvailable, wslDistros, () =>
window.api.wsl.isAvailable()
)
@@ -68,7 +77,7 @@ export async function readWindowsTerminalCapabilities(
wslDistros,
pwshAvailable,
gitBashAvailable,
hostPlatform,
hostPlatform: runtimeStatus?.hostPlatform ?? null,
isLoading: false
}
}
+1
View File
@@ -77,6 +77,7 @@ export type RuntimeStatus = {
worktreeCreateIdempotency?: {
dedupeTtlMs: number
}
windowsProcessStartTimeAvailable?: boolean
/**
* Optional for mixed-version peers. Absence means the host predates structured
* degradation reporting, not that the host proved every optional feature available.