diff --git a/app/src/main/java/com/yugahashimoto/andcode/AndCodeApplication.kt b/app/src/main/java/com/yugahashimoto/andcode/AndCodeApplication.kt index 0f14e8c75..9d3c0a588 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/AndCodeApplication.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/AndCodeApplication.kt @@ -74,6 +74,7 @@ import com.yugahashimoto.andcode.runtime.local.LocalRuntimeReleaseClient import com.yugahashimoto.andcode.runtime.local.LocalRuntimeServiceController import com.yugahashimoto.andcode.runtime.local.LocalRuntimeTarget import com.yugahashimoto.andcode.runtime.local.LocalRuntimeUpdater +import com.yugahashimoto.andcode.runtime.local.StartupCommandsRunner import com.yugahashimoto.andcode.runtime.local.SystemPromptStore import com.yugahashimoto.andcode.runtime.local.VerifiedRuntimeDownloader import com.yugahashimoto.andcode.runtime.local.applyOpenCodeSystemPrompt @@ -287,6 +288,13 @@ class AndCodeApplication : Application() { accessCoordinator = accessCoordinator, ) localRuntimeInstaller = installer + // Guest startup commands (issue #379): read from the workspace on every runtime start, + // launched detached so they can neither block nor fail the start itself. + val startupCommandsRunner = + StartupCommandsRunner( + runtimeDirectory = runtimeDirectory, + githubToken = { settings.githubToken }, + ) val launcher = LocalRuntimeProcessLauncher( runtimeDirectory = runtimeDirectory, @@ -300,6 +308,7 @@ class AndCodeApplication : Application() { if (settings.githubToken.isNullOrBlank()) helper.remove() else helper.install() } } + runCatching { startupCommandsRunner.schedule(installed) } }, ) commandRunner = diff --git a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClient.kt b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClient.kt index f15738e59..acccd4bf6 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClient.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClient.kt @@ -184,13 +184,17 @@ class OpenCodeV2ApiClient( ) } + /** + * Internal V2 agents (compaction, title, summary) are reported as `mode: primary` with + * `hidden: true`; the app's agent model has no hidden flag yet, so they are dropped here to + * keep them out of the mode picker and settings. + */ override suspend fun agents(): List = - getData>("api/agent").map { agent -> + getData>("api/agent").filterNot { it.hidden }.map { agent -> OpenCodeAgent( name = agent.id, description = agent.description, mode = agent.mode, - hidden = agent.hidden, ) } @@ -527,6 +531,7 @@ class OpenCodeV2ApiClient( directory: String?, ): Boolean { sendVoid(requestBuilder("api/session/${encodePath(sessionId)}").delete()) + sessionLocks.remove(sessionId) return true } diff --git a/app/src/main/java/com/yugahashimoto/andcode/core/api/ProtocolDetectingOpenCodeApi.kt b/app/src/main/java/com/yugahashimoto/andcode/core/api/ProtocolDetectingOpenCodeApi.kt index f9f867982..435725183 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/core/api/ProtocolDetectingOpenCodeApi.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/core/api/ProtocolDetectingOpenCodeApi.kt @@ -22,6 +22,7 @@ import okhttp3.Credentials import okhttp3.HttpUrl import okhttp3.OkHttpClient import okhttp3.Request +import java.io.IOException /** Which HTTP API a server speaks. V2 servers answer under the `api/` prefix; V1 servers answer the legacy routes. */ enum class OpenCodeProtocol { V1, V2 } @@ -298,24 +299,43 @@ class ProtocolDetectingOpenCodeApi( * * V1 servers answer `api/info` with the single-page-app HTML, which is not conclusive by itself. * V1 is only confirmed when `global/health` succeeds, so an outage or proxy error cannot be - * mistaken for a V1 server. Transient statuses (5xx, 408, 429) are thrown and not cached. + * mistaken for a V1 server. + * + * Older V1 servers, and V1 builds without an embedded web UI, proxy unknown paths such as + * `api/info` to app.opencode.ai, so a 5xx or a timeout there can be a perfectly healthy V1 + * server whose upstream web host is unreachable. Those failures still try `global/health`, but + * only a 1.x version on it is accepted as V1: a V2 server answers that path with HTML, so its + * transient failures keep throwing and are never cached as V1. */ private suspend fun probe(): Probe = withContext(Dispatchers.IO) { - val info = fetchStatus("api/info") + val info = + try { + fetchStatus("api/info") + } catch (error: IOException) { + return@withContext confirmLegacyMajorOne() ?: throw error + } when { info.code in 200..299 -> versionOf(info.body)?.let { Probe.V2(it) } ?: confirmLegacy() info.code == 401 && isUnauthorizedTag(info.body) -> Probe.V2Unauthorized info.code == 401 || info.code == 403 || info.code == 404 || info.code in 300..399 -> confirmLegacy() - else -> throw OpenCodeApiException(statusCode = info.code, message = "OpenCode probe failed (HTTP ${info.code})") + else -> + confirmLegacyMajorOne() + ?: throw OpenCodeApiException(statusCode = info.code, message = "OpenCode probe failed (HTTP ${info.code})") } } + /** [confirmLegacy] for an inconclusive `api/info`: a 1.x server, or null when that is not proven. */ + private fun confirmLegacyMajorOne(): Probe.Legacy? = + runCatching { confirmLegacy() } + .getOrNull() + ?.takeIf { legacy -> legacy.health.version.trim().removePrefix("v").substringBefore('.') == "1" } + /** * Confirms a V1 server with `global/health`; any non-success is not a V1 verdict. The body is * kept so that [health] can return it without a second request. */ - private fun confirmLegacy(): Probe { + private fun confirmLegacy(): Probe.Legacy { // A V1 server may run with a custom OPENCODE_SERVER_USERNAME, which the V1 client honours, // so the V1 confirmation authenticates as the profile does rather than as V2's fixed user. val response = fetchStatus("global/health", username = profile.username.ifBlank { OpenCodeV2ApiClient.USERNAME }) diff --git a/app/src/main/java/com/yugahashimoto/andcode/di/AppModule.kt b/app/src/main/java/com/yugahashimoto/andcode/di/AppModule.kt index d8e1a094e..b5af1b2f6 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/di/AppModule.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/di/AppModule.kt @@ -32,6 +32,7 @@ import com.yugahashimoto.andcode.runtime.local.LocalRuntimeReleaseClient import com.yugahashimoto.andcode.runtime.local.LocalRuntimeServiceController import com.yugahashimoto.andcode.runtime.local.LocalRuntimeTarget import com.yugahashimoto.andcode.runtime.local.LocalRuntimeUpdater +import com.yugahashimoto.andcode.runtime.local.StartupCommandsRunner import com.yugahashimoto.andcode.runtime.local.VerifiedRuntimeDownloader import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -88,6 +89,13 @@ val appModule = val providerCredentials: LocalProviderCredentialStore = get() val customProviders: CustomProviderStore = get() val runtimeDirectory: File = get() + // Guest startup commands (issue #379): read from the workspace on every runtime start, + // launched detached so they can neither block nor fail the start itself. + val startupCommandsRunner = + StartupCommandsRunner( + runtimeDirectory = runtimeDirectory, + githubToken = { settings.githubToken }, + ) LocalRuntimeProcessLauncher( runtimeDirectory = runtimeDirectory, portProbe = LocalRuntimeManager::defaultPortProbe, @@ -100,6 +108,7 @@ val appModule = if (settings.githubToken.isNullOrBlank()) helper.remove() else helper.install() } } + runCatching { startupCommandsRunner.schedule(installed) } }, ) } diff --git a/app/src/main/java/com/yugahashimoto/andcode/di/ViewModelModule.kt b/app/src/main/java/com/yugahashimoto/andcode/di/ViewModelModule.kt index b17013820..3fb75b63a 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/di/ViewModelModule.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/di/ViewModelModule.kt @@ -5,6 +5,7 @@ import com.yugahashimoto.andcode.feature.activity.ActivityViewModel import com.yugahashimoto.andcode.feature.chat.ChatViewModel import com.yugahashimoto.andcode.feature.settings.SettingsViewModel import com.yugahashimoto.andcode.feature.workspace.WorkspaceViewModel +import com.yugahashimoto.andcode.runtime.local.CustomProviderModelFetcher import kotlinx.coroutines.flow.first import org.koin.android.ext.koin.androidContext import org.koin.core.module.dsl.viewModel @@ -53,6 +54,13 @@ val viewModelModule = androidContext().getString(com.yugahashimoto.andcode.R.string.provider_disconnect_rejected), customProviderInvalidMessage = androidContext().getString(com.yugahashimoto.andcode.R.string.provider_custom_invalid), + modelFetcher = CustomProviderModelFetcher(get()), + customProviderFetchNeedsBaseUrlMessage = + androidContext().getString(com.yugahashimoto.andcode.R.string.provider_custom_fetch_needs_base_url), + customProviderFetchEmptyMessage = + androidContext().getString(com.yugahashimoto.andcode.R.string.provider_custom_fetch_empty), + customProviderFetchFailedMessage = + androidContext().getString(com.yugahashimoto.andcode.R.string.provider_custom_fetch_failed), ) } diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt index e3b7a1749..1d30bfc12 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt @@ -672,6 +672,7 @@ fun ChatHomeScreen( .firstOrNull { it.id == selectedProviderId } ?.models?.get(selectedModelId) ?.limit?.context ?: 0L, + tokensPerSecond = state.connectionQuality?.tokensPerSecond ?: 0.0, showSlashCommands = showSlashCommands, slashCommands = state.slashCommands, slashSkills = state.slashSkills, @@ -1257,6 +1258,7 @@ private fun ChatComposer( enterToSend: Boolean, contextTokensUsed: Long, contextLimit: Long, + tokensPerSecond: Double, showSlashCommands: Boolean, slashCommands: List, slashSkills: List, @@ -1630,6 +1632,9 @@ private fun ChatComposer( contextLimit = contextLimit, ) } + if (isRunning && tokensPerSecond >= 0.05) { + CompactTokenSpeed(tokensPerSecond) + } } } } @@ -2167,6 +2172,20 @@ private fun formatTokenCount(tokens: Long): String = else -> tokens.toString() } +@Composable +private fun CompactTokenSpeed(tokensPerSecond: Double) { + Text( + text = stringResource(R.string.chat_token_speed, formatTokenRate(tokensPerSecond)), + style = MaterialTheme.typography.labelSmall, + fontSize = 9.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 2.dp), + ) +} + +private fun formatTokenRate(tokensPerSecond: Double): String = + if (tokensPerSecond >= 10.0) "%.0f".format(tokensPerSecond) else "%.1f".format(tokensPerSecond) + @Preview(showBackground = true) @Composable private fun ChatHomeScreenEmptyPreview() { diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/CustomProviderDialog.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/CustomProviderDialog.kt index 23273f6dd..d0a8b3f8c 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/CustomProviderDialog.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/CustomProviderDialog.kt @@ -2,18 +2,27 @@ package com.yugahashimoto.andcode.feature.settings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField 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.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import com.yugahashimoto.andcode.R @@ -24,12 +33,20 @@ fun CustomProviderDialog( onNameChange: (String) -> Unit, onBaseUrlChange: (String) -> Unit, onModelsChange: (String) -> Unit, + onApiKeyChange: (String) -> Unit, + onFetchModels: () -> Unit, onSubmit: () -> Unit, onDismiss: () -> Unit, ) { AlertDialog( onDismissRequest = { if (!state.isSubmitting) onDismiss() }, - title = { Text(stringResource(R.string.provider_add_custom_title)) }, + title = { + Text( + stringResource( + if (state.editing) R.string.provider_edit_custom_title else R.string.provider_add_custom_title, + ), + ) + }, text = { Column( modifier = Modifier.verticalScroll(rememberScrollState()), @@ -46,7 +63,7 @@ fun CustomProviderDialog( label = { Text(stringResource(R.string.provider_custom_id_label)) }, placeholder = { Text(stringResource(R.string.provider_custom_id_hint)) }, singleLine = true, - enabled = !state.isSubmitting, + enabled = !state.isBusy && !state.editing, modifier = Modifier.fillMaxWidth(), ) OutlinedTextField( @@ -55,7 +72,7 @@ fun CustomProviderDialog( label = { Text(stringResource(R.string.provider_custom_name_label)) }, placeholder = { Text(stringResource(R.string.provider_custom_name_hint)) }, singleLine = true, - enabled = !state.isSubmitting, + enabled = !state.isBusy, modifier = Modifier.fillMaxWidth(), ) OutlinedTextField( @@ -64,16 +81,63 @@ fun CustomProviderDialog( label = { Text(stringResource(R.string.provider_custom_base_url_label)) }, placeholder = { Text(stringResource(R.string.provider_custom_base_url_hint)) }, singleLine = true, - enabled = !state.isSubmitting, + enabled = !state.isBusy, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), modifier = Modifier.fillMaxWidth(), ) + OutlinedTextField( + value = state.apiKey, + onValueChange = onApiKeyChange, + label = { Text(stringResource(R.string.api_key_hint)) }, + supportingText = { + Text( + stringResource( + if (state.hasStoredApiKey) { + R.string.provider_custom_api_key_stored_help + } else { + R.string.provider_custom_api_key_help + }, + ), + ) + }, + singleLine = true, + enabled = !state.isBusy, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth(), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = onFetchModels, enabled = !state.isBusy) { + Text(stringResource(R.string.provider_custom_fetch_models)) + } + if (state.isFetchingModels) { + CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(20.dp)) + } + } + state.fetchedModelCount?.let { count -> + Text( + pluralStringResource(R.plurals.provider_custom_fetch_success, count, count), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + state.fetchError?.let { error -> + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } OutlinedTextField( value = state.models, onValueChange = onModelsChange, label = { Text(stringResource(R.string.provider_custom_models_label)) }, placeholder = { Text(stringResource(R.string.provider_custom_models_hint)) }, - singleLine = true, - enabled = !state.isSubmitting, + maxLines = 6, + enabled = !state.isBusy, modifier = Modifier.fillMaxWidth(), ) state.error?.let { error -> @@ -86,8 +150,8 @@ fun CustomProviderDialog( } }, confirmButton = { - Button(onClick = onSubmit, enabled = !state.isSubmitting) { - Text(stringResource(R.string.continue_label)) + Button(onClick = onSubmit, enabled = !state.isBusy) { + Text(stringResource(if (state.editing) R.string.save else R.string.continue_label)) } }, dismissButton = { diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/McpViewModel.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/McpViewModel.kt index a7bad0d09..4c1ee78f0 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/McpViewModel.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/McpViewModel.kt @@ -6,6 +6,7 @@ import com.yugahashimoto.andcode.core.api.McpServer import com.yugahashimoto.andcode.runtime.LocalAgent import com.yugahashimoto.andcode.runtime.OpenCodeBackend import com.yugahashimoto.andcode.runtime.RuntimeRegistry +import com.yugahashimoto.andcode.runtime.RuntimeTarget import com.yugahashimoto.andcode.runtime.local.ClaudeCodeTarget import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -77,7 +78,16 @@ class McpViewModel( _state.update { it.copy(isLoading = true, error = null) } runCatching { backend.mcpServers() } .onSuccess { servers -> - _state.update { it.copy(servers = servers, isLoading = false) } + // Read after the list call: that call is what lets a remote target detect its + // protocol, and an OpenCode V2 server has no MCP OAuth endpoints to offer. + val oauthOffered = (backend as? RuntimeTarget)?.capabilities?.mcpOAuth != false + _state.update { + it.copy( + servers = servers, + isLoading = false, + supportsOAuth = agent == LocalAgent.OPEN_CODE && oauthOffered, + ) + } } .onFailure { e -> _state.update { it.copy(error = e.message, isLoading = false) } diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ProviderAuthUiModels.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ProviderAuthUiModels.kt index ee95645ba..03ef6576a 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ProviderAuthUiModels.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ProviderAuthUiModels.kt @@ -42,6 +42,20 @@ data class CustomProviderDialogState( val name: String = "", val baseUrl: String = "", val models: String = "", + /** Only used to call `/models`; it is handed to the API-key step afterwards, never stored here. */ + val apiKey: String = "", + /** True when the dialog edits an already registered provider, whose id must not change. */ + val editing: Boolean = false, + /** Whether a stored credential exists for [id], so fetching works without re-typing the key. */ + val hasStoredApiKey: Boolean = false, + val isFetchingModels: Boolean = false, + val fetchedModelCount: Int? = null, + val fetchError: String? = null, val isSubmitting: Boolean = false, val error: String? = null, -) +) { + val isBusy: Boolean get() = isSubmitting || isFetchingModels +} + +/** Splits the comma- or newline-separated model field into distinct, trimmed ids. */ +internal fun parseModelIdList(raw: String): List = raw.split(',', '\n').map(String::trim).filter(String::isNotEmpty).distinct() diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ProviderSettingsScreen.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ProviderSettingsScreen.kt index bdd46db89..fbbb45f78 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ProviderSettingsScreen.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ProviderSettingsScreen.kt @@ -66,6 +66,9 @@ fun ProviderSettingsScreen( onCustomProviderNameChange: (String) -> Unit, onCustomProviderBaseUrlChange: (String) -> Unit, onCustomProviderModelsChange: (String) -> Unit, + onCustomProviderApiKeyChange: (String) -> Unit, + onFetchCustomProviderModels: () -> Unit, + onEditCustomProvider: (String) -> Unit, onSubmitCustomProvider: () -> Unit, onDismissCustomProviderDialog: () -> Unit, onRemoveCustomProvider: (String) -> Unit, @@ -157,6 +160,7 @@ fun ProviderSettingsScreen( onConnect = { onOpenProviderAuth(provider.id) }, onDisconnect = { onDisconnectProvider(provider.id) }, onRemove = { onRemoveCustomProvider(provider.id) }, + onEdit = { onEditCustomProvider(provider.id) }, ) } } @@ -204,6 +208,8 @@ fun ProviderSettingsScreen( onNameChange = onCustomProviderNameChange, onBaseUrlChange = onCustomProviderBaseUrlChange, onModelsChange = onCustomProviderModelsChange, + onApiKeyChange = onCustomProviderApiKeyChange, + onFetchModels = onFetchCustomProviderModels, onSubmit = onSubmitCustomProvider, onDismiss = onDismissCustomProviderDialog, ) @@ -291,6 +297,7 @@ private fun ProviderRow( onConnect: () -> Unit, onDisconnect: () -> Unit, onRemove: () -> Unit, + onEdit: () -> Unit, ) { Column( modifier = @@ -348,6 +355,9 @@ private fun ProviderRow( } } if (isCustom) { + TextButton(onClick = onEdit) { + Text(stringResource(R.string.provider_custom_edit)) + } TextButton(onClick = onRemove) { Text(stringResource(R.string.provider_custom_remove)) } @@ -397,6 +407,9 @@ private fun ProviderSettingsScreenPreview() { onCustomProviderNameChange = {}, onCustomProviderBaseUrlChange = {}, onCustomProviderModelsChange = {}, + onCustomProviderApiKeyChange = {}, + onFetchCustomProviderModels = {}, + onEditCustomProvider = {}, onSubmitCustomProvider = {}, onDismissCustomProviderDialog = {}, onRemoveCustomProvider = {}, diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt index 48b3f2b31..e6c43af34 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt @@ -26,9 +26,11 @@ import com.yugahashimoto.andcode.runtime.LocalAgent import com.yugahashimoto.andcode.runtime.RuntimeRegistry import com.yugahashimoto.andcode.runtime.RuntimeTarget import com.yugahashimoto.andcode.runtime.local.CustomProviderDefinition +import com.yugahashimoto.andcode.runtime.local.CustomProviderModelFetcher import com.yugahashimoto.andcode.runtime.local.CustomProviderStore import com.yugahashimoto.andcode.runtime.local.LocalProviderCredentialStore import com.yugahashimoto.andcode.runtime.local.toProviderConfig +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -113,6 +115,10 @@ class SettingsViewModel( private val voskModels: VoskModelStore, private val providerDisconnectRejectedMessage: String = "Provider disconnect was not accepted", private val customProviderInvalidMessage: String = "Enter a provider id, base URL, and at least one model id", + private val modelFetcher: CustomProviderModelFetcher = CustomProviderModelFetcher(), + private val customProviderFetchNeedsBaseUrlMessage: String = "Enter a base URL first", + private val customProviderFetchEmptyMessage: String = "The endpoint returned no models", + private val customProviderFetchFailedMessage: String = "Could not fetch the model list", ) : ViewModel() { private val settingsTick = MutableStateFlow(0) private val oauthState = MutableStateFlow(OAuthState()) @@ -617,8 +623,26 @@ class SettingsViewModel( customProviderDialogState.value = CustomProviderDialogState() } + /** + * Opens the same dialog prefilled with a registered provider, so its model list can be changed + * (or re-fetched) without removing and re-adding it. The id stays fixed: it keys the stored + * credential and every saved model selection. + */ + fun openEditCustomProvider(providerId: String) { + val definition = customProviders.definitions().firstOrNull { it.id == providerId } ?: return + customProviderDialogState.value = + CustomProviderDialogState( + id = definition.id, + name = definition.name, + baseUrl = definition.baseUrl, + models = definition.models.joinToString(", "), + editing = true, + hasStoredApiKey = credentials.hasCredential(definition.id), + ) + } + fun updateCustomProviderId(value: String) { - customProviderDialogState.update { it?.copy(id = value, error = null) } + customProviderDialogState.update { if (it == null || it.editing) it else it.copy(id = value, error = null) } } fun updateCustomProviderName(value: String) { @@ -626,11 +650,69 @@ class SettingsViewModel( } fun updateCustomProviderBaseUrl(value: String) { - customProviderDialogState.update { it?.copy(baseUrl = value, error = null) } + customProviderDialogState.update { it?.copy(baseUrl = value, error = null, fetchError = null) } } fun updateCustomProviderModels(value: String) { - customProviderDialogState.update { it?.copy(models = value, error = null) } + customProviderDialogState.update { it?.copy(models = value, error = null, fetchedModelCount = null) } + } + + fun updateCustomProviderApiKey(value: String) { + customProviderDialogState.update { it?.copy(apiKey = value, error = null, fetchError = null) } + } + + /** + * Fills the model field from the endpoint's own `GET /models` listing, using the key + * typed into the dialog or, while editing a registered provider, the one already stored for it. + */ + fun fetchCustomProviderModels() { + val dialog = customProviderDialogState.value ?: return + if (dialog.isBusy || customProviderJob?.isActive == true) return + val baseUrl = dialog.baseUrl.trim() + if (baseUrl.isEmpty()) { + customProviderDialogState.update { it?.copy(fetchError = customProviderFetchNeedsBaseUrlMessage) } + return + } + // The stored key only falls back while editing that very provider: in add mode the id is + // free text, and typing an existing id (say `openai`) would otherwise send that provider's + // saved key to whatever base URL was typed into the dialog. + val apiKey = + dialog.apiKey.trim().ifEmpty { + if (dialog.editing) credentials.credentials()[dialog.id.trim()].orEmpty() else "" + } + customProviderJob = + viewModelScope.launch { + customProviderDialogState.update { + it?.copy(isFetchingModels = true, fetchError = null, fetchedModelCount = null, error = null) + } + runCatching { modelFetcher.fetch(baseUrl, apiKey) } + .onSuccess { models -> + customProviderJob = null + customProviderDialogState.update { + if (models.isEmpty()) { + it?.copy(isFetchingModels = false, fetchError = customProviderFetchEmptyMessage) + } else { + it?.copy( + isFetchingModels = false, + models = models.joinToString(", "), + fetchedModelCount = models.size, + ) + } + } + }.onFailure { error -> + // A dismissed dialog cancels this job; its late failure must not land in a + // dialog opened afterwards, nor clear that dialog's own job reference. + if (error is CancellationException) throw error + customProviderJob = null + val detail = error.message?.takeIf(String::isNotBlank)?.let(SecretRedaction::redact) + customProviderDialogState.update { + it?.copy( + isFetchingModels = false, + fetchError = listOfNotNull(customProviderFetchFailedMessage, detail).joinToString("\n"), + ) + } + } + } } fun dismissCustomProviderDialog() { @@ -646,15 +728,16 @@ class SettingsViewModel( */ fun submitCustomProvider() { val dialog = customProviderDialogState.value ?: return - if (dialog.isSubmitting || customProviderJob?.isActive == true) return + if (dialog.isBusy || customProviderJob?.isActive == true) return val target = providerTarget() ?: return val definition = CustomProviderDefinition( id = dialog.id.trim(), name = dialog.name.trim().ifEmpty { dialog.id.trim() }, baseUrl = dialog.baseUrl.trim(), - models = dialog.models.split(',').map(String::trim).filter(String::isNotEmpty).distinct(), + models = parseModelIdList(dialog.models), ) + val typedApiKey = dialog.apiKey.trim() if (definition.id.isEmpty() || definition.baseUrl.isEmpty() || definition.models.isEmpty()) { customProviderDialogState.update { it?.copy(error = customProviderInvalidMessage) } return @@ -675,8 +758,9 @@ class SettingsViewModel( catalog.refreshProvidersOnly() catalog.refresh() refreshProviderAuth() - openProviderAuth(definition.id) + finishCustomProviderSubmit(definition.id, dialog.editing, typedApiKey, target) }.onFailure { error -> + if (error is CancellationException) throw error customProviderJob = null customProviderDialogState.update { it?.copy(isSubmitting = false, error = error.message?.takeIf(String::isNotBlank) ?: customProviderInvalidMessage) @@ -685,6 +769,30 @@ class SettingsViewModel( } } + private fun finishCustomProviderSubmit( + providerId: String, + editing: Boolean, + typedApiKey: String, + target: RuntimeTarget, + ) { + if (editing && typedApiKey.isEmpty() && credentials.hasCredential(providerId)) { + // Only the definition changed and the stored key still applies: skip the key dialog, + // but the runtime still has to reload to pick up the new model list. + settingsTick.update { it + 1 } + if (target.kind == BackendKind.LOCAL) onLocalRuntimeRestartNeeded?.invoke() + return + } + openProviderAuth(providerId) + if (typedApiKey.isEmpty()) return + // The key was already typed to fetch models; prefill it so the user just confirms instead + // of pasting it a second time. + val apiIndex = oauthState.value.dialog?.methods?.indexOfFirst { it.type == "api" } ?: -1 + if (apiIndex >= 0) { + selectProviderAuthMethod(apiIndex) + updateProviderApiKey(typedApiKey) + } + } + /** Forgets a custom provider entirely: its definition and its stored credential. */ fun removeCustomProvider(providerId: String) { customProviders.remove(providerId) diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/RuntimeCapabilities.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/RuntimeCapabilities.kt index 408937cdb..3b141d0d1 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/RuntimeCapabilities.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/RuntimeCapabilities.kt @@ -55,4 +55,11 @@ data class RuntimeCapabilities( * the settings screens hide those entry points rather than letting the write fail. */ val configEditing: Boolean = false, + /** + * False when the runtime is known to offer no MCP OAuth endpoints. OpenCode V2 handles MCP + * sign-in outside its REST API, so its targets turn this off and the MCP screen hides the + * authenticate/remove-auth actions. Defaults to true so runtimes that never report it keep the + * per-agent decision the MCP screen already makes. + */ + val mcpOAuth: Boolean = true, ) diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CustomProviderModelFetcher.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CustomProviderModelFetcher.kt new file mode 100644 index 000000000..66d8c37fa --- /dev/null +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CustomProviderModelFetcher.kt @@ -0,0 +1,81 @@ +package com.yugahashimoto.andcode.runtime.local + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request + +/** + * Lists the model ids an OpenAI-compatible endpoint serves, via its `GET /models`. + * + * Typing every model id of a custom provider by hand is error-prone, and gateways that proxy many + * vendors expose dozens of them. The response shape is OpenAI's `{"data":[{"id":"..."}]}`; a bare + * array and a `models` field are accepted too, since several self-hosted servers answer that way. + */ +class CustomProviderModelFetcher( + private val client: OkHttpClient = OkHttpClient(), + private val json: Json = Json { ignoreUnknownKeys = true }, +) { + suspend fun fetch( + baseUrl: String, + apiKey: String?, + ): List = + withContext(Dispatchers.IO) { + val url = modelsUrl(baseUrl) + val request = + Request.Builder() + .url(url) + .header("Accept", "application/json") + .apply { + apiKey?.trim()?.takeIf(String::isNotEmpty)?.let { header("Authorization", "Bearer $it") } + } + .get() + .build() + client.newCall(request).execute().use { response -> + check(response.isSuccessful) { "HTTP ${response.code} from $url" } + parseModelIds(response.body?.string().orEmpty(), json) + } + } + + companion object { + /** `/models`, tolerating a trailing slash; fails on anything that is not an http(s) URL. */ + fun modelsUrl(baseUrl: String): String { + val trimmed = baseUrl.trim().trimEnd('/') + requireNotNull(trimmed.toHttpUrlOrNull()) { "Invalid base URL: $baseUrl" } + return "$trimmed/models" + } + + fun parseModelIds( + body: String, + json: Json = Json { ignoreUnknownKeys = true }, + ): List { + val root = runCatching { json.parseToJsonElement(body) }.getOrElse { throw IllegalStateException("Response is not JSON", it) } + val entries: JsonArray = + when (root) { + is JsonArray -> root + is JsonObject -> (root["data"] as? JsonArray) ?: (root["models"] as? JsonArray) ?: JsonArray(emptyList()) + else -> throw IllegalStateException("Expected a model list object or array") + } + return entries + .mapNotNull(::modelId) + .map(String::trim) + .filter(String::isNotEmpty) + .distinct() + .sorted() + } + + private fun modelId(entry: JsonElement): String? = + when (entry) { + is JsonPrimitive -> entry.contentOrNull + is JsonObject -> (entry["id"] as? JsonPrimitive)?.contentOrNull ?: (entry["name"] as? JsonPrimitive)?.contentOrNull + else -> null + } + } +} diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CustomProviderStore.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CustomProviderStore.kt index 426034255..071f0eb2c 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CustomProviderStore.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/CustomProviderStore.kt @@ -84,7 +84,17 @@ class CustomProviderStore( val current = definitions() val currentIds = current.map { it.id }.toSet() (loadSyncedIds() - currentIds).forEach(providers::remove) - current.forEach { definition -> providers[definition.id] = definition.toProviderConfig() } + current.forEach { definition -> + val configured = definition.toProviderConfig() + val existingModels = (providers[definition.id] as? JsonObject)?.get("models") as? JsonObject + // Starting the runtime must not erase models added outside the app, or their custom + // limits/options. Stored definitions supply missing ids, not an authoritative allowlist. + providers[definition.id] = + JsonObject( + configured + + ("models" to JsonObject(configured["models"]!!.jsonObject + existingModels.orEmpty())), + ) + } val updatedRoot = JsonObject(existingRoot + ("provider" to JsonObject(providers))) if (updatedRoot != existingRoot) { diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/GuestProotCommand.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/GuestProotCommand.kt new file mode 100644 index 000000000..b3c9aeacc --- /dev/null +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/GuestProotCommand.kt @@ -0,0 +1,39 @@ +package com.yugahashimoto.andcode.runtime.local + +import com.yugahashimoto.andcode.core.storage.DeviceStorage +import java.io.File + +/** + * The PRoot argument list every guest process shares: the embedded binary, its isolation flags, the + * rootfs, the fixed device binds, and - when [workspace] is given - the bind that mounts the guest + * workspace at `/workspace`. Callers append the working directory and the guest executable plus its + * arguments. + */ +internal fun prootCommandPrefix( + commandSuite: EmbeddedCommandSuite.Paths, + rootfs: File, + workspace: File? = null, +): List = + buildList { + add(commandSuite.proot.absolutePath) + add("--kill-on-exit") + add("--link2symlink") + add("-0") + add("-r") + add(rootfs.absolutePath) + add("-b") + add("/dev") + add("-b") + add("/proc") + add("-b") + add("/sys") + add("-b") + add("/system") + workspace?.let { + add("-b") + add("${it.absolutePath}:/workspace") + } + // Nothing while all-files access is ungranted, so the sandbox stays exactly as + // narrow as it was until the user opens the device up to it. + addAll(DeviceStorage.bindArguments()) + } diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeCommandRunner.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeCommandRunner.kt index 5ec5f8a30..d22cba26d 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeCommandRunner.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeCommandRunner.kt @@ -1,6 +1,5 @@ package com.yugahashimoto.andcode.runtime.local -import com.yugahashimoto.andcode.core.storage.DeviceStorage import java.io.File import java.util.concurrent.TimeUnit @@ -33,29 +32,14 @@ class LocalRuntimeCommandRunner( val outputFile = File.createTempFile("diagnostic-", ".log", File(runtimeDirectory, "logs").apply { mkdirs() }) try { val command = - buildList { - add(runtime.commandSuite.proot.absolutePath) - add("--kill-on-exit") - add("--link2symlink") - add("-0") - add("-r") - add(runtime.rootfs.absolutePath) - add("-b") - add("/dev") - add("-b") - add("/proc") - add("-b") - add("/sys") - add("-b") - add("/system") - // So a shell command can reach the device's files once the user allows it. - addAll(DeviceStorage.bindArguments()) - add("-w") - add("/root") - add("/bin/sh") - add("-lc") - add(commandText) - } + prootCommandPrefix(runtime.commandSuite, runtime.rootfs) + + listOf( + "-w", + "/root", + "/bin/sh", + "-lc", + commandText, + ) val process = ProcessBuilder(command) .redirectErrorStream(true) diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeProcessLauncher.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeProcessLauncher.kt index b5d3315eb..79691c6a3 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeProcessLauncher.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeProcessLauncher.kt @@ -1,6 +1,5 @@ package com.yugahashimoto.andcode.runtime.local -import com.yugahashimoto.andcode.core.storage.DeviceStorage import java.io.File import java.util.concurrent.TimeUnit @@ -56,35 +55,17 @@ class LocalRuntimeProcessLauncher( val prootTmp = File(runtimeDirectory, "proot-tmp").apply { mkdirs() } val command = - buildList { - add(suite.proot.absolutePath) - add("--kill-on-exit") - add("--link2symlink") - add("-0") - add("-r") - add(rootfs.absolutePath) - add("-b") - add("/dev") - add("-b") - add("/proc") - add("-b") - add("/sys") - add("-b") - add("/system") - add("-b") - add("${workspace.absolutePath}:/workspace") - // Nothing while all-files access is ungranted, so the sandbox stays exactly as - // narrow as it was until the user opens the device up to it. - addAll(DeviceStorage.bindArguments()) - add("-w") - add("/workspace") - add("/usr/local/bin/opencode") - add("serve") - add("--hostname") - add("127.0.0.1") - add("--port") - add(port.toString()) - } + prootCommandPrefix(suite, rootfs, workspace) + + listOf( + "-w", + "/workspace", + "/usr/local/bin/opencode", + "serve", + "--hostname", + "127.0.0.1", + "--port", + port.toString(), + ) val builder = ProcessBuilder(command) diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeTarget.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeTarget.kt index 0b66f2e41..7a9c58131 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeTarget.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeTarget.kt @@ -65,6 +65,7 @@ class LocalRuntimeTarget( editMessages = backend.protocol != OpenCodeProtocol.V2, diffCapable = true, configEditing = backend.protocol != OpenCodeProtocol.V2, + mcpOAuth = backend.protocol != OpenCodeProtocol.V2, ) private val mutableState = MutableStateFlow(mapStatus(runtimeManager.status())) diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/StartupCommandsRunner.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/StartupCommandsRunner.kt new file mode 100644 index 000000000..99ce4f56a --- /dev/null +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/StartupCommandsRunner.kt @@ -0,0 +1,178 @@ +package com.yugahashimoto.andcode.runtime.local + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * Launches guest-side startup commands declared in the workspace's + * `.and-code/startup-commands.json` whenever the local runtime is (re)started (issue #379). + * + * The file is read on every [LocalRuntimeProcessLauncher.start]; each entry runs detached in its + * own PRoot instance with `/workspace` as the working directory, so a long-running service neither + * blocks the runtime start nor outlives it: the same managed-process sweep that stops the server + * matches every PRoot rooted in this runtime directory and stops the services too. + * + * Schema (`command` is the only required field): + * ```json + * { + * "commands": [ + * { + * "command": "npx @wonderwhy-er/desktop-commander@latest remote", + * "checkCommand": "pgrep -f desktop-commander" + * } + * ] + * } + * ``` + * A `checkCommand` that exits 0 within [checkTimeoutSeconds] skips its `command` - that is how an + * entry opts into idempotent startup instead of racing a second copy of an already-running service. + * + * Best-effort by design: a missing or malformed config runs nothing, a spawn failure affects only + * its own entry, and nothing here can delay or abort the runtime start. A command's output is + * appended to `logs/startup-command-.log`, truncated the same way the server log is. + */ +class StartupCommandsRunner( + private val runtimeDirectory: File, + private val workspaceDirectory: File = File(runtimeDirectory, "workspace"), + private val githubToken: () -> String? = { null }, + private val checkTimeoutSeconds: Long = 10L, + private val maxLogBytes: Long = 1_048_576L, + /** Overridable so a JVM test can supply a fake [Process] instead of a real guest command. */ + private val processStarter: ( + command: List, + environment: Map, + logFile: File, + ) -> Process = { command, environment, logFile -> + ProcessBuilder(command) + .directory(runtimeDirectory) + .redirectErrorStream(true) + .redirectOutput(ProcessBuilder.Redirect.appendTo(logFile)) + .apply { + environment().clear() + environment().putAll(environment) + } + .start() + }, +) { + private val json: Json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + init { + require(checkTimeoutSeconds > 0) + } + + /** + * Reads the config and launches the commands on a daemon thread. [schedule] runs on the + * runtime start path, so it must return immediately and must never throw - hence the work + * being off-thread and wrapped wholesale. + */ + fun schedule(installed: LocalRuntimeInstaller.InstalledRuntime) { + Thread({ runCatching { runAll(installed) } }, "startup-commands").apply { isDaemon = true }.start() + } + + /** Synchronous for tests; [schedule] is the production entry point. */ + internal fun runAll(installed: LocalRuntimeInstaller.InstalledRuntime) { + val config = readConfig() ?: return + val logs = File(runtimeDirectory, "logs").apply { mkdirs() } + config.commands.forEachIndexed { index, entry -> + val commandText = entry.command.trim() + if (commandText.isEmpty()) return@forEachIndexed + // A blank checkCommand is "no check": probing `/bin/sh -lc ""` would always exit 0 + // and silently skip the command it guards. + val checkCommand = entry.checkCommand?.takeIf(String::isNotBlank) + if (checkCommand != null && alreadyRunning(installed, checkCommand)) { + return@forEachIndexed + } + launchDetached(installed, commandText, File(logs, "$LOG_FILE_PREFIX-$index.log")) + } + } + + private fun readConfig(): StartupCommandsConfig? { + val file = File(workspaceDirectory, CONFIG_FILE_RELATIVE_PATH) + if (!file.isFile) return null + return runCatching { + json.decodeFromString(file.readText()) + }.getOrNull() + } + + /** True when the entry's check command exits 0 in time, i.e. the service is already up. */ + private fun alreadyRunning( + installed: LocalRuntimeInstaller.InstalledRuntime, + checkCommand: String, + ): Boolean { + val logs = File(runtimeDirectory, "logs").apply { mkdirs() } + val outputFile = File.createTempFile("startup-check-", ".log", logs) + val probe = + runCatching { + processStarter(guestCommand(installed, checkCommand), guestEnvironment(installed), outputFile) + }.getOrNull() + if (probe == null) { + outputFile.delete() + return false + } + return try { + val exited = probe.waitFor(checkTimeoutSeconds, TimeUnit.SECONDS) + if (!exited) probe.destroyForcibly() + exited && probe.exitValue() == 0 + } catch (error: InterruptedException) { + Thread.currentThread().interrupt() + probe.destroyForcibly() + false + } finally { + outputFile.delete() + } + } + + private fun launchDetached( + installed: LocalRuntimeInstaller.InstalledRuntime, + commandText: String, + logFile: File, + ) { + truncateLogFile(logFile, maxLogBytes) + // Deliberately not waited on and not tracked. The detached guest process is killed by the + // managed-runtime sweep on stop, which matches every PRoot rooted in this runtime + // directory, so there is no handle worth keeping. + runCatching { processStarter(guestCommand(installed, commandText), guestEnvironment(installed), logFile) } + } + + private fun guestCommand( + installed: LocalRuntimeInstaller.InstalledRuntime, + commandText: String, + ): List = + prootCommandPrefix(installed.commandSuite, installed.rootfs, workspaceDirectory) + + listOf( + "-w", + "/workspace", + "/bin/sh", + "-lc", + commandText, + ) + + private fun guestEnvironment(installed: LocalRuntimeInstaller.InstalledRuntime): Map = + localRuntimeEnvironment( + suiteEnvironment = installed.commandSuite.environment(), + prootTmp = File(runtimeDirectory, "proot-tmp").apply { mkdirs() }, + githubToken = githubToken(), + ) + + private companion object { + const val CONFIG_FILE_RELATIVE_PATH = ".and-code/startup-commands.json" + const val LOG_FILE_PREFIX = "startup-command" + } +} + +@Serializable +private data class StartupCommandsConfig( + @SerialName("commands") val commands: List = emptyList(), +) + +@Serializable +private data class StartupCommandEntry( + @SerialName("command") val command: String = "", + @SerialName("checkCommand") val checkCommand: String? = null, +) diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/remote/RemoteRuntimeTarget.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/remote/RemoteRuntimeTarget.kt index 920b09c44..d32cccd50 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/remote/RemoteRuntimeTarget.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/remote/RemoteRuntimeTarget.kt @@ -58,6 +58,7 @@ class RemoteRuntimeTarget( diffCapable = true, // V1 accepts config patches; V2 does not, so its config entry points stay hidden. configEditing = backend.protocol != OpenCodeProtocol.V2, + mcpOAuth = backend.protocol != OpenCodeProtocol.V2, ) private val mutableState = MutableStateFlow(RuntimeState.Disconnected) diff --git a/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt b/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt index 9cfee9893..ba6e60f34 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt @@ -256,6 +256,9 @@ fun AndCodeApp( voskModels = app.voskModels, providerDisconnectRejectedMessage = context.getString(R.string.provider_disconnect_rejected), customProviderInvalidMessage = context.getString(R.string.provider_custom_invalid), + customProviderFetchNeedsBaseUrlMessage = context.getString(R.string.provider_custom_fetch_needs_base_url), + customProviderFetchEmptyMessage = context.getString(R.string.provider_custom_fetch_empty), + customProviderFetchFailedMessage = context.getString(R.string.provider_custom_fetch_failed), ) }, ) diff --git a/app/src/main/java/com/yugahashimoto/andcode/ui/navigation/SettingsNavGraph.kt b/app/src/main/java/com/yugahashimoto/andcode/ui/navigation/SettingsNavGraph.kt index 1104af3fd..f43ddcb97 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/ui/navigation/SettingsNavGraph.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/ui/navigation/SettingsNavGraph.kt @@ -454,6 +454,9 @@ fun NavGraphBuilder.settingsNavGraph( onCustomProviderNameChange = settingsViewModel::updateCustomProviderName, onCustomProviderBaseUrlChange = settingsViewModel::updateCustomProviderBaseUrl, onCustomProviderModelsChange = settingsViewModel::updateCustomProviderModels, + onCustomProviderApiKeyChange = settingsViewModel::updateCustomProviderApiKey, + onFetchCustomProviderModels = settingsViewModel::fetchCustomProviderModels, + onEditCustomProvider = settingsViewModel::openEditCustomProvider, onSubmitCustomProvider = settingsViewModel::submitCustomProvider, onDismissCustomProviderDialog = settingsViewModel::dismissCustomProviderDialog, onRemoveCustomProvider = settingsViewModel::removeCustomProvider, diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index ce5bd904e..b4a06946e 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -198,6 +198,22 @@ مفصولة بفواصل، مثال: gpt-4o, gpt-4o-mini أدخل معرف الموفر وعنوان URL الأساسي ونموذجًا واحدًا على الأقل. إزالة + تعديل + تعديل المزوّد المخصّص + جلب النماذج + اختياري. يُستخدم لجلب النماذج من <base URL>/models، ويمكنك أيضًا إدخاله في الخطوة التالية. + اتركه فارغًا لاستخدام مفتاح API المحفوظ. + أدخل عنوان URL الأساسي أولًا. + لم تُرجع نقطة النهاية أي نماذج. + تعذّر جلب قائمة النماذج. + + تم جلب %1$d نموذج. احذف ما لا تحتاج إليه. + تم جلب نموذج واحد. احذفه إن لم تكن بحاجة إليه. + تم جلب نموذجين. احذف ما لا تحتاج إليه. + تم جلب %1$d نماذج. احذف ما لا تحتاج إليه. + تم جلب %1$d نموذجًا. احذف ما لا تحتاج إليه. + تم جلب %1$d نموذج. احذف ما لا تحتاج إليه. + متابعة أخرى (اكتب إجابتك) رفض الإجابة @@ -470,6 +486,7 @@ تخطيط قبول تلقائي تلقائي + %1$s tok/s عمليات GitHub Git غير متصل الاتصال بـ GitHub diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 20e1da151..c9002a930 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -195,6 +195,19 @@ Separados por comas, ej. gpt-4o, gpt-4o-mini Ingresa un ID de proveedor, una URL base y al menos un ID de modelo. Eliminar + Editar + Editar proveedor personalizado + Obtener modelos + Opcional. Se usa para obtener los modelos de <base URL>/models; también puedes introducirla en el siguiente paso. + Déjalo vacío para usar la clave API guardada. + Introduce primero una URL base. + El endpoint no devolvió ningún modelo. + No se pudo obtener la lista de modelos. + + Se obtuvo %1$d modelo. Elimina los que no quieras. + Se obtuvieron %1$d modelos. Elimina los que no quieras. + Se obtuvieron %1$d modelos. Elimina los que no quieras. + Continuar Otro (escribe tu respuesta) No responder @@ -467,6 +480,7 @@ Planificar Aceptación automática Auto + %1$s tok/s Operaciones Git de GitHub No conectado Conectar GitHub diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 4193c4ad7..7794e2f48 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -195,6 +195,19 @@ Séparés par des virgules, ex. gpt-4o, gpt-4o-mini Saisissez un ID de fournisseur, une URL de base et au moins un ID de modèle. Supprimer + Modifier + Modifier le fournisseur personnalisé + Récupérer les modèles + Facultatif. Sert à récupérer les modèles depuis <base URL>/models ; vous pouvez aussi la saisir à l\'étape suivante. + Laissez vide pour utiliser la clé API enregistrée. + Saisissez d\'abord une URL de base. + Le point de terminaison n\'a renvoyé aucun modèle. + Impossible de récupérer la liste des modèles. + + %1$d modèle récupéré. Supprimez ceux dont vous n\'avez pas besoin. + %1$d modèles récupérés. Supprimez ceux dont vous n\'avez pas besoin. + %1$d modèles récupérés. Supprimez ceux dont vous n\'avez pas besoin. + Continuer Autre (saisissez votre réponse) Ne pas répondre @@ -467,6 +480,7 @@ Planifier Acceptation automatique Auto + %1$s tok/s Opérations Git GitHub Non connecté Connecter GitHub diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 9dd253c46..fc319ecdb 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -193,6 +193,17 @@ カンマ区切り。例: gpt-4o, gpt-4o-mini プロバイダーID、ベースURL、モデルIDを少なくとも1つ入力してください。 削除 + 編集 + カスタムプロバイダーを編集 + モデル一覧を取得 + 任意。<base URL>/models からモデルを取得するために使います。次の手順で入力することもできます。 + 空欄のままにすると保存済みの API キーを使います。 + 先にベース URL を入力してください。 + エンドポイントがモデルを返しませんでした。 + モデル一覧を取得できませんでした。 + + %1$d 件のモデルを取得しました。不要なものは削除してください。 + 続ける その他(自由入力) 回答しない @@ -467,6 +478,7 @@ 計画 自動承認 自動 + %1$s tok/s GitHub Git 操作 未接続 GitHubに接続 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index d35373c48..567c4d4e5 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -195,6 +195,19 @@ Separados por vírgula, ex. gpt-4o, gpt-4o-mini Informe um ID de provedor, uma URL base e pelo menos um ID de modelo. Remover + Editar + Editar provedor personalizado + Buscar modelos + Opcional. Usada para buscar os modelos em <base URL>/models; você também pode informá-la na próxima etapa. + Deixe em branco para usar a chave de API salva. + Informe primeiro uma URL base. + O endpoint não retornou nenhum modelo. + Não foi possível buscar a lista de modelos. + + %1$d modelo encontrado. Remova os que não quiser. + %1$d modelos encontrados. Remova os que não quiser. + %1$d modelos encontrados. Remova os que não quiser. + Continuar Outro (digite sua resposta) Não responder @@ -467,6 +480,7 @@ Planejar Aceitação automática Auto + %1$s tok/s Operações Git do GitHub Não conectado Conectar GitHub diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 1626bc403..c54572b78 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -196,6 +196,20 @@ Через запятую, напр. gpt-4o, gpt-4o-mini Введите ID провайдера, базовый URL и хотя бы один ID модели. Удалить + Изменить + Изменить пользовательского провайдера + Получить модели + Необязательно. Нужен для получения моделей из <base URL>/models; его также можно ввести на следующем шаге. + Оставьте пустым, чтобы использовать сохранённый API-ключ. + Сначала введите базовый URL. + Эндпоинт не вернул ни одной модели. + Не удалось получить список моделей. + + Получена %1$d модель. Удалите ненужные. + Получено %1$d модели. Удалите ненужные. + Получено %1$d моделей. Удалите ненужные. + Получено %1$d модели. Удалите ненужные. + Продолжить Другое (введите свой ответ) Не отвечать @@ -468,6 +482,7 @@ План Автоприём Авто + %1$s tok/s Операции GitHub Git Не подключено Подключить GitHub diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 56c2d2c23..47bc074cb 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -193,6 +193,17 @@ 用逗号分隔,例如 gpt-4o, gpt-4o-mini 请输入提供商 ID、基础 URL 以及至少一个模型 ID。 移除 + 编辑 + 编辑自定义提供商 + 获取模型列表 + 可选。用于从 <base URL>/models 获取模型;也可以在下一步再输入。 + 留空则使用已保存的 API 密钥。 + 请先输入基础 URL。 + 该端点没有返回任何模型。 + 无法获取模型列表。 + + 已获取 %1$d 个模型。可删除不需要的模型。 + 继续 其他(自行输入答案) 不回答 @@ -465,6 +476,7 @@ 计划 自动接受 自动 + %1$s tok/s GitHub Git 操作 未连接 连接 GitHub diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9ded3562e..3ca369747 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -180,6 +180,18 @@ Comma-separated, e.g. gpt-4o, gpt-4o-mini Enter a provider ID, base URL, and at least one model ID. Remove + Edit + Edit custom provider + Fetch models + Optional. Used to fetch models from <base URL>/models; you can also enter it in the next step. + Leave empty to use the saved API key. + Enter a base URL first. + The endpoint returned no models. + Could not fetch the model list. + + Fetched %1$d model. Remove any you don\'t want. + Fetched %1$d models. Remove any you don\'t want. + Continue Other (type your own answer) Decline to answer @@ -454,6 +466,7 @@ Plan Auto accept Auto + %1$s tok/s GitHub Git operations Not connected Connect GitHub diff --git a/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClientTest.kt b/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClientTest.kt index 7183279a1..1d2daf03b 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClientTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeV2ApiClientTest.kt @@ -145,6 +145,8 @@ class OpenCodeV2ApiClientTest { @Test fun `probe does not cache a transport failure`() = runBlocking { + server.enqueue(MockResponse().setSocketPolicy(okhttp3.mockwebserver.SocketPolicy.DISCONNECT_AT_START)) + // The legacy fallback after a transport failure finds no V1 server either. server.enqueue(MockResponse().setSocketPolicy(okhttp3.mockwebserver.SocketPolicy.DISCONNECT_AT_START)) server.enqueue(json(V2_INFO)) val detector = ProtocolDetectingOpenCodeApi(profile()) @@ -161,6 +163,8 @@ class OpenCodeV2ApiClientTest { fun `a 5xx from api info neither decides the protocol nor is cached`() = runBlocking { server.enqueue(MockResponse().setResponseCode(503).setBody("upstream")) + // A V2 server answers the legacy health path with its SPA shell, not V1 JSON. + server.enqueue(MockResponse().setHeader("Content-Type", "text/html").setBody("")) server.enqueue(json(V2_INFO)) val detector = ProtocolDetectingOpenCodeApi(profile()) @@ -172,6 +176,34 @@ class OpenCodeV2ApiClientTest { assertEquals("2.0.28", detector.health().version) } + @Test + fun `a V1 server whose api info proxy fails is still confirmed through global health`() = + runBlocking { + // Older V1 servers proxy unknown paths to app.opencode.ai; when that host is + // unreachable, api/info is a 500 even though the server itself is healthy. + server.enqueue(MockResponse().setResponseCode(500).setBody("proxy error")) + server.enqueue(json("""{"healthy":true,"version":"1.2.0"}""")) + val detector = ProtocolDetectingOpenCodeApi(profile()) + + val health = detector.health() + + assertEquals("1.2.0", health.version) + assertEquals(OpenCodeProtocol.V1, detector.protocol) + } + + @Test + fun `a 5xx from api info with a non 1 x global health is not taken as V1`() = + runBlocking { + server.enqueue(MockResponse().setResponseCode(500)) + server.enqueue(json("""{"healthy":true,"version":"2.0.28"}""")) + val detector = ProtocolDetectingOpenCodeApi(profile()) + + val failed = runCatching { detector.health() }.exceptionOrNull() + + assertEquals(500, (failed as OpenCodeApiException).statusCode) + assertNull(detector.protocol) + } + @Test fun `V1 is only confirmed when global health succeeds`() = runBlocking { @@ -761,15 +793,14 @@ class OpenCodeV2ApiClientTest { } @Test - fun `agents map id to name and keep mode and hidden`() = + fun `agents map id to name, keep mode and drop hidden agents`() = runBlocking { server.enqueue(json(AGENT_LIST)) val agents = client().agents() - assertEquals(listOf("build", "compaction"), agents.map { it.name }) + assertEquals(listOf("build"), agents.map { it.name }) assertEquals("primary", agents[0].mode) - assertTrue(agents[1].hidden) } @Test diff --git a/app/src/test/java/com/yugahashimoto/andcode/feature/settings/CustomProviderDialogStateTest.kt b/app/src/test/java/com/yugahashimoto/andcode/feature/settings/CustomProviderDialogStateTest.kt new file mode 100644 index 000000000..9d2f26631 --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/feature/settings/CustomProviderDialogStateTest.kt @@ -0,0 +1,23 @@ +package com.yugahashimoto.andcode.feature.settings + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CustomProviderDialogStateTest { + @Test + fun `parseModelIdList splits on commas and newlines and drops blanks and duplicates`() { + assertEquals( + listOf("gpt-4o", "gpt-4o-mini", "llama3"), + parseModelIdList(" gpt-4o, gpt-4o-mini\nllama3,,gpt-4o \n"), + ) + } + + @Test + fun `isBusy covers both fetching and submitting`() { + assertFalse(CustomProviderDialogState().isBusy) + assertTrue(CustomProviderDialogState(isFetchingModels = true).isBusy) + assertTrue(CustomProviderDialogState(isSubmitting = true).isBusy) + } +} diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CustomProviderModelFetcherTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CustomProviderModelFetcherTest.kt new file mode 100644 index 000000000..d2629a630 --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CustomProviderModelFetcherTest.kt @@ -0,0 +1,89 @@ +package com.yugahashimoto.andcode.runtime.local + +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class CustomProviderModelFetcherTest { + private lateinit var server: MockWebServer + private val fetcher = CustomProviderModelFetcher(OkHttpClient()) + + @Before + fun setUp() { + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun `fetch reads OpenAI style data ids and sends the bearer key`() { + server.enqueue( + MockResponse().setBody("""{"object":"list","data":[{"id":"b-model"},{"id":"a-model"},{"id":"b-model"}]}"""), + ) + + val models = runBlocking { fetcher.fetch(server.url("/v1/").toString(), " sk-test-key ") } + + assertEquals(listOf("a-model", "b-model"), models) + val request = server.takeRequest() + assertEquals("/v1/models", request.path) + assertEquals("Bearer sk-test-key", request.getHeader("Authorization")) + } + + @Test + fun `fetch omits the authorization header without a key`() { + server.enqueue(MockResponse().setBody("""{"data":[{"id":"llama3"}]}""")) + + val models = runBlocking { fetcher.fetch(server.url("/v1").toString(), " ") } + + assertEquals(listOf("llama3"), models) + assertNull(server.takeRequest().getHeader("Authorization")) + } + + @Test + fun `fetch fails on an http error`() { + server.enqueue(MockResponse().setResponseCode(401).setBody("""{"error":"unauthorized"}""")) + + val error = + assertThrows(IllegalStateException::class.java) { + runBlocking { fetcher.fetch(server.url("/v1").toString(), "bad") } + } + assertTrue(error.message.orEmpty().contains("401")) + } + + @Test + fun `modelsUrl rejects a non http url`() { + assertThrows(IllegalArgumentException::class.java) { CustomProviderModelFetcher.modelsUrl("not a url") } + assertEquals("https://api.example.com/v1/models", CustomProviderModelFetcher.modelsUrl(" https://api.example.com/v1/ ")) + } + + @Test + fun `parseModelIds accepts bare arrays and models fields`() { + assertEquals(listOf("x", "y"), CustomProviderModelFetcher.parseModelIds("""["y","x"," "]""")) + assertEquals(listOf("m1"), CustomProviderModelFetcher.parseModelIds("""{"models":[{"name":"m1"}]}""")) + assertEquals(emptyList(), CustomProviderModelFetcher.parseModelIds("""{"data":"nope"}""")) + } + + @Test + fun `parseModelIds rejects non json`() { + assertThrows(IllegalStateException::class.java) { CustomProviderModelFetcher.parseModelIds("") } + } + + @Test + fun `parseModelIds rejects scalar responses rather than treating them as empty lists`() { + listOf("null", "true", "42", "\"error\"").forEach { body -> + assertThrows(IllegalStateException::class.java) { CustomProviderModelFetcher.parseModelIds(body) } + } + } +} diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CustomProviderStoreTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CustomProviderStoreTest.kt index 51a8fdcd2..3d4ea6634 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CustomProviderStoreTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/CustomProviderStoreTest.kt @@ -151,6 +151,46 @@ class CustomProviderStoreTest { assertEquals("not-json", configFile.readText()) } + @Test + fun `sync preserves existing model ids and metadata while adding missing stored models`() { + val rootfs = temp.newFolder("models-rootfs") + val configFile = + File(rootfs, "root/.config/opencode/opencode.json").apply { + parentFile.mkdirs() + writeText( + """ + { + "disabled_providers": ["opencode"], + "provider": { + "acme": { + "models": { + "manual": {"name": "Manual model", "options": {"temperature": 0.5}}, + "shared": {"name": "Custom name", "limit": {"context": 100000}} + } + } + } + } + """.trimIndent(), + ) + } + val before = Json.parseToJsonElement(configFile.readText()).jsonObject + val originalModels = before["provider"]!!.jsonObject["acme"]!!.jsonObject["models"]!!.jsonObject + val memory = memoryStore() + memory.store.upsert(CustomProviderDefinition("acme", "Acme", "https://acme.example/v1", listOf("shared", "new"))) + + memory.store.syncToRuntime(rootfs) + + val after = Json.parseToJsonElement(configFile.readText()).jsonObject + val models = after["provider"]!!.jsonObject["acme"]!!.jsonObject["models"]!!.jsonObject + assertEquals(setOf("manual", "shared", "new"), models.keys) + assertEquals(originalModels["manual"], models["manual"]) + assertEquals(originalModels["shared"], models["shared"]) + assertEquals(before["disabled_providers"], after["disabled_providers"]) + + memory.store.syncToRuntime(rootfs) + assertEquals(after, Json.parseToJsonElement(configFile.readText()).jsonObject) + } + private fun memoryStore(): MemoryProviderStore { val definitions = mutableListOf() val syncedIds = mutableSetOf() diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/StartupCommandsRunnerTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/StartupCommandsRunnerTest.kt new file mode 100644 index 000000000..a20cdb6c3 --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/StartupCommandsRunnerTest.kt @@ -0,0 +1,280 @@ +package com.yugahashimoto.andcode.runtime.local + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.InputStream +import java.io.OutputStream +import java.util.concurrent.TimeUnit + +class StartupCommandsRunnerTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val launches = mutableListOf() + + /** Returns the process for a launch, or null to simulate a spawn failure. */ + private var nextProcess: (RecordedLaunch) -> Process? = { FakeProcess() } + + private lateinit var runtimeDirectory: File + + private lateinit var workspaceDirectory: File + + private fun setUpRunner(githubToken: String? = null): StartupCommandsRunner { + runtimeDirectory = temporaryFolder.newFolder("runtime") + workspaceDirectory = temporaryFolder.newFolder("workspace") + return StartupCommandsRunner( + runtimeDirectory = runtimeDirectory, + workspaceDirectory = workspaceDirectory, + githubToken = { githubToken }, + processStarter = { command, environment, logFile -> + val launch = RecordedLaunch(command, environment, logFile) + launches += launch + nextProcess(launch) ?: error("simulated spawn failure") + }, + ) + } + + private fun writeConfig(json: String) { + File(workspaceDirectory, ".and-code").mkdirs() + File(workspaceDirectory, ".and-code/startup-commands.json").writeText(json) + } + + @Test + fun `missing config runs nothing`() { + val runner = setUpRunner() + + runner.runAll(installedRuntime()) + + assertTrue(launches.isEmpty()) + } + + @Test + fun `commands run detached in the guest shell at the workspace`() { + val runner = setUpRunner() + writeConfig( + """{"commands":[{"command":"npx @wonderwhy-er/desktop-commander@latest remote"},""" + + """{"command":"python3 -m http.server 8000"}]}""", + ) + + runner.runAll(installedRuntime()) + + assertEquals(2, launches.size) + val first = launches[0] + assertEquals("/suite-proot", first.command.first()) + assertTrue("rootfs is the installed one", first.command.contains("/runtime-rootfs")) + assertTrue( + "workspace is bound into the guest", + first.command.contains("${workspaceDirectory.absolutePath}:/workspace"), + ) + assertEquals("-w", first.command[first.command.size - 5]) + assertEquals("/workspace", first.command[first.command.size - 4]) + assertEquals("/bin/sh", first.command[first.command.size - 3]) + assertEquals("-lc", first.command[first.command.size - 2]) + assertEquals("npx @wonderwhy-er/desktop-commander@latest remote", first.command.last()) + assertEquals( + File(runtimeDirectory, "logs/startup-command-0.log").absolutePath, + first.logFile.absolutePath, + ) + assertEquals( + File(runtimeDirectory, "logs/startup-command-1.log").absolutePath, + launches[1].logFile.absolutePath, + ) + } + + @Test + fun `guest environment is the shared local runtime environment`() { + val runner = setUpRunner(githubToken = "token-from-encrypted-store") + writeConfig("""{"commands":[{"command":"echo hi"}]}""") + + runner.runAll(installedRuntime()) + + val environment = launches.single().environment + assertEquals("/root", environment["HOME"]) + assertEquals("token-from-encrypted-store", environment["OPENCODE_GITHUB_TOKEN"]) + assertEquals( + File(runtimeDirectory, "proot-tmp").absolutePath, + environment["PROOT_TMP_DIR"], + ) + } + + @Test + fun `check command exiting zero skips its command`() { + val runner = setUpRunner() + writeConfig( + """{"commands":[{"command":"npx desktop-commander remote","checkCommand":"pgrep -f desktop-commander"},""" + + """{"command":"python3 -m http.server 8000"}]}""", + ) + nextProcess = { launch -> + if (launch.command.last() == "pgrep -f desktop-commander") FakeProcess(exitCode = 0) else FakeProcess() + } + + runner.runAll(installedRuntime()) + + assertFalse( + "the guarded command must not run", + launches.any { it.command.last() == "npx desktop-commander remote" }, + ) + assertEquals( + "the unguarded entry still runs", + listOf("pgrep -f desktop-commander", "python3 -m http.server 8000"), + launches.map { it.command.last() }, + ) + } + + @Test + fun `check command failing runs the command anyway`() { + val runner = setUpRunner() + writeConfig("""{"commands":[{"command":"npx desktop-commander remote","checkCommand":"pgrep -f desktop-commander"}]}""") + nextProcess = { launch -> + if (launch.command.last() == "pgrep -f desktop-commander") FakeProcess(exitCode = 1) else FakeProcess() + } + + runner.runAll(installedRuntime()) + + assertEquals( + listOf("pgrep -f desktop-commander", "npx desktop-commander remote"), + launches.map { it.command.last() }, + ) + } + + @Test + fun `check command that hangs is killed and its command still runs`() { + val runner = setUpRunner() + writeConfig("""{"commands":[{"command":"npx desktop-commander remote","checkCommand":"pgrep -f desktop-commander"}]}""") + var killed = false + nextProcess = { launch -> + if (launch.command.last() == "pgrep -f desktop-commander") { + FakeProcess(exitCode = 0, exitsInTime = false, onDestroyed = { killed = true }) + } else { + FakeProcess() + } + } + + runner.runAll(installedRuntime()) + + assertTrue("the hung probe must be killed", killed) + assertEquals("npx desktop-commander remote", launches.last().command.last()) + } + + @Test + fun `blank check command is treated as no check and runs the command`() { + val runner = setUpRunner() + writeConfig("""{"commands":[{"command":"npx desktop-commander remote","checkCommand":" "}]}""") + + runner.runAll(installedRuntime()) + + // An empty-shell probe would always exit 0 and silently skip the guarded command. + assertEquals(listOf("npx desktop-commander remote"), launches.map { it.command.last() }) + } + + @Test + fun `malformed config runs nothing and never throws`() { + val runner = setUpRunner() + writeConfig("""{"commands": [ {"command": """") + + runner.runAll(installedRuntime()) + + assertTrue(launches.isEmpty()) + } + + @Test + fun `blank command entries are skipped`() { + val runner = setUpRunner() + writeConfig("""{"commands":[{"command":" "},{"command":"python3 -m http.server 8000"}]}""") + + runner.runAll(installedRuntime()) + + assertEquals(1, launches.size) + assertEquals( + "the surviving entry keeps its own log slot", + File(runtimeDirectory, "logs/startup-command-1.log").absolutePath, + launches.single().logFile.absolutePath, + ) + } + + @Test + fun `one entry failing to spawn does not stop the others`() { + val runner = setUpRunner() + writeConfig("""{"commands":[{"command":"will-not-spawn"},{"command":"python3 -m http.server 8000"}]}""") + nextProcess = { launch -> + if (launch.command.last() == "will-not-spawn") null else FakeProcess() + } + + runner.runAll(installedRuntime()) + + assertEquals( + listOf("will-not-spawn", "python3 -m http.server 8000"), + launches.map { it.command.last() }, + ) + } + + @Test + fun `detached processes are not waited on`() { + val runner = setUpRunner() + writeConfig("""{"commands":[{"command":"python3 -m http.server 8000"}]}""") + var waited = false + nextProcess = { FakeProcess(onWaitFor = { waited = true }) } + + runner.runAll(installedRuntime()) + + assertFalse("a detached service must never block the caller", waited) + } + + private fun installedRuntime(): LocalRuntimeInstaller.InstalledRuntime = + LocalRuntimeInstaller.InstalledRuntime( + metadata = LocalRuntimeMetadata(version = "1.17.20", port = 4096, installedAt = 123), + commandSuite = + EmbeddedCommandSuite.Paths( + home = File("/suite-home"), + tmp = File("/suite-tmp"), + nativeLibraryDirectory = File("/suite-native"), + proot = File("/suite-proot"), + loader = File("/suite-loader"), + loader32 = File("/suite-loader32"), + ), + rootfs = File("/runtime-rootfs"), + openCode = null, + ) + + private data class RecordedLaunch( + val command: List, + val environment: Map, + val logFile: File, + ) + + private class FakeProcess( + private val exitCode: Int = 0, + private val exitsInTime: Boolean = true, + private val onDestroyed: () -> Unit = {}, + private val onWaitFor: () -> Unit = {}, + ) : Process() { + override fun getOutputStream(): OutputStream = error("unused") + + override fun getInputStream(): InputStream = error("unused") + + override fun getErrorStream(): InputStream = error("unused") + + override fun waitFor(): Int = exitCode + + override fun waitFor( + timeout: Long, + unit: TimeUnit, + ): Boolean { + onWaitFor() + return exitsInTime + } + + override fun exitValue(): Int = exitCode + + override fun destroy() { + onDestroyed() + } + + override fun destroyForcibly(): Process = apply { onDestroyed() } + } +} diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/remote/RemoteRuntimeTargetCapabilitiesTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/remote/RemoteRuntimeTargetCapabilitiesTest.kt new file mode 100644 index 000000000..0ff5f025d --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/remote/RemoteRuntimeTargetCapabilitiesTest.kt @@ -0,0 +1,98 @@ +package com.yugahashimoto.andcode.runtime.remote + +import com.yugahashimoto.andcode.data.connection.ConnectionProfile +import com.yugahashimoto.andcode.feature.settings.McpViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RemoteRuntimeTargetCapabilitiesTest { + private lateinit var server: MockWebServer + + @Before + fun setUp() { + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.shutdown() + } + + private fun target() = + RemoteRuntimeTarget( + ConnectionProfile( + id = "remote", + name = "Remote", + baseUrl = server.url("/").toString(), + username = "opencode", + password = "pw", + allowInsecureLan = true, + ), + ) + + @Test + fun `a V1 server keeps message edits, config editing and MCP OAuth`() = + runBlocking { + server.enqueue(MockResponse().setHeader("Content-Type", "text/html").setBody("")) + server.enqueue(MockResponse().setBody("""{"healthy":true,"version":"1.18.5"}""")) + val target = target() + + target.connect().getOrThrow() + + val capabilities = target.capabilities + assertTrue(capabilities.editMessages) + assertTrue(capabilities.configEditing) + assertTrue(capabilities.mcpOAuth) + } + + @Test + fun `a V2 server turns off what its API does not offer`() = + runBlocking { + server.enqueue(MockResponse().setBody("""{"version":"2.0.28","pid":1,"urls":[],"paths":{},"capabilities":{}}""")) + val target = target() + + target.connect().getOrThrow() + + val capabilities = target.capabilities + assertFalse(capabilities.editMessages) + assertFalse(capabilities.configEditing) + assertFalse(capabilities.mcpOAuth) + // What V2 does support is unchanged. + assertTrue(capabilities.permissions) + assertTrue(capabilities.diffCapable) + } + + @Test + fun `the MCP screen hides OAuth actions on a V2 server`() { + // The view model loads on the main dispatcher; run it eagerly on this thread. + Dispatchers.setMain(Dispatchers.Unconfined) + try { + server.enqueue(MockResponse().setBody("""{"version":"2.0.28","pid":1,"urls":[],"paths":{},"capabilities":{}}""")) + server.enqueue(MockResponse().setBody("""{"data":[]}""")) + val target = target() + + val viewModel = McpViewModel({ target }) + val deadline = System.currentTimeMillis() + 5_000 + while (viewModel.state.value.isLoading || server.requestCount < 2) { + check(System.currentTimeMillis() < deadline) { "MCP list never loaded" } + Thread.sleep(10) + } + + assertFalse(viewModel.state.value.supportsOAuth) + } finally { + Dispatchers.resetMain() + } + } +}