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
1 change: 1 addition & 0 deletions .oxlintrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
}
],
"no-throw-literal": "error",
"no-implicit-coercion": "error",
"require-await": "off",
"typescript/await-thenable": "error",
"typescript/ban-ts-comment": "error",
Expand Down
12 changes: 10 additions & 2 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -901,7 +901,15 @@ export class Commands {
* Apply recommended SSH settings for reliable Coder workspace connections.
*/
public async applyRecommendedSettings(): Promise<void> {
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",
Expand Down
200 changes: 139 additions & 61 deletions src/remote/sshOverrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,77 +2,85 @@ import { formatDuration, intervalToDuration } from "date-fns";
import * as jsonc from "jsonc-parser";
import * as fs from "node:fs/promises";

Comment thread
EhabY marked this conversation as resolved.
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 {
readonly value: number | null;
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<string, RecommendedSetting>;
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<Record<string, RecommendedSetting>>;

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<Record<SshSettingKey, number | null>>;

/**
* 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<Exclude<SshSettingKey, "connectTimeout">, 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<RemoteSshExtensionId, readonly SshSettingKey[]>
>;

export function getRecommendedSshSettings(
config: Pick<WorkspaceConfiguration, "inspect">,
): Readonly<Record<string, RecommendedSetting>> {
return Object.fromEntries(
getSshSettings(config).map(({ name, key }) => [
key,
RECOMMENDED_SSH_SETTINGS[name],
]),
);
}

/**
* Build the list of VS Code setting overrides needed for a remote SSH
* connection to a Coder workspace.
*/
export function buildSshOverrides(
config: Pick<WorkspaceConfiguration, "get">,
config: Pick<WorkspaceConfiguration, "get" | "inspect">,
sshHost: string,
agentOS: string,
remoteCommand: string | undefined,
Expand Down Expand Up @@ -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<number>(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<number>(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;
Expand All @@ -143,7 +158,7 @@ export function buildSshOverrides(
*/
export async function applySettingOverrides(
settingsFilePath: string,
overrides: SettingOverride[],
overrides: readonly SettingOverride[],
logger: Logger,
): Promise<boolean> {
if (overrides.length === 0) {
Expand Down Expand Up @@ -173,3 +188,66 @@ export async function applySettingOverrides(
return false;
}
}

function getSshSettings(config: Pick<WorkspaceConfiguration, "inspect">) {
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<WorkspaceConfiguration, "inspect">,
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<WorkspaceConfiguration["inspect"]>,
): 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}` };
}
32 changes: 29 additions & 3 deletions test/mocks/testHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export function makeNetworkInfo(
*/
export class MockConfigurationProvider {
private readonly config = new Map<string, unknown>();
private readonly defaults = new Map<string, unknown>();

constructor() {
this.setupVSCodeMock();
Expand All @@ -123,15 +124,30 @@ export class MockConfigurationProvider {
get<T>(key: string): T | undefined;
get<T>(key: string, defaultValue: T): T;
get<T>(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<T>(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();
}

/**
Expand Down Expand Up @@ -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: <T>(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();
Expand Down
Loading
Loading