Skip to content
Open
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## 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.
- updated CLI login to keep REST client and CLI auth on the same persisted token, reducing credential drift.
- added an opt-in keyring-backed CLI login mode to securely store the session token on supported platforms.

## 0.9.0 - 2026-05-14

### Added
Expand Down
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -456,11 +456,16 @@ storage paths. The options can be configured from the plugin's main Workspaces p

- `Data directory` directory where deployment-specific data such as session tokens and CLI binaries
are stored. Each deployment gets a host-specific subdirectory (e.g. `coder.example.com`). Supports `~` and `$HOME`
expansion.
expansion. When keyring-backed CLI storage is enabled, the session token is no longer persisted in this directory.

- `Header command` command that outputs additional HTTP headers. Each line of output must be in the format key=value.
The environment variable CODER_URL will be available to the command process.

- `Store CLI session in OS keyring when supported (CLI >= 2.29.0)` is an opt-in setting to force the CLI to use its
default credential backend on supported platforms instead of forcing a custom `--global-config` login path.
On macOS and Windows, that allows newer CLIs to persist the token in the OS keyring. On Linux, we continue to use
the plugin-managed `--global-config` flow even if this setting is enabled. Disabled by default.

- `lastDeploymentURL` the last Coder deployment URL that Coder Toolbox successfully authenticated to.

- `workspaceViewUrl` specifies the dashboard page full URL where users can view details about a workspace.
Expand Down Expand Up @@ -490,6 +495,27 @@ Once the binary location is resolved:
3. If **downloads are disabled** and the CLI exists but its version does not match, the stale
CLI is used with a warning. If no CLI exists at all, an error is raised.

#### How keyring-backed CLI login works

When **Store CLI session in OS keyring when supported (CLI >= 2.29.0)** is disabled, Toolbox logs the CLI in with a
deployment-specific `--global-config` directory under the configured data directory. That keeps the CLI session
isolated inside the plugin-managed config tree.

When the setting is enabled, Toolbox still logs in with `CODER_SESSION_TOKEN` and `--use-token-as-session`, but it
stops forcing `--global-config` for authenticated CLI commands and relies on deployment URL-based auth resolution
instead. On supported platforms, this lets newer Coder CLIs use their default OS-backed storage instead of the
plugin-managed config directory.

This matters for two reasons:

- the CLI persists the same token that Toolbox already uses for REST API calls, instead of minting a separate token
- Toolbox can move toward managing one shared credential consistently across REST and CLI flows, including future
revoke/logout behavior

Keyring-backed storage requires Coder CLI `2.29.0` or newer and is only supported on macOS and Windows. The setting is
opt-in and defaults to disabled. On Linux, enabling the setting does not switch the
CLI to keyring-backed auth; Toolbox keeps using the deployment-specific `--global-config` directory.

### TLS settings

The following options control the secure communication behavior of the plugin with Coder deployment and its available
Expand Down Expand Up @@ -539,6 +565,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
102 changes: 76 additions & 26 deletions src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,21 @@ import com.coder.toolbox.sdk.v2.models.Workspace
import com.coder.toolbox.sdk.v2.models.WorkspaceAgent
import com.coder.toolbox.settings.SignatureFallbackStrategy.ALLOW
import com.coder.toolbox.util.InvalidVersionException
import com.coder.toolbox.util.OS
import com.coder.toolbox.util.SemVer
import com.coder.toolbox.util.escape
import com.coder.toolbox.util.escapeSubcommand
import com.coder.toolbox.util.getOS
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.runBlocking
import kotlinx.coroutines.withContext
import org.zeroturnaround.exec.ProcessExecutor
import retrofit2.Retrofit
import java.io.EOFException
import java.io.FileNotFoundException
Expand Down Expand Up @@ -104,6 +108,7 @@ data class Features(
val reportWorkspaceUsage: Boolean = false,
val wildcardSsh: Boolean = false,
val buildReason: Boolean = false,
val keyringAuth: Boolean = false,
)

/**
Expand All @@ -112,7 +117,8 @@ data class Features(
class CoderCLIManager(
private val context: CoderToolboxContext,
// The URL of the deployment this CLI is for.
private val deploymentURL: URL
private val deploymentURL: URL,
private val currentOs: OS? = getOS(),
) {
private val downloader = createDownloadService()
private val gpgVerifier = GPGVerifier(context)
Expand Down Expand Up @@ -260,27 +266,35 @@ class CoderCLIManager(
}

/**
* Use the provided token to initializeSession the CLI.
* Use the provided token to initialize the CLI.
*
* When keyring storage is enabled and supported, omit --global-config so supported CLIs
* can select their default OS-backed credential storage. This only applies
* on macOS and Windows.
*/
fun login(token: String): String {
context.logger.info("Storing CLI credentials in $coderConfigPath")
return exec(
fun login(token: String, feats: Features = features): String {
maybeWarnAboutKeyringFallback(feats)
val args = mutableListOf(
"login",
"--use-token-as-session",
deploymentURL.toString(),
"--token",
token,
"--global-config",
coderConfigPath.toString(),
)
if (!shouldUseKeyringAuth(feats)) {
args.addAll(globalConfigArgs())
}
return exec(
env = mapOf(CODER_SESSION_TOKEN_ENV_VAR to token),
*args.toTypedArray(),
)
}

/**
* Start a workspace. Throws if the command execution fails.
*/
fun startWorkspace(workspaceOwner: String, workspaceName: String, feats: Features = features): String {
maybeWarnAboutKeyringFallback(feats)
val args = mutableListOf(
"--global-config",
coderConfigPath.toString(),
*workspaceAuthArgs(feats).toTypedArray(),
"start",
"--yes",
"$workspaceOwner/$workspaceName"
Expand Down Expand Up @@ -336,8 +350,8 @@ class CoderCLIManager(
val baseArgs =
listOfNotNull(
escape(localBinaryPath.toString()),
"--global-config",
escape(coderConfigPath.toString()),
if (!shouldUseKeyringAuth(feats)) "--global-config" else null,
if (!shouldUseKeyringAuth(feats)) escape(coderConfigPath.toString()) else null,
// CODER_URL might be set, and it will override the URL file in
// the config directory, so override that here to make sure we
// always use the correct URL.
Expand Down Expand Up @@ -534,17 +548,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 All @@ -559,6 +574,7 @@ class CoderCLIManager(
reportWorkspaceUsage = version >= SemVer(2, 13, 0),
wildcardSsh = version >= SemVer(2, 19, 0),
buildReason = version >= SemVer(2, 25, 0),
keyringAuth = version >= SemVer(2, 29, 0),
)
}
}
Expand All @@ -572,7 +588,9 @@ class CoderCLIManager(
}

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

internal fun supportsKeyringStorage(os: OS?): Boolean = os == OS.MAC || os == OS.WINDOWS

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

Expand All @@ -583,4 +601,36 @@ class CoderCLIManager(

private fun Pair<Workspace, WorkspaceAgent>.agent() = this.second
}

private fun globalConfigArgs(): List<String> = listOf("--global-config", coderConfigPath.toString())

private fun workspaceAuthArgs(feats: Features): List<String> =
if (shouldUseKeyringAuth(feats)) {
listOf("--url", deploymentURL.toString())
} else {
globalConfigArgs()
}

private fun shouldUseKeyringAuth(feats: Features): Boolean =
context.settingsStore.useKeyring && feats.keyringAuth && supportsKeyringStorage(currentOs)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any visibility for users that enable the feature/setting, but it isn't enabled due to this logic? I am mainly thinking about how we avoid silently falling back to file storage.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On top of your recommandations I added another commit that hides the checkbox field on unsupported operating systems (Linux)


private fun maybeWarnAboutKeyringFallback(feats: Features) {
if (!context.settingsStore.useKeyring || shouldUseKeyringAuth(feats)) {
return
}

val warning = when {
!supportsKeyringStorage(currentOs) ->
"OS keyring storage is enabled, but keyring-backed CLI auth is only supported on macOS and Windows. Falling back to file-based CLI storage."

!feats.keyringAuth ->
"OS keyring storage is enabled, but the installed Coder CLI does not support keyring-backed auth. Coder CLI 2.29.0 or newer is required. Falling back to file-based CLI storage."

else -> null
} ?: return

runBlocking {
context.logAndShowWarning("Keyring storage unavailable", warning)
}
}
}
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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ interface ReadOnlyCoderSettings {

/**
* Where to save plugin data like the Coder binary (if not configured with
* binaryDestination) and the deployment URL and session token.
* binaryDestination) and the deployment URL. When keyring storage is not
* enabled, this also stores the CLI session token.
*/
val dataDirectory: String?

Expand Down Expand Up @@ -98,6 +99,13 @@ interface ReadOnlyCoderSettings {
*/
val headerCommand: String?

/**
* Whether CLI login should allow the Coder CLI to persist the session in
* the operating system keyring when supported. This only takes effect on
* macOS and Windows.
*/
val useKeyring: Boolean

/**
* Optional TLS settings
*/
Expand Down Expand Up @@ -286,4 +294,4 @@ enum class HttpLoggingVerbosity {
else -> NONE
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class CoderSettingsStore(
override val globalConfigDir: String get() = getDefaultGlobalConfigDir().normalize().toString()
override val enableDownloads: Boolean get() = store[ENABLE_DOWNLOADS]?.toBooleanStrictOrNull() ?: true
override val headerCommand: String? get() = store[HEADER_COMMAND]
override val useKeyring: Boolean get() = store[USE_KEYRING]?.toBooleanStrictOrNull() ?: false
override val tls: ReadOnlyTLSSettings
get() = TLSSettings(
certPath = store[TLS_CERT_PATH],
Expand Down Expand Up @@ -214,6 +215,10 @@ class CoderSettingsStore(
store[HEADER_COMMAND] = cmd
}

fun updateUseKeyring(useKeyring: Boolean) {
store[USE_KEYRING] = useKeyring.toString()
}

fun updateCertPath(path: String) {
store[TLS_CERT_PATH] = path
}
Expand Down Expand Up @@ -365,4 +370,4 @@ class CoderSettingsStore(
val host = if (url.port > 0) "${url.safeHost()}-${url.port}" else url.safeHost()
return path.resolve(host)
}
}
}
2 changes: 2 additions & 0 deletions src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ internal const val ENABLE_DOWNLOADS = "enableDownloads"

internal const val HEADER_COMMAND = "headerCommand"

internal const val USE_KEYRING = "useKeyring"

internal const val TLS_CERT_PATH = "tlsCertPath"

internal const val TLS_KEY_PATH = "tlsKeyPath"
Expand Down
Loading
Loading