Skip to content
Open
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
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,11 @@
"title": "Refresh Tasks",
"category": "Coder",
"icon": "$(refresh)"
},
{
"command": "coder.applyRecommendedSettings",
"title": "Apply Recommended SSH Settings",
"category": "Coder"
}
],
"menus": {
Expand Down Expand Up @@ -386,6 +391,9 @@
{
"command": "coder.tasks.refresh",
"when": "false"
},
{
"command": "coder.applyRecommendedSettings"
}
],
"view/title": [
Expand Down
20 changes: 20 additions & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { type Logger } from "./logging/logger";
import { type LoginCoordinator } from "./login/loginCoordinator";
import { withProgress } from "./progress";
import { maybeAskAgent, maybeAskUrl } from "./promptUtils";
import { RECOMMENDED_SSH_SETTINGS } from "./remote/userSettings";
import { escapeCommandArg, toRemoteAuthority, toSafeHost } from "./util";
import { vscodeProposed } from "./vscodeProposed";
import {
Expand Down Expand Up @@ -310,6 +311,25 @@ export class Commands {
}
}

/**
* Apply recommended SSH settings for reliable Coder workspace connections.
*/
public async applyRecommendedSettings(): Promise<void> {
const config = vscode.workspace.getConfiguration();
const entries = Object.entries(RECOMMENDED_SSH_SETTINGS);
for (const [key, setting] of entries) {
await config.update(
key,
setting.value,
vscode.ConfigurationTarget.Global,
);
}
const summary = entries.map(([, s]) => s.label).join(", ");
vscode.window.showInformationMessage(
`Applied recommended SSH settings: ${summary}`,
);
}

/**
* Create a new workspace for the currently logged-in deployment.
*
Expand Down
4 changes: 4 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
"coder.manageCredentials",
commands.manageCredentials.bind(commands),
),
vscode.commands.registerCommand(
"coder.applyRecommendedSettings",
commands.applyRecommendedSettings.bind(commands),
),
);

const remote = new Remote(serviceContainer, commands, ctx);
Expand Down
90 changes: 11 additions & 79 deletions src/remote/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
type Workspace,
type WorkspaceAgent,
} from "coder/site/src/api/typesGenerated";
import * as jsonc from "jsonc-parser";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
Expand Down Expand Up @@ -60,6 +59,7 @@ import {
} from "./sshConfig";
import { SshProcessMonitor } from "./sshProcess";
import { computeSshProperties, sshSupportsSetEnv } from "./sshSupport";
import { applySettingOverrides, buildSshOverrides } from "./userSettings";
import { WorkspaceStateMachine } from "./workspaceStateMachine";

export interface RemoteDetails extends vscode.Disposable {
Expand Down Expand Up @@ -459,85 +459,17 @@ export class Remote {
const inbox = await Inbox.create(workspace, workspaceClient, this.logger);
disposables.push(inbox);

// Do some janky setting manipulation.
this.logger.info("Modifying settings...");
const remotePlatforms = vscodeProposed.workspace
.getConfiguration()
.get<Record<string, string>>("remote.SSH.remotePlatform", {});
const connTimeout = vscodeProposed.workspace
.getConfiguration()
.get<number | undefined>("remote.SSH.connectTimeout");

// We have to directly munge the settings file with jsonc because trying to
// update properly through the extension API hangs indefinitely. Possibly
// VS Code is trying to update configuration on the remote, which cannot
// connect until we finish here leading to a deadlock. We need to update it
// locally, anyway, and it does not seem possible to force that via API.
let settingsContent = "{}";
try {
settingsContent = await fs.readFile(
this.pathResolver.getUserSettingsPath(),
"utf8",
);
} catch {
// Ignore! It's probably because the file doesn't exist.
}

// Add the remote platform for this host to bypass a step where VS Code asks
// the user for the platform.
let mungedPlatforms = false;
if (
!remotePlatforms[parts.sshHost] ||
remotePlatforms[parts.sshHost] !== agent.operating_system
) {
remotePlatforms[parts.sshHost] = agent.operating_system;
settingsContent = jsonc.applyEdits(
settingsContent,
jsonc.modify(
settingsContent,
["remote.SSH.remotePlatform"],
remotePlatforms,
{},
),
);
mungedPlatforms = true;
}

// VS Code ignores the connect timeout in the SSH config and uses a default
// of 15 seconds, which can be too short in the case where we wait for
// startup scripts. For now we hardcode a longer value. Because this is
// potentially overwriting user configuration, it feels a bit sketchy. If
// microsoft/vscode-remote-release#8519 is resolved we can remove this.
const minConnTimeout = 1800;
let mungedConnTimeout = false;
if (!connTimeout || connTimeout < minConnTimeout) {
settingsContent = jsonc.applyEdits(
settingsContent,
jsonc.modify(
settingsContent,
["remote.SSH.connectTimeout"],
minConnTimeout,
{},
),
);
mungedConnTimeout = true;
}

if (mungedPlatforms || mungedConnTimeout) {
try {
await fs.writeFile(
this.pathResolver.getUserSettingsPath(),
settingsContent,
);
} catch (ex) {
// This could be because the user's settings.json is read-only. This is
// the case when using home-manager on NixOS, for example. Failure to
// write here is not necessarily catastrophic since the user will be
// asked for the platform and the default timeout might be sufficient.
mungedPlatforms = mungedConnTimeout = false;
this.logger.warn("Failed to configure settings", ex);
}
}
const overrides = buildSshOverrides(
vscodeProposed.workspace.getConfiguration(),
parts.sshHost,
agent.operating_system,
);
await applySettingOverrides(
this.pathResolver.getUserSettingsPath(),
overrides,
this.logger,
);

const logDir = this.getLogDir(featureSet);

Expand Down
124 changes: 124 additions & 0 deletions src/remote/userSettings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import * as jsonc from "jsonc-parser";
import * as fs from "node:fs/promises";

import type { WorkspaceConfiguration } from "vscode";

import type { Logger } from "../logging/logger";

export interface SettingOverride {
key: string;
value: unknown;
}

interface RecommendedSetting {
readonly value: number | null;
readonly label: string;
}

export const RECOMMENDED_SSH_SETTINGS = {
"remote.SSH.connectTimeout": {
value: 1800,
label: "Connect Timeout: 1800s (30 min)",
},
"remote.SSH.reconnectionGraceTime": {
value: 28800,
label: "Reconnection Grace Time: 28800s (8 hours)",
},
"remote.SSH.serverShutdownTimeout": {
value: 28800,
label: "Server Shutdown Timeout: 28800s (8 hours)",
},
"remote.SSH.maxReconnectionAttempts": {
value: null,
label: "Max Reconnection Attempts: max allowed",
},
} as const satisfies Record<string, RecommendedSetting>;

/**
* 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">,
sshHost: string,
agentOS: string,
): SettingOverride[] {
const overrides: SettingOverride[] = [];

// Set the remote platform for this host to bypass the platform prompt.
const remotePlatforms = config.get<Record<string, string>>(
"remote.SSH.remotePlatform",
{},
);
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 minConnTimeout =
RECOMMENDED_SSH_SETTINGS["remote.SSH.connectTimeout"].value;
const connTimeout = config.get<number>("remote.SSH.connectTimeout");
if (!connTimeout || connTimeout < minConnTimeout) {
overrides.push({
key: "remote.SSH.connectTimeout",
value: minConnTimeout,
});
}

// Set recommended defaults for settings the user hasn't configured.
const setIfUndefined = [
"remote.SSH.reconnectionGraceTime",
"remote.SSH.serverShutdownTimeout",
"remote.SSH.maxReconnectionAttempts",
] as const;
for (const key of setIfUndefined) {
if (config.get(key) === undefined) {
overrides.push({ key, value: RECOMMENDED_SSH_SETTINGS[key].value });
}
}

return overrides;
}

/**
* Apply setting overrides to the user's settings.json file.
*
* We munge the file directly with jsonc instead of using the VS Code API
* because the API hangs indefinitely during remote connection setup (likely
* a deadlock from trying to update config on the not-yet-connected remote).
*/
export async function applySettingOverrides(
settingsFilePath: string,
overrides: SettingOverride[],
logger: Logger,
): Promise<boolean> {
if (overrides.length === 0) {
return false;
}

let settingsContent = "{}";
try {
settingsContent = await fs.readFile(settingsFilePath, "utf8");
} catch {
// File probably doesn't exist yet.
}

for (const { key, value } of overrides) {
settingsContent = jsonc.applyEdits(
settingsContent,
jsonc.modify(settingsContent, [key], value, {}),
);
}

try {
await fs.writeFile(settingsFilePath, settingsContent);
return true;
} catch (ex) {
// Could be read-only (e.g. home-manager on NixOS). Not catastrophic.
logger.warn("Failed to configure settings", ex);
return false;
}
}
Loading