From 938e5ce95577bb872059447c1ef229ca5536e05b Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 7 May 2026 15:24:25 +0000 Subject: [PATCH 1/4] fix: parse remote authority from right --- src/core/pathResolver.ts | 14 +---- src/core/secretsManager.ts | 2 +- src/extension.ts | 6 ++- src/remote/sshConfig.ts | 12 ++--- src/util.ts | 57 +++++++++----------- test/unit/core/pathResolver.test.ts | 9 ---- test/unit/remote/sshConfig.test.ts | 80 ----------------------------- test/unit/util.test.ts | 72 +++++++++++++------------- 8 files changed, 69 insertions(+), 183 deletions(-) diff --git a/src/core/pathResolver.ts b/src/core/pathResolver.ts index ee94b268..f4b33758 100644 --- a/src/core/pathResolver.ts +++ b/src/core/pathResolver.ts @@ -13,22 +13,16 @@ export class PathResolver { * Return the directory for the deployment with the provided hostname to * where the global Coder configs are stored. * - * If the hostname is empty, read the old deployment-unaware config instead. - * * The caller must ensure this directory exists before use. */ public getGlobalConfigDir(safeHostname: string): string { - return safeHostname - ? path.join(this.basePath, safeHostname) - : this.basePath; + return path.join(this.basePath, safeHostname); } /** * Return the directory for a deployment with the provided hostname to where * its binary is cached. * - * If the hostname is empty, read the old deployment-unaware config instead. - * * The caller must ensure this directory exists before use. */ public getBinaryCachePath(safeHostname: string): string { @@ -85,8 +79,6 @@ export class PathResolver { * Return the directory for the deployment with the provided hostname to * where its session token is stored. * - * If the hostname is empty, read the old deployment-unaware config instead. - * * The caller must ensure this directory exists before use. */ public getSessionTokenPath(safeHostname: string): string { @@ -97,8 +89,6 @@ export class PathResolver { * Return the directory for the deployment with the provided hostname to * where its session token was stored by older code. * - * If the hostname is empty, read the old deployment-unaware config instead. - * * The caller must ensure this directory exists before use. */ public getLegacySessionTokenPath(safeHostname: string): string { @@ -109,8 +99,6 @@ export class PathResolver { * Return the directory for the deployment with the provided hostname to * where its url is stored. * - * If the hostname is empty, read the old deployment-unaware config instead. - * * The caller must ensure this directory exists before use. */ public getUrlPath(safeHostname: string): string { diff --git a/src/core/secretsManager.ts b/src/core/secretsManager.ts index ac3ee6b1..1d9c22be 100644 --- a/src/core/secretsManager.ts +++ b/src/core/secretsManager.ts @@ -58,7 +58,7 @@ export class SecretsManager { ) {} private buildKey(prefix: SecretKeyPrefix, safeHostname: string): string { - return `${prefix}${safeHostname || ""}`; + return `${prefix}${safeHostname}`; } private async getSecret( diff --git a/src/extension.ts b/src/extension.ts index 0ba96aff..a190465d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -109,10 +109,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // This client tracks the current login and will be used through the life of // the plugin to poll workspaces for the current login, as well as being used // in commands that operate on the current login. + const sessionAuth = deployment + ? await secretsManager.getSessionAuth(deployment.safeHostname) + : undefined; const client = CoderApi.create( deployment?.url || "", - (await secretsManager.getSessionAuth(deployment?.safeHostname ?? "")) - ?.token, + sessionAuth?.token, output, ); ctx.subscriptions.push(client); diff --git a/src/remote/sshConfig.ts b/src/remote/sshConfig.ts index ff7da8af..4eb11027 100644 --- a/src/remote/sshConfig.ts +++ b/src/remote/sshConfig.ts @@ -183,14 +183,10 @@ export class SshConfig { private raw: string | undefined; private startBlockComment(safeHostname: string): string { - return safeHostname - ? `# --- START CODER VSCODE ${safeHostname} ---` - : `# --- START CODER VSCODE ---`; + return `# --- START CODER VSCODE ${safeHostname} ---`; } private endBlockComment(safeHostname: string): string { - return safeHostname - ? `# --- END CODER VSCODE ${safeHostname} ---` - : `# --- END CODER VSCODE ---`; + return `# --- END CODER VSCODE ${safeHostname} ---`; } constructor( @@ -248,13 +244,13 @@ export class SshConfig { const endBlockCount = countSubstring(endBlock, raw); if (startBlockCount !== endBlockCount) { throw new SshConfigBadFormat( - `Malformed config: ${this.filePath} has an unterminated START CODER VSCODE ${safeHostname ? safeHostname + " " : ""}block. Each START block must have an END block.`, + `Malformed config: ${this.filePath} has an unterminated START CODER VSCODE ${safeHostname} block. Each START block must have an END block.`, ); } if (startBlockCount > 1 || endBlockCount > 1) { throw new SshConfigBadFormat( - `Malformed config: ${this.filePath} has ${startBlockCount} START CODER VSCODE ${safeHostname ? safeHostname + " " : ""}sections. Please remove all but one.`, + `Malformed config: ${this.filePath} has ${startBlockCount} START CODER VSCODE ${safeHostname} sections. Please remove all but one.`, ); } diff --git a/src/util.ts b/src/util.ts index 4aafe728..81d5eaf2 100644 --- a/src/util.ts +++ b/src/util.ts @@ -56,52 +56,43 @@ export function findPort(text: string): number | null { export function parseRemoteAuthority(authority: string): AuthorityParts | null { // The authority looks like: vscode://ssh-remote+ const authorityParts = authority.split("+"); + const sshHost = authorityParts[1]; + if (!sshHost) { + return null; + } - // 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("--"); - if ( - parts.length <= 1 || - (parts[0] !== AuthorityPrefix && - !parts[0].startsWith(`${AuthorityPrefix}.`)) - ) { + const parts = sshHost.split("--"); + if (!parts[0].startsWith(`${AuthorityPrefix}.`)) { return null; } - // Reassemble Punycode labels (xn--...) the split broke apart: when the - // prefix ends in ".xn", the cut landed inside an "xn--..." label. - while (parts.length >= 2 && parts[0].endsWith(".xn")) { - parts.splice(0, 2, `${parts[0]}--${parts[1]}`); + const invalidAuthorityMessage = + "Invalid Coder SSH authority. Must be: ----(.)"; + if (parts.length < 3 || parts.some((p) => !p)) { + throw new Error(invalidAuthorityMessage); } - // It has the proper prefix, so this is probably a Coder host name. - // Validate the SSH host name. Including the prefix, we expect at least - // three parts, or four if including the agent. - if ((parts.length !== 3 && parts.length !== 4) || parts.some((p) => !p)) { - throw new Error( - `Invalid Coder SSH authority. Must be: --(--|.)`, - ); + const hostPrefix = parts.slice(0, -2).join("--"); + const safeHostname = hostPrefix.slice(`${AuthorityPrefix}.`.length); + if (!safeHostname) { + throw new Error(invalidAuthorityMessage); } + const username = parts[parts.length - 2]; + const workspaceAndAgent = parts[parts.length - 1]; - let workspace = parts[2]; + let workspace = workspaceAndAgent; let agent = ""; - if (parts.length === 4) { - agent = parts[3]; - } else if (parts.length === 3) { - const workspaceParts = parts[2].split("."); - if (workspaceParts.length === 2) { - workspace = workspaceParts[0]; - agent = workspaceParts[1]; - } + const workspaceParts = workspaceAndAgent.split("."); + if (workspaceParts.length === 2) { + workspace = workspaceParts[0]; + agent = workspaceParts[1]; } return { agent: agent, - sshHost: authorityParts[1], - safeHostname: parts[0].replace(/^coder-vscode\.?/, ""), - username: parts[1], + sshHost: sshHost, + safeHostname: safeHostname, + username: username, workspace: workspace, }; } diff --git a/test/unit/core/pathResolver.test.ts b/test/unit/core/pathResolver.test.ts index 6962a633..4a602382 100644 --- a/test/unit/core/pathResolver.test.ts +++ b/test/unit/core/pathResolver.test.ts @@ -19,15 +19,6 @@ describe("PathResolver", () => { mockConfig = new MockConfigurationProvider(); }); - it("should use base path for empty labels", () => { - expectPathsEqual(pathResolver.getGlobalConfigDir(""), basePath); - expectPathsEqual( - pathResolver.getSessionTokenPath(""), - path.join(basePath, "session"), - ); - expectPathsEqual(pathResolver.getUrlPath(""), path.join(basePath, "url")); - }); - describe("getProxyLogPath", () => { const defaultLogPath = path.join(basePath, "log"); diff --git a/test/unit/remote/sshConfig.test.ts b/test/unit/remote/sshConfig.test.ts index 19442aeb..c8763364 100644 --- a/test/unit/remote/sshConfig.test.ts +++ b/test/unit/remote/sshConfig.test.ts @@ -45,44 +45,6 @@ afterEach(() => { vi.clearAllMocks(); }); -it("creates a new file and adds config with empty label", async () => { - mockFileSystem.readFile.mockRejectedValueOnce("No file found"); - mockFileSystem.stat.mockRejectedValueOnce({ code: "ENOENT" }); - - const sshConfig = new SshConfig(sshFilePath, mockLogger, mockFileSystem); - await sshConfig.load(); - await sshConfig.update("", { ...BASE_SSH_VALUES, Host: "coder-vscode--*" }); - - const expectedOutput = `# --- START CODER VSCODE --- -${managedHeader} -Host coder-vscode--* - ConnectTimeout 0 - LogLevel ERROR - ProxyCommand some-command-here - ServerAliveCountMax 3 - ServerAliveInterval 10 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null -# --- END CODER VSCODE ---`; - - expect(mockFileSystem.readFile).toHaveBeenCalledWith( - sshFilePath, - expect.anything(), - ); - expect(mockFileSystem.writeFile).toHaveBeenCalledWith( - expect.stringContaining(sshTempFilePrefix), - expectedOutput, - expect.objectContaining({ - encoding: "utf-8", - mode: 0o600, // Default mode for new files. - }), - ); - expect(mockFileSystem.rename).toHaveBeenCalledWith( - expect.stringContaining(sshTempFilePrefix), - sshFilePath, - ); -}); - it("creates a new file and adds the config", async () => { mockFileSystem.readFile.mockRejectedValueOnce("No file found"); mockFileSystem.stat.mockRejectedValueOnce({ code: "ENOENT" }); @@ -407,48 +369,6 @@ Host afterconfig ); }); -it("throws an error if there is a mismatched start and end block count (without label)", async () => { - // As above, but without a label. - const existentSSHConfig = `Host beforeconfig - HostName before.config.tld - User before - -# --- START CODER VSCODE --- -Host coder-vscode.dev.coder.com--* - ConnectTimeout 0 - LogLevel ERROR - ProxyCommand some-command-here - StrictHostKeyChecking no - UserKnownHostsFile /dev/null -# missing END CODER VSCODE --- - -Host donotdelete - HostName dont.delete.me - User please - -# --- START CODER VSCODE --- -Host coder-vscode.dev.coder.com--* - ConnectTimeout 0 - LogLevel ERROR - ProxyCommand some-command-here - StrictHostKeyChecking no - UserKnownHostsFile /dev/null -# --- END CODER VSCODE --- - -Host afterconfig - HostName after.config.tld - User after`; - - const sshConfig = new SshConfig(sshFilePath, mockLogger, mockFileSystem); - mockFileSystem.readFile.mockResolvedValueOnce(existentSSHConfig); - await sshConfig.load(); - - // When we try to update the config, it should throw an error. - await expect(sshConfig.update("", BASE_SSH_VALUES)).rejects.toThrow( - `Malformed config: ${sshFilePath} has an unterminated START CODER VSCODE block. Each START block must have an END block.`, - ); -}); - it("throws an error if there are more than one sections with the same label", async () => { const existentSSHConfig = `Host beforeconfig HostName before.config.tld diff --git a/test/unit/util.test.ts b/test/unit/util.test.ts index 7bf25ff8..3b2c375e 100644 --- a/test/unit/util.test.ts +++ b/test/unit/util.test.ts @@ -21,15 +21,23 @@ describe("parseRemoteAuthority", () => { "vscode://ssh-remote+coder-vscode-test--foo--bar", "vscode://ssh-remote+coder-vscode-foo--bar", "vscode://ssh-remote+coder--foo--bar", + "vscode://ssh-remote+coder-vscode--foo", + "vscode://ssh-remote+coder-vscode--", + "vscode://ssh-remote+coder-vscode--foo--", + "vscode://ssh-remote+coder-vscode--foo--bar", + "vscode://ssh-remote+coder-vscode--foo--bar--", + "vscode://ssh-remote+coder-vscode--foo--bar--baz", ])("ignores unrelated authority: %s", (input) => { expect(parseRemoteAuthority(input)).toBe(null); }); it.each([ - "vscode://ssh-remote+coder-vscode--foo", - "vscode://ssh-remote+coder-vscode--", - "vscode://ssh-remote+coder-vscode--foo--", - "vscode://ssh-remote+coder-vscode--foo--bar--", + "vscode://ssh-remote+coder-vscode.dev.coder.com--foo", + "vscode://ssh-remote+coder-vscode.dev.coder.com--", + "vscode://ssh-remote+coder-vscode.dev.coder.com--foo--", + "vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar--", + "vscode://ssh-remote+coder-vscode.dev.coder.com--foo----bar", + "vscode://ssh-remote+coder-vscode.--foo--bar", ])("rejects invalid authority: %s", (input) => { expect(() => parseRemoteAuthority(input)).toThrow("Invalid"); }); @@ -42,56 +50,58 @@ describe("parseRemoteAuthority", () => { it.each([ { - label: "legacy form, no agent", - input: "vscode://ssh-remote+coder-vscode--foo--bar", + label: "with hostname, no agent", + input: "vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar", expected: { agent: "", - sshHost: "coder-vscode--foo--bar", - safeHostname: "", + sshHost: "coder-vscode.dev.coder.com--foo--bar", + safeHostname: "dev.coder.com", username: "foo", workspace: "bar", }, }, { - label: "legacy form with agent", - input: "vscode://ssh-remote+coder-vscode--foo--bar--baz", + label: "with hostname and . agent", + input: "vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar.baz", expected: { agent: "baz", - sshHost: "coder-vscode--foo--bar--baz", - safeHostname: "", + sshHost: "coder-vscode.dev.coder.com--foo--bar.baz", + safeHostname: "dev.coder.com", username: "foo", workspace: "bar", }, }, { - label: "with hostname, no agent", - input: "vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar", + label: "hostname with double dash", + input: "vscode://ssh-remote+coder-vscode.test--domain.com--foo--bar", expected: { agent: "", - sshHost: "coder-vscode.dev.coder.com--foo--bar", - safeHostname: "dev.coder.com", + sshHost: "coder-vscode.test--domain.com--foo--bar", + safeHostname: "test--domain.com", username: "foo", workspace: "bar", }, }, { - label: "with hostname and -- agent", - input: "vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar--baz", + label: "Punycode hostname with triple dash", + input: + "vscode://ssh-remote+coder-vscode.xn--test---8o4.example--foo--bar", expected: { - agent: "baz", - sshHost: "coder-vscode.dev.coder.com--foo--bar--baz", - safeHostname: "dev.coder.com", + agent: "", + sshHost: "coder-vscode.xn--test---8o4.example--foo--bar", + safeHostname: "xn--test---8o4.example", username: "foo", workspace: "bar", }, }, { - label: "with hostname and . agent", - input: "vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar.baz", + label: "hostname with multiple double dashes", + input: + "vscode://ssh-remote+coder-vscode.first--middle--last.example--foo--bar.baz", expected: { agent: "baz", - sshHost: "coder-vscode.dev.coder.com--foo--bar.baz", - safeHostname: "dev.coder.com", + sshHost: "coder-vscode.first--middle--last.example--foo--bar.baz", + safeHostname: "first--middle--last.example", username: "foo", workspace: "bar", }, @@ -108,18 +118,6 @@ describe("parseRemoteAuthority", () => { workspace: "bar", }, }, - { - label: "Punycode hostname with -- agent", - input: - "vscode://ssh-remote+coder-vscode.xn--eckwd4c7cu47r2wf.jp--foo--bar--baz", - expected: { - agent: "baz", - sshHost: "coder-vscode.xn--eckwd4c7cu47r2wf.jp--foo--bar--baz", - safeHostname: "xn--eckwd4c7cu47r2wf.jp", - username: "foo", - workspace: "bar", - }, - }, { label: "Punycode hostname with . agent", input: From b6204714c3d0f7bfc2cc6ccfb1372d6bff39d7c4 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 7 May 2026 15:42:53 +0000 Subject: [PATCH 2/4] docs: remove stale empty-hostname comment --- src/core/cliManager.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/core/cliManager.ts b/src/core/cliManager.ts index 022d29a7..6d94629f 100644 --- a/src/core/cliManager.ts +++ b/src/core/cliManager.ts @@ -98,9 +98,8 @@ export class CliManager { } /** - * Download and return the path to a working binary for the deployment with - * the provided hostname using the provided client. If the hostname is empty, - * use the old deployment-unaware path instead. + * Download and return the path to a working binary for the deployment using + * the provided client. * * If there is already a working binary and it matches the server version, * return that, skipping the download. If it does not match but downloads are From 988f5859748b2cfaea07f279c2e93dda75f2c345 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 7 May 2026 15:51:44 +0000 Subject: [PATCH 3/4] fix: clarify remote authority parsing --- src/util.ts | 15 ++- test/unit/util.test.ts | 223 ++++++++++++++++++----------------------- 2 files changed, 107 insertions(+), 131 deletions(-) diff --git a/src/util.ts b/src/util.ts index 81d5eaf2..59b13abb 100644 --- a/src/util.ts +++ b/src/util.ts @@ -13,6 +13,10 @@ export interface AuthorityParts { // they should be handled by this extension. export const AuthorityPrefix = "coder-vscode"; +const authorityHostPrefix = `${AuthorityPrefix}.`; +const invalidAuthorityMessage = + "Invalid Coder SSH authority. Must be: ----(.)"; + // Regex patterns to find the SSH port from Remote SSH extension logs. // `ms-vscode-remote.remote-ssh`: `-> socksPort ->` or `between local port ` // `codeium.windsurf-remote-openssh`, `jeanp413.open-remote-ssh`, `google.antigravity-remote-openssh`: `=> (socks) =>` @@ -62,18 +66,17 @@ export function parseRemoteAuthority(authority: string): AuthorityParts | null { } const parts = sshHost.split("--"); - if (!parts[0].startsWith(`${AuthorityPrefix}.`)) { + if (!parts[0].startsWith(authorityHostPrefix)) { return null; } - const invalidAuthorityMessage = - "Invalid Coder SSH authority. Must be: ----(.)"; if (parts.length < 3 || parts.some((p) => !p)) { throw new Error(invalidAuthorityMessage); } + // Parse from the right because safe hostnames can contain "--". const hostPrefix = parts.slice(0, -2).join("--"); - const safeHostname = hostPrefix.slice(`${AuthorityPrefix}.`.length); + const safeHostname = hostPrefix.slice(authorityHostPrefix.length); if (!safeHostname) { throw new Error(invalidAuthorityMessage); } @@ -83,9 +86,13 @@ export function parseRemoteAuthority(authority: string): AuthorityParts | null { let workspace = workspaceAndAgent; let agent = ""; const workspaceParts = workspaceAndAgent.split("."); + // Multiple dots are ambiguous because workspace and agent share this separator. if (workspaceParts.length === 2) { workspace = workspaceParts[0]; agent = workspaceParts[1]; + if (!workspace || !agent) { + throw new Error(invalidAuthorityMessage); + } } return { diff --git a/test/unit/util.test.ts b/test/unit/util.test.ts index 3b2c375e..37e4ac36 100644 --- a/test/unit/util.test.ts +++ b/test/unit/util.test.ts @@ -14,158 +14,127 @@ import { } from "@/util"; describe("parseRemoteAuthority", () => { + const remoteAuthority = (sshHost: string) => `vscode://ssh-remote+${sshHost}`; + it.each([ - "vscode://ssh-remote+some-unrelated-host.com", - "vscode://ssh-remote+coder-vscode", - "vscode://ssh-remote+coder-vscode-test", - "vscode://ssh-remote+coder-vscode-test--foo--bar", - "vscode://ssh-remote+coder-vscode-foo--bar", - "vscode://ssh-remote+coder--foo--bar", - "vscode://ssh-remote+coder-vscode--foo", - "vscode://ssh-remote+coder-vscode--", - "vscode://ssh-remote+coder-vscode--foo--", - "vscode://ssh-remote+coder-vscode--foo--bar", - "vscode://ssh-remote+coder-vscode--foo--bar--", - "vscode://ssh-remote+coder-vscode--foo--bar--baz", - ])("ignores unrelated authority: %s", (input) => { + { label: "missing SSH host", input: "vscode://ssh-remote" }, + { label: "empty SSH host", input: "vscode://ssh-remote+" }, + { + label: "non-Coder host", + input: remoteAuthority("some-unrelated-host.com"), + }, + { + label: "prefix without safeHostname separator", + input: remoteAuthority("coder-vscode--foo--bar"), + }, + { + label: "similar prefix", + input: remoteAuthority("coder-vscode-test--foo--bar"), + }, + { label: "wrong prefix", input: remoteAuthority("coder--foo--bar") }, + ])("ignores unrelated authority: $label", ({ input }) => { expect(parseRemoteAuthority(input)).toBe(null); }); it.each([ - "vscode://ssh-remote+coder-vscode.dev.coder.com--foo", - "vscode://ssh-remote+coder-vscode.dev.coder.com--", - "vscode://ssh-remote+coder-vscode.dev.coder.com--foo--", - "vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar--", - "vscode://ssh-remote+coder-vscode.dev.coder.com--foo----bar", - "vscode://ssh-remote+coder-vscode.--foo--bar", - ])("rejects invalid authority: %s", (input) => { - expect(() => parseRemoteAuthority(input)).toThrow("Invalid"); - }); - - interface ParseCase { - label: string; - input: string; - expected: AuthorityParts; - } - - it.each([ { - label: "with hostname, no agent", - input: "vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar", - expected: { - agent: "", - sshHost: "coder-vscode.dev.coder.com--foo--bar", - safeHostname: "dev.coder.com", - username: "foo", - workspace: "bar", - }, + label: "missing user and workspace", + sshHost: "coder-vscode.dev.coder.com", }, { - label: "with hostname and . agent", - input: "vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar.baz", - expected: { - agent: "baz", - sshHost: "coder-vscode.dev.coder.com--foo--bar.baz", - safeHostname: "dev.coder.com", - username: "foo", - workspace: "bar", - }, + label: "missing workspace", + sshHost: "coder-vscode.dev.coder.com--foo", }, { - label: "hostname with double dash", - input: "vscode://ssh-remote+coder-vscode.test--domain.com--foo--bar", - expected: { - agent: "", - sshHost: "coder-vscode.test--domain.com--foo--bar", - safeHostname: "test--domain.com", - username: "foo", - workspace: "bar", - }, + label: "empty username", + sshHost: "coder-vscode.dev.coder.com----bar", }, { - label: "Punycode hostname with triple dash", - input: - "vscode://ssh-remote+coder-vscode.xn--test---8o4.example--foo--bar", - expected: { - agent: "", - sshHost: "coder-vscode.xn--test---8o4.example--foo--bar", - safeHostname: "xn--test---8o4.example", - username: "foo", - workspace: "bar", - }, + label: "empty workspace", + sshHost: "coder-vscode.dev.coder.com--foo--", }, { - label: "hostname with multiple double dashes", - input: - "vscode://ssh-remote+coder-vscode.first--middle--last.example--foo--bar.baz", - expected: { - agent: "baz", - sshHost: "coder-vscode.first--middle--last.example--foo--bar.baz", - safeHostname: "first--middle--last.example", - username: "foo", - workspace: "bar", - }, + label: "empty hostname", + sshHost: "coder-vscode.--foo--bar", }, { - label: "Punycode label in hostname", - input: - "vscode://ssh-remote+coder-vscode.dev.coder.xn--eckwd4c7cu47r2wf.jp--foo--bar", - expected: { - agent: "", - sshHost: "coder-vscode.dev.coder.xn--eckwd4c7cu47r2wf.jp--foo--bar", - safeHostname: "dev.coder.xn--eckwd4c7cu47r2wf.jp", - username: "foo", - workspace: "bar", - }, + label: "empty trailing segment", + sshHost: "coder-vscode.dev.coder.com--foo--bar--", }, { - label: "Punycode hostname with . agent", - input: - "vscode://ssh-remote+coder-vscode.xn--eckwd4c7cu47r2wf.jp--foo--bar.baz", - expected: { - agent: "baz", - sshHost: "coder-vscode.xn--eckwd4c7cu47r2wf.jp--foo--bar.baz", - safeHostname: "xn--eckwd4c7cu47r2wf.jp", - username: "foo", - workspace: "bar", - }, + label: "empty workspace before agent separator", + sshHost: "coder-vscode.dev.coder.com--foo--.agent", }, { - label: "multiple Punycode labels", - input: "vscode://ssh-remote+coder-vscode.xn--abc.xn--def.com--foo--bar", - expected: { - agent: "", - sshHost: "coder-vscode.xn--abc.xn--def.com--foo--bar", - safeHostname: "xn--abc.xn--def.com", - username: "foo", - workspace: "bar", - }, + label: "empty agent after separator", + sshHost: "coder-vscode.dev.coder.com--foo--bar.", }, + ])("rejects invalid authority: $label", ({ sshHost }) => { + expect(() => parseRemoteAuthority(remoteAuthority(sshHost))).toThrow( + "Invalid Coder SSH authority", + ); + }); + + interface ParseCase { + label: string; + sshHost: string; + safeHostname: string; + workspace: string; + agent?: string; + username?: string; + } + + it.each([ { - label: "apex Punycode", - input: "vscode://ssh-remote+coder-vscode.xn--p1ai--owner--ws", - expected: { - agent: "", - sshHost: "coder-vscode.xn--p1ai--owner--ws", - safeHostname: "xn--p1ai", - username: "owner", - workspace: "ws", - }, + label: "hostname without agent", + sshHost: "coder-vscode.dev.coder.com--foo--bar", + safeHostname: "dev.coder.com", + workspace: "bar", }, { - label: "consecutive apex Punycode labels", - input: "vscode://ssh-remote+coder-vscode.xn--p1ai.xn--abc--owner--ws", - expected: { - agent: "", - sshHost: "coder-vscode.xn--p1ai.xn--abc--owner--ws", - safeHostname: "xn--p1ai.xn--abc", - username: "owner", - workspace: "ws", - }, + label: "hostname with agent", + sshHost: "coder-vscode.dev.coder.com--foo--bar.baz", + safeHostname: "dev.coder.com", + workspace: "bar", + agent: "baz", }, - ])("parses $label", ({ input, expected }) => { - expect(parseRemoteAuthority(input)).toStrictEqual(expected); - }); + { + label: "hostname containing delimiter", + sshHost: "coder-vscode.test--domain.com--foo--bar", + safeHostname: "test--domain.com", + workspace: "bar", + }, + { + label: "Punycode hostname containing delimiter", + sshHost: "coder-vscode.xn--test---8o4.example--foo--bar", + safeHostname: "xn--test---8o4.example", + workspace: "bar", + }, + { + label: "hostname with repeated delimiters and agent", + sshHost: "coder-vscode.first--middle--last.example--foo--bar.baz", + safeHostname: "first--middle--last.example", + workspace: "bar", + agent: "baz", + }, + { + label: "ambiguous workspace/agent separator", + sshHost: "coder-vscode.dev.coder.com--foo--bar.baz.qux", + safeHostname: "dev.coder.com", + workspace: "bar.baz.qux", + }, + ])( + "parses $label", + ({ sshHost, safeHostname, workspace, agent, username }) => { + expect(parseRemoteAuthority(remoteAuthority(sshHost))).toStrictEqual({ + agent: agent ?? "", + sshHost, + safeHostname, + username: username ?? "foo", + workspace, + } satisfies AuthorityParts); + }, + ); }); describe("toSafeHost", () => { From 71931972f0559da66b6e2dc87b843695429cf8db Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 7 May 2026 15:52:39 +0000 Subject: [PATCH 4/4] refactor: remove redundant authority comment --- CHANGELOG.md | 7 ++++--- src/util.ts | 1 - 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dcc92f1..743ca410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,9 +19,10 @@ ### Fixed -- Workspaces hosted on internationalized (IDN) domains can now be opened from - recent connections. The SSH authority parser was splitting Punycode (`xn--`) - domain labels across the field separator and rejecting the host as invalid. +- Workspaces on hostnames containing `--`, such as internationalized (IDN) + domains with Punycode (`xn--`) labels, can now be opened from recent + connections. The SSH authority parser was splitting these names across the + field separator and rejecting the host as invalid. ## [v1.14.5](https://github.com/coder/vscode-coder/releases/tag/v1.14.5) 2026-04-30 diff --git a/src/util.ts b/src/util.ts index 59b13abb..355ad5a1 100644 --- a/src/util.ts +++ b/src/util.ts @@ -58,7 +58,6 @@ export function findPort(text: string): number | null { * 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("+"); const sshHost = authorityParts[1]; if (!sshHost) {