Skip to content

Commit 5dfbdca

Browse files
authored
impl: remember the ssh connection state (#125)
And try to automatically establish the connections after an expired token was refreshed (by going again through the login sequence) In addition a fix was provided in order to show errors when TBX is visible after being minimized. Errors encountered while TBX was running but the window was not visible were never displayed by TBX. This fix queues the errors while TBX is minimized, and they will be displayed again only when visible. This implementation is possible due to an observable state object that can provide information about TBX and plugin visibility. Among other things we also display a more human friendly version for the exceptions raised by the http client during (but not only) workspace polling. Attention: users will still have to manually launch a new a remote IDE if it was opened while a session expired. - resolves #121
1 parent 792dba9 commit 5dfbdca

File tree

11 files changed

+159
-51
lines changed

11 files changed

+159
-51
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,16 @@
22

33
## Unreleased
44

5+
### Changed
6+
7+
- the plugin will now remember the SSH connection state for each workspace, and it will try to automatically
8+
establish it after an expired token was refreshed.
9+
510
### Fixed
611

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

1016
## 0.3.0 - 2025-06-10
1117

README.md

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,12 @@ If `ide_product_code` and `ide_build_number` is missing, Toolbox will only open
101101
page. Coder Toolbox will attempt to start the workspace if it’s not already running; however, for the most reliable
102102
experience, it’s recommended to ensure the workspace is running prior to initiating the connection.
103103

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

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

145147
## Debugging and Reporting issues
146148

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

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

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

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

167-
4. Hit the back button at the top.
169+
4. Hit the back button at the top.
168170

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

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

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

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

181183
## Coder Settings
182184

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

186-
### CLI related settings
188+
### CLI related settings
187189

188190
```Binary source``` specifies the source URL or relative path from which the Coder CLI should be downloaded.
189191
If a relative path is provided, it is resolved against the deployment domain.
190192

191193
```Enable downloads``` allows automatic downloading of the CLI if the current version is missing or outdated.
192194

193-
```Binary directory``` specifies the directory where CLI binaries are stored. If omitted, it defaults to the data directory.
195+
```Binary directory``` specifies the directory where CLI binaries are stored. If omitted, it defaults to the data
196+
directory.
194197

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

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

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

204207
### TLS settings
205208

206-
The following options control the secure communication behavior of the plugin with Coder deployment and its available API.
209+
The following options control the secure communication behavior of the plugin with Coder deployment and its available
210+
API.
207211

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

218-
```TLS alternate hostname``` overrides the hostname used in TLS verification. This is useful when the hostname
222+
```TLS alternate hostname``` overrides the hostname used in TLS verification. This is useful when the hostname
219223
used to connect to the Coder deployment does not match the hostname in the TLS certificate.
220224

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

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

235-
```Extra SSH options``` additional options appended to the SSH configuration. Can be used to customize the behavior of SSH connections.
239+
```Extra SSH options``` additional options appended to the SSH configuration. Can be used to customize the behavior of
240+
SSH connections.
236241

237242
### Saving Changes
238243

239-
Changes made in the settings page are saved by clicking the Save button. Some changes, like toggling SSH wildcard support,
244+
Changes made in the settings page are saved by clicking the Save button. Some changes, like toggling SSH wildcard
245+
support,
240246
may trigger regeneration of SSH configurations.
241247

242248
### Security considerations

src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -234,28 +234,38 @@ class CoderRemoteEnvironment(
234234
* The contents are provided by the SSH view provided by Toolbox, all we
235235
* have to do is provide it a host name.
236236
*/
237-
override suspend
238-
fun getContentsView(): EnvironmentContentsView = EnvironmentView(
237+
override suspend fun getContentsView(): EnvironmentContentsView = EnvironmentView(
239238
client.url,
240239
cli,
241240
workspace,
242241
agent
243242
)
244243

245244
/**
246-
* Does nothing. In theory, we could do something like start the workspace
247-
* when you click into the workspace, but you would still need to press
248-
* "connect" anyway before the content is populated so there does not seem
249-
* to be much value.
245+
* Automatically launches the SSH connection if the workspace is visible, is ready and there is no
246+
* connection already established.
250247
*/
251248
override fun setVisible(visibilityState: EnvironmentVisibilityState) {
252-
if (wsRawStatus.ready() && visibilityState.contentsVisible == true && isConnected.value == false) {
249+
if (visibilityState.contentsVisible) {
250+
startSshConnection()
251+
}
252+
}
253+
254+
/**
255+
* Launches the SSH connection if the workspace is ready and there is no connection already established.
256+
*
257+
* Returns true if the SSH connection was scheduled to start, false otherwise.
258+
*/
259+
fun startSshConnection(): Boolean {
260+
if (wsRawStatus.ready() && !isConnected.value) {
253261
context.cs.launch {
254262
connectionRequest.update {
255263
true
256264
}
257265
}
266+
return true
258267
}
268+
return false
259269
}
260270

261271
override fun getDeleteEnvironmentConfirmationParams(): DeleteEnvironmentConfirmationParams? {
@@ -298,6 +308,8 @@ class CoderRemoteEnvironment(
298308
}
299309
}
300310

311+
fun isConnected(): Boolean = isConnected.value
312+
301313
/**
302314
* An environment is equal if it has the same ID.
303315
*/

src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.coder.toolbox
33
import com.coder.toolbox.browser.browse
44
import com.coder.toolbox.cli.CoderCLIManager
55
import com.coder.toolbox.sdk.CoderRestClient
6+
import com.coder.toolbox.sdk.ex.APIResponseException
67
import com.coder.toolbox.sdk.v2.models.WorkspaceStatus
78
import com.coder.toolbox.util.CoderProtocolHandler
89
import com.coder.toolbox.util.DialogUi
@@ -19,7 +20,6 @@ import com.jetbrains.toolbox.api.core.util.LoadableState
1920
import com.jetbrains.toolbox.api.localization.LocalizableString
2021
import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState
2122
import com.jetbrains.toolbox.api.remoteDev.RemoteProvider
22-
import com.jetbrains.toolbox.api.remoteDev.RemoteProviderEnvironment
2323
import com.jetbrains.toolbox.api.ui.actions.ActionDelimiter
2424
import com.jetbrains.toolbox.api.ui.actions.ActionDescription
2525
import com.jetbrains.toolbox.api.ui.components.UiPage
@@ -65,10 +65,18 @@ class CoderRemoteProvider(
6565
private val isInitialized: MutableStateFlow<Boolean> = MutableStateFlow(false)
6666
private var coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(context.deploymentUrl.toString()))
6767
private val linkHandler = CoderProtocolHandler(context, dialogUi, isInitialized)
68-
override val environments: MutableStateFlow<LoadableState<List<RemoteProviderEnvironment>>> = MutableStateFlow(
68+
69+
override val environments: MutableStateFlow<LoadableState<List<CoderRemoteEnvironment>>> = MutableStateFlow(
6970
LoadableState.Loading
7071
)
7172

73+
private val visibilityState = MutableStateFlow(
74+
ProviderVisibilityState(
75+
applicationVisible = false,
76+
providerVisible = false
77+
)
78+
)
79+
7280
/**
7381
* With the provided client, start polling for workspaces. Every time a new
7482
* workspace is added, reconfigure SSH using the provided cli (including the
@@ -118,7 +126,7 @@ class CoderRemoteProvider(
118126
environments.update {
119127
LoadableState.Value(resolvedEnvironments.toList())
120128
}
121-
if (isInitialized.value == false) {
129+
if (!isInitialized.value) {
122130
context.logger.info("Environments for ${client.url} are now initialized")
123131
isInitialized.update {
124132
true
@@ -128,6 +136,21 @@ class CoderRemoteProvider(
128136
clear()
129137
addAll(resolvedEnvironments.sortedBy { it.id })
130138
}
139+
140+
if (WorkspaceConnectionManager.shouldEstablishWorkspaceConnections) {
141+
WorkspaceConnectionManager.allConnected().forEach { wsId ->
142+
val env = lastEnvironments.firstOrNull() { it.id == wsId }
143+
if (env != null && !env.isConnected()) {
144+
context.logger.info("Establishing lost SSH connection for workspace with id $wsId")
145+
if (!env.startSshConnection()) {
146+
context.logger.info("Can't establish lost SSH connection for workspace with id $wsId")
147+
}
148+
}
149+
}
150+
WorkspaceConnectionManager.reset()
151+
}
152+
153+
WorkspaceConnectionManager.collectStatuses(lastEnvironments)
131154
} catch (_: CancellationException) {
132155
context.logger.debug("${client.url} polling loop canceled")
133156
break
@@ -138,7 +161,12 @@ class CoderRemoteProvider(
138161
client.setupSession()
139162
} else {
140163
context.logger.error(ex, "workspace polling error encountered, trying to auto-login")
164+
if (ex is APIResponseException && ex.isTokenExpired) {
165+
WorkspaceConnectionManager.shouldEstablishWorkspaceConnections = true
166+
}
141167
close()
168+
// force auto-login
169+
firstRun = true
142170
goToEnvironmentsPage()
143171
break
144172
}
@@ -168,6 +196,7 @@ class CoderRemoteProvider(
168196
// Keep the URL and token to make it easy to log back in, but set
169197
// rememberMe to false so we do not try to automatically log in.
170198
context.secrets.rememberMe = false
199+
WorkspaceConnectionManager.reset()
171200
close()
172201
}
173202

@@ -261,7 +290,11 @@ class CoderRemoteProvider(
261290
* a place to put a timer ("last updated 10 seconds ago" for example)
262291
* and a manual refresh button.
263292
*/
264-
override fun setVisible(visibilityState: ProviderVisibilityState) {}
293+
override fun setVisible(visibility: ProviderVisibilityState) {
294+
visibilityState.update {
295+
visibility
296+
}
297+
}
265298

266299
/**
267300
* Handle incoming links (like from the dashboard).
@@ -320,7 +353,7 @@ class CoderRemoteProvider(
320353
if (autologin && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !settings.requireTokenAuth)) {
321354
try {
322355
AuthWizardState.goToStep(WizardStep.LOGIN)
323-
return AuthWizardPage(context, settingsPage, true, ::onConnect)
356+
return AuthWizardPage(context, settingsPage, visibilityState, true, ::onConnect)
324357
} catch (ex: Exception) {
325358
errorBuffer.add(ex)
326359
}
@@ -330,7 +363,7 @@ class CoderRemoteProvider(
330363
firstRun = false
331364

332365
// Login flow.
333-
val authWizard = AuthWizardPage(context, settingsPage, false, ::onConnect)
366+
val authWizard = AuthWizardPage(context, settingsPage, visibilityState, onConnect = ::onConnect)
334367
// We might have navigated here due to a polling error.
335368
errorBuffer.forEach {
336369
authWizard.notify("Error encountered", it)
@@ -358,7 +391,7 @@ class CoderRemoteProvider(
358391
context.refreshMainPage()
359392
}
360393

361-
private fun MutableStateFlow<LoadableState<List<RemoteProviderEnvironment>>>.showLoadingMessage() {
394+
private fun MutableStateFlow<LoadableState<List<CoderRemoteEnvironment>>>.showLoadingMessage() {
362395
this.update {
363396
LoadableState.Loading
364397
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.coder.toolbox
2+
3+
object WorkspaceConnectionManager {
4+
private val workspaceConnectionState = mutableMapOf<String, Boolean>()
5+
6+
var shouldEstablishWorkspaceConnections = false
7+
8+
fun allConnected(): Set<String> = workspaceConnectionState.filter { it.value }.map { it.key }.toSet()
9+
10+
fun collectStatuses(workspaces: Set<CoderRemoteEnvironment>) {
11+
workspaces.forEach { register(it.id, it.isConnected()) }
12+
}
13+
14+
private fun register(wsId: String, isConnected: Boolean) {
15+
workspaceConnectionState[wsId] = isConnected
16+
}
17+
18+
fun reset() {
19+
workspaceConnectionState.clear()
20+
shouldEstablishWorkspaceConnections = false
21+
}
22+
}

src/main/kotlin/com/coder/toolbox/sdk/ex/APIResponseException.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ import java.net.URL
88
class APIResponseException(action: String, url: URL, code: Int, errorResponse: ApiErrorResponse?) :
99
IOException(formatToPretty(action, url, code, errorResponse)) {
1010

11-
11+
val reason = errorResponse?.detail
1212
val isUnauthorized = HttpURLConnection.HTTP_UNAUTHORIZED == code
13+
val isTokenExpired = isUnauthorized && reason?.contains("API key expired") == true
1314

1415
companion object {
1516
private fun formatToPretty(

0 commit comments

Comments
 (0)