Skip to content

impl: remember the ssh connection state #125

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 12, 2025
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@

## Unreleased

### Changed

- the plugin will now remember the SSH connection state for each workspace, and it will try to automatically
establish it after an expired token was refreshed.

### Fixed

- `Stop` action is now available for running workspaces that have an out of date template.
- outdated and stopped workspaces are now updated and started when handling URI
- show errors when the Toolbox is visible again after being minimized.

## 0.3.0 - 2025-06-10

Expand Down
36 changes: 21 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,12 @@ If `ide_product_code` and `ide_build_number` is missing, Toolbox will only open
page. Coder Toolbox will attempt to start the workspace if it’s not already running; however, for the most reliable
experience, it’s recommended to ensure the workspace is running prior to initiating the connection.

> ⚠️ Note: `folder` should point to a remote IDEA project that has already been opened and appears in the `Projects` tab.
> ⚠️ Note: `folder` should point to a remote IDEA project that has already been opened and appears in the `Projects`
> tab.
> If the path refers to a project that doesn't exist, the remote IDE won’t start or load it.
> Until [TBX-14952](https://youtrack.jetbrains.com/issue/TBX-14952/) is fixed, it's best to either use a path to a previously opened project or leave it empty.
> Until [TBX-14952](https://youtrack.jetbrains.com/issue/TBX-14952/) is fixed, it's best to either use a path to a
> previously opened project or leave it empty.
## Configuring and Testing workspace polling with HTTP & SOCKS5 Proxy

Expand Down Expand Up @@ -144,11 +146,11 @@ mitmproxy can do HTTP and SOCKS5 proxying. To configure one or the other:

## Debugging and Reporting issues

Enabling debug logging is essential for diagnosing issues with the Toolbox plugin, especially when SSH
connections to the remote environment fail — it provides detailed output that includes SSH negotiation
Enabling debug logging is essential for diagnosing issues with the Toolbox plugin, especially when SSH
connections to the remote environment fail — it provides detailed output that includes SSH negotiation
and command execution, which is not visible at the default log level.

If you encounter a problem with Coder's JetBrains Toolbox plugin, follow the steps below to gather more
If you encounter a problem with Coder's JetBrains Toolbox plugin, follow the steps below to gather more
information and help us diagnose and resolve it quickly.

### Enable Debug Logging
Expand All @@ -164,46 +166,48 @@ Steps to enable debug logging:

3. In the screen that appears, select _DEBUG_ for the `Log level:` section.

4. Hit the back button at the top.
4. Hit the back button at the top.

There is no need to restart Toolbox, as it will begin logging at the __DEBUG__ level right away.

> ⚠️ **Attention:** Toolbox does not persist log level configuration between restarts.
#### Viewing the Logs

Once enabled, debug logs will be written to the Toolbox log files. You can access logs directly
Once enabled, debug logs will be written to the Toolbox log files. You can access logs directly
via Toolbox App Menu > About > Show log files.

Alternatively, you can generate a ZIP file using the Workspace action menu, available either on the main
Alternatively, you can generate a ZIP file using the Workspace action menu, available either on the main
Workspaces page in Coder or within the individual workspace view, under the option labeled _Collect logs_.

## Coder Settings

The Coder Settings allows users to control CLI download behavior, SSH configuration, TLS parameters, and data
storage paths. The options can be configured from the plugin's main Workspaces page > deployment action menu > Settings.

### CLI related settings
### CLI related settings

```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.

```Binary directory``` specifies the directory where CLI binaries are stored. If omitted, it defaults to the data directory.
```Binary directory``` specifies the directory where CLI binaries are stored. If omitted, it defaults to the data
directory.

```Enable binary directory fallback``` if enabled, falls back to the data directory when the specified binary
directory is not writable.

```Data directory``` directory where plugin-specific data such as session tokens and binaries are stored if not
```Data directory``` directory where plugin-specific data such as session tokens and binaries are stored if not
overridden by the binary directory setting.

```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.

### TLS settings

The following options control the secure communication behavior of the plugin with Coder deployment and its available API.
The following options control the secure communication behavior of the plugin with Coder deployment and its available
API.

```TLS cert path``` path to a client certificate file for TLS authentication with Coder deployment.
The certificate should be in X.509 PEM format.
Expand All @@ -215,7 +219,7 @@ The certificate should be in X.509 PEM format.
certs returned by the Coder deployment. The file should be in X.509 PEM format. This option can also be used to verify
proxy certificates.

```TLS alternate hostname``` overrides the hostname used in TLS verification. This is useful when the hostname
```TLS alternate hostname``` overrides the hostname used in TLS verification. This is useful when the hostname
used to connect to the Coder deployment does not match the hostname in the TLS certificate.

### SSH settings
Expand All @@ -232,11 +236,13 @@ rules for matching multiple workspaces.

```SSH network metrics directory``` directory where network information used by the SSH proxy is stored.

```Extra SSH options``` additional options appended to the SSH configuration. Can be used to customize the behavior of SSH connections.
```Extra SSH options``` additional options appended to the SSH configuration. Can be used to customize the behavior of
SSH connections.

### Saving Changes

Changes made in the settings page are saved by clicking the Save button. Some changes, like toggling SSH wildcard support,
Changes made in the settings page are saved by clicking the Save button. Some changes, like toggling SSH wildcard
support,
may trigger regeneration of SSH configurations.

### Security considerations
Expand Down
26 changes: 19 additions & 7 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -234,28 +234,38 @@ class CoderRemoteEnvironment(
* The contents are provided by the SSH view provided by Toolbox, all we
* have to do is provide it a host name.
*/
override suspend
fun getContentsView(): EnvironmentContentsView = EnvironmentView(
override suspend fun getContentsView(): EnvironmentContentsView = EnvironmentView(
client.url,
cli,
workspace,
agent
)

/**
* Does nothing. In theory, we could do something like start the workspace
* when you click into the workspace, but you would still need to press
* "connect" anyway before the content is populated so there does not seem
* to be much value.
* Automatically launches the SSH connection if the workspace is visible, is ready and there is no
* connection already established.
*/
override fun setVisible(visibilityState: EnvironmentVisibilityState) {
if (wsRawStatus.ready() && visibilityState.contentsVisible == true && isConnected.value == false) {
if (visibilityState.contentsVisible) {
startSshConnection()
}
}

/**
* Launches the SSH connection if the workspace is ready and there is no connection already established.
*
* Returns true if the SSH connection was scheduled to start, false otherwise.
*/
fun startSshConnection(): Boolean {
if (wsRawStatus.ready() && !isConnected.value) {
context.cs.launch {
connectionRequest.update {
true
}
}
return true
}
return false
}

override fun getDeleteEnvironmentConfirmationParams(): DeleteEnvironmentConfirmationParams? {
Expand Down Expand Up @@ -298,6 +308,8 @@ class CoderRemoteEnvironment(
}
}

fun isConnected(): Boolean = isConnected.value

/**
* An environment is equal if it has the same ID.
*/
Expand Down
47 changes: 40 additions & 7 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.coder.toolbox
import com.coder.toolbox.browser.browse
import com.coder.toolbox.cli.CoderCLIManager
import com.coder.toolbox.sdk.CoderRestClient
import com.coder.toolbox.sdk.ex.APIResponseException
import com.coder.toolbox.sdk.v2.models.WorkspaceStatus
import com.coder.toolbox.util.CoderProtocolHandler
import com.coder.toolbox.util.DialogUi
Expand All @@ -19,7 +20,6 @@ import com.jetbrains.toolbox.api.core.util.LoadableState
import com.jetbrains.toolbox.api.localization.LocalizableString
import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState
import com.jetbrains.toolbox.api.remoteDev.RemoteProvider
import com.jetbrains.toolbox.api.remoteDev.RemoteProviderEnvironment
import com.jetbrains.toolbox.api.ui.actions.ActionDelimiter
import com.jetbrains.toolbox.api.ui.actions.ActionDescription
import com.jetbrains.toolbox.api.ui.components.UiPage
Expand Down Expand Up @@ -65,10 +65,18 @@ class CoderRemoteProvider(
private val isInitialized: MutableStateFlow<Boolean> = MutableStateFlow(false)
private var coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(context.deploymentUrl.toString()))
private val linkHandler = CoderProtocolHandler(context, dialogUi, isInitialized)
override val environments: MutableStateFlow<LoadableState<List<RemoteProviderEnvironment>>> = MutableStateFlow(

override val environments: MutableStateFlow<LoadableState<List<CoderRemoteEnvironment>>> = MutableStateFlow(
LoadableState.Loading
)

private val visibilityState = MutableStateFlow(
ProviderVisibilityState(
applicationVisible = false,
providerVisible = false
)
)

/**
* With the provided client, start polling for workspaces. Every time a new
* workspace is added, reconfigure SSH using the provided cli (including the
Expand Down Expand Up @@ -118,7 +126,7 @@ class CoderRemoteProvider(
environments.update {
LoadableState.Value(resolvedEnvironments.toList())
}
if (isInitialized.value == false) {
if (!isInitialized.value) {
context.logger.info("Environments for ${client.url} are now initialized")
isInitialized.update {
true
Expand All @@ -128,6 +136,21 @@ class CoderRemoteProvider(
clear()
addAll(resolvedEnvironments.sortedBy { it.id })
}

if (WorkspaceConnectionManager.shouldEstablishWorkspaceConnections) {
WorkspaceConnectionManager.allConnected().forEach { wsId ->
val env = lastEnvironments.firstOrNull() { it.id == wsId }
if (env != null && !env.isConnected()) {
context.logger.info("Establishing lost SSH connection for workspace with id $wsId")
if (!env.startSshConnection()) {
context.logger.info("Can't establish lost SSH connection for workspace with id $wsId")
}
}
}
WorkspaceConnectionManager.reset()
}

WorkspaceConnectionManager.collectStatuses(lastEnvironments)
} catch (_: CancellationException) {
context.logger.debug("${client.url} polling loop canceled")
break
Expand All @@ -138,7 +161,12 @@ class CoderRemoteProvider(
client.setupSession()
} else {
context.logger.error(ex, "workspace polling error encountered, trying to auto-login")
if (ex is APIResponseException && ex.isTokenExpired) {
WorkspaceConnectionManager.shouldEstablishWorkspaceConnections = true
}
close()
// force auto-login
firstRun = true
goToEnvironmentsPage()
break
}
Expand Down Expand Up @@ -168,6 +196,7 @@ class CoderRemoteProvider(
// Keep the URL and token to make it easy to log back in, but set
// rememberMe to false so we do not try to automatically log in.
context.secrets.rememberMe = false
WorkspaceConnectionManager.reset()
close()
}

Expand Down Expand Up @@ -261,7 +290,11 @@ class CoderRemoteProvider(
* a place to put a timer ("last updated 10 seconds ago" for example)
* and a manual refresh button.
*/
override fun setVisible(visibilityState: ProviderVisibilityState) {}
override fun setVisible(visibility: ProviderVisibilityState) {
visibilityState.update {
visibility
}
}

/**
* Handle incoming links (like from the dashboard).
Expand Down Expand Up @@ -320,7 +353,7 @@ class CoderRemoteProvider(
if (autologin && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !settings.requireTokenAuth)) {
try {
AuthWizardState.goToStep(WizardStep.LOGIN)
return AuthWizardPage(context, settingsPage, true, ::onConnect)
return AuthWizardPage(context, settingsPage, visibilityState, true, ::onConnect)
} catch (ex: Exception) {
errorBuffer.add(ex)
}
Expand All @@ -330,7 +363,7 @@ class CoderRemoteProvider(
firstRun = false

// Login flow.
val authWizard = AuthWizardPage(context, settingsPage, false, ::onConnect)
val authWizard = AuthWizardPage(context, settingsPage, visibilityState, onConnect = ::onConnect)
// We might have navigated here due to a polling error.
errorBuffer.forEach {
authWizard.notify("Error encountered", it)
Expand Down Expand Up @@ -358,7 +391,7 @@ class CoderRemoteProvider(
context.refreshMainPage()
}

private fun MutableStateFlow<LoadableState<List<RemoteProviderEnvironment>>>.showLoadingMessage() {
private fun MutableStateFlow<LoadableState<List<CoderRemoteEnvironment>>>.showLoadingMessage() {
this.update {
LoadableState.Loading
}
Expand Down
22 changes: 22 additions & 0 deletions src/main/kotlin/com/coder/toolbox/WorkspaceConnectionManager.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.coder.toolbox

object WorkspaceConnectionManager {
private val workspaceConnectionState = mutableMapOf<String, Boolean>()

var shouldEstablishWorkspaceConnections = false

fun allConnected(): Set<String> = workspaceConnectionState.filter { it.value }.map { it.key }.toSet()

fun collectStatuses(workspaces: Set<CoderRemoteEnvironment>) {
workspaces.forEach { register(it.id, it.isConnected()) }
}

private fun register(wsId: String, isConnected: Boolean) {
workspaceConnectionState[wsId] = isConnected
}

fun reset() {
workspaceConnectionState.clear()
shouldEstablishWorkspaceConnections = false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import java.net.URL
class APIResponseException(action: String, url: URL, code: Int, errorResponse: ApiErrorResponse?) :
IOException(formatToPretty(action, url, code, errorResponse)) {


val reason = errorResponse?.detail
val isUnauthorized = HttpURLConnection.HTTP_UNAUTHORIZED == code
val isTokenExpired = isUnauthorized && reason?.contains("API key expired") == true

companion object {
private fun formatToPretty(
Expand Down
Loading
Loading