Skip to content

impl: add support for matching agent by name #146

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jul 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@

- support for basic authentication for HTTP/HTTPS proxy
- support for Toolbox 2.7 release
- support for matching workspace agent in the URI via the agent name

### Changed

- improved message while loading the workspace

### Removed

- dropped support for `agent_id` as a URI parameter

### Fixed

- URI protocol handler is now able to switch to the Coder provider even if the last opened provider was something else
Expand Down
55 changes: 30 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,9 @@ You can use specially crafted JetBrains Gateway URIs to automatically:
### Example URIs

```text
jetbrains://gateway/com.coder.toolbox?url=https%3A%2F%2Fdev.coder.com&token=zeoX4SbSpP-j2qGpajkdwxR9jBdcekXS2&workspace=bobiverse-bob&agent=dev&ide_product_code=GO&ide_build_number=241.23774.119&folder=%2Fhome%2Fcoder%2Fworkspace%2Fhello-world-rs
jetbrains://gateway/com.coder.toolbox?url=https%3A%2F%2Fdev.coder.com&token=zeoX4SbSpP-j2qGpajkdwxR9jBdcekXS2&workspace=bobiverse-bob&agent_name=dev&ide_product_code=GO&ide_build_number=241.23774.119&folder=%2Fhome%2Fcoder%2Fworkspace%2Fhello-world-rs

jetbrains://gateway/com.coder.toolbox?url=https%3A%2F%2Fj5gj2r1so5nbi.pit-1.try.coder.app%2F&token=gqEirOoI1U-FfCQ6uj8iOLtybBIk99rr8&workspace=bobiverse-riker&agent=dev&ide_product_code=RR&ide_build_number=243.26053.17&folder=%2Fhome%2Fcoder%2Fworkspace%2Fhello-world-rs
jetbrains://gateway/coder?url=https%3A%2F%2Fj5gj2r1so5nbi.pit-1.try.coder.app%2F&token=gqEirOoI1U-FfCQ6uj8iOLtybBIk99rr8&workspace=bobiverse-riker&agent_name=dev&ide_product_code=RR&ide_build_number=243.26053.17&folder=%2Fhome%2Fcoder%2Fworkspace%2Fhello-world-rs
```

### URI Breakdown
Expand All @@ -76,13 +76,15 @@ jetbrains://gateway/com.coder.toolbox
?url=http(s)://<your-coder-deployment>
&token=<auth-token>
&workspace=<workspace-name>
&agent_id=<agent--id>
&agent_name=<agent-name>
&ide_product_code=<IDE-code>
&ide_build_number=<IDE-build>
&folder=<absolute-path-to-a-project-folder>
```

Starting from Toolbox 2.7, you can use `coder` as a shortcut in place of the full plugin ID. The URI can be simplified as:
Starting from Toolbox 2.7, you can use `coder` as a shortcut in place of the full plugin ID. The URI can be simplified
as:

```text
jetbrains://gateway/coder?url=http(s)://<your-coder-deployment>
```
Expand All @@ -92,16 +94,16 @@ jetbrains://gateway/coder?url=http(s)://<your-coder-deployment>
| url | Your Coder deployment URL (encoded) | Yes |
| token | Coder authentication token | Yes |
| workspace | Name of the Coder workspace to connect to. | Yes |
| agent_id | ID of the agent associated with the workspace | No |
| agent_name | The name of the agent with the workspace | No |
| ide_product_code | JetBrains IDE product code (e.g., GO for GoLand, RR for Rider) | No |
| ide_build_number | Specific build number of the JetBrains IDE to install on the workspace | No |
| folder | Absolute path to the project folder to open in the remote IDE (URL-encoded) | No |

> [!NOTE]
> If only a single agent is available, specifying an agent ID is optional. However, if multiple agents exist,
> you must provide either the ID to target a specific one. Note that this version of the Coder Toolbox plugin
> does not automatically start agents if they are offline, so please ensure the selected agent is running before
> proceeding.
> If only a single agent is available, specifying an agent name is optional. However, if multiple agents exist, you must
> provide the
> agent name. Note that this version of the Coder Toolbox plugin does not automatically start agents if they
> are offline, so please ensure the selected agent is running before proceeding.

If `ide_product_code` and `ide_build_number` is missing, Toolbox will only open and highlight the workspace environment
page. Coder Toolbox will attempt to start the workspace if it’s not already running; however, for the most reliable
Expand Down Expand Up @@ -151,7 +153,9 @@ mitmweb --ssl-insecure --set stream_large_bodies="10m" --mode socks5
> [!NOTE]
> Coder Toolbox plugin handles only HTTP/HTTPS proxy authentication.
> SOCKS5 proxy authentication is currently not supported due to limitations
> described in: https://youtrack.jetbrains.com/issue/TBX-14532/Missing-proxy-authentication-settings#focus=Comments-27-12265861.0-0
> described
>
in: https://youtrack.jetbrains.com/issue/TBX-14532/Missing-proxy-authentication-settings#focus=Comments-27-12265861.0-0

## Debugging and Reporting issues

Expand Down Expand Up @@ -198,56 +202,56 @@ storage paths. The options can be configured from the plugin's main Workspaces p
### CLI related settings

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

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

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

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

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

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

### TLS settings

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

- `TLS cert path` path to a client certificate file for TLS authentication with Coder deployment.
The certificate should be in X.509 PEM format.
The certificate should be in X.509 PEM format.

- `TLS key path` path to the private key corresponding to the TLS certificate from above.
The certificate should be in X.509 PEM format.
The certificate should be in X.509 PEM format.

- `TLS CA path` the path of a file containing certificates for an alternate certificate authority used to verify TLS
certs returned by the Coder deployment. The file should be in X.509 PEM format. This option can also be used to verify
proxy certificates.
certs returned by the Coder deployment. The file should be in X.509 PEM format. This option can also be used to verify
proxy certificates.

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

### SSH settings

The following options control the SSH behavior of the Coder CLI.

- `Disable autostart` adds the --disable-autostart flag to the SSH proxy command, preventing the CLI from keeping
workspaces constantly active.
workspaces constantly active.

- `Enable SSH wildcard config` enables or disables wildcard entries in the SSH configuration, which allow generic
rules for matching multiple workspaces.
rules for matching multiple workspaces.

- `SSH proxy log directory` directory where SSH proxy logs are written. Useful for debugging SSH connection issues.

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

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

### Saving Changes

Expand All @@ -256,7 +260,7 @@ support, may trigger regeneration of SSH configurations.

### Security considerations

> [!IMPORTANT]
> [!IMPORTANT]
> Token authentication is required when TLS certificates are not configured.

## Releasing
Expand All @@ -269,6 +273,7 @@ support, may trigger regeneration of SSH configurations.
JetBrains enabled auto-approval for the plugin, so we need to ensure we continue to meet the following requirements:
- do **not** use Kotlin experimental APIs.
- do **not** add any lambdas, handlers, or class handles to Java runtime hooks.
- do **not** create threads manually (including via libraries). If you must, ensure they are properly cleaned up in the plugin's `CoderRemoteProvider#close()` method.
- do **not** create threads manually (including via libraries). If you must, ensure they are properly cleaned up in
the plugin's `CoderRemoteProvider#close()` method.
- do **not** bundle libraries that are already provided by Toolbox.
- do **not** perform any ill-intentioned actions.
21 changes: 10 additions & 11 deletions src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -258,26 +258,25 @@ open class CoderProtocolHandler(
}

// If the agent is missing and the workspace has only one, use that.
val agent =
if (!parameters.agentID().isNullOrBlank()) {
agents.firstOrNull { it.id.toString() == parameters.agentID() }
} else if (agents.size == 1) {
agents.first()
} else {
null
}
val agent = if (!parameters.agentName().isNullOrBlank()) {
agents.firstOrNull { it.name == parameters.agentName() }
} else if (agents.size == 1) {
agents.first()
} else {
null
}

if (agent == null) {
if (!parameters.agentID().isNullOrBlank()) {
if (!parameters.agentName().isNullOrBlank()) {
context.logAndShowError(
CAN_T_HANDLE_URI_TITLE,
"The workspace \"${workspace.name}\" does not have an agent with ID \"${parameters.agentID()}\""
"The workspace \"${workspace.name}\" does not have an agent with name \"${parameters.agentName()}\""
)
return null
} else {
context.logAndShowError(
CAN_T_HANDLE_URI_TITLE,
"Unable to determine which agent to connect to; \"$AGENT_ID\" must be set because the workspace \"${workspace.name}\" has more than one agent"
"Unable to determine which agent to connect to; \"$AGENT_NAME\" must be set because the workspace \"${workspace.name}\" has more than one agent"
)
return null
}
Expand Down
4 changes: 2 additions & 2 deletions src/main/kotlin/com/coder/toolbox/util/LinkMap.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package com.coder.toolbox.util
const val URL = "url"
const val TOKEN = "token"
const val WORKSPACE = "workspace"
const val AGENT_ID = "agent_id"
const val AGENT_NAME = "agent_name"
private const val IDE_PRODUCT_CODE = "ide_product_code"
private const val IDE_BUILD_NUMBER = "ide_build_number"
private const val FOLDER = "folder"
Expand All @@ -14,7 +14,7 @@ fun Map<String, String>.token() = this[TOKEN]

fun Map<String, String>.workspace() = this[WORKSPACE]

fun Map<String, String?>.agentID() = this[AGENT_ID]
fun Map<String, String?>.agentName() = this[AGENT_NAME]

fun Map<String, String>.ideProductCode() = this[IDE_PRODUCT_CODE]

Expand Down
85 changes: 35 additions & 50 deletions src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.DisplayName
import java.util.UUID
import kotlin.test.Test
import kotlin.test.assertEquals
Expand Down Expand Up @@ -47,40 +48,34 @@ internal class CoderProtocolHandlerTest {

private val agents =
mapOf(
"agent_name_3" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24",
"agent_name_2" to "fb3daea4-da6b-424d-84c7-36b90574cfef",
"agent_name" to "9a920eee-47fb-4571-9501-e4b3120c12f2",
"agent_name_bob" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24",
"agent_name_bill" to "fb3daea4-da6b-424d-84c7-36b90574cfef",
"agent_name_riker" to "9a920eee-47fb-4571-9501-e4b3120c12f2",
)
private val oneAgent =
private val agentBob =
mapOf(
"agent_name_3" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24",
"agent_name_bob" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24",
)

@Test
fun tstgetMatchingAgent() {
@DisplayName("given a ws with multiple agents, expect the correct agent to be resolved if it matches the agent_name query param")
fun getMatchingAgent() {
val ws = DataGen.workspace("ws", agents = agents)

val tests =
listOf(
Pair(
mapOf("agent_id" to "9a920eee-47fb-4571-9501-e4b3120c12f2"),
mapOf("agent_name" to "agent_name_riker"),
"9a920eee-47fb-4571-9501-e4b3120c12f2"
),
Pair(
mapOf("agent_id" to "fb3daea4-da6b-424d-84c7-36b90574cfef"),
mapOf("agent_name" to "agent_name_bill"),
"fb3daea4-da6b-424d-84c7-36b90574cfef"
),
Pair(
mapOf("agent_id" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24"),
mapOf("agent_name" to "agent_name_bob"),
"b0e4c54d-9ba9-4413-8512-11ca1e826a24"
),
// Prefer agent_id.
Pair(
mapOf(
"agent_id" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24",
),
"b0e4c54d-9ba9-4413-8512-11ca1e826a24",
),
)
)
runBlocking {
tests.forEach {
Expand All @@ -90,28 +85,20 @@ internal class CoderProtocolHandlerTest {
}

@Test
@DisplayName("given a ws with only multiple agents expect the agent resolution to fail if none match the agent_name query param")
fun failsToGetMatchingAgent() {
val ws = DataGen.workspace("ws", agents = agents)
val tests =
listOf(
Triple(emptyMap(), MissingArgumentException::class, "Unable to determine"),
Triple(mapOf("agent_id" to ""), MissingArgumentException::class, "Unable to determine"),
Triple(mapOf("agent_id" to null), MissingArgumentException::class, "Unable to determine"),
Triple(mapOf("agent_id" to "not-a-uuid"), IllegalArgumentException::class, "agent with ID"),
Triple(mapOf("agent_name" to ""), MissingArgumentException::class, "Unable to determine"),
Triple(mapOf("agent_name" to null), MissingArgumentException::class, "Unable to determine"),
Triple(mapOf("agent_name" to "not-an-agent-name"), IllegalArgumentException::class, "agent with ID"),
Triple(
mapOf("agent_id" to "ceaa7bcf-1612-45d7-b484-2e0da9349168"),
mapOf("agent_name" to "agent_name_homer"),
IllegalArgumentException::class,
"agent with ID"
),
// Will ignore agent if agent_id is set even if agent matches.
Triple(
mapOf(
"agent" to "agent_name",
"agent_id" to "ceaa7bcf-1612-45d7-b484-2e0da9349168",
),
IllegalArgumentException::class,
"agent with ID",
),
"agent with name"
)
)
runBlocking {
tests.forEach {
Expand All @@ -121,15 +108,14 @@ internal class CoderProtocolHandlerTest {
}

@Test
@DisplayName("given a ws with only one agent, the agent is selected even when agent_name query param was not provided")
fun getsFirstAgentWhenOnlyOne() {
val ws = DataGen.workspace("ws", agents = oneAgent)
val ws = DataGen.workspace("ws", agents = agentBob)
val tests =
listOf(
emptyMap(),
mapOf("agent" to ""),
mapOf("agent_id" to ""),
mapOf("agent" to null),
mapOf("agent_id" to null),
mapOf("agent_name" to ""),
mapOf("agent_name" to null)
)
runBlocking {
tests.forEach {
Expand All @@ -145,43 +131,42 @@ internal class CoderProtocolHandlerTest {
}

@Test
@DisplayName("given a ws with only one agent, the agent is NOT selected when agent_name query param was provided but does not match")
fun failsToGetAgentWhenOnlyOne() {
val ws = DataGen.workspace("ws", agents = oneAgent)
val wsWithAgentBob = DataGen.workspace("ws", agents = agentBob)
val tests =
listOf(
Triple(
mapOf("agent_id" to "ceaa7bcf-1612-45d7-b484-2e0da9349168"),
mapOf("agent_name" to "agent_name_garfield"),
IllegalArgumentException::class,
"agent with ID"
"agent with name"
),
)
runBlocking {
tests.forEach {
assertNull(protocolHandler.getMatchingAgent(it.first, ws)?.id)
assertNull(protocolHandler.getMatchingAgent(it.first, wsWithAgentBob))
}
}
}

@Test
fun failsToGetAgentWithoutAgents() {
val ws = DataGen.workspace("ws")
@DisplayName("fails to resolve any agent when the workspace has no agents")
fun failsToGetAgentWhenWorkspaceHasNoAgents() {
val wsWithoutAgents = DataGen.workspace("ws")
val tests =
listOf(
Triple(emptyMap(), IllegalArgumentException::class, "has no agents"),
Triple(mapOf("agent" to ""), IllegalArgumentException::class, "has no agents"),
Triple(mapOf("agent_id" to ""), IllegalArgumentException::class, "has no agents"),
Triple(mapOf("agent" to null), IllegalArgumentException::class, "has no agents"),
Triple(mapOf("agent_id" to null), IllegalArgumentException::class, "has no agents"),
Triple(mapOf("agent" to "agent_name"), IllegalArgumentException::class, "has no agents"),
Triple(mapOf("agent_name" to ""), IllegalArgumentException::class, "has no agents"),
Triple(mapOf("agent_name" to null), IllegalArgumentException::class, "has no agents"),
Triple(
mapOf("agent_id" to "9a920eee-47fb-4571-9501-e4b3120c12f2"),
mapOf("agent_name" to "agent_name_riker"),
IllegalArgumentException::class,
"has no agents"
),
)
runBlocking {
tests.forEach {
assertNull(protocolHandler.getMatchingAgent(it.first, ws)?.id)
assertNull(protocolHandler.getMatchingAgent(it.first, wsWithoutAgents))
}
}
}
Expand Down
Loading