diff --git a/plugins/agent-workbench/codex/common/src/CodexAppServerClient.kt b/plugins/agent-workbench/codex/common/src/CodexAppServerClient.kt index 9368528087eb..20ea514d3861 100644 --- a/plugins/agent-workbench/codex/common/src/CodexAppServerClient.kt +++ b/plugins/agent-workbench/codex/common/src/CodexAppServerClient.kt @@ -3,7 +3,7 @@ package com.intellij.agent.workbench.codex.common import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.core.JsonParser -import com.intellij.execution.configurations.PathEnvironmentVariableUtil +import com.intellij.execution.configurations.GeneralCommandLine import com.intellij.openapi.diagnostic.debug import com.intellij.openapi.diagnostic.logger import com.intellij.util.io.awaitExit @@ -20,16 +20,21 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeout import java.io.BufferedReader import java.io.BufferedWriter +import java.io.IOException import java.io.InputStreamReader import java.io.OutputStreamWriter import java.nio.charset.StandardCharsets import java.nio.file.Files +import java.nio.file.NoSuchFileException import java.nio.file.Path import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicLong +import kotlin.time.Duration.Companion.milliseconds private const val CODEX_COMMAND = "codex" private const val REQUEST_TIMEOUT_MS = 30_000L +private const val PROCESS_TERMINATION_TIMEOUT_MS = 2_000L private const val MAX_PAGES = 10 private const val PAGE_LIMIT = 50 @@ -37,18 +42,17 @@ private val LOG = logger() class CodexAppServerClient( private val coroutineScope: CoroutineScope, - private val executablePathProvider: () -> String? = { - PathEnvironmentVariableUtil.findExecutableInPathOnAnyOS(CODEX_COMMAND)?.absolutePath - }, + private val executablePathProvider: () -> String? = { null }, private val environmentOverrides: Map = emptyMap(), - private val workingDirectory: Path? = null, + workingDirectory: Path? = null, ) { private val pending = ConcurrentHashMap>() private val requestCounter = AtomicLong(0) private val writeMutex = Mutex() private val startMutex = Mutex() private val initMutex = Mutex() - private val protocol = CodexAppServerProtocol(workingDirectory) + private val workingDirectoryPath = workingDirectory + private val protocol = CodexAppServerProtocol(workingDirectoryPath) @Volatile private var process: Process? = null @@ -152,7 +156,7 @@ class CodexAppServerClient( pending[id] = deferred try { sendRequest(id, method, paramsWriter) - val response = withTimeout(REQUEST_TIMEOUT_MS) { deferred.await() } + val response = withTimeout(REQUEST_TIMEOUT_MS.milliseconds) { deferred.await() } return protocol.parseResponse(response, resultParser, defaultResult) } catch (t: TimeoutCancellationException) { @@ -250,26 +254,27 @@ class CodexAppServerClient( } private fun startProcess(): Process { - val executable = executablePathProvider() ?: throw CodexCliNotFoundException() + val configuredExecutable = executablePathProvider() + ?.trim() + ?.takeIf { it.isNotEmpty() } + val executable = configuredExecutable ?: CODEX_COMMAND val process = try { - ProcessBuilder(executable, "app-server").apply { - if (environmentOverrides.isNotEmpty()) { - val env = environment() - for ((key, value) in environmentOverrides) { - env[key] = value + GeneralCommandLine(executable, "app-server") + .withParentEnvironmentType(GeneralCommandLine.ParentEnvironmentType.CONSOLE) + .withEnvironment(environmentOverrides) + .apply { + val directory = workingDirectoryPath + if (directory != null && Files.isDirectory(directory)) { + withWorkingDirectory(directory) } } - val directory = workingDirectory - if (directory != null && Files.isDirectory(directory)) { - @Suppress("IO_FILE_USAGE") - directory(directory.toFile()) - } - } - .redirectErrorStream(false) - .start() + .createProcess() } catch (t: Throwable) { - throw CodexAppServerException("Failed to start Codex app-server", t) + if (configuredExecutable == null && isExecutableNotFound(t)) { + throw CodexCliNotFoundException() + } + throw CodexAppServerException("Failed to start Codex app-server from $executable", t) } this.process = process this.writer = BufferedWriter(OutputStreamWriter(process.outputStream, StandardCharsets.UTF_8)) @@ -400,10 +405,34 @@ class CodexAppServerClient( stderrJob?.cancel() waitJob?.cancel() current.destroy() + try { + if (!current.waitFor(PROCESS_TERMINATION_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + current.destroyForcibly() + current.waitFor(PROCESS_TERMINATION_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } + } + catch (_: Throwable) { + } } - } +private fun isExecutableNotFound(error: Throwable): Boolean { + return generateSequence(error) { it.cause } + .any { cause -> + when (cause) { + is NoSuchFileException -> true + is IOException -> { + val message = cause.message ?: return@any false + message.contains("error=2") || + message.contains("no such file or directory", ignoreCase = true) || + message.contains("cannot find the file", ignoreCase = true) + } + else -> false + } + } +} + + open class CodexAppServerException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) class CodexCliNotFoundException : CodexAppServerException("Codex CLI not found") diff --git a/plugins/agent-workbench/sessions/testSrc/CodexAppServerClientTest.kt b/plugins/agent-workbench/sessions/testSrc/CodexAppServerClientTest.kt index cd959ee2667d..1dd3c897c325 100644 --- a/plugins/agent-workbench/sessions/testSrc/CodexAppServerClientTest.kt +++ b/plugins/agent-workbench/sessions/testSrc/CodexAppServerClientTest.kt @@ -1,7 +1,9 @@ // Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.intellij.agent.workbench.sessions +import com.intellij.agent.workbench.codex.common.CodexAppServerClient import com.intellij.agent.workbench.codex.common.CodexAppServerException +import com.intellij.agent.workbench.codex.common.CodexCliNotFoundException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import org.assertj.core.api.Assertions.assertThat @@ -387,4 +389,99 @@ class CodexAppServerClientTest { client.shutdown() } } + + @Test + fun listThreadsUsesPathOverrideWhenExecutableProviderMissing(): Unit = runBlocking(Dispatchers.IO) { + val workingDir = tempDir.resolve("project-path-override") + Files.createDirectories(workingDir) + val configPath = workingDir.resolve("codex-config.json") + writeConfig( + path = configPath, + threads = listOf( + ThreadSpec( + id = "thread-path", + title = "Path Thread", + cwd = workingDir.toString(), + updatedAt = 1_700_000_000_000L, + archived = false, + ), + ), + ) + + val backendDir = tempDir.resolve("backend-path-override") + Files.createDirectories(backendDir) + val codexShim = createMockCodexShim(backendDir, configPath) + val client = CodexAppServerClient( + coroutineScope = this, + executablePathProvider = { null }, + environmentOverrides = mapOf("PATH" to codexShim.parent.toString()), + workingDirectory = workingDir, + ) + try { + val threads = client.listThreads(archived = false) + assertThat(threads.map { it.id }).containsExactly("thread-path") + } + finally { + client.shutdown() + } + } + + @Test + fun listThreadsFailsWithoutFallbackWhenConfiguredExecutableIsInvalid(): Unit = runBlocking(Dispatchers.IO) { + val workingDir = tempDir.resolve("project-invalid-exec") + Files.createDirectories(workingDir) + val configPath = workingDir.resolve("codex-config.json") + writeConfig(path = configPath, threads = emptyList()) + + val backendDir = tempDir.resolve("backend-invalid-exec") + Files.createDirectories(backendDir) + val codexShim = createMockCodexShim(backendDir, configPath) + val invalidExecutable = tempDir.resolve("missing-codex").toString() + val client = CodexAppServerClient( + coroutineScope = this, + executablePathProvider = { invalidExecutable }, + environmentOverrides = mapOf("PATH" to codexShim.parent.toString()), + workingDirectory = workingDir, + ) + try { + try { + client.listThreads(archived = false) + fail("Expected CodexAppServerException") + } + catch (e: CodexAppServerException) { + assertThat(e.message).contains(invalidExecutable) + } + } + finally { + client.shutdown() + } + } + + @Test + fun listThreadsReportsDefaultExecutableStartFailuresWithoutCliMissingError(): Unit = runBlocking(Dispatchers.IO) { + val workingDir = tempDir.resolve("project-invalid-env") + Files.createDirectories(workingDir) + val configPath = workingDir.resolve("codex-config.json") + writeConfig(path = configPath, threads = emptyList()) + + val client = CodexAppServerClient( + coroutineScope = this, + executablePathProvider = { null }, + environmentOverrides = mapOf("INVALID=KEY" to "value"), + workingDirectory = workingDir, + ) + try { + try { + client.listThreads(archived = false) + fail("Expected CodexAppServerException") + } + catch (e: CodexAppServerException) { + assertThat(e).isNotInstanceOf(CodexCliNotFoundException::class.java) + assertThat(e.message).contains("Failed to start Codex app-server from codex") + } + } + finally { + client.shutdown() + } + } } diff --git a/plugins/agent-workbench/sessions/testSrc/CodexAppServerClientTestSupport.kt b/plugins/agent-workbench/sessions/testSrc/CodexAppServerClientTestSupport.kt index fe647b46c1b4..e0e1d3a47dcd 100644 --- a/plugins/agent-workbench/sessions/testSrc/CodexAppServerClientTestSupport.kt +++ b/plugins/agent-workbench/sessions/testSrc/CodexAppServerClientTestSupport.kt @@ -180,6 +180,10 @@ internal fun createMockClient( ) } +internal fun createMockCodexShim(tempDir: Path, configPath: Path): Path { + return createCodexShim(tempDir, configPath) +} + private fun createCodexShim(tempDir: Path, configPath: Path): Path { val javaHome = System.getProperty("java.home") val javaBin = Path.of(javaHome, "bin", if (OS.CURRENT == OS.Windows) "java.exe" else "java")