diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 5bd38db7179..d3522fb799f 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -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 } 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) { 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' } }))({ diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index fdee522e056..f780bc2ffb0 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -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): Record { + const next: Record = {} + 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, diff --git a/src/main/claude/claude-tui-resume-launch.test.ts b/src/main/claude/claude-tui-resume-launch.test.ts index 8e850573682..ee676c7be4e 100644 --- a/src/main/claude/claude-tui-resume-launch.test.ts +++ b/src/main/claude/claude-tui-resume-launch.test.ts @@ -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 } 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', diff --git a/src/main/claude/claude-tui-resume-launch.ts b/src/main/claude/claude-tui-resume-launch.ts index de72b27216b..6b4d02b07a2 100644 --- a/src/main/claude/claude-tui-resume-launch.ts +++ b/src/main/claude/claude-tui-resume-launch.ts @@ -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 } diff --git a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts index 82841f49817..af1fbc20372 100644 --- a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts @@ -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' + }) + }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index c597a0a5c2e..810db4de0fd 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 }) => { diff --git a/src/renderer/src/lib/structured-native-chat-availability.test.ts b/src/renderer/src/lib/structured-native-chat-availability.test.ts index 770413208cc..71622ab44f2 100644 --- a/src/renderer/src/lib/structured-native-chat-availability.test.ts +++ b/src/renderer/src/lib/structured-native-chat-availability.test.ts @@ -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 = { diff --git a/src/renderer/src/lib/structured-native-chat-availability.ts b/src/renderer/src/lib/structured-native-chat-availability.ts index bc14ccfd4f0..6ee6abd594d 100644 --- a/src/renderer/src/lib/structured-native-chat-availability.ts +++ b/src/renderer/src/lib/structured-native-chat-availability.ts @@ -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 diff --git a/src/renderer/src/lib/windows-terminal-capabilities.ts b/src/renderer/src/lib/windows-terminal-capabilities.ts index c759567df15..4490016a731 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.ts @@ -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() } diff --git a/src/renderer/src/lib/windows-terminal-capability-read.ts b/src/renderer/src/lib/windows-terminal-capability-read.ts index 3c9a7edc6bc..0a1cf856762 100644 --- a/src/renderer/src/lib/windows-terminal-capability-read.ts +++ b/src/renderer/src/lib/windows-terminal-capability-read.ts @@ -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 } } diff --git a/src/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts index 1249870c548..6b24da52b70 100644 --- a/src/shared/runtime-session-contracts.ts +++ b/src/shared/runtime-session-contracts.ts @@ -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.