From 95b5b4eb89b3d67a98c6e03bc5b40e2aefa23193 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 17 Apr 2025 12:34:30 +1000 Subject: [PATCH 01/14] feat: coder connect integration --- package.json | 1 + src/commands.ts | 110 +++++++++++++++++++++++++++++++++----- src/workspacesProvider.ts | 2 +- yarn.lock | 20 ++++++- 4 files changed, 117 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index a85fd235..7004e518 100644 --- a/package.json +++ b/package.json @@ -316,6 +316,7 @@ "node-forge": "^1.3.1", "pretty-bytes": "^6.1.1", "proxy-agent": "^6.4.0", + "range_check": "^3.2.0", "semver": "^7.6.2", "ua-parser-js": "^1.0.38", "ws": "^8.18.1", diff --git a/src/commands.ts b/src/commands.ts index d24df729..7ef3a608 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,6 +1,10 @@ +import { isAxiosError } from "axios" 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 { lookup } from "dns" +import { inRange } from "range_check" +import { promisify } from "util" import * as vscode from "vscode" import { makeCoderSdk, needToken } from "./api" import { extractAgents } from "./api-helper" @@ -392,14 +396,33 @@ export class Commands { if (!baseUrl) { throw new Error("You are not logged in") } - await openWorkspace( - baseUrl, - treeItem.workspaceOwner, - treeItem.workspaceName, - treeItem.workspaceAgent, - treeItem.workspaceFolderPath, - true, - ) + + let agent = treeItem.workspaceAgent + if (!agent) { + // `openFromSidebar` is only callable on agents or single-agent workspaces, + // where this will always be set. + return + } + + try { + await openWorkspace( + this.restClient, + baseUrl, + treeItem.workspaceOwner, + treeItem.workspaceName, + agent, + treeItem.workspaceFolderPath, + true, + ) + } catch (err) { + const message = getErrorMessage(err, "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, + }) + } } else { // If there is no tree item, then the user manually ran this command. // Default to the regular open instead. @@ -491,12 +514,30 @@ 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 folderPath = args[3] as string | undefined openRecent = args[4] as boolean | undefined } - await openWorkspace(baseUrl, workspaceOwner, workspaceName, workspaceAgent, folderPath, openRecent) + try { + await openWorkspace( + this.restClient, + baseUrl, + workspaceOwner, + workspaceName, + workspaceAgent, + folderPath, + openRecent, + ) + } catch (err) { + const message = getErrorMessage(err, "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, + }) + } } /** @@ -547,16 +588,42 @@ export class Commands { * both to the Remote SSH plugin in the form of a remote authority URI. */ async function openWorkspace( + restClient: Api, baseUrl: string, workspaceOwner: string, workspaceName: string, - workspaceAgent: string | undefined, + workspaceAgent: string, 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 remoteAuthority = toRemoteAuthority(baseUrl, workspaceOwner, workspaceName, workspaceAgent) + + let hostnameSuffix = "coder" + try { + const sshConfig = await restClient.getDeploymentSSHConfig() + // If the field is undefined, it's an older server, and always 'coder' + hostnameSuffix = sshConfig.hostname_suffix ?? hostnameSuffix + } catch (error) { + if (!isAxiosError(error)) { + throw error + } + switch (error.response?.status) { + case 404: { + // Likely a very old deployment, just use the default. + break + } + case 401: { + throw error + } + default: + throw error + } + } + + const coderConnectAddr = await maybeCoderConnectAddr(workspaceAgent, workspaceName, workspaceOwner, hostnameSuffix) + if (coderConnectAddr) { + remoteAuthority = `ssh-remote+${coderConnectAddr}` + } let newWindow = true // Open in the existing window if no workspaces are open. @@ -616,6 +683,21 @@ async function openWorkspace( }) } +async function maybeCoderConnectAddr( + agent: string, + workspace: string, + owner: string, + hostnameSuffix: string, +): Promise { + const coderConnectHostname = `${agent}.${workspace}.${owner}.${hostnameSuffix}` + try { + const res = await promisify(lookup)(coderConnectHostname) + return res.family == 6 && inRange(res.address, "fd60:627a:a42b::/48") ? coderConnectHostname : undefined + } catch { + return undefined + } +} + async function openDevContainer( baseUrl: string, workspaceOwner: string, diff --git a/src/workspacesProvider.ts b/src/workspacesProvider.ts index 0709487e..ea9d4084 100644 --- a/src/workspacesProvider.ts +++ b/src/workspacesProvider.ts @@ -353,7 +353,7 @@ export class WorkspaceTreeItem extends OpenableTreeItem { showOwner ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.Expanded, workspace.owner_name, workspace.name, - undefined, + agents.length > 0 ? agents[0].name : undefined, agents[0]?.expanded_directory, agents.length > 1 ? "coderWorkspaceMultipleAgents" : "coderWorkspaceSingleAgent", ) diff --git a/yarn.lock b/yarn.lock index efc2df73..c0f76803 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1749,7 +1749,7 @@ co@3.1.0: "coder@https://github.com/coder/coder#main": version "0.0.0" - resolved "https://github.com/coder/coder#3ac844ad3d341d2910542b83d4f33df7bd0be85e" + resolved "https://github.com/coder/coder#f8971bb3cc01d81b3085b2b3c9253d8d340d125c" collapse-white-space@^1.0.2: version "1.0.6" @@ -3441,6 +3441,16 @@ ip-address@^9.0.5: jsbn "1.1.0" sprintf-js "^1.1.3" +ip6@^0.2.10: + version "0.2.11" + resolved "https://registry.yarnpkg.com/ip6/-/ip6-0.2.11.tgz#b7cf71864ef16c7418c29f7b1f2f5db892a189ec" + integrity sha512-OmTP7FyIp+ZoNvZ7Xr97bWrCgypa3BeuYuRFNTOPT8Y11cxMW1pW1VC70kHZP1onSHHMotADcjdg5QyECiIMUw== + +ipaddr.js@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.2.0.tgz#d33fa7bac284f4de7af949638c9d68157c6b92e8" + integrity sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA== + irregular-plurals@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/irregular-plurals/-/irregular-plurals-2.0.0.tgz#39d40f05b00f656d0b7fa471230dd3b714af2872" @@ -4834,6 +4844,14 @@ randombytes@^2.1.0: dependencies: safe-buffer "^5.1.0" +range_check@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/range_check/-/range_check-3.2.0.tgz#6ef17940bb382a7fb905ecda8204f2f28ce7f61d" + integrity sha512-JxiMqvzQJJLt5vaKSUm7f++UkDM1TuMbkQsqRZJYaSvvCTTVtoUMkE/rm+ZNgLXNFAQPhO74WgMPHJaxz/JOEA== + dependencies: + ip6 "^0.2.10" + ipaddr.js "^2.2.0" + rc@^1.2.7: version "1.2.8" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" From a65e550753b99702c4cc222627c11b67d0fe18fd Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 17 Apr 2025 12:40:57 +1000 Subject: [PATCH 02/14] lint --- src/commands.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 7ef3a608..6e43cd25 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -397,7 +397,7 @@ export class Commands { throw new Error("You are not logged in") } - let agent = treeItem.workspaceAgent + const agent = treeItem.workspaceAgent if (!agent) { // `openFromSidebar` is only callable on agents or single-agent workspaces, // where this will always be set. @@ -692,7 +692,7 @@ async function maybeCoderConnectAddr( const coderConnectHostname = `${agent}.${workspace}.${owner}.${hostnameSuffix}` try { const res = await promisify(lookup)(coderConnectHostname) - return res.family == 6 && inRange(res.address, "fd60:627a:a42b::/48") ? coderConnectHostname : undefined + return res.family === 6 && inRange(res.address, "fd60:627a:a42b::/48") ? coderConnectHostname : undefined } catch { return undefined } From 2ecf1dff733b05c7aeec99d3f830e6e8b8bc5292 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 17 Apr 2025 12:55:38 +1000 Subject: [PATCH 03/14] support stopped workspaces --- src/commands.ts | 59 +++++++++++++++++++++++-------------------------- 1 file changed, 28 insertions(+), 31 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 6e43cd25..327d355f 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -397,20 +397,13 @@ export class Commands { throw new Error("You are not logged in") } - const agent = treeItem.workspaceAgent - if (!agent) { - // `openFromSidebar` is only callable on agents or single-agent workspaces, - // where this will always be set. - return - } - try { await openWorkspace( this.restClient, baseUrl, treeItem.workspaceOwner, treeItem.workspaceName, - agent, + treeItem.workspaceAgent, treeItem.workspaceFolderPath, true, ) @@ -592,37 +585,41 @@ async function openWorkspace( baseUrl: string, workspaceOwner: string, workspaceName: string, - workspaceAgent: string, + workspaceAgent: string | undefined, folderPath: string | undefined, openRecent: boolean | undefined, ) { let remoteAuthority = toRemoteAuthority(baseUrl, workspaceOwner, workspaceName, workspaceAgent) - let hostnameSuffix = "coder" - try { - const sshConfig = await restClient.getDeploymentSSHConfig() - // If the field is undefined, it's an older server, and always 'coder' - hostnameSuffix = sshConfig.hostname_suffix ?? hostnameSuffix - } catch (error) { - if (!isAxiosError(error)) { - throw error - } - switch (error.response?.status) { - case 404: { - // Likely a very old deployment, just use the default. - break - } - case 401: { + // When called from `openFromSidebar`, the workspaceAgent will only not be set + // if the workspace is stopped, in which case we can't use Coder Connect + // When called from `open`, the workspaceAgent will always be set. + if (workspaceAgent) { + let hostnameSuffix = "coder" + try { + const sshConfig = await restClient.getDeploymentSSHConfig() + // If the field is undefined, it's an older server, and always 'coder' + hostnameSuffix = sshConfig.hostname_suffix ?? hostnameSuffix + } catch (error) { + if (!isAxiosError(error)) { throw error } - default: - throw error + switch (error.response?.status) { + case 404: { + // Likely a very old deployment, just use the default. + break + } + case 401: { + throw error + } + default: + throw error + } + } + const coderConnectAddr = await maybeCoderConnectAddr(workspaceAgent, workspaceName, workspaceOwner, hostnameSuffix) + if (coderConnectAddr) { + remoteAuthority = `ssh-remote+${coderConnectAddr}` } - } - - const coderConnectAddr = await maybeCoderConnectAddr(workspaceAgent, workspaceName, workspaceOwner, hostnameSuffix) - if (coderConnectAddr) { - remoteAuthority = `ssh-remote+${coderConnectAddr}` } let newWindow = true From fb9a263c4a498e06291e2097ba5777da1aace274 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 17 Apr 2025 13:01:17 +1000 Subject: [PATCH 04/14] fixup --- src/workspacesProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/workspacesProvider.ts b/src/workspacesProvider.ts index ea9d4084..12718546 100644 --- a/src/workspacesProvider.ts +++ b/src/workspacesProvider.ts @@ -353,7 +353,7 @@ export class WorkspaceTreeItem extends OpenableTreeItem { showOwner ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.Expanded, workspace.owner_name, workspace.name, - agents.length > 0 ? agents[0].name : undefined, + agents[0]?.name, agents[0]?.expanded_directory, agents.length > 1 ? "coderWorkspaceMultipleAgents" : "coderWorkspaceSingleAgent", ) From 3a77138674ac2bdc852bc1a8034af6e14f294aba Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 17 Apr 2025 13:49:01 +1000 Subject: [PATCH 05/14] review --- package.json | 2 +- src/commands.ts | 232 ++++++++++++++++++++++++------------------------ yarn.lock | 26 +++--- 3 files changed, 125 insertions(+), 135 deletions(-) diff --git a/package.json b/package.json index 7004e518..2e60495b 100644 --- a/package.json +++ b/package.json @@ -311,12 +311,12 @@ "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", "pretty-bytes": "^6.1.1", "proxy-agent": "^6.4.0", - "range_check": "^3.2.0", "semver": "^7.6.2", "ua-parser-js": "^1.0.38", "ws": "^8.18.1", diff --git a/src/commands.ts b/src/commands.ts index 327d355f..6ae18d37 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -3,7 +3,7 @@ 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 { lookup } from "dns" -import { inRange } from "range_check" +import ipRangeCheck from "ip-range-check" import { promisify } from "util" import * as vscode from "vscode" import { makeCoderSdk, needToken } from "./api" @@ -396,26 +396,14 @@ export class Commands { if (!baseUrl) { throw new Error("You are not logged in") } - - try { - await openWorkspace( - this.restClient, - baseUrl, - treeItem.workspaceOwner, - treeItem.workspaceName, - treeItem.workspaceAgent, - treeItem.workspaceFolderPath, - true, - ) - } catch (err) { - const message = getErrorMessage(err, "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, - }) - } + await this.openWorkspace( + baseUrl, + treeItem.workspaceOwner, + treeItem.workspaceName, + treeItem.workspaceAgent, + treeItem.workspaceFolderPath, + true, + ) } else { // If there is no tree item, then the user manually ran this command. // Default to the regular open instead. @@ -513,15 +501,7 @@ export class Commands { } try { - await openWorkspace( - this.restClient, - baseUrl, - workspaceOwner, - workspaceName, - workspaceAgent, - folderPath, - openRecent, - ) + await this.openWorkspace(baseUrl, workspaceOwner, workspaceName, workspaceAgent, folderPath, openRecent) } catch (err) { const message = getErrorMessage(err, "no response from the server") this.storage.writeToCoderOutputChannel(`Failed to open workspace: ${message}`) @@ -574,32 +554,112 @@ 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( - restClient: Api, - baseUrl: string, - workspaceOwner: string, - workspaceName: string, - workspaceAgent: string | undefined, - folderPath: string | undefined, - openRecent: boolean | undefined, -) { - let remoteAuthority = toRemoteAuthority(baseUrl, workspaceOwner, workspaceName, workspaceAgent) + /** + * 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) + + // When called from `openFromSidebar`, the workspaceAgent will only not be set + // if the workspace is stopped, in which case we can't use Coder Connect + // When called from `open`, the workspaceAgent will always be set. + if (workspaceAgent) { + let hostnameSuffix = "coder" + try { + // If the field was undefined, it's an older server, and always 'coder' + hostnameSuffix = (await this.fetchHostnameSuffix()) ?? hostnameSuffix + } 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, + }) + } - // When called from `openFromSidebar`, the workspaceAgent will only not be set - // if the workspace is stopped, in which case we can't use Coder Connect - // When called from `open`, the workspaceAgent will always be set. - if (workspaceAgent) { - let hostnameSuffix = "coder" + const coderConnectAddr = await maybeCoderConnectAddr( + workspaceAgent, + workspaceName, + workspaceOwner, + hostnameSuffix, + ) + if (coderConnectAddr) { + remoteAuthority = `ssh-remote+${coderConnectAddr}` + } + } + + 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, + }) + } + + private async fetchHostnameSuffix(): Promise { try { - const sshConfig = await restClient.getDeploymentSSHConfig() - // If the field is undefined, it's an older server, and always 'coder' - hostnameSuffix = sshConfig.hostname_suffix ?? hostnameSuffix + const sshConfig = await this.restClient.getDeploymentSSHConfig() + return sshConfig.hostname_suffix } catch (error) { if (!isAxiosError(error)) { throw error @@ -609,75 +669,11 @@ async function openWorkspace( // Likely a very old deployment, just use the default. break } - case 401: { - throw error - } default: throw error } } - const coderConnectAddr = await maybeCoderConnectAddr(workspaceAgent, workspaceName, workspaceOwner, hostnameSuffix) - if (coderConnectAddr) { - remoteAuthority = `ssh-remote+${coderConnectAddr}` - } - } - - 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, - }) } async function maybeCoderConnectAddr( @@ -689,7 +685,7 @@ async function maybeCoderConnectAddr( const coderConnectHostname = `${agent}.${workspace}.${owner}.${hostnameSuffix}` try { const res = await promisify(lookup)(coderConnectHostname) - return res.family === 6 && inRange(res.address, "fd60:627a:a42b::/48") ? coderConnectHostname : undefined + return res.family === 6 && ipRangeCheck(res.address, "fd60:627a:a42b::/48") ? coderConnectHostname : undefined } catch { return undefined } diff --git a/yarn.lock b/yarn.lock index c0f76803..72bc49da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3441,15 +3441,17 @@ ip-address@^9.0.5: jsbn "1.1.0" sprintf-js "^1.1.3" -ip6@^0.2.10: - version "0.2.11" - resolved "https://registry.yarnpkg.com/ip6/-/ip6-0.2.11.tgz#b7cf71864ef16c7418c29f7b1f2f5db892a189ec" - integrity sha512-OmTP7FyIp+ZoNvZ7Xr97bWrCgypa3BeuYuRFNTOPT8Y11cxMW1pW1VC70kHZP1onSHHMotADcjdg5QyECiIMUw== +ip-range-check@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/ip-range-check/-/ip-range-check-0.2.0.tgz#e67f126c8fb36c8f11d4c07d7924b7e364365157" + integrity sha512-oaM3l/3gHbLlt/tCWLvt0mj1qUaI+STuRFnUvARGCujK9vvU61+2JsDpmkMzR4VsJhuFXWWgeKKVnwwoFfzCqw== + dependencies: + ipaddr.js "^1.0.1" -ipaddr.js@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.2.0.tgz#d33fa7bac284f4de7af949638c9d68157c6b92e8" - integrity sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA== +ipaddr.js@^1.0.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== irregular-plurals@^2.0.0: version "2.0.0" @@ -4844,14 +4846,6 @@ randombytes@^2.1.0: dependencies: safe-buffer "^5.1.0" -range_check@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/range_check/-/range_check-3.2.0.tgz#6ef17940bb382a7fb905ecda8204f2f28ce7f61d" - integrity sha512-JxiMqvzQJJLt5vaKSUm7f++UkDM1TuMbkQsqRZJYaSvvCTTVtoUMkE/rm+ZNgLXNFAQPhO74WgMPHJaxz/JOEA== - dependencies: - ip6 "^0.2.10" - ipaddr.js "^2.2.0" - rc@^1.2.7: version "1.2.8" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" From 2a3500e84d5f09ab394c8df675b498020bc98851 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 17 Apr 2025 13:51:25 +1000 Subject: [PATCH 06/14] fixup --- src/commands.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands.ts b/src/commands.ts index 6ae18d37..44260f84 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -585,6 +585,7 @@ export class Commands { modal: true, useCustom: true, }) + return } const coderConnectAddr = await maybeCoderConnectAddr( From 9252fff15c48b36ec24846b16956564f0d2cf5ea Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 17 Apr 2025 13:59:23 +1000 Subject: [PATCH 07/14] fixup --- src/commands.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/commands.ts b/src/commands.ts index 44260f84..e85572cf 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -686,6 +686,8 @@ async function maybeCoderConnectAddr( const coderConnectHostname = `${agent}.${workspace}.${owner}.${hostnameSuffix}` try { const res = await promisify(lookup)(coderConnectHostname) + // Captive DNS portals may return an unrelated address, so we check it's + // within the Coder Service Prefix. return res.family === 6 && ipRangeCheck(res.address, "fd60:627a:a42b::/48") ? coderConnectHostname : undefined } catch { return undefined From 195151a2b8d80d7cb1510471be3cd5516e8a5c85 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 17 Apr 2025 14:56:04 +1000 Subject: [PATCH 08/14] switch to coder connect dynamically --- src/api.ts | 35 ++++++++++++++++++++++++-- src/commands.ts | 52 +++++---------------------------------- src/remote.ts | 65 ++++++++++++++++++++++++++++--------------------- src/util.ts | 20 +++++++++++++++ 4 files changed, 96 insertions(+), 76 deletions(-) diff --git a/src/api.ts b/src/api.ts index fdb83b81..b239df68 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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" @@ -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 { + 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 + } + } +} diff --git a/src/commands.ts b/src/commands.ts index e85572cf..dfa5f16a 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,16 +1,12 @@ -import { isAxiosError } from "axios" 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 { lookup } from "dns" -import ipRangeCheck from "ip-range-check" -import { promisify } from "util" 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 { @@ -573,10 +569,10 @@ export class Commands { // if the workspace is stopped, in which case we can't use Coder Connect // When called from `open`, the workspaceAgent will always be set. if (workspaceAgent) { - let hostnameSuffix = "coder" + let sshConfig try { - // If the field was undefined, it's an older server, and always 'coder' - hostnameSuffix = (await this.fetchHostnameSuffix()) ?? hostnameSuffix + // 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}`) @@ -592,7 +588,7 @@ export class Commands { workspaceAgent, workspaceName, workspaceOwner, - hostnameSuffix, + sshConfig.hostname_suffix, ) if (coderConnectAddr) { remoteAuthority = `ssh-remote+${coderConnectAddr}` @@ -656,42 +652,6 @@ export class Commands { reuseWindow: !newWindow, }) } - - private async fetchHostnameSuffix(): Promise { - try { - const sshConfig = await this.restClient.getDeploymentSSHConfig() - return sshConfig.hostname_suffix - } catch (error) { - if (!isAxiosError(error)) { - throw error - } - switch (error.response?.status) { - case 404: { - // Likely a very old deployment, just use the default. - break - } - default: - throw error - } - } - } -} - -async function maybeCoderConnectAddr( - agent: string, - workspace: string, - owner: string, - hostnameSuffix: string, -): Promise { - const coderConnectHostname = `${agent}.${workspace}.${owner}.${hostnameSuffix}` - try { - const res = await promisify(lookup)(coderConnectHostname) - // Captive DNS portals may return an unrelated address, so we check it's - // within the Coder Service Prefix. - return res.family === 6 && ipRangeCheck(res.address, "fd60:627a:a42b::/48") ? coderConnectHostname : undefined - } catch { - return undefined - } } async function openDevContainer( diff --git a/src/remote.ts b/src/remote.ts index 5b8a9694..b1234c4a 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -1,6 +1,6 @@ import { isAxiosError } from "axios" import { Api } from "coder/site/src/api/api" -import { Workspace } from "coder/site/src/api/typesGenerated" +import { SSHConfigResponse, Workspace } from "coder/site/src/api/typesGenerated" import find from "find-process" import * as fs from "fs/promises" import * as jsonc from "jsonc-parser" @@ -9,7 +9,14 @@ import * as path from "path" import prettyBytes from "pretty-bytes" import * as semver from "semver" import * as vscode from "vscode" -import { createHttpAgent, makeCoderSdk, needToken, startWorkspaceIfStoppedOrFailed, waitForBuild } from "./api" +import { + createHttpAgent, + fetchSSHConfig, + makeCoderSdk, + needToken, + startWorkspaceIfStoppedOrFailed, + waitForBuild, +} from "./api" import { extractAgents } from "./api-helper" import * as cli from "./cliManager" import { Commands } from "./commands" @@ -19,7 +26,7 @@ import { Inbox } from "./inbox" import { SSHConfig, SSHValues, mergeSSHConfigValues } from "./sshConfig" import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport" import { Storage } from "./storage" -import { AuthorityPrefix, expandPath, parseRemoteAuthority } from "./util" +import { AuthorityPrefix, expandPath, maybeCoderConnectAddr, parseRemoteAuthority } from "./util" import { WorkspaceMonitor } from "./workspaceMonitor" export interface RemoteDetails extends vscode.Disposable { @@ -469,9 +476,19 @@ export class Remote { // // If we didn't write to the SSH config file, connecting would fail with // "Host not found". + let sshConfigResponse: SSHConfigResponse try { this.storage.writeToCoderOutputChannel("Updating SSH config...") - await this.updateSSHConfig(workspaceRestClient, parts.label, parts.host, binaryPath, logDir, featureSet) + sshConfigResponse = await fetchSSHConfig(workspaceRestClient, this.vscodeProposed) + await this.updateSSHConfig( + workspaceRestClient, + parts.label, + parts.host, + binaryPath, + logDir, + featureSet, + sshConfigResponse, + ) } catch (error) { this.storage.writeToCoderOutputChannel(`Failed to configure SSH: ${error}`) throw error @@ -503,6 +520,20 @@ export class Remote { this.storage.writeToCoderOutputChannel("Remote setup complete") + // If Coder Connect is available for this workspace, switch to that + const coderConnectAddr = await maybeCoderConnectAddr( + agent.name, + parts.workspace, + parts.username, + sshConfigResponse.hostname_suffix, + ) + if (coderConnectAddr) { + await vscode.commands.executeCommand("vscode.newWindow", { + remoteAuthority: `ssh-remote+${coderConnectAddr}`, + reuseWindow: true, + }) + } + // Returning the URL and token allows the plugin to authenticate its own // client, for example to display the list of workspaces belonging to this // deployment in the sidebar. We use our own client in here for reasons @@ -550,30 +581,8 @@ export class Remote { binaryPath: string, logDir: string, featureSet: FeatureSet, + sshConfigResponse: SSHConfigResponse, ) { - let deploymentSSHConfig = {} - try { - const deploymentConfig = await restClient.getDeploymentSSHConfig() - deploymentSSHConfig = deploymentConfig.ssh_config_options - } catch (error) { - if (!isAxiosError(error)) { - throw error - } - switch (error.response?.status) { - case 404: { - // Deployment does not support overriding ssh config yet. Likely an - // older version, just use the default. - break - } - case 401: { - await this.vscodeProposed.window.showErrorMessage("Your session expired...") - throw error - } - default: - throw error - } - } - // deploymentConfig is now set from the remote coderd deployment. // Now override with the user's config. const userConfigSSH = vscode.workspace.getConfiguration("coder").get("sshConfig") || [] @@ -596,7 +605,7 @@ export class Remote { }, {} as Record, ) - const sshConfigOverrides = mergeSSHConfigValues(deploymentSSHConfig, userConfig) + const sshConfigOverrides = mergeSSHConfigValues(sshConfigResponse.ssh_config_options, userConfig) let sshConfigFile = vscode.workspace.getConfiguration().get("remote.SSH.configFile") if (!sshConfigFile) { diff --git a/src/util.ts b/src/util.ts index 8253f152..92b0cd35 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,5 +1,8 @@ +import { lookup } from "dns" +import ipRangeCheck from "ip-range-check" import * as os from "os" import url from "url" +import { promisify } from "util" export interface AuthorityParts { agent: string | undefined @@ -61,6 +64,23 @@ export function parseRemoteAuthority(authority: string): AuthorityParts | null { } } +export async function maybeCoderConnectAddr( + agent: string, + workspace: string, + owner: string, + hostnameSuffix: string, +): Promise { + const coderConnectHostname = `${agent}.${workspace}.${owner}.${hostnameSuffix}` + try { + const res = await promisify(lookup)(coderConnectHostname) + // Captive DNS portals may return an unrelated address, so we check it's + // within the Coder Service Prefix. + return res.family === 6 && ipRangeCheck(res.address, "fd60:627a:a42b::/48") ? coderConnectHostname : undefined + } catch { + return undefined + } +} + export function toRemoteAuthority( baseUrl: string, workspaceOwner: string, From e7cad8219b361742109dec0bb070ccf551e22955 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 17 Apr 2025 22:45:27 +1000 Subject: [PATCH 09/14] handle dev containers --- src/commands.ts | 88 +++++++++++++++++++++++++++++++----------------- src/remote.ts | 30 ++++++++++++++--- src/util.test.ts | 14 ++++++++ src/util.ts | 23 ++++++++++--- 4 files changed, 117 insertions(+), 38 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index dfa5f16a..701f3bc2 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -491,7 +491,7 @@ export class Commands { } else { workspaceOwner = args[0] as string workspaceName = args[1] as string - workspaceAgent = args[2] as string + workspaceAgent = args[2] as string | undefined folderPath = args[3] as string | undefined openRecent = args[4] as boolean | undefined } @@ -522,11 +522,11 @@ 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) } /** @@ -652,33 +652,61 @@ export class Commands { 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 - } + 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}` + } + } + + const devContainer = Buffer.from(JSON.stringify({ containerName: devContainerName }), "utf-8").toString("hex") + const devContainerAuthority = `attached-container+${devContainer}@${remoteAuthority}` - await vscode.commands.executeCommand( - "vscode.openFolder", - vscode.Uri.from({ - scheme: "vscode-remote", - authority: devContainerAuthority, - path: devContainerFolder, - }), - newWindow, - ) + 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, + ) + } } + diff --git a/src/remote.ts b/src/remote.ts index b1234c4a..ef7e679c 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -528,10 +528,32 @@ export class Remote { sshConfigResponse.hostname_suffix, ) if (coderConnectAddr) { - await vscode.commands.executeCommand("vscode.newWindow", { - remoteAuthority: `ssh-remote+${coderConnectAddr}`, - reuseWindow: true, - }) + // Find the path of the current workspace, which will have the same authority + const folderPath = this.vscodeProposed.workspace.workspaceFolders + ?.find(folder => folder.uri.authority === remoteAuthority) + ?.uri.path; + let newRemoteAuthority = `ssh-remote+${coderConnectAddr}` + if (parts.containerNameHex) { + newRemoteAuthority = `attached-container+${parts.containerNameHex}@${newRemoteAuthority}` + } + + if (folderPath) { + await vscode.commands.executeCommand( + "vscode.openFolder", + vscode.Uri.from({ + scheme: "vscode-remote", + authority: newRemoteAuthority, + path: folderPath, + }), + //`ForceNewWindow` + false, + ) + } else { + await vscode.commands.executeCommand("vscode.newWindow", { + remoteAuthority: newRemoteAuthority, + reuseWindow: true, + }) + } } // Returning the URL and token allows the plugin to authenticate its own diff --git a/src/util.test.ts b/src/util.test.ts index 4fffcc75..ac46a08e 100644 --- a/src/util.test.ts +++ b/src/util.test.ts @@ -9,6 +9,7 @@ it("ignore unrelated authorities", async () => { "vscode://ssh-remote+coder-vscode-test--foo--bar", "vscode://ssh-remote+coder-vscode-foo--bar", "vscode://ssh-remote+coder--foo--bar", + "vscode://attached-container+namehash@ssh-remote+dev.foo.admin.coder" ] for (const test of tests) { expect(parseRemoteAuthority(test)).toBe(null) @@ -29,6 +30,7 @@ it("should error on invalid authorities", async () => { it("should parse authority", async () => { expect(parseRemoteAuthority("vscode://ssh-remote+coder-vscode--foo--bar")).toStrictEqual({ + containerNameHex: undefined, agent: "", host: "coder-vscode--foo--bar", label: "", @@ -36,6 +38,7 @@ it("should parse authority", async () => { workspace: "bar", }) expect(parseRemoteAuthority("vscode://ssh-remote+coder-vscode--foo--bar--baz")).toStrictEqual({ + containerNameHex: undefined, agent: "baz", host: "coder-vscode--foo--bar--baz", label: "", @@ -43,6 +46,7 @@ it("should parse authority", async () => { workspace: "bar", }) expect(parseRemoteAuthority("vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar")).toStrictEqual({ + containerNameHex: undefined, agent: "", host: "coder-vscode.dev.coder.com--foo--bar", label: "dev.coder.com", @@ -50,6 +54,7 @@ it("should parse authority", async () => { workspace: "bar", }) expect(parseRemoteAuthority("vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar--baz")).toStrictEqual({ + containerNameHex: undefined, agent: "baz", host: "coder-vscode.dev.coder.com--foo--bar--baz", label: "dev.coder.com", @@ -57,6 +62,15 @@ it("should parse authority", async () => { workspace: "bar", }) expect(parseRemoteAuthority("vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar.baz")).toStrictEqual({ + containerNameHex: undefined, + agent: "baz", + host: "coder-vscode.dev.coder.com--foo--bar.baz", + label: "dev.coder.com", + username: "foo", + workspace: "bar", + }) + expect(parseRemoteAuthority("vscode://attached-container+namehash@ssh-remote+coder-vscode.dev.coder.com--foo--bar.baz")).toStrictEqual({ + containerNameHex: "namehash", agent: "baz", host: "coder-vscode.dev.coder.com--foo--bar.baz", label: "dev.coder.com", diff --git a/src/util.ts b/src/util.ts index 92b0cd35..f45ad8e3 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,10 +1,12 @@ import { lookup } from "dns" import ipRangeCheck from "ip-range-check" +import { ssh } from "node-forge" import * as os from "os" import url from "url" import { promisify } from "util" export interface AuthorityParts { + containerNameHex: string | undefined agent: string | undefined host: string label: string @@ -24,14 +26,26 @@ export const AuthorityPrefix = "coder-vscode" * Throw an error if the host is invalid. */ export function parseRemoteAuthority(authority: string): AuthorityParts | null { - // The authority looks like: vscode://ssh-remote+ - const authorityParts = authority.split("+") + // The Dev Container authority looks like: vscode://attached-container+containerNameHex@ssh-remote+ + // The SSH authority looks like: vscode://ssh-remote+ + const authorityParts = authority.split("@") + let containerNameHex = undefined + let sshAuthority + if (authorityParts.length == 1) { + sshAuthority = authorityParts[0] + } else if (authorityParts.length == 2 && authorityParts[0].includes("attached-container+")) { + sshAuthority = authorityParts[1] + containerNameHex = authorityParts[0].split("+")[1] + } else { + return null + } + const sshAuthorityParts = sshAuthority.split("+") // We create SSH host names in a format matching: // coder-vscode(--|.)--(--|.) // The agent can be omitted; the user will be prompted for it instead. // Anything else is unrelated to Coder and can be ignored. - const parts = authorityParts[1].split("--") + const parts = sshAuthorityParts[1].split("--") if (parts.length <= 1 || (parts[0] !== AuthorityPrefix && !parts[0].startsWith(`${AuthorityPrefix}.`))) { return null } @@ -56,8 +70,9 @@ export function parseRemoteAuthority(authority: string): AuthorityParts | null { } return { + containerNameHex: containerNameHex, agent: agent, - host: authorityParts[1], + host: sshAuthorityParts[1], label: parts[0].replace(/^coder-vscode\.?/, ""), username: parts[1], workspace: workspace, From ea4b17958d78f23572d18d1c718cad568334d098 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 17 Apr 2025 22:47:35 +1000 Subject: [PATCH 10/14] lint --- src/commands.ts | 10 ++++++++-- src/remote.ts | 6 +++--- src/util.test.ts | 6 ++++-- src/util.ts | 2 +- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 701f3bc2..22caa69f 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -526,7 +526,14 @@ export class Commands { const devContainerName = args[3] as string const devContainerFolder = args[4] as string - await this.openDevContainerInner(baseUrl, workspaceOwner, workspaceName, workspaceAgent, devContainerName, devContainerFolder) + await this.openDevContainerInner( + baseUrl, + workspaceOwner, + workspaceName, + workspaceAgent, + devContainerName, + devContainerFolder, + ) } /** @@ -709,4 +716,3 @@ export class Commands { ) } } - diff --git a/src/remote.ts b/src/remote.ts index ef7e679c..3f4897b8 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -529,9 +529,9 @@ export class Remote { ) if (coderConnectAddr) { // Find the path of the current workspace, which will have the same authority - const folderPath = this.vscodeProposed.workspace.workspaceFolders - ?.find(folder => folder.uri.authority === remoteAuthority) - ?.uri.path; + const folderPath = this.vscodeProposed.workspace.workspaceFolders?.find( + (folder) => folder.uri.authority === remoteAuthority, + )?.uri.path let newRemoteAuthority = `ssh-remote+${coderConnectAddr}` if (parts.containerNameHex) { newRemoteAuthority = `attached-container+${parts.containerNameHex}@${newRemoteAuthority}` diff --git a/src/util.test.ts b/src/util.test.ts index ac46a08e..b3583da1 100644 --- a/src/util.test.ts +++ b/src/util.test.ts @@ -9,7 +9,7 @@ it("ignore unrelated authorities", async () => { "vscode://ssh-remote+coder-vscode-test--foo--bar", "vscode://ssh-remote+coder-vscode-foo--bar", "vscode://ssh-remote+coder--foo--bar", - "vscode://attached-container+namehash@ssh-remote+dev.foo.admin.coder" + "vscode://attached-container+namehash@ssh-remote+dev.foo.admin.coder", ] for (const test of tests) { expect(parseRemoteAuthority(test)).toBe(null) @@ -69,7 +69,9 @@ it("should parse authority", async () => { username: "foo", workspace: "bar", }) - expect(parseRemoteAuthority("vscode://attached-container+namehash@ssh-remote+coder-vscode.dev.coder.com--foo--bar.baz")).toStrictEqual({ + expect( + parseRemoteAuthority("vscode://attached-container+namehash@ssh-remote+coder-vscode.dev.coder.com--foo--bar.baz"), + ).toStrictEqual({ containerNameHex: "namehash", agent: "baz", host: "coder-vscode.dev.coder.com--foo--bar.baz", diff --git a/src/util.ts b/src/util.ts index f45ad8e3..42d6c403 100644 --- a/src/util.ts +++ b/src/util.ts @@ -33,7 +33,7 @@ export function parseRemoteAuthority(authority: string): AuthorityParts | null { let sshAuthority if (authorityParts.length == 1) { sshAuthority = authorityParts[0] - } else if (authorityParts.length == 2 && authorityParts[0].includes("attached-container+")) { + } else if (authorityParts.length == 2 && authorityParts[0].includes("attached-container+")) { sshAuthority = authorityParts[1] containerNameHex = authorityParts[0].split("+")[1] } else { From 5e4e79507e2f5f0e191abd10ff260b3e713e70ed Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 17 Apr 2025 22:55:57 +1000 Subject: [PATCH 11/14] comment --- src/commands.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 22caa69f..e5dce2cc 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -572,9 +572,14 @@ export class Commands { ) { let remoteAuthority = toRemoteAuthority(baseUrl, workspaceOwner, workspaceName, workspaceAgent) - // When called from `openFromSidebar`, the workspaceAgent will only not be set - // if the workspace is stopped, in which case we can't use Coder Connect - // When called from `open`, the workspaceAgent will always be set. + // 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 { From c3287eb6212f3e6de12a556f8b3d131c54f037b3 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 17 Apr 2025 22:57:21 +1000 Subject: [PATCH 12/14] lint --- src/util.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/util.ts b/src/util.ts index 42d6c403..29bbe9fc 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,6 +1,5 @@ import { lookup } from "dns" import ipRangeCheck from "ip-range-check" -import { ssh } from "node-forge" import * as os from "os" import url from "url" import { promisify } from "util" @@ -31,9 +30,9 @@ export function parseRemoteAuthority(authority: string): AuthorityParts | null { const authorityParts = authority.split("@") let containerNameHex = undefined let sshAuthority - if (authorityParts.length == 1) { + if (authorityParts.length === 1) { sshAuthority = authorityParts[0] - } else if (authorityParts.length == 2 && authorityParts[0].includes("attached-container+")) { + } else if (authorityParts.length === 2 && authorityParts[0].includes("attached-container+")) { sshAuthority = authorityParts[1] containerNameHex = authorityParts[0].split("+")[1] } else { From a2df5ccad169663e3d0436768ff5b8095b691972 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 17 Apr 2025 23:11:15 +1000 Subject: [PATCH 13/14] fixup --- src/commands.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index e5dce2cc..5583edea 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -496,17 +496,7 @@ export class Commands { openRecent = args[4] as boolean | undefined } - try { - await this.openWorkspace(baseUrl, workspaceOwner, workspaceName, workspaceAgent, folderPath, openRecent) - } catch (err) { - const message = getErrorMessage(err, "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, - }) - } + await this.openWorkspace(baseUrl, workspaceOwner, workspaceName, workspaceAgent, folderPath, openRecent) } /** From feb1021be7d38cbb98f07b66841f4cefdc6fb1ba Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 18 Apr 2025 15:42:12 +1000 Subject: [PATCH 14/14] review --- src/api.ts | 6 +---- src/commands.ts | 72 ++++++++++++++++++++++++++++++++++++++----------- src/remote.ts | 2 +- src/util.ts | 16 ++++------- 4 files changed, 63 insertions(+), 33 deletions(-) diff --git a/src/api.ts b/src/api.ts index b239df68..0c78d7cd 100644 --- a/src/api.ts +++ b/src/api.ts @@ -281,7 +281,7 @@ export async function waitForBuild( return updatedWorkspace } -export async function fetchSSHConfig(restClient: Api, vsc: typeof vscode): Promise { +export async function fetchSSHConfig(restClient: Api): Promise { try { const sshConfig = await restClient.getDeploymentSSHConfig() return { @@ -302,10 +302,6 @@ export async function fetchSSHConfig(restClient: Api, vsc: typeof vscode): Promi ssh_config_options: {}, } } - case 401: { - vsc.window.showErrorMessage("Your session expired...") - throw error - } default: throw error } diff --git a/src/commands.ts b/src/commands.ts index 5583edea..f9019856 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,3 +1,4 @@ +import { isAxiosError } from "axios" 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" @@ -574,16 +575,11 @@ export class Commands { let sshConfig try { // Fetch (or get defaults) for the SSH config. - sshConfig = await fetchSSHConfig(this.restClient, this.vscodeProposed) + sshConfig = await fetchSSHConfig(this.restClient) } 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 this.handleInitialRequestError(error, workspaceName, baseUrl, async () => { + await this.openWorkspace(baseUrl, workspaceOwner, workspaceName, workspaceAgent, folderPath, openRecent) }) - return } const coderConnectAddr = await maybeCoderConnectAddr( @@ -669,16 +665,18 @@ export class Commands { let sshConfig try { // Fetch (or get defaults) for the SSH config. - sshConfig = await fetchSSHConfig(this.restClient, this.vscodeProposed) + sshConfig = await fetchSSHConfig(this.restClient) } 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 this.handleInitialRequestError(error, workspaceName, baseUrl, async () => { + await this.openDevContainerInner( + baseUrl, + workspaceOwner, + workspaceName, + workspaceAgent, + devContainerName, + devContainerFolder, + ) }) - return } const coderConnectAddr = await maybeCoderConnectAddr( @@ -710,4 +708,46 @@ export class Commands { newWindow, ) } + + private async handleInitialRequestError( + error: unknown, + workspaceName: string, + baseUrl: string, + retryCallback: () => Promise, + ) { + if (!isAxiosError(error)) { + throw error + } + switch (error.response?.status) { + case 401: { + const result = await this.vscodeProposed.window.showInformationMessage( + "Your session expired...", + { + useCustom: true, + modal: true, + detail: `You must log in to access ${workspaceName}.`, + }, + "Log In", + ) + if (!result) { + // User declined to log in. + return + } + // Log in then try again + await vscode.commands.executeCommand("coder.login", baseUrl, undefined, undefined) + await retryCallback() + return + } + default: { + 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 + } + } + } } diff --git a/src/remote.ts b/src/remote.ts index 3f4897b8..5ef942a9 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -479,7 +479,7 @@ export class Remote { let sshConfigResponse: SSHConfigResponse try { this.storage.writeToCoderOutputChannel("Updating SSH config...") - sshConfigResponse = await fetchSSHConfig(workspaceRestClient, this.vscodeProposed) + sshConfigResponse = await fetchSSHConfig(workspaceRestClient) await this.updateSSHConfig( workspaceRestClient, parts.label, diff --git a/src/util.ts b/src/util.ts index 29bbe9fc..c42dcd72 100644 --- a/src/util.ts +++ b/src/util.ts @@ -27,17 +27,11 @@ export const AuthorityPrefix = "coder-vscode" export function parseRemoteAuthority(authority: string): AuthorityParts | null { // The Dev Container authority looks like: vscode://attached-container+containerNameHex@ssh-remote+ // The SSH authority looks like: vscode://ssh-remote+ - const authorityParts = authority.split("@") - let containerNameHex = undefined - let sshAuthority - if (authorityParts.length === 1) { - sshAuthority = authorityParts[0] - } else if (authorityParts.length === 2 && authorityParts[0].includes("attached-container+")) { - sshAuthority = authorityParts[1] - containerNameHex = authorityParts[0].split("+")[1] - } else { - return null - } + const authorityURI = authority.startsWith("vscode://") ? authority : `vscode://${authority}` + const authorityParts = new URL(authorityURI) + const containerParts = authorityParts.username.split("+") + const containerNameHex = containerParts[1] + const sshAuthority = authorityParts.host const sshAuthorityParts = sshAuthority.split("+") // We create SSH host names in a format matching: