Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

### Changed

- reduce token exposure in process arguments and command logs
- replaced the external process execution dependency with a lightweight internal runner, reducing plugin dependencies
and improving error sanitization.

## 0.9.0 - 2026-05-14

### Added
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,10 @@ support, may trigger regeneration of SSH configurations.
> [!IMPORTANT]
> Token authentication is required when TLS certificates are not configured.

When the plugin logs the Coder CLI in with a session token, it passes that token through the
`CODER_SESSION_TOKEN` environment variable instead of `--token`. This reduces the chances of the token showing up in
process listings, shell history, or command-line audit logs.

## Releasing

1. Check that the changelog lists all the important changes.
Expand Down
1 change: 0 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ dependencies {
compileOnly(libs.bundles.serialization)
compileOnly(libs.coroutines.core)
implementation(libs.okhttp)
implementation(libs.exec)
implementation(libs.moshi)
ksp(libs.moshi.codegen)
implementation(libs.retrofit)
Expand Down
2 changes: 0 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ okhttp = "4.12.0"
dependency-license-report = "3.1.2"
marketplace-client = "2.0.51"
gradle-wrapper = "0.16.0"
exec = "1.12"
moshi = "1.15.2"
ksp = "2.3.6"
retrofit = "3.0.0"
Expand All @@ -28,7 +27,6 @@ serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cor
serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
serialization-json-okio = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json-okio", version.ref = "serialization" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
exec = { module = "org.zeroturnaround:zt-exec", version.ref = "exec" }
moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
Expand Down
31 changes: 16 additions & 15 deletions src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,15 @@ import com.coder.toolbox.util.InvalidVersionException
import com.coder.toolbox.util.SemVer
import com.coder.toolbox.util.escape
import com.coder.toolbox.util.escapeSubcommand
import com.coder.toolbox.util.runProcess
import com.coder.toolbox.util.safeHost
import com.coder.toolbox.util.sanitizeSecrets
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.squareup.moshi.JsonDataException
import com.squareup.moshi.Moshi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.zeroturnaround.exec.ProcessExecutor
import retrofit2.Retrofit
import java.io.EOFException
import java.io.FileNotFoundException
Expand Down Expand Up @@ -265,10 +266,9 @@ class CoderCLIManager(
fun login(token: String): String {
context.logger.info("Storing CLI credentials in $coderConfigPath")
return exec(
env = mapOf(CODER_SESSION_TOKEN_ENV_VAR to token),
"login",
deploymentURL.toString(),
"--token",
token,
"--global-config",
coderConfigPath.toString(),
)
Expand Down Expand Up @@ -534,17 +534,18 @@ class CoderCLIManager(
return matches
}

private fun exec(vararg args: String): String {
val stdout =
ProcessExecutor()
.command(localBinaryPath.toString(), *args)
.environment("CODER_HEADER_COMMAND", context.settingsStore.headerCommand)
.exitValues(0)
.readOutput(true)
.execute()
.outputUTF8()
val redactedArgs = listOf(*args).joinToString(" ").replace(tokenRegex, "--token <redacted>")
context.logger.info("`$localBinaryPath $redactedArgs`: $stdout")
private fun exec(vararg args: String): String = exec(env = emptyMap(), *args)

private fun exec(env: Map<String, String>, vararg args: String): String {
val command = listOf(localBinaryPath.toString(), *args)
val processEnv = buildMap {
context.settingsStore.headerCommand?.let { put("CODER_HEADER_COMMAND", it) }
putAll(env)
}

val stdout = runProcess(command, environment = processEnv).stdout
val sanitizedStdout = stdout.sanitizeSecrets()
context.logger.info("`$localBinaryPath ${listOf(*args).joinToString(" ")}`: $sanitizedStdout")
return stdout
}

Expand Down Expand Up @@ -572,7 +573,7 @@ class CoderCLIManager(
}

companion object {
private val tokenRegex = "--token [^ ]+".toRegex()
private const val CODER_SESSION_TOKEN_ENV_VAR = "CODER_SESSION_TOKEN"

private fun getHostnamePrefix(url: URL): String = "coder-jetbrains-toolbox-${url.safeHost()}"

Expand Down
15 changes: 7 additions & 8 deletions src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import com.coder.toolbox.sdk.v2.models.WorkspaceBuildReason
import com.coder.toolbox.sdk.v2.models.WorkspaceResource
import com.coder.toolbox.sdk.v2.models.WorkspaceTransition
import com.coder.toolbox.util.ReloadableTlsContext
import com.coder.toolbox.util.runProcess
import com.coder.toolbox.views.state.CoderOAuthSessionContext
import com.coder.toolbox.views.state.hasRefreshToken
import com.squareup.moshi.Moshi
Expand All @@ -32,7 +33,6 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.zeroturnaround.exec.ProcessExecutor
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
Expand Down Expand Up @@ -411,18 +411,17 @@ open class CoderRestClient(
if (command.isNullOrBlank()) return@withContext false

return@withContext try {
val result = ProcessExecutor()
.command(command.split(" ").toList())
.exitValueAny()
.readOutput(true)
.execute()
val result = runProcess(
command.split(" ").filter { it.isNotBlank() },
expectedExitCodes = Int.MIN_VALUE..Int.MAX_VALUE,
)
if (tlsContext.reload()) {
context.logger.info("Certificate refresh successful. Reloading TLS and evicting pool.")
// forces OkHttp to close the broken HTTP/2 connection.
httpClient.connectionPool.evictAll()
return@withContext true
} else {
context.logger.error("Refresh command failed with code ${result.exitValue}")
context.logger.error("Refresh command failed with code ${result.exitCode}")
false
}
} catch (ex: Exception) {
Expand All @@ -448,4 +447,4 @@ private fun Response<*>.parseErrorBody(moshi: Moshi): ApiErrorResponse? {
} catch (e: Exception) {
null
}
}
}
3 changes: 1 addition & 2 deletions src/main/kotlin/com/coder/toolbox/util/Error.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package com.coder.toolbox.util

import com.coder.toolbox.cli.ex.ResponseException
import com.coder.toolbox.sdk.ex.APIResponseException
import org.zeroturnaround.exec.InvalidExitValueException
import java.net.ConnectException
import java.net.UnknownHostException

Expand All @@ -17,7 +16,7 @@ fun Throwable.prettify(): String {
is FileSystemException -> fileSystemFailed(this.file.toString())
is java.nio.file.FileSystemException -> fileSystemFailed(this.file)
is UnknownHostException -> "Unknown host $reason"
is InvalidExitValueException -> "CLI exited unexpectedly with ${this.exitValue}."
is ProcessExitException -> "CLI exited unexpectedly with ${this.result.exitCode}."
is APIResponseException -> {
if (this.isUnauthorized) {
"Authorization failed"
Expand Down
17 changes: 5 additions & 12 deletions src/main/kotlin/com/coder/toolbox/util/Headers.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package com.coder.toolbox.util

import org.zeroturnaround.exec.ProcessExecutor
import java.io.OutputStream
import java.net.URL

private val newlineRegex = "\r?\n".toRegex()
Expand All @@ -20,16 +18,11 @@ fun getHeaders(
else -> Pair("sh", "-c")
}
val output =
ProcessExecutor()
.command(shell, caller, headerCommand)
.environment("CODER_URL", url.toString())
// By default stderr is in the output, but we want to ignore it. stderr
// will still be included in the exception if something goes wrong.
.redirectError(OutputStream.nullOutputStream())
.exitValues(0)
.readOutput(true)
.execute()
.outputUTF8()
runProcess(
listOf(shell, caller, headerCommand),
environment = mapOf("CODER_URL" to url.toString()),
stderrMode = ProcessStderrMode.DISCARD_ON_SUCCESS,
).stdout

// The Coder CLI will allow no output, but not blank lines. Possibly we
// should skip blank lines, but it is better to have parity so commands will
Expand Down
113 changes: 113 additions & 0 deletions src/main/kotlin/com/coder/toolbox/util/ProcessRunner.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package com.coder.toolbox.util

import java.io.IOException
import java.nio.charset.Charset
import kotlin.concurrent.thread

data class ProcessResult(
val command: List<String>,
val exitCode: Int,
val stdout: String,
val stderr: String,
)

sealed class ProcessRunnerException(
message: String,
cause: Throwable? = null,
) : RuntimeException(message.sanitizeSecrets(), cause)

enum class ProcessStderrMode {
CAPTURE,
DISCARD_ON_SUCCESS,
}

class ProcessExecutionException(
message: String,
cause: Throwable? = null
) : ProcessRunnerException(message, cause)

class ProcessExitException(
val result: ProcessResult,
private val expectedExitCodes: IntRange,
) : ProcessRunnerException(
buildString {
append("Unexpected exit value: ${result.exitCode}, allowed exit values: $expectedExitCodes")
append(", executed command ${result.command}")
if (result.stdout.isNotBlank()) {
append(", stdout was ${result.stdout.length} bytes:\n${result.stdout}")
}
if (result.stderr.isNotBlank()) {
append(", stderr was ${result.stderr.length} bytes:\n${result.stderr}")
}
}.sanitizeSecrets()
)

/**
* Runs a process and waits for it to finish.
*
* The wait is intentionally unbounded. Only exit code 0 is accepted by default.
* Pass [expectedExitCodes] when a command has additional valid exit codes.
*
* Standard output is always captured and returned in [ProcessResult.stdout] while
* standard error is captured by default and returned in [ProcessResult.stderr]. Use
* [ProcessStderrMode.DISCARD_ON_SUCCESS] in order to ignore it.
* Stderr is ignored for successful results, but preserved in [ProcessExitException] when the process fails.
*/
fun runProcess(
command: List<String>,
environment: Map<String, String> = emptyMap(),
expectedExitCodes: IntRange = 0..0,
stderrMode: ProcessStderrMode = ProcessStderrMode.CAPTURE,
charset: Charset = Charsets.UTF_8,
): ProcessResult {
val process =
try {
ProcessBuilder(command)
.apply { environment().putAll(environment) }
.start()
} catch (ex: IOException) {
throw ProcessExecutionException("Failed to start process $command: ${ex.message}", ex)
}

val stdout = StringBuilder()
val stderr = StringBuilder()
val stdoutReader = thread(start = true, name = "process-stdout-reader") {
process.inputStream.bufferedReader(charset).use { stdout.append(it.readText()) }
}
val stderrReader = thread(start = true, name = "process-stderr-reader") {
process.errorStream.bufferedReader(charset).use { stderr.append(it.readText()) }
}

val exitCode =
try {
process.waitFor()
} catch (ex: InterruptedException) {
process.destroyForcibly()
Thread.currentThread().interrupt()
throw ProcessExecutionException("Interrupted while waiting for process $command", ex)
}

try {
stdoutReader.join()
stderrReader.join()
} catch (ex: InterruptedException) {
Thread.currentThread().interrupt()
throw ProcessExecutionException("Interrupted while reading process output for $command", ex)
}

val stderrText = stderr.toString()
val result = ProcessResult(
command = command,
exitCode = exitCode,
stdout = stdout.toString(),
stderr = if (exitCode in expectedExitCodes && stderrMode == ProcessStderrMode.DISCARD_ON_SUCCESS) {
""
} else {
stderrText
},
)
if (exitCode !in expectedExitCodes) {
throw ProcessExitException(result, expectedExitCodes)
}
return result
}
14 changes: 14 additions & 0 deletions src/main/kotlin/com/coder/toolbox/util/SecretSanitizer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.coder.toolbox.util

private val sensitivePatterns = listOf(
Regex("""(CODER_SESSION_TOKEN=)([^,\s}]+)"""),
Regex("""(Coder-Session-Token:\s*)([^\s,]+)""", RegexOption.IGNORE_CASE),
Regex("""(--token\s+)(\S+)"""),
Regex("""([?&]token=)([^&\s]+)""", RegexOption.IGNORE_CASE),
)

fun String.sanitizeSecrets(): String {
return sensitivePatterns.fold(this) { acc, regex ->
acc.replace(regex, "$1<redacted>")
}
}
7 changes: 0 additions & 7 deletions src/main/resources/dependencies.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,5 @@
"url": "http://www.slf4j.org",
"license": "MIT License",
"licenseUrl": "http://www.opensource.org/licenses/mit-license.php"
},
{
"name": "org.zeroturnaround:zt-exec",
"version": "1.12",
"url": "https://github.com/zeroturnaround/zt-exec",
"license": "The Apache Software License, Version 2.0",
"licenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
}
]
Loading
Loading