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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

### Added

- support for configuring the SSH config path, letting Toolbox manage a separate SSH config file instead of always
writing to `~/.ssh/config`

## 0.9.3 - 2026-08-11

### Changed
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,9 @@ The following options control the SSH behavior of the Coder CLI.
- `Enable SSH wildcard config` enables or disables wildcard entries in the SSH configuration, which allow generic rules
for matching multiple workspaces.

- `SSH config path` is the SSH configuration file managed by the plugin and used for Toolbox connections. It defaults to
`~/.ssh/config`; choose a separate writable file to keep the primary SSH configuration read-only.

- `SSH connnection timeout (seconds)` controls how long the SSH client will wait while trying to establish a TCP
connection to the remote host before giving up. Defaults to 0 seconds which means it uses the system’s TCP timeout
settings instead.
Expand Down
35 changes: 27 additions & 8 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import kotlinx.coroutines.selects.onTimeout
import kotlinx.coroutines.selects.select
import java.net.URI
import java.net.URL
import java.nio.file.Path
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeSource
Expand All @@ -74,7 +75,7 @@ class CoderRemoteProvider(
internal val lastEnvironments = mutableListOf<CoderRemoteEnvironment>()
private var isSshConfigurationWarningShown = false

private val sshConfigTrigger = Channel<Boolean>(Channel.CONFLATED)
private val sshConfigTrigger = Channel<String?>(Channel.CONFLATED)
private val workspaceRefreshTrigger = Channel<Boolean>(Channel.CONFLATED)
private val providerVisibleTrigger = Channel<Boolean>(Channel.CONFLATED)
private val dialogUi = DialogUi(context)
Expand Down Expand Up @@ -185,11 +186,9 @@ class CoderRemoteProvider(
onTimeout(POLL_INTERVAL) {
context.logger.debug("workspace poller waked up by the $POLL_INTERVAL timeout")
}
sshConfigTrigger.onReceive { shouldTrigger ->
if (shouldTrigger) {
context.logger.debug("workspace poller waked up because it should reconfigure the ssh configurations")
configureSsh(cli, lastEnvironments)
}
sshConfigTrigger.onReceive { staleSshConfigPath ->
context.logger.debug("workspace poller waked up because it should reconfigure the ssh configurations")
configureSsh(cli, lastEnvironments, staleSshConfigPath)
}
workspaceRefreshTrigger.onReceive { shouldTrigger ->
if (shouldTrigger) {
Expand All @@ -211,10 +210,30 @@ class CoderRemoteProvider(
* configuration is necessary to connect, but a read-only or malformed SSH
* config must not prevent Toolbox from showing the workspaces it resolved.
*/
private fun configureSsh(cli: CoderCLIManager, resolvedEnvironments: List<CoderRemoteEnvironment>) {
private fun configureSsh(
cli: CoderCLIManager,
resolvedEnvironments: List<CoderRemoteEnvironment>,
staleSshConfigPath: String? = null,
) {
try {
cli.configSsh(resolvedEnvironments.mapNotNull { it.toWorkspaceAddressOrNull() }.toSet())
cli.configSsh(
resolvedEnvironments.mapNotNull { it.toWorkspaceAddressOrNull() }.toSet(),
sshConfigPath = context.settingsStore.sshConfigPath,
)
isSshConfigurationWarningShown = false

// Only attempt cleanup if the previous file actually exists; configSsh would
// otherwise create a stray file there just to hold an empty managed block.
if (staleSshConfigPath != null && Path.of(staleSshConfigPath).toFile().exists()) {
runCatching {
cli.configSsh(emptySet(), sshConfigPath = staleSshConfigPath)
}.onFailure { ex ->
context.logger.warn(
ex,
"Failed to remove the managed SSH config block from the previous location: $staleSshConfigPath"
)
}
}
} catch (ex: Exception) {
// Identifier failures are security boundary violations, not recoverable file-system errors.
// Let the outer poll handler reject the response before it publishes the environments.
Expand Down
17 changes: 9 additions & 8 deletions src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -298,17 +298,18 @@ class CoderCLIManager(
internal fun configSsh(
workspaceAddresses: Set<WorkspaceAddress>,
feats: Features = features,
sshConfigPath: String = context.settingsStore.sshConfigPath,
) {
context.logger.info("Configuring SSH config at ${context.settingsStore.sshConfigPath}")
writeSSHConfig(modifySSHConfig(readSSHConfig(), workspaceAddresses, feats))
context.logger.info("Configuring SSH config at $sshConfigPath")
writeSSHConfig(modifySSHConfig(readSSHConfig(sshConfigPath), workspaceAddresses, feats), sshConfigPath)
context.logger.info("Finished configuring SSH config")
}

/**
* Return the contents of the SSH config or null if it does not exist.
*/
private fun readSSHConfig(): String? = try {
Path.of(context.settingsStore.sshConfigPath).toFile().readText()
private fun readSSHConfig(sshConfigPath: String): String? = try {
Path.of(sshConfigPath).toFile().readText()
} catch (_: FileNotFoundException) {
null
}
Expand Down Expand Up @@ -484,11 +485,11 @@ class CoderCLIManager(
/**
* Write the provided SSH config or do nothing if null.
*/
private fun writeSSHConfig(contents: String?) {
private fun writeSSHConfig(contents: String?, sshConfigPath: String) {
if (contents != null) {
if (context.settingsStore.sshConfigPath.isNotBlank()) {
val sshConfPath = Path.of(context.settingsStore.sshConfigPath)
sshConfPath.parent.toFile().mkdirs()
if (sshConfigPath.isNotBlank()) {
val sshConfPath = Path.of(sshConfigPath)
sshConfPath.parent?.toFile()?.mkdirs()
sshConfPath.toFile().writeText(contents)
}
// The Coder cli will *not* create the log directory.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ class CoderSettingsStore(
override val isSshWildcardConfigEnabled: Boolean
get() = store[ENABLE_SSH_WILDCARD_CONFIG]?.toBooleanStrictOrNull() ?: true
override val sshConfigPath: String
get() = store[SSH_CONFIG_PATH].takeUnless { it.isNullOrEmpty() }
get() = store[SSH_CONFIG_PATH]?.takeIf { it.isNotBlank() }?.let { expand(it) }
?: Path.of(System.getProperty("user.home")).resolve(".ssh/config").normalize().toString()
override val sshLogDirectory: String? get() = store[SSH_LOG_DIR]?.takeIf { it.isNotBlank() }?.let { expand(it) }
override val sshConfigOptions: String?
Expand Down Expand Up @@ -242,6 +242,10 @@ class CoderSettingsStore(
store[ENABLE_SSH_WILDCARD_CONFIG] = enable.toString()
}

fun updateSshConfigPath(path: String) {
store[SSH_CONFIG_PATH] = path
}

fun updateSshLogDir(path: String) {
store[SSH_LOG_DIR] = path
}
Expand Down
8 changes: 8 additions & 0 deletions src/main/kotlin/com/coder/toolbox/views/CoderPage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,20 @@ class Action(
closesPage: Boolean = false,
highlightInRed: Boolean = false,
enabled: () -> Boolean = { true },
private val validateBlock: () -> Boolean = { true },
private val actionBlock: suspend () -> Unit,
) : RunnableActionDescription {
override val label: LocalizableString = context.i18n.ptrl(description)
override val shouldClosePage: Boolean = closesPage
override val isEnabled: Boolean = enabled()
override val isDangerous: Boolean = highlightInRed

/**
* Toolbox calls this before [run], and only calls [run] (and closes the
* page, if [shouldClosePage]) when this returns true.
*/
override fun validate(): Boolean = validateBlock()

override fun run() {
context.cs.launch(CoroutineName("$description Action")) {
try {
Expand Down
74 changes: 70 additions & 4 deletions src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,29 @@ import com.coder.toolbox.settings.HttpLoggingVerbosity.BASIC
import com.coder.toolbox.settings.HttpLoggingVerbosity.BODY
import com.coder.toolbox.settings.HttpLoggingVerbosity.HEADERS
import com.coder.toolbox.settings.HttpLoggingVerbosity.NONE
import com.coder.toolbox.util.canCreateDirectory
import com.coder.toolbox.util.expand
import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription
import com.jetbrains.toolbox.api.ui.components.CheckboxField
import com.jetbrains.toolbox.api.ui.components.ComboBoxField
import com.jetbrains.toolbox.api.ui.components.ComboBoxField.LabelledValue
import com.jetbrains.toolbox.api.ui.components.FieldModifier
import com.jetbrains.toolbox.api.ui.components.SectionField
import com.jetbrains.toolbox.api.ui.components.TextField
import com.jetbrains.toolbox.api.ui.components.TextType
import com.jetbrains.toolbox.api.ui.components.UiField
import com.jetbrains.toolbox.api.ui.components.ValidatableField
import com.jetbrains.toolbox.api.ui.components.ValidationResult
import com.jetbrains.toolbox.api.ui.components.validate
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.nio.file.Files
import java.nio.file.Path

/**
* A page for modifying Coder settings.
Expand All @@ -30,7 +38,7 @@ import kotlinx.coroutines.launch
*/
class CoderSettingsPage(
private val context: CoderToolboxContext,
sshConfigTrigger: Channel<Boolean>,
sshConfigTrigger: Channel<String?>,
private val onSettingsClosed: () -> Unit
) :
CoderPage(MutableStateFlow(context.i18n.ptrl("Coder Settings")), false) {
Expand Down Expand Up @@ -108,6 +116,13 @@ class CoderSettingsPage(
TextType.Integer
)

private val sshConfigPathField = TextField(
context.i18n.ptrl("SSH config path"),
settings.sshConfigPath,
TextType.General,
validator = ::validateSshConfigPath
)

private val sshExtraArgs = TextField(
context.i18n.ptrl("Extra SSH options"),
settings.sshConfigOptions ?: "",
Expand All @@ -125,6 +140,47 @@ class CoderSettingsPage(
TextType.General
)

/**
* Toolbox does not validate fields on its own; a page's action is
* responsible for validating and reporting whether everything came back
* clean. `UiPage.runValidations()` only looks at the top-level fields
* list, but ours are nested inside `SectionField`s, so fields have to be
* flattened out of their sections before each is validated.
*/
private fun validateFields(): Boolean = fields.value.allFieldsValid()

private fun List<UiField>.allFieldsValid(): Boolean = all { field ->
when (field) {
is SectionField -> field.contentState.value.allFieldsValid()
is ValidatableField<*> -> {
field.validate()
field.modifiers.value.none { it is FieldModifier.LocalizableError || it is FieldModifier.Error }
}

else -> true
}
}

private fun validateSshConfigPath(rawPath: String): ValidationResult {
val expandedPath = expand(rawPath)
if (expandedPath.isBlank()) {
return ValidationResult.Invalid(context.i18n.ptrl("SSH config path must not be empty"))
}
val configPath = Path.of(expandedPath)
if (Files.exists(configPath)) {
return if (Files.isRegularFile(configPath) && Files.isWritable(configPath)) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(context.i18n.ptrl("SSH config path must point to a writable file"))
}
}
return if (configPath.parent?.canCreateDirectory() == true) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(context.i18n.ptrl("SSH config path's parent directory must be writable"))
}
}

private lateinit var visibilityUpdateJob: Job
override val fields: StateFlow<List<UiField>> = MutableStateFlow(
listOf(
Expand Down Expand Up @@ -166,6 +222,7 @@ class CoderSettingsPage(
false,
listOf(
enableSshWildCardConfig,
sshConfigPathField,
sshConnectionTimeoutField,
sshLogDirField,
networkInfoDirField,
Expand All @@ -177,7 +234,7 @@ class CoderSettingsPage(

override val actionButtons: StateFlow<List<RunnableActionDescription>> = MutableStateFlow(
listOf(
Action(context, "Save", closesPage = true) {
Action(context, "Save", closesPage = true, validateBlock = ::validateFields) {
with(context.settingsStore) {
updateBinarySource(binarySourceField.contentState.value)
updateBinaryDestination(binaryDestinationField.contentState.value)
Expand All @@ -197,16 +254,21 @@ class CoderSettingsPage(

val sshWildcardEnabled = enableSshWildCardConfig.checkedState.value
val sshTimeout = sshConnectionTimeoutField.contentState.value.toInt()
val sshConfigPath = sshConfigPathField.contentState.value
val previousSshConfigPath = settings.sshConfigPath

val sshSettingsChanged = sshWildcardEnabled != settings.isSshWildcardConfigEnabled ||
sshTimeout != settings.sshConnectionTimeoutInSeconds
sshTimeout != settings.sshConnectionTimeoutInSeconds ||
sshConfigPath != previousSshConfigPath

updateEnableSshWildcardConfig(sshWildcardEnabled)
updateSshConnectionTimeoutInSeconds(sshTimeout)
updateSshConfigPath(sshConfigPath)

if (sshSettingsChanged) {
val staleSshConfigPath = previousSshConfigPath.takeIf { it != settings.sshConfigPath }
runCatching {
sshConfigTrigger.send(true)
sshConfigTrigger.send(staleSshConfigPath)
context.logger.info("Settings have been modified, ssh config is going to be regenerated...")
}
}
Expand Down Expand Up @@ -276,6 +338,10 @@ class CoderSettingsPage(
settings.sshConnectionTimeoutInSeconds.toString()
}

sshConfigPathField.contentState.update {
settings.sshConfigPath
}

sshExtraArgs.contentState.update {
settings.sshConfigOptions ?: ""
}
Expand Down
8 changes: 8 additions & 0 deletions src/main/kotlin/com/coder/toolbox/views/EnvironmentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ private class WorkspaceSshConnectionInfo(
*/
override val userName: String? = null

/**
* Use the config managed by this plugin rather than Toolbox's default SSH
* config, which may be a separate read-only user-managed file.
*/
override val sshConfigPath: String = context.settingsStore.sshConfigPath

override val connectionTimeoutMillis: Long
get() = context.settingsStore.sshConnectionTimeoutInSeconds.seconds.inWholeMilliseconds

Expand All @@ -64,6 +70,7 @@ private class WorkspaceSshConnectionInfo(
if (workspace.name != other.workspace.name) return false
if (agent.name != other.agent.name) return false
if (host != other.host) return false
if (sshConfigPath != other.sshConfigPath) return false

return true
}
Expand All @@ -73,6 +80,7 @@ private class WorkspaceSshConnectionInfo(
result = 31 * result + workspace.name.hashCode()
result = 31 * result + agent.name.hashCode()
result = 31 * result + host.hashCode()
result = 31 * result + sshConfigPath.hashCode()
return result
}

Expand Down
4 changes: 2 additions & 2 deletions src/test/kotlin/com/coder/toolbox/CoderRemoteProviderTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ class CoderRemoteProviderTest {
val agent = mockAgent("agent1")
val workspace = mockWorkspace("ws1", WorkspaceStatus.RUNNING, listOf(mockResource(listOf(agent))))
coEvery { mockClient.workspaces(any()) } returns listOf(workspace)
every { mockCli.configSsh(any(), any()) } throws FileNotFoundException("Permission denied")
every { mockCli.configSsh(any(), any(), any()) } throws FileNotFoundException("Permission denied")

// when
val pollJob = remoteProvider.poll(mockClient, mockCli)
Expand Down Expand Up @@ -115,7 +115,7 @@ class CoderRemoteProviderTest {
val agent = mockAgent("agent1")
val workspace = mockWorkspace("ws1", WorkspaceStatus.RUNNING, listOf(mockResource(listOf(agent))))
coEvery { mockClient.workspaces(any()) } returns listOf(workspace)
every { mockCli.configSsh(any(), any()) } throws
every { mockCli.configSsh(any(), any(), any()) } throws
InvalidCoderIdentifierException("The deployment returned an invalid workspace name")

val pollJob = remoteProvider.poll(mockClient, mockCli)
Expand Down
Loading
Loading