From 9115166b74e019849823debb609723f1fff6de72 Mon Sep 17 00:00:00 2001 From: Ashot Nazaryan Date: Wed, 15 Apr 2026 16:49:45 -0700 Subject: [PATCH 1/7] feat: introduce settings UI --- .../relay/settings/OpenCodeSettings.kt | 45 +++ .../OpenCodeSettingsChangedListener.kt | 5 + .../settings/OpenCodeSettingsConfigurable.kt | 266 +++++++++++++++++- .../OpenCodeSettingsConfigurableTest.kt | 145 ++++++++++ 4 files changed, 459 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt index 474bc6f..0da9b97 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt @@ -10,6 +10,11 @@ import com.intellij.openapi.project.Project ) class OpenCodeSettings : PersistentStateComponent { + data class EnvironmentVariable( + var name: String = "", + var value: String = "", + ) + enum class TerminalEngine { /** JBTerminalWidget (classic terminal plugin, works on all supported IDE versions). */ CLASSIC, @@ -20,6 +25,11 @@ class OpenCodeSettings : PersistentStateComponent { data class State( var serverPort: Int = 4096, + var serverHostname: String = "127.0.0.1", + var serverMdnsEnabled: Boolean = false, + var serverMdnsDomain: String = "opencode.local", + var serverCorsOrigins: String = "", + var serverEnvironmentVariables: MutableList = mutableListOf(), var executablePath: String = "", var inlineDiffEnabled: Boolean = true, var diffTraceEnabled: Boolean = false, @@ -43,6 +53,36 @@ class OpenCodeSettings : PersistentStateComponent { state.serverPort = value } + var serverHostname: String + get() = state.serverHostname + set(value) { + state.serverHostname = value + } + + var serverMdnsEnabled: Boolean + get() = state.serverMdnsEnabled + set(value) { + state.serverMdnsEnabled = value + } + + var serverMdnsDomain: String + get() = state.serverMdnsDomain + set(value) { + state.serverMdnsDomain = value + } + + var serverCorsOrigins: String + get() = state.serverCorsOrigins + set(value) { + state.serverCorsOrigins = value + } + + var serverEnvironmentVariables: MutableList + get() = state.serverEnvironmentVariables + set(value) { + state.serverEnvironmentVariables = value + } + var executablePath: String get() = state.executablePath set(value) { @@ -93,6 +133,11 @@ class OpenCodeSettings : PersistentStateComponent { fun OpenCodeSettings.snapshot(): OpenCodeSettingsSnapshot = OpenCodeSettingsSnapshot( serverPort = serverPort, + serverHostname = serverHostname, + serverMdnsEnabled = serverMdnsEnabled, + serverMdnsDomain = serverMdnsDomain, + serverCorsOrigins = serverCorsOrigins, + serverEnvironmentVariables = serverEnvironmentVariables.map { it.copy() }, executablePath = executablePath, inlineDiffEnabled = inlineDiffEnabled, diffTraceEnabled = diffTraceEnabled, diff --git a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsChangedListener.kt b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsChangedListener.kt index 8c772c3..375a832 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsChangedListener.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsChangedListener.kt @@ -4,6 +4,11 @@ import com.intellij.util.messages.Topic data class OpenCodeSettingsSnapshot( val serverPort: Int, + val serverHostname: String, + val serverMdnsEnabled: Boolean, + val serverMdnsDomain: String, + val serverCorsOrigins: String, + val serverEnvironmentVariables: List, val executablePath: String, val inlineDiffEnabled: Boolean, val diffTraceEnabled: Boolean, diff --git a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt index 248fd7b..fc7ed04 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt @@ -15,20 +15,62 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.ui.DialogPanel import com.intellij.openapi.ui.Messages import com.intellij.openapi.ui.TextFieldWithBrowseButton +import com.intellij.ui.ToolbarDecorator import com.intellij.ui.components.JBTextField import com.intellij.ui.dsl.builder.* +import com.intellij.ui.table.TableView +import com.intellij.util.ui.ColumnInfo +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.ListTableModel import java.util.concurrent.atomic.AtomicReference +import javax.swing.JCheckBox +import javax.swing.JComponent +import javax.swing.text.JTextComponent class OpenCodeSettingsConfigurable(private val project: Project) : BoundConfigurable("OpenCode Relay") { + internal data class CorsOriginRow(var origin: String = "") + + private data class EditableTablePanel( + val table: TableView, + val panel: JComponent, + ) + private val pendingState = OpenCodeSettings.State() + internal lateinit var serverCorsOriginsModel: ListTableModel + internal lateinit var serverEnvironmentVariablesModel: ListTableModel + internal lateinit var serverCorsOriginsTable: TableView + internal lateinit var serverEnvironmentVariablesTable: TableView + + internal lateinit var serverHostnameField: JBTextField + internal lateinit var serverMdnsEnabledCheckBox: JCheckBox + internal lateinit var serverMdnsDomainField: JBTextField internal var executableResolver: (String?) -> OpenCodeInfo? = { path -> OpenCodeChecker.findExecutable(path) } override fun createPanel(): DialogPanel { loadPendingFromPersisted() + serverCorsOriginsModel = ListTableModel(corsOriginColumn()) + val serverCorsOriginsEditor = createEditableTablePanel( + model = serverCorsOriginsModel, + preferredHeight = 110, + onAddRow = ::CorsOriginRow, + ) + serverCorsOriginsTable = serverCorsOriginsEditor.table + serverEnvironmentVariablesModel = + ListTableModel(environmentVariableNameColumn(), environmentVariableValueColumn()) + val serverEnvironmentVariablesEditor = createEditableTablePanel( + model = serverEnvironmentVariablesModel, + preferredHeight = 140, + onAddRow = ::environmentVariableRow, + ) + serverEnvironmentVariablesTable = serverEnvironmentVariablesEditor.table + + syncServerCorsOriginsModel(pendingState.serverCorsOrigins) + syncServerEnvironmentVariablesModel(pendingState.serverEnvironmentVariables) + return panel { group("Executable") { val executablePathField = TextFieldWithBrowseButton().apply { @@ -65,10 +107,44 @@ class OpenCodeSettingsConfigurable(private val project: Project) : } } group("Server") { - row("Server Port:") { + row("Port:") { intTextField(1024..65535) .bindIntText(pendingState::serverPort) - .comment("Port the OpenCode server listens on (default: 4096)") + .comment("`--port`: Port to listen on. Default: 4096") + } + row("Hostname:") { + val cell = textField() + .bindText(pendingState::serverHostname) + .comment("`--hostname`: Hostname to listen on. Default: 127.0.0.1") + .align(AlignX.FILL) + serverHostnameField = cell.component + } + row("mDNS:") { + val cell = checkBox("Enable discovery") + .bindSelected(pendingState::serverMdnsEnabled) + .comment("`--mdns`: Enable mDNS discovery. Default: false") + serverMdnsEnabledCheckBox = cell.component + } + row("mDNS Domain:") { + val cell = textField() + .bindText(pendingState::serverMdnsDomain) + .comment("`--mdns-domain`: Custom domain name for mDNS service. Default: opencode.local") + .align(AlignX.FILL) + serverMdnsDomainField = cell.component + } + row("CORS Origins:") { + cell(serverCorsOriginsEditor.panel) + .comment("`--cors`: Additional browser origins to allow. Default: []. Use Add and Remove to manage entries, and edit values inline.") + .align(AlignX.FILL) + .resizableColumn() + } + } + group("Environment") { + row("Variables:") { + cell(serverEnvironmentVariablesEditor.panel) + .comment("Additional environment variables passed to the OpenCode server process. Applies only to servers launched by the plugin.") + .align(AlignX.FILL) + .resizableColumn() } } group("Editor") { @@ -118,17 +194,30 @@ class OpenCodeSettingsConfigurable(private val project: Project) : } override fun reset() { + cancelTableEdits() loadPendingFromPersisted() + syncServerCorsOriginsModel(pendingState.serverCorsOrigins) + syncServerEnvironmentVariablesModel(pendingState.serverEnvironmentVariables) super.reset() } + override fun isModified(): Boolean { + val settings = OpenCodeSettings.getInstance(project) + return super.isModified() || + serializeServerCorsOrigins() != settings.serverCorsOrigins || + serializeServerEnvironmentVariables() != settings.serverEnvironmentVariables + } + override fun apply() { val settings = OpenCodeSettings.getInstance(project) val plugin = OpenCodePlugin.getInstance(project) val oldSettings = snapshot(settings.state) val oldResolutionState = plugin.executableResolutionState + commitTableEdits() super.apply() // Pushes UI values into pendingState. + pendingState.serverCorsOrigins = serializeServerCorsOrigins() + pendingState.serverEnvironmentVariables = serializeServerEnvironmentVariables() val newSettings = snapshot(pendingState) val settingsChanged = newSettings != oldSettings @@ -207,6 +296,11 @@ class OpenCodeSettingsConfigurable(private val project: Project) : private fun loadPendingFromPersisted(settings: OpenCodeSettings = OpenCodeSettings.getInstance(project)) { pendingState.serverPort = settings.serverPort + pendingState.serverHostname = settings.serverHostname + pendingState.serverMdnsEnabled = settings.serverMdnsEnabled + pendingState.serverMdnsDomain = settings.serverMdnsDomain + pendingState.serverCorsOrigins = settings.serverCorsOrigins + pendingState.serverEnvironmentVariables = settings.serverEnvironmentVariables.map { it.copy() }.toMutableList() pendingState.executablePath = settings.executablePath pendingState.inlineDiffEnabled = settings.inlineDiffEnabled pendingState.diffTraceEnabled = settings.diffTraceEnabled @@ -217,12 +311,103 @@ class OpenCodeSettingsConfigurable(private val project: Project) : if (settings.terminalEngine == TerminalEngine.REWORKED) TerminalEngine.CLASSIC else settings.terminalEngine } + private fun syncServerCorsOriginsModel(serializedOrigins: String) { + if (!::serverCorsOriginsModel.isInitialized) return + serverCorsOriginsModel.setItems( + serializedOrigins.lineSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .map(::CorsOriginRow) + .toList(), + ) + } + + private fun serializeServerCorsOrigins(): String = + currentCorsOriginRows() + .map { it.origin.trim() } + .filter(String::isNotEmpty) + .joinToString("\n") + + private fun syncServerEnvironmentVariablesModel(entries: List) { + if (!::serverEnvironmentVariablesModel.isInitialized) return + serverEnvironmentVariablesModel.setItems(entries.map { it.copy() }) + } + + private fun serializeServerEnvironmentVariables(): MutableList = + currentEnvironmentVariables() + .map { it.copy(name = it.name.trim()) } + .filter { it.name.isNotEmpty() } + .toMutableList() + + private fun currentCorsOriginRows(): List = + currentTableItems( + table = serverCorsOriginsTable, + model = serverCorsOriginsModel, + copyItem = CorsOriginRow::copy, + ) { item, _, value -> + item.origin = value + } + + private fun currentEnvironmentVariables(): List = + currentTableItems( + table = serverEnvironmentVariablesTable, + model = serverEnvironmentVariablesModel, + copyItem = OpenCodeSettings.EnvironmentVariable::copy, + ) { item, column, value -> + if (column == 0) item.name = value else item.value = value + } + + private fun currentTableItems( + table: TableView, + model: ListTableModel, + copyItem: (T) -> T, + applyEditorValue: (item: T, column: Int, value: String) -> Unit, + ): List { + val items = model.items.map(copyItem).toMutableList() + if (!table.isEditing) return items + + val editingRow = table.editingRow + val editingColumn = table.editingColumn + val editorValue = (table.editorComponent as? JTextComponent)?.text ?: return items + if (editingRow !in items.indices || editingColumn < 0) return items + + applyEditorValue(items[editingRow], editingColumn, editorValue) + return items + } + + private fun commitTableEdits() { + stopCellEditing(serverCorsOriginsTable) + stopCellEditing(serverEnvironmentVariablesTable) + } + + private fun cancelTableEdits() { + cancelCellEditing(serverCorsOriginsTable) + cancelCellEditing(serverEnvironmentVariablesTable) + } + + private fun stopCellEditing(table: TableView<*>) { + if (table.isEditing) { + table.cellEditor?.stopCellEditing() + } + } + + private fun cancelCellEditing(table: TableView<*>) { + if (table.isEditing) { + table.cellEditor?.cancelCellEditing() + } + } + private fun persistPendingToSettings(settings: OpenCodeSettings) { settings.loadState(pendingState.copy()) } private fun snapshot(state: OpenCodeSettings.State): OpenCodeSettingsSnapshot = OpenCodeSettingsSnapshot( serverPort = state.serverPort, + serverHostname = state.serverHostname, + serverMdnsEnabled = state.serverMdnsEnabled, + serverMdnsDomain = state.serverMdnsDomain, + serverCorsOrigins = state.serverCorsOrigins, + serverEnvironmentVariables = state.serverEnvironmentVariables.map { it.copy() }, executablePath = state.executablePath, inlineDiffEnabled = state.inlineDiffEnabled, diffTraceEnabled = state.diffTraceEnabled, @@ -231,4 +416,81 @@ class OpenCodeSettingsConfigurable(private val project: Project) : sessionsSectionVisible = state.sessionsSectionVisible, terminalEngine = state.terminalEngine, ) + + private fun corsOriginColumn(): ColumnInfo = + object : ColumnInfo("Origin") { + override fun valueOf(item: CorsOriginRow): String = item.origin + + override fun isCellEditable(item: CorsOriginRow): Boolean = true + + override fun setValue(item: CorsOriginRow, value: String?) { + item.origin = value.orEmpty() + } + } + + private fun environmentVariableNameColumn(): ColumnInfo = + object : ColumnInfo("Name") { + override fun valueOf(item: OpenCodeSettings.EnvironmentVariable): String = item.name + + override fun isCellEditable(item: OpenCodeSettings.EnvironmentVariable): Boolean = true + + override fun setValue(item: OpenCodeSettings.EnvironmentVariable, value: String?) { + item.name = value.orEmpty() + } + } + + private fun environmentVariableValueColumn(): ColumnInfo = + object : ColumnInfo("Value") { + override fun valueOf(item: OpenCodeSettings.EnvironmentVariable): String = item.value + + override fun isCellEditable(item: OpenCodeSettings.EnvironmentVariable): Boolean = true + + override fun setValue(item: OpenCodeSettings.EnvironmentVariable, value: String?) { + item.value = value.orEmpty() + } + } + + private fun environmentVariableRow(): OpenCodeSettings.EnvironmentVariable = OpenCodeSettings.EnvironmentVariable() + + private fun createEditableTablePanel( + model: ListTableModel, + preferredHeight: Int, + onAddRow: () -> T, + ): EditableTablePanel { + val table = TableView(model).apply { + setShowGrid(false) + intercellSpacing = JBUI.emptySize() + tableHeader.reorderingAllowed = false + rowHeight = JBUI.scale(24) + } + + val panel = ToolbarDecorator.createDecorator(table) + .disableUpDownActions() + .setAddAction { + val updatedItems = model.items.toMutableList().apply { + add(onAddRow()) + } + model.setItems(updatedItems) + val row = updatedItems.lastIndex + if (row >= 0) { + table.selectionModel.setSelectionInterval(row, row) + table.editCellAt(row, 0) + table.editorComponent?.requestFocusInWindow() + } + } + .setRemoveAction { + val selectedRow = table.selectedRow + if (selectedRow >= 0) { + val updatedItems = model.items.toMutableList() + updatedItems.removeAt(selectedRow) + model.setItems(updatedItems) + } + } + .createPanel().apply { + minimumSize = JBUI.size(0, JBUI.scale(preferredHeight)) + preferredSize = JBUI.size(0, JBUI.scale(preferredHeight)) + } + + return EditableTablePanel(table = table, panel = panel) + } } diff --git a/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurableTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurableTest.kt index 06dec52..e3a8493 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurableTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurableTest.kt @@ -11,10 +11,155 @@ import java.awt.Component import java.awt.Container import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference +import javax.swing.text.JTextComponent import kotlin.test.assertFailsWith class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() { + fun testIsModifiedWhenOnlyCorsOriginsChange() { + val configurable = OpenCodeSettingsConfigurable(project) + + try { + getOnEdt { configurable.createComponent() } + + runOnEdt { + configurable.serverCorsOriginsModel.setItems( + listOf(OpenCodeSettingsConfigurable.CorsOriginRow("http://localhost:5173")) + ) + } + + assertTrue(configurable.isModified()) + } finally { + runOnEdt { configurable.disposeUIResources() } + } + } + + fun testIsModifiedWhenOnlyEnvironmentVariablesChange() { + val configurable = OpenCodeSettingsConfigurable(project) + + try { + getOnEdt { configurable.createComponent() } + + runOnEdt { + configurable.serverEnvironmentVariablesModel.setItems( + listOf(OpenCodeSettings.EnvironmentVariable("FOO", "bar")) + ) + } + + assertTrue(configurable.isModified()) + } finally { + runOnEdt { configurable.disposeUIResources() } + } + } + + fun testApplyPersistsServerEnvironmentVariables() { + val settings = OpenCodeSettings.getInstance(project) + val configurable = OpenCodeSettingsConfigurable(project) + + try { + getOnEdt { configurable.createComponent() } + + runOnEdt { + configurable.serverEnvironmentVariablesModel.setItems( + listOf( + OpenCodeSettings.EnvironmentVariable("FOO", "bar"), + OpenCodeSettings.EnvironmentVariable("HELLO", "world"), + ) + ) + configurable.apply() + } + + assertEquals( + listOf( + OpenCodeSettings.EnvironmentVariable("FOO", "bar"), + OpenCodeSettings.EnvironmentVariable("HELLO", "world"), + ), + settings.serverEnvironmentVariables, + ) + } finally { + runOnEdt { configurable.disposeUIResources() } + } + } + + fun testApplyPersistsServeCommandOptions() { + val settings = OpenCodeSettings.getInstance(project) + val configurable = OpenCodeSettingsConfigurable(project) + + try { + getOnEdt { configurable.createComponent() } + + runOnEdt { + configurable.serverHostnameField.text = "0.0.0.0" + configurable.serverMdnsEnabledCheckBox.isSelected = true + configurable.serverMdnsDomainField.text = "relay.local" + configurable.serverCorsOriginsModel.setItems( + listOf( + OpenCodeSettingsConfigurable.CorsOriginRow("http://localhost:5173"), + OpenCodeSettingsConfigurable.CorsOriginRow("https://app.example.com"), + ) + ) + configurable.apply() + } + + assertEquals("0.0.0.0", settings.serverHostname) + assertTrue(settings.serverMdnsEnabled) + assertEquals("relay.local", settings.serverMdnsDomain) + assertEquals("http://localhost:5173\nhttps://app.example.com", settings.serverCorsOrigins) + } finally { + runOnEdt { configurable.disposeUIResources() } + } + } + + fun testApplyCommitsActiveCorsCellEdit() { + val settings = OpenCodeSettings.getInstance(project) + val configurable = OpenCodeSettingsConfigurable(project) + + try { + getOnEdt { configurable.createComponent() } + + runOnEdt { + configurable.serverCorsOriginsModel.setItems( + listOf(OpenCodeSettingsConfigurable.CorsOriginRow("http://localhost:5173")) + ) + assertTrue(configurable.serverCorsOriginsTable.editCellAt(0, 0)) + val editor = configurable.serverCorsOriginsTable.editorComponent as? JTextComponent + ?: error("CORS editor component not found") + editor.text = "https://app.example.com" + configurable.apply() + } + + assertEquals("https://app.example.com", settings.serverCorsOrigins) + } finally { + runOnEdt { configurable.disposeUIResources() } + } + } + + fun testApplyPreservesEnvValueWhitespaceAndDropsBlankNames() { + val settings = OpenCodeSettings.getInstance(project) + val configurable = OpenCodeSettingsConfigurable(project) + + try { + getOnEdt { configurable.createComponent() } + + runOnEdt { + configurable.serverEnvironmentVariablesModel.setItems( + listOf( + OpenCodeSettings.EnvironmentVariable(" FOO ", " spaced value "), + OpenCodeSettings.EnvironmentVariable("", "should be dropped"), + ) + ) + configurable.apply() + } + + assertEquals( + listOf(OpenCodeSettings.EnvironmentVariable("FOO", " spaced value ")), + settings.serverEnvironmentVariables, + ) + } finally { + runOnEdt { configurable.disposeUIResources() } + } + } + fun testApplyAllowsSavingWhenPathIsBlankAndResolutionFails() { val settings = OpenCodeSettings.getInstance(project) settings.executablePath = "C:/Users/VM/AppData/Roaming/npm/opencode.cmd" From 71f79030190427e1794a4854122fa959b66d218c Mon Sep 17 00:00:00 2001 From: Ashot Nazaryan Date: Mon, 20 Apr 2026 12:38:36 -0700 Subject: [PATCH 2/7] feat: env vars for server/client --- .idea/runConfigurations/Run_Plugin.xml | 11 ++- .../relay/OpenCodeProcessEnvironment.kt | 60 +++++++++++--- .../ashotn/opencode/relay/ServerManager.kt | 5 +- .../relay/actions/OpenTerminalAction.kt | 14 +++- .../relay/settings/OpenCodeSettings.kt | 3 + .../settings/OpenCodeSettingsConfigurable.kt | 4 + .../relay/terminal/ClassicTuiPanel.kt | 6 +- .../relay/terminal/ReworkedTuiPanel.kt | 5 +- .../relay/OpenCodeProcessEnvironmentTest.kt | 79 +++++++++++++++++++ 9 files changed, 170 insertions(+), 17 deletions(-) create mode 100644 src/test/kotlin/com/ashotn/opencode/relay/OpenCodeProcessEnvironmentTest.kt diff --git a/.idea/runConfigurations/Run_Plugin.xml b/.idea/runConfigurations/Run_Plugin.xml index fe9721b..81308da 100644 --- a/.idea/runConfigurations/Run_Plugin.xml +++ b/.idea/runConfigurations/Run_Plugin.xml @@ -1,5 +1,5 @@ - + + true + true + false + false + false + false + false - + \ No newline at end of file diff --git a/src/main/kotlin/com/ashotn/opencode/relay/OpenCodeProcessEnvironment.kt b/src/main/kotlin/com/ashotn/opencode/relay/OpenCodeProcessEnvironment.kt index 60aacdf..e09bfe4 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/OpenCodeProcessEnvironment.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/OpenCodeProcessEnvironment.kt @@ -5,23 +5,35 @@ import java.io.File internal object OpenCodeProcessEnvironment { - fun configure(processBuilder: ProcessBuilder, executablePath: String) { + fun configure( + processBuilder: ProcessBuilder, + executablePath: String, + environmentVariables: Map = emptyMap(), + ) { + applyEnvironmentVariables(processBuilder.environment(), environmentVariables) nvmBinDirectory(executablePath)?.let { binDir -> prependPath(processBuilder.environment(), binDir) } } - fun terminalCommand(command: List): List { + fun terminalCommand( + command: List, + environmentVariables: Map, + ): List { if (command.isEmpty()) return command - val executablePath = command.first() - val nvmBinDirectory = nvmBinDirectory(executablePath) ?: return command - if (SystemInfo.isWindows) return command + val environment = linkedMapOf() + applyEnvironmentVariables(environment, environmentVariables) + nvmBinDirectory(command.first())?.let { binDir -> + prependPath(environment, binDir) + } + if (environment.isEmpty()) return command - return listOf( - "/usr/bin/env", - "PATH=${pathWithPrependedDirectory(System.getenv("PATH"), nvmBinDirectory)}", - ) + command + return if (SystemInfo.isWindows) { + listOf("cmd", "/c", buildWindowsTerminalCommand(command, environment)) + } else { + listOf("/usr/bin/env") + environment.map { (name, value) -> "$name=$value" } + command + } } private fun nvmBinDirectory(executablePath: String): String? { @@ -40,11 +52,41 @@ internal object OpenCodeProcessEnvironment { } } + private fun applyEnvironmentVariables( + environment: MutableMap, + environmentVariables: Map, + ) { + environmentVariables.forEach { (name, value) -> + val normalizedName = name.trim() + if (normalizedName.isBlank()) return@forEach + + val key = environment.keys.firstOrNull { it.equals(normalizedName, ignoreCase = true) } ?: normalizedName + environment[key] = value + } + } + private fun prependPath(environment: MutableMap, directory: String) { val pathKey = environment.keys.firstOrNull { it.equals("PATH", ignoreCase = true) } ?: "PATH" environment[pathKey] = pathWithPrependedDirectory(environment[pathKey], directory) } + private fun buildWindowsTerminalCommand( + command: List, + environment: Map, + ): String = buildString { + environment.forEach { (name, value) -> + append("set \"") + append(name) + append('=') + append(value.replace("\"", "\"\"")) + append("\" && ") + } + append(command.joinToString(" ") { windowsQuote(it) }) + } + + private fun windowsQuote(value: String): String = + "\"${value.replace("\"", "\\\"")}\"" + private fun pathWithPrependedDirectory(currentPath: String?, directory: String): String { val pathEntries = currentPath.orEmpty().split(File.pathSeparator).filter { it.isNotBlank() } if (pathEntries.any { it == directory }) return currentPath.orEmpty() diff --git a/src/main/kotlin/com/ashotn/opencode/relay/ServerManager.kt b/src/main/kotlin/com/ashotn/opencode/relay/ServerManager.kt index 46cf693..a0fb795 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/ServerManager.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/ServerManager.kt @@ -2,6 +2,8 @@ package com.ashotn.opencode.relay import com.ashotn.opencode.relay.api.health.HealthApiClient import com.ashotn.opencode.relay.api.transport.ApiResult +import com.ashotn.opencode.relay.settings.OpenCodeSettings +import com.ashotn.opencode.relay.settings.processEnvironmentVariables import com.ashotn.opencode.relay.util.showNotification import com.intellij.notification.NotificationType import com.intellij.openapi.diagnostic.logger @@ -308,6 +310,7 @@ class ServerManager( } try { + val environmentVariables = OpenCodeSettings.getInstance(project).processEnvironmentVariables() val command = if (SystemInfo.isWindows) { listOf("cmd", "/c", buildWindowsCommand(executablePath, "serve", "--port", port.toString())) @@ -317,7 +320,7 @@ class ServerManager( val process = ProcessBuilder(command) .inheritIO() .apply { - OpenCodeProcessEnvironment.configure(this, executablePath) + OpenCodeProcessEnvironment.configure(this, executablePath, environmentVariables) val basePath = project.basePath if (basePath != null) directory(File(basePath)) } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/actions/OpenTerminalAction.kt b/src/main/kotlin/com/ashotn/opencode/relay/actions/OpenTerminalAction.kt index c393dd9..32e792b 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/actions/OpenTerminalAction.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/actions/OpenTerminalAction.kt @@ -1,8 +1,10 @@ package com.ashotn.opencode.relay.actions import com.ashotn.opencode.relay.OpenCodePlugin +import com.ashotn.opencode.relay.OpenCodeProcessEnvironment import com.ashotn.opencode.relay.ServerState import com.ashotn.opencode.relay.settings.OpenCodeSettings +import com.ashotn.opencode.relay.settings.processEnvironmentVariables import com.ashotn.opencode.relay.util.applyStrings import com.ashotn.opencode.relay.util.showNotification import com.ashotn.opencode.relay.util.serverUrl @@ -47,6 +49,7 @@ class OpenTerminalAction(private val project: Project) : AnAction() { * Launches an external OS terminal window running ` attach `. */ private fun launchExternalTerminal(executablePath: String, url: String) { + val environmentVariables = OpenCodeSettings.getInstance(project).processEnvironmentVariables() val attachArgs = listOf(executablePath, "attach", url) val processCommand: List = when { SystemInfo.isWindows -> listOf( @@ -56,7 +59,9 @@ class OpenTerminalAction(private val project: Project) : AnAction() { ) SystemInfo.isMac -> { - val command = buildPosixAttachCommand(executablePath, url) + val command = buildPosixAttachCommand( + OpenCodeProcessEnvironment.terminalCommand(attachArgs, environmentVariables) + ) listOf( "osascript", "-e", @@ -79,6 +84,9 @@ class OpenTerminalAction(private val project: Project) : AnAction() { try { ProcessBuilder(processCommand) .inheritIO() + .apply { + OpenCodeProcessEnvironment.configure(this, executablePath, environmentVariables) + } .start() } catch (ex: Exception) { project.showNotification( @@ -92,8 +100,8 @@ class OpenTerminalAction(private val project: Project) : AnAction() { private fun buildWindowsAttachCommand(executablePath: String, url: String): String = "start \"\" \"$executablePath\" attach \"$url\"" - private fun buildPosixAttachCommand(executablePath: String, url: String): String = - "${shellQuote(executablePath)} attach ${shellQuote(url)}" + private fun buildPosixAttachCommand(command: List): String = + command.joinToString(" ", transform = ::shellQuote) private fun shellQuote(value: String): String = "'${value.replace("'", "'\"'\"'")}'" diff --git a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt index 0da9b97..a222aa7 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt @@ -146,3 +146,6 @@ fun OpenCodeSettings.snapshot(): OpenCodeSettingsSnapshot = OpenCodeSettingsSnap sessionsSectionVisible = sessionsSectionVisible, terminalEngine = terminalEngine, ) + +fun OpenCodeSettings.processEnvironmentVariables(): Map = + serverEnvironmentVariables.associate { it.name to it.value } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt index fc7ed04..3e00994 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt @@ -219,6 +219,10 @@ class OpenCodeSettingsConfigurable(private val project: Project) : pendingState.serverCorsOrigins = serializeServerCorsOrigins() pendingState.serverEnvironmentVariables = serializeServerEnvironmentVariables() + if (pendingState.serverEnvironmentVariables.any { it.name.contains('=') }) { + throw ConfigurationException("Environment variable names cannot contain '='") + } + val newSettings = snapshot(pendingState) val settingsChanged = newSettings != oldSettings val newPort = newSettings.serverPort diff --git a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt index 42850d9..4ab639f 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt @@ -3,6 +3,7 @@ package com.ashotn.opencode.relay.terminal import com.ashotn.opencode.relay.OpenCodePlugin import com.ashotn.opencode.relay.OpenCodeProcessEnvironment import com.ashotn.opencode.relay.settings.OpenCodeSettings +import com.ashotn.opencode.relay.settings.processEnvironmentVariables import com.ashotn.opencode.relay.util.serverUrl import com.intellij.ide.DataManager import com.intellij.openapi.Disposable @@ -70,18 +71,21 @@ class ClassicTuiPanel( } val workingDir = project.basePath ?: System.getProperty("user.home") + val environmentVariables = OpenCodeSettings.getInstance(project).processEnvironmentVariables() val command = OpenCodeProcessEnvironment.terminalCommand( listOf( executablePath, "attach", serverUrl(OpenCodeSettings.getInstance(project).serverPort), - ) + ), + environmentVariables, ) val runner = LocalTerminalDirectRunner.createTerminalRunner(project) val startupOptions = ShellStartupOptions.Builder() .workingDirectory(workingDir) .shellCommand(command) + .envVariables(environmentVariables) .build() val widget = runner.startShellTerminalWidget(this, startupOptions, true) diff --git a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ReworkedTuiPanel.kt b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ReworkedTuiPanel.kt index 5ef3977..f728788 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ReworkedTuiPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ReworkedTuiPanel.kt @@ -5,6 +5,7 @@ package com.ashotn.opencode.relay.terminal import com.ashotn.opencode.relay.OpenCodePlugin import com.ashotn.opencode.relay.OpenCodeProcessEnvironment import com.ashotn.opencode.relay.settings.OpenCodeSettings +import com.ashotn.opencode.relay.settings.processEnvironmentVariables import com.ashotn.opencode.relay.util.serverUrl import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager @@ -72,12 +73,14 @@ class ReworkedTuiPanel( } val workingDir = project.basePath ?: System.getProperty("user.home") + val environmentVariables = OpenCodeSettings.getInstance(project).processEnvironmentVariables() val command = OpenCodeProcessEnvironment.terminalCommand( listOf( executablePath, "attach", serverUrl(OpenCodeSettings.getInstance(project).serverPort), - ) + ), + environmentVariables, ) val manager = TerminalToolWindowTabsManager.getInstance(project) diff --git a/src/test/kotlin/com/ashotn/opencode/relay/OpenCodeProcessEnvironmentTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/OpenCodeProcessEnvironmentTest.kt new file mode 100644 index 0000000..01726b7 --- /dev/null +++ b/src/test/kotlin/com/ashotn/opencode/relay/OpenCodeProcessEnvironmentTest.kt @@ -0,0 +1,79 @@ +package com.ashotn.opencode.relay + +import org.junit.Test +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class OpenCodeProcessEnvironmentTest { + + @Test + fun `configure applies custom environment variables`() { + val processBuilder = ProcessBuilder("opencode") + + OpenCodeProcessEnvironment.configure( + processBuilder, + "/tmp/opencode", + mapOf( + "FOO" to "bar", + "BAR" to " spaced value ", + ), + ) + + assertEquals("bar", processBuilder.environment()["FOO"]) + assertEquals(" spaced value ", processBuilder.environment()["BAR"]) + } + + @Test + fun `configure ignores blank names and still prepends nvm path`() { + val processBuilder = ProcessBuilder("opencode") + processBuilder.environment()["PATH"] = "/usr/bin" + + OpenCodeProcessEnvironment.configure( + processBuilder, + "/Users/test/.nvm/versions/node/v20.0.0/bin/opencode", + mapOf( + " PATH " to "/custom/bin", + " " to "ignored", + "FOO" to "bar", + ), + ) + + val path = processBuilder.environment()["PATH"] ?: error("PATH was not set") + val nvmBinDirectory = "/Users/test/.nvm/versions/node/v20.0.0/bin" + + assertTrue(path.startsWith(nvmBinDirectory + File.pathSeparator)) + assertTrue(path.contains("/custom/bin")) + assertEquals("bar", processBuilder.environment()["FOO"]) + assertFalse(processBuilder.environment().containsKey(" ")) + } + + @Test + fun `terminalCommand applies custom environment variables`() { + val command = OpenCodeProcessEnvironment.terminalCommand( + listOf("/tmp/opencode", "attach", "http://127.0.0.1:4096"), + mapOf( + "FOO" to "bar", + "BAR" to " spaced value ", + ), + ) + + assertEquals("/usr/bin/env", command.first()) + assertTrue(command.contains("FOO=bar")) + assertTrue(command.contains("BAR= spaced value ")) + assertEquals(listOf("/tmp/opencode", "attach", "http://127.0.0.1:4096"), command.takeLast(3)) + } + + @Test + fun `terminalCommand merges environment variables with nvm path`() { + val command = OpenCodeProcessEnvironment.terminalCommand( + listOf("/Users/test/.nvm/versions/node/v20.0.0/bin/opencode", "attach", "http://127.0.0.1:4096"), + mapOf("PATH" to "/custom/bin"), + ) + + val pathEntry = command.firstOrNull { it.startsWith("PATH=") } ?: error("PATH entry not found") + assertTrue(pathEntry.startsWith("PATH=/Users/test/.nvm/versions/node/v20.0.0/bin${File.pathSeparator}")) + assertTrue(pathEntry.contains("/custom/bin")) + } +} From d5c0277a60e0ee0dc5be164ca1d064ba5e85d762 Mon Sep 17 00:00:00 2001 From: Ashot Nazaryan Date: Mon, 20 Apr 2026 13:23:30 -0700 Subject: [PATCH 3/7] feat: auth UI settings --- .../relay/settings/OpenCodeSettings.kt | 27 +++++-- .../OpenCodeSettingsChangedListener.kt | 2 + .../settings/OpenCodeSettingsConfigurable.kt | 70 ++++++++++++++++++- .../OpenCodeSettingsConfigurableTest.kt | 70 +++++++++++++++++++ 4 files changed, 162 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt index a222aa7..3a984fd 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt @@ -10,6 +10,13 @@ import com.intellij.openapi.project.Project ) class OpenCodeSettings : PersistentStateComponent { + companion object { + const val DEFAULT_SERVER_AUTH_USERNAME: String = "opencode" + + fun getInstance(project: Project): OpenCodeSettings = + project.getService(OpenCodeSettings::class.java) + } + data class EnvironmentVariable( var name: String = "", var value: String = "", @@ -29,6 +36,8 @@ class OpenCodeSettings : PersistentStateComponent { var serverMdnsEnabled: Boolean = false, var serverMdnsDomain: String = "opencode.local", var serverCorsOrigins: String = "", + var serverAuthUsername: String = DEFAULT_SERVER_AUTH_USERNAME, + var protectPluginLaunchedServerWithAuth: Boolean = false, var serverEnvironmentVariables: MutableList = mutableListOf(), var executablePath: String = "", var inlineDiffEnabled: Boolean = true, @@ -77,6 +86,18 @@ class OpenCodeSettings : PersistentStateComponent { state.serverCorsOrigins = value } + var serverAuthUsername: String + get() = state.serverAuthUsername + set(value) { + state.serverAuthUsername = value + } + + var protectPluginLaunchedServerWithAuth: Boolean + get() = state.protectPluginLaunchedServerWithAuth + set(value) { + state.protectPluginLaunchedServerWithAuth = value + } + var serverEnvironmentVariables: MutableList get() = state.serverEnvironmentVariables set(value) { @@ -125,10 +146,6 @@ class OpenCodeSettings : PersistentStateComponent { state.terminalEngine = value } - companion object { - fun getInstance(project: Project): OpenCodeSettings = - project.getService(OpenCodeSettings::class.java) - } } fun OpenCodeSettings.snapshot(): OpenCodeSettingsSnapshot = OpenCodeSettingsSnapshot( @@ -137,6 +154,8 @@ fun OpenCodeSettings.snapshot(): OpenCodeSettingsSnapshot = OpenCodeSettingsSnap serverMdnsEnabled = serverMdnsEnabled, serverMdnsDomain = serverMdnsDomain, serverCorsOrigins = serverCorsOrigins, + serverAuthUsername = serverAuthUsername, + protectPluginLaunchedServerWithAuth = protectPluginLaunchedServerWithAuth, serverEnvironmentVariables = serverEnvironmentVariables.map { it.copy() }, executablePath = executablePath, inlineDiffEnabled = inlineDiffEnabled, diff --git a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsChangedListener.kt b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsChangedListener.kt index 375a832..f4900a5 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsChangedListener.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsChangedListener.kt @@ -8,6 +8,8 @@ data class OpenCodeSettingsSnapshot( val serverMdnsEnabled: Boolean, val serverMdnsDomain: String, val serverCorsOrigins: String, + val serverAuthUsername: String, + val protectPluginLaunchedServerWithAuth: Boolean, val serverEnvironmentVariables: List, val executablePath: String, val inlineDiffEnabled: Boolean, diff --git a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt index 3e00994..4e7faaf 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt @@ -16,6 +16,7 @@ import com.intellij.openapi.ui.DialogPanel import com.intellij.openapi.ui.Messages import com.intellij.openapi.ui.TextFieldWithBrowseButton import com.intellij.ui.ToolbarDecorator +import com.intellij.ui.components.JBPasswordField import com.intellij.ui.components.JBTextField import com.intellij.ui.dsl.builder.* import com.intellij.ui.table.TableView @@ -44,6 +45,9 @@ class OpenCodeSettingsConfigurable(private val project: Project) : internal lateinit var serverEnvironmentVariablesTable: TableView internal lateinit var serverHostnameField: JBTextField + internal lateinit var serverAuthUsernameField: JBTextField + internal lateinit var serverAuthPasswordField: JBPasswordField + internal lateinit var protectPluginLaunchedServerWithAuthCheckBox: JCheckBox internal lateinit var serverMdnsEnabledCheckBox: JCheckBox internal lateinit var serverMdnsDomainField: JBTextField @@ -70,6 +74,7 @@ class OpenCodeSettingsConfigurable(private val project: Project) : syncServerCorsOriginsModel(pendingState.serverCorsOrigins) syncServerEnvironmentVariablesModel(pendingState.serverEnvironmentVariables) + resetServerAuthPasswordField() return panel { group("Executable") { @@ -139,10 +144,32 @@ class OpenCodeSettingsConfigurable(private val project: Project) : .resizableColumn() } } + group("Server Authentication") { + row("Username:") { + val cell = textField() + .bindText(pendingState::serverAuthUsername) + .comment("Used for protected OpenCode server connections. Default: opencode") + .align(AlignX.FILL) + serverAuthUsernameField = cell.component + } + row("Password:") { + val field = JBPasswordField() + serverAuthPasswordField = field + cell(field) + .comment("Stored securely in the IDE password safe.") + .align(AlignX.FILL) + } + row { + val cell = checkBox("Protect server launched by plugin") + .bindSelected(pendingState::protectPluginLaunchedServerWithAuth) + .comment("When enabled, the plugin will use these credentials for plugin-launched OpenCode servers.") + protectPluginLaunchedServerWithAuthCheckBox = cell.component + } + } group("Environment") { row("Variables:") { cell(serverEnvironmentVariablesEditor.panel) - .comment("Additional environment variables passed to the OpenCode server process. Applies only to servers launched by the plugin.") + .comment("Additional environment variables passed to OpenCode processes launched by the plugin. `OPENCODE_SERVER_USERNAME` and `OPENCODE_SERVER_PASSWORD` are managed above.") .align(AlignX.FILL) .resizableColumn() } @@ -199,39 +226,48 @@ class OpenCodeSettingsConfigurable(private val project: Project) : syncServerCorsOriginsModel(pendingState.serverCorsOrigins) syncServerEnvironmentVariablesModel(pendingState.serverEnvironmentVariables) super.reset() + resetServerAuthPasswordField() } override fun isModified(): Boolean { val settings = OpenCodeSettings.getInstance(project) return super.isModified() || serializeServerCorsOrigins() != settings.serverCorsOrigins || - serializeServerEnvironmentVariables() != settings.serverEnvironmentVariables + serializeServerEnvironmentVariables() != settings.serverEnvironmentVariables || + currentServerAuthPassword() != OpenCodeServerAuth.getInstance(project).password() } override fun apply() { val settings = OpenCodeSettings.getInstance(project) + val serverAuth = OpenCodeServerAuth.getInstance(project) val plugin = OpenCodePlugin.getInstance(project) val oldSettings = snapshot(settings.state) + val oldPassword = serverAuth.password() val oldResolutionState = plugin.executableResolutionState commitTableEdits() super.apply() // Pushes UI values into pendingState. pendingState.serverCorsOrigins = serializeServerCorsOrigins() + pendingState.serverAuthUsername = + pendingState.serverAuthUsername.trim().ifEmpty { OpenCodeSettings.DEFAULT_SERVER_AUTH_USERNAME } pendingState.serverEnvironmentVariables = serializeServerEnvironmentVariables() if (pendingState.serverEnvironmentVariables.any { it.name.contains('=') }) { throw ConfigurationException("Environment variable names cannot contain '='") } + validateReservedServerAuthEnvironmentVariables(pendingState.serverEnvironmentVariables) val newSettings = snapshot(pendingState) val settingsChanged = newSettings != oldSettings + val newPassword = currentServerAuthPassword() + val passwordChanged = newPassword != oldPassword val newPort = newSettings.serverPort val newPath = newSettings.executablePath val portChanged = newPort != oldSettings.serverPort val pathChanged = newPath != oldSettings.executablePath val shouldUpdateExecutableResolution = pathChanged || (newPath.isBlank() && oldResolutionState == OpenCodeExecutableResolutionState.Resolving) - if (!settingsChanged && !shouldUpdateExecutableResolution) return + if (!settingsChanged && !passwordChanged && !shouldUpdateExecutableResolution) return val mustConfirmStop = plugin.isRunning && plugin.ownsProcess && (portChanged || pathChanged) val mustReattach = plugin.isRunning && !plugin.ownsProcess && portChanged @@ -256,6 +292,9 @@ class OpenCodeSettingsConfigurable(private val project: Project) : } persistPendingToSettings(settings) + if (passwordChanged) { + serverAuth.setPassword(newPassword) + } when { mustConfirmStop -> plugin.stopServer() @@ -304,6 +343,8 @@ class OpenCodeSettingsConfigurable(private val project: Project) : pendingState.serverMdnsEnabled = settings.serverMdnsEnabled pendingState.serverMdnsDomain = settings.serverMdnsDomain pendingState.serverCorsOrigins = settings.serverCorsOrigins + pendingState.serverAuthUsername = settings.serverAuthUsername + pendingState.protectPluginLaunchedServerWithAuth = settings.protectPluginLaunchedServerWithAuth pendingState.serverEnvironmentVariables = settings.serverEnvironmentVariables.map { it.copy() }.toMutableList() pendingState.executablePath = settings.executablePath pendingState.inlineDiffEnabled = settings.inlineDiffEnabled @@ -337,12 +378,33 @@ class OpenCodeSettingsConfigurable(private val project: Project) : serverEnvironmentVariablesModel.setItems(entries.map { it.copy() }) } + private fun resetServerAuthPasswordField() { + if (!::serverAuthPasswordField.isInitialized) return + serverAuthPasswordField.text = OpenCodeServerAuth.getInstance(project).password() + } + + private fun currentServerAuthPassword(): String = + if (::serverAuthPasswordField.isInitialized) String(serverAuthPasswordField.password) else "" + private fun serializeServerEnvironmentVariables(): MutableList = currentEnvironmentVariables() .map { it.copy(name = it.name.trim()) } .filter { it.name.isNotEmpty() } .toMutableList() + private fun validateReservedServerAuthEnvironmentVariables( + environmentVariables: List, + ) { + val reservedNames = setOf("OPENCODE_SERVER_USERNAME", "OPENCODE_SERVER_PASSWORD") + val reservedName = environmentVariables + .firstOrNull { it.name.uppercase() in reservedNames } + ?.name + ?: return + throw ConfigurationException( + "Use the Server Authentication fields instead of $reservedName", + ) + } + private fun currentCorsOriginRows(): List = currentTableItems( table = serverCorsOriginsTable, @@ -411,6 +473,8 @@ class OpenCodeSettingsConfigurable(private val project: Project) : serverMdnsEnabled = state.serverMdnsEnabled, serverMdnsDomain = state.serverMdnsDomain, serverCorsOrigins = state.serverCorsOrigins, + serverAuthUsername = state.serverAuthUsername, + protectPluginLaunchedServerWithAuth = state.protectPluginLaunchedServerWithAuth, serverEnvironmentVariables = state.serverEnvironmentVariables.map { it.copy() }, executablePath = state.executablePath, inlineDiffEnabled = state.inlineDiffEnabled, diff --git a/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurableTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurableTest.kt index e3a8493..198a64a 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurableTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurableTest.kt @@ -16,6 +16,14 @@ import kotlin.test.assertFailsWith class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() { + override fun tearDown() { + try { + OpenCodeServerAuth.getInstance(project).setPassword("") + } finally { + super.tearDown() + } + } + fun testIsModifiedWhenOnlyCorsOriginsChange() { val configurable = OpenCodeSettingsConfigurable(project) @@ -110,6 +118,47 @@ class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() { } } + fun testApplyPersistsServerAuthenticationSettings() { + val settings = OpenCodeSettings.getInstance(project) + val serverAuth = OpenCodeServerAuth.getInstance(project) + val configurable = OpenCodeSettingsConfigurable(project) + + try { + getOnEdt { configurable.createComponent() } + + runOnEdt { + configurable.serverAuthUsernameField.text = "alice" + configurable.serverAuthPasswordField.text = "secret" + configurable.protectPluginLaunchedServerWithAuthCheckBox.isSelected = true + configurable.apply() + } + + assertEquals("alice", settings.serverAuthUsername) + assertTrue(settings.protectPluginLaunchedServerWithAuth) + assertEquals("secret", serverAuth.password()) + } finally { + runOnEdt { configurable.disposeUIResources() } + } + } + + fun testApplyDefaultsBlankServerAuthUsername() { + val settings = OpenCodeSettings.getInstance(project) + val configurable = OpenCodeSettingsConfigurable(project) + + try { + getOnEdt { configurable.createComponent() } + + runOnEdt { + configurable.serverAuthUsernameField.text = " " + configurable.apply() + } + + assertEquals(OpenCodeSettings.DEFAULT_SERVER_AUTH_USERNAME, settings.serverAuthUsername) + } finally { + runOnEdt { configurable.disposeUIResources() } + } + } + fun testApplyCommitsActiveCorsCellEdit() { val settings = OpenCodeSettings.getInstance(project) val configurable = OpenCodeSettingsConfigurable(project) @@ -160,6 +209,27 @@ class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() { } } + fun testApplyRejectsReservedServerAuthEnvironmentVariables() { + val configurable = OpenCodeSettingsConfigurable(project) + + try { + getOnEdt { configurable.createComponent() } + + runOnEdt { + configurable.serverEnvironmentVariablesModel.setItems( + listOf(OpenCodeSettings.EnvironmentVariable("OPENCODE_SERVER_PASSWORD", "secret")) + ) + } + + val exception = assertFailsWith { + runOnEdt { configurable.apply() } + } + assertTrue(exception.localizedMessage.orEmpty().contains("Server Authentication fields")) + } finally { + runOnEdt { configurable.disposeUIResources() } + } + } + fun testApplyAllowsSavingWhenPathIsBlankAndResolutionFails() { val settings = OpenCodeSettings.getInstance(project) settings.executablePath = "C:/Users/VM/AppData/Roaming/npm/opencode.cmd" From f86c52a8a99926f0e71a8c965bebd17ceeace8ab Mon Sep 17 00:00:00 2001 From: Ashot Nazaryan Date: Mon, 20 Apr 2026 16:35:48 -0700 Subject: [PATCH 4/7] feat: add server authentication support --- .../ashotn/opencode/relay/OpenCodePlugin.kt | 6 +- .../ashotn/opencode/relay/ServerManager.kt | 141 +++++++++++++++--- .../relay/actions/McpServersAction.kt | 2 +- .../relay/actions/McpServersPopupPanel.kt | 3 +- .../relay/actions/OpenTerminalAction.kt | 4 +- .../relay/api/event/EventStreamClient.kt | 10 +- .../opencode/relay/api/mcp/McpService.kt | 15 ++ .../opencode/relay/api/transport/ApiError.kt | 3 + .../api/transport/OpenCodeHttpTransport.kt | 2 + .../relay/core/OpenCodeCoreService.kt | 16 +- .../ashotn/opencode/relay/ipc/SseClient.kt | 8 + .../permission/OpenCodePermissionService.kt | 15 +- .../relay/settings/OpenCodeServerAuth.kt | 72 +++++++++ .../relay/settings/OpenCodeSettings.kt | 4 +- .../settings/OpenCodeSettingsConfigurable.kt | 17 ++- .../relay/terminal/ClassicTuiPanel.kt | 4 +- .../relay/terminal/ReworkedTuiPanel.kt | 4 +- .../relay/toolwindow/InstalledPanel.kt | 6 + .../opencode/relay/tui/OpenCodeTuiClient.kt | 15 +- .../opencode/relay/ServerManagerTest.kt | 50 +++++++ .../relay/api/event/EventStreamClientTest.kt | 26 ++++ .../transport/OpenCodeHttpTransportTest.kt | 20 +++ .../opencode/relay/ipc/SseClientTest.kt | 44 ++++++ .../relay/settings/OpenCodeServerAuthTest.kt | 54 +++++++ .../OpenCodeSettingsConfigurableTest.kt | 23 +++ 25 files changed, 523 insertions(+), 41 deletions(-) create mode 100644 src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuth.kt create mode 100644 src/test/kotlin/com/ashotn/opencode/relay/ServerManagerTest.kt create mode 100644 src/test/kotlin/com/ashotn/opencode/relay/api/event/EventStreamClientTest.kt create mode 100644 src/test/kotlin/com/ashotn/opencode/relay/ipc/SseClientTest.kt create mode 100644 src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuthTest.kt diff --git a/src/main/kotlin/com/ashotn/opencode/relay/OpenCodePlugin.kt b/src/main/kotlin/com/ashotn/opencode/relay/OpenCodePlugin.kt index 7409568..1d28cf4 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/OpenCodePlugin.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/OpenCodePlugin.kt @@ -31,7 +31,7 @@ class OpenCodePlugin(private val project: Project) : Disposable { OpenCodeCoreService.getInstance(project).startListening(port) OpenCodeTuiClient.getInstance(project).setPort(port) } - } else if (state == ServerState.STOPPED) { + } else if (state == ServerState.STOPPED || state == ServerState.PORT_CONFLICT || state == ServerState.AUTH_REQUIRED) { ApplicationManager.getApplication().executeOnPooledThread { if (project.isDisposed) return@executeOnPooledThread OpenCodeCoreService.getInstance(project).stopListening() @@ -153,6 +153,10 @@ class OpenCodePlugin(private val project: Project) : Disposable { } } + fun reportAuthenticationRequired() { + serverManager.reportAuthenticationRequired() + } + // --- Disposable --- override fun dispose() { diff --git a/src/main/kotlin/com/ashotn/opencode/relay/ServerManager.kt b/src/main/kotlin/com/ashotn/opencode/relay/ServerManager.kt index a0fb795..4a84aa4 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/ServerManager.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/ServerManager.kt @@ -1,10 +1,12 @@ package com.ashotn.opencode.relay import com.ashotn.opencode.relay.api.health.HealthApiClient +import com.ashotn.opencode.relay.api.transport.ApiError import com.ashotn.opencode.relay.api.transport.ApiResult +import com.ashotn.opencode.relay.api.transport.OpenCodeHttpTransport import com.ashotn.opencode.relay.settings.OpenCodeSettings -import com.ashotn.opencode.relay.settings.processEnvironmentVariables -import com.ashotn.opencode.relay.util.showNotification +import com.ashotn.opencode.relay.settings.OpenCodeServerAuth +import com.intellij.notification.NotificationGroupManager import com.intellij.notification.NotificationType import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.project.Project @@ -38,6 +40,9 @@ enum class ServerState { /** Port is open but occupied by a non-OpenCode process - user action required. */ PORT_CONFLICT, + /** Port is open and the process listening on it requires authentication. */ + AUTH_REQUIRED, + /** A reset was requested - clearing state and reconnecting. */ RESETTING, } @@ -54,6 +59,12 @@ class ServerManager( private val onStateChanged: (ServerState) -> Unit, ) { + private enum class HealthStatus { + HEALTHY, + AUTH_REQUIRED, + UNHEALTHY, + } + companion object { private val log = logger() @@ -87,7 +98,14 @@ class ServerManager( /** Monotonic revision to ignore stale external health checks. */ private val externalHealthRevision = AtomicLong(0) - private val healthApiClient = HealthApiClient() + private val serverAuth = OpenCodeServerAuth.getInstance(project) + private val healthApiClient = HealthApiClient( + transport = OpenCodeHttpTransport( + defaultConnectTimeoutMs = 1_000, + defaultReadTimeoutMs = 1_000, + authorizationHeaderProvider = serverAuth::connectionAuthorizationHeader, + ), + ) private val scheduler = Executors.newSingleThreadScheduledExecutor { r -> Thread(r, "opencode-poll").apply { isDaemon = true } @@ -155,7 +173,7 @@ class ServerManager( externalHealthRevision.incrementAndGet() if (wasPortOpen) { - project.showNotification( + showNotification( "OpenCode stopped unexpectedly", "The OpenCode server process was terminated externally.", NotificationType.WARNING, @@ -182,42 +200,87 @@ class ServerManager( } private fun doCheckHealth(port: Int) { - if (checkHealthOnce(port)) { - SwingUtilities.invokeLater { - stopHealthPolling() - if (serverState == ServerState.STARTING) applyState(ServerState.READY) + when (checkHealthStatus(port)) { + HealthStatus.HEALTHY -> { + SwingUtilities.invokeLater { + stopHealthPolling() + if (serverState == ServerState.STARTING) applyState(ServerState.READY) + } } + + HealthStatus.AUTH_REQUIRED -> { + SwingUtilities.invokeLater { + wasPortOpen = true + stopHealthPolling() + applyState(ServerState.AUTH_REQUIRED) + } + } + + HealthStatus.UNHEALTHY -> Unit } } private fun checkExternalHealth(port: Int, revision: Long) { - val healthy = checkHealthOnce(port) + val healthStatus = checkHealthStatus(port) SwingUtilities.invokeLater { if (revision != externalHealthRevision.get()) return@invokeLater if (ownedProcess != null || serverState == ServerState.STARTING) return@invokeLater - if (healthy) { - wasPortOpen = true - applyState(ServerState.READY) - } else { - applyState(ServerState.PORT_CONFLICT) + when (healthStatus) { + HealthStatus.HEALTHY -> { + wasPortOpen = true + applyState(ServerState.READY) + } + + HealthStatus.AUTH_REQUIRED -> { + wasPortOpen = true + applyState(ServerState.AUTH_REQUIRED) + } + + HealthStatus.UNHEALTHY -> { + applyState(ServerState.PORT_CONFLICT) + } } } } - private fun checkHealthOnce(port: Int): Boolean = + private fun checkHealthStatus(port: Int): HealthStatus = try { when (val result = healthApiClient.isHealthy(port)) { - is ApiResult.Success -> result.value + is ApiResult.Success -> if (result.value) HealthStatus.HEALTHY else HealthStatus.UNHEALTHY is ApiResult.Failure -> { log.debug("ServerManager: health check failed for port $port reason=${result.error}") - false + if (result.error.isAuthenticationFailure()) HealthStatus.AUTH_REQUIRED else HealthStatus.UNHEALTHY } } } catch (e: Exception) { log.debug("ServerManager: health check failed for port $port", e) - false + HealthStatus.UNHEALTHY + } + + private fun ApiError.isAuthenticationFailure(): Boolean = + this is ApiError.HttpError && (statusCode == 401 || statusCode == 403) + + private fun processEnvironmentVariables( + settings: OpenCodeSettings, + overrides: Map + ): Map = + settings.serverEnvironmentVariables.associate { it.name to it.value } + overrides + + private fun showNotification(title: String, content: String, type: NotificationType) { + NotificationGroupManager.getInstance() + .getNotificationGroup(OpenCodeConstants.NOTIFICATION_GROUP_ID) + .createNotification(title, content, type) + .notify(project) + } + + fun reportAuthenticationRequired() { + SwingUtilities.invokeLater { + wasPortOpen = true + stopHealthPolling() + applyState(ServerState.AUTH_REQUIRED) } + } private fun isPortOpen(port: Int): Boolean { val addresses = try { @@ -283,6 +346,35 @@ class ServerManager( } } + internal fun buildServeArguments(settings: OpenCodeSettings, port: Int): List { + val hostname = settings.serverHostname.trim() + val mdnsDomain = settings.serverMdnsDomain.trim() + return buildList { + add("serve") + add("--port") + add(port.toString()) + if (hostname.isNotEmpty()) { + add("--hostname") + add(hostname) + } + if (settings.serverMdnsEnabled) { + add("--mdns") + } + if (mdnsDomain.isNotEmpty()) { + add("--mdns-domain") + add(mdnsDomain) + } + settings.serverCorsOrigins + .lineSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .forEach { origin -> + add("--cors") + add(origin) + } + } + } + fun startServer(port: Int, executablePath: String) { val executable = File(executablePath) val isLaunchable = if (SystemInfo.isWindows) { @@ -291,7 +383,7 @@ class ServerManager( executable.isFile && executable.canExecute() } if (!isLaunchable) { - project.showNotification( + showNotification( "Failed to start OpenCode Relay", "OpenCode Relay executable is not valid or executable: $executablePath", NotificationType.ERROR, @@ -310,12 +402,15 @@ class ServerManager( } try { - val environmentVariables = OpenCodeSettings.getInstance(project).processEnvironmentVariables() + val settings = OpenCodeSettings.getInstance(project) + val environmentVariables = + processEnvironmentVariables(settings, serverAuth.serverLaunchEnvironmentVariables()) + val serveArguments = buildServeArguments(settings, port) val command = if (SystemInfo.isWindows) { - listOf("cmd", "/c", buildWindowsCommand(executablePath, "serve", "--port", port.toString())) + listOf("cmd", "/c", buildWindowsCommand(executablePath, *serveArguments.toTypedArray())) } else { - listOf(executablePath, "serve", "--port", port.toString()) + listOf(executablePath) + serveArguments } val process = ProcessBuilder(command) .inheritIO() @@ -347,7 +442,7 @@ class ServerManager( } catch (e: Exception) { val message = e.message ?: e.javaClass.simpleName SwingUtilities.invokeLater { - project.showNotification( + showNotification( "Failed to start OpenCode Relay", "Could not launch the OpenCode Relay process: $message", NotificationType.ERROR, diff --git a/src/main/kotlin/com/ashotn/opencode/relay/actions/McpServersAction.kt b/src/main/kotlin/com/ashotn/opencode/relay/actions/McpServersAction.kt index cd1df21..15fd534 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/actions/McpServersAction.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/actions/McpServersAction.kt @@ -25,7 +25,7 @@ class McpServersAction(private val project: Project) : AnAction() { override fun actionPerformed(e: AnActionEvent) { val port = OpenCodeSettings.getInstance(project).serverPort - val content = McpServersPopupPanel(port) + val content = McpServersPopupPanel(project, port) val popup = JBPopupFactory.getInstance() .createComponentPopupBuilder(content, content) diff --git a/src/main/kotlin/com/ashotn/opencode/relay/actions/McpServersPopupPanel.kt b/src/main/kotlin/com/ashotn/opencode/relay/actions/McpServersPopupPanel.kt index 4384dbd..51f9f13 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/actions/McpServersPopupPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/actions/McpServersPopupPanel.kt @@ -33,8 +33,9 @@ import javax.swing.SwingConstants * [Disposable] so the subscription is cleaned up when the popup closes. */ class McpServersPopupPanel( + project: Project, private val port: Int, - private val mcpService: McpService = McpService(), + private val mcpService: McpService = McpService.forProject(project), ) : JPanel(BorderLayout()) { private val listPanel = JPanel(GridBagLayout()).apply { isOpaque = false } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/actions/OpenTerminalAction.kt b/src/main/kotlin/com/ashotn/opencode/relay/actions/OpenTerminalAction.kt index 32e792b..23482f3 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/actions/OpenTerminalAction.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/actions/OpenTerminalAction.kt @@ -4,6 +4,7 @@ import com.ashotn.opencode.relay.OpenCodePlugin import com.ashotn.opencode.relay.OpenCodeProcessEnvironment import com.ashotn.opencode.relay.ServerState import com.ashotn.opencode.relay.settings.OpenCodeSettings +import com.ashotn.opencode.relay.settings.OpenCodeServerAuth import com.ashotn.opencode.relay.settings.processEnvironmentVariables import com.ashotn.opencode.relay.util.applyStrings import com.ashotn.opencode.relay.util.showNotification @@ -49,7 +50,8 @@ class OpenTerminalAction(private val project: Project) : AnAction() { * Launches an external OS terminal window running ` attach `. */ private fun launchExternalTerminal(executablePath: String, url: String) { - val environmentVariables = OpenCodeSettings.getInstance(project).processEnvironmentVariables() + val environmentVariables = OpenCodeSettings.getInstance(project) + .processEnvironmentVariables(OpenCodeServerAuth.getInstance(project).connectionEnvironmentVariables()) val attachArgs = listOf(executablePath, "attach", url) val processCommand: List = when { SystemInfo.isWindows -> listOf( diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/event/EventStreamClient.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/event/EventStreamClient.kt index 0bdf81e..9379e5e 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/api/event/EventStreamClient.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/api/event/EventStreamClient.kt @@ -9,7 +9,10 @@ import java.net.URI class EventStreamClient( private val connectTimeoutMs: Int = 5_000, private val readTimeoutMs: Int = 0, + private val authorizationHeaderProvider: () -> String? = { null }, ) { + class AuthenticationException(message: String) : IOException(message) + @Volatile private var activeSubscription: EventSubscription? = null @@ -43,6 +46,7 @@ class EventStreamClient( connection.requestMethod = "GET" connection.setRequestProperty("Accept", "text/event-stream") connection.setRequestProperty("Cache-Control", "no-cache") + authorizationHeaderProvider()?.let { connection.setRequestProperty("Authorization", it) } connection.connectTimeout = connectTimeoutMs connection.readTimeout = readTimeoutMs connection.connect() @@ -51,7 +55,11 @@ class EventStreamClient( if (statusCode !in 200..299) { val errorBody = connection.errorStream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() } connection.disconnect() - throw IOException("SSE stream failed HTTP $statusCode body=${errorBody?.take(200)}") + val message = "SSE stream failed HTTP $statusCode body=${errorBody?.take(200)}" + if (statusCode == 401 || statusCode == 403) { + throw AuthenticationException(message) + } + throw IOException(message) } val contentType = connection.contentType?.lowercase() ?: "" diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/mcp/McpService.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/mcp/McpService.kt index 16331a4..4f07762 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/api/mcp/McpService.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/api/mcp/McpService.kt @@ -3,6 +3,9 @@ package com.ashotn.opencode.relay.api.mcp import com.ashotn.opencode.relay.api.config.ConfigApiClient import com.ashotn.opencode.relay.api.config.ConfigApiClient.McpServerConfig import com.ashotn.opencode.relay.api.transport.ApiResult +import com.ashotn.opencode.relay.api.transport.OpenCodeHttpTransport +import com.ashotn.opencode.relay.settings.OpenCodeServerAuth +import com.intellij.openapi.project.Project import java.util.concurrent.CopyOnWriteArraySet /** @@ -102,4 +105,16 @@ class McpService( loadingNames.remove(name) } } + + companion object { + fun forProject(project: Project): McpService { + val transport = OpenCodeHttpTransport( + authorizationHeaderProvider = OpenCodeServerAuth.getInstance(project)::connectionAuthorizationHeader, + ) + return McpService( + configClient = ConfigApiClient(transport), + mcpClient = McpApiClient(transport), + ) + } + } } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/transport/ApiError.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/transport/ApiError.kt index ecfcb35..bb7beb4 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/api/transport/ApiError.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/api/transport/ApiError.kt @@ -16,3 +16,6 @@ sealed interface ApiError { val cause: Throwable? = null, ) : ApiError } + +fun ApiError.isAuthenticationFailure(): Boolean = + this is ApiError.HttpError && (statusCode == 401 || statusCode == 403) diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/transport/OpenCodeHttpTransport.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/transport/OpenCodeHttpTransport.kt index cddc0c7..aa3c8b4 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/api/transport/OpenCodeHttpTransport.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/api/transport/OpenCodeHttpTransport.kt @@ -12,6 +12,7 @@ import java.time.Duration class OpenCodeHttpTransport( defaultConnectTimeoutMs: Int = 3_000, private val defaultReadTimeoutMs: Int = 5_000, + private val authorizationHeaderProvider: () -> String? = { null }, ) { data class Timeouts(val readTimeoutMs: Int) @@ -137,6 +138,7 @@ class OpenCodeHttpTransport( .method(method, bodyPublisher) .apply { header("Accept", accept) + authorizationHeaderProvider()?.let { header("Authorization", it) } if (payload != null && contentType != null) header("Content-Type", contentType) timeout(Duration.ofMillis(resolvedTimeouts.readTimeoutMs.toLong())) } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/core/OpenCodeCoreService.kt b/src/main/kotlin/com/ashotn/opencode/relay/core/OpenCodeCoreService.kt index 539ec0e..a7c6bd1 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/core/OpenCodeCoreService.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/core/OpenCodeCoreService.kt @@ -1,16 +1,19 @@ package com.ashotn.opencode.relay.core +import com.ashotn.opencode.relay.OpenCodePlugin import com.ashotn.opencode.relay.api.session.Session import com.ashotn.opencode.relay.api.session.SessionApiClient import com.ashotn.opencode.relay.api.transport.ApiResult +import com.ashotn.opencode.relay.api.transport.OpenCodeHttpTransport import com.ashotn.opencode.relay.core.session.PendingSessionSelection import com.ashotn.opencode.relay.core.session.SessionInfo import com.ashotn.opencode.relay.core.session.SessionScopeResolver import com.ashotn.opencode.relay.ipc.McpChangedListener import com.ashotn.opencode.relay.ipc.OpenCodeEvent -import com.ashotn.opencode.relay.settings.OpenCodeSettings import com.ashotn.opencode.relay.ipc.SseClient import com.ashotn.opencode.relay.permission.OpenCodePermissionService +import com.ashotn.opencode.relay.settings.OpenCodeServerAuth +import com.ashotn.opencode.relay.settings.OpenCodeSettings import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service @@ -58,7 +61,10 @@ class OpenCodeCoreService(private val project: Project) : Disposable { private val permissionService = OpenCodePermissionService.getInstance(project) private val documentSyncService = DocumentSyncService(project) - private val sessionApiClient = SessionApiClient() + private val serverAuth = OpenCodeServerAuth.getInstance(project) + private val sessionApiClient = SessionApiClient( + OpenCodeHttpTransport(authorizationHeaderProvider = serverAuth::connectionAuthorizationHeader), + ) private val hunkComputer = DiffHunkComputer(log) private val sessionDiffApplyComputer = SessionDiffApplyComputer( contentReader = documentSyncService::readCurrentContent, @@ -98,6 +104,10 @@ class OpenCodeCoreService(private val project: Project) : Disposable { sseClient = SseClient( port = port, onEvent = { event -> handleEvent(event, generation) }, + authorizationHeaderProvider = serverAuth::connectionAuthorizationHeader, + onAuthenticationFailure = { + OpenCodePlugin.getInstance(project).reportAuthenticationRequired() + }, ).also { it.start() } trace("listener.started") { val traceFilePath = (tracer as? JsonlDiffTracer)?.traceFilePath() @@ -894,4 +904,4 @@ class OpenCodeCoreService(private val project: Project) : Disposable { fun getInstance(project: Project): OpenCodeCoreService = project.getService(OpenCodeCoreService::class.java) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/ashotn/opencode/relay/ipc/SseClient.kt b/src/main/kotlin/com/ashotn/opencode/relay/ipc/SseClient.kt index 348105b..a038cce 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/ipc/SseClient.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/ipc/SseClient.kt @@ -27,6 +27,8 @@ import java.util.concurrent.atomic.AtomicBoolean class SseClient( private val port: Int, private val onEvent: (OpenCodeEvent) -> Unit, + authorizationHeaderProvider: () -> String? = { null }, + private val onAuthenticationFailure: (() -> Unit)? = null, ) { companion object { private const val CONNECT_TIMEOUT_MS = 5_000 @@ -45,6 +47,7 @@ class SseClient( private val eventStreamClient = EventStreamClient( connectTimeoutMs = CONNECT_TIMEOUT_MS, readTimeoutMs = READ_TIMEOUT_MS, + authorizationHeaderProvider = authorizationHeaderProvider, ) private var future: Future<*>? = null @@ -70,6 +73,11 @@ class SseClient( try { connect() retryDelayMs = RETRY_INITIAL_MS + } catch (e: EventStreamClient.AuthenticationException) { + log.info("SseClient: authentication failed, stopping SSE loop", e) + running.set(false) + onAuthenticationFailure?.invoke() + break } catch (_: InterruptedException) { break } catch (e: Exception) { diff --git a/src/main/kotlin/com/ashotn/opencode/relay/permission/OpenCodePermissionService.kt b/src/main/kotlin/com/ashotn/opencode/relay/permission/OpenCodePermissionService.kt index 345ae18..711f16c 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/permission/OpenCodePermissionService.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/permission/OpenCodePermissionService.kt @@ -3,9 +3,12 @@ package com.ashotn.opencode.relay.permission import com.ashotn.opencode.relay.api.permission.PermissionApiClient import com.ashotn.opencode.relay.api.transport.ApiError import com.ashotn.opencode.relay.api.transport.ApiResult +import com.ashotn.opencode.relay.api.transport.OpenCodeHttpTransport +import com.ashotn.opencode.relay.api.transport.isAuthenticationFailure import com.ashotn.opencode.relay.ipc.OpenCodeEvent import com.ashotn.opencode.relay.ipc.PermissionChangedListener import com.ashotn.opencode.relay.ipc.PermissionReply +import com.ashotn.opencode.relay.settings.OpenCodeServerAuth import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service @@ -22,7 +25,11 @@ class OpenCodePermissionService(private val project: Project) : Disposable { } private val log = logger() - private val permissionApiClient = PermissionApiClient() + private val permissionApiClient = PermissionApiClient( + OpenCodeHttpTransport( + authorizationHeaderProvider = OpenCodeServerAuth.getInstance(project)::connectionAuthorizationHeader, + ), + ) @Volatile private var port: Int = 0 @@ -95,7 +102,11 @@ class OpenCodePermissionService(private val project: Project) : Disposable { } private fun apiErrorMessage(error: ApiError): String = when (error) { - is ApiError.HttpError -> "HTTP ${error.statusCode}" + is ApiError.HttpError -> if (error.isAuthenticationFailure()) { + "OpenCode authentication failed. Update Server Authentication settings." + } else { + "HTTP ${error.statusCode}" + } is ApiError.NetworkError -> error.message is ApiError.ParseError -> error.message } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuth.kt b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuth.kt new file mode 100644 index 0000000..96d56ff --- /dev/null +++ b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuth.kt @@ -0,0 +1,72 @@ +package com.ashotn.opencode.relay.settings + +import com.intellij.credentialStore.CredentialAttributes +import com.intellij.credentialStore.Credentials +import com.intellij.credentialStore.generateServiceName +import com.intellij.ide.passwordSafe.PasswordSafe +import com.intellij.openapi.components.Service +import com.intellij.openapi.project.Project +import java.nio.charset.StandardCharsets +import java.util.Base64 + +@Service(Service.Level.PROJECT) +class OpenCodeServerAuth(private val project: Project) { + + data class BasicAuthCredentials( + val username: String, + val password: String, + ) { + fun authorizationHeader(): String { + val token = Base64.getEncoder().encodeToString("$username:$password".toByteArray(StandardCharsets.UTF_8)) + return "Basic $token" + } + + fun environmentVariables(): Map = mapOf( + "OPENCODE_SERVER_USERNAME" to username, + "OPENCODE_SERVER_PASSWORD" to password, + ) + } + + fun password(): String = PasswordSafe.instance.getPassword(attributes()).orEmpty() + + fun setPassword(password: String) { + val credentials = password.takeIf { it.isNotEmpty() }?.let { + Credentials(credentialUserName(), it) + } + PasswordSafe.instance.set(attributes(), credentials) + } + + fun connectionCredentials(): BasicAuthCredentials? { + val password = password() + if (password.isEmpty()) return null + + val username = OpenCodeSettings.getInstance(project).serverAuthUsername + .trim() + .ifEmpty { OpenCodeSettings.DEFAULT_SERVER_AUTH_USERNAME } + return BasicAuthCredentials(username = username, password = password) + } + + fun connectionAuthorizationHeader(): String? = connectionCredentials()?.authorizationHeader() + + fun connectionEnvironmentVariables(): Map = + connectionCredentials()?.environmentVariables().orEmpty() + + fun serverLaunchEnvironmentVariables(): Map { + val settings = OpenCodeSettings.getInstance(project) + if (!settings.protectPluginLaunchedServerWithAuth) return emptyMap() + return connectionEnvironmentVariables() + } + + private fun attributes(): CredentialAttributes = + CredentialAttributes( + generateServiceName("OpenCode Relay", "Server Authentication"), + credentialUserName(), + ) + + private fun credentialUserName(): String = "server-auth:${project.locationHash}" + + companion object { + fun getInstance(project: Project): OpenCodeServerAuth = + project.getService(OpenCodeServerAuth::class.java) + } +} diff --git a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt index 3a984fd..87e8ed0 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt @@ -166,5 +166,5 @@ fun OpenCodeSettings.snapshot(): OpenCodeSettingsSnapshot = OpenCodeSettingsSnap terminalEngine = terminalEngine, ) -fun OpenCodeSettings.processEnvironmentVariables(): Map = - serverEnvironmentVariables.associate { it.name to it.value } +fun OpenCodeSettings.processEnvironmentVariables(overrides: Map = emptyMap()): Map = + serverEnvironmentVariables.associate { it.name to it.value } + overrides diff --git a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt index 4e7faaf..f1f68e2 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt @@ -256,11 +256,20 @@ class OpenCodeSettingsConfigurable(private val project: Project) : throw ConfigurationException("Environment variable names cannot contain '='") } validateReservedServerAuthEnvironmentVariables(pendingState.serverEnvironmentVariables) + if (pendingState.protectPluginLaunchedServerWithAuth && currentServerAuthPassword().isBlank()) { + throw ConfigurationException("Enter a password to protect the server launched by plugin") + } val newSettings = snapshot(pendingState) val settingsChanged = newSettings != oldSettings val newPassword = currentServerAuthPassword() val passwordChanged = newPassword != oldPassword + val connectionAuthChanged = oldSettings.serverAuthUsername != newSettings.serverAuthUsername || passwordChanged + val oldLaunchAuthEnabled = oldSettings.protectPluginLaunchedServerWithAuth && oldPassword.isNotEmpty() + val newLaunchAuthEnabled = newSettings.protectPluginLaunchedServerWithAuth && newPassword.isNotEmpty() + val launchAuthChanged = + oldLaunchAuthEnabled != newLaunchAuthEnabled || + (newLaunchAuthEnabled && connectionAuthChanged) val newPort = newSettings.serverPort val newPath = newSettings.executablePath val portChanged = newPort != oldSettings.serverPort @@ -269,8 +278,9 @@ class OpenCodeSettingsConfigurable(private val project: Project) : pathChanged || (newPath.isBlank() && oldResolutionState == OpenCodeExecutableResolutionState.Resolving) if (!settingsChanged && !passwordChanged && !shouldUpdateExecutableResolution) return - val mustConfirmStop = plugin.isRunning && plugin.ownsProcess && (portChanged || pathChanged) + val mustConfirmStop = plugin.isRunning && plugin.ownsProcess && (portChanged || pathChanged || launchAuthChanged) val mustReattach = plugin.isRunning && !plugin.ownsProcess && portChanged + val mustResetConnection = plugin.isRunning && !plugin.ownsProcess && !portChanged && connectionAuthChanged var resolvedState = oldResolutionState if (shouldUpdateExecutableResolution) { @@ -299,6 +309,11 @@ class OpenCodeSettingsConfigurable(private val project: Project) : when { mustConfirmStop -> plugin.stopServer() mustReattach -> plugin.reattach(newPort) + mustResetConnection -> plugin.resetConnection() + } + + if (connectionAuthChanged && !plugin.ownsProcess && !mustReattach && !mustResetConnection) { + plugin.checkPort(newPort) } if (shouldUpdateExecutableResolution && resolvedState != oldResolutionState) { diff --git a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt index 4ab639f..e6aef3a 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt @@ -3,6 +3,7 @@ package com.ashotn.opencode.relay.terminal import com.ashotn.opencode.relay.OpenCodePlugin import com.ashotn.opencode.relay.OpenCodeProcessEnvironment import com.ashotn.opencode.relay.settings.OpenCodeSettings +import com.ashotn.opencode.relay.settings.OpenCodeServerAuth import com.ashotn.opencode.relay.settings.processEnvironmentVariables import com.ashotn.opencode.relay.util.serverUrl import com.intellij.ide.DataManager @@ -71,7 +72,8 @@ class ClassicTuiPanel( } val workingDir = project.basePath ?: System.getProperty("user.home") - val environmentVariables = OpenCodeSettings.getInstance(project).processEnvironmentVariables() + val environmentVariables = OpenCodeSettings.getInstance(project) + .processEnvironmentVariables(OpenCodeServerAuth.getInstance(project).connectionEnvironmentVariables()) val command = OpenCodeProcessEnvironment.terminalCommand( listOf( executablePath, diff --git a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ReworkedTuiPanel.kt b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ReworkedTuiPanel.kt index f728788..2533d1a 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ReworkedTuiPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ReworkedTuiPanel.kt @@ -5,6 +5,7 @@ package com.ashotn.opencode.relay.terminal import com.ashotn.opencode.relay.OpenCodePlugin import com.ashotn.opencode.relay.OpenCodeProcessEnvironment import com.ashotn.opencode.relay.settings.OpenCodeSettings +import com.ashotn.opencode.relay.settings.OpenCodeServerAuth import com.ashotn.opencode.relay.settings.processEnvironmentVariables import com.ashotn.opencode.relay.util.serverUrl import com.intellij.openapi.Disposable @@ -73,7 +74,8 @@ class ReworkedTuiPanel( } val workingDir = project.basePath ?: System.getProperty("user.home") - val environmentVariables = OpenCodeSettings.getInstance(project).processEnvironmentVariables() + val environmentVariables = OpenCodeSettings.getInstance(project) + .processEnvironmentVariables(OpenCodeServerAuth.getInstance(project).connectionEnvironmentVariables()) val command = OpenCodeProcessEnvironment.terminalCommand( listOf( executablePath, diff --git a/src/main/kotlin/com/ashotn/opencode/relay/toolwindow/InstalledPanel.kt b/src/main/kotlin/com/ashotn/opencode/relay/toolwindow/InstalledPanel.kt index ccb8b13..29d9b19 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/toolwindow/InstalledPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/toolwindow/InstalledPanel.kt @@ -200,6 +200,12 @@ class InstalledPanel( buttonPanel.isVisible = false } + ServerState.AUTH_REQUIRED -> { + portStatusLabel.text = "Authentication required for the server on port $port" + portStatusLabel.foreground = JBUI.CurrentTheme.Label.disabledForeground() + buttonPanel.isVisible = false + } + ServerState.RESETTING -> { portStatusLabel.text = "Resetting OpenCode Relay…" portStatusLabel.foreground = JBUI.CurrentTheme.Label.disabledForeground() diff --git a/src/main/kotlin/com/ashotn/opencode/relay/tui/OpenCodeTuiClient.kt b/src/main/kotlin/com/ashotn/opencode/relay/tui/OpenCodeTuiClient.kt index 4d1631b..06b4044 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/tui/OpenCodeTuiClient.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/tui/OpenCodeTuiClient.kt @@ -3,8 +3,11 @@ package com.ashotn.opencode.relay.tui import com.ashotn.opencode.relay.api.session.SessionApiClient import com.ashotn.opencode.relay.api.transport.ApiError import com.ashotn.opencode.relay.api.transport.ApiResult +import com.ashotn.opencode.relay.api.transport.OpenCodeHttpTransport +import com.ashotn.opencode.relay.api.transport.isAuthenticationFailure import com.ashotn.opencode.relay.api.tui.TuiApiClient import com.ashotn.opencode.relay.core.OpenCodeCoreService +import com.ashotn.opencode.relay.settings.OpenCodeServerAuth import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service import com.intellij.openapi.project.Project @@ -22,8 +25,10 @@ import com.intellij.openapi.project.Project @Service(Service.Level.PROJECT) class OpenCodeTuiClient(private val project: Project) { - private val sessionApiClient = SessionApiClient() - private val tuiApiClient = TuiApiClient() + private val serverAuth = OpenCodeServerAuth.getInstance(project) + private val transport = OpenCodeHttpTransport(authorizationHeaderProvider = serverAuth::connectionAuthorizationHeader) + private val sessionApiClient = SessionApiClient(transport) + private val tuiApiClient = TuiApiClient(transport) @Volatile private var port: Int = 0 @@ -202,7 +207,11 @@ class OpenCodeTuiClient(private val project: Project) { } private fun apiErrorMessage(error: ApiError): String = when (error) { - is ApiError.HttpError -> "Server returned HTTP ${error.statusCode}" + is ApiError.HttpError -> if (error.isAuthenticationFailure()) { + "OpenCode authentication failed. Update Server Authentication settings." + } else { + "Server returned HTTP ${error.statusCode}" + } is ApiError.NetworkError -> error.message is ApiError.ParseError -> error.message } diff --git a/src/test/kotlin/com/ashotn/opencode/relay/ServerManagerTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/ServerManagerTest.kt new file mode 100644 index 0000000..cf2a134 --- /dev/null +++ b/src/test/kotlin/com/ashotn/opencode/relay/ServerManagerTest.kt @@ -0,0 +1,50 @@ +package com.ashotn.opencode.relay + +import com.ashotn.opencode.relay.settings.OpenCodeSettings +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class ServerManagerTest : BasePlatformTestCase() { + + fun testBuildServeArgumentsIncludesHostnameAndCorsOrigins() { + val settings = OpenCodeSettings.getInstance(project) + settings.serverHostname = "0.0.0.0" + settings.serverMdnsEnabled = true + settings.serverMdnsDomain = "relay.local" + settings.serverCorsOrigins = "http://localhost:5173\nhttps://app.example.com" + + val manager = ServerManager(project) { } + + assertEquals( + listOf( + "serve", + "--port", + "4096", + "--hostname", + "0.0.0.0", + "--mdns", + "--mdns-domain", + "relay.local", + "--cors", + "http://localhost:5173", + "--cors", + "https://app.example.com", + ), + manager.buildServeArguments(settings, 4096), + ) + } + + fun testBuildServeArgumentsOmitsBlankHostnameAndCorsOrigins() { + val settings = OpenCodeSettings.getInstance(project) + settings.serverHostname = " " + settings.serverMdnsEnabled = false + settings.serverMdnsDomain = " " + settings.serverCorsOrigins = "\n \n" + + val manager = ServerManager(project) { } + + assertEquals( + listOf("serve", "--port", "4096"), + manager.buildServeArguments(settings, 4096), + ) + } +} diff --git a/src/test/kotlin/com/ashotn/opencode/relay/api/event/EventStreamClientTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/api/event/EventStreamClientTest.kt new file mode 100644 index 0000000..9beb054 --- /dev/null +++ b/src/test/kotlin/com/ashotn/opencode/relay/api/event/EventStreamClientTest.kt @@ -0,0 +1,26 @@ +package com.ashotn.opencode.relay.api.event + +import com.ashotn.opencode.relay.api.withTestServer +import org.junit.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class EventStreamClientTest { + + @Test + fun `consume throws authentication exception on unauthorized response`() { + withTestServer { server, port -> + server.createContext("/event") { exchange -> + val body = "Unauthorized" + exchange.sendResponseHeaders(401, body.toByteArray(Charsets.UTF_8).size.toLong()) + exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } + } + + val exception = assertFailsWith { + EventStreamClient().consume(port) { } + } + + assertTrue(exception.message.orEmpty().contains("401")) + } + } +} diff --git a/src/test/kotlin/com/ashotn/opencode/relay/api/transport/OpenCodeHttpTransportTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/api/transport/OpenCodeHttpTransportTest.kt index df6a5c7..572166a 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/api/transport/OpenCodeHttpTransportTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/api/transport/OpenCodeHttpTransportTest.kt @@ -74,6 +74,26 @@ class OpenCodeHttpTransportTest { } } + @Test + fun `adds authorization header when configured`() { + withTestServer { server, port -> + var capturedAuthorization = "" + server.createContext("/auth") { exchange -> + capturedAuthorization = exchange.requestHeaders.getFirst("Authorization") ?: "" + + val response = "{}" + exchange.sendResponseHeaders(200, response.toByteArray(Charsets.UTF_8).size.toLong()) + exchange.responseBody.use { it.write(response.toByteArray(Charsets.UTF_8)) } + } + + val transport = OpenCodeHttpTransport(authorizationHeaderProvider = { "Basic abc123" }) + val result = transport.get(port, "/auth") + + assertIs>(result) + assertEquals("Basic abc123", capturedAuthorization) + } + } + @Test fun `maps malformed json parsing to parse error`() { val transport = OpenCodeHttpTransport() diff --git a/src/test/kotlin/com/ashotn/opencode/relay/ipc/SseClientTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/ipc/SseClientTest.kt new file mode 100644 index 0000000..fa185c8 --- /dev/null +++ b/src/test/kotlin/com/ashotn/opencode/relay/ipc/SseClientTest.kt @@ -0,0 +1,44 @@ +package com.ashotn.opencode.relay.ipc + +import com.ashotn.opencode.relay.api.withTestServer +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SseClientTest { + + @Test + fun `reports authentication failure once and stops retrying`() { + withTestServer { server, port -> + val requests = AtomicInteger(0) + server.createContext("/event") { exchange -> + requests.incrementAndGet() + val body = "Unauthorized" + exchange.sendResponseHeaders(401, body.toByteArray(Charsets.UTF_8).size.toLong()) + exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } + } + + val authFailure = CountDownLatch(1) + val client = SseClient( + port = port, + onEvent = { }, + onAuthenticationFailure = { authFailure.countDown() }, + ) + + try { + client.start() + assertTrue(authFailure.await(2, TimeUnit.SECONDS)) + + val requestCountAfterFailure = requests.get() + Thread.sleep(1_300) + + assertEquals(requestCountAfterFailure, requests.get()) + } finally { + client.stop() + } + } + } +} diff --git a/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuthTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuthTest.kt new file mode 100644 index 0000000..8d8d7df --- /dev/null +++ b/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuthTest.kt @@ -0,0 +1,54 @@ +package com.ashotn.opencode.relay.settings + +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class OpenCodeServerAuthTest : BasePlatformTestCase() { + + override fun tearDown() { + try { + val settings = OpenCodeSettings.getInstance(project) + settings.serverAuthUsername = OpenCodeSettings.DEFAULT_SERVER_AUTH_USERNAME + settings.protectPluginLaunchedServerWithAuth = false + OpenCodeServerAuth.getInstance(project).setPassword("") + } finally { + super.tearDown() + } + } + + fun testConnectionAuthorizationHeaderUsesConfiguredCredentials() { + val settings = OpenCodeSettings.getInstance(project) + val auth = OpenCodeServerAuth.getInstance(project) + + settings.serverAuthUsername = "alice" + auth.setPassword("secret") + + assertEquals("Basic YWxpY2U6c2VjcmV0", auth.connectionAuthorizationHeader()) + assertEquals( + mapOf( + "OPENCODE_SERVER_USERNAME" to "alice", + "OPENCODE_SERVER_PASSWORD" to "secret", + ), + auth.connectionEnvironmentVariables(), + ) + } + + fun testServerLaunchEnvironmentVariablesRespectProtectionToggle() { + val settings = OpenCodeSettings.getInstance(project) + val auth = OpenCodeServerAuth.getInstance(project) + + settings.serverAuthUsername = "alice" + auth.setPassword("secret") + + settings.protectPluginLaunchedServerWithAuth = false + assertTrue(auth.serverLaunchEnvironmentVariables().isEmpty()) + + settings.protectPluginLaunchedServerWithAuth = true + assertEquals( + mapOf( + "OPENCODE_SERVER_USERNAME" to "alice", + "OPENCODE_SERVER_PASSWORD" to "secret", + ), + auth.serverLaunchEnvironmentVariables(), + ) + } +} diff --git a/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurableTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurableTest.kt index 198a64a..5f73125 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurableTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurableTest.kt @@ -18,6 +18,9 @@ class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() { override fun tearDown() { try { + val settings = OpenCodeSettings.getInstance(project) + settings.serverAuthUsername = OpenCodeSettings.DEFAULT_SERVER_AUTH_USERNAME + settings.protectPluginLaunchedServerWithAuth = false OpenCodeServerAuth.getInstance(project).setPassword("") } finally { super.tearDown() @@ -159,6 +162,26 @@ class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() { } } + fun testApplyRejectsProtectedServerWithoutPassword() { + val configurable = OpenCodeSettingsConfigurable(project) + + try { + getOnEdt { configurable.createComponent() } + + runOnEdt { + configurable.protectPluginLaunchedServerWithAuthCheckBox.isSelected = true + configurable.serverAuthPasswordField.text = "" + } + + val exception = assertFailsWith { + runOnEdt { configurable.apply() } + } + assertTrue(exception.localizedMessage.orEmpty().contains("protect the server launched by plugin")) + } finally { + runOnEdt { configurable.disposeUIResources() } + } + } + fun testApplyCommitsActiveCorsCellEdit() { val settings = OpenCodeSettings.getInstance(project) val configurable = OpenCodeSettingsConfigurable(project) From fef6c32ad859f9f77a437d4c93a7b28187643764 Mon Sep 17 00:00:00 2001 From: Ashot Nazaryan Date: Mon, 20 Apr 2026 16:59:37 -0700 Subject: [PATCH 5/7] refactor: streamline environment variable handling and snapshot logic --- .../integration/OpenCodeTestEventCollector.kt | 15 ++++++++----- .../ashotn/opencode/relay/ServerManager.kt | 17 +++++--------- .../relay/settings/OpenCodeServerAuth.kt | 3 ++- .../relay/settings/OpenCodeSettings.kt | 4 +++- .../settings/OpenCodeSettingsConfigurable.kt | 22 ++----------------- 5 files changed, 22 insertions(+), 39 deletions(-) diff --git a/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/OpenCodeTestEventCollector.kt b/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/OpenCodeTestEventCollector.kt index 2dc3801..85edd5a 100644 --- a/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/OpenCodeTestEventCollector.kt +++ b/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/OpenCodeTestEventCollector.kt @@ -10,12 +10,15 @@ class OpenCodeTestEventCollector( @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") private val lock = Object() private val events = mutableListOf() - private val sseClient = SseClient(port) { event -> - synchronized(lock) { - events.add(event) - lock.notifyAll() - } - } + private val sseClient = SseClient( + port = port, + onEvent = { event -> + synchronized(lock) { + events.add(event) + lock.notifyAll() + } + }, + ) init { sseClient.start() diff --git a/src/main/kotlin/com/ashotn/opencode/relay/ServerManager.kt b/src/main/kotlin/com/ashotn/opencode/relay/ServerManager.kt index 4a84aa4..87fef2d 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/ServerManager.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/ServerManager.kt @@ -6,6 +6,7 @@ import com.ashotn.opencode.relay.api.transport.ApiResult import com.ashotn.opencode.relay.api.transport.OpenCodeHttpTransport import com.ashotn.opencode.relay.settings.OpenCodeSettings import com.ashotn.opencode.relay.settings.OpenCodeServerAuth +import com.ashotn.opencode.relay.settings.processEnvironmentVariables import com.intellij.notification.NotificationGroupManager import com.intellij.notification.NotificationType import com.intellij.openapi.diagnostic.logger @@ -261,12 +262,6 @@ class ServerManager( private fun ApiError.isAuthenticationFailure(): Boolean = this is ApiError.HttpError && (statusCode == 401 || statusCode == 403) - private fun processEnvironmentVariables( - settings: OpenCodeSettings, - overrides: Map - ): Map = - settings.serverEnvironmentVariables.associate { it.name to it.value } + overrides - private fun showNotification(title: String, content: String, type: NotificationType) { NotificationGroupManager.getInstance() .getNotificationGroup(OpenCodeConstants.NOTIFICATION_GROUP_ID) @@ -359,10 +354,10 @@ class ServerManager( } if (settings.serverMdnsEnabled) { add("--mdns") - } - if (mdnsDomain.isNotEmpty()) { - add("--mdns-domain") - add(mdnsDomain) + if (mdnsDomain.isNotEmpty()) { + add("--mdns-domain") + add(mdnsDomain) + } } settings.serverCorsOrigins .lineSequence() @@ -404,7 +399,7 @@ class ServerManager( try { val settings = OpenCodeSettings.getInstance(project) val environmentVariables = - processEnvironmentVariables(settings, serverAuth.serverLaunchEnvironmentVariables()) + settings.processEnvironmentVariables(serverAuth.serverLaunchEnvironmentVariables()) val serveArguments = buildServeArguments(settings, port) val command = if (SystemInfo.isWindows) { diff --git a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuth.kt b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuth.kt index 96d56ff..3eedae3 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuth.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuth.kt @@ -54,7 +54,8 @@ class OpenCodeServerAuth(private val project: Project) { fun serverLaunchEnvironmentVariables(): Map { val settings = OpenCodeSettings.getInstance(project) if (!settings.protectPluginLaunchedServerWithAuth) return emptyMap() - return connectionEnvironmentVariables() + return connectionCredentials()?.environmentVariables() + ?: error("Server launch authentication is enabled but credentials are incomplete") } private fun attributes(): CredentialAttributes = diff --git a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt index 87e8ed0..0ccb38a 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt @@ -148,7 +148,9 @@ class OpenCodeSettings : PersistentStateComponent { } -fun OpenCodeSettings.snapshot(): OpenCodeSettingsSnapshot = OpenCodeSettingsSnapshot( +fun OpenCodeSettings.snapshot(): OpenCodeSettingsSnapshot = state.toSnapshot() + +fun OpenCodeSettings.State.toSnapshot(): OpenCodeSettingsSnapshot = OpenCodeSettingsSnapshot( serverPort = serverPort, serverHostname = serverHostname, serverMdnsEnabled = serverMdnsEnabled, diff --git a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt index f1f68e2..8994122 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt @@ -241,7 +241,7 @@ class OpenCodeSettingsConfigurable(private val project: Project) : val settings = OpenCodeSettings.getInstance(project) val serverAuth = OpenCodeServerAuth.getInstance(project) val plugin = OpenCodePlugin.getInstance(project) - val oldSettings = snapshot(settings.state) + val oldSettings = settings.state.toSnapshot() val oldPassword = serverAuth.password() val oldResolutionState = plugin.executableResolutionState @@ -260,7 +260,7 @@ class OpenCodeSettingsConfigurable(private val project: Project) : throw ConfigurationException("Enter a password to protect the server launched by plugin") } - val newSettings = snapshot(pendingState) + val newSettings = pendingState.toSnapshot() val settingsChanged = newSettings != oldSettings val newPassword = currentServerAuthPassword() val passwordChanged = newPassword != oldPassword @@ -482,24 +482,6 @@ class OpenCodeSettingsConfigurable(private val project: Project) : settings.loadState(pendingState.copy()) } - private fun snapshot(state: OpenCodeSettings.State): OpenCodeSettingsSnapshot = OpenCodeSettingsSnapshot( - serverPort = state.serverPort, - serverHostname = state.serverHostname, - serverMdnsEnabled = state.serverMdnsEnabled, - serverMdnsDomain = state.serverMdnsDomain, - serverCorsOrigins = state.serverCorsOrigins, - serverAuthUsername = state.serverAuthUsername, - protectPluginLaunchedServerWithAuth = state.protectPluginLaunchedServerWithAuth, - serverEnvironmentVariables = state.serverEnvironmentVariables.map { it.copy() }, - executablePath = state.executablePath, - inlineDiffEnabled = state.inlineDiffEnabled, - diffTraceEnabled = state.diffTraceEnabled, - diffTraceHistoryEnabled = state.diffTraceHistoryEnabled, - inlineTerminalEnabled = state.inlineTerminalEnabled, - sessionsSectionVisible = state.sessionsSectionVisible, - terminalEngine = state.terminalEngine, - ) - private fun corsOriginColumn(): ColumnInfo = object : ColumnInfo("Origin") { override fun valueOf(item: CorsOriginRow): String = item.origin From df982998372091200501313f0988909c8843e647 Mon Sep 17 00:00:00 2001 From: Ashot Nazaryan Date: Mon, 20 Apr 2026 19:12:09 -0700 Subject: [PATCH 6/7] feat: improve server error handling and environment variable validation --- .../ashotn/opencode/relay/OpenCodePlugin.kt | 57 ++++++++++++------- .../ashotn/opencode/relay/ServerManager.kt | 28 ++++++--- .../relay/settings/OpenCodeServerAuth.kt | 6 +- .../relay/settings/OpenCodeSettings.kt | 12 ++-- .../settings/OpenCodeSettingsConfigurable.kt | 20 ++++++- .../opencode/relay/ServerManagerTest.kt | 52 +++++++++-------- .../relay/settings/OpenCodeServerAuthTest.kt | 14 +++++ .../OpenCodeSettingsConfigurableTest.kt | 24 ++++++++ 8 files changed, 155 insertions(+), 58 deletions(-) diff --git a/src/main/kotlin/com/ashotn/opencode/relay/OpenCodePlugin.kt b/src/main/kotlin/com/ashotn/opencode/relay/OpenCodePlugin.kt index 1d28cf4..a10327e 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/OpenCodePlugin.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/OpenCodePlugin.kt @@ -9,6 +9,7 @@ import com.intellij.openapi.components.Service import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.project.Project import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executors @Service(Service.Level.PROJECT) class OpenCodePlugin(private val project: Project) : Disposable { @@ -16,6 +17,9 @@ class OpenCodePlugin(private val project: Project) : Disposable { // --- Listeners --- private val listeners = CopyOnWriteArrayList() + private val connectionSyncExecutor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "opencode-connection-sync").apply { isDaemon = true } + } fun addListener(listener: ServerStateListener) = listeners.add(listener) fun removeListener(listener: ServerStateListener) = listeners.remove(listener) @@ -24,20 +28,7 @@ class OpenCodePlugin(private val project: Project) : Disposable { private val serverManager = ServerManager(project) { state -> listeners.forEach { it.onStateChanged(state) } - if (state == ServerState.READY) { - val port = OpenCodeSettings.getInstance(project).serverPort - ApplicationManager.getApplication().executeOnPooledThread { - if (project.isDisposed) return@executeOnPooledThread - OpenCodeCoreService.getInstance(project).startListening(port) - OpenCodeTuiClient.getInstance(project).setPort(port) - } - } else if (state == ServerState.STOPPED || state == ServerState.PORT_CONFLICT || state == ServerState.AUTH_REQUIRED) { - ApplicationManager.getApplication().executeOnPooledThread { - if (project.isDisposed) return@executeOnPooledThread - OpenCodeCoreService.getInstance(project).stopListening() - OpenCodeTuiClient.getInstance(project).setPort(0) - } - } + scheduleConnectionSync(state) } val isRunning: Boolean get() = serverManager.isRunning @@ -105,6 +96,27 @@ class OpenCodePlugin(private val project: Project) : Disposable { listeners.forEach { it.onStateChanged(state) } } + private fun scheduleConnectionSync(state: ServerState) { + connectionSyncExecutor.execute { + if (project.isDisposed) return@execute + + when (state) { + ServerState.READY -> { + val port = OpenCodeSettings.getInstance(project).serverPort + OpenCodeCoreService.getInstance(project).startListening(port) + OpenCodeTuiClient.getInstance(project).setPort(port) + } + + ServerState.STOPPED, ServerState.PORT_CONFLICT, ServerState.AUTH_REQUIRED -> { + OpenCodeCoreService.getInstance(project).stopListening() + OpenCodeTuiClient.getInstance(project).setPort(0) + } + + else -> Unit + } + } + } + fun startPolling(port: Int, intervalSeconds: Long = 10L) = serverManager.startPolling(port, intervalSeconds) fun checkPort(port: Int) = serverManager.checkPort(port) @@ -138,17 +150,19 @@ class OpenCodePlugin(private val project: Project) : Disposable { val port = OpenCodeSettings.getInstance(project).serverPort overrideState = ServerState.RESETTING broadcastState(ServerState.RESETTING) - ApplicationManager.getApplication().executeOnPooledThread { - if (project.isDisposed) return@executeOnPooledThread + connectionSyncExecutor.execute { + if (project.isDisposed) return@execute val diffService = OpenCodeCoreService.getInstance(project) + val tuiClient = OpenCodeTuiClient.getInstance(project) diffService.stopListening() - overrideState = null + tuiClient.setPort(0) diffService.startListening(port) - // Broadcast the real underlying state now that the override is cleared, - // so listeners (e.g. the TUI panel) can react and restore themselves. - val realState = serverManager.serverState + tuiClient.setPort(port) + overrideState = null ApplicationManager.getApplication().invokeLater { - broadcastState(realState) + if (!project.isDisposed) { + broadcastState(serverManager.serverState) + } } } } @@ -162,6 +176,7 @@ class OpenCodePlugin(private val project: Project) : Disposable { override fun dispose() { listeners.clear() serverManager.dispose() + connectionSyncExecutor.shutdownNow() } companion object { diff --git a/src/main/kotlin/com/ashotn/opencode/relay/ServerManager.kt b/src/main/kotlin/com/ashotn/opencode/relay/ServerManager.kt index 87fef2d..25a45f2 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/ServerManager.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/ServerManager.kt @@ -379,8 +379,8 @@ class ServerManager( } if (!isLaunchable) { showNotification( - "Failed to start OpenCode Relay", - "OpenCode Relay executable is not valid or executable: $executablePath", + "Failed to start OpenCode server", + "The OpenCode executable is not valid or executable: $executablePath", NotificationType.ERROR, ) applyState(ServerState.STOPPED) @@ -435,13 +435,25 @@ class ServerManager( scheduler.schedule({ doCheckPort(port) }, HEALTH_INITIAL_DELAY_SECONDS, TimeUnit.SECONDS) } catch (e: Exception) { - val message = e.message ?: e.javaClass.simpleName SwingUtilities.invokeLater { - showNotification( - "Failed to start OpenCode Relay", - "Could not launch the OpenCode Relay process: $message", - NotificationType.ERROR, - ) + when (e) { + is OpenCodeServerAuth.MissingServerLaunchAuthCredentialsException -> { + showNotification( + "Failed to start OpenCode server", + "Server authentication is enabled, but the password is missing. Re-enter it in Settings | OpenCode Relay.", + NotificationType.ERROR, + ) + } + + else -> { + val message = e.message ?: e.javaClass.simpleName + showNotification( + "Failed to start OpenCode server", + "Could not launch the OpenCode server process: $message", + NotificationType.ERROR, + ) + } + } applyState(ServerState.STOPPED) } } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuth.kt b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuth.kt index 3eedae3..9c49001 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuth.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuth.kt @@ -12,6 +12,10 @@ import java.util.Base64 @Service(Service.Level.PROJECT) class OpenCodeServerAuth(private val project: Project) { + class MissingServerLaunchAuthCredentialsException : IllegalStateException( + "Server launch authentication is enabled but credentials are incomplete", + ) + data class BasicAuthCredentials( val username: String, val password: String, @@ -55,7 +59,7 @@ class OpenCodeServerAuth(private val project: Project) { val settings = OpenCodeSettings.getInstance(project) if (!settings.protectPluginLaunchedServerWithAuth) return emptyMap() return connectionCredentials()?.environmentVariables() - ?: error("Server launch authentication is enabled but credentials are incomplete") + ?: throw MissingServerLaunchAuthCredentialsException() } private fun attributes(): CredentialAttributes = diff --git a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt index 0ccb38a..d068f9d 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt @@ -53,7 +53,7 @@ class OpenCodeSettings : PersistentStateComponent { override fun getState(): State = state override fun loadState(state: State) { - this.state = state + this.state = state.deepCopy() } var serverPort: Int @@ -98,10 +98,10 @@ class OpenCodeSettings : PersistentStateComponent { state.protectPluginLaunchedServerWithAuth = value } - var serverEnvironmentVariables: MutableList - get() = state.serverEnvironmentVariables + var serverEnvironmentVariables: List + get() = state.serverEnvironmentVariables.map { it.copy() } set(value) { - state.serverEnvironmentVariables = value + state.serverEnvironmentVariables = value.map { it.copy() }.toMutableList() } var executablePath: String @@ -148,6 +148,10 @@ class OpenCodeSettings : PersistentStateComponent { } +fun OpenCodeSettings.State.deepCopy(): OpenCodeSettings.State = copy( + serverEnvironmentVariables = serverEnvironmentVariables.map { it.copy() }.toMutableList(), +) + fun OpenCodeSettings.snapshot(): OpenCodeSettingsSnapshot = state.toSnapshot() fun OpenCodeSettings.State.toSnapshot(): OpenCodeSettingsSnapshot = OpenCodeSettingsSnapshot( diff --git a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt index 8994122..c9c6fd0 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt @@ -255,6 +255,7 @@ class OpenCodeSettingsConfigurable(private val project: Project) : if (pendingState.serverEnvironmentVariables.any { it.name.contains('=') }) { throw ConfigurationException("Environment variable names cannot contain '='") } + validateUniqueEnvironmentVariableNames(pendingState.serverEnvironmentVariables) validateReservedServerAuthEnvironmentVariables(pendingState.serverEnvironmentVariables) if (pendingState.protectPluginLaunchedServerWithAuth && currentServerAuthPassword().isBlank()) { throw ConfigurationException("Enter a password to protect the server launched by plugin") @@ -420,6 +421,23 @@ class OpenCodeSettingsConfigurable(private val project: Project) : ) } + private fun validateUniqueEnvironmentVariableNames( + environmentVariables: List, + ) { + val duplicateName = environmentVariables + .map { it.name.trim() } + .filter(String::isNotEmpty) + .groupingBy { it.uppercase() } + .eachCount() + .entries + .firstOrNull { it.value > 1 } + ?.key + ?: return + + val originalName = environmentVariables.first { it.name.trim().uppercase() == duplicateName }.name.trim() + throw ConfigurationException("Environment variable names must be unique: $originalName") + } + private fun currentCorsOriginRows(): List = currentTableItems( table = serverCorsOriginsTable, @@ -479,7 +497,7 @@ class OpenCodeSettingsConfigurable(private val project: Project) : } private fun persistPendingToSettings(settings: OpenCodeSettings) { - settings.loadState(pendingState.copy()) + settings.loadState(pendingState.deepCopy()) } private fun corsOriginColumn(): ColumnInfo = diff --git a/src/test/kotlin/com/ashotn/opencode/relay/ServerManagerTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/ServerManagerTest.kt index cf2a134..d02409d 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/ServerManagerTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/ServerManagerTest.kt @@ -13,24 +13,27 @@ class ServerManagerTest : BasePlatformTestCase() { settings.serverCorsOrigins = "http://localhost:5173\nhttps://app.example.com" val manager = ServerManager(project) { } - - assertEquals( - listOf( - "serve", - "--port", - "4096", - "--hostname", - "0.0.0.0", - "--mdns", - "--mdns-domain", - "relay.local", - "--cors", - "http://localhost:5173", - "--cors", - "https://app.example.com", - ), - manager.buildServeArguments(settings, 4096), - ) + try { + assertEquals( + listOf( + "serve", + "--port", + "4096", + "--hostname", + "0.0.0.0", + "--mdns", + "--mdns-domain", + "relay.local", + "--cors", + "http://localhost:5173", + "--cors", + "https://app.example.com", + ), + manager.buildServeArguments(settings, 4096), + ) + } finally { + manager.dispose() + } } fun testBuildServeArgumentsOmitsBlankHostnameAndCorsOrigins() { @@ -41,10 +44,13 @@ class ServerManagerTest : BasePlatformTestCase() { settings.serverCorsOrigins = "\n \n" val manager = ServerManager(project) { } - - assertEquals( - listOf("serve", "--port", "4096"), - manager.buildServeArguments(settings, 4096), - ) + try { + assertEquals( + listOf("serve", "--port", "4096"), + manager.buildServeArguments(settings, 4096), + ) + } finally { + manager.dispose() + } } } diff --git a/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuthTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuthTest.kt index 8d8d7df..75e5f0b 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuthTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuthTest.kt @@ -1,6 +1,7 @@ package com.ashotn.opencode.relay.settings import com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlin.test.assertFailsWith class OpenCodeServerAuthTest : BasePlatformTestCase() { @@ -51,4 +52,17 @@ class OpenCodeServerAuthTest : BasePlatformTestCase() { auth.serverLaunchEnvironmentVariables(), ) } + + fun testServerLaunchEnvironmentVariablesFailWhenProtectionEnabledAndPasswordMissing() { + val settings = OpenCodeSettings.getInstance(project) + val auth = OpenCodeServerAuth.getInstance(project) + + settings.serverAuthUsername = "alice" + auth.setPassword("") + settings.protectPluginLaunchedServerWithAuth = true + + assertFailsWith { + auth.serverLaunchEnvironmentVariables() + } + } } diff --git a/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurableTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurableTest.kt index 5f73125..b251c6a 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurableTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurableTest.kt @@ -253,6 +253,30 @@ class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() { } } + fun testApplyRejectsDuplicateEnvironmentVariableNamesIgnoringCase() { + val configurable = OpenCodeSettingsConfigurable(project) + + try { + getOnEdt { configurable.createComponent() } + + runOnEdt { + configurable.serverEnvironmentVariablesModel.setItems( + listOf( + OpenCodeSettings.EnvironmentVariable("FOO", "one"), + OpenCodeSettings.EnvironmentVariable(" foo ", "two"), + ) + ) + } + + val exception = assertFailsWith { + runOnEdt { configurable.apply() } + } + assertTrue(exception.localizedMessage.orEmpty().contains("must be unique")) + } finally { + runOnEdt { configurable.disposeUIResources() } + } + } + fun testApplyAllowsSavingWhenPathIsBlankAndResolutionFails() { val settings = OpenCodeSettings.getInstance(project) settings.executablePath = "C:/Users/VM/AppData/Roaming/npm/opencode.cmd" From a20b55cefbc30d05b955afd113d8d261399bc8b2 Mon Sep 17 00:00:00 2001 From: Ashot Nazaryan Date: Mon, 20 Apr 2026 20:04:19 -0700 Subject: [PATCH 7/7] fix: deprecated API usage --- .../relay/terminal/ClassicTuiPanel.kt | 20 ++------ .../relay/terminal/TerminalDataProviders.kt | 21 ++++++++ .../terminal/TerminalDataProvidersTest.kt | 49 +++++++++++++++++++ 3 files changed, 75 insertions(+), 15 deletions(-) create mode 100644 src/main/kotlin/com/ashotn/opencode/relay/terminal/TerminalDataProviders.kt create mode 100644 src/test/kotlin/com/ashotn/opencode/relay/terminal/TerminalDataProvidersTest.kt diff --git a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt index e6aef3a..48efdf6 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt @@ -6,10 +6,7 @@ import com.ashotn.opencode.relay.settings.OpenCodeSettings import com.ashotn.opencode.relay.settings.OpenCodeServerAuth import com.ashotn.opencode.relay.settings.processEnvironmentVariables import com.ashotn.opencode.relay.util.serverUrl -import com.intellij.ide.DataManager import com.intellij.openapi.Disposable -import com.intellij.openapi.actionSystem.CustomizedDataContext -import com.intellij.openapi.actionSystem.PlatformDataKeys import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.Project @@ -93,9 +90,7 @@ class ClassicTuiPanel( val widget = runner.startShellTerminalWidget(this, startupOptions, true) terminalWidget = widget Disposer.register(this, widget) - terminalPanel = ShellTerminalWidget.asShellJediTermWidget(widget)?.terminalPanel?.also { panel -> - installEmbeddedTerminalDataProvider(panel) - } + terminalPanel = ShellTerminalWidget.asShellJediTermWidget(widget)?.terminalPanel?.also(::installEmbeddedTerminalDataProvider) // When the shell process exits, clean up and notify the owner. widget.addTerminationCallback({ @@ -154,18 +149,13 @@ class ClassicTuiPanel( private fun installEmbeddedTerminalDataProvider(panel: JBTerminalPanel) { // JBTerminalPanel's internal TerminalEscapeKeyListener moves focus back to the editor // whenever the terminal reports a ToolWindow in its data context. Our terminal is embedded - // inside a custom tool window, so return an explicit null for TOOL_WINDOW to keep ESC inside - // the TUI while still allowing the key event to reach the terminal process. - DataManager.registerDataProvider(panel) { dataId -> - when { - PlatformDataKeys.TOOL_WINDOW.`is`(dataId) -> CustomizedDataContext.EXPLICIT_NULL - else -> null - } - } + // inside a custom tool window. Install the override on the terminal panel itself so the + // explicit null wins before any ancestor ToolWindow provider in the data-context chain. + installTerminalToolWindowOverride(panel) } private fun uninstallEmbeddedTerminalDataProvider() { - terminalPanel?.let(DataManager::removeDataProvider) + terminalPanel?.let(::uninstallTerminalToolWindowOverride) terminalPanel = null } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/terminal/TerminalDataProviders.kt b/src/main/kotlin/com/ashotn/opencode/relay/terminal/TerminalDataProviders.kt new file mode 100644 index 0000000..093b2e8 --- /dev/null +++ b/src/main/kotlin/com/ashotn/opencode/relay/terminal/TerminalDataProviders.kt @@ -0,0 +1,21 @@ +package com.ashotn.opencode.relay.terminal + +import com.intellij.openapi.actionSystem.CustomizedDataContext +import com.intellij.openapi.actionSystem.DataProvider +import com.intellij.openapi.actionSystem.PlatformDataKeys +import javax.swing.JComponent + +private const val DATA_PROVIDER_CLIENT_PROPERTY = "DataProvider" + +internal fun installTerminalToolWindowOverride(component: JComponent) { + component.putClientProperty(DATA_PROVIDER_CLIENT_PROPERTY, DataProvider { dataId -> + when { + PlatformDataKeys.TOOL_WINDOW.`is`(dataId) -> CustomizedDataContext.EXPLICIT_NULL + else -> null + } + }) +} + +internal fun uninstallTerminalToolWindowOverride(component: JComponent) { + component.putClientProperty(DATA_PROVIDER_CLIENT_PROPERTY, null) +} diff --git a/src/test/kotlin/com/ashotn/opencode/relay/terminal/TerminalDataProvidersTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/terminal/TerminalDataProvidersTest.kt new file mode 100644 index 0000000..431d93d --- /dev/null +++ b/src/test/kotlin/com/ashotn/opencode/relay/terminal/TerminalDataProvidersTest.kt @@ -0,0 +1,49 @@ +package com.ashotn.opencode.relay.terminal + +import com.intellij.ide.DataManager +import com.intellij.openapi.actionSystem.DataProvider +import com.intellij.openapi.actionSystem.PlatformDataKeys +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.wm.ToolWindow +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.BorderLayout +import java.lang.reflect.Proxy +import javax.swing.JPanel + +class TerminalDataProvidersTest : BasePlatformTestCase() { + + fun `test terminal panel override hides ancestor tool window from data context`() { + val container = JPanel(BorderLayout()) + val terminalPanel = JPanel(BorderLayout()) + val toolWindow = toolWindowStub() + + container.putClientProperty("DataProvider", DataProvider { dataId -> + when { + PlatformDataKeys.TOOL_WINDOW.`is`(dataId) -> toolWindow + else -> null + } + }) + container.add(terminalPanel, BorderLayout.CENTER) + + installTerminalToolWindowOverride(terminalPanel) + try { + ApplicationManager.getApplication().invokeAndWait { + val dataContext = DataManager.getInstance().getDataContext(terminalPanel) + assertNull(dataContext.getData(PlatformDataKeys.TOOL_WINDOW)) + } + } finally { + uninstallTerminalToolWindowOverride(terminalPanel) + } + } + + private fun toolWindowStub(): ToolWindow = + Proxy.newProxyInstance( + ToolWindow::class.java.classLoader, + arrayOf(ToolWindow::class.java), + ) { _, method, _ -> + when (method.name) { + "toString" -> "ToolWindowStub" + else -> if (method.returnType == Boolean::class.javaPrimitiveType) false else null + } + } as ToolWindow +}