diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index 83be646421..a9423ee32c 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -45,6 +45,7 @@ } ], "no-throw-literal": "error", + "no-implicit-coercion": "error", "require-await": "off", "typescript/await-thenable": "error", "typescript/ban-ts-comment": "error", diff --git a/src/commands.ts b/src/commands.ts index 8ed44367d0..19b7017d5f 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -40,8 +40,8 @@ import { } from "./progress"; import { maybeAskAgent, maybeAskUrl } from "./promptUtils"; import { - RECOMMENDED_SSH_SETTINGS, applySettingOverrides, + getRecommendedSshSettings, } from "./remote/sshOverrides"; import { isKeyringEnabled, resolveCliAuth } from "./settings/cli"; import { appendVsCodeLogs } from "./supportBundle/appendVsCodeLogs"; @@ -901,7 +901,15 @@ export class Commands { * Apply recommended SSH settings for reliable Coder workspace connections. */ public async applyRecommendedSettings(): Promise { - const entries = Object.entries(RECOMMENDED_SSH_SETTINGS); + const entries = Object.entries( + getRecommendedSshSettings(vscode.workspace.getConfiguration()), + ); + if (entries.length === 0) { + vscode.window.showInformationMessage( + "No recommended SSH settings are supported by the installed Remote SSH extension.", + ); + return; + } const summary = entries.map(([, s]) => s.label).join("\n"); const confirm = await vscodeProposed.window.showWarningMessage( "Apply Recommended SSH Settings", diff --git a/src/remote/sshOverrides.ts b/src/remote/sshOverrides.ts index 65b133450a..ccfb8a062c 100644 --- a/src/remote/sshOverrides.ts +++ b/src/remote/sshOverrides.ts @@ -2,13 +2,18 @@ import { formatDuration, intervalToDuration } from "date-fns"; import * as jsonc from "jsonc-parser"; import * as fs from "node:fs/promises"; +import { + getRemoteSshExtension, + type RemoteSshExtensionId, +} from "./sshExtension"; + import type { WorkspaceConfiguration } from "vscode"; import type { Logger } from "../logging/logger"; export interface SettingOverride { - key: string; - value: unknown; + readonly key: string; + readonly value: unknown; } interface RecommendedSetting { @@ -16,55 +21,58 @@ interface RecommendedSetting { readonly label: string; } -function recommended( - shortName: string, - value: number | null, -): RecommendedSetting { - if (value === null) { - return { value, label: `${shortName}: max allowed` }; - } - const humanized = formatDuration( - intervalToDuration({ start: 0, end: value * 1000 }), - ); - return { value, label: `${shortName}: ${humanized}` }; -} +const MIN_CONNECT_TIMEOUT = 1800; /** * Applied by the "Apply Recommended SSH Settings" command. * These are more aggressive (24h) than AUTO_SETUP_DEFAULTS (8h) because the * user is explicitly opting in via the command palette. */ -export const RECOMMENDED_SSH_SETTINGS = { - "remote.SSH.connectTimeout": recommended("Connect Timeout", 1800), - "remote.SSH.reconnectionGraceTime": recommended( - "Reconnection Grace Time", - 86400, - ), - "remote.SSH.serverShutdownTimeout": recommended( - "Server Shutdown Timeout", - 86400, - ), - "remote.SSH.maxReconnectionAttempts": recommended( - "Max Reconnection Attempts", - null, - ), -} as const satisfies Record; +const RECOMMENDED_SSH_SETTINGS = { + connectTimeout: recommended("Connect Timeout", MIN_CONNECT_TIMEOUT), + reconnectionGraceTime: recommended("Reconnection Grace Time", 86400), + serverShutdownTimeout: recommended("Server Shutdown Timeout", 86400), + maxReconnectionAttempts: recommended("Max Reconnection Attempts", null), +} as const satisfies Readonly>; type SshSettingKey = keyof typeof RECOMMENDED_SSH_SETTINGS; /** Defaults set during connection when the user hasn't configured a value. */ const AUTO_SETUP_DEFAULTS = { - "remote.SSH.reconnectionGraceTime": 28800, // 8h - "remote.SSH.serverShutdownTimeout": 28800, // 8h - "remote.SSH.maxReconnectionAttempts": null, // max allowed -} as const satisfies Partial>; - -/** - * Whether the given RemoteCommand value represents an active command - * (i.e. present, non-empty, and not the SSH default "none"). - */ -function isActiveRemoteCommand(cmd: string | undefined): boolean { - return !!cmd && cmd.toLowerCase() !== "none"; + reconnectionGraceTime: 28800, // 8h + serverShutdownTimeout: 28800, // 8h + maxReconnectionAttempts: null, // max allowed +} as const satisfies Readonly< + Record, number | null> +>; + +const PROVIDER_SETTINGS = { + "ms-vscode-remote.remote-ssh": [ + "connectTimeout", + "reconnectionGraceTime", + "maxReconnectionAttempts", + ], + "anysphere.remote-ssh": ["connectTimeout", "serverShutdownTimeout"], + "jeanp413.open-remote-ssh": ["connectTimeout"], + "codeium.windsurf-remote-openssh": [ + "connectTimeout", + "reconnectionGraceTime", + "maxReconnectionAttempts", + ], + "google.antigravity-remote-openssh": [], +} as const satisfies Readonly< + Record +>; + +export function getRecommendedSshSettings( + config: Pick, +): Readonly> { + return Object.fromEntries( + getSshSettings(config).map(({ name, key }) => [ + key, + RECOMMENDED_SSH_SETTINGS[name], + ]), + ); } /** @@ -72,7 +80,7 @@ function isActiveRemoteCommand(cmd: string | undefined): boolean { * connection to a Coder workspace. */ export function buildSshOverrides( - config: Pick, + config: Pick, sshHost: string, agentOS: string, remoteCommand: string | undefined, @@ -106,29 +114,36 @@ export function buildSshOverrides( value: rest, }); } - } else { + } else if (remotePlatforms[sshHost] !== agentOS) { // Set the remote platform to bypass the platform prompt. - if (remotePlatforms[sshHost] !== agentOS) { - overrides.push({ - key: "remote.SSH.remotePlatform", - value: { ...remotePlatforms, [sshHost]: agentOS }, - }); - } - } - - // Default 15s is too short for startup scripts; enforce a minimum. - const connTimeoutKey: SshSettingKey = "remote.SSH.connectTimeout"; - const { value: minConnTimeout } = RECOMMENDED_SSH_SETTINGS[connTimeoutKey]; - const connTimeout = config.get(connTimeoutKey); - if (minConnTimeout && (!connTimeout || connTimeout < minConnTimeout)) { - overrides.push({ key: connTimeoutKey, value: minConnTimeout }); + overrides.push({ + key: "remote.SSH.remotePlatform", + value: { ...remotePlatforms, [sshHost]: agentOS }, + }); } - // Set conservative defaults for settings the user hasn't configured. - for (const [key, value] of Object.entries(AUTO_SETUP_DEFAULTS)) { - if (config.get(key) === undefined) { - overrides.push({ key, value }); + for (const { name, key } of getSshSettings(config)) { + const effectiveKey = effectiveSettingKey(config, key); + if (name === "connectTimeout") { + // Default 15s is too short for startup scripts; enforce a minimum. + const timeout = config.get(effectiveKey); + if (!timeout || timeout < MIN_CONNECT_TIMEOUT) { + overrides.push({ key, value: MIN_CONNECT_TIMEOUT }); + } + continue; + } + if ( + effectiveKey !== key || + config.inspect(key)?.globalValue !== undefined + ) { + continue; + } + const value = AUTO_SETUP_DEFAULTS[name]; + // VS Code inspect() reports explicit null values as undefined. + if (value === null && config.get(key) === null) { + continue; } + overrides.push({ key, value }); } return overrides; @@ -143,7 +158,7 @@ export function buildSshOverrides( */ export async function applySettingOverrides( settingsFilePath: string, - overrides: SettingOverride[], + overrides: readonly SettingOverride[], logger: Logger, ): Promise { if (overrides.length === 0) { @@ -173,3 +188,66 @@ export async function applySettingOverrides( return false; } } + +function getSshSettings(config: Pick) { + const extensionId = getRemoteSshExtension()?.id; + if (!extensionId) return []; + const namespace = + extensionId === "codeium.windsurf-remote-openssh" + ? (["remote.devinSSH", "remote.windsurfSSH"] as const).find( + (candidate) => + config.inspect(`${candidate}.connectTimeout`)?.defaultValue !== + undefined, + ) + : "remote.SSH"; + if (!namespace) return []; + return PROVIDER_SETTINGS[extensionId].map( + (name) => ({ name, key: `${namespace}.${name}` }) as const, + ); +} + +/** Devin reads deprecated Windsurf values only when its own key is unset. */ +function effectiveSettingKey( + config: Pick, + key: string, +): string { + if ( + !key.startsWith("remote.devinSSH.") || + hasExplicitValue(config.inspect(key)) + ) { + return key; + } + const fallbackKey = key.replace("remote.devinSSH.", "remote.windsurfSSH."); + return hasExplicitValue(config.inspect(fallbackKey)) ? fallbackKey : key; +} + +function hasExplicitValue( + setting: ReturnType, +): boolean { + return ( + setting !== undefined && + (setting.globalValue !== undefined || + setting.workspaceValue !== undefined || + setting.workspaceFolderValue !== undefined) + ); +} + +/** + * Whether the given RemoteCommand value represents an active command + * (i.e. present, non-empty, and not the SSH default "none"). + */ +function isActiveRemoteCommand(cmd: string | undefined): boolean { + if (!cmd) return false; + return cmd.toLowerCase() !== "none"; +} + +function recommended( + shortName: string, + value: number | null, +): RecommendedSetting { + const description = + value === null + ? "max allowed" + : formatDuration(intervalToDuration({ start: 0, end: value * 1000 })); + return { value, label: `${shortName}: ${description}` }; +} diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index c061370675..0d86635fa2 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -103,6 +103,7 @@ export function makeNetworkInfo( */ export class MockConfigurationProvider { private readonly config = new Map(); + private readonly defaults = new Map(); constructor() { this.setupVSCodeMock(); @@ -123,15 +124,30 @@ export class MockConfigurationProvider { get(key: string): T | undefined; get(key: string, defaultValue: T): T; get(key: string, defaultValue?: T): T | undefined { - const value = this.config.get(key); + const value = this.config.has(key) + ? this.config.get(key) + : this.defaults.get(key); return value === undefined ? defaultValue : (value as T); } + setDefault(key: string, value: unknown): void { + this.defaults.set(key, value); + } + + inspect(key: string) { + return { + key, + defaultValue: this.defaults.get(key) as T | undefined, + globalValue: (this.config.get(key) ?? undefined) as T | undefined, + }; + } + /** * Clear all configuration values */ clear(): void { this.config.clear(); + this.defaults.clear(); } /** @@ -159,18 +175,28 @@ export class MockConfigurationProvider { (section?: string) => { // Create a snapshot of the current config when getConfiguration is called const snapshot = new Map(this.config); + const defaults = new Map(this.defaults); const getFullKey = (part: string) => section ? `${section}.${part}` : part; return { get: vi.fn((key: string, defaultValue?: unknown) => { - const value = snapshot.get(getFullKey(key)); + const fullKey = getFullKey(key); + const value = snapshot.has(fullKey) + ? snapshot.get(fullKey) + : defaults.get(fullKey); return value === undefined ? defaultValue : value; }), has: vi.fn((key: string) => { return snapshot.has(getFullKey(key)); }), - inspect: vi.fn(), + inspect: (key: string) => ({ + key: getFullKey(key), + defaultValue: defaults.get(getFullKey(key)) as T | undefined, + globalValue: (snapshot.get(getFullKey(key)) ?? undefined) as + | T + | undefined, + }), update: vi.fn((key: string, value: unknown) => { this.set(getFullKey(key), value); return Promise.resolve(); diff --git a/test/unit/remote/sshOverrides.test.ts b/test/unit/remote/sshOverrides.test.ts index 08d7f78f7b..abeadd3d8f 100644 --- a/test/unit/remote/sshOverrides.test.ts +++ b/test/unit/remote/sshOverrides.test.ts @@ -1,10 +1,12 @@ import { vol } from "memfs"; import * as fsPromises from "node:fs/promises"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; import { applySettingOverrides, buildSshOverrides, + getRecommendedSshSettings, } from "@/remote/sshOverrides"; import { @@ -14,6 +16,14 @@ import { vi.mock("node:fs/promises", async () => (await import("memfs")).fs.promises); +beforeEach(() => { + vi.mocked(vscode.extensions.getExtension).mockImplementation((id) => + id === "ms-vscode-remote.remote-ssh" + ? ({ id } as vscode.Extension) + : undefined, + ); +}); + /** Helper to extract a single override by key from the result. */ function findOverride( overrides: Array<{ key: string; value: unknown }>, @@ -206,20 +216,6 @@ describe("buildSshOverrides", () => { }); }); - it.each([ - { key: "remote.SSH.serverShutdownTimeout", expected: 28800 }, - { key: "remote.SSH.maxReconnectionAttempts", expected: null }, - ])("defaults $key when not configured", ({ key, expected }) => { - const overrides = buildSshOverrides( - new MockConfigurationProvider(), - "host", - "linux", - undefined, - buildLogger, - ); - expect(findOverride(overrides, key)).toBe(expected); - }); - it("produces no overrides when all settings are already correct", () => { const config = new MockConfigurationProvider(); config.set("remote.SSH.remotePlatform", { "my-host": "linux" }); @@ -233,6 +229,158 @@ describe("buildSshOverrides", () => { }); }); +describe("provider-specific tuning", () => { + const logger = createMockLogger(); + const tuning = ( + config: MockConfigurationProvider, + extensionId: string | undefined, + ) => { + vi.mocked(vscode.extensions.getExtension).mockImplementation((id) => + id === extensionId ? ({ id } as vscode.Extension) : undefined, + ); + return Object.fromEntries( + buildSshOverrides(config, "host", "linux", undefined, logger) + .filter(({ key }) => key !== "remote.SSH.remotePlatform") + .map(({ key, value }) => [key, value]), + ); + }; + + it.each([ + [ + "VS Code", + "ms-vscode-remote.remote-ssh", + "remote.SSH", + { + connectTimeout: 1800, + reconnectionGraceTime: 28800, + maxReconnectionAttempts: null, + }, + ], + [ + "Cursor", + "anysphere.remote-ssh", + "remote.SSH", + { connectTimeout: 1800, serverShutdownTimeout: 28800 }, + ], + [ + "Open Remote SSH forks", + "jeanp413.open-remote-ssh", + "remote.SSH", + { connectTimeout: 1800 }, + ], + [ + "Devin", + "codeium.windsurf-remote-openssh", + "remote.devinSSH", + { + connectTimeout: 1800, + reconnectionGraceTime: 28800, + maxReconnectionAttempts: null, + }, + ], + [ + "Windsurf 2.x", + "codeium.windsurf-remote-openssh", + "remote.windsurfSSH", + { + connectTimeout: 1800, + reconnectionGraceTime: 28800, + maxReconnectionAttempts: null, + }, + ], + ["Windsurf before 2.0", "codeium.windsurf-remote-openssh", undefined, {}], + ["Antigravity", "google.antigravity-remote-openssh", undefined, {}], + ["Unrecognized provider", undefined, undefined, {}], + ] as const)( + "applies supported settings for %s", + (_editor, id, namespace, settings) => { + const config = new MockConfigurationProvider(); + if (namespace) config.setDefault(`${namespace}.connectTimeout`, 15); + config.setDefault("remote.SSH.serverShutdownTimeout", 300); + if (namespace) + config.setDefault(`${namespace}.reconnectionGraceTime`, null); + const automatic = Object.fromEntries( + Object.entries(settings).map(([key, value]) => [ + `${namespace}.${key}`, + value, + ]), + ); + expect(tuning(config, id)).toEqual(automatic); + const recommended = getRecommendedSshSettings(config); + expect(Object.keys(recommended)).toEqual(Object.keys(automatic)); + if (namespace && id === "codeium.windsurf-remote-openssh") { + const attemptsKey = `${namespace}.maxReconnectionAttempts`; + config.setDefault(attemptsKey, null); + const { [attemptsKey]: _attempts, ...withDefault } = automatic; + expect(tuning(config, id)).toEqual(withDefault); + } + for (const [key, value] of Object.entries(automatic)) { + expect(recommended[key]?.value).toBe(value === 28800 ? 86400 : value); + config.set(key, value); + } + expect(tuning(config, id)).toEqual({}); + }, + ); + + it.each([ + { + label: "deprecated values", + user: { + "remote.windsurfSSH.connectTimeout": 3600, + "remote.windsurfSSH.reconnectionGraceTime": 600, + "remote.windsurfSSH.maxReconnectionAttempts": 4, + }, + expected: {}, + }, + { + label: "deprecated timeout below minimum", + user: { + "remote.windsurfSSH.connectTimeout": 60, + "remote.windsurfSSH.reconnectionGraceTime": 0, + }, + expected: { "remote.devinSSH.connectTimeout": 1800 }, + }, + { + label: "Devin values take precedence", + user: { + "remote.devinSSH.connectTimeout": 60, + "remote.windsurfSSH.connectTimeout": 3600, + "remote.devinSSH.reconnectionGraceTime": 28800, + "remote.windsurfSSH.reconnectionGraceTime": 600, + }, + expected: { "remote.devinSSH.connectTimeout": 1800 }, + }, + ])("respects Devin's fallback: $label", ({ user, expected }) => { + const config = new MockConfigurationProvider(); + config.setDefault("remote.devinSSH.connectTimeout", 15); + config.setDefault("remote.windsurfSSH.connectTimeout", 15); + config.setDefault("remote.devinSSH.maxReconnectionAttempts", null); + for (const [key, value] of Object.entries(user)) config.set(key, value); + expect(tuning(config, "codeium.windsurf-remote-openssh")).toEqual(expected); + }); + + it.each([ + { + id: "anysphere.remote-ssh", + key: "remote.SSH.serverShutdownTimeout", + value: 300, + }, + { + id: "ms-vscode-remote.remote-ssh", + key: "remote.SSH.maxReconnectionAttempts", + value: null, + }, + ] as const)( + "preserves explicit $key, including defaults and null", + ({ id, key, value }) => { + const config = new MockConfigurationProvider(); + config.setDefault(key, value); + config.set(key, value); + expect(tuning(config, id)[key]).toBeUndefined(); + }, + ); +}); + describe("applySettingOverrides", () => { const settingsPath = "/settings.json"; const logger = createMockLogger();