fix(jetbrains): address runtime CLI review feedback

This commit is contained in:
kirillk
2026-07-06 16:10:38 -04:00
parent 891a7a5847
commit a435baedac
11 changed files with 277 additions and 35 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
"@kilocode/kilo-jetbrains": patch
---
Download the pinned Kilo Core release at connect time instead of bundling Core binaries in the plugin.
Reduce JetBrains plugin size by downloading the Kilo Core release on first connect.
@@ -18,8 +18,7 @@ val rawSpec = layout.buildDirectory.file("generated/openapi-spec/openapi.raw.jso
val generatedSpec = layout.buildDirectory.file("generated/openapi-spec/openapi.json")
val generatedProps = layout.buildDirectory.dir("generated/kilo-props")
val pinnedCliVersion = providers.provider {
val text = rootProject.layout.projectDirectory.file("package.json").asFile.readText()
val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirectory.file("package.json")).asText.map { text ->
Regex("\"version\"\\s*:\\s*\"([^\"]+)\"").find(text)?.groupValues?.get(1)
?: error("Could not read version from package.json")
}
@@ -97,7 +97,7 @@ class KiloBackendAppService private constructor(
private const val MAX_RETRIES = 3
private const val RETRY_DELAY_MS = 1000L
private const val APP_LOAD_TIMEOUT_MS = 30_000L
private const val READY_TIMEOUT_MS = 5_000L
private const val READY_TIMEOUT_MS = 120_000L
/** Test factory — no IntelliJ deps needed. */
internal fun create(
@@ -271,8 +271,8 @@ class KiloBackendSessionManager(
title = s.title,
version = s.version,
time = SessionTimeDto(
created = s.time.created.toDouble(),
updated = s.time.updated.toDouble(),
created = time(s.id, "created", s.time.created),
updated = time(s.id, "updated", s.time.updated),
archived = s.time.archived,
),
summary = s.summary?.let {
@@ -292,8 +292,8 @@ class KiloBackendSessionManager(
title = s.title,
version = s.version,
time = SessionTimeDto(
created = s.time.created?.toDouble() ?: 0.0,
updated = s.time.updated?.toDouble() ?: 0.0,
created = time(s.id, "created", s.time.created),
updated = time(s.id, "updated", s.time.updated),
archived = s.time.archived,
),
summary = s.summary?.let {
@@ -315,6 +315,12 @@ class KiloBackendSessionManager(
private fun encode(value: String) = java.net.URLEncoder.encode(value, Charsets.UTF_8)
private fun time(id: String, field: String, value: Number?): Double {
if (value != null) return value.toDouble()
log.warn("Session $id missing $field timestamp; defaulting to 0.0")
return 0.0
}
private fun escape(value: String) = buildString {
for (c in value) {
when (c) {
@@ -5,53 +5,104 @@ import com.intellij.openapi.application.PathManager
import com.intellij.openapi.util.SystemInfo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient
import okhttp3.Request
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
import java.io.File
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
import java.util.zip.ZipInputStream
import kotlin.math.roundToInt
class KiloCliDownloader(
private val http: OkHttpClient = OkHttpClient(),
private val http: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.writeTimeout(120, TimeUnit.SECONDS)
.build(),
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",
private val api: String = "https://api.github.com/repos/Kilo-Org/kilocode/releases/tags",
) {
companion object {
private val DIGEST = Regex("^sha256:[a-f0-9]{64}$")
private val JSON = Json { ignoreUnknownKeys = true }
}
suspend fun resolve(version: String, force: Boolean = false, onProgress: (CliDownload) -> Unit = {}): File =
withContext(Dispatchers.IO) {
val platform = KiloCliPlatform.current()
val dir = File(File(root, version), platform)
val exe = File(dir, "bin/${KiloCliPlatform.exe()}")
val done = File(dir, ".complete")
val cached = done.takeIf { it.isFile }?.readText()?.trim()
val ext = KiloCliPlatform.archive(platform)
val archive = File(dir, "kilo-$platform.$ext")
if (force && dir.exists()) {
log.info("Deleting cached CLI $version under ${dir.absolutePath}")
dir.deleteRecursively()
}
if (exe.isFile && done.isFile) {
if (!force && exe.isFile && cached != null && cached.matches(DIGEST) && matches(archive, cached)) {
log.info("Using cached Kilo CLI $version for $platform at ${exe.absolutePath}")
if (!SystemInfo.isWindows) exe.setExecutable(true)
return@withContext exe
}
val digest = asset(version, platform, ext)
if (dir.exists()) {
log.info("Deleting cached CLI $version under ${dir.absolutePath}")
if (!dir.deleteRecursively()) {
throw IllegalStateException("Failed to delete cached Kilo CLI $version under ${dir.absolutePath}")
}
}
if (!dir.isDirectory && !dir.mkdirs()) {
throw IllegalStateException("Failed to create Kilo CLI cache directory ${dir.absolutePath}")
}
log.info("Kilo CLI $version for $platform is not cached; downloading new release into ${dir.absolutePath}")
dir.mkdirs()
val ext = KiloCliPlatform.archive(platform)
val archive = File(dir, "kilo-$platform.$ext")
onProgress(CliDownload(0, version, platform))
download(version, platform, ext, archive, onProgress)
verify(archive, digest)
log.info("Downloaded Kilo CLI $version for $platform to ${archive.absolutePath} (size=${archive.length()} bytes)")
extract(archive, dir)
if (!exe.isFile) throw IllegalStateException("Downloaded CLI archive did not contain bin/${KiloCliPlatform.exe()}")
if (!SystemInfo.isWindows) exe.setExecutable(true)
done.writeText("ok\n")
done.writeText("$digest\n")
onProgress(CliDownload(100, version, platform))
exe
}
private fun asset(version: String, platform: String, ext: String): String {
val name = "kilo-$platform.$ext"
val url = "${api.trimEnd('/')}/v$version"
log.info("Fetching Kilo CLI release metadata for $version from $url")
val request = Request.Builder()
.url(url)
.header("Accept", "application/vnd.github+json")
.build()
http.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IllegalStateException("Failed to fetch Kilo CLI release metadata for $version: HTTP ${response.code}")
}
val body = response.body?.string()
?: throw IllegalStateException("Failed to fetch Kilo CLI release metadata for $version: empty response body")
val digest = JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray
?.firstOrNull { it.jsonObject["name"]?.jsonPrimitive?.contentOrNull == name }
?.jsonObject?.get("digest")?.jsonPrimitive?.contentOrNull
?: throw IllegalStateException("Kilo CLI release $version did not include $name")
if (!digest.matches(DIGEST)) {
throw IllegalStateException("Kilo CLI release $version asset $name has invalid digest")
}
return digest
}
}
private fun download(version: String, platform: String, ext: String, file: File, onProgress: (CliDownload) -> Unit) {
val url = url(version, platform, ext)
log.info("Downloading Kilo CLI $version for $platform from $url")
@@ -85,6 +136,30 @@ class KiloCliDownloader(
}
}
private fun verify(file: File, digest: String) {
val actual = sum(file)
if (actual == digest) return
if (file.exists() && !file.delete()) log.warn("Failed to delete invalid Kilo CLI archive ${file.absolutePath}")
throw IllegalStateException("Kilo CLI archive digest mismatch for ${file.name}: expected $digest, got $actual")
}
private fun matches(file: File, digest: String) = file.isFile && sum(file) == digest
private fun sum(file: File) = "sha256:${sha256(file)}"
private fun sha256(file: File): String {
val md = MessageDigest.getInstance("SHA-256")
file.inputStream().buffered().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val n = input.read(buffer)
if (n < 0) break
md.update(buffer, 0, n)
}
}
return md.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
}
private fun extract(file: File, dir: File) {
log.info("Extracting Kilo CLI archive ${file.absolutePath}")
if (file.name.endsWith(".zip")) {
@@ -155,12 +155,15 @@ internal fun profileDto(p: KiloProfile200Response): ProfileDto = ProfileDto(
organizations = p.profile.organizations.orEmpty().map { org ->
ProfileOrganizationDto(id = org.id, name = org.name, role = org.role)
},
balance = p.balance?.let { ProfileBalanceDto(balance = it.balance ?: 0.0) },
balance = p.balance?.balance?.let { ProfileBalanceDto(balance = it) },
kiloPass = p.kiloPass?.let {
val base = it.currentPeriodBaseCreditsUsd ?: return@let null
val usage = it.currentPeriodUsageUsd ?: return@let null
val bonus = it.currentPeriodBonusCreditsUsd ?: return@let null
ProfileKiloPassDto(
currentPeriodBaseCreditsUsd = it.currentPeriodBaseCreditsUsd ?: 0.0,
currentPeriodUsageUsd = it.currentPeriodUsageUsd ?: 0.0,
currentPeriodBonusCreditsUsd = it.currentPeriodBonusCreditsUsd ?: 0.0,
currentPeriodBaseCreditsUsd = base,
currentPeriodUsageUsd = usage,
currentPeriodBonusCreditsUsd = bonus,
nextBillingAt = it.nextBillingAt,
)
},
@@ -109,7 +109,7 @@ class KiloBackendAppServiceTest {
@Test
fun `download progress maps to app state before ready`() = runBlocking {
val resolved = CompletableDeferred<Unit>()
val ready = CompletableDeferred<Unit>()
val signal = CompletableDeferred<Unit>()
val server = object : CliServer {
override var forceExtract = false
override fun process(): Process? = null
@@ -117,7 +117,7 @@ class KiloBackendAppServiceTest {
onProgress(CliDownload(37, "1.2.3", "darwin-arm64"))
resolved.await()
onResolved()
ready.await()
signal.await()
return CliServer.State.Ready(mock.start(), mock.password)
}
override fun exited(proc: Process) {}
@@ -141,7 +141,7 @@ class KiloBackendAppServiceTest {
svc.appState.first { it == KiloAppState.Connecting }
}
ready.complete(Unit)
signal.complete(Unit)
ready(svc)
job.join()
}
@@ -852,6 +852,30 @@ class KiloBackendAppServiceTest {
assertEquals("org_1", dto.profile?.currentOrgId)
}
@Test
fun `ready dto hides unknown profile amounts`() = runBlocking {
mock.profile = """{
"profile":{"email":"alice@test.com","name":"Alice"},
"balance":{"balance":null},
"kiloPass":{
"currentPeriodBaseCreditsUsd":null,
"currentPeriodUsageUsd":null,
"currentPeriodBonusCreditsUsd":null,
"nextBillingAt":null
},
"currentOrgId":null
}""".trimIndent()
val svc = create()
svc.connect()
ready(svc)
val dto = appStateDto(svc.appState.value)
assertEquals("alice@test.com", dto.profile?.email)
assertNull(dto.profile?.balance)
assertNull(dto.profile?.kiloPass)
}
@Test
fun `refresh profile updates ready dto profile`() = runBlocking {
mock.profile = """{"profile":{"email":"alice@test.com","name":"Alice"},"balance":null,"currentOrgId":null}"""
@@ -4,17 +4,21 @@ import ai.kilocode.backend.testing.TestLog
import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okio.Buffer
import org.apache.commons.compress.archivers.tar.TarArchiveEntry
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream
import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream
import org.junit.jupiter.api.io.TempDir
import java.io.ByteArrayOutputStream
import java.io.File
import java.security.MessageDigest
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class KiloCliDownloaderTest {
@@ -25,19 +29,22 @@ class KiloCliDownloaderTest {
fun `downloads extracts and caches pinned cli`() = runBlocking {
MockWebServer().use { server ->
val bytes = archive()
server.enqueue(MockResponse().setResponseCode(200).setBody(okio.Buffer().write(bytes)))
server.enqueue(metadata(bytes))
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
val seen = mutableListOf<CliDownload>()
val log = TestLog()
val cli = KiloCliDownloader(
log = log,
root = dir,
baseUrl = server.url("/release").toString(),
api = server.url("/api").toString(),
).resolve("1.2.3", onProgress = { seen.add(it) })
assertTrue(cli.isFile)
assertEquals(File(File(dir, "1.2.3"), KiloCliPlatform.current()).absolutePath, cli.parentFile.parentFile.absolutePath)
assertEquals("#!/bin/sh\n", cli.readText())
assertTrue(File(cli.parentFile, "kilo-sandbox-mutation-worker.js").isFile)
assertEquals("/api/v1.2.3", server.takeRequest().path)
assertEquals("/release/v1.2.3/kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}", server.takeRequest().path)
assertEquals(CliDownload(0, "1.2.3", KiloCliPlatform.current()), seen.first())
assertTrue(seen.any { it.percent == 100 && it.version == "1.2.3" && it.platform == KiloCliPlatform.current() })
@@ -48,20 +55,55 @@ class KiloCliDownloaderTest {
log = log,
root = dir,
baseUrl = server.url("/release").toString(),
api = server.url("/api").toString(),
).resolve("1.2.3", onProgress = { cachedProgress.add(it) })
assertEquals(cli.absolutePath, cached.absolutePath)
assertEquals(1, server.requestCount)
assertEquals(2, server.requestCount)
assertTrue(cachedProgress.isEmpty())
assertContains(log.messages, "INFO: Using cached Kilo CLI 1.2.3 for ${KiloCliPlatform.current()} at ${cli.absolutePath}")
server.enqueue(MockResponse().setResponseCode(200).setBody(okio.Buffer().write(bytes)))
File(cli.parentFile.parentFile, ".complete").writeText("ok\n")
server.enqueue(metadata(bytes))
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
val stale = KiloCliDownloader(
log = log,
root = dir,
baseUrl = server.url("/release").toString(),
api = server.url("/api").toString(),
).resolve("1.2.3")
assertEquals(cli.absolutePath, stale.absolutePath)
assertEquals(4, server.requestCount)
server.enqueue(metadata(bytes))
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
val forced = KiloCliDownloader(
log = log,
root = dir,
baseUrl = server.url("/release").toString(),
api = server.url("/api").toString(),
).resolve("1.2.3", force = true)
assertEquals(cli.absolutePath, forced.absolutePath)
assertEquals(2, server.requestCount)
assertEquals(6, server.requestCount)
}
}
@Test
fun `rejects cli archive with mismatched digest`() = runBlocking {
MockWebServer().use { server ->
val bytes = archive()
server.enqueue(metadata("sha256:${sha256("different".toByteArray())}"))
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
val ex = assertFailsWith<IllegalStateException> {
KiloCliDownloader(
root = dir,
baseUrl = server.url("/release").toString(),
api = server.url("/api").toString(),
).resolve("1.2.3")
}
assertContains(ex.message.orEmpty(), "digest mismatch")
assertFalse(File(File(File(dir, "1.2.3"), KiloCliPlatform.current()), ".complete").exists())
}
}
@@ -74,6 +116,16 @@ class KiloCliDownloaderTest {
return tar(files)
}
private fun metadata(bytes: ByteArray) = metadata("sha256:${sha256(bytes)}")
private fun metadata(digest: String) = MockResponse().setResponseCode(200).setBody(
"""{"assets":[{"name":"kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}","digest":"$digest"}]}"""
)
private fun sha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256")
.digest(bytes)
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
private fun zip(files: Map<String, ByteArray>): ByteArray {
val out = ByteArrayOutputStream()
ZipOutputStream(out).use { zip ->
@@ -404,6 +404,20 @@ class KiloBackendWorkspaceTest {
assertEquals("ses_1", result.sessions[0].id)
}
@Test
fun `workspace maps missing session timestamps to zero`() = runBlocking {
mock.sessions = """[
{"id":"ses_1","slug":"s","projectID":"p","directory":"/test/project","title":"T","version":"1","time":{"created":null,"updated":null}}
]"""
val app = setup()
val ws = ready(app)
loaded(ws)
val session = ws.sessions().sessions.single()
assertEquals(0.0, session.time.created)
assertEquals(0.0, session.time.updated)
}
@Test
fun `workspace creates session in its directory`() = runBlocking {
mock.sessionCreate = """{"id":"ses_new","slug":"n","projectID":"p","directory":"/test/project","title":"New","version":"1","time":{"created":1,"updated":1}}"""
@@ -1,3 +1,8 @@
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
import org.gradle.api.DefaultTask
@@ -14,6 +19,7 @@ import java.io.ByteArrayOutputStream
import java.io.File
import java.net.HttpURLConnection
import java.net.URI
import java.security.MessageDigest
import java.util.zip.ZipInputStream
import javax.inject.Inject
@@ -21,6 +27,12 @@ import javax.inject.Inject
* Generates the CLI OpenAPI spec from the pinned release binary.
*/
abstract class GenerateOpenApiSpecTask : DefaultTask() {
companion object {
private val DIGEST = Regex("^sha256:[a-f0-9]{64}$")
private val JSON = Json { ignoreUnknownKeys = true }
private const val API = "https://api.github.com/repos/Kilo-Org/kilocode/releases/tags"
}
@get:Input
abstract val cliVersion: Property<String>
@@ -68,20 +80,53 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() {
val dir = cacheDir.dir(version).map { it.dir(platform) }.get().asFile
val exe = File(dir, "bin/${exe()}")
val done = File(dir, ".complete")
if (exe.isFile && done.isFile) {
val cached = done.takeIf { it.isFile }?.readText()?.trim()
val archive = File(dir, "kilo-$platform.$ext")
if (exe.isFile && cached != null && cached.matches(DIGEST) && matches(archive, cached)) {
if (!windows()) exe.setExecutable(true)
return exe
}
dir.mkdirs()
val archive = File(dir, "kilo-$platform.$ext")
val name = "kilo-$platform.$ext"
val digest = asset(version, name)
if (dir.exists() && !dir.deleteRecursively()) {
throw GradleException("Failed to delete cached pinned Kilo CLI under ${dir.absolutePath}")
}
if (!dir.isDirectory && !dir.mkdirs()) {
throw GradleException("Failed to create pinned Kilo CLI cache directory ${dir.absolutePath}")
}
download("https://github.com/Kilo-Org/kilocode/releases/download/v$version/kilo-$platform.$ext", archive)
verify(archive, digest)
extract(archive, dir)
if (!exe.isFile) throw GradleException("Downloaded CLI archive did not contain bin/${exe()}")
if (!windows()) exe.setExecutable(true)
done.writeText("ok\n")
done.writeText("$digest\n")
return exe
}
private fun asset(version: String, name: String): String {
val url = "$API/v$version"
logger.lifecycle("Fetching pinned Kilo CLI release metadata from $url")
val conn = URI(url).toURL().openConnection() as HttpURLConnection
conn.connectTimeout = 30_000
conn.readTimeout = 120_000
conn.instanceFollowRedirects = true
conn.setRequestProperty("Accept", "application/vnd.github+json")
try {
val code = conn.responseCode
if (code !in 200..299) throw GradleException("Failed to fetch pinned Kilo CLI release metadata: HTTP $code from $url")
val body = conn.inputStream.bufferedReader().use { it.readText() }
val digest = JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray
?.firstOrNull { it.jsonObject["name"]?.jsonPrimitive?.contentOrNull == name }
?.jsonObject?.get("digest")?.jsonPrimitive?.contentOrNull
?: throw GradleException("Pinned Kilo CLI release $version did not include $name")
if (!digest.matches(DIGEST)) throw GradleException("Pinned Kilo CLI release $version asset $name has invalid digest")
return digest
} finally {
conn.disconnect()
}
}
private fun download(url: String, file: File) {
logger.lifecycle("Downloading pinned Kilo CLI from $url")
val conn = URI(url).toURL().openConnection() as HttpURLConnection
@@ -99,6 +144,30 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() {
}
}
private fun verify(file: File, digest: String) {
val actual = sum(file)
if (actual == digest) return
if (file.exists() && !file.delete()) logger.warn("Failed to delete invalid pinned Kilo CLI archive ${file.absolutePath}")
throw GradleException("Pinned Kilo CLI archive digest mismatch for ${file.name}: expected $digest, got $actual")
}
private fun matches(file: File, digest: String) = file.isFile && sum(file) == digest
private fun sum(file: File) = "sha256:${sha256(file)}"
private fun sha256(file: File): String {
val md = MessageDigest.getInstance("SHA-256")
file.inputStream().buffered().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val n = input.read(buffer)
if (n < 0) break
md.update(buffer, 0, n)
}
}
return md.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
}
private fun extract(file: File, dir: File) {
if (file.name.endsWith(".zip")) {
ZipInputStream(file.inputStream().buffered()).use { zip ->
@@ -82,10 +82,10 @@ class KiloAppService internal constructor(
}
private fun onState(state: KiloAppStateDto) {
_state.value = state
val version = state.downloadVersion
val platform = state.downloadPlatform
if (version != null && platform != null) info = CoreInfo(version, platform)
_state.value = state
if (state.status == KiloAppStatusDto.READY) refreshModelFavoritesAsync()
}