Skip to content

Commit 0626218

Browse files
committed
chore: address review feedback
1 parent 56a8ab6 commit 0626218

8 files changed

Lines changed: 112 additions & 128 deletions

File tree

CONTRIBUTING.md

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,21 +34,25 @@ the current editor, and if so we delay activation to:
3434
3. Download the matching server binary to the client.
3535
4. Configure the binary with the URL and token, asking the user for them if they
3636
are missing. Each domain gets its own config directory.
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.
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.
4045

4146
```text
42-
# --- START CODER cursor ---
43-
Include "~/.config/Cursor/User/globalStorage/coder.coder-remote/ssh-config"
44-
# --- END CODER cursor ---
45-
46-
# --- START CODER vscode ---
47-
Include "~/.config/Code/User/globalStorage/coder.coder-remote/ssh-config"
48-
# --- END CODER vscode ---
47+
# --- START CODER INCLUDE CODER-REMOTE ---
48+
# Managed by each editor's Coder extension (coder.coder-remote).
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 ---
4952
```
5053

51-
Each included file contains only that editor's host entries:
54+
Each generated file contains only its own editor's host entries for one
55+
deployment:
5256

5357
```text
5458
Host coder-cursor.dev.coder.com--*

src/remote/remote.ts

Lines changed: 41 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,9 @@ import { getHeaderCommand } from "../settings/headers";
4040
import { escapeCommandArg, expandPath } from "../util";
4141
import {
4242
type AuthorityParts,
43-
classifyRemoteAuthority,
43+
classifySshHost,
4444
parseRemoteAuthority,
4545
retargetRemoteAuthority,
46-
toCurrentAuthorityHostPrefix,
4746
} from "../util/authority";
4847
import { createStatusBarItem } from "../util/statusBar";
4948
import { vscodeProposed } from "../vscodeProposed";
@@ -54,7 +53,6 @@ import { migrateAuthToSecretsStorage } from "./migration";
5453
import {
5554
SshConfig,
5655
type SshValues,
57-
cleanupStaleSshConfigs,
5856
mergeSshConfigValues,
5957
parseCoderSshOptions,
6058
parseSshConfig,
@@ -164,9 +162,11 @@ export class Remote {
164162

165163
// parseRemoteAuthority returned null for foreign hosts, so this is
166164
// either the current editor's authority or a migratable legacy one.
167-
if (classifyRemoteAuthority(parts) === "legacy") {
168-
await this.migrateLegacyAuthority(remoteAuthority, startupMode);
169-
return;
165+
if (classifySshHost(parts.sshHost) === "legacy") {
166+
if (await this.migrateLegacyAuthority(remoteAuthority, startupMode)) {
167+
return;
168+
}
169+
// Not reopened: keep going so the legacy host still connects.
170170
}
171171

172172
this.logger.info("Setting up remote connection", {
@@ -676,8 +676,7 @@ export class Remote {
676676
this.logger.info("Updating SSH config...");
677677
return await this.updateSSHConfig(
678678
workspaceClient,
679-
context.parts.safeHostname,
680-
context.parts.sshHost,
679+
context.parts,
681680
binaryPath,
682681
logDir,
683682
featureSet,
@@ -727,39 +726,42 @@ export class Remote {
727726
return undefined;
728727
}
729728

729+
/**
730+
* Reopen the window on this editor's own authority. Returns false when the
731+
* workspace cannot be reopened losslessly, so the caller connects over the
732+
* legacy host instead.
733+
*/
730734
private async migrateLegacyAuthority(
731735
remoteAuthority: string,
732736
startupMode: StartupMode,
733-
): Promise<void> {
737+
): Promise<boolean> {
734738
const migratedAuthority = retargetRemoteAuthority(remoteAuthority);
735739
const workspaceFile = vscode.workspace.workspaceFile;
736740
const workspaceFolders = vscode.workspace.workspaceFolders ?? [];
737741
const savedWorkspaceFile =
738742
workspaceFile?.scheme === "untitled" ? undefined : workspaceFile;
739743
if (!savedWorkspaceFile && workspaceFolders.length > 1) {
740744
this.logger.warn(
741-
"Cannot migrate an unsaved multi-root workspace",
745+
"Cannot migrate an unsaved multi-root workspace; connecting over the legacy host",
742746
remoteAuthority,
743747
);
744-
const choice = await vscodeProposed.window.showWarningMessage(
745-
"Opening the remote over the old coder-vscode SSH host",
746-
{
747-
modal: true,
748-
useCustom: true,
749-
detail:
750-
"This editor now uses its own SSH hosts, but switching an unsaved multi-root workspace would drop its folders. " +
751-
"To switch, save the workspace, then reload the window.",
752-
},
753-
"Learn More",
754-
);
755-
if (choice === "Learn More") {
756-
await vscode.env.openExternal(
757-
vscode.Uri.parse(
758-
"https://code.visualstudio.com/docs/editing/workspaces/multi-root-workspaces",
759-
),
760-
);
761-
}
762-
return;
748+
// Fire-and-forget: the connection proceeds either way.
749+
void vscode.window
750+
.showWarningMessage(
751+
"This workspace still opens over the old coder-vscode SSH host. " +
752+
"To switch it to this editor's own host, save the workspace, then reload the window.",
753+
"Learn More",
754+
)
755+
.then(async (choice) => {
756+
if (choice === "Learn More") {
757+
await vscode.env.openExternal(
758+
vscode.Uri.parse(
759+
"https://code.visualstudio.com/docs/editing/workspaces/multi-root-workspaces",
760+
),
761+
);
762+
}
763+
});
764+
return false;
763765
}
764766

765767
await this.serviceContainer
@@ -779,12 +781,13 @@ export class Remote {
779781
}),
780782
false,
781783
);
782-
return;
784+
return true;
783785
}
784786
await vscode.commands.executeCommand("vscode.newWindow", {
785787
remoteAuthority: migratedAuthority,
786788
reuseWindow: true,
787789
});
790+
return true;
788791
}
789792

790793
private async resolveRemoteBinary(workspaceClient: Api): Promise<string> {
@@ -959,13 +962,14 @@ export class Remote {
959962
// all Coder entries.
960963
private async updateSSHConfig(
961964
restClient: Api,
962-
safeHostname: string,
963-
hostName: string,
965+
parts: AuthorityParts,
964966
binaryPath: string,
965967
logDir: string,
966968
featureSet: FeatureSet,
967969
cliAuth: CliAuth,
968970
): Promise<SshProperties> {
971+
// Taken from the authority, so an unmigrated legacy host keeps working.
972+
const { hostPrefix, safeHostname } = parts;
969973
// One file per (editor, deployment); the user's config gains one shared include.
970974
const sshConfig = new SshConfig(this.getMainSshConfigPath(), this.logger);
971975
await sshConfig.load();
@@ -1017,8 +1021,6 @@ export class Remote {
10171021
userConfig,
10181022
);
10191023

1020-
const hostPrefix = toCurrentAuthorityHostPrefix(safeHostname);
1021-
10221024
const proxyCommand = await this.buildProxyCommand(
10231025
binaryPath,
10241026
safeHostname,
@@ -1046,15 +1048,14 @@ export class Remote {
10461048

10471049
// Write our file before including it, so the include never dangles.
10481050
await coderConfig.update(sshValues, sshConfigOverrides);
1049-
const sharedSshConfigDir = this.pathResolver.getSshConfigDir();
1050-
await sshConfig.updateInclude(sharedSshConfigDir, safeHostname);
1051-
// Our file was just written, so only other unused deployments are swept.
1052-
// Never throws, and the connection does not depend on it.
1053-
void cleanupStaleSshConfigs(sharedSshConfigDir, this.logger);
1051+
await sshConfig.updateInclude(
1052+
this.pathResolver.getSshConfigDir(),
1053+
safeHostname,
1054+
);
10541055

10551056
// Mirror SSH's parse order; RemoteCommand can come from the user's config.
10561057
return computeSshProperties(
1057-
hostName,
1058+
parts.sshHost,
10581059
`${coderConfig.getRaw()}\n${sshConfig.getRaw()}`,
10591060
);
10601061
}

src/remote/sshConfig.ts

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import path from "node:path";
1111

1212
import { SSH_CONFIG_EXT } from "../core/pathResolver";
1313
import { countSubstring, lowercase } from "../util";
14-
import { cleanupFiles } from "../util/fileCleanup";
1514
import { renameWithRetry, tempFilePath } from "../util/fs";
1615

1716
import type { Logger } from "../logging/logger";
@@ -79,32 +78,20 @@ function legacyDeploymentMarkers(safeHostname: string): BlockMarkers {
7978
};
8079
}
8180

82-
/** Shared include block; identical bytes from every editor, so writers converge. */
81+
/**
82+
* Shared include block; identical bytes from every editor, so writers
83+
* converge. "CODER INCLUDE <id>" is the convention for Coder-managed include
84+
* blocks with disjoint hosts, so integrations can recognize each other's.
85+
*/
8386
const INCLUDE_MARKERS: BlockMarkers = {
84-
start: "# --- START CODER ---",
85-
end: "# --- END CODER ---",
87+
start: "# --- START CODER INCLUDE CODER-REMOTE ---",
88+
end: "# --- END CODER INCLUDE CODER-REMOTE ---",
8689
};
8790

8891
/** Header of the generated per-deployment file. */
8992
const CODER_SSH_CONFIG_HEADER = `# Coder workspace hosts. Do not edit; the Coder extension rewrites this file
9093
# on every connection. Override options with the "coder.sshConfig" setting.`;
9194

92-
/** Connects rewrite the file, so anything older is unused and safe to sweep. */
93-
const STALE_CONFIG_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
94-
95-
/** Delete generated configs (from any editor) not connected to recently. */
96-
export async function cleanupStaleSshConfigs(
97-
dir: string,
98-
logger: Logger,
99-
): Promise<void> {
100-
await cleanupFiles(dir, logger, {
101-
label: "generated SSH config",
102-
filter: (name) => name.endsWith(SSH_CONFIG_EXT),
103-
select: (files, now) =>
104-
files.filter((file) => now - file.mtime > STALE_CONFIG_MAX_AGE_MS),
105-
});
106-
}
107-
10895
/**
10996
* SSH options a deployment may not set, mirroring the server's validation of
11097
* --ssh-config-options (codersdk.ValidateSSHConfigOption).
@@ -410,6 +397,7 @@ export class SshConfig {
410397
private renderIncludeBlock(includeDir: string): string {
411398
return [
412399
INCLUDE_MARKERS.start,
400+
"# Managed by each editor's Coder extension (coder.coder-remote).",
413401
"# Moves back to the top on connect; override options via coder.sshConfig.",
414402
`Include "${this.escapeIncludePath(includeDir)}/*${SSH_CONFIG_EXT}"`,
415403
INCLUDE_MARKERS.end,
@@ -509,13 +497,15 @@ export class SshConfig {
509497
}
510498

511499
private async discardTemp(tempPath: string): Promise<void> {
512-
await this.fileSystem.unlink(tempPath).catch((unlinkErr: unknown) => {
500+
try {
501+
await this.fileSystem.unlink(tempPath);
502+
} catch (unlinkErr) {
513503
this.logger.warn(
514504
"Failed to clean up temp SSH config file",
515505
tempPath,
516506
unlinkErr,
517507
);
518-
});
508+
}
519509
}
520510

521511
private async read(): Promise<string> {

src/util/authority.ts

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ export const LegacyAuthorityPrefix = "coder-vscode";
66

77
export interface AuthorityParts {
88
agent: string | undefined;
9+
/** The `coder-<editor>.<safeHostname>--` prefix of the parsed host. */
10+
hostPrefix: string;
911
sshHost: string;
1012
safeHostname: string;
1113
username: string;
@@ -46,7 +48,7 @@ function getSshHostStart(authority: string): number | undefined {
4648
return undefined;
4749
}
4850

49-
function classifySshHost(sshHost: string): AuthorityClassification {
51+
export function classifySshHost(sshHost: string): AuthorityClassification {
5052
const currentPrefix = currentAuthorityPrefix();
5153
if (sshHost.startsWith(`${currentPrefix}.`)) {
5254
return "current";
@@ -120,19 +122,14 @@ export function parseRemoteAuthority(authority: string): AuthorityParts | null {
120122

121123
return {
122124
agent,
125+
hostPrefix: `${prefix}${safeHostname}--`,
123126
sshHost,
124127
safeHostname,
125128
username,
126129
workspace,
127130
};
128131
}
129132

130-
export function classifyRemoteAuthority(
131-
parts: AuthorityParts,
132-
): AuthorityClassification {
133-
return classifySshHost(parts.sshHost);
134-
}
135-
136133
export function toRemoteAuthority(
137134
baseUrl: string,
138135
workspaceOwner: string,
@@ -146,10 +143,6 @@ export function toRemoteAuthority(
146143
return remoteAuthority;
147144
}
148145

149-
export function toCurrentAuthorityHostPrefix(safeHostname: string): string {
150-
return `${currentAuthorityPrefix()}.${safeHostname}--`;
151-
}
152-
153146
export function retargetRemoteAuthority(authority: string): string {
154147
const sshHostStart = getSshHostStart(authority);
155148
if (sshHostStart === undefined) {

test/unit/remote/remote.test.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,10 +209,15 @@ describe("Remote", () => {
209209
docsUrls: [expect.stringContaining("multi-root-workspaces")],
210210
},
211211
])(
212-
"keeps an untitled multi-root workspace on the old host (choice: $choice)",
212+
"sets up an untitled multi-root workspace on the old host (choice: $choice)",
213213
async ({ choice, docsUrls }) => {
214214
mockEnv.uriScheme = "cursor";
215-
const { remote, mementoManager, userInteraction } = createRemote();
215+
const {
216+
remote,
217+
ensureLoggedInWithDialog,
218+
mementoManager,
219+
userInteraction,
220+
} = createRemote();
216221
setWorkspace(
217222
[createUri("/first-folder"), createUri("/second-folder")],
218223
createUri("/Untitled-1.code-workspace", {
@@ -232,7 +237,17 @@ describe("Remote", () => {
232237
expect(warning?.message).toContain("coder-vscode SSH host");
233238
expect(warning?.items).toEqual(["Learn More"]);
234239
expect(userInteraction.getExternalUrls()).toEqual(docsUrls);
235-
expect(vscode.commands.executeCommand).not.toHaveBeenCalled();
240+
// Setup continues over the legacy host instead of reopening the window.
241+
expect(ensureLoggedInWithDialog).toHaveBeenCalledOnce();
242+
expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith(
243+
"vscode.openFolder",
244+
expect.anything(),
245+
expect.anything(),
246+
);
247+
expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith(
248+
"vscode.newWindow",
249+
expect.anything(),
250+
);
236251
expect(await mementoManager.getAndClearStartupMode()).toBe("none");
237252
},
238253
);

0 commit comments

Comments
 (0)