mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 00:27:39 +00:00
Fix Claude structured launch gating
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user