From 4550664f224377bc32fccb4e61a70bf5c3ed81a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 21 May 2025 21:18:15 +0300 Subject: [PATCH 01/93] Changelog update - `v0.2.2` (#117) Current pull request contains patched `CHANGELOG.md` file for the `v0.2.2` version. Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 307ee1c..0f04c9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.2.2 - 2025-05-21 + ### Added - render network status in the Settings tab, under `Additional environment information` section. From dd2166ffee73cc0e902144c9173a202fafbc5173 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 21 May 2025 21:19:35 +0300 Subject: [PATCH 02/93] doc: improve documentation and debugging steps (#112) This PR documents a number of things: - how to configure the debug level before reporting any Coder plugin issue - how to collect or view the Toolbox log files. - the settings page and the available options --- README.md | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c70df74..2b749e3 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Connects your JetBrains IDE to Coder workspaces To install this plugin using JetBrains Toolbox, follow the steps below. -1. Install [JetBrains Toolbox](https://www.jetbrains.com/toolbox-app/). Make sure it's the `2.6.0.40284` release or +1. Install [JetBrains Toolbox](https://www.jetbrains.com/toolbox-app/). Make sure it's the `2.6.0.40632` release or above. 2. Launch the Toolbox app and sign in with your JetBrains account (if needed). @@ -137,6 +137,107 @@ mitmproxy can do HTTP and SOCKS5 proxying. To configure one or the other: 5. Before authenticating to the Coder deployment we need to tell the plugin where can we find mitmproxy certificates. In Coder's Settings page, set the `TLS CA path` to `~/.mitmproxy/mitmproxy-ca-cert.pem` +## 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 +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 +information and help us diagnose and resolve it quickly. + +### Enable Debug Logging + +To help with troubleshooting or to gain more insight into the behavior of the plugin and the SSH connection to +the workspace, you can increase the log level to _DEBUG_. + +Steps to enable debug logging: + +1. Open Toolbox + +2. Navigate to the Toolbox App Menu (hexagonal menu icon) > Settings > Advanced. + +3. In the screen that appears, select _DEBUG_ for the `Log level:` section. + +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 +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 +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 + +```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. + +```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 +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. + +```TLS cert path``` path to a client certificate file for TLS authentication with Coder deployment. +The certificate should be in X.509 PEM format. + +```TLS key path``` path to the private key corresponding to the TLS certificate from above. +The certificate should be in X.509 PEM format. + +```TLS CA path``` the path of a file containing certificates for an alternate certificate authority used to verify TLS +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 +used to connect to the Coder deployment does not match the hostname in the TLS certificate. + +### SSH settings + +The following options control the SSH behavior of the Coder CLI. + +```Disable autostart``` adds the --disable-autostart flag to the SSH proxy command, preventing the CLI from keeping +workspaces constantly active. + +```Enable SSH wildcard config``` enables or disables wildcard entries in the SSH configuration, which allow generic +rules for matching multiple workspaces. + +```SSH proxy log directory``` directory where SSH proxy logs are written. Useful for debugging SSH connection issues. + +```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. + +### Saving Changes + +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 + +> ⚠️ **Attention:** Token authentication is required when TLS certificates are not configured. + ## Releasing 1. Check that the changelog lists all the important changes. From fcb9dc7dedb7e3191a1808dde6a2903bbee5216a Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 22 May 2025 21:35:08 +0300 Subject: [PATCH 03/93] impl: workspace status reporting (color and icons) (#118) There were a couple of discrepancies in the status reporting especially around icons and colors: - offline workspaces are marked by a new "offline" icon and a gray color (instead of a half pie icon with a red color) - stopping state now has a gray progress spinner - same for deleting state which previously used the offline icon instead of the spinner. - failed workspaces used to render a gray offline icon instead of a red warning (exclamation mark) sign. - there was no progress while establishing the SSH connection. Now we have a "SSHing" label with a circular progress bar while connecting to the SSH. --- CHANGELOG.md | 5 ++++ .../coder/toolbox/CoderRemoteEnvironment.kt | 5 ++++ .../toolbox/models/WorkspaceAndAgentStatus.kt | 30 ++++++++++++++----- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f04c9e..9637c92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Changed + +- improved workspace status reporting (icon and colors) when it is failed, stopping, deleting, stopped or when we are + establishing the SSH connection. + ## 0.2.2 - 2025-05-21 ### Added diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index 9effe19..e6118c3 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -157,6 +157,11 @@ class CoderRemoteEnvironment( override fun beforeConnection() { context.logger.info("Connecting to $id...") + context.cs.launch { + state.update { + wsRawStatus.toSshConnectingEnvState(context) + } + } isConnected.update { true } pollJob = pollNetworkMetrics() } diff --git a/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt b/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt index 7a67b36..3c9ad5f 100644 --- a/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt +++ b/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt @@ -11,6 +11,9 @@ import com.jetbrains.toolbox.api.remoteDev.states.CustomRemoteEnvironmentState import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateIcons import com.jetbrains.toolbox.api.remoteDev.states.StandardRemoteEnvironmentState + +private val CircularSpinner: EnvironmentStateIcons = EnvironmentStateIcons.Connecting + /** * WorkspaceAndAgentStatus represents the combined status of a single agent and * its workspace (or just the workspace if there are no agents). @@ -69,23 +72,34 @@ enum class WorkspaceAndAgentStatus(val label: String, val description: String) { } private fun getStateColor(context: CoderToolboxContext): StateColor { - return if (ready()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Active) - else if (unhealthy()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Unhealthy) - else if (canStart()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Failed) - else if (pending()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Activating) + return if (this == FAILED) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.FailedToStart) else if (this == DELETING) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Deleting) else if (this == DELETED) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Deleted) + else if (ready()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Active) + else if (unhealthy()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Unhealthy) + else if (canStart() || this == STOPPING) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Hibernating) + else if (pending()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Activating) else context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Unreachable) } private fun getStateIcon(): EnvironmentStateIcons { - return if (ready() || unhealthy()) EnvironmentStateIcons.Active - else if (canStart()) EnvironmentStateIcons.Hibernated - else if (pending()) EnvironmentStateIcons.Connecting - else if (this == DELETING || this == DELETED) EnvironmentStateIcons.Offline + return if (this == FAILED) EnvironmentStateIcons.Error + else if (pending() || this == DELETING || this == DELETED || this == STOPPING) CircularSpinner + else if (ready() || unhealthy()) EnvironmentStateIcons.Active + else if (canStart()) EnvironmentStateIcons.Offline else EnvironmentStateIcons.NoIcon } + fun toSshConnectingEnvState(context: CoderToolboxContext): CustomRemoteEnvironmentState { + val existingState = toRemoteEnvironmentState(context) + return CustomRemoteEnvironmentState( + "SSHing", + existingState.color, + existingState.isReachable, + EnvironmentStateIcons.Connecting + ) + } + /** * Return true if the agent is in a connectable state. */ From e4ce4c4648984ed4cbae55774ce368db18defd9a Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 23 May 2025 21:36:24 +0300 Subject: [PATCH 04/93] fix: url refresh after switching deployments (#119) The main env header page API is quite limiting, in the sense that the title is never allowed to change. Today we display the Coder URL as the title. However, if the user switches between two deployments via log out and then log in, the URL is never refreshed, leading to a confusing UI (URL is old, while workspaces are from the new deployment) JetBrains suggested a workaround in https://youtrack.jetbrains.com/issue/TBX-14421/ by creating a new env page instance with the updated title after each deployment switch. In which case Toolbox redraws the main screen when page state changes. I tested the workaround and indeed it works but with a caveat. The new env page is refreshed only when Toolbox window page is made visible or when navigating from main env page to a single env page and then back to the main page. For some reason, if we call envPageManager.showPluginEnvironmentsPage()`` from the authentication page displayed in getOverrideUiPage() it doesn't trigger the new env refresh giving the false impression that the URL won't change. In fact this was the reason why I missed this workaround in previously. It is interesting that if I first navigate to a blank page - toolboxUi.showUiPage(emptyPage) - and then immediately call envPageManager.showPluginEnvironmentsPage(), doing this rapid sequence seems to do the trick without any visible artifacts (it happens so fast that we can't see the blank page being rendered) - resolves #66 --- CHANGELOG.md | 6 ++++-- src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt | 3 +++ src/main/kotlin/com/coder/toolbox/views/CoderPage.kt | 4 ++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9637c92..28b1c43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,11 @@ - improved workspace status reporting (icon and colors) when it is failed, stopping, deleting, stopped or when we are establishing the SSH connection. -## 0.2.2 - 2025-05-21 +### Fixed -### Added +- url on the main page is now refreshed when switching between multiple deployments (via logout/login or URI handling) + +## 0.2.2 - 2025-05-21 - render network status in the Settings tab, under `Additional environment information` section. - quick action for creating new workspaces from the web dashboard. diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 952b0dd..865a488 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -9,6 +9,7 @@ import com.coder.toolbox.util.DialogUi import com.coder.toolbox.util.withPath import com.coder.toolbox.views.Action import com.coder.toolbox.views.AuthWizardPage +import com.coder.toolbox.views.CoderPage import com.coder.toolbox.views.CoderSettingsPage import com.coder.toolbox.views.NewEnvironmentPage import com.coder.toolbox.views.state.AuthWizardState @@ -340,7 +341,9 @@ class CoderRemoteProvider( this.client = client pollJob?.cancel() environments.showLoadingMessage() + coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(client.url.toString())) pollJob = poll(client, cli) + context.ui.showUiPage(CoderPage.emptyPage(context)) goToEnvironmentsPage() } diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt index 77fb1c2..ac94a36 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt @@ -53,6 +53,10 @@ abstract class CoderPage( ) } } + + companion object { + fun emptyPage(ctx: CoderToolboxContext): UiPage = UiPage(ctx.i18n.pnotr("")) + } } /** From 208225bdb44b866e66012f595d64cbb7b9df92f7 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 26 May 2025 21:46:24 +0300 Subject: [PATCH 05/93] fix: remember token when switching deployments (#120) If we log in on deployment 1, then log out and login to deployment 2 and then in the same session we try to log in back to deployment 1, the token is no longer valid. The plugin will associate with deployment 1 the token from the second deployment. There is an overly complicated block of code inherited from Gateway plugin with multiple fallback sequences for both the deployment url and token from multiple sources (secrets store, data dir config, env, etc...). This fix simplifies the approach, we only store the url and the token in the secrets store, the token is always associated to a hostname. If there is no previous URL to remember (like the first time login) we default to https://dev.coder.com/ and empty token. --- CHANGELOG.md | 1 + .../com/coder/toolbox/CoderRemoteProvider.kt | 3 +- .../com/coder/toolbox/CoderToolboxContext.kt | 32 +----- .../com/coder/toolbox/browser/BrowserUtil.kt | 4 +- .../toolbox/settings/ReadOnlyCoderSettings.kt | 12 +- .../coder/toolbox/store/CoderSecretsStore.kt | 7 ++ .../coder/toolbox/store/CoderSettingsStore.kt | 45 +------- .../com/coder/toolbox/views/AuthWizardPage.kt | 19 +++- .../com/coder/toolbox/views/ConnectStep.kt | 34 +++--- .../com/coder/toolbox/views/SignInStep.kt | 23 ++-- .../com/coder/toolbox/views/TokenStep.kt | 30 ++--- .../coder/toolbox/views/state/AuthContext.kt | 45 ++++++++ .../toolbox/views/state/AuthWizardState.kt | 7 ++ .../toolbox/settings/CoderSettingsTest.kt | 103 ------------------ 14 files changed, 134 insertions(+), 231 deletions(-) create mode 100644 src/main/kotlin/com/coder/toolbox/views/state/AuthContext.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 28b1c43..6a9db4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed - url on the main page is now refreshed when switching between multiple deployments (via logout/login or URI handling) +- tokens are now remembered after switching between multiple deployments ## 0.2.2 - 2025-05-21 diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 865a488..7aabdce 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -64,7 +64,7 @@ class CoderRemoteProvider( // On the first load, automatically log in if we can. private var firstRun = true private val isInitialized: MutableStateFlow = MutableStateFlow(false) - private var coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(context.deploymentUrl?.first ?: "")) + private var coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(context.deploymentUrl.toString())) private val linkHandler = CoderProtocolHandler(context, dialogUi, isInitialized) override val environments: MutableStateFlow>> = MutableStateFlow( LoadableState.Loading @@ -336,6 +336,7 @@ class CoderRemoteProvider( // Store the URL and token for use next time. context.secrets.lastDeploymentURL = client.url.toString() context.secrets.lastToken = client.token ?: "" + context.secrets.storeTokenFor(client.url, context.secrets.lastToken) // Currently we always remember, but this could be made an option. context.secrets.rememberMe = true this.client = client diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index 856b88f..06e1496 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -1,6 +1,5 @@ package com.coder.toolbox -import com.coder.toolbox.settings.SettingSource import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore import com.coder.toolbox.util.toURL @@ -13,6 +12,7 @@ import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager import com.jetbrains.toolbox.api.ui.ToolboxUi import kotlinx.coroutines.CoroutineScope +import java.net.URL data class CoderToolboxContext( val ui: ToolboxUi, @@ -37,31 +37,11 @@ data class CoderToolboxContext( * 3. CODER_URL. * 4. URL in global cli config. */ - val deploymentUrl: Pair? - get() = this.secrets.lastDeploymentURL.let { - if (it.isNotBlank()) { - it to SettingSource.LAST_USED - } else { - this.settingsStore.defaultURL() + val deploymentUrl: URL + get() { + if (this.secrets.lastDeploymentURL.isNotBlank()) { + return this.secrets.lastDeploymentURL.toURL() } + return this.settingsStore.defaultURL.toURL() } - - /** - * Try to find a token. - * - * Order of preference: - * - * 1. Last used token, if it was for this deployment. - * 2. Token on disk for this deployment. - * 3. Global token for Coder, if it matches the deployment. - */ - fun getToken(deploymentURL: String?): Pair? = this.secrets.lastToken.let { - if (it.isNotBlank() && this.secrets.lastDeploymentURL == deploymentURL) { - it to SettingSource.LAST_USED - } else { - if (deploymentURL != null) { - this.settingsStore.token(deploymentURL.toURL()) - } else null - } - } } diff --git a/src/main/kotlin/com/coder/toolbox/browser/BrowserUtil.kt b/src/main/kotlin/com/coder/toolbox/browser/BrowserUtil.kt index f81bba3..37918b7 100644 --- a/src/main/kotlin/com/coder/toolbox/browser/BrowserUtil.kt +++ b/src/main/kotlin/com/coder/toolbox/browser/BrowserUtil.kt @@ -1,12 +1,12 @@ package com.coder.toolbox.browser +import com.coder.toolbox.util.toURL import com.jetbrains.toolbox.api.core.os.LocalDesktopManager -import java.net.URI suspend fun LocalDesktopManager.browse(rawUrl: String, errorHandler: suspend (BrowserException) -> Unit) { try { - val url = URI.create(rawUrl).toURL() + val url = rawUrl.toURL() this.openUrl(url) } catch (e: Exception) { errorHandler( diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt index 478fdd1..4d17c09 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -10,7 +10,7 @@ interface ReadOnlyCoderSettings { /** * The default URL to show in the connection window. */ - val defaultURL: String? + val defaultURL: String /** * Used to download the Coder CLI which is necessary to proxy SSH @@ -116,16 +116,6 @@ interface ReadOnlyCoderSettings { */ val networkInfoDir: String - /** - * The default URL to show in the connection window. - */ - fun defaultURL(): Pair? - - /** - * Given a deployment URL, try to find a token for it if required. - */ - fun token(deploymentURL: URL): Pair? - /** * Where the specified deployment should put its data. */ diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt index e82402e..3170a06 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt @@ -1,6 +1,7 @@ package com.coder.toolbox.store import com.jetbrains.toolbox.api.core.PluginSecretStore +import java.net.URL /** @@ -26,4 +27,10 @@ class CoderSecretsStore(private val store: PluginSecretStore) { var rememberMe: Boolean get() = get("remember-me").toBoolean() set(value) = set("remember-me", value.toString()) + + fun tokenFor(url: URL): String? = store[url.host] + + fun storeTokenFor(url: URL, token: String) { + store[url.host] = token + } } diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index 50d6c25..d08e8d6 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -3,7 +3,6 @@ package com.coder.toolbox.store import com.coder.toolbox.settings.Environment import com.coder.toolbox.settings.ReadOnlyCoderSettings import com.coder.toolbox.settings.ReadOnlyTLSSettings -import com.coder.toolbox.settings.SettingSource import com.coder.toolbox.util.Arch import com.coder.toolbox.util.OS import com.coder.toolbox.util.expand @@ -35,7 +34,7 @@ class CoderSettingsStore( ) : ReadOnlyTLSSettings // Properties implementation - override val defaultURL: String? get() = store[DEFAULT_URL] + override val defaultURL: String get() = store[DEFAULT_URL] ?: "https://dev.coder.com" override val binarySource: String? get() = store[BINARY_SOURCE] override val binaryDirectory: String? get() = store[BINARY_DIRECTORY] override val defaultCliBinaryNameByOsAndArch: String get() = getCoderCLIForOS(getOS(), getArch()) @@ -71,48 +70,6 @@ class CoderSettingsStore( .normalize() .toString() - /** - * The default URL to show in the connection window. - */ - override fun defaultURL(): Pair? { - val envURL = env.get(CODER_URL) - if (!defaultURL.isNullOrEmpty()) { - return defaultURL!! to SettingSource.SETTINGS - } else if (envURL.isNotBlank()) { - return envURL to SettingSource.ENVIRONMENT - } else { - val (configUrl, _) = readConfig(Path.of(globalConfigDir)) - if (!configUrl.isNullOrBlank()) { - return configUrl to SettingSource.CONFIG - } - } - return null - } - - /** - * Given a deployment URL, try to find a token for it if required. - */ - override fun token(deploymentURL: URL): Pair? { - // No need to bother if we do not need token auth anyway. - if (!requireTokenAuth) { - return null - } - // Try the deployment's config directory. This could exist if someone - // has entered a URL that they are not currently connected to, but have - // connected to in the past. - val (_, deploymentToken) = readConfig(dataDir(deploymentURL).resolve("config")) - if (!deploymentToken.isNullOrBlank()) { - return deploymentToken to SettingSource.DEPLOYMENT_CONFIG - } - // Try the global config directory, in case they previously set up the - // CLI with this URL. - val (configUrl, configToken) = readConfig(Path.of(globalConfigDir)) - if (configUrl == deploymentURL.toString() && !configToken.isNullOrBlank()) { - return configToken to SettingSource.CONFIG - } - return null - } - /** * Where the specified deployment should put its data. */ diff --git a/src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt index feea50d..06009d7 100644 --- a/src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt @@ -3,6 +3,8 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.sdk.CoderRestClient +import com.coder.toolbox.util.toURL +import com.coder.toolbox.views.state.AuthContext import com.coder.toolbox.views.state.AuthWizardState import com.coder.toolbox.views.state.WizardStep import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription @@ -23,10 +25,16 @@ class AuthWizardPage( private val settingsAction = Action(context.i18n.ptrl("Settings"), actionBlock = { context.ui.showUiPage(settingsPage) }) + private val signInStep = SignInStep(context, this::notify) private val tokenStep = TokenStep(context) - private val connectStep = ConnectStep(context, shouldAutoLogin, this::notify, this::displaySteps, onConnect) - + private val connectStep = ConnectStep( + context, + shouldAutoLogin, + this::notify, + this::displaySteps, + onConnect + ) /** * Fields for this page, displayed in order. @@ -34,6 +42,13 @@ class AuthWizardPage( override val fields: MutableStateFlow> = MutableStateFlow(emptyList()) override val actionButtons: MutableStateFlow> = MutableStateFlow(emptyList()) + init { + if (shouldAutoLogin.value) { + AuthContext.url = context.secrets.lastDeploymentURL.toURL() + AuthContext.token = context.secrets.lastToken + } + } + override fun beforeShow() { displaySteps() } diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index 3abbae8..7875728 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -5,7 +5,7 @@ import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.cli.ensureCLI import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderRestClient -import com.coder.toolbox.util.toURL +import com.coder.toolbox.views.state.AuthContext import com.coder.toolbox.views.state.AuthWizardState import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.ui.components.LabelField @@ -50,8 +50,14 @@ class ConnectStep( context.i18n.pnotr("") } - val url = context.deploymentUrl?.first?.toURL() - statusField.textState.update { context.i18n.pnotr("Connecting to ${url?.host}...") } + if (AuthContext.isNotReadyForAuth()) { + errorField.textState.update { + context.i18n.pnotr("URL and token were not properly configured. Please go back and provide a proper URL and token!") + } + return + } + + statusField.textState.update { context.i18n.pnotr("Connecting to ${AuthContext.url!!.host}...") } connect() } @@ -59,25 +65,23 @@ class ConnectStep( * Try connecting to Coder with the provided URL and token. */ private fun connect() { - val url = context.deploymentUrl?.first?.toURL() - val token = context.getToken(context.deploymentUrl?.first)?.first - if (url == null) { + if (!AuthContext.hasUrl()) { errorField.textState.update { context.i18n.ptrl("URL is required") } return } - if (token.isNullOrBlank()) { + if (!AuthContext.hasToken()) { errorField.textState.update { context.i18n.ptrl("Token is required") } return } signInJob?.cancel() signInJob = context.cs.launch { try { - statusField.textState.update { (context.i18n.ptrl("Authenticating to ${url.host}...")) } + statusField.textState.update { (context.i18n.ptrl("Authenticating to ${AuthContext.url!!.host}...")) } val client = CoderRestClient( context, - url, - token, + AuthContext.url!!, + AuthContext.token!!, PluginManager.pluginInfo.version, ) // allows interleaving with the back/cancel action @@ -92,19 +96,20 @@ class ConnectStep( yield() cli.login(client.token) } - statusField.textState.update { (context.i18n.ptrl("Successfully configured ${url.host}...")) } + statusField.textState.update { (context.i18n.ptrl("Successfully configured ${AuthContext.url!!.host}...")) } // allows interleaving with the back/cancel action yield() - onConnect(client, cli) + AuthContext.reset() AuthWizardState.resetSteps() + onConnect(client, cli) } catch (ex: CancellationException) { if (ex.message != USER_HIT_THE_BACK_BUTTON) { - notify("Connection to ${url.host} was configured", ex) + notify("Connection to ${AuthContext.url!!.host} was configured", ex) onBack() refreshWizard() } } catch (ex: Exception) { - notify("Failed to configure ${url.host}", ex) + notify("Failed to configure ${AuthContext.url!!.host}", ex) onBack() refreshWizard() } @@ -120,6 +125,7 @@ class ConnectStep( signInJob?.cancel(CancellationException(USER_HIT_THE_BACK_BUTTON)) } finally { if (shouldAutoLogin.value) { + AuthContext.reset() AuthWizardState.resetSteps() context.secrets.rememberMe = false } else { diff --git a/src/main/kotlin/com/coder/toolbox/views/SignInStep.kt b/src/main/kotlin/com/coder/toolbox/views/SignInStep.kt index 488045e..34cdf2d 100644 --- a/src/main/kotlin/com/coder/toolbox/views/SignInStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/SignInStep.kt @@ -2,15 +2,16 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.util.toURL +import com.coder.toolbox.views.state.AuthContext import com.coder.toolbox.views.state.AuthWizardState import com.jetbrains.toolbox.api.localization.LocalizableString -import com.jetbrains.toolbox.api.ui.components.LabelField import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.TextField import com.jetbrains.toolbox.api.ui.components.TextType import com.jetbrains.toolbox.api.ui.components.ValidationErrorField import kotlinx.coroutines.flow.update import java.net.MalformedURLException +import java.net.URL /** * A page with a field for providing the Coder deployment URL. @@ -18,15 +19,16 @@ import java.net.MalformedURLException * Populates with the provided URL, at which point the user can accept or * enter their own. */ -class SignInStep(private val context: CoderToolboxContext, private val notify: (String, Throwable) -> Unit) : +class SignInStep( + private val context: CoderToolboxContext, + private val notify: (String, Throwable) -> Unit +) : WizardStep { private val urlField = TextField(context.i18n.ptrl("Deployment URL"), "", TextType.General) - private val descriptionField = LabelField(context.i18n.pnotr("")) private val errorField = ValidationErrorField(context.i18n.pnotr("")) override val panel: RowGroup = RowGroup( RowGroup.RowField(urlField), - RowGroup.RowField(descriptionField), RowGroup.RowField(errorField) ) @@ -37,11 +39,7 @@ class SignInStep(private val context: CoderToolboxContext, private val notify: ( context.i18n.pnotr("") } urlField.textState.update { - context.deploymentUrl?.first ?: "" - } - - descriptionField.textState.update { - context.i18n.pnotr(context.deploymentUrl?.second?.description("URL") ?: "") + context.secrets.lastDeploymentURL } } @@ -57,12 +55,11 @@ class SignInStep(private val context: CoderToolboxContext, private val notify: ( url } try { - validateRawUrl(url) + AuthContext.url = validateRawUrl(url) } catch (e: MalformedURLException) { notify("URL is invalid", e) return false } - context.secrets.lastDeploymentURL = url AuthWizardState.goToNextStep() return true } @@ -70,9 +67,9 @@ class SignInStep(private val context: CoderToolboxContext, private val notify: ( /** * Throws [MalformedURLException] if the given string violates RFC-2396 */ - private fun validateRawUrl(url: String) { + private fun validateRawUrl(url: String): URL { try { - url.toURL() + return url.toURL() } catch (e: Exception) { throw MalformedURLException(e.message) } diff --git a/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt b/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt index afd9aa5..b02e9ed 100644 --- a/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt @@ -1,11 +1,10 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext -import com.coder.toolbox.util.toURL import com.coder.toolbox.util.withPath +import com.coder.toolbox.views.state.AuthContext import com.coder.toolbox.views.state.AuthWizardState import com.jetbrains.toolbox.api.localization.LocalizableString -import com.jetbrains.toolbox.api.ui.components.LabelField import com.jetbrains.toolbox.api.ui.components.LinkField import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.TextField @@ -20,15 +19,15 @@ import kotlinx.coroutines.flow.update * Populate with the provided token, at which point the user can accept or * enter their own. */ -class TokenStep(private val context: CoderToolboxContext) : WizardStep { +class TokenStep( + private val context: CoderToolboxContext, +) : WizardStep { private val tokenField = TextField(context.i18n.ptrl("Token"), "", TextType.Password) - private val descriptionField = LabelField(context.i18n.pnotr("")) private val linkField = LinkField(context.i18n.ptrl("Get a token"), "") private val errorField = ValidationErrorField(context.i18n.pnotr("")) override val panel: RowGroup = RowGroup( RowGroup.RowField(tokenField), - RowGroup.RowField(descriptionField), RowGroup.RowField(linkField), RowGroup.RowField(errorField) ) @@ -38,17 +37,18 @@ class TokenStep(private val context: CoderToolboxContext) : WizardStep { errorField.textState.update { context.i18n.pnotr("") } - tokenField.textState.update { - context.getToken(context.deploymentUrl?.first)?.first ?: "" - } - descriptionField.textState.update { - context.i18n.pnotr( - context.getToken(context.deploymentUrl?.first)?.second?.description("token") - ?: "No existing token for ${context.deploymentUrl} found." - ) + if (AuthContext.hasUrl()) { + tokenField.textState.update { + context.secrets.tokenFor(AuthContext.url!!) ?: "" + } + } else { + errorField.textState.update { + context.i18n.pnotr("URL not configure in the previous step. Please go back and provide a proper URL.") + return + } } (linkField.urlState as MutableStateFlow).update { - context.deploymentUrl?.first?.toURL()?.withPath("/login?redirect=%2Fcli-auth")?.toString() ?: "" + AuthContext.url!!.withPath("/login?redirect=%2Fcli-auth")?.toString() ?: "" } } @@ -59,7 +59,7 @@ class TokenStep(private val context: CoderToolboxContext) : WizardStep { return false } - context.secrets.lastToken = token + AuthContext.token = token AuthWizardState.goToNextStep() return true } diff --git a/src/main/kotlin/com/coder/toolbox/views/state/AuthContext.kt b/src/main/kotlin/com/coder/toolbox/views/state/AuthContext.kt new file mode 100644 index 0000000..320bd63 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/views/state/AuthContext.kt @@ -0,0 +1,45 @@ +package com.coder.toolbox.views.state + +import java.net.URL + +/** + * Singleton that holds authentication context (URL and token) across multiple + * Toolbox window lifecycle events. + * + * This ensures that user input (URL and token) is not lost when the Toolbox + * window is temporarily closed or recreated. + */ +object AuthContext { + /** + * The currently entered URL. + */ + var url: URL? = null + + /** + * The token associated with the URL. + */ + var token: String? = null + + /** + * Returns true if a URL is currently set. + */ + fun hasUrl(): Boolean = url != null + + /** + * Returns true if a token is currently set. + */ + fun hasToken(): Boolean = !token.isNullOrBlank() + + /** + * Returns true if URL or token is missing and auth is not yet possible. + */ + fun isNotReadyForAuth(): Boolean = !(hasUrl() && token != null) + + /** + * Resets both URL and token to null. + */ + fun reset() { + url = null + token = null + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/views/state/AuthWizardState.kt b/src/main/kotlin/com/coder/toolbox/views/state/AuthWizardState.kt index 42bf2c0..c29fbc9 100644 --- a/src/main/kotlin/com/coder/toolbox/views/state/AuthWizardState.kt +++ b/src/main/kotlin/com/coder/toolbox/views/state/AuthWizardState.kt @@ -1,6 +1,13 @@ package com.coder.toolbox.views.state +/** + * A singleton that maintains the state of the authorization wizard across Toolbox window lifecycle events. + * + * This is used to persist the wizard's progress (i.e., current step) between visibility changes + * of the Toolbox window. Without this object, closing and reopening the window would reset the wizard + * to its initial state by creating a new instance. + */ object AuthWizardState { private var currentStep = WizardStep.URL_REQUEST diff --git a/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt b/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt index d80b237..5033487 100644 --- a/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt +++ b/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt @@ -3,7 +3,6 @@ package com.coder.toolbox.settings import com.coder.toolbox.store.BINARY_NAME import com.coder.toolbox.store.CODER_SSH_CONFIG_OPTIONS import com.coder.toolbox.store.CoderSettingsStore -import com.coder.toolbox.store.DEFAULT_URL import com.coder.toolbox.store.DISABLE_AUTOSTART import com.coder.toolbox.store.ENABLE_BINARY_DIR_FALLBACK import com.coder.toolbox.store.ENABLE_DOWNLOADS @@ -277,108 +276,6 @@ internal class CoderSettingsTest { assertEquals(false, settings.readOnly().requireTokenAuth) } - @Test - fun testDefaultURL() { - val tmp = Path.of(System.getProperty("java.io.tmpdir")) - val dir = tmp.resolve("coder-toolbox-test/test-default-url") - var env = Environment(mapOf("CODER_CONFIG_DIR" to dir.toString())) - dir.toFile().deleteRecursively() - - // No config. - var settings = CoderSettingsStore(pluginTestSettingsStore(), env, logger) - assertEquals(null, settings.defaultURL()) - - // Read from global config. - val globalConfigPath = Path.of(settings.readOnly().globalConfigDir) - globalConfigPath.toFile().mkdirs() - globalConfigPath.resolve("url").toFile().writeText("url-from-global-config") - settings = CoderSettingsStore(pluginTestSettingsStore(), env, logger) - assertEquals("url-from-global-config" to SettingSource.CONFIG, settings.defaultURL()) - - // Read from environment. - env = - Environment( - mapOf( - "CODER_URL" to "url-from-env", - "CODER_CONFIG_DIR" to dir.toString(), - ), - ) - settings = CoderSettingsStore(pluginTestSettingsStore(), env, logger) - assertEquals("url-from-env" to SettingSource.ENVIRONMENT, settings.defaultURL()) - - // Read from settings. - settings = - CoderSettingsStore( - pluginTestSettingsStore( - DEFAULT_URL to "url-from-settings", - ), - env, - logger - ) - assertEquals("url-from-settings" to SettingSource.SETTINGS, settings.defaultURL()) - } - - @Test - fun testToken() { - val tmp = Path.of(System.getProperty("java.io.tmpdir")) - val url = URL("http://test.deployment.coder.com") - val dir = tmp.resolve("coder-toolbox-test/test-default-token") - val env = - Environment( - mapOf( - "CODER_CONFIG_DIR" to dir.toString(), - "LOCALAPPDATA" to dir.toString(), - "XDG_DATA_HOME" to dir.toString(), - "HOME" to dir.toString(), - ), - ) - dir.toFile().deleteRecursively() - - // No config. - var settings = CoderSettingsStore(pluginTestSettingsStore(), env, logger) - assertEquals(null, settings.readOnly().token(url)) - - val globalConfigPath = Path.of(settings.readOnly().globalConfigDir) - globalConfigPath.toFile().mkdirs() - globalConfigPath.resolve("url").toFile().writeText(url.toString()) - globalConfigPath.resolve("session").toFile().writeText("token-from-global-config") - - // Ignore global config if it does not match. - assertEquals(null, settings.readOnly().token(URL("http://some.random.url"))) - - // Read from global config. - assertEquals("token-from-global-config" to SettingSource.CONFIG, settings.readOnly().token(url)) - - // Compares exactly. - assertEquals(null, settings.readOnly().token(url.withPath("/test"))) - - val deploymentConfigPath = settings.readOnly().dataDir(url).resolve("config") - deploymentConfigPath.toFile().mkdirs() - deploymentConfigPath.resolve("url").toFile().writeText("url-from-deployment-config") - deploymentConfigPath.resolve("session").toFile().writeText("token-from-deployment-config") - - // Read from deployment config. - assertEquals("token-from-deployment-config" to SettingSource.DEPLOYMENT_CONFIG, settings.readOnly().token(url)) - - // Only compares host . - assertEquals( - "token-from-deployment-config" to SettingSource.DEPLOYMENT_CONFIG, - settings.readOnly().token(url.withPath("/test")) - ) - - // Ignore if using mTLS. - settings = - CoderSettingsStore( - pluginTestSettingsStore( - TLS_KEY_PATH to "key", - TLS_CERT_PATH to "cert", - ), - env, - logger - ) - assertEquals(null, settings.readOnly().token(url)) - } - @Test fun testDefaults() { // Test defaults for the remaining settings. From 620547f1050c80639fd920310e43fcef565382ea Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 26 May 2025 22:13:23 +0300 Subject: [PATCH 06/93] chore: next version is 0.2.3 (#122) --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 843e9e9..8433f61 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.2.2 +version=0.2.3 group=com.coder.toolbox name=coder-toolbox From 99e78352902901c5d90a11ef0679498b26dee91a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 26 May 2025 22:56:54 +0300 Subject: [PATCH 07/93] Changelog update - `v0.2.3` (#123) Current pull request contains patched `CHANGELOG.md` file for the `v0.2.3` version. Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a9db4f..893daa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.2.3 - 2025-05-26 + ### Changed - improved workspace status reporting (icon and colors) when it is failed, stopping, deleting, stopped or when we are From 09ecfcf98c29d6db56e08b2c62a946c8d77bb58d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Jun 2025 18:45:48 +0300 Subject: [PATCH 08/93] chore: bump org.jetbrains.intellij.plugins:structure-toolbox from 3.306 to 3.307 (#126) Bumps [org.jetbrains.intellij.plugins:structure-toolbox](https://github.com/JetBrains/intellij-plugin-verifier) from 3.306 to 3.307.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.jetbrains.intellij.plugins:structure-toolbox&package-manager=gradle&previous-version=3.306&new-version=3.307)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8546bd8..2fe9b70 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ ksp = "2.1.0-1.0.29" retrofit = "2.11.0" changelog = "2.2.1" gettext = "0.7.0" -plugin-structure = "3.306" +plugin-structure = "3.307" mockk = "1.14.2" [libraries] From 6bb05997b02a5de2d41ddd564a7d6dfcf77ed780 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 10 Jun 2025 15:15:53 +0300 Subject: [PATCH 09/93] impl: support for Toolbox 2.6.3 (#124) Toolbox 2.6.3 comes with a couple of new additions in the API which need the following changes: - finish support for URI handling. The available API up to TBX 2.6.3 was buggy in terms of URI handling. It didn't allow plugins to programmatically install remote ides and launch them. The launch operation only worked when the IDE was already installed and a project was already opened with the IDE. TBX 2.6.3 adds a new API, _RemoteToolboxHelp_ which provides routines for listing the available IDEs on the remote, what is already installed and a command to install specific versions of the IDE. Additionally, there were fixes provided to the existing _ClientHelper_ which now launches the JBClient if a project was not specified. An additional quirk I've discovered is that if we provide a project, and that project was not already opened (present in the Projects tab) the IDE still won't open. And there is no API available to query the available projects. This PR uses the new API to: - query the installed ides - check if the provided ide is in the list of already installed IDEs. - if that's not the case we query the available list of IDEs and the available versions - if the provided ide and build no., is in the available list we will schedule it for install - if not, we select the latest available build number for the provided product code. - wait for the remote IDE to be installed - and then download and launch the JBClient with a project path if it was provided. - update the minimum API requirement. Toolbox API is upgraded to 1.1.41749 which comes with new API additions and some deprecations. Kotlin stdlib was also increased to a newer patch version - use new environment state API. The _CustomRemoteEnvironmentState_ is deprecated, and replaced by a new class _CustomRemoteEnvironmentStateV2_ which now supports i18n state labels - use the new ssh disconnect callback. Toolbox provides two callbacks, one before an SSH connection is established and another one which executes when the ssh connection is stopped. The latter was deprecated in the favor of a new callback that also provides hints on whether the user requested the disconnect. - use the new delete callback API. Toolbox provides a callback for scenarios that involve the env. deletion. This allows plugins to react and clean the internal state. With the new TBX API, the delete callback API is deprecated in the favor of a mutable state flow, a reactive approach that allows consumers to observe and react to state changes over time. --- CHANGELOG.md | 4 + README.md | 5 + gradle.properties | 2 +- gradle/libs.versions.toml | 6 +- .../coder/toolbox/CoderRemoteEnvironment.kt | 5 +- .../com/coder/toolbox/CoderToolboxContext.kt | 46 +- .../coder/toolbox/CoderToolboxExtension.kt | 2 + .../toolbox/models/WorkspaceAndAgentStatus.kt | 14 +- .../com/coder/toolbox/sdk/CoderRestClient.kt | 6 +- .../toolbox/util/CoderProtocolHandler.kt | 496 +++++++++++------- .../kotlin/com/coder/toolbox/util/Dialogs.kt | 32 -- .../kotlin/com/coder/toolbox/util/LinkMap.kt | 1 - .../resources/localization/defaultMessages.po | 3 + .../coder/toolbox/cli/CoderCLIManagerTest.kt | 2 + .../coder/toolbox/sdk/CoderRestClientTest.kt | 2 + ...lerTest.kt => CoderProtocolHandlerTest.kt} | 165 +++--- 16 files changed, 449 insertions(+), 342 deletions(-) rename src/test/kotlin/com/coder/toolbox/util/{LinkHandlerTest.kt => CoderProtocolHandlerTest.kt} (59%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 893daa4..fa101fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- support for Toolbox 2.6.3 with improved URI handling + ## 0.2.3 - 2025-05-26 ### Changed diff --git a/README.md b/README.md index 2b749e3..2034504 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,11 @@ 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. +> 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. + ## Configuring and Testing workspace polling with HTTP & SOCKS5 Proxy This section explains how to set up a local proxy (without authentication which is not yet supported) and verify that diff --git a/gradle.properties b/gradle.properties index 8433f61..a6129a9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.2.3 +version=0.3.0 group=com.coder.toolbox name=coder-toolbox diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2fe9b70..01bb486 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] -toolbox-plugin-api = "1.0.38881" -kotlin = "2.1.0" +toolbox-plugin-api = "1.1.41749" +kotlin = "2.1.10" coroutines = "1.10.1" serialization = "1.8.0" okhttp = "4.12.0" @@ -9,7 +9,7 @@ marketplace-client = "2.0.46" gradle-wrapper = "0.14.0" exec = "1.12" moshi = "1.15.2" -ksp = "2.1.0-1.0.29" +ksp = "2.1.10-1.0.31" retrofit = "2.11.0" changelog = "2.2.1" gettext = "0.7.0" diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index e6118c3..0608817 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -27,6 +27,7 @@ import com.squareup.moshi.Moshi import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -203,7 +204,7 @@ class CoderRemoteEnvironment( private fun File.doesNotExists(): Boolean = !this.exists() - override fun afterDisconnect() { + override fun afterDisconnect(isManual: Boolean) { context.logger.info("Stopping the network metrics poll job for $id") pollJob?.cancel() this.connectionRequest.update { false } @@ -269,7 +270,7 @@ class CoderRemoteEnvironment( } } - override fun onDelete() { + override val deleteActionFlow: StateFlow<(() -> Unit)?> = MutableStateFlow { context.cs.launch { try { client.removeWorkspace(workspace) diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index 06e1496..4291321 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -7,18 +7,22 @@ import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper +import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager import com.jetbrains.toolbox.api.ui.ToolboxUi import kotlinx.coroutines.CoroutineScope import java.net.URL +import java.util.UUID +@Suppress("UnstableApiUsage") data class CoderToolboxContext( val ui: ToolboxUi, val envPageManager: EnvironmentUiPageManager, val envStateColorPalette: EnvironmentStateColorPalette, - val ideOrchestrator: ClientHelper, + val remoteIdeOrchestrator: RemoteToolsHelper, + val jbClientOrchestrator: ClientHelper, val desktop: LocalDesktopManager, val cs: CoroutineScope, val logger: Logger, @@ -44,4 +48,44 @@ data class CoderToolboxContext( } return this.settingsStore.defaultURL.toURL() } + + suspend fun logAndShowError(title: String, error: String) { + logger.error(error) + ui.showSnackbar( + UUID.randomUUID().toString(), + i18n.pnotr(title), + i18n.pnotr(error), + i18n.ptrl("OK") + ) + } + + suspend fun logAndShowError(title: String, error: String, exception: Exception) { + logger.error(exception, error) + ui.showSnackbar( + UUID.randomUUID().toString(), + i18n.pnotr(title), + i18n.pnotr(error), + i18n.ptrl("OK") + ) + } + + suspend fun logAndShowWarning(title: String, warning: String) { + logger.warn(warning) + ui.showSnackbar( + UUID.randomUUID().toString(), + i18n.pnotr(title), + i18n.pnotr(warning), + i18n.ptrl("OK") + ) + } + + suspend fun logAndShowInfo(title: String, info: String) { + logger.info(info) + ui.showSnackbar( + UUID.randomUUID().toString(), + i18n.pnotr(title), + i18n.pnotr(info), + i18n.ptrl("OK") + ) + } } diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt index 05424ae..5cfcd11 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt @@ -13,6 +13,7 @@ import com.jetbrains.toolbox.api.localization.LocalizableStringFactory import com.jetbrains.toolbox.api.remoteDev.RemoteDevExtension import com.jetbrains.toolbox.api.remoteDev.RemoteProvider import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper +import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager @@ -31,6 +32,7 @@ class CoderToolboxExtension : RemoteDevExtension { serviceLocator.getService(), serviceLocator.getService(), serviceLocator.getService(), + serviceLocator.getService(), serviceLocator.getService(), serviceLocator.getService(), serviceLocator.getService(), diff --git a/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt b/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt index 3c9ad5f..cc04dfe 100644 --- a/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt +++ b/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt @@ -7,7 +7,7 @@ import com.coder.toolbox.sdk.v2.models.WorkspaceAgentLifecycleState import com.coder.toolbox.sdk.v2.models.WorkspaceAgentStatus import com.coder.toolbox.sdk.v2.models.WorkspaceStatus import com.jetbrains.toolbox.api.core.ui.color.StateColor -import com.jetbrains.toolbox.api.remoteDev.states.CustomRemoteEnvironmentState +import com.jetbrains.toolbox.api.remoteDev.states.CustomRemoteEnvironmentStateV2 import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateIcons import com.jetbrains.toolbox.api.remoteDev.states.StandardRemoteEnvironmentState @@ -61,9 +61,9 @@ enum class WorkspaceAndAgentStatus(val label: String, val description: String) { * Note that a reachable environment will always display "connected" or * "disconnected" regardless of the label we give that status. */ - fun toRemoteEnvironmentState(context: CoderToolboxContext): CustomRemoteEnvironmentState { - return CustomRemoteEnvironmentState( - label, + fun toRemoteEnvironmentState(context: CoderToolboxContext): CustomRemoteEnvironmentStateV2 { + return CustomRemoteEnvironmentStateV2( + context.i18n.pnotr(label), color = getStateColor(context), reachable = ready() || unhealthy(), // TODO@JB: How does this work? Would like a spinner for pending states. @@ -90,10 +90,10 @@ enum class WorkspaceAndAgentStatus(val label: String, val description: String) { else EnvironmentStateIcons.NoIcon } - fun toSshConnectingEnvState(context: CoderToolboxContext): CustomRemoteEnvironmentState { + fun toSshConnectingEnvState(context: CoderToolboxContext): CustomRemoteEnvironmentStateV2 { val existingState = toRemoteEnvironmentState(context) - return CustomRemoteEnvironmentState( - "SSHing", + return CustomRemoteEnvironmentStateV2( + context.i18n.pnotr("SSHing"), existingState.color, existingState.isReachable, EnvironmentStateIcons.Connecting diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 6785675..9f619bc 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -192,12 +192,12 @@ open class CoderRestClient( } /** - * Maps the list of workspaces to the associated agents. + * Maps the available workspaces to the associated agents. */ - suspend fun groupByAgents(workspaces: List): Set> { + suspend fun workspacesByAgents(): Set> { // It is possible for there to be resources with duplicate names so we // need to use a set. - return workspaces.flatMap { ws -> + return workspaces().flatMap { ws -> when (ws.latestBuild.status) { WorkspaceStatus.RUNNING -> ws.latestBuild.resources else -> resources(ws) diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index ad42d18..af07548 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -9,19 +9,23 @@ import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent import com.coder.toolbox.sdk.v2.models.WorkspaceStatus -import com.jetbrains.toolbox.api.localization.LocalizableString +import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper +import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.time.withTimeout -import java.net.HttpURLConnection import java.net.URI -import java.net.URL +import java.util.UUID +import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration +private const val CAN_T_HANDLE_URI_TITLE = "Can't handle URI" + +@Suppress("UnstableApiUsage") open class CoderProtocolHandler( private val context: CoderToolboxContext, private val dialogUi: DialogUi, @@ -41,113 +45,245 @@ open class CoderProtocolHandler( shouldWaitForAutoLogin: Boolean, reInitialize: suspend (CoderRestClient, CoderCLIManager) -> Unit ) { - context.popupPluginMainPage() val params = uri.toQueryParameters() if (params.isEmpty()) { // probably a plugin installation scenario + context.logAndShowInfo("URI will not be handled", "No query parameters were provided") return } + if (shouldWaitForAutoLogin) { + isInitialized.waitForTrue() + } + + context.logger.info("Handling $uri...") + val deploymentURL = resolveDeploymentUrl(params) ?: return + val token = resolveToken(params) ?: return + val workspaceName = resolveWorkspaceName(params) ?: return + val restClient = buildRestClient(deploymentURL, token) ?: return + val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL) ?: return + if (!prepareWorkspace(workspace, restClient, workspaceName, deploymentURL)) return + + // we resolve the agent after the workspace is started otherwise we can get misleading + // errors like: no agent available while workspace is starting or stopping + val agent = resolveAgent(params, workspace) ?: return + if (!ensureAgentIsReady(workspace, agent)) return + + val cli = configureCli(deploymentURL, restClient) + reInitialize(restClient, cli) + + val environmentId = "${workspace.name}.${agent.name}" + context.showEnvironmentPage(environmentId) + + val productCode = params.ideProductCode() + val buildNumber = params.ideBuildNumber() + val projectFolder = params.projectFolder() + + if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) { + launchIde(environmentId, productCode, buildNumber, projectFolder) + } + } + + private suspend fun resolveDeploymentUrl(params: Map): String? { val deploymentURL = params.url() ?: askUrl() if (deploymentURL.isNullOrBlank()) { - context.logger.error("Query parameter \"$URL\" is missing from URI $uri") - context.showErrorPopup(MissingArgumentException("Can't handle URI because query parameter \"$URL\" is missing")) - return + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"$URL\" is missing from URI") + return null } + return deploymentURL + } - val queryToken = params.token() - val restClient = try { - authenticate(deploymentURL, queryToken) - } catch (ex: Exception) { - context.logger.error(ex, "Query parameter \"$TOKEN\" is missing from URI $uri") - context.showErrorPopup(IllegalStateException(humanizeConnectionError(deploymentURL.toURL(), true, ex))) - return + private suspend fun resolveToken(params: Map): String? { + val token = params.token() + if (token.isNullOrBlank()) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"$TOKEN\" is missing from URI") + return null + } + return token + } + + private suspend fun resolveWorkspaceName(params: Map): String? { + val workspace = params.workspace() + if (workspace.isNullOrBlank()) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"$WORKSPACE\" is missing from URI") + return null } + return workspace + } - // TODO: Show a dropdown and ask for the workspace if missing. Right now it's not possible because dialogs are quite limited - val workspaceName = params.workspace() - if (workspaceName.isNullOrBlank()) { - context.logger.error("Query parameter \"$WORKSPACE\" is missing from URI $uri") - context.showErrorPopup(MissingArgumentException("Can't handle URI because query parameter \"$WORKSPACE\" is missing")) - return + private suspend fun buildRestClient(deploymentURL: String, token: String): CoderRestClient? { + try { + return authenticate(deploymentURL, token) + } catch (ex: Exception) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, humanizeConnectionError(deploymentURL.toURL(), true, ex)) + return null } + } + + /** + * Returns an authenticated Coder CLI. + */ + private suspend fun authenticate(deploymentURL: String, token: String): CoderRestClient { + val client = CoderRestClient( + context, + deploymentURL.toURL(), + if (settings.requireTokenAuth) token else null, + PluginManager.pluginInfo.version + ) + client.authenticate() + return client + } - val workspaces = restClient.workspaces() - val workspace = workspaces.firstOrNull { it.name == workspaceName } + private suspend fun List.matchName(workspaceName: String, deploymentURL: String): Workspace? { + val workspace = this.firstOrNull { it.name == workspaceName } if (workspace == null) { - context.logger.error("There is no workspace with name $workspaceName on $deploymentURL") - context.showErrorPopup(MissingArgumentException("Can't handle URI because workspace with name $workspaceName does not exist")) - return + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "There is no workspace with name $workspaceName on $deploymentURL" + ) + return null } + return workspace + } + private suspend fun prepareWorkspace( + workspace: Workspace, + restClient: CoderRestClient, + workspaceName: String, + deploymentURL: String + ): Boolean { when (workspace.latestBuild.status) { WorkspaceStatus.PENDING, WorkspaceStatus.STARTING -> - if (restClient.waitForReady(workspace) != true) { - context.logger.error("$workspaceName from $deploymentURL could not be ready on time") - context.showErrorPopup(MissingArgumentException("Can't handle URI because workspace $workspaceName could not be ready on time")) - return + if (!restClient.waitForReady(workspace)) { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "$workspaceName from $deploymentURL could not be ready on time" + ) + return false } WorkspaceStatus.STOPPING, WorkspaceStatus.STOPPED, WorkspaceStatus.CANCELING, WorkspaceStatus.CANCELED -> { if (settings.disableAutostart) { - context.logger.warn("$workspaceName from $deploymentURL is not started and autostart is disabled.") - context.showInfoPopup( - context.i18n.pnotr("$workspaceName is not running"), - context.i18n.ptrl("Can't handle URI because workspace is not running and autostart is disabled. Please start the workspace manually and execute the URI again."), - context.i18n.ptrl("OK") + context.logAndShowWarning( + CAN_T_HANDLE_URI_TITLE, + "$workspaceName from $deploymentURL is not running and autostart is disabled" ) - return + return false } try { restClient.startWorkspace(workspace) } catch (e: Exception) { - context.logger.error( - e, - "$workspaceName from $deploymentURL could not be started while handling URI" + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "$workspaceName from $deploymentURL could not be started", + e ) - context.showErrorPopup(MissingArgumentException("Can't handle URI because an error was encountered while trying to start workspace $workspaceName")) - return + return false } - if (restClient.waitForReady(workspace) != true) { - context.logger.error("$workspaceName from $deploymentURL could not be started on time") - context.showErrorPopup(MissingArgumentException("Can't handle URI because workspace $workspaceName could not be started on time")) - return + + if (!restClient.waitForReady(workspace)) { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "$workspaceName from $deploymentURL could not be started on time", + ) + return false } } WorkspaceStatus.FAILED, WorkspaceStatus.DELETING, WorkspaceStatus.DELETED -> { - context.logger.error("Unable to connect to $workspaceName from $deploymentURL") - context.showErrorPopup(MissingArgumentException("Can't handle URI because because we're unable to connect to workspace $workspaceName")) - return + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "Unable to connect to $workspaceName from $deploymentURL" + ) + return false } - WorkspaceStatus.RUNNING -> Unit // All is well + WorkspaceStatus.RUNNING -> return true // All is well } + return true + } - // TODO: Show a dropdown and ask for an agent if missing. - val agent: WorkspaceAgent + private suspend fun resolveAgent( + params: Map, + workspace: Workspace + ): WorkspaceAgent? { try { - agent = getMatchingAgent(params, workspace) + return getMatchingAgent(params, workspace) } catch (e: IllegalArgumentException) { - context.logger.error(e, "Can't resolve an agent for workspace $workspaceName from $deploymentURL") - context.showErrorPopup( - MissingArgumentException( - "Can't handle URI because we can't resolve an agent for workspace $workspaceName from $deploymentURL", - e - ) + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "Can't resolve an agent for workspace ${workspace.name}", + e ) - return + return null + } + } + + /** + * Return the agent matching the provided agent ID or name in the parameters. + * + * @throws [IllegalArgumentException] + */ + internal suspend fun getMatchingAgent( + parameters: Map, + workspace: Workspace, + ): WorkspaceAgent? { + val agents = workspace.latestBuild.resources.filter { it.agents != null }.flatMap { it.agents!! } + if (agents.isEmpty()) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "The workspace \"${workspace.name}\" has no agents") + return null + } + + // If the agent is missing and the workspace has only one, use that. + val agent = + if (!parameters.agentID().isNullOrBlank()) { + agents.firstOrNull { it.id.toString() == parameters.agentID() } + } else if (agents.size == 1) { + agents.first() + } else { + null + } + + if (agent == null) { + if (!parameters.agentID().isNullOrBlank()) { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "The workspace \"${workspace.name}\" does not have an agent with ID \"${parameters.agentID()}\"" + ) + return null + } else { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "Unable to determine which agent to connect to; \"$AGENT_ID\" must be set because the workspace \"${workspace.name}\" has more than one agent" + ) + return null + } } + return agent + } + + private suspend fun ensureAgentIsReady( + workspace: Workspace, + agent: WorkspaceAgent + ): Boolean { val status = WorkspaceAndAgentStatus.from(workspace, agent) if (!status.ready()) { - context.logger.error("Agent ${agent.name} for workspace $workspaceName from $deploymentURL is not ready") - context.showErrorPopup(MissingArgumentException("Can't handle URI because agent ${agent.name} for workspace $workspaceName from $deploymentURL is not ready")) - return + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "Agent ${agent.name} for workspace ${workspace.name} is not ready" + ) + return false } + return true + } + private suspend fun configureCli( + deploymentURL: String, + restClient: CoderRestClient + ): CoderCLIManager { val cli = ensureCLI( context, deploymentURL.toURL(), @@ -161,31 +297,92 @@ open class CoderProtocolHandler( } context.logger.info("Configuring Coder CLI...") - cli.configSsh(restClient.groupByAgents(workspaces)) + cli.configSsh(restClient.workspacesByAgents()) + return cli + } - if (shouldWaitForAutoLogin) { - isInitialized.waitForTrue() + private fun launchIde( + environmentId: String, + productCode: String, + buildNumber: String, + projectFolder: String? + ) { + context.cs.launch { + val selectedIde = selectAndInstallRemoteIde(productCode, buildNumber, environmentId) ?: return@launch + context.logger.info("$productCode-$buildNumber is already on $environmentId. Going to launch JBClient") + installJBClient(selectedIde, environmentId).join() + launchJBClient(selectedIde, environmentId, projectFolder) } - reInitialize(restClient, cli) + } - val environmentId = "${workspace.name}.${agent.name}" - context.popupPluginMainPage() - context.envPageManager.showEnvironmentPage(environmentId, false) - val productCode = params.ideProductCode() - val buildNumber = params.ideBuildNumber() - val projectFolder = params.projectFolder() - if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) { - context.cs.launch { - val ideVersion = "$productCode-$buildNumber" - context.logger.info("installing $ideVersion on $environmentId") - val job = context.cs.launch { - context.ideOrchestrator.prepareClient(environmentId, ideVersion) - } - job.join() - context.logger.info("launching $ideVersion on $environmentId") - context.ideOrchestrator.connectToIde(environmentId, ideVersion, projectFolder) + private suspend fun selectAndInstallRemoteIde( + productCode: String, + buildNumber: String, + environmentId: String + ): String? { + val installedIdes = context.remoteIdeOrchestrator.getInstalledRemoteTools(environmentId, productCode) + + var selectedIde = "$productCode-$buildNumber" + if (installedIdes.firstOrNull { it.contains(buildNumber) } != null) { + context.logger.info("$selectedIde is already installed on $environmentId") + return selectedIde + } + + selectedIde = resolveAvailableIde(environmentId, productCode, buildNumber) ?: return null + + // needed otherwise TBX will install it again + if (!installedIdes.contains(selectedIde)) { + context.logger.info("Installing $selectedIde on $environmentId...") + context.remoteIdeOrchestrator.installRemoteTool(environmentId, selectedIde) + + if (context.remoteIdeOrchestrator.waitForIdeToBeInstalled(environmentId, selectedIde)) { + context.logger.info("Successfully installed $selectedIde on $environmentId...") + return selectedIde + } else { + context.ui.showSnackbar( + UUID.randomUUID().toString(), + context.i18n.pnotr("$selectedIde could not be installed"), + context.i18n.pnotr("$selectedIde could not be installed on time. Check the logs for more details"), + context.i18n.ptrl("OK") + ) + return null } + } else { + context.logger.info("$selectedIde is already present on $environmentId...") + return selectedIde + } + } + + private suspend fun resolveAvailableIde(environmentId: String, productCode: String, buildNumber: String): String? { + val availableVersions = context + .remoteIdeOrchestrator + .getAvailableRemoteTools(environmentId, productCode) + + if (availableVersions.isEmpty()) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "$productCode is not available on $environmentId") + return null } + + val matchingBuildNumber = availableVersions.firstOrNull { it.contains(buildNumber) } != null + if (!matchingBuildNumber) { + val selectedIde = availableVersions.maxOf { it } + context.logAndShowInfo( + "$productCode-$buildNumber not available", + "$productCode-$buildNumber is not available, we've selected the latest $selectedIde" + ) + return selectedIde + } + return null + } + + private fun installJBClient(selectedIde: String, environmentId: String): Job = context.cs.launch { + context.logger.info("Downloading and installing JBClient counterpart to $selectedIde locally") + context.jbClientOrchestrator.prepareClient(environmentId, selectedIde) + } + + private fun launchJBClient(selectedIde: String, environmentId: String, projectFolder: String?) { + context.logger.info("Launching $selectedIde on $environmentId") + context.jbClientOrchestrator.connectToIde(environmentId, selectedIde, projectFolder) } private suspend fun CoderRestClient.waitForReady(workspace: Workspace): Boolean { @@ -203,6 +400,25 @@ open class CoderProtocolHandler( } } + private suspend fun RemoteToolsHelper.waitForIdeToBeInstalled( + environmentId: String, + ideHint: String, + waitTime: Duration = 2.minutes + ): Boolean { + var isInstalled = false + try { + withTimeout(waitTime.toJavaDuration()) { + while (!isInstalled) { + delay(5.seconds) + isInstalled = getInstalledRemoteTools(environmentId, ideHint).isNotEmpty() + } + } + return true + } catch (_: TimeoutCancellationException) { + return false + } + } + private suspend fun askUrl(): String? { context.popupPluginMainPage() return dialogUi.ask( @@ -210,119 +426,6 @@ open class CoderProtocolHandler( context.i18n.ptrl("Enter the full URL of your Coder deployment") ) } - - /** - * Return an authenticated Coder CLI, asking for the token. - * Throw MissingArgumentException if the user aborts. Any network or invalid - * token error may also be thrown. - */ - private suspend fun authenticate( - deploymentURL: String, - tryToken: String? - ): CoderRestClient { - val token = - if (settings.requireTokenAuth) { - // Try the provided token immediately on the first attempt. - if (!tryToken.isNullOrBlank()) { - tryToken - } else { - context.popupPluginMainPage() - // Otherwise ask for a new token, showing the previous token. - dialogUi.askToken(deploymentURL.toURL()) - } - } else { - null - } - - if (settings.requireTokenAuth && token == null) { // User aborted. - throw MissingArgumentException("Token is required") - } - val client = CoderRestClient( - context, - deploymentURL.toURL(), - token, - PluginManager.pluginInfo.version - ) - client.authenticate() - return client - } - -} - -/** - * Follow a URL's redirects to its final destination. - */ -internal fun resolveRedirects(url: URL): URL { - var location = url - val maxRedirects = 10 - for (i in 1..maxRedirects) { - val conn = location.openConnection() as HttpURLConnection - conn.instanceFollowRedirects = false - conn.connect() - val code = conn.responseCode - val nextLocation = conn.getHeaderField("Location") - conn.disconnect() - // Redirects are triggered by any code starting with 3 plus a - // location header. - if (code < 300 || code >= 400 || nextLocation.isNullOrBlank()) { - return location - } - // Location headers might be relative. - location = URL(location, nextLocation) - } - throw Exception("Too many redirects") -} - -/** - * Return the agent matching the provided agent ID or name in the parameters. - * - * @throws [IllegalArgumentException] - */ -internal fun getMatchingAgent( - parameters: Map, - workspace: Workspace, -): WorkspaceAgent { - val agents = workspace.latestBuild.resources.filter { it.agents != null }.flatMap { it.agents!! } - if (agents.isEmpty()) { - throw IllegalArgumentException("The workspace \"${workspace.name}\" has no agents") - } - - // If the agent is missing and the workspace has only one, use that. - // Prefer the ID over the name if both are set. - val agent = - if (!parameters.agentID().isNullOrBlank()) { - agents.firstOrNull { it.id.toString() == parameters.agentID() } - } else if (agents.size == 1) { - agents.first() - } else { - null - } - - if (agent == null) { - if (!parameters.agentID().isNullOrBlank()) { - throw IllegalArgumentException("The workspace \"${workspace.name}\" does not have an agent with ID \"${parameters.agentID()}\"") - } else { - throw MissingArgumentException( - "Unable to determine which agent to connect to; \"$AGENT_ID\" must be set because the workspace \"${workspace.name}\" has more than one agent", - ) - } - } - - return agent -} - -private suspend fun CoderToolboxContext.showErrorPopup(error: Throwable) { - popupPluginMainPage() - this.ui.showErrorInfoPopup(error) -} - -private suspend fun CoderToolboxContext.showInfoPopup( - title: LocalizableString, - message: LocalizableString, - okLabel: LocalizableString -) { - popupPluginMainPage() - this.ui.showInfoPopup(title, message, okLabel) } private fun CoderToolboxContext.popupPluginMainPage() { @@ -330,4 +433,9 @@ private fun CoderToolboxContext.popupPluginMainPage() { this.envPageManager.showPluginEnvironmentsPage(true) } +private suspend fun CoderToolboxContext.showEnvironmentPage(envId: String) { + this.ui.showWindow() + this.envPageManager.showEnvironmentPage(envId, false) +} + class MissingArgumentException(message: String, ex: Throwable? = null) : IllegalArgumentException(message, ex) diff --git a/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt b/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt index d3adabc..3678813 100644 --- a/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt +++ b/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt @@ -1,10 +1,8 @@ package com.coder.toolbox.util import com.coder.toolbox.CoderToolboxContext -import com.coder.toolbox.browser.browse import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.ui.components.TextType -import java.net.URL /** * Dialog implementation for standalone Gateway. @@ -26,34 +24,4 @@ class DialogUi(private val context: CoderToolboxContext) { title, description, placeholder, TextType.General, context.i18n.ptrl("OK"), context.i18n.ptrl("Cancel") ) } - - suspend fun askPassword( - title: LocalizableString, - description: LocalizableString, - placeholder: LocalizableString? = null, - ): String? { - return context.ui.showTextInputPopup( - title, description, placeholder, TextType.Password, context.i18n.ptrl("OK"), context.i18n.ptrl("Cancel") - ) - } - - private suspend fun openUrl(url: URL) { - context.desktop.browse(url.toString()) { - context.ui.showErrorInfoPopup(it) - } - } - - /** - * Open a dialog for providing the token. - */ - suspend fun askToken( - url: URL, - ): String? { - openUrl(url.withPath("/login?redirect=%2Fcli-auth")) - return askPassword( - title = context.i18n.ptrl("Session Token"), - description = context.i18n.pnotr("Please paste the session token from the web-page"), - placeholder = context.i18n.pnotr("") - ) - } } diff --git a/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt b/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt index 1135227..0a15db8 100644 --- a/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt +++ b/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt @@ -3,7 +3,6 @@ package com.coder.toolbox.util const val URL = "url" const val TOKEN = "token" const val WORKSPACE = "workspace" -const val AGENT_NAME = "agent" const val AGENT_ID = "agent_id" private const val IDE_PRODUCT_CODE = "ide_product_code" private const val IDE_BUILD_NUMBER = "ide_build_number" diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index ceba2e9..73da796 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -137,4 +137,7 @@ msgid "Network Status" msgstr "" msgid "Create workspace" +msgstr "" + +msgid "Error encountered while handling Coder URI" msgstr "" \ No newline at end of file diff --git a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt index a7c6f72..4603fda 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -34,6 +34,7 @@ import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper +import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager @@ -66,6 +67,7 @@ internal class CoderCLIManagerTest { mockk(), mockk(), mockk(), + mockk(), mockk(), mockk(), mockk(), diff --git a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt index c32e7b1..2727228 100644 --- a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt +++ b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt @@ -24,6 +24,7 @@ import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper +import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager @@ -102,6 +103,7 @@ class CoderRestClientTest { mockk(), mockk(), mockk(), + mockk(), mockk(), mockk(), mockk(), diff --git a/src/test/kotlin/com/coder/toolbox/util/LinkHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt similarity index 59% rename from src/test/kotlin/com/coder/toolbox/util/LinkHandlerTest.kt rename to src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt index bb87151..2914eae 100644 --- a/src/test/kotlin/com/coder/toolbox/util/LinkHandlerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt @@ -1,41 +1,49 @@ package com.coder.toolbox.util +import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.sdk.DataGen -import com.sun.net.httpserver.HttpHandler -import com.sun.net.httpserver.HttpServer -import java.net.HttpURLConnection -import java.net.InetSocketAddress +import com.coder.toolbox.settings.Environment +import com.coder.toolbox.store.CoderSecretsStore +import com.coder.toolbox.store.CoderSettingsStore +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import com.jetbrains.toolbox.api.core.os.LocalDesktopManager +import com.jetbrains.toolbox.api.localization.LocalizableStringFactory +import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper +import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper +import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings +import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette +import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager +import com.jetbrains.toolbox.api.ui.ToolboxUi +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking import java.util.UUID import kotlin.test.Test -import kotlin.test.assertContains import kotlin.test.assertEquals -import kotlin.test.assertFailsWith - -internal class LinkHandlerTest { - /** - * Create, start, and return a server that uses the provided handler. - */ - private fun mockServer(handler: HttpHandler): Pair { - val srv = HttpServer.create(InetSocketAddress(0), 0) - srv.createContext("/", handler) - srv.start() - return Pair(srv, "http://localhost:" + srv.address.port) - } - - /** - * Create, start, and return a server that mocks redirects. - */ - private fun mockRedirectServer( - location: String, - temp: Boolean, - ): Pair = mockServer { exchange -> - exchange.responseHeaders.set("Location", location) - exchange.sendResponseHeaders( - if (temp) HttpURLConnection.HTTP_MOVED_TEMP else HttpURLConnection.HTTP_MOVED_PERM, - -1, - ) - exchange.close() - } +import kotlin.test.assertNull + +internal class CoderProtocolHandlerTest { + private val context = CoderToolboxContext( + mockk(relaxed = true), + mockk(), + mockk(), + mockk(), + mockk(), + mockk(), + mockk(), + mockk(relaxed = true), + mockk(relaxed = true), + CoderSettingsStore(pluginTestSettingsStore(), Environment(), mockk(relaxed = true)), + mockk(), + mockk() + ) + + private val protocolHandler = CoderProtocolHandler( + context, + DialogUi(context), + MutableStateFlow(false) + ) private val agents = mapOf( @@ -49,7 +57,7 @@ internal class LinkHandlerTest { ) @Test - fun getMatchingAgent() { + fun tstgetMatchingAgent() { val ws = DataGen.workspace("ws", agents = agents) val tests = @@ -74,9 +82,10 @@ internal class LinkHandlerTest { "b0e4c54d-9ba9-4413-8512-11ca1e826a24", ), ) - - tests.forEach { - assertEquals(UUID.fromString(it.second), getMatchingAgent(it.first, ws).id) + runBlocking { + tests.forEach { + assertEquals(UUID.fromString(it.second), protocolHandler.getMatchingAgent(it.first, ws)?.id) + } } } @@ -104,14 +113,10 @@ internal class LinkHandlerTest { "agent with ID", ), ) - - tests.forEach { - val ex = - assertFailsWith( - exceptionClass = it.second, - block = { getMatchingAgent(it.first, ws).id }, - ) - assertContains(ex.message.toString(), it.third) + runBlocking { + tests.forEach { + assertNull(protocolHandler.getMatchingAgent(it.first, ws)?.id) + } } } @@ -126,15 +131,16 @@ internal class LinkHandlerTest { mapOf("agent" to null), mapOf("agent_id" to null), ) - - tests.forEach { - assertEquals( - UUID.fromString("b0e4c54d-9ba9-4413-8512-11ca1e826a24"), - getMatchingAgent( - it, - ws, - ).id, - ) + runBlocking { + tests.forEach { + assertEquals( + UUID.fromString("b0e4c54d-9ba9-4413-8512-11ca1e826a24"), + protocolHandler.getMatchingAgent( + it, + ws, + )?.id, + ) + } } } @@ -149,14 +155,10 @@ internal class LinkHandlerTest { "agent with ID" ), ) - - tests.forEach { - val ex = - assertFailsWith( - exceptionClass = it.second, - block = { getMatchingAgent(it.first, ws).id }, - ) - assertContains(ex.message.toString(), it.third) + runBlocking { + tests.forEach { + assertNull(protocolHandler.getMatchingAgent(it.first, ws)?.id) + } } } @@ -177,43 +179,10 @@ internal class LinkHandlerTest { "has no agents" ), ) - - tests.forEach { - val ex = - assertFailsWith( - exceptionClass = it.second, - block = { getMatchingAgent(it.first, ws).id }, - ) - assertContains(ex.message.toString(), it.third) - } - } - - @Test - fun followsRedirects() { - val (srv1, url1) = - mockServer { exchange -> - exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, -1) - exchange.close() + runBlocking { + tests.forEach { + assertNull(protocolHandler.getMatchingAgent(it.first, ws)?.id) } - val (srv2, url2) = mockRedirectServer(url1, false) - val (srv3, url3) = mockRedirectServer(url2, true) - - assertEquals(url1.toURL(), resolveRedirects(java.net.URL(url3))) - - srv1.stop(0) - srv2.stop(0) - srv3.stop(0) - } - - @Test - fun followsMaximumRedirects() { - val (srv, url) = mockRedirectServer(".", true) - - assertFailsWith( - exceptionClass = Exception::class, - block = { resolveRedirects(java.net.URL(url)) }, - ) - - srv.stop(0) + } } } From b3d0b731419ca414a86e49092d7de45a54c6f6cd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 10 Jun 2025 23:00:49 +0300 Subject: [PATCH 10/93] Changelog update - `v0.3.0` (#127) Current pull request contains patched `CHANGELOG.md` file for the `v0.3.0` version. Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa101fd..0a4be5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.3.0 - 2025-06-10 + ### Added - support for Toolbox 2.6.3 with improved URI handling From 77f39cf09fe9118ac399512375259a470c42c635 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 12 Jun 2025 01:31:00 +0300 Subject: [PATCH 11/93] fix: Stop action should be available when workspace is out of date (#129) Similarly to the web dashboard, `Stop` should be available alongside `Update and restart` action when a workspace is running but with a template out of date. --- CHANGELOG.md | 4 ++++ .../com/coder/toolbox/CoderRemoteEnvironment.kt | 17 ++++++++--------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a4be5d..a8c780b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixed + +- `Stop` action is now available for running workspaces that have an out of date template. + ## 0.3.0 - 2025-06-10 ### Added diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index 0608817..068f519 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -126,16 +126,15 @@ class CoderRemoteEnvironment( update(workspace.copy(latestBuild = build), agent) } }) - } else { - actions.add(Action(context.i18n.ptrl("Stop")) { - context.cs.launch { - tryStopSshConnection() - - val build = client.stopWorkspace(workspace) - update(workspace.copy(latestBuild = build), agent) - } - }) } + actions.add(Action(context.i18n.ptrl("Stop")) { + context.cs.launch { + tryStopSshConnection() + + val build = client.stopWorkspace(workspace) + update(workspace.copy(latestBuild = build), agent) + } + }) } return actions } From 792dba9fb1b7626dbff8e055ab643368912bed19 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 12 Jun 2025 22:22:42 +0300 Subject: [PATCH 12/93] fix: update&start outdated workspaces (#128) URI handling was not able to start workspaces that were stopped and also outdated. With this PR we check the ws status and call the proper REST endpoint depending on whether the workspace is outdated or not. During URI handling we can wait for workspaces to start up. The existing implementation had no visual feedback that we are waiting/doing anything. With this PR the header bar shows a nice work in progress visual. Additionally, the deployment URL was not properly refreshed when switching between different urls via URI handling. This is also fixed by forcing TBX to render a blank page for a very short period of time and then going back to the main page. --- CHANGELOG.md | 1 + .../com/coder/toolbox/CoderRemoteProvider.kt | 22 +++++++++---- .../com/coder/toolbox/CoderToolboxContext.kt | 25 +++++++++++++++ .../toolbox/util/CoderProtocolHandler.kt | 32 +++++++++++++++---- .../com/coder/toolbox/views/AuthWizardPage.kt | 2 +- .../com/coder/toolbox/views/CoderPage.kt | 3 ++ .../com/coder/toolbox/views/ConnectStep.kt | 2 +- 7 files changed, 72 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8c780b..801c048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### 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 ## 0.3.0 - 2025-06-10 diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 7aabdce..0e01ee3 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -9,7 +9,6 @@ import com.coder.toolbox.util.DialogUi import com.coder.toolbox.util.withPath import com.coder.toolbox.views.Action import com.coder.toolbox.views.AuthWizardPage -import com.coder.toolbox.views.CoderPage import com.coder.toolbox.views.CoderSettingsPage import com.coder.toolbox.views.NewEnvironmentPage import com.coder.toolbox.views.state.AuthWizardState @@ -110,7 +109,6 @@ class CoderRemoteProvider( return@launch } - // Reconfigure if environments changed. if (lastEnvironments.size != resolvedEnvironments.size || lastEnvironments != resolvedEnvironments) { context.logger.info("Workspaces have changed, reconfiguring CLI: $resolvedEnvironments") @@ -269,12 +267,25 @@ class CoderRemoteProvider( * Handle incoming links (like from the dashboard). */ override suspend fun handleUri(uri: URI) { - linkHandler.handle(uri, shouldDoAutoLogin()) { restClient, cli -> + linkHandler.handle( + uri, shouldDoAutoLogin(), + { + coderHeaderPage.isBusyCreatingNewEnvironment.update { + true + } + }, + { + coderHeaderPage.isBusyCreatingNewEnvironment.update { + false + } + } + ) { restClient, cli -> // stop polling and de-initialize resources close() // start initialization with the new settings this@CoderRemoteProvider.client = restClient coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(restClient.url.toString())) + environments.showLoadingMessage() pollJob = poll(restClient, cli) } @@ -332,7 +343,7 @@ class CoderRemoteProvider( private fun shouldDoAutoLogin(): Boolean = firstRun && context.secrets.rememberMe == true - private fun onConnect(client: CoderRestClient, cli: CoderCLIManager) { + private suspend fun onConnect(client: CoderRestClient, cli: CoderCLIManager) { // Store the URL and token for use next time. context.secrets.lastDeploymentURL = client.url.toString() context.secrets.lastToken = client.token ?: "" @@ -344,8 +355,7 @@ class CoderRemoteProvider( environments.showLoadingMessage() coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(client.url.toString())) pollJob = poll(client, cli) - context.ui.showUiPage(CoderPage.emptyPage(context)) - goToEnvironmentsPage() + context.refreshMainPage() } private fun MutableStateFlow>>.showLoadingMessage() { diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index 4291321..0bb4135 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -3,6 +3,7 @@ package com.coder.toolbox import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore import com.coder.toolbox.util.toURL +import com.coder.toolbox.views.CoderPage import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory @@ -13,8 +14,10 @@ import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager import com.jetbrains.toolbox.api.ui.ToolboxUi import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import java.net.URL import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds @Suppress("UnstableApiUsage") data class CoderToolboxContext( @@ -88,4 +91,26 @@ data class CoderToolboxContext( i18n.ptrl("OK") ) } + + /** + * Forces the title bar on the main page to be refreshed + */ + suspend fun refreshMainPage() { + // the url/title on the main page is only refreshed if + // we're navigating to the main env page from another page. + // If TBX is already on the main page the title is not refreshed + // hence we force a navigation from a blank page. + ui.showUiPage(CoderPage.emptyPage(this)) + + + // Toolbox uses an internal shared flow with a buffer of 4 items and a DROP_OLDEST strategy. + // Both showUiPage and showPluginEnvironmentsPage send events to this flow. + // If we emit two events back-to-back, the first one often gets dropped and only the second is shown. + // To reduce this risk, we add a small delay to let the UI coroutine process the first event. + // Simply yielding the coroutine isn't reliable, especially right after Toolbox starts via URI handling. + // Based on my testing, a 5–10 ms delay is enough to ensure the blank page is processed, + // while still short enough to be invisible to users. + delay(10.milliseconds) + envPageManager.showPluginEnvironmentsPage() + } } diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index af07548..90f3465 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -43,6 +43,8 @@ open class CoderProtocolHandler( suspend fun handle( uri: URI, shouldWaitForAutoLogin: Boolean, + markAsBusy: () -> Unit, + unmarkAsBusy: () -> Unit, reInitialize: suspend (CoderRestClient, CoderCLIManager) -> Unit ) { val params = uri.toQueryParameters() @@ -62,16 +64,27 @@ open class CoderProtocolHandler( val workspaceName = resolveWorkspaceName(params) ?: return val restClient = buildRestClient(deploymentURL, token) ?: return val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL) ?: return - if (!prepareWorkspace(workspace, restClient, workspaceName, deploymentURL)) return - - // we resolve the agent after the workspace is started otherwise we can get misleading - // errors like: no agent available while workspace is starting or stopping - val agent = resolveAgent(params, workspace) ?: return - if (!ensureAgentIsReady(workspace, agent)) return val cli = configureCli(deploymentURL, restClient) reInitialize(restClient, cli) + var agent: WorkspaceAgent + try { + markAsBusy() + context.refreshMainPage() + if (!prepareWorkspace(workspace, restClient, workspaceName, deploymentURL)) return + // we resolve the agent after the workspace is started otherwise we can get misleading + // errors like: no agent available while workspace is starting or stopping + // we also need to retrieve the workspace again to have the latest resources (ex: agent) + // attached to the workspace. + agent = resolveAgent( + params, + restClient.workspace(workspace.id) + ) ?: return + if (!ensureAgentIsReady(workspace, agent)) return + } finally { + unmarkAsBusy() + } val environmentId = "${workspace.name}.${agent.name}" context.showEnvironmentPage(environmentId) @@ -173,7 +186,11 @@ open class CoderProtocolHandler( } try { - restClient.startWorkspace(workspace) + if (workspace.outdated) { + restClient.updateWorkspace(workspace) + } else { + restClient.startWorkspace(workspace) + } } catch (e: Exception) { context.logAndShowError( CAN_T_HANDLE_URI_TITLE, @@ -428,6 +445,7 @@ open class CoderProtocolHandler( } } + private fun CoderToolboxContext.popupPluginMainPage() { this.ui.showWindow() this.envPageManager.showPluginEnvironmentsPage(true) diff --git a/src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt index 06009d7..d7f0dbf 100644 --- a/src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt @@ -16,7 +16,7 @@ class AuthWizardPage( private val context: CoderToolboxContext, private val settingsPage: CoderSettingsPage, initialAutoLogin: Boolean = false, - onConnect: ( + onConnect: suspend ( client: CoderRestClient, cli: CoderCLIManager, ) -> Unit, diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt index ac94a36..43c6fa5 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt @@ -6,6 +6,7 @@ import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon.IconType import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription import com.jetbrains.toolbox.api.ui.components.UiPage +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import java.util.UUID @@ -39,6 +40,8 @@ abstract class CoderPage( SvgIcon(byteArrayOf(), type = IconType.Masked) } + override val isBusyCreatingNewEnvironment: MutableStateFlow = MutableStateFlow(false) + /** * Show an error as a popup on this page. */ diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index 7875728..58e154e 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -28,7 +28,7 @@ class ConnectStep( private val shouldAutoLogin: StateFlow, private val notify: (String, Throwable) -> Unit, private val refreshWizard: () -> Unit, - private val onConnect: ( + private val onConnect: suspend ( client: CoderRestClient, cli: CoderCLIManager, ) -> Unit, From 5dfbdcad78c103fd93a8805acfe82c8508baa91d Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 12 Jun 2025 23:13:22 +0300 Subject: [PATCH 13/93] 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 --- CHANGELOG.md | 6 +++ README.md | 36 ++++++++------ .../coder/toolbox/CoderRemoteEnvironment.kt | 26 +++++++--- .../com/coder/toolbox/CoderRemoteProvider.kt | 47 ++++++++++++++++--- .../toolbox/WorkspaceConnectionManager.kt | 22 +++++++++ .../toolbox/sdk/ex/APIResponseException.kt | 3 +- .../com/coder/toolbox/views/AuthWizardPage.kt | 45 +++++++++++++++++- .../com/coder/toolbox/views/CoderPage.kt | 18 ------- .../coder/toolbox/views/CoderSettingsPage.kt | 2 +- .../coder/toolbox/views/NewEnvironmentPage.kt | 2 +- .../resources/localization/defaultMessages.po | 3 ++ 11 files changed, 159 insertions(+), 51 deletions(-) create mode 100644 src/main/kotlin/com/coder/toolbox/WorkspaceConnectionManager.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 801c048..a61780a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 2034504..56636f9 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -164,7 +166,7 @@ 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. @@ -172,10 +174,10 @@ There is no need to restart Toolbox, as it will begin logging at the __DEBUG__ l #### 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 @@ -183,19 +185,20 @@ Workspaces page in Coder or within the individual workspace view, under the opti 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. @@ -203,7 +206,8 @@ 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. @@ -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 @@ -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 diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index 068f519..3c4de20 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -234,8 +234,7 @@ 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, @@ -243,19 +242,30 @@ class CoderRemoteEnvironment( ) /** - * 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? { @@ -298,6 +308,8 @@ class CoderRemoteEnvironment( } } + fun isConnected(): Boolean = isConnected.value + /** * An environment is equal if it has the same ID. */ diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 0e01ee3..d72b130 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -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 @@ -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 @@ -65,10 +65,18 @@ class CoderRemoteProvider( private val isInitialized: MutableStateFlow = MutableStateFlow(false) private var coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(context.deploymentUrl.toString())) private val linkHandler = CoderProtocolHandler(context, dialogUi, isInitialized) - override val environments: MutableStateFlow>> = MutableStateFlow( + + override val environments: MutableStateFlow>> = 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 @@ -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 @@ -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 @@ -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 } @@ -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() } @@ -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). @@ -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) } @@ -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) @@ -358,7 +391,7 @@ class CoderRemoteProvider( context.refreshMainPage() } - private fun MutableStateFlow>>.showLoadingMessage() { + private fun MutableStateFlow>>.showLoadingMessage() { this.update { LoadableState.Loading } diff --git a/src/main/kotlin/com/coder/toolbox/WorkspaceConnectionManager.kt b/src/main/kotlin/com/coder/toolbox/WorkspaceConnectionManager.kt new file mode 100644 index 0000000..9196729 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/WorkspaceConnectionManager.kt @@ -0,0 +1,22 @@ +package com.coder.toolbox + +object WorkspaceConnectionManager { + private val workspaceConnectionState = mutableMapOf() + + var shouldEstablishWorkspaceConnections = false + + fun allConnected(): Set = workspaceConnectionState.filter { it.value }.map { it.key }.toSet() + + fun collectStatuses(workspaces: Set) { + workspaces.forEach { register(it.id, it.isConnected()) } + } + + private fun register(wsId: String, isConnected: Boolean) { + workspaceConnectionState[wsId] = isConnected + } + + fun reset() { + workspaceConnectionState.clear() + shouldEstablishWorkspaceConnections = false + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/sdk/ex/APIResponseException.kt b/src/main/kotlin/com/coder/toolbox/sdk/ex/APIResponseException.kt index 9f78198..d109c75 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/ex/APIResponseException.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/ex/APIResponseException.kt @@ -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( diff --git a/src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt index d7f0dbf..affa96e 100644 --- a/src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt @@ -3,24 +3,29 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.sdk.CoderRestClient +import com.coder.toolbox.sdk.ex.APIResponseException import com.coder.toolbox.util.toURL import com.coder.toolbox.views.state.AuthContext import com.coder.toolbox.views.state.AuthWizardState import com.coder.toolbox.views.state.WizardStep +import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription import com.jetbrains.toolbox.api.ui.components.UiField import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.util.UUID class AuthWizardPage( private val context: CoderToolboxContext, private val settingsPage: CoderSettingsPage, + private val visibilityState: MutableStateFlow, initialAutoLogin: Boolean = false, onConnect: suspend ( client: CoderRestClient, cli: CoderCLIManager, ) -> Unit, -) : CoderPage(context, context.i18n.ptrl("Authenticate to Coder"), false) { +) : CoderPage(context.i18n.ptrl("Authenticate to Coder"), false) { private val shouldAutoLogin = MutableStateFlow(initialAutoLogin) private val settingsAction = Action(context.i18n.ptrl("Settings"), actionBlock = { context.ui.showUiPage(settingsPage) @@ -42,6 +47,8 @@ class AuthWizardPage( override val fields: MutableStateFlow> = MutableStateFlow(emptyList()) override val actionButtons: MutableStateFlow> = MutableStateFlow(emptyList()) + private val errorBuffer = mutableListOf() + init { if (shouldAutoLogin.value) { AuthContext.url = context.secrets.lastDeploymentURL.toURL() @@ -51,6 +58,12 @@ class AuthWizardPage( override fun beforeShow() { displaySteps() + if (errorBuffer.isNotEmpty() && visibilityState.value.applicationVisible) { + errorBuffer.forEach { + showError(it) + } + errorBuffer.clear() + } } private fun displaySteps() { @@ -113,4 +126,34 @@ class AuthWizardPage( } } } + + /** + * Show an error as a popup on this page. + */ + fun notify(logPrefix: String, ex: Throwable) { + context.logger.error(ex, logPrefix) + if (!visibilityState.value.applicationVisible) { + context.logger.debug("Toolbox is not yet visible, scheduling error to be displayed later") + errorBuffer.add(ex) + return + } + showError(ex) + } + + private fun showError(ex: Throwable) { + val textError = if (ex is APIResponseException) { + if (!ex.reason.isNullOrBlank()) { + ex.reason + } else ex.message + } else ex.message + + context.cs.launch { + context.ui.showSnackbar( + UUID.randomUUID().toString(), + context.i18n.ptrl("Error encountered during authentication"), + context.i18n.pnotr(textError ?: ""), + context.i18n.ptrl("Dismiss") + ) + } + } } diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt index 43c6fa5..9b83f45 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt @@ -7,8 +7,6 @@ import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription import com.jetbrains.toolbox.api.ui.components.UiPage import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch -import java.util.UUID /** * Base page that handles the icon, displaying error notifications, and @@ -21,7 +19,6 @@ import java.util.UUID * to use the mouse. */ abstract class CoderPage( - private val context: CoderToolboxContext, title: LocalizableString, showIcon: Boolean = true, ) : UiPage(title) { @@ -42,21 +39,6 @@ abstract class CoderPage( override val isBusyCreatingNewEnvironment: MutableStateFlow = MutableStateFlow(false) - /** - * Show an error as a popup on this page. - */ - fun notify(logPrefix: String, ex: Throwable) { - context.logger.error(ex, logPrefix) - context.cs.launch { - context.ui.showSnackbar( - UUID.randomUUID().toString(), - context.i18n.pnotr(logPrefix), - context.i18n.pnotr(ex.message ?: ""), - context.i18n.ptrl("Dismiss") - ) - } - } - companion object { fun emptyPage(ctx: CoderToolboxContext): UiPage = UiPage(ctx.i18n.pnotr("")) } diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index f888c3d..de2ce0b 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -20,7 +20,7 @@ import kotlinx.coroutines.launch * I have not been able to test this page. */ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel) : - CoderPage(context, context.i18n.ptrl("Coder Settings"), false) { + CoderPage(context.i18n.ptrl("Coder Settings"), false) { private val settings = context.settingsStore.readOnly() // TODO: Copy over the descriptions, holding until I can test this page. diff --git a/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt b/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt index 56b2910..83e07c7 100644 --- a/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt @@ -15,6 +15,6 @@ import kotlinx.coroutines.flow.StateFlow * support creating environments from the plugin. */ class NewEnvironmentPage(context: CoderToolboxContext, deploymentURL: LocalizableString) : - CoderPage(context, deploymentURL) { + CoderPage(deploymentURL) { override val fields: StateFlow> = MutableStateFlow(emptyList()) } diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index 73da796..1b04695 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -140,4 +140,7 @@ msgid "Create workspace" msgstr "" msgid "Error encountered while handling Coder URI" +msgstr "" + +msgid "Error encountered during authentication" msgstr "" \ No newline at end of file From 81d7c29736627a58c34dfb4c333e0204ea3a2431 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Jun 2025 23:07:17 +0300 Subject: [PATCH 14/93] chore: bump retrofit from 2.11.0 to 3.0.0 (#113) Bumps `retrofit` from 2.11.0 to 3.0.0. Updates `com.squareup.retrofit2:retrofit` from 2.11.0 to 3.0.0
Release notes

Sourced from com.squareup.retrofit2:retrofit's releases.

3.0.0

Changed

  • Upgrade to OkHttp 4.12 (from 3.14).

    This is the version of OkHttp that is written in Kotlin, and as a result Retrofit now has a transitive Kotlin dependency. However, this is also the supported version of OkHttp whereas the previous version was out of support for nearly 4 years.

Note: The 3.x versions of Retrofit maintain forward binary-compatibility with the 2.x versions. This means libraries compiled against 2.x can still be used with the 3.x versions.

2.12.0

New

  • First-party converters now support deferring serialization to happen when the request body is written (i.e., during HTTP execution) rather than when the HTTP request is created. In some cases this moves conversion from a calling thread to a background thread, such as in the case when using Call.enqueue directly.

    The following converters support this feature through a new withStreaming() factory method:

    • Gson
    • Jackson
    • Moshi
    • Protobuf
    • Wire

Fixed

  • Primitive types used with @Tag now work by storing the value boxed with the boxed class as the key.
Changelog

Sourced from com.squareup.retrofit2:retrofit's changelog.

3.0.0 - 2025-05-15

Changed

  • Upgrade to OkHttp 4.12 (from 3.14).

    This is the version of OkHttp that is written in Kotlin, and as a result Retrofit now has a transitive Kotlin dependency. However, this is also the supported version of OkHttp whereas the previous version was out of support for nearly 4 years.

Note: The 3.x versions of Retrofit maintain forward binary-compatibility with the 2.x versions. This means libraries compiled against 2.x can still be used with the 3.x versions.

2.12.0 - 2025-05-15

New

  • First-party converters now support deferring serialization to happen when the request body is written (i.e., during HTTP execution) rather than when the HTTP request is created. In some cases this moves conversion from a calling thread to a background thread, such as in the case when using Call.enqueue directly.

    The following converters support this feature through a new withStreaming() factory method:

    • Gson
    • Jackson
    • Moshi
    • Protobuf
    • Wire

Fixed

  • Primitive types used with @Tag now work by storing the value boxed with the boxed class as the key.
Commits

Updates `com.squareup.retrofit2:converter-moshi` from 2.11.0 to 3.0.0
Release notes

Sourced from com.squareup.retrofit2:converter-moshi's releases.

3.0.0

Changed

  • Upgrade to OkHttp 4.12 (from 3.14).

    This is the version of OkHttp that is written in Kotlin, and as a result Retrofit now has a transitive Kotlin dependency. However, this is also the supported version of OkHttp whereas the previous version was out of support for nearly 4 years.

Note: The 3.x versions of Retrofit maintain forward binary-compatibility with the 2.x versions. This means libraries compiled against 2.x can still be used with the 3.x versions.

2.12.0

New

  • First-party converters now support deferring serialization to happen when the request body is written (i.e., during HTTP execution) rather than when the HTTP request is created. In some cases this moves conversion from a calling thread to a background thread, such as in the case when using Call.enqueue directly.

    The following converters support this feature through a new withStreaming() factory method:

    • Gson
    • Jackson
    • Moshi
    • Protobuf
    • Wire

Fixed

  • Primitive types used with @Tag now work by storing the value boxed with the boxed class as the key.
Changelog

Sourced from com.squareup.retrofit2:converter-moshi's changelog.

3.0.0 - 2025-05-15

Changed

  • Upgrade to OkHttp 4.12 (from 3.14).

    This is the version of OkHttp that is written in Kotlin, and as a result Retrofit now has a transitive Kotlin dependency. However, this is also the supported version of OkHttp whereas the previous version was out of support for nearly 4 years.

Note: The 3.x versions of Retrofit maintain forward binary-compatibility with the 2.x versions. This means libraries compiled against 2.x can still be used with the 3.x versions.

2.12.0 - 2025-05-15

New

  • First-party converters now support deferring serialization to happen when the request body is written (i.e., during HTTP execution) rather than when the HTTP request is created. In some cases this moves conversion from a calling thread to a background thread, such as in the case when using Call.enqueue directly.

    The following converters support this feature through a new withStreaming() factory method:

    • Gson
    • Jackson
    • Moshi
    • Protobuf
    • Wire

Fixed

  • Primitive types used with @Tag now work by storing the value boxed with the boxed class as the key.
Commits

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 01bb486..0db6399 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,7 @@ gradle-wrapper = "0.14.0" exec = "1.12" moshi = "1.15.2" ksp = "2.1.10-1.0.31" -retrofit = "2.11.0" +retrofit = "3.0.0" changelog = "2.2.1" gettext = "0.7.0" plugin-structure = "3.307" From 8eb08e96040d0133c32eb2c11df2df7ddad51048 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 18 Jun 2025 21:07:56 +0300 Subject: [PATCH 15/93] fix: install the build number if available (#131) This PR fixes an issue with URI handling which was not installing the exact same version requested by the user if the version was available for install on the workspace. --- CHANGELOG.md | 1 + gradle.properties | 2 +- .../kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt | 6 +++--- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a61780a..038ecb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - `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. +- URI handling now installs the exact build number if it is available for the workspace. ## 0.3.0 - 2025-06-10 diff --git a/gradle.properties b/gradle.properties index a6129a9..759f5c9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.3.0 +version=0.3.1 group=com.coder.toolbox name=coder-toolbox diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 90f3465..c605dec 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -380,8 +380,8 @@ open class CoderProtocolHandler( return null } - val matchingBuildNumber = availableVersions.firstOrNull { it.contains(buildNumber) } != null - if (!matchingBuildNumber) { + val buildNumberIsNotAvailable = availableVersions.firstOrNull { it.contains(buildNumber) } == null + if (buildNumberIsNotAvailable) { val selectedIde = availableVersions.maxOf { it } context.logAndShowInfo( "$productCode-$buildNumber not available", @@ -389,7 +389,7 @@ open class CoderProtocolHandler( ) return selectedIde } - return null + return "$productCode-$buildNumber" } private fun installJBClient(selectedIde: String, environmentId: String): Job = context.cs.launch { From 2eb4848750594806db7e74c4ba33f70e1adb997f Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 19 Jun 2025 20:55:17 +0300 Subject: [PATCH 16/93] impl: visual text progress during Coder CLI downloading (#130) This PR implements a mechanism to provide recurrent stats about the number of the KB and MB of Coder CLI downloaded. --- CHANGELOG.md | 4 ++ .../com/coder/toolbox/CoderRemoteProvider.kt | 29 ++++----- .../com/coder/toolbox/cli/CoderCLIManager.kt | 61 +++++++++++++++---- .../com/coder/toolbox/sdk/CoderRestClient.kt | 12 ++-- .../toolbox/util/CoderProtocolHandler.kt | 6 +- ...zardPage.kt => CoderCliSetupWizardPage.kt} | 38 ++++++------ .../com/coder/toolbox/views/ConnectStep.kt | 49 +++++++-------- .../{SignInStep.kt => DeploymentUrlStep.kt} | 13 ++-- .../com/coder/toolbox/views/TokenStep.kt | 18 +++--- .../com/coder/toolbox/views/WizardStep.kt | 2 - ...AuthContext.kt => CoderCliSetupContext.kt} | 4 +- ...rdState.kt => CoderCliSetupWizardState.kt} | 6 +- .../resources/localization/defaultMessages.po | 7 ++- .../coder/toolbox/cli/CoderCLIManagerTest.kt | 37 +++++------ 14 files changed, 166 insertions(+), 120 deletions(-) rename src/main/kotlin/com/coder/toolbox/views/{AuthWizardPage.kt => CoderCliSetupWizardPage.kt} (80%) rename src/main/kotlin/com/coder/toolbox/views/{SignInStep.kt => DeploymentUrlStep.kt} (85%) rename src/main/kotlin/com/coder/toolbox/views/state/{AuthContext.kt => CoderCliSetupContext.kt} (89%) rename src/main/kotlin/com/coder/toolbox/views/state/{AuthWizardState.kt => CoderCliSetupWizardState.kt} (82%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 038ecb5..368f967 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- visual text progress during Coder CLI downloading + ### Changed - the plugin will now remember the SSH connection state for each workspace, and it will try to automatically diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index d72b130..101cf71 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -9,10 +9,10 @@ import com.coder.toolbox.util.CoderProtocolHandler import com.coder.toolbox.util.DialogUi import com.coder.toolbox.util.withPath import com.coder.toolbox.views.Action -import com.coder.toolbox.views.AuthWizardPage +import com.coder.toolbox.views.CoderCliSetupWizardPage import com.coder.toolbox.views.CoderSettingsPage import com.coder.toolbox.views.NewEnvironmentPage -import com.coder.toolbox.views.state.AuthWizardState +import com.coder.toolbox.views.state.CoderCliSetupWizardState import com.coder.toolbox.views.state.WizardStep import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon.IconType @@ -242,7 +242,7 @@ class CoderRemoteProvider( environments.value = LoadableState.Value(emptyList()) isInitialized.update { false } client = null - AuthWizardState.resetSteps() + CoderCliSetupWizardState.resetSteps() } override val svgIcon: SvgIcon = @@ -301,7 +301,7 @@ class CoderRemoteProvider( */ override suspend fun handleUri(uri: URI) { linkHandler.handle( - uri, shouldDoAutoLogin(), + uri, shouldDoAutoSetup(), { coderHeaderPage.isBusyCreatingNewEnvironment.update { true @@ -343,17 +343,17 @@ class CoderRemoteProvider( * list. */ override fun getOverrideUiPage(): UiPage? { - // Show sign in page if we have not configured the client yet. + // Show the setup page if we have not configured the client yet. if (client == null) { val errorBuffer = mutableListOf() - // When coming back to the application, authenticate immediately. - val autologin = shouldDoAutoLogin() + // When coming back to the application, initializeSession immediately. + val autoSetup = shouldDoAutoSetup() context.secrets.lastToken.let { lastToken -> context.secrets.lastDeploymentURL.let { lastDeploymentURL -> - if (autologin && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !settings.requireTokenAuth)) { + if (autoSetup && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !settings.requireTokenAuth)) { try { - AuthWizardState.goToStep(WizardStep.LOGIN) - return AuthWizardPage(context, settingsPage, visibilityState, true, ::onConnect) + CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) + return CoderCliSetupWizardPage(context, settingsPage, visibilityState, true, ::onConnect) } catch (ex: Exception) { errorBuffer.add(ex) } @@ -363,18 +363,19 @@ class CoderRemoteProvider( firstRun = false // Login flow. - val authWizard = AuthWizardPage(context, settingsPage, visibilityState, onConnect = ::onConnect) + val setupWizardPage = + CoderCliSetupWizardPage(context, settingsPage, visibilityState, onConnect = ::onConnect) // We might have navigated here due to a polling error. errorBuffer.forEach { - authWizard.notify("Error encountered", it) + setupWizardPage.notify("Error encountered", it) } // and now reset the errors, otherwise we show it every time on the screen - return authWizard + return setupWizardPage } return null } - private fun shouldDoAutoLogin(): Boolean = firstRun && context.secrets.rememberMe == true + private fun shouldDoAutoSetup(): Boolean = firstRun && context.secrets.rememberMe == true private suspend fun onConnect(client: CoderRestClient, cli: CoderCLIManager) { // Store the URL and token for use next time. diff --git a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt index 2898179..e4ef501 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt @@ -32,7 +32,7 @@ import java.net.HttpURLConnection import java.net.URL import java.nio.file.Files import java.nio.file.Path -import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption import java.util.zip.GZIPInputStream import javax.net.ssl.HttpsURLConnection @@ -44,6 +44,8 @@ internal data class Version( @Json(name = "version") val version: String, ) +private const val DOWNLOADING_CODER_CLI = "Downloading Coder CLI..." + /** * Do as much as possible to get a valid, up-to-date CLI. * @@ -60,6 +62,7 @@ fun ensureCLI( context: CoderToolboxContext, deploymentURL: URL, buildVersion: String, + showTextProgress: (String) -> Unit ): CoderCLIManager { val settings = context.settingsStore.readOnly() val cli = CoderCLIManager(deploymentURL, context.logger, settings) @@ -76,9 +79,10 @@ fun ensureCLI( // If downloads are enabled download the new version. if (settings.enableDownloads) { - context.logger.info("Downloading Coder CLI...") + context.logger.info(DOWNLOADING_CODER_CLI) + showTextProgress(DOWNLOADING_CODER_CLI) try { - cli.download() + cli.download(buildVersion, showTextProgress) return cli } catch (e: java.nio.file.AccessDeniedException) { // Might be able to fall back to the data directory. @@ -98,8 +102,9 @@ fun ensureCLI( } if (settings.enableDownloads) { - context.logger.info("Downloading Coder CLI...") - dataCLI.download() + context.logger.info(DOWNLOADING_CODER_CLI) + showTextProgress(DOWNLOADING_CODER_CLI) + dataCLI.download(buildVersion, showTextProgress) return dataCLI } @@ -137,7 +142,7 @@ class CoderCLIManager( /** * Download the CLI from the deployment if necessary. */ - fun download(): Boolean { + fun download(buildVersion: String, showTextProgress: (String) -> Unit): Boolean { val eTag = getBinaryETag() val conn = remoteBinaryURL.openConnection() as HttpURLConnection if (!settings.headerCommand.isNullOrBlank()) { @@ -162,13 +167,27 @@ class CoderCLIManager( when (conn.responseCode) { HttpURLConnection.HTTP_OK -> { logger.info("Downloading binary to $localBinaryPath") + Files.deleteIfExists(localBinaryPath) Files.createDirectories(localBinaryPath.parent) - conn.inputStream.use { - Files.copy( - if (conn.contentEncoding == "gzip") GZIPInputStream(it) else it, - localBinaryPath, - StandardCopyOption.REPLACE_EXISTING, - ) + val outputStream = Files.newOutputStream( + localBinaryPath, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING + ) + val sourceStream = if (conn.isGzip()) GZIPInputStream(conn.inputStream) else conn.inputStream + + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytesRead: Int + var totalRead = 0L + + sourceStream.use { source -> + outputStream.use { sink -> + while (source.read(buffer).also { bytesRead = it } != -1) { + sink.write(buffer, 0, bytesRead) + totalRead += bytesRead + showTextProgress("${settings.defaultCliBinaryNameByOsAndArch} $buildVersion - ${totalRead.toHumanReadableSize()} downloaded") + } + } } if (getOS() != OS.WINDOWS) { localBinaryPath.toFile().setExecutable(true) @@ -178,6 +197,7 @@ class CoderCLIManager( HttpURLConnection.HTTP_NOT_MODIFIED -> { logger.info("Using cached binary at $localBinaryPath") + showTextProgress("Using cached binary") return false } } @@ -190,6 +210,21 @@ class CoderCLIManager( throw ResponseException("Unexpected response from $remoteBinaryURL", conn.responseCode) } + private fun HttpURLConnection.isGzip(): Boolean = this.contentEncoding.equals("gzip", ignoreCase = true) + + fun Long.toHumanReadableSize(): String { + if (this < 1024) return "$this B" + + val kb = this / 1024.0 + if (kb < 1024) return String.format("%.1f KB", kb) + + val mb = kb / 1024.0 + if (mb < 1024) return String.format("%.1f MB", mb) + + val gb = mb / 1024.0 + return String.format("%.1f GB", gb) + } + /** * Return the entity tag for the binary on disk, if any. */ @@ -203,7 +238,7 @@ class CoderCLIManager( } /** - * Use the provided token to authenticate the CLI. + * Use the provided token to initializeSession the CLI. */ fun login(token: String): String { logger.info("Storing CLI credentials in $coderConfigPath") diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 9f619bc..365e1ed 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -131,12 +131,11 @@ open class CoderRestClient( } /** - * Authenticate and load information about the current user and the build - * version. + * Load information about the current user and the build version. * * @throws [APIResponseException]. */ - suspend fun authenticate(): User { + suspend fun initializeSession(): User { me = me() buildVersion = buildInfo().version return me @@ -149,7 +148,12 @@ open class CoderRestClient( suspend fun me(): User { val userResponse = retroRestClient.me() if (!userResponse.isSuccessful) { - throw APIResponseException("authenticate", url, userResponse.code(), userResponse.parseErrorBody(moshi)) + throw APIResponseException( + "initializeSession", + url, + userResponse.code(), + userResponse.parseErrorBody(moshi) + ) } return userResponse.body()!! diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index c605dec..7d24029 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -24,6 +24,7 @@ import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration private const val CAN_T_HANDLE_URI_TITLE = "Can't handle URI" +private val noOpTextProgress: (String) -> Unit = { _ -> } @Suppress("UnstableApiUsage") open class CoderProtocolHandler( @@ -143,7 +144,7 @@ open class CoderProtocolHandler( if (settings.requireTokenAuth) token else null, PluginManager.pluginInfo.version ) - client.authenticate() + client.initializeSession() return client } @@ -304,7 +305,8 @@ open class CoderProtocolHandler( val cli = ensureCLI( context, deploymentURL.toURL(), - restClient.buildInfo().version + restClient.buildInfo().version, + noOpTextProgress ) // We only need to log in if we are using token-based auth. diff --git a/src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt similarity index 80% rename from src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt rename to src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt index affa96e..c6193da 100644 --- a/src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt @@ -5,8 +5,8 @@ import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.ex.APIResponseException import com.coder.toolbox.util.toURL -import com.coder.toolbox.views.state.AuthContext -import com.coder.toolbox.views.state.AuthWizardState +import com.coder.toolbox.views.state.CoderCliSetupContext +import com.coder.toolbox.views.state.CoderCliSetupWizardState import com.coder.toolbox.views.state.WizardStep import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription @@ -16,26 +16,26 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.util.UUID -class AuthWizardPage( +class CoderCliSetupWizardPage( private val context: CoderToolboxContext, private val settingsPage: CoderSettingsPage, private val visibilityState: MutableStateFlow, - initialAutoLogin: Boolean = false, + initialAutoSetup: Boolean = false, onConnect: suspend ( client: CoderRestClient, cli: CoderCLIManager, ) -> Unit, -) : CoderPage(context.i18n.ptrl("Authenticate to Coder"), false) { - private val shouldAutoLogin = MutableStateFlow(initialAutoLogin) +) : CoderPage(context.i18n.ptrl("Setting up Coder"), false) { + private val shouldAutoSetup = MutableStateFlow(initialAutoSetup) private val settingsAction = Action(context.i18n.ptrl("Settings"), actionBlock = { context.ui.showUiPage(settingsPage) }) - private val signInStep = SignInStep(context, this::notify) + private val deploymentUrlStep = DeploymentUrlStep(context, this::notify) private val tokenStep = TokenStep(context) private val connectStep = ConnectStep( context, - shouldAutoLogin, + shouldAutoSetup, this::notify, this::displaySteps, onConnect @@ -50,9 +50,9 @@ class AuthWizardPage( private val errorBuffer = mutableListOf() init { - if (shouldAutoLogin.value) { - AuthContext.url = context.secrets.lastDeploymentURL.toURL() - AuthContext.token = context.secrets.lastToken + if (shouldAutoSetup.value) { + CoderCliSetupContext.url = context.secrets.lastDeploymentURL.toURL() + CoderCliSetupContext.token = context.secrets.lastToken } } @@ -67,22 +67,22 @@ class AuthWizardPage( } private fun displaySteps() { - when (AuthWizardState.currentStep()) { + when (CoderCliSetupWizardState.currentStep()) { WizardStep.URL_REQUEST -> { fields.update { - listOf(signInStep.panel) + listOf(deploymentUrlStep.panel) } actionButtons.update { listOf( - Action(context.i18n.ptrl("Sign In"), closesPage = false, actionBlock = { - if (signInStep.onNext()) { + Action(context.i18n.ptrl("Next"), closesPage = false, actionBlock = { + if (deploymentUrlStep.onNext()) { displaySteps() } }), settingsAction ) } - signInStep.onVisible() + deploymentUrlStep.onVisible() } WizardStep.TOKEN_REQUEST -> { @@ -106,7 +106,7 @@ class AuthWizardPage( tokenStep.onVisible() } - WizardStep.LOGIN -> { + WizardStep.CONNECT -> { fields.update { listOf(connectStep.panel) } @@ -115,7 +115,7 @@ class AuthWizardPage( settingsAction, Action(context.i18n.ptrl("Back"), closesPage = false, actionBlock = { connectStep.onBack() - shouldAutoLogin.update { + shouldAutoSetup.update { false } displaySteps() @@ -150,7 +150,7 @@ class AuthWizardPage( context.cs.launch { context.ui.showSnackbar( UUID.randomUUID().toString(), - context.i18n.ptrl("Error encountered during authentication"), + context.i18n.ptrl("Error encountered while setting up Coder"), context.i18n.pnotr(textError ?: ""), context.i18n.ptrl("Dismiss") ) diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index 58e154e..9964d0c 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -5,9 +5,8 @@ import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.cli.ensureCLI import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderRestClient -import com.coder.toolbox.views.state.AuthContext -import com.coder.toolbox.views.state.AuthWizardState -import com.jetbrains.toolbox.api.localization.LocalizableString +import com.coder.toolbox.views.state.CoderCliSetupContext +import com.coder.toolbox.views.state.CoderCliSetupWizardState import com.jetbrains.toolbox.api.ui.components.LabelField import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.ValidationErrorField @@ -43,21 +42,19 @@ class ConnectStep( RowGroup.RowField(errorField) ) - override val nextButtonTitle: LocalizableString? = null - override fun onVisible() { errorField.textState.update { context.i18n.pnotr("") } - if (AuthContext.isNotReadyForAuth()) { + if (CoderCliSetupContext.isNotReadyForAuth()) { errorField.textState.update { context.i18n.pnotr("URL and token were not properly configured. Please go back and provide a proper URL and token!") } return } - statusField.textState.update { context.i18n.pnotr("Connecting to ${AuthContext.url!!.host}...") } + statusField.textState.update { context.i18n.pnotr("Connecting to ${CoderCliSetupContext.url!!.host}...") } connect() } @@ -65,51 +62,55 @@ class ConnectStep( * Try connecting to Coder with the provided URL and token. */ private fun connect() { - if (!AuthContext.hasUrl()) { + if (!CoderCliSetupContext.hasUrl()) { errorField.textState.update { context.i18n.ptrl("URL is required") } return } - if (!AuthContext.hasToken()) { + if (!CoderCliSetupContext.hasToken()) { errorField.textState.update { context.i18n.ptrl("Token is required") } return } signInJob?.cancel() signInJob = context.cs.launch { try { - statusField.textState.update { (context.i18n.ptrl("Authenticating to ${AuthContext.url!!.host}...")) } val client = CoderRestClient( context, - AuthContext.url!!, - AuthContext.token!!, + CoderCliSetupContext.url!!, + CoderCliSetupContext.token!!, PluginManager.pluginInfo.version, ) // allows interleaving with the back/cancel action yield() - client.authenticate() - statusField.textState.update { (context.i18n.ptrl("Checking Coder binary...")) } - val cli = ensureCLI(context, client.url, client.buildVersion) + client.initializeSession() + statusField.textState.update { (context.i18n.ptrl("Checking Coder CLI...")) } + val cli = ensureCLI( + context, client.url, + client.buildVersion + ) { progress -> + statusField.textState.update { (context.i18n.pnotr(progress)) } + } // We only need to log in if we are using token-based auth. if (client.token != null) { - statusField.textState.update { (context.i18n.ptrl("Configuring CLI...")) } + statusField.textState.update { (context.i18n.ptrl("Configuring Coder CLI...")) } // allows interleaving with the back/cancel action yield() cli.login(client.token) } - statusField.textState.update { (context.i18n.ptrl("Successfully configured ${AuthContext.url!!.host}...")) } + statusField.textState.update { (context.i18n.ptrl("Successfully configured ${CoderCliSetupContext.url!!.host}...")) } // allows interleaving with the back/cancel action yield() - AuthContext.reset() - AuthWizardState.resetSteps() + CoderCliSetupContext.reset() + CoderCliSetupWizardState.resetSteps() onConnect(client, cli) } catch (ex: CancellationException) { if (ex.message != USER_HIT_THE_BACK_BUTTON) { - notify("Connection to ${AuthContext.url!!.host} was configured", ex) + notify("Connection to ${CoderCliSetupContext.url!!.host} was configured", ex) onBack() refreshWizard() } } catch (ex: Exception) { - notify("Failed to configure ${AuthContext.url!!.host}", ex) + notify("Failed to configure ${CoderCliSetupContext.url!!.host}", ex) onBack() refreshWizard() } @@ -125,11 +126,11 @@ class ConnectStep( signInJob?.cancel(CancellationException(USER_HIT_THE_BACK_BUTTON)) } finally { if (shouldAutoLogin.value) { - AuthContext.reset() - AuthWizardState.resetSteps() + CoderCliSetupContext.reset() + CoderCliSetupWizardState.resetSteps() context.secrets.rememberMe = false } else { - AuthWizardState.goToPreviousStep() + CoderCliSetupWizardState.goToPreviousStep() } } } diff --git a/src/main/kotlin/com/coder/toolbox/views/SignInStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt similarity index 85% rename from src/main/kotlin/com/coder/toolbox/views/SignInStep.kt rename to src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index 34cdf2d..aa87b57 100644 --- a/src/main/kotlin/com/coder/toolbox/views/SignInStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -2,9 +2,8 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.util.toURL -import com.coder.toolbox.views.state.AuthContext -import com.coder.toolbox.views.state.AuthWizardState -import com.jetbrains.toolbox.api.localization.LocalizableString +import com.coder.toolbox.views.state.CoderCliSetupContext +import com.coder.toolbox.views.state.CoderCliSetupWizardState import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.TextField import com.jetbrains.toolbox.api.ui.components.TextType @@ -19,7 +18,7 @@ import java.net.URL * Populates with the provided URL, at which point the user can accept or * enter their own. */ -class SignInStep( +class DeploymentUrlStep( private val context: CoderToolboxContext, private val notify: (String, Throwable) -> Unit ) : @@ -32,8 +31,6 @@ class SignInStep( RowGroup.RowField(errorField) ) - override val nextButtonTitle: LocalizableString? = context.i18n.ptrl("Sign In") - override fun onVisible() { errorField.textState.update { context.i18n.pnotr("") @@ -55,12 +52,12 @@ class SignInStep( url } try { - AuthContext.url = validateRawUrl(url) + CoderCliSetupContext.url = validateRawUrl(url) } catch (e: MalformedURLException) { notify("URL is invalid", e) return false } - AuthWizardState.goToNextStep() + CoderCliSetupWizardState.goToNextStep() return true } diff --git a/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt b/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt index b02e9ed..b449f40 100644 --- a/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt @@ -2,9 +2,8 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.util.withPath -import com.coder.toolbox.views.state.AuthContext -import com.coder.toolbox.views.state.AuthWizardState -import com.jetbrains.toolbox.api.localization.LocalizableString +import com.coder.toolbox.views.state.CoderCliSetupContext +import com.coder.toolbox.views.state.CoderCliSetupWizardState import com.jetbrains.toolbox.api.ui.components.LinkField import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.TextField @@ -31,15 +30,14 @@ class TokenStep( RowGroup.RowField(linkField), RowGroup.RowField(errorField) ) - override val nextButtonTitle: LocalizableString? = context.i18n.ptrl("Connect") override fun onVisible() { errorField.textState.update { context.i18n.pnotr("") } - if (AuthContext.hasUrl()) { + if (CoderCliSetupContext.hasUrl()) { tokenField.textState.update { - context.secrets.tokenFor(AuthContext.url!!) ?: "" + context.secrets.tokenFor(CoderCliSetupContext.url!!) ?: "" } } else { errorField.textState.update { @@ -48,7 +46,7 @@ class TokenStep( } } (linkField.urlState as MutableStateFlow).update { - AuthContext.url!!.withPath("/login?redirect=%2Fcli-auth")?.toString() ?: "" + CoderCliSetupContext.url!!.withPath("/login?redirect=%2Fcli-auth")?.toString() ?: "" } } @@ -59,12 +57,12 @@ class TokenStep( return false } - AuthContext.token = token - AuthWizardState.goToNextStep() + CoderCliSetupContext.token = token + CoderCliSetupWizardState.goToNextStep() return true } override fun onBack() { - AuthWizardState.goToPreviousStep() + CoderCliSetupWizardState.goToPreviousStep() } } diff --git a/src/main/kotlin/com/coder/toolbox/views/WizardStep.kt b/src/main/kotlin/com/coder/toolbox/views/WizardStep.kt index 6ba3d52..bb19281 100644 --- a/src/main/kotlin/com/coder/toolbox/views/WizardStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/WizardStep.kt @@ -1,11 +1,9 @@ package com.coder.toolbox.views -import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.ui.components.RowGroup interface WizardStep { val panel: RowGroup - val nextButtonTitle: LocalizableString? /** * Callback when step is visible diff --git a/src/main/kotlin/com/coder/toolbox/views/state/AuthContext.kt b/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupContext.kt similarity index 89% rename from src/main/kotlin/com/coder/toolbox/views/state/AuthContext.kt rename to src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupContext.kt index 320bd63..8d503b9 100644 --- a/src/main/kotlin/com/coder/toolbox/views/state/AuthContext.kt +++ b/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupContext.kt @@ -3,13 +3,13 @@ package com.coder.toolbox.views.state import java.net.URL /** - * Singleton that holds authentication context (URL and token) across multiple + * Singleton that holds Coder CLI setup context (URL and token) across multiple * Toolbox window lifecycle events. * * This ensures that user input (URL and token) is not lost when the Toolbox * window is temporarily closed or recreated. */ -object AuthContext { +object CoderCliSetupContext { /** * The currently entered URL. */ diff --git a/src/main/kotlin/com/coder/toolbox/views/state/AuthWizardState.kt b/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupWizardState.kt similarity index 82% rename from src/main/kotlin/com/coder/toolbox/views/state/AuthWizardState.kt rename to src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupWizardState.kt index c29fbc9..f1efca4 100644 --- a/src/main/kotlin/com/coder/toolbox/views/state/AuthWizardState.kt +++ b/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupWizardState.kt @@ -2,13 +2,13 @@ package com.coder.toolbox.views.state /** - * A singleton that maintains the state of the authorization wizard across Toolbox window lifecycle events. + * A singleton that maintains the state of the coder setup wizard across Toolbox window lifecycle events. * * This is used to persist the wizard's progress (i.e., current step) between visibility changes * of the Toolbox window. Without this object, closing and reopening the window would reset the wizard * to its initial state by creating a new instance. */ -object AuthWizardState { +object CoderCliSetupWizardState { private var currentStep = WizardStep.URL_REQUEST fun currentStep(): WizardStep = currentStep @@ -31,5 +31,5 @@ object AuthWizardState { } enum class WizardStep { - URL_REQUEST, TOKEN_REQUEST, LOGIN; + URL_REQUEST, TOKEN_REQUEST, CONNECT; } \ No newline at end of file diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index 1b04695..fe1f90c 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -106,7 +106,7 @@ msgstr "" msgid "Configuring CLI..." msgstr "" -msgid "Sign In" +msgid "Next" msgstr "" msgid "Token" @@ -142,5 +142,8 @@ msgstr "" msgid "Error encountered while handling Coder URI" msgstr "" -msgid "Error encountered during authentication" +msgid "Error encountered while setting up Coder" +msgstr "" + +msgid "Setting up Coder" msgstr "" \ No newline at end of file diff --git a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt index 4603fda..5c37c9e 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -62,6 +62,9 @@ import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertTrue +private const val VERSION_FOR_PROGRESS_REPORTING = "v2.23.1-devel+de07351b8" +private val noOpTextProgress: (String) -> Unit = { _ -> } + internal class CoderCLIManagerTest { private val context = CoderToolboxContext( mockk(), @@ -145,7 +148,7 @@ internal class CoderCLIManagerTest { val ex = assertFailsWith( exceptionClass = ResponseException::class, - block = { ccm.download() }, + block = { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }, ) assertEquals(HttpURLConnection.HTTP_INTERNAL_ERROR, ex.code) @@ -200,7 +203,7 @@ internal class CoderCLIManagerTest { assertFailsWith( exceptionClass = AccessDeniedException::class, - block = { ccm.download() }, + block = { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }, ) srv.stop(0) @@ -229,11 +232,11 @@ internal class CoderCLIManagerTest { ).readOnly(), ) - assertTrue(ccm.download()) + assertTrue(ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) assertDoesNotThrow { ccm.version() } // It should skip the second attempt. - assertFalse(ccm.download()) + assertFalse(ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) // Make sure login failures propagate. assertFailsWith( @@ -258,11 +261,11 @@ internal class CoderCLIManagerTest { ).readOnly(), ) - assertEquals(true, ccm.download()) + assertEquals(true, ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version()) // It should skip the second attempt. - assertEquals(false, ccm.download()) + assertEquals(false, ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) // Should use the source override. ccm = CoderCLIManager( @@ -278,7 +281,7 @@ internal class CoderCLIManagerTest { ).readOnly(), ) - assertEquals(true, ccm.download()) + assertEquals(true, ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) assertContains(ccm.localBinaryPath.toFile().readText(), "0.0.0") srv.stop(0) @@ -326,7 +329,7 @@ internal class CoderCLIManagerTest { assertEquals("cli", ccm.localBinaryPath.toFile().readText()) assertEquals(0, ccm.localBinaryPath.toFile().lastModified()) - assertTrue(ccm.download()) + assertTrue(ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) assertNotEquals("cli", ccm.localBinaryPath.toFile().readText()) assertNotEquals(0, ccm.localBinaryPath.toFile().lastModified()) @@ -351,8 +354,8 @@ internal class CoderCLIManagerTest { val ccm1 = CoderCLIManager(url1, context.logger, settings) val ccm2 = CoderCLIManager(url2, context.logger, settings) - assertTrue(ccm1.download()) - assertTrue(ccm2.download()) + assertTrue(ccm1.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) + assertTrue(ccm2.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) srv1.stop(0) srv2.stop(0) @@ -883,12 +886,12 @@ internal class CoderCLIManagerTest { Result.ERROR -> { assertFailsWith( exceptionClass = AccessDeniedException::class, - block = { ensureCLI(localContext, url, it.buildVersion) }, + block = { ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) }, ) } Result.NONE -> { - val ccm = ensureCLI(localContext, url, it.buildVersion) + val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) assertEquals(settings.binPath(url), ccm.localBinaryPath) assertFailsWith( exceptionClass = ProcessInitException::class, @@ -897,25 +900,25 @@ internal class CoderCLIManagerTest { } Result.DL_BIN -> { - val ccm = ensureCLI(localContext, url, it.buildVersion) + val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) assertEquals(settings.binPath(url), ccm.localBinaryPath) assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version()) } Result.DL_DATA -> { - val ccm = ensureCLI(localContext, url, it.buildVersion) + val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) assertEquals(settings.binPath(url, true), ccm.localBinaryPath) assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version()) } Result.USE_BIN -> { - val ccm = ensureCLI(localContext, url, it.buildVersion) + val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) assertEquals(settings.binPath(url), ccm.localBinaryPath) assertEquals(SemVer.parse(it.version ?: ""), ccm.version()) } Result.USE_DATA -> { - val ccm = ensureCLI(localContext, url, it.buildVersion) + val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) assertEquals(settings.binPath(url, true), ccm.localBinaryPath) assertEquals(SemVer.parse(it.fallbackVersion ?: ""), ccm.version()) } @@ -955,7 +958,7 @@ internal class CoderCLIManagerTest { context.logger, ).readOnly(), ) - assertEquals(true, ccm.download()) + assertEquals(true, ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) assertEquals(it.second, ccm.features, "version: ${it.first}") srv.stop(0) From a8d04bb8dfe00c8b8047d732aa395a189a7da55f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 19 Jun 2025 21:56:08 +0300 Subject: [PATCH 17/93] Changelog update - `v0.3.1` (#132) Current pull request contains patched `CHANGELOG.md` file for the `v0.3.1` version. Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 368f967..17bbac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.3.1 - 2025-06-19 + ### Added - visual text progress during Coder CLI downloading From 022696b274d64cb3309b97698454ab9a0d4013e6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Jun 2025 22:36:35 +0300 Subject: [PATCH 18/93] chore: bump org.jetbrains.intellij.plugins:structure-toolbox from 3.307 to 3.308 (#134) Bumps [org.jetbrains.intellij.plugins:structure-toolbox](https://github.com/JetBrains/intellij-plugin-verifier) from 3.307 to 3.308.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.jetbrains.intellij.plugins:structure-toolbox&package-manager=gradle&previous-version=3.307&new-version=3.308)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0db6399..4f554f6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ ksp = "2.1.10-1.0.31" retrofit = "3.0.0" changelog = "2.2.1" gettext = "0.7.0" -plugin-structure = "3.307" +plugin-structure = "3.308" mockk = "1.14.2" [libraries] From c63506d8decd1aa70fe32162b14b526d9230fd25 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 25 Jun 2025 23:48:13 +0300 Subject: [PATCH 19/93] impl: update icons to match the new branding (#136) Some samples with the new icons: Screenshot 2025-06-25 at 23 04 48 Screenshot 2025-06-25 at 23 04 35 Screenshot 2025-06-25 at 23 04 21 Screenshot 2025-06-25 at 23 04 11 Screenshot 2025-06-25 at 23 03 53 Screenshot 2025-06-25 at 23 03 40 Screenshot 2025-06-25 at 23 03 27 Screenshot 2025-06-25 at 23 03 17 --- CHANGELOG.md | 4 + build.gradle.kts | 2 +- gradle.properties | 2 +- src/main/resources/icon.svg | 16 +--- src/main/resources/icons/create.svg | 8 -- src/main/resources/icons/create_dark.svg | 8 -- src/main/resources/icons/delete.svg | 7 -- src/main/resources/icons/delete_dark.svg | 7 -- src/main/resources/icons/homeFolder.svg | 7 -- src/main/resources/icons/homeFolder_dark.svg | 7 -- src/main/resources/icons/open_terminal.svg | 3 - .../resources/icons/open_terminal_dark.svg | 3 - src/main/resources/icons/run.svg | 6 -- src/main/resources/icons/run_dark.svg | 6 -- src/main/resources/icons/stop.svg | 6 -- src/main/resources/icons/stop_dark.svg | 6 -- src/main/resources/icons/unknown.svg | 6 -- src/main/resources/icons/update.svg | 3 - src/main/resources/icons/update_dark.svg | 3 - src/main/resources/logo/coder_logo.svg | 80 ----------------- src/main/resources/logo/coder_logo_16.svg | 87 ------------------- .../resources/logo/coder_logo_16_dark.svg | 87 ------------------- src/main/resources/logo/coder_logo_dark.svg | 80 ----------------- src/main/resources/pluginIcon.svg | 4 + 24 files changed, 12 insertions(+), 436 deletions(-) delete mode 100644 src/main/resources/icons/create.svg delete mode 100644 src/main/resources/icons/create_dark.svg delete mode 100644 src/main/resources/icons/delete.svg delete mode 100644 src/main/resources/icons/delete_dark.svg delete mode 100644 src/main/resources/icons/homeFolder.svg delete mode 100644 src/main/resources/icons/homeFolder_dark.svg delete mode 100644 src/main/resources/icons/open_terminal.svg delete mode 100644 src/main/resources/icons/open_terminal_dark.svg delete mode 100644 src/main/resources/icons/run.svg delete mode 100644 src/main/resources/icons/run_dark.svg delete mode 100644 src/main/resources/icons/stop.svg delete mode 100644 src/main/resources/icons/stop_dark.svg delete mode 100644 src/main/resources/icons/unknown.svg delete mode 100644 src/main/resources/icons/update.svg delete mode 100644 src/main/resources/icons/update_dark.svg delete mode 100644 src/main/resources/logo/coder_logo.svg delete mode 100644 src/main/resources/logo/coder_logo_16.svg delete mode 100644 src/main/resources/logo/coder_logo_16_dark.svg delete mode 100644 src/main/resources/logo/coder_logo_dark.svg create mode 100644 src/main/resources/pluginIcon.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 17bbac0..87f07e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Changed + +- the logos and icons now match the new branding + ## 0.3.1 - 2025-06-19 ### Added diff --git a/build.gradle.kts b/build.gradle.kts index 9c81da9..93d13a0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -133,7 +133,7 @@ fun CopySpec.fromCompileDependencies() { } from("src/main/resources") { include("icon.svg") - rename("icon.svg", "pluginIcon.svg") + include("pluginIcon.svg") } // Copy dependencies, excluding those provided by Toolbox. diff --git a/gradle.properties b/gradle.properties index 759f5c9..b2b2959 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.3.1 +version=0.3.2 group=com.coder.toolbox name=coder-toolbox diff --git a/src/main/resources/icon.svg b/src/main/resources/icon.svg index 15696c6..4d780a6 100644 --- a/src/main/resources/icon.svg +++ b/src/main/resources/icon.svg @@ -1,15 +1,3 @@ - - - - - - - - - - - - - - + + \ No newline at end of file diff --git a/src/main/resources/icons/create.svg b/src/main/resources/icons/create.svg deleted file mode 100644 index c6da8ba..0000000 --- a/src/main/resources/icons/create.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/main/resources/icons/create_dark.svg b/src/main/resources/icons/create_dark.svg deleted file mode 100644 index 511a8ef..0000000 --- a/src/main/resources/icons/create_dark.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/main/resources/icons/delete.svg b/src/main/resources/icons/delete.svg deleted file mode 100644 index a6a94e9..0000000 --- a/src/main/resources/icons/delete.svg +++ /dev/null @@ -1,7 +0,0 @@ - - DeleteTest - - - - - diff --git a/src/main/resources/icons/delete_dark.svg b/src/main/resources/icons/delete_dark.svg deleted file mode 100644 index 901c57e..0000000 --- a/src/main/resources/icons/delete_dark.svg +++ /dev/null @@ -1,7 +0,0 @@ - - DeleteTest_dark - - - - - diff --git a/src/main/resources/icons/homeFolder.svg b/src/main/resources/icons/homeFolder.svg deleted file mode 100644 index 2d482b2..0000000 --- a/src/main/resources/icons/homeFolder.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/main/resources/icons/homeFolder_dark.svg b/src/main/resources/icons/homeFolder_dark.svg deleted file mode 100644 index b7ba16b..0000000 --- a/src/main/resources/icons/homeFolder_dark.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/main/resources/icons/open_terminal.svg b/src/main/resources/icons/open_terminal.svg deleted file mode 100644 index 12d2164..0000000 --- a/src/main/resources/icons/open_terminal.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/main/resources/icons/open_terminal_dark.svg b/src/main/resources/icons/open_terminal_dark.svg deleted file mode 100644 index 3994064..0000000 --- a/src/main/resources/icons/open_terminal_dark.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/main/resources/icons/run.svg b/src/main/resources/icons/run.svg deleted file mode 100644 index d0f970e..0000000 --- a/src/main/resources/icons/run.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/src/main/resources/icons/run_dark.svg b/src/main/resources/icons/run_dark.svg deleted file mode 100644 index 25c1892..0000000 --- a/src/main/resources/icons/run_dark.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/src/main/resources/icons/stop.svg b/src/main/resources/icons/stop.svg deleted file mode 100644 index 8347961..0000000 --- a/src/main/resources/icons/stop.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/src/main/resources/icons/stop_dark.svg b/src/main/resources/icons/stop_dark.svg deleted file mode 100644 index 6392389..0000000 --- a/src/main/resources/icons/stop_dark.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/main/resources/icons/unknown.svg b/src/main/resources/icons/unknown.svg deleted file mode 100644 index 1f8cd75..0000000 --- a/src/main/resources/icons/unknown.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/main/resources/icons/update.svg b/src/main/resources/icons/update.svg deleted file mode 100644 index 50ad46f..0000000 --- a/src/main/resources/icons/update.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/src/main/resources/icons/update_dark.svg b/src/main/resources/icons/update_dark.svg deleted file mode 100644 index ebc8059..0000000 --- a/src/main/resources/icons/update_dark.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/src/main/resources/logo/coder_logo.svg b/src/main/resources/logo/coder_logo.svg deleted file mode 100644 index c500929..0000000 --- a/src/main/resources/logo/coder_logo.svg +++ /dev/null @@ -1,80 +0,0 @@ - - - - - Coder logo - - - - - - - - - - Coder logo - - - - diff --git a/src/main/resources/logo/coder_logo_16.svg b/src/main/resources/logo/coder_logo_16.svg deleted file mode 100644 index f4ab0e1..0000000 --- a/src/main/resources/logo/coder_logo_16.svg +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - Coder logo - - - - - - - - - - Coder logo - - - - diff --git a/src/main/resources/logo/coder_logo_16_dark.svg b/src/main/resources/logo/coder_logo_16_dark.svg deleted file mode 100644 index 77715c2..0000000 --- a/src/main/resources/logo/coder_logo_16_dark.svg +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - Coder logo - - - - - - - - - - Coder logo - - - - diff --git a/src/main/resources/logo/coder_logo_dark.svg b/src/main/resources/logo/coder_logo_dark.svg deleted file mode 100644 index e8c05d1..0000000 --- a/src/main/resources/logo/coder_logo_dark.svg +++ /dev/null @@ -1,80 +0,0 @@ - - - - - Coder logo - - - - - - - - - - Coder logo - - - - diff --git a/src/main/resources/pluginIcon.svg b/src/main/resources/pluginIcon.svg new file mode 100644 index 0000000..853f895 --- /dev/null +++ b/src/main/resources/pluginIcon.svg @@ -0,0 +1,4 @@ + + + + From 67cb723810f215bf162b4ab9516f8da8ccb71cdb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 26 Jun 2025 22:33:22 +0300 Subject: [PATCH 20/93] Changelog update - `v0.3.2` (#137) Current pull request contains patched `CHANGELOG.md` file for the `v0.3.2` version. Co-authored-by: GitHub Action --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87f07e6..b699b66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,11 @@ ## Unreleased +## 0.3.2 - 2025-06-25 + ### Changed -- the logos and icons now match the new branding +- the logos and icons now match the new branding ## 0.3.1 - 2025-06-19 From 10028e401016019fbb0f5e613c4e2b6adca869d7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Jun 2025 22:33:44 +0300 Subject: [PATCH 21/93] chore: bump io.mockk:mockk from 1.14.2 to 1.14.4 (#133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [io.mockk:mockk](https://github.com/mockk/mockk) from 1.14.2 to 1.14.4.
Release notes

Sourced from io.mockk:mockk's releases.

1.14.4

This release is functionally equivalent to v1.14.3, I just wanted to try out the new publishing process that uses Maven Central instead of OSSRH.

Full Changelog: https://github.com/mockk/mockk/compare/1.14.3...1.14.4

1.14.3

What's Changed

New Contributors

Full Changelog: https://github.com/mockk/mockk/compare/1.14.2...1.14.3

Commits
  • 80062c4 New publishing process to maven central
  • ff28c49 Fix broken build
  • 18a9f51 Version bump
  • c3aa4db Merge pull request #1403 from esafak/fix-value-class-instance-factory
  • 5e49821 Use instance factory for value classes with any() matcher
  • b03c36f Merge pull request #1394 from WhosNickDoglio/ndoglio/agp-upgrade
  • f00c780 Add an article to the README
  • ef21035 Merge pull request #1395 from Djaler/withArg-logging
  • a685387 Update CMakeList.txt file to include change to compile app using 16KB ELF ali...
  • 4bf4ec7 Merge pull request #1396 from happysubin/master
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=io.mockk:mockk&package-manager=gradle&previous-version=1.14.2&new-version=1.14.4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4f554f6..4647eb8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ retrofit = "3.0.0" changelog = "2.2.1" gettext = "0.7.0" plugin-structure = "3.308" -mockk = "1.14.2" +mockk = "1.14.4" [libraries] toolbox-core-api = { module = "com.jetbrains.toolbox:core-api", version.ref = "toolbox-plugin-api" } From 5d05fff35efec508814fc65d9fd2a1b22161ff68 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 26 Jun 2025 22:53:18 +0300 Subject: [PATCH 22/93] chore: update release section for auto-approval process (#138) JetBrains enabled auto-approval for the plugin and this plugin needs to follow some rules in order to NOT break the auto-approval process. This PR documents the guidelines. --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 56636f9..8bffe5b 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,13 @@ may trigger regeneration of SSH configurations. ## Releasing 1. Check that the changelog lists all the important changes. -2. Update the gradle.properties version. +2. Update the `gradle.properties` version. 3. Publish the resulting draft release after validating it. 4. Merge the resulting changelog PR. +5. **Compliance Reminder for auto-approval** + JetBrains enabled auto-approval for the plugin, so we need to ensure we continue to meet the following requirements: + - do **not** use Kotlin experimental APIs. + - do **not** add any lambdas, handlers, or class handles to Java runtime hooks. + - do **not** create threads manually (including via libraries). If you must, ensure they are properly cleaned up in the plugin's `CoderRemoteProvider#close()` method. + - do **not** bundle libraries that are already provided by Toolbox. + - do **not** perform any ill-intentioned actions. \ No newline at end of file From a32ef212b88773a92633e2de478cef7c0c8e8ce9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Jul 2025 22:17:01 +0300 Subject: [PATCH 23/93] chore: bump org.jetbrains.intellij.plugins:structure-toolbox from 3.308 to 3.309 (#140) Bumps [org.jetbrains.intellij.plugins:structure-toolbox](https://github.com/JetBrains/intellij-plugin-verifier) from 3.308 to 3.309.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.jetbrains.intellij.plugins:structure-toolbox&package-manager=gradle&previous-version=3.308&new-version=3.309)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4647eb8..4f46170 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ ksp = "2.1.10-1.0.31" retrofit = "3.0.0" changelog = "2.2.1" gettext = "0.7.0" -plugin-structure = "3.308" +plugin-structure = "3.309" mockk = "1.14.4" [libraries] From 768f5f61fe63b017b681d88690fc2fcddcd35d2e Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Mon, 7 Jul 2025 23:34:20 +0500 Subject: [PATCH 24/93] chore: update README.md (#145) --- README.md | 58 +++++++++++++++++++++++++------------------------------ 1 file changed, 26 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 8bffe5b..e2a718b 100644 --- a/README.md +++ b/README.md @@ -92,22 +92,16 @@ jetbrains://gateway/com.coder.toolbox | ide_build_number | Specific build number of the JetBrains IDE to install on the workspace | No | | folder | Absolute path to the project folder to open in the remote IDE (URL-encoded) | No | -If only a single agent is available, specifying an agent ID is optional. However, if multiple agents exist, -you must provide either the ID to target a specific one. Note that this version of the Coder Toolbox plugin -does not automatically start agents if they are offline, so please ensure the selected agent is running before -proceeding. +> [!NOTE] +> If only a single agent is available, specifying an agent ID is optional. However, if multiple agents exist, +> you must provide either the ID to target a specific one. Note that this version of the Coder Toolbox plugin +> does not automatically start agents if they are offline, so please ensure the selected agent is running before +> proceeding. If `ide_product_code` and `ide_build_number` is missing, Toolbox will only open and highlight the workspace environment 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. -> 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. - ## Configuring and Testing workspace polling with HTTP & SOCKS5 Proxy This section explains how to set up a local proxy (without authentication which is not yet supported) and verify that @@ -122,7 +116,6 @@ interception. 2. Start the proxy: ```bash - mitmweb --ssl-insecure --set stream_large_bodies="10m" ``` @@ -170,7 +163,8 @@ Steps to enable debug logging: 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. +> [!WARNING] +> Toolbox does not persist log level configuration between restarts. #### Viewing the Logs @@ -187,21 +181,21 @@ storage paths. The options can be configured from the plugin's main Workspaces p ### CLI related settings -```Binary source``` specifies the source URL or relative path from which the Coder CLI should be downloaded. +- `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. +- `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 +- `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 +- `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. +- `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 @@ -209,45 +203,45 @@ The environment variable CODER_URL will be available to the command process. 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. +- `TLS cert path` path to a client certificate file for TLS authentication with Coder deployment. The certificate should be in X.509 PEM format. -```TLS key path``` path to the private key corresponding to the TLS certificate from above. +- `TLS key path` path to the private key corresponding to the TLS certificate from above. The certificate should be in X.509 PEM format. -```TLS CA path``` the path of a file containing certificates for an alternate certificate authority used to verify TLS +- `TLS CA path` the path of a file containing certificates for an alternate certificate authority used to verify TLS 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 The following options control the SSH behavior of the Coder CLI. -```Disable autostart``` adds the --disable-autostart flag to the SSH proxy command, preventing the CLI from keeping +- `Disable autostart` adds the --disable-autostart flag to the SSH proxy command, preventing the CLI from keeping workspaces constantly active. -```Enable SSH wildcard config``` enables or disables wildcard entries in the SSH configuration, which allow generic +- `Enable SSH wildcard config` enables or disables wildcard entries in the SSH configuration, which allow generic rules for matching multiple workspaces. -```SSH proxy log directory``` directory where SSH proxy logs are written. Useful for debugging SSH connection issues. +- `SSH proxy log directory` directory where SSH proxy logs are written. Useful for debugging SSH connection issues. -```SSH network metrics directory``` directory where network information used by the SSH proxy is stored. +- `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 +- `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, -may trigger regeneration of SSH configurations. +support, may trigger regeneration of SSH configurations. ### Security considerations -> ⚠️ **Attention:** Token authentication is required when TLS certificates are not configured. +> [!IMPORTANT] +> Token authentication is required when TLS certificates are not configured. ## Releasing @@ -261,4 +255,4 @@ may trigger regeneration of SSH configurations. - do **not** add any lambdas, handlers, or class handles to Java runtime hooks. - do **not** create threads manually (including via libraries). If you must, ensure they are properly cleaned up in the plugin's `CoderRemoteProvider#close()` method. - do **not** bundle libraries that are already provided by Toolbox. - - do **not** perform any ill-intentioned actions. \ No newline at end of file + - do **not** perform any ill-intentioned actions. From dbf40e6dca5b21f2e13dbef13c51e6021ddc617b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Jul 2025 21:46:23 +0300 Subject: [PATCH 25/93] chore: bump org.jetbrains.intellij.plugins:structure-toolbox from 3.309 to 3.310 (#143) Bumps [org.jetbrains.intellij.plugins:structure-toolbox](https://github.com/JetBrains/intellij-plugin-verifier) from 3.309 to 3.310.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.jetbrains.intellij.plugins:structure-toolbox&package-manager=gradle&previous-version=3.309&new-version=3.310)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4f46170..797656b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ ksp = "2.1.10-1.0.31" retrofit = "3.0.0" changelog = "2.2.1" gettext = "0.7.0" -plugin-structure = "3.309" +plugin-structure = "3.310" mockk = "1.14.4" [libraries] From 1a2212bc56002770c8b706f164cf8119c638039a Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 7 Jul 2025 22:02:42 +0300 Subject: [PATCH 26/93] impl: support for Toolbox 2.7 (#135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Things that were changed or added to TBX 2.7: **Support for Proxy Authentication** The new API introduced in TBX 2.7, and HTTP proxy authentication works flawlessly. However, SOCKS5 proxy authentication does not appear to be properly supported in the current TBX implementation. While users can configure a SOCKS5 proxy with basic authentication, Toolbox fails to authenticate successfully. Coder uses OkHttp as the HTTP client, which in turn delegates SOCKS5 authentication to the JVM (java.net.SocksSocketImpl). We can configure a java.net.Authenticator with the credentials exposed by the new TBX API. However, since the Authenticator is set globally, doing so would affect all plugins — including TBX itself — which may not be desirable. **Customizable messages while loading the workspaces** The new TBX 2.7 API allows us to change the message displayed while loading the workspaces from "Loading environments" to "Loading workspaces" **UI pages with customizable titles** **Support for custom aliases in the URI handling protocol** --- CHANGELOG.md | 13 ++++++++ README.md | 18 ++++++++++- gradle.properties | 2 +- gradle/libs.versions.toml | 10 +++---- .../coder/toolbox/CoderRemoteEnvironment.kt | 5 ---- .../com/coder/toolbox/CoderRemoteProvider.kt | 30 ++++++++----------- .../com/coder/toolbox/CoderToolboxContext.kt | 25 ---------------- .../toolbox/models/WorkspaceAndAgentStatus.kt | 15 ++-------- .../com/coder/toolbox/sdk/CoderRestClient.kt | 27 +++++++++-------- .../toolbox/util/CoderProtocolHandler.kt | 11 ++++--- .../toolbox/views/CoderCliSetupWizardPage.kt | 2 +- .../com/coder/toolbox/views/CoderPage.kt | 11 +++++-- .../coder/toolbox/views/CoderSettingsPage.kt | 2 +- .../coder/toolbox/views/NewEnvironmentPage.kt | 5 ++-- .../resources/localization/defaultMessages.po | 3 ++ .../coder/toolbox/sdk/CoderRestClientTest.kt | 4 +++ 16 files changed, 93 insertions(+), 90 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b699b66..d8c11b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +### Added + +- support for basic authentication for HTTP/HTTPS proxy +- support for Toolbox 2.7 release + +### Changed + +- improved message while loading the workspace + +### Fixed + +- URI protocol handler is now able to switch to the Coder provider even if the last opened provider was something else + ## 0.3.2 - 2025-06-25 ### Changed diff --git a/README.md b/README.md index e2a718b..e2fb731 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,11 @@ jetbrains://gateway/com.coder.toolbox &folder= ``` +Starting from Toolbox 2.7, you can use `coder` as a shortcut in place of the full plugin ID. The URI can be simplified as: +```text +jetbrains://gateway/coder?url=http(s):// +``` + | Query param | Description | Mandatory | |------------------|------------------------------------------------------------------------------|-----------| | url | Your Coder deployment URL (encoded) | Yes | @@ -104,7 +109,7 @@ experience, it’s recommended to ensure the workspace is running prior to initi ## Configuring and Testing workspace polling with HTTP & SOCKS5 Proxy -This section explains how to set up a local proxy (without authentication which is not yet supported) and verify that +This section explains how to set up a local proxy and verify that the plugin’s REST client works correctly when routed through it. We’ll use [mitmproxy](https://mitmproxy.org/) for this — it can act as both an HTTP and SOCKS5 proxy with SSL @@ -127,6 +132,12 @@ mitmproxy can do HTTP and SOCKS5 proxying. To configure one or the other: 2. Navigate to `Options -> Edit Options` 3. Update the `Mode` field to `regular` in order to activate HTTP/HTTPS or to `socks5` 4. Proxy authentication can be enabled by updating the `proxyauth` to `username:password` +5. Alternatively you can run the following commands: + +```bash +mitmweb --ssl-insecure --set stream_large_bodies="10m" --mode regular --proxyauth proxyUsername:proxyPassword +mitmweb --ssl-insecure --set stream_large_bodies="10m" --mode socks5 +``` ### Configure Proxy in Toolbox @@ -137,6 +148,11 @@ mitmproxy can do HTTP and SOCKS5 proxying. To configure one or the other: 5. Before authenticating to the Coder deployment we need to tell the plugin where can we find mitmproxy certificates. In Coder's Settings page, set the `TLS CA path` to `~/.mitmproxy/mitmproxy-ca-cert.pem` +> [!NOTE] +> Coder Toolbox plugin handles only HTTP/HTTPS proxy authentication. +> SOCKS5 proxy authentication is currently not supported due to limitations +> described in: https://youtrack.jetbrains.com/issue/TBX-14532/Missing-proxy-authentication-settings#focus=Comments-27-12265861.0-0 + ## Debugging and Reporting issues Enabling debug logging is essential for diagnosing issues with the Toolbox plugin, especially when SSH diff --git a/gradle.properties b/gradle.properties index b2b2959..efbc54f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.3.2 +version=0.4.0 group=com.coder.toolbox name=coder-toolbox diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 797656b..46aa964 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,15 +1,15 @@ [versions] -toolbox-plugin-api = "1.1.41749" -kotlin = "2.1.10" -coroutines = "1.10.1" -serialization = "1.8.0" +toolbox-plugin-api = "1.3.47293" +kotlin = "2.1.20" +coroutines = "1.10.2" +serialization = "1.8.1" okhttp = "4.12.0" dependency-license-report = "2.9" marketplace-client = "2.0.46" gradle-wrapper = "0.14.0" exec = "1.12" moshi = "1.15.2" -ksp = "2.1.10-1.0.31" +ksp = "2.1.20-2.0.1" retrofit = "3.0.0" changelog = "2.2.1" gettext = "0.7.0" diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index 3c4de20..08f5a07 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -157,11 +157,6 @@ class CoderRemoteEnvironment( override fun beforeConnection() { context.logger.info("Connecting to $id...") - context.cs.launch { - state.update { - wsRawStatus.toSshConnectingEnvState(context) - } - } isConnected.update { true } pollJob = pollNetworkMetrics() } diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 101cf71..be4c40a 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -7,6 +7,7 @@ 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 +import com.coder.toolbox.util.waitForTrue import com.coder.toolbox.util.withPath import com.coder.toolbox.views.Action import com.coder.toolbox.views.CoderCliSetupWizardPage @@ -63,9 +64,10 @@ class CoderRemoteProvider( // On the first load, automatically log in if we can. private var firstRun = true private val isInitialized: MutableStateFlow = MutableStateFlow(false) - private var coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(context.deploymentUrl.toString())) + private val coderHeaderPage = NewEnvironmentPage(context.i18n.pnotr(context.deploymentUrl.toString())) private val linkHandler = CoderProtocolHandler(context, dialogUi, isInitialized) + override val loadingEnvironmentsDescription: LocalizableString = context.i18n.ptrl("Loading workspaces...") override val environments: MutableStateFlow>> = MutableStateFlow( LoadableState.Loading ) @@ -167,7 +169,7 @@ class CoderRemoteProvider( close() // force auto-login firstRun = true - goToEnvironmentsPage() + context.envPageManager.showPluginEnvironmentsPage() break } } @@ -315,27 +317,19 @@ class CoderRemoteProvider( ) { restClient, cli -> // stop polling and de-initialize resources close() + isInitialized.update { + false + } // start initialization with the new settings this@CoderRemoteProvider.client = restClient - coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(restClient.url.toString())) + coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString())) environments.showLoadingMessage() pollJob = poll(restClient, cli) + isInitialized.waitForTrue() } } - /** - * Make Toolbox ask for the page again. Use any time we need to change the - * root page (for example, sign-in or the environment list). - * - * When moving between related pages, instead use ui.showUiPage() and - * ui.hideUiPage() which stacks and has built-in back navigation, rather - * than using multiple root pages. - */ - private fun goToEnvironmentsPage() { - context.envPageManager.showPluginEnvironmentsPage() - } - /** * Return the sign-in page if we do not have a valid client. @@ -377,7 +371,7 @@ class CoderRemoteProvider( private fun shouldDoAutoSetup(): Boolean = firstRun && context.secrets.rememberMe == true - private suspend fun onConnect(client: CoderRestClient, cli: CoderCLIManager) { + private fun onConnect(client: CoderRestClient, cli: CoderCLIManager) { // Store the URL and token for use next time. context.secrets.lastDeploymentURL = client.url.toString() context.secrets.lastToken = client.token ?: "" @@ -387,9 +381,9 @@ class CoderRemoteProvider( this.client = client pollJob?.cancel() environments.showLoadingMessage() - coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(client.url.toString())) + coderHeaderPage.setTitle(context.i18n.pnotr(client.url.toString())) pollJob = poll(client, cli) - context.refreshMainPage() + context.envPageManager.showPluginEnvironmentsPage() } private fun MutableStateFlow>>.showLoadingMessage() { diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index 0bb4135..4291321 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -3,7 +3,6 @@ package com.coder.toolbox import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore import com.coder.toolbox.util.toURL -import com.coder.toolbox.views.CoderPage import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory @@ -14,10 +13,8 @@ import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager import com.jetbrains.toolbox.api.ui.ToolboxUi import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay import java.net.URL import java.util.UUID -import kotlin.time.Duration.Companion.milliseconds @Suppress("UnstableApiUsage") data class CoderToolboxContext( @@ -91,26 +88,4 @@ data class CoderToolboxContext( i18n.ptrl("OK") ) } - - /** - * Forces the title bar on the main page to be refreshed - */ - suspend fun refreshMainPage() { - // the url/title on the main page is only refreshed if - // we're navigating to the main env page from another page. - // If TBX is already on the main page the title is not refreshed - // hence we force a navigation from a blank page. - ui.showUiPage(CoderPage.emptyPage(this)) - - - // Toolbox uses an internal shared flow with a buffer of 4 items and a DROP_OLDEST strategy. - // Both showUiPage and showPluginEnvironmentsPage send events to this flow. - // If we emit two events back-to-back, the first one often gets dropped and only the second is shown. - // To reduce this risk, we add a small delay to let the UI coroutine process the first event. - // Simply yielding the coroutine isn't reliable, especially right after Toolbox starts via URI handling. - // Based on my testing, a 5–10 ms delay is enough to ensure the blank page is processed, - // while still short enough to be invisible to users. - delay(10.milliseconds) - envPageManager.showPluginEnvironmentsPage() - } } diff --git a/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt b/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt index cc04dfe..1f48a10 100644 --- a/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt +++ b/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt @@ -65,9 +65,10 @@ enum class WorkspaceAndAgentStatus(val label: String, val description: String) { return CustomRemoteEnvironmentStateV2( context.i18n.pnotr(label), color = getStateColor(context), - reachable = ready() || unhealthy(), + isReachable = ready() || unhealthy(), // TODO@JB: How does this work? Would like a spinner for pending states. - icon = getStateIcon() + iconId = getStateIcon().id, + isPriorityShow = true ) } @@ -90,16 +91,6 @@ enum class WorkspaceAndAgentStatus(val label: String, val description: String) { else EnvironmentStateIcons.NoIcon } - fun toSshConnectingEnvState(context: CoderToolboxContext): CustomRemoteEnvironmentStateV2 { - val existingState = toRemoteEnvironmentState(context) - return CustomRemoteEnvironmentStateV2( - context.i18n.pnotr("SSHing"), - existingState.color, - existingState.isReachable, - EnvironmentStateIcons.Connecting - ) - } - /** * Return true if the agent is in a connectable state. */ diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 365e1ed..9aa3dfb 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -24,7 +24,9 @@ import com.coder.toolbox.util.coderTrustManagers import com.coder.toolbox.util.getArch import com.coder.toolbox.util.getHeaders import com.coder.toolbox.util.getOS +import com.jetbrains.toolbox.api.remoteDev.connection.ProxyAuth import com.squareup.moshi.Moshi +import okhttp3.Credentials import okhttp3.OkHttpClient import retrofit2.Response import retrofit2.Retrofit @@ -78,18 +80,19 @@ open class CoderRestClient( builder.proxySelector(context.proxySettings.getProxySelector()!!) } - //TODO - add support for proxy auth. when Toolbox exposes them -// builder.proxyAuthenticator { _, response -> -// if (proxyValues.useAuth && proxyValues.username != null && proxyValues.password != null) { -// val credentials = Credentials.basic(proxyValues.username, proxyValues.password) -// response.request.newBuilder() -// .header("Proxy-Authorization", credentials) -// .build() -// } else { -// null -// } -// } -// } + // Note: This handles only HTTP/HTTPS proxy authentication. + // SOCKS5 proxy authentication is currently not supported due to limitations described in: + // https://youtrack.jetbrains.com/issue/TBX-14532/Missing-proxy-authentication-settings#focus=Comments-27-12265861.0-0 + builder.proxyAuthenticator { _, response -> + val proxyAuth = context.proxySettings.getProxyAuth() + if (proxyAuth == null || proxyAuth !is ProxyAuth.Basic) { + return@proxyAuthenticator null + } + val credentials = Credentials.basic(proxyAuth.username, proxyAuth.password) + response.request.newBuilder() + .header("Proxy-Authorization", credentials) + .build() + } if (token != null) { builder = builder.addInterceptor { diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 7d24029..7e50c8a 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -54,7 +54,10 @@ open class CoderProtocolHandler( context.logAndShowInfo("URI will not be handled", "No query parameters were provided") return } - + // this switches to the main plugin screen, even + // if last opened provider was not Coder + context.envPageManager.showPluginEnvironmentsPage() + markAsBusy() if (shouldWaitForAutoLogin) { isInitialized.waitForTrue() } @@ -67,12 +70,11 @@ open class CoderProtocolHandler( val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL) ?: return val cli = configureCli(deploymentURL, restClient) - reInitialize(restClient, cli) var agent: WorkspaceAgent try { - markAsBusy() - context.refreshMainPage() + reInitialize(restClient, cli) + context.envPageManager.showPluginEnvironmentsPage() if (!prepareWorkspace(workspace, restClient, workspaceName, deploymentURL)) return // we resolve the agent after the workspace is started otherwise we can get misleading // errors like: no agent available while workspace is starting or stopping @@ -86,6 +88,7 @@ open class CoderProtocolHandler( } finally { unmarkAsBusy() } + delay(2.seconds) val environmentId = "${workspace.name}.${agent.name}" context.showEnvironmentPage(environmentId) diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt index c6193da..5115204 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt @@ -25,7 +25,7 @@ class CoderCliSetupWizardPage( client: CoderRestClient, cli: CoderCLIManager, ) -> Unit, -) : CoderPage(context.i18n.ptrl("Setting up Coder"), false) { +) : CoderPage(MutableStateFlow(context.i18n.ptrl("Setting up Coder")), false) { private val shouldAutoSetup = MutableStateFlow(initialAutoSetup) private val settingsAction = Action(context.i18n.ptrl("Settings"), actionBlock = { context.ui.showUiPage(settingsPage) diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt index 9b83f45..363d618 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt @@ -7,6 +7,7 @@ import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription import com.jetbrains.toolbox.api.ui.components.UiPage import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update /** * Base page that handles the icon, displaying error notifications, and @@ -19,9 +20,15 @@ import kotlinx.coroutines.flow.MutableStateFlow * to use the mouse. */ abstract class CoderPage( - title: LocalizableString, + private val titleObservable: MutableStateFlow, showIcon: Boolean = true, -) : UiPage(title) { +) : UiPage(titleObservable) { + + fun setTitle(title: LocalizableString) { + titleObservable.update { + title + } + } /** * Return the icon, if showing one. diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index de2ce0b..61827be 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -20,7 +20,7 @@ import kotlinx.coroutines.launch * I have not been able to test this page. */ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel) : - CoderPage(context.i18n.ptrl("Coder Settings"), false) { + CoderPage(MutableStateFlow(context.i18n.ptrl("Coder Settings")), false) { private val settings = context.settingsStore.readOnly() // TODO: Copy over the descriptions, holding until I can test this page. diff --git a/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt b/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt index 83e07c7..cde23b2 100644 --- a/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt @@ -1,6 +1,5 @@ package com.coder.toolbox.views -import com.coder.toolbox.CoderToolboxContext import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.ui.components.UiField import kotlinx.coroutines.flow.MutableStateFlow @@ -14,7 +13,7 @@ import kotlinx.coroutines.flow.StateFlow * For now we just use this to display the deployment URL since we do not * support creating environments from the plugin. */ -class NewEnvironmentPage(context: CoderToolboxContext, deploymentURL: LocalizableString) : - CoderPage(deploymentURL) { +class NewEnvironmentPage(deploymentURL: LocalizableString) : + CoderPage(MutableStateFlow(deploymentURL)) { override val fields: StateFlow> = MutableStateFlow(emptyList()) } diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index fe1f90c..787424c 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -146,4 +146,7 @@ msgid "Error encountered while setting up Coder" msgstr "" msgid "Setting up Coder" +msgstr "" + +msgid "Loading workspaces..." msgstr "" \ No newline at end of file diff --git a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt index 2727228..c42ead2 100644 --- a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt +++ b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt @@ -24,6 +24,7 @@ import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper +import com.jetbrains.toolbox.api.remoteDev.connection.ProxyAuth import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette @@ -114,6 +115,8 @@ class CoderRestClientTest { object : ToolboxProxySettings { override fun getProxy(): Proxy? = null override fun getProxySelector(): ProxySelector? = null + override fun getProxyAuth(): ProxyAuth? = null + override fun addProxyChangeListener(listener: Runnable) { } @@ -579,6 +582,7 @@ class CoderRestClientTest { } } + override fun getProxyAuth(): ProxyAuth? = null override fun addProxyChangeListener(listener: Runnable) { } From abdf6d8f82eff78a24d268f3fc5da0beac2403f9 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 8 Jul 2025 10:44:12 +0300 Subject: [PATCH 27/93] impl: add support for matching agent by name (#146) This PR adds support for matching workspace agent in the URI via the `agent_name` query param. The existing support for `agent_id` is dropped and replaced by the new param. --- CHANGELOG.md | 5 ++ README.md | 55 ++++++------ .../toolbox/util/CoderProtocolHandler.kt | 21 +++-- .../kotlin/com/coder/toolbox/util/LinkMap.kt | 4 +- .../toolbox/util/CoderProtocolHandlerTest.kt | 85 ++++++++----------- 5 files changed, 82 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8c11b3..2fcd70c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,16 @@ - support for basic authentication for HTTP/HTTPS proxy - support for Toolbox 2.7 release +- support for matching workspace agent in the URI via the agent name ### Changed - improved message while loading the workspace +### Removed + +- dropped support for `agent_id` as a URI parameter + ### Fixed - URI protocol handler is now able to switch to the Coder provider even if the last opened provider was something else diff --git a/README.md b/README.md index e2fb731..41d430d 100644 --- a/README.md +++ b/README.md @@ -64,9 +64,9 @@ You can use specially crafted JetBrains Gateway URIs to automatically: ### Example URIs ```text -jetbrains://gateway/com.coder.toolbox?url=https%3A%2F%2Fdev.coder.com&token=zeoX4SbSpP-j2qGpajkdwxR9jBdcekXS2&workspace=bobiverse-bob&agent=dev&ide_product_code=GO&ide_build_number=241.23774.119&folder=%2Fhome%2Fcoder%2Fworkspace%2Fhello-world-rs +jetbrains://gateway/com.coder.toolbox?url=https%3A%2F%2Fdev.coder.com&token=zeoX4SbSpP-j2qGpajkdwxR9jBdcekXS2&workspace=bobiverse-bob&agent_name=dev&ide_product_code=GO&ide_build_number=241.23774.119&folder=%2Fhome%2Fcoder%2Fworkspace%2Fhello-world-rs -jetbrains://gateway/com.coder.toolbox?url=https%3A%2F%2Fj5gj2r1so5nbi.pit-1.try.coder.app%2F&token=gqEirOoI1U-FfCQ6uj8iOLtybBIk99rr8&workspace=bobiverse-riker&agent=dev&ide_product_code=RR&ide_build_number=243.26053.17&folder=%2Fhome%2Fcoder%2Fworkspace%2Fhello-world-rs +jetbrains://gateway/coder?url=https%3A%2F%2Fj5gj2r1so5nbi.pit-1.try.coder.app%2F&token=gqEirOoI1U-FfCQ6uj8iOLtybBIk99rr8&workspace=bobiverse-riker&agent_name=dev&ide_product_code=RR&ide_build_number=243.26053.17&folder=%2Fhome%2Fcoder%2Fworkspace%2Fhello-world-rs ``` ### URI Breakdown @@ -76,13 +76,15 @@ jetbrains://gateway/com.coder.toolbox ?url=http(s):// &token= &workspace= - &agent_id= + &agent_name= &ide_product_code= &ide_build_number= &folder= ``` -Starting from Toolbox 2.7, you can use `coder` as a shortcut in place of the full plugin ID. The URI can be simplified as: +Starting from Toolbox 2.7, you can use `coder` as a shortcut in place of the full plugin ID. The URI can be simplified +as: + ```text jetbrains://gateway/coder?url=http(s):// ``` @@ -92,16 +94,16 @@ jetbrains://gateway/coder?url=http(s):// | url | Your Coder deployment URL (encoded) | Yes | | token | Coder authentication token | Yes | | workspace | Name of the Coder workspace to connect to. | Yes | -| agent_id | ID of the agent associated with the workspace | No | +| agent_name | The name of the agent with the workspace | No | | ide_product_code | JetBrains IDE product code (e.g., GO for GoLand, RR for Rider) | No | | ide_build_number | Specific build number of the JetBrains IDE to install on the workspace | No | | folder | Absolute path to the project folder to open in the remote IDE (URL-encoded) | No | > [!NOTE] -> If only a single agent is available, specifying an agent ID is optional. However, if multiple agents exist, -> you must provide either the ID to target a specific one. Note that this version of the Coder Toolbox plugin -> does not automatically start agents if they are offline, so please ensure the selected agent is running before -> proceeding. +> If only a single agent is available, specifying an agent name is optional. However, if multiple agents exist, you must +> provide the +> agent name. Note that this version of the Coder Toolbox plugin does not automatically start agents if they +> are offline, so please ensure the selected agent is running before proceeding. If `ide_product_code` and `ide_build_number` is missing, Toolbox will only open and highlight the workspace environment page. Coder Toolbox will attempt to start the workspace if it’s not already running; however, for the most reliable @@ -151,7 +153,9 @@ mitmweb --ssl-insecure --set stream_large_bodies="10m" --mode socks5 > [!NOTE] > Coder Toolbox plugin handles only HTTP/HTTPS proxy authentication. > SOCKS5 proxy authentication is currently not supported due to limitations -> described in: https://youtrack.jetbrains.com/issue/TBX-14532/Missing-proxy-authentication-settings#focus=Comments-27-12265861.0-0 +> described +> +in: https://youtrack.jetbrains.com/issue/TBX-14532/Missing-proxy-authentication-settings#focus=Comments-27-12265861.0-0 ## Debugging and Reporting issues @@ -198,21 +202,21 @@ storage paths. The options can be configured from the plugin's main Workspaces p ### 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. + 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. + directory. - `Enable binary directory fallback` if enabled, falls back to the data directory when the specified binary -directory is not writable. + directory is not writable. - `Data directory` directory where plugin-specific data such as session tokens and binaries are stored if not -overridden by the binary directory setting. + 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. + The environment variable CODER_URL will be available to the command process. ### TLS settings @@ -220,34 +224,34 @@ The following options control the secure communication behavior of the plugin wi 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. + The certificate should be in X.509 PEM format. - `TLS key path` path to the private key corresponding to the TLS certificate from above. -The certificate should be in X.509 PEM format. + The certificate should be in X.509 PEM format. - `TLS CA path` the path of a file containing certificates for an alternate certificate authority used to verify TLS -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. + 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 -used to connect to the Coder deployment does not match the hostname in the TLS certificate. + used to connect to the Coder deployment does not match the hostname in the TLS certificate. ### SSH settings The following options control the SSH behavior of the Coder CLI. - `Disable autostart` adds the --disable-autostart flag to the SSH proxy command, preventing the CLI from keeping -workspaces constantly active. + workspaces constantly active. - `Enable SSH wildcard config` enables or disables wildcard entries in the SSH configuration, which allow generic -rules for matching multiple workspaces. + rules for matching multiple workspaces. - `SSH proxy log directory` directory where SSH proxy logs are written. Useful for debugging SSH connection issues. - `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. + SSH connections. ### Saving Changes @@ -256,7 +260,7 @@ support, may trigger regeneration of SSH configurations. ### Security considerations -> [!IMPORTANT] +> [!IMPORTANT] > Token authentication is required when TLS certificates are not configured. ## Releasing @@ -269,6 +273,7 @@ support, may trigger regeneration of SSH configurations. JetBrains enabled auto-approval for the plugin, so we need to ensure we continue to meet the following requirements: - do **not** use Kotlin experimental APIs. - do **not** add any lambdas, handlers, or class handles to Java runtime hooks. - - do **not** create threads manually (including via libraries). If you must, ensure they are properly cleaned up in the plugin's `CoderRemoteProvider#close()` method. + - do **not** create threads manually (including via libraries). If you must, ensure they are properly cleaned up in + the plugin's `CoderRemoteProvider#close()` method. - do **not** bundle libraries that are already provided by Toolbox. - do **not** perform any ill-intentioned actions. diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 7e50c8a..23b015d 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -258,26 +258,25 @@ open class CoderProtocolHandler( } // If the agent is missing and the workspace has only one, use that. - val agent = - if (!parameters.agentID().isNullOrBlank()) { - agents.firstOrNull { it.id.toString() == parameters.agentID() } - } else if (agents.size == 1) { - agents.first() - } else { - null - } + val agent = if (!parameters.agentName().isNullOrBlank()) { + agents.firstOrNull { it.name == parameters.agentName() } + } else if (agents.size == 1) { + agents.first() + } else { + null + } if (agent == null) { - if (!parameters.agentID().isNullOrBlank()) { + if (!parameters.agentName().isNullOrBlank()) { context.logAndShowError( CAN_T_HANDLE_URI_TITLE, - "The workspace \"${workspace.name}\" does not have an agent with ID \"${parameters.agentID()}\"" + "The workspace \"${workspace.name}\" does not have an agent with name \"${parameters.agentName()}\"" ) return null } else { context.logAndShowError( CAN_T_HANDLE_URI_TITLE, - "Unable to determine which agent to connect to; \"$AGENT_ID\" must be set because the workspace \"${workspace.name}\" has more than one agent" + "Unable to determine which agent to connect to; \"$AGENT_NAME\" must be set because the workspace \"${workspace.name}\" has more than one agent" ) return null } diff --git a/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt b/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt index 0a15db8..a343e14 100644 --- a/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt +++ b/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt @@ -3,7 +3,7 @@ package com.coder.toolbox.util const val URL = "url" const val TOKEN = "token" const val WORKSPACE = "workspace" -const val AGENT_ID = "agent_id" +const val AGENT_NAME = "agent_name" private const val IDE_PRODUCT_CODE = "ide_product_code" private const val IDE_BUILD_NUMBER = "ide_build_number" private const val FOLDER = "folder" @@ -14,7 +14,7 @@ fun Map.token() = this[TOKEN] fun Map.workspace() = this[WORKSPACE] -fun Map.agentID() = this[AGENT_ID] +fun Map.agentName() = this[AGENT_NAME] fun Map.ideProductCode() = this[IDE_PRODUCT_CODE] diff --git a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt index 2914eae..b26acde 100644 --- a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt @@ -18,6 +18,7 @@ import io.mockk.mockk import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.DisplayName import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals @@ -47,40 +48,34 @@ internal class CoderProtocolHandlerTest { private val agents = mapOf( - "agent_name_3" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24", - "agent_name_2" to "fb3daea4-da6b-424d-84c7-36b90574cfef", - "agent_name" to "9a920eee-47fb-4571-9501-e4b3120c12f2", + "agent_name_bob" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24", + "agent_name_bill" to "fb3daea4-da6b-424d-84c7-36b90574cfef", + "agent_name_riker" to "9a920eee-47fb-4571-9501-e4b3120c12f2", ) - private val oneAgent = + private val agentBob = mapOf( - "agent_name_3" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24", + "agent_name_bob" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24", ) @Test - fun tstgetMatchingAgent() { + @DisplayName("given a ws with multiple agents, expect the correct agent to be resolved if it matches the agent_name query param") + fun getMatchingAgent() { val ws = DataGen.workspace("ws", agents = agents) val tests = listOf( Pair( - mapOf("agent_id" to "9a920eee-47fb-4571-9501-e4b3120c12f2"), + mapOf("agent_name" to "agent_name_riker"), "9a920eee-47fb-4571-9501-e4b3120c12f2" ), Pair( - mapOf("agent_id" to "fb3daea4-da6b-424d-84c7-36b90574cfef"), + mapOf("agent_name" to "agent_name_bill"), "fb3daea4-da6b-424d-84c7-36b90574cfef" ), Pair( - mapOf("agent_id" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24"), + mapOf("agent_name" to "agent_name_bob"), "b0e4c54d-9ba9-4413-8512-11ca1e826a24" - ), - // Prefer agent_id. - Pair( - mapOf( - "agent_id" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24", - ), - "b0e4c54d-9ba9-4413-8512-11ca1e826a24", - ), + ) ) runBlocking { tests.forEach { @@ -90,28 +85,20 @@ internal class CoderProtocolHandlerTest { } @Test + @DisplayName("given a ws with only multiple agents expect the agent resolution to fail if none match the agent_name query param") fun failsToGetMatchingAgent() { val ws = DataGen.workspace("ws", agents = agents) val tests = listOf( Triple(emptyMap(), MissingArgumentException::class, "Unable to determine"), - Triple(mapOf("agent_id" to ""), MissingArgumentException::class, "Unable to determine"), - Triple(mapOf("agent_id" to null), MissingArgumentException::class, "Unable to determine"), - Triple(mapOf("agent_id" to "not-a-uuid"), IllegalArgumentException::class, "agent with ID"), + Triple(mapOf("agent_name" to ""), MissingArgumentException::class, "Unable to determine"), + Triple(mapOf("agent_name" to null), MissingArgumentException::class, "Unable to determine"), + Triple(mapOf("agent_name" to "not-an-agent-name"), IllegalArgumentException::class, "agent with ID"), Triple( - mapOf("agent_id" to "ceaa7bcf-1612-45d7-b484-2e0da9349168"), + mapOf("agent_name" to "agent_name_homer"), IllegalArgumentException::class, - "agent with ID" - ), - // Will ignore agent if agent_id is set even if agent matches. - Triple( - mapOf( - "agent" to "agent_name", - "agent_id" to "ceaa7bcf-1612-45d7-b484-2e0da9349168", - ), - IllegalArgumentException::class, - "agent with ID", - ), + "agent with name" + ) ) runBlocking { tests.forEach { @@ -121,15 +108,14 @@ internal class CoderProtocolHandlerTest { } @Test + @DisplayName("given a ws with only one agent, the agent is selected even when agent_name query param was not provided") fun getsFirstAgentWhenOnlyOne() { - val ws = DataGen.workspace("ws", agents = oneAgent) + val ws = DataGen.workspace("ws", agents = agentBob) val tests = listOf( emptyMap(), - mapOf("agent" to ""), - mapOf("agent_id" to ""), - mapOf("agent" to null), - mapOf("agent_id" to null), + mapOf("agent_name" to ""), + mapOf("agent_name" to null) ) runBlocking { tests.forEach { @@ -145,43 +131,42 @@ internal class CoderProtocolHandlerTest { } @Test + @DisplayName("given a ws with only one agent, the agent is NOT selected when agent_name query param was provided but does not match") fun failsToGetAgentWhenOnlyOne() { - val ws = DataGen.workspace("ws", agents = oneAgent) + val wsWithAgentBob = DataGen.workspace("ws", agents = agentBob) val tests = listOf( Triple( - mapOf("agent_id" to "ceaa7bcf-1612-45d7-b484-2e0da9349168"), + mapOf("agent_name" to "agent_name_garfield"), IllegalArgumentException::class, - "agent with ID" + "agent with name" ), ) runBlocking { tests.forEach { - assertNull(protocolHandler.getMatchingAgent(it.first, ws)?.id) + assertNull(protocolHandler.getMatchingAgent(it.first, wsWithAgentBob)) } } } @Test - fun failsToGetAgentWithoutAgents() { - val ws = DataGen.workspace("ws") + @DisplayName("fails to resolve any agent when the workspace has no agents") + fun failsToGetAgentWhenWorkspaceHasNoAgents() { + val wsWithoutAgents = DataGen.workspace("ws") val tests = listOf( Triple(emptyMap(), IllegalArgumentException::class, "has no agents"), - Triple(mapOf("agent" to ""), IllegalArgumentException::class, "has no agents"), - Triple(mapOf("agent_id" to ""), IllegalArgumentException::class, "has no agents"), - Triple(mapOf("agent" to null), IllegalArgumentException::class, "has no agents"), - Triple(mapOf("agent_id" to null), IllegalArgumentException::class, "has no agents"), - Triple(mapOf("agent" to "agent_name"), IllegalArgumentException::class, "has no agents"), + Triple(mapOf("agent_name" to ""), IllegalArgumentException::class, "has no agents"), + Triple(mapOf("agent_name" to null), IllegalArgumentException::class, "has no agents"), Triple( - mapOf("agent_id" to "9a920eee-47fb-4571-9501-e4b3120c12f2"), + mapOf("agent_name" to "agent_name_riker"), IllegalArgumentException::class, "has no agents" ), ) runBlocking { tests.forEach { - assertNull(protocolHandler.getMatchingAgent(it.first, ws)?.id) + assertNull(protocolHandler.getMatchingAgent(it.first, wsWithoutAgents)) } } } From 6ca08df59956f6aad8b6a2a0545e1ebcfdbd6cee Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Tue, 8 Jul 2025 16:40:20 +0500 Subject: [PATCH 28/93] chore: add JetBrains auto-approval compliance linter (#139) Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com> Co-authored-by: matifali <10648092+matifali@users.noreply.github.com> --- .github/workflows/jetbrains-compliance.yml | 68 +++++++ JETBRAINS_COMPLIANCE.md | 85 +++++++++ build.gradle.kts | 19 ++ detekt.yml | 204 +++++++++++++++++++++ gradle/libs.versions.toml | 4 +- 5 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/jetbrains-compliance.yml create mode 100644 JETBRAINS_COMPLIANCE.md create mode 100644 detekt.yml diff --git a/.github/workflows/jetbrains-compliance.yml b/.github/workflows/jetbrains-compliance.yml new file mode 100644 index 0000000..d1d2019 --- /dev/null +++ b/.github/workflows/jetbrains-compliance.yml @@ -0,0 +1,68 @@ +name: JetBrains Auto-Approval Compliance + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + compliance-check: + runs-on: ubuntu-latest + name: JetBrains Compliance Linting + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Run JetBrains Compliance Checks + run: | + echo "Running JetBrains auto-approval compliance checks with detekt..." + ./gradlew detekt + + - name: Upload detekt reports + uses: actions/upload-artifact@v4 + if: always() + with: + name: detekt-reports + path: | + build/reports/detekt/ + retention-days: 30 + + - name: Comment PR with compliance status + if: github.event_name == 'pull_request' && failure() + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: '⚠️ **JetBrains Auto-Approval Compliance Check Failed**\n\n' + + 'This PR contains code that violates JetBrains auto-approval requirements:\n\n' + + '- ❌ Do **not** use forbidden Kotlin experimental APIs\n' + + '- ❌ Do **not** add lambdas, handlers, or class handles to Java runtime hooks\n' + + '- ❌ Do **not** create threads manually (use coroutines or ensure cleanup in `CoderRemoteProvider#close()`)\n' + + '- ❌ Do **not** bundle libraries already provided by Toolbox\n' + + '- ❌ Do **not** perform ill-intentioned actions\n\n' + + 'Please check the workflow logs for detailed violations and fix them before merging.' + }) diff --git a/JETBRAINS_COMPLIANCE.md b/JETBRAINS_COMPLIANCE.md new file mode 100644 index 0000000..306d684 --- /dev/null +++ b/JETBRAINS_COMPLIANCE.md @@ -0,0 +1,85 @@ +# JetBrains Auto-Approval Compliance + +This document describes the linting setup to ensure compliance with JetBrains auto-approval requirements for Toolbox plugins. + +## Overview + +JetBrains has enabled auto-approval for this plugin, which requires following specific guidelines to maintain the approval status. This repository includes automated checks to ensure compliance. + +## Requirements + +Based on communication with JetBrains team, the following requirements must be met: + +### ✅ Allowed +- **Coroutines**: Use `coroutineScope.launch` for concurrent operations +- **Library-managed threads**: Libraries like OkHttp with their own thread pools are acceptable +- **Some experimental coroutines APIs**: `kotlinx.coroutines.selects.select` and `kotlinx.coroutines.selects.onTimeout` are acceptable +- **Proper cleanup**: Ensure resources are released in `CoderRemoteProvider#close()` method + +### ❌ Forbidden +- **Kotlin experimental APIs**: Core Kotlin experimental APIs (not coroutines-specific ones) +- **Java runtime hooks**: No lambdas, handlers, or class handles to Java runtime hooks +- **Manual thread creation**: Avoid `Thread()`, `Executors.new*()`, `ThreadPoolExecutor`, etc. +- **Bundled libraries**: Don't bundle libraries already provided by Toolbox +- **Ill-intentioned actions**: No malicious or harmful code + +## Linting Setup + +### JetBrains Compliance with Detekt + +The primary compliance checking is done using Detekt with custom configuration in `detekt.yml`: + +```bash +./gradlew detekt +``` + +This configuration includes JetBrains-specific rules that check for: +- **ForbiddenAnnotation**: Detects forbidden experimental API usage +- **ForbiddenMethodCall**: Detects Java runtime hooks and manual thread creation +- **ForbiddenImport**: Detects potentially bundled libraries +- **Standard code quality rules**: Complexity, naming, performance, etc. + + + +## CI/CD Integration + +The GitHub Actions workflow `.github/workflows/jetbrains-compliance.yml` runs compliance checks on every PR and push. + +## Running Locally + +```bash +# Run JetBrains compliance and code quality check +./gradlew detekt + +# View HTML report +open build/reports/detekt/detekt.html +``` + + + +## Understanding Results + +### Compliance Check Results + +- **✅ No critical violations**: Code complies with JetBrains requirements +- **❌ Critical violations**: Must be fixed before auto-approval +- **⚠️ Warnings**: Should be reviewed but may be acceptable + +### Common Warnings + +1. **Manual thread creation**: If you see warnings about thread creation: + - Prefer coroutines: `coroutineScope.launch { ... }` + - If using libraries with threads, ensure cleanup in `close()` + +2. **Library imports**: If you see warnings about library imports: + - Verify the library isn't bundled in the final plugin + - Check that Toolbox doesn't already provide the library + +3. **GlobalScope usage**: If you see warnings about `GlobalScope`: + - Use the coroutine scope provided by Toolbox instead + +## Resources + +- [JetBrains Toolbox Plugin Development](https://plugins.jetbrains.com/docs/toolbox/) +- [Detekt Documentation](https://detekt.dev/) +- [Kotlin Coroutines Guide](https://kotlinlang.org/docs/coroutines-guide.html) diff --git a/build.gradle.kts b/build.gradle.kts index 93d13a0..1e8c5cc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,6 +22,7 @@ plugins { alias(libs.plugins.gradle.wrapper) alias(libs.plugins.changelog) alias(libs.plugins.gettext) + alias(libs.plugins.detekt) } @@ -110,6 +111,24 @@ tasks.test { useJUnitPlatform() } +// Detekt configuration for JetBrains compliance and code quality +detekt { + config.setFrom("$projectDir/detekt.yml") + buildUponDefaultConfig = true + allRules = false +} + +// Configure detekt for JetBrains compliance and code quality +tasks.withType().configureEach { + jvmTarget = "21" + reports { + html.required.set(true) + xml.required.set(true) + } + // Fail build on detekt issues for JetBrains compliance + ignoreFailures = false +} + tasks.jar { archiveBaseName.set(extension.id) diff --git a/detekt.yml b/detekt.yml new file mode 100644 index 0000000..5e5e6c8 --- /dev/null +++ b/detekt.yml @@ -0,0 +1,204 @@ +# Detekt configuration for JetBrains Toolbox Plugin Auto-Approval Compliance +# Based on clarified requirements from JetBrains team + +build: + maxIssues: 1000 # Allow many issues for code quality reporting + excludeCorrectable: false + +config: + validation: true + warningsAsErrors: false # Don't treat warnings as errors + checkExhaustiveness: false + +# CRITICAL: JetBrains Compliance Rules using detekt built-in rules +style: + active: true + + # JetBrains Auto-Approval Compliance: Forbidden experimental annotations + ForbiddenAnnotation: + active: true + annotations: + - reason: 'Forbidden for JetBrains auto-approval: Core Kotlin experimental APIs are not allowed' + value: 'kotlin.ExperimentalStdlibApi' + - reason: 'Forbidden for JetBrains auto-approval: Core Kotlin experimental APIs are not allowed' + value: 'kotlin.ExperimentalUnsignedTypes' + - reason: 'Forbidden for JetBrains auto-approval: Core Kotlin experimental APIs are not allowed' + value: 'kotlin.contracts.ExperimentalContracts' + - reason: 'Forbidden for JetBrains auto-approval: Core Kotlin experimental APIs are not allowed' + value: 'kotlin.experimental.ExperimentalTypeInference' + - reason: 'Forbidden for JetBrains auto-approval: Internal coroutines APIs should be avoided' + value: 'kotlinx.coroutines.InternalCoroutinesApi' + - reason: 'Forbidden for JetBrains auto-approval: Experimental time APIs are not allowed' + value: 'kotlin.time.ExperimentalTime' + # Note: ExperimentalCoroutinesApi, DelicateCoroutinesApi, FlowPreview are acceptable + # based on JetBrains feedback about select/onTimeout being OK + + # JetBrains Auto-Approval Compliance: Forbidden method calls + ForbiddenMethodCall: + active: true + methods: + # Java runtime hooks - forbidden + - reason: 'Forbidden for JetBrains auto-approval: Java runtime hooks are not allowed' + value: 'java.lang.Runtime.addShutdownHook' + - reason: 'Forbidden for JetBrains auto-approval: Java runtime hooks are not allowed' + value: 'java.lang.System.setSecurityManager' + - reason: 'Forbidden for JetBrains auto-approval: Java runtime hooks are not allowed' + value: 'java.lang.Thread.setUncaughtExceptionHandler' + - reason: 'Forbidden for JetBrains auto-approval: Java runtime hooks are not allowed' + value: 'java.lang.Thread.setDefaultUncaughtExceptionHandler' + # Manual thread creation - warnings (allowed with proper cleanup) + - reason: 'Warning for JetBrains auto-approval: Manual thread creation detected. Consider using coroutineScope.launch or ensure proper cleanup in CoderRemoteProvider#close()' + value: 'java.lang.Thread.' + - reason: 'Warning for JetBrains auto-approval: Manual thread creation detected. Consider using coroutineScope.launch or ensure proper cleanup in CoderRemoteProvider#close()' + value: 'java.util.concurrent.Executors.newFixedThreadPool' + - reason: 'Warning for JetBrains auto-approval: Manual thread creation detected. Consider using coroutineScope.launch or ensure proper cleanup in CoderRemoteProvider#close()' + value: 'java.util.concurrent.Executors.newCachedThreadPool' + - reason: 'Warning for JetBrains auto-approval: Manual thread creation detected. Consider using coroutineScope.launch or ensure proper cleanup in CoderRemoteProvider#close()' + value: 'java.util.concurrent.Executors.newSingleThreadExecutor' + - reason: 'Warning for JetBrains auto-approval: Manual thread creation detected. Consider using coroutineScope.launch or ensure proper cleanup in CoderRemoteProvider#close()' + value: 'java.util.concurrent.CompletableFuture.runAsync' + - reason: 'Warning for JetBrains auto-approval: Manual thread creation detected. Consider using coroutineScope.launch or ensure proper cleanup in CoderRemoteProvider#close()' + value: 'java.util.concurrent.CompletableFuture.supplyAsync' + + # JetBrains Auto-Approval Compliance: Forbidden imports + ForbiddenImport: + active: true + imports: + # Potentially bundled libraries - warnings + - reason: 'Warning for JetBrains auto-approval: Ensure slf4j is not bundled - it is provided by Toolbox' + value: 'org.slf4j.*' + - reason: 'Warning for JetBrains auto-approval: Ensure annotations library is not bundled - it is provided by Toolbox' + value: 'org.jetbrains.annotations.*' + # Runtime hook classes - forbidden + - reason: 'Forbidden for JetBrains auto-approval: Runtime hook classes are not allowed' + value: 'java.lang.Runtime' + - reason: 'Forbidden for JetBrains auto-approval: Security manager modifications are not allowed' + value: 'java.security.SecurityManager' + + # Other important style rules + MagicNumber: + active: true + ignoreNumbers: + - '-1' + - '0' + - '1' + - '2' + ignoreHashCodeFunction: true + ignorePropertyDeclaration: false + ignoreLocalVariableDeclaration: false + ignoreConstantDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreAnnotation: false + ignoreNamedArgument: true + ignoreEnums: false + ignoreRanges: false + ignoreExtensionFunctions: true + + MaxLineLength: + active: true + maxLineLength: 120 + excludePackageStatements: true + excludeImportStatements: true + excludeCommentStatements: false + + NewLineAtEndOfFile: + active: true + + WildcardImport: + active: true + +# Essential built-in rules for basic code quality +complexity: + active: true + CyclomaticComplexMethod: + active: true + threshold: 15 + LongMethod: + active: true + threshold: 60 + LongParameterList: + active: true + functionThreshold: 6 + constructorThreshold: 7 + NestedBlockDepth: + active: true + threshold: 4 + +coroutines: + active: true + GlobalCoroutineUsage: + active: true + RedundantSuspendModifier: + active: true + SleepInsteadOfDelay: + active: true + +exceptions: + active: true + ExceptionRaisedInUnexpectedLocation: + active: true + ObjectExtendsThrowable: + active: true + PrintStackTrace: + active: true + ReturnFromFinally: + active: true + SwallowedException: + active: true + ThrowingExceptionFromFinally: + active: true + ThrowingExceptionsWithoutMessageOrCause: + active: true + TooGenericExceptionCaught: + active: true + TooGenericExceptionThrown: + active: true + +naming: + active: true + ClassNaming: + active: true + classPattern: '[A-Z][a-zA-Z0-9]*' + FunctionNaming: + active: true + functionPattern: '[a-z][a-zA-Z0-9]*' + PackageNaming: + active: true + packagePattern: '[a-z]+(\.?[a-z][A-Za-z0-9]*)*' + VariableNaming: + active: true + variablePattern: '[a-z][A-Za-z0-9]*' + +performance: + active: true + ArrayPrimitive: + active: true + ForEachOnRange: + active: true + SpreadOperator: + active: true + UnnecessaryTemporaryInstantiation: + active: true + +potential-bugs: + active: true + EqualsAlwaysReturnsTrueOrFalse: + active: true + EqualsWithHashCodeExist: + active: true + ExplicitGarbageCollectionCall: + active: true + HasPlatformType: + active: true + InvalidRange: + active: true + UnreachableCatchBlock: + active: true + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: true + UnsafeCast: + active: true + WrongEqualsTypeParameter: + active: true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 46aa964..e2dc1b4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ changelog = "2.2.1" gettext = "0.7.0" plugin-structure = "3.310" mockk = "1.14.4" +detekt = "1.23.7" [libraries] toolbox-core-api = { module = "com.jetbrains.toolbox:core-api", version.ref = "toolbox-plugin-api" } @@ -45,4 +46,5 @@ dependency-license-report = { id = "com.github.jk1.dependency-license-report", v ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } gradle-wrapper = { id = "me.filippov.gradle.jvm.wrapper", version.ref = "gradle-wrapper" } changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" } -gettext = { id = "name.kropp.kotlinx-gettext", version.ref = "gettext" } \ No newline at end of file +gettext = { id = "name.kropp.kotlinx-gettext", version.ref = "gettext" } +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } From 8dbfa13c5a9928cc71c12fabf2385a53edf34a11 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 11 Jul 2025 18:44:05 +0300 Subject: [PATCH 29/93] Changelog update v0.4.0 (#149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made a big mistake, I approved the previous PR created by the release bot, and the instead of merging the PR I closed the PR 🥺 This PR reconciles the changelog Co-authored-by: GitHub Action --- CHANGELOG.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fcd70c..1a1b072 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,21 +4,26 @@ ### Added +- support for matching workspace agent in the URI via the agent name + +### Removed + +- dropped support for `agent_id` as a URI parameter + +## 0.4.0 - 2025-07-08 + +### Added + - support for basic authentication for HTTP/HTTPS proxy - support for Toolbox 2.7 release -- support for matching workspace agent in the URI via the agent name ### Changed - improved message while loading the workspace -### Removed - -- dropped support for `agent_id` as a URI parameter - ### Fixed -- URI protocol handler is now able to switch to the Coder provider even if the last opened provider was something else +- URI protocol handler is now able to switch to the Coder provider even if the last opened provider was something else ## 0.3.2 - 2025-06-25 From 4fc6cf849e4c4ee34d8661e853d18acc6a99adec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Jul 2025 22:53:03 +0300 Subject: [PATCH 30/93] chore: bump me.filippov.gradle.jvm.wrapper from 0.14.0 to 0.15.0 (#152) Bumps me.filippov.gradle.jvm.wrapper from 0.14.0 to 0.15.0. [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=me.filippov.gradle.jvm.wrapper&package-manager=gradle&previous-version=0.14.0&new-version=0.15.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e2dc1b4..19491b8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ serialization = "1.8.1" okhttp = "4.12.0" dependency-license-report = "2.9" marketplace-client = "2.0.46" -gradle-wrapper = "0.14.0" +gradle-wrapper = "0.15.0" exec = "1.12" moshi = "1.15.2" ksp = "2.1.20-2.0.1" From d0e2fbc7e75b4750ae617991fe8a3bbcd66bdc55 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Jul 2025 22:54:27 +0300 Subject: [PATCH 31/93] chore: bump org.jetbrains.intellij:plugin-repository-rest-client from 2.0.46 to 2.0.47 (#153) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [//]: # (dependabot-start) ⚠️ **Dependabot is rebasing this PR** ⚠️ Rebasing might not happen immediately, so don't worry if this takes some time. Note: if you make any changes to this PR yourself, they will take precedence over the rebase. --- [//]: # (dependabot-end) Bumps [org.jetbrains.intellij:plugin-repository-rest-client](https://github.com/JetBrains/plugin-repository-rest-client) from 2.0.46 to 2.0.47.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.jetbrains.intellij:plugin-repository-rest-client&package-manager=gradle&previous-version=2.0.46&new-version=2.0.47)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 19491b8..32f2877 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ coroutines = "1.10.2" serialization = "1.8.1" okhttp = "4.12.0" dependency-license-report = "2.9" -marketplace-client = "2.0.46" +marketplace-client = "2.0.47" gradle-wrapper = "0.15.0" exec = "1.12" moshi = "1.15.2" From e02c86656b205208b300128a024903efd19f759b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Jul 2025 22:55:13 +0300 Subject: [PATCH 32/93] chore: bump io.gitlab.arturbosch.detekt from 1.23.7 to 1.23.8 (#154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [io.gitlab.arturbosch.detekt](https://github.com/detekt/detekt) from 1.23.7 to 1.23.8.
Release notes

Sourced from io.gitlab.arturbosch.detekt's releases.

v1.23.8

1.23.8 - 2025-02-20

This is a point release for Detekt 1.23.0, built against Kotlin 2.0.21, with fixes for several bugs that got reported by the community.

Notable Changes
  • fix(deps): Update kotlin to 2.0.21 - #7580
  • fix(deps): Update AGP to v8.8.1 - #7879
  • fix(deps): update Gradle to v8.12.1 - #7780
Changelog
  • UseDataClass: do not report on expect classes - #7857
  • Fix InjectDispatcher false positives - #7797
  • [UnnecessaryParentheses] Allow float/double without integer part - #7751
  • Fix ThrowingExceptionsWithoutMessageOrCause false positive - #7715
  • Issue #7634: Make UndocumentedPublicClass configurable to flag `com… - #7635
  • Fix redundant empty tags in baseline XML - #7625
  • MatchingDeclarationName now supports platofrm suffixes - #6426
Contributors

We would like to thank the following contributors that made this release possible: @​BraisGabin, @​JordanLongstaff, @​Nava2, @​atulgpt, @​eygraber, @​lexa-diky, @​t-kameyama

Commits
  • 0462637 Prepare Release 1.23.8 (#7976)
  • 42856f6 chore(deps): update dependency gradle to v8.12.1 (#7780)
  • 8f354e6 fix(deps): update dependency com.android.tools.build:gradle to v8.8.1 (#7936)
  • 435188c [LOCAL] Bump upload-artifacts to v4 to unblock GHA
  • a147198 [UnnecessaryParentheses] Allow float/double without integer part (#7751)
  • 66d5f2c [LOCAL] Unbreak functionalTest after 2.0.21 bump
  • 4487e61 [LOCAL] Unbreak warnings-as-errors after 2.0.21 bump
  • b07d697 [LOCAL] Unblock build after Kotlin 2.0.21 bump
  • a6d0bd7 fix(deps): update kotlin to 2.0.21 (#7580)
  • ba84337 chore(deps): update dependency gradle to v8.10.2 (#7668)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=io.gitlab.arturbosch.detekt&package-manager=gradle&previous-version=1.23.7&new-version=1.23.8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 32f2877..f925bdc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,7 +15,7 @@ changelog = "2.2.1" gettext = "0.7.0" plugin-structure = "3.310" mockk = "1.14.4" -detekt = "1.23.7" +detekt = "1.23.8" [libraries] toolbox-core-api = { module = "com.jetbrains.toolbox:core-api", version.ref = "toolbox-plugin-api" } From cb3aae6a41b748aa38c3c4a873a93bb91db4d326 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 18 Jul 2025 01:00:17 +0300 Subject: [PATCH 33/93] impl: verify cli signature (#148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR introduces support for verifying the CLI binary using a detached PGP signature. Starting with version 2.24, Coder signs all CLI binaries. For clients using older versions or running TBX in air-gapped environments, unsigned CLIs can still be executed — but users will have to confirm it each time. In terms of code changes - the PR includes a big refactor around CLI downloading with most of the code refactored and extracted in various components that provide clean steps and result state in the main download method. Then the pgp verification logic was added on top, with some particularities: - the pgp public key is embedded in the plugin as a jar resource - we support multiple key rings in the public key - the user has the option of running the CLI if no signature was found - the signature search has a fallback approach: first we look in the Coder deployment, and then fall back to releases.coder.com to search for the signature if the user allows it. - we expect the signature to be under the same relative path as the CLI (we have an option which allows user to pick the CLI from a different source other than the Coder deployment) --- CHANGELOG.md | 1 + build.gradle.kts | 1 + gradle.properties | 2 +- gradle/libs.versions.toml | 4 + .../com/coder/toolbox/CoderRemoteProvider.kt | 59 ++-- .../com/coder/toolbox/cli/CoderCLIManager.kt | 320 ++++++++++-------- .../cli/downloader/CoderDownloadApi.kt | 29 ++ .../cli/downloader/CoderDownloadService.kt | 233 +++++++++++++ .../toolbox/cli/downloader/DownloadResult.kt | 23 ++ .../com/coder/toolbox/cli/ex/Exceptions.kt | 2 + .../com/coder/toolbox/cli/gpg/GPGVerifier.kt | 137 ++++++++ .../toolbox/cli/gpg/VerificationResult.kt | 15 + .../toolbox/settings/ReadOnlyCoderSettings.kt | 39 +++ .../coder/toolbox/store/CoderSettingsStore.kt | 77 +++-- .../com/coder/toolbox/store/StoreKeys.kt | 2 + src/main/kotlin/com/coder/toolbox/util/OS.kt | 22 +- .../kotlin/com/coder/toolbox/util/SemVer.kt | 2 +- .../coder/toolbox/views/CoderSettingsPage.kt | 93 ++++- .../coder/toolbox/views/DeploymentUrlStep.kt | 36 +- .../META-INF/trusted-keys/pgp-public.key | 99 ++++++ .../resources/localization/defaultMessages.po | 15 + .../coder/toolbox/cli/CoderCLIManagerTest.kt | 248 ++++++++------ .../toolbox/store/CoderSettingsStoreTest.kt | 93 +++++ 23 files changed, 1230 insertions(+), 322 deletions(-) create mode 100644 src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadApi.kt create mode 100644 src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt create mode 100644 src/main/kotlin/com/coder/toolbox/cli/downloader/DownloadResult.kt create mode 100644 src/main/kotlin/com/coder/toolbox/cli/gpg/GPGVerifier.kt create mode 100644 src/main/kotlin/com/coder/toolbox/cli/gpg/VerificationResult.kt create mode 100644 src/main/resources/META-INF/trusted-keys/pgp-public.key create mode 100644 src/test/kotlin/com/coder/toolbox/store/CoderSettingsStoreTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a1b072..69ccd25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - support for matching workspace agent in the URI via the agent name +- support for checking if CLI is signed ### Removed diff --git a/build.gradle.kts b/build.gradle.kts index 1e8c5cc..cdfc5e8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -63,6 +63,7 @@ dependencies { ksp(libs.moshi.codegen) implementation(libs.retrofit) implementation(libs.retrofit.moshi) + implementation(libs.bundles.bouncycastle) testImplementation(kotlin("test")) testImplementation(libs.mokk) testImplementation(libs.bundles.toolbox.plugin.api) diff --git a/gradle.properties b/gradle.properties index efbc54f..9513b30 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.4.0 +version=0.5.0 group=com.coder.toolbox name=coder-toolbox diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f925bdc..28820b1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,7 @@ gettext = "0.7.0" plugin-structure = "3.310" mockk = "1.14.4" detekt = "1.23.8" +bouncycastle = "1.81" [libraries] toolbox-core-api = { module = "com.jetbrains.toolbox:core-api", version.ref = "toolbox-plugin-api" } @@ -34,10 +35,13 @@ retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.re plugin-structure = { module = "org.jetbrains.intellij.plugins:structure-toolbox", version.ref = "plugin-structure" } mokk = { module = "io.mockk:mockk", version.ref = "mockk" } marketplace-client = { module = "org.jetbrains.intellij:plugin-repository-rest-client", version.ref = "marketplace-client" } +bouncycastle-bcpg = { module = "org.bouncycastle:bcpg-jdk18on", version.ref = "bouncycastle" } +bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" } [bundles] serialization = ["serialization-core", "serialization-json", "serialization-json-okio"] toolbox-plugin-api = ["toolbox-core-api", "toolbox-ui-api", "toolbox-remote-dev-api"] +bouncycastle = ["bouncycastle-bcpg", "bouncycastle-bcprov"] [plugins] kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index be4c40a..3e3172a 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select import java.net.URI +import java.util.UUID import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource @@ -302,31 +303,51 @@ class CoderRemoteProvider( * Handle incoming links (like from the dashboard). */ override suspend fun handleUri(uri: URI) { - linkHandler.handle( - uri, shouldDoAutoSetup(), - { - coderHeaderPage.isBusyCreatingNewEnvironment.update { - true + try { + linkHandler.handle( + uri, shouldDoAutoSetup(), + { + coderHeaderPage.isBusyCreatingNewEnvironment.update { + true + } + }, + { + coderHeaderPage.isBusyCreatingNewEnvironment.update { + false + } } - }, - { - coderHeaderPage.isBusyCreatingNewEnvironment.update { + ) { restClient, cli -> + // stop polling and de-initialize resources + close() + isInitialized.update { false } + // start initialization with the new settings + this@CoderRemoteProvider.client = restClient + coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString())) + + environments.showLoadingMessage() + pollJob = poll(restClient, cli) + isInitialized.waitForTrue() } - ) { restClient, cli -> - // stop polling and de-initialize resources - close() - isInitialized.update { + } catch (ex: Exception) { + context.logger.error(ex, "") + val textError = if (ex is APIResponseException) { + if (!ex.reason.isNullOrBlank()) { + ex.reason + } else ex.message + } else ex.message + + context.ui.showSnackbar( + UUID.randomUUID().toString(), + context.i18n.ptrl("Error encountered while handling Coder URI"), + context.i18n.pnotr(textError ?: ""), + context.i18n.ptrl("Dismiss") + ) + } finally { + coderHeaderPage.isBusyCreatingNewEnvironment.update { false } - // start initialization with the new settings - this@CoderRemoteProvider.client = restClient - coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString())) - - environments.showLoadingMessage() - pollJob = poll(restClient, cli) - isInitialized.waitForTrue() } } diff --git a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt index e4ef501..177ba81 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt @@ -1,40 +1,43 @@ package com.coder.toolbox.cli import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.cli.downloader.CoderDownloadApi +import com.coder.toolbox.cli.downloader.CoderDownloadService +import com.coder.toolbox.cli.downloader.DownloadResult +import com.coder.toolbox.cli.downloader.DownloadResult.Downloaded import com.coder.toolbox.cli.ex.MissingVersionException -import com.coder.toolbox.cli.ex.ResponseException import com.coder.toolbox.cli.ex.SSHConfigFormatException +import com.coder.toolbox.cli.ex.UnsignedBinaryExecutionDeniedException +import com.coder.toolbox.cli.gpg.GPGVerifier +import com.coder.toolbox.cli.gpg.VerificationResult +import com.coder.toolbox.cli.gpg.VerificationResult.Failed +import com.coder.toolbox.cli.gpg.VerificationResult.Invalid import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent -import com.coder.toolbox.settings.ReadOnlyCoderSettings +import com.coder.toolbox.settings.SignatureFallbackStrategy.ALLOW import com.coder.toolbox.util.CoderHostnameVerifier import com.coder.toolbox.util.InvalidVersionException -import com.coder.toolbox.util.OS import com.coder.toolbox.util.SemVer import com.coder.toolbox.util.coderSocketFactory +import com.coder.toolbox.util.coderTrustManagers import com.coder.toolbox.util.escape import com.coder.toolbox.util.escapeSubcommand -import com.coder.toolbox.util.getHeaders -import com.coder.toolbox.util.getOS import com.coder.toolbox.util.safeHost -import com.coder.toolbox.util.sha1 -import com.jetbrains.toolbox.api.core.diagnostics.Logger 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.withContext +import okhttp3.OkHttpClient import org.zeroturnaround.exec.ProcessExecutor +import retrofit2.Retrofit import java.io.EOFException -import java.io.FileInputStream import java.io.FileNotFoundException -import java.net.ConnectException -import java.net.HttpURLConnection import java.net.URL import java.nio.file.Files import java.nio.file.Path -import java.nio.file.StandardOpenOption -import java.util.zip.GZIPInputStream -import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.X509TrustManager /** * Version output from the CLI's version command. @@ -44,7 +47,6 @@ internal data class Version( @Json(name = "version") val version: String, ) -private const val DOWNLOADING_CODER_CLI = "Downloading Coder CLI..." /** * Do as much as possible to get a valid, up-to-date CLI. @@ -58,14 +60,19 @@ private const val DOWNLOADING_CODER_CLI = "Downloading Coder CLI..." * 6. Since the binary directory can be read-only, if downloading fails, start * from step 2 with the data directory. */ -fun ensureCLI( +suspend fun ensureCLI( context: CoderToolboxContext, deploymentURL: URL, buildVersion: String, showTextProgress: (String) -> Unit ): CoderCLIManager { + fun reportProgress(msg: String) { + showTextProgress(msg) + context.logger.info(msg) + } + val settings = context.settingsStore.readOnly() - val cli = CoderCLIManager(deploymentURL, context.logger, settings) + val cli = CoderCLIManager(context, deploymentURL) // Short-circuit if we already have the expected version. This // lets us bypass the 304 which is slower and may not be @@ -74,13 +81,13 @@ fun ensureCLI( // the 304 method. val cliMatches = cli.matchesVersion(buildVersion) if (cliMatches == true) { + reportProgress("Local CLI version matches server version: $buildVersion") return cli } // If downloads are enabled download the new version. if (settings.enableDownloads) { - context.logger.info(DOWNLOADING_CODER_CLI) - showTextProgress(DOWNLOADING_CODER_CLI) + reportProgress("Downloading Coder CLI...") try { cli.download(buildVersion, showTextProgress) return cli @@ -95,15 +102,15 @@ fun ensureCLI( } // Try falling back to the data directory. - val dataCLI = CoderCLIManager(deploymentURL, context.logger, settings, true) + 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 } if (settings.enableDownloads) { - context.logger.info(DOWNLOADING_CODER_CLI) - showTextProgress(DOWNLOADING_CODER_CLI) + reportProgress("Downloading Coder CLI to the data directory...") dataCLI.download(buildVersion, showTextProgress) return dataCLI } @@ -126,122 +133,162 @@ data class Features( * Manage the CLI for a single deployment. */ class CoderCLIManager( + private val context: CoderToolboxContext, // The URL of the deployment this CLI is for. private val deploymentURL: URL, - private val logger: Logger, - // Plugin configuration. - private val settings: ReadOnlyCoderSettings, // If the binary directory is not writable, this can be used to force the // manager to download to the data directory instead. - forceDownloadToData: Boolean = false, + private val forceDownloadToData: Boolean = false, ) { - val remoteBinaryURL: URL = settings.binSource(deploymentURL) - val localBinaryPath: Path = settings.binPath(deploymentURL, forceDownloadToData) - val coderConfigPath: Path = settings.dataDir(deploymentURL).resolve("config") + 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 coderConfigPath: Path = context.settingsStore.dataDir(deploymentURL).resolve("config") + + private fun createDownloadService(): CoderDownloadService { + val okHttpClient = OkHttpClient.Builder() + .sslSocketFactory( + coderSocketFactory(context.settingsStore.tls), + coderTrustManagers(context.settingsStore.tls.caPath)[0] as X509TrustManager + ) + .hostnameVerifier(CoderHostnameVerifier(context.settingsStore.tls.altHostname)) + .build() + + val retrofit = Retrofit.Builder() + .baseUrl(deploymentURL.toString()) + .client(okHttpClient) + .build() + + val service = retrofit.create(CoderDownloadApi::class.java) + return CoderDownloadService(context, service, deploymentURL, forceDownloadToData) + } /** * Download the CLI from the deployment if necessary. */ - fun download(buildVersion: String, showTextProgress: (String) -> Unit): Boolean { - val eTag = getBinaryETag() - val conn = remoteBinaryURL.openConnection() as HttpURLConnection - if (!settings.headerCommand.isNullOrBlank()) { - val headersFromHeaderCommand = getHeaders(deploymentURL, settings.headerCommand) - for ((key, value) in headersFromHeaderCommand) { - conn.setRequestProperty(key, value) + suspend fun download(buildVersion: String, showTextProgress: (String) -> Unit): Boolean { + try { + val cliResult = withContext(Dispatchers.IO) { + downloader.downloadCli(buildVersion, showTextProgress) + }.let { result -> + when { + result.isSkipped() -> return false + result.isNotFound() -> throw IllegalStateException("Could not find Coder CLI") + result.isFailed() -> throw (result as DownloadResult.Failed).error + else -> result as Downloaded + } } - } - if (eTag != null) { - logger.info("Found existing binary at $localBinaryPath; calculated hash as $eTag") - conn.setRequestProperty("If-None-Match", "\"$eTag\"") - } - conn.setRequestProperty("Accept-Encoding", "gzip") - if (conn is HttpsURLConnection) { - conn.sslSocketFactory = coderSocketFactory(settings.tls) - conn.hostnameVerifier = CoderHostnameVerifier(settings.tls.altHostname) - } - try { - conn.connect() - logger.info("GET ${conn.responseCode} $remoteBinaryURL") - when (conn.responseCode) { - HttpURLConnection.HTTP_OK -> { - logger.info("Downloading binary to $localBinaryPath") - Files.deleteIfExists(localBinaryPath) - Files.createDirectories(localBinaryPath.parent) - val outputStream = Files.newOutputStream( - localBinaryPath, - StandardOpenOption.CREATE, - StandardOpenOption.TRUNCATE_EXISTING - ) - val sourceStream = if (conn.isGzip()) GZIPInputStream(conn.inputStream) else conn.inputStream - - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var bytesRead: Int - var totalRead = 0L - - sourceStream.use { source -> - outputStream.use { sink -> - while (source.read(buffer).also { bytesRead = it } != -1) { - sink.write(buffer, 0, bytesRead) - totalRead += bytesRead - showTextProgress("${settings.defaultCliBinaryNameByOsAndArch} $buildVersion - ${totalRead.toHumanReadableSize()} downloaded") - } + var signatureResult = withContext(Dispatchers.IO) { + downloader.downloadSignature(showTextProgress) + } + + if (signatureResult.isNotDownloaded()) { + if (context.settingsStore.fallbackOnCoderForSignatures == ALLOW) { + context.logger.info("Trying to download signature file from releases.coder.com") + signatureResult = withContext(Dispatchers.IO) { + downloader.downloadReleasesSignature(buildVersion, showTextProgress) + } + + // if we could still not download it, ask the user if he accepts the risk + if (signatureResult.isNotDownloaded()) { + val acceptsUnsignedBinary = context.ui.showYesNoPopup( + context.i18n.ptrl("Security Warning"), + context.i18n.pnotr("Could not fetch any signatures for ${cliResult.source} from releases.coder.com. Would you like to run it anyway?"), + context.i18n.ptrl("Accept"), + context.i18n.ptrl("Abort"), + ) + + if (acceptsUnsignedBinary) { + downloader.commit() + return true + } else { + throw UnsignedBinaryExecutionDeniedException("Running unsigned CLI from ${cliResult.source} was denied by the user") } } - if (getOS() != OS.WINDOWS) { - localBinaryPath.toFile().setExecutable(true) + } else { + // we are not allowed to fetch signatures from releases.coder.com + // so we will ask the user if he wants to continue + val acceptsUnsignedBinary = context.ui.showYesNoPopup( + context.i18n.ptrl("Security Warning"), + context.i18n.pnotr("No signatures were found for ${cliResult.source} and fallback to releases.coder.com is not allowed. Would you like to run it anyway?"), + context.i18n.ptrl("Accept"), + context.i18n.ptrl("Abort"), + ) + + if (acceptsUnsignedBinary) { + downloader.commit() + return true + } else { + throw UnsignedBinaryExecutionDeniedException("Running unsigned CLI from ${cliResult.source} was denied by the user") } - return true } + } + + // we have the cli, and signature is downloaded, let's verify the signature + signatureResult = signatureResult as Downloaded + gpgVerifier.verifySignature(cliResult.dst, signatureResult.dst).let { result -> + when { + result.isValid() -> { + downloader.commit() + return true + } - HttpURLConnection.HTTP_NOT_MODIFIED -> { - logger.info("Using cached binary at $localBinaryPath") - showTextProgress("Using cached binary") - return false + else -> { + logFailure(result, cliResult, signatureResult) + // prompt the user if he wants to accept the risk + val shouldRunAnyway = context.ui.showYesNoPopup( + context.i18n.ptrl("Security Warning"), + context.i18n.pnotr("Could not verify the authenticity of the ${cliResult.source}, it may be tampered with. Would you like to run it anyway?"), + context.i18n.ptrl("Run anyway"), + context.i18n.ptrl("Abort"), + ) + + if (shouldRunAnyway) { + downloader.commit() + return true + } else { + throw UnsignedBinaryExecutionDeniedException("Running unverified CLI from ${cliResult.source} was denied by the user") + } + } } } - } catch (e: ConnectException) { - // Add the URL so this is more easily debugged. - throw ConnectException("${e.message} to $remoteBinaryURL") } finally { - conn.disconnect() + downloader.cleanup() } - throw ResponseException("Unexpected response from $remoteBinaryURL", conn.responseCode) } - private fun HttpURLConnection.isGzip(): Boolean = this.contentEncoding.equals("gzip", ignoreCase = true) - - fun Long.toHumanReadableSize(): String { - if (this < 1024) return "$this B" - - val kb = this / 1024.0 - if (kb < 1024) return String.format("%.1f KB", kb) - - val mb = kb / 1024.0 - if (mb < 1024) return String.format("%.1f MB", mb) + private fun logFailure( + result: VerificationResult, + cliResult: Downloaded, + signatureResult: Downloaded + ) { + when { + result.isInvalid() -> { + val reason = (result as Invalid).reason + context.logger.error("Signature of ${cliResult.dst} is invalid." + reason?.let { " Reason: $it" } + .orEmpty()) + } - val gb = mb / 1024.0 - return String.format("%.1f GB", gb) - } + result.signatureIsNotFound() -> { + context.logger.error("Can't verify signature of ${cliResult.dst} because ${signatureResult.dst} does not exist") + } - /** - * Return the entity tag for the binary on disk, if any. - */ - private fun getBinaryETag(): String? = try { - sha1(FileInputStream(localBinaryPath.toFile())) - } catch (e: FileNotFoundException) { - null - } catch (e: Exception) { - logger.warn(e, "Unable to calculate hash for $localBinaryPath") - null + else -> { + UnsignedBinaryExecutionDeniedException((result as Failed).error.message) + val failure = result as DownloadResult.Failed + context.logger.error(failure.error, "Failed to verify signature for ${cliResult.dst}") + } + } } /** * Use the provided token to initializeSession the CLI. */ fun login(token: String): String { - logger.info("Storing CLI credentials in $coderConfigPath") + context.logger.info("Storing CLI credentials in $coderConfigPath") return exec( "login", deploymentURL.toString(), @@ -261,7 +308,7 @@ class CoderCLIManager( wsWithAgents: Set>, feats: Features = features, ) { - logger.info("Configuring SSH config at ${settings.sshConfigPath}") + context.logger.info("Configuring SSH config at ${context.settingsStore.sshConfigPath}") writeSSHConfig(modifySSHConfig(readSSHConfig(), wsWithAgents, feats)) } @@ -269,8 +316,8 @@ class CoderCLIManager( * Return the contents of the SSH config or null if it does not exist. */ private fun readSSHConfig(): String? = try { - Path.of(settings.sshConfigPath).toFile().readText() - } catch (e: FileNotFoundException) { + Path.of(context.settingsStore.sshConfigPath).toFile().readText() + } catch (_: FileNotFoundException) { null } @@ -301,21 +348,21 @@ class CoderCLIManager( // always use the correct URL. "--url", escape(deploymentURL.toString()), - if (!settings.headerCommand.isNullOrBlank()) "--header-command" else null, - if (!settings.headerCommand.isNullOrBlank()) escapeSubcommand(settings.headerCommand!!) else null, + if (!context.settingsStore.headerCommand.isNullOrBlank()) "--header-command" else null, + if (!context.settingsStore.headerCommand.isNullOrBlank()) escapeSubcommand(context.settingsStore.headerCommand!!) else null, "ssh", "--stdio", - if (settings.disableAutostart && feats.disableAutostart) "--disable-autostart" else null, - "--network-info-dir ${escape(settings.networkInfoDir)}" + if (context.settingsStore.disableAutostart && feats.disableAutostart) "--disable-autostart" else null, + "--network-info-dir ${escape(context.settingsStore.networkInfoDir)}" ) val proxyArgs = baseArgs + listOfNotNull( - if (!settings.sshLogDirectory.isNullOrBlank()) "--log-dir" else null, - if (!settings.sshLogDirectory.isNullOrBlank()) escape(settings.sshLogDirectory!!) else null, + if (!context.settingsStore.sshLogDirectory.isNullOrBlank()) "--log-dir" else null, + if (!context.settingsStore.sshLogDirectory.isNullOrBlank()) escape(context.settingsStore.sshLogDirectory!!) else null, if (feats.reportWorkspaceUsage) "--usage-app=jetbrains" else null, ) val extraConfig = - if (!settings.sshConfigOptions.isNullOrBlank()) { - "\n" + settings.sshConfigOptions!!.prependIndent(" ") + if (!context.settingsStore.sshConfigOptions.isNullOrBlank()) { + "\n" + context.settingsStore.sshConfigOptions!!.prependIndent(" ") } else { "" } @@ -327,7 +374,7 @@ class CoderCLIManager( SetEnv CODER_SSH_SESSION_TYPE=JetBrains """.trimIndent() - val blockContent = if (settings.isSshWildcardConfigEnabled && feats.wildcardSsh) { + val blockContent = if (context.settingsStore.isSshWildcardConfigEnabled && feats.wildcardSsh) { startBlock + System.lineSeparator() + """ Host ${getHostnamePrefix(deploymentURL)}--* @@ -357,7 +404,7 @@ class CoderCLIManager( } if (contents == null) { - logger.info("No existing SSH config to modify") + context.logger.info("No existing SSH config to modify") return blockContent + System.lineSeparator() } @@ -365,12 +412,12 @@ class CoderCLIManager( val end = "$endBlock(\\s*)".toRegex().find(contents) if (start == null && end == null && isRemoving) { - logger.info("No workspaces and no existing config blocks to remove") + context.logger.info("No workspaces and no existing config blocks to remove") return null } if (start == null && end == null) { - logger.info("Appending config block") + context.logger.info("Appending config block") val toAppend = if (contents.isEmpty()) { blockContent @@ -394,7 +441,7 @@ class CoderCLIManager( } if (isRemoving) { - logger.info("No workspaces; removing config block") + context.logger.info("No workspaces; removing config block") return listOf( contents.substring(0, start.range.first), // Need to keep the trailing newline(s) if we are not at the @@ -405,7 +452,7 @@ class CoderCLIManager( ).joinToString("") } - logger.info("Replacing existing config block") + context.logger.info("Replacing existing config block") return listOf( contents.substring(0, start.range.first), start.groupValues[1], // Leading newline(s). @@ -420,14 +467,14 @@ class CoderCLIManager( */ private fun writeSSHConfig(contents: String?) { if (contents != null) { - if (!settings.sshConfigPath.isNullOrBlank()) { - val sshConfPath = Path.of(settings.sshConfigPath) + if (!context.settingsStore.sshConfigPath.isNullOrBlank()) { + val sshConfPath = Path.of(context.settingsStore.sshConfigPath) sshConfPath.parent.toFile().mkdirs() sshConfPath.toFile().writeText(contents) } // The Coder cli will *not* create the log directory. - if (!settings.sshLogDirectory.isNullOrBlank()) { - Path.of(settings.sshLogDirectory).toFile().mkdirs() + if (!context.settingsStore.sshLogDirectory.isNullOrBlank()) { + Path.of(context.settingsStore.sshLogDirectory).toFile().mkdirs() } } } @@ -460,14 +507,14 @@ class CoderCLIManager( } catch (e: Exception) { when (e) { is InvalidVersionException -> { - logger.info("Got invalid version from $localBinaryPath: ${e.message}") + context.logger.info("Got invalid version from $localBinaryPath: ${e.message}") } else -> { - // An error here most likely means the CLI does not exist or + // An error here most likely means the CLI does not exist, or // it executed successfully but output no version which // suggests it is not the right binary. - logger.info("Unable to determine $localBinaryPath version: ${e.message}") + context.logger.info("Unable to determine $localBinaryPath version: ${e.message}") } } null @@ -480,17 +527,18 @@ class CoderCLIManager( * version could not be parsed. */ fun matchesVersion(rawBuildVersion: String): Boolean? { + if (Files.notExists(localBinaryPath)) return null val cliVersion = tryVersion() ?: return null val buildVersion = try { SemVer.parse(rawBuildVersion) } catch (e: InvalidVersionException) { - logger.info("Got invalid build version: $rawBuildVersion") + context.logger.info("Got invalid build version: $rawBuildVersion") return null } val matches = cliVersion == buildVersion - logger.info("$localBinaryPath version $cliVersion matches $buildVersion: $matches") + context.logger.info("$localBinaryPath version $cliVersion matches $buildVersion: $matches") return matches } @@ -498,13 +546,13 @@ class CoderCLIManager( val stdout = ProcessExecutor() .command(localBinaryPath.toString(), *args) - .environment("CODER_HEADER_COMMAND", settings.headerCommand) + .environment("CODER_HEADER_COMMAND", context.settingsStore.headerCommand) .exitValues(0) .readOutput(true) .execute() .outputUTF8() val redactedArgs = listOf(*args).joinToString(" ").replace(tokenRegex, "--token ") - logger.info("`$localBinaryPath $redactedArgs`: $stdout") + context.logger.info("`$localBinaryPath $redactedArgs`: $stdout") return stdout } @@ -523,7 +571,7 @@ class CoderCLIManager( } fun getHostname(url: URL, ws: Workspace, agent: WorkspaceAgent): String { - return if (settings.isSshWildcardConfigEnabled && features.wildcardSsh) { + return if (context.settingsStore.isSshWildcardConfigEnabled && features.wildcardSsh) { "${getHostnamePrefix(url)}--${ws.ownerName}--${ws.name}.${agent.name}" } else { "coder-jetbrains-toolbox--${ws.ownerName}--${ws.name}.${agent.name}--${url.safeHost()}" diff --git a/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadApi.kt b/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadApi.kt new file mode 100644 index 0000000..4e27569 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadApi.kt @@ -0,0 +1,29 @@ +package com.coder.toolbox.cli.downloader + +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.HeaderMap +import retrofit2.http.Streaming +import retrofit2.http.Url + +/** + * Retrofit API for downloading CLI + */ +interface CoderDownloadApi { + @GET + @Streaming + suspend fun downloadCli( + @Url url: String, + @Header("If-None-Match") eTag: String? = null, + @HeaderMap headers: Map = emptyMap(), + @Header("Accept-Encoding") acceptEncoding: String = "gzip", + ): Response + + @GET + suspend fun downloadSignature( + @Url url: String, + @HeaderMap headers: Map = emptyMap() + ): Response +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt b/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt new file mode 100644 index 0000000..03e3a4d --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt @@ -0,0 +1,233 @@ +package com.coder.toolbox.cli.downloader + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.cli.ex.ResponseException +import com.coder.toolbox.util.OS +import com.coder.toolbox.util.SemVer +import com.coder.toolbox.util.getHeaders +import com.coder.toolbox.util.getOS +import com.coder.toolbox.util.sha1 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.ResponseBody +import retrofit2.Response +import java.io.FileInputStream +import java.net.HttpURLConnection.HTTP_NOT_FOUND +import java.net.HttpURLConnection.HTTP_NOT_MODIFIED +import java.net.HttpURLConnection.HTTP_OK +import java.net.URI +import java.net.URL +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.util.zip.GZIPInputStream +import kotlin.io.path.name +import kotlin.io.path.notExists + +/** + * 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 remoteBinaryURL: URL = context.settingsStore.binSource(deploymentUrl) + private val cliFinalDst: Path = context.settingsStore.binPath(deploymentUrl, forceDownloadToData) + private val cliTempDst: Path = cliFinalDst.resolveSibling("${cliFinalDst.name}.tmp") + + suspend fun downloadCli(buildVersion: String, showTextProgress: (String) -> Unit): DownloadResult { + val eTag = calculateLocalETag() + if (eTag != null) { + context.logger.info("Found existing binary at $cliFinalDst; calculated hash as $eTag") + } + val response = downloadApi.downloadCli( + url = remoteBinaryURL.toString(), + eTag = eTag?.let { "\"$it\"" }, + headers = getRequestHeaders() + ) + + return when (response.code()) { + HTTP_OK -> { + context.logger.info("Downloading binary to temporary $cliTempDst") + response.saveToDisk(cliTempDst, showTextProgress, buildVersion)?.makeExecutable() + DownloadResult.Downloaded(remoteBinaryURL, cliTempDst) + } + + HTTP_NOT_MODIFIED -> { + context.logger.info("Using cached binary at $cliFinalDst") + showTextProgress("Using cached binary") + DownloadResult.Skipped + } + + else -> { + throw ResponseException( + "Unexpected response from $remoteBinaryURL", + response.code() + ) + } + } + } + + /** + * Renames the temporary binary file to its original destination name. + * The implementation will override sibling file that has the original + * destination name. + */ + suspend fun commit(): Path { + return withContext(Dispatchers.IO) { + context.logger.info("Renaming binary from $cliTempDst to $cliFinalDst") + Files.move(cliTempDst, cliFinalDst, StandardCopyOption.REPLACE_EXISTING) + cliFinalDst.makeExecutable() + cliFinalDst + } + } + + /** + * Cleans up the temporary binary file if it exists. + */ + suspend fun cleanup() { + withContext(Dispatchers.IO) { + runCatching { Files.deleteIfExists(cliTempDst) } + .onFailure { ex -> + context.logger.warn(ex, "Failed to delete temporary CLI file: $cliTempDst") + } + } + } + + private fun calculateLocalETag(): String? { + return try { + if (cliFinalDst.notExists()) { + return null + } + sha1(FileInputStream(cliFinalDst.toFile())) + } catch (e: Exception) { + context.logger.warn(e, "Unable to calculate hash for $cliFinalDst") + null + } + } + + private fun getRequestHeaders(): Map { + return if (context.settingsStore.headerCommand.isNullOrBlank()) { + emptyMap() + } else { + getHeaders(deploymentUrl, context.settingsStore.headerCommand) + } + } + + private fun Response.saveToDisk( + localPath: Path, + showTextProgress: (String) -> Unit, + buildVersion: String? = null + ): Path? { + val responseBody = this.body() ?: return null + Files.deleteIfExists(localPath) + Files.createDirectories(localPath.parent) + + val outputStream = Files.newOutputStream( + localPath, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING + ) + val contentEncoding = this.headers()["Content-Encoding"] + val sourceStream = if (contentEncoding?.contains("gzip", ignoreCase = true) == true) { + GZIPInputStream(responseBody.byteStream()) + } else { + responseBody.byteStream() + } + + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytesRead: Int + var totalRead = 0L + // local path is a temporary filename, reporting the progress with the real name + val binaryName = localPath.name.removeSuffix(".tmp") + sourceStream.use { source -> + outputStream.use { sink -> + while (source.read(buffer).also { bytesRead = it } != -1) { + sink.write(buffer, 0, bytesRead) + totalRead += bytesRead + val prettyBuildVersion = buildVersion ?: "" + showTextProgress( + "$binaryName $prettyBuildVersion - ${totalRead.toHumanReadableSize()} downloaded" + ) + } + } + } + return cliFinalDst + } + + + private fun Path.makeExecutable() { + if (getOS() != OS.WINDOWS) { + context.logger.info("Making $this executable...") + this.toFile().setExecutable(true) + } + } + + private fun Long.toHumanReadableSize(): String { + if (this < 1024) return "$this B" + + val kb = this / 1024.0 + if (kb < 1024) return String.format("%.1f KB", kb) + + val mb = kb / 1024.0 + if (mb < 1024) return String.format("%.1f MB", mb) + + val gb = mb / 1024.0 + return String.format("%.1f GB", gb) + } + + suspend fun downloadSignature(showTextProgress: (String) -> Unit): DownloadResult { + return downloadSignature(remoteBinaryURL, showTextProgress, getRequestHeaders()) + } + + private suspend fun downloadSignature( + url: URL, + showTextProgress: (String) -> Unit, + headers: Map = emptyMap() + ): DownloadResult { + val signatureURL = url.toURI().resolve(context.settingsStore.defaultSignatureNameByOsAndArch).toURL() + val localSignaturePath = cliFinalDst.parent.resolve(context.settingsStore.defaultSignatureNameByOsAndArch) + context.logger.info("Downloading signature from $signatureURL") + + val response = downloadApi.downloadSignature( + url = signatureURL.toString(), + headers = headers + ) + + return when (response.code()) { + HTTP_OK -> { + response.saveToDisk(localSignaturePath, showTextProgress) + DownloadResult.Downloaded(signatureURL, localSignaturePath) + } + + HTTP_NOT_FOUND -> { + context.logger.warn("Signature file not found at $signatureURL") + DownloadResult.NotFound + } + + else -> { + DownloadResult.Failed( + ResponseException( + "Failed to download signature from $signatureURL", + response.code() + ) + ) + } + } + + } + + suspend fun downloadReleasesSignature( + buildVersion: String, + showTextProgress: (String) -> Unit + ): DownloadResult { + val semVer = SemVer.parse(buildVersion) + return downloadSignature( + URI.create("https://releases.coder.com/coder-cli/${semVer.major}.${semVer.minor}.${semVer.patch}/").toURL(), + showTextProgress + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/cli/downloader/DownloadResult.kt b/src/main/kotlin/com/coder/toolbox/cli/downloader/DownloadResult.kt new file mode 100644 index 0000000..29d4fda --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/cli/downloader/DownloadResult.kt @@ -0,0 +1,23 @@ +package com.coder.toolbox.cli.downloader + +import java.net.URL +import java.nio.file.Path + + +/** + * Result of a download operation + */ +sealed class DownloadResult { + object Skipped : DownloadResult() + object NotFound : DownloadResult() + data class Downloaded(val source: URL, val dst: Path) : DownloadResult() + data class Failed(val error: Exception) : DownloadResult() + + fun isSkipped(): Boolean = this is Skipped + + fun isNotFound(): Boolean = this is NotFound + + fun isFailed(): Boolean = this is Failed + + fun isNotDownloaded(): Boolean = this !is Downloaded +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/cli/ex/Exceptions.kt b/src/main/kotlin/com/coder/toolbox/cli/ex/Exceptions.kt index d3ca3a4..9fdff54 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/ex/Exceptions.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/ex/Exceptions.kt @@ -5,3 +5,5 @@ class ResponseException(message: String, val code: Int) : Exception(message) class SSHConfigFormatException(message: String) : Exception(message) class MissingVersionException(message: String) : Exception(message) + +class UnsignedBinaryExecutionDeniedException(message: String?) : Exception(message) \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/cli/gpg/GPGVerifier.kt b/src/main/kotlin/com/coder/toolbox/cli/gpg/GPGVerifier.kt new file mode 100644 index 0000000..490b48e --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/cli/gpg/GPGVerifier.kt @@ -0,0 +1,137 @@ +package com.coder.toolbox.cli.gpg + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.cli.gpg.VerificationResult.Failed +import com.coder.toolbox.cli.gpg.VerificationResult.Invalid +import com.coder.toolbox.cli.gpg.VerificationResult.SignatureNotFound +import com.coder.toolbox.cli.gpg.VerificationResult.Valid +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.bouncycastle.bcpg.ArmoredInputStream +import org.bouncycastle.openpgp.PGPException +import org.bouncycastle.openpgp.PGPPublicKey +import org.bouncycastle.openpgp.PGPPublicKeyRing +import org.bouncycastle.openpgp.PGPPublicKeyRingCollection +import org.bouncycastle.openpgp.PGPSignatureList +import org.bouncycastle.openpgp.PGPUtil +import org.bouncycastle.openpgp.jcajce.JcaPGPObjectFactory +import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator +import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentVerifierBuilderProvider +import java.io.ByteArrayInputStream +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.inputStream + +class GPGVerifier( + private val context: CoderToolboxContext, +) { + + suspend fun verifySignature( + cli: Path, + signature: Path, + ): VerificationResult { + return try { + if (!Files.exists(signature)) { + context.logger.warn("Signature file not found, skipping verification") + return SignatureNotFound + } + + val (signatureBytes, publicKeyRing) = withContext(Dispatchers.IO) { + val signatureBytes = Files.readAllBytes(signature) + val publicKeyRing = getCoderPublicKeyRings() + + Pair(signatureBytes, publicKeyRing) + } + return verifyDetachedSignature( + cliPath = cli, + signatureBytes = signatureBytes, + publicKeyRings = publicKeyRing + ) + } catch (e: Exception) { + context.logger.error(e, "GPG signature verification failed") + Failed(e) + } + } + + private fun getCoderPublicKeyRings(): List { + try { + val coderPublicKey = javaClass.getResourceAsStream("/META-INF/trusted-keys/pgp-public.key") + ?.readAllBytes() ?: throw IllegalStateException("Trusted public key not found") + return loadPublicKeyRings(coderPublicKey) + } catch (e: Exception) { + throw PGPException("Failed to load Coder public GPG key", e) + } + } + + /** + * Load public key rings from bytes + */ + fun loadPublicKeyRings(publicKeyBytes: ByteArray): List { + return try { + val keyInputStream = ArmoredInputStream(ByteArrayInputStream(publicKeyBytes)) + val keyRingCollection = PGPPublicKeyRingCollection( + PGPUtil.getDecoderStream(keyInputStream), + JcaKeyFingerprintCalculator() + ) + keyRingCollection.keyRings.asSequence().toList() + } catch (e: Exception) { + throw PGPException("Failed to load public key ring", e) + } + } + + /** + * Verify a detached GPG signature + */ + fun verifyDetachedSignature( + cliPath: Path, + signatureBytes: ByteArray, + publicKeyRings: List + ): VerificationResult { + try { + val signatureInputStream = ArmoredInputStream(ByteArrayInputStream(signatureBytes)) + val pgpObjectFactory = JcaPGPObjectFactory(signatureInputStream) + val signatureList = pgpObjectFactory.nextObject() as? PGPSignatureList + ?: throw PGPException("Invalid signature format") + + if (signatureList.isEmpty) { + return Invalid("No signatures found in signature file") + } + + val signature = signatureList[0] + val publicKey = findPublicKey(publicKeyRings, signature.keyID) + ?: throw PGPException("Public key not found for signature") + + signature.init(JcaPGPContentVerifierBuilderProvider(), publicKey) + cliPath.inputStream().use { fileStream -> + val buffer = ByteArray(8192) + var bytesRead: Int + while (fileStream.read(buffer).also { bytesRead = it } != -1) { + signature.update(buffer, 0, bytesRead) + } + } + + val isValid = signature.verify() + context.logger.info("GPG signature verification result: $isValid") + if (isValid) { + return Valid + } + return Invalid() + } catch (e: Exception) { + context.logger.error(e, "GPG signature verification failed") + return Failed(e) + } + } + + /** + * Find a public key across all key rings in the collection + */ + private fun findPublicKey( + keyRings: List, + keyId: Long + ): PGPPublicKey? { + keyRings.forEach { keyRing -> + keyRing.getPublicKey(keyId)?.let { return it } + } + return null + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/cli/gpg/VerificationResult.kt b/src/main/kotlin/com/coder/toolbox/cli/gpg/VerificationResult.kt new file mode 100644 index 0000000..eafafcd --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/cli/gpg/VerificationResult.kt @@ -0,0 +1,15 @@ +package com.coder.toolbox.cli.gpg + +/** + * Result of signature verification + */ +sealed class VerificationResult { + object Valid : VerificationResult() + data class Invalid(val reason: String? = null) : VerificationResult() + data class Failed(val error: Exception) : VerificationResult() + object SignatureNotFound : VerificationResult() + + fun isValid(): Boolean = this == Valid + fun isInvalid(): Boolean = this is Invalid + fun signatureIsNotFound(): Boolean = this == SignatureNotFound +} diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt index 4d17c09..a6d6143 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -2,6 +2,7 @@ package com.coder.toolbox.settings import java.net.URL import java.nio.file.Path +import java.util.Locale.getDefault /** * Read-only interface for accessing Coder settings @@ -27,6 +28,11 @@ interface ReadOnlyCoderSettings { */ val binaryDirectory: String? + /** + * Controls whether we fall back release.coder.com + */ + val fallbackOnCoderForSignatures: SignatureFallbackStrategy + /** * Default CLI binary name based on OS and architecture */ @@ -37,6 +43,11 @@ interface ReadOnlyCoderSettings { */ val binaryName: String + /** + * Default CLI signature name based on OS and architecture + */ + val defaultSignatureNameByOsAndArch: String + /** * Where to save plugin data like the Coder binary (if not configured with * binaryDirectory) and the deployment URL and session token. @@ -167,4 +178,32 @@ interface ReadOnlyTLSSettings { * Coder service does not match the hostname in the TLS certificate. */ val altHostname: String? +} + +enum class SignatureFallbackStrategy { + /** + * User has not yet decided whether he wants to fallback on releases.coder.com for signatures + */ + NOT_CONFIGURED, + + /** + * Can fall back on releases.coder.com for signatures. + */ + ALLOW, + + /** + * Can't fall back on releases.coder.com for signatures. + */ + FORBIDDEN; + + fun isAllowed(): Boolean = this == ALLOW + + companion object { + fun fromValue(value: String?): SignatureFallbackStrategy = when (value?.lowercase(getDefault())) { + "not_configured" -> NOT_CONFIGURED + "allow" -> ALLOW + "forbidden" -> FORBIDDEN + else -> NOT_CONFIGURED + } + } } \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index d08e8d6..a57ff45 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -3,6 +3,7 @@ package com.coder.toolbox.store import com.coder.toolbox.settings.Environment import com.coder.toolbox.settings.ReadOnlyCoderSettings import com.coder.toolbox.settings.ReadOnlyTLSSettings +import com.coder.toolbox.settings.SignatureFallbackStrategy import com.coder.toolbox.util.Arch import com.coder.toolbox.util.OS import com.coder.toolbox.util.expand @@ -37,8 +38,11 @@ class CoderSettingsStore( override val defaultURL: String get() = store[DEFAULT_URL] ?: "https://dev.coder.com" override val binarySource: String? get() = store[BINARY_SOURCE] override val binaryDirectory: String? get() = store[BINARY_DIRECTORY] + override val fallbackOnCoderForSignatures: SignatureFallbackStrategy + get() = SignatureFallbackStrategy.fromValue(store[FALLBACK_ON_CODER_FOR_SIGNATURES]) override val defaultCliBinaryNameByOsAndArch: String get() = getCoderCLIForOS(getOS(), getArch()) override val binaryName: String get() = store[BINARY_NAME] ?: getCoderCLIForOS(getOS(), getArch()) + override val defaultSignatureNameByOsAndArch: String get() = getCoderSignatureForOS(getOS(), getArch()) override val dataDirectory: String? get() = store[DATA_DIRECTORY] override val globalDataDirectory: String get() = getDefaultGlobalDataDir().normalize().toString() override val globalConfigDir: String get() = getDefaultGlobalConfigDir().normalize().toString() @@ -158,6 +162,13 @@ class CoderSettingsStore( store[ENABLE_DOWNLOADS] = shouldEnableDownloads.toString() } + fun updateSignatureFallbackStrategy(fallback: Boolean) { + store[FALLBACK_ON_CODER_FOR_SIGNATURES] = when (fallback) { + true -> SignatureFallbackStrategy.ALLOW.toString() + else -> SignatureFallbackStrategy.FORBIDDEN.toString() + } + } + fun updateBinaryDirectoryFallback(shouldEnableBinDirFallback: Boolean) { store[ENABLE_BINARY_DIR_FALLBACK] = shouldEnableBinDirFallback.toString() } @@ -237,41 +248,59 @@ class CoderSettingsStore( } /** - * Return the name of the binary (with extension) for the provided OS and - * architecture. + * Return the name of the binary (with extension) for the provided OS and architecture. */ private fun getCoderCLIForOS( os: OS?, arch: Arch?, ): String { - logger.info("Resolving binary for $os $arch") + logger.debug("Resolving binary for $os $arch") + return buildCoderFileName(os, arch) + } + + /** + * Return the name of the signature file (.asc) for the provided OS and architecture. + */ + private fun getCoderSignatureForOS( + os: OS?, + arch: Arch?, + ): String { + logger.debug("Resolving signature for $os $arch") + return buildCoderFileName(os, arch, true) + } + + /** + * Build the coder file name based on OS, architecture, and whether it's a signature file. + */ + private fun buildCoderFileName( + os: OS?, + arch: Arch?, + isSignature: Boolean = false + ): String { if (os == null) { logger.error("Could not resolve client OS and architecture, defaulting to WINDOWS AMD64") - return "coder-windows-amd64.exe" + return if (isSignature) "coder-windows-amd64.asc" else "coder-windows-amd64.exe" } - return when (os) { - OS.WINDOWS -> - when (arch) { - Arch.AMD64 -> "coder-windows-amd64.exe" - Arch.ARM64 -> "coder-windows-arm64.exe" - else -> "coder-windows-amd64.exe" - } - OS.LINUX -> - when (arch) { - Arch.AMD64 -> "coder-linux-amd64" - Arch.ARM64 -> "coder-linux-arm64" - Arch.ARMV7 -> "coder-linux-armv7" - else -> "coder-linux-amd64" - } + val osName = when (os) { + OS.WINDOWS -> "windows" + OS.LINUX -> "linux" + OS.MAC -> "darwin" + } - OS.MAC -> - when (arch) { - Arch.AMD64 -> "coder-darwin-amd64" - Arch.ARM64 -> "coder-darwin-arm64" - else -> "coder-darwin-amd64" - } + val archName = when (arch) { + Arch.AMD64 -> "amd64" + Arch.ARM64 -> "arm64" + Arch.ARMV7 -> "armv7" + else -> "amd64" // default fallback } + + val extension = if (isSignature) ".asc" else when (os) { + OS.WINDOWS -> ".exe" + OS.LINUX, OS.MAC -> "" + } + + return "coder-$osName-$archName$extension" } /** diff --git a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt index e34436f..91e3b74 100644 --- a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt +++ b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt @@ -10,6 +10,8 @@ internal const val BINARY_SOURCE = "binarySource" internal const val BINARY_DIRECTORY = "binaryDirectory" +internal const val FALLBACK_ON_CODER_FOR_SIGNATURES = "signatureFallbackStrategy" + internal const val BINARY_NAME = "binaryName" internal const val DATA_DIRECTORY = "dataDirectory" diff --git a/src/main/kotlin/com/coder/toolbox/util/OS.kt b/src/main/kotlin/com/coder/toolbox/util/OS.kt index 32abd5e..ba39204 100644 --- a/src/main/kotlin/com/coder/toolbox/util/OS.kt +++ b/src/main/kotlin/com/coder/toolbox/util/OS.kt @@ -1,30 +1,19 @@ package com.coder.toolbox.util -import java.util.* +import java.util.Locale fun getOS(): OS? = OS.from(System.getProperty("os.name")) -fun getArch(): Arch? = Arch.from(System.getProperty("os.arch").lowercase(Locale.getDefault())) +fun getArch(): Arch? = Arch.from(System.getProperty("os.arch")?.lowercase(Locale.getDefault())) enum class OS { WINDOWS, LINUX, MAC; - /** - * The name of the current desktop environment. - * For Linux systems it can be GNOME, KDE, XFCE, LXDE, and so on, - * while for macOS it will be Aqua and Windows Shell for Windows. - */ - fun getDesktopEnvironment(): String? = - when (this) { - WINDOWS -> "Windows Shell" - MAC -> "Aqua" - LINUX -> System.getenv("XDG_CURRENT_DESKTOP") - } - companion object { - fun from(os: String): OS? = when { + fun from(os: String?): OS? = when { + os.isNullOrBlank() -> null os.contains("win", true) -> { WINDOWS } @@ -49,7 +38,8 @@ enum class Arch { ; companion object { - fun from(arch: String): Arch? = when { + fun from(arch: String?): Arch? = when { + arch.isNullOrBlank() -> null arch.contains("amd64", true) || arch.contains("x86_64", true) -> AMD64 arch.contains("arm64", true) || arch.contains("aarch64", true) -> ARM64 arch.contains("armv7", true) -> ARMV7 diff --git a/src/main/kotlin/com/coder/toolbox/util/SemVer.kt b/src/main/kotlin/com/coder/toolbox/util/SemVer.kt index 238ce81..a40a9a9 100644 --- a/src/main/kotlin/com/coder/toolbox/util/SemVer.kt +++ b/src/main/kotlin/com/coder/toolbox/util/SemVer.kt @@ -1,6 +1,6 @@ package com.coder.toolbox.util -class SemVer(private val major: Long = 0, private val minor: Long = 0, private val patch: Long = 0) : Comparable { +class SemVer(val major: Long = 0, val minor: Long = 0, val patch: Long = 0) : Comparable { init { require(major >= 0) { "Coder major version must be a positive number" } require(minor >= 0) { "Coder minor version must be a positive number" } diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index 61827be..448a20f 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ClosedSendChannelException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** @@ -32,6 +33,11 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel< TextField(context.i18n.ptrl("Data directory"), settings.dataDirectory ?: "", TextType.General) private val enableDownloadsField = CheckboxField(settings.enableDownloads, context.i18n.ptrl("Enable downloads")) + private val signatureFallbackStrategyField = + CheckboxField( + settings.fallbackOnCoderForSignatures.isAllowed(), + context.i18n.ptrl("Verify binary signature using releases.coder.com when CLI signatures are not available from the deployment") + ) private val enableBinaryDirectoryFallbackField = CheckboxField( settings.enableBinaryDirectoryFallback, @@ -66,6 +72,7 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel< enableDownloadsField, binaryDirectoryField, enableBinaryDirectoryFallbackField, + signatureFallbackStrategyField, dataDirectoryField, headerCommandField, tlsCertPathField, @@ -83,16 +90,17 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel< override val actionButtons: StateFlow> = MutableStateFlow( listOf( Action(context.i18n.ptrl("Save"), closesPage = true) { - context.settingsStore.updateBinarySource(binarySourceField.textState.value) - context.settingsStore.updateBinaryDirectory(binaryDirectoryField.textState.value) - context.settingsStore.updateDataDirectory(dataDirectoryField.textState.value) + context.settingsStore.updateBinarySource(binarySourceField.contentState.value) + context.settingsStore.updateBinaryDirectory(binaryDirectoryField.contentState.value) + context.settingsStore.updateDataDirectory(dataDirectoryField.contentState.value) context.settingsStore.updateEnableDownloads(enableDownloadsField.checkedState.value) + context.settingsStore.updateSignatureFallbackStrategy(signatureFallbackStrategyField.checkedState.value) context.settingsStore.updateBinaryDirectoryFallback(enableBinaryDirectoryFallbackField.checkedState.value) - context.settingsStore.updateHeaderCommand(headerCommandField.textState.value) - context.settingsStore.updateCertPath(tlsCertPathField.textState.value) - context.settingsStore.updateKeyPath(tlsKeyPathField.textState.value) - context.settingsStore.updateCAPath(tlsCAPathField.textState.value) - context.settingsStore.updateAltHostname(tlsAlternateHostnameField.textState.value) + context.settingsStore.updateHeaderCommand(headerCommandField.contentState.value) + context.settingsStore.updateCertPath(tlsCertPathField.contentState.value) + context.settingsStore.updateKeyPath(tlsKeyPathField.contentState.value) + context.settingsStore.updateCAPath(tlsCAPathField.contentState.value) + context.settingsStore.updateAltHostname(tlsAlternateHostnameField.contentState.value) context.settingsStore.updateDisableAutostart(disableAutostartField.checkedState.value) val oldIsSshWildcardConfigEnabled = settings.isSshWildcardConfigEnabled context.settingsStore.updateEnableSshWildcardConfig(enableSshWildCardConfig.checkedState.value) @@ -106,10 +114,73 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel< } } } - context.settingsStore.updateSshLogDir(sshLogDirField.textState.value) - context.settingsStore.updateNetworkInfoDir(networkInfoDirField.textState.value) - context.settingsStore.updateSshConfigOptions(sshExtraArgs.textState.value) + context.settingsStore.updateSshLogDir(sshLogDirField.contentState.value) + context.settingsStore.updateNetworkInfoDir(networkInfoDirField.contentState.value) + context.settingsStore.updateSshConfigOptions(sshExtraArgs.contentState.value) } ) ) + + override fun beforeShow() { + // update the value of all fields + binarySourceField.contentState.update { + settings.binarySource ?: "" + } + binaryDirectoryField.contentState.update { + settings.binaryDirectory ?: "" + } + dataDirectoryField.contentState.update { + settings.dataDirectory ?: "" + } + enableDownloadsField.checkedState.update { + settings.enableDownloads + } + signatureFallbackStrategyField.checkedState.update { + settings.fallbackOnCoderForSignatures.isAllowed() + } + + enableBinaryDirectoryFallbackField.checkedState.update { + settings.enableBinaryDirectoryFallback + } + + headerCommandField.contentState.update { + settings.headerCommand ?: "" + } + + tlsCertPathField.contentState.update { + settings.tls.certPath ?: "" + } + + tlsKeyPathField.contentState.update { + settings.tls.keyPath ?: "" + } + + tlsCAPathField.contentState.update { + settings.tls.caPath ?: "" + } + + tlsAlternateHostnameField.contentState.update { + settings.tls.altHostname ?: "" + } + + disableAutostartField.checkedState.update { + settings.disableAutostart + } + + enableSshWildCardConfig.checkedState.update { + settings.isSshWildcardConfigEnabled + } + + sshExtraArgs.contentState.update { + settings.sshConfigOptions ?: "" + } + + sshLogDirField.contentState.update { + settings.sshLogDirectory ?: "" + } + + networkInfoDirField.contentState.update { + settings.networkInfoDir + } + } } diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index aa87b57..2a76864 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -1,9 +1,13 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.settings.SignatureFallbackStrategy import com.coder.toolbox.util.toURL import com.coder.toolbox.views.state.CoderCliSetupContext import com.coder.toolbox.views.state.CoderCliSetupWizardState +import com.jetbrains.toolbox.api.ui.components.CheckboxField +import com.jetbrains.toolbox.api.ui.components.LabelField +import com.jetbrains.toolbox.api.ui.components.LabelStyleType import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.TextField import com.jetbrains.toolbox.api.ui.components.TextType @@ -24,13 +28,32 @@ class DeploymentUrlStep( ) : WizardStep { private val urlField = TextField(context.i18n.ptrl("Deployment URL"), "", TextType.General) - private val errorField = ValidationErrorField(context.i18n.pnotr("")) + private val emptyLine = LabelField(context.i18n.pnotr(""), LabelStyleType.Normal) - override val panel: RowGroup = RowGroup( - RowGroup.RowField(urlField), - RowGroup.RowField(errorField) + private val signatureFallbackStrategyField = CheckboxField( + context.settingsStore.fallbackOnCoderForSignatures.isAllowed(), + context.i18n.ptrl("Verify binary signature using releases.coder.com when CLI signatures are not available from the deployment") ) + private val errorField = ValidationErrorField(context.i18n.pnotr("")) + + override val panel: RowGroup + get() { + if (context.settingsStore.fallbackOnCoderForSignatures == SignatureFallbackStrategy.NOT_CONFIGURED) { + return RowGroup( + RowGroup.RowField(urlField), + RowGroup.RowField(emptyLine), + RowGroup.RowField(signatureFallbackStrategyField), + RowGroup.RowField(errorField) + ) + + } + return RowGroup( + RowGroup.RowField(urlField), + RowGroup.RowField(errorField) + ) + } + override fun onVisible() { errorField.textState.update { context.i18n.pnotr("") @@ -38,9 +61,14 @@ class DeploymentUrlStep( urlField.textState.update { context.secrets.lastDeploymentURL } + + signatureFallbackStrategyField.checkedState.update { + context.settingsStore.fallbackOnCoderForSignatures.isAllowed() + } } override fun onNext(): Boolean { + context.settingsStore.updateSignatureFallbackStrategy(signatureFallbackStrategyField.checkedState.value) var url = urlField.textState.value if (url.isBlank()) { errorField.textState.update { context.i18n.ptrl("URL is required") } diff --git a/src/main/resources/META-INF/trusted-keys/pgp-public.key b/src/main/resources/META-INF/trusted-keys/pgp-public.key new file mode 100644 index 0000000..fb5c4c5 --- /dev/null +++ b/src/main/resources/META-INF/trusted-keys/pgp-public.key @@ -0,0 +1,99 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGPGrCwBEAC7SSKQIFoQdt3jYv/1okRdoleepLDG4NfcG52S45Ex3/fUA6Z/ +ewHQrx//SN+h1FLpb0zQMyamWrSh2O3dnkWridwlskb5/y8C/6OUdk4L/ZgHeyPO +Ncbyl1hqO8oViakiWt4IxwSYo83eJHxOUiCGZlqV6EpEsaur43BRHnK8EciNeIxF +Bjle3yXH1K3EgGGHpgnSoKe1nSVxtWIwX45d06v+VqnBoI6AyK0Zp+Nn8bL0EnXC +xGYU3XOkC6EmITlhMju1AhxnbkQiy8IUxXiaj3NoPc1khapOcyBybhESjRZHlgu4 +ToLZGaypjtfQJgMeFlpua7sJK0ziFMW4wOTX+6Ix/S6XA80dVbl3VEhSMpFCcgI+ +OmEd2JuBs6maG+92fCRIzGAClzV8/ifM//JU9D7Qlq6QJpcbNClODlPNDNe7RUEO +b7Bu7dJJS3VhHO9eEen6m6vRE4DNriHT4Zvq1UkHfpJUW7njzkIYRni3eNrsr4Da +U/eeGbVipok4lzZEOQtuaZlX9ytOdGrWEGMGSosTOG6u6KAKJoz7cQGZiz4pZpjR +3N2SIYv59lgpHrIV7UodGx9nzu0EKBhkoulaP1UzH8F16psSaJXRjeyl/YP8Rd2z +SYgZVLjTzkTUXkJT8fQO8zLBEuwA0IiXX5Dl7grfEeShANVrM9LVu8KkUwARAQAB +tC5Db2RlciBSZWxlYXNlIFNpZ25pbmcgS2V5IDxzZWN1cml0eUBjb2Rlci5jb20+ +iQJUBBMBCgA+FiEEKMY4lDj2Q3PIwvSKi87Yfbu4ZEsFAmPGrCwCGwMFCQWjmoAF +CwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQi87Yfbu4ZEvrQQ//a3ySdMVhnLP+ +KneonV2zuNilTMC2J/MNG7Q0hU+8I9bxCc6DDqcnBBCQkIUwJq3wmelt3nTC8RxI +fv+ggnbdF9pz7Fc91nIJsGlWpH+bu1tSIvKF/rzZA8v6xUblFFfaC7Gsc5P4xk/+ +h0XBDAy6K+7+AafgLFpRD08Y0Kf2aMcqdM6c2Zo4IPo6FNrOa66FNkypZdQ4IByW +4kMezZSTp4Phqd9yqGC4m44U8YgzmW9LHgrvS0JyIaRPcQFM31AJ50K3iYRxL1ll +ETqJvbDR8UORNQs3Qs3CEZL588BoDMX2TYObTCG6g9Om5vJT0kgUkjDxQHwbAj6E +z9j8BoWkDT2JNzwdfTbPueuRjO+A+TXA9XZtrzbEYEzh0sD9Bdr7ozSF3JAs4GZS +nqcVlyp7q44ZdePR9L8w0ksth56tBWHfE9hi5jbRDRY2OnkV7y7JtWnBDQx9bCIo +7L7aBT8eirI1ZOnUxHJrnqY5matfWjSDBFW+YmWUkjnzBsa9F4m8jq9MSD3Q/8hN +ksJFrmLQs0/8hnM39tS7kLnAaWeGvbmjnxdeMqZsICxNpbyQrq2AhF4GhWfc+NsZ +yznVagJZ9bIlGsycSXJbsA5GbXDnm172TlodMUbLF9FU8i0vV4Y7q6jKO/VsblKU +F0bhXIRqVLrd9g88IyVyyZozmwbJKIy5Ag0EY8asLAEQAMgI9bMurq6Zic4s5W0u +W6LBDHyZhe+w2a3oT/i2YgTsh8XmIjrNasYYWO67b50JKepA3fk3ZA44w8WJqq+z +HLpslEb2fY5I1HvENUMKjYAUIsswSC21DSBau4yYiRGF0MNqv/MWy5Rjc993vIU4 +4TM3mvVhPrYfIkr0jwSbxq8+cm3sBjr0gcBQO57C3w8QkcZ6jefuI7y+1ZeM7X3L +OngmBFJDEutd9LPO/6Is4j/iQfTb8WDR6OmMX3Y04RHrP4sm7jf+3ZZKjcFCZQjr +QA4XHcQyJjnMN34Fn1U7KWopivU+mqViAnVpA643dq9SiBqsl83/R03DrpwKpP7r +6qasUHSUULuS7A4n8+CDwK5KghvrS0hOwMiYoIwZIVPITSUFHPYxrCJK7gU2OHfk +IZHX5m9L5iNwLz958GwzwHuONs5bjMxILbKknRhEBOcbhcpk0jswiPNUrEdipRZY +GR9G9fzD6q4P5heV3kQRqyUUTxdDj8w7jbrwl8sm5zk+TMnPRsu2kg0uwIN1aILm +oVkDN5CiZtg00n2Fu3do5F3YkF0Cz7indx5yySr5iUuoCY0EnpqSwourJ/ZdZA9Y +ZCHjhgjwyPCbxpTGfLj1g25jzQBYn5Wdgr2aHCQcqnU8DKPCnYL9COHJJylgj0vN +NSxyDjNXYYwSrYMqs/91f5xVABEBAAGJAjwEGAEKACYWIQQoxjiUOPZDc8jC9IqL +zth9u7hkSwUCY8asLAIbDAUJBaOagAAKCRCLzth9u7hkSyMvD/0Qal5kwiKDjgBr +i/dtMka+WNBTMb6vKoM759o33YAl22On5WgLr9Uz0cjkJPtzMHxhUo8KQmiPRtsK +dOmG9NI9NttfSeQVbeL8V/DC672fWPKM4TB8X7Kkj56/KI7ueGRokDhXG2pJlhQr +HwzZsAKoCMMnjcquAhHJClK9heIpVLBGFVlmVzJETzxo6fbEU/c7L79+hOrR4BWx +Tg6Dk7mbAGe7BuQLNtw6gcWUVWtHS4iYQtE/4khU1QppC1Z/ZbZ+AJT2TAFXzIaw +0l9tcOh7+TXqsvCLsXN0wrUh1nOdxA81sNWEMY07bG1qgvHyVc7ZYM89/ApK2HP+ +bBDIpAsRCGu2MHtrnJIlNE1J14G1mnauR5qIqI3C0R5MPLXOcDtp+gnjFe+PLU+6 +rQxJObyOkyEpOvtVtJKfFnpI5bqyl8WEPN0rDaS2A27cGXi5nynSAqoM1xT15W21 +uyY2GXY26DIwVfc59wGeclwcM29nS7prRU3KtskjonJ0iQoQebYOHLxy896cK+pK +nnhZx5AQjYiZPsPktSNZjSuOvTZ3g+IDwbCSvmBHcQpitzUOPShTUTs0QjSttzk2 +I6WxP9ivoR9yJGsxwNgCgrYdyt5+hyXXW/aUVihnQwizQRbymjJ2/z+I8NRFIeYb +xbtNFaH3WjLnhm9CB/H+Lc8fUj6HaZkCDQRjxt6QARAAsjZuCMjZBaAC1LFMeRcv +9+Ck7T5UNXTL9xQr1jUFZR95I6loWiWvFJ3Uet7gIbgNYY5Dc1gDr1Oqx9KQBjsN +TUahXov5lmjF5mYeyWTDZ5TS8H3o50zQzfZRC1eEbqjiBMLAHv74KD13P62nvzv6 +Dejwc7Nwc6aOH3cdZm74kz4EmdobJYRVdd5X9EYH/hdM928SsipKhm44oj3RDGi/ +x+ptjW9gr0bnrgCbkyCMNKhnmHSM60I8f4/viRItb+hWRpZYfLxMGTBVunicSXcX +Zh6Fq/DD/yTjzN9N83/NdDvwCyKo5U/kPgD2Ixh5PyJ38cpz6774Awnb/tstCI1g +glnlNbu8Qz84STr3NRZMOgT5h5b5qASOeruG4aVo9euaYJHlnlgcoUmpbEMnwr0L +tREUXSHGXWor7EYPjUQLskIaPl9NCZ3MEw5LhsZTgEdFBnb54dxMSEl7/MYDYhD/ +uTIWOJmtsWHmuMmvfxnw5GDEhJnAp4dxUm9BZlJhfnVR07DtTKyEk37+kl6+i0ZQ +yU4HJ2GWItpLfK54E/CH+S91y7wpepb2TMkaFR2fCK0vXTGAXWK+Y+aTD8ZcLB5y +0IYPsvA0by5AFpmXNfWZiZtYvgJ5FAQZNuB5RILg3HsuDq2U4wzp5BoohWtsOzsn +antIUf/bN0D2g+pCySkc5ssAEQEAAbQuQ29kZXIgUmVsZWFzZSBTaWduaW5nIEtl +eSA8c2VjdXJpdHlAY29kZXIuY29tPokCVAQTAQoAPhYhBCHJaxy5UHGIdPZNvWpa +ZxteQKO5BQJjxt6QAhsDBQkFo5qABQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJ +EGpaZxteQKO5oysP/1rSdvbKMzozvnVZoglnPjnSGStY9Pr2ziGL7eIMk2yt+Orr +j/AwxYIDgsZPQoJEr87eX2dCYtUMM1x+CpZsWu8dDVFLxyZp8nPmhUzcUCFfutw1 +UmAVKQkOra9segZtw4HVcSctpdgLw7NHq7vIQm4knIvjWmdC15r1B6/VJJI8CeaR +Zy+ToPr9fKnYs1RNdz+DRDN2521skX1DaInhB/ALeid90rJTRujaP9XeyNb9k32K +qd3h4C0KUGIf0fNKj4mmDlNosX3V/pJZATpFiF8aVPlybHQ2W5xpn1U8FJxE4hgR +rvsZmO685Qwm6p/uRI5Eymfm8JC5OQNt9Kvs/BMhotsW0u+je8UXwnznptMILpVP ++qxNuHUe1MYLdjK21LFF+Pk5O4W1TT6mKcbisOmZuQMG5DxpzUwm1Rs5AX1omuJt +iOrmQEvmrKKWC9qbcmWW1t2scnIJsNtrsvME0UjJFz+RL6UUX3xXlLK6YOUghCr8 +gZ7ZPgFqygS6tMu8TAGURzSCfijDh+eZGwqrlvngBIaO5WiNdSXC/J9aE1KThXmX +90A3Gwry+yI2kRS7o8vmghXewPTZbnG0CVHiQIH2yqFNXnhKvhaJt0g04TcnxBte +kiFqRT4K1Bb7pUIlUANmrKo9/zRCxIOopEgRH5cVQ8ZglkT0t5d3ePmAo6h0uQIN +BGPG3pABEADghhNByVoC+qCMo+SErjxz9QYA+tKoAngbgPyxxyB4RD52Z58MwVaP ++Yk0qxJYUBat3dJwiCTlUGG+yTyMOwLl7qSDr53AD5ml0hwJqnLBJ6OUyGE4ax4D +RUVBprKlDltwr98cZDgzvwEhIO2T3tNZ4vySveITj9pLonOrLkAfGXqFOqom+S37 +6eZvjKTnEUbT+S0TTynwds70W31sxVUrL62qsUnmoKEnsKXk/7X8CLXWvtNqu9kf +eiXs5Jz4N6RZUqvS0WOaaWG9v1PHukTtb8RyeookhsBqf9fWOlw5foel+NQwGQjz +0D0dDTKxn2Taweq+gWNCRH7/FJNdWa9upZ2fUAjg9hN9Ow8Y5nE3J0YKCBAQTgNa +XNtsiGQjdEKYZslxZKFM34By3LD6IrkcAEPKu9plZthmqhQumqwYRAgB9O56jg3N +GDDRyAMS7y63nNphTSatpOZtPVVMtcBw5jPjMIPFfU2dlfsvmnCvru2dvfAij+Ng +EkwOLNS8rFQHMJSQysmHuAPSYT97Yl022mPrAtb9+hwtCXt3VI6dvIARl2qPyF0D +DMw2fW5E7ivhUr2WEFiBmXunrJvMIYldBzDkkBjamelPjoevR0wfoIn0x1CbSsQi +zbEs3PXHs7nGxb9TZnHY4+J94mYHdSXrImAuH/x97OnlfUpOKPv5lwARAQABiQI8 +BBgBCgAmFiEEIclrHLlQcYh09k29alpnG15Ao7kFAmPG3pACGwwFCQWjmoAACgkQ +alpnG15Ao7m2/g//Y/YRM+Qhf71G0MJpAfym6ZqmwsT78qQ8T9w95ZeIRD7UUE8d +tm39kqJTGP6DuHCNYEMs2M88o0SoQsS/7j/8is7H/13F5o40DWjuQphia2BWkB1B +G4QRRIXMlrPX8PS92GDCtGfvxn90Li2FhQGZWlNFwvKUB7+/yLMsZzOwo7BS6PwC +hvI3eC7DBC8sXjJUxsrgFAkxQxSx/njP8f4HdUwhNnB1YA2/5IY5bk8QrXxzrAK1 +sbIAjpJdtPYOrZByyyj4ZpRcSm3ngV2n8yd1muJ5u+oRIQoGCdEIaweCj598jNFa +k378ZA11hCyNFHjpPIKnF3tfsQ8vjDatoq4Asy+HXFuo1GA/lvNgNb3Nv4FUozuv +JYJ0KaW73FZXlFBIBkMkRQE8TspHy2v/IGyNXBwKncmkszaiiozBd+T+1NUZgtk5 +9o5uKQwLHVnHIU7r/w/oN5LvLawLg2dP/f2u/KoQXMxjwLZncSH4+5tRz4oa/GMn +k4F84AxTIjGfLJeXigyP6xIPQbvJy+8iLRaCpj+v/EPwAedbRV+u0JFeqqikca70 +aGN86JBOmwpU87sfFxLI7HdI02DkvlxYYK3vYlA6zEyWaeLZ3VNr6tHcQmOnFe8Q +26gcS0AQcxQZrcWTCZ8DJYF+RnXjSVRmHV/3YDts4JyMKcD6QX8s/3aaldk= +=dLmT +-----END PGP PUBLIC KEY BLOCK----- \ No newline at end of file diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index 787424c..f176105 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -79,6 +79,9 @@ msgstr "" msgid "Enable downloads" msgstr "" +msgid "Verify binary signature using releases.coder.com when CLI signatures are not available from the deployment" +msgstr "" + msgid "Enable binary directory fallback" msgstr "" @@ -149,4 +152,16 @@ msgid "Setting up Coder" msgstr "" msgid "Loading workspaces..." +msgstr "" + +msgid "Security Warning" +msgstr "" + +msgid "Accept" +msgstr "" + +msgid "Abort" +msgstr "" + +msgid "Run anyway" msgstr "" \ No newline at end of file diff --git a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt index 5c37c9e..4ef1235 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -17,6 +17,7 @@ import com.coder.toolbox.store.DATA_DIRECTORY import com.coder.toolbox.store.DISABLE_AUTOSTART import com.coder.toolbox.store.ENABLE_BINARY_DIR_FALLBACK import com.coder.toolbox.store.ENABLE_DOWNLOADS +import com.coder.toolbox.store.FALLBACK_ON_CODER_FOR_SIGNATURES import com.coder.toolbox.store.HEADER_COMMAND import com.coder.toolbox.store.NETWORK_INFO_DIR import com.coder.toolbox.store.SSH_CONFIG_OPTIONS @@ -41,8 +42,10 @@ import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager import com.jetbrains.toolbox.api.ui.ToolboxUi import com.squareup.moshi.JsonEncodingException import com.sun.net.httpserver.HttpServer +import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.assertDoesNotThrow import org.zeroturnaround.exec.InvalidExitValueException @@ -54,6 +57,7 @@ import java.net.URL import java.nio.file.AccessDeniedException import java.nio.file.Path import java.util.UUID +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals @@ -62,12 +66,13 @@ import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertTrue -private const val VERSION_FOR_PROGRESS_REPORTING = "v2.23.1-devel+de07351b8" +private const val VERSION_FOR_PROGRESS_REPORTING = "v2.13.1-devel+de07351b8" private val noOpTextProgress: (String) -> Unit = { _ -> } internal class CoderCLIManagerTest { + private val ui = mockk(relaxed = true) private val context = CoderToolboxContext( - mockk(), + ui, mockk(), mockk(), mockk(), @@ -75,7 +80,7 @@ internal class CoderCLIManagerTest { mockk(), mockk(), mockk(relaxed = true), - mockk(), + mockk(relaxed = true), CoderSettingsStore( pluginTestSettingsStore(), Environment(), @@ -85,6 +90,11 @@ internal class CoderCLIManagerTest { mockk() ) + @BeforeTest + fun setup() { + coEvery { ui.showYesNoPopup(any(), any(), any(), any()) } returns true + } + /** * Return the contents of a script that contains the string. */ @@ -112,6 +122,9 @@ internal class CoderCLIManagerTest { if (exchange.requestURI.path == "/bin/override") { code = HttpURLConnection.HTTP_OK response = mkbinVersion("0.0.0") + } else if (exchange.requestURI.path.contains(".asc")) { + code = HttpURLConnection.HTTP_NOT_FOUND + response = "not found" } else if (!exchange.requestURI.path.startsWith("/bin/coder-")) { code = HttpURLConnection.HTTP_NOT_FOUND response = "not found" @@ -136,19 +149,14 @@ internal class CoderCLIManagerTest { fun testServerInternalError() { val (srv, url) = mockServer(HttpURLConnection.HTTP_INTERNAL_ERROR) val ccm = CoderCLIManager( - url, - context.logger, - CoderSettingsStore( - pluginTestSettingsStore(), - Environment(), - mockk(relaxed = true) - ).readOnly() + context, + url ) val ex = assertFailsWith( exceptionClass = ResponseException::class, - block = { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }, + block = { runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) } } ) assertEquals(HttpURLConnection.HTTP_INTERNAL_ERROR, ex.code) @@ -164,16 +172,16 @@ internal class CoderCLIManagerTest { ), Environment(), context.logger - ).readOnly() + ) val url = URL("http://localhost") - val ccm1 = CoderCLIManager(url, context.logger, settings) + val ccm1 = CoderCLIManager(context.copy(settingsStore = settings), url) assertEquals(settings.binSource(url), ccm1.remoteBinaryURL) assertEquals(settings.dataDir(url), ccm1.coderConfigPath.parent) assertEquals(settings.binPath(url), ccm1.localBinaryPath) // Can force using data directory. - val ccm2 = CoderCLIManager(url, context.logger, settings, true) + val ccm2 = CoderCLIManager(context.copy(settingsStore = settings), url, true) assertEquals(settings.binSource(url), ccm2.remoteBinaryURL) assertEquals(settings.dataDir(url), ccm2.coderConfigPath.parent) assertEquals(settings.binPath(url, true), ccm2.localBinaryPath) @@ -187,15 +195,16 @@ internal class CoderCLIManagerTest { val (srv, url) = mockServer() val ccm = CoderCLIManager( - url, - context.logger, - CoderSettingsStore( + context.copy( + settingsStore = CoderSettingsStore( pluginTestSettingsStore( DATA_DIRECTORY to tmpdir.resolve("cli-dir-fail-to-write").toString(), ), Environment(), context.logger - ).readOnly(), + ) + ), + url ) ccm.localBinaryPath.parent.toFile().mkdirs() @@ -203,7 +212,7 @@ internal class CoderCLIManagerTest { assertFailsWith( exceptionClass = AccessDeniedException::class, - block = { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }, + block = { runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) } }, ) srv.stop(0) @@ -221,22 +230,24 @@ internal class CoderCLIManagerTest { } val ccm = CoderCLIManager( + context.copy( + settingsStore = CoderSettingsStore( + pluginTestSettingsStore( + DATA_DIRECTORY to tmpdir.resolve("real-cli").toString(), + FALLBACK_ON_CODER_FOR_SIGNATURES to "allow", + ), + Environment(), + context.logger + ) + ), url.toURL(), - context.logger, - CoderSettingsStore( - pluginTestSettingsStore( - DATA_DIRECTORY to tmpdir.resolve("real-cli").toString(), - ), - Environment(), - context.logger - ).readOnly(), ) - assertTrue(ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) + assertTrue(runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) assertDoesNotThrow { ccm.version() } // It should skip the second attempt. - assertFalse(ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) + assertFalse(runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) // Make sure login failures propagate. assertFailsWith( @@ -249,39 +260,43 @@ internal class CoderCLIManagerTest { fun testDownloadMockCLI() { val (srv, url) = mockServer() var ccm = CoderCLIManager( + context.copy( + settingsStore = CoderSettingsStore( + pluginTestSettingsStore( + BINARY_NAME to "coder.bat", + DATA_DIRECTORY to tmpdir.resolve("mock-cli").toString(), + FALLBACK_ON_CODER_FOR_SIGNATURES to "allow", + ), + Environment(), + context.logger, + ) + ), url, - context.logger, - CoderSettingsStore( - pluginTestSettingsStore( - BINARY_NAME to "coder.bat", - DATA_DIRECTORY to tmpdir.resolve("mock-cli").toString(), - ), - Environment(), - context.logger, - ).readOnly(), ) - assertEquals(true, ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) + assertEquals(true, runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version()) // It should skip the second attempt. - assertEquals(false, ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) + assertEquals(false, runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) // Should use the source override. ccm = CoderCLIManager( + context.copy( + settingsStore = CoderSettingsStore( + pluginTestSettingsStore( + BINARY_SOURCE to "/bin/override", + DATA_DIRECTORY to tmpdir.resolve("mock-cli").toString(), + FALLBACK_ON_CODER_FOR_SIGNATURES to "allow", + ), + Environment(), + context.logger + ) + ), url, - context.logger, - CoderSettingsStore( - pluginTestSettingsStore( - BINARY_SOURCE to "/bin/override", - DATA_DIRECTORY to tmpdir.resolve("mock-cli").toString(), - ), - Environment(), - context.logger - ).readOnly(), ) - assertEquals(true, ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) + assertEquals(true, runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) assertContains(ccm.localBinaryPath.toFile().readText(), "0.0.0") srv.stop(0) @@ -290,15 +305,16 @@ internal class CoderCLIManagerTest { @Test fun testRunNonExistentBinary() { val ccm = CoderCLIManager( - URL("https://foo"), - context.logger, - CoderSettingsStore( + context.copy( + settingsStore = CoderSettingsStore( pluginTestSettingsStore( DATA_DIRECTORY to tmpdir.resolve("does-not-exist").toString(), ), Environment(), context.logger - ).readOnly(), + ) + ), + URL("https://foo") ) assertFailsWith( @@ -311,15 +327,17 @@ internal class CoderCLIManagerTest { fun testOverwritesWrongVersion() { val (srv, url) = mockServer() val ccm = CoderCLIManager( - url, - context.logger, - CoderSettingsStore( + context.copy( + settingsStore = CoderSettingsStore( pluginTestSettingsStore( + FALLBACK_ON_CODER_FOR_SIGNATURES to "allow", DATA_DIRECTORY to tmpdir.resolve("overwrite-cli").toString(), ), Environment(), context.logger - ).readOnly(), + ) + ), + url ) ccm.localBinaryPath.parent.toFile().mkdirs() @@ -329,7 +347,7 @@ internal class CoderCLIManagerTest { assertEquals("cli", ccm.localBinaryPath.toFile().readText()) assertEquals(0, ccm.localBinaryPath.toFile().lastModified()) - assertTrue(ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) + assertTrue(runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) assertNotEquals("cli", ccm.localBinaryPath.toFile().readText()) assertNotEquals(0, ccm.localBinaryPath.toFile().lastModified()) @@ -346,16 +364,17 @@ internal class CoderCLIManagerTest { val settings = CoderSettingsStore( pluginTestSettingsStore( DATA_DIRECTORY to tmpdir.resolve("clobber-cli").toString(), + FALLBACK_ON_CODER_FOR_SIGNATURES to "allow" ), Environment(), context.logger - ).readOnly() + ) - val ccm1 = CoderCLIManager(url1, context.logger, settings) - val ccm2 = CoderCLIManager(url2, context.logger, settings) + val ccm1 = CoderCLIManager(context.copy(settingsStore = settings), url1) + val ccm2 = CoderCLIManager(context.copy(settingsStore = settings), url2) - assertTrue(ccm1.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) - assertTrue(ccm2.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) + assertTrue(runBlocking { ccm1.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) + assertTrue(runBlocking { ccm2.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) srv1.stop(0) srv2.stop(0) @@ -525,10 +544,13 @@ internal class CoderCLIManagerTest { ), env = it.env, context.logger, - ).readOnly() + ) val ccm = - CoderCLIManager(it.url ?: URI.create("https://test.coder.invalid").toURL(), context.logger, settings) + CoderCLIManager( + context.copy(settingsStore = settings), + it.url ?: URI.create("https://test.coder.invalid").toURL() + ) val sshConfigPath = Path.of(settings.sshConfigPath) // Input is the configuration that we start with, if any. @@ -609,7 +631,7 @@ internal class CoderCLIManagerTest { ), Environment(), context.logger - ).readOnly() + ) val sshConfigPath = Path.of(settings.sshConfigPath) sshConfigPath.parent.toFile().mkdirs() Path.of("src/test/resources/fixtures/inputs").resolve("$it.conf").toFile().copyTo( @@ -617,7 +639,7 @@ internal class CoderCLIManagerTest { true, ) - val ccm = CoderCLIManager(URL("https://test.coder.invalid"), context.logger, settings) + val ccm = CoderCLIManager(context.copy(settingsStore = settings), URL("https://test.coder.invalid")) assertFailsWith( exceptionClass = SSHConfigFormatException::class, @@ -644,15 +666,16 @@ internal class CoderCLIManagerTest { tests.forEach { val ccm = CoderCLIManager( + context.copy( + settingsStore = CoderSettingsStore( + pluginTestSettingsStore( + HEADER_COMMAND to it, + ), + Environment(), + context.logger + ) + ), URI.create("https://test.coder.invalid").toURL(), - context.logger, - CoderSettingsStore( - pluginTestSettingsStore( - HEADER_COMMAND to it, - ), - Environment(), - context.logger - ).readOnly(), ) assertFailsWith( @@ -695,16 +718,17 @@ internal class CoderCLIManagerTest { ) val ccm = CoderCLIManager( + context.copy( + settingsStore = CoderSettingsStore( + pluginTestSettingsStore( + BINARY_NAME to "coder.bat", + BINARY_DIRECTORY to tmpdir.resolve("bad-version").toString(), + ), + Environment(), + context.logger, + ) + ), URL("https://test.coder.parse-fail.invalid"), - context.logger, - CoderSettingsStore( - pluginTestSettingsStore( - BINARY_NAME to "coder.bat", - BINARY_DIRECTORY to tmpdir.resolve("bad-version").toString(), - ), - Environment(), - context.logger, - ).readOnly(), ) ccm.localBinaryPath.parent.toFile().mkdirs() @@ -748,16 +772,17 @@ internal class CoderCLIManagerTest { ) val ccm = CoderCLIManager( + context.copy( + settingsStore = CoderSettingsStore( + pluginTestSettingsStore( + BINARY_NAME to "coder.bat", + BINARY_DIRECTORY to tmpdir.resolve("matches-version").toString(), + ), + Environment(), + context.logger, + ) + ), URL("https://test.coder.matches-version.invalid"), - context.logger, - CoderSettingsStore( - pluginTestSettingsStore( - BINARY_NAME to "coder.bat", - BINARY_DIRECTORY to tmpdir.resolve("matches-version").toString(), - ), - Environment(), - context.logger, - ).readOnly(), ) ccm.localBinaryPath.parent.toFile().mkdirs() @@ -852,6 +877,7 @@ internal class CoderCLIManagerTest { ENABLE_BINARY_DIR_FALLBACK to it.enableFallback.toString(), DATA_DIRECTORY to tmpdir.resolve("ensure-data-dir").toString(), BINARY_DIRECTORY to tmpdir.resolve("ensure-bin-dir").toString(), + FALLBACK_ON_CODER_FOR_SIGNATURES to "allow" ), Environment(), context.logger @@ -886,12 +912,12 @@ internal class CoderCLIManagerTest { Result.ERROR -> { assertFailsWith( exceptionClass = AccessDeniedException::class, - block = { ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) }, + block = { runBlocking { ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) } } ) } Result.NONE -> { - val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) + val ccm = runBlocking { ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) } assertEquals(settings.binPath(url), ccm.localBinaryPath) assertFailsWith( exceptionClass = ProcessInitException::class, @@ -900,25 +926,25 @@ internal class CoderCLIManagerTest { } Result.DL_BIN -> { - val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) + val ccm = runBlocking { ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) } assertEquals(settings.binPath(url), ccm.localBinaryPath) assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version()) } Result.DL_DATA -> { - val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) + val ccm = runBlocking { ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) } assertEquals(settings.binPath(url, true), ccm.localBinaryPath) assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version()) } Result.USE_BIN -> { - val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) + val ccm = runBlocking { ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) } assertEquals(settings.binPath(url), ccm.localBinaryPath) assertEquals(SemVer.parse(it.version ?: ""), ccm.version()) } Result.USE_DATA -> { - val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) + val ccm = runBlocking { ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) } assertEquals(settings.binPath(url, true), ccm.localBinaryPath) assertEquals(SemVer.parse(it.fallbackVersion ?: ""), ccm.version()) } @@ -947,18 +973,20 @@ internal class CoderCLIManagerTest { tests.forEach { val (srv, url) = mockServer(version = it.first) val ccm = CoderCLIManager( + context.copy( + settingsStore = CoderSettingsStore( + pluginTestSettingsStore( + BINARY_NAME to "coder.bat", + DATA_DIRECTORY to tmpdir.resolve("features").toString(), + FALLBACK_ON_CODER_FOR_SIGNATURES to "allow" + ), + Environment(), + context.logger, + ) + ), url, - context.logger, - CoderSettingsStore( - pluginTestSettingsStore( - BINARY_NAME to "coder.bat", - DATA_DIRECTORY to tmpdir.resolve("features").toString(), - ), - Environment(), - context.logger, - ).readOnly(), ) - assertEquals(true, ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) + assertEquals(true, runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) assertEquals(it.second, ccm.features, "version: ${it.first}") srv.stop(0) diff --git a/src/test/kotlin/com/coder/toolbox/store/CoderSettingsStoreTest.kt b/src/test/kotlin/com/coder/toolbox/store/CoderSettingsStoreTest.kt new file mode 100644 index 0000000..5798524 --- /dev/null +++ b/src/test/kotlin/com/coder/toolbox/store/CoderSettingsStoreTest.kt @@ -0,0 +1,93 @@ +package com.coder.toolbox.store + +import com.coder.toolbox.settings.Environment +import com.coder.toolbox.util.pluginTestSettingsStore +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test + +class CoderSettingsStoreTest { + private var originalOsName: String? = null + private var originalOsArch: String? = null + + private lateinit var store: CoderSettingsStore + + @BeforeTest + fun setUp() { + originalOsName = System.getProperty("os.name") + originalOsArch = System.getProperty("os.arch") + + store = CoderSettingsStore( + pluginTestSettingsStore(), + Environment(), + mockk(relaxed = true) + ) + } + + @AfterTest + fun tearDown() { + System.setProperty("os.name", originalOsName) + System.setProperty("os.arch", originalOsArch) + } + + @Test + fun `Default CLI and signature for Windows AMD64`() = + assertBinaryAndSignature("Windows 10", "amd64", "coder-windows-amd64.exe", "coder-windows-amd64.asc") + + @Test + fun `Default CLI and signature for Windows ARM64`() = + assertBinaryAndSignature("Windows 10", "aarch64", "coder-windows-arm64.exe", "coder-windows-arm64.asc") + + @Test + fun `Default CLI and signature for Windows ARMV7`() = + assertBinaryAndSignature("Windows 10", "armv7l", "coder-windows-armv7.exe", "coder-windows-armv7.asc") + + @Test + fun `Default CLI and signature for Linux AMD64`() = + assertBinaryAndSignature("Linux", "x86_64", "coder-linux-amd64", "coder-linux-amd64.asc") + + @Test + fun `Default CLI and signature for Linux ARM64`() = + assertBinaryAndSignature("Linux", "aarch64", "coder-linux-arm64", "coder-linux-arm64.asc") + + @Test + fun `Default CLI and signature for Linux ARMV7`() = + assertBinaryAndSignature("Linux", "armv7l", "coder-linux-armv7", "coder-linux-armv7.asc") + + @Test + fun `Default CLI and signature for Mac AMD64`() = + assertBinaryAndSignature("Mac OS X", "x86_64", "coder-darwin-amd64", "coder-darwin-amd64.asc") + + @Test + fun `Default CLI and signature for Mac ARM64`() = + assertBinaryAndSignature("Mac OS X", "aarch64", "coder-darwin-arm64", "coder-darwin-arm64.asc") + + @Test + fun `Default CLI and signature for Mac ARMV7`() = + assertBinaryAndSignature("Mac OS X", "armv7l", "coder-darwin-armv7", "coder-darwin-armv7.asc") + + @Test + fun `Default CLI and signature for unknown OS and Arch`() = + assertBinaryAndSignature(null, null, "coder-windows-amd64.exe", "coder-windows-amd64.asc") + + @Test + fun `Default CLI and signature for unknown Arch fallback on Linux`() = + assertBinaryAndSignature("Linux", "mips64", "coder-linux-amd64", "coder-linux-amd64.asc") + + private fun assertBinaryAndSignature( + osName: String?, + arch: String?, + expectedBinary: String, + expectedSignature: String + ) { + if (osName == null) System.clearProperty("os.name") else System.setProperty("os.name", osName) + if (arch == null) System.clearProperty("os.arch") else System.setProperty("os.arch", arch) + + assertEquals(expectedBinary, store.defaultCliBinaryNameByOsAndArch) + assertEquals(expectedSignature, store.defaultSignatureNameByOsAndArch) + } + +} \ No newline at end of file From 3a21b457bbb6f952e06f5af2d675729df65cb7f2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 18 Jul 2025 01:08:04 +0300 Subject: [PATCH 34/93] Changelog update - `v0.5.0` (#156) Current pull request contains patched `CHANGELOG.md` file for the `v0.5.0` version. Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69ccd25..f55874e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.5.0 - 2025-07-17 + ### Added - support for matching workspace agent in the URI via the agent name From 23cab568326c944004db921131fde5ef74b3819e Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 21 Jul 2025 19:14:49 +0300 Subject: [PATCH 35/93] impl: support for certificate based authentication (#155) We now skip token input screen if the user provided a public and a private key for mTLS authentication on both the usual welcome screen and in the URI handling. Attention: the official coder deployment supports only authentication via token, which is why I could not fully test an end to end scenario. --- CHANGELOG.md | 4 ++++ gradle.properties | 2 +- .../com/coder/toolbox/CoderRemoteProvider.kt | 2 +- .../com/coder/toolbox/sdk/CoderRestClient.kt | 5 ++++- .../toolbox/util/CoderProtocolHandler.kt | 8 +++---- .../com/coder/toolbox/views/ConnectStep.kt | 20 ++++++++++------- .../coder/toolbox/views/DeploymentUrlStep.kt | 6 ++++- .../views/state/CoderCliSetupWizardState.kt | 6 ++++- .../coder/toolbox/sdk/CoderRestClientTest.kt | 22 ++++++++++++++++++- 9 files changed, 57 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f55874e..7c64bb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- support for certificate based authentication + ## 0.5.0 - 2025-07-17 ### Added diff --git a/gradle.properties b/gradle.properties index 9513b30..79386fe 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.5.0 +version=0.5.1 group=com.coder.toolbox name=coder-toolbox diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 3e3172a..d68b077 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -245,7 +245,7 @@ class CoderRemoteProvider( environments.value = LoadableState.Value(emptyList()) isInitialized.update { false } client = null - CoderCliSetupWizardState.resetSteps() + CoderCliSetupWizardState.goToFirstStep() } override val svgIcon: SvgIcon = diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 9aa3dfb..187e5db 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -94,7 +94,10 @@ open class CoderRestClient( .build() } - if (token != null) { + if (context.settingsStore.requireTokenAuth) { + if (token.isNullOrBlank()) { + throw IllegalStateException("Token is required for $url deployment") + } builder = builder.addInterceptor { it.proceed( it.request().newBuilder().addHeader("Coder-Session-Token", token).build() diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 23b015d..f299528 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -64,7 +64,7 @@ open class CoderProtocolHandler( context.logger.info("Handling $uri...") val deploymentURL = resolveDeploymentUrl(params) ?: return - val token = resolveToken(params) ?: return + val token = if (!context.settingsStore.requireTokenAuth) null else resolveToken(params) ?: return val workspaceName = resolveWorkspaceName(params) ?: return val restClient = buildRestClient(deploymentURL, token) ?: return val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL) ?: return @@ -128,7 +128,7 @@ open class CoderProtocolHandler( return workspace } - private suspend fun buildRestClient(deploymentURL: String, token: String): CoderRestClient? { + private suspend fun buildRestClient(deploymentURL: String, token: String?): CoderRestClient? { try { return authenticate(deploymentURL, token) } catch (ex: Exception) { @@ -140,11 +140,11 @@ open class CoderProtocolHandler( /** * Returns an authenticated Coder CLI. */ - private suspend fun authenticate(deploymentURL: String, token: String): CoderRestClient { + private suspend fun authenticate(deploymentURL: String, token: String?): CoderRestClient { val client = CoderRestClient( context, deploymentURL.toURL(), - if (settings.requireTokenAuth) token else null, + token, PluginManager.pluginInfo.version ) client.initializeSession() diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index 9964d0c..e01971c 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -47,7 +47,7 @@ class ConnectStep( context.i18n.pnotr("") } - if (CoderCliSetupContext.isNotReadyForAuth()) { + if (context.settingsStore.requireTokenAuth && CoderCliSetupContext.isNotReadyForAuth()) { errorField.textState.update { context.i18n.pnotr("URL and token were not properly configured. Please go back and provide a proper URL and token!") } @@ -67,7 +67,7 @@ class ConnectStep( return } - if (!CoderCliSetupContext.hasToken()) { + if (context.settingsStore.requireTokenAuth && !CoderCliSetupContext.hasToken()) { errorField.textState.update { context.i18n.ptrl("Token is required") } return } @@ -77,7 +77,7 @@ class ConnectStep( val client = CoderRestClient( context, CoderCliSetupContext.url!!, - CoderCliSetupContext.token!!, + if (context.settingsStore.requireTokenAuth) CoderCliSetupContext.token else null, PluginManager.pluginInfo.version, ) // allows interleaving with the back/cancel action @@ -91,17 +91,17 @@ class ConnectStep( statusField.textState.update { (context.i18n.pnotr(progress)) } } // We only need to log in if we are using token-based auth. - if (client.token != null) { + if (context.settingsStore.requireTokenAuth) { statusField.textState.update { (context.i18n.ptrl("Configuring Coder CLI...")) } // allows interleaving with the back/cancel action yield() - cli.login(client.token) + cli.login(client.token!!) } statusField.textState.update { (context.i18n.ptrl("Successfully configured ${CoderCliSetupContext.url!!.host}...")) } // allows interleaving with the back/cancel action yield() CoderCliSetupContext.reset() - CoderCliSetupWizardState.resetSteps() + CoderCliSetupWizardState.goToFirstStep() onConnect(client, cli) } catch (ex: CancellationException) { if (ex.message != USER_HIT_THE_BACK_BUTTON) { @@ -127,10 +127,14 @@ class ConnectStep( } finally { if (shouldAutoLogin.value) { CoderCliSetupContext.reset() - CoderCliSetupWizardState.resetSteps() + CoderCliSetupWizardState.goToFirstStep() context.secrets.rememberMe = false } else { - CoderCliSetupWizardState.goToPreviousStep() + if (context.settingsStore.requireTokenAuth) { + CoderCliSetupWizardState.goToPreviousStep() + } else { + CoderCliSetupWizardState.goToFirstStep() + } } } } diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index 2a76864..128bba4 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -85,7 +85,11 @@ class DeploymentUrlStep( notify("URL is invalid", e) return false } - CoderCliSetupWizardState.goToNextStep() + if (context.settingsStore.requireTokenAuth) { + CoderCliSetupWizardState.goToNextStep() + } else { + CoderCliSetupWizardState.goToLastStep() + } return true } diff --git a/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupWizardState.kt b/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupWizardState.kt index f1efca4..92a0845 100644 --- a/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupWizardState.kt +++ b/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupWizardState.kt @@ -25,7 +25,11 @@ object CoderCliSetupWizardState { currentStep = WizardStep.entries.toTypedArray()[(currentStep.ordinal - 1) % WizardStep.entries.size] } - fun resetSteps() { + fun goToLastStep() { + currentStep = WizardStep.CONNECT + } + + fun goToFirstStep() { currentStep = WizardStep.URL_REQUEST } } diff --git a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt index c42ead2..49314c5 100644 --- a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt +++ b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt @@ -225,7 +225,7 @@ class CoderRestClientTest { val client = CoderRestClient(context, URL(url), "token") assertEquals(user.username, runBlocking { client.me() }.username) - val tests = listOf("invalid", null) + val tests = listOf("invalid") tests.forEach { token -> val ex = assertFailsWith( @@ -238,6 +238,26 @@ class CoderRestClientTest { srv.stop(0) } + @Test + fun `exception is raised when token is required for authentication and token value is null or empty`() { + listOf("", null).forEach { token -> + val ex = + assertFailsWith( + exceptionClass = IllegalStateException::class, + block = { + runBlocking { + CoderRestClient( + context, + URI.create("https://coder.com").toURL(), + token + ).me() + } + }, + ) + assertEquals(ex.message, "Token is required for https://coder.com deployment") + } + } + @Test fun testGetsWorkspaces() { val tests = From a9c5eb355e809a09e6988c72e097c97cde4da215 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 21 Jul 2025 20:27:38 +0300 Subject: [PATCH 36/93] Changelog update - `v0.5.1` (#157) Current pull request contains patched `CHANGELOG.md` file for the `v0.5.1` version. Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c64bb0..4d769d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.5.1 - 2025-07-21 + ### Added - support for certificate based authentication From 478a5b1b6a8f5d9ad61cb1f21d0a83683b0f03a6 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Tue, 22 Jul 2025 13:14:45 +0200 Subject: [PATCH 37/93] feat: set 'jetbrains_connection' as build reason on workspace start (#150) This PR is part of https://github.com/coder/coder/pull/18827 which introduces new build reason values to identify what type of connection triggered a workspace build, helping to troubleshoot workspace-related issues. --- src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt | 8 +++++++- .../toolbox/sdk/v2/models/CreateWorkspaceBuildRequest.kt | 5 ++++- .../coder/toolbox/sdk/v2/models/WorkspaceBuildReason.kt | 7 +++++++ 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuildReason.kt diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 187e5db..e3a882f 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -15,6 +15,7 @@ import com.coder.toolbox.sdk.v2.models.User import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent import com.coder.toolbox.sdk.v2.models.WorkspaceBuild +import com.coder.toolbox.sdk.v2.models.WorkspaceBuildReason import com.coder.toolbox.sdk.v2.models.WorkspaceResource import com.coder.toolbox.sdk.v2.models.WorkspaceStatus import com.coder.toolbox.sdk.v2.models.WorkspaceTransition @@ -271,7 +272,12 @@ open class CoderRestClient( * @throws [APIResponseException]. */ suspend fun startWorkspace(workspace: Workspace): WorkspaceBuild { - val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.START) + val buildRequest = CreateWorkspaceBuildRequest( + null, + WorkspaceTransition.START, + null, + WorkspaceBuildReason.JETBRAINS_CONNECTION + ) val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest) if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) { throw APIResponseException( diff --git a/src/main/kotlin/com/coder/toolbox/sdk/v2/models/CreateWorkspaceBuildRequest.kt b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/CreateWorkspaceBuildRequest.kt index a2f1ca2..53c70c8 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/v2/models/CreateWorkspaceBuildRequest.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/CreateWorkspaceBuildRequest.kt @@ -10,7 +10,8 @@ data class CreateWorkspaceBuildRequest( @Json(name = "template_version_id") val templateVersionID: UUID?, // Use to start, stop and delete the workspace. @Json(name = "transition") val transition: WorkspaceTransition, - @Json(name = "orphan") var orphan: Boolean? = null + @Json(name = "orphan") var orphan: Boolean? = null, + @Json(name = "reason") var reason: WorkspaceBuildReason? = null ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -21,6 +22,7 @@ data class CreateWorkspaceBuildRequest( if (templateVersionID != other.templateVersionID) return false if (transition != other.transition) return false if (orphan != other.orphan) return false + if (reason != other.reason) return false return true } @@ -28,6 +30,7 @@ data class CreateWorkspaceBuildRequest( var result = orphan?.hashCode() ?: 0 result = 31 * result + (templateVersionID?.hashCode() ?: 0) result = 31 * result + transition.hashCode() + result = 31 * result + (reason?.hashCode() ?: 0) return result } } diff --git a/src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuildReason.kt b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuildReason.kt new file mode 100644 index 0000000..390c934 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuildReason.kt @@ -0,0 +1,7 @@ +package com.coder.toolbox.sdk.v2.models + +import com.squareup.moshi.Json + +enum class WorkspaceBuildReason { + @Json(name = "jetbrains_connection") JETBRAINS_CONNECTION, +} \ No newline at end of file From 1f23762d4db35cb20c243e47e65b354cf3e4943c Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 23 Jul 2025 01:56:36 +0300 Subject: [PATCH 38/93] fix: class cast exception when handling Failed verification result & signature download on Windows (#158) Verification result was improperly cast to download result when signature verification failed to run. I discovered this issue while porting the signature verifications to Coder Gateway plugin. Additionally the signature for windows CLI follows the format: coder-windows-amd64.exe.asc Currently it is coded to coder-windows-amd64.asc which means the plugin always fail to find any signature for windows CLI --- CHANGELOG.md | 5 ++ gradle.properties | 2 +- .../com/coder/toolbox/cli/CoderCLIManager.kt | 12 ++-- .../coder/toolbox/store/CoderSettingsStore.kt | 56 ++++++------------- .../toolbox/store/CoderSettingsStoreTest.kt | 14 +---- 5 files changed, 32 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d769d0..68290b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Fixed + +- fix class cast exception during signature verification +- the correct CLI signature for Windows is now downloaded + ## 0.5.1 - 2025-07-21 ### Added diff --git a/gradle.properties b/gradle.properties index 79386fe..e6dc4d8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.5.1 +version=0.5.2 group=com.coder.toolbox name=coder-toolbox diff --git a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt index 177ba81..8afd954 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt @@ -277,8 +277,8 @@ class CoderCLIManager( } else -> { - UnsignedBinaryExecutionDeniedException((result as Failed).error.message) - val failure = result as DownloadResult.Failed + val failure = result as Failed + UnsignedBinaryExecutionDeniedException(result.error.message) context.logger.error(failure.error, "Failed to verify signature for ${cliResult.dst}") } } @@ -467,7 +467,7 @@ class CoderCLIManager( */ private fun writeSSHConfig(contents: String?) { if (contents != null) { - if (!context.settingsStore.sshConfigPath.isNullOrBlank()) { + if (context.settingsStore.sshConfigPath.isNotBlank()) { val sshConfPath = Path.of(context.settingsStore.sshConfigPath) sshConfPath.parent.toFile().mkdirs() sshConfPath.toFile().writeText(contents) @@ -492,9 +492,9 @@ class CoderCLIManager( throw MissingVersionException("No version found in output") } return SemVer.parse(json.version) - } catch (exception: JsonDataException) { + } catch (_: JsonDataException) { throw MissingVersionException("No version found in output") - } catch (exception: EOFException) { + } catch (_: EOFException) { throw MissingVersionException("No version found in output") } } @@ -532,7 +532,7 @@ class CoderCLIManager( val buildVersion = try { SemVer.parse(rawBuildVersion) - } catch (e: InvalidVersionException) { + } catch (_: InvalidVersionException) { context.logger.info("Got invalid build version: $rawBuildVersion") return null } diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index a57ff45..ff56380 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -250,42 +250,17 @@ class CoderSettingsStore( /** * Return the name of the binary (with extension) for the provided OS and architecture. */ - private fun getCoderCLIForOS( - os: OS?, - arch: Arch?, - ): String { + private fun getCoderCLIForOS(os: OS?, arch: Arch?): String { logger.debug("Resolving binary for $os $arch") - return buildCoderFileName(os, arch) - } - - /** - * Return the name of the signature file (.asc) for the provided OS and architecture. - */ - private fun getCoderSignatureForOS( - os: OS?, - arch: Arch?, - ): String { - logger.debug("Resolving signature for $os $arch") - return buildCoderFileName(os, arch, true) - } - - /** - * Build the coder file name based on OS, architecture, and whether it's a signature file. - */ - private fun buildCoderFileName( - os: OS?, - arch: Arch?, - isSignature: Boolean = false - ): String { - if (os == null) { - logger.error("Could not resolve client OS and architecture, defaulting to WINDOWS AMD64") - return if (isSignature) "coder-windows-amd64.asc" else "coder-windows-amd64.exe" - } - val osName = when (os) { - OS.WINDOWS -> "windows" - OS.LINUX -> "linux" - OS.MAC -> "darwin" + val (osName, extension) = when (os) { + OS.WINDOWS -> "windows" to ".exe" + OS.LINUX -> "linux" to "" + OS.MAC -> "darwin" to "" + null -> { + logger.error("Could not resolve client OS and architecture, defaulting to WINDOWS AMD64") + return "coder-windows-amd64.exe" + } } val archName = when (arch) { @@ -295,14 +270,17 @@ class CoderSettingsStore( else -> "amd64" // default fallback } - val extension = if (isSignature) ".asc" else when (os) { - OS.WINDOWS -> ".exe" - OS.LINUX, OS.MAC -> "" - } - return "coder-$osName-$archName$extension" } + /** + * Return the name of the signature file (.asc) for the provided OS and architecture. + */ + private fun getCoderSignatureForOS(os: OS?, arch: Arch?): String { + logger.debug("Resolving signature for $os $arch") + return "${getCoderCLIForOS(os, arch)}.asc" + } + /** * Append the host to the path. For example, foo/bar could become * foo/bar/dev.coder.com-8080. diff --git a/src/test/kotlin/com/coder/toolbox/store/CoderSettingsStoreTest.kt b/src/test/kotlin/com/coder/toolbox/store/CoderSettingsStoreTest.kt index 5798524..636ef61 100644 --- a/src/test/kotlin/com/coder/toolbox/store/CoderSettingsStoreTest.kt +++ b/src/test/kotlin/com/coder/toolbox/store/CoderSettingsStoreTest.kt @@ -35,15 +35,11 @@ class CoderSettingsStoreTest { @Test fun `Default CLI and signature for Windows AMD64`() = - assertBinaryAndSignature("Windows 10", "amd64", "coder-windows-amd64.exe", "coder-windows-amd64.asc") + assertBinaryAndSignature("Windows 10", "amd64", "coder-windows-amd64.exe", "coder-windows-amd64.exe.asc") @Test fun `Default CLI and signature for Windows ARM64`() = - assertBinaryAndSignature("Windows 10", "aarch64", "coder-windows-arm64.exe", "coder-windows-arm64.asc") - - @Test - fun `Default CLI and signature for Windows ARMV7`() = - assertBinaryAndSignature("Windows 10", "armv7l", "coder-windows-armv7.exe", "coder-windows-armv7.asc") + assertBinaryAndSignature("Windows 10", "aarch64", "coder-windows-arm64.exe", "coder-windows-arm64.exe.asc") @Test fun `Default CLI and signature for Linux AMD64`() = @@ -65,13 +61,9 @@ class CoderSettingsStoreTest { fun `Default CLI and signature for Mac ARM64`() = assertBinaryAndSignature("Mac OS X", "aarch64", "coder-darwin-arm64", "coder-darwin-arm64.asc") - @Test - fun `Default CLI and signature for Mac ARMV7`() = - assertBinaryAndSignature("Mac OS X", "armv7l", "coder-darwin-armv7", "coder-darwin-armv7.asc") - @Test fun `Default CLI and signature for unknown OS and Arch`() = - assertBinaryAndSignature(null, null, "coder-windows-amd64.exe", "coder-windows-amd64.asc") + assertBinaryAndSignature(null, null, "coder-windows-amd64.exe", "coder-windows-amd64.exe.asc") @Test fun `Default CLI and signature for unknown Arch fallback on Linux`() = From 60cbfe915364004835f983ad77e0072baa5bb293 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 23 Jul 2025 02:19:58 +0300 Subject: [PATCH 39/93] Changelog update - `v0.5.2` (#159) Current pull request contains patched `CHANGELOG.md` file for the `v0.5.2` version. Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68290b6..d161afe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.5.2 - 2025-07-22 + ### Fixed - fix class cast exception during signature verification From 296e3114b912f63031f63277d9515c28b66df0f0 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 25 Jul 2025 10:47:41 -0300 Subject: [PATCH 40/93] impl: enhanced workflow for network disruptions (#162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently, when the network connection drops, the Coder TBX plugin resets itself, redirects users to the authentication page, and terminates active SSH sessions to remote IDEs. This disrupts the user experience, forcing users to manually reconnect once the network is restored. Additionally, since the SSH session to the remote IDE is lost, the JBClient is unable to re-establish a connection with the remote backend. This PR aims to improve that experience by adopting a behavior similar to the SSH plugin. Instead of clearing the list of workspaces or dropping existing SSH sessions during a network outage, we retain them. Once the network is restored, the plugin will automatically reinitialize the HTTP client and regenerate the SSH configuration—only if the number of workspaces has changed during the disconnection—without requiring user intervention. Additionally we also add support for remembering SSH connections that were not manually disconnected by the user. This allows the plugin to automatically restore those connections on the next startup enabling remote IDEs that remained open to reconnect once the SSH link is re-established. --- CHANGELOG.md | 4 ++ gradle.properties | 4 +- .../coder/toolbox/CoderRemoteEnvironment.kt | 19 +++++-- .../com/coder/toolbox/CoderRemoteProvider.kt | 52 ++++++++----------- .../com/coder/toolbox/sdk/CoderRestClient.kt | 2 +- .../toolbox/settings/ReadOnlyCoderSettings.kt | 5 ++ .../coder/toolbox/store/CoderSecretsStore.kt | 5 +- .../coder/toolbox/store/CoderSettingsStore.kt | 8 +++ .../com/coder/toolbox/store/StoreKeys.kt | 2 + .../com/coder/toolbox/views/ConnectStep.kt | 1 - 10 files changed, 61 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d161afe..3b6e569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Changed + +- improved workflow when network connection is flaky + ## 0.5.2 - 2025-07-22 ### Fixed diff --git a/gradle.properties b/gradle.properties index e6dc4d8..0becc24 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.5.2 +version=0.6.0 group=com.coder.toolbox -name=coder-toolbox +name=coder-toolbox \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index 08f5a07..f8b3a17 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -68,6 +68,13 @@ class CoderRemoteEnvironment( private val proxyCommandHandle = SshCommandProcessHandle(context) private var pollJob: Job? = null + init { + if (context.settingsStore.shouldAutoConnect(id)) { + context.logger.info("resuming SSH connection to $id — last session was still active.") + startSshConnection() + } + } + fun asPairOfWorkspaceAndAgent(): Pair = Pair(workspace, agent) private fun getAvailableActions(): List { @@ -158,6 +165,7 @@ class CoderRemoteEnvironment( override fun beforeConnection() { context.logger.info("Connecting to $id...") isConnected.update { true } + context.settingsStore.updateAutoConnect(this.id, true) pollJob = pollNetworkMetrics() } @@ -180,12 +188,9 @@ class CoderRemoteEnvironment( } context.logger.debug("Loading metrics from ${metricsFile.absolutePath} for $id") try { - val metrics = networkMetricsMarshaller.fromJson(metricsFile.readText()) - if (metrics == null) { - return@launch - } + val metrics = networkMetricsMarshaller.fromJson(metricsFile.readText()) ?: return@launch context.logger.debug("$id metrics: $metrics") - additionalEnvironmentInformation.put(context.i18n.ptrl("Network Status"), metrics.toPretty()) + additionalEnvironmentInformation[context.i18n.ptrl("Network Status")] = metrics.toPretty() } catch (e: Exception) { context.logger.error( e, @@ -203,6 +208,10 @@ class CoderRemoteEnvironment( pollJob?.cancel() this.connectionRequest.update { false } isConnected.update { false } + if (isManual) { + // if the user manually disconnects the ssh connection we should not connect automatically + context.settingsStore.updateAutoConnect(this.id, false) + } context.logger.info("Disconnected from $id") } diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index d68b077..2e5d557 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -80,6 +80,8 @@ class CoderRemoteProvider( ) ) + private val errorBuffer = mutableListOf() + /** * With the provided client, start polling for workspaces. Every time a new * workspace is added, reconfigure SSH using the provided cli (including the @@ -160,23 +162,20 @@ class CoderRemoteProvider( } catch (ex: Exception) { val elapsed = lastPollTime.elapsedNow() if (elapsed > POLL_INTERVAL * 2) { - context.logger.info("wake-up from an OS sleep was detected, going to re-initialize the http client...") - client.setupSession() + context.logger.info("wake-up from an OS sleep was detected") } else { - context.logger.error(ex, "workspace polling error encountered, trying to auto-login") + context.logger.error(ex, "workspace polling error encountered") if (ex is APIResponseException && ex.isTokenExpired) { WorkspaceConnectionManager.shouldEstablishWorkspaceConnections = true + close() + context.envPageManager.showPluginEnvironmentsPage() + errorBuffer.add(ex) + break } - close() - // force auto-login - firstRun = true - context.envPageManager.showPluginEnvironmentsPage() - break } } - // TODO: Listening on a web socket might be better? - select { + select { onTimeout(POLL_INTERVAL) { context.logger.trace("workspace poller waked up by the $POLL_INTERVAL timeout") } @@ -196,9 +195,6 @@ class CoderRemoteProvider( * first page. */ private fun logout() { - // 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() } @@ -360,22 +356,17 @@ class CoderRemoteProvider( override fun getOverrideUiPage(): UiPage? { // Show the setup page if we have not configured the client yet. if (client == null) { - val errorBuffer = mutableListOf() // When coming back to the application, initializeSession immediately. - val autoSetup = shouldDoAutoSetup() - context.secrets.lastToken.let { lastToken -> - context.secrets.lastDeploymentURL.let { lastDeploymentURL -> - if (autoSetup && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !settings.requireTokenAuth)) { - try { - CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) - return CoderCliSetupWizardPage(context, settingsPage, visibilityState, true, ::onConnect) - } catch (ex: Exception) { - errorBuffer.add(ex) - } - } + if (shouldDoAutoSetup()) { + try { + CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) + return CoderCliSetupWizardPage(context, settingsPage, visibilityState, true, ::onConnect) + } catch (ex: Exception) { + errorBuffer.add(ex) + } finally { + firstRun = false } } - firstRun = false // Login flow. val setupWizardPage = @@ -384,21 +375,24 @@ class CoderRemoteProvider( errorBuffer.forEach { setupWizardPage.notify("Error encountered", it) } + errorBuffer.clear() // and now reset the errors, otherwise we show it every time on the screen return setupWizardPage } return null } - private fun shouldDoAutoSetup(): Boolean = firstRun && context.secrets.rememberMe == true + /** + * Auto-login only on first the firs run if there is a url & token configured or the auth + * should be done via certificates. + */ + private fun shouldDoAutoSetup(): Boolean = firstRun && (context.secrets.canAutoLogin || !settings.requireTokenAuth) private fun onConnect(client: CoderRestClient, cli: CoderCLIManager) { // Store the URL and token for use next time. context.secrets.lastDeploymentURL = client.url.toString() context.secrets.lastToken = client.token ?: "" context.secrets.storeTokenFor(client.url, context.secrets.lastToken) - // Currently we always remember, but this could be made an option. - context.secrets.rememberMe = true this.client = client pollJob?.cancel() environments.showLoadingMessage() diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index e3a882f..1a0f18e 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -60,7 +60,7 @@ open class CoderRestClient( setupSession() } - fun setupSession() { + private fun setupSession() { moshi = Moshi.Builder() .add(ArchConverter()) diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt index a6d6143..693c1fd 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -146,6 +146,11 @@ interface ReadOnlyCoderSettings { * Return the URL and token from the config, if they exist. */ fun readConfig(dir: Path): Pair + + /** + * Returns whether the SSH connection should be automatically established. + */ + fun shouldAutoConnect(workspaceId: String): Boolean } /** diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt index 3170a06..a807b69 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt @@ -24,9 +24,8 @@ class CoderSecretsStore(private val store: PluginSecretStore) { var lastToken: String get() = get("last-token") set(value) = set("last-token", value) - var rememberMe: Boolean - get() = get("remember-me").toBoolean() - set(value) = set("remember-me", value.toString()) + val canAutoLogin: Boolean + get() = lastDeploymentURL.isNotBlank() && lastToken.isNotBlank() fun tokenFor(url: URL): String? = store[url.host] diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index ff56380..0fa4914 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -142,6 +142,10 @@ class CoderSettingsStore( } } + override fun shouldAutoConnect(workspaceId: String): Boolean { + return store["$SSH_AUTO_CONNECT_PREFIX$workspaceId"]?.toBooleanStrictOrNull() ?: false + } + // a readonly cast fun readOnly(): ReadOnlyCoderSettings = this @@ -213,6 +217,10 @@ class CoderSettingsStore( store[SSH_CONFIG_OPTIONS] = options } + fun updateAutoConnect(workspaceId: String, autoConnect: Boolean) { + store["$SSH_AUTO_CONNECT_PREFIX$workspaceId"] = autoConnect.toString() + } + private fun getDefaultGlobalDataDir(): Path { return when (getOS()) { OS.WINDOWS -> Paths.get(env.get("LOCALAPPDATA"), "coder-toolbox") diff --git a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt index 91e3b74..cd1a05d 100644 --- a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt +++ b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt @@ -42,3 +42,5 @@ internal const val SSH_CONFIG_OPTIONS = "sshConfigOptions" internal const val NETWORK_INFO_DIR = "networkInfoDir" +internal const val SSH_AUTO_CONNECT_PREFIX = "ssh_auto_connect_" + diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index e01971c..7ea93e4 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -128,7 +128,6 @@ class ConnectStep( if (shouldAutoLogin.value) { CoderCliSetupContext.reset() CoderCliSetupWizardState.goToFirstStep() - context.secrets.rememberMe = false } else { if (context.settingsStore.requireTokenAuth) { CoderCliSetupWizardState.goToPreviousStep() From c5f8e120bc48c23a31e72731a67d3b77ea93cefb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 25 Jul 2025 18:38:25 +0300 Subject: [PATCH 41/93] Changelog update - `v0.6.0` (#163) Current pull request contains patched `CHANGELOG.md` file for the `v0.6.0` version. Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b6e569..1cab6bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.6.0 - 2025-07-25 + ### Changed - improved workflow when network connection is flaky From 82eee1f9eb85044a41d0ae32dc09f941e9464dc5 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 30 Jul 2025 23:25:27 +0300 Subject: [PATCH 42/93] impl: strict URL validation (#164) This commit rejects any URL that is opaque, not hierarchical, not using http or https protocol, or it misses the hostname. The rejection is handled in the connection/auth screen and also in the URI protocol handling logicimage image image --- CHANGELOG.md | 4 + .../toolbox/util/CoderProtocolHandler.kt | 6 ++ .../com/coder/toolbox/util/URLExtensions.kt | 38 ++++++++ .../coder/toolbox/views/DeploymentUrlStep.kt | 13 +-- .../coder/toolbox/util/URLExtensionsTest.kt | 92 +++++++++++++++++++ 5 files changed, 147 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cab6bf..b79d7d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Changed + +- URL validation is stricter in the connection screen and URI protocol handler + ## 0.6.0 - 2025-07-25 ### Changed diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index f299528..f0e84b9 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -9,6 +9,7 @@ import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent import com.coder.toolbox.sdk.v2.models.WorkspaceStatus +import com.coder.toolbox.util.WebUrlValidationResult.Invalid import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException @@ -107,6 +108,11 @@ open class CoderProtocolHandler( context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"$URL\" is missing from URI") return null } + val validationResult = deploymentURL.validateStrictWebUrl() + if (validationResult is Invalid) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "\"$URL\" is invalid: ${validationResult.reason}") + return null + } return deploymentURL } diff --git a/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt b/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt index c1aaa81..7e2a8e3 100644 --- a/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt +++ b/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt @@ -1,11 +1,44 @@ package com.coder.toolbox.util +import com.coder.toolbox.util.WebUrlValidationResult.Invalid +import com.coder.toolbox.util.WebUrlValidationResult.Valid import java.net.IDN import java.net.URI import java.net.URL fun String.toURL(): URL = URI.create(this).toURL() +fun String.validateStrictWebUrl(): WebUrlValidationResult = try { + val uri = URI(this) + + when { + uri.isOpaque -> Invalid( + "The URL \"$this\" is invalid because it is not in the standard format. " + + "Please enter a full web address like \"https://example.com\"" + ) + + !uri.isAbsolute -> Invalid( + "The URL \"$this\" is missing a scheme (like https://). " + + "Please enter a full web address like \"https://example.com\"" + ) + uri.scheme?.lowercase() !in setOf("http", "https") -> + Invalid( + "The URL \"$this\" must start with http:// or https://, not \"${uri.scheme}\"" + ) + uri.authority.isNullOrBlank() -> + Invalid( + "The URL \"$this\" does not include a valid website name. " + + "Please enter a full web address like \"https://example.com\"" + ) + else -> Valid + } +} catch (_: Exception) { + Invalid( + "The input \"$this\" is not a valid web address. " + + "Please enter a full web address like \"https://example.com\"" + ) +} + fun URL.withPath(path: String): URL = URL( this.protocol, this.host, @@ -30,3 +63,8 @@ fun URI.toQueryParameters(): Map = (this.query ?: "") parts[0] to "" } } + +sealed class WebUrlValidationResult { + object Valid : WebUrlValidationResult() + data class Invalid(val reason: String) : WebUrlValidationResult() +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index 128bba4..0608347 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -2,7 +2,9 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.settings.SignatureFallbackStrategy +import com.coder.toolbox.util.WebUrlValidationResult.Invalid import com.coder.toolbox.util.toURL +import com.coder.toolbox.util.validateStrictWebUrl import com.coder.toolbox.views.state.CoderCliSetupContext import com.coder.toolbox.views.state.CoderCliSetupWizardState import com.jetbrains.toolbox.api.ui.components.CheckboxField @@ -69,16 +71,11 @@ class DeploymentUrlStep( override fun onNext(): Boolean { context.settingsStore.updateSignatureFallbackStrategy(signatureFallbackStrategyField.checkedState.value) - var url = urlField.textState.value + val url = urlField.textState.value if (url.isBlank()) { errorField.textState.update { context.i18n.ptrl("URL is required") } return false } - url = if (!url.startsWith("http://") && !url.startsWith("https://")) { - "https://$url" - } else { - url - } try { CoderCliSetupContext.url = validateRawUrl(url) } catch (e: MalformedURLException) { @@ -98,6 +95,10 @@ class DeploymentUrlStep( */ private fun validateRawUrl(url: String): URL { try { + val result = url.validateStrictWebUrl() + if (result is Invalid) { + throw MalformedURLException(result.reason) + } return url.toURL() } catch (e: Exception) { throw MalformedURLException(e.message) diff --git a/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt b/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt index 1db26c7..af1b4ef 100644 --- a/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt @@ -60,4 +60,96 @@ internal class URLExtensionsTest { ) } } + + @Test + fun `valid http URL should return Valid`() { + val result = "http://coder.com".validateStrictWebUrl() + assertEquals(WebUrlValidationResult.Valid, result) + } + + @Test + fun `valid https URL with path and query should return Valid`() { + val result = "https://coder.com/bin/coder-linux-amd64?query=1".validateStrictWebUrl() + assertEquals(WebUrlValidationResult.Valid, result) + } + + @Test + fun `relative URL should return Invalid with appropriate message`() { + val url = "/bin/coder-linux-amd64" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("The URL \"/bin/coder-linux-amd64\" is missing a scheme (like https://). Please enter a full web address like \"https://example.com\""), + result + ) + } + + @Test + fun `opaque URI like mailto should return Invalid`() { + val url = "mailto:user@coder.com" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("The URL \"mailto:user@coder.com\" is invalid because it is not in the standard format. Please enter a full web address like \"https://example.com\""), + result + ) + } + + @Test + fun `unsupported scheme like ftp should return Invalid`() { + val url = "ftp://coder.com" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("The URL \"ftp://coder.com\" must start with http:// or https://, not \"ftp\""), + result + ) + } + + @Test + fun `http URL with missing authority should return Invalid`() { + val url = "http:///bin/coder-linux-amd64" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("The URL \"http:///bin/coder-linux-amd64\" does not include a valid website name. Please enter a full web address like \"https://example.com\""), + result + ) + } + + @Test + fun `malformed URI should return Invalid with parsing error message`() { + val url = "http://[invalid-uri]" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("The input \"http://[invalid-uri]\" is not a valid web address. Please enter a full web address like \"https://example.com\""), + result + ) + } + + @Test + fun `URI without colon should return Invalid as URI is not absolute`() { + val url = "http//coder.com" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("The URL \"http//coder.com\" is missing a scheme (like https://). Please enter a full web address like \"https://example.com\""), + result + ) + } + + @Test + fun `URI without double forward slashes should return Invalid because the URI is not hierarchical`() { + val url = "http:coder.com" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("The URL \"http:coder.com\" is invalid because it is not in the standard format. Please enter a full web address like \"https://example.com\""), + result + ) + } + + @Test + fun `URI without a single forward slash should return Invalid because the URI does not have a hostname`() { + val url = "https:/coder.com" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("The URL \"https:/coder.com\" does not include a valid website name. Please enter a full web address like \"https://example.com\""), + result + ) + } } From 0ad31dd7c045f9288e000739efbf41920bc79563 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 31 Jul 2025 22:24:36 +0300 Subject: [PATCH 43/93] impl: add support for disabling CLI signature verification (#166) This PR implements a new configurable option to allow users to disable GPG signature verification for downloaded Coder CLI binaries. This feature provides flexibility for environments where signature verification may not be required or where fallback signature sources are not accessible. A new option `disableSignatureVerification` is now available only from the Settings page, with no quick shortcut in the main page to discourage users from quickly disabling this option. The `fallbackOnCoderForSignatures` is hidden/not available for configuration once signature verification is disabled. Additionally a rough draft for developer facing documentation regarding CLI signature verification was added. To make things more consistent with Coder Gateway, the fallback setting is always displayed if signature verification is enabled, we no longer display it only once in the main page. This PR is a port of https://github.com/coder/jetbrains-coder/pull/564 from Coder Gateway. image image --- CHANGELOG.md | 4 ++ JETBRAINS_COMPLIANCE.md | 4 -- README.md | 63 +++++++++++++++++++ gradle.properties | 2 +- .../com/coder/toolbox/cli/CoderCLIManager.kt | 6 ++ .../toolbox/settings/ReadOnlyCoderSettings.kt | 7 ++- .../coder/toolbox/store/CoderSettingsStore.kt | 6 ++ .../com/coder/toolbox/store/StoreKeys.kt | 2 + .../coder/toolbox/views/CoderSettingsPage.kt | 26 +++++++- .../coder/toolbox/views/DeploymentUrlStep.kt | 3 +- .../resources/localization/defaultMessages.po | 3 + 11 files changed, 116 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b79d7d7..640bb7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- support for skipping CLI signature verification + ### Changed - URL validation is stricter in the connection screen and URI protocol handler diff --git a/JETBRAINS_COMPLIANCE.md b/JETBRAINS_COMPLIANCE.md index 306d684..91162ed 100644 --- a/JETBRAINS_COMPLIANCE.md +++ b/JETBRAINS_COMPLIANCE.md @@ -39,8 +39,6 @@ This configuration includes JetBrains-specific rules that check for: - **ForbiddenImport**: Detects potentially bundled libraries - **Standard code quality rules**: Complexity, naming, performance, etc. - - ## CI/CD Integration The GitHub Actions workflow `.github/workflows/jetbrains-compliance.yml` runs compliance checks on every PR and push. @@ -55,8 +53,6 @@ The GitHub Actions workflow `.github/workflows/jetbrains-compliance.yml` runs co open build/reports/detekt/detekt.html ``` - - ## Understanding Results ### Compliance Check Results diff --git a/README.md b/README.md index 41d430d..0c671ce 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,69 @@ 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. +## GPG Signature Verification + +The Coder Toolbox plugin starting with version *0.5.0* implements a comprehensive GPG signature verification system to +ensure the authenticity and integrity of downloaded Coder CLI binaries. This security feature helps protect users from +running potentially malicious or tampered binaries. + +### How It Works + +1. **Binary Download**: When connecting to a Coder deployment, the plugin downloads the appropriate Coder CLI binary for + the user's operating system and architecture from the deployment's `/bin/` endpoint. + +2. **Signature Download**: After downloading the binary, the plugin attempts to download the corresponding `.asc` + signature file from the same location. The signature file is named according to the binary (e.g., + `coder-linux-amd64.asc` for `coder-linux-amd64`). + +3. **Fallback Signature Sources**: If the signature is not available from the deployment, the plugin can optionally fall + back to downloading signatures from `releases.coder.com`. This is controlled by the `fallbackOnCoderForSignatures` + setting. + +4. **GPG Verification**: The plugin uses the BouncyCastle library to verify the detached GPG signature against the + downloaded binary using Coder's trusted public key. + +5. **User Interaction**: If signature verification fails or signatures are unavailable, the plugin presents security + warnings to users, allowing them to accept the risk and continue or abort the operation. + +### Verification Process + +The verification process involves several components: + +- **`GPGVerifier`**: Handles the core GPG signature verification logic using BouncyCastle +- **`VerificationResult`**: Represents the outcome of verification (Valid, Invalid, Failed, SignatureNotFound) +- **`CoderDownloadService`**: Manages downloading both binaries and their signatures +- **`CoderCLIManager`**: Orchestrates the download and verification workflow + +### Configuration Options + +Users can control signature verification behavior through plugin settings: + +- **`disableSignatureVerification`**: When enabled, skips all signature verification. This is useful for clients running + custom CLI builds, or customers with old deployment versions that don't have a signature published on + `releases.coder.com`. +- **`fallbackOnCoderForSignatures`**: When enabled, allows downloading signatures from `releases.coder.com` if not + available from the deployment. + +### Security Considerations + +- The plugin embeds Coder's trusted public key in the plugin resources +- Verification uses detached signatures, which are more secure than attached signatures +- Users are warned about security risks when verification fails +- The system gracefully handles cases where signatures are unavailable +- All verification failures are logged for debugging purposes + +### Error Handling + +The system handles various failure scenarios: + +- **Missing signatures**: Prompts user to accept risk or abort +- **Invalid signatures**: Warns user about potential tampering and prompts user to accept risk or abort +- **Verification failures**: Prompts user to accept risk or abort + +This signature verification system ensures that users can trust the Coder CLI binaries they download through the plugin, +protecting against supply chain attacks and ensuring binary integrity. + ## Configuring and Testing workspace polling with HTTP & SOCKS5 Proxy This section explains how to set up a local proxy and verify that diff --git a/gradle.properties b/gradle.properties index 0becc24..b31ebe6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.6.0 +version=0.6.1 group=com.coder.toolbox name=coder-toolbox \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt index 8afd954..582a85b 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt @@ -181,6 +181,12 @@ class CoderCLIManager( } } + if (context.settingsStore.disableSignatureVerification) { + downloader.commit() + context.logger.info("Skipping over CLI signature verification, it is disabled by the user") + return true + } + var signatureResult = withContext(Dispatchers.IO) { downloader.downloadSignature(showTextProgress) } diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt index 693c1fd..0775a63 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -29,7 +29,12 @@ interface ReadOnlyCoderSettings { val binaryDirectory: String? /** - * Controls whether we fall back release.coder.com + * Controls whether we verify the cli signature + */ + val disableSignatureVerification: Boolean + + /** + * Controls whether we fall back on release.coder.com for signatures if signature validation is enabled */ val fallbackOnCoderForSignatures: SignatureFallbackStrategy diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index 0fa4914..82b6e80 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -38,6 +38,8 @@ class CoderSettingsStore( override val defaultURL: String get() = store[DEFAULT_URL] ?: "https://dev.coder.com" override val binarySource: String? get() = store[BINARY_SOURCE] override val binaryDirectory: String? get() = store[BINARY_DIRECTORY] + override val disableSignatureVerification: Boolean + get() = store[DISABLE_SIGNATURE_VALIDATION]?.toBooleanStrictOrNull() ?: false override val fallbackOnCoderForSignatures: SignatureFallbackStrategy get() = SignatureFallbackStrategy.fromValue(store[FALLBACK_ON_CODER_FOR_SIGNATURES]) override val defaultCliBinaryNameByOsAndArch: String get() = getCoderCLIForOS(getOS(), getArch()) @@ -166,6 +168,10 @@ class CoderSettingsStore( store[ENABLE_DOWNLOADS] = shouldEnableDownloads.toString() } + fun updateDisableSignatureVerification(shouldDisableSignatureVerification: Boolean) { + store[DISABLE_SIGNATURE_VALIDATION] = shouldDisableSignatureVerification.toString() + } + fun updateSignatureFallbackStrategy(fallback: Boolean) { store[FALLBACK_ON_CODER_FOR_SIGNATURES] = when (fallback) { true -> SignatureFallbackStrategy.ALLOW.toString() diff --git a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt index cd1a05d..1626ce1 100644 --- a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt +++ b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt @@ -10,6 +10,8 @@ internal const val BINARY_SOURCE = "binarySource" internal const val BINARY_DIRECTORY = "binaryDirectory" +internal const val DISABLE_SIGNATURE_VALIDATION = "disableSignatureValidation" + internal const val FALLBACK_ON_CODER_FOR_SIGNATURES = "signatureFallbackStrategy" internal const val BINARY_NAME = "binaryName" diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index 448a20f..d27a1c0 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -6,6 +6,7 @@ import com.jetbrains.toolbox.api.ui.components.CheckboxField 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 kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ClosedSendChannelException import kotlinx.coroutines.flow.MutableStateFlow @@ -20,7 +21,7 @@ import kotlinx.coroutines.launch * TODO@JB: There is no scroll, and our settings do not fit. As a consequence, * I have not been able to test this page. */ -class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel) : +class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConfig: Channel) : CoderPage(MutableStateFlow(context.i18n.ptrl("Coder Settings")), false) { private val settings = context.settingsStore.readOnly() @@ -33,6 +34,11 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel< TextField(context.i18n.ptrl("Data directory"), settings.dataDirectory ?: "", TextType.General) private val enableDownloadsField = CheckboxField(settings.enableDownloads, context.i18n.ptrl("Enable downloads")) + + private val disableSignatureVerificationField = CheckboxField( + settings.disableSignatureVerification, + context.i18n.ptrl("Disable Coder CLI signature verification") + ) private val signatureFallbackStrategyField = CheckboxField( settings.fallbackOnCoderForSignatures.isAllowed(), @@ -65,13 +71,14 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel< private val networkInfoDirField = TextField(context.i18n.ptrl("SSH network metrics directory"), settings.networkInfoDir, TextType.General) - + private lateinit var visibilityUpdateJob: Job override val fields: StateFlow> = MutableStateFlow( listOf( binarySourceField, enableDownloadsField, binaryDirectoryField, enableBinaryDirectoryFallbackField, + disableSignatureVerificationField, signatureFallbackStrategyField, dataDirectoryField, headerCommandField, @@ -94,6 +101,7 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel< context.settingsStore.updateBinaryDirectory(binaryDirectoryField.contentState.value) context.settingsStore.updateDataDirectory(dataDirectoryField.contentState.value) context.settingsStore.updateEnableDownloads(enableDownloadsField.checkedState.value) + context.settingsStore.updateDisableSignatureVerification(disableSignatureVerificationField.checkedState.value) context.settingsStore.updateSignatureFallbackStrategy(signatureFallbackStrategyField.checkedState.value) context.settingsStore.updateBinaryDirectoryFallback(enableBinaryDirectoryFallbackField.checkedState.value) context.settingsStore.updateHeaderCommand(headerCommandField.contentState.value) @@ -182,5 +190,19 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel< networkInfoDirField.contentState.update { settings.networkInfoDir } + + visibilityUpdateJob = context.cs.launch { + disableSignatureVerificationField.checkedState.collect { state -> + signatureFallbackStrategyField.visibility.update { + // the fallback checkbox should not be visible + // if signature verification is disabled + !state + } + } + } + } + + override fun afterHide() { + visibilityUpdateJob.cancel() } } diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index 0608347..34b027c 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -1,7 +1,6 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext -import com.coder.toolbox.settings.SignatureFallbackStrategy import com.coder.toolbox.util.WebUrlValidationResult.Invalid import com.coder.toolbox.util.toURL import com.coder.toolbox.util.validateStrictWebUrl @@ -41,7 +40,7 @@ class DeploymentUrlStep( override val panel: RowGroup get() { - if (context.settingsStore.fallbackOnCoderForSignatures == SignatureFallbackStrategy.NOT_CONFIGURED) { + if (!context.settingsStore.disableSignatureVerification) { return RowGroup( RowGroup.RowField(urlField), RowGroup.RowField(emptyLine), diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index f176105..30c4484 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -164,4 +164,7 @@ msgid "Abort" msgstr "" msgid "Run anyway" +msgstr "" + +msgid "Disable Coder CLI signature verification" msgstr "" \ No newline at end of file From 5af07afbc41caab4b2726b084b8760395c110832 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 6 Aug 2025 23:59:45 +0300 Subject: [PATCH 44/93] impl: improved logging and error collection for the http client (#165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For some clients, workspace polling fails due to the following error: ``` com.squareup.moshi.JsonEncodingException: Use JsonReader.setLenient(true) to accept malformed JSON at path $ ``` Although I’ve been unable to reproduce this issue — even using the exact version deployed at the client (2.20.2) — I've introduced a logging mechanism to improve diagnostics in such cases. This PR introduces a configurable HTTP logging interceptor. Users can choose from various levels via the plugin UI: - None - Basic (method, URL, response code) - Headers (sanitized) - Body (full content) Importantly, the logging converter remains in place to capture critical information during JSON deserialization failures, even when users have disabled detailed logging (e.g., to avoid logging full bodies). To address the original error more effectively, I wrapped the Moshi converter with a custom Converter that logs the raw response body, content type, and exception details when a deserialization failure occurs. This helps debug malformed JSON responses, particularly during workspace polling. This implementation only logs when deserialization fails. In the success path, the performance impact is minimal: the response body is converted to a string for potential logging, then re-wrapped as a stream for the Moshi converter. Users can opt in to always provide extra logging details but the constom converter ensures us that we have some minimum details regardless of user's choice. image --- CHANGELOG.md | 1 + README.md | 58 +++++++++ .../com/coder/toolbox/sdk/CoderRestClient.kt | 14 +- .../sdk/convertors/LoggingConverterFactory.kt | 53 ++++++++ .../sdk/convertors/LoggingMoshiConverter.kt | 34 +++++ .../sdk/interceptors/LoggingInterceptor.kt | 120 ++++++++++++++++++ .../toolbox/settings/ReadOnlyCoderSettings.kt | 33 +++++ .../coder/toolbox/store/CoderSettingsStore.kt | 8 ++ .../com/coder/toolbox/store/StoreKeys.kt | 2 + .../coder/toolbox/views/CoderSettingsPage.kt | 20 +++ .../resources/localization/defaultMessages.po | 12 ++ 11 files changed, 352 insertions(+), 3 deletions(-) create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/convertors/LoggingConverterFactory.kt create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/convertors/LoggingMoshiConverter.kt create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/interceptors/LoggingInterceptor.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 640bb7c..f5e89de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Changed - URL validation is stricter in the connection screen and URI protocol handler +- support for verbose logging a sanitized version of the REST API request and responses ## 0.6.0 - 2025-07-25 diff --git a/README.md b/README.md index 0c671ce..3e5da52 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,64 @@ 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 Workspaces page in Coder or within the individual workspace view, under the option labeled _Collect logs_. +### HTTP Request Logging + +The Coder Toolbox plugin includes comprehensive HTTP request logging capabilities to help diagnose API communication +issues with Coder deployments. +This feature allows you to monitor all HTTP requests and responses made by the plugin. + +#### Configuring HTTP Logging + +You can configure HTTP logging verbosity through the Coder Settings page: + +1. Navigate to the Coder Workspaces page +2. Click on the deployment action menu (three dots) +3. Select "Settings" +4. Find the "HTTP logging level" dropdown + +#### Available Logging Levels + +The plugin supports four levels of HTTP logging verbosity: + +- **None**: No HTTP request/response logging (default) +- **Basic**: Logs HTTP method, URL, and response status code +- **Headers**: Logs basic information plus sanitized request and response headers +- **Body**: Logs headers plus request and response body content + +#### Log Output Format + +HTTP logs follow this format: + +``` +request --> GET https://your-coder-deployment.com/api/v2/users/me +User-Agent: Coder Toolbox/1.0.0 (darwin; amd64) +Coder-Session-Token: + +response <-- 200 https://your-coder-deployment.com/api/v2/users/me +Content-Type: application/json +Content-Length: 245 + +{"id":"12345678-1234-1234-1234-123456789012","username":"coder","email":"coder@example.com"} +``` + +#### Use Cases + +HTTP logging is particularly useful for: + +- **API Debugging**: Diagnosing issues with Coder API communication +- **Authentication Problems**: Troubleshooting token or certificate authentication issues +- **Network Issues**: Identifying connectivity problems with Coder deployments +- **Performance Analysis**: Monitoring request/response times and payload sizes + +#### Troubleshooting with HTTP Logs + +When reporting issues, include HTTP logs to help diagnose: + +1. **Authentication Failures**: Check for 401/403 responses and token headers +2. **Network Connectivity**: Look for connection timeouts or DNS resolution issues +3. **API Compatibility**: Verify request/response formats match expected API versions +4. **Proxy Issues**: Monitor proxy authentication and routing problems + ## Coder Settings The Coder Settings allows users to control CLI download behavior, SSH configuration, TLS parameters, and data diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 1a0f18e..9b2e7b3 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -3,9 +3,11 @@ package com.coder.toolbox.sdk import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.sdk.convertors.ArchConverter import com.coder.toolbox.sdk.convertors.InstantConverter +import com.coder.toolbox.sdk.convertors.LoggingConverterFactory import com.coder.toolbox.sdk.convertors.OSConverter import com.coder.toolbox.sdk.convertors.UUIDConverter import com.coder.toolbox.sdk.ex.APIResponseException +import com.coder.toolbox.sdk.interceptors.LoggingInterceptor import com.coder.toolbox.sdk.v2.CoderV2RestFacade import com.coder.toolbox.sdk.v2.models.ApiErrorResponse import com.coder.toolbox.sdk.v2.models.BuildInfo @@ -74,10 +76,10 @@ open class CoderRestClient( var builder = OkHttpClient.Builder() if (context.proxySettings.getProxy() != null) { - context.logger.debug("proxy: ${context.proxySettings.getProxy()}") + context.logger.info("proxy: ${context.proxySettings.getProxy()}") builder.proxy(context.proxySettings.getProxy()) } else if (context.proxySettings.getProxySelector() != null) { - context.logger.debug("proxy selector: ${context.proxySettings.getProxySelector()}") + context.logger.info("proxy selector: ${context.proxySettings.getProxySelector()}") builder.proxySelector(context.proxySettings.getProxySelector()!!) } @@ -129,11 +131,17 @@ open class CoderRestClient( } it.proceed(request) } + .addInterceptor(LoggingInterceptor(context)) .build() retroRestClient = Retrofit.Builder().baseUrl(url.toString()).client(httpClient) - .addConverterFactory(MoshiConverterFactory.create(moshi)) + .addConverterFactory( + LoggingConverterFactory.wrap( + context, + MoshiConverterFactory.create(moshi) + ) + ) .build().create(CoderV2RestFacade::class.java) } diff --git a/src/main/kotlin/com/coder/toolbox/sdk/convertors/LoggingConverterFactory.kt b/src/main/kotlin/com/coder/toolbox/sdk/convertors/LoggingConverterFactory.kt new file mode 100644 index 0000000..839d753 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/convertors/LoggingConverterFactory.kt @@ -0,0 +1,53 @@ +package com.coder.toolbox.sdk.convertors + +import com.coder.toolbox.CoderToolboxContext +import okhttp3.RequestBody +import okhttp3.ResponseBody +import retrofit2.Converter +import retrofit2.Retrofit +import java.lang.reflect.Type + +class LoggingConverterFactory private constructor( + private val context: CoderToolboxContext, + private val delegate: Converter.Factory, +) : Converter.Factory() { + + override fun responseBodyConverter( + type: Type, + annotations: Array, + retrofit: Retrofit + ): Converter? { + // Get the delegate converter + val delegateConverter = delegate.responseBodyConverter(type, annotations, retrofit) + ?: return null + + @Suppress("UNCHECKED_CAST") + return LoggingMoshiConverter(context, delegateConverter as Converter) + } + + override fun requestBodyConverter( + type: Type, + parameterAnnotations: Array, + methodAnnotations: Array, + retrofit: Retrofit + ): Converter<*, RequestBody>? { + return delegate.requestBodyConverter(type, parameterAnnotations, methodAnnotations, retrofit) + } + + override fun stringConverter( + type: Type, + annotations: Array, + retrofit: Retrofit + ): Converter<*, String>? { + return delegate.stringConverter(type, annotations, retrofit) + } + + companion object { + fun wrap( + context: CoderToolboxContext, + delegate: Converter.Factory, + ): LoggingConverterFactory { + return LoggingConverterFactory(context, delegate) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/sdk/convertors/LoggingMoshiConverter.kt b/src/main/kotlin/com/coder/toolbox/sdk/convertors/LoggingMoshiConverter.kt new file mode 100644 index 0000000..9cc548a --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/convertors/LoggingMoshiConverter.kt @@ -0,0 +1,34 @@ +package com.coder.toolbox.sdk.convertors + +import com.coder.toolbox.CoderToolboxContext +import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody +import retrofit2.Converter + +class LoggingMoshiConverter( + private val context: CoderToolboxContext, + private val delegate: Converter +) : Converter { + + override fun convert(value: ResponseBody): Any? { + val bodyString = value.string() + + return try { + // Parse with Moshi + delegate.convert(bodyString.toResponseBody(value.contentType())) + } catch (e: Exception) { + // Log the raw content that failed to parse + context.logger.error( + """ + |Moshi parsing failed: + |Content-Type: ${value.contentType()} + |Content: $bodyString + |Error: ${e.message} + """.trimMargin() + ) + + // Re-throw so the onFailure callback still gets called + throw e + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/sdk/interceptors/LoggingInterceptor.kt b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/LoggingInterceptor.kt new file mode 100644 index 0000000..4bbb1b9 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/LoggingInterceptor.kt @@ -0,0 +1,120 @@ +package com.coder.toolbox.sdk.interceptors + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.settings.HttpLoggingVerbosity +import okhttp3.Headers +import okhttp3.Interceptor +import okhttp3.MediaType +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.Response +import okhttp3.ResponseBody +import okio.Buffer +import java.nio.charset.StandardCharsets + +private val SENSITIVE_HEADERS = setOf("Coder-Session-Token", "Proxy-Authorization") + +class LoggingInterceptor(private val context: CoderToolboxContext) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val logLevel = context.settingsStore.httpClientLogLevel + if (logLevel == HttpLoggingVerbosity.NONE) { + return chain.proceed(chain.request()) + } + + val request = chain.request() + logRequest(request, logLevel) + + val response = chain.proceed(request) + logResponse(response, request, logLevel) + + return response + } + + private fun logRequest(request: Request, logLevel: HttpLoggingVerbosity) { + val log = buildString { + append("request --> ${request.method} ${request.url}") + + if (logLevel >= HttpLoggingVerbosity.HEADERS) { + append("\n${request.headers.sanitized()}") + } + + if (logLevel == HttpLoggingVerbosity.BODY) { + request.body?.let { body -> + append("\n${body.toPrintableString()}") + } + } + } + + context.logger.info(log) + } + + private fun logResponse(response: Response, request: Request, logLevel: HttpLoggingVerbosity) { + val log = buildString { + append("response <-- ${response.code} ${response.message} ${request.url}") + + if (logLevel >= HttpLoggingVerbosity.HEADERS) { + append("\n${response.headers.sanitized()}") + } + + if (logLevel == HttpLoggingVerbosity.BODY) { + response.body?.let { body -> + append("\n${body.toPrintableString()}") + } + } + } + + context.logger.info(log) + } +} + +// Extension functions for cleaner code +private fun Headers.sanitized(): String = buildString { + this@sanitized.forEach { (name, value) -> + val displayValue = if (name in SENSITIVE_HEADERS) "" else value + append("$name: $displayValue\n") + } +} + +private fun RequestBody.toPrintableString(): String { + if (!contentType().isPrintable()) { + return "[Binary body: ${contentLength().formatBytes()}, ${contentType()}]" + } + + return try { + val buffer = Buffer() + writeTo(buffer) + buffer.readString(contentType()?.charset() ?: StandardCharsets.UTF_8) + } catch (e: Exception) { + "[Error reading body: ${e.message}]" + } +} + +private fun ResponseBody.toPrintableString(): String { + if (!contentType().isPrintable()) { + return "[Binary body: ${contentLength().formatBytes()}, ${contentType()}]" + } + + return try { + val source = source() + source.request(Long.MAX_VALUE) + source.buffer.clone().readString(contentType()?.charset() ?: StandardCharsets.UTF_8) + } catch (e: Exception) { + "[Error reading body: ${e.message}]" + } +} + +private fun MediaType?.isPrintable(): Boolean = when { + this == null -> false + type == "text" -> true + subtype == "json" || subtype.endsWith("+json") -> true + else -> false +} + +private fun Long.formatBytes(): String = when { + this < 0 -> "unknown" + this < 1024 -> "${this}B" + this < 1024 * 1024 -> "${this / 1024}KB" + this < 1024 * 1024 * 1024 -> "${this / (1024 * 1024)}MB" + else -> "${this / (1024 * 1024 * 1024)}GB" +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt index 0775a63..0000ea6 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -38,6 +38,11 @@ interface ReadOnlyCoderSettings { */ val fallbackOnCoderForSignatures: SignatureFallbackStrategy + /** + * Controls the logging for the rest client. + */ + val httpClientLogLevel: HttpLoggingVerbosity + /** * Default CLI binary name based on OS and architecture */ @@ -216,4 +221,32 @@ enum class SignatureFallbackStrategy { else -> NOT_CONFIGURED } } +} + +enum class HttpLoggingVerbosity { + NONE, + + /** + * Logs URL, method, and status + */ + BASIC, + + /** + * Logs BASIC + sanitized headers + */ + HEADERS, + + /** + * Logs HEADERS + body content + */ + BODY; + + companion object { + fun fromValue(value: String?): HttpLoggingVerbosity = when (value?.lowercase(getDefault())) { + "basic" -> BASIC + "headers" -> HEADERS + "body" -> BODY + else -> NONE + } + } } \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index 82b6e80..f770da8 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -1,6 +1,7 @@ package com.coder.toolbox.store import com.coder.toolbox.settings.Environment +import com.coder.toolbox.settings.HttpLoggingVerbosity import com.coder.toolbox.settings.ReadOnlyCoderSettings import com.coder.toolbox.settings.ReadOnlyTLSSettings import com.coder.toolbox.settings.SignatureFallbackStrategy @@ -42,6 +43,8 @@ class CoderSettingsStore( get() = store[DISABLE_SIGNATURE_VALIDATION]?.toBooleanStrictOrNull() ?: false override val fallbackOnCoderForSignatures: SignatureFallbackStrategy get() = SignatureFallbackStrategy.fromValue(store[FALLBACK_ON_CODER_FOR_SIGNATURES]) + override val httpClientLogLevel: HttpLoggingVerbosity + get() = HttpLoggingVerbosity.fromValue(store[HTTP_CLIENT_LOG_LEVEL]) override val defaultCliBinaryNameByOsAndArch: String get() = getCoderCLIForOS(getOS(), getArch()) override val binaryName: String get() = store[BINARY_NAME] ?: getCoderCLIForOS(getOS(), getArch()) override val defaultSignatureNameByOsAndArch: String get() = getCoderSignatureForOS(getOS(), getArch()) @@ -179,6 +182,11 @@ class CoderSettingsStore( } } + fun updateHttpClientLogLevel(level: HttpLoggingVerbosity?) { + if (level == null) return + store[HTTP_CLIENT_LOG_LEVEL] = level.toString() + } + fun updateBinaryDirectoryFallback(shouldEnableBinDirFallback: Boolean) { store[ENABLE_BINARY_DIR_FALLBACK] = shouldEnableBinDirFallback.toString() } diff --git a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt index 1626ce1..5f8f5af 100644 --- a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt +++ b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt @@ -14,6 +14,8 @@ internal const val DISABLE_SIGNATURE_VALIDATION = "disableSignatureValidation" internal const val FALLBACK_ON_CODER_FOR_SIGNATURES = "signatureFallbackStrategy" +internal const val HTTP_CLIENT_LOG_LEVEL = "httpClientLogLevel" + internal const val BINARY_NAME = "binaryName" internal const val DATA_DIRECTORY = "dataDirectory" diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index d27a1c0..e937600 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -1,8 +1,14 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext +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.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.TextField import com.jetbrains.toolbox.api.ui.components.TextType import com.jetbrains.toolbox.api.ui.components.UiField @@ -44,6 +50,18 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf settings.fallbackOnCoderForSignatures.isAllowed(), context.i18n.ptrl("Verify binary signature using releases.coder.com when CLI signatures are not available from the deployment") ) + + private val httpLoggingField = ComboBoxField( + ComboBoxField.Label(context.i18n.ptrl("HTTP logging level:")), + settings.httpClientLogLevel, + listOf( + LabelledValue(context.i18n.ptrl("None"), NONE, listOf("" to "No logs")), + LabelledValue(context.i18n.ptrl("Basic"), BASIC, listOf("" to "Method, URL and status")), + LabelledValue(context.i18n.ptrl("Header"), HEADERS, listOf("" to " Basic + sanitized headers")), + LabelledValue(context.i18n.ptrl("Body"), BODY, listOf("" to "Headers + body content")), + ) + ) + private val enableBinaryDirectoryFallbackField = CheckboxField( settings.enableBinaryDirectoryFallback, @@ -80,6 +98,7 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf enableBinaryDirectoryFallbackField, disableSignatureVerificationField, signatureFallbackStrategyField, + httpLoggingField, dataDirectoryField, headerCommandField, tlsCertPathField, @@ -103,6 +122,7 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf context.settingsStore.updateEnableDownloads(enableDownloadsField.checkedState.value) context.settingsStore.updateDisableSignatureVerification(disableSignatureVerificationField.checkedState.value) context.settingsStore.updateSignatureFallbackStrategy(signatureFallbackStrategyField.checkedState.value) + context.settingsStore.updateHttpClientLogLevel(httpLoggingField.selectedValueState.value) context.settingsStore.updateBinaryDirectoryFallback(enableBinaryDirectoryFallbackField.checkedState.value) context.settingsStore.updateHeaderCommand(headerCommandField.contentState.value) context.settingsStore.updateCertPath(tlsCertPathField.contentState.value) diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index 30c4484..8aabe3f 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -167,4 +167,16 @@ msgid "Run anyway" msgstr "" msgid "Disable Coder CLI signature verification" +msgstr "" + +msgid "None" +msgstr "" + +msgid "Basic" +msgstr "" + +msgid "Headers" +msgstr "" + +msgid "Body" msgstr "" \ No newline at end of file From 6d509d61a1bcfbd13ce0a619f6b1c4f9d73dd541 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 11 Aug 2025 20:31:40 +0300 Subject: [PATCH 45/93] fix: remote ide no longer reconnects after plugin upgrade (#167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the plugin is upgraded while JBClient is connected to a remote dev server via the Coder SSH proxy/tunnel, the upgrade process kills and re-establishes the SSH connection. However, JBClient/Toolbox fails to detect the restored connection and reports "Toolbox: Target environment com.coder.toolbox:bobiverse-bob.dev not found" error. While digging into the Toolbox bytecode—specifically `ClientOverSshTunnelConnector` — I realized the issue likely stems from an incorrect equals implementation in our custom SSH connection info object. In short, when a plugin upgrade terminates the SSH tunnel, the connector’s monitoring logic correctly detects the lost connection and waits. But when the SSH connection is re-established, the monitoring logic fails to recognize it as a valid replacement, because equals is still using the default `Object#equals` rather than a proper value-based implementation. Unfortunately, I wasn’t able to properly test this—specifically, upgrading from a version without the fix to one that includes it—because all Toolbox marketplace feeds are signed, preventing us from using a tool like mitmproxy to serve a locally modified plugin version. Given that, I propose releasing the change first and then performing the upgrade test to confirm the fix. - resolves #61 --- CHANGELOG.md | 4 ++ .../coder/toolbox/views/EnvironmentView.kt | 61 ++++++++++++++----- 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5e89de..faf43dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ - URL validation is stricter in the connection screen and URI protocol handler - support for verbose logging a sanitized version of the REST API request and responses +### Fixed + +- remote IDE reconnects automatically after plugin upgrade + ## 0.6.0 - 2025-07-25 ### Changed diff --git a/src/main/kotlin/com/coder/toolbox/views/EnvironmentView.kt b/src/main/kotlin/com/coder/toolbox/views/EnvironmentView.kt index 020ed8a..3353fe4 100644 --- a/src/main/kotlin/com/coder/toolbox/views/EnvironmentView.kt +++ b/src/main/kotlin/com/coder/toolbox/views/EnvironmentView.kt @@ -21,20 +21,51 @@ class EnvironmentView( private val workspace: Workspace, private val agent: WorkspaceAgent, ) : SshEnvironmentContentsView { - override suspend fun getConnectionInfo(): SshConnectionInfo = object : SshConnectionInfo { - /** - * The host name generated by the cli manager for this workspace. - */ - override val host: String = cli.getHostname(url, workspace, agent) - - /** - * The port is ignored by the Coder proxy command. - */ - override val port: Int = 22 - - /** - * The username is ignored by the Coder proxy command. - */ - override val userName: String? = null + override suspend fun getConnectionInfo(): SshConnectionInfo = WorkspaceSshConnectionInfo(url, cli, workspace, agent) +} + +private class WorkspaceSshConnectionInfo( + url: URL, + cli: CoderCLIManager, + private val workspace: Workspace, + private val agent: WorkspaceAgent, +) : SshConnectionInfo { + /** + * The host name generated by the cli manager for this workspace. + */ + override val host: String = cli.getHostname(url, workspace, agent) + + /** + * The port is ignored by the Coder proxy command. + */ + override val port: Int = 22 + + /** + * The username is ignored by the Coder proxy command. + */ + override val userName: String? = null + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as WorkspaceSshConnectionInfo + + if (port != other.port) return false + if (workspace.name != other.workspace.name) return false + if (agent.name != other.agent.name) return false + if (host != other.host) return false + + return true + } + + override fun hashCode(): Int { + var result = port + result = 31 * result + workspace.name.hashCode() + result = 31 * result + agent.name.hashCode() + result = 31 * result + host.hashCode() + return result } + + } \ No newline at end of file From 8f8822e09d02c8d52f0e839b3ffe923597ad86d3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 20:39:43 +0300 Subject: [PATCH 46/93] Changelog update - `v0.6.1` (#172) Current pull request contains patched `CHANGELOG.md` file for the `v0.6.1` version. Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index faf43dc..5b5507a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.6.1 - 2025-08-11 + ### Added - support for skipping CLI signature verification From fad5a3d99df228f89bc7038ca21d015db459cc19 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 20:40:23 +0300 Subject: [PATCH 47/93] chore: bump actions/download-artifact from 4 to 5 (#168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 5.
Release notes

Sourced from actions/download-artifact's releases.

v5.0.0

What's Changed

v5.0.0

🚨 Breaking Change

This release fixes an inconsistency in path behavior for single artifact downloads by ID. If you're downloading single artifacts by ID, the output path may change.

What Changed

Previously, single artifact downloads behaved differently depending on how you specified the artifact:

  • By name: name: my-artifact → extracted to path/ (direct)
  • By ID: artifact-ids: 12345 → extracted to path/my-artifact/ (nested)

Now both methods are consistent:

  • By name: name: my-artifact → extracted to path/ (unchanged)
  • By ID: artifact-ids: 12345 → extracted to path/ (fixed - now direct)

Migration Guide

✅ No Action Needed If:
  • You download artifacts by name
  • You download multiple artifacts by ID
  • You already use merge-multiple: true as a workaround
⚠️ Action Required If:

You download single artifacts by ID and your workflows expect the nested directory structure.

Before v5 (nested structure):

- uses: actions/download-artifact@v4
  with:
    artifact-ids: 12345
    path: dist
# Files were in: dist/my-artifact/

Where my-artifact is the name of the artifact you previously uploaded

To maintain old behavior (if needed):

</tr></table>

... (truncated)

Commits
  • 634f93c Merge pull request #416 from actions/single-artifact-id-download-path
  • b19ff43 refactor: resolve download path correctly in artifact download tests (mainly ...
  • e262cbe bundle dist
  • bff23f9 update docs
  • fff8c14 fix download path logic when downloading a single artifact by id
  • 448e3f8 Merge pull request #407 from actions/nebuk89-patch-1
  • 47225c4 Update README.md
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/download-artifact&package-manager=github_actions&previous-version=4&new-version=5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8a55c79..de884ff 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -113,7 +113,7 @@ jobs: | xargs -I '{}' gh api -X DELETE repos/${{ github.repository }}/releases/{} - name: Download Build Artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: zip-artifacts path: artifacts/ @@ -121,7 +121,7 @@ jobs: run: ls -R artifacts/ - name: Download Release Notes - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: release-notes path: notes/ From 22b433b1f5180e9eafa98f7f6d5458b160f7ff0a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 20:40:44 +0300 Subject: [PATCH 48/93] chore: bump io.mockk:mockk from 1.14.4 to 1.14.5 (#169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [io.mockk:mockk](https://github.com/mockk/mockk) from 1.14.4 to 1.14.5.
Release notes

Sourced from io.mockk:mockk's releases.

1.14.5

What's Changed

New Contributors

Full Changelog: https://github.com/mockk/mockk/compare/1.14.4...1.14.5

Commits
  • 4982eda Version bump
  • 290312e Merge pull request #1413 from Komdosh/master
  • dadf4ec Merge pull request #1399 from Minseok-2001/bdd
  • ef31e7d docs: Remove colon from BDD style section in README
  • ea45e43 docs: Add BDD style usage and aliases to README
  • cd08da0 fix: downgrade byte-buddy to 1.5.11 to be compatible with current android bui...
  • ab602a9 test: Add Android instrumentation tests for BDD API
  • 4880451 chore: Add AndroidManifest.xml for mockk-bdd-android module
  • 50c716b chore: Add mockk-bdd-android.api file
  • dd2f484 Clear a warning on android builds
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=io.mockk:mockk&package-manager=gradle&previous-version=1.14.4&new-version=1.14.5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 28820b1..bed4b16 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ retrofit = "3.0.0" changelog = "2.2.1" gettext = "0.7.0" plugin-structure = "3.310" -mockk = "1.14.4" +mockk = "1.14.5" detekt = "1.23.8" bouncycastle = "1.81" From 88efd5a3b286a1331e591870298d6e03f6161684 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 20:41:18 +0300 Subject: [PATCH 49/93] chore: bump org.jetbrains.intellij:plugin-repository-rest-client from 2.0.47 to 2.0.49 (#170) Bumps [org.jetbrains.intellij:plugin-repository-rest-client](https://github.com/JetBrains/plugin-repository-rest-client) from 2.0.47 to 2.0.49.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.jetbrains.intellij:plugin-repository-rest-client&package-manager=gradle&previous-version=2.0.47&new-version=2.0.49)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bed4b16..26032a3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ coroutines = "1.10.2" serialization = "1.8.1" okhttp = "4.12.0" dependency-license-report = "2.9" -marketplace-client = "2.0.47" +marketplace-client = "2.0.49" gradle-wrapper = "0.15.0" exec = "1.12" moshi = "1.15.2" From 3b88d155795e99ad73908b09e92b10c0be6b5bd7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 20:46:43 +0300 Subject: [PATCH 50/93] chore: bump org.jetbrains.changelog from 2.2.1 to 2.4.0 (#171) Bumps org.jetbrains.changelog from 2.2.1 to 2.4.0. [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.jetbrains.changelog&package-manager=gradle&previous-version=2.2.1&new-version=2.4.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 26032a3..a3d0755 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ exec = "1.12" moshi = "1.15.2" ksp = "2.1.20-2.0.1" retrofit = "3.0.0" -changelog = "2.2.1" +changelog = "2.4.0" gettext = "0.7.0" plugin-structure = "3.310" mockk = "1.14.5" From a923f58675943c3bef7f3fa5fd6e08e7a0f30dbb Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 14 Aug 2025 22:17:55 +0300 Subject: [PATCH 51/93] fix: enforce Content-Type to accept only binary responses (#174) Add validation for CLI downloads that ensures the Content-Type header is indicating a binary stream (`application/octet-stream`), including common variants with parameters. This prevents saving unexpected HTML or other non-binary responses (e.g., from frontend dev servers on :8080) as binaries, improving reliability and providing clearer error feedback. --- CHANGELOG.md | 4 +++ gradle.properties | 2 +- .../cli/downloader/CoderDownloadService.kt | 7 ++++ .../coder/toolbox/cli/CoderCLIManagerTest.kt | 33 ++++++++++--------- 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b5507a..1dc0b6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Changed + +- content-type is now enforced when downloading the CLI to accept only binary responses + ## 0.6.1 - 2025-08-11 ### Added diff --git a/gradle.properties b/gradle.properties index b31ebe6..846b191 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.6.1 +version=0.6.2 group=com.coder.toolbox name=coder-toolbox \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt b/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt index 03e3a4d..574184c 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt @@ -51,6 +51,13 @@ class CoderDownloadService( return when (response.code()) { HTTP_OK -> { + val contentType = response.headers()["Content-Type"]?.lowercase() + if (contentType?.startsWith("application/octet-stream") != true) { + throw ResponseException( + "Invalid content type '$contentType' when downloading CLI from $remoteBinaryURL. Expected application/octet-stream.", + response.code() + ) + } context.logger.info("Downloading binary to temporary $cliTempDst") response.saveToDisk(cliTempDst, showTextProgress, buildVersion)?.makeExecutable() DownloadResult.Downloaded(remoteBinaryURL, cliTempDst) diff --git a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt index 4ef1235..b9deab7 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -137,6 +137,7 @@ internal class CoderCLIManagerTest { } val body = response.toByteArray() + exchange.responseHeaders["Content-Type"] = "application/octet-stream" exchange.sendResponseHeaders(code, if (code == HttpURLConnection.HTTP_OK) body.size.toLong() else -1) exchange.responseBody.write(body) exchange.close() @@ -197,11 +198,11 @@ internal class CoderCLIManagerTest { val ccm = CoderCLIManager( context.copy( settingsStore = CoderSettingsStore( - pluginTestSettingsStore( - DATA_DIRECTORY to tmpdir.resolve("cli-dir-fail-to-write").toString(), - ), - Environment(), - context.logger + pluginTestSettingsStore( + DATA_DIRECTORY to tmpdir.resolve("cli-dir-fail-to-write").toString(), + ), + Environment(), + context.logger ) ), url @@ -307,11 +308,11 @@ internal class CoderCLIManagerTest { val ccm = CoderCLIManager( context.copy( settingsStore = CoderSettingsStore( - pluginTestSettingsStore( - DATA_DIRECTORY to tmpdir.resolve("does-not-exist").toString(), - ), - Environment(), - context.logger + pluginTestSettingsStore( + DATA_DIRECTORY to tmpdir.resolve("does-not-exist").toString(), + ), + Environment(), + context.logger ) ), URL("https://foo") @@ -329,12 +330,12 @@ internal class CoderCLIManagerTest { val ccm = CoderCLIManager( context.copy( settingsStore = CoderSettingsStore( - pluginTestSettingsStore( - FALLBACK_ON_CODER_FOR_SIGNATURES to "allow", - DATA_DIRECTORY to tmpdir.resolve("overwrite-cli").toString(), - ), - Environment(), - context.logger + pluginTestSettingsStore( + FALLBACK_ON_CODER_FOR_SIGNATURES to "allow", + DATA_DIRECTORY to tmpdir.resolve("overwrite-cli").toString(), + ), + Environment(), + context.logger ) ), url From fc9705962391df356a34ea583804e8e7cc8ba31d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 15 Aug 2025 00:56:52 +0300 Subject: [PATCH 52/93] Changelog update - `v0.6.2` (#175) Current pull request contains patched `CHANGELOG.md` file for the `v0.6.2` version. Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dc0b6a..574103d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.6.2 - 2025-08-14 + ### Changed - content-type is now enforced when downloading the CLI to accept only binary responses From 6ab431e26b3dfb60ea832065731fc23020b4e2ed Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Sun, 17 Aug 2025 18:35:34 +0300 Subject: [PATCH 53/93] impl: poll workspaces when Toolbox screen becomes visible (#176) Some users complained that Coder Toolbox has a noticeable delay in rendering the workspaces status compared with the web Dashboard, even though they have the same polling frequency, which is every 5 seconds. However, the web Dashboard also reacts and when the tab receives focus from the user which improves the experience. This PR tries to implement the same behavior, when Coder Toolbox screen becomes visible the polling is triggered immediately which should result in a status update. --- CHANGELOG.md | 4 ++++ gradle.properties | 2 +- .../com/coder/toolbox/CoderRemoteProvider.kt | 16 +++++++++++++--- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 574103d..abee5fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Changed + +- workspaces status is now refresh every time Coder Toolbox becomes visible + ## 0.6.2 - 2025-08-14 ### Changed diff --git a/gradle.properties b/gradle.properties index 846b191..b10e0c2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.6.2 +version=0.6.3 group=com.coder.toolbox name=coder-toolbox \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 2e5d557..596255e 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -54,8 +54,8 @@ class CoderRemoteProvider( private val settings = context.settingsStore.readOnly() - // Create our services from the Toolbox ones. private val triggerSshConfig = Channel(Channel.CONFLATED) + private val triggerProviderVisible = Channel(Channel.CONFLATED) private val settingsPage: CoderSettingsPage = CoderSettingsPage(context, triggerSshConfig) private val dialogUi = DialogUi(context) @@ -177,14 +177,19 @@ class CoderRemoteProvider( select { onTimeout(POLL_INTERVAL) { - context.logger.trace("workspace poller waked up by the $POLL_INTERVAL timeout") + context.logger.debug("workspace poller waked up by the $POLL_INTERVAL timeout") } triggerSshConfig.onReceive { shouldTrigger -> if (shouldTrigger) { - context.logger.trace("workspace poller waked up because it should reconfigure the ssh configurations") + context.logger.debug("workspace poller waked up because it should reconfigure the ssh configurations") cli.configSsh(lastEnvironments.map { it.asPairOfWorkspaceAndAgent() }.toSet()) } } + triggerProviderVisible.onReceive { isCoderProviderVisible -> + if (isCoderProviderVisible) { + context.logger.debug("workspace poller waked up by Coder Toolbox which is currently visible, fetching latest workspace statuses") + } + } } lastPollTime = TimeSource.Monotonic.markNow() } @@ -293,6 +298,11 @@ class CoderRemoteProvider( visibilityState.update { visibility } + if (visibility.providerVisible) { + context.cs.launch { + triggerProviderVisible.send(true) + } + } } /** From acd057859ffa94656feb3cfa455971a043cb41fc Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 19 Aug 2025 23:32:58 +0300 Subject: [PATCH 54/93] fix: support for downloading the CLI when proxy is configured (#177) Until this commit, the CLI download manager relied on a separately configured HTTP client that lacked proxy support, unlike the REST client which was refactored and modularized. Now we have the same support for proxy and a proper user agent and custom logging interceptor. --- CHANGELOG.md | 4 + README.md | 21 +++++ .../com/coder/toolbox/cli/CoderCLIManager.kt | 23 +++-- .../toolbox/sdk/CoderHttpClientBuilder.kt | 56 ++++++++++++ .../com/coder/toolbox/sdk/CoderRestClient.kt | 85 +++---------------- .../toolbox/sdk/interceptors/Interceptors.kt | 64 ++++++++++++++ .../coder/toolbox/cli/CoderCLIManagerTest.kt | 25 ++++-- src/test/resources/extension.json | 4 + 8 files changed, 192 insertions(+), 90 deletions(-) create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/interceptors/Interceptors.kt create mode 100644 src/test/resources/extension.json diff --git a/CHANGELOG.md b/CHANGELOG.md index abee5fe..e87ca97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - workspaces status is now refresh every time Coder Toolbox becomes visible +### Fixed + +- support for downloading the CLI when proxy is configured + ## 0.6.2 - 2025-08-14 ### Changed diff --git a/README.md b/README.md index 3e5da52..74e9cd5 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,27 @@ mitmweb --ssl-insecure --set stream_large_bodies="10m" --mode socks5 > in: https://youtrack.jetbrains.com/issue/TBX-14532/Missing-proxy-authentication-settings#focus=Comments-27-12265861.0-0 +### Mitmproxy returns 502 Bad Gateway to the client + +When running traffic through mitmproxy, you may encounter 502 Bad Gateway errors that mention HTTP/2 protocol error: * +*Received header value surrounded by whitespace**. +This happens because some upstream servers (including dev.coder.com) send back headers such as Content-Security-Policy +with leading or trailing spaces. +While browsers and many HTTP clients accept these headers, mitmproxy enforces the stricter HTTP/2 and HTTP/1.1 RFCs, +which forbid whitespace around header values. +As a result, mitmproxy rejects the response and surfaces a 502 to the client. + +The workaround is to disable HTTP/2 in mitmproxy and force HTTP/1.1 on both the client and upstream sides. This avoids +the strict header validation path and allows +mitmproxy to pass responses through unchanged. You can do this by starting mitmproxy with: + +```bash +mitmproxy --set http2=false --set upstream_http_version=HTTP/1.1 +``` + +This ensures coder toolbox http client ↔ mitmproxy ↔ server connections all run over HTTP/1.1, preventing the whitespace +error. + ## Debugging and Reporting issues Enabling debug logging is essential for diagnosing issues with the Toolbox plugin, especially when SSH diff --git a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt index 582a85b..67947c3 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt @@ -12,14 +12,14 @@ import com.coder.toolbox.cli.gpg.GPGVerifier import com.coder.toolbox.cli.gpg.VerificationResult import com.coder.toolbox.cli.gpg.VerificationResult.Failed import com.coder.toolbox.cli.gpg.VerificationResult.Invalid +import com.coder.toolbox.plugin.PluginManager +import com.coder.toolbox.sdk.CoderHttpClientBuilder +import com.coder.toolbox.sdk.interceptors.Interceptors import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent import com.coder.toolbox.settings.SignatureFallbackStrategy.ALLOW -import com.coder.toolbox.util.CoderHostnameVerifier import com.coder.toolbox.util.InvalidVersionException import com.coder.toolbox.util.SemVer -import com.coder.toolbox.util.coderSocketFactory -import com.coder.toolbox.util.coderTrustManagers import com.coder.toolbox.util.escape import com.coder.toolbox.util.escapeSubcommand import com.coder.toolbox.util.safeHost @@ -29,7 +29,6 @@ import com.squareup.moshi.JsonDataException import com.squareup.moshi.Moshi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient import org.zeroturnaround.exec.ProcessExecutor import retrofit2.Retrofit import java.io.EOFException @@ -37,7 +36,6 @@ import java.io.FileNotFoundException import java.net.URL import java.nio.file.Files import java.nio.file.Path -import javax.net.ssl.X509TrustManager /** * Version output from the CLI's version command. @@ -148,13 +146,14 @@ class CoderCLIManager( val coderConfigPath: Path = context.settingsStore.dataDir(deploymentURL).resolve("config") private fun createDownloadService(): CoderDownloadService { - val okHttpClient = OkHttpClient.Builder() - .sslSocketFactory( - coderSocketFactory(context.settingsStore.tls), - coderTrustManagers(context.settingsStore.tls.caPath)[0] as X509TrustManager - ) - .hostnameVerifier(CoderHostnameVerifier(context.settingsStore.tls.altHostname)) - .build() + val interceptors = buildList { + add((Interceptors.userAgent(PluginManager.pluginInfo.version))) + add(Interceptors.logging(context)) + } + val okHttpClient = CoderHttpClientBuilder.build( + context, + interceptors + ) val retrofit = Retrofit.Builder() .baseUrl(deploymentURL.toString()) diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt new file mode 100644 index 0000000..f80d60c --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt @@ -0,0 +1,56 @@ +package com.coder.toolbox.sdk + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.util.CoderHostnameVerifier +import com.coder.toolbox.util.coderSocketFactory +import com.coder.toolbox.util.coderTrustManagers +import com.jetbrains.toolbox.api.remoteDev.connection.ProxyAuth +import okhttp3.Credentials +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import javax.net.ssl.X509TrustManager + +object CoderHttpClientBuilder { + fun build( + context: CoderToolboxContext, + interceptors: List + ): OkHttpClient { + val settings = context.settingsStore.readOnly() + + val socketFactory = coderSocketFactory(settings.tls) + val trustManagers = coderTrustManagers(settings.tls.caPath) + var builder = OkHttpClient.Builder() + + if (context.proxySettings.getProxy() != null) { + context.logger.info("proxy: ${context.proxySettings.getProxy()}") + builder.proxy(context.proxySettings.getProxy()) + } else if (context.proxySettings.getProxySelector() != null) { + context.logger.info("proxy selector: ${context.proxySettings.getProxySelector()}") + builder.proxySelector(context.proxySettings.getProxySelector()!!) + } + + // Note: This handles only HTTP/HTTPS proxy authentication. + // SOCKS5 proxy authentication is currently not supported due to limitations described in: + // https://youtrack.jetbrains.com/issue/TBX-14532/Missing-proxy-authentication-settings#focus=Comments-27-12265861.0-0 + builder.proxyAuthenticator { _, response -> + val proxyAuth = context.proxySettings.getProxyAuth() + if (proxyAuth == null || proxyAuth !is ProxyAuth.Basic) { + return@proxyAuthenticator null + } + val credentials = Credentials.basic(proxyAuth.username, proxyAuth.password) + response.request.newBuilder() + .header("Proxy-Authorization", credentials) + .build() + } + + builder.sslSocketFactory(socketFactory, trustManagers[0] as X509TrustManager) + .hostnameVerifier(CoderHostnameVerifier(settings.tls.altHostname)) + .retryOnConnectionFailure(true) + + interceptors.forEach { interceptor -> + builder.addInterceptor(interceptor) + + } + return builder.build() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 9b2e7b3..803472c 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -7,7 +7,7 @@ import com.coder.toolbox.sdk.convertors.LoggingConverterFactory import com.coder.toolbox.sdk.convertors.OSConverter import com.coder.toolbox.sdk.convertors.UUIDConverter import com.coder.toolbox.sdk.ex.APIResponseException -import com.coder.toolbox.sdk.interceptors.LoggingInterceptor +import com.coder.toolbox.sdk.interceptors.Interceptors import com.coder.toolbox.sdk.v2.CoderV2RestFacade import com.coder.toolbox.sdk.v2.models.ApiErrorResponse import com.coder.toolbox.sdk.v2.models.BuildInfo @@ -21,15 +21,7 @@ import com.coder.toolbox.sdk.v2.models.WorkspaceBuildReason import com.coder.toolbox.sdk.v2.models.WorkspaceResource import com.coder.toolbox.sdk.v2.models.WorkspaceStatus import com.coder.toolbox.sdk.v2.models.WorkspaceTransition -import com.coder.toolbox.util.CoderHostnameVerifier -import com.coder.toolbox.util.coderSocketFactory -import com.coder.toolbox.util.coderTrustManagers -import com.coder.toolbox.util.getArch -import com.coder.toolbox.util.getHeaders -import com.coder.toolbox.util.getOS -import com.jetbrains.toolbox.api.remoteDev.connection.ProxyAuth import com.squareup.moshi.Moshi -import okhttp3.Credentials import okhttp3.OkHttpClient import retrofit2.Response import retrofit2.Retrofit @@ -37,7 +29,6 @@ import retrofit2.converter.moshi.MoshiConverterFactory import java.net.HttpURLConnection import java.net.URL import java.util.UUID -import javax.net.ssl.X509TrustManager /** * An HTTP client that can make requests to the Coder API. @@ -50,7 +41,6 @@ open class CoderRestClient( val token: String?, private val pluginVersion: String = "development", ) { - private val settings = context.settingsStore.readOnly() private lateinit var moshi: Moshi private lateinit var httpClient: OkHttpClient private lateinit var retroRestClient: CoderV2RestFacade @@ -70,69 +60,22 @@ open class CoderRestClient( .add(OSConverter()) .add(UUIDConverter()) .build() - - val socketFactory = coderSocketFactory(settings.tls) - val trustManagers = coderTrustManagers(settings.tls.caPath) - var builder = OkHttpClient.Builder() - - if (context.proxySettings.getProxy() != null) { - context.logger.info("proxy: ${context.proxySettings.getProxy()}") - builder.proxy(context.proxySettings.getProxy()) - } else if (context.proxySettings.getProxySelector() != null) { - context.logger.info("proxy selector: ${context.proxySettings.getProxySelector()}") - builder.proxySelector(context.proxySettings.getProxySelector()!!) - } - - // Note: This handles only HTTP/HTTPS proxy authentication. - // SOCKS5 proxy authentication is currently not supported due to limitations described in: - // https://youtrack.jetbrains.com/issue/TBX-14532/Missing-proxy-authentication-settings#focus=Comments-27-12265861.0-0 - builder.proxyAuthenticator { _, response -> - val proxyAuth = context.proxySettings.getProxyAuth() - if (proxyAuth == null || proxyAuth !is ProxyAuth.Basic) { - return@proxyAuthenticator null - } - val credentials = Credentials.basic(proxyAuth.username, proxyAuth.password) - response.request.newBuilder() - .header("Proxy-Authorization", credentials) - .build() - } - - if (context.settingsStore.requireTokenAuth) { - if (token.isNullOrBlank()) { - throw IllegalStateException("Token is required for $url deployment") - } - builder = builder.addInterceptor { - it.proceed( - it.request().newBuilder().addHeader("Coder-Session-Token", token).build() - ) + val interceptors = buildList { + if (context.settingsStore.requireTokenAuth) { + if (token.isNullOrBlank()) { + throw IllegalStateException("Token is required for $url deployment") + } + add(Interceptors.tokenAuth(token)) } + add((Interceptors.userAgent(pluginVersion))) + add(Interceptors.externalHeaders(context, url)) + add(Interceptors.logging(context)) } - httpClient = - builder - .sslSocketFactory(socketFactory, trustManagers[0] as X509TrustManager) - .hostnameVerifier(CoderHostnameVerifier(settings.tls.altHostname)) - .retryOnConnectionFailure(true) - .addInterceptor { - it.proceed( - it.request().newBuilder().addHeader( - "User-Agent", - "Coder Toolbox/$pluginVersion (${getOS()}; ${getArch()})", - ).build(), - ) - } - .addInterceptor { - var request = it.request() - val headers = getHeaders(url, settings.headerCommand) - if (headers.isNotEmpty()) { - val reqBuilder = request.newBuilder() - headers.forEach { h -> reqBuilder.addHeader(h.key, h.value) } - request = reqBuilder.build() - } - it.proceed(request) - } - .addInterceptor(LoggingInterceptor(context)) - .build() + httpClient = CoderHttpClientBuilder.build( + context, + interceptors + ) retroRestClient = Retrofit.Builder().baseUrl(url.toString()).client(httpClient) diff --git a/src/main/kotlin/com/coder/toolbox/sdk/interceptors/Interceptors.kt b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/Interceptors.kt new file mode 100644 index 0000000..9c9f3ee --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/Interceptors.kt @@ -0,0 +1,64 @@ +package com.coder.toolbox.sdk.interceptors + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.util.getArch +import com.coder.toolbox.util.getHeaders +import com.coder.toolbox.util.getOS +import okhttp3.Interceptor +import java.net.URL + +/** + * Factory of okhttp interceptors + */ +object Interceptors { + + /** + * Creates a token authentication interceptor + */ + fun tokenAuth(token: String): Interceptor { + return Interceptor { chain -> + chain.proceed( + chain.request().newBuilder() + .addHeader("Coder-Session-Token", token) + .build() + ) + } + } + + /** + * Creates a User-Agent header interceptor + */ + fun userAgent(pluginVersion: String): Interceptor { + return Interceptor { chain -> + chain.proceed( + chain.request().newBuilder() + .addHeader("User-Agent", "Coder Toolbox/$pluginVersion (${getOS()}; ${getArch()})") + .build() + ) + } + } + + /** + * Adds headers generated by executing a native command + */ + fun externalHeaders(context: CoderToolboxContext, url: URL): Interceptor { + val settings = context.settingsStore.readOnly() + return Interceptor { chain -> + var request = chain.request() + val headers = getHeaders(url, settings.headerCommand) + if (headers.isNotEmpty()) { + val reqBuilder = request.newBuilder() + headers.forEach { h -> reqBuilder.addHeader(h.key, h.value) } + request = reqBuilder.build() + } + chain.proceed(request) + } + } + + /** + * Creates a logging interceptor + */ + fun logging(context: CoderToolboxContext): Interceptor { + return LoggingInterceptor(context) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt index b9deab7..7f5c831 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -35,6 +35,7 @@ import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper +import com.jetbrains.toolbox.api.remoteDev.connection.ProxyAuth import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette @@ -52,6 +53,8 @@ import org.zeroturnaround.exec.InvalidExitValueException import org.zeroturnaround.exec.ProcessInitException import java.net.HttpURLConnection import java.net.InetSocketAddress +import java.net.Proxy +import java.net.ProxySelector import java.net.URI import java.net.URL import java.nio.file.AccessDeniedException @@ -87,8 +90,17 @@ internal class CoderCLIManagerTest { mockk(relaxed = true) ), mockk(), - mockk() - ) + object : ToolboxProxySettings { + override fun getProxy(): Proxy? = null + override fun getProxySelector(): ProxySelector? = null + override fun getProxyAuth(): ProxyAuth? = null + + override fun addProxyChangeListener(listener: Runnable) { + } + + override fun removeProxyChangeListener(listener: Runnable) { + } + }) @BeforeTest fun setup() { @@ -547,11 +559,10 @@ internal class CoderCLIManagerTest { context.logger, ) - val ccm = - CoderCLIManager( - context.copy(settingsStore = settings), - it.url ?: URI.create("https://test.coder.invalid").toURL() - ) + val ccm = CoderCLIManager( + context.copy(settingsStore = settings), + it.url ?: URI.create("https://test.coder.invalid").toURL() + ) val sshConfigPath = Path.of(settings.sshConfigPath) // Input is the configuration that we start with, if any. diff --git a/src/test/resources/extension.json b/src/test/resources/extension.json new file mode 100644 index 0000000..3f897e2 --- /dev/null +++ b/src/test/resources/extension.json @@ -0,0 +1,4 @@ +{ + "id": "com.coder.toolbox", + "version": "development" +} \ No newline at end of file From 0852d8879d3a49f0285f77d1028b44758d7e5d87 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 26 Aug 2025 00:34:21 +0300 Subject: [PATCH 55/93] impl: report progress while handling URI (#180) Up until now there was no progress while downloading CLI, setting up the cli and the ssh config while handling URIs. This PR reworks the uri handler and the connection screen to be able to reuse the later part in the URI handler. This should improve the experience because the user is no longer left in the dark for a good couple of seconds. --- CHANGELOG.md | 4 + .../com/coder/toolbox/CoderRemoteProvider.kt | 55 ++++++-------- .../com/coder/toolbox/CoderToolboxContext.kt | 5 ++ .../toolbox/util/CoderProtocolHandler.kt | 74 +++++++++++-------- .../toolbox/views/CoderCliSetupWizardPage.kt | 13 +--- .../com/coder/toolbox/views/CoderPage.kt | 7 -- .../com/coder/toolbox/views/ConnectStep.kt | 7 +- .../toolbox/util/CoderProtocolHandlerTest.kt | 5 ++ 8 files changed, 91 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e87ca97..08ac799 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- progress reporting while handling URIs + ### Changed - workspaces status is now refresh every time Coder Toolbox becomes visible diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 596255e..eb9997e 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -7,12 +7,14 @@ 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 +import com.coder.toolbox.util.toURL import com.coder.toolbox.util.waitForTrue import com.coder.toolbox.util.withPath import com.coder.toolbox.views.Action import com.coder.toolbox.views.CoderCliSetupWizardPage import com.coder.toolbox.views.CoderSettingsPage import com.coder.toolbox.views.NewEnvironmentPage +import com.coder.toolbox.views.state.CoderCliSetupContext import com.coder.toolbox.views.state.CoderCliSetupWizardState import com.coder.toolbox.views.state.WizardStep import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon @@ -35,7 +37,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select import java.net.URI -import java.util.UUID import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource @@ -66,19 +67,18 @@ class CoderRemoteProvider( private var firstRun = true private val isInitialized: MutableStateFlow = MutableStateFlow(false) private val coderHeaderPage = NewEnvironmentPage(context.i18n.pnotr(context.deploymentUrl.toString())) - private val linkHandler = CoderProtocolHandler(context, dialogUi, isInitialized) - - override val loadingEnvironmentsDescription: LocalizableString = context.i18n.ptrl("Loading workspaces...") - override val environments: MutableStateFlow>> = MutableStateFlow( - LoadableState.Loading - ) - private val visibilityState = MutableStateFlow( ProviderVisibilityState( applicationVisible = false, providerVisible = false ) ) + private val linkHandler = CoderProtocolHandler(context, dialogUi, settingsPage, visibilityState, isInitialized) + + override val loadingEnvironmentsDescription: LocalizableString = context.i18n.ptrl("Loading workspaces...") + override val environments: MutableStateFlow>> = MutableStateFlow( + LoadableState.Loading + ) private val errorBuffer = mutableListOf() @@ -311,17 +311,8 @@ class CoderRemoteProvider( override suspend fun handleUri(uri: URI) { try { linkHandler.handle( - uri, shouldDoAutoSetup(), - { - coderHeaderPage.isBusyCreatingNewEnvironment.update { - true - } - }, - { - coderHeaderPage.isBusyCreatingNewEnvironment.update { - false - } - } + uri, + shouldDoAutoSetup() ) { restClient, cli -> // stop polling and de-initialize resources close() @@ -337,23 +328,16 @@ class CoderRemoteProvider( isInitialized.waitForTrue() } } catch (ex: Exception) { - context.logger.error(ex, "") val textError = if (ex is APIResponseException) { if (!ex.reason.isNullOrBlank()) { ex.reason } else ex.message } else ex.message - - context.ui.showSnackbar( - UUID.randomUUID().toString(), - context.i18n.ptrl("Error encountered while handling Coder URI"), - context.i18n.pnotr(textError ?: ""), - context.i18n.ptrl("Dismiss") + context.logAndShowError( + "Error encountered while handling Coder URI", + textError ?: "" ) - } finally { - coderHeaderPage.isBusyCreatingNewEnvironment.update { - false - } + context.envPageManager.showPluginEnvironmentsPage() } } @@ -369,8 +353,17 @@ class CoderRemoteProvider( // When coming back to the application, initializeSession immediately. if (shouldDoAutoSetup()) { try { + CoderCliSetupContext.apply { + url = context.secrets.lastDeploymentURL.toURL() + token = context.secrets.lastToken + } CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) - return CoderCliSetupWizardPage(context, settingsPage, visibilityState, true, ::onConnect) + return CoderCliSetupWizardPage( + context, settingsPage, visibilityState, + initialAutoSetup = true, + jumpToMainPageOnError = false, + onConnect = ::onConnect + ) } catch (ex: Exception) { errorBuffer.add(ex) } finally { diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index 4291321..baac820 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -88,4 +88,9 @@ data class CoderToolboxContext( i18n.ptrl("OK") ) } + + fun popupPluginMainPage() { + this.ui.showWindow() + this.envPageManager.showPluginEnvironmentsPage(true) + } } diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index f0e84b9..76877cf 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -10,10 +10,17 @@ import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent import com.coder.toolbox.sdk.v2.models.WorkspaceStatus import com.coder.toolbox.util.WebUrlValidationResult.Invalid +import com.coder.toolbox.views.CoderCliSetupWizardPage +import com.coder.toolbox.views.CoderSettingsPage +import com.coder.toolbox.views.state.CoderCliSetupContext +import com.coder.toolbox.views.state.CoderCliSetupWizardState +import com.coder.toolbox.views.state.WizardStep +import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.time.withTimeout @@ -25,12 +32,13 @@ import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration private const val CAN_T_HANDLE_URI_TITLE = "Can't handle URI" -private val noOpTextProgress: (String) -> Unit = { _ -> } @Suppress("UnstableApiUsage") open class CoderProtocolHandler( private val context: CoderToolboxContext, private val dialogUi: DialogUi, + private val settingsPage: CoderSettingsPage, + private val visibilityState: MutableStateFlow, private val isInitialized: StateFlow, ) { private val settings = context.settingsStore.readOnly() @@ -45,8 +53,6 @@ open class CoderProtocolHandler( suspend fun handle( uri: URI, shouldWaitForAutoLogin: Boolean, - markAsBusy: () -> Unit, - unmarkAsBusy: () -> Unit, reInitialize: suspend (CoderRestClient, CoderCLIManager) -> Unit ) { val params = uri.toQueryParameters() @@ -58,7 +64,6 @@ open class CoderProtocolHandler( // this switches to the main plugin screen, even // if last opened provider was not Coder context.envPageManager.showPluginEnvironmentsPage() - markAsBusy() if (shouldWaitForAutoLogin) { isInitialized.waitForTrue() } @@ -67,13 +72,16 @@ open class CoderProtocolHandler( val deploymentURL = resolveDeploymentUrl(params) ?: return val token = if (!context.settingsStore.requireTokenAuth) null else resolveToken(params) ?: return val workspaceName = resolveWorkspaceName(params) ?: return - val restClient = buildRestClient(deploymentURL, token) ?: return - val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL) ?: return - val cli = configureCli(deploymentURL, restClient) - - var agent: WorkspaceAgent - try { + suspend fun onConnect( + restClient: CoderRestClient, + cli: CoderCLIManager + ) { + val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL) + if (workspace == null) { + context.envPageManager.showPluginEnvironmentsPage() + return + } reInitialize(restClient, cli) context.envPageManager.showPluginEnvironmentsPage() if (!prepareWorkspace(workspace, restClient, workspaceName, deploymentURL)) return @@ -81,25 +89,36 @@ open class CoderProtocolHandler( // errors like: no agent available while workspace is starting or stopping // we also need to retrieve the workspace again to have the latest resources (ex: agent) // attached to the workspace. - agent = resolveAgent( + val agent: WorkspaceAgent = resolveAgent( params, restClient.workspace(workspace.id) ) ?: return if (!ensureAgentIsReady(workspace, agent)) return - } finally { - unmarkAsBusy() - } - delay(2.seconds) - val environmentId = "${workspace.name}.${agent.name}" - context.showEnvironmentPage(environmentId) + delay(2.seconds) + val environmentId = "${workspace.name}.${agent.name}" + context.showEnvironmentPage(environmentId) - val productCode = params.ideProductCode() - val buildNumber = params.ideBuildNumber() - val projectFolder = params.projectFolder() + val productCode = params.ideProductCode() + val buildNumber = params.ideBuildNumber() + val projectFolder = params.projectFolder() + + if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) { + launchIde(environmentId, productCode, buildNumber, projectFolder) + } + } - if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) { - launchIde(environmentId, productCode, buildNumber, projectFolder) + CoderCliSetupContext.apply { + url = deploymentURL.toURL() + CoderCliSetupContext.token = token } + CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) + context.ui.showUiPage( + CoderCliSetupWizardPage( + context, settingsPage, visibilityState, true, + jumpToMainPageOnError = true, + onConnect = ::onConnect + ) + ) } private suspend fun resolveDeploymentUrl(params: Map): String? { @@ -308,13 +327,14 @@ open class CoderProtocolHandler( private suspend fun configureCli( deploymentURL: String, - restClient: CoderRestClient + restClient: CoderRestClient, + progressReporter: (String) -> Unit ): CoderCLIManager { val cli = ensureCLI( context, deploymentURL.toURL(), restClient.buildInfo().version, - noOpTextProgress + progressReporter ) // We only need to log in if we are using token-based auth. @@ -455,12 +475,6 @@ open class CoderProtocolHandler( } } - -private fun CoderToolboxContext.popupPluginMainPage() { - this.ui.showWindow() - this.envPageManager.showPluginEnvironmentsPage(true) -} - private suspend fun CoderToolboxContext.showEnvironmentPage(envId: String) { this.ui.showWindow() this.envPageManager.showEnvironmentPage(envId, false) diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt index 5115204..1e6b152 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt @@ -4,8 +4,6 @@ import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.ex.APIResponseException -import com.coder.toolbox.util.toURL -import com.coder.toolbox.views.state.CoderCliSetupContext import com.coder.toolbox.views.state.CoderCliSetupWizardState import com.coder.toolbox.views.state.WizardStep import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState @@ -21,6 +19,7 @@ class CoderCliSetupWizardPage( private val settingsPage: CoderSettingsPage, private val visibilityState: MutableStateFlow, initialAutoSetup: Boolean = false, + jumpToMainPageOnError: Boolean = false, onConnect: suspend ( client: CoderRestClient, cli: CoderCLIManager, @@ -35,7 +34,8 @@ class CoderCliSetupWizardPage( private val tokenStep = TokenStep(context) private val connectStep = ConnectStep( context, - shouldAutoSetup, + shouldAutoLogin = shouldAutoSetup, + jumpToMainPageOnError, this::notify, this::displaySteps, onConnect @@ -49,13 +49,6 @@ class CoderCliSetupWizardPage( private val errorBuffer = mutableListOf() - init { - if (shouldAutoSetup.value) { - CoderCliSetupContext.url = context.secrets.lastDeploymentURL.toURL() - CoderCliSetupContext.token = context.secrets.lastToken - } - } - override fun beforeShow() { displaySteps() if (errorBuffer.isNotEmpty() && visibilityState.value.applicationVisible) { diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt index 363d618..eec0765 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt @@ -1,6 +1,5 @@ package com.coder.toolbox.views -import com.coder.toolbox.CoderToolboxContext import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon.IconType import com.jetbrains.toolbox.api.localization.LocalizableString @@ -43,12 +42,6 @@ abstract class CoderPage( } else { SvgIcon(byteArrayOf(), type = IconType.Masked) } - - override val isBusyCreatingNewEnvironment: MutableStateFlow = MutableStateFlow(false) - - companion object { - fun emptyPage(ctx: CoderToolboxContext): UiPage = UiPage(ctx.i18n.pnotr("")) - } } /** diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index 7ea93e4..40db0cb 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -25,6 +25,7 @@ private const val USER_HIT_THE_BACK_BUTTON = "User hit the back button" class ConnectStep( private val context: CoderToolboxContext, private val shouldAutoLogin: StateFlow, + private val jumpToMainPageOnError: Boolean, private val notify: (String, Throwable) -> Unit, private val refreshWizard: () -> Unit, private val onConnect: suspend ( @@ -127,7 +128,11 @@ class ConnectStep( } finally { if (shouldAutoLogin.value) { CoderCliSetupContext.reset() - CoderCliSetupWizardState.goToFirstStep() + if (jumpToMainPageOnError) { + context.popupPluginMainPage() + } else { + CoderCliSetupWizardState.goToFirstStep() + } } else { if (context.settingsStore.requireTokenAuth) { CoderCliSetupWizardState.goToPreviousStep() diff --git a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt index b26acde..56402e5 100644 --- a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt @@ -5,9 +5,11 @@ import com.coder.toolbox.sdk.DataGen import com.coder.toolbox.settings.Environment import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore +import com.coder.toolbox.views.CoderSettingsPage import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory +import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings @@ -16,6 +18,7 @@ import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager import com.jetbrains.toolbox.api.ui.ToolboxUi import io.mockk.mockk import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.DisplayName @@ -43,6 +46,8 @@ internal class CoderProtocolHandlerTest { private val protocolHandler = CoderProtocolHandler( context, DialogUi(context), + CoderSettingsPage(context, Channel(Channel.CONFLATED)), + MutableStateFlow(ProviderVisibilityState(applicationVisible = true, providerVisible = true)), MutableStateFlow(false) ) From 2f263f49d16cc484fbf6283f3cb95d9738afd401 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 27 Aug 2025 00:56:40 +0300 Subject: [PATCH 56/93] Changelog update - `v0.6.3` (#182) Current pull request contains patched `CHANGELOG.md` file for the `v0.6.3` version. Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08ac799..66990d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.6.3 - 2025-08-25 + ### Added - progress reporting while handling URIs From ab9152819c64c9230c262e8e830a73fcbc17de42 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 27 Aug 2025 00:57:18 +0300 Subject: [PATCH 57/93] chore: bump actions/setup-java from 4 to 5 (#181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/setup-java](https://github.com/actions/setup-java) from 4 to 5.
Release notes

Sourced from actions/setup-java's releases.

v5.0.0

What's Changed

Breaking Changes

Make sure your runner is updated to this version or newer to use this release. v2.327.1 Release Notes

Dependency Upgrades

Bug Fixes

New Contributors

Full Changelog: https://github.com/actions/setup-java/compare/v4...v5.0.0

v4.7.1

What's Changed

Documentation changes

Dependency updates:

Full Changelog: https://github.com/actions/setup-java/compare/v4...v4.7.1

v4.7.0

What's Changed

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/setup-java&package-manager=github_actions&previous-version=4&new-version=5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 4 ++-- .github/workflows/jetbrains-compliance.yml | 2 +- .github/workflows/release.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index de884ff..a858afb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ jobs: steps: - uses: actions/checkout@v4.2.2 - - uses: actions/setup-java@v4 + - uses: actions/setup-java@v5 with: distribution: zulu java-version: 21 @@ -54,7 +54,7 @@ jobs: # Setup Java 21 environment for the next steps - name: Setup Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: zulu java-version: 21 diff --git a/.github/workflows/jetbrains-compliance.yml b/.github/workflows/jetbrains-compliance.yml index d1d2019..a583816 100644 --- a/.github/workflows/jetbrains-compliance.yml +++ b/.github/workflows/jetbrains-compliance.yml @@ -16,7 +16,7 @@ jobs: uses: actions/checkout@v4 - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: java-version: '21' distribution: 'temurin' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 44fb3be..88bfaec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: # Setup Java 21 environment for the next steps - name: Setup Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: zulu java-version: 21 From 5059a5d2c732044eec577fd842d7971ff688b118 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 27 Aug 2025 00:57:55 +0300 Subject: [PATCH 58/93] chore: bump actions/checkout from 4 to 5 (#183) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [//]: # (dependabot-start) ⚠️ **Dependabot is rebasing this PR** ⚠️ Rebasing might not happen immediately, so don't worry if this takes some time. Note: if you make any changes to this PR yourself, they will take precedence over the rebase. --- [//]: # (dependabot-end) Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
Release notes

Sourced from actions/checkout's releases.

v5.0.0

What's Changed

⚠️ Minimum Compatible Runner Version

v2.327.1
Release Notes

Make sure your runner is updated to this version or newer to use this release.

Full Changelog: https://github.com/actions/checkout/compare/v4...v5.0.0

v4.3.0

What's Changed

New Contributors

Full Changelog: https://github.com/actions/checkout/compare/v4...v4.3.0

v4.2.2

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v4.2.1...v4.2.2

v4.2.1

What's Changed

New Contributors

Full Changelog: https://github.com/actions/checkout/compare/v4.2.0...v4.2.1

... (truncated)

Changelog

Sourced from actions/checkout's changelog.

Changelog

V5.0.0

V4.3.0

v4.2.2

v4.2.1

v4.2.0

v4.1.7

v4.1.6

v4.1.5

v4.1.4

v4.1.3

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/checkout&package-manager=github_actions&previous-version=4&new-version=5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 6 +++--- .github/workflows/jetbrains-compliance.yml | 2 +- .github/workflows/release.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a858afb..323b3d5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,7 +20,7 @@ jobs: - windows-latest runs-on: ${{ matrix.platform }} steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v5 - uses: actions/setup-java@v5 with: @@ -50,7 +50,7 @@ jobs: steps: # Check out current repository - name: Fetch Sources - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 # Setup Java 21 environment for the next steps - name: Setup Java @@ -101,7 +101,7 @@ jobs: # Check out current repository - name: Fetch Sources - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 # Remove old release drafts by using GitHub CLI - name: Remove Old Release Drafts diff --git a/.github/workflows/jetbrains-compliance.yml b/.github/workflows/jetbrains-compliance.yml index a583816..ff28fe5 100644 --- a/.github/workflows/jetbrains-compliance.yml +++ b/.github/workflows/jetbrains-compliance.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up JDK 21 uses: actions/setup-java@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 88bfaec..6918c4e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: # Check out current repository - name: Fetch Sources - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 with: ref: ${{ github.event.release.tag_name }} From 98228768d8d8249a866a5469d021818580c5ba46 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 27 Aug 2025 00:58:52 +0300 Subject: [PATCH 59/93] chore: bump org.jetbrains.intellij.plugins:structure-toolbox from 3.310 to 3.315 (#184) Bumps [org.jetbrains.intellij.plugins:structure-toolbox](https://github.com/JetBrains/intellij-plugin-verifier) from 3.310 to 3.315.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.jetbrains.intellij.plugins:structure-toolbox&package-manager=gradle&previous-version=3.310&new-version=3.315)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a3d0755..1bbf2b9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ ksp = "2.1.20-2.0.1" retrofit = "3.0.0" changelog = "2.4.0" gettext = "0.7.0" -plugin-structure = "3.310" +plugin-structure = "3.315" mockk = "1.14.5" detekt = "1.23.8" bouncycastle = "1.81" From 22f53f68b3fde1e104b6680f950544ccab805238 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 28 Aug 2025 23:23:54 +0300 Subject: [PATCH 60/93] impl: improved debugging with named coroutines and additional logging (#185) - this commit add descriptive names to launched coroutines for better stack traces. - extra logging lines during connection setup. Should be helpful in cases where the coroutines fail with exceptions --- CHANGELOG.md | 4 + gradle.properties | 2 +- .../coder/toolbox/CoderRemoteEnvironment.kt | 25 +-- .../com/coder/toolbox/CoderRemoteProvider.kt | 194 +++++++++--------- .../com/coder/toolbox/cli/CoderCLIManager.kt | 1 + .../toolbox/util/CoderProtocolHandler.kt | 12 +- .../toolbox/views/CoderCliSetupWizardPage.kt | 3 +- .../coder/toolbox/views/CoderSettingsPage.kt | 5 +- .../com/coder/toolbox/views/ConnectStep.kt | 58 ++++-- 9 files changed, 169 insertions(+), 135 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66990d7..826e038 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- improved diagnose support + ## 0.6.3 - 2025-08-25 ### Added diff --git a/gradle.properties b/gradle.properties index b10e0c2..3b06eb4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.6.3 +version=0.6.4 group=com.coder.toolbox name=coder-toolbox \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index f8b3a17..d4531a6 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -24,6 +24,7 @@ import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentDescription import com.jetbrains.toolbox.api.remoteDev.states.RemoteEnvironmentState import com.jetbrains.toolbox.api.ui.actions.ActionDescription import com.squareup.moshi.Moshi +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -81,7 +82,7 @@ class CoderRemoteEnvironment( val actions = mutableListOf() if (wsRawStatus.canStop()) { actions.add(Action(context.i18n.ptrl("Open web terminal")) { - context.cs.launch { + context.cs.launch(CoroutineName("Open Web Terminal Action")) { context.desktop.browse(client.url.withPath("/${workspace.ownerName}/$name/terminal").toString()) { context.ui.showErrorInfoPopup(it) } @@ -90,7 +91,7 @@ class CoderRemoteEnvironment( } actions.add( Action(context.i18n.ptrl("Open in dashboard")) { - context.cs.launch { + context.cs.launch(CoroutineName("Open in Dashboard Action")) { context.desktop.browse( client.url.withPath("/@${workspace.ownerName}/${workspace.name}").toString() ) { @@ -100,7 +101,7 @@ class CoderRemoteEnvironment( }) actions.add(Action(context.i18n.ptrl("View template")) { - context.cs.launch { + context.cs.launch(CoroutineName("View Template Action")) { context.desktop.browse(client.url.withPath("/templates/${workspace.templateName}").toString()) { context.ui.showErrorInfoPopup(it) } @@ -110,14 +111,14 @@ class CoderRemoteEnvironment( if (wsRawStatus.canStart()) { if (workspace.outdated) { actions.add(Action(context.i18n.ptrl("Update and start")) { - context.cs.launch { + context.cs.launch(CoroutineName("Update and Start Action")) { val build = client.updateWorkspace(workspace) update(workspace.copy(latestBuild = build), agent) } }) } else { actions.add(Action(context.i18n.ptrl("Start")) { - context.cs.launch { + context.cs.launch(CoroutineName("Start Action")) { val build = client.startWorkspace(workspace) update(workspace.copy(latestBuild = build), agent) @@ -128,14 +129,14 @@ class CoderRemoteEnvironment( if (wsRawStatus.canStop()) { if (workspace.outdated) { actions.add(Action(context.i18n.ptrl("Update and restart")) { - context.cs.launch { + context.cs.launch(CoroutineName("Update and Restart Action")) { val build = client.updateWorkspace(workspace) update(workspace.copy(latestBuild = build), agent) } }) } actions.add(Action(context.i18n.ptrl("Stop")) { - context.cs.launch { + context.cs.launch(CoroutineName("Stop Action")) { tryStopSshConnection() val build = client.stopWorkspace(workspace) @@ -169,7 +170,7 @@ class CoderRemoteEnvironment( pollJob = pollNetworkMetrics() } - private fun pollNetworkMetrics(): Job = context.cs.launch { + private fun pollNetworkMetrics(): Job = context.cs.launch(CoroutineName("Network Metrics Poller")) { context.logger.info("Starting the network metrics poll job for $id") while (isActive) { context.logger.debug("Searching SSH command's PID for workspace $id...") @@ -227,7 +228,7 @@ class CoderRemoteEnvironment( actionsList.update { getAvailableActions() } - context.cs.launch { + context.cs.launch(CoroutineName("Workspace Status Updater")) { state.update { wsRawStatus.toRemoteEnvironmentState(context) } @@ -262,7 +263,7 @@ class CoderRemoteEnvironment( */ fun startSshConnection(): Boolean { if (wsRawStatus.ready() && !isConnected.value) { - context.cs.launch { + context.cs.launch(CoroutineName("SSH Connection Trigger")) { connectionRequest.update { true } @@ -284,7 +285,7 @@ class CoderRemoteEnvironment( } override val deleteActionFlow: StateFlow<(() -> Unit)?> = MutableStateFlow { - context.cs.launch { + context.cs.launch(CoroutineName("Delete Workspace Action")) { try { client.removeWorkspace(workspace) // mark the env as deleting otherwise we will have to @@ -293,7 +294,7 @@ class CoderRemoteEnvironment( WorkspaceAndAgentStatus.DELETING.toRemoteEnvironmentState(context) } - context.cs.launch { + context.cs.launch(CoroutineName("Workspace Deletion Poller")) { withTimeout(5.minutes) { var workspaceStillExists = true while (context.cs.isActive && workspaceStillExists) { diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index eb9997e..cf9e04e 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -26,6 +26,7 @@ import com.jetbrains.toolbox.api.remoteDev.RemoteProvider import com.jetbrains.toolbox.api.ui.actions.ActionDelimiter import com.jetbrains.toolbox.api.ui.actions.ActionDescription import com.jetbrains.toolbox.api.ui.components.UiPage +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel @@ -87,113 +88,114 @@ class CoderRemoteProvider( * workspace is added, reconfigure SSH using the provided cli (including the * first time). */ - private fun poll(client: CoderRestClient, cli: CoderCLIManager): Job = context.cs.launch { - var lastPollTime = TimeSource.Monotonic.markNow() - while (isActive) { - try { - context.logger.debug("Fetching workspace agents from ${client.url}") - val resolvedEnvironments = client.workspaces().flatMap { ws -> - // Agents are not included in workspaces that are off - // so fetch them separately. - when (ws.latestBuild.status) { - WorkspaceStatus.RUNNING -> ws.latestBuild.resources - else -> emptyList() - }.ifEmpty { - client.resources(ws) - }.flatMap { resource -> - resource.agents?.distinctBy { - // There can be duplicates with coder_agent_instance. - // TODO: Can we just choose one or do they hold - // different information? - it.name - }?.map { agent -> - // If we have an environment already, update that. - val env = CoderRemoteEnvironment(context, client, cli, ws, agent) - lastEnvironments.firstOrNull { it == env }?.let { - it.update(ws, agent) - it - } ?: env - } ?: emptyList() - } - }.toSet() + private fun poll(client: CoderRestClient, cli: CoderCLIManager): Job = + context.cs.launch(CoroutineName("Workspace Poller")) { + var lastPollTime = TimeSource.Monotonic.markNow() + while (isActive) { + try { + context.logger.debug("Fetching workspace agents from ${client.url}") + val resolvedEnvironments = client.workspaces().flatMap { ws -> + // Agents are not included in workspaces that are off + // so fetch them separately. + when (ws.latestBuild.status) { + WorkspaceStatus.RUNNING -> ws.latestBuild.resources + else -> emptyList() + }.ifEmpty { + client.resources(ws) + }.flatMap { resource -> + resource.agents?.distinctBy { + // There can be duplicates with coder_agent_instance. + // TODO: Can we just choose one or do they hold + // different information? + it.name + }?.map { agent -> + // If we have an environment already, update that. + val env = CoderRemoteEnvironment(context, client, cli, ws, agent) + lastEnvironments.firstOrNull { it == env }?.let { + it.update(ws, agent) + it + } ?: env + } ?: emptyList() + } + }.toSet() - // In case we logged out while running the query. - if (!isActive) { - return@launch - } + // In case we logged out while running the query. + if (!isActive) { + return@launch + } - // Reconfigure if environments changed. - if (lastEnvironments.size != resolvedEnvironments.size || lastEnvironments != resolvedEnvironments) { - context.logger.info("Workspaces have changed, reconfiguring CLI: $resolvedEnvironments") - cli.configSsh(resolvedEnvironments.map { it.asPairOfWorkspaceAndAgent() }.toSet()) - } + // Reconfigure if environments changed. + if (lastEnvironments.size != resolvedEnvironments.size || lastEnvironments != resolvedEnvironments) { + context.logger.info("Workspaces have changed, reconfiguring CLI: $resolvedEnvironments") + cli.configSsh(resolvedEnvironments.map { it.asPairOfWorkspaceAndAgent() }.toSet()) + } - environments.update { - LoadableState.Value(resolvedEnvironments.toList()) - } - if (!isInitialized.value) { - context.logger.info("Environments for ${client.url} are now initialized") - isInitialized.update { - true + environments.update { + LoadableState.Value(resolvedEnvironments.toList()) + } + if (!isInitialized.value) { + context.logger.info("Environments for ${client.url} are now initialized") + isInitialized.update { + true + } + } + lastEnvironments.apply { + clear() + addAll(resolvedEnvironments.sortedBy { it.id }) } - } - lastEnvironments.apply { - 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") + 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.reset() - } - WorkspaceConnectionManager.collectStatuses(lastEnvironments) - } catch (_: CancellationException) { - context.logger.debug("${client.url} polling loop canceled") - break - } catch (ex: Exception) { - val elapsed = lastPollTime.elapsedNow() - if (elapsed > POLL_INTERVAL * 2) { - context.logger.info("wake-up from an OS sleep was detected") - } else { - context.logger.error(ex, "workspace polling error encountered") - if (ex is APIResponseException && ex.isTokenExpired) { - WorkspaceConnectionManager.shouldEstablishWorkspaceConnections = true - close() - context.envPageManager.showPluginEnvironmentsPage() - errorBuffer.add(ex) - break + WorkspaceConnectionManager.collectStatuses(lastEnvironments) + } catch (_: CancellationException) { + context.logger.debug("${client.url} polling loop canceled") + break + } catch (ex: Exception) { + val elapsed = lastPollTime.elapsedNow() + if (elapsed > POLL_INTERVAL * 2) { + context.logger.info("wake-up from an OS sleep was detected") + } else { + context.logger.error(ex, "workspace polling error encountered") + if (ex is APIResponseException && ex.isTokenExpired) { + WorkspaceConnectionManager.shouldEstablishWorkspaceConnections = true + close() + context.envPageManager.showPluginEnvironmentsPage() + errorBuffer.add(ex) + break + } } } - } - select { - onTimeout(POLL_INTERVAL) { - context.logger.debug("workspace poller waked up by the $POLL_INTERVAL timeout") - } - triggerSshConfig.onReceive { shouldTrigger -> - if (shouldTrigger) { - context.logger.debug("workspace poller waked up because it should reconfigure the ssh configurations") - cli.configSsh(lastEnvironments.map { it.asPairOfWorkspaceAndAgent() }.toSet()) + select { + onTimeout(POLL_INTERVAL) { + context.logger.debug("workspace poller waked up by the $POLL_INTERVAL timeout") } - } - triggerProviderVisible.onReceive { isCoderProviderVisible -> - if (isCoderProviderVisible) { - context.logger.debug("workspace poller waked up by Coder Toolbox which is currently visible, fetching latest workspace statuses") + triggerSshConfig.onReceive { shouldTrigger -> + if (shouldTrigger) { + context.logger.debug("workspace poller waked up because it should reconfigure the ssh configurations") + cli.configSsh(lastEnvironments.map { it.asPairOfWorkspaceAndAgent() }.toSet()) + } + } + triggerProviderVisible.onReceive { isCoderProviderVisible -> + if (isCoderProviderVisible) { + context.logger.debug("workspace poller waked up by Coder Toolbox which is currently visible, fetching latest workspace statuses") + } } } + lastPollTime = TimeSource.Monotonic.markNow() } - lastPollTime = TimeSource.Monotonic.markNow() } - } /** * Stop polling, clear the client and environments, then go back to the @@ -221,7 +223,7 @@ class CoderRemoteProvider( override val additionalPluginActions: StateFlow> = MutableStateFlow( listOf( Action(context.i18n.ptrl("Create workspace")) { - context.cs.launch { + context.cs.launch(CoroutineName("Create Workspace Action")) { context.desktop.browse(client?.url?.withPath("/templates").toString()) { context.ui.showErrorInfoPopup(it) } @@ -299,7 +301,7 @@ class CoderRemoteProvider( visibility } if (visibility.providerVisible) { - context.cs.launch { + context.cs.launch(CoroutineName("Notify Plugin Visibility")) { triggerProviderVisible.send(true) } } @@ -396,11 +398,17 @@ class CoderRemoteProvider( context.secrets.lastDeploymentURL = client.url.toString() context.secrets.lastToken = client.token ?: "" context.secrets.storeTokenFor(client.url, context.secrets.lastToken) + context.logger.info("Deployment URL and token were stored and will be available for automatic connection") this.client = client - pollJob?.cancel() + pollJob?.let { + it.cancel() + context.logger.info("Workspace poll job with reference ${pollJob} was canceled") + } environments.showLoadingMessage() coderHeaderPage.setTitle(context.i18n.pnotr(client.url.toString())) + context.logger.info("Displaying ${client.url} in the UI") pollJob = poll(client, cli) + context.logger.info("Workspace poll job created with reference $pollJob") context.envPageManager.showPluginEnvironmentsPage() } diff --git a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt index 67947c3..9b058e5 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt @@ -315,6 +315,7 @@ class CoderCLIManager( ) { context.logger.info("Configuring SSH config at ${context.settingsStore.sshConfigPath}") writeSSHConfig(modifySSHConfig(readSSHConfig(), wsWithAgents, feats)) + context.logger.info("Finished configuring SSH config") } /** diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 76877cf..a4c0b48 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -17,6 +17,7 @@ import com.coder.toolbox.views.state.CoderCliSetupWizardState import com.coder.toolbox.views.state.WizardStep import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay @@ -354,7 +355,7 @@ open class CoderProtocolHandler( buildNumber: String, projectFolder: String? ) { - context.cs.launch { + context.cs.launch(CoroutineName("Launch Remote IDE")) { val selectedIde = selectAndInstallRemoteIde(productCode, buildNumber, environmentId) ?: return@launch context.logger.info("$productCode-$buildNumber is already on $environmentId. Going to launch JBClient") installJBClient(selectedIde, environmentId).join() @@ -422,10 +423,11 @@ open class CoderProtocolHandler( return "$productCode-$buildNumber" } - private fun installJBClient(selectedIde: String, environmentId: String): Job = context.cs.launch { - context.logger.info("Downloading and installing JBClient counterpart to $selectedIde locally") - context.jbClientOrchestrator.prepareClient(environmentId, selectedIde) - } + private fun installJBClient(selectedIde: String, environmentId: String): Job = + context.cs.launch(CoroutineName("JBClient Installer")) { + context.logger.info("Downloading and installing JBClient counterpart to $selectedIde locally") + context.jbClientOrchestrator.prepareClient(environmentId, selectedIde) + } private fun launchJBClient(selectedIde: String, environmentId: String, projectFolder: String?) { context.logger.info("Launching $selectedIde on $environmentId") diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt index 1e6b152..7ff36bc 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt @@ -9,6 +9,7 @@ import com.coder.toolbox.views.state.WizardStep import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription import com.jetbrains.toolbox.api.ui.components.UiField +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -140,7 +141,7 @@ class CoderCliSetupWizardPage( } else ex.message } else ex.message - context.cs.launch { + context.cs.launch(CoroutineName("Coder Setup Visual Error Reporting")) { context.ui.showSnackbar( UUID.randomUUID().toString(), context.i18n.ptrl("Error encountered while setting up Coder"), diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index e937600..3444683 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -12,6 +12,7 @@ import com.jetbrains.toolbox.api.ui.components.ComboBoxField.LabelledValue 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 kotlinx.coroutines.CoroutineName import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ClosedSendChannelException @@ -134,7 +135,7 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf context.settingsStore.updateEnableSshWildcardConfig(enableSshWildCardConfig.checkedState.value) if (enableSshWildCardConfig.checkedState.value != oldIsSshWildcardConfigEnabled) { - context.cs.launch { + context.cs.launch(CoroutineName("SSH Wildcard Setting")) { try { triggerSshConfig.send(true) context.logger.info("Wildcard settings have been modified from $oldIsSshWildcardConfigEnabled to ${!oldIsSshWildcardConfigEnabled}, ssh config is going to be regenerated...") @@ -211,7 +212,7 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf settings.networkInfoDir } - visibilityUpdateJob = context.cs.launch { + visibilityUpdateJob = context.cs.launch(CoroutineName("Signature Verification Fallback Setting")) { disableSignatureVerificationField.checkedState.collect { state -> signatureFallbackStrategyField.visibility.update { // the fallback checkbox should not be visible diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index 40db0cb..f005b53 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -10,12 +10,13 @@ import com.coder.toolbox.views.state.CoderCliSetupWizardState import com.jetbrains.toolbox.api.ui.components.LabelField import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.ValidationErrorField +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.yield -import java.util.concurrent.CancellationException private const val USER_HIT_THE_BACK_BUTTON = "User hit the back button" @@ -73,8 +74,9 @@ class ConnectStep( return } signInJob?.cancel() - signInJob = context.cs.launch { + signInJob = context.cs.launch(CoroutineName("Http and CLI Setup")) { try { + context.logger.info("Setting up the HTTP client...") val client = CoderRestClient( context, CoderCliSetupContext.url!!, @@ -84,7 +86,7 @@ class ConnectStep( // allows interleaving with the back/cancel action yield() client.initializeSession() - statusField.textState.update { (context.i18n.ptrl("Checking Coder CLI...")) } + logAndReportProgress("Checking Coder CLI...") val cli = ensureCLI( context, client.url, client.buildVersion @@ -93,53 +95,67 @@ class ConnectStep( } // We only need to log in if we are using token-based auth. if (context.settingsStore.requireTokenAuth) { - statusField.textState.update { (context.i18n.ptrl("Configuring Coder CLI...")) } + logAndReportProgress("Configuring Coder CLI...") // allows interleaving with the back/cancel action yield() cli.login(client.token!!) } - statusField.textState.update { (context.i18n.ptrl("Successfully configured ${CoderCliSetupContext.url!!.host}...")) } + logAndReportProgress("Successfully configured ${CoderCliSetupContext.url!!.host}...") // allows interleaving with the back/cancel action yield() CoderCliSetupContext.reset() CoderCliSetupWizardState.goToFirstStep() + context.logger.info("Connection setup done, initializing the workspace poller...") onConnect(client, cli) } catch (ex: CancellationException) { if (ex.message != USER_HIT_THE_BACK_BUTTON) { notify("Connection to ${CoderCliSetupContext.url!!.host} was configured", ex) - onBack() + handleNavigation() refreshWizard() } } catch (ex: Exception) { notify("Failed to configure ${CoderCliSetupContext.url!!.host}", ex) - onBack() + handleNavigation() refreshWizard() } } } + private fun logAndReportProgress(msg: String) { + context.logger.info(msg) + statusField.textState.update { context.i18n.pnotr(msg) } + } + + /** + * Handle navigation logic for both errors and back button + */ + private fun handleNavigation() { + if (shouldAutoLogin.value) { + CoderCliSetupContext.reset() + if (jumpToMainPageOnError) { + context.popupPluginMainPage() + } else { + CoderCliSetupWizardState.goToFirstStep() + } + } else { + if (context.settingsStore.requireTokenAuth) { + CoderCliSetupWizardState.goToPreviousStep() + } else { + CoderCliSetupWizardState.goToFirstStep() + } + } + } + override fun onNext(): Boolean { return false } override fun onBack() { try { + context.logger.info("Back button was pressed, cancelling in-progress connection setup...") signInJob?.cancel(CancellationException(USER_HIT_THE_BACK_BUTTON)) } finally { - if (shouldAutoLogin.value) { - CoderCliSetupContext.reset() - if (jumpToMainPageOnError) { - context.popupPluginMainPage() - } else { - CoderCliSetupWizardState.goToFirstStep() - } - } else { - if (context.settingsStore.requireTokenAuth) { - CoderCliSetupWizardState.goToPreviousStep() - } else { - CoderCliSetupWizardState.goToFirstStep() - } - } + handleNavigation() } } } From a68ab3a2df66dfa6b15d7a438d11c6bcdbecd1a0 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 2 Sep 2025 19:49:26 +0300 Subject: [PATCH 61/93] fix: NPE during error reporting (#186) The try/catch block raised NPE in the `notify` if another exception was raised after the context containing the URL was reset - so that means an error in the onConnect handler. In addition, some of the reset steps were moved after onConnect to make sure they execute only if onConnect callback is successful. Because of the fault in how the steps were arranged, the original exception was never logged instead a misleading NPE was treated by the coroutine's exception handler. --- CHANGELOG.md | 4 + .../com/coder/toolbox/CoderRemoteProvider.kt | 1 - .../toolbox/views/CoderCliSetupWizardPage.kt | 47 ++---------- .../com/coder/toolbox/views/ConnectStep.kt | 25 ++++--- .../coder/toolbox/views/DeploymentUrlStep.kt | 9 ++- .../com/coder/toolbox/views/ErrorReporter.kt | 73 +++++++++++++++++++ 6 files changed, 106 insertions(+), 53 deletions(-) create mode 100644 src/main/kotlin/com/coder/toolbox/views/ErrorReporter.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 826e038..8358d75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - improved diagnose support +### Fixed + +- NPE during error reporting + ## 0.6.3 - 2025-08-25 ### Added diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index cf9e04e..8c6dcd3 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -409,7 +409,6 @@ class CoderRemoteProvider( context.logger.info("Displaying ${client.url} in the UI") pollJob = poll(client, cli) context.logger.info("Workspace poll job created with reference $pollJob") - context.envPageManager.showPluginEnvironmentsPage() } private fun MutableStateFlow>>.showLoadingMessage() { diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt index 7ff36bc..bca3606 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt @@ -3,22 +3,19 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.sdk.CoderRestClient -import com.coder.toolbox.sdk.ex.APIResponseException import com.coder.toolbox.views.state.CoderCliSetupWizardState import com.coder.toolbox.views.state.WizardStep import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription import com.jetbrains.toolbox.api.ui.components.UiField -import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import java.util.UUID class CoderCliSetupWizardPage( private val context: CoderToolboxContext, private val settingsPage: CoderSettingsPage, - private val visibilityState: MutableStateFlow, + visibilityState: StateFlow, initialAutoSetup: Boolean = false, jumpToMainPageOnError: Boolean = false, onConnect: suspend ( @@ -31,16 +28,17 @@ class CoderCliSetupWizardPage( context.ui.showUiPage(settingsPage) }) - private val deploymentUrlStep = DeploymentUrlStep(context, this::notify) + private val deploymentUrlStep = DeploymentUrlStep(context, visibilityState) private val tokenStep = TokenStep(context) private val connectStep = ConnectStep( context, shouldAutoLogin = shouldAutoSetup, jumpToMainPageOnError, - this::notify, + visibilityState, this::displaySteps, onConnect ) + private val errorReporter = ErrorReporter.create(context, visibilityState, this.javaClass) /** * Fields for this page, displayed in order. @@ -48,16 +46,10 @@ class CoderCliSetupWizardPage( override val fields: MutableStateFlow> = MutableStateFlow(emptyList()) override val actionButtons: MutableStateFlow> = MutableStateFlow(emptyList()) - private val errorBuffer = mutableListOf() override fun beforeShow() { displaySteps() - if (errorBuffer.isNotEmpty() && visibilityState.value.applicationVisible) { - errorBuffer.forEach { - showError(it) - } - errorBuffer.clear() - } + errorReporter.flush() } private fun displaySteps() { @@ -124,30 +116,5 @@ class CoderCliSetupWizardPage( /** * Show an error as a popup on this page. */ - fun notify(logPrefix: String, ex: Throwable) { - context.logger.error(ex, logPrefix) - if (!visibilityState.value.applicationVisible) { - context.logger.debug("Toolbox is not yet visible, scheduling error to be displayed later") - errorBuffer.add(ex) - return - } - showError(ex) - } - - private fun showError(ex: Throwable) { - val textError = if (ex is APIResponseException) { - if (!ex.reason.isNullOrBlank()) { - ex.reason - } else ex.message - } else ex.message - - context.cs.launch(CoroutineName("Coder Setup Visual Error Reporting")) { - context.ui.showSnackbar( - UUID.randomUUID().toString(), - context.i18n.ptrl("Error encountered while setting up Coder"), - context.i18n.pnotr(textError ?: ""), - context.i18n.ptrl("Dismiss") - ) - } - } + fun notify(message: String, ex: Throwable) = errorReporter.report(message, ex) } diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index f005b53..7798328 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -7,6 +7,7 @@ import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.views.state.CoderCliSetupContext import com.coder.toolbox.views.state.CoderCliSetupWizardState +import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.ui.components.LabelField import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.ValidationErrorField @@ -27,17 +28,15 @@ class ConnectStep( private val context: CoderToolboxContext, private val shouldAutoLogin: StateFlow, private val jumpToMainPageOnError: Boolean, - private val notify: (String, Throwable) -> Unit, + visibilityState: StateFlow, private val refreshWizard: () -> Unit, - private val onConnect: suspend ( - client: CoderRestClient, - cli: CoderCLIManager, - ) -> Unit, + private val onConnect: suspend (client: CoderRestClient, cli: CoderCLIManager) -> Unit, ) : WizardStep { private var signInJob: Job? = null private val statusField = LabelField(context.i18n.pnotr("")) private val errorField = ValidationErrorField(context.i18n.pnotr("")) + private val errorReporter = ErrorReporter.create(context, visibilityState, this.javaClass) override val panel: RowGroup = RowGroup( RowGroup.RowField(statusField), @@ -45,6 +44,7 @@ class ConnectStep( ) override fun onVisible() { + errorReporter.flush() errorField.textState.update { context.i18n.pnotr("") } @@ -73,6 +73,9 @@ class ConnectStep( errorField.textState.update { context.i18n.ptrl("Token is required") } return } + // Capture the host name early for error reporting + val hostName = CoderCliSetupContext.url!!.host + signInJob?.cancel() signInJob = context.cs.launch(CoroutineName("Http and CLI Setup")) { try { @@ -100,21 +103,23 @@ class ConnectStep( yield() cli.login(client.token!!) } - logAndReportProgress("Successfully configured ${CoderCliSetupContext.url!!.host}...") + logAndReportProgress("Successfully configured ${hostName}...") // allows interleaving with the back/cancel action yield() - CoderCliSetupContext.reset() - CoderCliSetupWizardState.goToFirstStep() context.logger.info("Connection setup done, initializing the workspace poller...") onConnect(client, cli) + + CoderCliSetupContext.reset() + CoderCliSetupWizardState.goToFirstStep() + context.envPageManager.showPluginEnvironmentsPage() } catch (ex: CancellationException) { if (ex.message != USER_HIT_THE_BACK_BUTTON) { - notify("Connection to ${CoderCliSetupContext.url!!.host} was configured", ex) + errorReporter.report("Connection to $hostName was configured", ex) handleNavigation() refreshWizard() } } catch (ex: Exception) { - notify("Failed to configure ${CoderCliSetupContext.url!!.host}", ex) + errorReporter.report("Failed to configure $hostName", ex) handleNavigation() refreshWizard() } diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index 34b027c..be3d4d0 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -6,6 +6,7 @@ import com.coder.toolbox.util.toURL import com.coder.toolbox.util.validateStrictWebUrl import com.coder.toolbox.views.state.CoderCliSetupContext import com.coder.toolbox.views.state.CoderCliSetupWizardState +import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.ui.components.CheckboxField import com.jetbrains.toolbox.api.ui.components.LabelField import com.jetbrains.toolbox.api.ui.components.LabelStyleType @@ -13,6 +14,7 @@ import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.TextField import com.jetbrains.toolbox.api.ui.components.TextType import com.jetbrains.toolbox.api.ui.components.ValidationErrorField +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import java.net.MalformedURLException import java.net.URL @@ -25,9 +27,11 @@ import java.net.URL */ class DeploymentUrlStep( private val context: CoderToolboxContext, - private val notify: (String, Throwable) -> Unit + visibilityState: StateFlow, ) : WizardStep { + private val errorReporter = ErrorReporter.create(context, visibilityState, this.javaClass) + private val urlField = TextField(context.i18n.ptrl("Deployment URL"), "", TextType.General) private val emptyLine = LabelField(context.i18n.pnotr(""), LabelStyleType.Normal) @@ -66,6 +70,7 @@ class DeploymentUrlStep( signatureFallbackStrategyField.checkedState.update { context.settingsStore.fallbackOnCoderForSignatures.isAllowed() } + errorReporter.flush() } override fun onNext(): Boolean { @@ -78,7 +83,7 @@ class DeploymentUrlStep( try { CoderCliSetupContext.url = validateRawUrl(url) } catch (e: MalformedURLException) { - notify("URL is invalid", e) + errorReporter.report("URL is invalid", e) return false } if (context.settingsStore.requireTokenAuth) { diff --git a/src/main/kotlin/com/coder/toolbox/views/ErrorReporter.kt b/src/main/kotlin/com/coder/toolbox/views/ErrorReporter.kt new file mode 100644 index 0000000..88ace65 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/views/ErrorReporter.kt @@ -0,0 +1,73 @@ +package com.coder.toolbox.views + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.sdk.ex.APIResponseException +import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.util.UUID + +sealed class ErrorReporter { + + /** + * Logs and show errors as popups. + */ + abstract fun report(message: String, ex: Throwable) + + /** + * Processes any buffered errors when the application becomes visible. + */ + abstract fun flush() + + companion object { + fun create( + context: CoderToolboxContext, + visibilityState: StateFlow, + callerClass: Class<*> + ): ErrorReporter = ErrorReporterImpl(context, visibilityState, callerClass) + } +} + +private class ErrorReporterImpl( + private val context: CoderToolboxContext, + private val visibilityState: StateFlow, + private val callerClass: Class<*> +) : ErrorReporter() { + private val errorBuffer = mutableListOf() + + override fun report(message: String, ex: Throwable) { + context.logger.error(ex, "[${callerClass.simpleName}] $message") + if (!visibilityState.value.applicationVisible) { + context.logger.debug("Toolbox is not yet visible, scheduling error to be displayed later") + errorBuffer.add(ex) + return + } + showError(ex) + } + + private fun showError(ex: Throwable) { + val textError = if (ex is APIResponseException) { + if (!ex.reason.isNullOrBlank()) { + ex.reason + } else ex.message + } else ex.message ?: ex.toString() + context.cs.launch { + context.ui.showSnackbar( + UUID.randomUUID().toString(), + context.i18n.ptrl("Error encountered while setting up Coder"), + context.i18n.pnotr(textError ?: ""), + context.i18n.ptrl("Dismiss") + ) + } + } + + + override fun flush() { + if (errorBuffer.isNotEmpty() && visibilityState.value.applicationVisible) { + errorBuffer.forEach { + showError(it) + } + errorBuffer.clear() + } + } +} \ No newline at end of file From c2d658bded85ad772f37f29d6a12212ea3f00a21 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 3 Sep 2025 18:31:46 +0300 Subject: [PATCH 62/93] fix: relaxed Content-Type checks for CLI download (#189) This PR fixes download failure for Windows .exe binaries by relaxing strict Content-Type checks. Previously, the plugin only accepted application/octet-stream, causing failures when .exe files were served as application/x-msdos-executable by some servers. - resolves #187 --- CHANGELOG.md | 1 + .../toolbox/cli/downloader/CoderDownloadService.kt | 14 +++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8358d75..404d50e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - NPE during error reporting +- relaxed `Content-Type` checks while downloading the CLI ## 0.6.3 - 2025-08-25 diff --git a/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt b/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt index 574184c..468bfd8 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt @@ -25,6 +25,18 @@ import java.util.zip.GZIPInputStream import kotlin.io.path.name import kotlin.io.path.notExists +private val SUPPORTED_BIN_MIME_TYPES = listOf( + "application/octet-stream", + "application/exe", + "application/dos-exe", + "application/msdos-windows", + "application/x-exe", + "application/x-msdownload", + "application/x-winexe", + "application/x-msdos-program", + "application/x-msdos-executable", + "application/vnd.microsoft.portable-executable" +) /** * Handles the download steps of Coder CLI */ @@ -52,7 +64,7 @@ class CoderDownloadService( return when (response.code()) { HTTP_OK -> { val contentType = response.headers()["Content-Type"]?.lowercase() - if (contentType?.startsWith("application/octet-stream") != true) { + if (contentType !in SUPPORTED_BIN_MIME_TYPES) { throw ResponseException( "Invalid content type '$contentType' when downloading CLI from $remoteBinaryURL. Expected application/octet-stream.", response.code() From 52a98da4665f45e9a1398bb6c8dcdfddf139ab52 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Sep 2025 18:33:16 +0300 Subject: [PATCH 63/93] chore: bump org.jetbrains.intellij.plugins:structure-toolbox from 3.315 to 3.316 (#188) Bumps [org.jetbrains.intellij.plugins:structure-toolbox](https://github.com/JetBrains/intellij-plugin-verifier) from 3.315 to 3.316.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.jetbrains.intellij.plugins:structure-toolbox&package-manager=gradle&previous-version=3.315&new-version=3.316)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1bbf2b9..5d03af3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ ksp = "2.1.20-2.0.1" retrofit = "3.0.0" changelog = "2.4.0" gettext = "0.7.0" -plugin-structure = "3.315" +plugin-structure = "3.316" mockk = "1.14.5" detekt = "1.23.8" bouncycastle = "1.81" From f0568132db152afd4b2e51d8ea5eba010622882b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 3 Sep 2025 18:50:18 +0300 Subject: [PATCH 64/93] Changelog update - `v0.6.4` (#190) Current pull request contains patched `CHANGELOG.md` file for the `v0.6.4` version. Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 404d50e..63b71d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.6.4 - 2025-09-03 + ### Added - improved diagnose support From 6b00191be9b386c2e5db0b81a7c5241202ec61d7 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Sat, 13 Sep 2025 13:20:12 +0300 Subject: [PATCH 65/93] fix: don't store token when certificates are configured (#192) This was a regression for the custom flows that required authentication via certificates, the http client and the coder cli were properly initialized but afterward the token was still required for storing. Note: the issue was hard to catch early on because the official coder cli is not supporting auth. via certificates. --- CHANGELOG.md | 4 ++++ gradle.properties | 2 +- .../com/coder/toolbox/CoderRemoteProvider.kt | 20 +++++++++++++------ 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63b71d2..ee09b84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixed + +- token is no longer required when authentication is done via certificates + ## 0.6.4 - 2025-09-03 ### Added diff --git a/gradle.properties b/gradle.properties index 3b06eb4..a2e2a3f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.6.4 +version=0.6.5 group=com.coder.toolbox name=coder-toolbox \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 8c6dcd3..5d5acbb 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -242,7 +242,10 @@ class CoderRemoteProvider( * Also called as part of our own logout. */ override fun close() { - pollJob?.cancel() + pollJob?.let { + it.cancel() + context.logger.info("Cancelled workspace poll job ${pollJob.toString()}") + } client?.close() lastEnvironments.clear() environments.value = LoadableState.Value(emptyList()) @@ -327,6 +330,7 @@ class CoderRemoteProvider( environments.showLoadingMessage() pollJob = poll(restClient, cli) + context.logger.info("Workspace poll job with name ${pollJob.toString()} was created while handling URI $uri") isInitialized.waitForTrue() } } catch (ex: Exception) { @@ -396,19 +400,23 @@ class CoderRemoteProvider( private fun onConnect(client: CoderRestClient, cli: CoderCLIManager) { // Store the URL and token for use next time. context.secrets.lastDeploymentURL = client.url.toString() - context.secrets.lastToken = client.token ?: "" - context.secrets.storeTokenFor(client.url, context.secrets.lastToken) - context.logger.info("Deployment URL and token were stored and will be available for automatic connection") + if (context.settingsStore.requireTokenAuth) { + context.secrets.lastToken = client.token ?: "" + context.secrets.storeTokenFor(client.url, context.secrets.lastToken) + context.logger.info("Deployment URL and token were stored and will be available for automatic connection") + } else { + context.logger.info("Deployment URL was stored and will be available for automatic connection") + } this.client = client pollJob?.let { it.cancel() - context.logger.info("Workspace poll job with reference ${pollJob} was canceled") + context.logger.info("Cancelled workspace poll job ${pollJob.toString()} in order to start a new one") } environments.showLoadingMessage() coderHeaderPage.setTitle(context.i18n.pnotr(client.url.toString())) context.logger.info("Displaying ${client.url} in the UI") pollJob = poll(client, cli) - context.logger.info("Workspace poll job created with reference $pollJob") + context.logger.info("Workspace poll job with name ${pollJob.toString()} was created") } private fun MutableStateFlow>>.showLoadingMessage() { From 7a49167c47c0d0a6c779441deeca58d78ce7b34a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Sep 2025 22:40:54 +0300 Subject: [PATCH 66/93] chore: bump actions/github-script from 7 to 8 (#191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 8.
Release notes

Sourced from actions/github-script's releases.

v8.0.0

What's Changed

⚠️ Minimum Compatible Runner Version

v2.327.1
Release Notes

Make sure your runner is updated to this version or newer to use this release.

New Contributors

Full Changelog: https://github.com/actions/github-script/compare/v7.1.0...v8.0.0

v7.1.0

What's Changed

New Contributors

Full Changelog: https://github.com/actions/github-script/compare/v7...v7.1.0

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/github-script&package-manager=github_actions&previous-version=7&new-version=8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/jetbrains-compliance.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/jetbrains-compliance.yml b/.github/workflows/jetbrains-compliance.yml index ff28fe5..74339e8 100644 --- a/.github/workflows/jetbrains-compliance.yml +++ b/.github/workflows/jetbrains-compliance.yml @@ -50,7 +50,7 @@ jobs: - name: Comment PR with compliance status if: github.event_name == 'pull_request' && failure() - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | github.rest.issues.createComment({ From 3867a156e7b71ad14c7378521eb43c3d71ada7fe Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 16 Sep 2025 23:34:34 +0300 Subject: [PATCH 67/93] fix: report errors while running actions (#193) JetBrains team reported in the past a couple of errors in the log, one of them being `A workspace build is already active`. The issue can be reproduced if the user hits the `Stop` action for example quite quick. It takes maybe one or two seconds to make rest api request, then for the backend to enqueue the build and change the workspace action. If we hit the action buttons really fast then this error could be reproduced. One approach I tried was to disable the action buttons in the context menu for the duration the request is executed. But for some reason the "enabled" property is not working in context menu, only when the actions are rendered on a UI "page". Instead, I decided to refactor the existing code and (also) visually report the errors in the UI screen to make the user aware in some cases that a job is already running on the backend. Another error reported by JetBrains is a `RejectedExecutionException` in the rest api client, and from the stack trace it seems the thread pool in the rest client was at some point shutdown. I think it is some sort of race condition, some thread calling shutting down the rest api client while the UI thread still executes polling and user's action. I tried to reproduce the issue with no success, and so I'm improving the logging around plugin de-initialization in the hope that next time the sequence of events is more helpful. --- CHANGELOG.md | 1 + .../coder/toolbox/CoderRemoteEnvironment.kt | 83 +++++++++---------- .../com/coder/toolbox/CoderRemoteProvider.kt | 24 +++--- .../toolbox/views/CoderCliSetupWizardPage.kt | 12 +-- .../com/coder/toolbox/views/CoderPage.kt | 24 +++++- .../coder/toolbox/views/CoderSettingsPage.kt | 2 +- 6 files changed, 80 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee09b84..22230b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - token is no longer required when authentication is done via certificates +- errors while running actions are now reported ## 0.6.4 - 2025-09-03 diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index d4531a6..df27a37 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -81,68 +81,61 @@ class CoderRemoteEnvironment( private fun getAvailableActions(): List { val actions = mutableListOf() if (wsRawStatus.canStop()) { - actions.add(Action(context.i18n.ptrl("Open web terminal")) { - context.cs.launch(CoroutineName("Open Web Terminal Action")) { - context.desktop.browse(client.url.withPath("/${workspace.ownerName}/$name/terminal").toString()) { - context.ui.showErrorInfoPopup(it) - } + actions.add(Action(context, "Open web terminal") { + context.desktop.browse(client.url.withPath("/${workspace.ownerName}/$name/terminal").toString()) { + context.ui.showErrorInfoPopup(it) } - }) + } + ) } actions.add( - Action(context.i18n.ptrl("Open in dashboard")) { - context.cs.launch(CoroutineName("Open in Dashboard Action")) { - context.desktop.browse( - client.url.withPath("/@${workspace.ownerName}/${workspace.name}").toString() - ) { - context.ui.showErrorInfoPopup(it) - } - } - }) - - actions.add(Action(context.i18n.ptrl("View template")) { - context.cs.launch(CoroutineName("View Template Action")) { - context.desktop.browse(client.url.withPath("/templates/${workspace.templateName}").toString()) { + Action(context, "Open in dashboard") { + context.desktop.browse( + client.url.withPath("/@${workspace.ownerName}/${workspace.name}").toString() + ) { context.ui.showErrorInfoPopup(it) } } - }) + ) + + actions.add(Action(context, "View template") { + context.desktop.browse(client.url.withPath("/templates/${workspace.templateName}").toString()) { + context.ui.showErrorInfoPopup(it) + } + } + ) if (wsRawStatus.canStart()) { if (workspace.outdated) { - actions.add(Action(context.i18n.ptrl("Update and start")) { - context.cs.launch(CoroutineName("Update and Start Action")) { - val build = client.updateWorkspace(workspace) - update(workspace.copy(latestBuild = build), agent) - } - }) + actions.add(Action(context, "Update and start") { + val build = client.updateWorkspace(workspace) + update(workspace.copy(latestBuild = build), agent) + } + ) } else { - actions.add(Action(context.i18n.ptrl("Start")) { - context.cs.launch(CoroutineName("Start Action")) { - val build = client.startWorkspace(workspace) - update(workspace.copy(latestBuild = build), agent) + actions.add(Action(context, "Start") { + val build = client.startWorkspace(workspace) + update(workspace.copy(latestBuild = build), agent) - } - }) + } + ) } } if (wsRawStatus.canStop()) { if (workspace.outdated) { - actions.add(Action(context.i18n.ptrl("Update and restart")) { - context.cs.launch(CoroutineName("Update and Restart Action")) { - val build = client.updateWorkspace(workspace) - update(workspace.copy(latestBuild = build), agent) - } - }) - } - actions.add(Action(context.i18n.ptrl("Stop")) { - context.cs.launch(CoroutineName("Stop Action")) { - tryStopSshConnection() - - val build = client.stopWorkspace(workspace) + actions.add(Action(context, "Update and restart") { + val build = client.updateWorkspace(workspace) update(workspace.copy(latestBuild = build), agent) } - }) + ) + } + actions.add(Action(context, "Stop") { + tryStopSshConnection() + + val build = client.stopWorkspace(workspace) + update(workspace.copy(latestBuild = build), agent) + } + ) } return actions } diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 5d5acbb..f076123 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -202,8 +202,10 @@ class CoderRemoteProvider( * first page. */ private fun logout() { + context.logger.info("Logging out ${client?.me?.username}...") WorkspaceConnectionManager.reset() close() + context.logger.info("User ${client?.me?.username} logged out successfully") } /** @@ -222,15 +224,13 @@ class CoderRemoteProvider( override val additionalPluginActions: StateFlow> = MutableStateFlow( listOf( - Action(context.i18n.ptrl("Create workspace")) { - context.cs.launch(CoroutineName("Create Workspace Action")) { - context.desktop.browse(client?.url?.withPath("/templates").toString()) { - context.ui.showErrorInfoPopup(it) - } + Action(context, "Create workspace") { + context.desktop.browse(client?.url?.withPath("/templates").toString()) { + context.ui.showErrorInfoPopup(it) } }, CoderDelimiter(context.i18n.pnotr("")), - Action(context.i18n.ptrl("Settings")) { + Action(context, "Settings") { context.ui.showUiPage(settingsPage) }, ) @@ -246,12 +246,16 @@ class CoderRemoteProvider( it.cancel() context.logger.info("Cancelled workspace poll job ${pollJob.toString()}") } - client?.close() + client?.let { + it.close() + context.logger.info("REST API client closed and resources released") + } + client = null lastEnvironments.clear() environments.value = LoadableState.Value(emptyList()) isInitialized.update { false } - client = null CoderCliSetupWizardState.goToFirstStep() + context.logger.info("Coder plugin is now closed") } override val svgIcon: SvgIcon = @@ -319,12 +323,12 @@ class CoderRemoteProvider( uri, shouldDoAutoSetup() ) { restClient, cli -> - // stop polling and de-initialize resources + context.logger.info("Stopping workspace polling and de-initializing resources") close() isInitialized.update { false } - // start initialization with the new settings + context.logger.info("Starting initialization with the new settings") this@CoderRemoteProvider.client = restClient coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString())) diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt index bca3606..eca1179 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt @@ -24,9 +24,9 @@ class CoderCliSetupWizardPage( ) -> Unit, ) : CoderPage(MutableStateFlow(context.i18n.ptrl("Setting up Coder")), false) { private val shouldAutoSetup = MutableStateFlow(initialAutoSetup) - private val settingsAction = Action(context.i18n.ptrl("Settings"), actionBlock = { + private val settingsAction = Action(context, "Settings") { context.ui.showUiPage(settingsPage) - }) + } private val deploymentUrlStep = DeploymentUrlStep(context, visibilityState) private val tokenStep = TokenStep(context) @@ -60,7 +60,7 @@ class CoderCliSetupWizardPage( } actionButtons.update { listOf( - Action(context.i18n.ptrl("Next"), closesPage = false, actionBlock = { + Action(context, "Next", closesPage = false, actionBlock = { if (deploymentUrlStep.onNext()) { displaySteps() } @@ -77,13 +77,13 @@ class CoderCliSetupWizardPage( } actionButtons.update { listOf( - Action(context.i18n.ptrl("Connect"), closesPage = false, actionBlock = { + Action(context, "Connect", closesPage = false, actionBlock = { if (tokenStep.onNext()) { displaySteps() } }), settingsAction, - Action(context.i18n.ptrl("Back"), closesPage = false, actionBlock = { + Action(context, "Back", closesPage = false, actionBlock = { tokenStep.onBack() displaySteps() }) @@ -99,7 +99,7 @@ class CoderCliSetupWizardPage( actionButtons.update { listOf( settingsAction, - Action(context.i18n.ptrl("Back"), closesPage = false, actionBlock = { + Action(context, "Back", closesPage = false, actionBlock = { connectStep.onBack() shouldAutoSetup.update { false diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt index eec0765..7a8c5a5 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt @@ -1,12 +1,16 @@ package com.coder.toolbox.views +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.sdk.ex.APIResponseException import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon.IconType import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription import com.jetbrains.toolbox.api.ui.components.UiPage +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch /** * Base page that handles the icon, displaying error notifications, and @@ -48,15 +52,27 @@ abstract class CoderPage( * An action that simply runs the provided callback. */ class Action( - description: LocalizableString, + private val context: CoderToolboxContext, + private val description: String, closesPage: Boolean = false, enabled: () -> Boolean = { true }, - private val actionBlock: () -> Unit, + private val actionBlock: suspend () -> Unit, ) : RunnableActionDescription { - override val label: LocalizableString = description + override val label: LocalizableString = context.i18n.ptrl(description) override val shouldClosePage: Boolean = closesPage override val isEnabled: Boolean = enabled() override fun run() { - actionBlock() + context.cs.launch(CoroutineName("$description Action")) { + try { + actionBlock() + } catch (ex: Exception) { + val textError = if (ex is APIResponseException) { + if (!ex.reason.isNullOrBlank()) { + ex.reason + } else ex.message + } else ex.message + context.logAndShowError("Error while running `$description`", textError ?: "", ex) + } + } } } diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index 3444683..5d5f115 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -116,7 +116,7 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf override val actionButtons: StateFlow> = MutableStateFlow( listOf( - Action(context.i18n.ptrl("Save"), closesPage = true) { + Action(context, "Save", closesPage = true) { context.settingsStore.updateBinarySource(binarySourceField.contentState.value) context.settingsStore.updateBinaryDirectory(binaryDirectoryField.contentState.value) context.settingsStore.updateDataDirectory(dataDirectoryField.contentState.value) From ec17eef6ec2ec6277e8452c7100553b731d374c6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 23:24:55 +0300 Subject: [PATCH 68/93] Changelog update - `v0.6.5` (#194) Current pull request contains patched `CHANGELOG.md` file for the `v0.6.5` version. Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22230b9..69fc079 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.6.5 - 2025-09-16 + ### Fixed - token is no longer required when authentication is done via certificates From 7005d1e7cae2aace799292cb8f515013589601d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 23:26:51 +0300 Subject: [PATCH 69/93] chore: bump bouncycastle from 1.81 to 1.82 (#196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps `bouncycastle` from 1.81 to 1.82. Updates `org.bouncycastle:bcpg-jdk18on` from 1.81 to 1.82
Changelog

Sourced from org.bouncycastle:bcpg-jdk18on's changelog.

2.1.1 Version Release: 1.82 Date:      2025, 17th September.

... (truncated)

Commits

Updates `org.bouncycastle:bcprov-jdk18on` from 1.81 to 1.82
Changelog

Sourced from org.bouncycastle:bcprov-jdk18on's changelog.

2.1.1 Version Release: 1.82 Date:      2025, 17th September.

... (truncated)

Commits

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5d03af3..2e12b62 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,7 @@ gettext = "0.7.0" plugin-structure = "3.316" mockk = "1.14.5" detekt = "1.23.8" -bouncycastle = "1.81" +bouncycastle = "1.82" [libraries] toolbox-core-api = { module = "com.jetbrains.toolbox:core-api", version.ref = "toolbox-plugin-api" } From c00704dfe5c2783593d09c342280ba638767352b Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 23 Sep 2025 00:27:21 +0300 Subject: [PATCH 70/93] fix: relaxed SNI hostname resolution (#197) When establishing TLS connections, SNI resolution may fail if the configured altHostname contains `_` or any other characters not allowed by domain name standards (i.e. letters, digits and hyphens). This change introduces a relaxed SNI resolution strategy which ignores the LDH rules completely. Because this change goes hand in hand with auth. via certificates, I was able to reproduce the issue only via UTs. At this point the official Coder releases supports only auth. via API keys. --- CHANGELOG.md | 4 + gradle.properties | 2 +- src/main/kotlin/com/coder/toolbox/util/TLS.kt | 22 +- .../util/AlternateNameSSLSocketFactoryTest.kt | 237 +++++++++++++++++ .../toolbox/util/CoderHostnameVerifierTest.kt | 238 ++++++++++++++++++ 5 files changed, 497 insertions(+), 6 deletions(-) create mode 100644 src/test/kotlin/com/coder/toolbox/util/AlternateNameSSLSocketFactoryTest.kt create mode 100644 src/test/kotlin/com/coder/toolbox/util/CoderHostnameVerifierTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 69fc079..96082fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixed + +- relaxed SNI hostname resolution + ## 0.6.5 - 2025-09-16 ### Fixed diff --git a/gradle.properties b/gradle.properties index a2e2a3f..da96b92 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.6.5 +version=0.6.6 group=com.coder.toolbox name=coder-toolbox \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/util/TLS.kt b/src/main/kotlin/com/coder/toolbox/util/TLS.kt index dac816e..0a4b72f 100644 --- a/src/main/kotlin/com/coder/toolbox/util/TLS.kt +++ b/src/main/kotlin/com/coder/toolbox/util/TLS.kt @@ -4,8 +4,10 @@ import com.coder.toolbox.settings.ReadOnlyTLSSettings import okhttp3.internal.tls.OkHostnameVerifier import java.io.File import java.io.FileInputStream +import java.net.IDN import java.net.InetAddress import java.net.Socket +import java.nio.charset.StandardCharsets import java.security.KeyFactory import java.security.KeyStore import java.security.cert.CertificateException @@ -18,11 +20,12 @@ import java.util.Locale import javax.net.ssl.HostnameVerifier import javax.net.ssl.KeyManager import javax.net.ssl.KeyManagerFactory -import javax.net.ssl.SNIHostName +import javax.net.ssl.SNIServerName import javax.net.ssl.SSLContext import javax.net.ssl.SSLSession import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.StandardConstants import javax.net.ssl.TrustManager import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager @@ -83,11 +86,13 @@ fun sslContextFromPEMs( fun coderSocketFactory(settings: ReadOnlyTLSSettings): SSLSocketFactory { val sslContext = sslContextFromPEMs(settings.certPath, settings.keyPath, settings.caPath) - if (settings.altHostname.isNullOrBlank()) { + + val altHostname = settings.altHostname + if (altHostname.isNullOrBlank()) { return sslContext.socketFactory } - return AlternateNameSSLSocketFactory(sslContext.socketFactory, settings.altHostname) + return AlternateNameSSLSocketFactory(sslContext.socketFactory, altHostname) } fun coderTrustManagers(tlsCAPath: String?): Array { @@ -111,7 +116,7 @@ fun coderTrustManagers(tlsCAPath: String?): Array { return trustManagerFactory.trustManagers.map { MergedSystemTrustManger(it as X509TrustManager) }.toTypedArray() } -class AlternateNameSSLSocketFactory(private val delegate: SSLSocketFactory, private val alternateName: String?) : +class AlternateNameSSLSocketFactory(private val delegate: SSLSocketFactory, private val alternateName: String) : SSLSocketFactory() { override fun getDefaultCipherSuites(): Array = delegate.defaultCipherSuites @@ -176,12 +181,19 @@ class AlternateNameSSLSocketFactory(private val delegate: SSLSocketFactory, priv private fun customizeSocket(socket: SSLSocket) { val params = socket.sslParameters - params.serverNames = listOf(SNIHostName(alternateName)) + + params.serverNames = listOf(RelaxedSNIHostname(alternateName)) socket.sslParameters = params } } +private class RelaxedSNIHostname(hostname: String) : SNIServerName( + StandardConstants.SNI_HOST_NAME, + IDN.toASCII(hostname, 0).toByteArray(StandardCharsets.UTF_8) +) + class CoderHostnameVerifier(private val alternateName: String?) : HostnameVerifier { + override fun verify( host: String, session: SSLSession, diff --git a/src/test/kotlin/com/coder/toolbox/util/AlternateNameSSLSocketFactoryTest.kt b/src/test/kotlin/com/coder/toolbox/util/AlternateNameSSLSocketFactoryTest.kt new file mode 100644 index 0000000..1b5460f --- /dev/null +++ b/src/test/kotlin/com/coder/toolbox/util/AlternateNameSSLSocketFactoryTest.kt @@ -0,0 +1,237 @@ +package com.coder.toolbox.util + +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import java.net.InetAddress +import java.net.Socket +import javax.net.ssl.SSLParameters +import javax.net.ssl.SSLSocket +import javax.net.ssl.SSLSocketFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertSame + + +class AlternateNameSSLSocketFactoryTest { + + @Test + fun `createSocket with no parameters should customize socket with alternate name`() { + // Given + val mockFactory = mockk() + val mockSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + + every { mockFactory.createSocket() } returns mockSocket + every { mockSocket.sslParameters } returns mockParams + every { mockSocket.sslParameters = any() } just Runs + + val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "alternate.example.com") + + // When + val result = alternateFactory.createSocket() + + // Then + verify { mockSocket.sslParameters = any() } + assertSame(mockSocket, result) + } + + @Test + fun `createSocket with host and port should customize socket with alternate name`() { + // Given + val mockFactory = mockk() + val mockSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + + every { mockFactory.createSocket("original.com", 443) } returns mockSocket + every { mockSocket.sslParameters } returns mockParams + every { mockSocket.sslParameters = any() } just Runs + + val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "alternate.example.com") + + // When + val result = alternateFactory.createSocket("original.com", 443) + + // Then + verify { mockSocket.sslParameters = any() } + assertSame(mockSocket, result) + } + + @Test + fun `createSocket with host port and local address should customize socket`() { + // Given + val mockFactory = mockk() + val mockSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + val localHost = mockk() + + every { mockFactory.createSocket("original.com", 443, localHost, 8080) } returns mockSocket + every { mockSocket.sslParameters } returns mockParams + every { mockSocket.sslParameters = any() } just Runs + + val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "alternate.example.com") + + // When + val result = alternateFactory.createSocket("original.com", 443, localHost, 8080) + + // Then + verify { mockSocket.sslParameters = any() } + assertSame(mockSocket, result) + } + + @Test + fun `createSocket with InetAddress should customize socket with alternate name`() { + // Given + val mockFactory = mockk() + val mockSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + val address = mockk() + + every { mockFactory.createSocket(address, 443) } returns mockSocket + every { mockSocket.sslParameters } returns mockParams + every { mockSocket.sslParameters = any() } just Runs + + val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "alternate.example.com") + + // When + val result = alternateFactory.createSocket(address, 443) + + // Then + verify { mockSocket.sslParameters = any() } + assertSame(mockSocket, result) + } + + @Test + fun `createSocket with InetAddress and local address should customize socket`() { + // Given + val mockFactory = mockk() + val mockSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + val address = mockk() + val localAddress = mockk() + + every { mockFactory.createSocket(address, 443, localAddress, 8080) } returns mockSocket + every { mockSocket.sslParameters } returns mockParams + every { mockSocket.sslParameters = any() } just Runs + + val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "alternate.example.com") + + // When + val result = alternateFactory.createSocket(address, 443, localAddress, 8080) + + // Then + verify { mockSocket.sslParameters = any() } + assertSame(mockSocket, result) + } + + @Test + fun `createSocket with existing socket should customize socket with alternate name`() { + // Given + val mockFactory = mockk() + val mockSSLSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + val existingSocket = mockk() + + every { mockFactory.createSocket(existingSocket, "original.com", 443, true) } returns mockSSLSocket + every { mockSSLSocket.sslParameters } returns mockParams + every { mockSSLSocket.sslParameters = any() } just Runs + + val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "alternate.example.com") + + // When + val result = alternateFactory.createSocket(existingSocket, "original.com", 443, true) + + // Then + verify { mockSSLSocket.sslParameters = any() } + assertSame(mockSSLSocket, result) + } + + @Test + fun `customizeSocket should set SNI hostname to alternate name for valid hostname`() { + // Given + val mockFactory = mockk() + val mockSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + + every { mockFactory.createSocket() } returns mockSocket + every { mockSocket.sslParameters } returns mockParams + every { mockSocket.sslParameters = any() } just Runs + + val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "valid-hostname.example.com") + + // When & Then - This should work without throwing an exception + assertNotNull(alternateFactory.createSocket()) + verify { mockSocket.sslParameters = any() } + } + + @Test + fun `customizeSocket should NOT throw IllegalArgumentException for hostname with underscore`() { + // Given + val mockFactory = mockk() + val mockSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + + every { mockFactory.createSocket() } returns mockSocket + every { mockSocket.sslParameters } returns mockParams + every { mockSocket.sslParameters = any() } just Runs + + val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "non_compliant_hostname.example.com") + + // When & Then - This should work without throwing an exception + assertNotNull(alternateFactory.createSocket()) + verify { mockSocket.sslParameters = any() } + assertEquals(0, mockSocket.sslParameters.serverNames.size) + } + + @Test + fun `createSocket should work with valid international domain names`() { + // Given + val mockFactory = mockk() + val mockSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + + every { mockFactory.createSocket() } returns mockSocket + every { mockSocket.sslParameters } returns mockParams + every { mockSocket.sslParameters = any() } just Runs + + val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "test-server.example.com") + + // When & Then - This should work as hyphens are valid + assertNotNull(alternateFactory.createSocket()) + verify { mockSocket.sslParameters = any() } + } + + private fun createMockSSLSocketFactory(): SSLSocketFactory { + val mockFactory = mockk() + val mockSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + + // Setup default behavior + every { mockFactory.defaultCipherSuites } returns arrayOf("TLS_AES_256_GCM_SHA384") + every { mockFactory.supportedCipherSuites } returns arrayOf("TLS_AES_256_GCM_SHA384", "TLS_AES_128_GCM_SHA256") + + // Make all createSocket methods return our mock socket + every { mockFactory.createSocket() } returns mockSocket + every { mockFactory.createSocket(any(), any()) } returns mockSocket + every { mockFactory.createSocket(any(), any(), any(), any()) } returns mockSocket + every { mockFactory.createSocket(any(), any()) } returns mockSocket + every { + mockFactory.createSocket( + any(), + any(), + any(), + any() + ) + } returns mockSocket + every { mockFactory.createSocket(any(), any(), any(), any()) } returns mockSocket + + // Setup SSL parameters + every { mockSocket.sslParameters } returns mockParams + every { mockSocket.sslParameters = any() } just Runs + + return mockFactory + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/coder/toolbox/util/CoderHostnameVerifierTest.kt b/src/test/kotlin/com/coder/toolbox/util/CoderHostnameVerifierTest.kt new file mode 100644 index 0000000..f2bb0d2 --- /dev/null +++ b/src/test/kotlin/com/coder/toolbox/util/CoderHostnameVerifierTest.kt @@ -0,0 +1,238 @@ +package com.coder.toolbox.util + +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.slf4j.Logger +import java.security.cert.Certificate +import java.security.cert.X509Certificate +import javax.net.ssl.SSLSession +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CoderHostnameVerifierTest { + + private lateinit var sslSession: SSLSession + private lateinit var x509Certificate: X509Certificate + private lateinit var logger: Logger + private lateinit var verifier: CoderHostnameVerifier + + @BeforeEach + fun setUp() { + sslSession = mockk() + x509Certificate = mockk() + logger = mockk(relaxed = true) + } + + @Test + fun `should return false when no certificates are present`() { + // Given + verifier = CoderHostnameVerifier("test_host.example.com") + every { sslSession.peerCertificates } returns null + + // When + val result = verifier.verify("example.com", sslSession) + + // Then + assertFalse(result) + } + + @Test + fun `should return false when certificates array is empty`() { + // Given + verifier = CoderHostnameVerifier("test_host.example.com") + every { sslSession.peerCertificates } returns arrayOf() + + // When + val result = verifier.verify("example.com", sslSession) + + // Then + assertFalse(result) + } + + @Test + fun `should return true when SAN contains matching alternate name with underscore`() { + // Given + val alternateNameWithUnderscore = "test_server.internal.com" + verifier = CoderHostnameVerifier(alternateNameWithUnderscore) + + // Mock certificate with SAN containing underscore + val sanEntries = listOf( + listOf(2, "example.com"), // Standard DNS name + listOf(2, "test_server.internal.com"), // SAN with underscore + listOf(2, "api.example.com") // Another DNS name + ) + + every { sslSession.peerCertificates } returns arrayOf(x509Certificate) + every { x509Certificate.subjectAlternativeNames } returns sanEntries + + // When + val result = verifier.verify("example.com", sslSession) + + // Then + assertTrue(result, "Should return true when SAN contains matching alternate name with underscore") + } + + @Test + fun `should return false when SAN does not contain matching alternate name`() { + // Given + verifier = CoderHostnameVerifier("missing_host.example.com") + + // Mock certificate without matching SAN + val sanEntries = listOf( + listOf(2, "example.com"), + listOf(2, "api.example.com"), + listOf(2, "different_host.example.com") + ) + + every { sslSession.peerCertificates } returns arrayOf(x509Certificate) + every { x509Certificate.subjectAlternativeNames } returns sanEntries + + // When + val result = verifier.verify("example.com", sslSession) + + // Then + assertFalse(result, "Should return false when SAN does not contain matching alternate name") + } + + @Test + fun `should ignore non-DNS SAN entries`() { + // Given + verifier = CoderHostnameVerifier("test_host.example.com") + + // Mock certificate with various SAN types + val sanEntries = listOf( + listOf(1, "user@example.com"), // Email (type 1) + listOf(6, "http://example.com"), // URI (type 6) + listOf(7, "192.168.1.1"), // IP Address (type 7) + listOf(2, "test_host.example.com") // DNS Name (type 2) - this should match + ) + + every { sslSession.peerCertificates } returns arrayOf(x509Certificate) + every { x509Certificate.subjectAlternativeNames } returns sanEntries + + // When + val result = verifier.verify("example.com", sslSession) + + // Then + assertTrue(result, "Should ignore non-DNS SAN entries and find the matching DNS entry") + } + + @Test + fun `should return false when certificate has no SAN extension`() { + // Given + verifier = CoderHostnameVerifier("test_host.example.com") + + every { sslSession.peerCertificates } returns arrayOf(x509Certificate) + every { x509Certificate.subjectAlternativeNames } returns null + + // When + val result = verifier.verify("example.com", sslSession) + + // Then + assertFalse(result, "Should return false when certificate has no SAN extension") + } + + @Test + fun `should handle multiple certificates and find match in second certificate`() { + // Given + verifier = CoderHostnameVerifier("api_server.internal.com") + + val cert1Mock = mockk() + val cert2Mock = mockk() + + // First certificate has no matching SAN + val sanEntries1 = listOf( + listOf(2, "example.com"), + listOf(2, "www.example.com") + ) + + // Second certificate has matching SAN with underscore + val sanEntries2 = listOf( + listOf(2, "internal.com"), + listOf(2, "api_server.internal.com") + ) + + every { sslSession.peerCertificates } returns arrayOf(cert1Mock, cert2Mock) + every { cert1Mock.subjectAlternativeNames } returns sanEntries1 + every { cert2Mock.subjectAlternativeNames } returns sanEntries2 + + // When + val result = verifier.verify("example.com", sslSession) + + // Then + assertTrue(result, "Should find match in second certificate") + } + + @Test + fun `should handle non-X509 certificates gracefully`() { + // Given + verifier = CoderHostnameVerifier("test_host.example.com") + + val nonX509Cert = mockk() // Not an X509Certificate + every { sslSession.peerCertificates } returns arrayOf(nonX509Cert, x509Certificate) + + val sanEntries = listOf( + listOf(2, "test_host.example.com") + ) + every { x509Certificate.subjectAlternativeNames } returns sanEntries + + // When + val result = verifier.verify("example.com", sslSession) + + // Then + assertTrue(result, "Should skip non-X509 certificates and process X509 certificates") + } + + @Test + fun `should reproduce the underscore bug scenario`() { + // Given - This test reproduces the exact scenario from the bug report + val problematicHostname = "coder_instance.dev.company.com" + verifier = CoderHostnameVerifier(problematicHostname) + + // Mock a certificate that would be valid but contains underscore in SAN + val sanEntries = listOf( + listOf(2, "dev.company.com"), + listOf(2, "coder_instance.dev.company.com"), // This contains underscore + listOf(2, "*.dev.company.com") + ) + + every { x509Certificate.subjectAlternativeNames } returns sanEntries + every { sslSession.peerCertificates } returns arrayOf(x509Certificate) + + // When + val result = verifier.verify("dev.company.com", sslSession) + + // Then + assertTrue(result, "Should successfully verify hostname with underscore in SAN") + + // Additional verification that the problematic hostname would be found + val foundHostnames = mutableListOf() + sanEntries.forEach { entry -> + if (entry[0] == 2) { // DNS name type + foundHostnames.add(entry[1] as String) + } + } + + assertTrue( + foundHostnames.any { it.equals(problematicHostname, ignoreCase = true) }, + "Certificate should contain the problematic hostname with underscore" + ) + } + + @Test + fun `should handle edge case with empty SAN list`() { + // Given + verifier = CoderHostnameVerifier("test_host.example.com") + + every { sslSession.peerCertificates } returns arrayOf(x509Certificate) + every { x509Certificate.subjectAlternativeNames } returns emptyList() + + // When + val result = verifier.verify("example.com", sslSession) + + // Then + assertFalse(result, "Should return false when SAN list is empty") + } +} \ No newline at end of file From 757ee87569f1f7958446ed133e3a46353b9ea52b Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 24 Sep 2025 21:50:04 +0300 Subject: [PATCH 71/93] impl: confirmation dialog for workspace deletion (#179) Users are now required to confirm the workspace name if they want to delete a workspace. This is in order to avoid any accidental removals. Note: right now there are two issues with Toolbox input dialogs, the dialog title is not rendered, and worse - the text field is rendered as a password input field, so it does not make sense to merge this until Toolbox fixes the issues. image - resolves #178 --- CHANGELOG.md | 4 + .../coder/toolbox/CoderRemoteEnvironment.kt | 78 +++++++++++-------- .../com/coder/toolbox/CoderRemoteProvider.kt | 6 +- .../com/coder/toolbox/views/CoderPage.kt | 5 ++ .../resources/localization/defaultMessages.po | 11 ++- 5 files changed, 66 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96082fd..d6a1a90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Changed + +- workspaces can no longer be removed by accident - users are now required to input the workspace name. + ### Fixed - relaxed SNI hostname resolution diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index df27a37..5bb4296 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -12,17 +12,18 @@ import com.coder.toolbox.sdk.v2.models.WorkspaceAgent import com.coder.toolbox.util.waitForFalseWithTimeout import com.coder.toolbox.util.withPath import com.coder.toolbox.views.Action +import com.coder.toolbox.views.CoderDelimiter import com.coder.toolbox.views.EnvironmentView import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.remoteDev.AfterDisconnectHook import com.jetbrains.toolbox.api.remoteDev.BeforeConnectionHook -import com.jetbrains.toolbox.api.remoteDev.DeleteEnvironmentConfirmationParams import com.jetbrains.toolbox.api.remoteDev.EnvironmentVisibilityState import com.jetbrains.toolbox.api.remoteDev.RemoteProviderEnvironment import com.jetbrains.toolbox.api.remoteDev.environments.EnvironmentContentsView import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentDescription import com.jetbrains.toolbox.api.remoteDev.states.RemoteEnvironmentState import com.jetbrains.toolbox.api.ui.actions.ActionDescription +import com.jetbrains.toolbox.api.ui.components.TextType import com.squareup.moshi.Moshi import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.Job @@ -79,7 +80,7 @@ class CoderRemoteEnvironment( fun asPairOfWorkspaceAndAgent(): Pair = Pair(workspace, agent) private fun getAvailableActions(): List { - val actions = mutableListOf() + val actions = mutableListOf() if (wsRawStatus.canStop()) { actions.add(Action(context, "Open web terminal") { context.desktop.browse(client.url.withPath("/${workspace.ownerName}/$name/terminal").toString()) { @@ -137,6 +138,28 @@ class CoderRemoteEnvironment( } ) } + actions.add(CoderDelimiter(context.i18n.pnotr(""))) + actions.add(Action(context, "Delete workspace", highlightInRed = true) { + context.cs.launch(CoroutineName("Delete Workspace Action")) { + var dialogText = + if (wsRawStatus.canStop()) "This will close the workspace and remove all its information, including files, unsaved changes, history, and usage data." + else "This will remove all information from the workspace, including files, unsaved changes, history, and usage data." + dialogText += "\n\nType \"${workspace.name}\" below to confirm:" + + val confirmation = context.ui.showTextInputPopup( + if (wsRawStatus.canStop()) context.i18n.ptrl("Delete running workspace?") else context.i18n.ptrl("Delete workspace?"), + context.i18n.pnotr(dialogText), + context.i18n.ptrl("Workspace name"), + TextType.General, + context.i18n.ptrl("OK"), + context.i18n.ptrl("Cancel") + ) + if (confirmation != workspace.name) { + return@launch + } + deleteWorkspace() + } + }) return actions } @@ -266,43 +289,32 @@ class CoderRemoteEnvironment( return false } - override fun getDeleteEnvironmentConfirmationParams(): DeleteEnvironmentConfirmationParams? { - return object : DeleteEnvironmentConfirmationParams { - override val cancelButtonText: String = "Cancel" - override val confirmButtonText: String = "Delete" - override val message: String = - if (wsRawStatus.canStop()) "Workspace will be closed and all the information will be lost, including all files, unsaved changes, historical info and usage data." - else "All the information in this workspace will be lost, including all files, unsaved changes, historical info and usage data." - override val title: String = if (wsRawStatus.canStop()) "Delete running workspace?" else "Delete workspace?" - } - } + override val deleteActionFlow: StateFlow<(() -> Unit)?> = MutableStateFlow(null) - override val deleteActionFlow: StateFlow<(() -> Unit)?> = MutableStateFlow { - context.cs.launch(CoroutineName("Delete Workspace Action")) { - try { - client.removeWorkspace(workspace) - // mark the env as deleting otherwise we will have to - // wait for the poller to update the status in the next 5 seconds - state.update { - WorkspaceAndAgentStatus.DELETING.toRemoteEnvironmentState(context) - } + suspend fun deleteWorkspace() { + try { + client.removeWorkspace(workspace) + // mark the env as deleting otherwise we will have to + // wait for the poller to update the status in the next 5 seconds + state.update { + WorkspaceAndAgentStatus.DELETING.toRemoteEnvironmentState(context) + } - context.cs.launch(CoroutineName("Workspace Deletion Poller")) { - withTimeout(5.minutes) { - var workspaceStillExists = true - while (context.cs.isActive && workspaceStillExists) { - if (wsRawStatus == WorkspaceAndAgentStatus.DELETING || wsRawStatus == WorkspaceAndAgentStatus.DELETED) { - workspaceStillExists = false - context.envPageManager.showPluginEnvironmentsPage() - } else { - delay(1.seconds) - } + context.cs.launch(CoroutineName("Workspace Deletion Poller")) { + withTimeout(5.minutes) { + var workspaceStillExists = true + while (context.cs.isActive && workspaceStillExists) { + if (wsRawStatus == WorkspaceAndAgentStatus.DELETING || wsRawStatus == WorkspaceAndAgentStatus.DELETED) { + workspaceStillExists = false + context.envPageManager.showPluginEnvironmentsPage() + } else { + delay(1.seconds) } } } - } catch (e: APIResponseException) { - context.ui.showErrorInfoPopup(e) } + } catch (e: APIResponseException) { + context.ui.showErrorInfoPopup(e) } } diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index f076123..d65484c 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -12,6 +12,7 @@ import com.coder.toolbox.util.waitForTrue import com.coder.toolbox.util.withPath import com.coder.toolbox.views.Action import com.coder.toolbox.views.CoderCliSetupWizardPage +import com.coder.toolbox.views.CoderDelimiter import com.coder.toolbox.views.CoderSettingsPage import com.coder.toolbox.views.NewEnvironmentPage import com.coder.toolbox.views.state.CoderCliSetupContext @@ -23,7 +24,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.ui.actions.ActionDelimiter import com.jetbrains.toolbox.api.ui.actions.ActionDescription import com.jetbrains.toolbox.api.ui.components.UiPage import kotlinx.coroutines.CoroutineName @@ -428,6 +428,4 @@ class CoderRemoteProvider( LoadableState.Loading } } -} - -private class CoderDelimiter(override val label: LocalizableString) : ActionDelimiter \ No newline at end of file +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt index 7a8c5a5..a7ad70f 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt @@ -5,6 +5,7 @@ import com.coder.toolbox.sdk.ex.APIResponseException import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon.IconType import com.jetbrains.toolbox.api.localization.LocalizableString +import com.jetbrains.toolbox.api.ui.actions.ActionDelimiter import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription import com.jetbrains.toolbox.api.ui.components.UiPage import kotlinx.coroutines.CoroutineName @@ -55,12 +56,14 @@ class Action( private val context: CoderToolboxContext, private val description: String, closesPage: Boolean = false, + highlightInRed: Boolean = false, enabled: () -> 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 override fun run() { context.cs.launch(CoroutineName("$description Action")) { try { @@ -76,3 +79,5 @@ class Action( } } } + +class CoderDelimiter(override val label: LocalizableString) : ActionDelimiter \ No newline at end of file diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index 8aabe3f..29351e3 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -179,4 +179,13 @@ msgid "Headers" msgstr "" msgid "Body" -msgstr "" \ No newline at end of file +msgstr "" + +msgid "Delete workspace" +msgstr "" + +msgid "Delete running workspace?" +msgstr "" + +msgid "Workspace name" +msgstr "" From 5d648efdcaff79e8b03cc0530def5ca82aa4b659 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 24 Sep 2025 22:57:10 +0300 Subject: [PATCH 72/93] Changelog update - `v0.6.6` (#198) Current pull request contains patched `CHANGELOG.md` file for the `v0.6.6` version. Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6a1a90..1fae586 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.6.6 - 2025-09-24 + ### Changed - workspaces can no longer be removed by accident - users are now required to input the workspace name. From 08c29121fc7a06aa6291c7bd4400530f7eb70d2d Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 25 Sep 2025 23:40:46 +0300 Subject: [PATCH 73/93] doc: the augmentation around SNI & certificate validation (#199) This PR documents why the SNI is altered during TLS handshake and why a certificate SAN is compared against an alternate hostname. --- src/main/kotlin/com/coder/toolbox/util/TLS.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/main/kotlin/com/coder/toolbox/util/TLS.kt b/src/main/kotlin/com/coder/toolbox/util/TLS.kt index 0a4b72f..97a5df9 100644 --- a/src/main/kotlin/com/coder/toolbox/util/TLS.kt +++ b/src/main/kotlin/com/coder/toolbox/util/TLS.kt @@ -84,6 +84,30 @@ fun sslContextFromPEMs( return sslContext } +/** + * Netflix TLS Workaround — SNI & Hostname Validation + * + * Context: + * - The Netflix servers we connect to rely on the SNI in the ClientHello + * beyond just the typical use case of serving multiple hostnames from a + * single IP. The alternate hostname for the SNI can contain underscores + * (non-compliant for hostnames). + * - The server always presents the same certificate, regardless of the SNI + * - The certificate’s SAN entries do not match the server’s DNS name, and in + * - Because of this mismatch, the TLS handshake fails unless we apply two + * client-side workarounds: + * + * 1. SNI manipulation — we rewrite the SNI in the ClientHello via a custom + * SSLSocketFactory. Even though the server’s cert does not vary by SNI, + * connections fail if this rewrite is removed. The server’s TLS stack + * appears to depend on the SNI being set in a particular way. + * + * 2. Hostname validation override — we relax certificate checks by allowing + * an “alternate hostname” to be matched against the cert SANs. This avoids + * rejections when the SAN does not align with the requested DNS name. + * + * See [this issue](https://github.com/coder/jetbrains-coder/issues/578) for more details. + */ fun coderSocketFactory(settings: ReadOnlyTLSSettings): SSLSocketFactory { val sslContext = sslContextFromPEMs(settings.certPath, settings.keyPath, settings.caPath) From 94b329f4f47f9ccde9b879b5315a411fdc9989bc Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Sat, 27 Sep 2025 11:48:56 +0300 Subject: [PATCH 74/93] impl: store last used URL in Toolbox Settings Store (#200) Context: Toolbox can store key/value pairs in two places: - a settings store which is backed by a clear text json file per each plugin - native keystore for sensitive data At the same time some of Coder's clients (ex: Netflix) would like to deploy at scale preconfigured settings for Toolbox. Most of the needed settings are part of json backed store except the last used URL. This PR reworks the code around the last used URL/token and moves the URL in the json backed store, making it easy to configure. At the same time we still support the pair stored in the native keystore for backward compatibility reasons. --- CHANGELOG.md | 4 ++++ gradle.properties | 2 +- .../com/coder/toolbox/CoderRemoteProvider.kt | 14 +++++------ .../com/coder/toolbox/CoderToolboxContext.kt | 14 +++++------ .../toolbox/settings/ReadOnlyCoderSettings.kt | 6 +++++ .../coder/toolbox/store/CoderSecretsStore.kt | 23 ++++--------------- .../coder/toolbox/store/CoderSettingsStore.kt | 5 ++++ .../com/coder/toolbox/store/StoreKeys.kt | 2 +- .../coder/toolbox/views/DeploymentUrlStep.kt | 4 ++-- 9 files changed, 37 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fae586..e4eaf18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Changed + +- simplified storage for last used url and token + ## 0.6.6 - 2025-09-24 ### Changed diff --git a/gradle.properties b/gradle.properties index da96b92..dc031f5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.6.6 +version=0.7.0 group=com.coder.toolbox name=coder-toolbox \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index d65484c..ed4854c 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -7,7 +7,6 @@ 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 -import com.coder.toolbox.util.toURL import com.coder.toolbox.util.waitForTrue import com.coder.toolbox.util.withPath import com.coder.toolbox.views.Action @@ -364,8 +363,8 @@ class CoderRemoteProvider( if (shouldDoAutoSetup()) { try { CoderCliSetupContext.apply { - url = context.secrets.lastDeploymentURL.toURL() - token = context.secrets.lastToken + url = context.deploymentUrl + token = context.secrets.tokenFor(context.deploymentUrl) } CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) return CoderCliSetupWizardPage( @@ -399,14 +398,15 @@ class CoderRemoteProvider( * Auto-login only on first the firs run if there is a url & token configured or the auth * should be done via certificates. */ - private fun shouldDoAutoSetup(): Boolean = firstRun && (context.secrets.canAutoLogin || !settings.requireTokenAuth) + private fun shouldDoAutoSetup(): Boolean = firstRun && (canAutoLogin() || !settings.requireTokenAuth) + + fun canAutoLogin(): Boolean = !context.secrets.tokenFor(context.deploymentUrl).isNullOrBlank() private fun onConnect(client: CoderRestClient, cli: CoderCLIManager) { // Store the URL and token for use next time. - context.secrets.lastDeploymentURL = client.url.toString() + context.settingsStore.updateLastUsedUrl(client.url) if (context.settingsStore.requireTokenAuth) { - context.secrets.lastToken = client.token ?: "" - context.secrets.storeTokenFor(client.url, context.secrets.lastToken) + context.secrets.storeTokenFor(client.url, client.token ?: "") context.logger.info("Deployment URL and token were stored and will be available for automatic connection") } else { context.logger.info("Deployment URL was stored and will be available for automatic connection") diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index baac820..ac3cbcc 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -36,17 +36,15 @@ data class CoderToolboxContext( * * In order of preference: * - * 1. Last used URL. - * 2. URL in settings. - * 3. CODER_URL. - * 4. URL in global cli config. + * 1. Last used URL from the settings. + * 2. Last used URL from the secrets store. + * 3. Default URL */ val deploymentUrl: URL get() { - if (this.secrets.lastDeploymentURL.isNotBlank()) { - return this.secrets.lastDeploymentURL.toURL() - } - return this.settingsStore.defaultURL.toURL() + return settingsStore.lastDeploymentURL?.takeIf { it.isNotBlank() }?.toURL() + ?: secrets.lastDeploymentURL.takeIf { it.isNotBlank() }?.toURL() + ?: settingsStore.defaultURL.toURL() } suspend fun logAndShowError(title: String, error: String) { diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt index 0000ea6..9ac6438 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -8,6 +8,12 @@ import java.util.Locale.getDefault * Read-only interface for accessing Coder settings */ interface ReadOnlyCoderSettings { + + /** + * The last used deployment URL. + */ + val lastDeploymentURL: String? + /** * The default URL to show in the connection window. */ diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt index a807b69..a5466b4 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt @@ -8,24 +8,11 @@ import java.net.URL * Provides Coder secrets backed by the secrets store service. */ class CoderSecretsStore(private val store: PluginSecretStore) { - private fun get(key: String): String = store[key] ?: "" - - private fun set(key: String, value: String) { - if (value.isBlank()) { - store.clear(key) - } else { - store[key] = value - } - } - - var lastDeploymentURL: String - get() = get("last-deployment-url") - set(value) = set("last-deployment-url", value) - var lastToken: String - get() = get("last-token") - set(value) = set("last-token", value) - val canAutoLogin: Boolean - get() = lastDeploymentURL.isNotBlank() && lastToken.isNotBlank() + @Deprecated( + message = "The URL is now stored the JSON backed settings store. Use CoderSettingsStore#lastDeploymentURL", + replaceWith = ReplaceWith("context.settingsStore.lastDeploymentURL") + ) + val lastDeploymentURL: String = store["last-deployment-url"] ?: "" fun tokenFor(url: URL): String? = store[url.host] diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index f770da8..66706ca 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -36,6 +36,7 @@ class CoderSettingsStore( ) : ReadOnlyTLSSettings // Properties implementation + override val lastDeploymentURL: String? get() = store[LAST_USED_URL] override val defaultURL: String get() = store[DEFAULT_URL] ?: "https://dev.coder.com" override val binarySource: String? get() = store[BINARY_SOURCE] override val binaryDirectory: String? get() = store[BINARY_DIRECTORY] @@ -155,6 +156,10 @@ class CoderSettingsStore( fun readOnly(): ReadOnlyCoderSettings = this // Write operations + fun updateLastUsedUrl(url: URL) { + store[LAST_USED_URL] = url.toString() + } + fun updateBinarySource(source: String) { store[BINARY_SOURCE] = source } diff --git a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt index 5f8f5af..555c6b5 100644 --- a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt +++ b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt @@ -2,7 +2,7 @@ package com.coder.toolbox.store internal const val CODER_SSH_CONFIG_OPTIONS = "CODER_SSH_CONFIG_OPTIONS" -internal const val CODER_URL = "CODER_URL" +internal const val LAST_USED_URL = "lastDeploymentURL" internal const val DEFAULT_URL = "defaultURL" diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index be3d4d0..27e53f9 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -63,8 +63,8 @@ class DeploymentUrlStep( errorField.textState.update { context.i18n.pnotr("") } - urlField.textState.update { - context.secrets.lastDeploymentURL + urlField.contentState.update { + context.deploymentUrl.toString() } signatureFallbackStrategyField.checkedState.update { From b316d1d92db00a1a228a010f3aeab068c6cbb4ab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 29 Sep 2025 21:07:56 +0300 Subject: [PATCH 75/93] Changelog update - `v0.7.0` (#202) Current pull request contains patched `CHANGELOG.md` file for the `v0.7.0` version. Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4eaf18..d45a85c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.7.0 - 2025-09-27 + ### Changed - simplified storage for last used url and token From 29bec3e257fa73d0855e2a8b665bad425881d5aa Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 3 Oct 2025 22:46:04 +0300 Subject: [PATCH 76/93] test: rewrite UTs related to agent resolution in URI handling (#203) Inspired by https://github.com/coder/jetbrains-coder/pull/585/commits/5f0e3633d7da533a24ef25172b5a664a5bdd169b which took a while to debug and understand. This rewrite arguably provides better test names, better data setup with cleaner descriptions. --- .../toolbox/util/CoderProtocolHandlerTest.kt | 237 +++++++++++------- 1 file changed, 147 insertions(+), 90 deletions(-) diff --git a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt index 56402e5..4a9ef88 100644 --- a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt @@ -21,13 +21,27 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking -import org.junit.jupiter.api.DisplayName import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull internal class CoderProtocolHandlerTest { + + private companion object { + val AGENT_RIKER = AgentTestData(name = "Riker", id = "9a920eee-47fb-4571-9501-e4b3120c12f2") + val AGENT_BILL = AgentTestData(name = "Bill", id = "fb3daea4-da6b-424d-84c7-36b90574cfef") + val AGENT_BOB = AgentTestData(name = "Bob", id = "b0e4c54d-9ba9-4413-8512-11ca1e826a24") + + val ALL_AGENTS = mapOf( + AGENT_BOB.name to AGENT_BOB.id, + AGENT_BILL.name to AGENT_BILL.id, + AGENT_RIKER.name to AGENT_RIKER.id + ) + + val SINGLE_AGENT = mapOf(AGENT_BOB.name to AGENT_BOB.id) + } + private val context = CoderToolboxContext( mockk(relaxed = true), mockk(), @@ -51,128 +65,171 @@ internal class CoderProtocolHandlerTest { MutableStateFlow(false) ) - private val agents = - mapOf( - "agent_name_bob" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24", - "agent_name_bill" to "fb3daea4-da6b-424d-84c7-36b90574cfef", - "agent_name_riker" to "9a920eee-47fb-4571-9501-e4b3120c12f2", - ) - private val agentBob = - mapOf( - "agent_name_bob" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24", - ) - @Test - @DisplayName("given a ws with multiple agents, expect the correct agent to be resolved if it matches the agent_name query param") - fun getMatchingAgent() { - val ws = DataGen.workspace("ws", agents = agents) - - val tests = - listOf( - Pair( - mapOf("agent_name" to "agent_name_riker"), - "9a920eee-47fb-4571-9501-e4b3120c12f2" - ), - Pair( - mapOf("agent_name" to "agent_name_bill"), - "fb3daea4-da6b-424d-84c7-36b90574cfef" - ), - Pair( - mapOf("agent_name" to "agent_name_bob"), - "b0e4c54d-9ba9-4413-8512-11ca1e826a24" - ) + fun `given a workspace with multiple agents when getMatchingAgent is called with a valid agent name then it correctly resolves resolves an agent`() { + val ws = DataGen.workspace("ws", agents = ALL_AGENTS) + + val testCases = listOf( + AgentMatchTestCase( + "resolves agent with name Riker", + mapOf("agent_name" to AGENT_RIKER.name), + AGENT_RIKER.uuid + ), + AgentMatchTestCase( + "resolves agent with name Bill", + mapOf("agent_name" to AGENT_BILL.name), + AGENT_BILL.uuid + ), + AgentMatchTestCase( + "resolves agent with name Bob", + mapOf("agent_name" to AGENT_BOB.name), + AGENT_BOB.uuid ) + ) + runBlocking { - tests.forEach { - assertEquals(UUID.fromString(it.second), protocolHandler.getMatchingAgent(it.first, ws)?.id) + testCases.forEach { testCase -> + assertEquals( + testCase.expectedAgentId, + protocolHandler.getMatchingAgent(testCase.params, ws)?.id, + "Failed: ${testCase.description}" + ) } } } @Test - @DisplayName("given a ws with only multiple agents expect the agent resolution to fail if none match the agent_name query param") - fun failsToGetMatchingAgent() { - val ws = DataGen.workspace("ws", agents = agents) - val tests = - listOf( - Triple(emptyMap(), MissingArgumentException::class, "Unable to determine"), - Triple(mapOf("agent_name" to ""), MissingArgumentException::class, "Unable to determine"), - Triple(mapOf("agent_name" to null), MissingArgumentException::class, "Unable to determine"), - Triple(mapOf("agent_name" to "not-an-agent-name"), IllegalArgumentException::class, "agent with ID"), - Triple( - mapOf("agent_name" to "agent_name_homer"), - IllegalArgumentException::class, - "agent with name" - ) + fun `given a workspace with multiple agents when getMatchingAgent is called with invalid agent names then no agent is resolved`() { + val ws = DataGen.workspace("ws", agents = ALL_AGENTS) + + val testCases = listOf( + AgentNullResultTestCase( + "empty parameters (i.e. no agent name) does not return any agent", + emptyMap() + ), + AgentNullResultTestCase( + "empty agent_name does not return any agent", + mapOf("agent_name" to "") + ), + AgentNullResultTestCase( + "null agent_name does not return any agent", + mapOf("agent_name" to null) + ), + AgentNullResultTestCase( + "non-existent agent does not return any agent", + mapOf("agent_name" to "agent_name_homer") + ), + AgentNullResultTestCase( + "UUID instead of name does not return any agent", + mapOf("agent_name" to "not-an-agent-name") ) + ) + runBlocking { - tests.forEach { - assertNull(protocolHandler.getMatchingAgent(it.first, ws)?.id) + testCases.forEach { testCase -> + assertNull( + protocolHandler.getMatchingAgent(testCase.params, ws)?.id, + "Failed: ${testCase.description}" + ) } } } @Test - @DisplayName("given a ws with only one agent, the agent is selected even when agent_name query param was not provided") - fun getsFirstAgentWhenOnlyOne() { - val ws = DataGen.workspace("ws", agents = agentBob) - val tests = - listOf( + fun `given a workspace with a single agent when getMatchingAgent is called with an empty agent name then the default agent is resolved`() { + val ws = DataGen.workspace("ws", agents = SINGLE_AGENT) + + val testCases = listOf( + AgentMatchTestCase( + "empty parameters (i.e. no agent name) auto-selects the one and only agent available", emptyMap(), + AGENT_BOB.uuid + ), + AgentMatchTestCase( + "empty agent_name auto-selects the one and only agent available", mapOf("agent_name" to ""), - mapOf("agent_name" to null) + AGENT_BOB.uuid + ), + AgentMatchTestCase( + "null agent_name auto-selects the one and only agent available", + mapOf("agent_name" to null), + AGENT_BOB.uuid ) + ) + runBlocking { - tests.forEach { + testCases.forEach { testCase -> assertEquals( - UUID.fromString("b0e4c54d-9ba9-4413-8512-11ca1e826a24"), - protocolHandler.getMatchingAgent( - it, - ws, - )?.id, + testCase.expectedAgentId, + protocolHandler.getMatchingAgent(testCase.params, ws)?.id, + "Failed: ${testCase.description}" ) } } } @Test - @DisplayName("given a ws with only one agent, the agent is NOT selected when agent_name query param was provided but does not match") - fun failsToGetAgentWhenOnlyOne() { - val wsWithAgentBob = DataGen.workspace("ws", agents = agentBob) - val tests = - listOf( - Triple( - mapOf("agent_name" to "agent_name_garfield"), - IllegalArgumentException::class, - "agent with name" - ), - ) + fun `given a workspace with a single agent when getMatchingAgent is called with an invalid agent name then no agent is resolved`() { + val ws = DataGen.workspace("ws", agents = SINGLE_AGENT) + + val testCase = AgentNullResultTestCase( + "non-matching agent_name with single agent", + mapOf("agent_name" to "agent_name_garfield") + ) + runBlocking { - tests.forEach { - assertNull(protocolHandler.getMatchingAgent(it.first, wsWithAgentBob)) - } + assertNull( + protocolHandler.getMatchingAgent(testCase.params, ws), + "Failed: ${testCase.description}" + ) } } @Test - @DisplayName("fails to resolve any agent when the workspace has no agents") - fun failsToGetAgentWhenWorkspaceHasNoAgents() { - val wsWithoutAgents = DataGen.workspace("ws") - val tests = - listOf( - Triple(emptyMap(), IllegalArgumentException::class, "has no agents"), - Triple(mapOf("agent_name" to ""), IllegalArgumentException::class, "has no agents"), - Triple(mapOf("agent_name" to null), IllegalArgumentException::class, "has no agents"), - Triple( - mapOf("agent_name" to "agent_name_riker"), - IllegalArgumentException::class, - "has no agents" - ), + fun `given a workspace with no agent when getMatchingAgent is called then no agent is resolved`() { + val ws = DataGen.workspace("ws") + + val testCases = listOf( + AgentNullResultTestCase( + "empty parameters (i.e. no agent name) does not return any agent", + emptyMap() + ), + AgentNullResultTestCase( + "empty agent_name does not return any agent", + mapOf("agent_name" to "") + ), + AgentNullResultTestCase( + "null agent_name does not return any agent", + mapOf("agent_name" to null) + ), + AgentNullResultTestCase( + "valid agent_name does not return any agent", + mapOf("agent_name" to AGENT_RIKER.name) ) + ) + runBlocking { - tests.forEach { - assertNull(protocolHandler.getMatchingAgent(it.first, wsWithoutAgents)) + testCases.forEach { testCase -> + assertNull( + protocolHandler.getMatchingAgent(testCase.params, ws), + "Failed: ${testCase.description}" + ) } } } -} + + internal data class AgentTestData(val name: String, val id: String) { + val uuid: UUID get() = UUID.fromString(id) + } + + internal data class AgentMatchTestCase( + val description: String, + val params: Map, + val expectedAgentId: UUID + ) + + internal data class AgentNullResultTestCase( + val description: String, + val params: Map + ) +} \ No newline at end of file From d5930fea05db83530b7879bc444e5a6e7d731729 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 3 Oct 2025 22:46:24 +0300 Subject: [PATCH 77/93] refactor: remove unused logic in URI handler (#204) PR #180 delegated all the logic for rest client and cli initialization to the usual authentication screen which provided better feedback/progress. But it also left over previous logic that can be removed. --- .../toolbox/util/CoderProtocolHandler.kt | 48 ------------------- 1 file changed, 48 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index a4c0b48..3eb6fbc 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -2,9 +2,7 @@ package com.coder.toolbox.util import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.cli.CoderCLIManager -import com.coder.toolbox.cli.ensureCLI import com.coder.toolbox.models.WorkspaceAndAgentStatus -import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent @@ -154,29 +152,6 @@ open class CoderProtocolHandler( return workspace } - private suspend fun buildRestClient(deploymentURL: String, token: String?): CoderRestClient? { - try { - return authenticate(deploymentURL, token) - } catch (ex: Exception) { - context.logAndShowError(CAN_T_HANDLE_URI_TITLE, humanizeConnectionError(deploymentURL.toURL(), true, ex)) - return null - } - } - - /** - * Returns an authenticated Coder CLI. - */ - private suspend fun authenticate(deploymentURL: String, token: String?): CoderRestClient { - val client = CoderRestClient( - context, - deploymentURL.toURL(), - token, - PluginManager.pluginInfo.version - ) - client.initializeSession() - return client - } - private suspend fun List.matchName(workspaceName: String, deploymentURL: String): Workspace? { val workspace = this.firstOrNull { it.name == workspaceName } if (workspace == null) { @@ -326,29 +301,6 @@ open class CoderProtocolHandler( return true } - private suspend fun configureCli( - deploymentURL: String, - restClient: CoderRestClient, - progressReporter: (String) -> Unit - ): CoderCLIManager { - val cli = ensureCLI( - context, - deploymentURL.toURL(), - restClient.buildInfo().version, - progressReporter - ) - - // We only need to log in if we are using token-based auth. - if (restClient.token != null) { - context.logger.info("Authenticating Coder CLI...") - cli.login(restClient.token) - } - - context.logger.info("Configuring Coder CLI...") - cli.configSsh(restClient.workspacesByAgents()) - return cli - } - private fun launchIde( environmentId: String, productCode: String, From 404efcec0c151c452fee189461d14cdd197509d7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Oct 2025 21:26:06 +0300 Subject: [PATCH 78/93] chore: bump io.mockk:mockk from 1.14.5 to 1.14.6 (#206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [io.mockk:mockk](https://github.com/mockk/mockk) from 1.14.5 to 1.14.6.
Release notes

Sourced from io.mockk:mockk's releases.

1.14.6

What's Changed

New Contributors

Full Changelog: https://github.com/mockk/mockk/compare/1.14.5...1.14.6

Commits
  • b089459 Version bump
  • 1688904 Merge pull request #1427 from felix-dolderer-el/master
  • de0ba9e docs: update README to include clear option for confirmVerified
  • 794cd06 remove whitespaces from README
  • aa1f91e default: false for internalConfirmVerified
  • ace1da9 add KDoc explaining clear parameter for confirmVerified
  • 6e93ff3 refactor: enhance confirmVerified function to include clear option
  • 244af21 Fix code example and clarify that the matchers must match
  • 50331c6 Merge pull request #1424 from tigermint/fix-duration-denormalized-error
  • 5d8c9b2 Apply review feedback
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=io.mockk:mockk&package-manager=gradle&previous-version=1.14.5&new-version=1.14.6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2e12b62..6951eef 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ retrofit = "3.0.0" changelog = "2.4.0" gettext = "0.7.0" plugin-structure = "3.316" -mockk = "1.14.5" +mockk = "1.14.6" detekt = "1.23.8" bouncycastle = "1.82" From a8bff3e5bd6b0ee82d20bb2a308388cc3d86985b Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 7 Oct 2025 21:47:53 +0300 Subject: [PATCH 79/93] refactor: remove unsafe non-null assertions to prevent race condition (#205) Replace !! operators with safe idiom takeIf/let chains. The non-null assertions were unsafe in concurrent scenarios where one thread could potentially modify the settings while another thread reads and makes non-null assertions. --- CHANGELOG.md | 4 ++ .../com/coder/toolbox/cli/CoderCLIManager.kt | 18 +++-- .../toolbox/sdk/CoderHttpClientBuilder.kt | 12 ++-- .../com/coder/toolbox/sdk/CoderRestClient.kt | 68 ++++++++++--------- .../toolbox/util/CoderProtocolHandler.kt | 5 +- .../com/coder/toolbox/views/ConnectStep.kt | 9 +-- 6 files changed, 64 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d45a85c..0a3aa82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixed + +- potential race condition that could cause crashes when settings are modified concurrently + ## 0.7.0 - 2025-09-27 ### Changed diff --git a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt index 9b058e5..3c0aedd 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt @@ -354,24 +354,22 @@ class CoderCLIManager( // always use the correct URL. "--url", escape(deploymentURL.toString()), - if (!context.settingsStore.headerCommand.isNullOrBlank()) "--header-command" else null, - if (!context.settingsStore.headerCommand.isNullOrBlank()) escapeSubcommand(context.settingsStore.headerCommand!!) else null, + context.settingsStore.headerCommand?.takeIf { it.isNotBlank() }?.let { "--header-command" }, + context.settingsStore.headerCommand?.takeIf { it.isNotBlank() }?.let { escapeSubcommand(it) }, "ssh", "--stdio", if (context.settingsStore.disableAutostart && feats.disableAutostart) "--disable-autostart" else null, "--network-info-dir ${escape(context.settingsStore.networkInfoDir)}" ) val proxyArgs = baseArgs + listOfNotNull( - if (!context.settingsStore.sshLogDirectory.isNullOrBlank()) "--log-dir" else null, - if (!context.settingsStore.sshLogDirectory.isNullOrBlank()) escape(context.settingsStore.sshLogDirectory!!) else null, + context.settingsStore.sshLogDirectory?.takeIf { it.isNotBlank() }?.let { "--log-dir" }, + context.settingsStore.sshLogDirectory?.takeIf { it.isNotBlank() }?.let { escape(it) }, if (feats.reportWorkspaceUsage) "--usage-app=jetbrains" else null, ) - val extraConfig = - if (!context.settingsStore.sshConfigOptions.isNullOrBlank()) { - "\n" + context.settingsStore.sshConfigOptions!!.prependIndent(" ") - } else { - "" - } + val extraConfig = context.settingsStore.sshConfigOptions + ?.takeIf { it.isNotBlank() } + ?.let { "\n" + it.prependIndent(" ") } + ?: "" val options = """ ConnectTimeout 0 StrictHostKeyChecking no diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt index f80d60c..86474d9 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt @@ -21,12 +21,12 @@ object CoderHttpClientBuilder { val trustManagers = coderTrustManagers(settings.tls.caPath) var builder = OkHttpClient.Builder() - if (context.proxySettings.getProxy() != null) { - context.logger.info("proxy: ${context.proxySettings.getProxy()}") - builder.proxy(context.proxySettings.getProxy()) - } else if (context.proxySettings.getProxySelector() != null) { - context.logger.info("proxy selector: ${context.proxySettings.getProxySelector()}") - builder.proxySelector(context.proxySettings.getProxySelector()!!) + context.proxySettings.getProxy()?.let { proxy -> + context.logger.info("proxy: $proxy") + builder.proxy(proxy) + } ?: context.proxySettings.getProxySelector()?.let { proxySelector -> + context.logger.info("proxy selector: $proxySelector") + builder.proxySelector(proxySelector) } // Note: This handles only HTTP/HTTPS proxy authentication. diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 803472c..1ded07a 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -15,11 +15,9 @@ import com.coder.toolbox.sdk.v2.models.CreateWorkspaceBuildRequest import com.coder.toolbox.sdk.v2.models.Template import com.coder.toolbox.sdk.v2.models.User import com.coder.toolbox.sdk.v2.models.Workspace -import com.coder.toolbox.sdk.v2.models.WorkspaceAgent import com.coder.toolbox.sdk.v2.models.WorkspaceBuild import com.coder.toolbox.sdk.v2.models.WorkspaceBuildReason import com.coder.toolbox.sdk.v2.models.WorkspaceResource -import com.coder.toolbox.sdk.v2.models.WorkspaceStatus import com.coder.toolbox.sdk.v2.models.WorkspaceTransition import com.squareup.moshi.Moshi import okhttp3.OkHttpClient @@ -114,7 +112,9 @@ open class CoderRestClient( ) } - return userResponse.body()!! + return requireNotNull(userResponse.body()) { + "Successful response returned null body or user" + } } /** @@ -132,7 +132,9 @@ open class CoderRestClient( ) } - return workspacesResponse.body()!!.workspaces + return requireNotNull(workspacesResponse.body()?.workspaces) { + "Successful response returned null body or workspaces" + } } /** @@ -140,33 +142,19 @@ open class CoderRestClient( * @throws [APIResponseException]. */ suspend fun workspace(workspaceID: UUID): Workspace { - val workspacesResponse = retroRestClient.workspace(workspaceID) - if (!workspacesResponse.isSuccessful) { + val workspaceResponse = retroRestClient.workspace(workspaceID) + if (!workspaceResponse.isSuccessful) { throw APIResponseException( "retrieve workspace", url, - workspacesResponse.code(), - workspacesResponse.parseErrorBody(moshi) + workspaceResponse.code(), + workspaceResponse.parseErrorBody(moshi) ) } - return workspacesResponse.body()!! - } - - /** - * Maps the available workspaces to the associated agents. - */ - suspend fun workspacesByAgents(): Set> { - // It is possible for there to be resources with duplicate names so we - // need to use a set. - return workspaces().flatMap { ws -> - when (ws.latestBuild.status) { - WorkspaceStatus.RUNNING -> ws.latestBuild.resources - else -> resources(ws) - }.filter { it.agents != null }.flatMap { it.agents!! }.map { - ws to it - } - }.toSet() + return requireNotNull(workspaceResponse.body()) { + "Successful response returned null body or workspace" + } } /** @@ -187,7 +175,10 @@ open class CoderRestClient( resourcesResponse.parseErrorBody(moshi) ) } - return resourcesResponse.body()!! + + return requireNotNull(resourcesResponse.body()) { + "Successful response returned null body or workspace resources" + } } suspend fun buildInfo(): BuildInfo { @@ -200,7 +191,10 @@ open class CoderRestClient( buildInfoResponse.parseErrorBody(moshi) ) } - return buildInfoResponse.body()!! + + return requireNotNull(buildInfoResponse.body()) { + "Successful response returned null body or build info" + } } /** @@ -216,7 +210,10 @@ open class CoderRestClient( templateResponse.parseErrorBody(moshi) ) } - return templateResponse.body()!! + + return requireNotNull(templateResponse.body()) { + "Successful response returned null body or template" + } } /** @@ -238,7 +235,10 @@ open class CoderRestClient( buildResponse.parseErrorBody(moshi) ) } - return buildResponse.body()!! + + return requireNotNull(buildResponse.body()) { + "Successful response returned null body or workspace build" + } } /** @@ -254,7 +254,10 @@ open class CoderRestClient( buildResponse.parseErrorBody(moshi) ) } - return buildResponse.body()!! + + return requireNotNull(buildResponse.body()) { + "Successful response returned null body or workspace build" + } } /** @@ -296,7 +299,10 @@ open class CoderRestClient( buildResponse.parseErrorBody(moshi) ) } - return buildResponse.body()!! + + return requireNotNull(buildResponse.body()) { + "Successful response returned null body or workspace build" + } } fun close() { diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 3eb6fbc..39f398d 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -252,7 +252,10 @@ open class CoderProtocolHandler( parameters: Map, workspace: Workspace, ): WorkspaceAgent? { - val agents = workspace.latestBuild.resources.filter { it.agents != null }.flatMap { it.agents!! } + val agents = workspace.latestBuild.resources + .mapNotNull { it.agents } + .flatten() + if (agents.isEmpty()) { context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "The workspace \"${workspace.name}\" has no agents") return null diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index 7798328..b6d0bbb 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -56,7 +56,7 @@ class ConnectStep( return } - statusField.textState.update { context.i18n.pnotr("Connecting to ${CoderCliSetupContext.url!!.host}...") } + statusField.textState.update { context.i18n.pnotr("Connecting to ${CoderCliSetupContext.url?.host ?: "unknown host"}...") } connect() } @@ -64,7 +64,8 @@ class ConnectStep( * Try connecting to Coder with the provided URL and token. */ private fun connect() { - if (!CoderCliSetupContext.hasUrl()) { + val url = CoderCliSetupContext.url + if (url == null) { errorField.textState.update { context.i18n.ptrl("URL is required") } return } @@ -74,7 +75,7 @@ class ConnectStep( return } // Capture the host name early for error reporting - val hostName = CoderCliSetupContext.url!!.host + val hostName = url.host signInJob?.cancel() signInJob = context.cs.launch(CoroutineName("Http and CLI Setup")) { @@ -82,7 +83,7 @@ class ConnectStep( context.logger.info("Setting up the HTTP client...") val client = CoderRestClient( context, - CoderCliSetupContext.url!!, + url, if (context.settingsStore.requireTokenAuth) CoderCliSetupContext.token else null, PluginManager.pluginInfo.version, ) From 77f78358a8847879171e032ba72525ea2c338a82 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 13 Oct 2025 18:56:56 +0300 Subject: [PATCH 80/93] fix: allow x-ms-dos-executable content type (#207) On some Windows versions the cli stream comes as application/x-ms-dos-executable. - resolves #187 --- CHANGELOG.md | 1 + gradle.properties | 2 +- .../com/coder/toolbox/cli/downloader/CoderDownloadService.kt | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a3aa82..0352205 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - potential race condition that could cause crashes when settings are modified concurrently +- CLI download on some Windows versions ## 0.7.0 - 2025-09-27 diff --git a/gradle.properties b/gradle.properties index dc031f5..d1e72be 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.7.0 +version=0.7.1 group=com.coder.toolbox name=coder-toolbox \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt b/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt index 468bfd8..2c2e87c 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt @@ -35,6 +35,7 @@ private val SUPPORTED_BIN_MIME_TYPES = listOf( "application/x-winexe", "application/x-msdos-program", "application/x-msdos-executable", + "application/x-ms-dos-executable", "application/vnd.microsoft.portable-executable" ) /** From 68cc4b857e23073f49e5df233d2c03f95af5b0a0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 20:34:08 +0300 Subject: [PATCH 81/93] Changelog update - `v0.7.1` (#208) Current pull request contains patched `CHANGELOG.md` file for the `v0.7.1` version. Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0352205..8b94dad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.7.1 - 2025-10-13 + ### Fixed - potential race condition that could cause crashes when settings are modified concurrently From d50ed7d976b25764b6098248590346083bb1bf42 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Oct 2025 23:15:30 +0300 Subject: [PATCH 82/93] chore: bump org.jetbrains.intellij.plugins:structure-toolbox from 3.316 to 3.318 (#210) Bumps [org.jetbrains.intellij.plugins:structure-toolbox](https://github.com/JetBrains/intellij-plugin-verifier) from 3.316 to 3.318.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.jetbrains.intellij.plugins:structure-toolbox&package-manager=gradle&previous-version=3.316&new-version=3.318)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6951eef..7866e7b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ ksp = "2.1.20-2.0.1" retrofit = "3.0.0" changelog = "2.4.0" gettext = "0.7.0" -plugin-structure = "3.316" +plugin-structure = "3.318" mockk = "1.14.6" detekt = "1.23.8" bouncycastle = "1.82" From 56e530f4ce340a8fb3b99e56dc3ea9d00fefa668 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Oct 2025 22:25:57 +0200 Subject: [PATCH 83/93] chore: bump actions/upload-artifact from 4 to 5 (#213) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 5.
Release notes

Sourced from actions/upload-artifact's releases.

v5.0.0

What's Changed

BREAKING CHANGE: this update supports Node v24.x. This is not a breaking change per-se but we're treating it as such.

New Contributors

Full Changelog: https://github.com/actions/upload-artifact/compare/v4...v5.0.0

v4.6.2

What's Changed

New Contributors

Full Changelog: https://github.com/actions/upload-artifact/compare/v4...v4.6.2

v4.6.1

What's Changed

Full Changelog: https://github.com/actions/upload-artifact/compare/v4...v4.6.1

v4.6.0

What's Changed

Full Changelog: https://github.com/actions/upload-artifact/compare/v4...v4.6.0

v4.5.0

What's Changed

New Contributors

... (truncated)

Commits
  • 330a01c Merge pull request #734 from actions/danwkennedy/prepare-5.0.0
  • 03f2824 Update github.dep.yml
  • 905a1ec Prepare v5.0.0
  • 2d9f9cd Merge pull request #725 from patrikpolyak/patch-1
  • 9687587 Merge branch 'main' into patch-1
  • 2848b2c Merge pull request #727 from danwkennedy/patch-1
  • 9b51177 Spell out the first use of GHES
  • cd231ca Update GHES guidance to include reference to Node 20 version
  • de65e23 Merge pull request #712 from actions/nebuk89-patch-1
  • 8747d8c Update README.md
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/upload-artifact&package-manager=github_actions&previous-version=4&new-version=5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 6 +++--- .github/workflows/jetbrains-compliance.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 323b3d5..9202a68 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,7 +35,7 @@ jobs: # Collect Tests Result of failed tests - if: ${{ failure() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: tests-result path: ${{ github.workspace }}/build/reports/tests @@ -79,13 +79,13 @@ jobs: # Store already-built plugin as an artifact for downloading - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: zip-artifacts path: ./build/distributions/*.zip - name: Upload Release Notes - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: release-notes path: RELEASE_NOTES.md diff --git a/.github/workflows/jetbrains-compliance.yml b/.github/workflows/jetbrains-compliance.yml index 74339e8..40c2421 100644 --- a/.github/workflows/jetbrains-compliance.yml +++ b/.github/workflows/jetbrains-compliance.yml @@ -40,7 +40,7 @@ jobs: ./gradlew detekt - name: Upload detekt reports - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: always() with: name: detekt-reports From 947be1ce212a38ba2952ae3c6f716ce053d39778 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Oct 2025 22:26:54 +0200 Subject: [PATCH 84/93] chore: bump actions/download-artifact from 5 to 6 (#212) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 5 to 6.
Release notes

Sourced from actions/download-artifact's releases.

v6.0.0

What's Changed

BREAKING CHANGE: this update supports Node v24.x. This is not a breaking change per-se but we're treating it as such.

New Contributors

Full Changelog: https://github.com/actions/download-artifact/compare/v5...v6.0.0

Commits
  • 018cc2c Merge pull request #438 from actions/danwkennedy/prepare-6.0.0
  • 815651c Revert "Remove github.dep.yml"
  • bb3a066 Remove github.dep.yml
  • fa1ce46 Prepare v6.0.0
  • 4a24838 Merge pull request #431 from danwkennedy/patch-1
  • 5e3251c Readme: spell out the first use of GHES
  • abefc31 Merge pull request #424 from actions/yacaovsnc/update_readme
  • ac43a60 Update README with artifact extraction details
  • de96f46 Merge pull request #417 from actions/yacaovsnc/update_readme
  • 7993cb4 Remove migration guide for artifact download changes
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/download-artifact&package-manager=github_actions&previous-version=5&new-version=6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9202a68..cc1d400 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -113,7 +113,7 @@ jobs: | xargs -I '{}' gh api -X DELETE repos/${{ github.repository }}/releases/{} - name: Download Build Artifacts - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v6 with: name: zip-artifacts path: artifacts/ @@ -121,7 +121,7 @@ jobs: run: ls -R artifacts/ - name: Download Release Notes - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v6 with: name: release-notes path: notes/ From 3ac53e89ba487f1419c521ed160c9bdc3139a76e Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 31 Oct 2025 01:44:06 +0200 Subject: [PATCH 85/93] impl: ability to customize the links to Dashboard (#211) Some clients (Netflix in this specific case) rely on mainly their own dashboard tools instead of the Coder one. Two main reasons that were mentioned by Netflix: - aggregate many dev tools in a unified internal console - specific platform/security needs that their own UI handles better For this reason they would like the actions that open up the Coder Dashboard (`Create workspace` and `Open in dashboard`) to be fully customizable, and allow clients to override the URL. For `Create workspace` we now have a config that defaults $lastDeploymentUrl/templates, but it can be replaced with a complete new URL. It also supports `$workspaceOwner` as a placeholder that is replaced by the plugin with the username that logged in. For `Open in dashboard` a full URL can be provided and we also introduced two placeholders `$workspaceOwner` and `$workspaceName` which will be replaced by the plugin but only for this action. For now the decision is to not allow configuration from UI since Netflix is the only target for this change, and they deploy at scale a templated settings.json. --- README.md | 10 ++++++++++ gradle.properties | 2 +- .../com/coder/toolbox/CoderRemoteEnvironment.kt | 7 ++++++- .../kotlin/com/coder/toolbox/CoderRemoteProvider.kt | 6 +++++- .../coder/toolbox/settings/ReadOnlyCoderSettings.kt | 11 +++++++++++ .../com/coder/toolbox/store/CoderSettingsStore.kt | 5 +++++ src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt | 3 +++ .../com/coder/toolbox/util/URLExtensionsTest.kt | 12 ++++++------ 8 files changed, 47 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 74e9cd5..2d50806 100644 --- a/README.md +++ b/README.md @@ -360,6 +360,16 @@ storage paths. The options can be configured from the plugin's main Workspaces p - `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. +- `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 customers that have their own in-house dashboards. Defaults to the Coder deployment workspace page. + This setting supports `$workspaceOwner` and `$workspaceName` as placeholders. + +- `workspaceCreateUrl` specifies the dashboard page full URL where users can create new workspaces. + Helpful for customers that have their own in-house dashboards. Defaults to the Coder deployment templates page. + This setting supports `$workspaceOwner` as placeholder with the replacing value being the username that logged in. + ### TLS settings The following options control the secure communication behavior of the plugin with Coder deployment and its available diff --git a/gradle.properties b/gradle.properties index d1e72be..2c53740 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.7.1 +version=0.7.3 group=com.coder.toolbox name=coder-toolbox \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index 5bb4296..ff413c5 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -91,8 +91,13 @@ class CoderRemoteEnvironment( } actions.add( Action(context, "Open in dashboard") { + val urlTemplate = context.settingsStore.workspaceViewUrl + ?: client.url.withPath("/@${workspace.ownerName}/${workspace.name}").toString() + val url = urlTemplate + .replace("\$workspaceOwner", "${workspace.ownerName}") + .replace("\$workspaceName", workspace.name) context.desktop.browse( - client.url.withPath("/@${workspace.ownerName}/${workspace.name}").toString() + url ) { context.ui.showErrorInfoPopup(it) } diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index ed4854c..300f5a9 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -224,7 +224,11 @@ class CoderRemoteProvider( override val additionalPluginActions: StateFlow> = MutableStateFlow( listOf( Action(context, "Create workspace") { - context.desktop.browse(client?.url?.withPath("/templates").toString()) { + val url = context.settingsStore.workspaceCreateUrl ?: client?.url?.withPath("/templates").toString() + context.desktop.browse( + url + .replace("\$workspaceOwner", client?.me()?.username ?: "") + ) { context.ui.showErrorInfoPopup(it) } }, diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt index 9ac6438..8eed699 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -137,6 +137,17 @@ interface ReadOnlyCoderSettings { */ val sshConfigOptions: String? + /** + * A custom full URL to the dashboard page used for viewing details about a workspace. + * Supports `$workspaceOwner` and `$workspaceName` as placeholders. + */ + val workspaceViewUrl: String? + + /** + * A custom full URL to the dashboard page used for creating workspaces. + * Supports `$workspaceOwner` as placeholder. + */ + val workspaceCreateUrl: String? /** * The path where network information for SSH hosts are stored diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index 66706ca..becdea0 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -80,6 +80,11 @@ class CoderSettingsStore( .normalize() .toString() + override val workspaceViewUrl: String? + get() = store[WORKSPACE_VIEW_URL] + override val workspaceCreateUrl: String? + get() = store[WORKSPACE_CREATE_URL] + /** * Where the specified deployment should put its data. */ diff --git a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt index 555c6b5..d38631a 100644 --- a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt +++ b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt @@ -46,5 +46,8 @@ internal const val SSH_CONFIG_OPTIONS = "sshConfigOptions" internal const val NETWORK_INFO_DIR = "networkInfoDir" +internal const val WORKSPACE_VIEW_URL = "workspaceViewUrl" +internal const val WORKSPACE_CREATE_URL = "workspaceCreateUrl" + internal const val SSH_AUTO_CONNECT_PREFIX = "ssh_auto_connect_" diff --git a/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt b/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt index af1b4ef..eebd424 100644 --- a/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt @@ -9,21 +9,21 @@ internal class URLExtensionsTest { @Test fun testToURL() { assertEquals( - URL("https", "localhost", 8080, "/path"), - "https://localhost:8080/path".toURL(), + expected = URI.create("https://localhost:8080/path").toURL(), + actual = "https://localhost:8080/path".toURL(), ) } @Test fun testWithPath() { assertEquals( - URL("https", "localhost", 8080, "/foo/bar"), - URL("https", "localhost", 8080, "/").withPath("/foo/bar"), + expected = "https://localhost:8080/foo/bar".toURL(), + actual = "https://localhost:8080/".toURL().withPath("/foo/bar"), ) assertEquals( - URL("https", "localhost", 8080, "/foo/bar"), - URL("https", "localhost", 8080, "/old/path").withPath("/foo/bar"), + expected = "https://localhost:8080/foo/bar".toURL(), + actual = "https://localhost:8080/old/path".toURL().withPath("/foo/bar"), ) } From 81921d75bf8d73577e9d62a92bc05c242303b649 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 3 Nov 2025 19:31:53 +0200 Subject: [PATCH 86/93] Improve uri handling workflow (#214) This PR addresses two issues in the URI handler workflow to improve user experience and reliability. 1. Streamline version fallback behavior Problem: When the URI handler receives a build number that is no longer available, the application would fall back to the latest version but display a confirmation dialog. Netflix reported that this confirmation dialog disrupts the user workflow. Solution: Removed the confirmation dialog and replaced it with logging. The handler now silently falls back to the latest available version when the requested build number is unavailable, maintaining a seamless user experience. 2. Fix connect page not displaying when Toolbox is already open Problem: When Toolbox is already running and a URI is executed, the connect page fails to display. Investigation revealed that the UI event emitted via MutableSharedFlow(replay = 0) is lost because the UI collector is not yet active when processEvent() is called. Solution: Introduced a 66-100ms delay before emitting the UI event. This delay ensures the collector is ready to receive events, preventing them from being dropped. The timing was determined through testing and appears to account for the collector initialization time. Note: The delay in fix #2 is a workaround for what appears to be a timing issue with the MutableSharedFlow collector initialization. --- CHANGELOG.md | 8 ++++++++ .../coder/toolbox/util/CoderProtocolHandler.kt | 18 ++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b94dad..bb3dbee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +### Changed + +- URI handling no longer waits for confirmation to use latest build if the provided build number is too old + +### Fixed + +- IDE is now launched when URI is handled by an already running Toolbox instance. + ## 0.7.1 - 2025-10-13 ### Fixed diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 39f398d..3dec81b 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.time.withTimeout import java.net.URI import java.util.UUID import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration @@ -111,6 +112,18 @@ open class CoderProtocolHandler( CoderCliSetupContext.token = token } CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) + + // If Toolbox is already opened and URI is executed the setup page + // from below is never called. I tried a couple of things, including + // yielding the coroutine - but it seems to be of no help. What works + // delaying the coroutine for 66 - to 100 milliseconds, these numbers + // were determined by trial and error. + // The only explanation that I have is that inspecting the TBX bytecode it seems the + // UI event is emitted via MutableSharedFlow(replay = 0) which has a buffer of 4 events + // and a drop oldest strategy. For some reason it seems that the UI collector + // is not yet active, causing the event to be lost unless we wait > 66 ms. + // I think this delay ensures the collector is ready before processEvent() is called. + delay(100.milliseconds) context.ui.showUiPage( CoderCliSetupWizardPage( context, settingsPage, visibilityState, true, @@ -369,10 +382,7 @@ open class CoderProtocolHandler( val buildNumberIsNotAvailable = availableVersions.firstOrNull { it.contains(buildNumber) } == null if (buildNumberIsNotAvailable) { val selectedIde = availableVersions.maxOf { it } - context.logAndShowInfo( - "$productCode-$buildNumber not available", - "$productCode-$buildNumber is not available, we've selected the latest $selectedIde" - ) + context.logger.info("$productCode-$buildNumber is not available, we've selected the latest $selectedIde") return selectedIde } return "$productCode-$buildNumber" From 1b0b53dd7c91b806289415fec47c8206e2c3578c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Nov 2025 19:32:24 +0200 Subject: [PATCH 87/93] chore: bump com.github.jk1.dependency-license-report from 2.9 to 3.0.1 (#215) Bumps com.github.jk1.dependency-license-report from 2.9 to 3.0.1. [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=com.github.jk1.dependency-license-report&package-manager=gradle&previous-version=2.9&new-version=3.0.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7866e7b..f933b4c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ kotlin = "2.1.20" coroutines = "1.10.2" serialization = "1.8.1" okhttp = "4.12.0" -dependency-license-report = "2.9" +dependency-license-report = "3.0.1" marketplace-client = "2.0.49" gradle-wrapper = "0.15.0" exec = "1.12" From 614b60b7aaa7afdd8264c13bfd9a4d49ebfeacfd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Nov 2025 19:32:53 +0200 Subject: [PATCH 88/93] chore: bump org.jetbrains.intellij.plugins:structure-toolbox from 3.318 to 3.319 (#216) Bumps [org.jetbrains.intellij.plugins:structure-toolbox](https://github.com/JetBrains/intellij-plugin-verifier) from 3.318 to 3.319.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.jetbrains.intellij.plugins:structure-toolbox&package-manager=gradle&previous-version=3.318&new-version=3.319)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f933b4c..e54161e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ ksp = "2.1.20-2.0.1" retrofit = "3.0.0" changelog = "2.4.0" gettext = "0.7.0" -plugin-structure = "3.318" +plugin-structure = "3.319" mockk = "1.14.6" detekt = "1.23.8" bouncycastle = "1.82" From 6314c409a89ea2dc58ffe21ffa1fbedc10c096a9 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 3 Nov 2025 22:04:06 +0200 Subject: [PATCH 89/93] chore: downgrade plugin version to 0.7.2 (#217) It was increased to 0.7.3 by mistake, 0.7.2 was not actually released. --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 2c53740..447537e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.7.3 +version=0.7.2 group=com.coder.toolbox name=coder-toolbox \ No newline at end of file From 86833187a9b3d750a9fc3d8ba63be8ba15449b71 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Nov 2025 21:58:33 +0200 Subject: [PATCH 90/93] Changelog update - `v0.7.2` (#218) Current pull request contains patched `CHANGELOG.md` file for the `v0.7.2` version. Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb3dbee..8817ffb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.7.2 - 2025-11-03 + ### Changed - URI handling no longer waits for confirmation to use latest build if the provided build number is too old From 186630f00ce2811e6c8420e8921ff2f2617f3466 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 22:49:01 +0200 Subject: [PATCH 91/93] chore: bump org.jetbrains.intellij.plugins:structure-toolbox from 3.319 to 3.320 (#219) Bumps [org.jetbrains.intellij.plugins:structure-toolbox](https://github.com/JetBrains/intellij-plugin-verifier) from 3.319 to 3.320.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.jetbrains.intellij.plugins:structure-toolbox&package-manager=gradle&previous-version=3.319&new-version=3.320)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e54161e..a40c643 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ ksp = "2.1.20-2.0.1" retrofit = "3.0.0" changelog = "2.4.0" gettext = "0.7.0" -plugin-structure = "3.319" +plugin-structure = "3.320" mockk = "1.14.6" detekt = "1.23.8" bouncycastle = "1.82" From 18bffe8bc14ff87469fc46b7d6c09b5c1c7f040b Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 12 Nov 2025 23:54:55 +0200 Subject: [PATCH 92/93] impl: ability to application name as main page title (#220) Netflix would like the ability to use application name displayed in the dashboard as the main page title instead of the URL. This PR adds a new option `useAppNameAsTitle` that allows users to specify whether or not they want to use the application name visible in the dashboard as Tbx main tile instead of the URL. The default will remain the URL. Unlike previous settings added for Netflix this one is also configurable from the UI (Coder Settings page) so not only via settings.json file. This is an option that probably makes sense for more users. --- CHANGELOG.md | 4 +++ .../com/coder/toolbox/CoderRemoteProvider.kt | 26 +++++++++++++++---- .../com/coder/toolbox/sdk/CoderRestClient.kt | 24 ++++++++++++++++- .../coder/toolbox/sdk/v2/CoderV2RestFacade.kt | 7 +++++ .../coder/toolbox/sdk/v2/models/Appearance.kt | 9 +++++++ .../toolbox/settings/ReadOnlyCoderSettings.kt | 6 +++++ .../coder/toolbox/store/CoderSettingsStore.kt | 5 ++++ .../com/coder/toolbox/store/StoreKeys.kt | 2 ++ .../coder/toolbox/views/CoderSettingsPage.kt | 14 +++++++++- .../resources/localization/defaultMessages.po | 3 +++ .../toolbox/util/CoderProtocolHandlerTest.kt | 2 +- 11 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/v2/models/Appearance.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 8817ffb..35e430f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- application name can now be displayed as the main title page instead of the URL + ## 0.7.2 - 2025-11-03 ### Changed diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 300f5a9..6084880 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -57,7 +57,6 @@ class CoderRemoteProvider( private val triggerSshConfig = Channel(Channel.CONFLATED) private val triggerProviderVisible = Channel(Channel.CONFLATED) - private val settingsPage: CoderSettingsPage = CoderSettingsPage(context, triggerSshConfig) private val dialogUi = DialogUi(context) // The REST client, if we are signed in @@ -65,8 +64,18 @@ class CoderRemoteProvider( // On the first load, automatically log in if we can. private var firstRun = true + private val isInitialized: MutableStateFlow = MutableStateFlow(false) private val coderHeaderPage = NewEnvironmentPage(context.i18n.pnotr(context.deploymentUrl.toString())) + private val settingsPage: CoderSettingsPage = CoderSettingsPage(context, triggerSshConfig) { + client?.let { restClient -> + if (context.settingsStore.useAppNameAsTitle) { + coderHeaderPage.setTitle(context.i18n.pnotr(restClient.appName)) + } else { + coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString())) + } + } + } private val visibilityState = MutableStateFlow( ProviderVisibilityState( applicationVisible = false, @@ -227,7 +236,7 @@ class CoderRemoteProvider( val url = context.settingsStore.workspaceCreateUrl ?: client?.url?.withPath("/templates").toString() context.desktop.browse( url - .replace("\$workspaceOwner", client?.me()?.username ?: "") + .replace("\$workspaceOwner", client?.me?.username ?: "") ) { context.ui.showErrorInfoPopup(it) } @@ -333,8 +342,11 @@ class CoderRemoteProvider( } context.logger.info("Starting initialization with the new settings") this@CoderRemoteProvider.client = restClient - coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString())) - + if (context.settingsStore.useAppNameAsTitle) { + coderHeaderPage.setTitle(context.i18n.pnotr(restClient.appName)) + } else { + coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString())) + } environments.showLoadingMessage() pollJob = poll(restClient, cli) context.logger.info("Workspace poll job with name ${pollJob.toString()} was created while handling URI $uri") @@ -421,7 +433,11 @@ class CoderRemoteProvider( context.logger.info("Cancelled workspace poll job ${pollJob.toString()} in order to start a new one") } environments.showLoadingMessage() - coderHeaderPage.setTitle(context.i18n.pnotr(client.url.toString())) + if (context.settingsStore.useAppNameAsTitle) { + coderHeaderPage.setTitle(context.i18n.pnotr(client.appName)) + } else { + coderHeaderPage.setTitle(context.i18n.pnotr(client.url.toString())) + } context.logger.info("Displaying ${client.url} in the UI") pollJob = poll(client, cli) context.logger.info("Workspace poll job with name ${pollJob.toString()} was created") diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 1ded07a..d4117db 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -10,6 +10,7 @@ import com.coder.toolbox.sdk.ex.APIResponseException import com.coder.toolbox.sdk.interceptors.Interceptors import com.coder.toolbox.sdk.v2.CoderV2RestFacade import com.coder.toolbox.sdk.v2.models.ApiErrorResponse +import com.coder.toolbox.sdk.v2.models.Appearance import com.coder.toolbox.sdk.v2.models.BuildInfo import com.coder.toolbox.sdk.v2.models.CreateWorkspaceBuildRequest import com.coder.toolbox.sdk.v2.models.Template @@ -45,6 +46,7 @@ open class CoderRestClient( lateinit var me: User lateinit var buildVersion: String + lateinit var appName: String init { setupSession() @@ -94,6 +96,7 @@ open class CoderRestClient( suspend fun initializeSession(): User { me = me() buildVersion = buildInfo().version + appName = appearance().applicationName return me } @@ -101,7 +104,7 @@ open class CoderRestClient( * Retrieve the current user. * @throws [APIResponseException]. */ - suspend fun me(): User { + internal suspend fun me(): User { val userResponse = retroRestClient.me() if (!userResponse.isSuccessful) { throw APIResponseException( @@ -117,6 +120,25 @@ open class CoderRestClient( } } + /** + * Retrieves the visual dashboard configuration. + */ + internal suspend fun appearance(): Appearance { + val appearanceResponse = retroRestClient.appearance() + if (!appearanceResponse.isSuccessful) { + throw APIResponseException( + "initializeSession", + url, + appearanceResponse.code(), + appearanceResponse.parseErrorBody(moshi) + ) + } + + return requireNotNull(appearanceResponse.body()) { + "Successful response returned null body for visual dashboard configuration" + } + } + /** * Retrieves the available workspaces created by the user. * @throws [APIResponseException]. diff --git a/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt b/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt index adcaa6e..5e7fc13 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt @@ -1,5 +1,6 @@ package com.coder.toolbox.sdk.v2 +import com.coder.toolbox.sdk.v2.models.Appearance import com.coder.toolbox.sdk.v2.models.BuildInfo import com.coder.toolbox.sdk.v2.models.CreateWorkspaceBuildRequest import com.coder.toolbox.sdk.v2.models.Template @@ -23,6 +24,12 @@ interface CoderV2RestFacade { @GET("api/v2/users/me") suspend fun me(): Response + /** + * Returns the configuration of the visual dashboard. + */ + @GET("api/v2/appearance") + suspend fun appearance(): Response + /** * Retrieves all workspaces the authenticated user has access to. */ diff --git a/src/main/kotlin/com/coder/toolbox/sdk/v2/models/Appearance.kt b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/Appearance.kt new file mode 100644 index 0000000..0c8d830 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/Appearance.kt @@ -0,0 +1,9 @@ +package com.coder.toolbox.sdk.v2.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class Appearance( + @property:Json(name = "application_name") val applicationName: String +) diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt index 8eed699..edf4801 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -19,6 +19,12 @@ interface ReadOnlyCoderSettings { */ val defaultURL: String + /** + * Whether to display the application name instead of the URL + * in the main screen. Defaults to URL + */ + val useAppNameAsTitle: Boolean + /** * Used to download the Coder CLI which is necessary to proxy SSH * connections. The If-None-Match header will be set to the SHA1 of the CLI diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index becdea0..ed8f009 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -38,6 +38,7 @@ class CoderSettingsStore( // Properties implementation override val lastDeploymentURL: String? get() = store[LAST_USED_URL] override val defaultURL: String get() = store[DEFAULT_URL] ?: "https://dev.coder.com" + override val useAppNameAsTitle: Boolean get() = store[APP_NAME_AS_TITLE]?.toBooleanStrictOrNull() ?: false override val binarySource: String? get() = store[BINARY_SOURCE] override val binaryDirectory: String? get() = store[BINARY_DIRECTORY] override val disableSignatureVerification: Boolean @@ -165,6 +166,10 @@ class CoderSettingsStore( store[LAST_USED_URL] = url.toString() } + fun updateUseAppNameAsTitle(appNameAsTitle: Boolean) { + store[APP_NAME_AS_TITLE] = appNameAsTitle.toString() + } + fun updateBinarySource(source: String) { store[BINARY_SOURCE] = source } diff --git a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt index d38631a..bc46c4f 100644 --- a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt +++ b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt @@ -6,6 +6,8 @@ internal const val LAST_USED_URL = "lastDeploymentURL" internal const val DEFAULT_URL = "defaultURL" +internal const val APP_NAME_AS_TITLE = "useAppNameAsTitle" + internal const val BINARY_SOURCE = "binarySource" internal const val BINARY_DIRECTORY = "binaryDirectory" diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index 5d5f115..b74b2d8 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -28,7 +28,11 @@ import kotlinx.coroutines.launch * TODO@JB: There is no scroll, and our settings do not fit. As a consequence, * I have not been able to test this page. */ -class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConfig: Channel) : +class CoderSettingsPage( + private val context: CoderToolboxContext, + triggerSshConfig: Channel, + private val onSettingsClosed: () -> Unit +) : CoderPage(MutableStateFlow(context.i18n.ptrl("Coder Settings")), false) { private val settings = context.settingsStore.readOnly() @@ -41,6 +45,8 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf TextField(context.i18n.ptrl("Data directory"), settings.dataDirectory ?: "", TextType.General) private val enableDownloadsField = CheckboxField(settings.enableDownloads, context.i18n.ptrl("Enable downloads")) + private val useAppNameField = + CheckboxField(settings.useAppNameAsTitle, context.i18n.ptrl("Use app name as main page title instead of URL")) private val disableSignatureVerificationField = CheckboxField( settings.disableSignatureVerification, @@ -95,6 +101,7 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf listOf( binarySourceField, enableDownloadsField, + useAppNameField, binaryDirectoryField, enableBinaryDirectoryFallbackField, disableSignatureVerificationField, @@ -121,6 +128,7 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf context.settingsStore.updateBinaryDirectory(binaryDirectoryField.contentState.value) context.settingsStore.updateDataDirectory(dataDirectoryField.contentState.value) context.settingsStore.updateEnableDownloads(enableDownloadsField.checkedState.value) + context.settingsStore.updateUseAppNameAsTitle(useAppNameField.checkedState.value) context.settingsStore.updateDisableSignatureVerification(disableSignatureVerificationField.checkedState.value) context.settingsStore.updateSignatureFallbackStrategy(signatureFallbackStrategyField.checkedState.value) context.settingsStore.updateHttpClientLogLevel(httpLoggingField.selectedValueState.value) @@ -164,6 +172,9 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf enableDownloadsField.checkedState.update { settings.enableDownloads } + useAppNameField.checkedState.update { + settings.useAppNameAsTitle + } signatureFallbackStrategyField.checkedState.update { settings.fallbackOnCoderForSignatures.isAllowed() } @@ -225,5 +236,6 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf override fun afterHide() { visibilityUpdateJob.cancel() + onSettingsClosed() } } diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index 29351e3..16b6ed5 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -189,3 +189,6 @@ msgstr "" msgid "Workspace name" msgstr "" + +msgid "Use app name as main page title instead of URL" +msgstr "" \ No newline at end of file diff --git a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt index 4a9ef88..1a84061 100644 --- a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt @@ -60,7 +60,7 @@ internal class CoderProtocolHandlerTest { private val protocolHandler = CoderProtocolHandler( context, DialogUi(context), - CoderSettingsPage(context, Channel(Channel.CONFLATED)), + CoderSettingsPage(context, Channel(Channel.CONFLATED), {}), MutableStateFlow(ProviderVisibilityState(applicationVisible = true, providerVisible = true)), MutableStateFlow(false) ) From e24f564a22de60a7787a7c9ab21bda1fd531d264 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 21 Nov 2025 00:10:42 +0200 Subject: [PATCH 93/93] impl: start the workspace via Coder CLI (#221) Netflix uses custom MFA that requires CLI middleware to handle auth flow. The custom CLI implementation on their side intercepts 403 responses from the REST API, handles the MFA challenge, and retries the rest call again. The MFA challenge is handled only by the `start` and `ssh` actions. The remaining actions can go directly to the REST endpoints because of the custom header command that provides MFA tokens to the http calls. Both Gateway and VS Code extension delegate the start logic to the CLI, but not Toolbox which caused issues for the customer. This PR ports some of the work from Gateway in Coder Toolbox. --- CHANGELOG.md | 4 ++ .../coder/toolbox/CoderRemoteEnvironment.kt | 33 +++++++++++-- .../com/coder/toolbox/cli/CoderCLIManager.kt | 23 ++++++++- .../com/coder/toolbox/sdk/CoderRestClient.kt | 1 + .../toolbox/sdk/v2/models/WorkspaceBuild.kt | 49 +++++++++++++------ .../toolbox/util/CoderProtocolHandler.kt | 5 +- .../coder/toolbox/cli/CoderCLIManagerTest.kt | 20 +++++++- 7 files changed, 113 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35e430f..40ad074 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - application name can now be displayed as the main title page instead of the URL +### Changed + +- workspaces are now started with the help of the CLI + ## 0.7.2 - 2025-11-03 ### Changed diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index ff413c5..4b9c607 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -26,6 +26,7 @@ import com.jetbrains.toolbox.api.ui.actions.ActionDescription import com.jetbrains.toolbox.api.ui.components.TextType import com.squareup.moshi.Moshi import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -36,6 +37,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import java.io.File import java.nio.file.Path +import java.util.concurrent.atomic.AtomicBoolean import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -69,6 +71,7 @@ class CoderRemoteEnvironment( private val networkMetricsMarshaller = Moshi.Builder().build().adapter(NetworkMetrics::class.java) private val proxyCommandHandle = SshCommandProcessHandle(context) private var pollJob: Job? = null + private val startIsInProgress = AtomicBoolean(false) init { if (context.settingsStore.shouldAutoConnect(id)) { @@ -120,9 +123,29 @@ class CoderRemoteEnvironment( ) } else { actions.add(Action(context, "Start") { - val build = client.startWorkspace(workspace) - update(workspace.copy(latestBuild = build), agent) - + try { + // needed in order to make sure Queuing is not overridden by the + // general polling loop with the `Stopped` state + startIsInProgress.set(true) + val startJob = context.cs + .launch(CoroutineName("Start Workspace Action CLI Runner") + Dispatchers.IO) { + cli.startWorkspace(workspace.ownerName, workspace.name) + } + // cli takes 15 seconds to move the workspace in queueing/starting state + // while the user won't see anything happening in TBX after start is clicked + // During those 15 seconds we work around by forcing a `Queuing` state + while (startJob.isActive && client.workspace(workspace.id).latestBuild.status.isNotStarted()) { + state.update { + WorkspaceAndAgentStatus.QUEUED.toRemoteEnvironmentState(context) + } + delay(1.seconds) + } + startIsInProgress.set(false) + // retrieve the status again and update the status + update(client.workspace(workspace.id), agent) + } finally { + startIsInProgress.set(false) + } } ) } @@ -241,6 +264,10 @@ class CoderRemoteEnvironment( * Update the workspace/agent status to the listeners, if it has changed. */ fun update(workspace: Workspace, agent: WorkspaceAgent) { + if (startIsInProgress.get()) { + context.logger.info("Skipping update for $id - workspace start is in progress") + return + } this.workspace = workspace this.agent = agent wsRawStatus = WorkspaceAndAgentStatus.from(workspace, agent) diff --git a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt index 3c0aedd..eb289af 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt @@ -125,6 +125,7 @@ data class Features( val disableAutostart: Boolean = false, val reportWorkspaceUsage: Boolean = false, val wildcardSsh: Boolean = false, + val buildReason: Boolean = false, ) /** @@ -304,6 +305,25 @@ class CoderCLIManager( ) } + /** + * Start a workspace. Throws if the command execution fails. + */ + fun startWorkspace(workspaceOwner: String, workspaceName: String, feats: Features = features): String { + val args = mutableListOf( + "--global-config", + coderConfigPath.toString(), + "start", + "--yes", + "$workspaceOwner/$workspaceName" + ) + + if (feats.buildReason) { + args.addAll(listOf("--reason", "jetbrains_connection")) + } + + return exec(*args.toTypedArray()) + } + /** * Configure SSH to use this binary. * @@ -569,7 +589,8 @@ class CoderCLIManager( Features( disableAutostart = version >= SemVer(2, 5, 0), reportWorkspaceUsage = version >= SemVer(2, 13, 0), - version >= SemVer(2, 19, 0), + wildcardSsh = version >= SemVer(2, 19, 0), + buildReason = version >= SemVer(2, 25, 0), ) } } diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index d4117db..7023c76 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -241,6 +241,7 @@ open class CoderRestClient( /** * @throws [APIResponseException]. */ + @Deprecated(message = "This operation needs to be delegated to the CLI") suspend fun startWorkspace(workspace: Workspace): WorkspaceBuild { val buildRequest = CreateWorkspaceBuildRequest( null, diff --git a/src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuild.kt b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuild.kt index 2c5767e..a7752a8 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuild.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuild.kt @@ -10,20 +10,41 @@ import java.util.UUID */ @JsonClass(generateAdapter = true) data class WorkspaceBuild( - @Json(name = "template_version_id") val templateVersionID: UUID, - @Json(name = "resources") val resources: List, - @Json(name = "status") val status: WorkspaceStatus, + @property:Json(name = "template_version_id") val templateVersionID: UUID, + @property:Json(name = "resources") val resources: List, + @property:Json(name = "status") val status: WorkspaceStatus, ) enum class WorkspaceStatus { - @Json(name = "pending") PENDING, - @Json(name = "starting") STARTING, - @Json(name = "running") RUNNING, - @Json(name = "stopping") STOPPING, - @Json(name = "stopped") STOPPED, - @Json(name = "failed") FAILED, - @Json(name = "canceling") CANCELING, - @Json(name = "canceled") CANCELED, - @Json(name = "deleting") DELETING, - @Json(name = "deleted") DELETED, -} + @Json(name = "pending") + PENDING, + + @Json(name = "starting") + STARTING, + + @Json(name = "running") + RUNNING, + + @Json(name = "stopping") + STOPPING, + + @Json(name = "stopped") + STOPPED, + + @Json(name = "failed") + FAILED, + + @Json(name = "canceling") + CANCELING, + + @Json(name = "canceled") + CANCELED, + + @Json(name = "deleting") + DELETING, + + @Json(name = "deleted") + DELETED; + + fun isNotStarted(): Boolean = this != STARTING && this != RUNNING +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 3dec81b..8e4dfbb 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -84,7 +84,7 @@ open class CoderProtocolHandler( } reInitialize(restClient, cli) context.envPageManager.showPluginEnvironmentsPage() - if (!prepareWorkspace(workspace, restClient, workspaceName, deploymentURL)) return + if (!prepareWorkspace(workspace, restClient, cli, workspaceName, deploymentURL)) return // we resolve the agent after the workspace is started otherwise we can get misleading // errors like: no agent available while workspace is starting or stopping // we also need to retrieve the workspace again to have the latest resources (ex: agent) @@ -180,6 +180,7 @@ open class CoderProtocolHandler( private suspend fun prepareWorkspace( workspace: Workspace, restClient: CoderRestClient, + cli: CoderCLIManager, workspaceName: String, deploymentURL: String ): Boolean { @@ -207,7 +208,7 @@ open class CoderProtocolHandler( if (workspace.outdated) { restClient.updateWorkspace(workspace) } else { - restClient.startWorkspace(workspace) + cli.startWorkspace(workspace.ownerName, workspace.name) } } catch (e: Exception) { context.logAndShowError( diff --git a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt index 7f5c831..74caf65 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -976,8 +976,24 @@ internal class CoderCLIManagerTest { val tests = listOf( Pair("2.5.0", Features(true)), - Pair("2.13.0", Features(true, true)), - Pair("4.9.0", Features(true, true, true)), + Pair("2.13.0", Features(disableAutostart = true, reportWorkspaceUsage = true)), + Pair( + "2.25.0", + Features( + disableAutostart = true, + reportWorkspaceUsage = true, + wildcardSsh = true, + buildReason = true + ) + ), + Pair( + "4.9.0", Features( + disableAutostart = true, + reportWorkspaceUsage = true, + wildcardSsh = true, + buildReason = true + ) + ), Pair("2.4.9", Features(false)), Pair("1.0.1", Features(false)), )