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 @@
-
+
@@ -15,6 +15,13 @@
+ true
+ true
+ false
+ false
+ false
+ false
+ false
-
+
\ No newline at end of file
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/OpenCodePlugin.kt b/src/main/kotlin/com/ashotn/opencode/relay/OpenCodePlugin.kt
index 7409568..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) {
- 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,26 +150,33 @@ 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)
+ }
}
}
}
+ fun reportAuthenticationRequired() {
+ serverManager.reportAuthenticationRequired()
+ }
+
// --- Disposable ---
override fun dispose() {
listeners.clear()
serverManager.dispose()
+ connectionSyncExecutor.shutdownNow()
}
companion object {
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..25a45f2 100644
--- a/src/main/kotlin/com/ashotn/opencode/relay/ServerManager.kt
+++ b/src/main/kotlin/com/ashotn/opencode/relay/ServerManager.kt
@@ -1,8 +1,13 @@
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.util.showNotification
+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
import com.intellij.openapi.project.Project
@@ -36,6 +41,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,
}
@@ -52,6 +60,12 @@ class ServerManager(
private val onStateChanged: (ServerState) -> Unit,
) {
+ private enum class HealthStatus {
+ HEALTHY,
+ AUTH_REQUIRED,
+ UNHEALTHY,
+ }
+
companion object {
private val log = logger()
@@ -85,7 +99,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 }
@@ -153,7 +174,7 @@ class ServerManager(
externalHealthRevision.incrementAndGet()
if (wasPortOpen) {
- project.showNotification(
+ showNotification(
"OpenCode stopped unexpectedly",
"The OpenCode server process was terminated externally.",
NotificationType.WARNING,
@@ -180,42 +201,81 @@ 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 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 {
@@ -281,6 +341,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) {
@@ -289,9 +378,9 @@ class ServerManager(
executable.isFile && executable.canExecute()
}
if (!isLaunchable) {
- project.showNotification(
- "Failed to start OpenCode Relay",
- "OpenCode Relay executable is not valid or executable: $executablePath",
+ showNotification(
+ "Failed to start OpenCode server",
+ "The OpenCode executable is not valid or executable: $executablePath",
NotificationType.ERROR,
)
applyState(ServerState.STOPPED)
@@ -308,16 +397,20 @@ class ServerManager(
}
try {
+ val settings = OpenCodeSettings.getInstance(project)
+ val environmentVariables =
+ settings.processEnvironmentVariables(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()
.apply {
- OpenCodeProcessEnvironment.configure(this, executablePath)
+ OpenCodeProcessEnvironment.configure(this, executablePath, environmentVariables)
val basePath = project.basePath
if (basePath != null) directory(File(basePath))
}
@@ -342,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 {
- project.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/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 c393dd9..23482f3 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,11 @@
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.OpenCodeServerAuth
+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 +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(OpenCodeServerAuth.getInstance(project).connectionEnvironmentVariables())
val attachArgs = listOf(executablePath, "attach", url)
val processCommand: List = when {
SystemInfo.isWindows -> listOf(
@@ -56,7 +61,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 +86,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 +102,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/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..9c49001
--- /dev/null
+++ b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuth.kt
@@ -0,0 +1,77 @@
+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) {
+
+ class MissingServerLaunchAuthCredentialsException : IllegalStateException(
+ "Server launch authentication is enabled but credentials are incomplete",
+ )
+
+ 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 connectionCredentials()?.environmentVariables()
+ ?: throw MissingServerLaunchAuthCredentialsException()
+ }
+
+ 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 474bc6f..d068f9d 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,18 @@ 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 = "",
+ )
+
enum class TerminalEngine {
/** JBTerminalWidget (classic terminal plugin, works on all supported IDE versions). */
CLASSIC,
@@ -20,6 +32,13 @@ 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 serverAuthUsername: String = DEFAULT_SERVER_AUTH_USERNAME,
+ var protectPluginLaunchedServerWithAuth: Boolean = false,
+ var serverEnvironmentVariables: MutableList = mutableListOf(),
var executablePath: String = "",
var inlineDiffEnabled: Boolean = true,
var diffTraceEnabled: Boolean = false,
@@ -34,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
@@ -43,6 +62,48 @@ 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 serverAuthUsername: String
+ get() = state.serverAuthUsername
+ set(value) {
+ state.serverAuthUsername = value
+ }
+
+ var protectPluginLaunchedServerWithAuth: Boolean
+ get() = state.protectPluginLaunchedServerWithAuth
+ set(value) {
+ state.protectPluginLaunchedServerWithAuth = value
+ }
+
+ var serverEnvironmentVariables: List
+ get() = state.serverEnvironmentVariables.map { it.copy() }
+ set(value) {
+ state.serverEnvironmentVariables = value.map { it.copy() }.toMutableList()
+ }
+
var executablePath: String
get() = state.executablePath
set(value) {
@@ -85,14 +146,23 @@ class OpenCodeSettings : PersistentStateComponent {
state.terminalEngine = value
}
- companion object {
- fun getInstance(project: Project): OpenCodeSettings =
- project.getService(OpenCodeSettings::class.java)
- }
}
-fun OpenCodeSettings.snapshot(): OpenCodeSettingsSnapshot = OpenCodeSettingsSnapshot(
+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(
serverPort = serverPort,
+ serverHostname = serverHostname,
+ serverMdnsEnabled = serverMdnsEnabled,
+ serverMdnsDomain = serverMdnsDomain,
+ serverCorsOrigins = serverCorsOrigins,
+ serverAuthUsername = serverAuthUsername,
+ protectPluginLaunchedServerWithAuth = protectPluginLaunchedServerWithAuth,
+ serverEnvironmentVariables = serverEnvironmentVariables.map { it.copy() },
executablePath = executablePath,
inlineDiffEnabled = inlineDiffEnabled,
diffTraceEnabled = diffTraceEnabled,
@@ -101,3 +171,6 @@ fun OpenCodeSettings.snapshot(): OpenCodeSettingsSnapshot = OpenCodeSettingsSnap
sessionsSectionVisible = sessionsSectionVisible,
terminalEngine = terminalEngine,
)
+
+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/OpenCodeSettingsChangedListener.kt b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsChangedListener.kt
index 8c772c3..f4900a5 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,13 @@ 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 serverAuthUsername: String,
+ val protectPluginLaunchedServerWithAuth: Boolean,
+ 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..c9c6fd0 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,67 @@ 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.JBPasswordField
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 serverAuthUsernameField: JBTextField
+ internal lateinit var serverAuthPasswordField: JBPasswordField
+ internal lateinit var protectPluginLaunchedServerWithAuthCheckBox: JCheckBox
+ 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)
+ resetServerAuthPasswordField()
+
return panel {
group("Executable") {
val executablePathField = TextFieldWithBrowseButton().apply {
@@ -65,10 +112,66 @@ 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("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 OpenCode processes launched by the plugin. `OPENCODE_SERVER_USERNAME` and `OPENCODE_SERVER_PASSWORD` are managed above.")
+ .align(AlignX.FILL)
+ .resizableColumn()
}
}
group("Editor") {
@@ -118,30 +221,67 @@ class OpenCodeSettingsConfigurable(private val project: Project) :
}
override fun reset() {
+ cancelTableEdits()
loadPendingFromPersisted()
+ 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 ||
+ 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 oldSettings = settings.state.toSnapshot()
+ 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 '='")
+ }
+ validateUniqueEnvironmentVariableNames(pendingState.serverEnvironmentVariables)
+ 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 newSettings = pendingState.toSnapshot()
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
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 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) {
@@ -163,10 +303,18 @@ class OpenCodeSettingsConfigurable(private val project: Project) :
}
persistPendingToSettings(settings)
+ if (passwordChanged) {
+ serverAuth.setPassword(newPassword)
+ }
when {
mustConfirmStop -> plugin.stopServer()
mustReattach -> plugin.reattach(newPort)
+ mustResetConnection -> plugin.resetConnection()
+ }
+
+ if (connectionAuthChanged && !plugin.ownsProcess && !mustReattach && !mustResetConnection) {
+ plugin.checkPort(newPort)
}
if (shouldUpdateExecutableResolution && resolvedState != oldResolutionState) {
@@ -207,6 +355,13 @@ 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.serverAuthUsername = settings.serverAuthUsername
+ pendingState.protectPluginLaunchedServerWithAuth = settings.protectPluginLaunchedServerWithAuth
+ pendingState.serverEnvironmentVariables = settings.serverEnvironmentVariables.map { it.copy() }.toMutableList()
pendingState.executablePath = settings.executablePath
pendingState.inlineDiffEnabled = settings.inlineDiffEnabled
pendingState.diffTraceEnabled = settings.diffTraceEnabled
@@ -217,18 +372,208 @@ 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 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 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,
+ 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,
- executablePath = state.executablePath,
- inlineDiffEnabled = state.inlineDiffEnabled,
- diffTraceEnabled = state.diffTraceEnabled,
- diffTraceHistoryEnabled = state.diffTraceHistoryEnabled,
- inlineTerminalEnabled = state.inlineTerminalEnabled,
- sessionsSectionVisible = state.sessionsSectionVisible,
- terminalEngine = state.terminalEngine,
- )
+ settings.loadState(pendingState.deepCopy())
+ }
+
+ 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/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt
index 42850d9..48efdf6 100644
--- a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt
+++ b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt
@@ -3,11 +3,10 @@ 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
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
@@ -70,26 +69,28 @@ class ClassicTuiPanel(
}
val workingDir = project.basePath ?: System.getProperty("user.home")
+ val environmentVariables = OpenCodeSettings.getInstance(project)
+ .processEnvironmentVariables(OpenCodeServerAuth.getInstance(project).connectionEnvironmentVariables())
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)
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({
@@ -148,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/ReworkedTuiPanel.kt b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ReworkedTuiPanel.kt
index 5ef3977..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,8 @@ 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
import com.intellij.openapi.application.ApplicationManager
@@ -72,12 +74,15 @@ class ReworkedTuiPanel(
}
val workingDir = project.basePath ?: System.getProperty("user.home")
+ val environmentVariables = OpenCodeSettings.getInstance(project)
+ .processEnvironmentVariables(OpenCodeServerAuth.getInstance(project).connectionEnvironmentVariables())
val command = OpenCodeProcessEnvironment.terminalCommand(
listOf(
executablePath,
"attach",
serverUrl(OpenCodeSettings.getInstance(project).serverPort),
- )
+ ),
+ environmentVariables,
)
val manager = TerminalToolWindowTabsManager.getInstance(project)
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/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/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"))
+ }
+}
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..d02409d
--- /dev/null
+++ b/src/test/kotlin/com/ashotn/opencode/relay/ServerManagerTest.kt
@@ -0,0 +1,56 @@
+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) { }
+ 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() {
+ val settings = OpenCodeSettings.getInstance(project)
+ settings.serverHostname = " "
+ settings.serverMdnsEnabled = false
+ settings.serverMdnsDomain = " "
+ settings.serverCorsOrigins = "\n \n"
+
+ val manager = ServerManager(project) { }
+ try {
+ assertEquals(
+ listOf("serve", "--port", "4096"),
+ manager.buildServeArguments(settings, 4096),
+ )
+ } finally {
+ manager.dispose()
+ }
+ }
+}
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..75e5f0b
--- /dev/null
+++ b/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuthTest.kt
@@ -0,0 +1,68 @@
+package com.ashotn.opencode.relay.settings
+
+import com.intellij.testFramework.fixtures.BasePlatformTestCase
+import kotlin.test.assertFailsWith
+
+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(),
+ )
+ }
+
+ 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 06dec52..b251c6a 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,272 @@ 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() {
+ 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 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 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 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)
+
+ 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 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 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"
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
+}