Skip to content

Commit 56e4bb6

Browse files
committed
Read url and token from cli config
Instead of pulling the url and token from the currently logged-in deployment, we use the url and token as they exist on the disk organized by the label (~/.config/.../coder.coder-remote/dev.coder.com for example). The label we extract from the host (coder-vscode.dev.coder.com-- for example). Because the new format is a little different, the parsing has also changed. It has been moved to utils for easier testing. This way, we pull the right url and token for the host even if the user is logged into a different deployment currently. If there is no label because it is the older format, we read the old deployment-unaware path instead (~/.config/.../coder.coder-remote). Since this now uses the same url and token the cli itself will use, this has the extra advantage of preventing a potential desync between what exists on disk and what exists in VS Code's memory.
1 parent e0406d5 commit 56e4bb6

File tree

5 files changed

+186
-74
lines changed

5 files changed

+186
-74
lines changed

src/commands.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { extractAgents } from "./api-helper"
77
import { CertificateError } from "./error"
88
import { Remote } from "./remote"
99
import { Storage } from "./storage"
10-
import { toSafeHost } from "./util"
10+
import { AuthorityPrefix, toSafeHost } from "./util"
1111
import { OpenableTreeItem } from "./workspacesProvider"
1212

1313
export class Commands {
@@ -475,7 +475,7 @@ async function openWorkspace(
475475
) {
476476
// A workspace can have multiple agents, but that's handled
477477
// when opening a workspace unless explicitly specified.
478-
let remoteAuthority = `ssh-remote+${Remote.Prefix}.${toSafeHost(baseUrl)}--${workspaceOwner}--${workspaceName}`
478+
let remoteAuthority = `ssh-remote+${AuthorityPrefix}.${toSafeHost(baseUrl)}--${workspaceOwner}--${workspaceName}`
479479
if (workspaceAgent) {
480480
remoteAuthority += `--${workspaceAgent}`
481481
}

src/remote.ts

+56-70
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,11 @@ import { getHeaderCommand } from "./headers"
1717
import { SSHConfig, SSHValues, mergeSSHConfigValues } from "./sshConfig"
1818
import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport"
1919
import { Storage } from "./storage"
20-
import { toSafeHost } from "./util"
20+
import { parseRemoteAuthority } from "./util"
2121
import { supportsCoderAgentLogDirFlag } from "./version"
2222
import { WorkspaceAction } from "./workspaceAction"
2323

2424
export class Remote {
25-
// Prefix is a magic string that is prepended to SSH hosts to indicate that
26-
// they should be handled by this extension.
27-
public static readonly Prefix = "coder-vscode"
28-
2925
public constructor(
3026
private readonly vscodeProposed: typeof vscode,
3127
private readonly storage: Storage,
@@ -34,34 +30,19 @@ export class Remote {
3430
) {}
3531

3632
public async setup(remoteAuthority: string): Promise<vscode.Disposable | undefined> {
37-
const authorityParts = remoteAuthority.split("+")
38-
// If the URI passed doesn't have the proper prefix ignore it. We don't need
39-
// to do anything special, because this isn't trying to open a Coder
40-
// workspace.
41-
if (!authorityParts[1].startsWith(Remote.Prefix)) {
33+
const parts = parseRemoteAuthority(remoteAuthority)
34+
if (!parts) {
35+
// Not a Coder host.
4236
return
4337
}
44-
const sshAuthority = authorityParts[1].substring(Remote.Prefix.length)
45-
46-
// Authorities are in one of two formats:
47-
// coder-vscode--<username>--<workspace>--<agent> (old style)
48-
// coder-vscode.<label>--<username>--<workspace>--<agent>
49-
// The agent can be omitted; the user will be prompted for it instead.
50-
const parts = sshAuthority.split("--")
51-
if (parts.length !== 2 && parts.length !== 3) {
52-
throw new Error(`Invalid Coder SSH authority. Must be: <username>--<workspace>--<agent?>`)
53-
}
54-
const workspaceName = `${parts[0]}/${parts[1]}`
5538

56-
// It is possible to connect to any previously connected workspace, which
57-
// might not belong to the deployment the plugin is currently logged into.
58-
// For that reason, create a separate REST client instead of using the
59-
// global one generally used by the plugin. For now this is not actually
60-
// useful because we are using the the current URL and token anyway, but in
61-
// a future PR we will store these per deployment and grab the right one
62-
// based on the host name of the workspace to which we are connecting.
63-
const baseUrlRaw = this.storage.getUrl()
64-
if (!baseUrlRaw) {
39+
const workspaceName = `${parts.username}/${parts.workspace}`
40+
41+
// Get the URL and token belonging to this host.
42+
const { url: baseUrlRaw, token } = await this.storage.readCliConfig(parts.label)
43+
44+
// It could be that the cli config was deleted. If so, ask for the url.
45+
if (!baseUrlRaw || !token) {
6546
const result = await this.vscodeProposed.window.showInformationMessage(
6647
"You are not logged in...",
6748
{
@@ -76,15 +57,16 @@ export class Remote {
7657
await this.closeRemote()
7758
} else {
7859
// Log in then try again.
79-
await vscode.commands.executeCommand("coder.login")
60+
await vscode.commands.executeCommand("coder.login", baseUrlRaw)
8061
await this.setup(remoteAuthority)
8162
}
8263
return
8364
}
8465

85-
const baseUrl = new URL(baseUrlRaw)
86-
const safeHost = toSafeHost(baseUrlRaw) // Deployment label.
87-
const token = await this.storage.getSessionToken()
66+
// It is possible to connect to any previously connected workspace, which
67+
// might not belong to the deployment the plugin is currently logged into.
68+
// For that reason, create a separate REST client instead of using the
69+
// global one generally used by the plugin.
8870
const restClient = await makeCoderSdk(baseUrlRaw, token, this.storage)
8971
// Store for use in commands.
9072
this.commands.workspaceRestClient = restClient
@@ -116,7 +98,7 @@ export class Remote {
11698
// Next is to find the workspace from the URI scheme provided.
11799
let workspace: Workspace
118100
try {
119-
workspace = await restClient.getWorkspaceByOwnerAndName(parts[0], parts[1])
101+
workspace = await restClient.getWorkspaceByOwnerAndName(parts.username, parts.workspace)
120102
this.commands.workspace = workspace
121103
} catch (error) {
122104
if (!isAxiosError(error)) {
@@ -235,24 +217,30 @@ export class Remote {
235217
path += `&after=${logs[logs.length - 1].id}`
236218
}
237219
await new Promise<void>((resolve, reject) => {
238-
const proto = baseUrl.protocol === "https:" ? "wss:" : "ws:"
239-
const socket = new ws.WebSocket(new URL(`${proto}//${baseUrl.host}${path}`), {
240-
headers: {
241-
"Coder-Session-Token": token,
242-
},
243-
})
244-
socket.binaryType = "nodebuffer"
245-
socket.on("message", (data) => {
246-
const buf = data as Buffer
247-
const log = JSON.parse(buf.toString()) as ProvisionerJobLog
248-
writeEmitter.fire(log.output + "\r\n")
249-
})
250-
socket.on("error", (err) => {
251-
reject(err)
252-
})
253-
socket.on("close", () => {
254-
resolve()
255-
})
220+
try {
221+
const baseUrl = new URL(baseUrlRaw)
222+
const proto = baseUrl.protocol === "https:" ? "wss:" : "ws:"
223+
const socket = new ws.WebSocket(new URL(`${proto}//${baseUrl.host}${path}`), {
224+
headers: {
225+
"Coder-Session-Token": token,
226+
},
227+
})
228+
socket.binaryType = "nodebuffer"
229+
socket.on("message", (data) => {
230+
const buf = data as Buffer
231+
const log = JSON.parse(buf.toString()) as ProvisionerJobLog
232+
writeEmitter.fire(log.output + "\r\n")
233+
})
234+
socket.on("error", (err) => {
235+
reject(err)
236+
})
237+
socket.on("close", () => {
238+
resolve()
239+
})
240+
} catch (error) {
241+
// If this errors, it is probably a malformed URL.
242+
reject(error)
243+
}
256244
})
257245
writeEmitter.fire("Build complete")
258246
workspace = await restClient.getWorkspace(workspace.id)
@@ -268,7 +256,7 @@ export class Remote {
268256
`This workspace is stopped!`,
269257
{
270258
modal: true,
271-
detail: `Click below to start and open ${parts[0]}/${parts[1]}.`,
259+
detail: `Click below to start and open ${workspaceName}.`,
272260
useCustom: true,
273261
},
274262
"Start Workspace",
@@ -286,28 +274,26 @@ export class Remote {
286274
return acc.concat(resource.agents || [])
287275
}, [] as WorkspaceAgent[])
288276

289-
let agent: WorkspaceAgent | undefined
290-
291-
if (parts.length === 2) {
277+
// With no agent specified, pick the first one. Otherwise choose the
278+
// matching agent.
279+
let agent: WorkspaceAgent
280+
if (!parts.agent) {
292281
if (agents.length === 1) {
293282
agent = agents[0]
283+
} else {
284+
// TODO: Show the agent selector here instead.
285+
throw new Error("Invalid Coder SSH authority. An agent must be specified when there are multiple.")
294286
}
295-
296-
// If there are multiple agents, we should select one here! TODO: Support
297-
// multiple agents!
298-
}
299-
300-
if (!agent) {
301-
const matchingAgents = agents.filter((agent) => agent.name === parts[2])
287+
} else {
288+
const matchingAgents = agents.filter((agent) => agent.name === parts.agent)
302289
if (matchingAgents.length !== 1) {
303-
// TODO: Show the agent selector here instead!
304-
throw new Error(`Invalid Coder SSH authority. Agent not found!`)
290+
// TODO: Show the agent selector here instead.
291+
throw new Error("Invalid Coder SSH authority. Agent not found.")
305292
}
306293
agent = matchingAgents[0]
307294
}
308295

309296
// Do some janky setting manipulation.
310-
const hostname = authorityParts[1]
311297
const remotePlatforms = this.vscodeProposed.workspace
312298
.getConfiguration()
313299
.get<Record<string, string>>("remote.SSH.remotePlatform", {})
@@ -330,8 +316,8 @@ export class Remote {
330316
// Add the remote platform for this host to bypass a step where VS Code asks
331317
// the user for the platform.
332318
let mungedPlatforms = false
333-
if (!remotePlatforms[hostname] || remotePlatforms[hostname] !== agent.operating_system) {
334-
remotePlatforms[hostname] = agent.operating_system
319+
if (!remotePlatforms[parts.host] || remotePlatforms[parts.host] !== agent.operating_system) {
320+
remotePlatforms[parts.host] = agent.operating_system
335321
settingsContent = jsonc.applyEdits(
336322
settingsContent,
337323
jsonc.modify(settingsContent, ["remote.SSH.remotePlatform"], remotePlatforms, {}),
@@ -512,7 +498,7 @@ export class Remote {
512498
// If we didn't write to the SSH config file, connecting would fail with
513499
// "Host not found".
514500
try {
515-
await this.updateSSHConfig(restClient, safeHost, authorityParts[1], hasCoderLogs)
501+
await this.updateSSHConfig(restClient, parts.label, parts.host, hasCoderLogs)
516502
} catch (error) {
517503
this.storage.writeToCoderOutputChannel(`Failed to configure SSH: ${error}`)
518504
throw error

src/storage.ts

+21-1
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export class Storage {
118118
public async fetchBinary(restClient: Api, label: string | string): Promise<string> {
119119
const baseUrl = restClient.getAxiosInstance().defaults.baseURL
120120
this.output.appendLine(`Using deployment URL: ${baseUrl}`)
121-
this.output.appendLine(`Using deployment label: ${label}`)
121+
this.output.appendLine(`Using deployment label: ${label || "n/a"}`)
122122

123123
// Settings can be undefined when set to their defaults (true in this case),
124124
// so explicitly check against false.
@@ -459,6 +459,26 @@ export class Storage {
459459
}
460460
}
461461

462+
/**
463+
* Read the CLI config for a deployment with the provided label.
464+
*
465+
* IF a config file does not exist, return an empty string.
466+
*
467+
* If the label is empty, read the old deployment-unaware config.
468+
*/
469+
public async readCliConfig(label: string): Promise<{ url: string; token: string }> {
470+
const urlPath = this.getURLPath(label)
471+
const tokenPath = this.getSessionTokenPath(label)
472+
const [url, token] = await Promise.allSettled([
473+
fs.readFile(urlPath, "utf8"),
474+
fs.readFile(tokenPath, "utf8"),
475+
])
476+
return {
477+
url: url.status === "fulfilled" ? url.value : "",
478+
token: token.status === "fulfilled" ? token.value : "",
479+
}
480+
}
481+
462482
/**
463483
* Run the header command and return the generated headers.
464484
*/

src/util.test.ts

+58-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,62 @@
11
import { it, expect } from "vitest"
2-
import { toSafeHost } from "./util"
2+
import { parseRemoteAuthority, toSafeHost } from "./util"
3+
4+
it("ignore unrelated authorities", async () => {
5+
const tests = [
6+
"vscode://ssh-remote+some-unrelated-host.com",
7+
"vscode://ssh-remote+coder-vscode",
8+
"vscode://ssh-remote+coder-vscode-test",
9+
"vscode://ssh-remote+coder-vscode-test--foo--bar",
10+
"vscode://ssh-remote+coder-vscode-foo--bar",
11+
"vscode://ssh-remote+coder--foo--bar",
12+
]
13+
for (const test of tests) {
14+
expect(parseRemoteAuthority(test)).toBe(null)
15+
}
16+
})
17+
18+
it("should error on invalid authorities", async () => {
19+
const tests = [
20+
"vscode://ssh-remote+coder-vscode--foo",
21+
"vscode://ssh-remote+coder-vscode--",
22+
"vscode://ssh-remote+coder-vscode--foo--",
23+
"vscode://ssh-remote+coder-vscode--foo--bar--",
24+
]
25+
for (const test of tests) {
26+
expect(() => parseRemoteAuthority(test)).toThrow("Invalid")
27+
}
28+
})
29+
30+
it("should parse authority", async () => {
31+
expect(parseRemoteAuthority("vscode://ssh-remote+coder-vscode--foo--bar")).toStrictEqual({
32+
agent: "",
33+
host: "coder-vscode--foo--bar",
34+
label: "",
35+
username: "foo",
36+
workspace: "bar",
37+
})
38+
expect(parseRemoteAuthority("vscode://ssh-remote+coder-vscode--foo--bar--baz")).toStrictEqual({
39+
agent: "baz",
40+
host: "coder-vscode--foo--bar--baz",
41+
label: "",
42+
username: "foo",
43+
workspace: "bar",
44+
})
45+
expect(parseRemoteAuthority("vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar")).toStrictEqual({
46+
agent: "",
47+
host: "coder-vscode.dev.coder.com--foo--bar",
48+
label: "dev.coder.com",
49+
username: "foo",
50+
workspace: "bar",
51+
})
52+
expect(parseRemoteAuthority("vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar--baz")).toStrictEqual({
53+
agent: "baz",
54+
host: "coder-vscode.dev.coder.com--foo--bar--baz",
55+
label: "dev.coder.com",
56+
username: "foo",
57+
workspace: "bar",
58+
})
59+
})
360

461
it("escapes url host", async () => {
562
expect(toSafeHost("https://foobar:8080")).toBe("foobar")

src/util.ts

+49
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,54 @@
11
import url from "url"
22

3+
export interface AuthorityParts {
4+
agent: string | undefined
5+
host: string
6+
label: string
7+
username: string
8+
workspace: string
9+
}
10+
11+
// Prefix is a magic string that is prepended to SSH hosts to indicate that
12+
// they should be handled by this extension.
13+
export const AuthorityPrefix = "coder-vscode"
14+
15+
/**
16+
* Given an authority, parse into the expected parts.
17+
*
18+
* If this is not a Coder host, return null.
19+
*
20+
* Throw an error if the host is invalid.
21+
*/
22+
export function parseRemoteAuthority(authority: string): AuthorityParts | null {
23+
// The authority looks like: vscode://ssh-remote+<ssh host name>
24+
const authorityParts = authority.split("+")
25+
26+
// We create SSH host names in one of two formats:
27+
// coder-vscode--<username>--<workspace>--<agent?> (old style)
28+
// coder-vscode.<label>--<username>--<workspace>--<agent>
29+
// The agent can be omitted; the user will be prompted for it instead.
30+
// Anything else is unrelated to Coder and can be ignored.
31+
const parts = authorityParts[1].split("--")
32+
if (parts.length <= 1 || (parts[0] !== AuthorityPrefix && !parts[0].startsWith(`${AuthorityPrefix}.`))) {
33+
return null
34+
}
35+
36+
// It has the proper prefix, so this is probably a Coder host name.
37+
// Validate the SSH host name. Including the prefix, we expect at least
38+
// three parts, or four if including the agent.
39+
if ((parts.length !== 3 && parts.length !== 4) || parts.some((p) => !p)) {
40+
throw new Error(`Invalid Coder SSH authority. Must be: <username>--<workspace>--<agent?>`)
41+
}
42+
43+
return {
44+
agent: parts[3] ?? "",
45+
host: authorityParts[1],
46+
label: parts[0].replace(/^coder-vscode\.?/, ""),
47+
username: parts[1],
48+
workspace: parts[2],
49+
}
50+
}
51+
352
/**
453
* Given a URL, return the host in a format that is safe to write.
554
*/

0 commit comments

Comments
 (0)