Skip to content

Commit 90df8e5

Browse files
committed
fix: isolate SSH configs per editor
1 parent 7382ee1 commit 90df8e5

13 files changed

Lines changed: 1427 additions & 1161 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,14 @@
1717

1818
### Changed
1919

20-
- Write workspace SSH hosts to `~/.ssh/coder/config` and include that file
21-
from the top of your own SSH config, rather than writing the block into your
22-
config directly. SSH uses the first value it obtains for each option, so a
23-
catch-all like `Host *` used to override the connection's `ProxyCommand` and
24-
abort it with an "Unexpected SSH Config Option" error; now the extension's
25-
options win and that error is gone. Your config is only written once, to add
26-
the include, and the block for a deployment moves out of it on next connect.
20+
- Write workspace SSH hosts to an editor-owned file in the extension's global
21+
storage and include that file from the top of your SSH config, rather than
22+
writing the block into your config directly. Each VS Code-based editor gets
23+
its own include and SSH host prefix, so VS Code, Cursor, Windsurf, and other
24+
clones no longer overwrite one another's machine-specific proxy settings.
25+
Legacy `coder-vscode` authorities are migrated in other editors. SSH
26+
uses the first value it obtains for each option, so a catch-all like `Host *`
27+
can no longer override the connection's `ProxyCommand`.
2728
- Filter the Shared Workspaces view with the server-side `shared_with_user`
2829
query instead of filtering `shared:true` results on the client, so fewer
2930
workspaces are fetched and the view loads faster. Deployments too old to

CONTRIBUTING.md

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,39 +14,57 @@ The `ssh-remote` scheme is registered by Microsoft's Remote - SSH extension and
1414
indicates that it should connect to the provided host name using SSH.
1515

1616
The host name takes the format
17-
`coder-vscode.<domain>--<username>--<workspace>`. This is parsed by the CLI
18-
(which is invoked via SSH's `ProxyCommand`) to route SSH to the right workspace.
17+
`coder-<editor>.<domain>--<username>--<workspace>`, where `<editor>` comes from
18+
that product's URI scheme, such as `vscode`, `cursor`, or `windsurf`. The CLI is
19+
invoked through SSH's `ProxyCommand` with this prefix so it can route SSH to the
20+
right workspace. A legacy `coder-vscode` authority opened in another editor is
21+
reopened once with that editor's prefix; legacy recent-folder entries remain
22+
compatible when opening the same workspace.
1923

2024
The Coder Remote extension also registers for the
2125
`onResolveRemoteAuthority:ssh-remote` [extension activation
2226
event](https://code.visualstudio.com/api/references/activation-events) to hook
2327
into this process, running before the Remote - SSH extension actually connects.
2428

25-
On activation of this event, we check if `vscode.workspace.workspaceFolders`
26-
contains the `coder-vscode` prefix, and if so we delay activation to:
29+
On activation of this event, we check whether the remote authority belongs to
30+
the current editor, and if so we delay activation to:
2731

2832
1. Parse the host name to get the domain, username, and workspace.
2933
2. Ensure the workspace is running.
3034
3. Download the matching server binary to the client.
3135
4. Configure the binary with the URL and token, asking the user for them if they
3236
are missing. Each domain gets its own config directory.
33-
5. Add an entry to the user's SSH config for `coder-vscode.<domain>--*`.
37+
5. Write an entry for `coder-<editor>.<domain>--*` to `ssh-config` in the
38+
extension's global storage directory.
39+
6. Add a per-editor `Include` block at the top of the user's SSH config.
3440

3541
```text
36-
Host coder-vscode.dev.coder.com--*
37-
ProxyCommand "/tmp/coder" --global-config "/home/kyle/.config/Code/User/globalStorage/coder.coder-remote/dev.coder.com" ssh --stdio --network-info-dir "/home/kyle/.config/Code/User/globalStorage/coder.coder-remote/net" --ssh-host-prefix coder-vscode.dev.coder.com-- %h
42+
# --- START CODER cursor ---
43+
Include "/home/kyle/.config/Cursor/User/globalStorage/coder.coder-remote/ssh-config"
44+
# --- END CODER cursor ---
45+
46+
# --- START CODER vscode ---
47+
Include "/home/kyle/.config/Code/User/globalStorage/coder.coder-remote/ssh-config"
48+
# --- END CODER vscode ---
49+
```
50+
51+
Each included file contains only that editor's host entries:
52+
53+
```text
54+
Host coder-cursor.dev.coder.com--*
55+
ProxyCommand "/tmp/coder" --global-config "/home/kyle/.config/Cursor/User/globalStorage/coder.coder-remote/dev.coder.com" ssh --stdio --network-info-dir "/home/kyle/.config/Cursor/User/globalStorage/coder.coder-remote/net" --ssh-host-prefix coder-cursor.dev.coder.com-- %h
3856
ConnectTimeout 0
3957
StrictHostKeyChecking no
4058
UserKnownHostsFile /dev/null
4159
LogLevel ERROR
4260
```
4361

44-
Which file that entry goes in depends on the Remote - SSH extension. Microsoft's
45-
and Cursor's pass `remote.SSH.configFile` to ssh with `-F`, and VSCodium's parses
46-
the file itself instead of running ssh, so all three connect through it.
47-
Antigravity and Windsurf/Devin renamed the setting but spawn ssh without `-F`, so
48-
ssh reads `~/.ssh/config` regardless; we ignore it there rather than write the
49-
host where the connection never looks.
62+
Which main file gains the include depends on the Remote - SSH extension.
63+
Microsoft's and Cursor's pass `remote.SSH.configFile` to ssh with `-F`, and
64+
VSCodium's parses the file itself instead of running ssh, so all three connect
65+
through it. Antigravity and Windsurf/Devin renamed the setting but spawn ssh
66+
without `-F`, so ssh reads `~/.ssh/config` regardless; we ignore the renamed
67+
setting there rather than add the include where the connection never looks.
5068

5169
If any step fails, we show an error message. Once the error message is closed
5270
we close the remote so the Remote - SSH connection does not continue to

src/commands.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@ import {
5050
toRemoteLogGlobs,
5151
} from "./supportBundle/remoteServerDataPath";
5252
import { runExportTelemetryCommand } from "./telemetry/export/command";
53-
import { toRemoteAuthority } from "./util/authority";
53+
import {
54+
isRemoteAuthorityCompatible,
55+
toRemoteAuthority,
56+
} from "./util/authority";
5457
import { openInBrowser, toSafeHost } from "./util/uri";
5558
import { vscodeProposed } from "./vscodeProposed";
5659
import { parseNetcheckReport } from "./webviews/netcheck/types";
@@ -1443,12 +1446,11 @@ export class Commands {
14431446
const output: {
14441447
workspaces: Array<{ folderUri: vscode.Uri; remoteAuthority: string }>;
14451448
} = await vscode.commands.executeCommand("_workbench.getRecentlyOpened");
1446-
const opened = output.workspaces.filter(
1447-
// Remove recents that do not belong to this connection. The remote
1448-
// authority maps to a workspace/agent combination (using the SSH host
1449-
// name). There may also be some legacy connections that still may
1450-
// reference a workspace without an agent name, which will be missed.
1451-
(opened) => opened.folderUri?.authority === remoteAuthority,
1449+
const opened = output.workspaces.filter((opened) =>
1450+
isRemoteAuthorityCompatible(
1451+
opened.folderUri?.authority,
1452+
remoteAuthority,
1453+
),
14521454
);
14531455
// openRecent will always use the most recent. Otherwise, if there are
14541456
// multiple we ask the user which to use.

src/core/pathResolver.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ export class PathResolver {
4242
return path.join(this.basePath, "net");
4343
}
4444

45+
public getSshConfigPath(): string {
46+
return path.join(this.basePath, "ssh-config");
47+
}
48+
4549
/**
4650
* Return the directory where telemetry files are written.
4751
*/

src/remote/remote.ts

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,11 @@ import {
3939
import { getHeaderCommand } from "../settings/headers";
4040
import { escapeCommandArg, expandPath } from "../util";
4141
import {
42-
AuthorityPrefix,
4342
type AuthorityParts,
43+
classifyRemoteAuthority,
4444
parseRemoteAuthority,
45+
retargetRemoteAuthority,
46+
toCurrentAuthorityHostPrefix,
4547
} from "../util/authority";
4648
import { createStatusBarItem } from "../util/statusBar";
4749
import { vscodeProposed } from "../vscodeProposed";
@@ -77,13 +79,6 @@ import type { SecretsManager } from "../core/secretsManager";
7779
import type { Logger } from "../logging/logger";
7880
import type { LoginCoordinator } from "../login/loginCoordinator";
7981

80-
/**
81-
* Our own config, included from the user's. Keep the tilde: relative includes
82-
* always resolve against ~/.ssh, and an absolute path would not survive a
83-
* config synced between machines.
84-
*/
85-
const CODER_SSH_CONFIG_PATH = "~/.ssh/coder/config";
86-
8782
export interface RemoteDetails extends vscode.Disposable {
8883
safeHostname: string;
8984
url: string;
@@ -160,6 +155,16 @@ export class Remote {
160155
return;
161156
}
162157

158+
switch (classifyRemoteAuthority(parts)) {
159+
case "current":
160+
break;
161+
case "legacy":
162+
await this.migrateLegacyAuthority(remoteAuthority, startupMode);
163+
return;
164+
case "foreign":
165+
return;
166+
}
167+
163168
this.logger.info("Setting up remote connection", {
164169
remoteAuthority,
165170
hostname: parts.safeHostname,
@@ -713,6 +718,46 @@ export class Remote {
713718
return undefined;
714719
}
715720

721+
private async migrateLegacyAuthority(
722+
remoteAuthority: string,
723+
startupMode: StartupMode,
724+
): Promise<void> {
725+
const migratedAuthority = retargetRemoteAuthority(remoteAuthority);
726+
const workspaceFile = vscode.workspace.workspaceFile;
727+
const workspaceFolders = vscode.workspace.workspaceFolders ?? [];
728+
const savedWorkspaceFile =
729+
workspaceFile?.scheme === "untitled" ? undefined : workspaceFile;
730+
if (!savedWorkspaceFile && workspaceFolders.length > 1) {
731+
throw new Error(
732+
"Cannot migrate a multi-root workspace without a saved workspace file.",
733+
);
734+
}
735+
736+
await this.serviceContainer
737+
.getMementoManager()
738+
.setStartupMode(startupMode === "none" ? "start" : startupMode);
739+
this.logger.info("Migrating legacy remote authority", {
740+
from: remoteAuthority,
741+
to: migratedAuthority,
742+
});
743+
744+
const currentUri = savedWorkspaceFile ?? workspaceFolders[0]?.uri;
745+
if (currentUri) {
746+
await vscode.commands.executeCommand(
747+
"vscode.openFolder",
748+
currentUri.with({
749+
authority: retargetRemoteAuthority(currentUri.authority),
750+
}),
751+
false,
752+
);
753+
return;
754+
}
755+
await vscode.commands.executeCommand("vscode.newWindow", {
756+
remoteAuthority: migratedAuthority,
757+
reuseWindow: true,
758+
});
759+
}
760+
716761
private async resolveRemoteBinary(workspaceClient: Api): Promise<string> {
717762
if (
718763
this.extensionContext.extensionMode === vscode.ExtensionMode.Production
@@ -967,10 +1012,8 @@ export class Remote {
9671012
// Our blocks live in our own file; the user's only gains the include.
9681013
const sshConfig = new SshConfig(this.getSshConfigPath(), this.logger);
9691014
await sshConfig.load();
970-
const coderConfig = new SshConfig(
971-
expandPath(CODER_SSH_CONFIG_PATH),
972-
this.logger,
973-
);
1015+
const coderConfigPath = this.pathResolver.getSshConfigPath();
1016+
const coderConfig = new SshConfig(coderConfigPath, this.logger);
9741017
await coderConfig.load();
9751018

9761019
// Merge SSH config from three sources (highest to lowest priority):
@@ -988,9 +1031,7 @@ export class Remote {
9881031
userConfig,
9891032
);
9901033

991-
const hostPrefix = safeHostname
992-
? `${AuthorityPrefix}.${safeHostname}--`
993-
: `${AuthorityPrefix}--`;
1034+
const hostPrefix = toCurrentAuthorityHostPrefix(safeHostname);
9941035

9951036
const proxyCommand = await this.buildProxyCommand(
9961037
binaryPath,
@@ -1019,7 +1060,13 @@ export class Remote {
10191060

10201061
// Write our file before including it, so the include never dangles.
10211062
await coderConfig.update(safeHostname, sshValues, sshConfigOverrides);
1022-
await sshConfig.updateInclude(CODER_SSH_CONFIG_PATH, safeHostname);
1063+
await sshConfig.updateInclude(
1064+
{
1065+
id: vscode.env.uriScheme,
1066+
includePath: coderConfigPath,
1067+
},
1068+
safeHostname,
1069+
);
10231070

10241071
// Mirror SSH's parse order; RemoteCommand can come from the user's config.
10251072
return computeSshProperties(

0 commit comments

Comments
 (0)