Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0bc801c
Pass CLI login tokens via CODER_SESSION_TOKEN instead of --token
fioan89 May 18, 2026
a5488c8
chore: add UTs for the token env
fioan89 May 18, 2026
bba2fbc
Replace zt-exec with internal process runner
fioan89 May 19, 2026
ad43762
chore: fix broken UTs
fioan89 May 19, 2026
c9008c0
chore: fix broken UT on Windows
fioan89 May 19, 2026
6d264b1
chore: more UTs for the new process runner
fioan89 May 19, 2026
be793d9
Keep CLI and API auth on the same persisted token
fioan89 May 20, 2026
d164f81
Add opt-in keyring-backed CLI auth for supported platforms
fioan89 May 20, 2026
a715f68
warn the user when keyring is enabled but not supported
fioan89 May 21, 2026
6cd4e6d
Hide the keyring setting on unsupported operating systems
fioan89 May 21, 2026
7ae1d31
Update the ssh config as soon as useKeyring setting changed
fioan89 May 21, 2026
5d5d21e
Merge branch 'main' into store-session-token-into-os-keyring
fioan89 May 22, 2026
1616c37
Warn user that OS keyring is not supported
fioan89 May 25, 2026
cf073b1
chore: also sanitize `--token=value`
fioan89 May 25, 2026
1e9d6ca
chore: update README
fioan89 May 25, 2026
7f86439
Merge branch 'main' into store-session-token-into-os-keyring
fioan89 May 25, 2026
f9b872b
chore: remove outdated UTs
fioan89 May 25, 2026
28e1834
Merge branch 'main' into store-session-token-into-os-keyring
fioan89 Jun 4, 2026
7d1c2bf
Merge branch 'main' into store-session-token-into-os-keyring
fioan89 Jun 4, 2026
d10872e
Merge remote-tracking branch 'origin/main' into store-session-token-i…
fioan89 Oct 1, 2026
b8dd0c1
Wait for killed processes to exit before returning from the process r…
fioan89 Oct 1, 2026
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@

### Changed

- store CLI session tokens in the OS keyring by default on supported platforms; set `useKeyring` to `false` to opt out
- share the same session token between REST and CLI authentication, passing it through the environment and sanitizing logs
- remove stored credentials on explicit logout, and remove old plaintext session files after successful keyring login
- bound credential and header commands to 60 seconds while preserving unbounded workspace starts and complete progress lines

- upgraded the Toolbox plugin API, dropping support for Toolbox versions older than 3.7.2
- display live workspace start output from the Coder CLI and poll build status and provisioner output while updating,
restarting, or stopping workspaces
Expand Down
47 changes: 44 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -540,13 +540,18 @@ explicit entry per resolved workspace/agent using
as-is) or a base directory (the CLI is placed under a host-specific subdirectory). If blank, the data directory is
used. Supports `~` and `$HOME` expansion.

- `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.
- `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. 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.
When this setting is left blank, the `CODER_HEADER_COMMAND` environment variable is used instead, if set.

- `Store CLI session in OS keyring when supported (CLI >= 2.29.0)` is enabled by default on macOS and Windows.
An unset or `true` `useKeyring` setting uses the OS keyring; `false` opts out and stores the CLI session in the
plugin's deployment-specific data directory. Linux and older CLIs use file storage. Changes apply on the next
sign-in or plugin restart.

- `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. Helpful for
Expand Down Expand Up @@ -576,6 +581,38 @@ 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

Toolbox passes session tokens to `coder login` through `CODER_SESSION_TOKEN` and uses `--use-token-as-session` so
REST requests and CLI commands share the same token. The token is not passed in command-line arguments.

On macOS and Windows with Coder CLI `2.29.0` or newer, Toolbox enables the OS keyring by default. It passes
`--use-keyring=true` and the deployment URL, and omits the plugin-specific `--global-config` directory. After a
successful keyring login, Toolbox removes any old plaintext session file from that deployment's plugin data directory.
A keyring write failure is reported as a login failure; Toolbox does not retry the write using file storage.

To opt out, uncheck **Store CLI session in OS keyring when supported (CLI >= 2.29.0)** or set `useKeyring` to `false`.
Toolbox then uses its deployment-specific `--global-config` directory and passes `--use-keyring=false` to CLIs that
support the flag. Linux also uses this file storage. On older CLIs, Toolbox omits the unsupported flag and warns at
login on macOS and Windows when the requested keyring storage is unavailable. Certificate-based authentication
continues to use the plugin-specific CLI configuration without storing a session token.

Saving the setting does not change the current session's credential backend or rewrite its SSH commands to use a
new backend. The change takes effect on the next sign-in or plugin restart, when Toolbox stores the token and
regenerates SSH configuration. Workspace starts, SSH connections, support bundles, token refreshes, and logout use
the active session's storage choice consistently.

The OS keyring entry is shared with the Coder CLI and VS Code extension for the same deployment. Explicitly logging
out of Toolbox runs `coder logout --yes`, which revokes the session and removes its stored credential, and clears
Toolbox's saved API token and OAuth credentials. Other clients using that shared session will need to sign in again.
If CLI cleanup fails, Toolbox still signs out and displays a warning. Closing the plugin alone preserves credentials.

Credential commands and header commands have a 60-second timeout. Workspace starts and SSH sessions are not subject
to that limit. Workspace start progress is reported one complete line at a time, including carriage-return updates.

Toolbox retains its own credentials in the Toolbox secret store for automatic sign-in. Importing a token created by
an independent terminal login is tracked separately in DEVEX-403.

### TLS settings

The following options control the secure communication behavior of the plugin with Coder deployment and its available
Expand Down Expand Up @@ -628,6 +665,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 @@ -38,7 +38,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.4"
marketplace-client = "2.0.51"
gradle-wrapper = "0.16.0"
exec = "1.13.0"
moshi = "1.15.2"
ksp = "2.3.6"
retrofit = "3.0.0"
Expand All @@ -29,7 +28,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
40 changes: 30 additions & 10 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import com.jetbrains.toolbox.api.ui.components.UiPage
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
Expand Down Expand Up @@ -115,8 +116,10 @@ class CoderRemoteProvider(
private val linkHandler =
CoderProtocolHandler(context, IdeFeedManager(context), workspaceRefreshTrigger, environments)
private val accountDropdownField = dropDownFactory(context.i18n.pnotr("")) {
logout()
context.envPageManager.showPluginEnvironmentsPage(false)
context.cs.launch(CoroutineName("Logout")) {
logout()
context.envPageManager.showPluginEnvironmentsPage(false)
}
}.apply {
visibility.update { false }
}
Expand Down Expand Up @@ -350,15 +353,32 @@ class CoderRemoteProvider(
}.sortedBy { it.id }
}

/**
* Stop polling, clear the client and environments, then go back to the
* first page.
*/
private fun logout() {
/** Sign out explicitly; provider shutdown alone must preserve credentials. */
internal suspend fun logout() {
val sessionIds = lastEnvironments.currentSessionIds()
context.logger.info(sessionIds, "Logging out ${client?.me?.username}...")
close()
context.logger.info(sessionIds, "User ${client?.me?.username} logged out successfully")
val activeClient = client
val activeCli = cli
context.logger.info(sessionIds, "Logging out ${activeClient?.me?.username}...")
try {
pollJob?.cancelAndJoin()
activeClient?.close()
if (activeClient != null) {
context.secrets.clearSessionFor(activeClient.url)
if (activeCli?.usesTokenAuth == true) activeCli.logout()
}
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
context.logger.logAndShowWarning(
sessionIds,
"CLI logout failed",
"Toolbox has signed out, but the CLI credential may remain stored. " +
"Sign in and try logging out again to remove it.",
ex,
)
} finally {
close()
}
}

/**
Expand Down
118 changes: 74 additions & 44 deletions src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,19 @@ import com.coder.toolbox.sdk.CoderHttpClientBuilder
import com.coder.toolbox.session.SessionId
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.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.runInterruptible
import kotlinx.coroutines.withContext
import org.zeroturnaround.exec.ProcessExecutor
import retrofit2.Retrofit
import java.io.EOFException
import java.io.FileNotFoundException
Expand Down Expand Up @@ -102,6 +105,7 @@ data class Features(
val reportWorkspaceUsage: Boolean = false,
val wildcardSsh: Boolean = false,
val buildReason: Boolean = false,
val keyringAuth: Boolean = false,
)

/**
Expand All @@ -110,8 +114,14 @@ 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(),
) {
internal val usesTokenAuth = context.settingsStore.requiresTokenAuth

// Keep credential storage consistent with existing SSH commands until the next sign-in.
private val useKeyring = context.settingsStore.useKeyring && usesTokenAuth
private var keyringFallbackWarningShown = false
private val downloader = createDownloadService()
private val gpgVerifier = GPGVerifier(context)

Expand Down Expand Up @@ -257,19 +267,34 @@ class CoderCLIManager(
}
}

/**
* Use the provided token to initializeSession the CLI.
*/
fun login(token: String): String {
context.logger.info("Storing CLI credentials in $coderConfigPath")
return exec(
"login",
deploymentURL.toString(),
"--token",
token,
"--global-config",
coderConfigPath.toString(),
)
/** Persist the same token used by the REST client, without exposing it in process arguments. */
suspend fun login(token: String, feats: Features = features): String {
if (useKeyring && supportsKeyringStorage(currentOs) && !feats.keyringAuth && !keyringFallbackWarningShown) {
keyringFallbackWarningShown = true
context.logger.logAndShowWarning(
"Keyring storage unavailable",
"Coder CLI 2.29.0 or newer is required for OS keyring storage. " +
"The CLI session will be stored in the plugin's data directory.",
)
}
return runInterruptible(Dispatchers.IO) {
val output = exec(
*workspaceAuthArgs(feats).toTypedArray(),
"login", "--use-token-as-session", deploymentURL.toString(),
env = mapOf(CODER_SESSION_TOKEN_ENV_VAR to token),
timeoutMillis = CREDENTIAL_TIMEOUT_MILLIS,
)
if (shouldUseKeyringAuth(feats)) {
// Remove the plaintext copy only after the keyring write succeeds.
Files.deleteIfExists(coderConfigPath.resolve("session"))
}
output
}
}

/** Revoke the CLI session and remove its persisted credential. The caller handles failures. */
internal suspend fun logout(feats: Features = features) = runInterruptible(Dispatchers.IO) {
exec(*workspaceAuthArgs(feats).toTypedArray(), "logout", "--yes", timeoutMillis = CREDENTIAL_TIMEOUT_MILLIS)
}

/**
Expand All @@ -281,8 +306,7 @@ class CoderCLIManager(
showTextProgress: (String) -> Unit = {},
): String {
val args = mutableListOf(
"--global-config",
coderConfigPath.toString(),
*workspaceAuthArgs(feats).toTypedArray(),
"start",
"--yes",
)
Expand Down Expand Up @@ -348,13 +372,7 @@ class CoderCLIManager(
val baseArgs =
listOfNotNull(
localBinaryPath.toString(),
"--global-config",
coderConfigPath.toString(),
// 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.
"--url",
deploymentURL.toString(),
*workspaceAuthArgs(feats).toTypedArray(),
context.settingsStore.headerCommand?.takeIf { it.isNotBlank() }?.let { "--header-command" },
context.settingsStore.headerCommand?.takeIf { it.isNotBlank() },
"ssh",
Expand Down Expand Up @@ -519,7 +537,7 @@ class CoderCLIManager(
* Throws if it could not be determined.
*/
fun version(): SemVer {
val raw = exec("version", "--output", "json")
val raw = exec("version", "--output", "json", timeoutMillis = CREDENTIAL_TIMEOUT_MILLIS)
try {
val json = Moshi.Builder().build().adapter(Version::class.java).fromJson(raw)
if (json?.version == null || json.version.isBlank()) {
Expand Down Expand Up @@ -578,34 +596,29 @@ class CoderCLIManager(

private fun exec(
vararg args: String,
env: Map<String, String> = emptyMap(),
timeoutMillis: Long? = null,
showTextProgress: ((String) -> Unit)? = null,
): String {
val processExecutor =
ProcessExecutor()
.command(localBinaryPath.toString(), *args)
.environment("CODER_HEADER_COMMAND", context.settingsStore.headerCommand)
.exitValues(0)

showTextProgress?.let { reportProgress ->
processExecutor.redirectOutput(reportProgress::invoke)
val command = listOf(localBinaryPath.toString(), *args)
val processEnv = buildMap {
context.settingsStore.headerCommand?.let { put("CODER_HEADER_COMMAND", it) }
putAll(env)
}

val stdout =
processExecutor
.readOutput(true)
.execute()
.outputUTF8()
val redactedArgs = listOf(*args).joinToString(" ").replace(tokenRegex, "--token <redacted>")
context.logger.info("`$localBinaryPath $redactedArgs`: $stdout")
val stdout = runProcess(
command, environment = processEnv, timeoutMillis = timeoutMillis, onOutputLine = showTextProgress,
).stdout
val sanitizedArgs = listOf(*args).joinToString(" ").sanitizeSecrets()
val sanitizedStdout = stdout.sanitizeSecrets(env[CODER_SESSION_TOKEN_ENV_VAR])
context.logger.info("`$localBinaryPath $sanitizedArgs`: $sanitizedStdout")
return stdout
}

/** Generates a support bundle for the workspace and optional agent, saving it to [outputFile]. */
internal suspend fun supportBundle(address: WorkspaceAddress, outputFile: Path) {
val command = listOfNotNull(
localBinaryPath.toString(),
"--global-config", coderConfigPath.toString(),
"--url", deploymentURL.toString(),
*workspaceAuthArgs(features).toTypedArray(),
"support", "bundle", "--yes", "--output-file", outputFile.toAbsolutePath().toString(),
"--", address.ownerAndWsName, address.agentName,
)
Expand Down Expand Up @@ -646,6 +659,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 @@ -661,6 +675,22 @@ class CoderCLIManager(
companion object {
private data class ManagedBlock(val start: MatchResult, val end: MatchResult)

private val tokenRegex = "--token [^ ]+".toRegex()
internal const val CREDENTIAL_TIMEOUT_MILLIS = 60_000L
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 globalConfigArgs(): List<String> = listOf("--global-config", coderConfigPath.toString())

private fun workspaceAuthArgs(feats: Features): List<String> = buildList {
if (!shouldUseKeyringAuth(feats)) addAll(globalConfigArgs())
// Override inherited CODER_URL and CODER_USE_KEYRING for every authenticated command.
addAll(listOf("--url", deploymentURL.toString()))
if (feats.keyringAuth) add("--use-keyring=${shouldUseKeyringAuth(feats)}")
}

private fun shouldUseKeyringAuth(feats: Features): Boolean =
useKeyring && feats.keyringAuth && supportsKeyringStorage(currentOs)

}
Loading
Loading