Skip to content

Commit aad1920

Browse files
authored
Refactor SSH process monitoring to support VS Code forks (#665)
Extract SSH process discovery and network status display into a dedicated `SshProcessMonitor` class. Add centralized Remote SSH extension detection to support Cursor, Windsurf, and other VS Code forks. Key changes: - Extract SSH monitoring logic from `remote.ts` into `sshProcess.ts` - Add `sshExtension.ts` to detect installed Remote SSH extension - Use `createRequire` instead of private `module._load` API to load the Remote SSH extension - Fix port detection to find most recent port and handle SSH reconnects - Add Cursor's "Socks port:" log format to port regex Closes #660
1 parent 3a67bc1 commit aad1920

File tree

10 files changed

+1197
-320
lines changed

10 files changed

+1197
-320
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717

1818
- WebSocket connections now automatically reconnect on network failures, improving reliability when
1919
communicating with Coder deployments.
20+
- Improved SSH process and log file discovery with better reconnect handling and support for
21+
VS Code forks (Cursor, Windsurf, Antigravity).
2022

2123
## [v1.11.4](https://github.com/coder/vscode-coder/releases/tag/v1.11.4) 2025-11-20
2224

src/extension.ts

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
import axios, { isAxiosError } from "axios";
44
import { getErrorMessage } from "coder/site/src/api/errors";
5-
import * as module from "module";
5+
import { createRequire } from "node:module";
6+
import * as path from "node:path";
67
import * as vscode from "vscode";
78

89
import { errToStr } from "./api/api-helper";
@@ -14,6 +15,7 @@ import { AuthAction } from "./core/secretsManager";
1415
import { CertificateError, getErrorDetail } from "./error";
1516
import { maybeAskUrl } from "./promptUtils";
1617
import { Remote } from "./remote/remote";
18+
import { getRemoteSshExtension } from "./remote/sshExtension";
1719
import { toSafeHost } from "./util";
1820
import {
1921
WorkspaceProvider,
@@ -33,30 +35,21 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
3335
// Cursor and VSCode are covered by ms remote, and the only other is windsurf for now
3436
// Means that vscodium is not supported by this for now
3537

36-
const remoteSSHExtension =
37-
vscode.extensions.getExtension("jeanp413.open-remote-ssh") ||
38-
vscode.extensions.getExtension("codeium.windsurf-remote-openssh") ||
39-
vscode.extensions.getExtension("anysphere.remote-ssh") ||
40-
vscode.extensions.getExtension("ms-vscode-remote.remote-ssh") ||
41-
vscode.extensions.getExtension("google.antigravity-remote-openssh");
38+
const remoteSshExtension = getRemoteSshExtension();
4239

4340
let vscodeProposed: typeof vscode = vscode;
4441

45-
if (!remoteSSHExtension) {
42+
if (remoteSshExtension) {
43+
const extensionRequire = createRequire(
44+
path.join(remoteSshExtension.extensionPath, "package.json"),
45+
);
46+
vscodeProposed = extensionRequire("vscode");
47+
} else {
4648
vscode.window.showErrorMessage(
4749
"Remote SSH extension not found, this may not work as expected.\n" +
4850
// NB should we link to documentation or marketplace?
4951
"Please install your choice of Remote SSH extension from the VS Code Marketplace.",
5052
);
51-
} else {
52-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
53-
vscodeProposed = (module as any)._load(
54-
"vscode",
55-
{
56-
filename: remoteSSHExtension.extensionPath,
57-
},
58-
false,
59-
);
6053
}
6154

6255
const serviceContainer = new ServiceContainer(ctx, vscodeProposed);
@@ -366,11 +359,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
366359
// after the Coder extension is installed, instead of throwing a fatal error
367360
// (this would require the user to uninstall the Coder extension and
368361
// reinstall after installing the remote SSH extension, which is annoying)
369-
if (remoteSSHExtension && vscodeProposed.env.remoteAuthority) {
362+
if (remoteSshExtension && vscodeProposed.env.remoteAuthority) {
370363
try {
371364
const details = await remote.setup(
372365
vscodeProposed.env.remoteAuthority,
373366
isFirstConnect,
367+
remoteSshExtension.id,
374368
);
375369
if (details) {
376370
ctx.subscriptions.push(details);

src/remote/remote.ts

Lines changed: 39 additions & 186 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,10 @@ import {
44
type Workspace,
55
type WorkspaceAgent,
66
} from "coder/site/src/api/typesGenerated";
7-
import find from "find-process";
87
import * as jsonc from "jsonc-parser";
98
import * as fs from "node:fs/promises";
109
import * as os from "node:os";
1110
import * as path from "node:path";
12-
import prettyBytes from "pretty-bytes";
1311
import * as semver from "semver";
1412
import * as vscode from "vscode";
1513

@@ -36,12 +34,12 @@ import {
3634
AuthorityPrefix,
3735
escapeCommandArg,
3836
expandPath,
39-
findPort,
4037
parseRemoteAuthority,
4138
} from "../util";
4239
import { WorkspaceMonitor } from "../workspace/workspaceMonitor";
4340

4441
import { SSHConfig, type SSHValues, mergeSSHConfigValues } from "./sshConfig";
42+
import { SshProcessMonitor } from "./sshProcess";
4543
import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport";
4644
import { WorkspaceStateMachine } from "./workspaceStateMachine";
4745

@@ -109,6 +107,7 @@ export class Remote {
109107
public async setup(
110108
remoteAuthority: string,
111109
firstConnect: boolean,
110+
remoteSshExtensionId: string,
112111
): Promise<RemoteDetails | undefined> {
113112
const parts = parseRemoteAuthority(remoteAuthority);
114113
if (!parts) {
@@ -148,15 +147,15 @@ export class Remote {
148147
]);
149148

150149
if (result.type === "login") {
151-
return this.setup(remoteAuthority, firstConnect);
150+
return this.setup(remoteAuthority, firstConnect, remoteSshExtensionId);
152151
} else if (!result.userChoice) {
153152
// User declined to log in.
154153
await this.closeRemote();
155154
return;
156155
} else {
157156
// Log in then try again.
158157
await this.commands.login({ url: baseUrlRaw, label: parts.label });
159-
return this.setup(remoteAuthority, firstConnect);
158+
return this.setup(remoteAuthority, firstConnect, remoteSshExtensionId);
160159
}
161160
};
162161

@@ -485,30 +484,26 @@ export class Remote {
485484
throw error;
486485
}
487486

488-
// TODO: This needs to be reworked; it fails to pick up reconnects.
489-
this.findSSHProcessID().then(async (pid) => {
490-
if (!pid) {
491-
// TODO: Show an error here!
492-
return;
493-
}
494-
disposables.push(this.showNetworkUpdates(pid));
495-
if (logDir) {
496-
const logFiles = await fs.readdir(logDir);
497-
const logFileName = logFiles
498-
.reverse()
499-
.find(
500-
(file) => file === `${pid}.log` || file.endsWith(`-${pid}.log`),
501-
);
502-
this.commands.workspaceLogPath = logFileName
503-
? path.join(logDir, logFileName)
504-
: undefined;
505-
} else {
506-
this.commands.workspaceLogPath = undefined;
507-
}
487+
// Monitor SSH process and display network status
488+
const sshMonitor = SshProcessMonitor.start({
489+
sshHost: parts.host,
490+
networkInfoPath: this.pathResolver.getNetworkInfoPath(),
491+
proxyLogDir: logDir || undefined,
492+
logger: this.logger,
493+
codeLogDir: this.pathResolver.getCodeLogDir(),
494+
remoteSshExtensionId,
508495
});
496+
disposables.push(sshMonitor);
497+
498+
this.commands.workspaceLogPath = sshMonitor.getLogFilePath();
509499

510-
// Register the label formatter again because SSH overrides it!
511500
disposables.push(
501+
sshMonitor.onLogFilePathChange((newPath) => {
502+
this.commands.workspaceLogPath = newPath;
503+
}),
504+
// Watch for logDir configuration changes
505+
this.watchLogDirSetting(logDir, featureSet),
506+
// Register the label formatter again because SSH overrides it!
512507
vscode.extensions.onDidChange(() => {
513508
// Dispose previous label formatter
514509
labelFormatterDisposable.dispose();
@@ -741,172 +736,30 @@ export class Remote {
741736
return ` ${args.join(" ")}`;
742737
}
743738

744-
// showNetworkUpdates finds the SSH process ID that is being used by this
745-
// workspace and reads the file being created by the Coder CLI.
746-
private showNetworkUpdates(sshPid: number): vscode.Disposable {
747-
const networkStatus = vscode.window.createStatusBarItem(
748-
vscode.StatusBarAlignment.Left,
749-
1000,
750-
);
751-
const networkInfoFile = path.join(
752-
this.pathResolver.getNetworkInfoPath(),
753-
`${sshPid}.json`,
754-
);
755-
756-
const updateStatus = (network: {
757-
p2p: boolean;
758-
latency: number;
759-
preferred_derp: string;
760-
derp_latency: { [key: string]: number };
761-
upload_bytes_sec: number;
762-
download_bytes_sec: number;
763-
using_coder_connect: boolean;
764-
}) => {
765-
let statusText = "$(globe) ";
766-
767-
// Coder Connect doesn't populate any other stats
768-
if (network.using_coder_connect) {
769-
networkStatus.text = statusText + "Coder Connect ";
770-
networkStatus.tooltip = "You're connected using Coder Connect.";
771-
networkStatus.show();
739+
private watchLogDirSetting(
740+
currentLogDir: string,
741+
featureSet: FeatureSet,
742+
): vscode.Disposable {
743+
return vscode.workspace.onDidChangeConfiguration((e) => {
744+
if (!e.affectsConfiguration("coder.proxyLogDirectory")) {
772745
return;
773746
}
774-
775-
if (network.p2p) {
776-
statusText += "Direct ";
777-
networkStatus.tooltip = "You're connected peer-to-peer ✨.";
778-
} else {
779-
statusText += network.preferred_derp + " ";
780-
networkStatus.tooltip =
781-
"You're connected through a relay 🕵.\nWe'll switch over to peer-to-peer when available.";
782-
}
783-
networkStatus.tooltip +=
784-
"\n\nDownload ↓ " +
785-
prettyBytes(network.download_bytes_sec, {
786-
bits: true,
787-
}) +
788-
"/s • Upload ↑ " +
789-
prettyBytes(network.upload_bytes_sec, {
790-
bits: true,
791-
}) +
792-
"/s\n";
793-
794-
if (!network.p2p) {
795-
const derpLatency = network.derp_latency[network.preferred_derp];
796-
797-
networkStatus.tooltip += `You ↔ ${derpLatency.toFixed(2)}ms ↔ ${network.preferred_derp}${(network.latency - derpLatency).toFixed(2)}ms ↔ Workspace`;
798-
799-
let first = true;
800-
Object.keys(network.derp_latency).forEach((region) => {
801-
if (region === network.preferred_derp) {
802-
return;
803-
}
804-
if (first) {
805-
networkStatus.tooltip += `\n\nOther regions:`;
806-
first = false;
807-
}
808-
networkStatus.tooltip += `\n${region}: ${Math.round(network.derp_latency[region] * 100) / 100}ms`;
809-
});
810-
}
811-
812-
statusText += "(" + network.latency.toFixed(2) + "ms)";
813-
networkStatus.text = statusText;
814-
networkStatus.show();
815-
};
816-
let disposed = false;
817-
const periodicRefresh = () => {
818-
if (disposed) {
747+
const newLogDir = this.getLogDir(featureSet);
748+
if (newLogDir === currentLogDir) {
819749
return;
820750
}
821-
fs.readFile(networkInfoFile, "utf8")
822-
.then((content) => {
823-
return JSON.parse(content);
824-
})
825-
.then((parsed) => {
826-
try {
827-
updateStatus(parsed);
828-
} catch {
829-
// Ignore
751+
752+
vscode.window
753+
.showInformationMessage(
754+
"Log directory configuration changed. Reload window to apply.",
755+
"Reload",
756+
)
757+
.then((action) => {
758+
if (action === "Reload") {
759+
vscode.commands.executeCommand("workbench.action.reloadWindow");
830760
}
831-
})
832-
.catch(() => {
833-
// TODO: Log a failure here!
834-
})
835-
.finally(() => {
836-
// This matches the write interval of `coder vscodessh`.
837-
setTimeout(periodicRefresh, 3000);
838761
});
839-
};
840-
periodicRefresh();
841-
842-
return {
843-
dispose: () => {
844-
disposed = true;
845-
networkStatus.dispose();
846-
},
847-
};
848-
}
849-
850-
// findSSHProcessID returns the currently active SSH process ID that is
851-
// powering the remote SSH connection.
852-
private async findSSHProcessID(timeout = 15000): Promise<number | undefined> {
853-
const search = async (logPath: string): Promise<number | undefined> => {
854-
// This searches for the socksPort that Remote SSH is connecting to. We do
855-
// this to find the SSH process that is powering this connection. That SSH
856-
// process will be logging network information periodically to a file.
857-
const text = await fs.readFile(logPath, "utf8");
858-
const port = findPort(text);
859-
if (!port) {
860-
return;
861-
}
862-
const processes = await find("port", port);
863-
if (processes.length < 1) {
864-
return;
865-
}
866-
const process = processes[0];
867-
return process.pid;
868-
};
869-
const start = Date.now();
870-
const loop = async (): Promise<number | undefined> => {
871-
if (Date.now() - start > timeout) {
872-
return undefined;
873-
}
874-
// Loop until we find the remote SSH log for this window.
875-
const filePath = await this.getRemoteSSHLogPath();
876-
if (!filePath) {
877-
return new Promise((resolve) => setTimeout(() => resolve(loop()), 500));
878-
}
879-
// Then we search the remote SSH log until we find the port.
880-
const result = await search(filePath);
881-
if (!result) {
882-
return new Promise((resolve) => setTimeout(() => resolve(loop()), 500));
883-
}
884-
return result;
885-
};
886-
return loop();
887-
}
888-
889-
/**
890-
* Returns the log path for the "Remote - SSH" output panel. There is no VS
891-
* Code API to get the contents of an output panel. We use this to get the
892-
* active port so we can display network information.
893-
*/
894-
private async getRemoteSSHLogPath(): Promise<string | undefined> {
895-
const upperDir = path.dirname(this.pathResolver.getCodeLogDir());
896-
// Node returns these directories sorted already!
897-
const dirs = await fs.readdir(upperDir);
898-
const latestOutput = dirs
899-
.reverse()
900-
.filter((dir) => dir.startsWith("output_logging_"));
901-
if (latestOutput.length === 0) {
902-
return undefined;
903-
}
904-
const dir = await fs.readdir(path.join(upperDir, latestOutput[0]));
905-
const remoteSSH = dir.filter((file) => file.indexOf("Remote - SSH") !== -1);
906-
if (remoteSSH.length === 0) {
907-
return undefined;
908-
}
909-
return path.join(upperDir, latestOutput[0], remoteSSH[0]);
762+
});
910763
}
911764

912765
/**

src/remote/sshExtension.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import * as vscode from "vscode";
2+
3+
export const REMOTE_SSH_EXTENSION_IDS = [
4+
"jeanp413.open-remote-ssh",
5+
"codeium.windsurf-remote-openssh",
6+
"anysphere.remote-ssh",
7+
"ms-vscode-remote.remote-ssh",
8+
"google.antigravity-remote-openssh",
9+
] as const;
10+
11+
export type RemoteSshExtensionId = (typeof REMOTE_SSH_EXTENSION_IDS)[number];
12+
13+
type RemoteSshExtension = vscode.Extension<unknown> & {
14+
id: RemoteSshExtensionId;
15+
};
16+
17+
export function getRemoteSshExtension(): RemoteSshExtension | undefined {
18+
for (const id of REMOTE_SSH_EXTENSION_IDS) {
19+
const extension = vscode.extensions.getExtension(id);
20+
if (extension) {
21+
return extension as RemoteSshExtension;
22+
}
23+
}
24+
return undefined;
25+
}

0 commit comments

Comments
 (0)