Skip to content

Commit 04503bc

Browse files
authored
fix: write workspace SSH hosts to a shared glob include that ssh reads first (#1061)
Workspace SSH hosts move out of the user's config into generated files under the platform data dir, one per editor and deployment, all behind a single editor-agnostic Include block that moves back to the top of the config on every connect. First value wins, so the generated options always take effect and editors stop overwriting each other's hosts. - Global options in the user's config, such as a catch-all `Host *`, no longer reach the connection; `coder.sshConfig` still overrides the generated ones. - Drop the post-write "Unexpected SSH Config Option" abort, now redundant. - Emit include paths inside $HOME as ~/..., quote and escape paths outside it, and reject characters ssh cannot read back. - Honor remote.SSH.configFile only where ssh actually reads it; ignore the renamed setting on Antigravity and Windsurf, which spawn ssh without -F. - Migrate only the historical coder-vscode authority, preserving wrappers and the full URI, reopening the window once; an untitled multi-root workspace keeps the old host behind an explanatory modal. - Add "Coder: Open Generated SSH Configuration File", opening read-only. - Add integration tests that run the real ssh -G against generated configs. Closes #1055
1 parent 908db8c commit 04503bc

23 files changed

Lines changed: 1757 additions & 1462 deletions

CHANGELOG.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,41 @@
55
from published versions since it shows up in the VS Code extension changelog
66
tab and is confusing to users. Add it back between releases if needed. -->
77

8+
## Unreleased
9+
10+
### Added
11+
12+
- Add a **Coder: Open Generated SSH Configuration File** command that opens
13+
this editor's generated workspace hosts file in a read-only editor. A
14+
connected window opens its own deployment's file; a local window asks which
15+
deployment to open when there are several.
16+
17+
### Changed
18+
19+
- Write workspace SSH hosts to generated files under your platform's data
20+
directory (`coder.coder-remote/ssh`) instead of into your SSH config. Your
21+
config now carries only an include of that directory, which moves back to the
22+
top on every connection. Because ssh uses the first value it finds for each
23+
option, global options in your config, such as a catch-all `Host *`, no
24+
longer change how the extension connects. To override the generated options,
25+
set them in `coder.sshConfig`, which still takes precedence.
26+
- Generate one file per editor and deployment, all behind the same include, so
27+
VS Code, Cursor, Windsurf, and other forks no longer overwrite one another's
28+
workspace hosts. Each fork keeps its own SSH host prefix, and legacy
29+
`coder-vscode` authorities are migrated by automatically reopening the window
30+
once.
31+
- Stop aborting the connection with "Unexpected SSH Config Option". The include
32+
now sits at the top of your config, so the generated options always take
33+
effect and there is nothing left to warn about. Validation of the options
34+
your deployment sends is unchanged.
35+
36+
### Fixed
37+
38+
- Ignore the SSH config file setting on Antigravity and Windsurf/Devin. They
39+
launch ssh without pointing it at a config file, so it always reads
40+
`~/.ssh/config`, and honoring the setting wrote the workspace host where the
41+
connection never looked.
42+
843
## [v1.16.0](https://github.com/coder/vscode-coder/releases/tag/v1.16.0) 2026-08-06
944

1045
### Added

CONTRIBUTING.md

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,33 +14,62 @@ 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 a per-editor,
38+
per-deployment file in a data directory shared by every editor, such as
39+
`~/.local/share/coder.coder-remote/ssh/cursor--dev.coder.com.conf`.
40+
6. Keep a shared `Include` block at the top of the user's SSH config that
41+
globs the whole directory. Every editor writes the identical block, so
42+
concurrent writers converge on the same content. The `CODER INCLUDE <id>`
43+
marker convention lets other Coder integrations recognize the block, since
44+
Coder-managed includes route disjoint hosts and are order-independent.
3445

3546
```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
38-
ConnectTimeout 0
39-
StrictHostKeyChecking no
40-
UserKnownHostsFile /dev/null
41-
LogLevel ERROR
47+
# --- START CODER INCLUDE CODER-REMOTE ---
48+
# Managed by the Coder extension for VS Code and its forks.
49+
# Moves back to the top on connect; override options via coder.sshConfig.
50+
Include "~/.local/share/coder.coder-remote/ssh/*.conf"
51+
# --- END CODER INCLUDE CODER-REMOTE ---
4252
```
4353

54+
Each generated file contains only its own editor's host entries for one
55+
deployment:
56+
57+
```text
58+
Host coder-cursor.dev.coder.com--*
59+
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
60+
ConnectTimeout 0
61+
StrictHostKeyChecking no
62+
UserKnownHostsFile /dev/null
63+
LogLevel ERROR
64+
```
65+
66+
Which main file gains the include depends on the Remote - SSH extension.
67+
Microsoft's and Cursor's pass `remote.SSH.configFile` to ssh with `-F`, and
68+
VSCodium's parses the file itself instead of running ssh, so all three connect
69+
through it. Antigravity and Windsurf/Devin renamed the setting but spawn ssh
70+
without `-F`, so ssh reads `~/.ssh/config` regardless; we ignore the renamed
71+
setting there rather than add the include where the connection never looks.
72+
4473
If any step fails, we show an error message. Once the error message is closed
4574
we close the remote so the Remote - SSH connection does not continue to
4675
connection. Otherwise, we yield, which lets the Remote - SSH continue.

eslint.config.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,12 @@ export default defineConfig(
123123
message:
124124
"Do not use registerCommand('coder.*', ...) directly. Use the CommandManager class instead.",
125125
},
126+
{
127+
selector:
128+
"MemberExpression[property.name='remoteAuthority'][object.property.name='env'][object.object.name='vscode']",
129+
message:
130+
"env.remoteAuthority is a proposed API (resolvers) and throws through our own vscode module. Read it via vscodeProposed.env.remoteAuthority.",
131+
},
126132
],
127133
},
128134
},

package.json

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -478,12 +478,20 @@
478478
},
479479
{
480480
"command": "coder.viewLogs",
481-
"title": "Coder: View Logs",
481+
"title": "View Logs",
482+
"category": "Coder",
482483
"icon": "$(list-unordered)"
483484
},
485+
{
486+
"command": "coder.openSshConfig",
487+
"title": "Open Generated SSH Configuration File",
488+
"category": "Coder",
489+
"icon": "$(file-code)"
490+
},
484491
{
485492
"command": "coder.exportTelemetry",
486-
"title": "Coder: Export Telemetry",
493+
"title": "Export Telemetry",
494+
"category": "Coder",
487495
"icon": "$(save)"
488496
},
489497
{
@@ -616,6 +624,10 @@
616624
"command": "coder.viewLogs",
617625
"when": "true"
618626
},
627+
{
628+
"command": "coder.openSshConfig",
629+
"when": "true"
630+
},
619631
{
620632
"command": "coder.exportTelemetry",
621633
"when": "true"

src/commands.ts

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,11 @@ 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+
parseRemoteAuthority,
56+
toRemoteAuthority,
57+
} from "./util/authority";
5458
import { openInBrowser, toSafeHost } from "./util/uri";
5559
import { vscodeProposed } from "./vscodeProposed";
5660
import { parseNetcheckReport } from "./webviews/netcheck/types";
@@ -83,6 +87,9 @@ import type {
8387
PongMessage,
8488
} from "./workspace/duplicateWorkspaceIpc";
8589

90+
const NO_SSH_CONFIG_MESSAGE =
91+
"No SSH config has been generated yet. It is written when you connect to a workspace.";
92+
8693
interface OpenOptions {
8794
workspaceOwner?: string;
8895
workspaceName?: string;
@@ -565,6 +572,56 @@ export class Commands {
565572
);
566573
}
567574

575+
/** Open this editor's generated SSH config, picking a deployment when several exist. */
576+
public async openSshConfig(): Promise<void> {
577+
const hostname = await this.pickSshHostname();
578+
if (!hostname) {
579+
return;
580+
}
581+
try {
582+
await openFile(this.pathResolver.getSshConfigPath(hostname));
583+
} catch {
584+
vscode.window.showInformationMessage(NO_SSH_CONFIG_MESSAGE);
585+
return;
586+
}
587+
// The file is rewritten on every connection, so edits would be lost.
588+
await vscode.commands.executeCommand(
589+
"workbench.action.files.setActiveEditorReadonlyInSession",
590+
);
591+
}
592+
593+
/** A connected window resolves to its own deployment; otherwise ask. */
594+
private async pickSshHostname(): Promise<string | undefined> {
595+
try {
596+
// remoteAuthority is a proposed API; our own vscode module may not read it.
597+
const remoteAuthority = vscodeProposed.env.remoteAuthority;
598+
if (remoteAuthority) {
599+
const parts = parseRemoteAuthority(remoteAuthority);
600+
if (parts) {
601+
return parts.safeHostname;
602+
}
603+
}
604+
} catch {
605+
// Malformed Coder authority or unavailable API; fall through to the picker.
606+
}
607+
const hostnames = (
608+
await readdirOrEmpty(this.pathResolver.getSshConfigDir())
609+
)
610+
.map((file) => this.pathResolver.parseSshConfigFile(file))
611+
.filter((name) => name !== undefined);
612+
if (hostnames.length === 0) {
613+
vscode.window.showInformationMessage(NO_SSH_CONFIG_MESSAGE);
614+
return undefined;
615+
}
616+
if (hostnames.length === 1) {
617+
return hostnames[0];
618+
}
619+
return vscode.window.showQuickPick(hostnames, {
620+
title: "Open generated SSH configuration",
621+
placeHolder: "Select a deployment",
622+
});
623+
}
624+
568625
/**
569626
* View the logs for the currently connected workspace.
570627
*/
@@ -1475,12 +1532,11 @@ export class Commands {
14751532
const output: {
14761533
workspaces: Array<{ folderUri: vscode.Uri; remoteAuthority: string }>;
14771534
} = await vscode.commands.executeCommand("_workbench.getRecentlyOpened");
1478-
const opened = output.workspaces.filter(
1479-
// Remove recents that do not belong to this connection. The remote
1480-
// authority maps to a workspace/agent combination (using the SSH host
1481-
// name). There may also be some legacy connections that still may
1482-
// reference a workspace without an agent name, which will be missed.
1483-
(opened) => opened.folderUri?.authority === remoteAuthority,
1535+
const opened = output.workspaces.filter((opened) =>
1536+
isRemoteAuthorityCompatible(
1537+
opened.folderUri?.authority,
1538+
remoteAuthority,
1539+
),
14841540
);
14851541
// openRecent will always use the most recent. Otherwise, if there are
14861542
// multiple we ask the user which to use.

src/core/commandManager.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const CODER_COMMAND_IDS = [
2020
"coder.navigateToWorkspaceSettings",
2121
"coder.refreshWorkspaces",
2222
"coder.viewLogs",
23+
"coder.openSshConfig",
2324
"coder.exportTelemetry",
2425
"coder.viewAnnouncements",
2526
"coder.searchMyWorkspaces",

src/core/pathResolver.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,28 @@
1+
import * as os from "node:os";
12
import * as path from "node:path";
23
import * as vscode from "vscode";
34

45
import { expandPath } from "../util";
6+
import { currentEditorId } from "../util/authority";
7+
8+
/** Extension of generated SSH config files; the include glob matches on it. */
9+
export const SSH_CONFIG_EXT = ".conf";
10+
11+
/** The per-user data dir of the platform, shared by every editor. */
12+
function platformDataDir(): string {
13+
switch (process.platform) {
14+
case "win32":
15+
return (
16+
process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming")
17+
);
18+
case "darwin":
19+
return path.join(os.homedir(), "Library", "Application Support");
20+
default:
21+
return (
22+
process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share")
23+
);
24+
}
25+
}
526

627
export class PathResolver {
728
constructor(
@@ -42,6 +63,30 @@ export class PathResolver {
4263
return path.join(this.basePath, "net");
4364
}
4465

66+
/**
67+
* Directory of generated SSH configs, glob-included from the user's config.
68+
* Lives in the platform data dir so every editor emits the same include.
69+
*/
70+
public getSshConfigDir(): string {
71+
return path.join(platformDataDir(), "coder.coder-remote", "ssh");
72+
}
73+
74+
/** This editor's generated SSH config for one deployment. */
75+
public getSshConfigPath(safeHostname: string): string {
76+
return path.join(
77+
this.getSshConfigDir(),
78+
`${currentEditorId()}--${safeHostname}${SSH_CONFIG_EXT}`,
79+
);
80+
}
81+
82+
/** The deployment hostname if this editor generated the file, else undefined. */
83+
public parseSshConfigFile(fileName: string): string | undefined {
84+
const prefix = `${currentEditorId()}--`;
85+
return fileName.startsWith(prefix) && fileName.endsWith(SSH_CONFIG_EXT)
86+
? fileName.slice(prefix.length, -SSH_CONFIG_EXT.length)
87+
: undefined;
88+
}
89+
4590
/**
4691
* Return the directory where telemetry files are written.
4792
*/

src/extension.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,10 @@ async function doActivate(
350350
void allWorkspacesProvider.fetchAndRefresh();
351351
});
352352
commandManager.register("coder.viewLogs", commands.viewLogs.bind(commands));
353+
commandManager.register(
354+
"coder.openSshConfig",
355+
commands.openSshConfig.bind(commands),
356+
);
353357
commandManager.register(
354358
"coder.exportTelemetry",
355359
commands.exportTelemetry.bind(commands),

0 commit comments

Comments
 (0)