Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@

### Fixed

- Write the SSH config to the file the active Remote-SSH extension actually
reads. Windsurf/Devin and Antigravity renamed the whole `remote.SSH` settings
section, so a custom config file set as `remote.devinSSH.configFile`,
`remote.windsurfSSH.configFile`, or `remote.antigravitySSH.configFile` was
ignored and the workspace host was written to `~/.ssh/config` instead, where
those editors never looked for it.
- Apply a 60-second default timeout to REST requests, so requests hung on a
half-open TCP connection don't stall pollers forever.
- Change `coder.binarySource`, `coder.binaryDestination`, `coder.headerCommand`,
Expand Down
5 changes: 2 additions & 3 deletions src/remote/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
parseCoderSshOptions,
parseSshConfig,
} from "./sshConfig";
import { getRemoteSshSetting } from "./sshExtension";
import { applySettingOverrides, buildSshOverrides } from "./sshOverrides";
import { SshProcessMonitor } from "./sshProcess";
import { computeSshProperties, sshSupportsSetEnv } from "./sshSupport";
Expand Down Expand Up @@ -918,9 +919,7 @@ export class Remote {
}

private getSshConfigPath(): string {
const configured = vscode.workspace
.getConfiguration()
.get<string>("remote.SSH.configFile");
const configured = getRemoteSshSetting("configFile");
return expandPath(configured || path.join("~", ".ssh", "config"));
}

Expand Down
28 changes: 28 additions & 0 deletions src/remote/sshExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,34 @@ export const REMOTE_SSH_EXTENSION_IDS = [

export type RemoteSshExtensionId = (typeof REMOTE_SSH_EXTENSION_IDS)[number];

/**
* Sections each extension reads, in order. The rebranded forks renamed the
* whole `remote.SSH` section, so reading it directly misses them.
*/
const SETTING_SECTIONS: Readonly<
Record<RemoteSshExtensionId, readonly string[]>
> = {
"jeanp413.open-remote-ssh": ["remote.SSH"],
// Windsurf became Devin and reads both, preferring the new name.
"codeium.windsurf-remote-openssh": ["remote.devinSSH", "remote.windsurfSSH"],
"anysphere.remote-ssh": ["remote.SSH"],
"ms-vscode-remote.remote-ssh": ["remote.SSH"],
"google.antigravity-remote-openssh": ["remote.antigravitySSH"],
};

/** First non-empty value for a string setting, e.g. `configFile`. */
export function getRemoteSshSetting(key: string): string | undefined {
const id = getRemoteSshExtension()?.id;
const sections = id ? SETTING_SECTIONS[id] : ["remote.SSH"];
for (const section of sections) {
const value = vscode.workspace.getConfiguration(section).get<string>(key);
if (value) {
return value;
}
}
return undefined;
}

/**
* VS Code Remote-SSH log layout, shared by the live SSH monitor and the
* support-bundle collector so a future layout change updates one place.
Expand Down
59 changes: 59 additions & 0 deletions test/unit/remote/sshExtension.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from "vitest";
import * as vscode from "vscode";

import { getRemoteSshSetting } from "@/remote/sshExtension";

import { config, type Settings } from "../../mocks/testHelpers";

/** Activate `extensionId`, or no Remote-SSH extension when empty. */
function setup(extensionId: string, settings: Settings = {}): void {
config(settings);
vi.mocked(vscode.extensions.getExtension).mockImplementation((id) =>
id === extensionId ? ({ id } as vscode.Extension<unknown>) : undefined,
);
}

describe("getRemoteSshSetting", () => {
it.each([
["ms-vscode-remote.remote-ssh", "remote.SSH.configFile"],
["anysphere.remote-ssh", "remote.SSH.configFile"],
["jeanp413.open-remote-ssh", "remote.SSH.configFile"],
["google.antigravity-remote-openssh", "remote.antigravitySSH.configFile"],
["codeium.windsurf-remote-openssh", "remote.devinSSH.configFile"],
])("reads the section %s uses", (extensionId, settingKey) => {
setup(extensionId, { [settingKey]: "/custom/config" });

expect(getRemoteSshSetting("configFile")).toBe("/custom/config");
});

it("falls back to the legacy Windsurf section", () => {
setup("codeium.windsurf-remote-openssh", {
"remote.windsurfSSH.configFile": "/legacy/config",
});

expect(getRemoteSshSetting("configFile")).toBe("/legacy/config");
});

it("prefers the Devin section over the legacy Windsurf one", () => {
setup("codeium.windsurf-remote-openssh", {
"remote.devinSSH.configFile": "/devin/config",
"remote.windsurfSSH.configFile": "/legacy/config",
});

expect(getRemoteSshSetting("configFile")).toBe("/devin/config");
});

it("ignores another extension's section", () => {
setup("google.antigravity-remote-openssh", {
"remote.SSH.configFile": "/custom/config",
});

expect(getRemoteSshSetting("configFile")).toBeUndefined();
});

it("defaults to remote.SSH when no extension is installed", () => {
setup("", { "remote.SSH.configFile": "/custom/config" });

expect(getRemoteSshSetting("configFile")).toBe("/custom/config");
});
});