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 01/15] 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 02/15] 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 03/15] 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 04/15] 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)), ) From b7fa4718efd3904dbc851535265f2f4eb1aaf65e Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 26 Nov 2025 09:42:38 +0200 Subject: [PATCH 05/15] refactor: simplify workspace start status management (#222) Current approach with a secondary poll loop that handles the start action of a workspace is overengineered. Basically the problem is the CLI takes too long before moving the workspace into the queued/starting state, during which the user doesn't have any feedback. To address the issue we: - stopped the main poll loop from updating the environment - moved the environment in the queued state immediately after the start button was pushed. - started a poll loop that moved the workspace from queued state to starting space only after that state became available in the backend. The intermediary stopped state is skipped by the secondary poll loop. @asher pointed out that a better approach can be implemented. We already store the status, and workspace and the agent in the environment. When the start comes in: 1. We directly update the env. status to "queued" 2. We only change the environment status if there is difference in the existing workspace&agent status vs the status from the main poll loop 3. no secondary poll loop is needed. --- .../coder/toolbox/CoderRemoteEnvironment.kt | 103 +++++++++--------- .../toolbox/sdk/v2/models/WorkspaceBuild.kt | 2 + .../kotlin/com/coder/toolbox/sdk/DataGen.kt | 33 +++--- 3 files changed, 72 insertions(+), 66 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index 4b9c607..a5790c3 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -37,7 +37,6 @@ 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 @@ -55,37 +54,39 @@ class CoderRemoteEnvironment( private var workspace: Workspace, private var agent: WorkspaceAgent, ) : RemoteProviderEnvironment("${workspace.name}.${agent.name}"), BeforeConnectionHook, AfterDisconnectHook { - private var wsRawStatus = WorkspaceAndAgentStatus.from(workspace, agent) + private var environmentStatus = WorkspaceAndAgentStatus.from(workspace, agent) override var name: String = "${workspace.name}.${agent.name}" private var isConnected: MutableStateFlow = MutableStateFlow(false) override val connectionRequest: MutableStateFlow = MutableStateFlow(false) override val state: MutableStateFlow = - MutableStateFlow(wsRawStatus.toRemoteEnvironmentState(context)) + MutableStateFlow(environmentStatus.toRemoteEnvironmentState(context)) override val description: MutableStateFlow = MutableStateFlow(EnvironmentDescription.General(context.i18n.pnotr(workspace.templateDisplayName))) override val additionalEnvironmentInformation: MutableMap = mutableMapOf() - override val actionsList: MutableStateFlow> = MutableStateFlow(getAvailableActions()) + override val actionsList: MutableStateFlow> = MutableStateFlow(emptyList()) 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)) { context.logger.info("resuming SSH connection to $id — last session was still active.") startSshConnection() } + refreshAvailableActions() } fun asPairOfWorkspaceAndAgent(): Pair = Pair(workspace, agent) - private fun getAvailableActions(): List { + private fun refreshAvailableActions() { val actions = mutableListOf() - if (wsRawStatus.canStop()) { + context.logger.debug("Refreshing available actions for workspace $id with status: $environmentStatus") + if (environmentStatus.canStop()) { actions.add(Action(context, "Open web terminal") { + context.logger.debug("Launching web terminal for $id...") context.desktop.browse(client.url.withPath("/${workspace.ownerName}/$name/terminal").toString()) { context.ui.showErrorInfoPopup(it) } @@ -97,8 +98,9 @@ class CoderRemoteEnvironment( val urlTemplate = context.settingsStore.workspaceViewUrl ?: client.url.withPath("/@${workspace.ownerName}/${workspace.name}").toString() val url = urlTemplate - .replace("\$workspaceOwner", "${workspace.ownerName}") + .replace("\$workspaceOwner", workspace.ownerName) .replace("\$workspaceName", workspace.name) + context.logger.debug("Opening the dashboard for $id...") context.desktop.browse( url ) { @@ -108,51 +110,39 @@ class CoderRemoteEnvironment( ) actions.add(Action(context, "View template") { + context.logger.debug("Opening the template for $id...") context.desktop.browse(client.url.withPath("/templates/${workspace.templateName}").toString()) { context.ui.showErrorInfoPopup(it) } - } - ) + }) - if (wsRawStatus.canStart()) { + if (environmentStatus.canStart()) { if (workspace.outdated) { actions.add(Action(context, "Update and start") { + context.logger.debug("Updating and starting $id...") val build = client.updateWorkspace(workspace) update(workspace.copy(latestBuild = build), agent) - } - ) + }) } else { actions.add(Action(context, "Start") { - 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) + context.logger.debug("Starting $id... ") + context.cs + .launch(CoroutineName("Start Workspace Action CLI Runner") + Dispatchers.IO) { + cli.startWorkspace(workspace.ownerName, workspace.name) } - startIsInProgress.set(false) - // retrieve the status again and update the status - update(client.workspace(workspace.id), agent) - } finally { - startIsInProgress.set(false) - } - } - ) + // 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 + updateStatus(WorkspaceAndAgentStatus.QUEUED) + // force refresh of the actions list (Start should no longer be available) + refreshAvailableActions() + }) } } - if (wsRawStatus.canStop()) { + if (environmentStatus.canStop()) { if (workspace.outdated) { actions.add(Action(context, "Update and restart") { + context.logger.debug("Updating and re-starting $id...") val build = client.updateWorkspace(workspace) update(workspace.copy(latestBuild = build), agent) } @@ -160,7 +150,7 @@ class CoderRemoteEnvironment( } actions.add(Action(context, "Stop") { tryStopSshConnection() - + context.logger.debug("Stoping $id...") val build = client.stopWorkspace(workspace) update(workspace.copy(latestBuild = build), agent) } @@ -170,12 +160,14 @@ class CoderRemoteEnvironment( 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." + if (environmentStatus.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?"), + if (environmentStatus.canStop()) context.i18n.ptrl("Delete running workspace?") else context.i18n.ptrl( + "Delete workspace?" + ), context.i18n.pnotr(dialogText), context.i18n.ptrl("Workspace name"), TextType.General, @@ -185,10 +177,14 @@ class CoderRemoteEnvironment( if (confirmation != workspace.name) { return@launch } + context.logger.debug("Deleting $id...") deleteWorkspace() } }) - return actions + + actionsList.update { + actions + } } private suspend fun tryStopSshConnection() { @@ -264,23 +260,28 @@ 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") + if (this.workspace.latestBuild == workspace.latestBuild) { return } this.workspace = workspace this.agent = agent - wsRawStatus = WorkspaceAndAgentStatus.from(workspace, agent) + // workspace&agent status can be different from "environment status" + // which is forced to queued state when a workspace is scheduled to start + updateStatus(WorkspaceAndAgentStatus.from(workspace, agent)) + // we have to regenerate the action list in order to force a redraw // because the actions don't have a state flow on the enabled property - actionsList.update { - getAvailableActions() - } + refreshAvailableActions() + } + + private fun updateStatus(status: WorkspaceAndAgentStatus) { + environmentStatus = status context.cs.launch(CoroutineName("Workspace Status Updater")) { state.update { - wsRawStatus.toRemoteEnvironmentState(context) + environmentStatus.toRemoteEnvironmentState(context) } } + context.logger.debug("Overall status for workspace $id is $environmentStatus. Workspace status: ${workspace.latestBuild.status}, agent status: ${agent.status}, agent lifecycle state: ${agent.lifecycleState}, login before ready: ${agent.loginBeforeReady}") } /** @@ -310,7 +311,7 @@ class CoderRemoteEnvironment( * Returns true if the SSH connection was scheduled to start, false otherwise. */ fun startSshConnection(): Boolean { - if (wsRawStatus.ready() && !isConnected.value) { + if (environmentStatus.ready() && !isConnected.value) { context.cs.launch(CoroutineName("SSH Connection Trigger")) { connectionRequest.update { true @@ -336,7 +337,7 @@ class CoderRemoteEnvironment( withTimeout(5.minutes) { var workspaceStillExists = true while (context.cs.isActive && workspaceStillExists) { - if (wsRawStatus == WorkspaceAndAgentStatus.DELETING || wsRawStatus == WorkspaceAndAgentStatus.DELETED) { + if (environmentStatus == WorkspaceAndAgentStatus.DELETING || environmentStatus == WorkspaceAndAgentStatus.DELETED) { workspaceStillExists = false context.envPageManager.showPluginEnvironmentsPage() } else { 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 a7752a8..6b39987 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,6 +10,8 @@ import java.util.UUID */ @JsonClass(generateAdapter = true) data class WorkspaceBuild( + @property:Json(name = "id") val id: UUID, + @property:Json(name = "build_number") val buildNumber: Int, @property:Json(name = "template_version_id") val templateVersionID: UUID, @property:Json(name = "resources") val resources: List, @property:Json(name = "status") val status: WorkspaceStatus, diff --git a/src/test/kotlin/com/coder/toolbox/sdk/DataGen.kt b/src/test/kotlin/com/coder/toolbox/sdk/DataGen.kt index 6d23c57..bd8762d 100644 --- a/src/test/kotlin/com/coder/toolbox/sdk/DataGen.kt +++ b/src/test/kotlin/com/coder/toolbox/sdk/DataGen.kt @@ -12,6 +12,7 @@ import com.coder.toolbox.sdk.v2.models.WorkspaceStatus import com.coder.toolbox.util.Arch import com.coder.toolbox.util.OS import java.util.UUID +import kotlin.random.Random class DataGen { companion object { @@ -20,19 +21,19 @@ class DataGen { agentId: String, ): WorkspaceResource = WorkspaceResource( agents = - listOf( - WorkspaceAgent( - id = UUID.fromString(agentId), - status = WorkspaceAgentStatus.CONNECTED, - name = agentName, - architecture = Arch.from("amd64"), - operatingSystem = OS.from("linux"), - directory = null, - expandedDirectory = null, - lifecycleState = WorkspaceAgentLifecycleState.READY, - loginBeforeReady = false, + listOf( + WorkspaceAgent( + id = UUID.fromString(agentId), + status = WorkspaceAgentStatus.CONNECTED, + name = agentName, + architecture = Arch.from("amd64"), + operatingSystem = OS.from("linux"), + directory = null, + expandedDirectory = null, + lifecycleState = WorkspaceAgentLifecycleState.READY, + loginBeforeReady = false, + ), ), - ), ) fun workspace( @@ -48,9 +49,9 @@ class DataGen { templateDisplayName = "template-display-name", templateIcon = "template-icon", latestBuild = - build( - resources = agents.map { resource(it.key, it.value) }, - ), + build( + resources = agents.map { resource(it.key, it.value) }, + ), outdated = false, name = name, ownerName = "owner", @@ -61,6 +62,8 @@ class DataGen { templateVersionID: UUID = UUID.randomUUID(), resources: List = emptyList(), ): WorkspaceBuild = WorkspaceBuild( + id = UUID.randomUUID(), + buildNumber = Random.nextInt(), templateVersionID = templateVersionID, resources = resources, status = WorkspaceStatus.RUNNING, From 912237d32d70ece1f86fdb538ebedb5cdf3f3f24 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 2 Dec 2025 01:06:40 +0200 Subject: [PATCH 06/15] feat: automatic mTLS certificate regeneration and retry mechanism (#224) This adds support for automatically recovering from SSL handshake errors when certificates expired. When an SSL error occurs, the plugin will now attempt to execute a configured external command to refresh certificates. If successful, the SSL context is reloaded and the failed request is transparently retried. This improves reliability in environments with short-lived or frequently rotating certificates. Netflix requested this, they don't have a reliable mechanism to detect and refresh the certificates before any major disruption in Coder Toolbox. --- CHANGELOG.md | 1 + .../com/coder/toolbox/CoderRemoteProvider.kt | 4 +- .../com/coder/toolbox/cli/CoderCLIManager.kt | 4 +- .../toolbox/sdk/CoderHttpClientBuilder.kt | 17 ++--- .../com/coder/toolbox/sdk/CoderRestClient.kt | 13 +++- .../CertificateRefreshInterceptor.kt | 53 +++++++++++++ .../toolbox/settings/ReadOnlyCoderSettings.kt | 13 +++- .../coder/toolbox/store/CoderSettingsStore.kt | 9 ++- .../com/coder/toolbox/store/StoreKeys.kt | 2 + .../toolbox/util/CoderProtocolHandler.kt | 2 +- src/main/kotlin/com/coder/toolbox/util/TLS.kt | 74 +++++++++++++++++++ .../com/coder/toolbox/views/ConnectStep.kt | 10 +-- .../coder/toolbox/views/DeploymentUrlStep.kt | 2 +- .../toolbox/settings/CoderSettingsTest.kt | 8 +- 14 files changed, 181 insertions(+), 31 deletions(-) create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/interceptors/CertificateRefreshInterceptor.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 40ad074..235d295 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - application name can now be displayed as the main title page instead of the URL +- automatic mTLS certificate regeneration and retry mechanism ### Changed diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 6084880..217d4b1 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -414,14 +414,14 @@ 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 && (canAutoLogin() || !settings.requireTokenAuth) + private fun shouldDoAutoSetup(): Boolean = firstRun && (canAutoLogin() || !settings.requiresTokenAuth) 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.settingsStore.updateLastUsedUrl(client.url) - if (context.settingsStore.requireTokenAuth) { + if (context.settingsStore.requiresTokenAuth) { context.secrets.storeTokenFor(client.url, client.token ?: "") context.logger.info("Deployment URL and token were stored and will be available for automatic connection") } else { diff --git a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt index eb289af..0fe6c25 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt @@ -19,6 +19,7 @@ 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.InvalidVersionException +import com.coder.toolbox.util.ReloadableTlsContext import com.coder.toolbox.util.SemVer import com.coder.toolbox.util.escape import com.coder.toolbox.util.escapeSubcommand @@ -153,7 +154,8 @@ class CoderCLIManager( } val okHttpClient = CoderHttpClientBuilder.build( context, - interceptors + interceptors, + ReloadableTlsContext(context.settingsStore.readOnly().tls) ) val retrofit = Retrofit.Builder() diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt index 86474d9..a526db0 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt @@ -2,24 +2,19 @@ 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.coder.toolbox.util.ReloadableTlsContext 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 + interceptors: List, + tlsContext: ReloadableTlsContext ): OkHttpClient { - val settings = context.settingsStore.readOnly() - - val socketFactory = coderSocketFactory(settings.tls) - val trustManagers = coderTrustManagers(settings.tls.caPath) - var builder = OkHttpClient.Builder() + val builder = OkHttpClient.Builder() context.proxySettings.getProxy()?.let { proxy -> context.logger.info("proxy: $proxy") @@ -43,8 +38,8 @@ object CoderHttpClientBuilder { .build() } - builder.sslSocketFactory(socketFactory, trustManagers[0] as X509TrustManager) - .hostnameVerifier(CoderHostnameVerifier(settings.tls.altHostname)) + builder.sslSocketFactory(tlsContext.sslSocketFactory, tlsContext.trustManager) + .hostnameVerifier(CoderHostnameVerifier(context.settingsStore.tls.altHostname)) .retryOnConnectionFailure(true) interceptors.forEach { interceptor -> diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 7023c76..b44352d 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -7,6 +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.CertificateRefreshInterceptor import com.coder.toolbox.sdk.interceptors.Interceptors import com.coder.toolbox.sdk.v2.CoderV2RestFacade import com.coder.toolbox.sdk.v2.models.ApiErrorResponse @@ -20,6 +21,7 @@ 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.WorkspaceTransition +import com.coder.toolbox.util.ReloadableTlsContext import com.squareup.moshi.Moshi import okhttp3.OkHttpClient import retrofit2.Response @@ -40,6 +42,7 @@ open class CoderRestClient( val token: String?, private val pluginVersion: String = "development", ) { + private lateinit var tlsContext: ReloadableTlsContext private lateinit var moshi: Moshi private lateinit var httpClient: OkHttpClient private lateinit var retroRestClient: CoderV2RestFacade @@ -60,12 +63,17 @@ open class CoderRestClient( .add(OSConverter()) .add(UUIDConverter()) .build() + + tlsContext = ReloadableTlsContext(context.settingsStore.readOnly().tls) + val interceptors = buildList { - if (context.settingsStore.requireTokenAuth) { + if (context.settingsStore.requiresTokenAuth) { if (token.isNullOrBlank()) { throw IllegalStateException("Token is required for $url deployment") } add(Interceptors.tokenAuth(token)) + } else if (context.settingsStore.requiresMTlsAuth && context.settingsStore.tls.certRefreshCommand?.isNotBlank() == true) { + add(CertificateRefreshInterceptor(context, tlsContext)) } add((Interceptors.userAgent(pluginVersion))) add(Interceptors.externalHeaders(context, url)) @@ -74,7 +82,8 @@ open class CoderRestClient( httpClient = CoderHttpClientBuilder.build( context, - interceptors + interceptors, + tlsContext ) retroRestClient = diff --git a/src/main/kotlin/com/coder/toolbox/sdk/interceptors/CertificateRefreshInterceptor.kt b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/CertificateRefreshInterceptor.kt new file mode 100644 index 0000000..55dae43 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/CertificateRefreshInterceptor.kt @@ -0,0 +1,53 @@ +package com.coder.toolbox.sdk.interceptors + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.util.ReloadableTlsContext +import okhttp3.Interceptor +import okhttp3.Response +import org.zeroturnaround.exec.ProcessExecutor +import javax.net.ssl.SSLHandshakeException +import javax.net.ssl.SSLPeerUnverifiedException + +class CertificateRefreshInterceptor( + private val context: CoderToolboxContext, + private val tlsContext: ReloadableTlsContext +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + try { + return chain.proceed(request) + } catch (e: Exception) { + if ((e is SSLHandshakeException || e is SSLPeerUnverifiedException) && (e.message?.contains("certificate_expired") == true)) { + val command = context.settingsStore.tls.certRefreshCommand + if (command.isNullOrBlank()) { + throw IllegalStateException( + "Certificate expiration interceptor was set but the refresh command was removed in the meantime", + e + ) + } + + context.logger.info("SSL handshake exception encountered: certificates expired. Running certificate refresh command: $command") + try { + val result = ProcessExecutor() + .command(command.split(" ").toList()) + .exitValueNormal() + .readOutput(true) + .execute() + context.logger.info("`$command`: ${result.outputUTF8()}") + + if (result.exitValue == 0) { + context.logger.info("Certificate refresh command executed successfully. Reloading SSL certificates.") + tlsContext.reload() + // Retry the request + return chain.proceed(request) + } else { + context.logger.error("Certificate refresh command failed with exit code ${result.exitValue}") + } + } catch (ex: Exception) { + context.logger.error(ex, "Failed to execute certificate refresh command") + } + } + throw e + } + } +} diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt index edf4801..689f279 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -114,7 +114,12 @@ interface ReadOnlyCoderSettings { /** * Whether login should be done with a token */ - val requireTokenAuth: Boolean + val requiresTokenAuth: Boolean + + /** + * Whether the authentication is done with certificates. + */ + val requiresMTlsAuth: Boolean /** * Whether to add --disable-autostart to the proxy command. This works @@ -216,6 +221,12 @@ interface ReadOnlyTLSSettings { * Coder service does not match the hostname in the TLS certificate. */ val altHostname: String? + + /** + * Command to run when certificates expire and SSLHandshakeException + * is raised with `Received fatal alert: certificate_expired` as message + */ + val certRefreshCommand: String? } enum class SignatureFallbackStrategy { diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index ed8f009..ab8e54b 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -32,7 +32,8 @@ class CoderSettingsStore( override val certPath: String?, override val keyPath: String?, override val caPath: String?, - override val altHostname: String? + override val altHostname: String?, + override val certRefreshCommand: String? ) : ReadOnlyTLSSettings // Properties implementation @@ -62,9 +63,11 @@ class CoderSettingsStore( certPath = store[TLS_CERT_PATH], keyPath = store[TLS_KEY_PATH], caPath = store[TLS_CA_PATH], - altHostname = store[TLS_ALTERNATE_HOSTNAME] + altHostname = store[TLS_ALTERNATE_HOSTNAME], + certRefreshCommand = store[TLS_CERT_REFRESH_COMMAND] ) - override val requireTokenAuth: Boolean get() = tls.certPath.isNullOrBlank() || tls.keyPath.isNullOrBlank() + override val requiresTokenAuth: Boolean get() = tls.certPath.isNullOrBlank() || tls.keyPath.isNullOrBlank() + override val requiresMTlsAuth: Boolean get() = tls.certPath?.isNotBlank() == true && tls.keyPath?.isNotBlank() == true override val disableAutostart: Boolean get() = store[DISABLE_AUTOSTART]?.toBooleanStrictOrNull() ?: (getOS() == OS.MAC) override val isSshWildcardConfigEnabled: Boolean diff --git a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt index bc46c4f..c199aec 100644 --- a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt +++ b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt @@ -36,6 +36,8 @@ internal const val TLS_CA_PATH = "tlsCAPath" internal const val TLS_ALTERNATE_HOSTNAME = "tlsAlternateHostname" +internal const val TLS_CERT_REFRESH_COMMAND = "tlsCertRefreshCommand" + internal const val DISABLE_AUTOSTART = "disableAutostart" internal const val ENABLE_SSH_WILDCARD_CONFIG = "enableSshWildcardConfig" diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 8e4dfbb..113ab9f 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -70,7 +70,7 @@ open class CoderProtocolHandler( context.logger.info("Handling $uri...") val deploymentURL = resolveDeploymentUrl(params) ?: return - val token = if (!context.settingsStore.requireTokenAuth) null else resolveToken(params) ?: return + val token = if (!context.settingsStore.requiresTokenAuth) null else resolveToken(params) ?: return val workspaceName = resolveWorkspaceName(params) ?: return suspend fun onConnect( diff --git a/src/main/kotlin/com/coder/toolbox/util/TLS.kt b/src/main/kotlin/com/coder/toolbox/util/TLS.kt index 97a5df9..101370d 100644 --- a/src/main/kotlin/com/coder/toolbox/util/TLS.kt +++ b/src/main/kotlin/com/coder/toolbox/util/TLS.kt @@ -280,3 +280,77 @@ class MergedSystemTrustManger(private val otherTrustManager: X509TrustManager) : override fun getAcceptedIssuers(): Array = otherTrustManager.acceptedIssuers + systemTrustManager.acceptedIssuers } + +class ReloadableX509TrustManager( + private val caPath: String?, +) : X509TrustManager { + @Volatile + private var delegate: X509TrustManager = loadTrustManager() + + private fun loadTrustManager(): X509TrustManager { + val trustManagers = coderTrustManagers(caPath) + return trustManagers.first { it is X509TrustManager } as X509TrustManager + } + + fun reload() { + delegate = loadTrustManager() + } + + override fun checkClientTrusted(chain: Array?, authType: String?) { + delegate.checkClientTrusted(chain, authType) + } + + override fun checkServerTrusted(chain: Array?, authType: String?) { + delegate.checkServerTrusted(chain, authType) + } + + override fun getAcceptedIssuers(): Array { + return delegate.acceptedIssuers + } +} + +class ReloadableSSLSocketFactory( + private val settings: ReadOnlyTLSSettings, +) : SSLSocketFactory() { + @Volatile + private var delegate: SSLSocketFactory = loadSocketFactory() + + private fun loadSocketFactory(): SSLSocketFactory { + return coderSocketFactory(settings) + } + + fun reload() { + delegate = loadSocketFactory() + } + + override fun getDefaultCipherSuites(): Array = delegate.defaultCipherSuites + + override fun getSupportedCipherSuites(): Array = delegate.supportedCipherSuites + + override fun createSocket(): Socket = delegate.createSocket() + + override fun createSocket(s: Socket?, host: String?, port: Int, autoClose: Boolean): Socket = + delegate.createSocket(s, host, port, autoClose) + + override fun createSocket(host: String?, port: Int): Socket = delegate.createSocket(host, port) + + override fun createSocket(host: String?, port: Int, localHost: InetAddress?, localPort: Int): Socket = + delegate.createSocket(host, port, localHost, localPort) + + override fun createSocket(host: InetAddress?, port: Int): Socket = delegate.createSocket(host, port) + + override fun createSocket(address: InetAddress?, port: Int, localAddress: InetAddress?, localPort: Int): Socket = + delegate.createSocket(address, port, localAddress, localPort) +} + +class ReloadableTlsContext( + settings: ReadOnlyTLSSettings +) { + val sslSocketFactory = ReloadableSSLSocketFactory(settings) + val trustManager = ReloadableX509TrustManager(settings.caPath) + + fun reload() { + sslSocketFactory.reload() + trustManager.reload() + } +} diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index b6d0bbb..247d2c4 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -49,7 +49,7 @@ class ConnectStep( context.i18n.pnotr("") } - if (context.settingsStore.requireTokenAuth && CoderCliSetupContext.isNotReadyForAuth()) { + if (context.settingsStore.requiresTokenAuth && 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!") } @@ -70,7 +70,7 @@ class ConnectStep( return } - if (context.settingsStore.requireTokenAuth && !CoderCliSetupContext.hasToken()) { + if (context.settingsStore.requiresTokenAuth && !CoderCliSetupContext.hasToken()) { errorField.textState.update { context.i18n.ptrl("Token is required") } return } @@ -84,7 +84,7 @@ class ConnectStep( val client = CoderRestClient( context, url, - if (context.settingsStore.requireTokenAuth) CoderCliSetupContext.token else null, + if (context.settingsStore.requiresTokenAuth) CoderCliSetupContext.token else null, PluginManager.pluginInfo.version, ) // allows interleaving with the back/cancel action @@ -98,7 +98,7 @@ class ConnectStep( statusField.textState.update { (context.i18n.pnotr(progress)) } } // We only need to log in if we are using token-based auth. - if (context.settingsStore.requireTokenAuth) { + if (context.settingsStore.requiresTokenAuth) { logAndReportProgress("Configuring Coder CLI...") // allows interleaving with the back/cancel action yield() @@ -144,7 +144,7 @@ class ConnectStep( CoderCliSetupWizardState.goToFirstStep() } } else { - if (context.settingsStore.requireTokenAuth) { + if (context.settingsStore.requiresTokenAuth) { 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 27e53f9..b4a6066 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -86,7 +86,7 @@ class DeploymentUrlStep( errorReporter.report("URL is invalid", e) return false } - if (context.settingsStore.requireTokenAuth) { + if (context.settingsStore.requiresTokenAuth) { CoderCliSetupWizardState.goToNextStep() } else { CoderCliSetupWizardState.goToLastStep() diff --git a/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt b/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt index 5033487..9d38c4f 100644 --- a/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt +++ b/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt @@ -261,19 +261,19 @@ internal class CoderSettingsTest { @Test fun testRequireTokenAuth() { var settings = CoderSettingsStore(pluginTestSettingsStore(), Environment(), logger) - assertEquals(true, settings.readOnly().requireTokenAuth) + assertEquals(true, settings.readOnly().requiresTokenAuth) settings = CoderSettingsStore(pluginTestSettingsStore(TLS_CERT_PATH to "cert path"), Environment(), logger) - assertEquals(true, settings.readOnly().requireTokenAuth) + assertEquals(true, settings.readOnly().requiresTokenAuth) settings = CoderSettingsStore(pluginTestSettingsStore(TLS_KEY_PATH to "key path"), Environment(), logger) - assertEquals(true, settings.readOnly().requireTokenAuth) + assertEquals(true, settings.readOnly().requiresTokenAuth) settings = CoderSettingsStore( pluginTestSettingsStore(TLS_CERT_PATH to "cert path", TLS_KEY_PATH to "key path"), Environment(), logger ) - assertEquals(false, settings.readOnly().requireTokenAuth) + assertEquals(false, settings.readOnly().requiresTokenAuth) } @Test From a73f53b5ed799d71a31fac10131b2e3a0916c23c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 01:07:18 +0200 Subject: [PATCH 07/15] chore: bump actions/checkout from 5 to 6 (#223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
Release notes

Sourced from actions/checkout's releases.

v6.0.0

What's Changed

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

v6-beta

What's Changed

Updated persist-credentials to store the credentials under $RUNNER_TEMP instead of directly in the local git config.

This requires a minimum Actions Runner version of v2.329.0 to access the persisted credentials for Docker container action scenarios.

v5.0.1

What's Changed

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

Changelog

Sourced from actions/checkout's changelog.

Changelog

V6.0.0

V5.0.1

V5.0.0

V4.3.1

V4.3.0

v4.2.2

v4.2.1

v4.2.0

v4.1.7

v4.1.6

v4.1.5

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/checkout&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 | 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 cc1d400..e6e4b79 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@v5 + - uses: actions/checkout@v6 - uses: actions/setup-java@v5 with: @@ -50,7 +50,7 @@ jobs: steps: # Check out current repository - name: Fetch Sources - uses: actions/checkout@v5 + uses: actions/checkout@v6 # 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@v5 + uses: actions/checkout@v6 # 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 40c2421..d41b69f 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@v5 + uses: actions/checkout@v6 - name: Set up JDK 21 uses: actions/setup-java@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6918c4e..2d8ca65 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@v5 + uses: actions/checkout@v6 with: ref: ${{ github.event.release.tag_name }} From e3c8f671e1077351f5741d9a1dd512e09017742c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 01:09:29 +0200 Subject: [PATCH 08/15] chore: bump org.jetbrains.changelog from 2.4.0 to 2.5.0 (#225) Bumps org.jetbrains.changelog from 2.4.0 to 2.5.0. [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.jetbrains.changelog&package-manager=gradle&previous-version=2.4.0&new-version=2.5.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 a40c643..7746766 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.4.0" +changelog = "2.5.0" gettext = "0.7.0" plugin-structure = "3.320" mockk = "1.14.6" From e537c6a175f4bf67ccc80ecab447ece04f497bba Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 01:18:51 +0200 Subject: [PATCH 09/15] chore: bump bouncycastle from 1.82 to 1.83 (#226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps `bouncycastle` from 1.82 to 1.83. Updates `org.bouncycastle:bcpg-jdk18on` from 1.82 to 1.83
Changelog

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

2.1.1 Version Release: 1.83 Date:      2025, November 27th.

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

... (truncated)

Commits

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

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

2.1.1 Version Release: 1.83 Date:      2025, November 27th.

2.2.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 7746766..a518f8f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,7 @@ gettext = "0.7.0" plugin-structure = "3.320" mockk = "1.14.6" detekt = "1.23.8" -bouncycastle = "1.82" +bouncycastle = "1.83" [libraries] toolbox-core-api = { module = "com.jetbrains.toolbox:core-api", version.ref = "toolbox-plugin-api" } From 75b1e23d6eba709f84147f7497aaeff389366ff6 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 3 Dec 2025 23:45:21 +0200 Subject: [PATCH 10/15] chore: next version is 0.8.0 (#228) Major features were added like the ability to refresh mTLS certificates and start workspace via CLI instead of REST API --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 447537e..60ed663 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.7.2 +version=0.8.0 group=com.coder.toolbox name=coder-toolbox \ No newline at end of file From 2a0957622e7a0d6d51fe97e18a0c0dd80aa6b7b7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 23:53:12 +0200 Subject: [PATCH 11/15] Changelog update - `v0.8.0` (#229) Current pull request contains patched `CHANGELOG.md` file for the `v0.8.0` version. Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 235d295..7c37f46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.8.0 - 2025-12-03 + ### Added - application name can now be displayed as the main title page instead of the URL From aa90d5761b94686177ef8f7fc89ee247b6c62693 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 22:19:09 +0200 Subject: [PATCH 12/15] chore: bump org.jetbrains.intellij:plugin-repository-rest-client from 2.0.49 to 2.0.50 (#230) Bumps [org.jetbrains.intellij:plugin-repository-rest-client](https://github.com/JetBrains/plugin-repository-rest-client) from 2.0.49 to 2.0.50.
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.49&new-version=2.0.50)](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 a518f8f..99fbba5 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 = "3.0.1" -marketplace-client = "2.0.49" +marketplace-client = "2.0.50" gradle-wrapper = "0.15.0" exec = "1.12" moshi = "1.15.2" From 874e8cc7494b0647783a8bb73ebdaf8bfa79116f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 22:19:40 +0200 Subject: [PATCH 13/15] chore: bump io.mockk:mockk from 1.14.6 to 1.14.7 (#231) 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.6 to 1.14.7.
Release notes

Sourced from io.mockk:mockk's releases.

v1.14.7

What's Changed

New Contributors

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

Commits
  • 3b99349 Version bump
  • d0e14bb Merge pull request #1455 from mockk/copilot/remove-transitive-junit-dependency
  • 9372ca6 Merge pull request #1464 from mockk/copilot/fix-stackoverflow-error-mockk
  • 73736a6 Address code review feedback for parseParamTypes
  • 6866dd0 Merge pull request #1454 from nishatoma/add-strict-mocking-system-property
  • ea99f88 Merge pull request #1456 from mockk/copilot/fix-mockk-compatibility-issue
  • b7b72de Merge pull request #1457 from mockk/copilot/fix-inaccessibleobjectexception
  • 08d1d1d Address comments
  • 7681de2 Merge pull request #1465 from TWiStErRob/patch-2
  • 54e6154 Fix configuration option example for restricted classes
  • 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.6&new-version=1.14.7)](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 99fbba5..253d2c1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ retrofit = "3.0.0" changelog = "2.5.0" gettext = "0.7.0" plugin-structure = "3.320" -mockk = "1.14.6" +mockk = "1.14.7" detekt = "1.23.8" bouncycastle = "1.83" From a2c028e9afefaeac380ddd365c52503f59961ff0 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 10 Dec 2025 23:29:40 +0200 Subject: [PATCH 14/15] fix: simplify URI handling when the same deployment URL is already opened (#227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Netflix reported that only seems to reproduce on Linux (we've only tested Ubuntu so far). I can’t reproduce it on macOS. First, here’s some context: 1. Polling workspaces: Coder Toolbox polls the deployment every 5 seconds for workspace updates. These updates (new workspaces, deletions,status changes) are stored in a cached “environments” list (an oversimplified explanation). When a URI is executed, we reset the content of the list and run the login sequence, which re-initializes the HTTP poller and CLI using the new deployment URL and token. A new polling loop then begins populating the environments list again. 2. Cache monitoring: Toolbox watches this cached list for changes—especially status changes, which determine when an SSH connection can be established. In Netflix’s case, they launched Toolbox, created a workspace from the Dashboard, and the poller added it to the environments list. When the workspace switched from starting to ready, they used a URI to connect to it. The URI reset the list, then the poller repopulated it. But because the list had the same IDs (but new object references), Toolbox didn’t detect any changes. As a result, it never triggered the SSH connection. This issue only reproduces on Linux, but it might explain some of the sporadic macOS failures Atif mentioned in the past. I need to dig deeper into the Toolbox bytecode to determine whether this is a Toolbox bug, but it does seem like Toolbox wasn’t designed to switch cleanly between multiple deployments and/or users. The current Coder plugin behavior—always performing a full login sequence on every URI—is also ...sub-optimal. It only really makes sense in these scenarios: 1. Toolbox started with deployment A, but the URI targets deployment B. 2. Toolbox started with deployment A/user X, but the URI targets deployment A/user Y. But this design is inefficient for the most common case: connecting via URI to a workspace on the same deployment and same user. While working on the fix, I realized that scenario (2) is not realistic. On the same host machine, why would multiple users log into the same deployment via Toolbox? The whole fix revolves around the idea of just recreating the http client and updating the CLI with the new token instead of going through the full authentication steps when the URI deployment URL is the same as the currently opened URL The fix focuses on simply recreating the HTTP client and updating the CLI token when the URI URL matches the existing deployment URL, instead of running a full login. This PR splits responsibilities more cleanly: - CoderProtocolHandler now only finds the workspace and agent and handles IDE installation and launch. - the logic for creating a new HTTP client, updating the CLI, cleaning up old resources (polling loop, environment cache), and handling deployment URL changes is separated out. The benefits would be: - shared logic for cleanup and re-initialization, with less coupling and clearer, more maintainable code. - a clean way to check whether the URI’s deployment URL matches the current one and react appropriately when they differ. --- CHANGELOG.md | 8 + gradle.properties | 2 +- .../coder/toolbox/CoderRemoteEnvironment.kt | 12 +- .../com/coder/toolbox/CoderRemoteProvider.kt | 156 ++++++++++++++---- .../toolbox/util/CoderProtocolHandler.kt | 129 ++------------- .../toolbox/views/CoderCliSetupWizardPage.kt | 6 +- .../com/coder/toolbox/views/CoderPage.kt | 2 + .../com/coder/toolbox/views/ConnectStep.kt | 20 ++- .../toolbox/util/CoderProtocolHandlerTest.kt | 10 +- 9 files changed, 182 insertions(+), 163 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c37f46..62cf837 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +### Changed + +- streamlined URI handling with a faster workflow, clearer progress, and an overall smoother experience + +### Fixed + +- URI handling on Linux can now launch IDEs on newly started workspaces + ## 0.8.0 - 2025-12-03 ### Added diff --git a/gradle.properties b/gradle.properties index 60ed663..d9ce8bf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.8.0 +version=0.8.1 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 a5790c3..5cf160d 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -276,10 +276,8 @@ class CoderRemoteEnvironment( private fun updateStatus(status: WorkspaceAndAgentStatus) { environmentStatus = status - context.cs.launch(CoroutineName("Workspace Status Updater")) { - state.update { - environmentStatus.toRemoteEnvironmentState(context) - } + state.update { + environmentStatus.toRemoteEnvironmentState(context) } context.logger.debug("Overall status for workspace $id is $environmentStatus. Workspace status: ${workspace.latestBuild.status}, agent status: ${agent.status}, agent lifecycle state: ${agent.lifecycleState}, login before ready: ${agent.loginBeforeReady}") } @@ -312,10 +310,8 @@ class CoderRemoteEnvironment( */ fun startSshConnection(): Boolean { if (environmentStatus.ready() && !isConnected.value) { - context.cs.launch(CoroutineName("SSH Connection Trigger")) { - connectionRequest.update { - true - } + connectionRequest.update { + true } return true } diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 217d4b1..98cc1ab 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -2,11 +2,20 @@ package com.coder.toolbox import com.coder.toolbox.browser.browse import com.coder.toolbox.cli.CoderCLIManager +import com.coder.toolbox.plugin.PluginManager 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 +import com.coder.toolbox.util.TOKEN +import com.coder.toolbox.util.URL +import com.coder.toolbox.util.WebUrlValidationResult.Invalid +import com.coder.toolbox.util.toQueryParameters +import com.coder.toolbox.util.toURL +import com.coder.toolbox.util.token +import com.coder.toolbox.util.url +import com.coder.toolbox.util.validateStrictWebUrl import com.coder.toolbox.util.waitForTrue import com.coder.toolbox.util.withPath import com.coder.toolbox.views.Action @@ -37,6 +46,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select import java.net.URI +import java.net.URL +import java.util.concurrent.atomic.AtomicBoolean import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource @@ -44,6 +55,7 @@ import com.jetbrains.toolbox.api.ui.components.AccountDropdownField as DropDownM import com.jetbrains.toolbox.api.ui.components.AccountDropdownField as dropDownFactory private val POLL_INTERVAL = 5.seconds +private const val CAN_T_HANDLE_URI_TITLE = "Can't handle URI" @OptIn(ExperimentalCoroutinesApi::class) class CoderRemoteProvider( @@ -61,11 +73,13 @@ class CoderRemoteProvider( // The REST client, if we are signed in private var client: CoderRestClient? = null + private var cli: CoderCLIManager? = null // On the first load, automatically log in if we can. private var firstRun = true private val isInitialized: MutableStateFlow = MutableStateFlow(false) + private val isHandlingUri: AtomicBoolean = AtomicBoolean(false) private val coderHeaderPage = NewEnvironmentPage(context.i18n.pnotr(context.deploymentUrl.toString())) private val settingsPage: CoderSettingsPage = CoderSettingsPage(context, triggerSshConfig) { client?.let { restClient -> @@ -82,7 +96,7 @@ class CoderRemoteProvider( providerVisible = false ) ) - private val linkHandler = CoderProtocolHandler(context, dialogUi, settingsPage, visibilityState, isInitialized) + private val linkHandler = CoderProtocolHandler(context) override val loadingEnvironmentsDescription: LocalizableString = context.i18n.ptrl("Loading workspaces...") override val environments: MutableStateFlow>> = MutableStateFlow( @@ -254,6 +268,17 @@ class CoderRemoteProvider( * Also called as part of our own logout. */ override fun close() { + softClose() + client = null + cli = null + lastEnvironments.clear() + environments.value = LoadableState.Value(emptyList()) + isInitialized.update { false } + CoderCliSetupWizardState.goToFirstStep() + context.logger.info("Coder plugin is now closed") + } + + private fun softClose() { pollJob?.let { it.cancel() context.logger.info("Cancelled workspace poll job ${pollJob.toString()}") @@ -262,12 +287,6 @@ class CoderRemoteProvider( it.close() context.logger.info("REST API client closed and resources released") } - client = null - lastEnvironments.clear() - environments.value = LoadableState.Value(emptyList()) - isInitialized.update { false } - CoderCliSetupWizardState.goToFirstStep() - context.logger.info("Coder plugin is now closed") } override val svgIcon: SvgIcon = @@ -331,27 +350,49 @@ class CoderRemoteProvider( */ override suspend fun handleUri(uri: URI) { try { - linkHandler.handle( - uri, - shouldDoAutoSetup() - ) { restClient, cli -> - context.logger.info("Stopping workspace polling and de-initializing resources") - close() - isInitialized.update { - false + 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 + } + isHandlingUri.set(true) + // this switches to the main plugin screen, even + // if last opened provider was not Coder + context.envPageManager.showPluginEnvironmentsPage() + coderHeaderPage.isBusy.update { true } + context.logger.info("Handling $uri...") + val newUrl = resolveDeploymentUrl(params)?.toURL() ?: return + val newToken = if (context.settingsStore.requiresMTlsAuth) null else resolveToken(params) ?: return + if (sameUrl(newUrl, client?.url)) { + if (context.settingsStore.requiresTokenAuth) { + newToken?.let { + refreshSession(newUrl, it) + } + } + } else { + CoderCliSetupContext.apply { + url = newUrl + token = newToken } - context.logger.info("Starting initialization with the new settings") - this@CoderRemoteProvider.client = restClient - if (context.settingsStore.useAppNameAsTitle) { - coderHeaderPage.setTitle(context.i18n.pnotr(restClient.appName)) - } else { - coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString())) + CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) + CoderCliSetupWizardPage( + context, settingsPage, visibilityState, + initialAutoSetup = true, + jumpToMainPageOnError = true, + connectSynchronously = true, + onConnect = ::onConnect + ).apply { + beforeShow() } - environments.showLoadingMessage() - pollJob = poll(restClient, cli) - context.logger.info("Workspace poll job with name ${pollJob.toString()} was created while handling URI $uri") - isInitialized.waitForTrue() } + // force the poll loop to run + triggerProviderVisible.send(true) + // wait for environments to be populated + isInitialized.waitForTrue() + + linkHandler.handle(params, newUrl, this.client!!, this.cli!!) + coderHeaderPage.isBusy.update { false } } catch (ex: Exception) { val textError = if (ex is APIResponseException) { if (!ex.reason.isNullOrBlank()) { @@ -363,7 +404,63 @@ class CoderRemoteProvider( textError ?: "" ) context.envPageManager.showPluginEnvironmentsPage() + } finally { + coderHeaderPage.isBusy.update { false } + isHandlingUri.set(false) + firstRun = false + } + } + + private suspend fun resolveDeploymentUrl(params: Map): String? { + val deploymentURL = params.url() ?: askUrl() + if (deploymentURL.isNullOrBlank()) { + 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 + } + + 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 fun sameUrl(first: URL, second: URL?): Boolean = first.toURI().normalize() == second?.toURI()?.normalize() + + private suspend fun refreshSession(url: URL, token: String): Pair { + context.logger.info("Stopping workspace polling and re-initializing the http client and cli with a new token") + softClose() + val newRestClient = CoderRestClient( + context, + url, + token, + PluginManager.pluginInfo.version, + ).apply { initializeSession() } + val newCli = CoderCLIManager(context, url).apply { + login(token) + } + this.client = newRestClient + this.cli = newCli + pollJob = poll(newRestClient, newCli) + context.logger.info("Workspace poll job with name ${pollJob.toString()} was created while handling URI") + return newRestClient to newCli + } + + private suspend fun askUrl(): String? { + context.popupPluginMainPage() + return dialogUi.ask( + context.i18n.ptrl("Deployment URL"), + context.i18n.ptrl("Enter the full URL of your Coder deployment") + ) } /** @@ -373,6 +470,9 @@ class CoderRemoteProvider( * list. */ override fun getOverrideUiPage(): UiPage? { + if (isHandlingUri.get()) { + return null + } // Show the setup page if we have not configured the client yet. if (client == null) { // When coming back to the application, initializeSession immediately. @@ -420,6 +520,7 @@ class CoderRemoteProvider( private fun onConnect(client: CoderRestClient, cli: CoderCLIManager) { // Store the URL and token for use next time. + close() context.settingsStore.updateLastUsedUrl(client.url) if (context.settingsStore.requiresTokenAuth) { context.secrets.storeTokenFor(client.url, client.token ?: "") @@ -428,10 +529,7 @@ class CoderRemoteProvider( context.logger.info("Deployment URL was stored and will be available for automatic connection") } this.client = client - pollJob?.let { - it.cancel() - context.logger.info("Cancelled workspace poll job ${pollJob.toString()} in order to start a new one") - } + this.cli = cli environments.showLoadingMessage() if (context.settingsStore.useAppNameAsTitle) { coderHeaderPage.setTitle(context.i18n.pnotr(client.appName)) diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 113ab9f..ae6d13a 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -7,26 +7,16 @@ 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.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.CoroutineName 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 -import java.net.URI +import java.net.URL 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 @@ -36,10 +26,6 @@ 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, - private val settingsPage: CoderSettingsPage, - private val visibilityState: MutableStateFlow, - private val isInitialized: StateFlow, ) { private val settings = context.settingsStore.readOnly() @@ -51,40 +37,15 @@ open class CoderProtocolHandler( * connectable state. */ suspend fun handle( - uri: URI, - shouldWaitForAutoLogin: Boolean, - reInitialize: suspend (CoderRestClient, CoderCLIManager) -> Unit + params: Map, + url: URL, + restClient: CoderRestClient, + cli: CoderCLIManager ) { - 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 - } - // this switches to the main plugin screen, even - // if last opened provider was not Coder - context.envPageManager.showPluginEnvironmentsPage() - if (shouldWaitForAutoLogin) { - isInitialized.waitForTrue() - } - - context.logger.info("Handling $uri...") - val deploymentURL = resolveDeploymentUrl(params) ?: return - val token = if (!context.settingsStore.requiresTokenAuth) null else resolveToken(params) ?: return val workspaceName = resolveWorkspaceName(params) ?: return - - 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, cli, workspaceName, deploymentURL)) return + val workspace = restClient.workspaces().matchName(workspaceName, url) + if (workspace != null) { + if (!prepareWorkspace(workspace, restClient, cli, url)) 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) @@ -105,55 +66,8 @@ open class CoderProtocolHandler( if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) { launchIde(environmentId, productCode, buildNumber, projectFolder) } - } - CoderCliSetupContext.apply { - url = deploymentURL.toURL() - 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, - jumpToMainPageOnError = true, - onConnect = ::onConnect - ) - ) - } - - private suspend fun resolveDeploymentUrl(params: Map): String? { - val deploymentURL = params.url() ?: askUrl() - if (deploymentURL.isNullOrBlank()) { - 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 - } - - 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? { @@ -165,7 +79,7 @@ open class CoderProtocolHandler( return workspace } - private suspend fun List.matchName(workspaceName: String, deploymentURL: String): Workspace? { + private suspend fun List.matchName(workspaceName: String, deploymentURL: URL): Workspace? { val workspace = this.firstOrNull { it.name == workspaceName } if (workspace == null) { context.logAndShowError( @@ -181,15 +95,14 @@ open class CoderProtocolHandler( workspace: Workspace, restClient: CoderRestClient, cli: CoderCLIManager, - workspaceName: String, - deploymentURL: String + url: URL ): Boolean { when (workspace.latestBuild.status) { WorkspaceStatus.PENDING, WorkspaceStatus.STARTING -> if (!restClient.waitForReady(workspace)) { context.logAndShowError( CAN_T_HANDLE_URI_TITLE, - "$workspaceName from $deploymentURL could not be ready on time" + "${workspace.name} from $url could not be ready on time" ) return false } @@ -199,7 +112,7 @@ open class CoderProtocolHandler( if (settings.disableAutostart) { context.logAndShowWarning( CAN_T_HANDLE_URI_TITLE, - "$workspaceName from $deploymentURL is not running and autostart is disabled" + "${workspace.name} from $url is not running and autostart is disabled" ) return false } @@ -213,7 +126,7 @@ open class CoderProtocolHandler( } catch (e: Exception) { context.logAndShowError( CAN_T_HANDLE_URI_TITLE, - "$workspaceName from $deploymentURL could not be started", + "${workspace.name} from $url could not be started", e ) return false @@ -222,7 +135,7 @@ open class CoderProtocolHandler( if (!restClient.waitForReady(workspace)) { context.logAndShowError( CAN_T_HANDLE_URI_TITLE, - "$workspaceName from $deploymentURL could not be started on time", + "${workspace.name} from $url could not be started on time", ) return false } @@ -231,7 +144,7 @@ open class CoderProtocolHandler( WorkspaceStatus.FAILED, WorkspaceStatus.DELETING, WorkspaceStatus.DELETED -> { context.logAndShowError( CAN_T_HANDLE_URI_TITLE, - "Unable to connect to $workspaceName from $deploymentURL" + "Unable to connect to ${workspace.name} from $url" ) return false } @@ -433,19 +346,9 @@ open class CoderProtocolHandler( return false } } - - private suspend fun askUrl(): String? { - context.popupPluginMainPage() - return dialogUi.ask( - context.i18n.ptrl("Deployment URL"), - context.i18n.ptrl("Enter the full URL of your Coder deployment") - ) - } } 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) +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt index eca1179..2c74024 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt @@ -18,6 +18,7 @@ class CoderCliSetupWizardPage( visibilityState: StateFlow, initialAutoSetup: Boolean = false, jumpToMainPageOnError: Boolean = false, + connectSynchronously: Boolean = false, onConnect: suspend ( client: CoderRestClient, cli: CoderCLIManager, @@ -33,9 +34,10 @@ class CoderCliSetupWizardPage( private val connectStep = ConnectStep( context, shouldAutoLogin = shouldAutoSetup, - jumpToMainPageOnError, + jumpToMainPageOnError = jumpToMainPageOnError, + connectSynchronously = connectSynchronously, visibilityState, - this::displaySteps, + refreshWizard = this::displaySteps, onConnect ) private val errorReporter = ErrorReporter.create(context, visibilityState, this.javaClass) diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt index a7ad70f..29a1e15 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt @@ -34,6 +34,8 @@ abstract class CoderPage( } } + override val isBusy: MutableStateFlow = MutableStateFlow(false) + /** * Return the icon, if showing one. * diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index 247d2c4..3c1c8ef 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -13,10 +13,12 @@ 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.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.yield private const val USER_HIT_THE_BACK_BUTTON = "User hit the back button" @@ -28,6 +30,7 @@ class ConnectStep( private val context: CoderToolboxContext, private val shouldAutoLogin: StateFlow, private val jumpToMainPageOnError: Boolean, + private val connectSynchronously: Boolean, visibilityState: StateFlow, private val refreshWizard: () -> Unit, private val onConnect: suspend (client: CoderRestClient, cli: CoderCLIManager) -> Unit, @@ -74,11 +77,15 @@ class ConnectStep( errorField.textState.update { context.i18n.ptrl("Token is required") } return } + // Capture the host name early for error reporting val hostName = url.host + // Cancel previous job regardless of the new mode signInJob?.cancel() - signInJob = context.cs.launch(CoroutineName("Http and CLI Setup")) { + + // 1. Extract the logic into a reusable suspend lambda + val connectionLogic: suspend CoroutineScope.() -> Unit = { try { context.logger.info("Setting up the HTTP client...") val client = CoderRestClient( @@ -125,6 +132,17 @@ class ConnectStep( refreshWizard() } } + + // 2. Choose the execution strategy based on the flag + if (connectSynchronously) { + // Blocks the current thread until connectionLogic completes + runBlocking(CoroutineName("Synchronous Http and CLI Setup")) { + connectionLogic() + } + } else { + // Runs asynchronously using the context's scope + signInJob = context.cs.launch(CoroutineName("Async Http and CLI Setup"), block = connectionLogic) + } } private fun logAndReportProgress(msg: String) { diff --git a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt index 1a84061..326fce0 100644 --- a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt @@ -5,11 +5,9 @@ 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 @@ -18,8 +16,6 @@ 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 java.util.UUID import kotlin.test.Test @@ -58,11 +54,7 @@ internal class CoderProtocolHandlerTest { ) private val protocolHandler = CoderProtocolHandler( - context, - DialogUi(context), - CoderSettingsPage(context, Channel(Channel.CONFLATED), {}), - MutableStateFlow(ProviderVisibilityState(applicationVisible = true, providerVisible = true)), - MutableStateFlow(false) + context ) @Test From 852b792b9da2e07a67084a0b9c5cf2b5f5ec02d4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 23:26:27 +0200 Subject: [PATCH 15/15] Changelog update - `v0.8.1` (#233) Current pull request contains patched `CHANGELOG.md` file for the `v0.8.1` version. Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62cf837..de79e05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.8.1 - 2025-12-11 + ### Changed - streamlined URI handling with a faster workflow, clearer progress, and an overall smoother experience