Skip to content
Merged
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
11 changes: 9 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@

### Added

- support for using a locally provided CLI binary when downloads are disabled
- `Binary destination` can now point directly to an executable, used as-is; otherwise it is treated as a base directory
as before

### Removed

- support for enabling or disabling the fallback to data dir. CLI resolution will always fall back on data dir if binary
destination is not configured.

## 0.8.6 - 2026-03-05

Expand All @@ -17,7 +23,8 @@
### Added

- support for configuring the SSH connection timeout, defaults to 10 seconds
- enhanced IDE resolution by supporting latest EAP, latest release, latest installed labels with clear fallback behavior in URI handlers
- enhanced IDE resolution by supporting latest EAP, latest release, latest installed labels with clear fallback behavior
in URI handlers

### Fixed

Expand Down
40 changes: 19 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,19 +447,16 @@ storage paths. The options can be configured from the plugin's main Workspaces p
- `Binary source` specifies the source URL or relative path from which the Coder CLI should be downloaded.
If a relative path is provided, it is resolved against the deployment domain.

- `Enable downloads` allows automatic downloading of the CLI if the current version is missing or outdated.
Defaults to enabled.
- `Enable downloads` allows automatic downloading of the CLI if the current version is missing or outdated. Enabled by
default.

- `Binary destination` specifies where the CLI binary is placed. This can be a path to an existing
executable (used 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.

- `Enable binary directory fallback` when enabled, if the binary destination is not writable the
plugin falls back to the data directory instead of failing. Only takes effect when downloads are
enabled and the binary destination differs from the data directory. Defaults to disabled.

- `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`).
are stored. Each deployment gets a host-specific subdirectory (e.g. `coder.example.com`). Supports `~` and `$HOME`
expansion.

- `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.
Expand All @@ -477,20 +474,21 @@ storage paths. The options can be configured from the plugin's main Workspaces p
#### How CLI resolution works

When connecting to a deployment the plugin ensures a compatible CLI binary is available.
The settings above interact as follows:

1. If a CLI already exists at the binary destination and its version matches the deployment, it is
used immediately.
2. If **downloads are enabled**, the plugin downloads the matching version to the binary destination.
- If the download fails with a permission error and **binary directory fallback** is enabled (and
the binary destination is not already in the data directory), the plugin checks whether the data
directory already has a matching CLI. If so it is used; otherwise the plugin downloads to the
data directory instead.
- Any other download error is reported to the user.
3. If **downloads are disabled**, the plugin checks the data directory for a CLI whose version
matches the deployment. If no exact match is found anywhere, whichever CLI is available is
returned — preferring the binary destination unless it is missing, in which case the data
directory CLI is used regardless of its version. If no CLI exists at all, an error is raised.
The binary location is resolved as follows:

- If **binary destination** points to an existing executable file, it is used as-is.
- If **binary destination** is set but is not an executable file, it is treated as a base
directory and the CLI is placed under a host-specific subdirectory (e.g.
`<binary destination>/coder.example.com/<default-cli-name>`).
- If **binary destination** is not set, the data directory is used instead.

Once the binary location is resolved:

1. If a CLI already exists there and its version matches the deployment, it is used immediately.
2. Otherwise, if **downloads are enabled**, the plugin downloads the matching version to that location.
Any download error is reported to the user.
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.

### TLS settings

Expand Down
67 changes: 14 additions & 53 deletions src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -48,24 +48,17 @@ internal data class Version(


/**
* Do as much as possible to get a valid, up-to-date CLI.
* Best effort to get an up-to-date CLI.
*
* 1. Create a CLI manager for the deployment URL.
* 2. If the CLI version matches the build version, return it immediately.
* 3. If downloads are enabled, attempt to download the CLI.
* 3. Otherwise, if downloads are enabled, attempt to download the CLI.
* a. On success, return the CLI.
* b. On [java.nio.file.AccessDeniedException]: rethrow if the binary
* path parent equals the data directory or if binary directory
* fallback is disabled. Otherwise, if the fallback data directory
* CLI already matches the build version return it; if not, download
* to the data directory and return the fallback CLI.
* c. Any other exception propagates to the caller.
* b. Any exception propagates to the user.
* 4. If downloads are disabled:
* a. If the data directory CLI version matches, return it.
* b. If neither the configured binary nor the data directory CLI can
* report a version, throw [IllegalStateException].
* c. Prefer the configured binary; fall back to the data directory CLI
* only when the configured binary is missing or unexecutable.
* a. [IllegalStateException] is raised if the CLI does not exist (look into binary destination if it was configured,
* fallback to data dir otherwise)
* b. Otherwise, warn the user and return the mismatched version.
*/
suspend fun ensureCLI(
context: CoderToolboxContext,
Expand Down Expand Up @@ -95,44 +88,15 @@ suspend fun ensureCLI(
// If downloads are enabled download the new version.
if (settings.enableDownloads) {
reportProgress("Downloading Coder CLI...")
try {
cli.download(buildVersion, showTextProgress)
return cli
} catch (e: java.nio.file.AccessDeniedException) {
// Might be able to fall back to the data directory.
val binPath = settings.binPath(deploymentURL)
val dataDir = settings.dataDir(deploymentURL)
if (binPath.parent == dataDir || !settings.enableBinaryDirectoryFallback) {
throw e
}
// fall back to the data directory.
val fallbackCLI = CoderCLIManager(context, deploymentURL, true)
val fallbackMatches = fallbackCLI.matchesVersion(buildVersion)
if (fallbackMatches == true) {
reportProgress("Local CLI version from data directory matches server version: $buildVersion")
return fallbackCLI
}

reportProgress("Downloading Coder CLI to the data directory...")
fallbackCLI.download(buildVersion, showTextProgress)
return fallbackCLI
}
}

// Try falling back to the data directory.
val dataCLI = CoderCLIManager(context, deploymentURL, true)
val dataCLIMatches = dataCLI.matchesVersion(buildVersion)
if (dataCLIMatches == true) {
reportProgress("Local CLI version from data directory matches server version: $buildVersion")
return dataCLI
cli.download(buildVersion, showTextProgress)
return cli
}

// Prefer the binary directory unless the data directory has a
// working binary and the binary directory does not.
if (cliMatches == null && dataCLIMatches == null && !settings.enableDownloads) {
if (cliMatches == null) {
throw IllegalStateException("Can't resolve Coder CLI and downloads are disabled")
}
return if (cliMatches == null && dataCLIMatches != null) dataCLI else cli
reportProgress("Downloads are disabled, and a cached CLI is used which does not match the server version $buildVersion and could cause compatibility issues")
return cli
}

/**
Expand All @@ -151,16 +115,13 @@ data class Features(
class CoderCLIManager(
private val context: CoderToolboxContext,
// The URL of the deployment this CLI is for.
private val deploymentURL: URL,
// If the binary directory is not writable, this can be used to force the
// manager to download to the data directory instead.
private val forceDownloadToData: Boolean = false,
private val deploymentURL: URL
) {
private val downloader = createDownloadService()
private val gpgVerifier = GPGVerifier(context)

val remoteBinaryURL: URL = context.settingsStore.binSource(deploymentURL)
val localBinaryPath: Path = context.settingsStore.binPath(deploymentURL, forceDownloadToData)
val localBinaryPath: Path = context.settingsStore.binPath(deploymentURL)
val coderConfigPath: Path = context.settingsStore.dataDir(deploymentURL).resolve("config")

private fun createDownloadService(): CoderDownloadService {
Expand All @@ -180,7 +141,7 @@ class CoderCLIManager(
.build()

val service = retrofit.create(CoderDownloadApi::class.java)
return CoderDownloadService(context, service, deploymentURL, forceDownloadToData)
return CoderDownloadService(context, service, deploymentURL)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,17 @@ private val SUPPORTED_BIN_MIME_TYPES = listOf(
"application/x-ms-dos-executable",
"application/vnd.microsoft.portable-executable"
)

/**
* Handles the download steps of Coder CLI
*/
class CoderDownloadService(
private val context: CoderToolboxContext,
private val downloadApi: CoderDownloadApi,
private val deploymentUrl: URL,
forceDownloadToData: Boolean,
private val deploymentUrl: URL
) {
private val remoteBinaryURL: URL = context.settingsStore.binSource(deploymentUrl)
private val cliFinalDst: Path = context.settingsStore.binPath(deploymentUrl, forceDownloadToData)
private val cliFinalDst: Path = context.settingsStore.binPath(deploymentUrl)
private val cliTempDst: Path = cliFinalDst.resolveSibling("${cliFinalDst.name}.tmp")

suspend fun downloadCli(buildVersion: String, showTextProgress: (String) -> Unit): DownloadResult {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,6 @@ interface ReadOnlyCoderSettings {
*/
val enableDownloads: Boolean

/**
* Whether to allow the plugin to fall back to the data directory when the
* CLI directory is not writable.
*/
val enableBinaryDirectoryFallback: Boolean

/**
* An external command that outputs additional HTTP headers added to all
* requests. The command must output each header as `key=value` on its own
Expand Down Expand Up @@ -179,9 +173,9 @@ interface ReadOnlyCoderSettings {
fun binSource(url: URL): URL

/**
* To where the specified deployment should download the binary.
* Returns a path to where the specified deployment should place the CLI binary.
*/
fun binPath(url: URL, forceDownloadToData: Boolean = false): Path
fun binPath(url: URL): Path

/**
* Return the URL and token from the config, if they exist.
Expand Down
35 changes: 9 additions & 26 deletions src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,6 @@ class CoderSettingsStore(
override val globalDataDirectory: String get() = getDefaultGlobalDataDir().normalize().toString()
override val globalConfigDir: String get() = getDefaultGlobalConfigDir().normalize().toString()
override val enableDownloads: Boolean get() = store[ENABLE_DOWNLOADS]?.toBooleanStrictOrNull() ?: true
override val enableBinaryDirectoryFallback: Boolean
get() = store[ENABLE_BINARY_DIR_FALLBACK]?.toBooleanStrictOrNull() ?: false
override val headerCommand: String? get() = store[HEADER_COMMAND]
override val tls: ReadOnlyTLSSettings
get() = TLSSettings(
Expand Down Expand Up @@ -127,30 +125,19 @@ class CoderSettingsStore(
*
* Resolution logic:
* 1. If [binaryDestination] is null/blank, return the deployment's data
* directory with the default CLI binary name. [forceDownloadToData]
* is ignored because both paths resolve to the same location.
* 2. If [forceDownloadToData] is true, return a host-specific subdirectory
* under the deployment's data directory with the default CLI binary name.
* 3. If the expanded (~ and $HOME) [binaryDestination] is an existing executable file,
* directory with the default CLI binary name.
* 2. If the expanded (~ and $HOME) [binaryDestination] is an existing executable file,
* return it as-is.
* 4. Otherwise, treat [binaryDestination] as a base directory and return a
* 3. Otherwise, treat [binaryDestination] as a base directory and return a
* host-specific subdirectory with the default CLI binary name.
*/
override fun binPath(
url: URL,
forceDownloadToData: Boolean,
): Path {
if (binaryDestination.isNullOrBlank()) {
return dataDir(url).resolve(defaultCliBinaryNameByOsAndArch).toAbsolutePath()
}

val dest = Path.of(expand(binaryDestination!!))
val isExecutable = Files.isRegularFile(dest) && Files.isExecutable(dest)
override fun binPath(url: URL): Path {
val raw = binaryDestination?.takeIf { it.isNotBlank() } ?: return dataDir(url).resolve(
defaultCliBinaryNameByOsAndArch
).toAbsolutePath()

if (forceDownloadToData) {
return dataDir(url).resolve(defaultCliBinaryNameByOsAndArch).toAbsolutePath()
}
if (isExecutable) {
val dest = Path.of(expand(raw))
if (Files.isRegularFile(dest) && Files.isExecutable(dest)) {
return dest.toAbsolutePath()
}
return withHost(dest, url).resolve(defaultCliBinaryNameByOsAndArch).toAbsolutePath()
Expand Down Expand Up @@ -222,10 +209,6 @@ class CoderSettingsStore(
store[HTTP_CLIENT_LOG_LEVEL] = level.toString()
}

fun updateBinaryDirectoryFallback(shouldEnableBinDirFallback: Boolean) {
store[ENABLE_BINARY_DIR_FALLBACK] = shouldEnableBinDirFallback.toString()
}

fun updateHeaderCommand(cmd: String) {
store[HEADER_COMMAND] = cmd
}
Expand Down
2 changes: 0 additions & 2 deletions src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ internal const val DATA_DIRECTORY = "dataDirectory"

internal const val ENABLE_DOWNLOADS = "enableDownloads"

internal const val ENABLE_BINARY_DIR_FALLBACK = "enableBinaryDirectoryFallback"

internal const val HEADER_COMMAND = "headerCommand"

internal const val TLS_CERT_PATH = "tlsCertPath"
Expand Down
32 changes: 15 additions & 17 deletions src/main/kotlin/com/coder/toolbox/util/Error.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,29 @@ 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.SocketTimeoutException
import java.net.URL
import java.net.UnknownHostException
import javax.net.ssl.SSLHandshakeException

fun humanizeConnectionError(deploymentURL: URL, requireTokenAuth: Boolean, e: Exception): String {
val reason = e.message ?: "No reason was provided."
return when (e) {
is java.nio.file.AccessDeniedException -> "Access denied to ${e.file}."
is UnknownHostException -> "Unknown host ${e.message ?: deploymentURL.host}."
is InvalidExitValueException -> "CLI exited unexpectedly with ${e.exitValue}."
private fun accessDenied(file: String) = "Access denied to $file"
private fun fileSystemFailed(file: String) = "A file system operation failed when trying to access $file"

fun Throwable.prettify(): String {
val reason = this.message ?: "No reason was provided"
return when (this) {
is AccessDeniedException -> accessDenied(this.file.toString())
is java.nio.file.AccessDeniedException -> accessDenied(this.file)
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 APIResponseException -> {
if (e.isUnauthorized) {
if (requireTokenAuth) {
"Token was rejected by $deploymentURL; has your token expired?"
} else {
"Authorization failed to $deploymentURL."
}
if (this.isUnauthorized) {
"Authorization failed"
} else {
reason
}
}
is SocketTimeoutException -> "Unable to connect to $deploymentURL; is it up?"

is ResponseException, is ConnectException -> "Failed to download Coder CLI: $reason"
is SSLHandshakeException -> "Connection to $deploymentURL failed: $reason. See the <a href='https://coder.com/docs/v2/latest/ides/gateway#configuring-the-gateway-plugin-to-use-internal-certificates'>documentation for TLS certificates</a> for information on how to make your system trust certificates coming from your deployment."
else -> reason
}
}
10 changes: 0 additions & 10 deletions src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,6 @@ class CoderSettingsPage(
)
)

private val enableBinaryDirectoryFallbackField = CheckboxField(
settings.enableBinaryDirectoryFallback,
context.i18n.ptrl("Enable binary directory fallback")
)
private val headerCommandField = TextField(
context.i18n.ptrl("Header command"),
settings.headerCommand ?: "",
Expand Down Expand Up @@ -132,7 +128,6 @@ class CoderSettingsPage(
enableDownloadsField,
useAppNameField,
binaryDestinationField,
enableBinaryDirectoryFallbackField,
disableSignatureVerificationField,
signatureFallbackStrategyField,
httpLoggingField,
Expand Down Expand Up @@ -163,7 +158,6 @@ class CoderSettingsPage(
updateDisableSignatureVerification(disableSignatureVerificationField.checkedState.value)
updateSignatureFallbackStrategy(signatureFallbackStrategyField.checkedState.value)
updateHttpClientLogLevel(httpLoggingField.selectedValueState.value)
updateBinaryDirectoryFallback(enableBinaryDirectoryFallbackField.checkedState.value)
updateHeaderCommand(headerCommandField.contentState.value)
updateCertPath(tlsCertPathField.contentState.value)
updateKeyPath(tlsKeyPathField.contentState.value)
Expand Down Expand Up @@ -216,10 +210,6 @@ class CoderSettingsPage(
settings.fallbackOnCoderForSignatures.isAllowed()
}

enableBinaryDirectoryFallbackField.checkedState.update {
settings.enableBinaryDirectoryFallback
}

headerCommandField.contentState.update {
settings.headerCommand ?: ""
}
Expand Down
Loading
Loading