diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHost.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHost.ts index 06b3c0f31..d0127428c 100644 --- a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHost.ts +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHost.ts @@ -93,8 +93,12 @@ const SSH_SENTENCES: [RegExp, (alias: string) => string][] = [ [/(Operation|Connection) timed out/, (alias) => `The SSH connection to ${alias} timed out. Check that it is on and reachable from this network.`], ]; -function sshFailureSentence(stderr: string, alias: string): string | undefined { - return SSH_SENTENCES.find(([pattern]) => pattern.test(stderr))?.[1](alias); +/** A direct session cannot prompt, so a key with a passphrase only works through ssh-agent. */ +const DIRECT_PERMISSION_HINT = 'Whiteboard cannot ask for a password or passphrase here: if your key has one, add it to ssh-agent with "ssh-add", then retry.'; + +function sshFailureSentence(stderr: string, alias: string, direct = false): string | undefined { + const sentence = SSH_SENTENCES.find(([pattern]) => pattern.test(stderr))?.[1](alias); + return direct && sentence && /Permission denied/.test(stderr) ? `${sentence} ${DIRECT_PERMISSION_HINT}` : sentence; } const SSH_DROPS: [RegExp, (alias: string) => string][] = [ @@ -599,7 +603,7 @@ export class ReviewRemoteHost { detail: sshDropSentence(last, this.alias) ?? `The SSH connection to ${this.alias} ended: ${last || exited}`, }); } - const sentence = sshFailureSentence(this.masterStderr, this.alias); + const sentence = sshFailureSentence(this.masterStderr, this.alias, this.direct); if (sentence) this.options.log(`${this.alias}: ${exited} ${firstLines(this.masterStderr)}`); this.fail({ state: classifySshFailure(this.masterStderr, this.promptCancelled), diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewSshCommand.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewSshCommand.ts index c5bc37fbc..bc8c5c0af 100644 --- a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewSshCommand.ts +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewSshCommand.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; import { chmod, lstat, mkdir } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, win32 } from "node:path"; import { validateSshAlias } from "../../common/reviewSshAlias.js"; @@ -32,6 +33,13 @@ export function reviewSshSharesConnections(env: NodeJS.ProcessEnv = process.env, return platform !== "win32" && !(env.VSCODE_DEV && env[REVIEW_SSH_DIRECT_ENV] === "1"); } +/** On Windows, the inbox Win32-OpenSSH rather than an MSYS ssh, such as Git for Windows', earlier on PATH. */ +export function reviewSshBinary(env: NodeJS.ProcessEnv = process.env, platform = process.platform, exists = existsSync): string { + if (platform !== "win32") return "ssh"; + const inbox = win32.join(env.SystemRoot ?? "C:\\Windows", "System32", "OpenSSH", "ssh.exe"); + return exists(inbox) ? inbox : "ssh"; +} + /** Connection sharing needs Unix sockets and a uid; Windows has neither. */ function currentUid(): number { if (!process.getuid) throw new Error("SSH connection sharing is not supported on this platform."); diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewSshConfigAliases.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewSshConfigAliases.ts index f5222c746..cf8effd05 100644 --- a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewSshConfigAliases.ts +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewSshConfigAliases.ts @@ -54,7 +54,8 @@ async function expand(pattern: string, includeDir: string): Promise { const path = pattern.startsWith("~/") ? join(homedir(), pattern.slice(2)) : isAbsolute(pattern) ? pattern : join(includeDir, pattern); const files: string[] = []; try { - for await (const file of glob(path)) files.push(file); + // A glob pattern escapes with backslashes; Windows paths take forward slashes too. + for await (const file of glob(process.platform === "win32" ? path.replaceAll("\\", "/") : path)) files.push(file); } catch { return []; } diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopHost.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopHost.ts index b4f552a63..f4f8e9c80 100644 --- a/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopHost.ts +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopHost.ts @@ -63,6 +63,7 @@ import { installRemote } from "./remote/reviewRemoteInstaller.js"; import { readRemoteInstallScript } from "./remote/reviewRemoteInstallScript.js"; import { createSshAskpass } from "./remote/reviewSshAskpass.js"; import { + reviewSshBinary, reviewSshConfigPath, reviewSshControlDirectory, reviewSshSharesConnections, @@ -357,11 +358,19 @@ export class ReviewDesktopHost extends Disposable { private startRemoteHosts( shellEnvironment: () => Promise, ): void { - // SSH hosts need ControlMaster sockets, which Windows OpenSSH lacks. - if (this.disposed || process.platform === "win32") return; + if (this.disposed) return; let version: Promise | undefined; + const ssh = reviewSshBinary(); const manager = new ReviewRemoteHosts({ - spawn: (args, options) => spawn("ssh", args, options), + // A detached ssh gets a console window on Windows; direct sessions never prompt there. + spawn: (args, options) => + spawn( + ssh, + args, + process.platform === "win32" + ? { ...options, detached: false, windowsHide: true } + : options, + ), controlDirectory: reviewSshSharesConnections() ? reviewSshControlDirectory() : undefined, diff --git a/docs/remote-hosts.md b/docs/remote-hosts.md index 3c460feb7..faa108630 100644 --- a/docs/remote-hosts.md +++ b/docs/remote-hosts.md @@ -27,8 +27,20 @@ config apply to Whiteboard's connections too. - An OpenSSH server that allows TCP forwarding. See [TCP forwarding](#tcp-forwarding). -Your laptop needs macOS or Linux with the OpenSSH client. A Windows laptop is -not supported yet. +Your laptop needs the OpenSSH client. On Windows, that is the built-in +OpenSSH Client, with two limits: + +- Whiteboard cannot show SSH prompts there. Use a key without a passphrase, or + load it into the `ssh-agent` service: + + ```powershell + Get-Service ssh-agent | Set-Service -StartupType Automatic -PassThru | Start-Service + ssh-add + ``` + +- Run `ssh ` once in a terminal to verify and accept the host's key. + +Each command Desktop runs on a Windows laptop opens its own SSH connection. ## Add a host @@ -50,8 +62,8 @@ remote is deleted unless you check The hosts are kept in the setting `review.remote.hosts`, a list of aliases. Only you can change it: a repository or a remote never adds a host. -If `ssh` asks for a password, a passphrase or a host key confirmation, -Whiteboard shows the prompt in its window. Your answer goes to `ssh` and is +On macOS and Linux, if `ssh` asks for a password, a passphrase or a host key +confirmation, Whiteboard shows the prompt in its window. Your answer goes to `ssh` and is never stored. ## How Desktop installs Whiteboard