Skip to content

feat: coder connect integration #482

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

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@
"date-fns": "^3.6.0",
"eventsource": "^3.0.6",
"find-process": "https://github.com/coder/find-process#fix/sequoia-compat",
"ip-range-check": "^0.2.0",
"jsonc-parser": "^3.3.1",
"memfs": "^4.9.3",
"node-forge": "^1.3.1",
Expand Down
35 changes: 33 additions & 2 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { AxiosInstance } from "axios"
import { AxiosInstance, isAxiosError } from "axios"
import { spawn } from "child_process"
import { Api } from "coder/site/src/api/api"
import { ProvisionerJobLog, Workspace } from "coder/site/src/api/typesGenerated"
import { ProvisionerJobLog, SSHConfigResponse, Workspace } from "coder/site/src/api/typesGenerated"
import { FetchLikeInit } from "eventsource"
import fs from "fs/promises"
import { ProxyAgent } from "proxy-agent"
Expand Down Expand Up @@ -280,3 +280,34 @@ export async function waitForBuild(
writeEmitter.fire(`Workspace is now ${updatedWorkspace.latest_build.status}\r\n`)
return updatedWorkspace
}

export async function fetchSSHConfig(restClient: Api, vsc: typeof vscode): Promise<SSHConfigResponse> {
try {
const sshConfig = await restClient.getDeploymentSSHConfig()
return {
hostname_prefix: sshConfig.hostname_prefix,
hostname_suffix: sshConfig.hostname_suffix ?? "coder",
ssh_config_options: sshConfig.ssh_config_options,
}
} catch (error) {
if (!isAxiosError(error)) {
throw error
}
switch (error.response?.status) {
case 404: {
// Very old deployment that doesn't support SSH config
return {
hostname_prefix: "coder",
hostname_suffix: "coder",
ssh_config_options: {},
}
}
case 401: {
vsc.window.showErrorMessage("Your session expired...")
throw error
}
default:
throw error
}
}
}
257 changes: 162 additions & 95 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { Api } from "coder/site/src/api/api"
import { getErrorMessage } from "coder/site/src/api/errors"
import { User, Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated"
import * as vscode from "vscode"
import { makeCoderSdk, needToken } from "./api"
import { fetchSSHConfig, makeCoderSdk, needToken } from "./api"
import { extractAgents } from "./api-helper"
import { CertificateError } from "./error"
import { Storage } from "./storage"
import { toRemoteAuthority, toSafeHost } from "./util"
import { maybeCoderConnectAddr, toRemoteAuthority, toSafeHost } from "./util"
import { OpenableTreeItem } from "./workspacesProvider"

export class Commands {
Expand Down Expand Up @@ -392,7 +392,7 @@ export class Commands {
if (!baseUrl) {
throw new Error("You are not logged in")
}
await openWorkspace(
await this.openWorkspace(
baseUrl,
treeItem.workspaceOwner,
treeItem.workspaceName,
Expand Down Expand Up @@ -491,12 +491,12 @@ export class Commands {
} else {
workspaceOwner = args[0] as string
workspaceName = args[1] as string
// workspaceAgent is reserved for args[2], but multiple agents aren't supported yet.
workspaceAgent = args[2] as string | undefined
folderPath = args[3] as string | undefined
openRecent = args[4] as boolean | undefined
}

await openWorkspace(baseUrl, workspaceOwner, workspaceName, workspaceAgent, folderPath, openRecent)
await this.openWorkspace(baseUrl, workspaceOwner, workspaceName, workspaceAgent, folderPath, openRecent)
}

/**
Expand All @@ -512,11 +512,18 @@ export class Commands {

const workspaceOwner = args[0] as string
const workspaceName = args[1] as string
const workspaceAgent = undefined // args[2] is reserved, but we do not support multiple agents yet.
const workspaceAgent = args[2] as string | undefined
const devContainerName = args[3] as string
const devContainerFolder = args[4] as string

await openDevContainer(baseUrl, workspaceOwner, workspaceName, workspaceAgent, devContainerName, devContainerFolder)
await this.openDevContainerInner(
baseUrl,
workspaceOwner,
workspaceName,
workspaceAgent,
devContainerName,
devContainerFolder,
)
}

/**
Expand All @@ -540,107 +547,167 @@ export class Commands {
await this.workspaceRestClient.updateWorkspaceVersion(this.workspace)
}
}
}

/**
* Given a workspace, build the host name, find a directory to open, and pass
* both to the Remote SSH plugin in the form of a remote authority URI.
*/
async function openWorkspace(
baseUrl: string,
workspaceOwner: string,
workspaceName: string,
workspaceAgent: string | undefined,
folderPath: string | undefined,
openRecent: boolean | undefined,
) {
// A workspace can have multiple agents, but that's handled
// when opening a workspace unless explicitly specified.
const remoteAuthority = toRemoteAuthority(baseUrl, workspaceOwner, workspaceName, workspaceAgent)

let newWindow = true
// Open in the existing window if no workspaces are open.
if (!vscode.workspace.workspaceFolders?.length) {
newWindow = false
}
/**
* Given a workspace, build the host name, find a directory to open, and pass
* both to the Remote SSH plugin in the form of a remote authority URI.
*/
private async openWorkspace(
baseUrl: string,
workspaceOwner: string,
workspaceName: string,
workspaceAgent: string | undefined,
folderPath: string | undefined,
openRecent: boolean | undefined,
) {
let remoteAuthority = toRemoteAuthority(baseUrl, workspaceOwner, workspaceName, workspaceAgent)

// We can't connect using Coder Connect straightaway if `workspaceAgent`
// is undefined. This happens when:
// 1. The workspace is stopped
// 2. A `vscode://coder.coder-remote` URI does not include the agent as a
// query parameter.
//
// For 1. `Remote.setup` will migrate us to Coder Connect once the workspace is built.
// For 2. `Remote.setup` will call `maybeAskAgent` and then migrate us to Coder Connect
if (workspaceAgent) {
let sshConfig
try {
// Fetch (or get defaults) for the SSH config.
sshConfig = await fetchSSHConfig(this.restClient, this.vscodeProposed)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The extension has an SSH config section which I assume is not going apply to Coder Connect, is that right? As well as anything the user has added to their SSH config manually.

Wondering if we should update the setting description to say the variables will not apply if there is a Coder Connect session we can use.

Speaking of settings, should using Coder Connect be behind a setting as well? Or a prompt? "We see Coder Connect, do you want to use that?" or similar.

Copy link
Member Author

@ethanndickson ethanndickson Apr 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering if we should update the setting description to say the variables will not apply if there is a Coder Connect session we can use.

Will add.

As well as anything the user has added to their SSH config manually.

These will apply, as long as they're on the Coder Connect hostname (so, if they just specify those options when running coder config-ssh it'll get added to *.<hostnamesuffix>).

Speaking of settings, should using Coder Connect be behind a setting as well? Or a prompt? "We see Coder Connect, do you want to use that?" or similar.

I don't think this is necessary, I can't imagine a scenario where the user would have Coder Connect on and not want to use it?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is necessary, I can't imagine a scenario where the user would have Coder Connect on and not want to use it?

I agree.

Copy link
Member

@code-asher code-asher Apr 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the tricky part is that the user might not realize it will use Connect. So, they have X11Forwarding yes in their VS Code config or in their SSH config under Host coder-vscode--*, they are trying out Connect, one day they update the plugin, and suddenly no more forwarding. Some days it does forward, some days does not, depending on whether Connect is up (idk if this is realistic usage/behavior though).

So there should be clear messaging what the plugin is going to do IMO, somewhere. Silently changing behavior feels bad to me. But, I am neither a VS Code nor Connect user, so take what I say with a grain of salt. 😆 Maybe the behavior change will be obvious to users.

If we could make the same SSH settings apply either way, then that would be a different story.

Copy link
Member Author

@ethanndickson ethanndickson Apr 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we could make the same SSH settings apply either way, then that would be a different story.

With the updated coder ssh-config, any options applied to that will also apply to Coder Connect via VSCode, at least.

Though if we change the approach to what I discussed below that solves this issue too ig

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the updated coder ssh-config, any options applied to that will also apply to Coder Connect via VSCode, at least.

That is quite nice!

} catch (error) {
const message = getErrorMessage(error, "no response from the server")
this.storage.writeToCoderOutputChannel(`Failed to open workspace: ${message}`)
this.vscodeProposed.window.showErrorMessage("Failed to open workspace", {
detail: message,
modal: true,
useCustom: true,
})
return
}

// If a folder isn't specified or we have been asked to open the most recent,
// we can try to open a recently opened folder/workspace.
if (!folderPath || openRecent) {
const output: {
workspaces: { folderUri: vscode.Uri; remoteAuthority: string }[]
} = await vscode.commands.executeCommand("_workbench.getRecentlyOpened")
const opened = output.workspaces.filter(
// Remove recents that do not belong to this connection. The remote
// authority maps to a workspace or workspace/agent combination (using the
// SSH host name). This means, at the moment, you can have a different
// set of recents for a workspace versus workspace/agent combination, even
// if that agent is the default for the workspace.
(opened) => opened.folderUri?.authority === remoteAuthority,
)
const coderConnectAddr = await maybeCoderConnectAddr(
workspaceAgent,
workspaceName,
workspaceOwner,
sshConfig.hostname_suffix,
)
if (coderConnectAddr) {
remoteAuthority = `ssh-remote+${coderConnectAddr}`
}
}

// openRecent will always use the most recent. Otherwise, if there are
// multiple we ask the user which to use.
if (opened.length === 1 || (opened.length > 1 && openRecent)) {
folderPath = opened[0].folderUri.path
} else if (opened.length > 1) {
const items = opened.map((f) => f.folderUri.path)
folderPath = await vscode.window.showQuickPick(items, {
title: "Select a recently opened folder",
})
if (!folderPath) {
// User aborted.
return
let newWindow = true
// Open in the existing window if no workspaces are open.
if (!vscode.workspace.workspaceFolders?.length) {
newWindow = false
}

// If a folder isn't specified or we have been asked to open the most recent,
// we can try to open a recently opened folder/workspace.
if (!folderPath || openRecent) {
const output: {
workspaces: { folderUri: vscode.Uri; remoteAuthority: string }[]
} = await vscode.commands.executeCommand("_workbench.getRecentlyOpened")
const opened = output.workspaces.filter(
// Remove recents that do not belong to this connection. The remote
// authority maps to a workspace or workspace/agent combination (using the
// SSH host name). This means, at the moment, you can have a different
// set of recents for a workspace versus workspace/agent combination, even
// if that agent is the default for the workspace.
(opened) => opened.folderUri?.authority === remoteAuthority,
)

// openRecent will always use the most recent. Otherwise, if there are
// multiple we ask the user which to use.
if (opened.length === 1 || (opened.length > 1 && openRecent)) {
folderPath = opened[0].folderUri.path
} else if (opened.length > 1) {
const items = opened.map((f) => f.folderUri.path)
folderPath = await vscode.window.showQuickPick(items, {
title: "Select a recently opened folder",
})
if (!folderPath) {
// User aborted.
return
}
}
}

if (folderPath) {
await vscode.commands.executeCommand(
"vscode.openFolder",
vscode.Uri.from({
scheme: "vscode-remote",
authority: remoteAuthority,
path: folderPath,
}),
// Open this in a new window!
newWindow,
)
return
}

// This opens the workspace without an active folder opened.
await vscode.commands.executeCommand("vscode.newWindow", {
remoteAuthority: remoteAuthority,
reuseWindow: !newWindow,
})
}

if (folderPath) {
private async openDevContainerInner(
baseUrl: string,
workspaceOwner: string,
workspaceName: string,
workspaceAgent: string | undefined,
devContainerName: string,
devContainerFolder: string,
) {
let remoteAuthority = toRemoteAuthority(baseUrl, workspaceOwner, workspaceName, workspaceAgent)

if (workspaceAgent) {
let sshConfig
try {
// Fetch (or get defaults) for the SSH config.
sshConfig = await fetchSSHConfig(this.restClient, this.vscodeProposed)
} catch (error) {
const message = getErrorMessage(error, "no response from the server")
this.storage.writeToCoderOutputChannel(`Failed to open workspace: ${message}`)
this.vscodeProposed.window.showErrorMessage("Failed to open workspace", {
detail: message,
modal: true,
useCustom: true,
})
return
}

const coderConnectAddr = await maybeCoderConnectAddr(
workspaceAgent,
workspaceName,
workspaceOwner,
sshConfig.hostname_suffix,
)
if (coderConnectAddr) {
remoteAuthority = `ssh-remote+${coderConnectAddr}`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably a dumb question, but this can result in multiple host names for the same workspace/agent? One for Connect, one for the fallback. Will that be potentially confusing, for example in the recents list I imagine it could show both? I know for the fallback we reopen with Connect if we can, but it does seem unfortunate to have both.

Is there any world where we use Connect if possible as part of the proxy command or does it have to be in the extension itself? That would make it trivial to implement for other IDEs and would let us reuse the same host name (and I suppose it would make it work for ssh <vscode host> on the command line too, although idk why someone would do that except for debugging). I would dread having to do this for Toolbox, if we even can. 😨

Copy link
Member Author

@ethanndickson ethanndickson Apr 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would dread having to do this for Toolbox, if we even can.

It looks like Toolbox & regular Jetbrains don't use wildcard SSH configs? I think that makes implementing this on those a lot easier:

Match host coder-jetbrains-toolbox-foo--test.coder.bar exec "/path/to/coder connect exists agent1.foo.owner.coder"
  HostName agent1.foo.owner.coder
Host coder-jetbrains-toolbox-foo--test.coder.bar
  ProxyCommand /path/to/coder ssh owner/foo.agent1
  ConnectTimeout 0
  StrictHostKeyChecking no
  UserKnownHostsFile /dev/null
  LogLevel ERROR
  SetEnv CODER_SSH_SESSION_TYPE=JetBrains

This is similar to how the new coder config-ssh works, we only use the ProxyCommand if the DNS record doesn't exist, which we check using coder connect exists.

Since we use wildcard configs for the VSCode extension, using just the SSH config is pretty complicated. We'd need coder connect exists to be able to handle an argument of the form <owner>--<workspace>.<agent>. Then, we'd need another command to translate <owner>--<workspace>.<agent> to <agent>.<workspace>.<owner>.<hostnamesuffix> if coder connect exists returned 0.

I assume it would look something like:

Host coder-vscode.dev.coder.com--* exec "/path/to/coder connect exists %h"
  ProxyCommand ssh "$(coder connect convert %h)

In both exists and convert, we'd need to auth with coderd to retrieve the hostname suffix, and then if the agent is missing from the authority string, we'd also need to resolve that somehow.

I think this approach is just too slow; two CLI invocations that need to init a codersdk.Client after reading from disk. Right now coder connect exists doesn't need to do that.

Alternatively, we could change the portion after coder-vscode.dev.coder.com--, to <agent>.<workspace>.<owner>.<hostnamesuffix>, but coder ssh doesn't support that right now, and we'd still need to fetch the hostnamesuffix in the extension.
This might be worth doing? We just only set the new hostname in the remote authority if the Coder CLI would support it.

However, it might be a big lift to ensure each plugin knows what agent it's connecting to before calling SSH. Right now the Coder CLI handles that if it's not supplied in the remote authority slug.

Will that be potentially confusing, for example in the recents list I imagine it could show both?

But yeah, it does show both unfortunately. WDYT about the alternatives?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Toolbox does use wildcard hostnames by default, with an option to opt out. see the sample config from my system

# --- START CODER JETBRAINS TOOLBOX dev.coder.com
Host coder-jetbrains-toolbox-dev.coder.com--*
  ProxyCommand "/Users/matifali/Library/Application Support/coder-toolbox/dev.coder.com/coder-darwin-arm64" --global-config "/Users/matifali/Library/Application Support/coder-toolbox/dev.coder.com/config" --url https://dev.coder.com ssh --stdio --disable-autostart --usage-app=jetbrains --ssh-host-prefix coder-jetbrains-toolbox-dev.coder.com-- %h
  ConnectTimeout 0
  StrictHostKeyChecking no
  UserKnownHostsFile /dev/null
  LogLevel ERROR
  SetEnv CODER_SSH_SESSION_TYPE=JetBrains

Host coder-jetbrains-toolbox-dev.coder.com-bg--*
  ProxyCommand "/Users/matifali/Library/Application Support/coder-toolbox/dev.coder.com/coder-darwin-arm64" --global-config "/Users/matifali/Library/Application Support/coder-toolbox/dev.coder.com/config" --url https://dev.coder.com ssh --stdio --disable-autostart --usage-app=disable --ssh-host-prefix coder-jetbrains-toolbox-dev.coder.com-bg-- %h
  ConnectTimeout 0
  StrictHostKeyChecking no
  UserKnownHostsFile /dev/null
  LogLevel ERROR
  SetEnv CODER_SSH_SESSION_TYPE=JetBrains
# --- END CODER JETBRAINS TOOLBOX dev.coder.com

Copy link
Member

@code-asher code-asher Apr 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, we do use wildcards in Gateway and Toolbox now. I do not think we will be able to switch the hostname from out of under Toolbox like we can here in VS Code unfortunately (at least not the way it is currently implemented, maybe it can if we implement our own connector instead of using their SSH stuff but idk if that is possible). 😢 If we can implement purely in the SSH config and CLI, that would be ideal.

IDK the best way to do it, but my initial thought would be to make coder ssh handle it. Maybe it should be behind a --use-connect flag or something, but basically it would just do whatever coder connect convert is doing, right? It could even do the exists part as well, if we want to avoid writing new blocks.

IMO remote editing from the IDE's point of view should be more or less just ssh <hostname> and we can hide away and reuse all this logic in the CLI.

Copy link
Member

@code-asher code-asher Apr 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But, I do like the example you have as well. How slow are we talking? Some extra seconds to connect may not be the worst thing, if it reduces complexity on our end and makes it possible to support Connect with Toolbox.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not directly related but with toolbox JB uses the system ssh and support all SSH options. In gateway they had a built-in SSH implementation that didn't support all options.

Copy link
Member Author

@ethanndickson ethanndickson Apr 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We decided against making it part of coder ssh because of the performance cost of a bicopy on the SSH connection. That's what led us to add coder connect exists for config-ssh.

How slow are we talking?

Honestly, it might be negligible on macOS and Linux. On the other hand, Spike discovered that coder connect exists takes almost 1.5s on Windows, and that's literally just to execute the binary and do a DNS lookup, so I'd expect this approach to be significantly slower there.

Copy link
Member

@code-asher code-asher Apr 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahhh, fair. Was the performance cost of the bicopy measured? It does sound unfortunate.

1.5s for running exists is wild lol, I guess that is Windows for ya.

IMO we have a few paths forward, ideally we figure out which one before merging since this may inform how we should do it here as well:

  1. Do it somehow in the SSH config to support Toolbox
  2. Decide not to support Toolbox
  3. Figure out if there is a different way we can support Toolbox

But also do not let me just be a blocker if we need to move forward for now haha. I do think the doubled-up host names and the SSH settings have the potential to cause some discomfort though.

It occurs to me that if we had our own connection code instead of delegating to Microsoft's plugin, we could transparently use a different host name under the connection without doubling up on them. We could name the recent connections anything we wanted at that point. We could even add settings at that point too, I think?? 🤔

}
}

const devContainer = Buffer.from(JSON.stringify({ containerName: devContainerName }), "utf-8").toString("hex")
const devContainerAuthority = `attached-container+${devContainer}@${remoteAuthority}`

let newWindow = true
if (!vscode.workspace.workspaceFolders?.length) {
newWindow = false
}

await vscode.commands.executeCommand(
"vscode.openFolder",
vscode.Uri.from({
scheme: "vscode-remote",
authority: remoteAuthority,
path: folderPath,
authority: devContainerAuthority,
path: devContainerFolder,
}),
// Open this in a new window!
newWindow,
)
return
}

// This opens the workspace without an active folder opened.
await vscode.commands.executeCommand("vscode.newWindow", {
remoteAuthority: remoteAuthority,
reuseWindow: !newWindow,
})
}

async function openDevContainer(
baseUrl: string,
workspaceOwner: string,
workspaceName: string,
workspaceAgent: string | undefined,
devContainerName: string,
devContainerFolder: string,
) {
const remoteAuthority = toRemoteAuthority(baseUrl, workspaceOwner, workspaceName, workspaceAgent)

const devContainer = Buffer.from(JSON.stringify({ containerName: devContainerName }), "utf-8").toString("hex")
const devContainerAuthority = `attached-container+${devContainer}@${remoteAuthority}`

let newWindow = true
if (!vscode.workspace.workspaceFolders?.length) {
newWindow = false
}

await vscode.commands.executeCommand(
"vscode.openFolder",
vscode.Uri.from({
scheme: "vscode-remote",
authority: devContainerAuthority,
path: devContainerFolder,
}),
newWindow,
)
}
Loading