Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 2 additions & 3 deletions src/core/cliManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 1 addition & 13 deletions src/core/pathResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/core/secretsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export class SecretsManager {
) {}

private buildKey(prefix: SecretKeyPrefix, safeHostname: string): string {
return `${prefix}${safeHostname || "<legacy>"}`;
return `${prefix}${safeHostname}`;
}

private async getSecret<T>(
Expand Down
6 changes: 4 additions & 2 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
// 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);
Expand Down
12 changes: 4 additions & 8 deletions src/remote/sshConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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.`,
);
}

Expand Down
63 changes: 30 additions & 33 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <hostname>--<username>--<workspace>(.<agent?>)";

// Regex patterns to find the SSH port from Remote SSH extension logs.
// `ms-vscode-remote.remote-ssh`: `-> socksPort <port> ->` or `between local port <port>`
// `codeium.windsurf-remote-openssh`, `jeanp413.open-remote-ssh`, `google.antigravity-remote-openssh`: `=> <port>(socks) =>`
Expand Down Expand Up @@ -54,54 +58,47 @@ 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+<ssh host name>
const authorityParts = authority.split("+");
const sshHost = authorityParts[1];
if (!sshHost) {
return null;
}

// We create SSH host names in a format matching:
// coder-vscode(--|.)<username>--<workspace>(--|.)<agent?>
// 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(authorityHostPrefix)) {
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]}`);
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: <username>--<workspace>(--|.)<agent?>`,
);
// Parse from the right because safe hostnames can contain "--".
const hostPrefix = parts.slice(0, -2).join("--");
const safeHostname = hostPrefix.slice(authorityHostPrefix.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(".");
// 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 {
agent: agent,
sshHost: authorityParts[1],
safeHostname: parts[0].replace(/^coder-vscode\.?/, ""),
username: parts[1],
sshHost: sshHost,
safeHostname: safeHostname,
username: username,
workspace: workspace,
};
}
Expand Down
9 changes: 0 additions & 9 deletions test/unit/core/pathResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
80 changes: 0 additions & 80 deletions test/unit/remote/sshConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading