diff --git a/android/app/src/main/kotlin/app/hapi/companion/Navigation.kt b/android/app/src/main/kotlin/app/hapi/companion/Navigation.kt index 36778cea..d7e8c8b1 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/Navigation.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/Navigation.kt @@ -830,4 +830,10 @@ private fun newSessionStrings(context: Context) = NewSessionStrings( codexModelsFailed = context.getString(R.string.new_session_error_codex_models), modelsFailedDetail = context.getString(R.string.new_session_error_models_detail), worktreeNameInvalid = context.getString(R.string.new_session_error_worktree_name), + directoryOutsideWorkspaceRoots = context.getString(R.string.new_session_error_directory_outside_workspace_roots), + directoryLookupFailed = context.getString(R.string.directory_browser_error), + agentAvailabilityFailed = context.getString(R.string.new_session_error_agent_availability), + runnerUpgradeRequired = context.getString(R.string.new_session_error_runner_upgrade_required), + noAvailableAgents = context.getString(R.string.new_session_error_no_available_agents), + selectedAgentUnavailable = context.getString(R.string.new_session_error_selected_agent_unavailable), ) diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/directorybrowser/RemoteDirectoryBrowserController.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/directorybrowser/RemoteDirectoryBrowserController.kt new file mode 100644 index 00000000..fe582bed --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/directorybrowser/RemoteDirectoryBrowserController.kt @@ -0,0 +1,229 @@ +package app.hapi.companion.feature.directorybrowser + +import app.hapi.protocol.wire.Machine +import app.hapi.protocol.wire.MachineDirectoryEntry +import app.hapi.protocol.wire.MachineListDirectoryResponse +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class RemoteDirectoryBreadcrumb( + val label: String, + val path: String, +) + +data class RemoteDirectoryBrowserState( + val open: Boolean = false, + val path: String = "", + val roots: List = emptyList(), + val breadcrumbs: List = emptyList(), + val entries: List = emptyList(), + val loading: Boolean = false, + val error: String? = null, + val includeHidden: Boolean = false, + val canGoUp: Boolean = false, +) + +/** Pure remote-path operations shared by directory-browser consumers. */ +object RemoteDirectoryPath { + fun browseRoots(machine: Machine): List { + val workspaceRoots = machine.metadata?.workspaceRoots + ?.filter { it.isNotBlank() } + ?.distinct() + .orEmpty() + if (workspaceRoots.isNotEmpty()) return workspaceRoots + return listOfNotNull(machine.metadata?.homeDir?.takeIf { it.isNotBlank() }) + } + + fun join(parent: String, child: String): String { + val separator = if (parent.contains('\\') && !parent.contains('/')) "\\" else "/" + return if (parent.endsWith('/') || parent.endsWith('\\')) parent + child else parent + separator + child + } + + fun parent(path: String): String? { + val trimmed = path.trim() + if (trimmed.isEmpty() || trimmed == "/" || Regex("^[A-Za-z]:[\\\\/]$").matches(trimmed)) return null + val unc = trimmed.startsWith("\\\\") || trimmed.startsWith("//") + val withoutTrailing = trimmed.trimEnd('/', '\\') + if (unc) { + val components = withoutTrailing.drop(2).split('/', '\\').filter { it.isNotEmpty() } + if (components.size <= 2) return null + } + val index = maxOf(withoutTrailing.lastIndexOf('/'), withoutTrailing.lastIndexOf('\\')) + if (index < 0) return null + if (index == 0) return withoutTrailing.substring(0, 1) + if (index == 2 && Regex("^[A-Za-z]:").containsMatchIn(withoutTrailing)) { + return withoutTrailing.substring(0, 3) + } + return withoutTrailing.substring(0, index) + } + + /** Lexical UI boundary; the runner remains authoritative and resolves symlinks. */ + fun isWithinRoot(path: String, root: String): Boolean { + fun normalize(value: String): String { + val slashed = value.trim().replace('\\', '/') + val rootLength = if (Regex("^[A-Za-z]:/$").matches(slashed)) 3 else 1 + return if (slashed.length > rootLength) slashed.trimEnd('/') else slashed + } + + val normalizedPath = normalize(path) + val normalizedRoot = normalize(root) + if (normalizedPath.isEmpty() || normalizedRoot.isEmpty()) return false + val ignoreCase = Regex("^[A-Za-z]:").containsMatchIn(normalizedRoot) || normalizedRoot.startsWith("//") + val rootPrefix = if (normalizedRoot.endsWith('/')) normalizedRoot else "$normalizedRoot/" + return normalizedPath.equals(normalizedRoot, ignoreCase) || + normalizedPath.startsWith(rootPrefix, ignoreCase) + } +} + +/** + * Reusable runner-backed directory navigation state machine. + * + * Consumers provide the machine list-directory request and decide what to do + * with the selected path. Navigation is lexically confined to [roots]; the + * runner performs the canonical symlink-aware boundary check. + */ +class RemoteDirectoryBrowserController( + private val scope: CoroutineScope, + private val listDirectory: suspend ( + machineId: String, + path: String, + includeHidden: Boolean, + ) -> MachineListDirectoryResponse, + private val fallbackError: String, +) { + private val mutableState = MutableStateFlow(RemoteDirectoryBrowserState()) + val state: StateFlow = mutableState.asStateFlow() + + private var machineId: String? = null + private var loadJob: Job? = null + private var requestVersion = 0L + + fun open(machineId: String, roots: List, initialPath: String? = null) { + close() + val usableRoots = roots.filter { it.isNotBlank() }.distinct() + val path = initialPath + ?.takeIf { candidate -> usableRoots.any { RemoteDirectoryPath.isWithinRoot(candidate, it) } } + ?: usableRoots.firstOrNull() + ?: return + this.machineId = machineId + mutableState.value = RemoteDirectoryBrowserState( + open = true, + path = path, + roots = usableRoots, + ) + load(path) + } + + fun close() { + loadJob?.cancel() + requestVersion += 1 + machineId = null + mutableState.value = RemoteDirectoryBrowserState() + } + + fun navigate(path: String) { + val current = mutableState.value + if (!current.open || current.roots.none { RemoteDirectoryPath.isWithinRoot(path, it) }) return + load(path) + } + + fun navigateEntry(name: String) { + navigate(RemoteDirectoryPath.join(mutableState.value.path, name)) + } + + fun navigateUp() { + val current = mutableState.value + val parent = RemoteDirectoryPath.parent(current.path) ?: return + if (current.roots.any { RemoteDirectoryPath.isWithinRoot(parent, it) }) navigate(parent) + } + + fun refresh() = load(mutableState.value.path) + + fun setIncludeHidden(includeHidden: Boolean) { + if (!mutableState.value.open) return + mutableState.update { it.copy(includeHidden = includeHidden) } + load(mutableState.value.path) + } + + private fun load(path: String) { + val targetMachineId = machineId ?: return + val current = mutableState.value + if (!current.open || current.roots.none { RemoteDirectoryPath.isWithinRoot(path, it) }) return + loadJob?.cancel() + val currentRequest = ++requestVersion + val includeHidden = current.includeHidden + mutableState.update { + it.copy( + path = path, + entries = emptyList(), + loading = true, + error = null, + breadcrumbs = breadcrumbs(path, it.roots), + canGoUp = RemoteDirectoryPath.parent(path) + ?.let { parent -> it.roots.any { root -> RemoteDirectoryPath.isWithinRoot(parent, root) } } + == true, + ) + } + loadJob = scope.launch { + val response = try { + listDirectory(targetMachineId, path, includeHidden) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + if (isCurrent(currentRequest, targetMachineId, path, includeHidden)) { + mutableState.update { + it.copy(loading = false, error = error.message ?: fallbackError) + } + } + return@launch + } + if (!isCurrent(currentRequest, targetMachineId, path, includeHidden)) return@launch + if (!response.success) { + mutableState.update { + it.copy(loading = false, error = response.error ?: fallbackError) + } + return@launch + } + mutableState.update { + it.copy( + loading = false, + error = null, + entries = response.entries.orEmpty() + .filter { entry -> entry.type == "directory" } + .sortedBy { entry -> entry.name.lowercase() }, + ) + } + } + } + + private fun isCurrent( + version: Long, + targetMachineId: String, + path: String, + includeHidden: Boolean, + ): Boolean = + requestVersion == version && + machineId == targetMachineId && + mutableState.value.open && + mutableState.value.path == path && + mutableState.value.includeHidden == includeHidden + + private fun breadcrumbs(path: String, roots: List): List { + val root = roots.filter { RemoteDirectoryPath.isWithinRoot(path, it) }.maxByOrNull { it.length } + ?: return listOf(RemoteDirectoryBreadcrumb(path, path)) + val result = mutableListOf(RemoteDirectoryBreadcrumb(root, root)) + val relative = path.drop(root.length).trim('/', '\\') + var cursor = root + for (segment in relative.split('/', '\\').filter { it.isNotEmpty() }) { + cursor = RemoteDirectoryPath.join(cursor, segment) + result += RemoteDirectoryBreadcrumb(segment, cursor) + } + return result + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/directorybrowser/RemoteDirectoryBrowserSheet.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/directorybrowser/RemoteDirectoryBrowserSheet.kt new file mode 100644 index 00000000..f5ac99e7 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/directorybrowser/RemoteDirectoryBrowserSheet.kt @@ -0,0 +1,134 @@ +package app.hapi.companion.feature.directorybrowser + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.hapi.companion.R + +/** Reusable presentation for [RemoteDirectoryBrowserController]. */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun RemoteDirectoryBrowserSheet( + state: RemoteDirectoryBrowserState, + onDismiss: () -> Unit, + onNavigate: (String) -> Unit, + onNavigateEntry: (String) -> Unit, + onNavigateUp: () -> Unit, + onRefresh: () -> Unit, + onIncludeHiddenChange: (Boolean) -> Unit, + onSelect: (String) -> Unit, +) { + ModalBottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(stringResource(R.string.directory_browser_choose_directory), style = MaterialTheme.typography.titleMedium) + if (state.roots.size > 1) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + state.roots.forEach { root -> + FilterChip( + selected = RemoteDirectoryPath.isWithinRoot(state.path, root), + onClick = { onNavigate(root) }, + label = { Text(root, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + ) + } + } + } + FlowRow(horizontalArrangement = Arrangement.spacedBy(2.dp)) { + state.breadcrumbs.forEach { breadcrumb -> + TextButton(onClick = { onNavigate(breadcrumb.path) }) { + Text(breadcrumb.label, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onNavigateUp, enabled = state.canGoUp && !state.loading) { + Icon( + Icons.Default.KeyboardArrowUp, + contentDescription = stringResource(R.string.directory_browser_up), + ) + } + IconButton(onClick = onRefresh, enabled = !state.loading) { + Icon(Icons.Default.Refresh, contentDescription = stringResource(R.string.directory_browser_refresh)) + } + Text( + state.path, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + ) + Text(stringResource(R.string.directory_browser_show_hidden), style = MaterialTheme.typography.labelSmall) + Switch(checked = state.includeHidden, onCheckedChange = onIncludeHiddenChange) + } + when { + state.loading -> CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally)) + state.error != null -> Text( + state.error, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + state.entries.isEmpty() -> Text( + stringResource(R.string.directory_browser_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + else -> Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 360.dp) + .verticalScroll(rememberScrollState()), + ) { + state.entries.forEachIndexed { index, entry -> + if (index > 0) HorizontalDivider() + DropdownMenuItem( + text = { Text(entry.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + onClick = { onNavigateEntry(entry.name) }, + ) + } + } + } + Button( + onClick = { onSelect(state.path) }, + enabled = !state.loading && state.error == null, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.directory_browser_select_current)) + } + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionForm.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionForm.kt index 1aef24e6..be9ed9b3 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionForm.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionForm.kt @@ -122,6 +122,8 @@ object NewSessionLogic { val parent: String, /** Typed tail the entries are prefix-filtered by (case-insensitive). */ val prefix: String, + /** Separator used by the typed path; suggestions preserve it. */ + val separator: String = "/", ) /** @@ -134,10 +136,28 @@ object NewSessionLogic { */ fun parentQuery(input: String): ParentQuery? { val text = input.trim() - if (!text.startsWith("/")) return null - val lastSlash = text.lastIndexOf('/') - val parent = if (lastSlash == 0) "/" else text.substring(0, lastSlash) - return ParentQuery(parent = parent, prefix = text.substring(lastSlash + 1)) + val isPosix = text.startsWith("/") + val isDrive = Regex("^[A-Za-z]:[\\\\/].*").matches(text) + val isUnc = text.startsWith("\\\\") || text.startsWith("//") + if (!isPosix && !isDrive && !isUnc) return null + + val lastForward = text.lastIndexOf('/') + val lastBackward = text.lastIndexOf('\\') + val separatorIndex = maxOf(lastForward, lastBackward) + if (separatorIndex < 0) return null + val separator = text[separatorIndex].toString() + val rawParent = text.substring(0, separatorIndex) + val parent = when { + separatorIndex == 0 -> separator + Regex("^[A-Za-z]:$").matches(rawParent) -> rawParent + separator + rawParent.isEmpty() && isUnc -> separator + separator + else -> rawParent + } + return ParentQuery( + parent = parent, + prefix = text.substring(separatorIndex + 1), + separator = separator, + ) } /** @@ -149,7 +169,11 @@ object NewSessionLogic { entries: List, limit: Int = 8, ): List { - val base = if (query.parent == "/") "/" else "${query.parent}/" + val base = if (query.parent.endsWith('/') || query.parent.endsWith('\\')) { + query.parent + } else { + query.parent + query.separator + } return entries.asSequence() .filter { it.type == "directory" } .filter { it.name.startsWith(query.prefix, ignoreCase = true) } diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionGateway.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionGateway.kt index 013b040a..90bba7fe 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionGateway.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionGateway.kt @@ -2,7 +2,9 @@ package app.hapi.companion.feature.newsession import app.hapi.data.api.HapiApi import app.hapi.protocol.wire.CodexModelsResponse +import app.hapi.protocol.wire.AgentAvailabilityResponse import app.hapi.protocol.wire.MachineListDirectoryResponse +import app.hapi.protocol.wire.MachinePathsExistsResponse import app.hapi.protocol.wire.SpawnResponse import app.hapi.protocol.wire.SpawnSessionRequest @@ -15,10 +17,17 @@ interface NewSessionGateway { suspend fun spawn(machineId: String, request: SpawnSessionRequest): SpawnResponse /** `POST /api/machines/:id/list-directory` (RPC-wrapped). */ - suspend fun listDirectory(machineId: String, path: String): MachineListDirectoryResponse + suspend fun listDirectory( + machineId: String, + path: String, + includeHidden: Boolean = false, + ): MachineListDirectoryResponse /** `POST /api/machines/:id/paths/exists`. */ - suspend fun pathsExist(machineId: String, paths: List): Map + suspend fun pathsExist(machineId: String, paths: List): MachinePathsExistsResponse + + /** `GET /api/machines/:id/agent-availability`. */ + suspend fun agentAvailability(machineId: String): AgentAvailabilityResponse /** `GET /api/machines/:id/codex-models` (RPC-wrapped; 503 `rpc_target_missing` = hide picker). */ suspend fun codexModels(machineId: String): CodexModelsResponse @@ -29,11 +38,17 @@ class ApiNewSessionGateway(private val api: HapiApi) : NewSessionGateway { override suspend fun spawn(machineId: String, request: SpawnSessionRequest): SpawnResponse = api.spawnSession(machineId, request) - override suspend fun listDirectory(machineId: String, path: String): MachineListDirectoryResponse = - api.listMachineDirectory(machineId, path) + override suspend fun listDirectory( + machineId: String, + path: String, + includeHidden: Boolean, + ): MachineListDirectoryResponse = api.listMachineDirectory(machineId, path, includeHidden) - override suspend fun pathsExist(machineId: String, paths: List): Map = - api.checkMachinePathsExist(machineId, paths).exists + override suspend fun pathsExist(machineId: String, paths: List): MachinePathsExistsResponse = + api.checkMachinePathsExist(machineId, paths) + + override suspend fun agentAvailability(machineId: String): AgentAvailabilityResponse = + api.getMachineAgentAvailability(machineId) override suspend fun codexModels(machineId: String): CodexModelsResponse = api.getMachineCodexModels(machineId) diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionScreen.kt index 0fd4a381..b9c2131c 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionScreen.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AssistChip import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator @@ -34,6 +35,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -51,6 +53,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import app.hapi.companion.R +import app.hapi.companion.feature.directorybrowser.RemoteDirectoryBrowserSheet import app.hapi.companion.ui.components.AgentFlavorIcon import app.hapi.companion.ui.theme.HapiTheme @@ -69,6 +72,7 @@ fun NewSessionScreen( onCreated: (sessionId: String) -> Unit, ) { val state by viewModel.uiState.collectAsState() + val directoryBrowser by viewModel.directoryBrowser.state.collectAsState() LaunchedEffect(viewModel) { viewModel.spawned.collect(onCreated) @@ -98,9 +102,11 @@ fun NewSessionScreen( onDirectoryChange = viewModel::setDirectory, onSuggestionPicked = viewModel::pickSuggestion, onRecentPathPicked = viewModel::pickRecentPath, + onBrowseDirectory = viewModel::openDirectoryBrowser, onSessionTypeChange = viewModel::setSessionType, onWorktreeNameChange = viewModel::setWorktreeName, onAgentSelected = viewModel::setAgent, + onRetryAgentAvailability = viewModel::retryAgentAvailability, onModelSelected = viewModel::setModel, onEffortSelected = viewModel::setEffort, onReasoningEffortSelected = viewModel::setModelReasoningEffort, @@ -112,6 +118,18 @@ fun NewSessionScreen( onCreate = viewModel::create, ) } + if (directoryBrowser.open) { + RemoteDirectoryBrowserSheet( + state = directoryBrowser, + onDismiss = viewModel.directoryBrowser::close, + onNavigate = viewModel.directoryBrowser::navigate, + onNavigateEntry = viewModel.directoryBrowser::navigateEntry, + onNavigateUp = viewModel.directoryBrowser::navigateUp, + onRefresh = viewModel.directoryBrowser::refresh, + onIncludeHiddenChange = viewModel.directoryBrowser::setIncludeHidden, + onSelect = viewModel::selectBrowsedDirectory, + ) + } } @OptIn(ExperimentalLayoutApi::class) @@ -123,9 +141,11 @@ internal fun NewSessionContent( onDirectoryChange: (String) -> Unit, onSuggestionPicked: (String) -> Unit, onRecentPathPicked: (String) -> Unit, + onBrowseDirectory: () -> Unit = {}, onSessionTypeChange: (String) -> Unit, onWorktreeNameChange: (String) -> Unit, onAgentSelected: (String) -> Unit, + onRetryAgentAvailability: () -> Unit = {}, onModelSelected: (String) -> Unit, onEffortSelected: (String) -> Unit, onReasoningEffortSelected: (String) -> Unit, @@ -150,9 +170,10 @@ internal fun NewSessionContent( onDirectoryChange = onDirectoryChange, onSuggestionPicked = onSuggestionPicked, onRecentPathPicked = onRecentPathPicked, + onBrowseDirectory = onBrowseDirectory, ) SessionTypeSection(state, onSessionTypeChange, onWorktreeNameChange) - AgentSection(state, onAgentSelected) + AgentSection(state, onAgentSelected, onRetryAgentAvailability) state.modelOptions?.let { options -> OptionDropdown( @@ -291,6 +312,7 @@ private fun DirectorySection( onDirectoryChange: (String) -> Unit, onSuggestionPicked: (String) -> Unit, onRecentPathPicked: (String) -> Unit, + onBrowseDirectory: () -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { OutlinedTextField( @@ -305,6 +327,11 @@ private fun DirectorySection( autoCorrectEnabled = false, imeAction = ImeAction.Done, ), + trailingIcon = { + IconButton(onClick = onBrowseDirectory, enabled = !state.isSpawning) { + Icon(Icons.Default.Search, contentDescription = stringResource(R.string.new_session_browse)) + } + }, modifier = Modifier.fillMaxWidth(), ) @@ -440,7 +467,11 @@ private fun SessionTypeSection( @OptIn(ExperimentalLayoutApi::class) @Composable -private fun AgentSection(state: NewSessionUiState, onAgentSelected: (String) -> Unit) { +private fun AgentSection( + state: NewSessionUiState, + onAgentSelected: (String) -> Unit, + onRetryAvailability: () -> Unit, +) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { SectionLabel(stringResource(R.string.new_session_agent)) FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { @@ -448,12 +479,36 @@ private fun AgentSection(state: NewSessionUiState, onAgentSelected: (String) -> FilterChip( selected = state.form.agent == agent.value, onClick = { onAgentSelected(agent.value) }, - enabled = !state.isSpawning, + enabled = !state.isSpawning && !state.agentAvailabilityLoading && state.agentAvailabilityError == null, leadingIcon = { AgentFlavorIcon(agent.value, modifier = Modifier.size(16.dp)) }, label = { Text(agent.label) }, ) } } + if (state.agentAvailabilityLoading) { + Text( + stringResource(R.string.new_session_agent_availability_loading), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + state.agentAvailabilityError?.let { error -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + error, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + TextButton(onClick = onRetryAvailability) { + Text(stringResource(R.string.new_session_retry)) + } + } + } } } @@ -609,6 +664,8 @@ private fun previewState(form: NewSessionForm): NewSessionUiState = NewSessionUi OptionItem("codex", "Codex"), OptionItem("grok", "Grok Build"), ), + agentAvailabilityLoading = false, + agentAvailabilityError = null, modelOptions = NewSessionCatalogs.CLAUDE_MODELS, modelsLoading = false, modelsError = null, diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModel.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModel.kt index dd8b4d7f..2e493c57 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModel.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModel.kt @@ -1,5 +1,7 @@ package app.hapi.companion.feature.newsession +import app.hapi.companion.feature.directorybrowser.RemoteDirectoryBrowserController +import app.hapi.companion.feature.directorybrowser.RemoteDirectoryPath import app.hapi.companion.feature.newsession.NewSessionLogic.buildSpawnRequest import app.hapi.companion.feature.newsession.NewSessionLogic.parentQuery import app.hapi.companion.feature.newsession.NewSessionLogic.pushRecent @@ -14,6 +16,7 @@ import app.hapi.protocol.catalog.Flavors import app.hapi.protocol.catalog.PermissionMode import app.hapi.protocol.catalog.PermissionModes import app.hapi.protocol.wire.CodexModelSummary +import app.hapi.protocol.wire.AgentAvailabilityEntry import app.hapi.protocol.wire.Machine import app.hapi.protocol.wire.MachineDirectoryEntry import app.hapi.protocol.wire.objOrNull @@ -66,6 +69,12 @@ class NewSessionStrings( /** `%1$s` = failure detail. */ val modelsFailedDetail: String = "Failed to load models: %1\$s", val worktreeNameInvalid: String = "Name needs at least one letter or digit", + val directoryOutsideWorkspaceRoots: String = "Directory must be inside one of this machine's workspace roots.", + val directoryLookupFailed: String = "Failed to browse directories", + val agentAvailabilityFailed: String = "Failed to check installed Agents", + val runnerUpgradeRequired: String = "Upgrade and restart this machine's HAPI runner before creating sessions.", + val noAvailableAgents: String = "No supported Agents are installed on this machine.", + val selectedAgentUnavailable: String = "The selected Agent is not available on this machine.", ) /** Which permission control the current flavor renders (web `PermissionField`). */ @@ -91,6 +100,12 @@ sealed interface CodexModelsUi { data class Failed(val message: String) : CodexModelsUi } +sealed interface AgentAvailabilityUi { + data object Loading : AgentAvailabilityUi + data class Loaded(val agents: List) : AgentAvailabilityUi + data class Failed(val message: String, val upgradeRequired: Boolean = false) : AgentAvailabilityUi +} + data class NewSessionUiState( val form: NewSessionForm, val machines: List, @@ -102,6 +117,8 @@ data class NewSessionUiState( val directoryStatus: DirectoryStatusUi?, /** Creatable flavors (value = flavor id, label from the catalog). */ val agents: List, + val agentAvailabilityLoading: Boolean, + val agentAvailabilityError: String?, /** Null hides the model picker (v1: only claude + supported codex). */ val modelOptions: List?, val modelsLoading: Boolean, @@ -152,8 +169,11 @@ class NewSessionViewModel( private val form = MutableStateFlow(NewSessionForm()) private val prefsData = MutableStateFlow(NewSessionPrefsData()) private val codexModels = MutableStateFlow(CodexModelsUi.Hidden) + private val agentAvailability = MutableStateFlow(AgentAvailabilityUi.Loading) private val suggestions = MutableStateFlow>(emptyList()) private val pathExistence = MutableStateFlow>(emptyMap()) + private val outsideWorkspaceRoots = MutableStateFlow>(emptySet()) + private val directoryLookupError = MutableStateFlow(null) private val isSpawning = MutableStateFlow(false) private val spawnError = MutableStateFlow(null) private val confirmCreateDirectory = MutableStateFlow(false) @@ -163,12 +183,20 @@ class NewSessionViewModel( /** Emits the new session id once — navigate-replace to `chat/{id}`. */ val spawned: SharedFlow = _spawned.asSharedFlow() + val directoryBrowser = RemoteDirectoryBrowserController( + scope = scope, + listDirectory = gateway::listDirectory, + fallbackError = strings.directoryLookupFailed, + ) private var directoryJob: Job? = null private var codexJob: Job? = null + private var availabilityJob: Job? = null + private var defaultDirectoryJob: Job? = null private var codexFetchedForMachine: String? = null private var suppressSuggestions = false private var spawnInFlight = false + private var directoryRequestVersion = 0L /** Parent-listing cache: retyping within the same parent re-filters locally. */ private var cachedListing: Pair, List>? = null @@ -187,10 +215,8 @@ class NewSessionViewModel( initial = initial.copy(machineId = initialMachineId) } } - if (initial.machineId != null && initial.directory.isBlank()) { - initial = initial.copy(directory = recentPathsFor(initial.machineId).firstOrNull().orEmpty()) - } form.value = initial + refreshAgentAvailability() refreshCodexModelsIfNeeded() // A restored directory should probe existence but not pop the // autocomplete dropdown — suggestions belong to typing. @@ -211,7 +237,10 @@ class NewSessionViewModel( machineStore.machines.collect { machines -> if (machines.isEmpty()) return@collect val current = form.value.machineId - if (current != null && machines.any { it.id == current }) return@collect + if (current != null && machines.any { it.id == current }) { + if (form.value.directory.isBlank()) applyMachineSelection(current, resetDirectory = true) + return@collect + } val lastUsed = prefsData.value.lastMachineId val target = machines.firstOrNull { it.id == lastUsed } ?: machines.first() applyMachineSelection(target.id, resetDirectory = form.value.directory.isBlank()) @@ -236,24 +265,25 @@ class NewSessionViewModel( val uiState: StateFlow = combine( form, machineStore.machines, - codexModels, - combine(suggestions, pathExistence, prefsData) { s, exists, stored -> Triple(s, exists, stored) }, + combine(codexModels, agentAvailability) { codex, availability -> CatalogState(codex, availability) }, + combine(suggestions, pathExistence, prefsData, outsideWorkspaceRoots, directoryLookupError) { + s, exists, stored, outside, lookupError -> + DirectoryData(s, exists, stored, outside, lookupError) + }, combine(isSpawning, spawnError, confirmCreateDirectory, machinesRefreshSettled) { spawning, error, confirmed, settled -> SpawnFlags(spawning, error, confirmed, settled) }, - ) { currentForm, machines, codex, (currentSuggestions, exists, stored), flags -> - buildUiState(currentForm, machines, codex, currentSuggestions, exists, stored, flags) + ) { currentForm, machines, catalogs, directoryData, flags -> + buildUiState(currentForm, machines, catalogs, directoryData, flags) }.stateIn( scope = scope, started = SharingStarted.Eagerly, initialValue = buildUiState( form.value, machineStore.machines.value, - codexModels.value, - emptyList(), - emptyMap(), - prefsData.value, + CatalogState(codexModels.value, agentAvailability.value), + DirectoryData(emptyList(), emptyMap(), prefsData.value, emptySet(), null), SpawnFlags(isSpawning = false, spawnError = null, confirmed = false, machinesSettled = false), ), ) @@ -265,6 +295,19 @@ class NewSessionViewModel( val machinesSettled: Boolean, ) + private data class CatalogState( + val codex: CodexModelsUi, + val availability: AgentAvailabilityUi, + ) + + private data class DirectoryData( + val suggestions: List, + val exists: Map, + val stored: NewSessionPrefsData, + val outsideWorkspaceRoots: Set, + val lookupError: String?, + ) + // ------------------------------------------------------------ actions -- fun setMachine(machineId: String) { @@ -283,6 +326,20 @@ class NewSessionViewModel( fun pickRecentPath(path: String) = pickPath(path) + fun openDirectoryBrowser() { + val machine = machineStore.machines.value.firstOrNull { it.id == form.value.machineId } ?: return + directoryBrowser.open( + machineId = machine.id, + roots = RemoteDirectoryPath.browseRoots(machine), + initialPath = form.value.trimmedDirectory, + ) + } + + fun selectBrowsedDirectory(path: String) { + if (path.isNotBlank()) pickPath(path) + directoryBrowser.close() + } + fun setAgent(agent: String) { if (agent == form.value.agent) return // Web parity: switching agents resets every agent-dependent field @@ -335,6 +392,8 @@ class NewSessionViewModel( refreshCodexModelsIfNeeded() } + fun retryAgentAvailability() = refreshAgentAvailability(force = true) + /** * Spawn. Directory existence is re-checked server-side first (web * `handleCreate`): a missing worktree base is an error; a missing simple @@ -346,17 +405,39 @@ class NewSessionViewModel( val machineId = current.machineId ?: return if (current.trimmedDirectory.isEmpty() || spawnInFlight) return if (worktreeNameBlocks(current)) return + when (val availability = agentAvailability.value) { + AgentAvailabilityUi.Loading -> return + is AgentAvailabilityUi.Failed -> { + spawnError.value = availability.message + return + } + is AgentAvailabilityUi.Loaded -> { + if (availability.agents.none { + it.agent == current.agent && it.available + }) { + spawnError.value = strings.selectedAgentUnavailable + return + } + } + } spawnInFlight = true isSpawning.value = true spawnError.value = null scope.launch { try { val directory = current.trimmedDirectory - val exists = runCatching { gateway.pathsExist(machineId, listOf(directory)) } - .getOrDefault(emptyMap())[directory] + val pathResult = gateway.pathsExist(machineId, listOf(directory)) + val exists = pathResult.exists[directory] + outsideWorkspaceRoots.update { currentOutside -> + (currentOutside - directory) + pathResult.outsideWorkspaceRoots.orEmpty() + } if (exists != null) { pathExistence.update { it + (directory to exists) } } + if (directory in pathResult.outsideWorkspaceRoots.orEmpty()) { + spawnError.value = strings.directoryOutsideWorkspaceRoots + return@launch + } if (current.sessionType == SESSION_TYPE_WORKTREE && exists == false) { spawnError.value = strings.worktreeMissing return@launch @@ -372,10 +453,22 @@ class NewSessionViewModel( persistOnSuccess(machineId, directory) _spawned.tryEmit(result.sessionId!!) } else { - spawnError.value = result.message ?: strings.createFailed + spawnError.value = when (result.code) { + "runner_upgrade_required" -> strings.runnerUpgradeRequired + "agent_unavailable" -> strings.selectedAgentUnavailable + "outside_workspace_roots" -> strings.directoryOutsideWorkspaceRoots + else -> result.message ?: strings.createFailed + } } } catch (cancellation: CancellationException) { throw cancellation + } catch (error: ApiError) { + spawnError.value = when (error.code) { + "runner_upgrade_required" -> strings.runnerUpgradeRequired + "agent_unavailable" -> strings.selectedAgentUnavailable + "outside_workspace_roots" -> strings.directoryOutsideWorkspaceRoots + else -> error.message ?: strings.createFailed + } } catch (error: Exception) { spawnError.value = error.message ?: strings.createFailed } finally { @@ -398,24 +491,53 @@ class NewSessionViewModel( private fun recentPathsFor(machineId: String?): List = machineId?.let { prefsData.value.recentPaths[it] }.orEmpty() + private fun resolveDefaultDirectory(machineId: String, fallback: String) { + defaultDirectoryJob?.cancel() + val recent = recentPathsFor(machineId) + if (recent.isEmpty()) return + defaultDirectoryJob = scope.launch { + val result = try { + gateway.pathsExist(machineId, recent) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + return@launch + } + val outside = result.outsideWorkspaceRoots.orEmpty().toSet() + val valid = recent.firstOrNull { result.exists[it] == true && it !in outside } ?: return@launch + if (form.value.machineId == machineId && form.value.directory == fallback) { + suppressSuggestions = true + form.update { it.copy(directory = valid) } + scheduleDirectoryWork() + } + } + } + private fun applyMachineSelection(machineId: String, resetDirectory: Boolean) { + directoryBrowser.close() pathExistence.value = emptyMap() + outsideWorkspaceRoots.value = emptySet() + directoryLookupError.value = null suggestions.value = emptyList() cachedListing = null confirmCreateDirectory.value = false // The seeded recent path is a pick, not typing — no dropdown. suppressSuggestions = true + val machine = machineStore.machines.value.firstOrNull { it.id == machineId } + val fallback = machine?.let(RemoteDirectoryPath::browseRoots)?.firstOrNull().orEmpty() form.update { current -> current.copy( machineId = machineId, model = "auto", directory = if (resetDirectory) { - recentPathsFor(machineId).firstOrNull().orEmpty() + fallback } else { current.directory }, ) } + if (resetDirectory) resolveDefaultDirectory(machineId, fallback) + refreshAgentAvailability(force = true) refreshCodexModelsIfNeeded() scheduleDirectoryWork() } @@ -423,6 +545,7 @@ class NewSessionViewModel( /** Debounced directory work: parent listing for autocomplete + exists probe. */ private fun scheduleDirectoryWork() { directoryJob?.cancel() + val requestVersion = ++directoryRequestVersion val machineId = form.value.machineId if (machineId == null) { suggestions.value = emptyList() @@ -430,24 +553,40 @@ class NewSessionViewModel( } directoryJob = scope.launch { delay(debounceMs) + if (directoryRequestVersion != requestVersion || form.value.machineId != machineId) return@launch val text = form.value.directory val trimmed = text.trim() val query = if (suppressSuggestions) null else parentQuery(text) if (query == null) { suggestions.value = emptyList() + directoryLookupError.value = null } else { val cacheKey = machineId to query.parent val cached = cachedListing?.takeIf { it.first == cacheKey }?.second - val entries = cached ?: try { - val response = gateway.listDirectory(machineId, query.parent) - val listed = if (response.success) response.entries.orEmpty() else emptyList() - if (response.success) cachedListing = cacheKey to listed - listed + val response = if (cached == null) try { + gateway.listDirectory(machineId, query.parent) } catch (cancellation: CancellationException) { throw cancellation - } catch (_: Exception) { - emptyList() + } catch (error: Exception) { + if (directoryRequestVersion == requestVersion && form.value.machineId == machineId) { + directoryLookupError.value = error.message ?: strings.directoryLookupFailed + } + null + } else { + null + } + if (directoryRequestVersion != requestVersion || form.value.machineId != machineId) return@launch + val entries = when { + cached != null -> cached + response?.success == true -> response.entries.orEmpty().also { + cachedListing = cacheKey to it + directoryLookupError.value = null + } + response != null -> emptyList().also { + directoryLookupError.value = response.error ?: strings.directoryLookupFailed + } + else -> emptyList() } // Never suggest the path already typed verbatim. suggestions.value = NewSessionLogic.buildSuggestions(query, entries) @@ -455,14 +594,56 @@ class NewSessionViewModel( } if (trimmed.isNotEmpty()) { - try { - val result = gateway.pathsExist(machineId, listOf(trimmed)) - pathExistence.update { it + result } + val result = try { + gateway.pathsExist(machineId, listOf(trimmed)) } catch (cancellation: CancellationException) { throw cancellation } catch (_: Exception) { - // Unknown existence: no status hint, spawn re-checks anyway. + null } + if (result != null && directoryRequestVersion == requestVersion && form.value.machineId == machineId) { + pathExistence.update { it + result.exists } + outsideWorkspaceRoots.update { currentOutside -> + (currentOutside - trimmed) + result.outsideWorkspaceRoots.orEmpty() + } + } + } + } + } + + private fun refreshAgentAvailability(force: Boolean = false) { + val machineId = form.value.machineId + if (machineId == null) { + availabilityJob?.cancel() + agentAvailability.value = AgentAvailabilityUi.Loading + return + } + if (!force && availabilityJob?.isActive == true) return + availabilityJob?.cancel() + agentAvailability.value = AgentAvailabilityUi.Loading + availabilityJob = scope.launch { + val state = try { + AgentAvailabilityUi.Loaded(gateway.agentAvailability(machineId).agents) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: ApiError) { + if (error.code == "runner_upgrade_required") { + AgentAvailabilityUi.Failed(strings.runnerUpgradeRequired, upgradeRequired = true) + } else { + AgentAvailabilityUi.Failed(error.message ?: strings.agentAvailabilityFailed) + } + } catch (error: Exception) { + AgentAvailabilityUi.Failed(error.message ?: strings.agentAvailabilityFailed) + } + if (form.value.machineId != machineId) return@launch + agentAvailability.value = state + val available = (state as? AgentAvailabilityUi.Loaded) + ?.agents + ?.filter { it.available && AgentFlavor.CREATABLE.any { flavor -> flavor.id == it.agent } } + ?.map { it.agent } + .orEmpty() + if (available.isNotEmpty() && form.value.agent !in available) { + setAgent(available.first()) } } } @@ -555,30 +736,47 @@ class NewSessionViewModel( private fun buildUiState( currentForm: NewSessionForm, machines: List, - codex: CodexModelsUi, - currentSuggestions: List, - exists: Map, - stored: NewSessionPrefsData, + catalogs: CatalogState, + directoryData: DirectoryData, flags: SpawnFlags, ): NewSessionUiState { + val codex = catalogs.codex + val availability = catalogs.availability + val currentSuggestions = directoryData.suggestions + val exists = directoryData.exists + val stored = directoryData.stored val agent = currentForm.agent val selectedMachine = machines.firstOrNull { it.id == currentForm.machineId } val trimmed = currentForm.trimmedDirectory val directoryExists = if (trimmed.isEmpty()) null else exists[trimmed] + val directoryOutsideRoots = trimmed in directoryData.outsideWorkspaceRoots val missingWorktreeDirectory = - currentForm.sessionType == SESSION_TYPE_WORKTREE && trimmed.isNotEmpty() && directoryExists == false + !directoryOutsideRoots && currentForm.sessionType == SESSION_TYPE_WORKTREE && trimmed.isNotEmpty() && directoryExists == false val needsCreationWarning = - currentForm.sessionType == SESSION_TYPE_SIMPLE && trimmed.isNotEmpty() && directoryExists == false + !directoryOutsideRoots && currentForm.sessionType == SESSION_TYPE_SIMPLE && trimmed.isNotEmpty() && directoryExists == false val directoryStatus = when { + directoryOutsideRoots -> DirectoryStatusUi(strings.directoryOutsideWorkspaceRoots, isError = true) missingWorktreeDirectory -> DirectoryStatusUi(strings.worktreeMissing, isError = true) needsCreationWarning -> DirectoryStatusUi( if (flags.confirmed) strings.directoryMissingConfirm else strings.directoryMissing, isError = false, ) + directoryData.lookupError != null -> DirectoryStatusUi(directoryData.lookupError, isError = true) else -> null } + val availableAgentIds = (availability as? AgentAvailabilityUi.Loaded) + ?.agents + ?.filter { entry -> entry.available && AgentFlavor.CREATABLE.any { it.id == entry.agent } } + ?.map { it.agent } + .orEmpty() + val availabilityError = when (availability) { + AgentAvailabilityUi.Loading -> null + is AgentAvailabilityUi.Failed -> availability.message + is AgentAvailabilityUi.Loaded -> if (availableAgentIds.isEmpty()) strings.noAvailableAgents else null + } + val modelOptions: List? = when { agent == "claude" -> NewSessionCatalogs.CLAUDE_MODELS agent == "codex" && codex is CodexModelsUi.Loaded -> { @@ -631,7 +829,9 @@ class NewSessionViewModel( suggestions = currentSuggestions, recentPaths = currentForm.machineId?.let { stored.recentPaths[it] }.orEmpty(), directoryStatus = directoryStatus, - agents = AgentFlavor.CREATABLE.map { OptionItem(it.id, Flavors.label(it.id)) }, + agents = availableAgentIds.map { OptionItem(it, Flavors.label(it)) }, + agentAvailabilityLoading = availability is AgentAvailabilityUi.Loading, + agentAvailabilityError = availabilityError, modelOptions = modelOptions, modelsLoading = agent == "codex" && codex is CodexModelsUi.Loading, modelsError = (codex as? CodexModelsUi.Failed)?.message?.let { strings.modelsFailedDetail.format(it) }, @@ -650,6 +850,9 @@ class NewSessionViewModel( trimmed.isNotEmpty() && !flags.isSpawning && !missingWorktreeDirectory && + !directoryOutsideRoots && + availability is AgentAvailabilityUi.Loaded && + currentForm.agent in availableAgentIds && nameError == null && !codexValidationPending, confirmCreateDirectory = flags.confirmed && needsCreationWarning, diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index 926edf7b..18011730 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -71,6 +71,13 @@ Runner 上次启动错误:%1$s 目录 /path/to/project + 浏览目录 + 选择目录 + 前往上级目录 + 刷新 + 显示隐藏目录 + 没有子目录 + 选择当前目录 最近路径 会话类型 简单 @@ -80,6 +87,8 @@ 工作树名称 feature-x(可选) 代理 + 正在检查已安装的代理… + 重试 模型 思考强度 推理强度 @@ -514,4 +523,10 @@ 加载 Codex 模型失败 加载模型失败:%1$s 名称需要至少包含一个字母或数字 + 目录必须位于此机器配置的某个工作区根目录内。 + 浏览目录失败 + 检查已安装代理失败 + 创建会话前,请升级并重启此机器上的 HAPI runner。 + 此机器上没有安装受支持的代理。 + 所选代理在此机器上不可用。 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 80008986..8f89e04c 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -64,6 +64,13 @@ Runner last spawn error: %1$s Directory /path/to/project + Browse directories + Choose directory + Go to parent directory + Refresh + Show hidden + No subdirectories + Select current directory Recent paths Session type Simple @@ -73,6 +80,8 @@ Worktree name feature-x (optional) Agent + Checking installed Agents… + Retry Model Effort Reasoning effort @@ -507,4 +516,10 @@ Failed to load Codex models Failed to load models: %1$s Name needs at least one letter or digit + Directory must be inside one of this machine’s workspace roots. + Failed to browse directories + Failed to check installed Agents + Upgrade and restart this machine’s HAPI runner before creating sessions. + No supported Agents are installed on this machine. + The selected Agent is not available on this machine. diff --git a/android/app/src/test/kotlin/app/hapi/companion/feature/directorybrowser/RemoteDirectoryBrowserControllerTest.kt b/android/app/src/test/kotlin/app/hapi/companion/feature/directorybrowser/RemoteDirectoryBrowserControllerTest.kt new file mode 100644 index 00000000..35e6ea23 --- /dev/null +++ b/android/app/src/test/kotlin/app/hapi/companion/feature/directorybrowser/RemoteDirectoryBrowserControllerTest.kt @@ -0,0 +1,69 @@ +@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + +package app.hapi.companion.feature.directorybrowser + +import app.hapi.protocol.wire.MachineDirectoryEntry +import app.hapi.protocol.wire.MachineListDirectoryResponse +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest + +class RemoteDirectoryBrowserControllerTest { + @Test + fun `path boundaries support POSIX drive and UNC paths`() { + assertTrue(RemoteDirectoryPath.isWithinRoot("/workspace/repo", "/workspace")) + assertFalse(RemoteDirectoryPath.isWithinRoot("/workspace-other/repo", "/workspace")) + assertTrue(RemoteDirectoryPath.isWithinRoot("/workspace", "/")) + assertTrue(RemoteDirectoryPath.isWithinRoot("c:\\Work\\Repo", "C:\\work")) + assertFalse(RemoteDirectoryPath.isWithinRoot("C:\\workspace-other", "C:\\workspace")) + assertTrue(RemoteDirectoryPath.isWithinRoot("\\\\SERVER\\Share\\Repo", "\\\\server\\share")) + assertEquals("C:\\", RemoteDirectoryPath.parent("C:\\Users")) + assertEquals("\\\\server\\share", RemoteDirectoryPath.parent("\\\\server\\share\\repo")) + } + + @Test + fun `browser owns navigation loading and hidden-directory state`() = runTest { + val calls = mutableListOf>() + val controller = RemoteDirectoryBrowserController( + scope = this, + listDirectory = { machineId, path, includeHidden -> + calls += Triple(machineId, path, includeHidden) + MachineListDirectoryResponse( + success = true, + entries = listOf( + MachineDirectoryEntry("repo", "directory"), + MachineDirectoryEntry("README.md", "file"), + ), + ) + }, + fallbackError = "Failed to browse directories", + ) + + controller.open("machine-1", listOf("/workspace"), "/workspace") + advanceUntilIdle() + assertEquals(listOf("repo"), controller.state.value.entries.map { it.name }) + + controller.navigate("/workspace-other") + advanceUntilIdle() + assertEquals(1, calls.size) + + controller.navigateEntry("repo") + advanceUntilIdle() + assertEquals("/workspace/repo", controller.state.value.path) + assertTrue(controller.state.value.canGoUp) + + controller.setIncludeHidden(true) + advanceUntilIdle() + assertTrue(calls.last().third) + + controller.navigateUp() + advanceUntilIdle() + assertEquals("/workspace", controller.state.value.path) + + controller.close() + assertFalse(controller.state.value.open) + } +} diff --git a/android/app/src/test/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModelTest.kt b/android/app/src/test/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModelTest.kt index 3b323eaf..f7db1e52 100644 --- a/android/app/src/test/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModelTest.kt +++ b/android/app/src/test/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModelTest.kt @@ -4,6 +4,9 @@ package app.hapi.companion.feature.newsession import app.hapi.data.api.ApiError import app.hapi.data.store.MachineListStore +import app.hapi.protocol.catalog.AgentFlavor +import app.hapi.protocol.wire.AgentAvailabilityEntry +import app.hapi.protocol.wire.AgentAvailabilityResponse import app.hapi.protocol.wire.CodexModelSummary import app.hapi.protocol.wire.CodexModelsResponse import app.hapi.protocol.wire.HapiJson @@ -11,9 +14,12 @@ import app.hapi.protocol.wire.Machine import app.hapi.protocol.wire.MachineDirectoryEntry import app.hapi.protocol.wire.MachineListDirectoryResponse import app.hapi.protocol.wire.MachineMetadata +import app.hapi.protocol.wire.MachinePathsExistsResponse import app.hapi.protocol.wire.SpawnResponse import app.hapi.protocol.wire.SpawnSessionRequest import app.hapi.protocol.wire.SyncEvent +import kotlin.coroutines.Continuation +import kotlin.coroutines.suspendCoroutine import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -28,6 +34,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.boolean @@ -40,26 +47,49 @@ private class FakeGateway : NewSessionGateway { val pathsExistCalls = mutableListOf>>() val spawnCalls = mutableListOf>() val codexCalls = mutableListOf() + val availabilityCalls = mutableListOf() + val includeHiddenCalls = mutableListOf() var entries: List = emptyList() var existsAnswer: (String) -> Boolean? = { true } var spawnResult: SpawnResponse = SpawnResponse(type = "success", sessionId = "s-new") var codexResult: CodexModelsResponse = CodexModelsResponse(success = true, models = emptyList()) var codexThrows: Exception? = null + var availabilityResult = AgentAvailabilityResponse( + AgentFlavor.CREATABLE.map { AgentAvailabilityEntry(agent = it.id, available = true) }, + ) + var availabilityThrows: Exception? = null + var outsideWorkspaceRoots: Set = emptySet() + var listDirectoryHandler: (suspend (String, Boolean) -> MachineListDirectoryResponse)? = null override suspend fun spawn(machineId: String, request: SpawnSessionRequest): SpawnResponse { spawnCalls.add(machineId to request) return spawnResult } - override suspend fun listDirectory(machineId: String, path: String): MachineListDirectoryResponse { + override suspend fun listDirectory( + machineId: String, + path: String, + includeHidden: Boolean, + ): MachineListDirectoryResponse { listDirectoryCalls.add(machineId to path) + includeHiddenCalls.add(includeHidden) + listDirectoryHandler?.let { return it(path, includeHidden) } return MachineListDirectoryResponse(success = true, entries = entries) } - override suspend fun pathsExist(machineId: String, paths: List): Map { + override suspend fun pathsExist(machineId: String, paths: List): MachinePathsExistsResponse { pathsExistCalls.add(machineId to paths) - return paths.mapNotNull { path -> existsAnswer(path)?.let { path to it } }.toMap() + return MachinePathsExistsResponse( + exists = paths.mapNotNull { path -> existsAnswer(path)?.let { path to it } }.toMap(), + outsideWorkspaceRoots = paths.filter { it in outsideWorkspaceRoots }.takeIf { it.isNotEmpty() }, + ) + } + + override suspend fun agentAvailability(machineId: String): AgentAvailabilityResponse { + availabilityCalls.add(machineId) + availabilityThrows?.let { throw it } + return availabilityResult } override suspend fun codexModels(machineId: String): CodexModelsResponse { @@ -99,7 +129,12 @@ private class FakePrefs( } } -private fun machine(id: String, host: String = "devbox"): Machine = Machine( +private fun machine( + id: String, + host: String = "devbox", + homeDir: String? = null, + workspaceRoots: List? = null, +): Machine = Machine( id = id, namespace = "default", seq = 1, @@ -107,7 +142,13 @@ private fun machine(id: String, host: String = "devbox"): Machine = Machine( updatedAt = 1, active = true, activeAt = 1, - metadata = MachineMetadata(host = host, platform = "linux", happyCliVersion = "1.0.0"), + metadata = MachineMetadata( + host = host, + platform = "linux", + happyCliVersion = "1.0.0", + homeDir = homeDir, + workspaceRoots = workspaceRoots, + ), metadataVersion = 1, runnerState = null, runnerStateVersion = 1, @@ -282,6 +323,29 @@ class NewSessionLogicTest { ) } + @Test + fun `windows drive and UNC autocomplete preserve separators`() { + assertEquals( + NewSessionLogic.ParentQuery("C:\\Users", "pro", "\\"), + NewSessionLogic.parentQuery("C:\\Users\\pro"), + ) + assertEquals( + NewSessionLogic.ParentQuery("C:\\", "Use", "\\"), + NewSessionLogic.parentQuery("C:\\Use"), + ) + assertEquals( + NewSessionLogic.ParentQuery("\\\\server\\share", "pro", "\\"), + NewSessionLogic.parentQuery("\\\\server\\share\\pro"), + ) + assertEquals( + listOf("C:\\Users\\projects"), + NewSessionLogic.buildSuggestions( + NewSessionLogic.ParentQuery("C:\\Users", "pro", "\\"), + listOf(dir("projects")), + ), + ) + } + @Test fun `recent paths LRU dedupes to front and caps at 8`() { var list = emptyList() @@ -403,6 +467,36 @@ class NewSessionViewModelTest { ) } + @Test + fun `stale autocomplete response cannot replace newer suggestions`() = runTest { + val gateway = FakeGateway() + var staleContinuation: Continuation? = null + gateway.listDirectoryHandler = { path, _ -> + when (path) { + "/old" -> suspendCoroutine { staleContinuation = it } + "/new" -> MachineListDirectoryResponse(success = true, entries = listOf(dir("beta"))) + else -> MachineListDirectoryResponse(success = true, entries = emptyList()) + } + } + val vm = buildViewModel(gateway, FakeMachineStore(listOf(machine("m1"))), FakePrefs()) + advanceUntilIdle() + + vm.setDirectory("/old/a") + advanceTimeBy(250) + runCurrent() + assertNotNull(staleContinuation) + + vm.setDirectory("/new/b") + advanceUntilIdle() + assertEquals(listOf("/new/beta"), vm.uiState.value.suggestions) + + staleContinuation!!.resumeWith( + Result.success(MachineListDirectoryResponse(success = true, entries = listOf(dir("alpha")))), + ) + advanceUntilIdle() + assertEquals(listOf("/new/beta"), vm.uiState.value.suggestions) + } + @Test fun `picking a suggestion suppresses the dropdown but keeps the exists probe`() = runTest { val gateway = FakeGateway().apply { entries = listOf(dir("github")) } @@ -469,6 +563,90 @@ class NewSessionViewModelTest { assertNotNull(prefs.draft) // edits persisted for back-out } + @Test + fun `availability hides unavailable agents and falls back to the first available agent`() = runTest { + val gateway = FakeGateway().apply { + availabilityResult = AgentAvailabilityResponse( + listOf( + AgentAvailabilityEntry(agent = "claude", available = false, reason = "not_found"), + AgentAvailabilityEntry(agent = "codex", available = true), + ), + ) + } + val vm = buildViewModel(gateway, FakeMachineStore(listOf(machine("m1"))), FakePrefs()) + advanceUntilIdle() + + assertEquals(listOf("codex"), vm.uiState.value.agents.map { it.value }) + assertEquals("codex", vm.uiState.value.form.agent) + vm.setDirectory("/repo") + advanceUntilIdle() + assertTrue(vm.uiState.value.canCreate) + } + + @Test + fun `old runner availability failure blocks create and asks for upgrade`() = runTest { + val gateway = FakeGateway().apply { + availabilityThrows = ApiError(status = 409, code = "runner_upgrade_required", body = null) + } + val vm = buildViewModel(gateway, FakeMachineStore(listOf(machine("m1"))), FakePrefs()) + advanceUntilIdle() + vm.setDirectory("/repo") + advanceUntilIdle() + + assertFalse(vm.uiState.value.canCreate) + assertEquals(NewSessionStrings().runnerUpgradeRequired, vm.uiState.value.agentAvailabilityError) + vm.create() + advanceUntilIdle() + assertEquals(NewSessionStrings().runnerUpgradeRequired, vm.uiState.value.spawnError) + assertTrue(gateway.spawnCalls.isEmpty()) + } + + @Test + fun `create relies on runner preflight without refreshing availability`() = runTest { + val gateway = FakeGateway().apply { + spawnResult = SpawnResponse( + type = "error", + message = "claude is not installed or is not on PATH", + code = "agent_unavailable", + agent = "claude", + ) + } + val vm = buildViewModel(gateway, FakeMachineStore(listOf(machine("m1"))), FakePrefs()) + advanceUntilIdle() + vm.setDirectory("/repo") + advanceUntilIdle() + assertTrue(vm.uiState.value.canCreate) + + val availabilityCallsBeforeCreate = gateway.availabilityCalls.size + vm.create() + advanceUntilIdle() + + assertEquals(NewSessionStrings().selectedAgentUnavailable, vm.uiState.value.spawnError) + assertEquals(availabilityCallsBeforeCreate, gateway.availabilityCalls.size) + assertEquals(1, gateway.spawnCalls.size) + } + + @Test + fun `outside workspace root is shown and refused before spawn`() = runTest { + val gateway = FakeGateway().apply { outsideWorkspaceRoots = setOf("/outside/repo") } + val vm = buildViewModel( + gateway, + FakeMachineStore(listOf(machine("m1", workspaceRoots = listOf("/workspace")))), + FakePrefs(), + ) + advanceUntilIdle() + vm.setDirectory("/outside/repo") + advanceUntilIdle() + + assertFalse(vm.uiState.value.canCreate) + assertTrue(vm.uiState.value.directoryStatus!!.isError) + assertEquals(NewSessionStrings().directoryOutsideWorkspaceRoots, vm.uiState.value.directoryStatus?.message) + vm.create() + advanceUntilIdle() + assertEquals(NewSessionStrings().directoryOutsideWorkspaceRoots, vm.uiState.value.spawnError) + assertTrue(gateway.spawnCalls.isEmpty()) + } + @Test fun `missing simple directory needs a second create tap`() = runTest { val gateway = FakeGateway().apply { existsAnswer = { false } } @@ -555,6 +733,68 @@ class NewSessionViewModelTest { assertEquals(listOf("/work/repo"), vm.uiState.value.recentPaths) } + @Test + fun `default directory uses valid recent path then workspace root or home`() = runTest { + val gateway = FakeGateway().apply { outsideWorkspaceRoots = setOf("/outside") } + val prefs = FakePrefs( + stored = NewSessionPrefsData( + recentPaths = mapOf("m1" to listOf("/outside", "/workspace/recent")), + ), + ) + val vm = buildViewModel( + gateway, + FakeMachineStore(listOf(machine("m1", homeDir = "/home/dev", workspaceRoots = listOf("/workspace")))), + prefs, + ) + advanceUntilIdle() + assertEquals("/workspace/recent", vm.uiState.value.form.directory) + + val homeVm = buildViewModel( + FakeGateway(), + FakeMachineStore(listOf(machine("home", homeDir = "/home/dev"))), + FakePrefs(), + ) + advanceUntilIdle() + assertEquals("/home/dev", homeVm.uiState.value.form.directory) + homeVm.openDirectoryBrowser() + advanceUntilIdle() + assertEquals(listOf("/home/dev"), homeVm.directoryBrowser.state.value.roots) + } + + @Test + fun `directory picker stays within roots and forwards hidden toggle`() = runTest { + val gateway = FakeGateway().apply { entries = listOf(dir("repo"), dir("archive")) } + val vm = buildViewModel( + gateway, + FakeMachineStore(listOf(machine("m1", workspaceRoots = listOf("/workspace", "/other")))), + FakePrefs(), + ) + advanceUntilIdle() + + vm.openDirectoryBrowser() + advanceUntilIdle() + assertEquals("/workspace", vm.directoryBrowser.state.value.path) + val callsBeforeEscape = gateway.listDirectoryCalls.size + vm.directoryBrowser.navigate("/workspace-other") + advanceUntilIdle() + assertEquals(callsBeforeEscape, gateway.listDirectoryCalls.size) + + vm.directoryBrowser.navigateEntry("repo") + advanceUntilIdle() + assertEquals("/workspace/repo", vm.directoryBrowser.state.value.path) + assertTrue(vm.directoryBrowser.state.value.canGoUp) + vm.directoryBrowser.setIncludeHidden(true) + advanceUntilIdle() + assertTrue(gateway.includeHiddenCalls.last()) + vm.directoryBrowser.navigateUp() + advanceUntilIdle() + assertEquals("/workspace", vm.directoryBrowser.state.value.path) + vm.selectBrowsedDirectory(vm.directoryBrowser.state.value.path) + advanceUntilIdle() + assertFalse(vm.directoryBrowser.state.value.open) + assertEquals("/workspace", vm.uiState.value.form.directory) + } + @Test fun `restored draft survives and initial machine mismatch clears it`() = runTest { val draft = NewSessionForm( diff --git a/android/core/data/src/main/kotlin/app/hapi/data/api/HapiApi.kt b/android/core/data/src/main/kotlin/app/hapi/data/api/HapiApi.kt index 23211570..b92995d7 100644 --- a/android/core/data/src/main/kotlin/app/hapi/data/api/HapiApi.kt +++ b/android/core/data/src/main/kotlin/app/hapi/data/api/HapiApi.kt @@ -1,6 +1,7 @@ package app.hapi.data.api import app.hapi.data.auth.HubUrls +import app.hapi.protocol.wire.AgentAvailabilityResponse import app.hapi.protocol.wire.ApprovePermissionRequest import app.hapi.protocol.wire.AuthRequest import app.hapi.protocol.wire.AuthResponse @@ -471,6 +472,10 @@ class HapiApi( suspend fun spawnSession(machineId: String, spawn: SpawnSessionRequest): SpawnResponse = request("POST", url("api", "machines", machineId, "spawn").build(), spawn.toJsonBody()) + /** Installed/static-configured Agents reported by the selected runner. */ + suspend fun getMachineAgentAvailability(machineId: String): AgentAvailabilityResponse = + request("GET", url("api", "machines", machineId, "agent-availability").build()) + /** `POST /api/machines/:id/list-directory` (RPC-wrapped: check `success`). */ suspend fun listMachineDirectory( machineId: String, diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/ApiResponses.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/ApiResponses.kt index 35f1188c..bc532e27 100644 --- a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/ApiResponses.kt +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/ApiResponses.kt @@ -166,6 +166,9 @@ data class SpawnResponse( val type: String, val sessionId: String? = null, val message: String? = null, + /** `agent_unavailable | runner_upgrade_required | outside_workspace_roots`. */ + val code: String? = null, + val agent: String? = null, ) /** `GET /api/sessions/:id/slash-commands` (RPC-wrapped: check [success]). */ @@ -225,6 +228,21 @@ data class MachineDirectoryEntry( @Serializable data class MachinePathsExistsResponse( val exists: Map, + val outsideWorkspaceRoots: List? = null, +) + +/** `GET /api/machines/:id/agent-availability`. */ +@Serializable +data class AgentAvailabilityResponse( + val agents: List, +) + +@Serializable +data class AgentAvailabilityEntry( + val agent: String, + val available: Boolean, + /** `not_found | invalid_configuration`. */ + val reason: String? = null, ) /** @@ -296,4 +314,3 @@ data class TranscriptionProviderInfo( /** Subset of `standard` / `realtime`; native dictation uses `standard`. */ val modes: List, ) - diff --git a/cli/README.md b/cli/README.md index c22eb2ac..5a8d3f05 100644 --- a/cli/README.md +++ b/cli/README.md @@ -72,7 +72,10 @@ Both `start` and `start-sync` accept repeatable `--workspace-root ` (or `- - The runner refuses `list-directory` and `spawn-session` requests for paths outside the configured roots. - `~` and `~/foo` are expanded. -Omitting the flag keeps the legacy behavior: no scoping, no `/browse` feature. +Omitting the flag keeps manual session spawning unrestricted and leaves the +web `/browse` feature disabled. Machine directory lookups used by session +autocomplete and native pickers are still available, but are limited to the +runner's home directory. See `src/runner/run.ts`. diff --git a/cli/src/agent/agentAvailability.test.ts b/cli/src/agent/agentAvailability.test.ts new file mode 100644 index 00000000..29d4c750 --- /dev/null +++ b/cli/src/agent/agentAvailability.test.ts @@ -0,0 +1,101 @@ +import { chmod, mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { executableCandidates, getAgentLaunchCommand, resolveExecutable } from './agentLaunchCommand' +import { getAgentAvailability } from './agentAvailability' + +async function makeExecutable(directory: string, name: string): Promise { + const path = join(directory, name) + await writeFile(path, '#!/bin/sh\nexit 0\n') + await chmod(path, 0o755) + return path +} + +describe('agent executable resolution', () => { + it('resolves commands from PATH and absolute environment overrides', async () => { + const directory = await mkdtemp(join(tmpdir(), 'hapi-agent-path-')) + const copilot = await makeExecutable(directory, 'copilot-custom') + + expect(resolveExecutable('copilot-custom', { pathValue: directory })).toBe(copilot) + expect(getAgentLaunchCommand('copilot', { COPILOT_CLI_PATH: copilot })).toBe(copilot) + expect(getAgentAvailability('copilot', { + COPILOT_CLI_PATH: copilot, + PATH: directory, + })).toEqual({ agent: 'copilot', available: true }) + }) + + it('uses PATHEXT when resolving Windows commands', () => { + expect(executableCandidates('agent', { + platform: 'win32', + pathValue: 'C:\\Tools;D:\\Bin', + pathExt: '.EXE;.CMD', + })).toEqual([ + 'C:\\Tools\\agent.EXE', + 'C:\\Tools\\agent.CMD', + 'D:\\Bin\\agent.EXE', + 'D:\\Bin\\agent.CMD', + ]) + }) + + it('reports missing executables without invoking them', () => { + expect(getAgentAvailability('grok', { PATH: '' })).toEqual({ + agent: 'grok', + available: false, + reason: 'not_found', + }) + expect(getAgentAvailability('claude', { PATH: '' })).toEqual({ + agent: 'claude', + available: false, + reason: 'not_found', + }) + expect(getAgentAvailability('codex', { + PATH: '', + HAPI_CODEX_APP_SERVER_BIN: '/missing/codex', + })).toEqual({ + agent: 'codex', + available: false, + reason: 'invalid_configuration', + }) + }) + + it('uses the configured Codex app-server executable for availability', async () => { + const directory = await mkdtemp(join(tmpdir(), 'hapi-agent-path-')) + const codex = await makeExecutable(directory, 'codex-app-server') + + expect(getAgentAvailability('codex', { + PATH: '', + HAPI_CODEX_APP_SERVER_BIN: codex, + })).toEqual({ agent: 'codex', available: true }) + }) + + it('rejects malformed or missing DSH static configuration', async () => { + const directory = await mkdtemp(join(tmpdir(), 'hapi-agent-path-')) + await makeExecutable(directory, 'dsh-acp-demo') + expect(getAgentAvailability('dsh', { + PATH: directory, + HAPI_DSH_ACP_ARGS_JSON: 'not json', + }).reason).toBe('invalid_configuration') + expect(getAgentAvailability('dsh', { + PATH: directory, + HAPI_DSH_ACP_CONFIG: join(directory, 'missing.yml'), + }).reason).toBe('invalid_configuration') + + const config = join(directory, 'cordis.yml') + await writeFile(config, 'agents: []\n') + expect(getAgentAvailability('dsh', { + PATH: directory, + HAPI_DSH_ACP_CONFIG: config, + })).toEqual({ agent: 'dsh', available: true }) + }) + + it('accepts a macOS Codex app executable when the CLI is absent', async () => { + const directory = await mkdtemp(join(tmpdir(), 'hapi-agent-path-')) + const appCommand = join(directory, 'Codex.app', 'Contents', 'Resources', 'codex') + await mkdir(join(directory, 'Codex.app', 'Contents', 'Resources'), { recursive: true }) + await writeFile(appCommand, '#!/bin/sh\n') + await chmod(appCommand, 0o755) + + expect(resolveExecutable(appCommand, { pathValue: '' })).toBe(appCommand) + }) +}) diff --git a/cli/src/agent/agentAvailability.ts b/cli/src/agent/agentAvailability.ts new file mode 100644 index 00000000..da8ed9b0 --- /dev/null +++ b/cli/src/agent/agentAvailability.ts @@ -0,0 +1,119 @@ +import { existsSync, statSync } from 'node:fs' +import { isAbsolute } from 'node:path' +import { + CREATABLE_AGENT_FLAVORS, + type AgentAvailabilityEntry, + type AgentAvailabilityResponse, + type AgentFlavor, +} from '@hapi/protocol' +import { getDefaultClaudeCodePath } from '@/claude/sdk/utils' +import { resolveCodexCommand } from '@/codex/utils/codexExecutable' +import { resolveDshAcpCommand } from '@/dsh/utils/dshBackend' +import { getAgentLaunchCommand, resolveExecutable } from './agentLaunchCommand' + +type LaunchEnvironment = Record + +type AgentLaunchSpec = { + command: string + args: string[] +} + +function resolveLaunchSpec(agent: AgentFlavor, env: LaunchEnvironment): AgentLaunchSpec { + if (agent === 'claude') { + return { command: getDefaultClaudeCodePath(env), args: [] } + } + if (agent === 'codex') { + // Remote Codex sessions launch app-server through this explicit + // override. Validate the same command the session will actually use, + // rather than falling back to an unrelated PATH/Desktop install. + if (env.HAPI_CODEX_APP_SERVER_BIN) { + return { command: env.HAPI_CODEX_APP_SERVER_BIN.trim(), args: [] } + } + return resolveCodexCommand(env) + } + if (agent === 'dsh') { + return resolveDshAcpCommand(env) + } + return { command: getAgentLaunchCommand(agent, env), args: [] } +} + +function hasValidDshConfiguration(env: LaunchEnvironment): boolean { + const config = env.HAPI_DSH_ACP_CONFIG?.trim() + if (!config) return true + if (!isAbsolute(config)) return false + try { + return existsSync(config) && statSync(config).isFile() + } catch { + return false + } +} + +function hasResolvableCommand(spec: AgentLaunchSpec, env: LaunchEnvironment): boolean { + const executable = resolveExecutable(spec.command, { + pathValue: env.PATH, + pathExt: env.PATHEXT, + }) + if (!executable) return false + + // Windows Codex npm shims resolve to `node `. + const script = spec.args[0] + if (script && isAbsolute(script)) { + try { + return existsSync(script) && statSync(script).isFile() + } catch { + return false + } + } + return true +} + +export function getAgentAvailability( + agent: AgentFlavor, + env: LaunchEnvironment = process.env, +): AgentAvailabilityEntry { + if (agent === 'gemini') { + return { agent, available: false, reason: 'not_found' } + } + + let spec: AgentLaunchSpec + try { + spec = resolveLaunchSpec(agent, env) + if (agent === 'dsh' && !hasValidDshConfiguration(env)) { + return { agent, available: false, reason: 'invalid_configuration' } + } + } catch { + // Claude's resolver throws when the default command is simply absent; + // that is an installation miss, not malformed static configuration. + return { + agent, + available: false, + reason: agent === 'claude' ? 'not_found' : 'invalid_configuration', + } + } + + if (hasResolvableCommand(spec, env)) { + return { agent, available: true } + } + return { + agent, + available: false, + reason: agent === 'codex' && Boolean(env.HAPI_CODEX_APP_SERVER_BIN) + ? 'invalid_configuration' + : 'not_found', + } +} + +export function getAgentAvailabilityResponse( + env: LaunchEnvironment = process.env, +): AgentAvailabilityResponse { + return { + agents: CREATABLE_AGENT_FLAVORS.map((agent) => getAgentAvailability(agent, env)), + } +} + +export function agentUnavailableMessage(entry: AgentAvailabilityEntry): string { + const detail = entry.reason === 'invalid_configuration' + ? 'has invalid runner configuration' + : 'is not installed or is not on PATH' + return `${entry.agent} ${detail}` +} diff --git a/cli/src/agent/agentLaunchCommand.ts b/cli/src/agent/agentLaunchCommand.ts new file mode 100644 index 00000000..5a9607f7 --- /dev/null +++ b/cli/src/agent/agentLaunchCommand.ts @@ -0,0 +1,90 @@ +import { accessSync, existsSync, statSync } from 'node:fs' +import { constants } from 'node:fs' +import { delimiter, posix, win32 } from 'node:path' +import type { AgentFlavor } from '@hapi/protocol' + +type LaunchEnvironment = Record + +const DEFAULT_COMMANDS: Record = { + agy: 'agy', + claude: 'claude', + codex: 'codex', + copilot: 'copilot', + cursor: 'agent', + dsh: 'dsh-acp-demo', + gemini: 'gemini', + grok: 'grok', + kimi: 'kimi', + opencode: 'opencode', + pi: 'pi', +} + +/** Command source shared by availability preflight and agent launchers. */ +export function getAgentLaunchCommand( + flavor: AgentFlavor, + env: LaunchEnvironment = process.env, +): string { + if (flavor === 'claude') return env.HAPI_CLAUDE_PATH?.trim() || DEFAULT_COMMANDS.claude + if (flavor === 'copilot') return env.COPILOT_CLI_PATH?.trim() || DEFAULT_COMMANDS.copilot + if (flavor === 'dsh') return env.HAPI_DSH_ACP_COMMAND?.trim() || DEFAULT_COMMANDS.dsh + return DEFAULT_COMMANDS[flavor] +} + +export function executableCandidates( + command: string, + options: { + platform?: NodeJS.Platform + pathValue?: string + pathExt?: string + cwd?: string + } = {}, +): string[] { + const platform = options.platform ?? process.platform + const isWindows = platform === 'win32' + const pathApi = isWindows ? win32 : posix + const cwd = options.cwd ?? process.cwd() + const hasPathSeparator = command.includes('/') || command.includes('\\') + const commandPath = pathApi.isAbsolute(command) + ? command + : hasPathSeparator + ? pathApi.resolve(cwd, command) + : null + + const extensions = isWindows && pathApi.extname(command) === '' + ? (options.pathExt ?? process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD') + .split(';') + .map((entry) => entry.trim()) + .filter(Boolean) + : [''] + + if (commandPath) { + return extensions.map((extension) => commandPath + extension) + } + + const pathValue = options.pathValue ?? process.env.PATH ?? '' + const pathDelimiter = isWindows ? ';' : delimiter + return pathValue + .split(pathDelimiter) + .filter(Boolean) + .flatMap((directory) => extensions.map((extension) => pathApi.join(directory, command + extension))) +} + +export function resolveExecutable( + command: string, + options: Parameters[1] & { + isExecutable?: (path: string) => boolean + } = {}, +): string | null { + const platform = options.platform ?? process.platform + const isExecutable = options.isExecutable ?? ((path: string) => { + try { + if (!existsSync(path) || !statSync(path).isFile()) return false + if (platform !== 'win32') accessSync(path, constants.X_OK) + return true + } catch { + return false + } + }) + + return executableCandidates(command, options).find(isExecutable) ?? null +} diff --git a/cli/src/agy/headless/agyHeadlessDriver.ts b/cli/src/agy/headless/agyHeadlessDriver.ts index 6c299f96..dc0b04c5 100644 --- a/cli/src/agy/headless/agyHeadlessDriver.ts +++ b/cli/src/agy/headless/agyHeadlessDriver.ts @@ -16,6 +16,7 @@ import { readAgyConversationTitle } from '../utils/agySessionTitle'; import { resolveAgyTurnModels } from '../utils/agyConversationModel'; import { killProcessByChildProcess } from '@/utils/process'; import { AGY_MODEL_LABELS } from '@hapi/protocol'; +import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand'; const AGY_PRINT_TIMEOUT = '30m'; @@ -117,7 +118,7 @@ export class AgyHeadlessDriver extends RemoteLauncherBase { super(process.env.DEBUG ? opts.session.logPath : undefined); this.session = opts.session; this.spawnAgy = opts.spawnAgy ?? ((args, cwd) => { - const child = spawn('agy', args, { + const child = spawn(getAgentLaunchCommand('agy'), args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env: buildAgySpawnEnv(), diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 2d7a6632..4d12c027 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -3,9 +3,8 @@ */ import { io, type Socket } from 'socket.io-client' -import { readdir, realpath, stat } from 'node:fs/promises' -import { realpathSync } from 'node:fs' -import { basename, dirname, isAbsolute, join, relative, resolve as resolvePath } from 'node:path' +import { readdir, stat } from 'node:fs/promises' +import { join } from 'node:path' import { logger } from '@/ui/logger' import { configuration } from '@/configuration' import type { ClientToServerEvents, ServerToClientEvents, Update, UpdateMachineBody } from '@hapi/protocol' @@ -14,6 +13,7 @@ import { ListCodexSessionsRpcRequestSchema, ListPiSessionsRpcRequestSchema, type ArchiveCodexSessionRpcResponse, + type AgentAvailabilityResponse, type ListCodexSessionsRpcResponse, type ListPiSessionsRpcResponse, type MachineDirectoryEntry, @@ -53,6 +53,10 @@ import { collectMachineHealth } from '@/utils/machineHealth' import { inspectCursorChatStore } from '@/cursor/cursorChatStoreStatus' import { homedir } from 'node:os' import type { CursorChatStoreStatus } from '@hapi/protocol/apiTypes' +import { MachinePathPolicy } from './machinePathPolicy' +import { getAgentAvailabilityResponse } from '@/agent/agentAvailability' + +export { normalizeWindowsDriveRoot } from './machinePathPolicy' type MachineRpcHandlers = { spawnSession: (options: SpawnSessionOptions) => Promise @@ -75,30 +79,6 @@ interface CursorChatStoreStatusRequest { homeDir?: string } -export function normalizeWindowsDriveRoot(path: string): string { - return /^[A-Za-z]:$/.test(path) ? `${path}\\` : path -} - -function canonicalRealpathSync(path: string): string { - return normalizeWindowsDriveRoot(realpathSync.native(path)) -} - -function normalizeWorkspaceRoots(paths?: string[]): string[] | undefined { - if (!paths?.length) { - return undefined - } - - const normalized = Array.from(new Set(paths.map((path) => { - try { - return canonicalRealpathSync(path) - } catch { - return normalizeWindowsDriveRoot(resolvePath(path)) - } - }))) - - return normalized.length > 0 ? normalized : undefined -} - function workspaceRootsEqual(left?: string[], right?: string[]): boolean { const normalizedLeft = left ?? [] const normalizedRight = right ?? [] @@ -119,17 +99,17 @@ export class ApiMachineClient { private keepAliveStartTimeout: ReturnType | null = null private rpcHandlerManager: RpcHandlerManager - private readonly normalizedWorkspaceRoots: string[] | undefined + private readonly pathPolicy: MachinePathPolicy constructor( private readonly token: string, private readonly machine: Machine, private readonly workspaceRoots?: string[] ) { - // Realpath roots once so all subsequent comparisons are against - // canonical, symlink-resolved locations. Falls back to lexical - // resolution if realpath fails so we still get protection. - this.normalizedWorkspaceRoots = normalizeWorkspaceRoots(workspaceRoots) + this.pathPolicy = new MachinePathPolicy({ + workspaceRoots, + homeDirectory: this.machine.metadata?.homeDir ?? homedir(), + }) this.rpcHandlerManager = new RpcHandlerManager({ scopePrefix: this.machine.id, @@ -138,23 +118,38 @@ export class ApiMachineClient { registerCommonHandlers(this.rpcHandlerManager, getInvokedCwd()) + this.rpcHandlerManager.registerHandler( + RPC_METHODS.AgentAvailability, + async () => getAgentAvailabilityResponse() + ) + this.rpcHandlerManager.registerHandler(RPC_METHODS.PathExists, async (params) => { const rawPaths = Array.isArray(params?.paths) ? params.paths : [] const uniquePaths = Array.from(new Set(rawPaths.filter((path): path is string => typeof path === 'string'))) const exists: Record = {} + const outsideWorkspaceRoots: string[] = [] await Promise.all(uniquePaths.map(async (path) => { const trimmed = path.trim() if (!trimmed) return + const resolved = await this.pathPolicy.resolveForCheck(trimmed) + if (!this.pathPolicy.isWithinSpawnRoots(resolved)) { + exists[trimmed] = false + outsideWorkspaceRoots.push(trimmed) + return + } try { - const stats = await stat(trimmed) + const stats = await stat(resolved) exists[trimmed] = stats.isDirectory() } catch { exists[trimmed] = false } })) - return { exists } + return { + exists, + ...(outsideWorkspaceRoots.length > 0 ? { outsideWorkspaceRoots } : {}), + } }) this.rpcHandlerManager.registerHandler( @@ -170,10 +165,6 @@ export class ApiMachineClient { ) this.rpcHandlerManager.registerHandler(RPC_METHODS.ListMachineDirectory, async (params) => { - if (!this.normalizedWorkspaceRoots?.length) { - return { success: false, error: 'Workspace browsing is not enabled for this machine' } - } - const rawPath = typeof params?.path === 'string' ? params.path.trim() : '' if (!rawPath) { return { success: false, error: 'Path is required' } @@ -181,9 +172,9 @@ export class ApiMachineClient { const includeHidden = params?.includeHidden === true - const targetPath = await this.resolveForWorkspaceCheck(rawPath) - if (!this.isWithinWorkspaceRoots(targetPath)) { - return { success: false, error: 'Path is outside workspace roots' } + const targetPath = await this.pathPolicy.resolveForCheck(rawPath) + if (!this.pathPolicy.isWithinBrowseRoots(targetPath)) { + return { success: false, error: 'Path is outside browse roots' } } try { @@ -256,8 +247,8 @@ export class ApiMachineClient { return { success: false, error: 'cwd is required' } } - const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd) - if (!this.isWithinWorkspaceRoots(resolvedCwd)) { + const resolvedCwd = await this.pathPolicy.resolveForCheck(rawCwd) + if (!this.pathPolicy.isWithinSpawnRoots(resolvedCwd)) { return { success: false, error: 'Path is outside workspace roots' } } @@ -271,8 +262,8 @@ export class ApiMachineClient { const rawCwd = typeof params?.cwd === 'string' ? params.cwd.trim() : '' if (!rawCwd) return { success: false, error: 'cwd is required' } - const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd) - if (!this.isWithinWorkspaceRoots(resolvedCwd)) { + const resolvedCwd = await this.pathPolicy.resolveForCheck(rawCwd) + if (!this.pathPolicy.isWithinSpawnRoots(resolvedCwd)) { return { success: false, error: 'Path is outside workspace roots' } } @@ -286,8 +277,8 @@ export class ApiMachineClient { const rawCwd = typeof params?.cwd === 'string' ? params.cwd.trim() : '' if (!rawCwd) return { success: false, error: 'cwd is required' } - const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd) - if (!this.isWithinWorkspaceRoots(resolvedCwd)) { + const resolvedCwd = await this.pathPolicy.resolveForCheck(rawCwd) + if (!this.pathPolicy.isWithinSpawnRoots(resolvedCwd)) { return { success: false, error: 'Path is outside workspace roots' } } @@ -302,8 +293,8 @@ export class ApiMachineClient { if (!parsed.success) return { success: false, error: 'Invalid Codex sessions request' } const rawCwd = typeof parsed.data.cwd === 'string' ? parsed.data.cwd.trim() : '' if (rawCwd) { - const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd) - if (!this.isWithinWorkspaceRoots(resolvedCwd)) { + const resolvedCwd = await this.pathPolicy.resolveForCheck(rawCwd) + if (!this.pathPolicy.isWithinSpawnRoots(resolvedCwd)) { return { success: false, error: 'Path is outside workspace roots' } } } @@ -342,8 +333,8 @@ export class ApiMachineClient { if (!parsed.success) return { success: false, error: 'Invalid Pi sessions request' } const rawCwd = typeof parsed.data.cwd === 'string' ? parsed.data.cwd.trim() : '' if (rawCwd) { - const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd) - if (!this.isWithinWorkspaceRoots(resolvedCwd)) { + const resolvedCwd = await this.pathPolicy.resolveForCheck(rawCwd) + if (!this.pathPolicy.isWithinSpawnRoots(resolvedCwd)) { return { success: false, error: 'Path is outside workspace roots' } } } @@ -361,50 +352,11 @@ export class ApiMachineClient { } private async isLocalSessionWithinWorkspaceRoots(session: { cwd?: string | null }): Promise { - if (!this.normalizedWorkspaceRoots?.length) return true + if (!this.pathPolicy.hasWorkspaceRoots()) return true const cwd = session.cwd?.trim() if (!cwd) return false - const resolvedCwd = await this.resolveForWorkspaceCheck(cwd) - return this.isWithinWorkspaceRoots(resolvedCwd) - } - - private isWithinWorkspaceRoots(absolutePath: string): boolean { - if (!this.normalizedWorkspaceRoots?.length) return true - return this.normalizedWorkspaceRoots.some((workspaceRoot) => { - const rel = relative(workspaceRoot, absolutePath) - return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) - }) - } - - /** - * Canonicalize a path for workspace-root containment checks. Resolves - * symlinks via realpath so a symlink such as `/safe/out -> /etc` cannot - * be used to escape the configured root with a lexical-only check. - * - * If the path doesn't exist (e.g. a session is being spawned in a - * directory we'll create), walks up to the nearest existing ancestor - * and realpaths *that*, joining the missing tail back on. This way the - * check still runs against the real on-disk location once any - * intermediate symlink in the parent chain has been resolved. - */ - private async resolveForWorkspaceCheck(path: string): Promise { - const absolute = resolvePath(path) - try { - return normalizeWindowsDriveRoot(await realpath(absolute)) - } catch { - const missing: string[] = [] - let cursor = absolute - while (cursor !== dirname(cursor)) { - missing.unshift(basename(cursor)) - cursor = dirname(cursor) - try { - return join(normalizeWindowsDriveRoot(await realpath(cursor)), ...missing) - } catch { - // keep walking to the nearest existing parent - } - } - return normalizeWindowsDriveRoot(absolute) - } + const resolvedCwd = await this.pathPolicy.resolveForCheck(cwd) + return this.pathPolicy.isWithinSpawnRoots(resolvedCwd) } setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void { @@ -415,9 +367,13 @@ export class ApiMachineClient { throw new Error('Directory is required') } - const resolvedDirectory = await this.resolveForWorkspaceCheck(directory) - if (!this.isWithinWorkspaceRoots(resolvedDirectory)) { - return { type: 'error', errorMessage: 'Directory is outside this machine\'s workspace roots' } + const resolvedDirectory = await this.pathPolicy.resolveForCheck(directory) + if (!this.pathPolicy.isWithinSpawnRoots(resolvedDirectory)) { + return { + type: 'error', + errorMessage: 'Directory is outside this machine\'s workspace roots', + code: 'outside_workspace_roots', + } } const result = await spawnSession({ @@ -440,7 +396,8 @@ export class ApiMachineClient { sessionType, worktreeName, startingMode, - forkSession: forkSession === true + forkSession: forkSession === true, + validateDirectory: async (path) => await this.pathPolicy.allowsSpawn(path), }) switch (result.type) { @@ -449,7 +406,12 @@ export class ApiMachineClient { case 'requestToApproveDirectoryCreation': return { type: 'requestToApproveDirectoryCreation', directory: result.directory } case 'error': - return { type: 'error', errorMessage: result.errorMessage } + return { + type: 'error', + errorMessage: result.errorMessage, + code: result.code, + agent: result.agent, + } } }) diff --git a/cli/src/api/machinePathPolicy.test.ts b/cli/src/api/machinePathPolicy.test.ts new file mode 100644 index 00000000..347beeb1 --- /dev/null +++ b/cli/src/api/machinePathPolicy.test.ts @@ -0,0 +1,47 @@ +import { mkdtemp, mkdir, realpath, symlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { MachinePathPolicy } from './machinePathPolicy' + +describe('MachinePathPolicy', () => { + it('accepts paths inside any configured root and rejects prefix collisions', async () => { + const base = await mkdtemp(join(tmpdir(), 'hapi-path-policy-')) + const first = join(base, 'one') + const second = join(base, 'two') + const collision = join(base, 'one-other') + await Promise.all([mkdir(first), mkdir(second), mkdir(collision)]) + const policy = new MachinePathPolicy({ workspaceRoots: [first, second] }) + + expect(await policy.allowsSpawn(join(first, 'missing', 'child'))).toBe(true) + expect(await policy.allowsSpawn(second)).toBe(true) + expect(await policy.allowsSpawn(collision)).toBe(false) + }) + + it('resolves an existing symlink before checking a missing child', async () => { + const base = await mkdtemp(join(tmpdir(), 'hapi-path-policy-')) + const root = join(base, 'root') + const outside = join(base, 'outside') + await Promise.all([mkdir(root), mkdir(outside)]) + const escape = join(root, 'escape') + await symlink(outside, escape, 'dir') + const policy = new MachinePathPolicy({ workspaceRoots: [root] }) + + expect(await policy.allowsSpawn(join(escape, 'missing'))).toBe(false) + expect(await policy.resolveForCheck(join(escape, 'missing'))).toBe( + join(await realpath(outside), 'missing') + ) + }) + + it('keeps manual spawn unrestricted but scopes browsing to home without roots', async () => { + const base = await mkdtemp(join(tmpdir(), 'hapi-path-policy-')) + const home = join(base, 'home') + const outside = join(base, 'outside') + await Promise.all([mkdir(home), mkdir(outside)]) + const policy = new MachinePathPolicy({ homeDirectory: home }) + + expect(await policy.allowsSpawn(outside)).toBe(true) + expect(await policy.allowsBrowse(join(home, 'missing'))).toBe(true) + expect(await policy.allowsBrowse(outside)).toBe(false) + }) +}) diff --git a/cli/src/api/machinePathPolicy.ts b/cli/src/api/machinePathPolicy.ts new file mode 100644 index 00000000..7b8d8943 --- /dev/null +++ b/cli/src/api/machinePathPolicy.ts @@ -0,0 +1,100 @@ +import { realpathSync } from 'node:fs' +import { realpath } from 'node:fs/promises' +import { homedir } from 'node:os' +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, +} from 'node:path' + +export function normalizeWindowsDriveRoot(path: string): string { + return /^[A-Za-z]:$/.test(path) ? `${path}\\` : path +} + +function canonicalizeExistingPathSync(path: string): string { + return normalizeWindowsDriveRoot(realpathSync.native(path)) +} + +function normalizeRoots(paths: readonly string[]): string[] { + return Array.from(new Set(paths.map((path) => { + try { + return canonicalizeExistingPathSync(path) + } catch { + return normalizeWindowsDriveRoot(resolve(path)) + } + }))) +} + +function isPathWithinRoots(path: string, roots: readonly string[]): boolean { + return roots.some((root) => { + const child = relative(root, path) + return child === '' || (!child.startsWith('..') && !isAbsolute(child)) + }) +} + +/** + * Single authority for machine-scoped path access. + * + * Spawn paths are unrestricted when the runner has no configured workspace + * roots, preserving the legacy manual-entry contract. Browse paths instead + * fall back to the runner's home directory so native autocomplete/pickers can + * remain useful without exposing the whole filesystem. + */ +export class MachinePathPolicy { + readonly workspaceRoots: readonly string[] + readonly browseRoots: readonly string[] + + constructor(options: { + workspaceRoots?: readonly string[] + homeDirectory?: string + } = {}) { + this.workspaceRoots = normalizeRoots(options.workspaceRoots ?? []) + this.browseRoots = this.workspaceRoots.length > 0 + ? this.workspaceRoots + : normalizeRoots([options.homeDirectory ?? homedir()]) + } + + hasWorkspaceRoots(): boolean { + return this.workspaceRoots.length > 0 + } + + isWithinSpawnRoots(path: string): boolean { + return !this.hasWorkspaceRoots() || isPathWithinRoots(path, this.workspaceRoots) + } + + isWithinBrowseRoots(path: string): boolean { + return isPathWithinRoots(path, this.browseRoots) + } + + async resolveForCheck(path: string): Promise { + const absolute = resolve(path) + try { + return normalizeWindowsDriveRoot(await realpath(absolute)) + } catch { + const missing: string[] = [] + let cursor = absolute + while (cursor !== dirname(cursor)) { + missing.unshift(basename(cursor)) + cursor = dirname(cursor) + try { + return join(normalizeWindowsDriveRoot(await realpath(cursor)), ...missing) + } catch { + // Continue to the nearest existing ancestor. This resolves + // symlinks in the existing prefix before adding a missing tail. + } + } + return normalizeWindowsDriveRoot(absolute) + } + } + + async allowsSpawn(path: string): Promise { + return this.isWithinSpawnRoots(await this.resolveForCheck(path)) + } + + async allowsBrowse(path: string): Promise { + return this.isWithinBrowseRoots(await this.resolveForCheck(path)) + } +} diff --git a/cli/src/claude/sdk/utils.ts b/cli/src/claude/sdk/utils.ts index 606daaaf..8c2f7c87 100644 --- a/cli/src/claude/sdk/utils.ts +++ b/cli/src/claude/sdk/utils.ts @@ -4,10 +4,11 @@ */ import { existsSync } from 'node:fs' -import { execFileSync, execSync } from 'node:child_process' +import { execFileSync } from 'node:child_process' import { homedir } from 'node:os' import path from 'node:path' import { logger } from '@/ui/logger' +import { getAgentLaunchCommand, resolveExecutable } from '@/agent/agentLaunchCommand' const windowsPath = path.win32 @@ -97,7 +98,7 @@ function findWindowsClaudePath(): string | null { * On Unix: Returns 'claude' if command works, or actual path via which * Runs from home directory to avoid local cwd side effects */ -function findGlobalClaudePath(): string | null { +function findGlobalClaudePath(env: Record): string | null { const homeDir = homedir() // Windows: Always return absolute path for shell: false compatibility @@ -105,35 +106,13 @@ function findGlobalClaudePath(): string | null { return findWindowsClaudePath() } - // Unix: Check if 'claude' command works directly from home dir - try { - execSync('claude --version', { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - cwd: homeDir - }) - logger.debug('[Claude SDK] Global claude command available') - return 'claude' - } catch { - // claude command not available globally - } - - // FALLBACK for Unix: try which to get actual path - try { - const result = execSync('which claude', { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - cwd: homeDir - }).trim() - if (result && existsSync(result)) { - logger.debug(`[Claude SDK] Found global claude path via which: ${result}`) - return result - } - } catch { - // which didn't find it - } - - return null + const resolved = resolveExecutable('claude', { + pathValue: env.PATH, + pathExt: env.PATHEXT, + cwd: homeDir, + }) + if (resolved) logger.debug(`[Claude SDK] Found global claude path: ${resolved}`) + return resolved } /** @@ -142,12 +121,14 @@ function findGlobalClaudePath(): string | null { * Environment variables: * - HAPI_CLAUDE_PATH: Force a specific path to claude executable */ -export function getDefaultClaudeCodePath(): string { +export function getDefaultClaudeCodePath( + env: Record = process.env, +): string { // Allow explicit override via env var. On Windows, tolerate npm // shim paths (`claude.cmd` / extensionless `claude`) by resolving them // to the real `claude.exe` because Claude is spawned with shell:false. - if (process.env.HAPI_CLAUDE_PATH) { - const configuredPath = process.env.HAPI_CLAUDE_PATH + if (env.HAPI_CLAUDE_PATH) { + const configuredPath = getAgentLaunchCommand('claude', env) if (process.platform === 'win32') { const resolved = resolveWindowsClaudePathCandidate(configuredPath) if (resolved) { @@ -160,7 +141,7 @@ export function getDefaultClaudeCodePath(): string { } // Find global claude - const globalPath = findGlobalClaudePath() + const globalPath = findGlobalClaudePath(env) if (!globalPath) { throw new Error('Claude Code CLI not found on PATH. Install Claude Code or set HAPI_CLAUDE_PATH.') } diff --git a/cli/src/codex/utils/codexExecutable.test.ts b/cli/src/codex/utils/codexExecutable.test.ts index 8a5a182c..03de6c92 100644 --- a/cli/src/codex/utils/codexExecutable.test.ts +++ b/cli/src/codex/utils/codexExecutable.test.ts @@ -181,4 +181,15 @@ describe('resolveCodexCommand', () => { args: [] }); }); + + it('uses the fixed macOS Codex app executable when PATH has no CLI', async () => { + setPlatform('darwin'); + const { MACOS_CODEX_APP_COMMAND, resolveCodexCommand } = await import('./codexExecutable'); + existsSyncMock.mockImplementation((candidate: string) => candidate === MACOS_CODEX_APP_COMMAND); + + expect(resolveCodexCommand()).toEqual({ + command: MACOS_CODEX_APP_COMMAND, + args: [] + }); + }); }); diff --git a/cli/src/codex/utils/codexExecutable.ts b/cli/src/codex/utils/codexExecutable.ts index 7a9e4986..5299ad5e 100644 --- a/cli/src/codex/utils/codexExecutable.ts +++ b/cli/src/codex/utils/codexExecutable.ts @@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process'; import { existsSync } from 'node:fs'; import { homedir } from 'node:os'; import path from 'node:path'; +import { resolveExecutable } from '@/agent/agentLaunchCommand'; const windowsPath = path.win32; @@ -67,8 +68,22 @@ function resolveWindowsCodexCommand(): CodexCommand { return { command: 'codex', args: [] }; } -export function resolveCodexCommand(): CodexCommand { - if (process.platform !== 'win32') { +export const MACOS_CODEX_APP_COMMAND = '/Applications/Codex.app/Contents/Resources/codex'; + +export function resolveCodexCommand( + env: Record = process.env, + platform: NodeJS.Platform = process.platform, +): CodexCommand { + if (platform !== 'win32') { + const command = resolveExecutable('codex', { + platform, + pathValue: env.PATH, + pathExt: env.PATHEXT, + }); + if (command) return { command, args: [] }; + if (platform === 'darwin' && existsSync(MACOS_CODEX_APP_COMMAND)) { + return { command: MACOS_CODEX_APP_COMMAND, args: [] }; + } return { command: 'codex', args: [] }; } diff --git a/cli/src/copilot/copilotLocal.ts b/cli/src/copilot/copilotLocal.ts index 3ca9778c..93fc94ee 100644 --- a/cli/src/copilot/copilotLocal.ts +++ b/cli/src/copilot/copilotLocal.ts @@ -1,5 +1,6 @@ import { logger } from '@/ui/logger'; import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard'; +import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand'; import type { CopilotAgentMode } from '@hapi/protocol'; import { assertSafeWindowsShellArg } from '@/grok/utils/windowsShellArgs'; @@ -34,7 +35,7 @@ export async function copilotLocal(opts: { logger.debug(`[CopilotLocal] Spawning copilot with args: ${JSON.stringify(args)}`); await spawnWithTerminalGuard({ - command: process.env.COPILOT_CLI_PATH ?? 'copilot', + command: getAgentLaunchCommand('copilot'), args, cwd: opts.path, env: process.env, diff --git a/cli/src/copilot/utils/copilotBackend.ts b/cli/src/copilot/utils/copilotBackend.ts index 965ce940..d67f76c7 100644 --- a/cli/src/copilot/utils/copilotBackend.ts +++ b/cli/src/copilot/utils/copilotBackend.ts @@ -1,5 +1,6 @@ import { AcpSdkBackend } from '@/agent/backends/acp'; import type { CopilotAgentMode } from '@hapi/protocol'; +import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand'; function filterEnv(env: NodeJS.ProcessEnv): Record { const result: Record = {}; @@ -22,7 +23,7 @@ export function buildCopilotAcpArgs(opts?: { agentMode?: CopilotAgentMode }): st export function createCopilotBackend(opts?: { agentMode?: CopilotAgentMode }): AcpSdkBackend { return new AcpSdkBackend({ - command: process.env.COPILOT_CLI_PATH ?? 'copilot', + command: getAgentLaunchCommand('copilot'), args: buildCopilotAcpArgs(opts), env: filterEnv(process.env) }); diff --git a/cli/src/cursor/cursorLegacyRemoteLauncher.ts b/cli/src/cursor/cursorLegacyRemoteLauncher.ts index a273423f..89eca09f 100644 --- a/cli/src/cursor/cursorLegacyRemoteLauncher.ts +++ b/cli/src/cursor/cursorLegacyRemoteLauncher.ts @@ -13,6 +13,7 @@ import { import type { CursorSession } from './session'; import type { EnhancedMode } from './loop'; import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; +import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand'; // TODO(cursor-acp): remove legacy stream-json resume path after migration window. // New Cursor sessions use ACP only. This path exists because pre-ACP Cursor // session_id values are not loadable via ACP session/load. @@ -266,7 +267,7 @@ class CursorRemoteLauncher extends RemoteLauncherBase { onEvent: (event: ReturnType & object) => void ): Promise<{ exitCode: number | null; stderr: string }> { return new Promise((resolve, reject) => { - const child = spawn('agent', args, { + const child = spawn(getAgentLaunchCommand('cursor'), args, { cwd, env: process.env, stdio: ['ignore', 'pipe', 'pipe'], diff --git a/cli/src/cursor/cursorLocal.ts b/cli/src/cursor/cursorLocal.ts index 7291c607..8ef116de 100644 --- a/cli/src/cursor/cursorLocal.ts +++ b/cli/src/cursor/cursorLocal.ts @@ -1,5 +1,6 @@ import { logger } from '@/ui/logger'; import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard'; +import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand'; /** * Filter out 'resume' subcommand which is managed internally by hapi. @@ -82,7 +83,7 @@ export async function cursorLocal(opts: { } await spawnWithTerminalGuard({ - command: 'agent', + command: getAgentLaunchCommand('cursor'), args, cwd: opts.path, env: process.env, diff --git a/cli/src/cursor/utils/cursorAcpBackend.ts b/cli/src/cursor/utils/cursorAcpBackend.ts index 43eeca50..a1c3ea9e 100644 --- a/cli/src/cursor/utils/cursorAcpBackend.ts +++ b/cli/src/cursor/utils/cursorAcpBackend.ts @@ -1,6 +1,7 @@ import { basename, join } from 'node:path'; import { homedir } from 'node:os'; import { AcpSdkBackend } from '@/agent/backends/acp'; +import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand'; function filterEnv(env: NodeJS.ProcessEnv): Record { const result: Record = {}; @@ -79,7 +80,7 @@ export function resolveCursorNativeWorktreePath(repoPath: string, worktreeName: export function createCursorAcpBackend(opts: CursorAcpBackendOptions): AcpSdkBackend { return new AcpSdkBackend({ - command: 'agent', + command: getAgentLaunchCommand('cursor'), args: buildCursorAcpArgs(opts), env: filterEnv(process.env), flavor: 'cursor', diff --git a/cli/src/grok/grokLocal.ts b/cli/src/grok/grokLocal.ts index 0f5b853e..4310a42e 100644 --- a/cli/src/grok/grokLocal.ts +++ b/cli/src/grok/grokLocal.ts @@ -1,5 +1,6 @@ import { logger } from '@/ui/logger' import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard' +import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand' import type { PermissionMode } from './types' import { assertSafeWindowsShellArg } from './utils/windowsShellArgs' @@ -44,7 +45,7 @@ export async function grokLocal(opts: GrokLocalOptions & { logger.debug(`[GrokLocal] Spawning grok with args: ${JSON.stringify(args)}`) await spawnWithTerminalGuard({ - command: 'grok', + command: getAgentLaunchCommand('grok'), args, cwd: opts.path, env: process.env, diff --git a/cli/src/grok/utils/grokBackend.ts b/cli/src/grok/utils/grokBackend.ts index 66d84783..f12eba02 100644 --- a/cli/src/grok/utils/grokBackend.ts +++ b/cli/src/grok/utils/grokBackend.ts @@ -1,4 +1,5 @@ import { AcpSdkBackend } from '@/agent/backends/acp' +import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand' import { assertSafeWindowsShellArg } from './windowsShellArgs' const ANSI_SGR_PATTERN = /\u001b\[[0-9;]*m/g @@ -42,7 +43,7 @@ export function createGrokBackend(opts: { effort?: string }): AcpSdkBackend { return new AcpSdkBackend({ - command: 'grok', + command: getAgentLaunchCommand('grok'), args: buildGrokAgentArgs(opts), env: filterEnv(process.env) }) diff --git a/cli/src/kimi/kimiLocal.ts b/cli/src/kimi/kimiLocal.ts index 306e23f2..44ad2db2 100644 --- a/cli/src/kimi/kimiLocal.ts +++ b/cli/src/kimi/kimiLocal.ts @@ -1,5 +1,6 @@ import { logger } from '@/ui/logger'; import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard'; +import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand'; export async function kimiLocal(opts: { path: string; @@ -31,7 +32,7 @@ export async function kimiLocal(opts: { logger.debug(`[KimiLocal] Spawning kimi with args: ${JSON.stringify(args)}`); await spawnWithTerminalGuard({ - command: 'kimi', + command: getAgentLaunchCommand('kimi'), args, cwd: opts.path, env, diff --git a/cli/src/kimi/utils/kimiBackend.ts b/cli/src/kimi/utils/kimiBackend.ts index 2604e978..8dd681ea 100644 --- a/cli/src/kimi/utils/kimiBackend.ts +++ b/cli/src/kimi/utils/kimiBackend.ts @@ -1,4 +1,5 @@ import { AcpSdkBackend } from '@/agent/backends/acp'; +import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand'; function filterEnv(env: NodeJS.ProcessEnv): Record { const result: Record = {}; @@ -19,7 +20,7 @@ function filterEnv(env: NodeJS.ProcessEnv): Record { */ export function createKimiBackend(): AcpSdkBackend { return new AcpSdkBackend({ - command: 'kimi', + command: getAgentLaunchCommand('kimi'), args: ['acp'], env: filterEnv(process.env), flavor: 'kimi', diff --git a/cli/src/modules/common/agyModels.ts b/cli/src/modules/common/agyModels.ts index d3395336..9949704d 100644 --- a/cli/src/modules/common/agyModels.ts +++ b/cli/src/modules/common/agyModels.ts @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process' +import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand' import { AGY_MODEL_LABELS, AGY_MODEL_PRESETS } from '@hapi/protocol' import type { AgyModelsResponse } from '@hapi/protocol/apiTypes' @@ -127,7 +128,7 @@ function buildAgyProbeEnv(): NodeJS.ProcessEnv { // unparseable output). An auth failure is surfaced so the UI can prompt sign-in. async function fetchAgyModels(): Promise { return await new Promise((resolve) => { - const child = spawn('agy', ['models'], { + const child = spawn(getAgentLaunchCommand('agy'), ['models'], { stdio: ['ignore', 'pipe', 'pipe'], env: buildAgyProbeEnv(), windowsHide: process.platform === 'win32', diff --git a/cli/src/modules/common/cursorModels.ts b/cli/src/modules/common/cursorModels.ts index ae9e0f64..33a17c5e 100644 --- a/cli/src/modules/common/cursorModels.ts +++ b/cli/src/modules/common/cursorModels.ts @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process'; +import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand'; import type { CursorModelsResponse, CursorModelSummary } from '@hapi/protocol/apiTypes'; import { releaseAgentCliSpawnLeaseSync, @@ -226,7 +227,7 @@ async function runCursorModelProbe(): Promise { }; return await new Promise((resolve, reject) => { - const child = spawn('agent', ['--list-models'], { + const child = spawn(getAgentLaunchCommand('cursor'), ['--list-models'], { env: process.env, stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32', diff --git a/cli/src/modules/common/grokModels.ts b/cli/src/modules/common/grokModels.ts index 65d8063b..5b704e09 100644 --- a/cli/src/modules/common/grokModels.ts +++ b/cli/src/modules/common/grokModels.ts @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process' +import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand' import { asString, isObject } from '@hapi/protocol' import type { GrokModelSummary, GrokModelsResponse, GrokReasoningEffortOption } from '@hapi/protocol/apiTypes' import { AcpStdioTransport } from '@/agent/backends/acp/AcpStdioTransport' @@ -107,7 +108,7 @@ export function parseGrokInitializeModels(response: unknown): { async function runGrokModelsCliProbe(cwd: string): Promise { return await new Promise((resolve, reject) => { - const child = spawn('grok', buildGrokModelsArgs(cwd), { + const child = spawn(getAgentLaunchCommand('grok'), buildGrokModelsArgs(cwd), { env: process.env, stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32', @@ -153,7 +154,7 @@ async function runGrokModelsProbe(cwd: string): Promise entry[1] !== undefined) diff --git a/cli/src/modules/common/opencodeModels.ts b/cli/src/modules/common/opencodeModels.ts index e837daeb..23b4b70c 100644 --- a/cli/src/modules/common/opencodeModels.ts +++ b/cli/src/modules/common/opencodeModels.ts @@ -3,6 +3,7 @@ import type { OpencodeModelsResponse, OpencodeModelSummary } from '@hapi/protoco import { AcpStdioTransport } from '@/agent/backends/acp/AcpStdioTransport'; import packageJson from '../../../package.json'; import { getErrorMessage } from './rpcResponses'; +import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand'; export interface ListOpencodeModelsForCwdRequest { cwd?: string; @@ -85,7 +86,7 @@ function extractModelsFromResponse(response: unknown): { async function runOpencodeProbe(cwd: string): Promise { const transport = await AcpStdioTransport.create({ - command: 'opencode', + command: getAgentLaunchCommand('opencode'), args: ['acp'] }); diff --git a/cli/src/modules/common/piModels.ts b/cli/src/modules/common/piModels.ts index 88a8e37a..5e8d19dc 100644 --- a/cli/src/modules/common/piModels.ts +++ b/cli/src/modules/common/piModels.ts @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process' +import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand' import { homedir } from 'node:os' import { parse } from 'node:path' import type { PiModelSummary, PiModelsResponse } from '@hapi/protocol/apiTypes' @@ -120,7 +121,7 @@ export function resolveProbeCwd(): string { function runPiModelsProbe(): Promise { return new Promise((resolve, reject) => { - const child = spawn('pi', [...PI_PROBE_ARGS], { + const child = spawn(getAgentLaunchCommand('pi'), [...PI_PROBE_ARGS], { env: process.env, // Probe from the runner's cwd, falling back to home at a // filesystem root (see resolveProbeCwd). diff --git a/cli/src/modules/common/rpcTypes.ts b/cli/src/modules/common/rpcTypes.ts index 9133ed87..53ae84f1 100644 --- a/cli/src/modules/common/rpcTypes.ts +++ b/cli/src/modules/common/rpcTypes.ts @@ -27,9 +27,16 @@ export interface SpawnSessionOptions { startingMode?: 'remote' | 'pty' /** Claude: spawn with --fork-session after --resume. */ forkSession?: boolean + /** Runner-internal post-create containment revalidation. Never serialized. */ + validateDirectory?: (path: string) => Promise } export type SpawnSessionResult = | { type: 'success'; sessionId: string } | { type: 'requestToApproveDirectoryCreation'; directory: string } - | { type: 'error'; errorMessage: string } + | { + type: 'error' + errorMessage: string + code?: 'agent_unavailable' | 'outside_workspace_roots' + agent?: AgentFlavor + } diff --git a/cli/src/opencode/opencodeLocal.ts b/cli/src/opencode/opencodeLocal.ts index fcba1ef2..4c0514ab 100644 --- a/cli/src/opencode/opencodeLocal.ts +++ b/cli/src/opencode/opencodeLocal.ts @@ -1,5 +1,6 @@ import { logger } from '@/ui/logger'; import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard'; +import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand'; export async function opencodeLocal(opts: { path: string; @@ -18,7 +19,7 @@ export async function opencodeLocal(opts: { logger.debug(`[OpencodeLocal] Spawning opencode with args: ${JSON.stringify(args)}`); await spawnWithTerminalGuard({ - command: 'opencode', + command: getAgentLaunchCommand('opencode'), args, cwd: opts.path, env: opts.env, diff --git a/cli/src/opencode/utils/opencodeBackend.ts b/cli/src/opencode/utils/opencodeBackend.ts index 9d8e5c3d..9e8d2abb 100644 --- a/cli/src/opencode/utils/opencodeBackend.ts +++ b/cli/src/opencode/utils/opencodeBackend.ts @@ -2,6 +2,7 @@ import { createServer } from 'node:net'; import { AcpSdkBackend } from '@/agent/backends/acp'; import { buildOpencodeEnv } from './config'; import { getInvokedCwd } from '@/utils/invokedCwd'; +import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand'; function filterEnv(env: NodeJS.ProcessEnv): Record { const result: Record = {}; @@ -61,7 +62,7 @@ export function createOpencodeBackend(opts: { } return new AcpSdkBackend({ - command: 'opencode', + command: getAgentLaunchCommand('opencode'), args, env: filterEnv(env), textChunkMode: 'delta', diff --git a/cli/src/pi/runPi.ts b/cli/src/pi/runPi.ts index b3268b8c..c5af64ad 100644 --- a/cli/src/pi/runPi.ts +++ b/cli/src/pi/runPi.ts @@ -7,6 +7,7 @@ import { registerLocalHandoffHandler } from '@/agent/localHandoff'; import { createRunnerLifecycle, createModeChangeHandler, setControlledByUser } from '@/agent/runnerLifecycle'; import { getInvokedCwd } from '@/utils/invokedCwd'; import { PiTransport } from './piTransport'; +import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand'; import { PiSession } from './session'; import { PiConversationHistory, PiHistoryRestoreError } from './conversationHistory'; import { parsePiModels, parsePiCommands, PiRpcTimeoutError, sendPiRpcAndWait, wireTransportEvents } from './loop'; @@ -243,7 +244,7 @@ export async function runPi(opts: { transportArgs.push('--session', opts.resumeSessionId); } const transport = new PiTransport({ - command: 'pi', + command: getAgentLaunchCommand('pi'), args: transportArgs, cwd: workingDirectory, env: { ...process.env, PI_RPC_EMIT_TITLE: '1' }, diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 1bba2f32..245dc398 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -30,6 +30,7 @@ import { resolveWorkspaceRoots } from '@/utils/workspaceRoot'; import { hashRunnerCliApiToken, hashRunnerExtraHeaders } from './runnerIdentity'; import { scheduleCursorModelsPrewarm } from '@/modules/common/cursorModelsPrewarm'; import { isLinkedGitWorktree } from '@/utils/isLinkedGitWorktree'; +import { agentUnavailableMessage, getAgentAvailability } from '@/agent/agentAvailability'; /** * Deduplicates a preallocated HAPI-row spawn only while its child is alive. @@ -499,8 +500,27 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): const { directory, sessionId, machineId, approvedNewDirectoryCreation = true } = options; const agent = options.agent ?? 'claude'; - if (agent === 'gemini') { - throw new Error('Gemini CLI is no longer supported and cannot be launched (Google sunset the consumer Gemini CLI on 2026-06-18). Existing Gemini sessions remain viewable in the web UI.'); + const availability = getAgentAvailability(agent); + if (!availability.available) { + const errorMessage = agentUnavailableMessage(availability); + logger.debug(`[RUNNER RUN] Agent preflight failed: ${errorMessage}`); + reportSpawnOutcomeToHub?.({ + type: 'error', + details: { message: errorMessage } + }); + return { + type: 'error', + errorMessage, + code: 'agent_unavailable', + agent + }; + } + if (options.validateDirectory && !(await options.validateDirectory(directory))) { + return { + type: 'error', + errorMessage: 'Directory is outside this machine\'s workspace roots', + code: 'outside_workspace_roots' + }; } const yolo = options.yolo === true; const sessionType = options.sessionType ?? 'simple'; @@ -547,6 +567,17 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): } } + // Re-check after mkdir/access so a newly materialized path or concurrent + // symlink swap cannot escape the roots checked by the machine RPC layer. + if (options.validateDirectory && !(await options.validateDirectory(directory))) { + logger.debug(`[RUNNER RUN] Workspace directory escaped roots during validation: ${directory}`); + return { + type: 'error', + errorMessage: 'Directory is outside this machine\'s workspace roots', + code: 'outside_workspace_roots' + }; + } + if (sessionType === 'worktree') { // Cursor Agent has native `--worktree` under ~/.cursor/worktrees/. Prefer that // over HAPI's sibling-directory worktree so Cursor sandbox/skills see the same layout. @@ -1159,7 +1190,7 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): // regardless of the verbose/quiet logger setting. console.log(''); console.log('Hapi runner started.'); - console.log(` Workspace roots: ${workspaceRoots?.join(', ') ?? '(not set — browse disabled; pass --workspace-root to enable)'}`); + console.log(` Workspace roots: ${workspaceRoots?.join(', ') ?? '(not set — browsing is limited to home)'}`); console.log(` Hub URL: ${configuration.apiUrl}`); console.log(` Machine ID: ${machine.id}`); console.log(` Control port: ${controlPort}`); diff --git a/docs/api/client-contract/errors.md b/docs/api/client-contract/errors.md index 49d738a1..2f3c606e 100644 --- a/docs/api/client-contract/errors.md +++ b/docs/api/client-contract/errors.md @@ -34,6 +34,7 @@ When no `code` is present, branch on status alone and treat the failure generica | 409 | `scratchlist_attachment_in_use` | `sessions.ts` attachment delete while still referenced | Detach from entry first | | 409 | `resume_unavailable` | `sessions.ts` resume/reopen result mapping | Session can't be resumed (e.g. unsupported state) | | 409 | `metadata_conflict` | `sessions.ts` reopen result mapping | Refetch session, retry once at most | +| 409 | `runner_upgrade_required` | `machines.ts` Agent availability | Upgrade and restart the runner; disable session creation | | 409 | — (version conflict) | `sessions.ts` PATCH rename/summary, `machines.ts` PATCH rename — message mentions `version`/`concurrently`; **no code** | Concurrent edit — refetch and reapply | | 409 | — | `sessions.ts` delete-while-active, archive of plain inactive row, fork/rewind refusals, remote-only config on terminal-controlled sessions (`controlledByUser`) | Surface message; refresh session state | | 413 | — | `sessions.ts` upload (> 50 MB decoded), export too large (`{error, count, limit}`); `voice.ts` transcription (`Audio file too large`, 25 MB audio / ~26 MB body) | Reduce payload | @@ -49,7 +50,7 @@ When no `code` is present, branch on status alone and treat the failure generica ## RPC-wrapped endpoints -Many endpoints do not answer from hub state — the hub relays the request over Socket.IO to the session's CLI process (or the machine's runner) and forwards the result: git/file/directory/search, generated images, uploads, model catalogs, slash-commands, skills, spawn, list-directory, paths/exists. (The mode/model/effort config endpoints are RPC-backed too, but map apply-failures to 409 with a message.) Their failure modes differ from plain endpoints: +Many endpoints do not answer from hub state — the hub relays the request over Socket.IO to the session's CLI process (or the machine's runner) and forwards the result: git/file/directory/search, generated images, uploads, model catalogs, Agent availability, slash-commands, skills, spawn, list-directory, paths/exists. (The mode/model/effort config endpoints are RPC-backed too, but map apply-failures to 409 with a message.) Their failure modes differ from plain endpoints: 1. **CLI reachable, command failed** → HTTP **200** with `{success: false, error}` (e.g. `runRpc` in `hub/src/web/routes/git.ts` catches RPC errors, including the 30 s RPC timeout, and returns them as a JSON envelope). Clients must check the `success` field on every RPC-shaped response; HTTP 200 alone means nothing. 2. **CLI offline / handler missing** → depends on the route: the model-catalog routes in `machines.ts` map `RpcTargetMissingError` to **503 `rpc_target_missing`**; `git.ts`-style routes fold it into the 200 `{success: false}` envelope; resume/reopen surface **503 `no_machine_online`**. diff --git a/docs/api/client-contract/rest.md b/docs/api/client-contract/rest.md index 7810e450..05beefab 100644 --- a/docs/api/client-contract/rest.md +++ b/docs/api/client-contract/rest.md @@ -116,12 +116,21 @@ Source: `hub/src/web/routes/machines.ts`; schemas `SpawnSessionRequestSchema`, ` |---|---|---| | `GET /api/machines` | — | `{machines: Machine[]}` (online machines in the caller's namespace) | | `PATCH /api/machines/:id` | `{displayName}` (trimmed; ≤ 64 chars; empty clears back to hostname) | `{ok: true}` | -| `POST /api/machines/:id/spawn` | `{directory, agent?, model?, effort?, modelReasoningEffort?, yolo?, permissionMode?, sessionType?: 'simple'\|'worktree', worktreeName?, serviceTier?, collaborationMode?, copilotAgentMode?, startingMode?: 'remote'\|'pty'}` | `{type: 'success', sessionId}` \| `{type: 'error', message}` (agy accepts only `remote`) | +| `GET /api/machines/:id/agent-availability` | — | `{agents: {agent, available, reason?: 'not_found'\|'invalid_configuration'}[]}`; 409 `runner_upgrade_required` on old runners | +| `POST /api/machines/:id/spawn` | `{directory, agent?, model?, effort?, modelReasoningEffort?, yolo?, permissionMode?, sessionType?: 'simple'\|'worktree', worktreeName?, serviceTier?, collaborationMode?, copilotAgentMode?, startingMode?: 'remote'\|'pty'}` | `{type: 'success', sessionId}` \| `{type: 'error', message, code?, agent?}` (agy accepts only `remote`) | | `POST /api/machines/:id/list-directory` | `{path, includeHidden?}` | `{success, entries?: (DirectoryEntry & {isGitRepo?})[], error?}` | -| `POST /api/machines/:id/paths/exists` | `{paths: string[]}` (≤ 1000) | `{exists: Record}` | +| `POST /api/machines/:id/paths/exists` | `{paths: string[]}` (≤ 1000) | `{exists: Record, outsideWorkspaceRoots?: string[]}` | | `POST /api/machines/:id/restart-runner` | `{}` | `{message}`; errors carry `code: 'machine_not_found' \| 'machine_offline'` | -Note the spawn response is discriminated on `type`, not HTTP status — a failed spawn is still HTTP 200. +Note the spawn response is discriminated on `type`, not HTTP status — a failed +spawn is still HTTP 200. Stable spawn failure codes are +`agent_unavailable`, `runner_upgrade_required`, and +`outside_workspace_roots`. Clients should fetch Agent availability when the +machine is selected and use that result to drive the form. The runner performs +the authoritative availability check as part of spawning, covering changes +after the form-level query without requiring a duplicate client RPC. +Availability checks executables and static runner configuration only; it does +not execute the Agent or verify account/login state. ### Git & files (RPC-wrapped) diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 5185a67a..1e805c27 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -338,6 +338,11 @@ Use `--workspace-root ` to restrict which directories the runner can brows hapi runner start --workspace-root ~/projects --workspace-root ~/work ``` +Without `--workspace-root`, manually entered spawn paths remain unrestricted. +Session directory autocomplete and native pickers browse only beneath the +runner's home directory; configuring roots makes both browsing and spawning +use those roots instead. + For running the hub and runner as persistent background services (pm2, launchd, systemd), see [Deployment](./deployment.md). Supervised installs should set `HAPI_RUNNER_SUPERVISED=1` on the runner process (systemd `Environment=` / pm2 `--env`) so the web **Restart** control can safely stop-runner knowing the supervisor will cold-start it. ### Multi-machine hubs diff --git a/hub/README.md b/hub/README.md index 6c82b5ea..3e293aaf 100644 --- a/hub/README.md +++ b/hub/README.md @@ -116,7 +116,9 @@ See `src/web/routes/` for all endpoints. ### Machines (`src/web/routes/machines.ts`) - `GET /api/machines` - List online machines. +- `GET /api/machines/:id/agent-availability` - List installed/configured Agents. - `POST /api/machines/:id/spawn` - Spawn new session on machine. +- `POST /api/machines/:id/list-directory` - Browse runner-scoped directories. - `POST /api/machines/:id/paths/exists` - Check if path exists. ### Usage (`src/web/routes/usage.ts`) diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 656da1dd..b749647f 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -2,12 +2,14 @@ import type { AgentFlavor, CodexCollaborationMode, CopilotAgentMode, PermissionM import { RPC_METHODS } from '@hapi/protocol/rpcMethods' import { ArchiveCodexSessionRpcResponseSchema, + AgentAvailabilityResponseSchema, CursorChatStoreStatusSchema, ListCodexSessionsRpcResponseSchema, ListPiSessionsRpcResponseSchema } from '@hapi/protocol/apiTypes' import type { AgyModelsResponse, + AgentAvailabilityResponse, CodexModelSummary, CodexModelsResponse, CommandResponse, @@ -186,7 +188,15 @@ export class RpcGateway { // CLI with `--hapi-session-id`, so the child reuses the existing hub // session row (same id) instead of minting a new one. forkSession?: boolean - ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { + ): Promise< + | { type: 'success'; sessionId: string } + | { + type: 'error' + message: string + code?: 'agent_unavailable' | 'outside_workspace_roots' + agent?: AgentFlavor + } + > { try { const result = await this.machineRpc( machineId, @@ -218,7 +228,16 @@ export class RpcGateway { return { type: 'success', sessionId: obj.sessionId } } if (obj.type === 'error' && typeof obj.errorMessage === 'string') { - return { type: 'error', message: obj.errorMessage } + const code = obj.code === 'agent_unavailable' || obj.code === 'outside_workspace_roots' + ? obj.code + : undefined + const unavailableAgent = typeof obj.agent === 'string' ? obj.agent as AgentFlavor : undefined + return { + type: 'error', + message: obj.errorMessage, + ...(code ? { code } : {}), + ...(unavailableAgent ? { agent: unavailableAgent } : {}), + } } if (obj.type === 'requestToApproveDirectoryCreation' && typeof obj.directory === 'string') { return { type: 'error', message: `Directory creation requires approval: ${obj.directory}` } @@ -253,7 +272,12 @@ export class RpcGateway { return result as RpcListDirectoryResponse } - async checkPathsExist(machineId: string, paths: string[]): Promise> { + async getAgentAvailability(machineId: string): Promise { + const result = await this.machineRpc(machineId, RPC_METHODS.AgentAvailability, {}) + return AgentAvailabilityResponseSchema.parse(result) + } + + async checkPathsExist(machineId: string, paths: string[]): Promise { const result = await this.machineRpc(machineId, RPC_METHODS.PathExists, { paths }) as RpcPathExistsResponse | unknown if (!result || typeof result !== 'object') { throw new Error('Unexpected path-exists result') @@ -268,7 +292,13 @@ export class RpcGateway { for (const [key, value] of Object.entries(existsValue)) { exists[key] = value === true } - return exists + const outsideWorkspaceRoots = Array.isArray((result as RpcPathExistsResponse).outsideWorkspaceRoots) + ? (result as RpcPathExistsResponse).outsideWorkspaceRoots?.filter((path): path is string => typeof path === 'string') + : undefined + return { + exists, + ...(outsideWorkspaceRoots?.length ? { outsideWorkspaceRoots } : {}), + } } async getCursorChatStoreStatus( diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 2bf0987e..baaf403a 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -1945,7 +1945,7 @@ export class SyncEngine { collaborationMode?: CodexCollaborationMode, copilotAgentMode?: CopilotAgentMode, startingMode?: 'remote' | 'pty' - ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { + ): ReturnType { return await this.rpcGateway.spawnSession( machineId, directory, @@ -3839,10 +3839,14 @@ export class SyncEngine { return false } - async checkPathsExist(machineId: string, paths: string[]): Promise> { + async checkPathsExist(machineId: string, paths: string[]): ReturnType { return await this.rpcGateway.checkPathsExist(machineId, paths) } + async getAgentAvailability(machineId: string): ReturnType { + return await this.rpcGateway.getAgentAvailability(machineId) + } + async listMachineDirectory(machineId: string, path: string, includeHidden?: boolean): Promise { return await this.rpcGateway.listMachineDirectory(machineId, path, includeHidden) } diff --git a/hub/src/web/routes/machines.test.ts b/hub/src/web/routes/machines.test.ts index 1b0c06cf..3795fc76 100644 --- a/hub/src/web/routes/machines.test.ts +++ b/hub/src/web/routes/machines.test.ts @@ -4,6 +4,7 @@ import type { Machine, SyncEngine } from '../../sync/syncEngine' import type { WebAppEnv } from '../middleware/auth' import { createMachinesRoutes } from './machines' import { RpcTargetMissingError } from '../../sync/rpcGateway' +import { MACHINE_CAPABILITIES } from '@hapi/protocol' function createMachine(overrides?: Partial): Machine { return { @@ -17,7 +18,8 @@ function createMachine(overrides?: Partial): Machine { metadata: { host: 'localhost', platform: 'darwin', - happyCliVersion: '1.0.0' + happyCliVersion: '1.0.0', + capabilities: [MACHINE_CAPABILITIES.AgentAvailability] }, metadataVersion: 1, runnerState: null, @@ -27,6 +29,86 @@ function createMachine(overrides?: Partial): Machine { } describe('machines routes', () => { + it('blocks spawn and availability inspection when the runner needs an upgrade', async () => { + const machine = createMachine({ + metadata: { + host: 'localhost', + platform: 'darwin', + happyCliVersion: '0.9.0', + capabilities: [] + } + }) + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + spawnSession: () => { throw new Error('must not spawn') }, + getAgentAvailability: () => { throw new Error('must not inspect') }, + } as unknown as Partial + const app = new Hono() + app.use('*', async (c, next) => { c.set('namespace', 'default'); await next() }) + app.route('/api', createMachinesRoutes(() => engine as SyncEngine)) + + const spawn = await app.request('/api/machines/machine-1/spawn', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ directory: '/tmp/project', agent: 'claude' }) + }) + expect(spawn.status).toBe(200) + expect(await spawn.json()).toEqual({ + type: 'error', + message: 'This runner must be upgraded before creating sessions', + code: 'runner_upgrade_required' + }) + + const availability = await app.request('/api/machines/machine-1/agent-availability') + expect(availability.status).toBe(409) + expect(await availability.json()).toEqual({ + error: 'This runner must be upgraded before creating sessions', + code: 'runner_upgrade_required' + }) + }) + + it('returns Agent availability and complete path boundary results', async () => { + const machine = createMachine() + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + getAgentAvailability: async () => ({ + agents: [ + { agent: 'claude' as const, available: false, reason: 'not_found' as const }, + { agent: 'codex' as const, available: true } + ] + }), + checkPathsExist: async () => ({ + exists: { '/workspace': true, '/outside': false }, + outsideWorkspaceRoots: ['/outside'] + }) + } as Partial + const app = new Hono() + app.use('*', async (c, next) => { c.set('namespace', 'default'); await next() }) + app.route('/api', createMachinesRoutes(() => engine as SyncEngine)) + + const availability = await app.request('/api/machines/machine-1/agent-availability') + expect(availability.status).toBe(200) + expect(await availability.json()).toEqual({ + agents: [ + { agent: 'claude', available: false, reason: 'not_found' }, + { agent: 'codex', available: true } + ] + }) + + const paths = await app.request('/api/machines/machine-1/paths/exists', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ paths: ['/workspace', '/outside'] }) + }) + expect(paths.status).toBe(200) + expect(await paths.json()).toEqual({ + exists: { '/workspace': true, '/outside': false }, + outsideWorkspaceRoots: ['/outside'] + }) + }) + it('forwards Grok Auto permission mode when spawning', async () => { const machine = createMachine() let capturedPermissionMode: string | undefined diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index 205ef896..c7f55186 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -1,5 +1,6 @@ import { MACHINE_DISPLAY_NAME_MAX_LENGTH, + MACHINE_CAPABILITIES, MachineListDirectoryRequestSchema, MachinePathsExistsRequestSchema, RenameMachineRequestSchema, @@ -75,6 +76,13 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho if (machine instanceof Response) { return machine } + if (!machine.metadata?.capabilities?.includes(MACHINE_CAPABILITIES.AgentAvailability)) { + return c.json({ + type: 'error' as const, + message: 'This runner must be upgraded before creating sessions', + code: 'runner_upgrade_required' as const, + }) + } const body = await c.req.json().catch(() => null) const parsed = SpawnSessionRequestSchema.safeParse(body) @@ -111,6 +119,29 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json(result) }) + app.get('/machines/:id/agent-availability', async (c) => { + const engine = getSyncEngine() + if (!engine) return c.json({ error: 'Not connected' }, 503) + + const machineId = c.req.param('id') + const machine = requireMachine(c, engine, machineId) + if (machine instanceof Response) return machine + if (!machine.metadata?.capabilities?.includes(MACHINE_CAPABILITIES.AgentAvailability)) { + return c.json({ + error: 'This runner must be upgraded before creating sessions', + code: 'runner_upgrade_required', + }, 409) + } + + try { + return c.json(await engine.getAgentAvailability(machineId)) + } catch (error) { + return c.json({ + error: error instanceof Error ? error.message : 'Failed to inspect Agent availability', + }, 500) + } + }) + app.post('/machines/:id/list-directory', async (c) => { const engine = getSyncEngine() if (!engine) { @@ -161,8 +192,7 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho } try { - const exists = await engine.checkPathsExist(machineId, uniquePaths) - return c.json({ exists }) + return c.json(await engine.checkPathsExist(machineId, uniquePaths)) } catch (error) { return c.json({ error: error instanceof Error ? error.message : 'Failed to check paths' }, 500) } diff --git a/ios/Hapi/Features/DirectoryBrowser/RemoteDirectoryBrowserView.swift b/ios/Hapi/Features/DirectoryBrowser/RemoteDirectoryBrowserView.swift new file mode 100644 index 00000000..6da25ff4 --- /dev/null +++ b/ios/Hapi/Features/DirectoryBrowser/RemoteDirectoryBrowserView.swift @@ -0,0 +1,128 @@ +import HapiClient +import SwiftUI + +/// Reusable SwiftUI presentation for ``RemoteDirectoryBrowserModel``. +struct RemoteDirectoryBrowserView: View { + @Bindable var model: RemoteDirectoryBrowserModel + let onSelect: (String) -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + if model.roots.count > 1 { + Section("Workspace Roots") { + ForEach(model.roots, id: \.self) { root in + Button { + model.navigate(to: root) + } label: { + Label(root, systemImage: "externaldrive") + .lineLimit(1) + .truncationMode(.middle) + } + } + } + } + + Section("Current Directory") { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + ForEach(model.breadcrumbs) { breadcrumb in + Button(breadcrumb.label) { + model.navigate(to: breadcrumb.path) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + Text(model.path) + .font(.system(.footnote, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(2) + .truncationMode(.middle) + Toggle( + "Show Hidden", + isOn: Binding( + get: { model.includeHidden }, + set: { model.setIncludeHidden($0) } + ) + ) + } + + Section("Subdirectories") { + if model.isLoading { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if let error = model.error { + VStack(alignment: .leading, spacing: 8) { + Text(error).foregroundStyle(.red) + Button("Retry") { + model.refresh() + } + } + } else if model.entries.isEmpty { + Text("No subdirectories") + .foregroundStyle(.secondary) + } else { + ForEach(model.entries, id: \.name) { entry in + Button { + model.navigateEntry(entry.name) + } label: { + HStack { + Image(systemName: "folder") + Text(entry.name) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } + } + } + } + .navigationTitle("Choose Directory") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + model.close() + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Select") { + let path = model.path + onSelect(path) + model.close() + dismiss() + } + .disabled(model.isLoading || model.error != nil) + } + ToolbarItemGroup(placement: .bottomBar) { + Button { + model.navigateUp() + } label: { + Label("Up", systemImage: "arrow.up") + } + .disabled(!model.canGoUp || model.isLoading) + + Spacer() + + Button { + model.refresh() + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .disabled(model.isLoading) + } + } + } + } +} diff --git a/ios/Hapi/Features/NewSession/NewSessionModel.swift b/ios/Hapi/Features/NewSession/NewSessionModel.swift index a72902b9..cb515671 100644 --- a/ios/Hapi/Features/NewSession/NewSessionModel.swift +++ b/ios/Hapi/Features/NewSession/NewSessionModel.swift @@ -40,6 +40,13 @@ enum CodexModelsState: Equatable { case failed(String) } +/// Installed/static-configured Agent catalog for the selected machine. +enum AgentAvailabilityState: Equatable { + case loading + case loaded([AgentAvailabilityEntry]) + case failed(message: String, upgradeRequired: Bool) +} + // MARK: - Persistence /// Create-form persistence: last-used machine + per-machine recent paths @@ -119,19 +126,33 @@ final class NewSessionModel { String(localized: "Directory does not exist. Creating the session will create it automatically.") static let msgDirectoryMissingConfirm = String(localized: "Directory does not exist. Tap Create again to create it automatically.") + static let msgDirectoryOutsideWorkspaceRoots = + String(localized: "Directory must be inside one of this machine's workspace roots.") + static let msgDirectoryLookupFailed = String(localized: "Failed to browse directories") + static let msgAgentAvailabilityFailed = String(localized: "Failed to check installed Agents") + static let msgRunnerUpgradeRequired = + String(localized: "Upgrade and restart this machine's HAPI runner before creating sessions.") + static let msgNoAvailableAgents = + String(localized: "No supported Agents are installed on this machine.") + static let msgSelectedAgentUnavailable = + String(localized: "The selected Agent is not available on this machine.") // MARK: Observable state private(set) var form = NewSessionForm() private(set) var suggestions: [String] = [] private(set) var codexModels: CodexModelsState = .hidden + private(set) var agentAvailability: AgentAvailabilityState = .loading private(set) var isSpawning = false private(set) var spawnError: String? private(set) var confirmCreateDirectoryArmed = false private(set) var machinesSettled = false /// Probed existence per trimmed path (feeds the directory status hint). private(set) var pathExistence: [String: Bool] = [:] + private(set) var outsideWorkspaceRoots: Set = [] + private(set) var directoryLookupError: String? private(set) var prefsData = NewSessionPrefsData() + let directoryBrowser: RemoteDirectoryBrowserModel // MARK: Wiring @@ -142,9 +163,13 @@ final class NewSessionModel { @ObservationIgnored private var directoryTask: Task? @ObservationIgnored private var codexTask: Task? + @ObservationIgnored private var availabilityTask: Task? + @ObservationIgnored private var defaultDirectoryTask: Task? @ObservationIgnored private var codexFetchedForMachine: String? + @ObservationIgnored private var availabilityFetchedForMachine: String? @ObservationIgnored private var suppressSuggestions = false @ObservationIgnored private var spawnInFlight = false + @ObservationIgnored private var directoryRequestVersion = 0 /// Parent-listing cache: retyping within the same parent re-filters /// locally instead of re-requesting. @ObservationIgnored private var cachedListing: @@ -154,6 +179,10 @@ final class NewSessionModel { self.session = session self.prefsStore = NewSessionPrefsStore(hubUrl: session.hubUrl) self.onCreated = onCreated + self.directoryBrowser = RemoteDirectoryBrowserModel( + requester: session.api, + fallbackError: Self.msgDirectoryLookupFailed + ) } // MARK: - Lifecycle (paired with the sheet's `.task`) @@ -162,12 +191,10 @@ final class NewSessionModel { /// roster. Call once per presentation. func start() async { prefsData = prefsStore.readPrefs() - var initial = prefsStore.readDraft().map(NewSessionLogic.sanitizeDraft) ?? NewSessionForm() - if initial.machineId != nil, initial.trimmedDirectory.isEmpty { - initial.directory = recentPaths(for: initial.machineId).first ?? "" - } + let initial = prefsStore.readDraft().map(NewSessionLogic.sanitizeDraft) ?? NewSessionForm() form = initial reconcileMachineSelection() + refreshAgentAvailability() refreshCodexModelsIfNeeded() // A restored directory should probe existence but not pop the // autocomplete dropdown — suggestions belong to typing. @@ -210,6 +237,9 @@ final class NewSessionModel { } var directoryStatus: DirectoryStatusUI? { + if directoryOutsideWorkspaceRoots { + return DirectoryStatusUI(message: Self.msgDirectoryOutsideWorkspaceRoots, isError: true) + } if missingWorktreeDirectory { return DirectoryStatusUI(message: Self.msgWorktreeMissing, isError: true) } @@ -221,16 +251,34 @@ final class NewSessionModel { isError: false ) } + if let directoryLookupError { + return DirectoryStatusUI(message: directoryLookupError, isError: true) + } return nil } - /// Creatable flavors (`CREATABLE_AGENT_FLAVORS`), labeled. + /// Installed creatable flavors, in runner catalog order. var agents: [NewSessionOption] { - AgentFlavor.creatableFlavors.map { + availableAgentFlavors.map { NewSessionOption(value: $0.rawValue, label: $0.displayLabel) } } + var agentAvailabilityLoading: Bool { + agentAvailability == .loading + } + + var agentAvailabilityError: String? { + switch agentAvailability { + case .loading: + return nil + case .failed(let message, _): + return message + case .loaded: + return availableAgentFlavors.isEmpty ? Self.msgNoAvailableAgents : nil + } + } + /// Nil hides the model picker (v1: only claude + supported codex). var modelOptions: [NewSessionOption]? { switch (form.agent, codexModels) { @@ -316,6 +364,8 @@ final class NewSessionModel { && !form.trimmedDirectory.isEmpty && !isSpawning && !missingWorktreeDirectory + && !directoryOutsideWorkspaceRoots + && selectedAgentAvailable && worktreeNameError == nil && !codexValidationPending } @@ -330,12 +380,29 @@ final class NewSessionModel { return pathExistence[trimmed] } + private var directoryOutsideWorkspaceRoots: Bool { + let trimmed = form.trimmedDirectory + return !trimmed.isEmpty && outsideWorkspaceRoots.contains(trimmed) + } + private var missingWorktreeDirectory: Bool { - form.sessionType == .worktree && directoryExists == false + !directoryOutsideWorkspaceRoots && form.sessionType == .worktree && directoryExists == false } private var needsCreationWarning: Bool { - form.sessionType == .simple && directoryExists == false + !directoryOutsideWorkspaceRoots && form.sessionType == .simple && directoryExists == false + } + + private var availableAgentFlavors: [AgentFlavor] { + guard case .loaded(let entries) = agentAvailability else { return [] } + return entries.compactMap { entry in + guard entry.available, AgentFlavor.creatableFlavors.contains(entry.agent) else { return nil } + return entry.agent + } + } + + private var selectedAgentAvailable: Bool { + availableAgentFlavors.contains(form.agent) } /// Web `isLaunchPreferenceValidationPending` (codex slice): a restored @@ -370,6 +437,21 @@ final class NewSessionModel { pickPath(path) } + func openDirectoryBrowser() { + guard let machine = selectedMachine else { return } + directoryBrowser.open( + machineId: machine.id, + roots: RemoteDirectoryPath.browseRoots(for: machine), + initialPath: form.trimmedDirectory + ) + } + + func selectBrowsedDirectory(_ path: String) { + if !path.isEmpty { + pickPath(path) + } + } + func setAgent(_ agent: AgentFlavor) { guard agent != form.agent else { return } // Web parity: switching agents resets every agent-dependent field @@ -445,6 +527,10 @@ final class NewSessionModel { refreshCodexModelsIfNeeded() } + func retryAgentAvailability() { + refreshAgentAvailability(force: true) + } + /// Spawn. Directory existence is re-checked server-side first (web /// `handleCreate`): a missing worktree base is an error; a missing /// simple directory arms a second-tap confirmation, after which the hub @@ -459,6 +545,10 @@ final class NewSessionModel { NewSessionLogic.worktreeNameError(current.worktreeName) != nil { return } + guard selectedAgentAvailable else { + spawnError = agentAvailabilityError ?? Self.msgSelectedAgentUnavailable + return + } spawnInFlight = true isSpawning = true spawnError = nil @@ -469,33 +559,48 @@ final class NewSessionModel { } guard let self else { return } let api = self.session.api - let exists = (try? await api.machinePathsExist(machineId: machineId, paths: [directory]))?[directory] - if let exists { - self.pathExistence[directory] = exists - } - if current.sessionType == .worktree, exists == false { - self.spawnError = Self.msgWorktreeMissing - return - } - if current.sessionType == .simple, exists == false, !self.confirmCreateDirectoryArmed { - self.confirmCreateDirectoryArmed = true - return - } - - let request = NewSessionLogic.buildSpawnRequest( - form: current, - codexFastTierVisible: self.codexFastTierVisible(current) - ) do { + let pathResult = try await api.machinePathsExist( + machineId: machineId, + paths: [directory] + ) + let exists = pathResult.exists[directory] + if let exists { + self.pathExistence[directory] = exists + } + self.outsideWorkspaceRoots.remove(directory) + self.outsideWorkspaceRoots.formUnion(pathResult.outsideWorkspaceRoots ?? []) + if pathResult.outsideWorkspaceRoots?.contains(directory) == true { + self.spawnError = Self.msgDirectoryOutsideWorkspaceRoots + return + } + if current.sessionType == .worktree, exists == false { + self.spawnError = Self.msgWorktreeMissing + return + } + if current.sessionType == .simple, + exists == false, + !self.confirmCreateDirectoryArmed { + self.confirmCreateDirectoryArmed = true + return + } + + let request = NewSessionLogic.buildSpawnRequest( + form: current, + codexFastTierVisible: self.codexFastTierVisible(current) + ) switch try await api.spawnSession(machineId: machineId, request) { case .success(let sessionId): self.persistOnSuccess(machineId: machineId, directory: directory) self.onCreated(sessionId) - case .error(let message): - self.spawnError = message.isEmpty - ? String(localized: "Failed to create session") - : message + case .error(let message, let code, _): + self.spawnError = Self.spawnErrorMessage(code: code, fallback: message) } + } catch let error as APIError { + self.spawnError = Self.spawnErrorMessage( + code: error.code, + fallback: error.errorDescription + ) } catch { self.spawnError = (error as? LocalizedError)?.errorDescription ?? String(localized: "Failed to create session") @@ -522,6 +627,9 @@ final class NewSessionModel { let machines = session.machineStore.machines guard !machines.isEmpty else { return } if let current = form.machineId, machines.contains(where: { $0.id == current }) { + if form.trimmedDirectory.isEmpty { + applyMachineSelection(current, resetDirectory: true) + } return } let target = machines.first { $0.id == prefsData.lastMachineId } ?? machines[0] @@ -529,7 +637,11 @@ final class NewSessionModel { } private func applyMachineSelection(_ machineId: String, resetDirectory: Bool) { + directoryBrowser.close() + defaultDirectoryTask?.cancel() pathExistence = [:] + outsideWorkspaceRoots = [] + directoryLookupError = nil suggestions = [] cachedListing = nil confirmCreateDirectoryArmed = false @@ -538,17 +650,56 @@ final class NewSessionModel { form.machineId = machineId form.model = "auto" if resetDirectory { - form.directory = recentPaths(for: machineId).first ?? "" + let machine = session.machineStore.machines.first { $0.id == machineId } + form.directory = machine.flatMap { RemoteDirectoryPath.browseRoots(for: $0).first } ?? "" } persistDraft() + if resetDirectory { + resolveDefaultDirectory(machineId: machineId, fallback: form.directory) + } + refreshAgentAvailability(force: true) refreshCodexModelsIfNeeded() scheduleDirectoryWork() } + private func resolveDefaultDirectory(machineId: String, fallback: String) { + defaultDirectoryTask?.cancel() + let recent = recentPaths(for: machineId) + guard !recent.isEmpty else { return } + defaultDirectoryTask = Task { [weak self] in + guard let self else { return } + let result: MachinePathsExistsResponse + do { + result = try await self.session.api.machinePathsExist( + machineId: machineId, + paths: recent + ) + } catch is CancellationError { + return + } catch { + return + } + guard !Task.isCancelled else { return } + let outside = Set(result.outsideWorkspaceRoots ?? []) + guard let valid = recent.first(where: { + result.exists[$0] == true && !outside.contains($0) + }) else { + return + } + guard self.form.machineId == machineId, self.form.directory == fallback else { return } + self.suppressSuggestions = true + self.form.directory = valid + self.persistDraft() + self.scheduleDirectoryWork() + } + } + /// Debounced directory work: parent listing for autocomplete + exists /// probe, both riding one 250 ms debounce like the Android reference. private func scheduleDirectoryWork() { directoryTask?.cancel() + directoryRequestVersion += 1 + let requestVersion = directoryRequestVersion guard let machineId = form.machineId else { suggestions = [] return @@ -556,11 +707,16 @@ final class NewSessionModel { directoryTask = Task { [weak self] in try? await Task.sleep(for: Self.debounce) guard !Task.isCancelled, let self else { return } - await self.performDirectoryWork(machineId: machineId) + guard self.directoryRequestVersion == requestVersion, + self.form.machineId == machineId + else { + return + } + await self.performDirectoryWork(machineId: machineId, requestVersion: requestVersion) } } - private func performDirectoryWork(machineId: String) async { + private func performDirectoryWork(machineId: String, requestVersion: Int) async { let text = form.directory let trimmed = form.trimmedDirectory let api = session.api @@ -571,36 +727,109 @@ final class NewSessionModel { let entries: [MachineDirectoryEntry] if let cached = cachedListing, (cached.machineId, cached.parent) == cacheKey { entries = cached.entries + directoryLookupError = nil } else { - let response = try? await api.listMachineDirectory( - machineId: machineId, - path: query.parent - ) - guard !Task.isCancelled else { return } - if let response, response.success { - entries = response.entries ?? [] - cachedListing = (machineId, query.parent, entries) - } else { + do { + let response = try await api.listMachineDirectory( + machineId: machineId, + path: query.parent + ) + guard isCurrentDirectoryRequest(requestVersion, machineId: machineId) else { return } + if response.success { + entries = response.entries ?? [] + cachedListing = (machineId, query.parent, entries) + directoryLookupError = nil + } else { + entries = [] + directoryLookupError = response.error ?? Self.msgDirectoryLookupFailed + } + } catch is CancellationError { + return + } catch { + guard isCurrentDirectoryRequest(requestVersion, machineId: machineId) else { return } + directoryLookupError = (error as? LocalizedError)?.errorDescription + ?? Self.msgDirectoryLookupFailed entries = [] } } + guard isCurrentDirectoryRequest(requestVersion, machineId: machineId) else { return } // Never suggest the path already typed verbatim. suggestions = NewSessionLogic.buildSuggestions(query: query, entries: entries) .filter { $0 != trimmed } } else { suggestions = [] + directoryLookupError = nil } if !trimmed.isEmpty { // Unknown existence (request failed): no status hint, the spawn // re-checks anyway. - if let result = try? await api.machinePathsExist(machineId: machineId, paths: [trimmed]) { - guard !Task.isCancelled else { return } - pathExistence.merge(result) { _, new in new } + do { + let result = try await api.machinePathsExist(machineId: machineId, paths: [trimmed]) + guard isCurrentDirectoryRequest(requestVersion, machineId: machineId) else { return } + pathExistence.merge(result.exists) { _, new in new } + outsideWorkspaceRoots.remove(trimmed) + outsideWorkspaceRoots.formUnion(result.outsideWorkspaceRoots ?? []) + } catch is CancellationError { + return + } catch { + // Unknown existence: no status hint, spawn re-checks anyway. } } } + private func isCurrentDirectoryRequest(_ requestVersion: Int, machineId: String) -> Bool { + !Task.isCancelled + && directoryRequestVersion == requestVersion + && form.machineId == machineId + } + + private func refreshAgentAvailability(force: Bool = false) { + guard let machineId = form.machineId else { + availabilityTask?.cancel() + availabilityFetchedForMachine = nil + agentAvailability = .loading + return + } + if !force, + availabilityFetchedForMachine == machineId, + agentAvailability != .loading { + return + } + availabilityTask?.cancel() + availabilityFetchedForMachine = machineId + agentAvailability = .loading + availabilityTask = Task { [weak self] in + guard let self else { return } + let state: AgentAvailabilityState + do { + let response = try await self.session.api.machineAgentAvailability(machineId: machineId) + state = .loaded(response.agents) + } catch let error as APIError where error.code == "runner_upgrade_required" { + state = .failed(message: Self.msgRunnerUpgradeRequired, upgradeRequired: true) + } catch is CancellationError { + return + } catch is APIError { + state = .failed(message: Self.msgAgentAvailabilityFailed, upgradeRequired: false) + } catch { + state = .failed( + message: (error as? LocalizedError)?.errorDescription + ?? Self.msgAgentAvailabilityFailed, + upgradeRequired: false + ) + } + guard !Task.isCancelled, self.form.machineId == machineId else { return } + self.agentAvailability = state + guard case .loaded = state, + let firstAvailable = self.availableAgentFlavors.first, + !self.availableAgentFlavors.contains(self.form.agent) + else { + return + } + self.setAgent(firstAvailable) + } + } + private func refreshCodexModelsIfNeeded() { guard form.agent == .codex else { codexTask?.cancel() @@ -636,7 +865,12 @@ final class NewSessionModel { ?? String(localized: "Failed to load Codex models") ) } - guard !Task.isCancelled else { return } + guard !Task.isCancelled, + self.form.machineId == machineId, + self.form.agent == .codex + else { + return + } self.codexModels = state if case .loaded = state { // Reconcile restored selections with the live catalog (web @@ -685,6 +919,20 @@ final class NewSessionModel { prefsStore.clearDraft() } + private static func spawnErrorMessage(code: String?, fallback: String?) -> String { + switch code { + case "runner_upgrade_required": + return msgRunnerUpgradeRequired + case "agent_unavailable": + return msgSelectedAgentUnavailable + case "outside_workspace_roots": + return msgDirectoryOutsideWorkspaceRoots + default: + let trimmed = fallback?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? String(localized: "Failed to create session") : trimmed + } + } + // MARK: - Formatting (Android `NewSessionViewModel` companion ports) /// `getMachineOptionLabel` (web `MachineSelector`), minus capability-skew diff --git a/ios/Hapi/Features/NewSession/NewSessionView.swift b/ios/Hapi/Features/NewSession/NewSessionView.swift index dffac79f..f69b161a 100644 --- a/ios/Hapi/Features/NewSession/NewSessionView.swift +++ b/ios/Hapi/Features/NewSession/NewSessionView.swift @@ -43,6 +43,12 @@ struct NewSessionView: View { model.machinesChanged() } } + .sheet(isPresented: directoryBrowserPresented) { + RemoteDirectoryBrowserView( + model: model.directoryBrowser, + onSelect: model.selectBrowsedDirectory + ) + } } // MARK: - Machine @@ -90,13 +96,23 @@ struct NewSessionView: View { private var directorySection: some View { Section { - TextField( - "/path/to/project", - text: Binding(get: { model.form.directory }, set: { model.setDirectory($0) }) - ) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - .font(.system(.callout, design: .monospaced)) + HStack(spacing: 8) { + TextField( + "/path/to/project", + text: Binding(get: { model.form.directory }, set: { model.setDirectory($0) }) + ) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .font(.system(.callout, design: .monospaced)) + + Button { + model.openDirectoryBrowser() + } label: { + Image(systemName: "folder.badge.plus") + } + .buttonStyle(.borderless) + .accessibilityLabel("Browse") + } // Server-side autocomplete (list-directory on the parent path). ForEach(model.suggestions, id: \.self) { suggestion in @@ -138,6 +154,17 @@ struct NewSessionView: View { .disabled(model.isSpawning) } + private var directoryBrowserPresented: Binding { + Binding( + get: { model.directoryBrowser.isPresented }, + set: { presented in + if !presented { + model.directoryBrowser.close() + } + } + ) + } + // MARK: - Session type private var sessionTypeSection: some View { @@ -204,8 +231,32 @@ struct NewSessionView: View { } } } + .disabled( + model.isSpawning + || model.agentAvailabilityLoading + || model.agentAvailabilityError != nil + ) + if model.agentAvailabilityLoading { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Checking installed Agents…") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + if let error = model.agentAvailabilityError { + HStack(alignment: .firstTextBaseline) { + Text(error) + .font(.footnote) + .foregroundStyle(.red) + Spacer() + Button("Retry") { + model.retryAgentAvailability() + } + .font(.footnote) + } + } } - .disabled(model.isSpawning) } private var agentBinding: Binding { diff --git a/ios/Hapi/Resources/Localizable.xcstrings b/ios/Hapi/Resources/Localizable.xcstrings index 676958b6..ce254285 100644 --- a/ios/Hapi/Resources/Localizable.xcstrings +++ b/ios/Hapi/Resources/Localizable.xcstrings @@ -4101,6 +4101,156 @@ } } }, + "Checking installed Agents…": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在检查已安装的代理…" + } + } + } + }, + "Choose Directory": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择目录" + } + } + } + }, + "Current Directory": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前目录" + } + } + } + }, + "Directory must be inside one of this machine's workspace roots.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "目录必须位于此机器配置的某个工作区根目录内。" + } + } + } + }, + "Failed to browse directories": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浏览目录失败" + } + } + } + }, + "Failed to check installed Agents": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检查已安装代理失败" + } + } + } + }, + "No subdirectories": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有子目录" + } + } + } + }, + "No supported Agents are installed on this machine.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此机器上没有安装受支持的代理。" + } + } + } + }, + "Select": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择" + } + } + } + }, + "Show Hidden": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示隐藏目录" + } + } + } + }, + "Subdirectories": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "子目录" + } + } + } + }, + "The selected Agent is not available on this machine.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "所选代理在此机器上不可用。" + } + } + } + }, + "Up": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上一级" + } + } + } + }, + "Upgrade and restart this machine's HAPI runner before creating sessions.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "创建会话前,请升级并重启此机器上的 HAPI runner。" + } + } + } + }, + "Workspace Roots": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "工作区根目录" + } + } + } + }, "✓ Approved": { "localizations": { "zh-Hans": { diff --git a/ios/Packages/HapiKit/Sources/HapiClient/DirectoryBrowser/RemoteDirectoryBrowserModel.swift b/ios/Packages/HapiKit/Sources/HapiClient/DirectoryBrowser/RemoteDirectoryBrowserModel.swift new file mode 100644 index 00000000..f638bc12 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiClient/DirectoryBrowser/RemoteDirectoryBrowserModel.swift @@ -0,0 +1,322 @@ +import Foundation +import HapiProtocol +import Observation + +public protocol MachineDirectoryRequesting: Sendable { + func listMachineDirectory( + machineId: String, + path: String, + includeHidden: Bool + ) async throws -> MachineListDirectoryResponse +} + +extension APIClient: MachineDirectoryRequesting {} + +public struct RemoteDirectoryBreadcrumb: Identifiable, Equatable, Sendable { + public let label: String + public let path: String + public var id: String { path } + + public init(label: String, path: String) { + self.label = label + self.path = path + } +} + +/** Pure remote-path operations shared by directory-browser consumers. */ +public enum RemoteDirectoryPath { + public static func browseRoots(for machine: Machine) -> [String] { + let workspaceRoots = unique( + machine.metadata?.workspaceRoots? + .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + ?? [] + ) + if !workspaceRoots.isEmpty { return workspaceRoots } + if let home = machine.metadata?.homeDir, + !home.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return [home] + } + return [] + } + + public static func join(parent: String, child: String) -> String { + let separator = parent.contains("\\") && !parent.contains("/") ? "\\" : "/" + return parent.hasSuffix("/") || parent.hasSuffix("\\") + ? parent + child + : parent + separator + child + } + + public static func parent(_ path: String) -> String? { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed != "/", !isDriveRoot(trimmed) else { return nil } + + var characters = Array(trimmed) + while characters.count > 1, let last = characters.last, isPathSeparator(last) { + if characters.count == 3, characters[1] == ":" { break } + characters.removeLast() + } + let withoutTrailing = String(characters) + let isUNC = withoutTrailing.hasPrefix("\\\\") || withoutTrailing.hasPrefix("//") + if isUNC { + let components = String(withoutTrailing.dropFirst(2)) + .split(whereSeparator: isPathSeparator) + if components.count <= 2 { return nil } + } + guard let separatorIndex = characters.lastIndex(where: isPathSeparator) else { return nil } + if separatorIndex == 0 { return String(characters.prefix(1)) } + if separatorIndex == 2, characters.count >= 2, characters[1] == ":" { + return String(characters.prefix(3)) + } + return String(characters[.. Bool { + func normalize(_ value: String) -> String { + var slashed = value + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "\\", with: "/") + while slashed.count > 1, slashed.hasSuffix("/"), !isDriveRoot(slashed) { + slashed.removeLast() + } + return slashed + } + + var normalizedPath = normalize(path) + var normalizedRoot = normalize(root) + guard !normalizedPath.isEmpty, !normalizedRoot.isEmpty else { return false } + let caseInsensitive = isDriveAbsolute(normalizedRoot) || normalizedRoot.hasPrefix("//") + if caseInsensitive { + normalizedPath = normalizedPath.lowercased() + normalizedRoot = normalizedRoot.lowercased() + } + let rootPrefix = normalizedRoot.hasSuffix("/") ? normalizedRoot : normalizedRoot + "/" + return normalizedPath == normalizedRoot || normalizedPath.hasPrefix(rootPrefix) + } + + private static func unique(_ paths: [String]) -> [String] { + var seen: Set = [] + return paths.filter { seen.insert($0).inserted } + } + + private static func isPathSeparator(_ character: Character) -> Bool { + character == "/" || character == "\\" + } + + private static func isDriveRoot(_ value: String) -> Bool { + let characters = Array(value) + return characters.count == 3 + && characters[0].isLetter + && characters[1] == ":" + && isPathSeparator(characters[2]) + } + + private static func isDriveAbsolute(_ value: String) -> Bool { + let characters = Array(value) + return characters.count >= 3 + && characters[0].isLetter + && characters[1] == ":" + && isPathSeparator(characters[2]) + } +} + +/** + * Reusable runner-backed directory navigation state machine. + * + * Consumers own presentation and selection handling. Navigation is lexically + * confined to ``roots``; the runner performs canonical symlink-aware checks. + */ +@MainActor @Observable +public final class RemoteDirectoryBrowserModel { + public private(set) var isPresented = false + public private(set) var path = "" + public private(set) var roots: [String] = [] + public private(set) var breadcrumbs: [RemoteDirectoryBreadcrumb] = [] + public private(set) var entries: [MachineDirectoryEntry] = [] + public private(set) var isLoading = false + public private(set) var error: String? + public private(set) var includeHidden = false + public private(set) var canGoUp = false + + private let requester: any MachineDirectoryRequesting + private let fallbackError: String + private var machineId: String? + @ObservationIgnored private var loadTask: Task? + @ObservationIgnored private var requestVersion = 0 + + public init( + requester: any MachineDirectoryRequesting, + fallbackError: String = "Failed to browse directories" + ) { + self.requester = requester + self.fallbackError = fallbackError + } + + public func open(machineId: String, roots: [String], initialPath: String? = nil) { + close() + let usableRoots = unique(roots.filter { + !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + }) + let selectedInitialPath = initialPath.flatMap { candidate in + usableRoots.contains { RemoteDirectoryPath.isWithinRoot(path: candidate, root: $0) } + ? candidate + : nil + } + guard let path = selectedInitialPath ?? usableRoots.first else { return } + self.machineId = machineId + self.isPresented = true + self.path = path + self.roots = usableRoots + load(path) + } + + public func close() { + loadTask?.cancel() + requestVersion += 1 + machineId = nil + isPresented = false + path = "" + roots = [] + breadcrumbs = [] + entries = [] + isLoading = false + error = nil + includeHidden = false + canGoUp = false + } + + public func navigate(to path: String) { + guard isPresented, + roots.contains(where: { RemoteDirectoryPath.isWithinRoot(path: path, root: $0) }) + else { + return + } + load(path) + } + + public func navigateEntry(_ name: String) { + navigate(to: RemoteDirectoryPath.join(parent: path, child: name)) + } + + public func navigateUp() { + guard let parent = RemoteDirectoryPath.parent(path), + roots.contains(where: { RemoteDirectoryPath.isWithinRoot(path: parent, root: $0) }) + else { + return + } + navigate(to: parent) + } + + public func refresh() { + load(path) + } + + public func setIncludeHidden(_ includeHidden: Bool) { + guard isPresented else { return } + self.includeHidden = includeHidden + load(path) + } + + private func load(_ path: String) { + guard let machineId, + isPresented, + roots.contains(where: { RemoteDirectoryPath.isWithinRoot(path: path, root: $0) }) + else { + return + } + loadTask?.cancel() + requestVersion += 1 + let currentRequest = requestVersion + let requestedIncludeHidden = includeHidden + let browseRoots = roots + self.path = path + entries = [] + isLoading = true + error = nil + breadcrumbs = makeBreadcrumbs(path: path, roots: browseRoots) + canGoUp = RemoteDirectoryPath.parent(path).map { parent in + browseRoots.contains { RemoteDirectoryPath.isWithinRoot(path: parent, root: $0) } + } ?? false + + loadTask = Task { [weak self] in + guard let self else { return } + let response: MachineListDirectoryResponse + do { + response = try await self.requester.listMachineDirectory( + machineId: machineId, + path: path, + includeHidden: requestedIncludeHidden + ) + } catch is CancellationError { + return + } catch { + guard self.isCurrent( + currentRequest, + machineId: machineId, + path: path, + includeHidden: requestedIncludeHidden + ) else { + return + } + self.isLoading = false + self.error = (error as? LocalizedError)?.errorDescription ?? self.fallbackError + return + } + guard self.isCurrent( + currentRequest, + machineId: machineId, + path: path, + includeHidden: requestedIncludeHidden + ) else { + return + } + guard response.success else { + self.isLoading = false + self.error = response.error ?? self.fallbackError + return + } + self.isLoading = false + self.entries = (response.entries ?? []) + .filter { $0.type == .directory } + .sorted { + $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + } + } + + private func isCurrent( + _ request: Int, + machineId: String, + path: String, + includeHidden: Bool + ) -> Bool { + requestVersion == request + && self.machineId == machineId + && isPresented + && self.path == path + && self.includeHidden == includeHidden + } + + private func makeBreadcrumbs(path: String, roots: [String]) -> [RemoteDirectoryBreadcrumb] { + guard let root = roots + .filter({ RemoteDirectoryPath.isWithinRoot(path: path, root: $0) }) + .max(by: { $0.count < $1.count }) + else { + return [RemoteDirectoryBreadcrumb(label: path, path: path)] + } + var result = [RemoteDirectoryBreadcrumb(label: root, path: root)] + let relative = String(path.dropFirst(root.count)) + .trimmingCharacters(in: CharacterSet(charactersIn: "/\\")) + var cursor = root + for segment in relative.split(whereSeparator: { $0 == "/" || $0 == "\\" }) { + cursor = RemoteDirectoryPath.join(parent: cursor, child: String(segment)) + result.append(RemoteDirectoryBreadcrumb(label: String(segment), path: cursor)) + } + return result + } + + private func unique(_ values: [String]) -> [String] { + var seen: Set = [] + return values.filter { seen.insert($0).inserted } + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiClient/Endpoints/MachineEndpoints.swift b/ios/Packages/HapiKit/Sources/HapiClient/Endpoints/MachineEndpoints.swift index 6d8d14de..96ce5e79 100644 --- a/ios/Packages/HapiKit/Sources/HapiClient/Endpoints/MachineEndpoints.swift +++ b/ios/Packages/HapiKit/Sources/HapiClient/Endpoints/MachineEndpoints.swift @@ -33,6 +33,13 @@ extension APIClient { ) } + /// `GET /api/machines/:id/agent-availability` — executable/static-config + /// availability for every supported Agent. A runner lacking the RPC + /// answers 409 `runner_upgrade_required`. + public func machineAgentAvailability(machineId: String) async throws -> AgentAvailabilityResponse { + try await request(.get, "/api/machines/\(encodePathComponent(machineId))/agent-availability") + } + /// `POST /api/machines/:id/list-directory` (RPC envelope — check /// `success`). public func listMachineDirectory( @@ -52,16 +59,18 @@ extension APIClient { } /// `POST /api/machines/:id/paths/exists` (≤ 1000 paths). - public func machinePathsExist(machineId: String, paths: [String]) async throws -> [String: Bool] { + public func machinePathsExist( + machineId: String, + paths: [String] + ) async throws -> MachinePathsExistsResponse { struct PathsExistsRequest: Encodable { let paths: [String] } - let response: MachinePathsExistsResponse = try await request( + return try await request( .post, "/api/machines/\(encodePathComponent(machineId))/paths/exists", body: PathsExistsRequest(paths: paths) ) - return response.exists } /// `GET /api/machines/:id/codex-models` — pre-spawn codex model catalog diff --git a/ios/Packages/HapiKit/Sources/HapiClient/NewSession/NewSessionForm.swift b/ios/Packages/HapiKit/Sources/HapiClient/NewSession/NewSessionForm.swift index 0b9e9c29..0b69f04d 100644 --- a/ios/Packages/HapiKit/Sources/HapiClient/NewSession/NewSessionForm.swift +++ b/ios/Packages/HapiKit/Sources/HapiClient/NewSession/NewSessionForm.swift @@ -197,10 +197,13 @@ public enum NewSessionLogic { public let parent: String /// Typed tail the entries are prefix-filtered by (case-insensitive). public let prefix: String + /// Separator used by the typed path; suggestions preserve it. + public let separator: String - public init(parent: String, prefix: String) { + public init(parent: String, prefix: String, separator: String = "/") { self.parent = parent self.prefix = prefix + self.separator = separator } } @@ -212,11 +215,38 @@ public enum NewSessionLogic { /// prefix; `/` → list `/`; relative text → nil (no request). public static func parentQuery(for input: String) -> ParentQuery? { let text = input.trimmingCharacters(in: .whitespacesAndNewlines) - guard text.hasPrefix("/") else { return nil } - guard let lastSlash = text.lastIndex(of: "/") else { return nil } - let parent = lastSlash == text.startIndex ? "/" : String(text[..= 3 + && characters[0].isLetter + && characters[1] == ":" + && isPathSeparator(characters[2]) + let isUNC = characters.count >= 2 + && ((characters[0] == "\\" && characters[1] == "\\") + || (characters[0] == "/" && characters[1] == "/")) + guard isPosix || isDrive || isUNC, + let separatorIndex = characters.lastIndex(where: isPathSeparator) + else { + return nil + } + + let separator = String(characters[separatorIndex]) + let rawParent = String(characters[.. [String] { - let base = query.parent == "/" ? "/" : "\(query.parent)/" + let base = query.parent.hasSuffix("/") || query.parent.hasSuffix("\\") + ? query.parent + : query.parent + query.separator let loweredPrefix = query.prefix.lowercased() return entries .filter { $0.type == .directory } @@ -235,6 +267,15 @@ public enum NewSessionLogic { .map { "\(base)\($0.name)" } } + private static func isPathSeparator(_ character: Character) -> Bool { + character == "/" || character == "\\" + } + + private static func isDrivePrefix(_ value: String) -> Bool { + let characters = Array(value) + return characters.count == 2 && characters[0].isLetter && characters[1] == ":" + } + // MARK: - Recent paths /// LRU cap per machine (web caps at 5; native chips fit a couple more). diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/ApiResponses.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/ApiResponses.swift index 04f57153..57ec6faa 100644 --- a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/ApiResponses.swift +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/ApiResponses.swift @@ -128,7 +128,7 @@ public struct ReopenSessionResponse: Codable, Equatable, Sendable { /// spawn is still HTTP 200 with `type: 'error'`. public enum SpawnResponse: Equatable, Sendable { case success(sessionId: String) - case error(message: String) + case error(message: String, code: String?, agent: AgentFlavor?) } extension SpawnResponse: Decodable { @@ -136,6 +136,8 @@ extension SpawnResponse: Decodable { case type case sessionId case message + case code + case agent } public init(from decoder: Decoder) throws { @@ -145,7 +147,11 @@ extension SpawnResponse: Decodable { case "success": self = .success(sessionId: try container.decode(String.self, forKey: .sessionId)) case "error": - self = .error(message: try container.decode(String.self, forKey: .message)) + self = .error( + message: try container.decodeIfPresent(String.self, forKey: .message) ?? "", + code: try container.decodeIfPresent(String.self, forKey: .code), + agent: try container.decodeIfPresent(AgentFlavor.self, forKey: .agent) + ) default: throw DecodingError.dataCorrupted(DecodingError.Context( codingPath: decoder.codingPath, @@ -396,9 +402,36 @@ public struct MachineListDirectoryResponse: Codable, Equatable, Sendable { /// Body of `POST /api/machines/:id/paths/exists`. public struct MachinePathsExistsResponse: Codable, Equatable, Sendable { public var exists: [String: Bool] + public var outsideWorkspaceRoots: [String]? - public init(exists: [String: Bool]) { + public init(exists: [String: Bool], outsideWorkspaceRoots: [String]? = nil) { self.exists = exists + self.outsideWorkspaceRoots = outsideWorkspaceRoots + } +} + +// MARK: - Agent availability + +/// Installed/static-configured Agent status reported by a machine runner. +public struct AgentAvailabilityEntry: Codable, Equatable, Sendable { + public var agent: AgentFlavor + public var available: Bool + /// `not_found | invalid_configuration`; open string for forward compatibility. + public var reason: String? + + public init(agent: AgentFlavor, available: Bool, reason: String? = nil) { + self.agent = agent + self.available = available + self.reason = reason + } +} + +/// Body of `GET /api/machines/:id/agent-availability`. +public struct AgentAvailabilityResponse: Codable, Equatable, Sendable { + public var agents: [AgentAvailabilityEntry] + + public init(agents: [AgentAvailabilityEntry]) { + self.agents = agents } } diff --git a/ios/Packages/HapiKit/Tests/HapiClientTests/DirectoryBrowser/RemoteDirectoryBrowserModelTests.swift b/ios/Packages/HapiKit/Tests/HapiClientTests/DirectoryBrowser/RemoteDirectoryBrowserModelTests.swift new file mode 100644 index 00000000..8924686c --- /dev/null +++ b/ios/Packages/HapiKit/Tests/HapiClientTests/DirectoryBrowser/RemoteDirectoryBrowserModelTests.swift @@ -0,0 +1,98 @@ +import Foundation +import HapiClient +import HapiProtocol +import Testing + +private struct DirectoryBrowserCall: Equatable, Sendable { + let machineId: String + let path: String + let includeHidden: Bool +} + +private actor FakeMachineDirectoryRequester: MachineDirectoryRequesting { + private(set) var calls: [DirectoryBrowserCall] = [] + + func listMachineDirectory( + machineId: String, + path: String, + includeHidden: Bool + ) async throws -> MachineListDirectoryResponse { + calls.append(DirectoryBrowserCall( + machineId: machineId, + path: path, + includeHidden: includeHidden + )) + return MachineListDirectoryResponse( + success: true, + entries: [ + MachineDirectoryEntry(name: "repo", type: .directory), + MachineDirectoryEntry(name: "README.md", type: .file), + ] + ) + } +} + +@MainActor +private func directoryBrowserEventually( + timeout: Duration = .seconds(5), + _ condition: @MainActor () -> Bool +) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if condition() { return true } + try? await Task.sleep(for: .milliseconds(10)) + } + return condition() +} + +@Suite("RemoteDirectoryBrowserModel") +@MainActor +struct RemoteDirectoryBrowserModelTests { + @Test func pathBoundariesSupportPosixDriveAndUNCPaths() { + #expect(RemoteDirectoryPath.isWithinRoot(path: "/workspace/repo", root: "/workspace")) + #expect(!RemoteDirectoryPath.isWithinRoot(path: "/workspace-other/repo", root: "/workspace")) + #expect(RemoteDirectoryPath.isWithinRoot(path: "/workspace", root: "/")) + #expect(RemoteDirectoryPath.isWithinRoot(path: "c:\\Work\\Repo", root: "C:\\work")) + #expect(!RemoteDirectoryPath.isWithinRoot( + path: "C:\\workspace-other", + root: "C:\\workspace" + )) + #expect(RemoteDirectoryPath.isWithinRoot( + path: "\\\\SERVER\\Share\\Repo", + root: "\\\\server\\share" + )) + #expect(RemoteDirectoryPath.parent("C:\\Users") == "C:\\") + #expect( + RemoteDirectoryPath.parent("\\\\server\\share\\repo") + == "\\\\server\\share" + ) + } + + @Test func modelOwnsNavigationLoadingAndHiddenDirectoryState() async { + let requester = FakeMachineDirectoryRequester() + let model = RemoteDirectoryBrowserModel(requester: requester) + + model.open(machineId: "machine-1", roots: ["/workspace"], initialPath: "/workspace") + #expect(await directoryBrowserEventually { !model.isLoading }) + #expect(model.entries.map(\.name) == ["repo"]) + + model.navigate(to: "/workspace-other") + try? await Task.sleep(for: .milliseconds(20)) + #expect(await requester.calls.count == 1) + + model.navigateEntry("repo") + #expect(await directoryBrowserEventually { !model.isLoading && model.path == "/workspace/repo" }) + #expect(model.canGoUp) + + model.setIncludeHidden(true) + #expect(await directoryBrowserEventually { !model.isLoading }) + #expect(await requester.calls.last?.includeHidden == true) + + model.navigateUp() + #expect(await directoryBrowserEventually { !model.isLoading && model.path == "/workspace" }) + + model.close() + #expect(!model.isPresented) + } +} diff --git a/ios/Packages/HapiKit/Tests/HapiClientTests/EndpointRequestTests.swift b/ios/Packages/HapiKit/Tests/HapiClientTests/EndpointRequestTests.swift index b2528a89..5cc63fc1 100644 --- a/ios/Packages/HapiKit/Tests/HapiClientTests/EndpointRequestTests.swift +++ b/ios/Packages/HapiKit/Tests/HapiClientTests/EndpointRequestTests.swift @@ -137,7 +137,55 @@ struct EndpointRequestTests { await harness.performer.enqueue(json: "{\"type\":\"error\",\"message\":\"no runner\"}") let failed = try await harness.client.spawnSession(machineId: "m1", SpawnRequest(directory: "/x")) - #expect(failed == .error(message: "no runner")) + #expect(failed == .error(message: "no runner", code: nil, agent: nil)) + + await harness.performer.enqueue( + json: "{\"type\":\"error\",\"message\":\"Codex is not installed\"," + + "\"code\":\"agent_unavailable\",\"agent\":\"codex\"}" + ) + let unavailable = try await harness.client.spawnSession( + machineId: "m1", + SpawnRequest(directory: "/x", agent: .codex) + ) + #expect( + unavailable == .error( + message: "Codex is not installed", + code: "agent_unavailable", + agent: .codex + ) + ) + } + + @Test func machineAvailabilityAndPathBoundaryResponses() async throws { + let harness = try makeHarness(jwt: freshJWT()) + await harness.performer.enqueue( + json: "{\"agents\":[{\"agent\":\"claude\",\"available\":false," + + "\"reason\":\"not_found\"},{\"agent\":\"codex\",\"available\":true}]}" + ) + let availability = try await harness.client.machineAgentAvailability(machineId: "m 1") + #expect(availability.agents.map(\.agent) == [.claude, .codex]) + #expect(!availability.agents[0].available) + #expect(availability.agents[0].reason == "not_found") + let availabilityRequest = await harness.performer.requests.first + #expect( + availabilityRequest?.url?.absoluteString + == "\(testHubURLString)/api/machines/m%201/agent-availability" + ) + #expect(availabilityRequest?.httpMethod == "GET") + + await harness.performer.enqueue( + json: "{\"exists\":{\"/workspace\":true,\"/outside\":false}," + + "\"outsideWorkspaceRoots\":[\"/outside\"]}" + ) + let paths = try await harness.client.machinePathsExist( + machineId: "m1", + paths: ["/workspace", "/outside"] + ) + #expect(paths.exists["/workspace"] == true) + #expect(paths.outsideWorkspaceRoots == ["/outside"]) + let pathRequest = await harness.performer.requests.last + #expect(pathRequest?.url?.absoluteString == "\(testHubURLString)/api/machines/m1/paths/exists") + #expect(bodyString(pathRequest) == "{\"paths\":[\"/workspace\",\"/outside\"]}") } @Test func machineCodexModelsRequestAndRpcTargetMissing() async throws { diff --git a/ios/Packages/HapiKit/Tests/HapiClientTests/NewSessionFormTests.swift b/ios/Packages/HapiKit/Tests/HapiClientTests/NewSessionFormTests.swift index f078b9ee..7dce7427 100644 --- a/ios/Packages/HapiKit/Tests/HapiClientTests/NewSessionFormTests.swift +++ b/ios/Packages/HapiKit/Tests/HapiClientTests/NewSessionFormTests.swift @@ -156,6 +156,35 @@ struct NewSessionLogicTests { ) } + @Test func windowsDriveAndUNCAutocompletePreserveSeparators() { + #expect( + NewSessionLogic.parentQuery(for: "C:\\Users\\pro") + == NewSessionLogic.ParentQuery(parent: "C:\\Users", prefix: "pro", separator: "\\") + ) + #expect( + NewSessionLogic.parentQuery(for: "C:\\Use") + == NewSessionLogic.ParentQuery(parent: "C:\\", prefix: "Use", separator: "\\") + ) + #expect( + NewSessionLogic.parentQuery(for: "\\\\server\\share\\pro") + == NewSessionLogic.ParentQuery( + parent: "\\\\server\\share", + prefix: "pro", + separator: "\\" + ) + ) + #expect( + NewSessionLogic.buildSuggestions( + query: NewSessionLogic.ParentQuery( + parent: "C:\\Users", + prefix: "pro", + separator: "\\" + ), + entries: [dir("projects")] + ) == ["C:\\Users\\projects"] + ) + } + @Test func recentPathsLRUDedupesToFrontAndCapsAtEight() { var list: [String] = [] for index in 1...10 { diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 735d8a5a..f0c0d132 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -130,7 +130,12 @@ export type MachinesResponse = { machines: Machine[] } export type SpawnResponse = | { type: 'success'; sessionId: string } - | { type: 'error'; message: string } + | { + type: 'error' + message: string + code?: 'agent_unavailable' | 'runner_upgrade_required' | 'outside_workspace_roots' + agent?: z.infer + } export const SessionPermissionModeRequestSchema = z.object({ mode: PermissionModeSchema @@ -633,6 +638,21 @@ export const MachinePathsExistsRequestSchema = z.object({ export type MachinePathsExistsRequest = z.infer +export const AgentAvailabilityReasonSchema = z.enum(['not_found', 'invalid_configuration']) +export type AgentAvailabilityReason = z.infer + +export const AgentAvailabilityEntrySchema = z.object({ + agent: AgentFlavorSchema, + available: z.boolean(), + reason: AgentAvailabilityReasonSchema.optional() +}) +export type AgentAvailabilityEntry = z.infer + +export const AgentAvailabilityResponseSchema = z.object({ + agents: z.array(AgentAvailabilityEntrySchema) +}) +export type AgentAvailabilityResponse = z.infer + export const AuthRequestSchema = z.union([ z.object({ initData: z.string() }), z.object({ accessToken: z.string() }) @@ -716,6 +736,8 @@ export type MachineListDirectoryResponse = { export type PathExistsResponse = { exists: Record + /** Requested paths rejected by the runner's configured workspace roots. */ + outsideWorkspaceRoots?: string[] } export type MachinePathsExistsResponse = PathExistsResponse diff --git a/shared/src/rpcMethods.ts b/shared/src/rpcMethods.ts index 2619ca27..78893518 100644 --- a/shared/src/rpcMethods.ts +++ b/shared/src/rpcMethods.ts @@ -10,6 +10,7 @@ export const RPC_METHODS = { StopRunner: 'stop-runner', ListMachineDirectory: 'list-directory', PathExists: 'path-exists', + AgentAvailability: 'agent-availability', CursorChatStoreStatus: 'cursor-chat-store-status', GitStatus: 'git-status', GitDiffNumstat: 'git-diff-numstat', diff --git a/shared/src/runnerCapabilities.test.ts b/shared/src/runnerCapabilities.test.ts index d9404b0f..ff842f32 100644 --- a/shared/src/runnerCapabilities.test.ts +++ b/shared/src/runnerCapabilities.test.ts @@ -9,7 +9,8 @@ import { } from './runnerCapabilities' describe('runnerCapabilities', () => { - it('requires cursor-chat-store-status so hub features cannot fail-closed without a registry entry', () => { + it('requires machine RPCs that session creation hard-depends on', () => { + expect(REQUIRED_MACHINE_CAPABILITIES).toContain(MACHINE_CAPABILITIES.AgentAvailability) expect(REQUIRED_MACHINE_CAPABILITIES).toContain(MACHINE_CAPABILITIES.CursorChatStoreStatus) expect(CURRENT_MACHINE_CAPABILITIES).toEqual(expect.arrayContaining([ ...REQUIRED_MACHINE_CAPABILITIES, @@ -21,6 +22,7 @@ describe('runnerCapabilities', () => { expect(isMachineCapabilitySkewed(null)).toBe(true) expect(isMachineCapabilitySkewed([])).toBe(true) expect(missingRequiredCapabilities([])).toEqual([ + MACHINE_CAPABILITIES.AgentAvailability, MACHINE_CAPABILITIES.CursorChatStoreStatus, ]) }) @@ -28,6 +30,7 @@ describe('runnerCapabilities', () => { it('is not skewed when required capabilities are advertised', () => { expect(isMachineCapabilitySkewed([...CURRENT_MACHINE_CAPABILITIES])).toBe(false) expect(missingRequiredCapabilities([ + MACHINE_CAPABILITIES.AgentAvailability, MACHINE_CAPABILITIES.CursorChatStoreStatus, 'other-cap', ])).toEqual([]) diff --git a/shared/src/runnerCapabilities.ts b/shared/src/runnerCapabilities.ts index 06a23793..9affa1c3 100644 --- a/shared/src/runnerCapabilities.ts +++ b/shared/src/runnerCapabilities.ts @@ -28,6 +28,7 @@ export type RunnerCapabilities = typeof RUNNER_CAPABILITIES * instead of a silent fail-closed product bug. */ export const MACHINE_CAPABILITIES = { + AgentAvailability: RPC_METHODS.AgentAvailability, CursorChatStoreStatus: RPC_METHODS.CursorChatStoreStatus, StopRunner: RPC_METHODS.StopRunner, } as const @@ -37,6 +38,7 @@ export type MachineCapability = /** Capabilities this CLI generation registers on the machine socket. */ export const CURRENT_MACHINE_CAPABILITIES: readonly MachineCapability[] = [ + MACHINE_CAPABILITIES.AgentAvailability, MACHINE_CAPABILITIES.CursorChatStoreStatus, MACHINE_CAPABILITIES.StopRunner, ] @@ -47,6 +49,7 @@ export const CURRENT_MACHINE_CAPABILITIES: readonly MachineCapability[] = [ * stop-runner ensure when a newer binary is already on disk). */ export const REQUIRED_MACHINE_CAPABILITIES: readonly MachineCapability[] = [ + MACHINE_CAPABILITIES.AgentAvailability, MACHINE_CAPABILITIES.CursorChatStoreStatus, ] diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 6ab7a919..0a3c3090 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -32,6 +32,7 @@ import type { } from '@/types/api' import type { AgyModelsResponse, + AgentAvailabilityResponse, CodexModelsResponse, CursorMigrateOutcome, CursorMigrateToAcpRequest, @@ -801,6 +802,12 @@ export class ApiClient { ) } + async getMachineAgentAvailability(machineId: string): Promise { + return await this.request( + `/api/machines/${encodeURIComponent(machineId)}/agent-availability` + ) + } + async checkMachinePathsExists( machineId: string, paths: string[] diff --git a/web/src/components/NewSession/AgentSelector.test.tsx b/web/src/components/NewSession/AgentSelector.test.tsx index fbb2ebba..6f6e0ae7 100644 --- a/web/src/components/NewSession/AgentSelector.test.tsx +++ b/web/src/components/NewSession/AgentSelector.test.tsx @@ -9,9 +9,14 @@ vi.mock('@/lib/use-translation', () => ({ import { AgentSelector } from './AgentSelector' import type { AgentType } from './types' -function renderedAgentValues(): string[] { +function renderedAgentValues(agents: readonly AgentType[] = CREATABLE_AGENT_FLAVORS): string[] { const { container } = render( - {}} /> + {}} + /> ) return Array.from(container.querySelectorAll('input[type="radio"]')) .map((el) => (el as HTMLInputElement).value) @@ -25,4 +30,8 @@ describe('AgentSelector', () => { it('offers exactly the creatable agent flavors', () => { expect(renderedAgentValues()).toEqual([...CREATABLE_AGENT_FLAVORS]) }) + + it('renders only Agents reported available by the machine', () => { + expect(renderedAgentValues(['claude', 'codex'])).toEqual(['claude', 'codex']) + }) }) diff --git a/web/src/components/NewSession/AgentSelector.tsx b/web/src/components/NewSession/AgentSelector.tsx index 3c283953..24886e17 100644 --- a/web/src/components/NewSession/AgentSelector.tsx +++ b/web/src/components/NewSession/AgentSelector.tsx @@ -1,10 +1,11 @@ -import { CREATABLE_AGENT_FLAVORS, getFlavorLabel } from '@hapi/protocol' +import { getFlavorLabel } from '@hapi/protocol' import type { AgentType } from './types' import { AgentFlavorIcon } from '@/components/AgentFlavorIcon' import { useTranslation } from '@/lib/use-translation' export function AgentSelector(props: { agent: AgentType + agents: readonly AgentType[] isDisabled: boolean onAgentChange: (value: AgentType) => void }) { @@ -16,7 +17,7 @@ export function AgentSelector(props: { {t('newSession.agent')}
- {CREATABLE_AGENT_FLAVORS.map((agentType) => ( + {props.agents.map((agentType) => (