Merge pull request #12214 from Kilo-Org/smart-carp

fix(jetbrains): honor IDE certificate and proxy settings for outbound HTTPS
This commit is contained in:
Kirill Kalishev
2026-07-14 13:00:09 -04:00
committed by GitHub
5 changed files with 139 additions and 22 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Honor JetBrains certificate and proxy settings when downloading the CLI and fetching custom provider models.
@@ -1,21 +1,29 @@
package ai.kilocode.backend.cli
import com.intellij.openapi.application.ApplicationManager
import com.intellij.util.net.JdkProxyProvider
import com.intellij.util.net.ssl.CertificateManager
import okhttp3.ConnectionPool
import okhttp3.Credentials
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import java.net.InetSocketAddress
import java.net.Proxy
import java.util.Base64
import java.util.concurrent.TimeUnit
import java.net.Authenticator as JdkAuthenticator
/**
* Factory for the OkHttp clients used by the plugin.
*
* Mirrors the VS Code architecture:
* - [api] client has no call/read timeout (streaming ops like prompt/SSE can run long)
* - [appLoad] client has a bounded timeout for startup REST calls
* - [health] client has a short 3 s timeout and a small dedicated connection pool
* Localhost clients ([api], [appLoad], [health]) talk only to the spawned CLI on
* `127.0.0.1`, bundle Basic Auth via an interceptor, and deliberately stay off the
* IntelliJ proxy stack so loopback traffic is never routed through a proxy.
*
* Both clients bundle Basic Auth via an interceptor and are fully independent
* of any IntelliJ-platform-provided HTTP stack.
* External clients ([cliDownload], [modelFetch]) reach the public internet (GitHub
* releases, user-supplied provider URLs) and are wired to the IDE's configured
* certificate store and proxy via [externalBuilder] so they work on corporate
* networks that MITM TLS or require an authenticated proxy.
*/
object KiloBackendHttpClients {
@@ -52,6 +60,46 @@ object KiloBackendHttpClients {
.connectionPool(ConnectionPool(1, 30, TimeUnit.SECONDS))
.build()
/** CLI download client — platform TLS/proxy settings for GitHub release traffic. */
fun cliDownload(): OkHttpClient = externalBuilder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.writeTimeout(120, TimeUnit.SECONDS)
.build()
/** Model fetch client — platform TLS/proxy settings for user-supplied provider URLs. */
fun modelFetch(): OkHttpClient = externalBuilder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.callTimeout(15, TimeUnit.SECONDS)
.build()
/** Derive a per-request bounded client from an existing one, preserving auth/interceptors. */
fun bounded(client: OkHttpClient, timeoutSeconds: Long): OkHttpClient {
val timeout = timeoutSeconds.coerceAtLeast(1L)
return client.newBuilder()
.callTimeout(timeout, TimeUnit.SECONDS)
.readTimeout(timeout, TimeUnit.SECONDS)
.build()
}
/**
* Builder for outbound internet requests wired to the IDE certificate store and proxy.
*
* When no IntelliJ application is available (unit tests, early bootstrap) the platform
* services cannot be resolved, so a bare builder is returned unchanged.
*/
fun externalBuilder(): OkHttpClient.Builder {
val builder = OkHttpClient.Builder()
ApplicationManager.getApplication() ?: return builder
val cert = CertificateManager.getInstance()
val proxy = JdkProxyProvider.getInstance()
return builder
.sslSocketFactory(cert.sslContext.socketFactory, cert.trustManager)
.proxySelector(proxy.proxySelector)
.proxyAuthenticator(proxyAuth(proxy.authenticator))
}
/** Shut down both dispatcher and connection pool for the given client. */
fun shutdown(client: OkHttpClient) {
client.dispatcher.executorService.shutdown()
@@ -68,4 +116,27 @@ object KiloBackendHttpClients {
)
}
}
/** Answer proxy 407 challenges using the IDE's proxy credentials, without touching global auth state. */
private fun proxyAuth(auth: JdkAuthenticator): okhttp3.Authenticator = okhttp3.Authenticator { route, response ->
if (response.code != 407) return@Authenticator null
val addr = (route?.proxy ?: Proxy.NO_PROXY).address() as? InetSocketAddress ?: return@Authenticator null
val url = response.request.url
response.challenges().firstNotNullOfOrNull { challenge ->
if (!"Basic".equals(challenge.scheme, ignoreCase = true)) return@firstNotNullOfOrNull null
val pwd = auth.requestPasswordAuthenticationInstance(
addr.hostString,
addr.address,
addr.port,
url.scheme,
challenge.realm,
challenge.scheme,
url.toUrl(),
JdkAuthenticator.RequestorType.PROXY,
) ?: return@firstNotNullOfOrNull null
response.request.newBuilder()
.header("Proxy-Authorization", Credentials.basic(pwd.userName, String(pwd.password), challenge.charset))
.build()
}
}
}
@@ -30,11 +30,7 @@ import java.util.zip.ZipInputStream
import kotlin.math.roundToInt
class KiloCliDownloader(
private val http: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.writeTimeout(120, TimeUnit.SECONDS)
.build(),
private val http: OkHttpClient = KiloBackendHttpClients.cliDownload(),
private val log: KiloLog = KiloLog.create(KiloCliDownloader::class.java),
private val root: File = File(PathManager.getSystemPath(), "kilo/cli"),
private val baseUrl: String = "https://github.com/Kilo-Org/kilocode/releases/download",
@@ -2,6 +2,7 @@ package ai.kilocode.backend.provider
import ai.kilocode.backend.app.KiloBackendAppService
import ai.kilocode.backend.app.LoadError
import ai.kilocode.backend.cli.KiloBackendHttpClients
import ai.kilocode.backend.cli.KiloCliDataParser
import ai.kilocode.backend.rpc.KiloWorkspaceDtoMapper
import ai.kilocode.log.KiloLog
@@ -21,12 +22,10 @@ import ai.kilocode.rpc.dto.ProviderSettingsDto
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.concurrent.TimeUnit
internal class KiloBackendProviderSettingsManager(
private val app: KiloBackendAppService,
@@ -34,11 +33,7 @@ internal class KiloBackendProviderSettingsManager(
companion object {
private val LOG = KiloLog.create(KiloBackendProviderSettingsManager::class.java)
private val JSON = "application/json".toMediaType()
private val FETCH = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.callTimeout(15, TimeUnit.SECONDS)
.build()
private val FETCH by lazy { KiloBackendHttpClients.modelFetch() }
private const val CALL_TIMEOUT_SECONDS = 15L
private const val OAUTH_CALL_TIMEOUT_SECONDS = 60L
}
@@ -253,10 +248,8 @@ internal class KiloBackendProviderSettingsManager(
private suspend fun request(request: Request, timeoutSeconds: Long = CALL_TIMEOUT_SECONDS): String {
val start = System.currentTimeMillis()
LOG.debug { "provider settings http: start ${request.method} ${request.url.encodedPath}" }
val http = app.http?.newBuilder()
?.callTimeout(timeoutSeconds, TimeUnit.SECONDS)
?.readTimeout(timeoutSeconds, TimeUnit.SECONDS)
?.build() ?: throw IllegalStateException("Kilo HTTP client is unavailable")
val http = app.http?.let { KiloBackendHttpClients.bounded(it, timeoutSeconds) }
?: throw IllegalStateException("Kilo HTTP client is unavailable")
return withContext(Dispatchers.IO) {
try {
http.newCall(request.newBuilder().header("Accept", "application/json").build()).execute().use { response ->
@@ -98,4 +98,56 @@ class KiloBackendHttpClientsTest {
KiloBackendHttpClients.shutdown(client)
assertEquals(0, client.connectionPool.connectionCount())
}
@Test
fun `cli download client keeps release download timeouts`() {
val client = KiloBackendHttpClients.cliDownload()
try {
assertEquals(30_000, client.connectTimeoutMillis)
assertEquals(120_000, client.readTimeoutMillis)
assertEquals(120_000, client.writeTimeoutMillis)
assertEquals(0, client.callTimeoutMillis)
} finally {
KiloBackendHttpClients.shutdown(client)
}
}
@Test
fun `model fetch client has bounded 15 second timeouts`() {
val client = KiloBackendHttpClients.modelFetch()
try {
assertEquals(15_000, client.connectTimeoutMillis)
assertEquals(15_000, client.readTimeoutMillis)
assertEquals(15_000, client.callTimeoutMillis)
} finally {
KiloBackendHttpClients.shutdown(client)
}
}
@Test
fun `bounded client applies per request timeout and preserves auth`() {
val pwd = "boundedpwd"
val server = MockWebServer()
server.enqueue(MockResponse().setBody("ok"))
server.start()
val client = KiloBackendHttpClients.api(pwd)
val bounded = KiloBackendHttpClients.bounded(client, 7)
try {
assertEquals(7_000, bounded.callTimeoutMillis)
assertEquals(7_000, bounded.readTimeoutMillis)
assertEquals(client.connectTimeoutMillis, bounded.connectTimeoutMillis)
val request = okhttp3.Request.Builder().url(server.url("/global/config")).build()
bounded.newCall(request).execute().use { response ->
assertEquals(200, response.code)
}
val recorded = server.takeRequest()
val expected = "Basic ${Base64.getEncoder().encodeToString("kilo:$pwd".toByteArray())}"
assertEquals(expected, recorded.getHeader("Authorization"))
} finally {
KiloBackendHttpClients.shutdown(client)
server.shutdown()
}
}
}