From a8d82324420d9257e77ae96920605450533a5dd7 Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Wed, 29 Apr 2026 22:54:50 +0000 Subject: [PATCH 01/26] add spec --- spec.md | 530 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 530 insertions(+) create mode 100644 spec.md diff --git a/spec.md b/spec.md new file mode 100644 index 00000000..94957829 --- /dev/null +++ b/spec.md @@ -0,0 +1,530 @@ +# Native SSH Connection Mode + +## Requirements + +- Add a new opt-in setting that lets users choose the connection backend used by Open Remote - SSH. +- Preserve the current `ssh2` implementation as the default behavior. +- Add a native OpenSSH backend that uses the user's installed `ssh` executable for authentication and transport, with reuse owned by the extension helper HTTP server. +- Let multiple VS Code windows connected to the same SSH authority reuse one authenticated native SSH connection through the shared helper without prompting again. +- Surface native SSH password, passphrase, keyboard-interactive, and host-key prompts through VS Code UI by combining OpenSSH askpass integration with prompt parsing from native `ssh` output. +- Share reusable connection information through the extension global storage directory so independent extension host processes can discover the same connection. +- Keep the existing user-facing connection flow, remote authority format, host tree commands, server installation behavior, and resolved authority result shape. +- Support the existing server installation path by providing the operations currently used by `installCodeServer`: command execution, partial-output command execution, tunnel creation, and cleanup. +- Continue supporting dynamic forwarding for port forwarding when enabled. +- Provide useful logs for backend selection, helper discovery, native SSH process startup, reuse, stale registry cleanup, and tunnel failures. + +## Constraints + +- Do not replace the current `ssh2` backend. Native SSH must be additive and gated by configuration. +- The native backend cannot handle interactive prompts directly through `ssh2` callbacks. Authentication, host-key verification, passphrase prompts, password prompts, and platform-specific SSH behavior should be delegated to the native `ssh` process and the user's SSH configuration. +- Native SSH prompts must not block on an invisible terminal. Every native `ssh` process that can authenticate must be launched with an askpass environment and monitored for output prompts so questions can be routed back to the helper and then to VS Code UI. +- The extension should not persist credentials, private keys, passwords, passphrases, or SSH agent details in global storage. +- Global storage data must be treated as coordination metadata only. Store local control endpoint details, process identity, authority keys, timestamps, and non-secret connection facts. +- HTTP control endpoints must bind to loopback only and require an unguessable per-helper token from the registry. +- Do not use OpenSSH ControlMaster, ControlPath, or ControlPersist. Multiplexing and reuse must be the role of the helper HTTP server. +- Registry files may be stale because VS Code windows and helper processes can exit independently. Every reuse path must validate liveness before trusting registry entries. +- The current codebase has no test directory, so initial verification should rely on TypeScript compilation plus focused manual extension-host testing unless tests are introduced as part of the implementation. +- `rg` is unavailable in the current workspace; repository inspection used `find`/`grep` fallback. + +## Current Architecture + +- `src/extension.ts` registers `RemoteSSHResolver` as the resolver for `ssh-remote` authorities. +- `src/authResolver.ts` owns the end-to-end resolve flow: + - parse the encoded SSH destination; + - read `remote.SSH.*` settings; + - load SSH config with `SSHConfiguration`; + - create `SSHConnection` instances using `ssh2`; + - authenticate with `ssh2` callbacks; + - run `installCodeServer`; + - open a tunnel to the remote VS Code server; + - return `new vscode.ResolvedAuthority('127.0.0.1', localPort, connectionToken)`. +- `src/ssh/sshConnection.ts` is the practical connection contract used by the resolver and server setup code: + - `connect()`; + - `exec()`; + - `execPartial()`; + - `forwardOut()`; + - `addTunnel()`; + - `closeTunnel()`; + - `close()`. +- `src/serverSetup.ts` is backend-independent except for its concrete `SSHConnection` type annotation. It runs shell commands and parses the install script output. + +## Local Helper Design Patterns + +The native SSH mode should use these helper-process patterns: + +- A long-lived helper process owning a native `ssh` child is a viable model. +- The helper should receive a structured startup configuration and use it to spawn native `ssh` with explicit command and argument arrays. +- A local HTTP management surface is consistent with the requested architecture, but it must be authenticated, loopback-only, schema-validated, and body-size-limited. +- The helper must own piped `ssh.stdin` so it can write command envelopes, cleanup scripts, and approved prompt responses. +- The helper should coordinate discovery through a registry entry and remove that entry on clean shutdown. +- The helper should use explicit keepalive or lease renewal, but multi-window attachment must be lease-based so one closing window does not disconnect other windows. +- The helper should clean up the management server, SSH child processes, local tunnel servers, generated askpass wrapper, and registry data on exit. +- The helper may suppress or route noisy SSH output after setup completes, but it must use per-command unique sentinels and robust stdout/stderr framing rather than broad text markers. +- Platform-specific command spawning behavior, especially Windows `.bat` and `.cmd` wrappers, must be handled centrally. +- Logs must redact sensitive SSH arguments, bearer tokens, prompt responses, and command payloads that may contain secrets. + +## Proposed Setting + +Add an application-scoped setting: + +- `remote.SSH.connectionBackend` +- Type: string enum +- Default: `"ssh2"` +- Values: + - `"ssh2"`: current in-process `ssh2` backend. + - `"native"`: use native OpenSSH helper and shared connection registry. + +Optional follow-up settings, if needed during implementation: + +- `remote.SSH.nativeSshPath`: explicit path to `ssh`, default empty for PATH lookup. + +Keep the first implementation minimal by adding only `connectionBackend` unless path discovery proves too brittle. + +## Proposed Architecture + +### 1. Connection Backend Abstraction + +Introduce an interface for the connection operations the resolver actually needs, for example `RemoteSSHConnection`: + +- `connect(): Promise` +- `exec(command: string, params?: string[], options?: unknown): Promise<{ stdout: string; stderr: string }>` +- `execPartial(command: string, tester: (stdout: string, stderr: string) => boolean, params?: string[], options?: unknown): Promise<{ stdout: string; stderr: string }>` +- `addTunnel(config: SSHTunnelConfig): Promise` +- `closeTunnel(name?: string): Promise` +- `close(): Promise` + +Refactor `installCodeServer` to accept this interface instead of the concrete `SSHConnection` class. + +Keep the existing `src/ssh/sshConnection.ts` as the `ssh2` implementation of that interface. + +### 2. Native Helper Process + +Add a small Node-based helper entrypoint bundled with the extension, for example: + +- `src/native/nativeSshServer.ts` +- `src/native/nativeSshClient.ts` +- `src/native/nativeSshConnection.ts` +- `src/native/nativeSshRegistry.ts` +- `src/native/askpass/askpass.js` + +The helper process should: + +- bind an HTTP server to `127.0.0.1` on an OS-assigned port; +- generate a random bearer token at startup; +- start and supervise native `ssh` child processes for one normalized SSH authority; +- own the reusable connection lifecycle so additional VS Code windows attach to the helper instead of creating their own authenticated transport; +- maintain an attached extension-host event channel so prompts detected by helper-owned `ssh` processes can be surfaced by the active VS Code window; +- expose loopback-only authenticated endpoints for: + - health checks; + - command execution; + - partial command execution with streamed stdout/stderr parsing; + - opening or closing local tunnels; + - askpass prompt requests from native `ssh`; + - parsed prompt requests detected from native `ssh` stdout/stderr; + - returning connection metadata needed by the resolver; + - graceful shutdown, if no windows remain attached. + +The extension host should communicate with the helper through `NativeSshClient`, not by reaching into helper internals. + +The helper API should be explicit and versioned. Minimum endpoints: + +- `GET /health`: validate the helper is alive and matches the expected authority/backend schema. +- `POST /attach`: register an extension-host lease and open an event stream for prompts/status. +- `POST /detach`: release an extension-host lease. +- `POST /exec`: run a command and return stdout/stderr/exit status. +- `POST /exec-partial`: run a command and resolve when the current `execPartial` tester condition is satisfied. +- `POST /tunnel/open`: open or reuse a local tunnel for a remote port or socket path. +- `POST /tunnel/close`: release a tunnel lease. +- `POST /askpass`: receive prompt requests from the askpass script. + +Use newline-delimited JSON, Server-Sent Events, long polling, or an equivalent simple stream for helper-to-extension-host events. A one-shot request/response API is not enough because prompt requests can happen while `connect()`, `exec()`, or tunnel startup is blocked inside a native `ssh` child process. + +### 3. Helper Startup Handshake + +The extension host needs a deterministic way to start a helper and know when it is safe to attach or publish registry metadata. + +- Spawn the helper with `child_process.fork` or `spawn(process.execPath, [helperEntry, configPath])`, depending on what works after webpack bundling. +- Pass startup configuration through a temporary JSON file or stdin instead of a giant command-line JSON blob if the config can exceed Windows command-line limits. +- Startup configuration should include schema version, normalized authority key, ssh executable path, ssh argument array, global storage paths, requested transport features, log routing mode, and a bootstrap token. +- The helper should bind its HTTP server first, then emit a ready message to stdout or an IPC channel containing only non-secret endpoint metadata. +- The extension should call `/health` before writing or trusting the registry entry. +- Registry writes should happen after the helper is reachable, so other windows do not attach to a half-started helper. +- If helper startup times out, the extension should kill the helper process, remove temporary files, and report a clear native-mode failure. +- The helper should report startup failures as structured errors where possible, including missing `ssh`, invalid askpass wrapper, bind failure, and native SSH authentication failure. + +### 4. Native SSH Prompt Bridge + +The native backend must support two prompt paths for all helper-owned `ssh` processes that may need authentication: + +- `SSH_ASKPASS` for prompts OpenSSH routes through askpass. +- stdout/stderr prompt parsing for prompts OpenSSH writes directly to the process output, such as host trust confirmation. + +#### Askpass + +- Bundle or generate a small JavaScript askpass script, for example `askpass.js`. +- Launch `ssh` with environment variables: + - `SSH_ASKPASS=`; + - `SSH_ASKPASS_REQUIRE=force` where supported; + - `DISPLAY` set to a harmless value on Unix-like platforms when OpenSSH requires it to invoke askpass; + - helper endpoint metadata such as askpass URL and one-time request token. +- On Windows, verify whether bundled OpenSSH honors `SSH_ASKPASS`; if it does not, document the limitation and keep password-based native mode unsupported on that platform until an equivalent prompt mechanism is available. +- The askpass script should: + - receive the OpenSSH prompt text from argv; + - call the helper HTTP endpoint over loopback with the prompt text and one-time token; + - print only the user response to stdout; + - exit non-zero when the user cancels or the helper rejects the request; + - avoid logging prompt responses. +- The helper should: + - validate the askpass token and associate it with the pending SSH process; + - forward the prompt to the attached extension host through `NativeSshClient`; + - display `vscode.window.showInputBox` or a modal choice UI depending on prompt type; + - mark password/passphrase responses as secret inputs; + - return cancellation cleanly so OpenSSH fails rather than hanging. +- Prompt classification should be conservative: + - passphrase/password prompts use password input boxes; + - host-key confirmation prompts use explicit accept/cancel choices and should respect the user's SSH config; + - unknown prompts should use password input only when OpenSSH indicates hidden input, otherwise plain input may be acceptable. +- Prompt requests must be serialized per SSH process to avoid interleaving multiple authentication questions. +- Prompt responses must never be written to global storage, output logs, command traces, or registry files. +- If no extension host is attached when a prompt is needed, the helper must fail the pending operation rather than waiting indefinitely. + +#### Output Prompt Parsing + +The helper must parse native `ssh` output while a process is connecting or running a command. + +- Capture stdout and stderr incrementally before forwarding sanitized output to normal command handlers. +- Maintain a small rolling buffer per stream so prompts split across chunks can still be detected. +- Detect known OpenSSH prompt families, including: + - host authenticity prompts like `Are you sure you want to continue connecting (yes/no/[fingerprint])?`; + - changed host-key warnings that require explicit failure messaging rather than blind confirmation; + - password prompts like `@'s password:`; + - private-key passphrase prompts like `Enter passphrase for key ...:`; + - keyboard-interactive prompts that may not go through askpass on all platforms. +- Classify prompts into UI actions: + - host trust: show a modal VS Code warning with the host, key type, and fingerprint when present; write `yes\n` to `ssh.stdin` only if the user accepts; + - password/passphrase: show a password input and write the response plus newline to `ssh.stdin`; + - keyboard-interactive visible prompt: show a normal input when the prompt clearly expects visible text, otherwise use password input; + - unrecoverable host-key mismatch: stop the process and return an actionable error instead of asking the user to bypass it. +- Ensure prompt text shown in VS Code is sanitized and bounded in length. +- Redact prompt responses and sensitive prompt context from logs. +- Add prompt timeouts and cancellation handling so a hidden or unrecognized prompt cannot hang the helper indefinitely. +- Prefer askpass when both mechanisms observe the same prompt; de-duplicate by tracking pending prompt state per process. +- Keep parsing rules centralized in a `NativeSshPromptParser` module so additional platform-specific prompt patterns can be added safely. + +### 5. Command Execution Protocol + +The plan cannot rely on `ssh ` per request as the normal execution path, because that can re-authenticate and breaks the reuse goal. The helper must provide a reusable command execution protocol. + +Recommended first implementation: + +- Start one helper-owned persistent remote shell process per authority for setup/exec work. +- Launch it with native `ssh` using the askpass and prompt-parser environment. +- Serialize command execution over that shell. This is acceptable because server setup is already sequential and avoids multiplexing complexity. +- Wrap every command in a generated command envelope with unique begin/end sentinels. +- Capture stdout/stderr separately where possible: + - for POSIX remotes, run a generated shell wrapper that redirects stdout and stderr through tagged streams or temporary files and prints sentinel metadata; + - for Windows remotes, use a PowerShell wrapper that emits tagged output and exit status. +- Return `{ stdout, stderr }` to match the current `SSHConnection.exec` behavior. +- Implement `execPartial` by watching tagged output for the caller's tester condition and then draining/cleaning the command envelope safely. +- If the persistent shell dies, mark the helper unhealthy and force a reconnect path instead of silently spawning a new authenticated shell behind an attached window. +- Do not run multiple commands concurrently on the same persistent shell until a robust framing protocol exists. + +Remote shell detection and command wrapping: + +- Reuse the existing `remotePlatform` setting when provided. +- When platform is unknown, run a minimal detection command through the persistent shell and classify POSIX shell, PowerShell, or cmd-like behavior. +- Keep separate command envelope implementations for POSIX shell, PowerShell, and cmd if cmd support is required. +- Ensure command envelopes preserve the behavior expected by `installCodeServer`, including long heredoc-like payloads and partial output parsing. +- Include an explicit cleanup command for interrupted envelopes so a canceled `execPartial` cannot leave the shell stuck in a heredoc or multiline input state. +- If cleanup cannot guarantee shell synchronization, discard the shell and mark the helper unhealthy. + +Command safety requirements: + +- Avoid concatenating untrusted strings into local `ssh` command lines. Use `child_process.spawn(file, args, { shell: false })`. +- Preserve the existing install script behavior, but treat command strings as remote shell payloads and wrap them in a controlled envelope. +- Escape only for the target remote shell and keep platform-specific quoting in one module. +- Bound command output buffers, with larger limits only for known server setup commands. +- Redact prompt responses and bearer tokens before logging command envelopes or process output. + +This protocol is a key missing piece because native OpenSSH without ControlMaster does not expose an API equivalent to `ssh2.exec()` for many independent channels over one authenticated connection. + +### 6. Native SSH Transport Strategy + +Use the helper HTTP server as the native reuse primitive. Do not use OpenSSH ControlMaster. + +- For each normalized SSH authority, create or attach to one helper process recorded in global storage. +- The helper should own the persistent native SSH resources for that authority, including at least the command shell and any long-lived forwarding processes. +- The helper should eagerly create the native SSH resources needed by normal resolution during the first connection so later windows do not trigger new authentication prompts. +- The helper must launch native `ssh` with the askpass environment for initial connection and for any later child process that may authenticate. +- The helper must also attach prompt parsers to native `ssh` stdout/stderr and be able to write approved prompt responses to `ssh.stdin`. +- Command execution should be exposed as helper HTTP requests and implemented by the helper against its owned SSH transport. +- Partial command execution must preserve the current `execPartial` behavior needed by server setup: resolve when the tester matches stdout/stderr, while cleaning up safely afterward. +- Opening tunnels should be exposed as helper HTTP requests and implemented by helper-owned forwarding resources. +- TCP forwarding can be implemented with: + - a helper-managed local proxy to a persistent native `ssh -D 127.0.0.1:` SOCKS process for remote TCP ports; or + - helper-owned `ssh -N -L ...` child processes when dynamic SOCKS is disabled. +- Stream-local forwarding can be implemented with helper-owned native `ssh` forwarding processes when the local OpenSSH version and platform support it. +- Every helper-owned forwarding process must also use the askpass/prompt-parser path because it may authenticate independently from the command shell. + +Important design notes: + +- A plain native `ssh ` subprocess per command would delegate authentication to OpenSSH, but would not satisfy the reuse requirement by itself. +- The helper must be the shared owner of any persistent authenticated process or forwarding process needed to avoid repeated authentication across VS Code windows. +- If OpenSSH does not expose a robust portable way to run independent exec requests over one non-ControlMaster process, the first native implementation should use a helper-owned persistent shell for install/setup commands and helper-owned tunnel subprocesses for forwarding. That limitation should be documented, and the default backend should remain `ssh2`. +- Without ControlMaster, a separate tunnel subprocess can still require authentication. The helper must either keep that process alive for reuse, route its prompts through VS Code UI, or use the persistent SOCKS process for TCP tunnels so second windows attach without prompting. + +The first implementation should support: + +- TCP server listening mode (`remoteServerListenOnSocket: false`); +- TCP tunnels through dynamic SOCKS or `-L`; +- ProxyJump, ProxyCommand, identity files, host aliases, and SSH-agent behavior as interpreted by native OpenSSH through the user's config. + +For `remoteServerListenOnSocket: true`, prefer supporting native OpenSSH stream-local forwarding when available. If support is not reliable across platforms, fail with a clear `NotAvailable` error in native mode and document that users should use the `ssh2` backend or disable socket listening. + +### 7. Askpass Packaging and Process Environment + +`SSH_ASKPASS` usually needs an executable program, not only a TypeScript module inside the extension bundle. + +- Generate a small askpass wrapper in the extension global storage directory at helper startup. +- On Unix-like platforms, write it with owner-only permissions and an executable bit. +- The wrapper should invoke the extension's runtime with the bundled askpass JS entrypoint and pass through the OpenSSH prompt argv. +- If the runtime executable cannot safely run the JS entrypoint, use a small shell wrapper that invokes the same Node/Electron runtime already hosting the extension. +- On Windows, verify the installed OpenSSH behavior: + - whether it honors `SSH_ASKPASS`; + - whether it requires a GUI subsystem executable; + - whether stdin prompt parsing is sufficient for password/host-key prompts. +- Launch native `ssh` with no inherited terminal and with `stdio: ['pipe', 'pipe', 'pipe']` so prompts can be parsed and responses can be written through the helper. +- Avoid `BatchMode=yes` in native mode because it disables password/passphrase prompting. +- Consider setting `NumberOfPasswordPrompts=1` per process attempt to avoid repeated hidden prompt loops; retries should be controlled by the helper. +- Avoid inheriting arbitrary extension environment variables into helper-owned `ssh` children except the variables required for SSH, askpass, locale, and platform operation. + +### 8. Authority Identity and Reuse Key + +The registry key must avoid both accidental sharing and unnecessary duplication. + +- Include the encoded remote authority target. +- Include the selected backend schema version. +- Include the configured SSH config file path and a fingerprint of the computed host configuration with sensitive values redacted. +- Include settings that affect transport shape, such as dynamic forwarding and socket-listen mode. +- Include the local platform because path and askpass behavior differ by OS. +- Do not include raw secrets, identity file contents, agent socket values, or tokens. + +If the SSH config changes, the next resolver should compute a different reuse key or invalidate the old helper after a failed health/config check. + +### 9. Terminal Environment and Agent Forwarding + +The current resolver updates `environmentVariableCollection` with values discovered during server setup, especially for agent forwarding behavior. Native mode must preserve that behavior. + +- Keep the existing `installCodeServer` environment variable flow. +- If the native SSH config uses `ForwardAgent yes`, ensure the remote server setup still reports the remote `SSH_AUTH_SOCK` value when needed. +- Update `context.environmentVariableCollection` exactly as the existing resolver does after install output is parsed. +- Do not expose local `SSH_AUTH_SOCK` or identity-agent paths in registry metadata or logs. +- Include terminal environment behavior in manual verification because terminals are a user-visible part of Remote SSH. + +### 10. Remote Server Cleanup and Management + +Native helper mode should account for remote cleanup operations and helper management commands. + +- Add a helper endpoint for remote cleanup if the extension needs to stop or remove the remote VS Code server. +- Generate cleanup scripts using the same platform-specific logic as server setup, not ad hoc process killing in the resolver. +- Run cleanup through the persistent command execution protocol so stdout/stderr and prompts are handled consistently. +- Add a keepalive or lease-renew endpoint separate from command execution. +- Add an explicit graceful shutdown endpoint that is only honored when there are no active leases, unless the resolver is disposing the owning helper due to failure. + +### 11. Security and Storage Hardening + +- Create `native-ssh` storage directories with owner-only permissions where the platform supports it. +- Treat registry files as untrusted input and validate every field before use. +- Use random high-entropy bearer tokens for helper HTTP requests. +- Use separate short-lived askpass prompt tokens, scoped to one process and one prompt. +- Reject non-loopback requests and unexpected HTTP methods. +- Bound request body sizes for all helper endpoints. +- Avoid permissive CORS. The helper is not a browser API. +- Never log bearer tokens, askpass tokens, prompt responses, full environment blocks, or full command payloads when they may contain secrets. +- Rotate helper tokens when a helper restarts and remove registry entries on clean shutdown. +- On stale or suspicious registry data, delete the entry and start a new helper. + +### 12. Global Storage Registry + +Store registry entries in `context.globalStorageUri`, for example: + +- `native-ssh/registry.json` +- `native-ssh//helper.json` +- `native-ssh//logs/...` + +Each registry entry should include: + +- normalized authority key; +- helper HTTP host and port; +- bearer token; +- helper PID; +- dynamic SOCKS port, when enabled; +- created and last-seen timestamps; +- extension version or registry schema version. + +Do not store askpass tokens in the long-lived registry. Askpass request tokens should be short-lived and scoped to a single pending prompt or SSH process. + +Use atomic writes: + +- write to a temporary file; +- rename over the registry file. + +Reuse algorithm: + +1. Normalize the authority from `SSHDestination` and relevant config identity. +2. Read the registry. +3. Find an entry for the same normalized authority and backend version. +4. Call `/health` with the token. +5. If healthy, attach to that helper. +6. If unhealthy, remove the stale entry and start a new helper. +7. On startup races, use a lock file or retry loop so two windows do not create duplicate helpers for the same authority. + +Do not store the VS Code server `connectionToken` as the primary reuse primitive unless needed. It is sensitive enough to avoid long-lived persistence. Prefer asking the helper to provide active tunnel metadata after liveness validation. + +### 13. Resolver Flow in Native Mode + +In `RemoteSSHResolver.resolve`: + +1. Read `remote.SSH.connectionBackend`. +2. Load SSH config and calculate the same host label and install settings as today. +3. If backend is `"ssh2"`, use the current flow. +4. If backend is `"native"`: + - create or attach to `NativeSshConnection` for the authority; + - call `connect()` to attach to or start the helper-owned native SSH transport; + - handle prompt requests from the helper using VS Code UI while `connect()` or command execution is pending; + - run `installCodeServer` through the backend interface; + - open the tunnel to `installResult.listeningOn`; + - update terminal environment variables from install output as today; + - register the label formatter as today; + - return `ResolvedAuthority` with the local tunnel port and server connection token. + +When attaching to an existing helper: + +- verify helper schema version, authority key, transport-affecting settings, and liveness; +- renew the current window's lease; +- open a prompt/status event stream before issuing operations that may prompt; +- request or create a local tunnel for this window rather than assuming another window's local tunnel port is reusable forever. + +Keep resolver-level error behavior consistent: + +- show the log and retry/close dialog on first resolve attempt; +- map install-script failures to `RemoteAuthorityResolverError.NotAvailable`; +- map transient helper/native process failures to `TemporarilyNotAvailable` where retry may help. + +### 14. Cleanup and Lifetime + +- In `ssh2` mode, preserve current disposal semantics. +- In native mode, disposing one extension host should close only the tunnels opened for that window, not necessarily the shared helper connection. +- The helper should keep a reference count or attachment lease per window. +- Leases should expire if a window exits without cleanup. +- The helper can stop its native SSH child processes after no leases remain and an idle timeout has elapsed. +- Registry entries should be removed when the helper exits cleanly. +- Stale entries should be removed by future resolver attempts. + +### 15. Failure Modes and Fallback Behavior + +- If native `ssh` is not found, fail native mode with a clear error and keep `ssh2` as the default backend. +- If the helper starts but cannot create the required command shell, mark the helper unhealthy and remove its registry entry. +- If prompt parsing detects a changed host-key warning, fail with instructions to fix the user's known hosts file; do not offer an accept button. +- If the helper loses its attached extension-host event stream during a prompt, cancel the prompt and fail the pending operation. +- If a forwarded-port process exits unexpectedly, close affected local servers and surface an actionable error. +- If the persistent command shell loses framing synchronization, discard it, mark the helper unhealthy, and force a clean reconnect. +- If the helper registry says a helper is healthy but `/health` reports a mismatched schema or authority key, remove the registry entry and start a new helper. +- If remote platform detection conflicts with `remote.SSH.remotePlatform`, prefer the explicit setting and log the detected mismatch. +- Do not silently fall back from `"native"` to `"ssh2"` after a native failure; the selected backend should be explicit so behavior is debuggable. + +## Implementation Steps + +1. Add the `remote.SSH.connectionBackend` configuration contribution in `package.json`. +2. Define a shared connection interface and update `installCodeServer` to depend on it. +3. Adapt `RemoteSSHResolver` so the existing `ssh2` flow is isolated behind a backend creation path with no behavior change. +4. Add registry helpers for authority hashing, atomic reads/writes, liveness validation, stale cleanup, and startup locking. +5. Add security and storage primitives for owner-only directories, validated registry reads, token generation, and body-size-limited helper requests. +6. Add helper startup handshake: + - startup config file or stdin input; + - ready signal; + - health validation before registry publication; + - startup timeout and cleanup. +7. Add the native helper HTTP server with authenticated loopback endpoints and an extension-host event stream for prompt/status events. +8. Add authority identity computation using encoded destination, backend schema, redacted computed SSH config, and transport-affecting settings. +9. Add the askpass bridge: + - bundle or generate the JavaScript askpass script; + - generate an executable platform wrapper in global storage; + - set `SSH_ASKPASS`, `SSH_ASKPASS_REQUIRE`, and any required display environment for native `ssh`; + - add helper endpoint handling for prompt requests; + - add client-side VS Code UI prompt handling; + - ensure prompt responses are never logged or persisted. +10. Add native SSH output prompt parsing: + - implement rolling-buffer parsing for stdout/stderr chunks; + - detect host authenticity, password, passphrase, keyboard-interactive, and host-key mismatch messages; + - surface parsed prompts through VS Code UI; + - write accepted prompt responses to `ssh.stdin`; + - fail safely on cancellation, timeout, host-key mismatch, or unknown blocking prompts. +11. Add OpenSSH process management: + - ssh path resolution; + - helper-owned persistent SSH resource startup; + - no inherited terminal, piped stdio, and controlled environment; + - command execution over the helper-owned persistent shell; + - stdout/stderr collection and partial-output completion; + - process exit/error logging. +12. Add the persistent command execution protocol: + - command envelopes with unique sentinels; + - stdout/stderr tagging or temporary-file collection; + - exit-code parsing; + - serialized command queue; + - shell desynchronization detection and recovery. +13. Add remote shell detection and platform-specific command wrappers for POSIX shell, PowerShell, and cmd if required. +14. Add terminal environment and agent-forwarding integration so install output still updates `environmentVariableCollection`. +15. Add remote cleanup and helper management endpoints for keepalive, lease renewal, graceful shutdown, and optional remote server cleanup. +16. Add native tunnel support: + - remote VS Code server TCP tunnel; + - dynamic SOCKS reuse when `remote.SSH.enableDynamicForwarding` is true; + - local port forwarding fallback when dynamic forwarding is false; + - explicit handling for stream-local socket forwarding. +17. Wire native mode into resolver settings and error handling. +18. Add logging and diagnostics for backend selection, helper attach/start, SSH command failures, parsed prompt handling, tunnel allocation, prompt cancellation, and cleanup. +19. Add focused tests if a test harness is introduced; otherwise verify with compile/lint/manual extension-host scenarios. +20. Update README or changelog notes for the new setting, askpass behavior, parsed prompt behavior, no-ControlMaster design, and native-mode limitations. + +## Verification Plan + +- Run `npm run compile:src`. +- Run `npm run bundle:dev`. +- Run `npm run lint` if practical after implementation. +- Add unit tests or fixture-based checks for `NativeSshPromptParser` before relying only on manual testing. +- Add helper integration tests with a fake `ssh` executable that emits prompt fixtures, accepts stdin responses, and simulates command/tunnel lifecycle. +- Manual local extension-host checks: + - default setting still uses existing `ssh2` behavior; + - helper startup does not publish registry metadata until `/health` succeeds; + - native mode connects to an SSH config alias; + - password authentication prompts are displayed through VS Code UI via `SSH_ASKPASS`; + - encrypted private-key passphrase prompts are displayed through VS Code UI via `SSH_ASKPASS`; + - host trust prompts parsed from native `ssh` output are displayed as explicit VS Code accept/cancel UI; + - changed host-key warnings fail with a clear error and are not auto-confirmed; + - prompt cancellation fails the connection cleanly and does not hang the helper; + - command execution returns separated stdout/stderr and correct exit status through the persistent shell protocol; + - persistent shell death marks the helper unhealthy and a later resolve starts cleanly; + - remote platform detection honors explicit `remote.SSH.remotePlatform` when set; + - terminal environment variables are updated after native-mode server setup; + - opening a second VS Code window for the same host reuses the helper without another authentication prompt; + - attaching to an existing helper opens a new lease and event stream before issuing operations; + - closing one window does not disconnect the other; + - stale registry entries are cleaned after killing the helper process; + - forwarded ports work with `remote.SSH.enableDynamicForwarding` enabled; + - server installation works on a host with no existing server; + - server reuse works on a host with an existing server; + - failure to find `ssh` produces a clear error. + +## Success Criteria + +- `remote.SSH.connectionBackend: "ssh2"` behaves the same as the current extension. +- `remote.SSH.connectionBackend: "native"` uses the system `ssh` executable for the remote connection without using OpenSSH ControlMaster. +- Native `ssh` authentication and host trust prompts are surfaced through VS Code UI using the askpass bridge and parsed `ssh` output. +- Two windows opened to the same normalized SSH authority can share one authenticated native SSH helper. +- The second window can resolve the remote authority without re-entering credentials when the helper-owned native SSH transport is healthy. +- Command execution in native mode works through a reusable helper-owned transport without spawning a fresh authenticated `ssh ` process for every request. +- Global storage contains only non-secret coordination metadata and can recover from stale entries. +- The resolver still returns a valid `ResolvedAuthority` backed by a local tunnel to the remote VS Code server. +- Port forwarding remains functional for native-mode TCP remotes. +- Errors are visible in the existing Remote - SSH output channel and are actionable. +- The implementation compiles and bundles successfully. From 8bf2f15e41a5c78d49471e19209bf302d7aa89a1 Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Tue, 27 Oct 2026 10:34:02 -0500 Subject: [PATCH 02/26] feat: refactor connection to native ssh binary --- .vscodeignore | 3 +- package.json | 22 +- src/authResolver.ts | 216 ++++++++- src/common/disposable.ts | 21 +- src/common/functional.ts | 31 ++ src/common/logger.ts | 8 +- src/common/platform.ts | 5 + {test/utils => src/common}/sleep.ts | 0 src/fetchRelease.ts | 4 +- src/native/askpass/askpass-node.ts | 68 +++ src/native/askpass/askpass.bat | 16 + src/native/askpass/askpass.sh | 17 + src/native/nativeSshAskpass.ts | 148 ++++++ src/native/nativeSshConnection.ts | 40 ++ src/native/nativeSshManager.ts | 710 ++++++++++++++++++++++++++++ src/native/nativeSshParser.ts | 167 +++++++ src/native/nativeSshServer.ts | 209 ++++++++ src/native/nativeSshServerClient.ts | 101 ++++ src/scripts/server-setup.sh | 30 +- src/serverSetup.ts | 133 ++++-- src/ssh/nativeClient.ts | 6 +- src/ssh/sshConfig.ts | 2 +- src/types.ts | 4 - test/utils/wait-for-ssh-ready.ts | 2 +- webpack.config.js | 16 +- 25 files changed, 1894 insertions(+), 85 deletions(-) create mode 100644 src/common/functional.ts rename {test/utils => src/common}/sleep.ts (100%) create mode 100644 src/native/askpass/askpass-node.ts create mode 100755 src/native/askpass/askpass.bat create mode 100755 src/native/askpass/askpass.sh create mode 100644 src/native/nativeSshAskpass.ts create mode 100644 src/native/nativeSshConnection.ts create mode 100644 src/native/nativeSshManager.ts create mode 100644 src/native/nativeSshParser.ts create mode 100644 src/native/nativeSshServer.ts create mode 100644 src/native/nativeSshServerClient.ts delete mode 100644 src/types.ts diff --git a/.vscodeignore b/.vscodeignore index 3c8e9673..23e65fc9 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -5,6 +5,5 @@ !CHANGELOG.md !LICENSE.txt !README.md -!lib/extension.js +!lib/** !resources/icon.png -!src/scripts/** diff --git a/package.json b/package.json index 2503d06d..8c369ab6 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "open-remote-ssh", "displayName": "Open Remote - SSH", "description": "Use any remote machine with a SSH server as your development environment.", - "version": "0.5.1", + "version": "0.5.2", "publisher": "jeanp413", "repository": { "type": "git", @@ -106,12 +106,32 @@ "configuration": { "title": "Remote - SSH", "properties": { + "remote.SSH.path": { + "type": "string", + "description": "An absolute path to the SSH executable. When empty, it will use \"ssh\" on the path or in common install locations.", + "default": "", + "scope": "application" + }, "remote.SSH.configFile": { "type": "string", "description": "The absolute file path to a custom SSH config file.", "default": "", "scope": "application" }, + "remote.SSH.connectionBackend": { + "type": "string", + "description": "Select the backend used for SSH connections. The native backend is additive and intentionally left opt-in.", + "default": "ssh2", + "scope": "application", + "enum": [ + "ssh2", + "native" + ], + "enumDescriptions": [ + "Use the existing ssh2 js library backend.", + "Use the native OpenSSH binary." + ] + }, "remote.SSH.connectTimeout": { "type": "number", "description": "Specifies the timeout in seconds used for the SSH command that connects to the remote.", diff --git a/src/authResolver.ts b/src/authResolver.ts index 9bd64040..28b8c1c1 100644 --- a/src/authResolver.ts +++ b/src/authResolver.ts @@ -5,24 +5,28 @@ import * as cp from 'child_process'; import * as fs from 'fs'; import * as net from 'net'; import * as stream from 'stream'; -import { SocksClient, SocksClientOptions } from 'socks'; import * as vscode from 'vscode'; +import * as path from 'path'; import * as ssh2 from 'ssh2'; +import * as os from 'os'; +import * as crypto from 'crypto'; import { Log } from './common/logger'; +import { SocksClient, SocksClientOptions } from 'socks'; import SSHDestination from './ssh/sshDestination'; import SSHConnection, { SSHTunnelConfig } from './ssh/sshConnection'; import { loadKnownHosts, matchHostKey, appendHostKey, replaceHostKey, removeHostFromEntry, findConflictingEntries, keyFingerprint, keyTypeOf, KnownHosts, KnownHostsEntry, KnownHostsFilesConfig } from './ssh/knownHosts'; import NativeSSHConnection, { isGssapiAuthenticationRequested } from './ssh/nativeClient'; import type { SSHClient, SSHExecChannel } from './ssh/sshClient'; -import SSHConfiguration, { getSSHConfigPath, type HostConfiguration } from './ssh/sshConfig'; +import SSHConfiguration, { defaultSSHConfigPath, getSSHConfigPath, type HostConfiguration } from './ssh/sshConfig'; import { gatherIdentityFiles, SSHKey } from './ssh/identityFiles'; import { untildify, exists as fileExists } from './common/files'; import { findRandomPort } from './common/ports'; import { disposeAll } from './common/disposable'; import { installCodeServer, ServerInstallError, findServerInstallPath } from './serverSetup'; -import type { RemotePlatform } from './types'; -import { isWindows } from './common/platform'; -import * as os from 'os'; +import { isWindows, RemotePlatform } from './common/platform'; +import { resolveNativeSshExecutable } from './native/nativeSshConnection'; +import { NativeSshConnectionOptions, NativeSshManager, NativeSshRegistry } from './native/nativeSshManager'; +import { AskPassServer } from './native/nativeSshAskpass'; const PASSWORD_RETRY_COUNT = 3; const PASSPHRASE_RETRY_COUNT = 3; @@ -103,7 +107,7 @@ class TunnelInfo implements vscode.Disposable { * ssh-config versions. */ function splitProxyCommand(value: string | string[]): string[] { - if (Array.isArray(value)) {return value.slice();} + if (Array.isArray(value)) { return value.slice(); } const out: string[] = []; let cur = ''; let i = 0; @@ -132,7 +136,7 @@ function splitProxyCommand(value: string | string[]): string[] { hasToken = true; i += 1; } - if (hasToken) {out.push(cur);} + if (hasToken) { out.push(cur); } return out; } @@ -216,6 +220,10 @@ export class RemoteSSHResolver implements vscode.RemoteAuthorityResolver, vscode private tunnels: TunnelInfo[] = []; private labelFormatterDisposable: vscode.Disposable | undefined; + private nativeSshKeepalive: vscode.Disposable | undefined; + + private nativeManager: NativeSshManager | undefined; + private nativeAskpassServer: AskPassServer | undefined; constructor( readonly context: vscode.ExtensionContext, @@ -233,6 +241,9 @@ export class RemoteSSHResolver implements vscode.RemoteAuthorityResolver, vscode const destination = SSHDestination.parseEncoded(dest); + const remoteSSHconfig = vscode.workspace.getConfiguration('remote.SSH'); + const connectionBackend = remoteSSHconfig.get('connectionBackend', 'ssh2'); + return vscode.window.withProgress({ title: `Setting up SSH Host ${destination.hostname}`, location: vscode.ProgressLocation.Notification, @@ -241,12 +252,7 @@ export class RemoteSSHResolver implements vscode.RemoteAuthorityResolver, vscode const sshconfig = await SSHConfiguration.loadFromFS(); const sshHostConfig = sshconfig.getHostConfiguration(destination.hostname); - // The bundled ssh2 library cannot perform the SSH - // gssapi-with-mic method, which needs access to the platform's - // Kerberos credentials. When the host config asks for GSSAPI, - // delegate the connection to the local ssh client: it supports - // GSSAPI natively and applies the user's own ssh_config for - // everything else (ProxyJump, ProxyCommand, ForwardAgent, ...). + // Deprecated const useNativeClient = isGssapiAuthenticationRequested(sshHostConfig); try { @@ -254,11 +260,12 @@ export class RemoteSSHResolver implements vscode.RemoteAuthorityResolver, vscode } catch (e: unknown) { this.logger.error(`Error resolving authority`, e); - if(!useNativeClient) { + if(!useNativeClient || connectionBackend === 'native') { return await this.throwOrCommand(context, destination, e); } } + // Deprecated try { return await this.connect(destination, sshconfig, true); } catch (e: unknown) { @@ -303,11 +310,135 @@ export class RemoteSSHResolver implements vscode.RemoteAuthorityResolver, vscode const serverInstallPathMap = remoteSSHconfig.get>('serverInstallPath', {}); const verifyUnknownHosts = remoteSSHconfig.get('verifyUnknownHosts', 'ask')!; const verifyKnownHosts = remoteSSHconfig.get('verifyKnownHosts', 'reject')!; + const connectionBackend = remoteSSHconfig.get('connectionBackend', 'ssh2'); + const configuredSshPath = remoteSSHconfig.get('path', ''); const sshHostConfig = sshconfig.getHostConfiguration(destination.hostname); const sshHostName = sshHostConfig['HostName'] ? SSHConfiguration.interpolate(sshHostConfig['HostName'], { '%': '%', 'h': destination.hostname }) : destination.hostname; const sshUser = sshHostConfig['User'] || destination.user || os.userInfo().username || ''; // https://github.com/openssh/openssh-portable/blob/5ec5504f1d328d5bfa64280cd617c3efec4f78f3/sshconnect.c#L1561-L1562 const sshPort = sshHostConfig['Port'] ? parseInt(sshHostConfig['Port'], 10) : (destination.port || 22); + if (connectionBackend === 'native') { + try { + const sshExecutable = await resolveNativeSshExecutable(configuredSshPath); + + const socksPort = await findRandomPort(); + const sshArgs: string[] = ['-v', '-T', '-D', String(socksPort),'-o', `ConnectTimeout=${connectTimeout}`]; + const configFile = getSSHConfigPath(); + if (configFile !== defaultSSHConfigPath) { + sshArgs.push('-F', configFile); + } + if (destination.port) { + sshArgs.push('-p', String(destination.port)); + } + if (destination.user) { + sshArgs.push('-l', destination.user); + } + sshArgs.push(destination.hostname); + + const marker = crypto.randomBytes(12).toString('hex'); + const connection: NativeSshConnectionOptions = { + authorityKey: destination.toString(), + sshExecutable, + sshArgs, + label: marker, + remotePlatform: remotePlatformMap[destination.hostname], + socksPort + }; + + const registry = await NativeSshRegistry.forAuthority(this.context.globalStorageUri.fsPath, connection.authorityKey); + const askpassServer = await AskPassServer.create(); + this.nativeAskpassServer = askpassServer; + + const manager = new NativeSshManager({ + connection, + registry, + serverScriptPath: path.join( + this.context.extensionPath, + 'lib', + 'native', + 'nativeSshServer.js' + ), + askpassPath: path.join( + this.context.extensionPath, + 'lib', + 'native', + 'askpass' + (isWindows ? '.bat' : '.sh'), + ), + askpassNodeScriptPath: path.join( + this.context.extensionPath, + 'lib', + 'native', + 'askpass-node.js', + ), + askpassSocketPath: askpassServer.socketPath, + logger: this.logger, + }); + this.nativeManager = manager; + + const envVariables: Record = {}; + if (enableAgentForwarding) { + envVariables['SSH_AUTH_SOCK'] = null; + } + + const registryEntry = await manager.initialize({ + serverDownloadUrlTemplate, + serverVersion, + extensionIds: defaultExtensions, + envVariables: Object.keys(envVariables), + remotePlatform: remotePlatformMap[destination.hostname], + useSocketPath: remoteServerListenOnSocket, + customInstallPath: findServerInstallPath(destination.hostname, serverInstallPathMap), + extensionPath: this.context.extensionPath, + }); + + const tunnel = await this.openNativeTunnel(0, registryEntry.listeningOn, registryEntry.socksPort); + this.tunnels.push(tunnel); + + for (const key of Object.keys(envVariables)) { + const value = registryEntry[key]; + if (value !== undefined && typeof value === 'string') { + envVariables[key] = value; + } + } + + this.context.environmentVariableCollection.persistent = false; + for (const [key, value] of Object.entries(envVariables)) { + if (value) { + this.context.environmentVariableCollection.replace(key, value); + } + } + + vscode.commands.executeCommand('setContext', 'forwardedPortsViewEnabled', true); + + this.labelFormatterDisposable?.dispose(); + this.labelFormatterDisposable = vscode.workspace.registerResourceLabelFormatter({ + scheme: 'vscode-remote', + authority: `${REMOTE_SSH_AUTHORITY}+*`, + formatting: { + label: '${path}', + separator: '/', + tildify: true, + workspaceSuffix: `SSH: ${destination.hostname}` + + (destination.port && destination.port !== 22 ? `:${destination.port}` : ''), + }, + }); + + const resolvedResult: vscode.ResolverResult = new vscode.ResolvedAuthority( + '127.0.0.1', + tunnel.localPort, + registryEntry.connectionToken, + ); + resolvedResult.extensionHostEnv = envVariables; + return resolvedResult; + + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error('Native SSH bootstrap failed', message); + throw vscode.RemoteAuthorityResolverError.NotAvailable(`Native OpenSSH backend failed to initialize: ${message}`); + } + } + + // This connection mode will be depreacted in the future const valuesToInterpolate = { '%': '%', 'd': os.homedir(), @@ -539,6 +670,57 @@ export class RemoteSSHResolver implements vscode.RemoteAuthorityResolver, vscode return resolvedResult; } + private async openNativeTunnel( + localPort: number, + remotePortOrSocketPath: number | string, + socksPort: number | undefined + ): Promise { + localPort = localPort > 0 ? localPort : await findRandomPort(); + + const disposables: vscode.Disposable[] = []; + const remotePort = typeof remotePortOrSocketPath === 'number' ? remotePortOrSocketPath : undefined; + // const remoteSocketPath = typeof remotePortOrSocketPath === 'string' ? remotePortOrSocketPath : undefined; + if (socksPort&& remotePort) { + const forwardingServer = await new Promise((resolve, reject) => { + this.logger.trace(`Creating forwarding server ${localPort}(local) => ${socksPort}(socks) => ${remotePort}(remote)`); + const socksOptions: SocksClientOptions = { + proxy: { + host: '127.0.0.1', + port: socksPort, + type: 5 + }, + command: 'connect', + destination: { + host: '127.0.0.1', + port: remotePort + } + }; + const server: net.Server = net.createServer() + .on('error', reject) + .on('connection', async (socket: net.Socket) => { + try { + const socksConn = await SocksClient.createConnection(socksOptions); + socket.pipe(socksConn.socket); + socksConn.socket.pipe(socket); + } catch (error) { + this.logger.error(`Error while creating SOCKS connection`, error); + } + }) + .on('listening', () => resolve(server)) + .listen(localPort, '127.0.0.1'); + }); + disposables.push({ + dispose: () => forwardingServer.close(() => { + this.logger.trace(`SOCKS forwading server closed`); + }), + }); + } else { + throw new Error('Tunnel without socks port not supported yet'); + } + + return new TunnelInfo(localPort, remotePortOrSocketPath, disposables); + } + // @types/ssh2 predates the current runtime and types the key as a string; // without `hostHash` set, ssh2 actually passes the raw key blob and // supports an async verify callback. @@ -940,6 +1122,7 @@ export class RemoteSSHResolver implements vscode.RemoteAuthorityResolver, vscode } dispose() { + this.nativeSshKeepalive?.dispose(); disposeAll(this.tunnels); this.agentForwardSession?.close(); this.agentForwardSession = undefined; @@ -951,5 +1134,10 @@ export class RemoteSSHResolver implements vscode.RemoteAuthorityResolver, vscode } this.proxyCommandProcess?.kill(); this.labelFormatterDisposable?.dispose(); + + this.nativeManager?.dispose(); + this.nativeManager = undefined; + this.nativeAskpassServer?.dispose(); + this.nativeAskpassServer = undefined; } } diff --git a/src/common/disposable.ts b/src/common/disposable.ts index bad17edf..10672200 100644 --- a/src/common/disposable.ts +++ b/src/common/disposable.ts @@ -1,6 +1,19 @@ -import * as vscode from 'vscode'; +import { createSingleCallFunction } from './functional'; -export function disposeAll(disposables: vscode.Disposable[]): void { +export interface IDisposable { + dispose(): void; +} + +export function toDisposable(fn: () => void): IDisposable { + const self = { + dispose: createSingleCallFunction(() => { + fn(); + }), + }; + return self; +} + +export function disposeAll(disposables: IDisposable[]): void { while (disposables.length) { const item = disposables.pop(); if (item) { @@ -12,7 +25,7 @@ export function disposeAll(disposables: vscode.Disposable[]): void { export abstract class Disposable { private _isDisposed = false; - protected _disposables: vscode.Disposable[] = []; + protected _disposables: IDisposable[] = []; public dispose(): void { if (this._isDisposed) { @@ -22,7 +35,7 @@ export abstract class Disposable { disposeAll(this._disposables); } - protected _register(value: T): T { + protected _register(value: T): T { if (this._isDisposed) { value.dispose(); } else { diff --git a/src/common/functional.ts b/src/common/functional.ts new file mode 100644 index 00000000..51255864 --- /dev/null +++ b/src/common/functional.ts @@ -0,0 +1,31 @@ +/* eslint-disable @typescript-eslint/no-this-alias */ +/* eslint-disable prefer-rest-params */ +/* eslint-disable @typescript-eslint/no-unsafe-function-type */ + +/** + * Given a function, returns a function that is only calling that function once. + */ +export function createSingleCallFunction(this: unknown, fn: T, fnDidRunCallback?: () => void): T { + const _this = this; + let didCall = false; + let result: unknown; + + return function () { + if (didCall) { + return result; + } + + didCall = true; + if (fnDidRunCallback) { + try { + result = fn.apply(_this, arguments); + } finally { + fnDidRunCallback(); + } + } else { + result = fn.apply(_this, arguments); + } + + return result; + } as unknown as T; +} diff --git a/src/common/logger.ts b/src/common/logger.ts index ca79528a..8077c57d 100644 --- a/src/common/logger.ts +++ b/src/common/logger.ts @@ -4,6 +4,12 @@ import { toString } from '../utils/to-string'; type LogLevel = 'Trace' | 'Info' | 'Error'; +export interface ILogger { + trace(message: string, data?: unknown): void; + info(message: string, data?: unknown): void; + error(message: string, data?: unknown): void; +} + export class Log { private output: vscode.OutputChannel; @@ -24,7 +30,7 @@ export class Log { } public logLevel(level: LogLevel, message: string, data?: unknown): void { - this.output.appendLine(`[${level} - ${this.now()}] ${message}`); + this.output.appendLine(`[${level} - ${this.now()}] ${message}`); if (data) { this.output.appendLine(toString(data)); } diff --git a/src/common/platform.ts b/src/common/platform.ts index 1fe4e026..fd6faaf4 100644 --- a/src/common/platform.ts +++ b/src/common/platform.ts @@ -1,3 +1,8 @@ export const isWindows = process.platform === 'win32'; export const isMacintosh = process.platform === 'darwin'; export const isLinux = process.platform === 'linux'; + +/** + * Platform values accepted by the `remote.SSH.remotePlatform` setting. + */ +export type RemotePlatform = 'linux' | 'macos' | 'windows'; diff --git a/test/utils/sleep.ts b/src/common/sleep.ts similarity index 100% rename from test/utils/sleep.ts rename to src/common/sleep.ts diff --git a/src/fetchRelease.ts b/src/fetchRelease.ts index 4c86be09..1876f273 100644 --- a/src/fetchRelease.ts +++ b/src/fetchRelease.ts @@ -1,6 +1,6 @@ import type { ServerVersion } from './server-config/types'; -import { Log } from './common/logger'; +import { ILogger } from './common/logger'; import * as semver from 'semver'; type githubReleasesData = { @@ -34,7 +34,7 @@ export function splitRelease(release: string): IRelease { return {version: `${major}.${minor}.${patchFused}`, build: buildFused ?? ''}; } -export async function fetchRelease(serverDownloadUrlTemplate: string, version: string, build: string, objective: ServerVersion, logger: Log): Promise { +export async function fetchRelease(serverDownloadUrlTemplate: string, version: string, build: string, objective: ServerVersion, logger: ILogger): Promise { // Just match the given version/build if (objective === 'match') { return {version, build}; diff --git a/src/native/askpass/askpass-node.ts b/src/native/askpass/askpass-node.ts new file mode 100644 index 00000000..06297d75 --- /dev/null +++ b/src/native/askpass/askpass-node.ts @@ -0,0 +1,68 @@ +import * as fs from 'fs'; +import * as http from 'http'; + +const prompt = process.argv.slice(2).join(' ') || ''; +const askpassSocketPath = process.env.NATIVE_SSH_ASKPASS_SOCKET || ''; +const askpassOutputPath = process.env.NATIVE_SSH_ASKPASS_FILE || ''; + +function fail(reason: string): never { + console.error(reason); + process.exitCode = 1; + process.exit(); +} + +if (!askpassSocketPath) { fail('NATIVE_SSH_ASKPASS_SOCKET is not set'); } +if (!askpassOutputPath) { fail('NATIVE_SSH_ASKPASS_FILE is not set'); } + +const payload = JSON.stringify({ prompt }); +const request = http.request({ + socketPath: askpassSocketPath, + path: '/prompt', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + }, +}, (response) => { + let body = ''; + response.setEncoding('utf8'); + response.on('data', (chunk: Buffer | string) => { + body += chunk.toString(); + }); + response.on('end', () => { + if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) { + return fail(`helper returned HTTP ${response.statusCode ?? '?'}`); + } + + const contentType = response.headers['content-type']; + if (!contentType || !contentType.includes('application/json')) { + return fail(`expected JSON response, but received: ${contentType ?? 'none'}`); + } + + let answer: { response?: string; cancelled? :boolean }; + try { + answer = body ? JSON.parse(body) as { response?: string; cancelled? :boolean } : {}; + } catch (err) { + return fail(`invalid JSON from helper: ${(err as Error).message}`); + } + + if (answer.cancelled) { + process.exit(1); + } + + try { + fs.writeFileSync(askpassOutputPath, answer.response + '\n', { encoding: 'utf8', mode: 0o600 }); + } catch (err) { + return fail(`could not write answer file: ${(err as Error).message}`); + } + + setTimeout(() => process.exit(0), 0); + }); +}); + +request.on('error', (err) => { + fail(`socket error: ${err.message}`); +}); + +request.write(payload); +request.end(); diff --git a/src/native/askpass/askpass.bat b/src/native/askpass/askpass.bat new file mode 100755 index 00000000..05f71097 --- /dev/null +++ b/src/native/askpass/askpass.bat @@ -0,0 +1,16 @@ +@echo off +setlocal + +:createTempFile +set "NATIVE_SSH_ASKPASS_FILE=%TEMP%\native-ssh-askpass-%RANDOM%.tmp" +if exist "%NATIVE_SSH_ASKPASS_FILE%" goto createTempFile + +"%NATIVE_SSH_NODE_BIN%" "%NATIVE_SSH_ASKPASS_NODE_SCRIPT%" %* +set "ASKPASS_STATUS=%ERRORLEVEL%" + +if exist "%NATIVE_SSH_ASKPASS_FILE%" ( + if "%ASKPASS_STATUS%"=="0" type "%NATIVE_SSH_ASKPASS_FILE%" + del /f /q "%NATIVE_SSH_ASKPASS_FILE%" +) + +exit /b %ASKPASS_STATUS% diff --git a/src/native/askpass/askpass.sh b/src/native/askpass/askpass.sh new file mode 100755 index 00000000..8253da0d --- /dev/null +++ b/src/native/askpass/askpass.sh @@ -0,0 +1,17 @@ +#!/bin/sh +set -u + +OUTPUT_FILE="$(mktemp)" || exit 1 +trap 'rm -f "$OUTPUT_FILE"' EXIT HUP INT TERM + +export NATIVE_SSH_ASKPASS_FILE="$OUTPUT_FILE" +"${NATIVE_SSH_NODE_BIN:?NATIVE_SSH_NODE_BIN is required}" \ + "${NATIVE_SSH_ASKPASS_NODE_SCRIPT:?NATIVE_SSH_ASKPASS_NODE_SCRIPT is required}" \ + "$@" +status=$? + +if [ "$status" -eq 0 ]; then + cat "$OUTPUT_FILE" +fi + +exit "$status" diff --git a/src/native/nativeSshAskpass.ts b/src/native/nativeSshAskpass.ts new file mode 100644 index 00000000..db86f6a5 --- /dev/null +++ b/src/native/nativeSshAskpass.ts @@ -0,0 +1,148 @@ +import * as fs from 'fs/promises'; +import * as http from 'http'; +import { exists } from '../common/files'; +import { parsePrompt, PromptKind } from './nativeSshParser'; +import { getNativeSshSocketPath } from './nativeSshConnection'; +import * as vscode from 'vscode'; + +async function askEntry(prompt: string): Promise { + return vscode.window.showInputBox({ + title: 'SSH Authentication', + prompt, + password: true, + ignoreFocusOut: true, + }); +} + +async function askHostKey(prompt: string, kind: PromptKind): Promise { + const acceptsFingerprint = + kind === 'host-key-confirm-fp' || kind === 'host-key-retry-fp'; + + const choices = acceptsFingerprint + ? ['yes', 'no', 'Enter fingerprint…'] + : ['yes', 'no']; + + const choice = await vscode.window.showQuickPick(choices, { + title: 'SSH Host Key Verification', + placeHolder: prompt, + ignoreFocusOut: true, + }); + + if (choice === 'Enter fingerprint…') { + return vscode.window.showInputBox({ + title: 'SSH Host Key Fingerprint', + prompt: 'Paste the expected host key fingerprint', + ignoreFocusOut: true, + }); + } + + return choice; +} + +export class AskPassServer { + private server!: http.Server; + private _socketPath!: string; + private closed = false; + + private constructor() { } + + public get socketPath() : string { + return this._socketPath; + } + + private async initialize( + socketPath: string + ): Promise { + this._socketPath = socketPath; + const server = http.createServer((req, res) => { + req.setEncoding('utf8'); + if (req.method === 'POST' && req.url === '/prompt') { + this.handleNativeSshPromptRequest(req, res); + return; + } + + res.statusCode = 404; + res.end(); + }); + server.setTimeout(300_000); + + return new Promise((resolve, reject) => { + const handleError = (error: Error) => { + server.removeListener('listening', handleListening); + reject(error); + }; + const handleListening = async () => { + server.removeListener('error', handleError); + try { + if (process.platform !== 'win32') { + fs.chmod(socketPath, 0o600); + } + } catch (error: unknown) { + server.close(); + reject(error instanceof Error ? error : new Error(String(error))); + return; + } + resolve(); + }; + + server.once('error', handleError); + server.once('listening', handleListening); + server.listen(socketPath); + }); + } + + private async handleNativeSshPromptRequest(req: http.IncomingMessage, res: http.ServerResponse) { + await new Promise((resolve, reject) => { + let body = ''; + req.on('data', (chunk: Buffer | string) => { + body += chunk.toString(); + }); + req.on('end', async () => { + try { + const parsed = JSON.parse(body) as { prompt: string }; + if (typeof parsed.prompt !== 'string') { + throw new Error('Malformed askpass request'); + } + + const parsedPrompt = parsePrompt(parsed.prompt); + + const response = parsedPrompt.kind === 'entry' + ? await askEntry(parsedPrompt.message) + : await askHostKey(parsedPrompt.message, parsedPrompt.kind); + + const cancelled = response === undefined; + + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + response, + cancelled + })); + resolve(); + } catch (err) { + res.statusCode = 500; + res.end(); + reject(err); + } + }); + }); + } + + static async create(): Promise { + const socketPath = getNativeSshSocketPath('askpass'); + const askpassServer = new AskPassServer(); + await askpassServer.initialize(socketPath); + return askpassServer; + } + + async dispose() { + if (this.closed) { + return; + } + this.closed = true; + this.server.close(); + if (process.platform !== 'win32' && await exists(this.socketPath)) { + await fs.unlink(this.socketPath); + } + } +} diff --git a/src/native/nativeSshConnection.ts b/src/native/nativeSshConnection.ts new file mode 100644 index 00000000..118cb3ea --- /dev/null +++ b/src/native/nativeSshConnection.ts @@ -0,0 +1,40 @@ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as crypto from 'crypto'; +import * as path from 'path'; +import { exists } from '../common/files'; + +export async function resolveNativeSshExecutable(explicitPath?: string): Promise { + if (explicitPath && explicitPath.trim().length > 0) { + if (await exists(explicitPath)) { + return explicitPath; + } + throw new Error(`Native SSH executable was not found at: ${explicitPath}`); + } + + const candidates = (process.env.PATH ?? '') + .split(path.delimiter) + .filter(Boolean) + .map((directory) => path.join(directory, process.platform === 'win32' ? 'ssh.exe' : 'ssh')); + + for (const candidate of candidates) { + try { + await fs.access(candidate, fs.constants.X_OK); + return candidate; + } catch { + // keep trying other PATH entries + } + } + + throw new Error('Native SSH executable not found in PATH. Install OpenSSH or set the ssh path explicitly.'); +} + +export function getNativeSshSocketPath(label: string): string { + const randomStr = crypto.randomBytes(15).toString('hex'); + if (process.platform === 'win32') { + return `\\\\.\\pipe\\open-remote-ssh-${label}}-${randomStr}`; + } + return process.env.XDG_RUNTIME_DIR + ? path.join(process.env.XDG_RUNTIME_DIR, `open-remote-ssh-${label}-${randomStr}.sock`) + : path.join(os.tmpdir(), `open-remote-ssh-${label}-${randomStr}.sock`); +} diff --git a/src/native/nativeSshManager.ts b/src/native/nativeSshManager.ts new file mode 100644 index 00000000..06df06ad --- /dev/null +++ b/src/native/nativeSshManager.ts @@ -0,0 +1,710 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as cp from 'child_process'; +import * as stream from 'stream'; +import * as vscode from 'vscode'; +import { exists } from '../common/files'; +import { getProductJson } from '../server-config/get-product-json'; +import { sleep } from '../common/sleep'; +import { ILogger } from '../common/logger'; +import { AUTH_CONFIRMED_RE, CommandRunner, LineParser } from './nativeSshParser'; +import { RemotePlatform } from '../common/platform'; +import { NativeSshServerClient, NativeSshKeepalive } from './nativeSshServerClient'; +import { Architecture, buildServerInstallOptions, generateBashInstallScript, generatePowerShellInstallScript, parseKernelName, parseMachineArch, parseServerInstallOutput, Platform, ServerInstallError, ServerInstallResult, Shell } from '../serverSetup'; +import { ServerVersion } from '../server-config/types'; +import { getNativeSshSocketPath } from './nativeSshConnection'; + +export interface NativeSshConnectionOptions { + authorityKey: string; + sshExecutable: string; + sshArgs: string[]; + label: string; + remotePlatform: RemotePlatform | undefined; + socksPort: number; +} + +export type NativeSshRegistryEntry = { + schemaVersion: number; + socketPath: string; + socksPort: number; + pid: number; +} & ServerInstallResult; + +export type NativeSshServerConfig = { + sshExecutable: string; + sshArgs: string[]; + socketPath: string; + registryFilePath: string; +}; + +export type NativeSshServerInfo = { + socketPath: string; + pid: number; + reused: boolean; +}; + +export interface NativeSshServerProcess { + child: cp.ChildProcess; + stdin: stream.Writable; + wait(): Promise; + terminate(): Promise; +} + +export interface NativeSshManagerOptions { + connection: NativeSshConnectionOptions; + registry: NativeSshRegistry; + serverScriptPath: string; + askpassPath: string; + askpassNodeScriptPath: string; + askpassSocketPath: string; + logger: ILogger; +} + +interface NativeDetectedPlatform { + detectedPlatform: Platform; + detectedArch: Architecture; + detectedShell: Shell; +} + +export interface NativeInstallInputs { + serverDownloadUrlTemplate: string | undefined; + serverVersion: ServerVersion; + extensionIds: string[]; + envVariables: string[]; + useSocketPath: boolean; + customInstallPath: string | undefined; + extensionPath: string; + remotePlatform: RemotePlatform | undefined; +} + +const SCHEMA_VERSION = 1; +const SERVER_READY_TIMEOUT_MS = 10_000; +const SERVER_READY_POLL_MS = 100; + +export class NativeSshRegistry { + private constructor(private readonly registryFilePath: string) { } + + static async forAuthority( + globalStorageDir: string, + authorityKey: string, + ): Promise { + const productJson = await getProductJson(); + return new NativeSshRegistry( + path.join( + globalStorageDir, + 'native-ssh', + `ssh-host-${authorityKey}-${productJson.commit as string}-${vscode.version.replace('-insider','')}`, + 'data.json' + ) + ); + } + + get filePath(): string { + return this.registryFilePath; + } + + async read(): Promise { + try { + const raw = await fs.readFile(this.registryFilePath, 'utf8'); + const parsed = JSON.parse(raw) as NativeSshRegistryEntry; + return isValidRegistryEntry(parsed) ? parsed : undefined; + } catch { + return undefined; + } + } + + async write(entry: NativeSshRegistryEntry): Promise { + await fs.mkdir(path.dirname(this.registryFilePath), { recursive: true, mode: 0o700 }); + const tmp = `${this.registryFilePath}.tmp-${process.pid}-${Date.now()}`; + await fs.writeFile(tmp, JSON.stringify(entry, null, 2), { mode: 0o600 }); + await fs.rename(tmp, this.registryFilePath); + } + + async removeIfOwnedBy(pid: number): Promise { + const entry = await this.read(); + if (!entry || entry.pid !== pid) { + return; + } + try { + await fs.unlink(this.registryFilePath); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + } + } +} + +function isValidRegistryEntry(value: unknown): value is NativeSshRegistryEntry { + if (!value || typeof value !== 'object') { + return false; + } + const v = value as Record; + return ( + typeof v.socketPath === 'string' && v.socketPath.length > 0 && + typeof v.pid === 'number' && Number.isInteger(v.pid) && v.pid > 0 && + typeof v.connectionToken === 'string' && v.connectionToken.length > 0 && + ( + typeof v.listeningOn === 'string' && v.listeningOn.length > 0 || + typeof v.listeningOn === 'number' && Number.isInteger(v.listeningOn) && v.listeningOn > 0 + ) + ); +} + +export class NativeSshManager { + private serverProcess: NativeSshServerProcess | undefined; + private serverInfo: NativeSshServerInfo | undefined; + private stdoutParser: LineParser | undefined; + private stderrParser: LineParser | undefined; + private cmdRunner: CommandRunner | undefined; + private keepalive: NativeSshKeepalive | undefined; + private closed = false; + + constructor(private readonly options: NativeSshManagerOptions) { } + + private get connection(): NativeSshConnectionOptions { return this.options.connection; } + private get registry(): NativeSshRegistry { return this.options.registry; } + + async initialize(installScriptInputs: NativeInstallInputs): Promise { + let registryEntry = await this.acquire(); + if (!registryEntry) { + await this.waitAuthSucceeded(); + await this.waitRemoteShellReady(); + + const platform = await this.probeRemotePlatform(); + this.options.logger.info(`Detected remote platform: ${platform}`); + + const result = await this.nativeInstallCodeServer(installScriptInputs); + + registryEntry = { + schemaVersion: SCHEMA_VERSION, + socketPath: this.serverInfo!.socketPath, + pid: this.serverInfo!.pid, + socksPort: this.connection.socksPort, + ...result + }; + await this.registry.write(registryEntry); + } + return registryEntry; + } + + private async acquire(): Promise { + const existing = await this.registry.read(); + if (existing) { + const client = new NativeSshServerClient(existing.socketPath); + if (await client.isHealthy()) { + this.startKeepalive(client); + this.options.logger.info(`Adopting local server pid=${existing.pid} socket=${existing.socketPath}`); + this.serverInfo = { socketPath: existing.socketPath, pid: existing.pid, reused: true }; + return existing; + } + this.options.logger.info(`Registry entry for pid=${existing.pid} is stale; removing`); + await this.registry.removeIfOwnedBy(existing.pid); + } + + const info = await this.spawnFresh(); + this.startKeepalive(new NativeSshServerClient(info.socketPath)); + this.serverInfo = info; + + return undefined; + } + + private startKeepalive(client: NativeSshServerClient): void { + if (this.keepalive) { + return; + } + this.keepalive = new NativeSshKeepalive(client, this.options.logger); + this.keepalive.start(); + } + + private waitAuthSucceeded() { + if (!this.stdoutParser || !this.stderrParser) { + throw new Error('No local server output parser attached'); + } + + return new Promise((resolve, reject) => { + let settled = false; + const finish = (err: Error | undefined): void => { + if (settled) { + return; + } + settled = true; + stdoutSubscription.dispose(); + stderrSubscription.dispose(); + if (err) { + reject(err); + } else { + resolve(); + } + }; + + const predicate = (line: string) => { + return AUTH_CONFIRMED_RE.test(line); + }; + + const stdoutSubscription = this.stdoutParser!.subscribe({ + onLine: (line) => { + try { + if (predicate(line)) { + finish(undefined); + } + } catch (e) { + finish(e); + } + }, + onError: (reason) => finish(new Error(reason)), + }); + const stderrSubscription = this.stderrParser!.subscribe({ + onLine: (line) => { + try { + if (predicate(line)) { + finish(undefined); + } + } catch (e) { + finish(e); + } + }, + onError: (reason) => finish(new Error(reason)), + }); + }); + } + + async runCommand( + command: string, + predicate: (stdout: string | undefined, stderr: string | undefined) => T | undefined, + ): Promise { + if (!this.cmdRunner) { + throw new Error('No helper process owned by this window.'); + } + return this.cmdRunner.run(command, predicate); + } + + private async nativeDetectPlatform( + platform: RemotePlatform | undefined, + ): Promise { + let detectedPlatform: Platform; + let detectedShell: Shell = 'bash'; + + if (!platform || platform === 'windows') { + const stdout = await this.runCommand('echo %COMSPEC%~$PSHOME', (stdout) =>{ + return stdout; + }); + const [comspec, pshome = ''] = stdout.replace(/\r?\n/g, ' ').trim().split('~'); + + if (comspec && comspec !== '%COMSPEC%') { + detectedPlatform = 'windows'; + detectedShell = 'cmd'; + } else if (pshome) { + detectedShell = 'powershell'; + detectedPlatform ||= /^[A-Za-z]:\\/.test(pshome) ? 'windows' : 'linux'; + } else if (!platform) { + const result = await this.runCommand('echo "==$(uname -s)=="', (stdout, stderr) => { + if (stderr) { + return { stderr }; + } + if (stdout) { + return { stdout }; + } + return undefined; + }); + if (result.stderr) { + throw new ServerInstallError(`Cannot execute "uname -s", yields: ${result.stderr}`); + } + const uname = /==\s*(.+?)\s*==/.exec(result.stdout!); + if (!uname) { + throw new ServerInstallError(`Platform not found: ${result.stdout}`); + } + detectedPlatform = parseKernelName(uname[1].trim()); + } else { + detectedPlatform = 'windows'; + } + } else if (platform === 'macos') { + detectedPlatform = 'darwin'; + } else { + detectedPlatform = 'linux'; + } + + this.options.logger.trace(`Detected platform: ${detectedPlatform}, ${detectedShell}`); + + let detectedArch: Architecture; + if (detectedPlatform === 'windows') { + detectedArch = 'x64'; + } else { + const result = await this.runCommand('echo "==$(uname -m)=="', (stdout, stderr) => { + if (stderr) { + return { stderr }; + } + if (stdout) { + return { stdout }; + } + return undefined; + }); + if (result.stderr) { + throw new ServerInstallError(`Cannot execute "uname -m", yields: ${result.stderr}`); + } + + const uname = /==\s*(.+?)\s*==/.exec(result.stdout!); + if (!uname) { + throw new ServerInstallError(`Architecture not found: ${result.stdout}`); + } + detectedArch = parseMachineArch(uname[1].trim()); + } + + this.options.logger.trace(`Detected architecture: ${detectedArch}`); + return { detectedPlatform, detectedArch, detectedShell }; + } + + private async waitRemoteShellReady() { + const readyMarker = `==${this.connection.label}==`; + await this.runCommand(`echo '${readyMarker}'`, (stdout: string | undefined) =>{ + if (stdout?.includes(readyMarker)) { + return true; + } + return undefined; + }); + } + + private async probeRemotePlatform(): Promise { + return this.runCommand('uname -rsv', (stdout: string | undefined, stderr: string | undefined) =>{ + if (stdout) { + const lower = stdout.toLowerCase(); + if (lower.includes('darwin') || lower.includes('macos') || lower.includes('mac os x')) { + return 'macos'; + } + if (lower.includes('linux')) { + return 'linux'; + } + if (lower.includes('cygwin') || lower.includes('mingw') || lower.includes('windows')) { + return 'windows'; + } + } else if (stderr) { + const lower = stderr.toLowerCase(); + if (lower.includes(`'uname'`)) { + // No 'uname' command found + return 'windows'; + } + } + return undefined; + }); + } + + private async nativeInstallCodeServer( + inputs: NativeInstallInputs, + ): Promise { + const { + serverDownloadUrlTemplate, + serverVersion, + extensionIds, + envVariables, + useSocketPath, + customInstallPath, + extensionPath, + } = inputs; + + const { detectedPlatform, detectedArch } = await this.nativeDetectPlatform(inputs.remotePlatform); + + const { options: installOptions } = await buildServerInstallOptions( + this.options.connection.label, + serverDownloadUrlTemplate, + serverVersion, + extensionIds, + envVariables, + {}, + useSocketPath, + customInstallPath, + detectedPlatform, + detectedArch, + true, + this.options.logger, + ); + + let commandOutput: { stdout: string; stderr: string }; + if (detectedPlatform === 'windows') { + commandOutput = await this.runNativeWindowsInstall( + generatePowerShellInstallScript(installOptions, extensionPath), + ); + } else { + await this.waitBashReady(); + + let stdoutBuffer = ''; + let stderrBuffer = ''; + commandOutput = await this.runCommand( + `scriptWrapper() {\n ${generateBashInstallScript(installOptions, extensionPath)}\n }\n scriptWrapper`, + (stdout, stderr) => { + if (stdout !== undefined) { + stdoutBuffer += stdout + '\n'; + } + if (stderr !== undefined) { + stderrBuffer += stderr + '\n'; + } + if (stdout?.includes(`${this.options.connection.label}: end`)) { + return { stdout: stdoutBuffer, stderr: stderrBuffer }; + } + return undefined; + } + ); + } + + this.options.logger.trace('Server install stdout:', commandOutput.stdout); + if (commandOutput.stderr) { + this.options.logger.trace('Server install stderr:', commandOutput.stderr); + } + + const resultMap = parseServerInstallOutput(commandOutput.stdout, installOptions.id); + if (!resultMap) { + throw new ServerInstallError('Failed parsing install script output'); + } + const exitCode = parseInt(resultMap.exitCode, 10); + if (exitCode !== 0) { + throw new ServerInstallError(`Couldn't install vscode server on remote server, install script returned non-zero exit status`); + } + + const listeningOn = resultMap.listeningOn.match(/^\d+$/) + ? parseInt(resultMap.listeningOn, 10) + : resultMap.listeningOn; + + return { + exitCode, + listeningOn, + connectionToken: resultMap.connectionToken, + logFile: resultMap.logFile, + osReleaseId: resultMap.osReleaseId, + arch: resultMap.arch || installOptions.serverArch, + platform: resultMap.platform, + tmpDir: resultMap.tmpDir, + }; + } + + private async waitBashReady(): Promise { + const readyMarker = `==${this.connection.label}==`; + await this.runCommand(`bash -c 'echo ${readyMarker}; exec bash'`, (stdout: string | undefined) =>{ + if (stdout?.includes(readyMarker)) { + return true; + } + return undefined; + }); + } + + private async runNativeWindowsInstall( + script: string, + ): Promise<{ stdout: string; stderr: string }> { + const remoteTemp = `$env:TEMP\\native-ssh-install-${this.options.connection.label}.ps1`; + const encoded = Buffer.from(script, 'utf16le').toString('base64'); + + const writeResult = await this.runCommand( + `[IO.File]::WriteAllBytes(${remoteTemp}, [Convert]::FromBase64String('${encoded}'))`, + (stdout, stderr) => { + if (stderr !== undefined) { + return { stderr }; + } + if (stdout !== undefined) { + return { stdout }; + } + return undefined; + } + ); + if (writeResult.stderr && writeResult.stderr.length > 0) { + this.options.logger.error('Failed writing remote install script:', writeResult.stderr); + throw new ServerInstallError('Failed writing remote install script'); + } + + let stdoutBuffer = ''; + let stderrBuffer = ''; + const runResult = await this.runCommand( + `powershell -ExecutionPolicy Bypass -File "${remoteTemp}"; Remove-Item -Force "${remoteTemp}"`, + (stdout, stderr) => { + if (stdout !== undefined) { + stdoutBuffer += stdout + '\n'; + } + if (stderr !== undefined) { + stderrBuffer += stderr + '\n'; + } + if (stdout?.includes(`${this.options.connection.label}: end`)) { + return { stdout: stdoutBuffer, stderr: stderrBuffer }; + } + return undefined; + } + ); + return runResult; + } + + private async spawnFresh(): Promise { + const serverScriptPath = this.options.serverScriptPath; + if (!await exists(serverScriptPath)) { + throw new Error(`Native SSH local server entrypoint was not found: ${serverScriptPath}`); + } + + await this.unlinkStaleSocket(); + + const serverConfig: NativeSshServerConfig = { + sshExecutable: this.connection.sshExecutable, + sshArgs: this.connection.sshArgs, + socketPath: getNativeSshSocketPath('localSshServer'), + registryFilePath: this.registry.filePath + }; + + const child = cp.spawn(process.execPath, [serverScriptPath, JSON.stringify(serverConfig)], { + detached: true, + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...process.env, + DISPLAY: process.env.DISPLAY || '1', + ELECTRON_RUN_AS_NODE: '1', + SSH_ASKPASS: this.options.askpassPath, + NATIVE_SSH_NODE_BIN: process.execPath, + NATIVE_SSH_ASKPASS_NODE_SCRIPT: this.options.askpassNodeScriptPath, + NATIVE_SSH_ASKPASS_SOCKET: this.options.askpassSocketPath, + }, + }); + + child.stdout?.on('error', (err) => this.options.logger.error(`ssh server stdout error: ${err.message}`)); + child.stderr?.on('error', (err) => this.options.logger.error(`ssh server stderr error: ${err.message}`)); + child.stdin?.on('error', (err) => this.options.logger.error(`ssh server stdin error: ${err.message}`)); + + let spawnError: Error | undefined; + child.once('error', (error) => { + spawnError = error; + this.options.logger.error(`ssh server spawn failed: ${error.message}`); + }); + + const pid = child.pid; + if (!pid) { + throw new Error('Native SSH local server failed to spawn: no pid assigned.'); + } + this.options.logger.info(`Spawned ssh server pid=${pid} socket=${serverConfig.socketPath}`); + + await this.waitUntilSshServerReady(serverConfig.socketPath, () => spawnError); + + const proc: NativeSshServerProcess = { + child, + stdin: child.stdin!, + wait: () => new Promise((resolve) => { + if (child.exitCode !== null) { + return resolve(child.exitCode); + } + child.once('close', (code) => resolve(code)); + }), + terminate: async () => { + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + child.stdin?.end(); + child.kill('SIGTERM'); + const timer = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL'); + } + }, 2_000); + timer.unref(); + await new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + return resolve(); + } + child.once('close', () => resolve()); + }); + clearTimeout(timer); + }, + }; + + this.serverProcess = proc; + this.attachParsers(proc, pid); + + return { + pid, + socketPath: serverConfig.socketPath, + reused: false + }; + } + + private attachParsers(proc: NativeSshServerProcess, pid: number): void { + const stdout = proc.child.stdout!; + const stderr = proc.child.stderr!; + + stdout.setEncoding('utf8'); + stderr.setEncoding('utf8'); + + this.stdoutParser = new LineParser({ onLine: (line) => this.options.logger.info(`[local-server] ${line}`) }); + this.stderrParser = new LineParser({ onLine: (line) => this.options.logger.error(`[local-server] ${line}`) }); + + this.cmdRunner = new CommandRunner( + this.stdoutParser, + this.stderrParser, + (data) => new Promise((resolve, reject) => { + this.serverProcess!.stdin.write(data + '\n', (err) => err ? reject(err) : resolve()); + }), + ); + + stdout.on('data', (chunk: string) => this.stdoutParser!.push(chunk)); + stderr.on('data', (chunk: string) => this.stderrParser!.push(chunk)); + + proc.child.once('close', (code, signal) => { + const reason = code !== null + ? `ssh server closed (code=${code})` + : `ssh server killed (signal=${signal ?? 'unknown'})`; + this.stdoutParser?.end(reason); + this.stderrParser?.end(reason); + this.stdoutParser = undefined; + this.stderrParser = undefined; + this.cmdRunner = undefined; + + if (code !== null) { + this.options.logger.info(`ssh server pid=${pid} exited code=${code}`); + } else { + this.options.logger.info(`ssh server pid=${pid} killed by signal=${signal ?? 'unknown'}`); + } + }); + } + + private async waitUntilSshServerReady( + socketPath: string, + checkSpawnError: () => Error | undefined, + ): Promise { + const client = new NativeSshServerClient(socketPath); + const deadline = Date.now() + SERVER_READY_TIMEOUT_MS; + + while (Date.now() < deadline) { + const err = checkSpawnError(); + if (err) { + throw err; + } + + if (await client.isHealthy()) { + return; + } + + await sleep(SERVER_READY_POLL_MS); + } + + throw new Error( + `Native SSH local server did not become ready within ${SERVER_READY_TIMEOUT_MS}ms` + ); + } + + private async unlinkStaleSocket(): Promise { + if (process.platform === 'win32') { + return; + } + try { + if (this.serverInfo) { + await fs.unlink(this.serverInfo.socketPath); + } + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + } + } + + async dispose(): Promise { + if (this.closed) { + return; + } + this.closed = true; + this.keepalive?.dispose(); + this.keepalive = undefined; + if (this.serverProcess) { + await this.serverProcess.terminate(); + this.serverProcess = undefined; + } + } +} diff --git a/src/native/nativeSshParser.ts b/src/native/nativeSshParser.ts new file mode 100644 index 00000000..f8449239 --- /dev/null +++ b/src/native/nativeSshParser.ts @@ -0,0 +1,167 @@ +import { IDisposable, toDisposable } from '../common/disposable'; + +export type PromptKind = + | 'host-key-confirm' // "Are you sure ... (yes/no)?" + | 'host-key-confirm-fp' // "Are you sure ... (yes/no/[fingerprint])?" + | 'host-key-retry' // "Please type 'yes' or 'no':" + | 'host-key-retry-fp' // "Please type 'yes', 'no' or the fingerprint:" + | 'entry'; // password, passphrase, KBI, OTP, ... + +export interface ParsedPrompt { + kind: PromptKind; + /** Prompt text exactly as ssh emitted it (shown verbatim to the user). */ + message: string; +} + +function parseHostKeyPrompt(prompt: string): PromptKind | null { + // Initial prompt with fingerprint option (OpenSSH 7.6+) + if (/^Are you sure you want to continue connecting \(yes\/no\/\[fingerprint\]\)\?/i.test(prompt)) { + return 'host-key-confirm-fp'; + } + // Initial prompt (all versions) + if (/^Are you sure you want to continue connecting \(yes\/no\)\?/i.test(prompt)) { + return 'host-key-confirm'; + } + // Re-prompt accepting a fingerprint + if (/^Please type 'yes', 'no' or the fingerprint:/i.test(prompt)) { + return 'host-key-retry-fp'; + } + // Re-prompt, generic + if (/^Please type 'yes' or 'no':/i.test(prompt)) { + return 'host-key-retry'; + } + return null; +} + +export const AUTH_CONFIRMED_RE = /(?:debug\d+:\s*)?(?:Authentication succeeded|Authenticated to .* using ")/i; + +export function parsePrompt(prompt: string): ParsedPrompt { + const kind = parseHostKeyPrompt(prompt); + if (kind) { + return { kind, message: prompt }; + } + return { kind: 'entry', message: prompt }; +} + +export interface LineParserHandlers { + onLine?(line: string): void; + onFlush?(partial: string): void; + onError?(reason: string): void; +} + +export class LineParser { + private buffer = ''; + private readonly handlers = new Set(); + private closed = false; + + constructor(handlers?: LineParserHandlers) { + if (handlers) { + this.handlers.add(handlers); + } + } + + subscribe(handlers: LineParserHandlers): IDisposable { + this.handlers.add(handlers); + return toDisposable(() => this.handlers.delete(handlers)); + } + + push(chunk: string): void { + if (this.closed) { + return; + } + this.buffer += chunk; + let idx: number; + while ((idx = this.buffer.indexOf('\n')) !== -1) { + const raw = this.buffer.slice(0, idx); + this.buffer = this.buffer.slice(idx + 1); + const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw; + for (const h of this.handlers) { + h.onLine?.(line); + } + } + } + + end(reason = 'stream ended'): void { + if (this.closed) { + return; + } + this.closed = true; + if (this.buffer.length > 0) { + const line = this.buffer; + this.buffer = ''; + for (const h of this.handlers) { + h.onFlush?.(line); + } + } + for (const h of this.handlers) { + h.onError?.(reason); + } + } +} + +export class CommandRunner { + constructor( + private readonly stdoutParser: LineParser, + private readonly stderrParser: LineParser, + private readonly write: (data: string) => Promise, + ) { } + + async run( + command: string, + predicate: (stdout: string | undefined, stderr: string | undefined) => T | undefined, + timeoutMs = 10_000, + ): Promise { + return new Promise((resolve, reject) => { + let settled = false; + + const finish = (err: Error | undefined, value?: T): void => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + stdoutSubscription.dispose(); + stderrSubscription.dispose(); + if (err) { + reject(err); + } else { + resolve(value!); + } + }; + + const timer = setTimeout(() => { + finish(new Error(`command timed out after ${timeoutMs}ms: ${command}`)); + }, timeoutMs); + timer.unref(); + + const stdoutSubscription = this.stdoutParser.subscribe({ + onLine: (line) => { + try { + const value = predicate(line, undefined); + if (value !== undefined) { + finish(undefined, value); + } + } catch (e) { + finish(e); + } + }, + onError: (reason) => finish(new Error(reason)), + }); + const stderrSubscription = this.stderrParser.subscribe({ + onLine: (line) => { + try { + const value = predicate(line, undefined); + if (value !== undefined) { + finish(undefined, value); + } + } catch (e) { + finish(e); + } + }, + onError: (reason) => finish(new Error(reason)), + }); + + this.write(command).catch((err) => finish(err)); + }); + } +} diff --git a/src/native/nativeSshServer.ts b/src/native/nativeSshServer.ts new file mode 100644 index 00000000..11344fe3 --- /dev/null +++ b/src/native/nativeSshServer.ts @@ -0,0 +1,209 @@ +import * as http from 'http'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as cp from 'child_process'; +import * as stream from 'stream'; + +import { type NativeSshServerConfig, NativeSshRegistryEntry } from './nativeSshManager'; + +const HELPER_IDLE_TIMEOUT_MS = 60_000; + +function readNativeSshRegistry(registryFilePath: string) { + const registryPath = registryFilePath; + if (!fs.existsSync(registryPath)) { + return undefined; + } + + try { + const raw = fs.readFileSync(registryPath, 'utf8'); + return JSON.parse(raw) as NativeSshRegistryEntry; + } catch { + return undefined; + } +} + +function removeNativeSshHelperRegistry(registryFilePath: string, pid: number) { + const entry = readNativeSshRegistry(registryFilePath); + if (!entry || entry.pid !== pid) { + return; + } + fs.unlinkSync(registryFilePath); +} + +class NativeSshHelperServer { + private readonly config: NativeSshServerConfig; + private readonly server: http.Server; + private sshProcess: NativeSshProcess | undefined; + private idleTimer: NodeJS.Timeout | undefined; + private closed = false; + + constructor(config: NativeSshServerConfig) { + this.config = config; + this.server = http.createServer((req, res) => this.handleRequest(req, res)); + } + + private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { + if (req.method === 'GET' && req.url === '/health') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ ok: true, pid: process.pid })); + return; + } + + if (req.method === 'POST' && req.url === '/keepalive') { + this.resetIdleTimer(); + res.statusCode = 200; + res.end(JSON.stringify({ ok: true })); + return; + } + + res.statusCode = 404; + res.end(JSON.stringify({ error: 'Not found' })); + } + + private async launchSshProcess(): Promise { + if (!this.config.sshExecutable || !this.config.sshArgs) { + throw new Error('Native SSH helper requires a command and argument array.'); + } + + const processHandle = await spawnNativeSshProcess( + this.config.sshExecutable, + this.config.sshArgs, + ); + const child = processHandle.child; + this.sshProcess = processHandle; + + console.log(`[ssh-process] Executed ${this.config.sshExecutable} ${this.config.sshArgs.join(' ')}`); + console.log(`[ssh-process] Ssh process pid=${child.pid}`); + + child.stdout.on('data', (chunk: Buffer | string) => process.stdout.write(chunk)); + child.stderr.on('data', (chunk: Buffer | string) => process.stderr.write(chunk)); + child.once('error', () => this.close()); + child.once('close', (code) => { + if (code && code !== 0) { + process.exitCode = code; + } + this.close(); + }); + } + + async start(): Promise { + return await new Promise((resolve, reject) => { + const handleError = (error: Error) => { + this.server.removeListener('listening', handleListening); + reject(error); + }; + const handleListening = () => { + (async () => { + try { + if (this.config.sshExecutable) { + await this.launchSshProcess(); + } + this.resetIdleTimer(); + resolve(); + } catch (error: unknown) { + this.close(); + reject(error instanceof Error ? error : new Error(String(error))); + } + })(); + }; + + this.server.once('error', handleError); + this.server.once('listening', handleListening); + this.server.listen(this.config.socketPath); + }); + } + + private resetIdleTimer(): void { + if (this.idleTimer) { + clearTimeout(this.idleTimer); + } + this.idleTimer = setTimeout(() => this.close(), HELPER_IDLE_TIMEOUT_MS); + this.idleTimer.unref(); + } + + public close(): void { + if (this.closed) { + return; + } + this.closed = true; + if (this.idleTimer) { + clearTimeout(this.idleTimer); + this.idleTimer = undefined; + } + this.sshProcess?.close(); + this.sshProcess = undefined; + this.server.close(); + if (process.platform !== 'win32' && fs.existsSync(this.config.socketPath)) { + fs.unlinkSync(this.config.socketPath); + } + removeNativeSshHelperRegistry(this.config.registryFilePath, process.pid); + } +} + +function startHelperFromArguments(): void { + try { + const serializedConfig = process.argv[2]; + if (!serializedConfig) { + throw new Error('Native SSH helper startup requires serialized configuration.'); + } + const config = JSON.parse(serializedConfig) as NativeSshServerConfig; + if ( + typeof config.sshExecutable !== 'string' || + !Array.isArray(config.sshArgs) || + !config.sshArgs.every((arg) => typeof arg === 'string') || + typeof config.registryFilePath !== 'string' || + typeof config.socketPath !== 'string' + ) { + throw new Error('Native SSH helper configuration must include a command, string argument array, and registry file path.'); + } + + const server = new NativeSshHelperServer(config); + process.once('exit', () => removeNativeSshHelperRegistry(config.registryFilePath, process.pid)); + server.start().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`[ssh-server] ${message}`); + process.exitCode = 1; + server.close(); + }); + process.once('SIGINT', () => server.close()); + process.once('SIGTERM', () => server.close()); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[ssh-server] ${message}`); + process.exitCode = 1; + } +} + +type NativeSshProcess = { + child: cp.ChildProcessByStdio; + close(): void; +}; + +async function spawnNativeSshProcess( + command: string, + args: string[], +): Promise { + const child = cp.spawn(command, args, { + stdio: ['inherit', 'pipe', 'pipe'], + windowsHide: true, + }); + + let closed = false; + return { + child, + close: () => { + if (closed) { + return; + } + closed = true; + if (child.exitCode === null && !child.killed) { + child.kill(); + } + }, + }; +} + +if (path.basename(process.argv[1] ?? '') === 'nativeSshServer.js') { + startHelperFromArguments(); +} diff --git a/src/native/nativeSshServerClient.ts b/src/native/nativeSshServerClient.ts new file mode 100644 index 00000000..6507d3f0 --- /dev/null +++ b/src/native/nativeSshServerClient.ts @@ -0,0 +1,101 @@ +import * as http from 'http'; +import { ILogger } from '../common/logger'; + +const REQUEST_TIMEOUT_MS = 5_000; +const KEEPALIVE_INTERVAL_MS = 15_000; + +interface SshClientResponse { + statusCode: number | undefined; + body: string; +} + +export class NativeSshServerClient { + constructor(private readonly socketPath: string) { } + + request(method: 'GET' | 'POST', route: string): Promise { + return new Promise((resolve, reject) => { + const request = http.request( + { + socketPath: this.socketPath, + path: route, + method, + }, + (response) => { + let responseBody = ''; + response.setEncoding('utf8'); + response.on('data', (chunk: string) => { + responseBody += chunk; + }); + response.on('end', () => + resolve({ statusCode: response.statusCode, body: responseBody }), + ); + response.on('error', reject); + }, + ); + + request.setTimeout(REQUEST_TIMEOUT_MS, () => { + request.destroy(new Error('Native SSH helper request timed out.')); + }); + request.on('error', reject); + request.end(); + }); + } + + async isHealthy(): Promise { + try { + const res = await this.request('GET', '/health'); + return res.statusCode === 200; + } catch { + return false; + } + } + + async keepalive(): Promise { + try { + const res = await this.request('POST', '/keepalive'); + if (res.statusCode !== 200) { + throw new Error(`${res.statusCode} ${res.body}`); + } + } catch { + // Helper is gone; the caller's next spawn() will notice. + } + } +} + +export class NativeSshKeepalive { + private timer: NodeJS.Timeout | undefined; + + constructor( + private readonly client: NativeSshServerClient, + private readonly logger: ILogger, + private readonly intervalMs: number = KEEPALIVE_INTERVAL_MS, + ) { } + + get running(): boolean { + return this.timer !== undefined; + } + + start(): void { + if (this.timer) { + return; + } + this.ping(); + this.timer = setInterval(() => this.ping(), this.intervalMs); + this.timer.unref(); + } + + dispose(): void { + if (!this.timer) { + return; + } + clearInterval(this.timer); + this.timer = undefined; + } + + private ping(): void { + void this.client.keepalive().catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + this.logger.error(`keepalive failed: ${message}`); + }); + } +} diff --git a/src/scripts/server-setup.sh b/src/scripts/server-setup.sh index 2b7e98fe..99b3da54 100644 --- a/src/scripts/server-setup.sh +++ b/src/scripts/server-setup.sh @@ -27,6 +27,7 @@ SERVER_ARCH="%%SERVER_ARCH%%" SERVER_CONNECTION_TOKEN= SERVER_DOWNLOAD_URL= SERVER_VALIDATION_FLAG="%%SERVER_VALIDATION_FLAG%%" +SCRIPT_PREVENT_EXIT=%%SCRIPT_PREVENT_EXIT%% LISTENING_ON= OS_RELEASE_ID= @@ -260,7 +261,8 @@ else print_install_results_and_exit 1 fi -for i in {1..35}; do +waitUntil=$((SECONDS+20)) +while (( SECONDS < waitUntil )) do if [[ -f $SERVER_LOGFILE ]]; then if [[ -n "$(cat $SERVER_LOGFILE | grep 'Error loading shared library libstdc++.so')" ]]; then echo "Error: missing libstdc++" @@ -268,20 +270,30 @@ for i in {1..35}; do fi LISTENING_ON="$(cat $SERVER_LOGFILE | grep -E 'Extension host agent listening on .+' | sed 's/Extension host agent listening on //')" - if [[ -n $LISTENING_ON ]]; then - # Finish server setup - print_install_results_and_exit 0 + break fi fi - sleep 0.5 + # Some systems may not support fractional time + sleep 0.5 || sleep 1 done -if [[ -f $SERVER_LOGFILE ]]; then - echo "Error: server did not start successfully" -else +if [[ ! -f $SERVER_LOGFILE ]]; then echo "Error: server log file not found $SERVER_LOGFILE" + print_install_results_and_exit 1 +fi +if [[ -z $LISTENING_ON ]]; then + echo "Error: server did not start successfully" + print_install_results_and_exit 1 fi -print_install_results_and_exit 1 +# Finish server setup +print_install_results_and_exit 0 + +if (( SCRIPT_PREVENT_EXIT )); then + while true; + do sleep 150; + printf ' '; + done +fi diff --git a/src/serverSetup.ts b/src/serverSetup.ts index e86f1e33..69a08726 100644 --- a/src/serverSetup.ts +++ b/src/serverSetup.ts @@ -3,19 +3,19 @@ import type { ServerValidation, ServerVersion } from './server-config/types'; import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; -import { Log } from './common/logger'; +import { ILogger } from './common/logger'; import type { SSHClient } from './ssh/sshClient'; import { getServerConfig } from './server-config/get-server-config'; -import { fetchRelease, IRelease } from './fetchRelease'; +import { fetchRelease } from './fetchRelease'; import { sanitizeExtensionIds } from './utils/sanitize-extension-ids'; -import { RemotePlatform } from './types'; +import { RemotePlatform } from './common/platform'; /** * Reads a script template from /scripts/ and * replaces every %%KEY%% occurrence with the matching value from `variables`. */ function compileTemplate(templateName: string, variables: Record, extensionPath: string): string { - const templatePath = path.join(extensionPath, 'src', 'scripts', templateName); + const templatePath = path.join(extensionPath, 'lib', 'scripts', templateName); let content = fs.readFileSync(templatePath, 'utf8'); for (const [key, value] of Object.entries(variables)) { content = content.replace(new RegExp(`%%${key}%%`, 'g'), value); @@ -94,6 +94,7 @@ export type ServerInstallOptions = { serverValidation: ServerValidation; serverPlatform: Platform; serverArch: Architecture; + scriptPreventExit: boolean; }; export type ServerInstallResult = { @@ -169,7 +170,7 @@ export function parseMachineArch(stdout: string): Architecture { } } -export async function detectPlatform(conn: Pick, platform: RemotePlatform | undefined, logger: Pick): Promise { +export async function detectPlatform(conn: Pick, platform: RemotePlatform | undefined, logger: ILogger): Promise { let detectedPlatform: Platform; let detectedShell: Shell = 'bash'; @@ -238,41 +239,36 @@ export async function installCodeServer( platform: RemotePlatform | undefined, useSocketPath: boolean, customInstallPath: string | undefined, - logger: Log, + logger: ILogger, extensionPath: string ): Promise { const { detectedPlatform, detectedArch, detectedShell: shell } = await detectPlatform(conn, platform, logger); const scriptId = crypto.randomBytes(12).toString('hex'); - - const vscodeServerConfig = await getServerConfig(); - - // Get the version and release - const serverDownloadUrlTemplateFinal = serverDownloadUrlTemplate || vscodeServerConfig.serverDownloadUrlTemplate || DEFAULT_DOWNLOAD_URL_TEMPLATE; - const bestRelease: IRelease = await fetchRelease(serverDownloadUrlTemplateFinal, vscodeServerConfig.version, vscodeServerConfig.release, serverVersion, logger); - - const installOptions: ServerInstallOptions = { - id: scriptId, - version: bestRelease.version, - commit: vscodeServerConfig.commit, - quality: vscodeServerConfig.quality, - release: bestRelease.build, - extensionIds: sanitizeExtensionIds(extensionIds), - envVariables, - sendEnvVars, - useSocketPath, - serverApplicationName: vscodeServerConfig.serverApplicationName, - serverDataFolderName: vscodeServerConfig.serverDataFolderName, - serverDownloadUrlTemplate: serverDownloadUrlTemplateFinal, - customInstallPath, - serverValidation: vscodeServerConfig.serverValidation, - serverPlatform: detectedPlatform, - serverArch: detectedArch, - }; - - const commandOutput = await runInstallScript(conn, detectedPlatform, shell, installOptions, vscodeServerConfig, scriptId, logger, extensionPath); - - return parseInstallOutput(commandOutput, installOptions, scriptId, envVariables, logger); + const { options: installOptions, vscodeServerConfig } = + await buildServerInstallOptions( + scriptId, + serverDownloadUrlTemplate, + serverVersion, + extensionIds, + envVariables, + sendEnvVars, + useSocketPath, + customInstallPath, + detectedPlatform, + detectedArch, + false, + logger, + ); + + const commandOutput = await runInstallScript( + conn, detectedPlatform, shell, installOptions, vscodeServerConfig, + installOptions.id, logger, extensionPath, + ); + + return parseInstallOutput( + commandOutput, installOptions, installOptions.id, envVariables, logger, + ); } async function runInstallScript( @@ -282,7 +278,7 @@ async function runInstallScript( installOptions: ServerInstallOptions, vscodeServerConfig: { serverDataFolderName: string; commit: string }, scriptId: string, - logger: Log, + logger: ILogger, extensionPath: string ): Promise<{ stdout: string; stderr: string }> { if (platform === 'windows') { @@ -348,7 +344,7 @@ function parseInstallOutput( installOptions: ServerInstallOptions, scriptId: string, envVariables: string[], - logger: Log + logger: ILogger ): ServerInstallResult { if (commandOutput.stderr) { logger.trace('Server install command stderr:', commandOutput.stderr); @@ -385,7 +381,7 @@ function parseInstallOutput( }; } -function parseServerInstallOutput(str: string, scriptId: string): { [k: string]: string } | undefined { +export function parseServerInstallOutput(str: string, scriptId: string): { [k: string]: string } | undefined { const startResultStr = `${scriptId}: start`; const endResultStr = `${scriptId}: end`; @@ -411,7 +407,7 @@ function parseServerInstallOutput(str: string, scriptId: string): { [k: string]: return resultMap; } -function generateBashInstallScript({ id, quality, version, commit, release, extensionIds, envVariables, sendEnvVars, useSocketPath, serverApplicationName, serverDataFolderName, serverDownloadUrlTemplate, customInstallPath, serverValidation, serverPlatform, serverArch }: ServerInstallOptions, extensionPath: string): string { +export function generateBashInstallScript({ id, quality, version, commit, release, extensionIds, envVariables, sendEnvVars, useSocketPath, serverApplicationName, serverDataFolderName, serverDownloadUrlTemplate, customInstallPath, serverValidation, serverPlatform, serverArch, scriptPreventExit }: ServerInstallOptions, extensionPath: string): string { const extensions = extensionIds.map(extId => '--install-extension ' + extId).join(' '); const serverDataDir = customInstallPath ? customInstallPath.replace(/^~(?=\/|$)/, '$HOME') @@ -441,10 +437,11 @@ function generateBashInstallScript({ id, quality, version, commit, release, exte SERVER_CONNECTION_TOKEN: crypto.randomUUID(), SERVER_PLATFORM: serverPlatform, SERVER_ARCH: serverArch, + SCRIPT_PREVENT_EXIT: String(scriptPreventExit) }, extensionPath); } -function generatePowerShellInstallScript({ id, quality, version, commit, release, extensionIds, envVariables, sendEnvVars, useSocketPath, serverApplicationName, serverDataFolderName, serverDownloadUrlTemplate, customInstallPath, serverValidation }: ServerInstallOptions, extensionPath: string): string { +export function generatePowerShellInstallScript({ id, quality, version, commit, release, extensionIds, envVariables, sendEnvVars, useSocketPath, serverApplicationName, serverDataFolderName, serverDownloadUrlTemplate, customInstallPath, serverValidation }: ServerInstallOptions, extensionPath: string): string { const extensions = extensionIds.map(extId => '--install-extension ' + extId).join(' '); const downloadUrl = serverDownloadUrlTemplate .replace(/\$\{quality\}/g, quality) @@ -481,3 +478,59 @@ function generatePowerShellInstallScript({ id, quality, version, commit, release SERVER_CONNECTION_TOKEN: crypto.randomUUID(), }, extensionPath); } + +export async function buildServerInstallOptions( + scriptId: string, + serverDownloadUrlTemplate: string | undefined, + serverVersion: ServerVersion, + extensionIds: string[], + envVariables: string[], + sendEnvVars: Record, + useSocketPath: boolean, + customInstallPath: string | undefined, + detectedPlatform: Platform, + detectedArch: Architecture, + scriptPreventExit: boolean, + logger: ILogger, +): Promise<{ + options: ServerInstallOptions; + vscodeServerConfig: Awaited>; +}> { + const vscodeServerConfig = await getServerConfig(); + + const serverDownloadUrlTemplateFinal = + serverDownloadUrlTemplate + || vscodeServerConfig.serverDownloadUrlTemplate + || DEFAULT_DOWNLOAD_URL_TEMPLATE; + + const bestRelease = await fetchRelease( + serverDownloadUrlTemplateFinal, + vscodeServerConfig.version, + vscodeServerConfig.release, + serverVersion, + logger, + ); + + return { + options: { + id: scriptId, + version: bestRelease.version, + commit: vscodeServerConfig.commit, + quality: vscodeServerConfig.quality, + release: bestRelease.build, + extensionIds: sanitizeExtensionIds(extensionIds), + envVariables, + sendEnvVars, + useSocketPath, + serverApplicationName: vscodeServerConfig.serverApplicationName, + serverDataFolderName: vscodeServerConfig.serverDataFolderName, + serverDownloadUrlTemplate: serverDownloadUrlTemplateFinal, + customInstallPath, + serverValidation: vscodeServerConfig.serverValidation, + serverPlatform: detectedPlatform, + serverArch: detectedArch, + scriptPreventExit + }, + vscodeServerConfig, + }; +} diff --git a/src/ssh/nativeClient.ts b/src/ssh/nativeClient.ts index 0996b64f..4289ff2f 100644 --- a/src/ssh/nativeClient.ts +++ b/src/ssh/nativeClient.ts @@ -4,7 +4,7 @@ import type { ChildProcess } from 'child_process'; import { randomBytes } from 'crypto'; import { EventEmitter } from 'events'; import * as net from 'net'; -import type { Log } from '../common/logger'; +import type { ILogger } from '../common/logger'; import { findRandomPort } from '../common/ports'; import { isWindows } from '../common/platform'; import type { HostConfiguration } from './sshConfig'; @@ -63,7 +63,7 @@ export interface NativeSSHConnectConfig { dynamicForwarding: boolean; /** Timeout for establishing the connection, in milliseconds. */ readyTimeout: number; - logger: Log; + logger: ILogger; } /** @@ -216,7 +216,7 @@ export default class NativeSSHConnection implements SSHClient { this.config = config; } - private get logger(): Log { + private get logger(): ILogger { return this.config.logger; } diff --git a/src/ssh/sshConfig.ts b/src/ssh/sshConfig.ts index bcf0a975..9b8c0b90 100644 --- a/src/ssh/sshConfig.ts +++ b/src/ssh/sshConfig.ts @@ -28,7 +28,7 @@ export type HostConfiguration = { } & Record; const systemSSHConfig = isWindows ? path.resolve(process.env.ALLUSERSPROFILE || 'C:\\ProgramData', 'ssh\\ssh_config') : '/etc/ssh/ssh_config'; -const defaultSSHConfigPath = path.resolve(os.homedir(), '.ssh/config'); +export const defaultSSHConfigPath = path.resolve(os.homedir(), '.ssh/config'); export function getSSHConfigPath() { const sshConfigPath = vscode.workspace.getConfiguration('remote.SSH').get('configFile'); diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index b3b0730c..00000000 --- a/src/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * Platform values accepted by the `remote.SSH.remotePlatform` setting. - */ -export type RemotePlatform = 'linux' | 'macos' | 'windows'; diff --git a/test/utils/wait-for-ssh-ready.ts b/test/utils/wait-for-ssh-ready.ts index 17dee699..4bb1c9ee 100644 --- a/test/utils/wait-for-ssh-ready.ts +++ b/test/utils/wait-for-ssh-ready.ts @@ -1,6 +1,6 @@ +import { sleep } from '../../src/common/sleep'; import SSHConnection from '../../src/ssh/sshConnection'; import { runDocker } from './run-docker'; -import { sleep } from './sleep'; export async function waitForSSHReady(username: string, password: string, port: number, timeoutMs: number, containerName: string): Promise { const start = Date.now(); diff --git a/webpack.config.js b/webpack.config.js index 904838c2..70ca6ace 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -8,10 +8,20 @@ const webpack = require('webpack'); /**@type {import('webpack').Configuration}*/ const config = { target: 'node', - entry: './src/extension.ts', + entry: { + extension: './src/extension.ts', + 'native/askpass-node': './src/native/askpass/askpass-node.ts', + 'native/nativeSshServer': './src/native/nativeSshServer.ts', + }, output: { path: path.resolve(__dirname, 'lib'), - filename: 'extension.js', + filename: '[name].js', + copy: [ + { from: path.resolve(__dirname, 'src/scripts/server-setup.sh'), to: 'scripts', preservePermissions: true }, + { from: path.resolve(__dirname, 'src/scripts/server-setup.ps1'), to: 'scripts', preservePermissions: true }, + { from: path.resolve(__dirname, 'src/native/askpass/askpass.sh'), to: 'native', preservePermissions: true }, + { from: path.resolve(__dirname, 'src/native/askpass/askpass.bat'), to: 'native', preservePermissions: true }, + ], libraryTarget: "commonjs2", devtoolModuleFilenameTemplate: "../[resource-path]", }, @@ -39,7 +49,7 @@ const config = { }), new webpack.IgnorePlugin({ resourceRegExp: /cpu-features/, - }) + }), ] } From 3fd64d1b6091f3cd8a55c6bbabf5b9e12f1048b4 Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Tue, 27 Oct 2026 10:42:18 -0500 Subject: [PATCH 03/26] revert: delete unused file --- spec.md | 530 -------------------------------------------------------- 1 file changed, 530 deletions(-) delete mode 100644 spec.md diff --git a/spec.md b/spec.md deleted file mode 100644 index 94957829..00000000 --- a/spec.md +++ /dev/null @@ -1,530 +0,0 @@ -# Native SSH Connection Mode - -## Requirements - -- Add a new opt-in setting that lets users choose the connection backend used by Open Remote - SSH. -- Preserve the current `ssh2` implementation as the default behavior. -- Add a native OpenSSH backend that uses the user's installed `ssh` executable for authentication and transport, with reuse owned by the extension helper HTTP server. -- Let multiple VS Code windows connected to the same SSH authority reuse one authenticated native SSH connection through the shared helper without prompting again. -- Surface native SSH password, passphrase, keyboard-interactive, and host-key prompts through VS Code UI by combining OpenSSH askpass integration with prompt parsing from native `ssh` output. -- Share reusable connection information through the extension global storage directory so independent extension host processes can discover the same connection. -- Keep the existing user-facing connection flow, remote authority format, host tree commands, server installation behavior, and resolved authority result shape. -- Support the existing server installation path by providing the operations currently used by `installCodeServer`: command execution, partial-output command execution, tunnel creation, and cleanup. -- Continue supporting dynamic forwarding for port forwarding when enabled. -- Provide useful logs for backend selection, helper discovery, native SSH process startup, reuse, stale registry cleanup, and tunnel failures. - -## Constraints - -- Do not replace the current `ssh2` backend. Native SSH must be additive and gated by configuration. -- The native backend cannot handle interactive prompts directly through `ssh2` callbacks. Authentication, host-key verification, passphrase prompts, password prompts, and platform-specific SSH behavior should be delegated to the native `ssh` process and the user's SSH configuration. -- Native SSH prompts must not block on an invisible terminal. Every native `ssh` process that can authenticate must be launched with an askpass environment and monitored for output prompts so questions can be routed back to the helper and then to VS Code UI. -- The extension should not persist credentials, private keys, passwords, passphrases, or SSH agent details in global storage. -- Global storage data must be treated as coordination metadata only. Store local control endpoint details, process identity, authority keys, timestamps, and non-secret connection facts. -- HTTP control endpoints must bind to loopback only and require an unguessable per-helper token from the registry. -- Do not use OpenSSH ControlMaster, ControlPath, or ControlPersist. Multiplexing and reuse must be the role of the helper HTTP server. -- Registry files may be stale because VS Code windows and helper processes can exit independently. Every reuse path must validate liveness before trusting registry entries. -- The current codebase has no test directory, so initial verification should rely on TypeScript compilation plus focused manual extension-host testing unless tests are introduced as part of the implementation. -- `rg` is unavailable in the current workspace; repository inspection used `find`/`grep` fallback. - -## Current Architecture - -- `src/extension.ts` registers `RemoteSSHResolver` as the resolver for `ssh-remote` authorities. -- `src/authResolver.ts` owns the end-to-end resolve flow: - - parse the encoded SSH destination; - - read `remote.SSH.*` settings; - - load SSH config with `SSHConfiguration`; - - create `SSHConnection` instances using `ssh2`; - - authenticate with `ssh2` callbacks; - - run `installCodeServer`; - - open a tunnel to the remote VS Code server; - - return `new vscode.ResolvedAuthority('127.0.0.1', localPort, connectionToken)`. -- `src/ssh/sshConnection.ts` is the practical connection contract used by the resolver and server setup code: - - `connect()`; - - `exec()`; - - `execPartial()`; - - `forwardOut()`; - - `addTunnel()`; - - `closeTunnel()`; - - `close()`. -- `src/serverSetup.ts` is backend-independent except for its concrete `SSHConnection` type annotation. It runs shell commands and parses the install script output. - -## Local Helper Design Patterns - -The native SSH mode should use these helper-process patterns: - -- A long-lived helper process owning a native `ssh` child is a viable model. -- The helper should receive a structured startup configuration and use it to spawn native `ssh` with explicit command and argument arrays. -- A local HTTP management surface is consistent with the requested architecture, but it must be authenticated, loopback-only, schema-validated, and body-size-limited. -- The helper must own piped `ssh.stdin` so it can write command envelopes, cleanup scripts, and approved prompt responses. -- The helper should coordinate discovery through a registry entry and remove that entry on clean shutdown. -- The helper should use explicit keepalive or lease renewal, but multi-window attachment must be lease-based so one closing window does not disconnect other windows. -- The helper should clean up the management server, SSH child processes, local tunnel servers, generated askpass wrapper, and registry data on exit. -- The helper may suppress or route noisy SSH output after setup completes, but it must use per-command unique sentinels and robust stdout/stderr framing rather than broad text markers. -- Platform-specific command spawning behavior, especially Windows `.bat` and `.cmd` wrappers, must be handled centrally. -- Logs must redact sensitive SSH arguments, bearer tokens, prompt responses, and command payloads that may contain secrets. - -## Proposed Setting - -Add an application-scoped setting: - -- `remote.SSH.connectionBackend` -- Type: string enum -- Default: `"ssh2"` -- Values: - - `"ssh2"`: current in-process `ssh2` backend. - - `"native"`: use native OpenSSH helper and shared connection registry. - -Optional follow-up settings, if needed during implementation: - -- `remote.SSH.nativeSshPath`: explicit path to `ssh`, default empty for PATH lookup. - -Keep the first implementation minimal by adding only `connectionBackend` unless path discovery proves too brittle. - -## Proposed Architecture - -### 1. Connection Backend Abstraction - -Introduce an interface for the connection operations the resolver actually needs, for example `RemoteSSHConnection`: - -- `connect(): Promise` -- `exec(command: string, params?: string[], options?: unknown): Promise<{ stdout: string; stderr: string }>` -- `execPartial(command: string, tester: (stdout: string, stderr: string) => boolean, params?: string[], options?: unknown): Promise<{ stdout: string; stderr: string }>` -- `addTunnel(config: SSHTunnelConfig): Promise` -- `closeTunnel(name?: string): Promise` -- `close(): Promise` - -Refactor `installCodeServer` to accept this interface instead of the concrete `SSHConnection` class. - -Keep the existing `src/ssh/sshConnection.ts` as the `ssh2` implementation of that interface. - -### 2. Native Helper Process - -Add a small Node-based helper entrypoint bundled with the extension, for example: - -- `src/native/nativeSshServer.ts` -- `src/native/nativeSshClient.ts` -- `src/native/nativeSshConnection.ts` -- `src/native/nativeSshRegistry.ts` -- `src/native/askpass/askpass.js` - -The helper process should: - -- bind an HTTP server to `127.0.0.1` on an OS-assigned port; -- generate a random bearer token at startup; -- start and supervise native `ssh` child processes for one normalized SSH authority; -- own the reusable connection lifecycle so additional VS Code windows attach to the helper instead of creating their own authenticated transport; -- maintain an attached extension-host event channel so prompts detected by helper-owned `ssh` processes can be surfaced by the active VS Code window; -- expose loopback-only authenticated endpoints for: - - health checks; - - command execution; - - partial command execution with streamed stdout/stderr parsing; - - opening or closing local tunnels; - - askpass prompt requests from native `ssh`; - - parsed prompt requests detected from native `ssh` stdout/stderr; - - returning connection metadata needed by the resolver; - - graceful shutdown, if no windows remain attached. - -The extension host should communicate with the helper through `NativeSshClient`, not by reaching into helper internals. - -The helper API should be explicit and versioned. Minimum endpoints: - -- `GET /health`: validate the helper is alive and matches the expected authority/backend schema. -- `POST /attach`: register an extension-host lease and open an event stream for prompts/status. -- `POST /detach`: release an extension-host lease. -- `POST /exec`: run a command and return stdout/stderr/exit status. -- `POST /exec-partial`: run a command and resolve when the current `execPartial` tester condition is satisfied. -- `POST /tunnel/open`: open or reuse a local tunnel for a remote port or socket path. -- `POST /tunnel/close`: release a tunnel lease. -- `POST /askpass`: receive prompt requests from the askpass script. - -Use newline-delimited JSON, Server-Sent Events, long polling, or an equivalent simple stream for helper-to-extension-host events. A one-shot request/response API is not enough because prompt requests can happen while `connect()`, `exec()`, or tunnel startup is blocked inside a native `ssh` child process. - -### 3. Helper Startup Handshake - -The extension host needs a deterministic way to start a helper and know when it is safe to attach or publish registry metadata. - -- Spawn the helper with `child_process.fork` or `spawn(process.execPath, [helperEntry, configPath])`, depending on what works after webpack bundling. -- Pass startup configuration through a temporary JSON file or stdin instead of a giant command-line JSON blob if the config can exceed Windows command-line limits. -- Startup configuration should include schema version, normalized authority key, ssh executable path, ssh argument array, global storage paths, requested transport features, log routing mode, and a bootstrap token. -- The helper should bind its HTTP server first, then emit a ready message to stdout or an IPC channel containing only non-secret endpoint metadata. -- The extension should call `/health` before writing or trusting the registry entry. -- Registry writes should happen after the helper is reachable, so other windows do not attach to a half-started helper. -- If helper startup times out, the extension should kill the helper process, remove temporary files, and report a clear native-mode failure. -- The helper should report startup failures as structured errors where possible, including missing `ssh`, invalid askpass wrapper, bind failure, and native SSH authentication failure. - -### 4. Native SSH Prompt Bridge - -The native backend must support two prompt paths for all helper-owned `ssh` processes that may need authentication: - -- `SSH_ASKPASS` for prompts OpenSSH routes through askpass. -- stdout/stderr prompt parsing for prompts OpenSSH writes directly to the process output, such as host trust confirmation. - -#### Askpass - -- Bundle or generate a small JavaScript askpass script, for example `askpass.js`. -- Launch `ssh` with environment variables: - - `SSH_ASKPASS=`; - - `SSH_ASKPASS_REQUIRE=force` where supported; - - `DISPLAY` set to a harmless value on Unix-like platforms when OpenSSH requires it to invoke askpass; - - helper endpoint metadata such as askpass URL and one-time request token. -- On Windows, verify whether bundled OpenSSH honors `SSH_ASKPASS`; if it does not, document the limitation and keep password-based native mode unsupported on that platform until an equivalent prompt mechanism is available. -- The askpass script should: - - receive the OpenSSH prompt text from argv; - - call the helper HTTP endpoint over loopback with the prompt text and one-time token; - - print only the user response to stdout; - - exit non-zero when the user cancels or the helper rejects the request; - - avoid logging prompt responses. -- The helper should: - - validate the askpass token and associate it with the pending SSH process; - - forward the prompt to the attached extension host through `NativeSshClient`; - - display `vscode.window.showInputBox` or a modal choice UI depending on prompt type; - - mark password/passphrase responses as secret inputs; - - return cancellation cleanly so OpenSSH fails rather than hanging. -- Prompt classification should be conservative: - - passphrase/password prompts use password input boxes; - - host-key confirmation prompts use explicit accept/cancel choices and should respect the user's SSH config; - - unknown prompts should use password input only when OpenSSH indicates hidden input, otherwise plain input may be acceptable. -- Prompt requests must be serialized per SSH process to avoid interleaving multiple authentication questions. -- Prompt responses must never be written to global storage, output logs, command traces, or registry files. -- If no extension host is attached when a prompt is needed, the helper must fail the pending operation rather than waiting indefinitely. - -#### Output Prompt Parsing - -The helper must parse native `ssh` output while a process is connecting or running a command. - -- Capture stdout and stderr incrementally before forwarding sanitized output to normal command handlers. -- Maintain a small rolling buffer per stream so prompts split across chunks can still be detected. -- Detect known OpenSSH prompt families, including: - - host authenticity prompts like `Are you sure you want to continue connecting (yes/no/[fingerprint])?`; - - changed host-key warnings that require explicit failure messaging rather than blind confirmation; - - password prompts like `@'s password:`; - - private-key passphrase prompts like `Enter passphrase for key ...:`; - - keyboard-interactive prompts that may not go through askpass on all platforms. -- Classify prompts into UI actions: - - host trust: show a modal VS Code warning with the host, key type, and fingerprint when present; write `yes\n` to `ssh.stdin` only if the user accepts; - - password/passphrase: show a password input and write the response plus newline to `ssh.stdin`; - - keyboard-interactive visible prompt: show a normal input when the prompt clearly expects visible text, otherwise use password input; - - unrecoverable host-key mismatch: stop the process and return an actionable error instead of asking the user to bypass it. -- Ensure prompt text shown in VS Code is sanitized and bounded in length. -- Redact prompt responses and sensitive prompt context from logs. -- Add prompt timeouts and cancellation handling so a hidden or unrecognized prompt cannot hang the helper indefinitely. -- Prefer askpass when both mechanisms observe the same prompt; de-duplicate by tracking pending prompt state per process. -- Keep parsing rules centralized in a `NativeSshPromptParser` module so additional platform-specific prompt patterns can be added safely. - -### 5. Command Execution Protocol - -The plan cannot rely on `ssh ` per request as the normal execution path, because that can re-authenticate and breaks the reuse goal. The helper must provide a reusable command execution protocol. - -Recommended first implementation: - -- Start one helper-owned persistent remote shell process per authority for setup/exec work. -- Launch it with native `ssh` using the askpass and prompt-parser environment. -- Serialize command execution over that shell. This is acceptable because server setup is already sequential and avoids multiplexing complexity. -- Wrap every command in a generated command envelope with unique begin/end sentinels. -- Capture stdout/stderr separately where possible: - - for POSIX remotes, run a generated shell wrapper that redirects stdout and stderr through tagged streams or temporary files and prints sentinel metadata; - - for Windows remotes, use a PowerShell wrapper that emits tagged output and exit status. -- Return `{ stdout, stderr }` to match the current `SSHConnection.exec` behavior. -- Implement `execPartial` by watching tagged output for the caller's tester condition and then draining/cleaning the command envelope safely. -- If the persistent shell dies, mark the helper unhealthy and force a reconnect path instead of silently spawning a new authenticated shell behind an attached window. -- Do not run multiple commands concurrently on the same persistent shell until a robust framing protocol exists. - -Remote shell detection and command wrapping: - -- Reuse the existing `remotePlatform` setting when provided. -- When platform is unknown, run a minimal detection command through the persistent shell and classify POSIX shell, PowerShell, or cmd-like behavior. -- Keep separate command envelope implementations for POSIX shell, PowerShell, and cmd if cmd support is required. -- Ensure command envelopes preserve the behavior expected by `installCodeServer`, including long heredoc-like payloads and partial output parsing. -- Include an explicit cleanup command for interrupted envelopes so a canceled `execPartial` cannot leave the shell stuck in a heredoc or multiline input state. -- If cleanup cannot guarantee shell synchronization, discard the shell and mark the helper unhealthy. - -Command safety requirements: - -- Avoid concatenating untrusted strings into local `ssh` command lines. Use `child_process.spawn(file, args, { shell: false })`. -- Preserve the existing install script behavior, but treat command strings as remote shell payloads and wrap them in a controlled envelope. -- Escape only for the target remote shell and keep platform-specific quoting in one module. -- Bound command output buffers, with larger limits only for known server setup commands. -- Redact prompt responses and bearer tokens before logging command envelopes or process output. - -This protocol is a key missing piece because native OpenSSH without ControlMaster does not expose an API equivalent to `ssh2.exec()` for many independent channels over one authenticated connection. - -### 6. Native SSH Transport Strategy - -Use the helper HTTP server as the native reuse primitive. Do not use OpenSSH ControlMaster. - -- For each normalized SSH authority, create or attach to one helper process recorded in global storage. -- The helper should own the persistent native SSH resources for that authority, including at least the command shell and any long-lived forwarding processes. -- The helper should eagerly create the native SSH resources needed by normal resolution during the first connection so later windows do not trigger new authentication prompts. -- The helper must launch native `ssh` with the askpass environment for initial connection and for any later child process that may authenticate. -- The helper must also attach prompt parsers to native `ssh` stdout/stderr and be able to write approved prompt responses to `ssh.stdin`. -- Command execution should be exposed as helper HTTP requests and implemented by the helper against its owned SSH transport. -- Partial command execution must preserve the current `execPartial` behavior needed by server setup: resolve when the tester matches stdout/stderr, while cleaning up safely afterward. -- Opening tunnels should be exposed as helper HTTP requests and implemented by helper-owned forwarding resources. -- TCP forwarding can be implemented with: - - a helper-managed local proxy to a persistent native `ssh -D 127.0.0.1:` SOCKS process for remote TCP ports; or - - helper-owned `ssh -N -L ...` child processes when dynamic SOCKS is disabled. -- Stream-local forwarding can be implemented with helper-owned native `ssh` forwarding processes when the local OpenSSH version and platform support it. -- Every helper-owned forwarding process must also use the askpass/prompt-parser path because it may authenticate independently from the command shell. - -Important design notes: - -- A plain native `ssh ` subprocess per command would delegate authentication to OpenSSH, but would not satisfy the reuse requirement by itself. -- The helper must be the shared owner of any persistent authenticated process or forwarding process needed to avoid repeated authentication across VS Code windows. -- If OpenSSH does not expose a robust portable way to run independent exec requests over one non-ControlMaster process, the first native implementation should use a helper-owned persistent shell for install/setup commands and helper-owned tunnel subprocesses for forwarding. That limitation should be documented, and the default backend should remain `ssh2`. -- Without ControlMaster, a separate tunnel subprocess can still require authentication. The helper must either keep that process alive for reuse, route its prompts through VS Code UI, or use the persistent SOCKS process for TCP tunnels so second windows attach without prompting. - -The first implementation should support: - -- TCP server listening mode (`remoteServerListenOnSocket: false`); -- TCP tunnels through dynamic SOCKS or `-L`; -- ProxyJump, ProxyCommand, identity files, host aliases, and SSH-agent behavior as interpreted by native OpenSSH through the user's config. - -For `remoteServerListenOnSocket: true`, prefer supporting native OpenSSH stream-local forwarding when available. If support is not reliable across platforms, fail with a clear `NotAvailable` error in native mode and document that users should use the `ssh2` backend or disable socket listening. - -### 7. Askpass Packaging and Process Environment - -`SSH_ASKPASS` usually needs an executable program, not only a TypeScript module inside the extension bundle. - -- Generate a small askpass wrapper in the extension global storage directory at helper startup. -- On Unix-like platforms, write it with owner-only permissions and an executable bit. -- The wrapper should invoke the extension's runtime with the bundled askpass JS entrypoint and pass through the OpenSSH prompt argv. -- If the runtime executable cannot safely run the JS entrypoint, use a small shell wrapper that invokes the same Node/Electron runtime already hosting the extension. -- On Windows, verify the installed OpenSSH behavior: - - whether it honors `SSH_ASKPASS`; - - whether it requires a GUI subsystem executable; - - whether stdin prompt parsing is sufficient for password/host-key prompts. -- Launch native `ssh` with no inherited terminal and with `stdio: ['pipe', 'pipe', 'pipe']` so prompts can be parsed and responses can be written through the helper. -- Avoid `BatchMode=yes` in native mode because it disables password/passphrase prompting. -- Consider setting `NumberOfPasswordPrompts=1` per process attempt to avoid repeated hidden prompt loops; retries should be controlled by the helper. -- Avoid inheriting arbitrary extension environment variables into helper-owned `ssh` children except the variables required for SSH, askpass, locale, and platform operation. - -### 8. Authority Identity and Reuse Key - -The registry key must avoid both accidental sharing and unnecessary duplication. - -- Include the encoded remote authority target. -- Include the selected backend schema version. -- Include the configured SSH config file path and a fingerprint of the computed host configuration with sensitive values redacted. -- Include settings that affect transport shape, such as dynamic forwarding and socket-listen mode. -- Include the local platform because path and askpass behavior differ by OS. -- Do not include raw secrets, identity file contents, agent socket values, or tokens. - -If the SSH config changes, the next resolver should compute a different reuse key or invalidate the old helper after a failed health/config check. - -### 9. Terminal Environment and Agent Forwarding - -The current resolver updates `environmentVariableCollection` with values discovered during server setup, especially for agent forwarding behavior. Native mode must preserve that behavior. - -- Keep the existing `installCodeServer` environment variable flow. -- If the native SSH config uses `ForwardAgent yes`, ensure the remote server setup still reports the remote `SSH_AUTH_SOCK` value when needed. -- Update `context.environmentVariableCollection` exactly as the existing resolver does after install output is parsed. -- Do not expose local `SSH_AUTH_SOCK` or identity-agent paths in registry metadata or logs. -- Include terminal environment behavior in manual verification because terminals are a user-visible part of Remote SSH. - -### 10. Remote Server Cleanup and Management - -Native helper mode should account for remote cleanup operations and helper management commands. - -- Add a helper endpoint for remote cleanup if the extension needs to stop or remove the remote VS Code server. -- Generate cleanup scripts using the same platform-specific logic as server setup, not ad hoc process killing in the resolver. -- Run cleanup through the persistent command execution protocol so stdout/stderr and prompts are handled consistently. -- Add a keepalive or lease-renew endpoint separate from command execution. -- Add an explicit graceful shutdown endpoint that is only honored when there are no active leases, unless the resolver is disposing the owning helper due to failure. - -### 11. Security and Storage Hardening - -- Create `native-ssh` storage directories with owner-only permissions where the platform supports it. -- Treat registry files as untrusted input and validate every field before use. -- Use random high-entropy bearer tokens for helper HTTP requests. -- Use separate short-lived askpass prompt tokens, scoped to one process and one prompt. -- Reject non-loopback requests and unexpected HTTP methods. -- Bound request body sizes for all helper endpoints. -- Avoid permissive CORS. The helper is not a browser API. -- Never log bearer tokens, askpass tokens, prompt responses, full environment blocks, or full command payloads when they may contain secrets. -- Rotate helper tokens when a helper restarts and remove registry entries on clean shutdown. -- On stale or suspicious registry data, delete the entry and start a new helper. - -### 12. Global Storage Registry - -Store registry entries in `context.globalStorageUri`, for example: - -- `native-ssh/registry.json` -- `native-ssh//helper.json` -- `native-ssh//logs/...` - -Each registry entry should include: - -- normalized authority key; -- helper HTTP host and port; -- bearer token; -- helper PID; -- dynamic SOCKS port, when enabled; -- created and last-seen timestamps; -- extension version or registry schema version. - -Do not store askpass tokens in the long-lived registry. Askpass request tokens should be short-lived and scoped to a single pending prompt or SSH process. - -Use atomic writes: - -- write to a temporary file; -- rename over the registry file. - -Reuse algorithm: - -1. Normalize the authority from `SSHDestination` and relevant config identity. -2. Read the registry. -3. Find an entry for the same normalized authority and backend version. -4. Call `/health` with the token. -5. If healthy, attach to that helper. -6. If unhealthy, remove the stale entry and start a new helper. -7. On startup races, use a lock file or retry loop so two windows do not create duplicate helpers for the same authority. - -Do not store the VS Code server `connectionToken` as the primary reuse primitive unless needed. It is sensitive enough to avoid long-lived persistence. Prefer asking the helper to provide active tunnel metadata after liveness validation. - -### 13. Resolver Flow in Native Mode - -In `RemoteSSHResolver.resolve`: - -1. Read `remote.SSH.connectionBackend`. -2. Load SSH config and calculate the same host label and install settings as today. -3. If backend is `"ssh2"`, use the current flow. -4. If backend is `"native"`: - - create or attach to `NativeSshConnection` for the authority; - - call `connect()` to attach to or start the helper-owned native SSH transport; - - handle prompt requests from the helper using VS Code UI while `connect()` or command execution is pending; - - run `installCodeServer` through the backend interface; - - open the tunnel to `installResult.listeningOn`; - - update terminal environment variables from install output as today; - - register the label formatter as today; - - return `ResolvedAuthority` with the local tunnel port and server connection token. - -When attaching to an existing helper: - -- verify helper schema version, authority key, transport-affecting settings, and liveness; -- renew the current window's lease; -- open a prompt/status event stream before issuing operations that may prompt; -- request or create a local tunnel for this window rather than assuming another window's local tunnel port is reusable forever. - -Keep resolver-level error behavior consistent: - -- show the log and retry/close dialog on first resolve attempt; -- map install-script failures to `RemoteAuthorityResolverError.NotAvailable`; -- map transient helper/native process failures to `TemporarilyNotAvailable` where retry may help. - -### 14. Cleanup and Lifetime - -- In `ssh2` mode, preserve current disposal semantics. -- In native mode, disposing one extension host should close only the tunnels opened for that window, not necessarily the shared helper connection. -- The helper should keep a reference count or attachment lease per window. -- Leases should expire if a window exits without cleanup. -- The helper can stop its native SSH child processes after no leases remain and an idle timeout has elapsed. -- Registry entries should be removed when the helper exits cleanly. -- Stale entries should be removed by future resolver attempts. - -### 15. Failure Modes and Fallback Behavior - -- If native `ssh` is not found, fail native mode with a clear error and keep `ssh2` as the default backend. -- If the helper starts but cannot create the required command shell, mark the helper unhealthy and remove its registry entry. -- If prompt parsing detects a changed host-key warning, fail with instructions to fix the user's known hosts file; do not offer an accept button. -- If the helper loses its attached extension-host event stream during a prompt, cancel the prompt and fail the pending operation. -- If a forwarded-port process exits unexpectedly, close affected local servers and surface an actionable error. -- If the persistent command shell loses framing synchronization, discard it, mark the helper unhealthy, and force a clean reconnect. -- If the helper registry says a helper is healthy but `/health` reports a mismatched schema or authority key, remove the registry entry and start a new helper. -- If remote platform detection conflicts with `remote.SSH.remotePlatform`, prefer the explicit setting and log the detected mismatch. -- Do not silently fall back from `"native"` to `"ssh2"` after a native failure; the selected backend should be explicit so behavior is debuggable. - -## Implementation Steps - -1. Add the `remote.SSH.connectionBackend` configuration contribution in `package.json`. -2. Define a shared connection interface and update `installCodeServer` to depend on it. -3. Adapt `RemoteSSHResolver` so the existing `ssh2` flow is isolated behind a backend creation path with no behavior change. -4. Add registry helpers for authority hashing, atomic reads/writes, liveness validation, stale cleanup, and startup locking. -5. Add security and storage primitives for owner-only directories, validated registry reads, token generation, and body-size-limited helper requests. -6. Add helper startup handshake: - - startup config file or stdin input; - - ready signal; - - health validation before registry publication; - - startup timeout and cleanup. -7. Add the native helper HTTP server with authenticated loopback endpoints and an extension-host event stream for prompt/status events. -8. Add authority identity computation using encoded destination, backend schema, redacted computed SSH config, and transport-affecting settings. -9. Add the askpass bridge: - - bundle or generate the JavaScript askpass script; - - generate an executable platform wrapper in global storage; - - set `SSH_ASKPASS`, `SSH_ASKPASS_REQUIRE`, and any required display environment for native `ssh`; - - add helper endpoint handling for prompt requests; - - add client-side VS Code UI prompt handling; - - ensure prompt responses are never logged or persisted. -10. Add native SSH output prompt parsing: - - implement rolling-buffer parsing for stdout/stderr chunks; - - detect host authenticity, password, passphrase, keyboard-interactive, and host-key mismatch messages; - - surface parsed prompts through VS Code UI; - - write accepted prompt responses to `ssh.stdin`; - - fail safely on cancellation, timeout, host-key mismatch, or unknown blocking prompts. -11. Add OpenSSH process management: - - ssh path resolution; - - helper-owned persistent SSH resource startup; - - no inherited terminal, piped stdio, and controlled environment; - - command execution over the helper-owned persistent shell; - - stdout/stderr collection and partial-output completion; - - process exit/error logging. -12. Add the persistent command execution protocol: - - command envelopes with unique sentinels; - - stdout/stderr tagging or temporary-file collection; - - exit-code parsing; - - serialized command queue; - - shell desynchronization detection and recovery. -13. Add remote shell detection and platform-specific command wrappers for POSIX shell, PowerShell, and cmd if required. -14. Add terminal environment and agent-forwarding integration so install output still updates `environmentVariableCollection`. -15. Add remote cleanup and helper management endpoints for keepalive, lease renewal, graceful shutdown, and optional remote server cleanup. -16. Add native tunnel support: - - remote VS Code server TCP tunnel; - - dynamic SOCKS reuse when `remote.SSH.enableDynamicForwarding` is true; - - local port forwarding fallback when dynamic forwarding is false; - - explicit handling for stream-local socket forwarding. -17. Wire native mode into resolver settings and error handling. -18. Add logging and diagnostics for backend selection, helper attach/start, SSH command failures, parsed prompt handling, tunnel allocation, prompt cancellation, and cleanup. -19. Add focused tests if a test harness is introduced; otherwise verify with compile/lint/manual extension-host scenarios. -20. Update README or changelog notes for the new setting, askpass behavior, parsed prompt behavior, no-ControlMaster design, and native-mode limitations. - -## Verification Plan - -- Run `npm run compile:src`. -- Run `npm run bundle:dev`. -- Run `npm run lint` if practical after implementation. -- Add unit tests or fixture-based checks for `NativeSshPromptParser` before relying only on manual testing. -- Add helper integration tests with a fake `ssh` executable that emits prompt fixtures, accepts stdin responses, and simulates command/tunnel lifecycle. -- Manual local extension-host checks: - - default setting still uses existing `ssh2` behavior; - - helper startup does not publish registry metadata until `/health` succeeds; - - native mode connects to an SSH config alias; - - password authentication prompts are displayed through VS Code UI via `SSH_ASKPASS`; - - encrypted private-key passphrase prompts are displayed through VS Code UI via `SSH_ASKPASS`; - - host trust prompts parsed from native `ssh` output are displayed as explicit VS Code accept/cancel UI; - - changed host-key warnings fail with a clear error and are not auto-confirmed; - - prompt cancellation fails the connection cleanly and does not hang the helper; - - command execution returns separated stdout/stderr and correct exit status through the persistent shell protocol; - - persistent shell death marks the helper unhealthy and a later resolve starts cleanly; - - remote platform detection honors explicit `remote.SSH.remotePlatform` when set; - - terminal environment variables are updated after native-mode server setup; - - opening a second VS Code window for the same host reuses the helper without another authentication prompt; - - attaching to an existing helper opens a new lease and event stream before issuing operations; - - closing one window does not disconnect the other; - - stale registry entries are cleaned after killing the helper process; - - forwarded ports work with `remote.SSH.enableDynamicForwarding` enabled; - - server installation works on a host with no existing server; - - server reuse works on a host with an existing server; - - failure to find `ssh` produces a clear error. - -## Success Criteria - -- `remote.SSH.connectionBackend: "ssh2"` behaves the same as the current extension. -- `remote.SSH.connectionBackend: "native"` uses the system `ssh` executable for the remote connection without using OpenSSH ControlMaster. -- Native `ssh` authentication and host trust prompts are surfaced through VS Code UI using the askpass bridge and parsed `ssh` output. -- Two windows opened to the same normalized SSH authority can share one authenticated native SSH helper. -- The second window can resolve the remote authority without re-entering credentials when the helper-owned native SSH transport is healthy. -- Command execution in native mode works through a reusable helper-owned transport without spawning a fresh authenticated `ssh ` process for every request. -- Global storage contains only non-secret coordination metadata and can recover from stale entries. -- The resolver still returns a valid `ResolvedAuthority` backed by a local tunnel to the remote VS Code server. -- Port forwarding remains functional for native-mode TCP remotes. -- Errors are visible in the existing Remote - SSH output channel and are actionable. -- The implementation compiles and bundles successfully. From ecd1093b5ecc848e82536cd979ef8b215a742af5 Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Tue, 27 Oct 2026 14:24:37 -0500 Subject: [PATCH 04/26] fix: fix tests --- src/scripts/server-setup.sh | 36 +++++++++++++++++++--------------- test/fixtures.test.ts | 4 ++-- test/forward-agent.test.ts | 2 +- test/gssapi.test.ts | 2 +- test/host-key.test.ts | 2 +- test/nested-proxy-jump.test.ts | 2 +- test/proxy-jump.test.ts | 2 +- test/server-startup.test.ts | 14 +++++++++---- 8 files changed, 37 insertions(+), 27 deletions(-) diff --git a/src/scripts/server-setup.sh b/src/scripts/server-setup.sh index 99b3da54..d66f966f 100644 --- a/src/scripts/server-setup.sh +++ b/src/scripts/server-setup.sh @@ -43,7 +43,7 @@ get_installed_commit() { } # Mimic output from logs of remote-ssh extension -print_install_results_and_exit() { +print_install_results() { echo "%%SCRIPT_ID%%: start" echo "exitCode==$1==" echo "listeningOn==$LISTENING_ON==" @@ -54,6 +54,10 @@ print_install_results_and_exit() { echo "tmpDir==$TMP_DIR==" %%ENV_VAR_LINES%% echo "%%SCRIPT_ID%%: end" +} + +fail_and_exit() { + print_install_results "$1" exit 0 } @@ -90,7 +94,7 @@ if command -v flock >/dev/null 2>&1; then if flock --help 2>&1 | grep -q -- '-w'; then # wait 30s to acquire lock, otherwise fail - flock -x -w 30 $FD || print_install_results_and_exit 1 + flock -x -w 30 $FD || print_install_results 1 else ELAPSED=0 @@ -106,7 +110,7 @@ if command -v flock >/dev/null 2>&1; then if [[ $ELAPSED -ge 30 ]]; then echo "Warning: flock cannot acquire the install lock" - print_install_results_and_exit 1 + print_install_results 1 fi fi @@ -118,7 +122,7 @@ fi # Check if platform is supported if ! command -v uname >/dev/null 2>&1; then echo "Error: 'uname' command not found, could not get platform/arch data." - print_install_results_and_exit 1 + print_install_results 1 fi # https://www.freedesktop.org/software/systemd/man/os-release.html @@ -134,7 +138,7 @@ fi if [[ ! -d $SERVER_DIR ]]; then mkdir -p $SERVER_DIR || ( echo "Error: creating server install directory" - print_install_results_and_exit 1 + print_install_results 1 ) fi @@ -158,7 +162,7 @@ if [[ ! -f $SERVER_SCRIPT ]]; then ;; *) echo "Error: '$PLATFORM' needs manual installation of remote extension host" - print_install_results_and_exit 1 + print_install_results 1 ;; esac @@ -172,25 +176,25 @@ if [[ ! -f $SERVER_SCRIPT ]]; then fetch --retry --timeout=10 --quiet --output=vscode-server.tar.gz "$SERVER_DOWNLOAD_URL" else echo "Error: no tool to download server binary" - print_install_results_and_exit 1 + print_install_results 1 fi if (( $? > 0 )); then echo "Error downloading server from $SERVER_DOWNLOAD_URL" rm -rf vscode-server.tar.gz - print_install_results_and_exit 1 + print_install_results 1 fi tar -xOf vscode-server.tar.gz > /dev/null 2>&1 || ( echo "Error downloaded tarball is corrupt or incomplete" rm -rf vscode-server.tar.gz - print_install_results_and_exit 1 + print_install_results 1 ) tar -xf vscode-server.tar.gz --strip-components 1 || ( echo "Error while extracting server contents" rm -rf vscode-server.tar.gz - print_install_results_and_exit 1 + print_install_results 1 ) rm -f vscode-server.tar.gz @@ -202,7 +206,7 @@ if [[ ! -f $SERVER_SCRIPT ]]; then rm -rf $SERVER_DIR/* - print_install_results_and_exit 1 + print_install_results 1 fi if ! validate_installed_commit; then @@ -211,7 +215,7 @@ if [[ ! -f $SERVER_SCRIPT ]]; then rm -rf "$SERVER_DIR"/* - print_install_results_and_exit 1 + print_install_results 1 fi else echo "Server script already installed in $SERVER_SCRIPT" @@ -258,7 +262,7 @@ if [[ -f $SERVER_TOKENFILE ]]; then SERVER_CONNECTION_TOKEN="$(cat $SERVER_TOKENFILE)" else echo "Error: server token file not found $SERVER_TOKENFILE" - print_install_results_and_exit 1 + print_install_results 1 fi waitUntil=$((SECONDS+20)) @@ -281,15 +285,15 @@ done if [[ ! -f $SERVER_LOGFILE ]]; then echo "Error: server log file not found $SERVER_LOGFILE" - print_install_results_and_exit 1 + print_install_results 1 fi if [[ -z $LISTENING_ON ]]; then echo "Error: server did not start successfully" - print_install_results_and_exit 1 + print_install_results 1 fi # Finish server setup -print_install_results_and_exit 0 +print_install_results 0 if (( SCRIPT_PREVENT_EXIT )); then while true; diff --git a/test/fixtures.test.ts b/test/fixtures.test.ts index 148da46f..02a80483 100644 --- a/test/fixtures.test.ts +++ b/test/fixtures.test.ts @@ -114,8 +114,8 @@ for (const file of files.value) { it(`test-${name}`, async () => { vol.fromJSON({ ...client.files, - '/data/vscodium/extensions/open-remote-ssh/src/scripts/server-setup.sh': SERVER_SETUP_BASH, - '/data/vscodium/extensions/open-remote-ssh/src/scripts/server-setup.ps1': SERVER_SETUP_POWERSHELL, + '/data/vscodium/extensions/open-remote-ssh/lib/scripts/server-setup.sh': SERVER_SETUP_BASH, + '/data/vscodium/extensions/open-remote-ssh/lib/scripts/server-setup.ps1': SERVER_SETUP_POWERSHELL, }); vscode.window.setPassword(server.password); diff --git a/test/forward-agent.test.ts b/test/forward-agent.test.ts index 160af2f2..7600bdcc 100644 --- a/test/forward-agent.test.ts +++ b/test/forward-agent.test.ts @@ -87,7 +87,7 @@ it('forwards the agent through a socket that stays alive', async () => { ` IdentityAgent ${authSock}`, ].join('\n'), '/bin/vscodium/app/product.json': PRODUCT_JSON, - '/data/vscodium/extensions/open-remote-ssh/src/scripts/server-setup.sh': SERVER_SETUP, + '/data/vscodium/extensions/open-remote-ssh/lib/scripts/server-setup.sh': SERVER_SETUP, }); vscode.window.setPassword(PASSWORD); diff --git a/test/gssapi.test.ts b/test/gssapi.test.ts index 6b26c6ab..cca4fd33 100644 --- a/test/gssapi.test.ts +++ b/test/gssapi.test.ts @@ -116,7 +116,7 @@ it('connects through the local ssh client when GSSAPI is requested', async () => vol.fromJSON({ [configPath]: config, '/bin/vscodium/app/product.json': PRODUCT_JSON, - '/data/vscodium/extensions/open-remote-ssh/src/scripts/server-setup.sh': SERVER_SETUP, + '/data/vscodium/extensions/open-remote-ssh/lib/scripts/server-setup.sh': SERVER_SETUP, }); vscode.workspace.setConfiguration('configFile', configPath); diff --git a/test/host-key.test.ts b/test/host-key.test.ts index 006ba883..619ce524 100644 --- a/test/host-key.test.ts +++ b/test/host-key.test.ts @@ -96,7 +96,7 @@ beforeAll(async () => { ` Password ${PASSWORD}`, ].join('\n'), '/bin/vscodium/app/product.json': PRODUCT_JSON, - '/data/vscodium/extensions/open-remote-ssh/src/scripts/server-setup.sh': SERVER_SETUP, + '/data/vscodium/extensions/open-remote-ssh/lib/scripts/server-setup.sh': SERVER_SETUP, }); }, 120_000); diff --git a/test/nested-proxy-jump.test.ts b/test/nested-proxy-jump.test.ts index 02d05fbd..81c3cf00 100644 --- a/test/nested-proxy-jump.test.ts +++ b/test/nested-proxy-jump.test.ts @@ -135,7 +135,7 @@ it('follows a ProxyJump declared by a jump host', async () => { ` Password ${PASSWORD}`, ].join('\n'), '/bin/vscodium/app/product.json': PRODUCT_JSON, - '/data/vscodium/extensions/open-remote-ssh/src/scripts/server-setup.sh': SERVER_SETUP, + '/data/vscodium/extensions/open-remote-ssh/lib/scripts/server-setup.sh': SERVER_SETUP, }); vscode.window.setPassword(PASSWORD); diff --git a/test/proxy-jump.test.ts b/test/proxy-jump.test.ts index d45d08cf..77e78c10 100644 --- a/test/proxy-jump.test.ts +++ b/test/proxy-jump.test.ts @@ -123,7 +123,7 @@ it('connects to a target reachable only through a ProxyJump host', async () => { ` Password ${PASSWORD}`, ].join('\n'), '/bin/vscodium/app/product.json': PRODUCT_JSON, - '/data/vscodium/extensions/open-remote-ssh/src/scripts/server-setup.sh': SERVER_SETUP, + '/data/vscodium/extensions/open-remote-ssh/lib/scripts/server-setup.sh': SERVER_SETUP, }); vscode.window.setPassword(PASSWORD); diff --git a/test/server-startup.test.ts b/test/server-startup.test.ts index 6150c938..d65b6e10 100644 --- a/test/server-startup.test.ts +++ b/test/server-startup.test.ts @@ -5,7 +5,8 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; function checkStartup(scenario: 'delayed' | 'missing' | 'empty' | 'library') { - const source = readFileSync('src/scripts/server-setup.sh', 'utf8'); + let source = readFileSync('src/scripts/server-setup.sh', 'utf8'); + source = source.replace('waitUntil=$((SECONDS+20))', 'waitUntil=$((SECONDS+1))'); const starts = [...source.matchAll(/^if \[\[ -f \$SERVER_TOKENFILE \]\]; then$/gm)]; expect(starts).toHaveLength(1); const readiness = source.slice(starts[0].index); @@ -24,6 +25,7 @@ function checkStartup(scenario: 'delayed' | 'missing' | 'empty' | 'library') { // Replace only the clock and result callback. The real readiness code // sees a missing log, then an empty log, then the listening address. const stub = ` +SCRIPT_PREVENT_EXIT=0 LISTENING_ON= ticks=0 sleep() { @@ -34,11 +36,17 @@ sleep() { 2) printf 'Extension host agent listening on 45707\\n' > "$SERVER_LOGFILE" ;; esac fi + return 0 } -print_install_results_and_exit() { +print_install_results() { printf 'result=%s ticks=%s listening=%s\\n' "$1" "$ticks" "$LISTENING_ON" exit "$1" } + +fail_and_exit() { + print_install_results "$1" + exit 0 +} `; const result = spawnSync('bash', ['-c', stub + readiness], { encoding: 'utf8', @@ -69,7 +77,6 @@ describe.skipIf(process.platform === 'win32')('server startup readiness', () => const result = checkStartup('missing'); expect(result.status).toBe(1); expect(result.output).toContain('Error: server log file not found'); - expect(result.output).toContain('result=1 ticks=35 listening='); expect(result.logExists).toBe(false); }); @@ -77,7 +84,6 @@ describe.skipIf(process.platform === 'win32')('server startup readiness', () => const result = checkStartup('empty'); expect(result.status).toBe(1); expect(result.output).toContain('Error: server did not start successfully'); - expect(result.output).toContain('result=1 ticks=35 listening='); expect(result.logExists).toBe(true); }); From 40554bc6aa8a8676fde46544289ddeeb22baac7a Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Tue, 27 Oct 2026 15:22:07 -0500 Subject: [PATCH 05/26] fix: fix tests --- test/nested-proxy-jump.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/nested-proxy-jump.test.ts b/test/nested-proxy-jump.test.ts index 81c3cf00..322b59a1 100644 --- a/test/nested-proxy-jump.test.ts +++ b/test/nested-proxy-jump.test.ts @@ -8,7 +8,7 @@ import * as vscode from './mocks/vscode'; import { runDocker } from './utils/run-docker'; import { getMappedPort } from './utils/get-mapped-port'; import { waitForSSHReady } from './utils/wait-for-ssh-ready'; -import { sleep } from './utils/sleep'; +import { sleep } from '../src/common/sleep'; const SERVER_SETUP = fse.readFile('./src/scripts/server-setup.sh', 'utf8').value!; From ea5de4c5c92b75bef6395b5ff9272ea948a2720c Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Tue, 27 Oct 2026 16:26:57 -0500 Subject: [PATCH 06/26] fix: fix tests --- test/proxy-jump.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/proxy-jump.test.ts b/test/proxy-jump.test.ts index 77e78c10..3933ac9d 100644 --- a/test/proxy-jump.test.ts +++ b/test/proxy-jump.test.ts @@ -8,7 +8,7 @@ import * as vscode from './mocks/vscode'; import { runDocker } from './utils/run-docker'; import { getMappedPort } from './utils/get-mapped-port'; import { waitForSSHReady } from './utils/wait-for-ssh-ready'; -import { sleep } from './utils/sleep'; +import { sleep } from '../src/common/sleep'; const SERVER_SETUP = fse.readFile('./src/scripts/server-setup.sh', 'utf8').value!; From 55618ef8326678455ba71b0c2a335cd1c313dd50 Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Tue, 27 Oct 2026 16:32:45 -0500 Subject: [PATCH 07/26] :lipstick: --- src/native/nativeSshConnection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/native/nativeSshConnection.ts b/src/native/nativeSshConnection.ts index 118cb3ea..c2ee0f97 100644 --- a/src/native/nativeSshConnection.ts +++ b/src/native/nativeSshConnection.ts @@ -32,7 +32,7 @@ export async function resolveNativeSshExecutable(explicitPath?: string): Promise export function getNativeSshSocketPath(label: string): string { const randomStr = crypto.randomBytes(15).toString('hex'); if (process.platform === 'win32') { - return `\\\\.\\pipe\\open-remote-ssh-${label}}-${randomStr}`; + return `\\\\.\\pipe\\open-remote-ssh-${label}-${randomStr}`; } return process.env.XDG_RUNTIME_DIR ? path.join(process.env.XDG_RUNTIME_DIR, `open-remote-ssh-${label}-${randomStr}.sock`) From 6021b60996189d49387b4e99d57eaab7676f420b Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Tue, 27 Oct 2026 17:10:47 -0500 Subject: [PATCH 08/26] :lipstick: --- src/native/nativeSshAskpass.ts | 4 +++- src/native/nativeSshManager.ts | 8 +++----- src/native/nativeSshServer.ts | 8 ++++++-- src/native/nativeSshServerClient.ts | 12 ++++-------- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/native/nativeSshAskpass.ts b/src/native/nativeSshAskpass.ts index db86f6a5..d2f6321f 100644 --- a/src/native/nativeSshAskpass.ts +++ b/src/native/nativeSshAskpass.ts @@ -54,7 +54,7 @@ export class AskPassServer { socketPath: string ): Promise { this._socketPath = socketPath; - const server = http.createServer((req, res) => { + this.server = http.createServer((req, res) => { req.setEncoding('utf8'); if (req.method === 'POST' && req.url === '/prompt') { this.handleNativeSshPromptRequest(req, res); @@ -64,6 +64,8 @@ export class AskPassServer { res.statusCode = 404; res.end(); }); + + const server = this.server; server.setTimeout(300_000); return new Promise((resolve, reject) => { diff --git a/src/native/nativeSshManager.ts b/src/native/nativeSshManager.ts index 06df06ad..4043c679 100644 --- a/src/native/nativeSshManager.ts +++ b/src/native/nativeSshManager.ts @@ -534,7 +534,7 @@ export class NativeSshManager { throw new Error(`Native SSH local server entrypoint was not found: ${serverScriptPath}`); } - await this.unlinkStaleSocket(); + await this.unlinkStaleSocket(serverScriptPath); const serverConfig: NativeSshServerConfig = { sshExecutable: this.connection.sshExecutable, @@ -680,14 +680,12 @@ export class NativeSshManager { ); } - private async unlinkStaleSocket(): Promise { + private async unlinkStaleSocket(socketPath: string): Promise { if (process.platform === 'win32') { return; } try { - if (this.serverInfo) { - await fs.unlink(this.serverInfo.socketPath); - } + await fs.unlink(socketPath); } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { throw err; diff --git a/src/native/nativeSshServer.ts b/src/native/nativeSshServer.ts index 11344fe3..e70716b8 100644 --- a/src/native/nativeSshServer.ts +++ b/src/native/nativeSshServer.ts @@ -27,10 +27,14 @@ function removeNativeSshHelperRegistry(registryFilePath: string, pid: number) { if (!entry || entry.pid !== pid) { return; } - fs.unlinkSync(registryFilePath); + try { + fs.unlinkSync(registryFilePath); + } catch { + // noop + } } -class NativeSshHelperServer { +export class NativeSshHelperServer { private readonly config: NativeSshServerConfig; private readonly server: http.Server; private sshProcess: NativeSshProcess | undefined; diff --git a/src/native/nativeSshServerClient.ts b/src/native/nativeSshServerClient.ts index 6507d3f0..974f61ec 100644 --- a/src/native/nativeSshServerClient.ts +++ b/src/native/nativeSshServerClient.ts @@ -34,7 +34,7 @@ export class NativeSshServerClient { ); request.setTimeout(REQUEST_TIMEOUT_MS, () => { - request.destroy(new Error('Native SSH helper request timed out.')); + request.destroy(new Error('SSH server request timed out.')); }); request.on('error', reject); request.end(); @@ -51,13 +51,9 @@ export class NativeSshServerClient { } async keepalive(): Promise { - try { - const res = await this.request('POST', '/keepalive'); - if (res.statusCode !== 200) { - throw new Error(`${res.statusCode} ${res.body}`); - } - } catch { - // Helper is gone; the caller's next spawn() will notice. + const res = await this.request('POST', '/keepalive'); + if (res.statusCode !== 200) { + throw new Error(`${res.statusCode} ${res.body}`); } } } From 5eae1fdff749de151bb50b42b799b766242c398c Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Wed, 28 Oct 2026 15:06:31 -0500 Subject: [PATCH 09/26] fixes --- src/authResolver.ts | 6 +++++- src/native/nativeSshAskpass.ts | 15 +++++++++++---- src/native/nativeSshManager.ts | 12 +++++++++--- src/native/nativeSshParser.ts | 2 +- src/native/nativeSshServerClient.ts | 2 +- src/scripts/server-setup.sh | 28 ++++++++++++++-------------- 6 files changed, 41 insertions(+), 24 deletions(-) diff --git a/src/authResolver.ts b/src/authResolver.ts index 28b8c1c1..726922dc 100644 --- a/src/authResolver.ts +++ b/src/authResolver.ts @@ -319,6 +319,10 @@ export class RemoteSSHResolver implements vscode.RemoteAuthorityResolver, vscode if (connectionBackend === 'native') { try { + if (remoteServerListenOnSocket) { + throw new Error('remoteServerListenOnSocket not supported in native connection mode yet'); + } + const sshExecutable = await resolveNativeSshExecutable(configuredSshPath); const socksPort = await findRandomPort(); @@ -337,7 +341,7 @@ export class RemoteSSHResolver implements vscode.RemoteAuthorityResolver, vscode const marker = crypto.randomBytes(12).toString('hex'); const connection: NativeSshConnectionOptions = { - authorityKey: destination.toString(), + authorityKey: destination.hostname, sshExecutable, sshArgs, label: marker, diff --git a/src/native/nativeSshAskpass.ts b/src/native/nativeSshAskpass.ts index d2f6321f..916d4fde 100644 --- a/src/native/nativeSshAskpass.ts +++ b/src/native/nativeSshAskpass.ts @@ -4,6 +4,7 @@ import { exists } from '../common/files'; import { parsePrompt, PromptKind } from './nativeSshParser'; import { getNativeSshSocketPath } from './nativeSshConnection'; import * as vscode from 'vscode'; +import { ILogger } from '../common/logger'; async function askEntry(prompt: string): Promise { return vscode.window.showInputBox({ @@ -44,7 +45,9 @@ export class AskPassServer { private _socketPath!: string; private closed = false; - private constructor() { } + private constructor( + private logger: ILogger + ) { } public get socketPath() : string { return this._socketPath; @@ -57,7 +60,11 @@ export class AskPassServer { this.server = http.createServer((req, res) => { req.setEncoding('utf8'); if (req.method === 'POST' && req.url === '/prompt') { - this.handleNativeSshPromptRequest(req, res); + this.handleNativeSshPromptRequest(req, res) + .catch(err => { + const message = err instanceof Error ? err.message : String(err); + this.logger.error(`Askpass server error: ${message}`); + }) ; return; } @@ -130,9 +137,9 @@ export class AskPassServer { }); } - static async create(): Promise { + static async create(logger:ILogger): Promise { const socketPath = getNativeSshSocketPath('askpass'); - const askpassServer = new AskPassServer(); + const askpassServer = new AskPassServer(logger); await askpassServer.initialize(socketPath); return askpassServer; } diff --git a/src/native/nativeSshManager.ts b/src/native/nativeSshManager.ts index 4043c679..dd10ce51 100644 --- a/src/native/nativeSshManager.ts +++ b/src/native/nativeSshManager.ts @@ -272,11 +272,12 @@ export class NativeSshManager { async runCommand( command: string, predicate: (stdout: string | undefined, stderr: string | undefined) => T | undefined, + timeout?: number ): Promise { if (!this.cmdRunner) { throw new Error('No helper process owned by this window.'); } - return this.cmdRunner.run(command, predicate); + return this.cmdRunner.run(command, predicate, timeout); } private async nativeDetectPlatform( @@ -441,7 +442,8 @@ export class NativeSshManager { return { stdout: stdoutBuffer, stderr: stderrBuffer }; } return undefined; - } + }, + 90_000 ); } @@ -463,6 +465,8 @@ export class NativeSshManager { ? parseInt(resultMap.listeningOn, 10) : resultMap.listeningOn; + const remoteEnvVars = Object.fromEntries(Object.entries(resultMap).filter(([key,]) => envVariables.includes(key))); + return { exitCode, listeningOn, @@ -472,6 +476,7 @@ export class NativeSshManager { arch: resultMap.arch || installOptions.serverArch, platform: resultMap.platform, tmpDir: resultMap.tmpDir, + ...remoteEnvVars }; } @@ -523,7 +528,8 @@ export class NativeSshManager { return { stdout: stdoutBuffer, stderr: stderrBuffer }; } return undefined; - } + }, + 90_000 ); return runResult; } diff --git a/src/native/nativeSshParser.ts b/src/native/nativeSshParser.ts index f8449239..93bef0bc 100644 --- a/src/native/nativeSshParser.ts +++ b/src/native/nativeSshParser.ts @@ -150,7 +150,7 @@ export class CommandRunner { const stderrSubscription = this.stderrParser.subscribe({ onLine: (line) => { try { - const value = predicate(line, undefined); + const value = predicate(undefined, line); if (value !== undefined) { finish(undefined, value); } diff --git a/src/native/nativeSshServerClient.ts b/src/native/nativeSshServerClient.ts index 974f61ec..89d852b1 100644 --- a/src/native/nativeSshServerClient.ts +++ b/src/native/nativeSshServerClient.ts @@ -89,7 +89,7 @@ export class NativeSshKeepalive { } private ping(): void { - void this.client.keepalive().catch((err: unknown) => { + this.client.keepalive().catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err); this.logger.error(`keepalive failed: ${message}`); }); diff --git a/src/scripts/server-setup.sh b/src/scripts/server-setup.sh index d66f966f..81f86457 100644 --- a/src/scripts/server-setup.sh +++ b/src/scripts/server-setup.sh @@ -94,7 +94,7 @@ if command -v flock >/dev/null 2>&1; then if flock --help 2>&1 | grep -q -- '-w'; then # wait 30s to acquire lock, otherwise fail - flock -x -w 30 $FD || print_install_results 1 + flock -x -w 30 $FD || fail_and_exit 1 else ELAPSED=0 @@ -110,7 +110,7 @@ if command -v flock >/dev/null 2>&1; then if [[ $ELAPSED -ge 30 ]]; then echo "Warning: flock cannot acquire the install lock" - print_install_results 1 + fail_and_exit 1 fi fi @@ -122,7 +122,7 @@ fi # Check if platform is supported if ! command -v uname >/dev/null 2>&1; then echo "Error: 'uname' command not found, could not get platform/arch data." - print_install_results 1 + fail_and_exit 1 fi # https://www.freedesktop.org/software/systemd/man/os-release.html @@ -138,7 +138,7 @@ fi if [[ ! -d $SERVER_DIR ]]; then mkdir -p $SERVER_DIR || ( echo "Error: creating server install directory" - print_install_results 1 + fail_and_exit 1 ) fi @@ -162,7 +162,7 @@ if [[ ! -f $SERVER_SCRIPT ]]; then ;; *) echo "Error: '$PLATFORM' needs manual installation of remote extension host" - print_install_results 1 + fail_and_exit 1 ;; esac @@ -176,25 +176,25 @@ if [[ ! -f $SERVER_SCRIPT ]]; then fetch --retry --timeout=10 --quiet --output=vscode-server.tar.gz "$SERVER_DOWNLOAD_URL" else echo "Error: no tool to download server binary" - print_install_results 1 + fail_and_exit 1 fi if (( $? > 0 )); then echo "Error downloading server from $SERVER_DOWNLOAD_URL" rm -rf vscode-server.tar.gz - print_install_results 1 + fail_and_exit 1 fi tar -xOf vscode-server.tar.gz > /dev/null 2>&1 || ( echo "Error downloaded tarball is corrupt or incomplete" rm -rf vscode-server.tar.gz - print_install_results 1 + fail_and_exit 1 ) tar -xf vscode-server.tar.gz --strip-components 1 || ( echo "Error while extracting server contents" rm -rf vscode-server.tar.gz - print_install_results 1 + fail_and_exit 1 ) rm -f vscode-server.tar.gz @@ -206,7 +206,7 @@ if [[ ! -f $SERVER_SCRIPT ]]; then rm -rf $SERVER_DIR/* - print_install_results 1 + fail_and_exit 1 fi if ! validate_installed_commit; then @@ -215,7 +215,7 @@ if [[ ! -f $SERVER_SCRIPT ]]; then rm -rf "$SERVER_DIR"/* - print_install_results 1 + fail_and_exit 1 fi else echo "Server script already installed in $SERVER_SCRIPT" @@ -262,7 +262,7 @@ if [[ -f $SERVER_TOKENFILE ]]; then SERVER_CONNECTION_TOKEN="$(cat $SERVER_TOKENFILE)" else echo "Error: server token file not found $SERVER_TOKENFILE" - print_install_results 1 + fail_and_exit 1 fi waitUntil=$((SECONDS+20)) @@ -285,11 +285,11 @@ done if [[ ! -f $SERVER_LOGFILE ]]; then echo "Error: server log file not found $SERVER_LOGFILE" - print_install_results 1 + fail_and_exit 1 fi if [[ -z $LISTENING_ON ]]; then echo "Error: server did not start successfully" - print_install_results 1 + fail_and_exit 1 fi # Finish server setup From 4668ee0d6024c39d4818de17767adf8b49780867 Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Wed, 28 Oct 2026 15:06:47 -0500 Subject: [PATCH 10/26] Add unit tests --- test/native-ssh-parser.test.ts | 158 ++++++++++++++++++++++++++ test/nativeSshAskpass.test.ts | 143 ++++++++++++++++++++++++ test/nativeSshServer.test.ts | 153 +++++++++++++++++++++++++ test/nativeSshServerClient.test.ts | 172 +++++++++++++++++++++++++++++ 4 files changed, 626 insertions(+) create mode 100644 test/native-ssh-parser.test.ts create mode 100644 test/nativeSshAskpass.test.ts create mode 100644 test/nativeSshServer.test.ts create mode 100644 test/nativeSshServerClient.test.ts diff --git a/test/native-ssh-parser.test.ts b/test/native-ssh-parser.test.ts new file mode 100644 index 00000000..90abdb50 --- /dev/null +++ b/test/native-ssh-parser.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + parsePrompt, + AUTH_CONFIRMED_RE, + LineParser, + CommandRunner, +} from '../src/native/nativeSshParser'; + +describe('parsePrompt', () => { + it.each([ + ['Are you sure you want to continue connecting (yes/no)? ', 'host-key-confirm'], + ['Are you sure you want to continue connecting (yes/no/[fingerprint])? ', 'host-key-confirm-fp'], + ['Please type \'yes\' or \'no\': ', 'host-key-retry'], + ['Please type \'yes\', \'no\' or the fingerprint: ', 'host-key-retry-fp'], + ['user@host\'s password: ', 'entry'], + ['Enter passphrase for key \'/home/u/.ssh/id_rsa\': ', 'entry'], + ['Password: ', 'entry'], + ['Welcome to Ubuntu 22.04 LTS', 'entry'], + ] as const)('classifies %j as %s', (input, expected) => { + expect(parsePrompt(input).kind).toBe(expected); + }); + + // Load-bearing: the -fp patterns must come before the generic ones. + it('prefers the fingerprint variant over the generic variant', () => { + expect(parsePrompt('Please type \'yes\', \'no\' or the fingerprint: ').kind) + .toBe('host-key-retry-fp'); + expect(parsePrompt('Are you sure you want to continue connecting (yes/no/[fingerprint])?').kind) + .toBe('host-key-confirm-fp'); + }); + + it('returns the prompt verbatim as the message', () => { + const input = 'user@host\'s password: '; + expect(parsePrompt(input).message).toBe(input); + }); +}); + +describe('AUTH_CONFIRMED_RE', () => { + it.each([ + // Pre-8.7 + ['debug1: Authentication succeeded (publickey).', true], + // 8.7+ + ['Authenticated to host ([10.0.0.1]:22) using "publickey".', true], + ['Authenticated to host ([::1]:22) using "none".', true], + // Non-matches + ['Authenticated to host ([10.0.0.1]:22).', false], + ['Permission denied (publickey,password).', false], + ['debug1: SSH2_MSG_SERVICE_ACCEPT received', false], + ])('%j → %s', (line, expected) => { + expect(AUTH_CONFIRMED_RE.test(line)).toBe(expected); + }); +}); + +describe('LineParser', () => { + it('buffers a partial line across chunks', () => { + const lines: string[] = []; + const parser = new LineParser({ onLine: (l) => lines.push(l) }); + parser.push('foo'); + parser.push('bar\n'); + expect(lines).toEqual(['foobar']); + }); + + it('strips CR from CRLF line endings', () => { + const lines: string[] = []; + const parser = new LineParser({ onLine: (l) => lines.push(l) }); + parser.push('a\r\nb\r\n'); + expect(lines).toEqual(['a', 'b']); + }); + + it('flushes a partial line on end()', () => { + const lines: string[] = []; + const flushes: string[] = []; + const errors: string[] = []; + const parser = new LineParser({ + onLine: (l) => lines.push(l), + onFlush: (l) => flushes.push(l), + onError: (r) => errors.push(r), + }); + parser.push('incomplete'); + parser.end('closed'); + expect(flushes).toEqual(['incomplete']); + expect(errors).toEqual(['closed']); + }); + + it('ignores pushes after end()', () => { + const lines: string[] = []; + const parser = new LineParser({ onLine: (l) => lines.push(l) }); + parser.end(); + parser.push('ignored\n'); + expect(lines).toEqual([]); + }); + + it('subscription.dispose stops delivery', () => { + const parser = new LineParser(); + const a: string[] = []; + const b: string[] = []; + parser.subscribe({ onLine: (l) => a.push(l) }); + const subB = parser.subscribe({ onLine: (l) => b.push(l) }); + parser.push('1\n'); + subB.dispose(); + parser.push('2\n'); + expect(a).toEqual(['1', '2']); + expect(b).toEqual(['1']); + }); +}); + +describe('CommandRunner', () => { + let stdout: LineParser; + let stderr: LineParser; + let write: ReturnType; + let runner: CommandRunner; + + beforeEach(() => { + stdout = new LineParser(); + stderr = new LineParser(); + write = vi.fn().mockResolvedValue(undefined); + runner = new CommandRunner(stdout, stderr, write as unknown as (d: string) => Promise); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('writes the command and resolves with the predicate value', async () => { + const promise = runner.run('ls\n', (s) => (s === 'done' ? 'ok' : undefined)); + expect(write).toHaveBeenCalledWith('ls\n'); + stdout.push('done\n'); + await expect(promise).resolves.toBe('ok'); + }); + + it('rejects when write() fails', async () => { + write.mockRejectedValueOnce(new Error('EPIPE')); + await expect(runner.run('cmd\n', () => undefined)).rejects.toThrow('EPIPE'); + }); + + it('rejects when a parser ends before a match', async () => { + const promise = runner.run('cmd\n', () => undefined); + stdout.end('helper closed'); + await expect(promise).rejects.toThrow(/helper closed/); + }); + + it('rejects after timeoutMs', async () => { + vi.useFakeTimers(); + const promise = runner.run('cmd\n', () => undefined, 5_000); + const settled = promise.catch((e: Error) => e); + await vi.advanceTimersByTimeAsync(5_000); + const err = await settled; + expect((err as Error).message).toMatch(/timed out after 5000ms/); + }); + + it('disposes subscriptions on resolution', async () => { + const promise = runner.run('cmd\n', (s) => (s === 'hit' ? true : undefined)); + stdout.push('hit\n'); + await promise; + // If the runner leaked its subscription, a subsequent end() would + // try to reject an already-settled promise — this must not throw. + expect(() => stdout.end()).not.toThrow(); + }); +}); diff --git a/test/nativeSshAskpass.test.ts b/test/nativeSshAskpass.test.ts new file mode 100644 index 00000000..f932b537 --- /dev/null +++ b/test/nativeSshAskpass.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('vscode', () => ({ + window: { + showInputBox: vi.fn(), + showQuickPick: vi.fn(), + }, +})); + +vi.mock('../src/native/nativeSshConnection', () => ({ + getNativeSshSocketPath: vi.fn((label: string) => + `${process.env.TMPDIR ?? '/tmp'}/open-remote-ssh-${label}-${Math.random().toString(16).slice(2)}.sock`, + ), +})); + +import * as vscode from 'vscode'; +import * as http from 'http'; +import { AskPassServer } from '../src/native/nativeSshAskpass'; +import { Log } from './mocks/logger'; + +const mockedVscode = vscode as unknown as { + window: { + showInputBox: ReturnType; + showQuickPick: ReturnType; + }; +}; + +interface PromptResponse { + response?: string; + cancelled?: boolean; +} + +function postPrompt(socketPath: string, prompt: string): Promise<{ statusCode: number | undefined; body: string }> { + return new Promise((resolve, reject) => { + const payload = JSON.stringify({ prompt }); + const request = http.request( + { + socketPath, + path: '/prompt', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + }, + }, + (response) => { + let body = ''; + response.setEncoding('utf8'); + response.on('data', (c: string) => { body += c; }); + response.on('end', () => resolve({ statusCode: response.statusCode, body })); + }, + ); + request.on('error', reject); + request.write(payload); + request.end(); + }); +} + +describe('AskPassServer', () => { + let server: AskPassServer; + + beforeEach(async () => { + mockedVscode.window.showInputBox.mockReset(); + mockedVscode.window.showQuickPick.mockReset(); + server = await AskPassServer.create(new Log('Test AskPassServer')); + }); + + it('routes an entry prompt through showInputBox', async () => { + mockedVscode.window.showInputBox.mockResolvedValue('hunter2'); + const res = await postPrompt(server.socketPath, 'user@host\'s password: '); + const parsed = JSON.parse(res.body) as PromptResponse; + expect(res.statusCode).toBe(200); + expect(parsed.response).toBe('hunter2'); + expect(parsed.cancelled).toBe(false); + expect(mockedVscode.window.showInputBox).toHaveBeenCalledWith( + expect.objectContaining({ password: true }), + ); + }); + + it('routes a host-key confirmation through showQuickPick', async () => { + mockedVscode.window.showQuickPick.mockResolvedValue('yes'); + const res = await postPrompt( + server.socketPath, + 'Are you sure you want to continue connecting (yes/no)? ', + ); + const parsed = JSON.parse(res.body) as PromptResponse; + expect(parsed.response).toBe('yes'); + expect(mockedVscode.window.showQuickPick).toHaveBeenCalled(); + }); + + it('offers the fingerprint option on fingerprint-variant confirm', async () => { + mockedVscode.window.showQuickPick.mockResolvedValue('Enter fingerprint…'); + mockedVscode.window.showInputBox.mockResolvedValue('SHA256:abc'); + const res = await postPrompt( + server.socketPath, + 'Are you sure you want to continue connecting (yes/no/[fingerprint])? ', + ); + const parsed = JSON.parse(res.body) as PromptResponse; + expect(parsed.response).toBe('SHA256:abc'); + const choices = mockedVscode.window.showQuickPick.mock.calls[0][0] as string[]; + expect(choices).toContain('Enter fingerprint…'); + }); + + it('reports cancellation when the user dismisses the input box', async () => { + mockedVscode.window.showInputBox.mockResolvedValue(undefined); + const res = await postPrompt(server.socketPath, 'user@host\'s password: '); + const parsed = JSON.parse(res.body) as PromptResponse; + expect(parsed.cancelled).toBe(true); + expect(parsed.response).toBeUndefined(); + }); + + it('returns 500 on a malformed body', async () => { + const res = await new Promise<{ statusCode: number | undefined }>((resolve, reject) => { + const req = http.request( + { + socketPath: server.socketPath, + path: '/prompt', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }, + (response) => { + response.resume(); + response.on('end', () => resolve({ statusCode: response.statusCode })); + }, + ); + req.on('error', reject); + req.write('not-json'); + req.end(); + }); + expect(res.statusCode).toBe(500); + }); + + // Regression: initialize() used to never assign this.server, so + // dispose() threw on this.server.close(). + it('dispose() closes cleanly', async () => { + await expect(server.dispose()).resolves.not.toThrow(); + }); + + it('dispose() is idempotent', async () => { + await server.dispose(); + await expect(server.dispose()).resolves.not.toThrow(); + }); +}); diff --git a/test/nativeSshServer.test.ts b/test/nativeSshServer.test.ts new file mode 100644 index 00000000..d116fa35 --- /dev/null +++ b/test/nativeSshServer.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import * as http from 'http'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { EventEmitter } from 'events'; + +class FakeChildProcess extends EventEmitter { + pid = 12345; + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + killed = false; + stdout = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }); + stderr = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }); + stdin = Object.assign(new EventEmitter(), { write: vi.fn(), end: vi.fn() }); + kill = vi.fn((signal?: NodeJS.Signals) => { + this.killed = true; + this.signalCode = signal ?? 'SIGTERM'; + return true; + }); +} + +let fakeChild: FakeChildProcess; + +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: vi.fn(() => { + fakeChild = new FakeChildProcess(); + return fakeChild; + }), + }; +}); + +import { NativeSshHelperServer } from '../src/native/nativeSshServer'; +import { NativeSshServerConfig } from '../src/native/nativeSshManager'; + +async function makeServer(overrides: Partial = {}): Promise<{ + server: NativeSshHelperServer; + config: NativeSshServerConfig; + dir: string; +}> { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'native-helper-test-')); + const config: NativeSshServerConfig = { + sshExecutable: '/usr/bin/ssh', + sshArgs: ['-T', 'user@host'], + socketPath: path.join(dir, 'helper.sock'), + registryFilePath: path.join(dir, 'registry.json'), + ...overrides, + }; + await fs.writeFile( + config.registryFilePath, + JSON.stringify({ schemaVersion: 1, socketPath: config.socketPath, pid: process.pid }), + ); + const server = new NativeSshHelperServer(config); + await server.start(); + return { server, config, dir }; +} + +function request( + socketPath: string, + method: 'GET' | 'POST', + route: string, +): Promise<{ statusCode: number | undefined; body: string }> { + return new Promise((resolve, reject) => { + const req = http.request({ socketPath, path: route, method }, (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (c: string) => { body += c; }); + res.on('end', () => resolve({ statusCode: res.statusCode, body })); + }); + req.on('error', reject); + req.end(); + }); +} + +describe('NativeSshHelperServer', () => { + let server: NativeSshHelperServer | undefined; + let dir: string | undefined; + + afterEach(async () => { + server?.close(); + server = undefined; + if (dir) { + await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + dir = undefined; + vi.useRealTimers(); + }); + + it('keepalive endpoint accepts only POST', async () => { + const ctx = await makeServer(); + server = ctx.server; + dir = ctx.dir; + + const post = await request(ctx.config.socketPath, 'POST', '/keepalive'); + expect(post.statusCode).toBe(200); + + const get = await request(ctx.config.socketPath, 'GET', '/keepalive'); + expect(get.statusCode).toBe(404); + }); + + // Regression: `removeNativeSshHelperRegistry` used unlinkSync without + // try/catch, so a race with the manager's cleanup threw ENOENT during + // shutdown. + it('close() does not throw when the registry file is already gone', async () => { + const ctx = await makeServer(); + server = ctx.server; + dir = ctx.dir; + await fs.unlink(ctx.config.registryFilePath); + expect(() => ctx.server.close()).not.toThrow(); + }); + + // The helper self-terminates when keepalives stop. This is the entire + // mechanism behind multi-window reuse — if it breaks, every window + // accumulates orphan helpers. + it('closes after HELPER_IDLE_TIMEOUT_MS without keepalives', async () => { + vi.useFakeTimers(); + const ctx = await makeServer(); + server = ctx.server; + dir = ctx.dir; + + await vi.advanceTimersByTimeAsync(60_000 + 100); + await Promise.resolve(); + + await expect( + request(ctx.config.socketPath, 'GET', '/health'), + ).rejects.toThrow(); + }); + + // The other half of the reuse contract: a keepalive from any window + // resets the timer, so the helper stays up while at least one window + // is alive. + it('keepalive resets the idle timer', async () => { + vi.useFakeTimers(); + const ctx = await makeServer(); + server = ctx.server; + dir = ctx.dir; + + await vi.advanceTimersByTimeAsync(50_000); + await request(ctx.config.socketPath, 'POST', '/keepalive'); + + // Past the original 60s deadline — the keepalive should have + // pushed it out, so the server is still up. + await vi.advanceTimersByTimeAsync(30_000); + const res = await request(ctx.config.socketPath, 'GET', '/health'); + expect(res.statusCode).toBe(200); + + // Drain the remaining timer so afterEach's close() isn't racing it. + await vi.advanceTimersByTimeAsync(60_000); + }); +}); diff --git a/test/nativeSshServerClient.test.ts b/test/nativeSshServerClient.test.ts new file mode 100644 index 00000000..f1e603ca --- /dev/null +++ b/test/nativeSshServerClient.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as http from 'http'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { NativeSshServerClient, NativeSshKeepalive } from '../src/native/nativeSshServerClient'; +import type { ILogger } from '../src/common/logger'; + +interface TestServer { + socketPath: string; + handlers: Map void>; + close(): Promise; +} + +async function startTestServer(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'native-client-test-')); + const socketPath = path.join(dir, 'test.sock'); + const handlers = new Map void>(); + + const server = http.createServer((req, res) => { + const handler = handlers.get(`${req.method} ${req.url}`); + if (handler) { handler(req, res); return; } + res.statusCode = 404; + res.end(JSON.stringify({ error: 'Not found' })); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, () => resolve()); + }); + + return { + socketPath, + handlers, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +function makeLogger(): ILogger & { error: ReturnType } { + return { + trace: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as ILogger & { error: ReturnType }; +} + +describe('NativeSshServerClient', () => { + let server: TestServer; + let client: NativeSshServerClient; + + beforeEach(async () => { + server = await startTestServer(); + client = new NativeSshServerClient(server.socketPath); + }); + + afterEach(async () => { + await server.close(); + }); + + it('isHealthy returns true on 200', async () => { + server.handlers.set('GET /health', (_req, res) => { res.statusCode = 200; res.end('{}'); }); + await expect(client.isHealthy()).resolves.toBe(true); + }); + + it('isHealthy returns false on 404', async () => { + await expect(client.isHealthy()).resolves.toBe(false); + }); + + it('isHealthy returns false when the socket does not exist', async () => { + const missing = new NativeSshServerClient(path.join(os.tmpdir(), `no-such-${Date.now()}.sock`)); + await expect(missing.isHealthy()).resolves.toBe(false); + }); + + it('keepalive sends POST to /keepalive', async () => { + let observed: string | undefined; + server.handlers.set('POST /keepalive', (req, res) => { + observed = req.method; + res.statusCode = 200; + res.end(); + }); + await client.keepalive(); + expect(observed).toBe('POST'); + }); + + // Regression: previously a 404 on /keepalive (e.g. wrong method) was + // silently swallowed and the connection eventually dropped with no + // log. The fix surfaces the failure to the caller. + it('keepalive rejects on 404', async () => { + // No POST /keepalive handler → 404. + await expect(client.keepalive()).rejects.toThrow(/404/); + }); + + it('keepalive rejects when the socket does not exist', async () => { + const missing = new NativeSshServerClient(path.join(os.tmpdir(), `no-such-${Date.now()}.sock`)); + await expect(missing.keepalive()).rejects.toThrow(); + }); +}); + +describe('NativeSshKeepalive', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('pings immediately on start', async () => { + const client = { keepalive: vi.fn().mockResolvedValue(undefined) }; + const ka = new NativeSshKeepalive(client as unknown as NativeSshServerClient, makeLogger(), 60_000); + ka.start(); + await Promise.resolve(); + expect(client.keepalive).toHaveBeenCalledTimes(1); + ka.dispose(); + }); + + it('pings on each interval tick', async () => { + vi.useFakeTimers(); + const client = { keepalive: vi.fn().mockResolvedValue(undefined) }; + const ka = new NativeSshKeepalive(client as unknown as NativeSshServerClient, makeLogger(), 1_000); + ka.start(); + await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(1_000); + expect(client.keepalive).toHaveBeenCalledTimes(3); + ka.dispose(); + }); + + it('start() is idempotent', async () => { + vi.useFakeTimers(); + const client = { keepalive: vi.fn().mockResolvedValue(undefined) }; + const ka = new NativeSshKeepalive(client as unknown as NativeSshServerClient, makeLogger(), 1_000); + ka.start(); + ka.start(); + await vi.advanceTimersByTimeAsync(1_000); + expect(client.keepalive).toHaveBeenCalledTimes(2); + ka.dispose(); + }); + + it('dispose() stops further pings', async () => { + vi.useFakeTimers(); + const client = { keepalive: vi.fn().mockResolvedValue(undefined) }; + const ka = new NativeSshKeepalive(client as unknown as NativeSshServerClient, makeLogger(), 1_000); + ka.start(); + await vi.advanceTimersByTimeAsync(1_000); + ka.dispose(); + await vi.advanceTimersByTimeAsync(5_000); + expect(client.keepalive).toHaveBeenCalledTimes(2); + }); + + it('running reflects the lifecycle', () => { + const ka = new NativeSshKeepalive( + { keepalive: vi.fn().mockResolvedValue(undefined) } as unknown as NativeSshServerClient, + makeLogger(), + 1_000, + ); + expect(ka.running).toBe(false); + ka.start(); + expect(ka.running).toBe(true); + ka.dispose(); + expect(ka.running).toBe(false); + }); + + // Regression: the silent-failure bug. With keepalive() fixed to + // reject, ping()'s catch logs the error. + it('logs when a ping fails', async () => { + const client = { keepalive: vi.fn().mockRejectedValue(new Error('boom')) }; + const logger = makeLogger(); + const ka = new NativeSshKeepalive(client as unknown as NativeSshServerClient, logger, 1_000); + ka.start(); + await Promise.resolve(); + await Promise.resolve(); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('boom')); + ka.dispose(); + }); +}); From 873b840e79de47e3bd3fa79183b664a9483242a8 Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Wed, 28 Oct 2026 15:34:06 -0500 Subject: [PATCH 11/26] fix --- src/authResolver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/authResolver.ts b/src/authResolver.ts index 726922dc..5c10a0ff 100644 --- a/src/authResolver.ts +++ b/src/authResolver.ts @@ -350,7 +350,7 @@ export class RemoteSSHResolver implements vscode.RemoteAuthorityResolver, vscode }; const registry = await NativeSshRegistry.forAuthority(this.context.globalStorageUri.fsPath, connection.authorityKey); - const askpassServer = await AskPassServer.create(); + const askpassServer = await AskPassServer.create(this.logger); this.nativeAskpassServer = askpassServer; const manager = new NativeSshManager({ From fe299eee6eab3a4f9daa1b4eb291628757114fe0 Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Wed, 28 Oct 2026 15:39:32 -0500 Subject: [PATCH 12/26] unlock lock in setup script --- src/scripts/server-setup.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/scripts/server-setup.sh b/src/scripts/server-setup.sh index 81f86457..2a138805 100644 --- a/src/scripts/server-setup.sh +++ b/src/scripts/server-setup.sh @@ -295,6 +295,13 @@ fi # Finish server setup print_install_results 0 +# Release lock +if [[ -n "$FD" ]]; then + flock -u "$FD" 2>/dev/null || true + exec 9>&- + trap - EXIT INT HUP +fi + if (( SCRIPT_PREVENT_EXIT )); then while true; do sleep 150; From 0365d9b7f3c4cfd8865f8339644b1bbba679d32c Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Wed, 28 Oct 2026 17:49:05 -0500 Subject: [PATCH 13/26] :lipstick: --- src/native/nativeSshManager.ts | 36 +++++++++++----------------------- src/native/nativeSshServer.ts | 22 ++++++++++----------- 2 files changed, 21 insertions(+), 37 deletions(-) diff --git a/src/native/nativeSshManager.ts b/src/native/nativeSshManager.ts index dd10ce51..0983f628 100644 --- a/src/native/nativeSshManager.ts +++ b/src/native/nativeSshManager.ts @@ -540,8 +540,6 @@ export class NativeSshManager { throw new Error(`Native SSH local server entrypoint was not found: ${serverScriptPath}`); } - await this.unlinkStaleSocket(serverScriptPath); - const serverConfig: NativeSshServerConfig = { sshExecutable: this.connection.sshExecutable, sshArgs: this.connection.sshArgs, @@ -579,7 +577,8 @@ export class NativeSshManager { } this.options.logger.info(`Spawned ssh server pid=${pid} socket=${serverConfig.socketPath}`); - await this.waitUntilSshServerReady(serverConfig.socketPath, () => spawnError); + this.attachParsers(child); + await this.waitUntilSshServerReady(serverConfig.socketPath, () => spawnError, SERVER_READY_TIMEOUT_MS); const proc: NativeSshServerProcess = { child, @@ -613,7 +612,6 @@ export class NativeSshManager { }; this.serverProcess = proc; - this.attachParsers(proc, pid); return { pid, @@ -622,9 +620,9 @@ export class NativeSshManager { }; } - private attachParsers(proc: NativeSshServerProcess, pid: number): void { - const stdout = proc.child.stdout!; - const stderr = proc.child.stderr!; + private attachParsers(child: cp.ChildProcess): void { + const stdout = child.stdout!; + const stderr = child.stderr!; stdout.setEncoding('utf8'); stderr.setEncoding('utf8'); @@ -643,7 +641,7 @@ export class NativeSshManager { stdout.on('data', (chunk: string) => this.stdoutParser!.push(chunk)); stderr.on('data', (chunk: string) => this.stderrParser!.push(chunk)); - proc.child.once('close', (code, signal) => { + child.once('close', (code, signal) => { const reason = code !== null ? `ssh server closed (code=${code})` : `ssh server killed (signal=${signal ?? 'unknown'})`; @@ -654,9 +652,9 @@ export class NativeSshManager { this.cmdRunner = undefined; if (code !== null) { - this.options.logger.info(`ssh server pid=${pid} exited code=${code}`); + this.options.logger.info(`ssh server pid=${child.pid} exited code=${code}`); } else { - this.options.logger.info(`ssh server pid=${pid} killed by signal=${signal ?? 'unknown'}`); + this.options.logger.info(`ssh server pid=${child.pid} killed by signal=${signal ?? 'unknown'}`); } }); } @@ -664,9 +662,10 @@ export class NativeSshManager { private async waitUntilSshServerReady( socketPath: string, checkSpawnError: () => Error | undefined, + timeout: number ): Promise { const client = new NativeSshServerClient(socketPath); - const deadline = Date.now() + SERVER_READY_TIMEOUT_MS; + const deadline = Date.now() + timeout; while (Date.now() < deadline) { const err = checkSpawnError(); @@ -682,23 +681,10 @@ export class NativeSshManager { } throw new Error( - `Native SSH local server did not become ready within ${SERVER_READY_TIMEOUT_MS}ms` + `Native SSH local server did not become ready within ${Math.floor(timeout / 1000)}s` ); } - private async unlinkStaleSocket(socketPath: string): Promise { - if (process.platform === 'win32') { - return; - } - try { - await fs.unlink(socketPath); - } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { - throw err; - } - } - } - async dispose(): Promise { if (this.closed) { return; diff --git a/src/native/nativeSshServer.ts b/src/native/nativeSshServer.ts index e70716b8..7e07ad49 100644 --- a/src/native/nativeSshServer.ts +++ b/src/native/nativeSshServer.ts @@ -97,19 +97,17 @@ export class NativeSshHelperServer { this.server.removeListener('listening', handleListening); reject(error); }; - const handleListening = () => { - (async () => { - try { - if (this.config.sshExecutable) { - await this.launchSshProcess(); - } - this.resetIdleTimer(); - resolve(); - } catch (error: unknown) { - this.close(); - reject(error instanceof Error ? error : new Error(String(error))); + const handleListening = async () => { + try { + if (this.config.sshExecutable) { + await this.launchSshProcess(); } - })(); + this.resetIdleTimer(); + resolve(); + } catch (error: unknown) { + this.close(); + reject(error instanceof Error ? error : new Error(String(error))); + } }; this.server.once('error', handleError); From 28357d2b2dd6e1204c9876dff143e9d7c0c71995 Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Wed, 28 Oct 2026 19:50:48 -0500 Subject: [PATCH 14/26] fix --- src/native/nativeSshManager.ts | 94 ++++++++++------------------------ src/native/nativeSshServer.ts | 2 +- 2 files changed, 29 insertions(+), 67 deletions(-) diff --git a/src/native/nativeSshManager.ts b/src/native/nativeSshManager.ts index 0983f628..28c7c1ca 100644 --- a/src/native/nativeSshManager.ts +++ b/src/native/nativeSshManager.ts @@ -1,7 +1,6 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import * as cp from 'child_process'; -import * as stream from 'stream'; import * as vscode from 'vscode'; import { exists } from '../common/files'; import { getProductJson } from '../server-config/get-product-json'; @@ -43,13 +42,6 @@ export type NativeSshServerInfo = { reused: boolean; }; -export interface NativeSshServerProcess { - child: cp.ChildProcess; - stdin: stream.Writable; - wait(): Promise; - terminate(): Promise; -} - export interface NativeSshManagerOptions { connection: NativeSshConnectionOptions; registry: NativeSshRegistry; @@ -152,7 +144,7 @@ function isValidRegistryEntry(value: unknown): value is NativeSshRegistryEntry { } export class NativeSshManager { - private serverProcess: NativeSshServerProcess | undefined; + private serverProcess: cp.ChildProcess | undefined; private serverInfo: NativeSshServerInfo | undefined; private stdoutParser: LineParser | undefined; private stderrParser: LineParser | undefined; @@ -571,47 +563,34 @@ export class NativeSshManager { this.options.logger.error(`ssh server spawn failed: ${error.message}`); }); + child.once('close', (code, signal) => { + const reason = code !== null + ? `ssh server closed (code=${code})` + : `ssh server killed (signal=${signal ?? 'unknown'})`; + this.stdoutParser?.end(reason); + this.stderrParser?.end(reason); + this.stdoutParser = undefined; + this.stderrParser = undefined; + this.cmdRunner = undefined; + this.keepalive?.dispose(); + + if (code !== null) { + this.options.logger.info(`ssh server pid=${child.pid} exited code=${code}`); + } else { + this.options.logger.info(`ssh server pid=${child.pid} killed by signal=${signal ?? 'unknown'}`); + } + }); + const pid = child.pid; if (!pid) { throw new Error('Native SSH local server failed to spawn: no pid assigned.'); } this.options.logger.info(`Spawned ssh server pid=${pid} socket=${serverConfig.socketPath}`); - this.attachParsers(child); - await this.waitUntilSshServerReady(serverConfig.socketPath, () => spawnError, SERVER_READY_TIMEOUT_MS); + this.serverProcess = child; + this.attachParsers(); - const proc: NativeSshServerProcess = { - child, - stdin: child.stdin!, - wait: () => new Promise((resolve) => { - if (child.exitCode !== null) { - return resolve(child.exitCode); - } - child.once('close', (code) => resolve(code)); - }), - terminate: async () => { - if (child.exitCode !== null || child.signalCode !== null) { - return; - } - child.stdin?.end(); - child.kill('SIGTERM'); - const timer = setTimeout(() => { - if (child.exitCode === null && child.signalCode === null) { - child.kill('SIGKILL'); - } - }, 2_000); - timer.unref(); - await new Promise((resolve) => { - if (child.exitCode !== null || child.signalCode !== null) { - return resolve(); - } - child.once('close', () => resolve()); - }); - clearTimeout(timer); - }, - }; - - this.serverProcess = proc; + await this.waitUntilSshServerReady(serverConfig.socketPath, () => spawnError, SERVER_READY_TIMEOUT_MS); return { pid, @@ -620,9 +599,11 @@ export class NativeSshManager { }; } - private attachParsers(child: cp.ChildProcess): void { + private attachParsers(): void { + const child = this.serverProcess!; const stdout = child.stdout!; const stderr = child.stderr!; + const stdin = child.stdin!; stdout.setEncoding('utf8'); stderr.setEncoding('utf8'); @@ -634,29 +615,12 @@ export class NativeSshManager { this.stdoutParser, this.stderrParser, (data) => new Promise((resolve, reject) => { - this.serverProcess!.stdin.write(data + '\n', (err) => err ? reject(err) : resolve()); + stdin.write(data + '\n', (err) => err ? reject(err) : resolve()); }), ); stdout.on('data', (chunk: string) => this.stdoutParser!.push(chunk)); stderr.on('data', (chunk: string) => this.stderrParser!.push(chunk)); - - child.once('close', (code, signal) => { - const reason = code !== null - ? `ssh server closed (code=${code})` - : `ssh server killed (signal=${signal ?? 'unknown'})`; - this.stdoutParser?.end(reason); - this.stderrParser?.end(reason); - this.stdoutParser = undefined; - this.stderrParser = undefined; - this.cmdRunner = undefined; - - if (code !== null) { - this.options.logger.info(`ssh server pid=${child.pid} exited code=${code}`); - } else { - this.options.logger.info(`ssh server pid=${child.pid} killed by signal=${signal ?? 'unknown'}`); - } - }); } private async waitUntilSshServerReady( @@ -692,9 +656,7 @@ export class NativeSshManager { this.closed = true; this.keepalive?.dispose(); this.keepalive = undefined; - if (this.serverProcess) { - await this.serverProcess.terminate(); - this.serverProcess = undefined; - } + // The server is detached and self-terminates when keepalives stop. + this.serverProcess = undefined; } } diff --git a/src/native/nativeSshServer.ts b/src/native/nativeSshServer.ts index 7e07ad49..853c0492 100644 --- a/src/native/nativeSshServer.ts +++ b/src/native/nativeSshServer.ts @@ -6,7 +6,7 @@ import * as stream from 'stream'; import { type NativeSshServerConfig, NativeSshRegistryEntry } from './nativeSshManager'; -const HELPER_IDLE_TIMEOUT_MS = 60_000; +const HELPER_IDLE_TIMEOUT_MS = 10_000; function readNativeSshRegistry(registryFilePath: string) { const registryPath = registryFilePath; From c7c689e8fca6bfd0adf1f1c329cad6ef5dbb4cf8 Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Thu, 29 Oct 2026 00:54:50 -0500 Subject: [PATCH 15/26] more fixes --- src/native/nativeSshManager.ts | 5 ++++- src/native/nativeSshServer.ts | 16 +++++++++++++--- src/native/nativeSshServerClient.ts | 2 +- test/nativeSshServer.test.ts | 10 +++++----- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/native/nativeSshManager.ts b/src/native/nativeSshManager.ts index 28c7c1ca..eb63b99a 100644 --- a/src/native/nativeSshManager.ts +++ b/src/native/nativeSshManager.ts @@ -188,8 +188,10 @@ export class NativeSshManager { this.startKeepalive(client); this.options.logger.info(`Adopting local server pid=${existing.pid} socket=${existing.socketPath}`); this.serverInfo = { socketPath: existing.socketPath, pid: existing.pid, reused: true }; + return existing; } + this.options.logger.info(`Registry entry for pid=${existing.pid} is stale; removing`); await this.registry.removeIfOwnedBy(existing.pid); } @@ -541,7 +543,7 @@ export class NativeSshManager { const child = cp.spawn(process.execPath, [serverScriptPath, JSON.stringify(serverConfig)], { detached: true, - stdio: ['pipe', 'pipe', 'pipe'], + stdio: 'pipe', env: { ...process.env, DISPLAY: process.env.DISPLAY || '1', @@ -552,6 +554,7 @@ export class NativeSshManager { NATIVE_SSH_ASKPASS_SOCKET: this.options.askpassSocketPath, }, }); + child.unref(); child.stdout?.on('error', (err) => this.options.logger.error(`ssh server stdout error: ${err.message}`)); child.stderr?.on('error', (err) => this.options.logger.error(`ssh server stderr error: ${err.message}`)); diff --git a/src/native/nativeSshServer.ts b/src/native/nativeSshServer.ts index 853c0492..fcdeaf01 100644 --- a/src/native/nativeSshServer.ts +++ b/src/native/nativeSshServer.ts @@ -34,7 +34,7 @@ function removeNativeSshHelperRegistry(registryFilePath: string, pid: number) { } } -export class NativeSshHelperServer { +export class NativeSshServer { private readonly config: NativeSshServerConfig; private readonly server: http.Server; private sshProcess: NativeSshProcess | undefined; @@ -160,7 +160,18 @@ function startHelperFromArguments(): void { throw new Error('Native SSH helper configuration must include a command, string argument array, and registry file path.'); } - const server = new NativeSshHelperServer(config); + function ignoreEpipeOnly(err: NodeJS.ErrnoException) { + if (err.code === 'EPIPE') { + // Ignore so the process does not die when trying to write to + // stdout/stderr when the parent process has died + return; + } + throw err; + } + process.stdout.on('error', ignoreEpipeOnly); + process.stderr.on('error', ignoreEpipeOnly); + + const server = new NativeSshServer(config); process.once('exit', () => removeNativeSshHelperRegistry(config.registryFilePath, process.pid)); server.start().catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); @@ -168,7 +179,6 @@ function startHelperFromArguments(): void { process.exitCode = 1; server.close(); }); - process.once('SIGINT', () => server.close()); process.once('SIGTERM', () => server.close()); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/native/nativeSshServerClient.ts b/src/native/nativeSshServerClient.ts index 89d852b1..5b11edc4 100644 --- a/src/native/nativeSshServerClient.ts +++ b/src/native/nativeSshServerClient.ts @@ -2,7 +2,7 @@ import * as http from 'http'; import { ILogger } from '../common/logger'; const REQUEST_TIMEOUT_MS = 5_000; -const KEEPALIVE_INTERVAL_MS = 15_000; +const KEEPALIVE_INTERVAL_MS = 3_000; interface SshClientResponse { statusCode: number | undefined; diff --git a/test/nativeSshServer.test.ts b/test/nativeSshServer.test.ts index d116fa35..cd98d22a 100644 --- a/test/nativeSshServer.test.ts +++ b/test/nativeSshServer.test.ts @@ -33,11 +33,11 @@ vi.mock('child_process', async (importOriginal) => { }; }); -import { NativeSshHelperServer } from '../src/native/nativeSshServer'; +import { NativeSshServer } from '../src/native/nativeSshServer'; import { NativeSshServerConfig } from '../src/native/nativeSshManager'; async function makeServer(overrides: Partial = {}): Promise<{ - server: NativeSshHelperServer; + server: NativeSshServer; config: NativeSshServerConfig; dir: string; }> { @@ -53,7 +53,7 @@ async function makeServer(overrides: Partial = {}): Promi config.registryFilePath, JSON.stringify({ schemaVersion: 1, socketPath: config.socketPath, pid: process.pid }), ); - const server = new NativeSshHelperServer(config); + const server = new NativeSshServer(config); await server.start(); return { server, config, dir }; } @@ -75,8 +75,8 @@ function request( }); } -describe('NativeSshHelperServer', () => { - let server: NativeSshHelperServer | undefined; +describe('NativeSshServer', () => { + let server: NativeSshServer | undefined; let dir: string | undefined; afterEach(async () => { From 8f0c1e948755a3824fffe12bbebbc1390a44172d Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Thu, 29 Oct 2026 01:03:17 -0500 Subject: [PATCH 16/26] :lipstick: --- test/{native-ssh-parser.test.ts => nativeSshParser.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/{native-ssh-parser.test.ts => nativeSshParser.test.ts} (100%) diff --git a/test/native-ssh-parser.test.ts b/test/nativeSshParser.test.ts similarity index 100% rename from test/native-ssh-parser.test.ts rename to test/nativeSshParser.test.ts From 84a2987f6a0e77a6a5ba0a348085aa2af763381a Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Thu, 29 Oct 2026 02:25:55 -0500 Subject: [PATCH 17/26] more fixes --- src/native/nativeSshConnection.ts | 27 +++++++++++++++++++++++---- src/native/nativeSshManager.ts | 2 +- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/native/nativeSshConnection.ts b/src/native/nativeSshConnection.ts index c2ee0f97..34d89673 100644 --- a/src/native/nativeSshConnection.ts +++ b/src/native/nativeSshConnection.ts @@ -3,6 +3,7 @@ import * as os from 'os'; import * as crypto from 'crypto'; import * as path from 'path'; import { exists } from '../common/files'; +import { isMacintosh } from '../common/platform'; export async function resolveNativeSshExecutable(explicitPath?: string): Promise { if (explicitPath && explicitPath.trim().length > 0) { @@ -29,12 +30,30 @@ export async function resolveNativeSshExecutable(explicitPath?: string): Promise throw new Error('Native SSH executable not found in PATH. Install OpenSSH or set the ssh path explicitly.'); } +const MAX_SOCKET_PATH_LENGTH = isMacintosh ? 103 : 107; + export function getNativeSshSocketPath(label: string): string { const randomStr = crypto.randomBytes(15).toString('hex'); if (process.platform === 'win32') { - return `\\\\.\\pipe\\open-remote-ssh-${label}-${randomStr}`; + return `\\\\.\\pipe\\or-ssh-${label}-${randomStr}-sock`; + } + + const baseDir = process.env.XDG_RUNTIME_DIR ? process.env.XDG_RUNTIME_DIR : os.tmpdir(); + const prefix = 'or-ssh-'; + const suffix = '.sock'; + + // Format: [baseDir]/[prefix][label]-[randomStr][suffix] + const basicPathLength = baseDir.length + 1 + prefix.length + label.length + 1 + suffix.length; + const availableRandomChars = MAX_SOCKET_PATH_LENGTH - basicPathLength; + const sliceLength = Math.max(6, Math.min(randomStr.length, availableRandomChars)); + + const truncatedRandomStr = randomStr.substring(0, sliceLength); + + const finalPath = path.join(baseDir, `${prefix}${label}-${truncatedRandomStr}${suffix}`); + + if (finalPath.length > MAX_SOCKET_PATH_LENGTH) { + throw new Error(`The socket path '${finalPath}' length exceeds the limit of ${MAX_SOCKET_PATH_LENGTH}`); } - return process.env.XDG_RUNTIME_DIR - ? path.join(process.env.XDG_RUNTIME_DIR, `open-remote-ssh-${label}-${randomStr}.sock`) - : path.join(os.tmpdir(), `open-remote-ssh-${label}-${randomStr}.sock`); + + return finalPath; } diff --git a/src/native/nativeSshManager.ts b/src/native/nativeSshManager.ts index eb63b99a..a1f0ae32 100644 --- a/src/native/nativeSshManager.ts +++ b/src/native/nativeSshManager.ts @@ -537,7 +537,7 @@ export class NativeSshManager { const serverConfig: NativeSshServerConfig = { sshExecutable: this.connection.sshExecutable, sshArgs: this.connection.sshArgs, - socketPath: getNativeSshSocketPath('localSshServer'), + socketPath: getNativeSshSocketPath('sshServer'), registryFilePath: this.registry.filePath }; From 9b3c4037d649aa9ba775bc8f00ed818d19ebc99b Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Thu, 29 Oct 2026 04:53:38 -0500 Subject: [PATCH 18/26] more fixes --- test/nativeSshAskpass.test.ts | 6 ------ test/nativeSshServer.test.ts | 13 +++++++------ 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/test/nativeSshAskpass.test.ts b/test/nativeSshAskpass.test.ts index f932b537..62b3ed36 100644 --- a/test/nativeSshAskpass.test.ts +++ b/test/nativeSshAskpass.test.ts @@ -7,12 +7,6 @@ vi.mock('vscode', () => ({ }, })); -vi.mock('../src/native/nativeSshConnection', () => ({ - getNativeSshSocketPath: vi.fn((label: string) => - `${process.env.TMPDIR ?? '/tmp'}/open-remote-ssh-${label}-${Math.random().toString(16).slice(2)}.sock`, - ), -})); - import * as vscode from 'vscode'; import * as http from 'http'; import { AskPassServer } from '../src/native/nativeSshAskpass'; diff --git a/test/nativeSshServer.test.ts b/test/nativeSshServer.test.ts index cd98d22a..28861c48 100644 --- a/test/nativeSshServer.test.ts +++ b/test/nativeSshServer.test.ts @@ -35,6 +35,7 @@ vi.mock('child_process', async (importOriginal) => { import { NativeSshServer } from '../src/native/nativeSshServer'; import { NativeSshServerConfig } from '../src/native/nativeSshManager'; +import { getNativeSshSocketPath } from '../src/native/nativeSshConnection'; async function makeServer(overrides: Partial = {}): Promise<{ server: NativeSshServer; @@ -45,7 +46,7 @@ async function makeServer(overrides: Partial = {}): Promi const config: NativeSshServerConfig = { sshExecutable: '/usr/bin/ssh', sshArgs: ['-T', 'user@host'], - socketPath: path.join(dir, 'helper.sock'), + socketPath: getNativeSshSocketPath('sshServer'), registryFilePath: path.join(dir, 'registry.json'), ...overrides, }; @@ -121,7 +122,7 @@ describe('NativeSshServer', () => { server = ctx.server; dir = ctx.dir; - await vi.advanceTimersByTimeAsync(60_000 + 100); + await vi.advanceTimersByTimeAsync(10_000 + 100); await Promise.resolve(); await expect( @@ -138,16 +139,16 @@ describe('NativeSshServer', () => { server = ctx.server; dir = ctx.dir; - await vi.advanceTimersByTimeAsync(50_000); + await vi.advanceTimersByTimeAsync(8_000); await request(ctx.config.socketPath, 'POST', '/keepalive'); - // Past the original 60s deadline — the keepalive should have + // Past the original 10s deadline — the keepalive should have // pushed it out, so the server is still up. - await vi.advanceTimersByTimeAsync(30_000); + await vi.advanceTimersByTimeAsync(5_000); const res = await request(ctx.config.socketPath, 'GET', '/health'); expect(res.statusCode).toBe(200); // Drain the remaining timer so afterEach's close() isn't racing it. - await vi.advanceTimersByTimeAsync(60_000); + await vi.advanceTimersByTimeAsync(10_000); }); }); From f96d417cf7416e71bd7be86d586465e21645907c Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Thu, 29 Oct 2026 23:05:05 -0500 Subject: [PATCH 19/26] Fix watch:src script --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8c369ab6..0a1e44c4 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "update:deps": "taze --all", "update:dts": "npx vscode-dts dev && npx vscode-dts main", "vscode:prepublish": "npm run build", - "watch:src": "tsc-watch -p . --onSuccess 'npm run bundle:dev'", + "watch:src": "tsc-watch -p . --onSuccess \"npm run bundle:dev\"", "watch:test": "tsc-watch -p test" }, "dependencies": { From b20f751a9c727294eac753adca8e2fbaa814079a Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Sat, 31 Oct 2026 00:24:36 -0500 Subject: [PATCH 20/26] Fixes for windows connection --- src/authResolver.ts | 31 ++++--- src/native/askpass/askpass.ps1 | 15 ++++ src/native/nativeSshManager.ts | 152 ++++++++++++++------------------- src/scripts/server-setup.ps1 | 39 +++++---- src/serverSetup.ts | 18 ++-- webpack.config.js | 2 +- 6 files changed, 127 insertions(+), 130 deletions(-) create mode 100644 src/native/askpass/askpass.ps1 diff --git a/src/authResolver.ts b/src/authResolver.ts index 5c10a0ff..461e7eb0 100644 --- a/src/authResolver.ts +++ b/src/authResolver.ts @@ -260,7 +260,7 @@ export class RemoteSSHResolver implements vscode.RemoteAuthorityResolver, vscode } catch (e: unknown) { this.logger.error(`Error resolving authority`, e); - if(!useNativeClient || connectionBackend === 'native') { + if (!useNativeClient || connectionBackend === 'native') { return await this.throwOrCommand(context, destination, e); } } @@ -326,7 +326,7 @@ export class RemoteSSHResolver implements vscode.RemoteAuthorityResolver, vscode const sshExecutable = await resolveNativeSshExecutable(configuredSshPath); const socksPort = await findRandomPort(); - const sshArgs: string[] = ['-v', '-T', '-D', String(socksPort),'-o', `ConnectTimeout=${connectTimeout}`]; + const sshArgs: string[] = ['-v', '-T', '-D', String(socksPort), '-o', `ConnectTimeout=${connectTimeout}`]; const configFile = getSSHConfigPath(); if (configFile !== defaultSSHConfigPath) { sshArgs.push('-F', configFile); @@ -362,12 +362,17 @@ export class RemoteSSHResolver implements vscode.RemoteAuthorityResolver, vscode 'native', 'nativeSshServer.js' ), - askpassPath: path.join( - this.context.extensionPath, - 'lib', - 'native', - 'askpass' + (isWindows ? '.bat' : '.sh'), - ), + askpassPath: isWindows + ? `powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -File "${path.join( + this.context.extensionPath, + 'lib', + 'native', + 'askpass.ps1')}"` + : path.join( + this.context.extensionPath, + 'lib', + 'native', + 'askpass.sh'), askpassNodeScriptPath: path.join( this.context.extensionPath, 'lib', @@ -675,16 +680,16 @@ export class RemoteSSHResolver implements vscode.RemoteAuthorityResolver, vscode } private async openNativeTunnel( - localPort: number, - remotePortOrSocketPath: number | string, - socksPort: number | undefined + localPort: number, + remotePortOrSocketPath: number | string, + socksPort: number | undefined ): Promise { localPort = localPort > 0 ? localPort : await findRandomPort(); const disposables: vscode.Disposable[] = []; const remotePort = typeof remotePortOrSocketPath === 'number' ? remotePortOrSocketPath : undefined; // const remoteSocketPath = typeof remotePortOrSocketPath === 'string' ? remotePortOrSocketPath : undefined; - if (socksPort&& remotePort) { + if (socksPort && remotePort) { const forwardingServer = await new Promise((resolve, reject) => { this.logger.trace(`Creating forwarding server ${localPort}(local) => ${socksPort}(socks) => ${remotePort}(remote)`); const socksOptions: SocksClientOptions = { @@ -719,7 +724,7 @@ export class RemoteSSHResolver implements vscode.RemoteAuthorityResolver, vscode }), }); } else { - throw new Error('Tunnel without socks port not supported yet'); + throw new Error('Tunnel without socks port not supported yet'); } return new TunnelInfo(localPort, remotePortOrSocketPath, disposables); diff --git a/src/native/askpass/askpass.ps1 b/src/native/askpass/askpass.ps1 new file mode 100644 index 00000000..fbdb24cb --- /dev/null +++ b/src/native/askpass/askpass.ps1 @@ -0,0 +1,15 @@ +do { + $env:NATIVE_SSH_ASKPASS_FILE = Join-Path $env:TEMP ("native-ssh-askpass-{0}.tmp" -f (Get-Random)) +} while (Test-Path -LiteralPath $env:NATIVE_SSH_ASKPASS_FILE) + +$p = Start-Process -FilePath $env:NATIVE_SSH_NODE_BIN -ArgumentList (@($env:NATIVE_SSH_ASKPASS_NODE_SCRIPT) + $args) -NoNewWindow -PassThru -Wait +$AskpassStatus = $p.ExitCode + +if (Test-Path -LiteralPath $env:NATIVE_SSH_ASKPASS_FILE) { + if ($AskpassStatus -eq 0) { + Get-Content -LiteralPath $env:NATIVE_SSH_ASKPASS_FILE -Raw + } + Remove-Item -LiteralPath $env:NATIVE_SSH_ASKPASS_FILE -Force -ErrorAction SilentlyContinue +} + +exit $AskpassStatus diff --git a/src/native/nativeSshManager.ts b/src/native/nativeSshManager.ts index a1f0ae32..6ebd2d8c 100644 --- a/src/native/nativeSshManager.ts +++ b/src/native/nativeSshManager.ts @@ -9,7 +9,7 @@ import { ILogger } from '../common/logger'; import { AUTH_CONFIRMED_RE, CommandRunner, LineParser } from './nativeSshParser'; import { RemotePlatform } from '../common/platform'; import { NativeSshServerClient, NativeSshKeepalive } from './nativeSshServerClient'; -import { Architecture, buildServerInstallOptions, generateBashInstallScript, generatePowerShellInstallScript, parseKernelName, parseMachineArch, parseServerInstallOutput, Platform, ServerInstallError, ServerInstallResult, Shell } from '../serverSetup'; +import { Architecture, buildServerInstallOptions, generateBashInstallScript, generatePowerShellInstallScript, parseKernelName, parseMachineArch, parseServerInstallOutput, Platform, ServerInstallError, ServerInstallResult } from '../serverSetup'; import { ServerVersion } from '../server-config/types'; import { getNativeSshSocketPath } from './nativeSshConnection'; @@ -55,7 +55,6 @@ export interface NativeSshManagerOptions { interface NativeDetectedPlatform { detectedPlatform: Platform; detectedArch: Architecture; - detectedShell: Shell; } export interface NativeInstallInputs { @@ -85,7 +84,7 @@ export class NativeSshRegistry { path.join( globalStorageDir, 'native-ssh', - `ssh-host-${authorityKey}-${productJson.commit as string}-${vscode.version.replace('-insider','')}`, + `ssh-host-${authorityKey}-${productJson.commit as string}-${vscode.version.replace('-insider', '')}`, 'data.json' ) ); @@ -278,19 +277,16 @@ export class NativeSshManager { platform: RemotePlatform | undefined, ): Promise { let detectedPlatform: Platform; - let detectedShell: Shell = 'bash'; if (!platform || platform === 'windows') { - const stdout = await this.runCommand('echo %COMSPEC%~$PSHOME', (stdout) =>{ + const stdout = await this.runCommand('echo %COMSPEC%~$PSHOME', (stdout) => { return stdout; }); const [comspec, pshome = ''] = stdout.replace(/\r?\n/g, ' ').trim().split('~'); if (comspec && comspec !== '%COMSPEC%') { detectedPlatform = 'windows'; - detectedShell = 'cmd'; } else if (pshome) { - detectedShell = 'powershell'; detectedPlatform ||= /^[A-Za-z]:\\/.test(pshome) ? 'windows' : 'linux'; } else if (!platform) { const result = await this.runCommand('echo "==$(uname -s)=="', (stdout, stderr) => { @@ -319,20 +315,20 @@ export class NativeSshManager { detectedPlatform = 'linux'; } - this.options.logger.trace(`Detected platform: ${detectedPlatform}, ${detectedShell}`); + this.options.logger.trace(`Detected platform: ${detectedPlatform}`); let detectedArch: Architecture; if (detectedPlatform === 'windows') { detectedArch = 'x64'; } else { const result = await this.runCommand('echo "==$(uname -m)=="', (stdout, stderr) => { - if (stderr) { - return { stderr }; - } - if (stdout) { - return { stdout }; - } - return undefined; + if (stderr) { + return { stderr }; + } + if (stdout) { + return { stdout }; + } + return undefined; }); if (result.stderr) { throw new ServerInstallError(`Cannot execute "uname -m", yields: ${result.stderr}`); @@ -346,12 +342,12 @@ export class NativeSshManager { } this.options.logger.trace(`Detected architecture: ${detectedArch}`); - return { detectedPlatform, detectedArch, detectedShell }; + return { detectedPlatform, detectedArch }; } private async waitRemoteShellReady() { const readyMarker = `==${this.connection.label}==`; - await this.runCommand(`echo '${readyMarker}'`, (stdout: string | undefined) =>{ + await this.runCommand(`echo '${readyMarker}'`, (stdout: string | undefined) => { if (stdout?.includes(readyMarker)) { return true; } @@ -360,7 +356,7 @@ export class NativeSshManager { } private async probeRemotePlatform(): Promise { - return this.runCommand('uname -rsv', (stdout: string | undefined, stderr: string | undefined) =>{ + return this.runCommand('uname -rsv', (stdout: string | undefined, stderr: string | undefined) => { if (stdout) { const lower = stdout.toLowerCase(); if (lower.includes('darwin') || lower.includes('macos') || lower.includes('mac os x')) { @@ -413,34 +409,41 @@ export class NativeSshManager { this.options.logger, ); - let commandOutput: { stdout: string; stderr: string }; + let remoteCmd: string; if (detectedPlatform === 'windows') { - commandOutput = await this.runNativeWindowsInstall( - generatePowerShellInstallScript(installOptions, extensionPath), - ); + await this.waitPowershellReady(); + + const encoded = Buffer.from(generatePowerShellInstallScript(installOptions, extensionPath), 'utf8').toString('base64'); + + remoteCmd = `powershell -NoProfile -NonInteractive -NoLogo -ExecutionPolicy Bypass -Command "iex ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}')))"`; + if (remoteCmd.length > 8191) { + throw new Error(`Script length ${remoteCmd.length} exceeds the maximun limit of 8191`); + } } else { await this.waitBashReady(); - let stdoutBuffer = ''; - let stderrBuffer = ''; - commandOutput = await this.runCommand( - `scriptWrapper() {\n ${generateBashInstallScript(installOptions, extensionPath)}\n }\n scriptWrapper`, - (stdout, stderr) => { - if (stdout !== undefined) { - stdoutBuffer += stdout + '\n'; - } - if (stderr !== undefined) { - stderrBuffer += stderr + '\n'; - } - if (stdout?.includes(`${this.options.connection.label}: end`)) { - return { stdout: stdoutBuffer, stderr: stderrBuffer }; - } - return undefined; - }, - 90_000 - ); + remoteCmd = `scriptWrapper() {\n ${generateBashInstallScript(installOptions, extensionPath)}\n }\n scriptWrapper`; } + let stdoutBuffer = ''; + let stderrBuffer = ''; + const commandOutput = await this.runCommand( + remoteCmd, + (stdout: string | undefined, stderr: string | undefined) => { + if (stdout !== undefined) { + stdoutBuffer += stdout + '\n'; + } + if (stderr !== undefined) { + stderrBuffer += stderr + '\n'; + } + if (stdout?.includes(`${this.options.connection.label}: end`)) { + return { stdout: stdoutBuffer, stderr: stderrBuffer }; + } + return undefined; + }, + 90_000 + ); + this.options.logger.trace('Server install stdout:', commandOutput.stdout); if (commandOutput.stderr) { this.options.logger.trace('Server install stderr:', commandOutput.stderr); @@ -476,7 +479,7 @@ export class NativeSshManager { private async waitBashReady(): Promise { const readyMarker = `==${this.connection.label}==`; - await this.runCommand(`bash -c 'echo ${readyMarker}; exec bash'`, (stdout: string | undefined) =>{ + await this.runCommand(`bash -c 'echo ${readyMarker}; exec bash'`, (stdout: string | undefined) => { if (stdout?.includes(readyMarker)) { return true; } @@ -484,48 +487,13 @@ export class NativeSshManager { }); } - private async runNativeWindowsInstall( - script: string, - ): Promise<{ stdout: string; stderr: string }> { - const remoteTemp = `$env:TEMP\\native-ssh-install-${this.options.connection.label}.ps1`; - const encoded = Buffer.from(script, 'utf16le').toString('base64'); - - const writeResult = await this.runCommand( - `[IO.File]::WriteAllBytes(${remoteTemp}, [Convert]::FromBase64String('${encoded}'))`, - (stdout, stderr) => { - if (stderr !== undefined) { - return { stderr }; - } - if (stdout !== undefined) { - return { stdout }; - } - return undefined; + private async waitPowershellReady(): Promise { + await this.runCommand('powershell -NoProfile', (stdout: string | undefined) => { + if (stdout?.includes('Windows PowerShell')) { + return true; } - ); - if (writeResult.stderr && writeResult.stderr.length > 0) { - this.options.logger.error('Failed writing remote install script:', writeResult.stderr); - throw new ServerInstallError('Failed writing remote install script'); - } - - let stdoutBuffer = ''; - let stderrBuffer = ''; - const runResult = await this.runCommand( - `powershell -ExecutionPolicy Bypass -File "${remoteTemp}"; Remove-Item -Force "${remoteTemp}"`, - (stdout, stderr) => { - if (stdout !== undefined) { - stdoutBuffer += stdout + '\n'; - } - if (stderr !== undefined) { - stderrBuffer += stderr + '\n'; - } - if (stdout?.includes(`${this.options.connection.label}: end`)) { - return { stdout: stdoutBuffer, stderr: stderrBuffer }; - } - return undefined; - }, - 90_000 - ); - return runResult; + return undefined; + }); } private async spawnFresh(): Promise { @@ -541,17 +509,21 @@ export class NativeSshManager { registryFilePath: this.registry.filePath }; + const envVars = { + DISPLAY: process.env.DISPLAY || '1', + ELECTRON_RUN_AS_NODE: '1', + SSH_ASKPASS: this.options.askpassPath, + SSH_ASKPASS_REQUIRE: 'force', + NATIVE_SSH_NODE_BIN: process.execPath, + NATIVE_SSH_ASKPASS_NODE_SCRIPT: this.options.askpassNodeScriptPath, + NATIVE_SSH_ASKPASS_SOCKET: this.options.askpassSocketPath, + }; const child = cp.spawn(process.execPath, [serverScriptPath, JSON.stringify(serverConfig)], { detached: true, stdio: 'pipe', env: { ...process.env, - DISPLAY: process.env.DISPLAY || '1', - ELECTRON_RUN_AS_NODE: '1', - SSH_ASKPASS: this.options.askpassPath, - NATIVE_SSH_NODE_BIN: process.execPath, - NATIVE_SSH_ASKPASS_NODE_SCRIPT: this.options.askpassNodeScriptPath, - NATIVE_SSH_ASKPASS_SOCKET: this.options.askpassSocketPath, + ...envVars }, }); child.unref(); @@ -566,7 +538,7 @@ export class NativeSshManager { this.options.logger.error(`ssh server spawn failed: ${error.message}`); }); - child.once('close', (code, signal) => { + child.once('close', (code, signal) => { const reason = code !== null ? `ssh server closed (code=${code})` : `ssh server killed (signal=${signal ?? 'unknown'})`; @@ -588,7 +560,7 @@ export class NativeSshManager { if (!pid) { throw new Error('Native SSH local server failed to spawn: no pid assigned.'); } - this.options.logger.info(`Spawned ssh server pid=${pid} socket=${serverConfig.socketPath}`); + this.options.logger.info(`Spawned ssh server pid=${pid} socket=${serverConfig.socketPath} env=${JSON.stringify(envVars)}`); this.serverProcess = child; this.attachParsers(); diff --git a/src/scripts/server-setup.ps1 b/src/scripts/server-setup.ps1 index 49749fc5..57bc9423 100644 --- a/src/scripts/server-setup.ps1 +++ b/src/scripts/server-setup.ps1 @@ -22,14 +22,14 @@ $SERVER_SCRIPT="$SERVER_DIR\bin\$SERVER_APP_NAME.cmd" $SERVER_LOGFILE="$SERVER_DATA_DIR\.$DISTRO_COMMIT.log" $SERVER_PIDFILE="$SERVER_DATA_DIR\.$DISTRO_COMMIT.pid" $SERVER_TOKENFILE="$SERVER_DATA_DIR\.$DISTRO_COMMIT.token" -$SERVER_ARCH= -$SERVER_CONNECTION_TOKEN= +$SERVER_ARCH="" +$SERVER_CONNECTION_TOKEN="" $SERVER_DOWNLOAD_URL="%%SERVER_DOWNLOAD_URL%%" $SERVER_VALIDATION_FLAG="%%SERVER_VALIDATION_FLAG%%" -$LISTENING_ON= -$OS_RELEASE_ID= -$ARCH= +$LISTENING_ON="" +$OS_RELEASE_ID="" +$ARCH="" $PLATFORM="win32" # Get commit from installed server's product.json @@ -63,6 +63,11 @@ function printInstallResults($code) { "%%SCRIPT_ID%%: end" } +function failAndExit($code) { + printInstallResults $code + exit 0 +} + # Test the commit of the installed server against the expected one (DISTRO_COMMIT) # # The install directory is named for the client's commit, but the download URL may still return a different build @@ -93,8 +98,7 @@ if(($ARCH -eq "AMD64") -or ($ARCH -eq "IA64") -or ($ARCH -eq "ARM64")) { } else { "Error architecture not supported: $ARCH" - printInstallResults 1 - exit 0 + failAndExit 1 } # Validate the commit of the installed server against the expected one (DISTRO_COMMIT) @@ -110,12 +114,12 @@ if(!(Test-Path $SERVER_DIR)) { ni -it d $SERVER_DIR -f -ea si } catch { "Error creating server install directory - $($_.ToString())" - exit 1 + failAndExit 1 } if(!(Test-Path $SERVER_DIR)) { "Error creating server install directory" - exit 1 + failAndExit 1 } } @@ -132,11 +136,12 @@ if(!(Test-Path $SERVER_SCRIPT)) { TimeoutSec=20 OutFile="vscode-server.tar.gz" UseBasicParsing=$True + ErrorAction= "Stop" } [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - Invoke-RestMethod @REQUEST_ARGUMENTS + Invoke-WebRequest @REQUEST_ARGUMENTS if(Test-Path "vscode-server.tar.gz") { tar -xOf vscode-server.tar.gz | Out-Null @@ -144,7 +149,7 @@ if(!(Test-Path $SERVER_SCRIPT)) { if($LastExitCode -ne 0) { del vscode-server.tar.gz "Error downloaded tarball is corrupt or incomplete" - exit 1 + failAndExit 1 } tar -xf vscode-server.tar.gz --strip-components 1 @@ -152,7 +157,7 @@ if(!(Test-Path $SERVER_SCRIPT)) { if($LastExitCode -ne 0) { del vscode-server.tar.gz "Error while extracting server contents" - exit 1 + failAndExit 1 } del vscode-server.tar.gz @@ -160,7 +165,7 @@ if(!(Test-Path $SERVER_SCRIPT)) { if(!(Test-Path $SERVER_SCRIPT)) { "Error while installing the server binary" - exit 1 + failAndExit 1 } if(!(Test-InstalledCommit)) { @@ -169,7 +174,7 @@ if(!(Test-Path $SERVER_SCRIPT)) { Remove-Item -Recurse -Force "$SERVER_DIR\*" - exit 1 + failAndExit 1 } } else { @@ -224,8 +229,7 @@ if(Test-Path $SERVER_TOKENFILE) { } else { "Error server token file not found $SERVER_TOKENFILE" - printInstallResults 1 - exit 0 + failAndExit 1 } sleep -Milliseconds 500 @@ -250,8 +254,7 @@ for($I = 1; $I -le 5; $I++) { if(!(Test-Path $SERVER_LOGFILE)) { "Error server log file not found $SERVER_LOGFILE" - printInstallResults 1 - exit 0 + failAndExit 1 } # Finish server setup diff --git a/src/serverSetup.ts b/src/serverSetup.ts index 69a08726..45b848ae 100644 --- a/src/serverSetup.ts +++ b/src/serverSetup.ts @@ -301,12 +301,6 @@ async function runInstallScript( command = `mkdir -p ${installDir.replace(/\\/g, '/')} && echo '\n${installServerScript.replace(/'/g, '\'"\'"\'')}\n' > ${installScript.replace(/\\/g, '/')} && powershell -ExecutionPolicy ByPass -File "${installScript}"`; } else if (shell === 'cmd') { const script = installServerScript.trim() - // remove comments - .replace(/^#.*$/gm, '') - // remove empty lines - .replace(/\n{2,}/gm, '\n') - // remove leading spaces - .replace(/^\s*/gm, '') // escape double quotes (from powershell/cmd) .replace(/"/g, '"""') // escape single quotes (from cmd) @@ -456,10 +450,10 @@ export function generatePowerShellInstallScript({ id, quality, version, commit, const listenFlag = useSocketPath ? `--socket-path="$TMP_DIR/vscode-server-sock-${crypto.randomUUID()}"` : '--port=0'; - const envVarLines = envVariables.map(envVar => ` "$${envVar}==$${envVar}=="`).join('\n'); + const envVarLines = envVariables.map(envVar => ` "${envVar}==$${envVar}=="`).join('\n'); const sendEnvVarLines = Object.entries(sendEnvVars).map(([key, value]) => `$env:${key}="${value.replace(/"/g, '`"')}"`).join('\n'); - return compileTemplate('server-setup.ps1', { + const compiledScript = compileTemplate('server-setup.ps1', { DISTRO_VERSION: version, DISTRO_COMMIT: commit, DISTRO_QUALITY: quality, @@ -477,6 +471,14 @@ export function generatePowerShellInstallScript({ id, quality, version, commit, MODIFY_PRODUCT_JSON: serverValidation === 'force' ? '$true' : '$false', SERVER_CONNECTION_TOKEN: crypto.randomUUID(), }, extensionPath); + + return compiledScript + // remove comments + .replace(/^\s*#.*$/gm, '') + // remove empty lines + .replace(/(\r?\n\s*){2,}/g, '\n') + // remove leading spaces + .replace(/^\s*/gm, ''); } export async function buildServerInstallOptions( diff --git a/webpack.config.js b/webpack.config.js index 70ca6ace..54550e0d 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -20,7 +20,7 @@ const config = { { from: path.resolve(__dirname, 'src/scripts/server-setup.sh'), to: 'scripts', preservePermissions: true }, { from: path.resolve(__dirname, 'src/scripts/server-setup.ps1'), to: 'scripts', preservePermissions: true }, { from: path.resolve(__dirname, 'src/native/askpass/askpass.sh'), to: 'native', preservePermissions: true }, - { from: path.resolve(__dirname, 'src/native/askpass/askpass.bat'), to: 'native', preservePermissions: true }, + { from: path.resolve(__dirname, 'src/native/askpass/askpass.ps1'), to: 'native', preservePermissions: true }, ], libraryTarget: "commonjs2", devtoolModuleFilenameTemplate: "../[resource-path]", From 2aa86d79ec56517c713b102844ac9ce285121f3a Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Sat, 31 Oct 2026 00:26:28 -0500 Subject: [PATCH 21/26] remmove unused file --- src/native/askpass/askpass.bat | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100755 src/native/askpass/askpass.bat diff --git a/src/native/askpass/askpass.bat b/src/native/askpass/askpass.bat deleted file mode 100755 index 05f71097..00000000 --- a/src/native/askpass/askpass.bat +++ /dev/null @@ -1,16 +0,0 @@ -@echo off -setlocal - -:createTempFile -set "NATIVE_SSH_ASKPASS_FILE=%TEMP%\native-ssh-askpass-%RANDOM%.tmp" -if exist "%NATIVE_SSH_ASKPASS_FILE%" goto createTempFile - -"%NATIVE_SSH_NODE_BIN%" "%NATIVE_SSH_ASKPASS_NODE_SCRIPT%" %* -set "ASKPASS_STATUS=%ERRORLEVEL%" - -if exist "%NATIVE_SSH_ASKPASS_FILE%" ( - if "%ASKPASS_STATUS%"=="0" type "%NATIVE_SSH_ASKPASS_FILE%" - del /f /q "%NATIVE_SSH_ASKPASS_FILE%" -) - -exit /b %ASKPASS_STATUS% From 02d7f4617a8f0acc11ff0de0d1b8e86a847f270b Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Sat, 31 Oct 2026 17:38:16 -0500 Subject: [PATCH 22/26] cleanup :broom: --- src/authResolver.ts | 236 +++++++-------- src/common/logger.ts | 2 + src/extension.ts | 6 + src/native/nativeSshAskpass.ts | 29 +- src/native/nativeSshManager.ts | 4 +- src/ssh/identityFiles.ts | 4 +- src/ssh/knownHosts.ts | 4 +- src/ssh/nativeClient.ts | 528 --------------------------------- test/gssapi.test.ts | 134 --------- test/native-client.test.ts | 392 ------------------------ 10 files changed, 138 insertions(+), 1201 deletions(-) delete mode 100644 src/ssh/nativeClient.ts delete mode 100644 test/gssapi.test.ts delete mode 100644 test/native-client.test.ts diff --git a/src/authResolver.ts b/src/authResolver.ts index 461e7eb0..c20c40bc 100644 --- a/src/authResolver.ts +++ b/src/authResolver.ts @@ -10,12 +10,11 @@ import * as path from 'path'; import * as ssh2 from 'ssh2'; import * as os from 'os'; import * as crypto from 'crypto'; -import { Log } from './common/logger'; +import { ILogger } from './common/logger'; import { SocksClient, SocksClientOptions } from 'socks'; import SSHDestination from './ssh/sshDestination'; import SSHConnection, { SSHTunnelConfig } from './ssh/sshConnection'; import { loadKnownHosts, matchHostKey, appendHostKey, replaceHostKey, removeHostFromEntry, findConflictingEntries, keyFingerprint, keyTypeOf, KnownHosts, KnownHostsEntry, KnownHostsFilesConfig } from './ssh/knownHosts'; -import NativeSSHConnection, { isGssapiAuthenticationRequested } from './ssh/nativeClient'; import type { SSHClient, SSHExecChannel } from './ssh/sshClient'; import SSHConfiguration, { defaultSSHConfigPath, getSSHConfigPath, type HostConfiguration } from './ssh/sshConfig'; import { gatherIdentityFiles, SSHKey } from './ssh/identityFiles'; @@ -227,7 +226,7 @@ export class RemoteSSHResolver implements vscode.RemoteAuthorityResolver, vscode constructor( readonly context: vscode.ExtensionContext, - readonly logger: Log + readonly logger: ILogger ) { } @@ -241,33 +240,15 @@ export class RemoteSSHResolver implements vscode.RemoteAuthorityResolver, vscode const destination = SSHDestination.parseEncoded(dest); - const remoteSSHconfig = vscode.workspace.getConfiguration('remote.SSH'); - const connectionBackend = remoteSSHconfig.get('connectionBackend', 'ssh2'); - return vscode.window.withProgress({ title: `Setting up SSH Host ${destination.hostname}`, location: vscode.ProgressLocation.Notification, cancellable: false }, async () => { const sshconfig = await SSHConfiguration.loadFromFS(); - const sshHostConfig = sshconfig.getHostConfiguration(destination.hostname); - - // Deprecated - const useNativeClient = isGssapiAuthenticationRequested(sshHostConfig); - try { - return await this.connect(destination, sshconfig, false); - } catch (e: unknown) { - this.logger.error(`Error resolving authority`, e); - - if (!useNativeClient || connectionBackend === 'native') { - return await this.throwOrCommand(context, destination, e); - } - } - - // Deprecated - try { - return await this.connect(destination, sshconfig, true); + try { + return await this.connect(destination, sshconfig); } catch (e: unknown) { this.logger.error(`Error resolving authority`, e); @@ -297,7 +278,7 @@ export class RemoteSSHResolver implements vscode.RemoteAuthorityResolver, vscode } } - private async connect(destination: SSHDestination, sshconfig: SSHConfiguration, useNativeClient: boolean): Promise { + private async connect(destination: SSHDestination, sshconfig: SSHConfiguration): Promise { const remoteSSHconfig = vscode.workspace.getConfiguration('remote.SSH'); const enableDynamicForwarding = remoteSSHconfig.get('enableDynamicForwarding', true)!; const enableAgentForwarding = remoteSSHconfig.get('enableAgentForwarding', true)!; @@ -317,6 +298,7 @@ export class RemoteSSHResolver implements vscode.RemoteAuthorityResolver, vscode const sshUser = sshHostConfig['User'] || destination.user || os.userInfo().username || ''; // https://github.com/openssh/openssh-portable/blob/5ec5504f1d328d5bfa64280cd617c3efec4f78f3/sshconnect.c#L1561-L1562 const sshPort = sshHostConfig['Port'] ? parseInt(sshHostConfig['Port'], 10) : (destination.port || 22); + const packageJSON = this.context.extension.packageJSON; if (connectionBackend === 'native') { try { if (remoteServerListenOnSocket) { @@ -349,7 +331,7 @@ export class RemoteSSHResolver implements vscode.RemoteAuthorityResolver, vscode socksPort }; - const registry = await NativeSshRegistry.forAuthority(this.context.globalStorageUri.fsPath, connection.authorityKey); + const registry = await NativeSshRegistry.forAuthority(this.context.globalStorageUri.fsPath, connection.authorityKey, packageJSON.version); const askpassServer = await AskPassServer.create(this.logger); this.nativeAskpassServer = askpassServer; @@ -486,121 +468,109 @@ export class RemoteSSHResolver implements vscode.RemoteAuthorityResolver, vscode let proxyStream: ssh2.ClientChannel | stream.Duplex | undefined; - if (!useNativeClient) { - const preferredAuthentications = sshHostConfig['PreferredAuthentications'] ? sshHostConfig['PreferredAuthentications'].split(',').map(s => s.trim()) : ['publickey', 'password', 'keyboard-interactive']; - - const identityFiles: string[] = (sshHostConfig['IdentityFile']?.map(f => untildify(interpolatePath(f)))) || []; - const identitiesOnly = (sshHostConfig['IdentitiesOnly'] || 'no').toLowerCase() === 'yes'; - const identityKeys = await gatherIdentityFiles(identityFiles, this.sshAgentSock, identitiesOnly, this.logger); - - const agent = agentForward && this.sshAgentSock ? new ssh2.OpenSSHAgent(this.sshAgentSock) : undefined; - - // Create proxy jump connections if any - if (sshHostConfig['ProxyJump']) { - const proxyJumps = resolveProxyJumps( - sshHostConfig['ProxyJump'], - (host) => sshconfig.getHostConfiguration(host) - ); - for (let i = 0; i < proxyJumps.length; i++) { - const [proxy, proxyHostConfig] = proxyJumps[i]; - const proxyHostName = proxyHostConfig['HostName'] ? SSHConfiguration.interpolate(proxyHostConfig['HostName'], { '%': '%', 'h': proxy.hostname }) : proxy.hostname; - const proxyUser = proxyHostConfig['User'] || proxy.user || sshUser; - const proxyPort = getProxyJumpPort(proxy, proxyHostConfig); - - const proxyAgentForward = enableAgentForwarding && (proxyHostConfig['ForwardAgent'] || 'no').toLowerCase() === 'yes'; - const proxyAgent = proxyAgentForward && this.sshAgentSock ? new ssh2.OpenSSHAgent(this.sshAgentSock) : undefined; - - const proxyIdentityFiles: string[] = (proxyHostConfig['IdentityFile']?.map(f => untildify(interpolatePath(f)))) || []; - const proxyIdentitiesOnly = (proxyHostConfig['IdentitiesOnly'] || 'no').toLowerCase() === 'yes'; - const proxyIdentityKeys = await gatherIdentityFiles(proxyIdentityFiles, this.sshAgentSock, proxyIdentitiesOnly, this.logger); - - const proxyAuthHandler = this.getSSHAuthHandler(proxyUser, proxyHostName, proxyIdentityKeys, preferredAuthentications); - const proxyConnection = new SSHConnection({ - host: !proxyStream ? proxyHostName : undefined, - port: !proxyStream ? proxyPort : undefined, - sock: proxyStream, - username: proxyUser, - readyTimeout: connectTimeout * 1000, - strictVendor: false, - agentForward: proxyAgentForward, - agent: proxyAgent, - hostVerifier: this.createHostVerifier(proxyHostName, proxyPort, proxyHostConfig, verifyUnknownHosts, verifyKnownHosts), - authHandler: (arg0, arg1, arg2) => (proxyAuthHandler(arg0, arg1, arg2), undefined) - }); - this.proxyConnections.push(proxyConnection); - - const nextProxyJump = i < proxyJumps.length - 1 ? proxyJumps[i + 1] : undefined; - const destIP = nextProxyJump ? (nextProxyJump[1]['HostName'] || nextProxyJump[0].hostname) : sshHostName; - const destPort = nextProxyJump ? getProxyJumpPort(nextProxyJump[0], nextProxyJump[1]) : sshPort; - proxyStream = await proxyConnection.forwardOut('127.0.0.1', 0, destIP, destPort); - } - } else if (sshHostConfig['ProxyCommand']) { - const proxyCommandValue: string | string[] = sshHostConfig['ProxyCommand']; - const proxyCommandLine = [proxyCommandValue].flat().join(' '); - const proxyCommandValues = { - '%': '%', - 'h': sshHostName, - 'n': destination.hostname, - 'p': sshPort.toString(), - 'r': sshUser, - }; - const interpolateProxyCommand = (value: string) => SSHConfiguration.interpolate(value, proxyCommandValues); - - // Like OpenSSH: the shell parses the whole line, quoting included. - let proxyCommand = interpolateProxyCommand(proxyCommandLine); - let proxyArgs: string[] = []; - let options: cp.SpawnOptionsWithoutStdio = { shell: true }; - if (isWindows) { - // Like Win32-OpenSSH: no shell, except cmd.exe for batch - // files and program paths with a space. - proxyArgs = splitProxyCommand(proxyCommandValue).map(interpolateProxyCommand); - proxyCommand = proxyArgs.shift()!; - options = {}; - if (/\.(bat|cmd)$/.test(proxyCommand) || proxyCommand.includes(' ')) { - proxyCommand = `"${proxyCommand}"`; - proxyArgs = proxyArgs.map((arg) => arg.includes(' ') ? `"${arg}"` : arg); - options = { shell: true, windowsHide: true, windowsVerbatimArguments: true }; - } - } + const preferredAuthentications = sshHostConfig['PreferredAuthentications'] ? sshHostConfig['PreferredAuthentications'].split(',').map(s => s.trim()) : ['publickey', 'password', 'keyboard-interactive']; + + const identityFiles: string[] = (sshHostConfig['IdentityFile']?.map(f => untildify(interpolatePath(f)))) || []; + const identitiesOnly = (sshHostConfig['IdentitiesOnly'] || 'no').toLowerCase() === 'yes'; + const identityKeys = await gatherIdentityFiles(identityFiles, this.sshAgentSock, identitiesOnly, this.logger); + + const agent = agentForward && this.sshAgentSock ? new ssh2.OpenSSHAgent(this.sshAgentSock) : undefined; + + // Create proxy jump connections if any + if (sshHostConfig['ProxyJump']) { + const proxyJumps = resolveProxyJumps( + sshHostConfig['ProxyJump'], + (host) => sshconfig.getHostConfiguration(host) + ); + for (let i = 0; i < proxyJumps.length; i++) { + const [proxy, proxyHostConfig] = proxyJumps[i]; + const proxyHostName = proxyHostConfig['HostName'] ? SSHConfiguration.interpolate(proxyHostConfig['HostName'], { '%': '%', 'h': proxy.hostname }) : proxy.hostname; + const proxyUser = proxyHostConfig['User'] || proxy.user || sshUser; + const proxyPort = getProxyJumpPort(proxy, proxyHostConfig); + + const proxyAgentForward = enableAgentForwarding && (proxyHostConfig['ForwardAgent'] || 'no').toLowerCase() === 'yes'; + const proxyAgent = proxyAgentForward && this.sshAgentSock ? new ssh2.OpenSSHAgent(this.sshAgentSock) : undefined; + + const proxyIdentityFiles: string[] = (proxyHostConfig['IdentityFile']?.map(f => untildify(interpolatePath(f)))) || []; + const proxyIdentitiesOnly = (proxyHostConfig['IdentitiesOnly'] || 'no').toLowerCase() === 'yes'; + const proxyIdentityKeys = await gatherIdentityFiles(proxyIdentityFiles, this.sshAgentSock, proxyIdentitiesOnly, this.logger); + + const proxyAuthHandler = this.getSSHAuthHandler(proxyUser, proxyHostName, proxyIdentityKeys, preferredAuthentications); + const proxyConnection = new SSHConnection({ + host: !proxyStream ? proxyHostName : undefined, + port: !proxyStream ? proxyPort : undefined, + sock: proxyStream, + username: proxyUser, + readyTimeout: connectTimeout * 1000, + strictVendor: false, + agentForward: proxyAgentForward, + agent: proxyAgent, + hostVerifier: this.createHostVerifier(proxyHostName, proxyPort, proxyHostConfig, verifyUnknownHosts, verifyKnownHosts), + authHandler: (arg0, arg1, arg2) => (proxyAuthHandler(arg0, arg1, arg2), undefined) + }); + this.proxyConnections.push(proxyConnection); - if (options.shell) { - assertShellSafeProxyCommand(proxyCommandLine, proxyCommandValues); + const nextProxyJump = i < proxyJumps.length - 1 ? proxyJumps[i + 1] : undefined; + const destIP = nextProxyJump ? (nextProxyJump[1]['HostName'] || nextProxyJump[0].hostname) : sshHostName; + const destPort = nextProxyJump ? getProxyJumpPort(nextProxyJump[0], nextProxyJump[1]) : sshPort; + proxyStream = await proxyConnection.forwardOut('127.0.0.1', 0, destIP, destPort); + } + } else if (sshHostConfig['ProxyCommand']) { + const proxyCommandValue: string | string[] = sshHostConfig['ProxyCommand']; + const proxyCommandLine = [proxyCommandValue].flat().join(' '); + const proxyCommandValues = { + '%': '%', + 'h': sshHostName, + 'n': destination.hostname, + 'p': sshPort.toString(), + 'r': sshUser, + }; + const interpolateProxyCommand = (value: string) => SSHConfiguration.interpolate(value, proxyCommandValues); + + // Like OpenSSH: the shell parses the whole line, quoting included. + let proxyCommand = interpolateProxyCommand(proxyCommandLine); + let proxyArgs: string[] = []; + let options: cp.SpawnOptionsWithoutStdio = { shell: true }; + if (isWindows) { + // Like Win32-OpenSSH: no shell, except cmd.exe for batch + // files and program paths with a space. + proxyArgs = splitProxyCommand(proxyCommandValue).map(interpolateProxyCommand); + proxyCommand = proxyArgs.shift()!; + options = {}; + if (/\.(bat|cmd)$/.test(proxyCommand) || proxyCommand.includes(' ')) { + proxyCommand = `"${proxyCommand}"`; + proxyArgs = proxyArgs.map((arg) => arg.includes(' ') ? `"${arg}"` : arg); + options = { shell: true, windowsHide: true, windowsVerbatimArguments: true }; } + } - this.logger.trace(`Spawning ProxyCommand: ${[proxyCommand, ...proxyArgs].join(' ')}`); - - const child = cp.spawn(proxyCommand, proxyArgs, options); - proxyStream = stream.Duplex.from({ readable: child.stdout, writable: child.stdin }); - this.proxyCommandProcess = child; + if (options.shell) { + assertShellSafeProxyCommand(proxyCommandLine, proxyCommandValues); } - // Create final shh connection - const sshAuthHandler = this.getSSHAuthHandler(sshUser, sshHostName, identityKeys, preferredAuthentications); - - this.sshConnection = new SSHConnection({ - host: !proxyStream ? sshHostName : undefined, - port: !proxyStream ? sshPort : undefined, - sock: proxyStream, - username: sshUser, - readyTimeout: connectTimeout * 1000, - strictVendor: false, - agentForward, - agent, - hostVerifier: this.createHostVerifier(sshHostName, sshPort, sshHostConfig, verifyUnknownHosts, verifyKnownHosts), - authHandler: (arg0, arg1, arg2) => (sshAuthHandler(arg0, arg1, arg2), undefined), - }); - } else { - this.logger.info(`GSSAPI authentication requested for ${destination.hostname}; connecting via the local SSH client`); - this.sshConnection = new NativeSSHConnection({ - destination: destination.user ? `${destination.user}@${destination.hostname}` : destination.hostname, - port: destination.port, - configFile: getSSHConfigPath(), - dynamicForwarding: enableDynamicForwarding, - readyTimeout: connectTimeout * 1000, - logger: this.logger - }); + this.logger.trace(`Spawning ProxyCommand: ${[proxyCommand, ...proxyArgs].join(' ')}`); + + const child = cp.spawn(proxyCommand, proxyArgs, options); + proxyStream = stream.Duplex.from({ readable: child.stdout, writable: child.stdin }); + this.proxyCommandProcess = child; } + // Create final shh connection + const sshAuthHandler = this.getSSHAuthHandler(sshUser, sshHostName, identityKeys, preferredAuthentications); + + this.sshConnection = new SSHConnection({ + host: !proxyStream ? sshHostName : undefined, + port: !proxyStream ? sshPort : undefined, + sock: proxyStream, + username: sshUser, + readyTimeout: connectTimeout * 1000, + strictVendor: false, + agentForward, + agent, + hostVerifier: this.createHostVerifier(sshHostName, sshPort, sshHostConfig, verifyUnknownHosts, verifyKnownHosts), + authHandler: (arg0, arg1, arg2) => (sshAuthHandler(arg0, arg1, arg2), undefined), + }); + await this.sshConnection.connect(); const envVariables: Record = {}; diff --git a/src/common/logger.ts b/src/common/logger.ts index 8077c57d..8ceb440f 100644 --- a/src/common/logger.ts +++ b/src/common/logger.ts @@ -8,6 +8,8 @@ export interface ILogger { trace(message: string, data?: unknown): void; info(message: string, data?: unknown): void; error(message: string, data?: unknown): void; + + show(): void; } export class Log { diff --git a/src/extension.ts b/src/extension.ts index 4833267a..5971c036 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import * as os from 'os'; import { Log } from './common/logger'; import { RemoteSSHResolver, REMOTE_SSH_AUTHORITY } from './authResolver'; import { openSSHConfigFile, promptOpenRemoteSSHWindow } from './commands'; @@ -6,9 +7,14 @@ import { HostTreeDataProvider } from './hostTreeView'; import { getRemoteWorkspaceLocationData, RemoteLocationHistory } from './remoteLocationHistory'; export async function activate(context: vscode.ExtensionContext) { + const extensionId = context.extension.id; + const packageJSON = context.extension.packageJSON; + const logger = new Log('Remote - SSH'); context.subscriptions.push(logger); + logger.info(`${extensionId}/${packageJSON.version} (${os.release()} ${os.platform()} ${os.arch()}) vscode/${vscode.version} (${vscode.env.appName})`); + const remoteSSHResolver = new RemoteSSHResolver(context, logger); context.subscriptions.push(vscode.workspace.registerRemoteAuthorityResolver(REMOTE_SSH_AUTHORITY, remoteSSHResolver)); context.subscriptions.push(remoteSSHResolver); diff --git a/src/native/nativeSshAskpass.ts b/src/native/nativeSshAskpass.ts index 916d4fde..0a620424 100644 --- a/src/native/nativeSshAskpass.ts +++ b/src/native/nativeSshAskpass.ts @@ -82,16 +82,29 @@ export class AskPassServer { }; const handleListening = async () => { server.removeListener('error', handleError); - try { - if (process.platform !== 'win32') { - fs.chmod(socketPath, 0o600); - } - } catch (error: unknown) { - server.close(); - reject(error instanceof Error ? error : new Error(String(error))); + if (process.platform === 'win32') { + resolve(); return; } - resolve(); + + let retries = 5; + const applyPermissions = async () => { + try { + await fs.chmod(socketPath, 0o600); + resolve(); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException)?.code === 'ENOENT' && retries > 0) { + retries--; + setTimeout(applyPermissions, 50); + return; + } + + server.close(); + reject(error instanceof Error ? error : new Error(String(error))); + } + }; + + await applyPermissions(); }; server.once('error', handleError); diff --git a/src/native/nativeSshManager.ts b/src/native/nativeSshManager.ts index 6ebd2d8c..9be2e0fd 100644 --- a/src/native/nativeSshManager.ts +++ b/src/native/nativeSshManager.ts @@ -1,7 +1,6 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import * as cp from 'child_process'; -import * as vscode from 'vscode'; import { exists } from '../common/files'; import { getProductJson } from '../server-config/get-product-json'; import { sleep } from '../common/sleep'; @@ -78,13 +77,14 @@ export class NativeSshRegistry { static async forAuthority( globalStorageDir: string, authorityKey: string, + extensionVersion: string ): Promise { const productJson = await getProductJson(); return new NativeSshRegistry( path.join( globalStorageDir, 'native-ssh', - `ssh-host-${authorityKey}-${productJson.commit as string}-${vscode.version.replace('-insider', '')}`, + `ssh-host-${authorityKey}-${productJson.commit as string}-${extensionVersion}`, 'data.json' ) ); diff --git a/src/ssh/identityFiles.ts b/src/ssh/identityFiles.ts index 599ddfb9..afa43853 100644 --- a/src/ssh/identityFiles.ts +++ b/src/ssh/identityFiles.ts @@ -5,7 +5,7 @@ import * as crypto from 'crypto'; import type { ParsedKey } from 'ssh2-streams'; import * as ssh2 from 'ssh2'; import { untildify } from '../common/files'; -import { Log } from '../common/logger'; +import { ILogger } from '../common/logger'; const homeDir = os.homedir(); const PATH_SSH_CLIENT_ID_DSA = path.join(homeDir, '.ssh', '/id_dsa'); @@ -35,7 +35,7 @@ export interface SSHKey { } // From https://github.com/openssh/openssh-portable/blob/acb2059febaddd71ee06c2ebf63dcf211d9ab9f2/sshconnect2.c#L1689-L1690 -export async function gatherIdentityFiles(identityFiles: string[], sshAgentSock: string | undefined, identitiesOnly: boolean, logger: Log) { +export async function gatherIdentityFiles(identityFiles: string[], sshAgentSock: string | undefined, identitiesOnly: boolean, logger: ILogger) { identityFiles = identityFiles.map(untildify).map(i => i.replace(/\.pub$/, '')); if (identityFiles.length === 0) { identityFiles.push(...DEFAULT_IDENTITY_FILES); diff --git a/src/ssh/knownHosts.ts b/src/ssh/knownHosts.ts index 9bb947da..16bd4fed 100644 --- a/src/ssh/knownHosts.ts +++ b/src/ssh/knownHosts.ts @@ -4,7 +4,7 @@ import * as path from 'path'; import * as crypto from 'crypto'; import { untildify, exists as fileExists } from '../common/files'; import { isWindows } from '../common/platform'; -import type { Log } from '../common/logger'; +import type { ILogger } from '../common/logger'; const HASH_MAGIC = '|1|'; const HASH_DELIM = '|'; @@ -71,7 +71,7 @@ function configuredFiles(value: string | string[] | undefined, defaults: string[ .map(v => untildify(v)); } -export async function loadKnownHosts(hostConfig: KnownHostsFilesConfig, logger: Log): Promise { +export async function loadKnownHosts(hostConfig: KnownHostsFilesConfig, logger: ILogger): Promise { const userFiles = configuredFiles(hostConfig['UserKnownHostsFile'], defaultUserFiles()); const globalFiles = configuredFiles(hostConfig['GlobalKnownHostsFile'], defaultGlobalFiles()); diff --git a/src/ssh/nativeClient.ts b/src/ssh/nativeClient.ts deleted file mode 100644 index 4289ff2f..00000000 --- a/src/ssh/nativeClient.ts +++ /dev/null @@ -1,528 +0,0 @@ -import { spawn } from 'child_process'; -// eslint-disable-next-line no-duplicate-imports -import type { ChildProcess } from 'child_process'; -import { randomBytes } from 'crypto'; -import { EventEmitter } from 'events'; -import * as net from 'net'; -import type { ILogger } from '../common/logger'; -import { findRandomPort } from '../common/ports'; -import { isWindows } from '../common/platform'; -import type { HostConfiguration } from './sshConfig'; -import type { SSHClient, SSHExecChannel } from './sshClient'; -import type { SSHTunnelConfig } from './sshConnection'; - -/** Time between two ControlMaster readiness probes. */ -const MASTER_POLL_INTERVAL = 300; -/** Grace period for a killed ssh process to exit on its own before SIGKILL. */ -const KILL_GRACE_PERIOD = 2000; -/** Cap on the stderr text kept around for error messages. */ -const MAX_STDERR_LENGTH = 8 * 1024; -const SSH_CLIENT = 'ssh'; - -/** - * `GSSAPIAuthentication yes` values per ssh_config(5). When a host requests - * GSSAPI, the connection is delegated to the local `ssh` client: the SSH - * `gssapi-with-mic` method needs access to the platform Kerberos credentials, - * which the bundled ssh2 library cannot provide. - */ -const GSSAPI_DIRECTIVE = 'GSSAPIAuthentication'; -const GSSAPI_ENABLED_VALUES = new Set(['yes', 'yes-point-to-point']); -const GSSAPI_METHOD = 'gssapi-with-mic'; - -/** - * Whether the resolved host configuration asks for GSSAPI authentication, - * either explicitly via `GSSAPIAuthentication` or by listing - * `gssapi-with-mic` in `PreferredAuthentications`. - */ -export function isGssapiAuthenticationRequested(hostConfig: HostConfiguration): boolean { - const gssapiAuth = hostConfig[GSSAPI_DIRECTIVE]; - if (gssapiAuth && GSSAPI_ENABLED_VALUES.has(gssapiAuth.trim().toLowerCase())) { - return true; - } - - const preferred = hostConfig['PreferredAuthentications']; - return !!preferred && preferred.split(',').some(method => method.trim().toLowerCase() === GSSAPI_METHOD); -} - -export interface NativeSSHConnectConfig { - /** - * The destination passed to the local `ssh` client, as `[user@]hostname`. - * Given verbatim so ssh_config aliases, ProxyJump/ProxyCommand, and - * per-host options keep working exactly like on the command line. - */ - destination: string; - /** Explicit port from the connection target; when absent ssh_config decides. */ - port?: number; - /** - * SSH config file the extension parsed, passed via `-F` so the client - * interprets the exact same configuration (aliases, GSSAPI, proxies). - * Should always point at the file `getSSHConfigPath()` resolves to. - */ - configFile: string; - /** Open a dynamic (-D) forward alongside the connection for SOCKS tunnels. */ - dynamicForwarding: boolean; - /** Timeout for establishing the connection, in milliseconds. */ - readyTimeout: number; - logger: ILogger; -} - -/** - * Options shared by every local `ssh` invocation: - * - `-F` keeps the client on the same config file the extension parsed. - * - `BatchMode=yes` makes the client fail instead of hanging on interactive - * prompts (passwords, host key confirmation) — the extension host has no tty. - * GSSAPI, agent keys, and passphrase-less identity files keep working. - * - `ControlPath` routes the invocation through the ControlMaster connection, - * so only the very first connection authenticates. - */ -function buildSshOptions(config: NativeSSHConnectConfig, controlPath?: string): string[] { - const options = ['-F', config.configFile, '-o', 'BatchMode=yes']; - if (controlPath) { - options.push('-o', `ControlPath=${controlPath}`); - } - return options; -} - -/** Explicit `-p` only when the target carries a port; ssh_config wins otherwise. */ -function buildPortArgs(port: number | undefined): string[] { - return port === undefined ? [] : ['-p', String(port)]; -} - -/** Args for the long-lived ControlMaster that owns the authenticated connection. */ -function buildMasterArgs(config: NativeSSHConnectConfig, controlPath: string, socksPort?: number): string[] { - const args = [ - ...buildSshOptions(config, controlPath), - '-o', 'ControlMaster=yes', - '-N', '-T' - ]; - if (socksPort !== undefined) { - args.push('-D', `127.0.0.1:${socksPort}`); - } - args.push(...buildPortArgs(config.port), '--', config.destination); - return args; -} - -/** Args for a one-shot command executed through the master connection. */ -function buildExecArgs(config: NativeSSHConnectConfig, controlPath: string | undefined, cmd: string, params: string[]): string[] { - return [ - ...buildSshOptions(config, controlPath), - ...buildPortArgs(config.port), - '--', config.destination, - cmd, ...params - ]; -} - -/** Args for a forwarding process (-L local forward or -D dynamic SOCKS). */ -function buildForwardArgs(config: NativeSSHConnectConfig, controlPath: string | undefined, forwardArgs: string[]): string[] { - return [ - ...buildSshOptions(config, controlPath), - '-N', '-T', - ...forwardArgs, - ...buildPortArgs(config.port), - '--', config.destination - ]; -} - -/** Args for the `-O check` probe that reports master liveness. */ -function buildCheckArgs(config: NativeSSHConnectConfig, controlPath: string): string[] { - return [...buildSshOptions(config, controlPath), '-O', 'check', '--', config.destination]; -} - -/** Trims collected stderr for display, keeping the tail where the reason lives. */ -function capStderr(text: string): string { - return text.length > MAX_STDERR_LENGTH ? `…${text.slice(-MAX_STDERR_LENGTH)}` : text; -} - -/** - * Terminates an ssh helper process, giving it a grace period to exit on its - * own (so ssh can clean up forwards and the ControlPath socket) before - * escalating to SIGKILL. - */ -function killChild(child: ChildProcess): Promise { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve(); - } - - return new Promise(resolve => { - const timer = setTimeout(() => child.kill('SIGKILL'), KILL_GRACE_PERIOD); - child.once('exit', () => { - clearTimeout(timer); - resolve(); - }); - child.kill('SIGTERM'); - }); -} - -/** - * Wraps a spawned `ssh ` process in the narrow channel surface consumed - * by `authResolver`, mirroring how it consumes an ssh2 `ClientChannel`. - * `data` listeners attach to the process' stdout stream, whose buffering - * keeps early output from being lost before a consumer subscribes. - */ -class SSHProcessChannel extends EventEmitter implements SSHExecChannel { - constructor(private readonly child: ChildProcess) { - super(); - // The channel is over once the process ends, whatever the reason. - child.on('close', () => this.emit('close')); - child.on('error', () => this.emit('close')); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - override on(event: string, listener: (...args: any[]) => void): this { - if (event === 'data') { - return this.forwardStdout('on', listener); - } - return super.on(event, listener); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - override removeListener(event: string, listener: (...args: any[]) => void): this { - if (event === 'data') { - return this.forwardStdout('removeListener', listener); - } - return super.removeListener(event, listener); - } - - close(): void { - void killChild(this.child); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private forwardStdout(method: 'on' | 'removeListener', listener: (...args: any[]) => void): this { - const stdout = this.child.stdout!; - // Safe: stdout only ever emits Buffer chunks for this listener. - stdout[method]('data', listener as (chunk: Buffer) => void); - return this; - } -} - -export default class NativeSSHConnection implements SSHClient { - private readonly config: NativeSSHConnectConfig; - /** ControlMaster process owning the single authenticated connection (POSIX only). */ - private master: ChildProcess | undefined; - /** - * Set when the master daemonized (ControlPersist in the user's config): - * the foreground process is gone, the control socket serves on, and the - * daemon must be shut down through `-O exit` instead of a kill. - */ - private masterDaemonized = false; - private controlPath: string | undefined; - /** Local port of the `-D` listener opened on the master, when enabled. */ - private masterSocksPort: number | undefined; - private masterStderr = ''; - private readonly tunnels = new Map(); - - constructor(config: NativeSSHConnectConfig) { - this.config = config; - } - - private get logger(): ILogger { - return this.config.logger; - } - - /** - * Establish the connection. On POSIX this spawns the ControlMaster and - * waits until it is authenticated; on Windows, where OpenSSH lacks - * multiplexing support, a probe command verifies reachability and auth, - * and every later operation opens its own connection. - */ - connect(): Promise { - return (isWindows ? this.probeConnectivity() : this.spawnMaster().then(() => undefined)) - .then(() => this); - } - - /** Spawn the ControlMaster: authenticates once, serves every later channel. */ - private spawnMaster(): Promise { - if (this.config.dynamicForwarding) { - return findRandomPort().then(port => this.spawnMasterProcess(port)); - } - return this.spawnMasterProcess(undefined); - } - - private spawnMasterProcess(socksPort?: number): Promise { - // Unix domain sockets are limited to ~104 bytes on macOS, and the - // default temp dir there (/var/folders/…) is too long on its own. - // /tmp keeps the path short on every platform the master runs on. - this.controlPath = `/tmp/open-remote-ssh-${randomBytes(6).toString('hex')}`; - this.masterSocksPort = socksPort; - const args = buildMasterArgs(this.config, this.controlPath, socksPort); - this.logger.trace(`Starting ssh ControlMaster: ${SSH_CLIENT} ${args.join(' ')}`); - - this.master = spawn(SSH_CLIENT, args, { stdio: ['ignore', 'ignore', 'pipe'] }); - this.master.stderr!.on('data', chunk => { - this.masterStderr = capStderr(this.masterStderr + chunk.toString()); - this.logger.trace(`ssh ControlMaster: ${chunk.toString().trim()}`); - }); - - return this.waitMasterReady(); - } - - /** Poll `-O check` until the master answers, it dies, or the timeout hits. */ - private waitMasterReady(): Promise { - const master = this.master!; - const destination = this.config.destination; - return new Promise((resolve, reject) => { - let settled = false; - const deadline = Date.now() + this.config.readyTimeout; - - const settle = (err?: Error) => { - if (settled) { - return; - } - settled = true; - master.removeListener('exit', onExit); - if (err) { - return reject(err); - } - resolve(); - }; - - const onExit = (code: number | null, signal: string | null) => { - // With ControlPersist in the user's config the master - // daemonizes once authenticated: the foreground process exits - // 0 while the control socket keeps serving. Let the check - // probe have the final word before declaring a failure. - void this.checkMasterAlive().then(alive => { - if (alive) { - this.masterDaemonized = true; - return settle(); - } - settle(new Error(this.formatMasterError(code, signal))); - }); - }; - master.once('exit', onExit); - - const poll = () => { - if (settled) { - return; - } - this.checkMasterAlive().then(alive => { - if (settled) { - return; - } - if (alive) { - return settle(); - } - if (Date.now() >= deadline) { - return settle(new Error(`Timed out connecting to ${destination} via the local SSH client: ${this.formatMasterError()}`)); - } - setTimeout(poll, MASTER_POLL_INTERVAL); - }); - }; - poll(); - }); - } - - private checkMasterAlive(): Promise { - const args = buildCheckArgs(this.config, this.controlPath!); - return new Promise(resolve => { - const check = spawn(SSH_CLIENT, args, { stdio: ['ignore', 'ignore', 'ignore'] }); - check.on('error', () => resolve(false)); - check.on('close', code => resolve(code === 0)); - }); - } - - private formatMasterError(code?: number | null, signal?: string | null): string { - const how = code !== undefined && code !== null ? `exit code ${code}` : signal ? `signal ${signal}` : 'before the connection was ready'; - const stderr = this.masterStderr.trim(); - const reason = stderr ? `: ${stderr}` : ''; - return `the ssh process for ${this.config.destination} stopped (${how})${reason}`; - } - - /** - * Windows fallback: no ControlMaster exists to probe, so run a no-op - * command through a regular connection to surface auth/network failures. - */ - private probeConnectivity(): Promise { - return this.exec('exit').then(() => undefined); - } - - async exec(cmd: string, params: string[] = []): Promise<{ stdout: string; stderr: string }> { - const args = buildExecArgs(this.config, this.controlPath, cmd, params); - this.logger.trace(`ssh exec: ${SSH_CLIENT} ${args.join(' ')}`); - - return new Promise((resolve, reject) => { - const child = spawn(SSH_CLIENT, args, { stdio: ['ignore', 'pipe', 'pipe'] }); - let stdout = ''; - let stderr = ''; - child.stdout!.on('data', chunk => { stdout += chunk.toString(); }); - child.stderr!.on('data', chunk => { stderr += chunk.toString(); }); - child.on('error', reject); - child.on('close', code => { - // Exit code 255 with no command output means the ssh client - // itself failed (auth, network, host key); anything else is - // the remote command's outcome, mirroring ssh2 semantics. - if (code === 255 && stdout === '') { - return reject(new Error(`ssh command failed on ${this.config.destination}: ${stderr.trim() || `exit code ${code}`}`)); - } - resolve({ stdout, stderr }); - }); - }); - } - - async execPartial(cmd: string, tester: (stdout: string, stderr: string) => boolean, params: string[] = []): Promise<{ stdout: string; stderr: string }> { - const args = buildExecArgs(this.config, this.controlPath, cmd, params); - this.logger.trace(`ssh execPartial: ${SSH_CLIENT} ${args.join(' ')}`); - - return new Promise((resolve, reject) => { - const child = spawn(SSH_CLIENT, args, { stdio: ['ignore', 'pipe', 'pipe'] }); - let stdout = ''; - let stderr = ''; - let settled = false; - - const finish = (result?: { stdout: string; stderr: string }, err?: Error) => { - if (settled) { - return; - } - settled = true; - if (err) { - return reject(err); - } - resolve(result!); - }; - - const test = () => { - if (!settled && tester(stdout, stderr)) { - finish({ stdout, stderr }); - } - }; - - child.stdout!.on('data', chunk => { - stdout += chunk.toString(); - test(); - }); - child.stderr!.on('data', chunk => { - stderr += chunk.toString(); - test(); - }); - child.on('error', reject); - child.on('close', () => finish({ stdout, stderr })); - }); - } - - async execChannel(cmd: string): Promise { - const args = buildExecArgs(this.config, this.controlPath, cmd, []); - this.logger.trace(`ssh execChannel: ${SSH_CLIENT} ${args.join(' ')}`); - - const child = spawn(SSH_CLIENT, args, { stdio: ['ignore', 'pipe', 'pipe'] }); - return new SSHProcessChannel(child); - } - - async addTunnel(config: SSHTunnelConfig): Promise { - const name = config.name || `${config.remoteAddr}@${config.remotePort || config.remoteSocketPath}`; - - if (config.socks) { - if (this.masterSocksPort !== undefined) { - // The master already listens with -D; hand out its port. - return { ...config, name, localPort: this.masterSocksPort, server: this.master }; - } - const localPort = config.localPort ?? await findRandomPort(); - const forward = this.spawnForward(['-D', `127.0.0.1:${localPort}`], name); - await this.waitForwardReady(forward, localPort); - return { ...config, name, localPort, server: forward.process }; - } - - const localPort = config.localPort ?? await findRandomPort(); - // `-L port:` for TCP targets, `-L port:` for remote - // Unix sockets — OpenSSH picks the variant from the target shape. - const target = config.remotePort !== undefined - ? `${config.remoteAddr ?? '127.0.0.1'}:${config.remotePort}` - : config.remoteSocketPath; - const forward = this.spawnForward(['-L', `${localPort}:${target}`], name); - await this.waitForwardReady(forward, localPort); - return { ...config, name, localPort, server: forward.process }; - } - - /** Spawn a forwarding-only ssh process and track it under `name`. */ - private spawnForward(forwardArgs: string[], name: string): { process: ChildProcess; stderr: string } { - const args = buildForwardArgs(this.config, this.controlPath, forwardArgs); - this.logger.trace(`ssh forward: ${SSH_CLIENT} ${args.join(' ')}`); - - const child = spawn(SSH_CLIENT, args, { stdio: ['ignore', 'ignore', 'pipe'] }); - const forward = { process: child, stderr: '' }; - child.stderr!.on('data', chunk => { - forward.stderr = capStderr(forward.stderr + chunk.toString()); - }); - this.tunnels.set(name, forward); - return forward; - } - - /** Race the local forward port against early process death. */ - private waitForwardReady(forward: { process: ChildProcess; stderr: string }, localPort: number): Promise { - return new Promise((resolve, reject) => { - let settled = false; - const deadline = Date.now() + this.config.readyTimeout; - const child = forward.process; - - const settle = (err?: Error) => { - if (settled) { - return; - } - settled = true; - child.removeListener('exit', onExit); - if (err) { - return reject(err); - } - resolve(); - }; - - const onExit = () => settle(new Error(`ssh forward on port ${localPort} failed: ${forward.stderr.trim() || 'the ssh process exited'}`)); - child.once('exit', onExit); - - const tryConnect = () => { - if (settled) { - return; - } - const socket = net.connect({ port: localPort, host: '127.0.0.1' }); - socket.once('connect', () => { - socket.destroy(); - settle(); - }); - socket.once('error', () => { - socket.destroy(); - if (Date.now() >= deadline) { - return settle(new Error(`Timed out waiting for the ssh forward on port ${localPort}: ${forward.stderr.trim() || this.formatMasterError()}`)); - } - setTimeout(tryConnect, MASTER_POLL_INTERVAL); - }); - }; - tryConnect(); - }); - } - - async closeTunnel(name?: string): Promise { - if (name) { - const forward = this.tunnels.get(name); - if (forward) { - this.tunnels.delete(name); - await killChild(forward.process); - } - return; - } - for (const tunnelName of [...this.tunnels.keys()]) { - await this.closeTunnel(tunnelName); - } - } - - async close(): Promise { - await this.closeTunnel(); - if (this.masterDaemonized) { - // The daemonized master lives outside our process tree, so it can - // only be reached through the control socket. - this.masterDaemonized = false; - await new Promise(resolve => { - const args = [...buildSshOptions(this.config, this.controlPath), '-O', 'exit', '--', this.config.destination]; - this.logger.trace(`Stopping ssh ControlMaster: ${SSH_CLIENT} ${args.join(' ')}`); - const quit = spawn(SSH_CLIENT, args, { stdio: ['ignore', 'ignore', 'ignore'] }); - quit.on('error', () => resolve()); - quit.on('close', () => resolve()); - }); - return; - } - if (this.master) { - const master = this.master; - this.master = undefined; - await killChild(master); - } - } -} diff --git a/test/gssapi.test.ts b/test/gssapi.test.ts deleted file mode 100644 index cca4fd33..00000000 --- a/test/gssapi.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { randomUUID } from 'node:crypto'; -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import fse from '@zokugun/fs-extra-plus/sync'; -import { vol } from 'memfs'; -import { afterAll, beforeAll, expect, it } from 'vitest'; -import { RemoteSSHResolver, getRemoteAuthority } from './rewires/remote'; -import { Log } from './mocks/logger'; -import * as vscode from './mocks/vscode'; -import { runDocker } from './utils/run-docker'; -import { getMappedPort } from './utils/get-mapped-port'; -import { waitForSSHReady } from './utils/wait-for-ssh-ready'; - -const SERVER_SETUP = fse.readFile('./src/scripts/server-setup.sh', 'utf8').value!; - -const PRODUCT_JSON = JSON.stringify({ - nameShort: 'VSCodium', - nameLong: 'VSCodium', - applicationName: 'codium', - quality: 'stable', - commit: '4c0b0c6cc561d2d3636d1ec250935431876ce4dc', - version: '1.126.04524', - serverApplicationName: 'codium-server', - serverDataFolderName: '.vscodium-server', - serverDownloadUrlTemplate: 'https://github.com/VSCodium/vscodium/releases/download/1.126.04524/vscodium-reh-${os}-${arch}-1.126.04524.tar.gz', -}); - -const IMAGE = 'local-ubuntu-bash'; -const USERNAME = 'openremotessh'; -const PASSWORD = 'openremotessh'; - -const containerName = `open-remote-ssh-test-${randomUUID()}`; -// The config lives on the real filesystem because the local ssh client reads -// it with a plain exec, and gets mirrored into memfs for the resolver, which -// runs against mocked fs. -const configPath = path.join(os.tmpdir(), `open-remote-ssh-test-config-${randomUUID()}`); -const identityKeyPath = path.join(os.tmpdir(), `open-remote-ssh-test-key-${randomUUID()}`); -const knownHostsPath = path.join(os.tmpdir(), `open-remote-ssh-test-known-hosts-${randomUUID()}`); - -let hostPort: number; - -function hostConfigLines(gssapi: boolean): string[] { - return [ - 'Host test', - ' HostName 127.0.0.1', - ` Port ${hostPort}`, - ` User ${USERNAME}`, - ...(gssapi ? [' GSSAPIAuthentication yes'] : []), - ` IdentityFile ${identityKeyPath}`, - // Keep the client from burning sshd's MaxAuthTries on the developer's own - // agent and default keys before reaching the fixture identity. - ' IdentitiesOnly yes', - ' StrictHostKeyChecking accept-new', - ` UserKnownHostsFile ${knownHostsPath}`, - ]; -} - -beforeAll(async () => { - vol.reset(); - vscode.workspace.resetConfiguration(); - - execFileSync('ssh-keygen', ['-t', 'ed25519', '-N', '', '-q', '-f', identityKeyPath]); - - const publicKey = fs.readFileSync(`${identityKeyPath}.pub`, 'utf8').trim(); - - runDocker(['rm', '-f', containerName], true); - - runDocker([ - 'run', - '--detach', - '--rm', - '--name', - containerName, - '--publish', - '2222', - '--env', - `USER_NAME=${USERNAME}`, - '--env', - `USER_PASSWORD=${PASSWORD}`, - '--env', - 'PASSWORD_ACCESS=true', - '--env', - 'SUDO_ACCESS=false', - '--env', - 'LOG_STDOUT=true', - IMAGE, - ]); - - hostPort = getMappedPort(containerName); - - await waitForSSHReady(USERNAME, PASSWORD, hostPort, 60_000, containerName); - - // The fixture's PUBLIC_KEY env lands in /config/.ssh, which sshd does not - // consult; install the key in the user's home instead so the local ssh - // client can authenticate non-interactively. The user only exists once - // the container's entrypoint has run, so this has to wait for readiness. - runDocker([ - 'exec', containerName, 'sh', '-c', - `mkdir -p /home/${USERNAME}/.ssh && echo '${publicKey}' > /home/${USERNAME}/.ssh/authorized_keys && chown -R ${USERNAME}:${USERNAME} /home/${USERNAME}/.ssh && chmod 700 /home/${USERNAME}/.ssh && chmod 600 /home/${USERNAME}/.ssh/authorized_keys`, - ]); -}, 120_000); - -afterAll(() => { - runDocker(['rm', '-f', containerName], true); - for (const file of [configPath, identityKeyPath, `${identityKeyPath}.pub`, knownHostsPath]) { - fs.rmSync(file, { force: true }); - } - vscode.workspace.resetConfiguration(); -}); - -it('connects through the local ssh client when GSSAPI is requested', async () => { - const config = hostConfigLines(true).join('\n'); - fs.writeFileSync(configPath, config); - vol.fromJSON({ - [configPath]: config, - '/bin/vscodium/app/product.json': PRODUCT_JSON, - '/data/vscodium/extensions/open-remote-ssh/lib/scripts/server-setup.sh': SERVER_SETUP, - }); - vscode.workspace.setConfiguration('configFile', configPath); - - const logger = new Log('Remote - SSH'); - const extContext = new vscode.ExtensionContext(); - const remoteSSHResolver = new RemoteSSHResolver(extContext, logger); - const remoteContext = new vscode.RemoteAuthorityResolverContext(); - const authority = getRemoteAuthority('test'); - const result = await remoteSSHResolver.resolve(authority, remoteContext); - - expect(result).toBeDefined(); - expect(result.host).to.eql('127.0.0.1'); - - remoteSSHResolver.dispose(); -}, 120_000); diff --git a/test/native-client.test.ts b/test/native-client.test.ts deleted file mode 100644 index e76ecec9..00000000 --- a/test/native-client.test.ts +++ /dev/null @@ -1,392 +0,0 @@ -import { EventEmitter } from 'node:events'; -import * as net from 'node:net'; -import { PassThrough } from 'node:stream'; -import { afterAll, describe, expect, it, vi } from 'vitest'; -import NativeSSHConnection, { isGssapiAuthenticationRequested } from '../src/ssh/nativeClient'; -import type { HostConfiguration } from '../src/ssh/sshConfig'; -import { Log } from './mocks/logger'; - -const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })); - -vi.mock('child_process', () => ({ spawn: spawnMock })); - -/** - * Minimal ChildProcess stand-in: only the surface `nativeClient` touches - * (stdout/stderr streams, exit events, kill) is implemented. - */ -class FakeChildProcess extends EventEmitter { - // PassThrough mirrors a real child's stdio: writes made before a consumer - // attaches a `data` listener stay buffered instead of being lost. - stdout = new PassThrough(); - stderr = new PassThrough(); - exitCode: number | null = null; - signalCode: string | null = null; - killed = false; - - kill(signal = 'SIGTERM'): boolean { - if (this.exitCode !== null || this.signalCode !== null) { - return false; - } - this.killed = true; - this.signalCode = signal; - this.end(null, signal); - return true; - } - - /** - * Mirrors Node's child process lifecycle: `exit` fires first, `close` - * after the stdio streams have drained. - */ - end(code: number | null, signal: string | null): void { - queueMicrotask(() => { - this.emit('exit', code, signal); - this.emit('close', code, signal); - }); - } - - /** Simulate the process ending by itself with the given exit code. */ - simulateExit(code: number): void { - this.exitCode = code; - this.end(code, null); - } - - simulateStdout(text: string): void { - this.stdout.write(Buffer.from(text)); - } - - simulateStderr(text: string): void { - this.stderr.write(Buffer.from(text)); - } -} - -type SpawnFactory = (args: string[]) => FakeChildProcess; - -/** A master that authenticates fine: `-O check` answers with exit 0. */ -const healthyMasterFactory: SpawnFactory = args => { - if (args.includes('check')) { - const check = new FakeChildProcess(); - check.simulateExit(0); - return check; - } - return new FakeChildProcess(); -}; - -function spawnCheck(exitCode: number): FakeChildProcess { - const check = new FakeChildProcess(); - check.simulateExit(exitCode); - return check; -} - -function mockSpawn(factory: SpawnFactory): FakeChildProcess[] { - spawnMock.mockClear(); - const children: FakeChildProcess[] = []; - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - const child = factory(args); - children.push(child); - return child; - }); - return children; -} - -const logger = new Log('test'); - -/** Starts a real local listener so `-L`/`-D` readiness probes can connect. */ -async function withLocalListener(fn: (port: number) => Promise): Promise { - const server = net.createServer(); - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); - const port = (server.address() as net.AddressInfo).port; - try { - return await fn(port); - } finally { - await new Promise(resolve => server.close(() => resolve())); - } -} - -const baseConfig = { - destination: 'k8s-dev', - configFile: '/tmp/test-ssh-config', - dynamicForwarding: false, - readyTimeout: 5000, - logger -}; - -describe('isGssapiAuthenticationRequested', () => { - const cases: Array<{ name: string; config: HostConfiguration; expected: boolean }> = [ - { name: 'GSSAPIAuthentication yes', config: { GSSAPIAuthentication: 'yes' }, expected: true }, - { name: 'point-to-point variant', config: { GSSAPIAuthentication: 'yes-point-to-point' }, expected: true }, - { name: 'case-insensitive value', config: { GSSAPIAuthentication: 'YES' }, expected: true }, - { name: 'explicit no', config: { GSSAPIAuthentication: 'no' }, expected: false }, - { name: 'gssapi-with-mic listed first', config: { PreferredAuthentications: 'gssapi-with-mic,publickey' }, expected: true }, - { name: 'gssapi-with-mic with spaces', config: { PreferredAuthentications: ' publickey , gssapi-with-mic ' }, expected: true }, - { name: 'other methods only', config: { PreferredAuthentications: 'publickey,password' }, expected: false }, - { name: 'no relevant directives', config: {}, expected: false } - ]; - - for (const { name, config, expected } of cases) { - it(name, () => { - expect(isGssapiAuthenticationRequested(config)).toBe(expected); - }); - } -}); - -describe('NativeSSHConnection', () => { - afterAll(() => { - spawnMock.mockRestore(); - }); - - it('spawns a ControlMaster with batch mode and probes it until ready', async () => { - const children = mockSpawn(healthyMasterFactory); - - await new NativeSSHConnection(baseConfig).connect(); - - const masterArgs = spawnMock.mock.calls[0][1] as string[]; - expect(masterArgs).toContain('BatchMode=yes'); - expect(masterArgs).toContain('ControlMaster=yes'); - expect(masterArgs[masterArgs.indexOf('-F') + 1]).toBe('/tmp/test-ssh-config'); - expect(masterArgs.filter(arg => arg.startsWith('ControlPath='))).toHaveLength(1); - expect(masterArgs[masterArgs.length - 1]).toBe('k8s-dev'); - expect(children.length).toBeGreaterThanOrEqual(2); - }); - - it('rejects with the ssh stderr when the master dies before becoming ready', async () => { - mockSpawn(args => { - if (args.includes('check')) { - return spawnCheck(1); - } - // The master is silent at first and only fails after the - // extension has attached its stderr listener, like a real ssh. - const master = new FakeChildProcess(); - queueMicrotask(() => { - master.simulateStderr('kex_exchange_identification: Connection closed by remote host'); - master.simulateExit(255); - }); - return master; - }); - - await expect(new NativeSSHConnection(baseConfig).connect()) - .rejects.toThrow('kex_exchange_identification'); - }); - - it('treats a daemonized ControlPersist master as ready', async () => { - const children = mockSpawn(args => { - if (args.includes('check')) { - // The daemonized master answers the control socket. - return spawnCheck(0); - } - // ControlPersist: the foreground master exits 0 after forking. - const master = new FakeChildProcess(); - queueMicrotask(() => master.simulateExit(0)); - return master; - }); - - await new NativeSSHConnection(baseConfig).connect(); - expect(children[0].exitCode).toBe(0); - }); - - it('stops a daemonized master through the control socket on close', async () => { - mockSpawn(args => { - if (args.includes('check')) { - return spawnCheck(0); - } - const master = new FakeChildProcess(); - queueMicrotask(() => master.simulateExit(0)); - return master; - }); - - const connection = new NativeSSHConnection(baseConfig); - await connection.connect(); - await connection.close(); - - const exitArgs = spawnMock.mock.calls.find(([, args]) => (args as string[]).includes('exit'))![1] as string[]; - expect(exitArgs).toContain('-O'); - expect(exitArgs[exitArgs.length - 1]).toBe('k8s-dev'); - }); - - it('times out when the master never becomes ready', async () => { - vi.useFakeTimers(); - try { - mockSpawn(args => { - if (args.includes('check')) { - return spawnCheck(1); - } - return new FakeChildProcess(); - }); - const connect = new NativeSSHConnection({ ...baseConfig, readyTimeout: 300 }).connect(); - const assertion = expect(connect).rejects.toThrow('Timed out'); - await vi.runAllTimersAsync(); - await assertion; - } finally { - vi.useRealTimers(); - } - }); - - it('runs commands through the master connection and aggregates output', async () => { - mockSpawn(args => { - if (args.includes('check')) { - const check = new FakeChildProcess(); - check.simulateExit(0); - return check; - } - if (args[args.length - 1] === 'uname') { - const exec = new FakeChildProcess(); - queueMicrotask(() => { - exec.simulateStdout('Linux\n'); - exec.simulateExit(0); - }); - return exec; - } - return new FakeChildProcess(); - }); - - const connection = new NativeSSHConnection(baseConfig); - await connection.connect(); - const result = await connection.exec('uname'); - - expect(result).toEqual({ stdout: 'Linux\n', stderr: '' }); - const execArgs = spawnMock.mock.calls.at(-1)![1] as string[]; - expect(execArgs.slice(-1)).toEqual(['uname']); - expect(execArgs.some(arg => arg.startsWith('ControlPath='))).toBe(true); - }); - - it('rejects exec when the ssh client itself fails', async () => { - mockSpawn(args => { - if (args.includes('check')) { - const check = new FakeChildProcess(); - check.simulateExit(0); - return check; - } - if (args[args.length - 1] === 'uname') { - const exec = new FakeChildProcess(); - queueMicrotask(() => { - exec.simulateStderr('Permission denied (gssapi-with-mic)'); - exec.simulateExit(255); - }); - return exec; - } - return new FakeChildProcess(); - }); - - const connection = new NativeSSHConnection(baseConfig); - await connection.connect(); - - await expect(connection.exec('uname')).rejects.toThrow('Permission denied'); - }); - - it('resolves execPartial as soon as the tester matches', async () => { - mockSpawn(args => { - if (args.includes('check')) { - return spawnCheck(0); - } - if (args.includes('ControlMaster=yes')) { - // The master connection must stay alive while the command runs. - return new FakeChildProcess(); - } - const exec = new FakeChildProcess(); - queueMicrotask(() => { - exec.simulateStdout('start '); - exec.simulateStdout('script-id: end'); - exec.simulateStdout(' trailing output that never gets tested'); - exec.simulateExit(0); - }); - return exec; - }); - - const connection = new NativeSSHConnection(baseConfig); - await connection.connect(); - const result = await connection.execPartial('script', stdout => stdout.includes('script-id: end')); - - expect(result.stdout).toContain('script-id: end'); - }); - - it('opens an -L tunnel and closes it again', async () => { - await withLocalListener(async listenerPort => { - let forwardChild: FakeChildProcess | undefined; - mockSpawn(args => { - if (args.includes('check')) { - const check = new FakeChildProcess(); - check.simulateExit(0); - return check; - } - if (args.some(arg => arg.startsWith('-L'))) { - // The tunnel port is already listening (our test server), - // so the readiness probe connects immediately. - forwardChild = new FakeChildProcess(); - return forwardChild; - } - return new FakeChildProcess(); - }); - - const connection = new NativeSSHConnection(baseConfig); - await connection.connect(); - const tunnel = await connection.addTunnel({ - name: 'tunnel-test', - remoteAddr: '127.0.0.1', - remotePort: listenerPort, - localPort: listenerPort - }); - - expect(tunnel.localPort).toBe(listenerPort); - const forwardArgs = spawnMock.mock.calls.find(([, args]) => (args as string[]).some(arg => arg.startsWith('-L')))![1] as string[]; - expect(forwardArgs).toContain('-L'); - expect(forwardArgs).toContain(`${listenerPort}:127.0.0.1:${listenerPort}`); - - await connection.closeTunnel('tunnel-test'); - expect(forwardChild?.killed).toBe(true); - }); - }); - - it('reuses the -D listener opened on the master for SOCKS tunnels', async () => { - const children = mockSpawn(args => { - if (args.includes('check')) { - const check = new FakeChildProcess(); - check.simulateExit(0); - return check; - } - return new FakeChildProcess(); - }); - - const connection = new NativeSSHConnection({ ...baseConfig, dynamicForwarding: true }); - await connection.connect(); - - const masterArgs = spawnMock.mock.calls[0][1] as string[]; - const dashDIndex = masterArgs.indexOf('-D'); - expect(dashDIndex).toBeGreaterThanOrEqual(0); - const socksPort = Number(masterArgs[dashDIndex + 1].split(':')[1]); - expect(socksPort).toBeGreaterThan(0); - - const tunnel = await connection.addTunnel({ name: 'socks', socks: true }); - expect(tunnel.localPort).toBe(socksPort); - expect(tunnel.server).toBe(children[0]); - - await connection.close(); - expect(children[0].killed).toBe(true); - }); - - it('exposes execChannel output and terminates the process on close', async () => { - mockSpawn(args => { - if (args.includes('check')) { - const check = new FakeChildProcess(); - check.simulateExit(0); - return check; - } - if (args[args.length - 1].startsWith('echo')) { - const exec = new FakeChildProcess(); - queueMicrotask(() => exec.simulateStdout('/tmp/agent.sock\n')); - return exec; - } - return new FakeChildProcess(); - }); - - const connection = new NativeSSHConnection(baseConfig); - await connection.connect(); - const channel = await connection.execChannel('echo "$SSH_AUTH_SOCK"; exec cat'); - - const chunks: string[] = []; - const closed = new Promise(resolve => channel.on('close', resolve)); - channel.on('data', chunk => chunks.push(chunk.toString())); - await vi.waitFor(() => expect(chunks.join('')).toContain('/tmp/agent.sock')); - - channel.close(); - await closed; - }); -}); From cf76ef34ac55387cbb918a5e85cf8c32c6810bd2 Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Sat, 31 Oct 2026 17:38:35 -0500 Subject: [PATCH 23/26] add linux fixture tests --- test/fixtures/native/shell-alpine-bash.yml | 24 +++ test/fixtures/native/shell-linux-bash.yml | 26 +++ test/fixtures/native/shell-linux-fish.yml | 24 +++ test/fixturesNative.test.ts | 209 +++++++++++++++++++++ test/mocks/vscode.ts | 53 +++++- 5 files changed, 333 insertions(+), 3 deletions(-) create mode 100644 test/fixtures/native/shell-alpine-bash.yml create mode 100644 test/fixtures/native/shell-linux-bash.yml create mode 100644 test/fixtures/native/shell-linux-fish.yml create mode 100644 test/fixturesNative.test.ts diff --git a/test/fixtures/native/shell-alpine-bash.yml b/test/fixtures/native/shell-alpine-bash.yml new file mode 100644 index 00000000..b4c81ce7 --- /dev/null +++ b/test/fixtures/native/shell-alpine-bash.yml @@ -0,0 +1,24 @@ +client: + files: + ssh_config: > + Host test + HostName 127.0.0.1 + Port 2222 + User openremotessh + Password openremotessh + product.json: > + { + "nameShort": "VSCodium", + "nameLong": "VSCodium", + "applicationName": "codium", + "quality": "stable", + "commit": "4c0b0c6cc561d2d3636d1ec250935431876ce4dc", + "version": "1.126.04524", + "serverApplicationName": "codium-server", + "serverDataFolderName": ".vscodium-server", + "serverDownloadUrlTemplate": "https://github.com/VSCodium/vscodium/releases/download/1.126.04524/vscodium-reh-${os}-${arch}-1.126.04524.tar.gz" + } +server: + image: local-alpine-bash + username: openremotessh + password: openremotessh diff --git a/test/fixtures/native/shell-linux-bash.yml b/test/fixtures/native/shell-linux-bash.yml new file mode 100644 index 00000000..2ad03023 --- /dev/null +++ b/test/fixtures/native/shell-linux-bash.yml @@ -0,0 +1,26 @@ +client: + files: + ssh_config: > + Host test + HostName 127.0.0.1 + Port 2222 + User openremotessh + Password openremotessh + product.json: > + { + "nameShort": "VSCodium", + "nameLong": "VSCodium", + "applicationName": "codium", + "quality": "stable", + "commit": "4c0b0c6cc561d2d3636d1ec250935431876ce4dc", + "version": "1.126.04524", + "serverApplicationName": "codium-server", + "serverDataFolderName": ".vscodium-server", + "serverDownloadUrlTemplate": "https://github.com/VSCodium/vscodium/releases/download/1.126.04524/vscodium-reh-${os}-${arch}-1.126.04524.tar.gz" + } + extensionConfiguration: + connectionBackend: native +server: + image: local-ubuntu-bash + username: openremotessh + password: openremotessh diff --git a/test/fixtures/native/shell-linux-fish.yml b/test/fixtures/native/shell-linux-fish.yml new file mode 100644 index 00000000..d25ec6a7 --- /dev/null +++ b/test/fixtures/native/shell-linux-fish.yml @@ -0,0 +1,24 @@ +client: + files: + ssh_config: > + Host test + HostName 127.0.0.1 + Port 2222 + User openremotessh + Password openremotessh + product.json: > + { + "nameShort": "VSCodium", + "nameLong": "VSCodium", + "applicationName": "codium", + "quality": "stable", + "commit": "4c0b0c6cc561d2d3636d1ec250935431876ce4dc", + "version": "1.126.04524", + "serverApplicationName": "codium-server", + "serverDataFolderName": ".vscodium-server", + "serverDownloadUrlTemplate": "https://github.com/VSCodium/vscodium/releases/download/1.126.04524/vscodium-reh-${os}-${arch}-1.126.04524.tar.gz" + } +server: + image: local-ubuntu-fish + username: openremotessh + password: openremotessh diff --git a/test/fixturesNative.test.ts b/test/fixturesNative.test.ts new file mode 100644 index 00000000..eb829f20 --- /dev/null +++ b/test/fixturesNative.test.ts @@ -0,0 +1,209 @@ +import { randomUUID } from 'node:crypto'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import fse from '@zokugun/fs-extra-plus/sync'; +import { xtry, xtryAsync } from '@zokugun/xtry/sync'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import YAML from 'yaml'; +import { Log } from './mocks/logger'; +import * as vscode from './mocks/vscode'; +import { runDocker } from './utils/run-docker'; +import { getMappedPort } from './utils/get-mapped-port'; +import { waitForSSHReady } from './utils/wait-for-ssh-ready'; +import { getRemoteAuthority, RemoteSSHResolver } from '../src/authResolver'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = path.resolve(__dirname, '..'); +const LIB_DIR = path.join(PROJECT_ROOT, 'lib'); +const FIXTURE_ROOT = fse.join('.', 'test', 'fixtures', 'native'); + +type TestDocument = { + client: { + files: Record; + extensionConfiguration?: Record; + }; + server: { + image: string; + username: string; + password: string; + platform?: 'linux' | 'windows'; + }; + run?: { + onlyInCI?: boolean; + }; + test: { + either?: string[] | string; + error?: string[] | string; + // The hosts the SSH config is expected to have. + hosts?: string[]; + output?: string[] | string; + }; +}; + +const files = fse.walk(FIXTURE_ROOT, { + absolute: true, + onlyFiles: true, + collect: true, + filter: (item) => item.path.endsWith('.yml'), +}); + +if (files.fails) { + throw files.error; +} + +const extensionFiles = fse.walk(LIB_DIR, { + absolute: true, + collect: true, + filter: (item) => { + return !item.path.endsWith('.map') + && (item.stats.isDirectory() || item.path.includes('native' + path.sep) || item.path.includes('scripts' + path.sep)); + }, +}); + +if (extensionFiles.fails) { + throw extensionFiles.error; +} + +for (const file of files.value) { + const name = fse.leafName(file.path, 1); + const content = fse.readFile(file.path, 'utf8'); + if (content.fails) { + throw content.error; + } + + const document = xtry(() => YAML.parse(content.value) as unknown); + if (document.fails) { + throw document.error; + } + + const { client, server, test, run } = document.value as TestDocument; + const containerName = `open-remote-ssh-test-${randomUUID()}`; + + if (run?.onlyInCI && !process.env.CI) { + continue; + } + + if ((server.platform === 'windows') !== (process.platform === 'win32')) { + continue; + } + + const rootDir = path.join(os.tmpdir(), `test-${name}`); + describe(name, async () => { + beforeAll(async () => { + vscode.workspace.resetConfiguration(); + + if(!server.image.startsWith('local-')) { + runDocker(['pull', server.image]); + } + + runDocker(['rm', '-f', containerName], true); + + runDocker([ + 'run', + '--detach', + '--rm', + '--name', + containerName, + '--publish', + '2222:2222', + '--env', + `USER_NAME=${server.username}`, + '--env', + `USER_PASSWORD=${server.password}`, + '--env', + 'PASSWORD_ACCESS=true', + '--env', + 'SUDO_ACCESS=false', + '--env', + 'LOG_STDOUT=true', + server.image, + ]); + + const hostPort = getMappedPort(containerName); + + await waitForSSHReady(server.username, server.password, hostPort, 60_000, containerName); + }, 120_000); + + afterAll(() => { + fse.rmdir(rootDir); + runDocker(['rm', '-f', containerName], true); + }, 120_000); + + it(`test-${name}`, async () => { + vscode.setRootDir(rootDir); + fse.mkdir(vscode.env.appRoot, { recursive: true}); + + for (const [k,v] of Object.entries(client.files)) { + if (k === 'product.json') { + fse.writeFile(path.join(vscode.env.appRoot, k), v); + } else { + fse.writeFile(path.join(rootDir, k), v); + } + } + + const extContext = new vscode.ExtensionContext(); + for (const extFile of extensionFiles.value) { + if( !extFile.stats.isDirectory() ) { + copyFileOrDir(extFile.path, path.join(extContext.extensionPath, path.relative(PROJECT_ROOT, extFile.path))); + } + } + + if (client.files['ssh_config']) { + vscode.workspace.setConfiguration('configFile', path.join(rootDir, 'ssh_config')); + } + if (client.extensionConfiguration) { + for (const [k, v] of Object.entries(client.extensionConfiguration)) { + vscode.workspace.setConfiguration(k, v); + } + } + vscode.window.setPassword(server.password); + + const logger = new Log('Remote - SSH'); + const remoteSSHResolver = new RemoteSSHResolver(extContext, logger); + const remoteContext = new vscode.RemoteAuthorityResolverContext(); + const authority = getRemoteAuthority('test'); + const expectedMessages = test?.error || test?.output || test?.either; + + if (expectedMessages) { + logger.capture(); + } + + if (test?.error || test?.either) { + const result = await xtryAsync(async () => await remoteSSHResolver.resolve(authority, remoteContext)); + + if (test?.error) { + expect(result.fails).toBe(true); + } + } else { + const result = await remoteSSHResolver.resolve(authority, remoteContext); + + expect(result).toBeDefined(); + expect(result.host).to.eql('127.0.0.1'); + } + + if (expectedMessages) { + const messages = logger.messages(); + + if (Array.isArray(expectedMessages)) { + for (const message of expectedMessages) { + expect(messages).to.contains(message); + } + } else { + if (!messages.includes(expectedMessages)) { + console.log(messages); + } + + expect(messages).to.contains(expectedMessages); + } + } + }, 60_000); + }); +} + +function copyFileOrDir(src: string, dest: string) { + const from = path.resolve(src); + const to = path.resolve(dest); + fse.mkdir(path.dirname(to), { recursive: true }); + fse.cp(from, to, { recursive: true }); +} diff --git a/test/mocks/vscode.ts b/test/mocks/vscode.ts index b8bdcc2e..c9ec398f 100644 --- a/test/mocks/vscode.ts +++ b/test/mocks/vscode.ts @@ -6,6 +6,11 @@ type ProgressTask = (progress: vscode.Progress<{ message?: string; increment? let $password: string = ''; let $warningAnswer: string | undefined = undefined; let $config: Record = {}; +let $rootDir: string | undefined = undefined; + +function setRootDir(path: string) { + $rootDir = path; +} const DEFAULT_CONFIG: Record = { verifyKnownHosts: 'accept', @@ -17,11 +22,52 @@ const commands = { }; const env = { - appRoot: '/bin/vscodium/app' + get appRoot(): string { + if ($rootDir) { + return `${$rootDir}/vscodium/app`; + } + return '/bin/vscodium/app'; + } }; +class Uri { + private constructor( + private path: string + ) {} + + static file(path: string): Uri { + return new Uri(path); + } + + get fsPath(): string { + return this.path; + } +} + class ExtensionContext { - extensionPath = '/data/vscodium/extensions/open-remote-ssh'; + get extensionPath(): string { + if ($rootDir) { + return `${$rootDir}/vscodium/extensions/open-remote-ssh`; + } + return '/data/vscodium/extensions/open-remote-ssh'; + } + + get globalStorageUri(): Uri { + if ($rootDir) { + return Uri.file(`${$rootDir}/vscodium/config/user/open-remote-ssh`); + } + return Uri.file('/data/vscodium/config/user/open-remote-ssh'); + } + + extension = { + id: 'jeanp413.open-remote-ssh', + packageJSON: { + name: 'open-remote-ssh', + version: '0.5.2', + publisher: 'jeanp413', + } + }; + environmentVariableCollection = { persistent: false, replace: vi.fn(), @@ -78,7 +124,7 @@ const window = { showErrorMessage: vi.fn(async () => undefined), // eslint-disable-next-line @typescript-eslint/no-unused-vars showInputBox: async (options?: vscode.InputBoxOptions, _token?: vscode.CancellationToken) => { - if(options?.title?.startsWith('Enter password for')) { + if(options?.title?.startsWith('Enter password for') || options?.title?.startsWith('SSH Authentication')) { return $password; } @@ -120,4 +166,5 @@ export { window, version, workspace, + setRootDir }; From 4f3fb4246214f4b2c0f48f560871e18a255b2df0 Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Sat, 31 Oct 2026 23:26:12 -0500 Subject: [PATCH 24/26] add windows fixture tests --- .../native/shell-windows-en-builtin-bash.yml | 30 +++++++++++++++++++ .../native/shell-windows-en-builtin-cmd.yml | 30 +++++++++++++++++++ .../native/shell-windows-en-builtin-pwsh.yml | 30 +++++++++++++++++++ .../shell-windows-en-msys2-bash-nalias.yml | 30 +++++++++++++++++++ .../shell-windows-en-msys2-bash-walias.yml | 30 +++++++++++++++++++ .../shell-windows-en-powershell-cmd.yml | 30 +++++++++++++++++++ .../shell-windows-en-powershell-pwsh.yml | 30 +++++++++++++++++++ .../native/shell-windows-fr-builtin-cmd.yml | 30 +++++++++++++++++++ 8 files changed, 240 insertions(+) create mode 100644 test/fixtures/native/shell-windows-en-builtin-bash.yml create mode 100644 test/fixtures/native/shell-windows-en-builtin-cmd.yml create mode 100644 test/fixtures/native/shell-windows-en-builtin-pwsh.yml create mode 100644 test/fixtures/native/shell-windows-en-msys2-bash-nalias.yml create mode 100644 test/fixtures/native/shell-windows-en-msys2-bash-walias.yml create mode 100644 test/fixtures/native/shell-windows-en-powershell-cmd.yml create mode 100644 test/fixtures/native/shell-windows-en-powershell-pwsh.yml create mode 100644 test/fixtures/native/shell-windows-fr-builtin-cmd.yml diff --git a/test/fixtures/native/shell-windows-en-builtin-bash.yml b/test/fixtures/native/shell-windows-en-builtin-bash.yml new file mode 100644 index 00000000..22d5a116 --- /dev/null +++ b/test/fixtures/native/shell-windows-en-builtin-bash.yml @@ -0,0 +1,30 @@ +client: + files: + ssh_config: > + Host test + HostName 127.0.0.1 + Port 2222 + User openremotessh + Password Open-Remote-SSH-1 + product.json: > + { + "nameShort": "VSCodium", + "nameLong": "VSCodium", + "applicationName": "codium", + "quality": "stable", + "commit": "4c0b0c6cc561d2d3636d1ec250935431876ce4dc", + "version": "1.126.04524", + "serverApplicationName": "codium-server", + "serverDataFolderName": ".vscodium-server", + "serverDownloadUrlTemplate": "https://github.com/VSCodium/vscodium/releases/download/1.126.04524/vscodium-reh-${os}-${arch}-1.126.04524.tar.gz" + } + +server: + image: local-windows-en-builtin-bash + platform: windows + username: openremotessh + password: Open-Remote-SSH-1 + +test: + output: |- + Detected platform: windows, bash diff --git a/test/fixtures/native/shell-windows-en-builtin-cmd.yml b/test/fixtures/native/shell-windows-en-builtin-cmd.yml new file mode 100644 index 00000000..f388559a --- /dev/null +++ b/test/fixtures/native/shell-windows-en-builtin-cmd.yml @@ -0,0 +1,30 @@ +client: + files: + ssh_config: > + Host test + HostName 127.0.0.1 + Port 2222 + User openremotessh + Password Open-Remote-SSH-1 + product.json: > + { + "nameShort": "VSCodium", + "nameLong": "VSCodium", + "applicationName": "codium", + "quality": "stable", + "commit": "4c0b0c6cc561d2d3636d1ec250935431876ce4dc", + "version": "1.126.04524", + "serverApplicationName": "codium-server", + "serverDataFolderName": ".vscodium-server", + "serverDownloadUrlTemplate": "https://github.com/VSCodium/vscodium/releases/download/1.126.04524/vscodium-reh-${os}-${arch}-1.126.04524.tar.gz" + } + +server: + image: local-windows-en-builtin-cmd + platform: windows + username: openremotessh + password: Open-Remote-SSH-1 + +test: + output: |- + Detected platform: windows, cmd diff --git a/test/fixtures/native/shell-windows-en-builtin-pwsh.yml b/test/fixtures/native/shell-windows-en-builtin-pwsh.yml new file mode 100644 index 00000000..a715a214 --- /dev/null +++ b/test/fixtures/native/shell-windows-en-builtin-pwsh.yml @@ -0,0 +1,30 @@ +client: + files: + ssh_config: > + Host test + HostName 127.0.0.1 + Port 2222 + User openremotessh + Password Open-Remote-SSH-1 + product.json: > + { + "nameShort": "VSCodium", + "nameLong": "VSCodium", + "applicationName": "codium", + "quality": "stable", + "commit": "4c0b0c6cc561d2d3636d1ec250935431876ce4dc", + "version": "1.126.04524", + "serverApplicationName": "codium-server", + "serverDataFolderName": ".vscodium-server", + "serverDownloadUrlTemplate": "https://github.com/VSCodium/vscodium/releases/download/1.126.04524/vscodium-reh-${os}-${arch}-1.126.04524.tar.gz" + } + +server: + image: local-windows-en-builtin-pwsh + platform: windows + username: openremotessh + password: Open-Remote-SSH-1 + +test: + output: |- + Detected platform: windows, powershell diff --git a/test/fixtures/native/shell-windows-en-msys2-bash-nalias.yml b/test/fixtures/native/shell-windows-en-msys2-bash-nalias.yml new file mode 100644 index 00000000..c6ce46ce --- /dev/null +++ b/test/fixtures/native/shell-windows-en-msys2-bash-nalias.yml @@ -0,0 +1,30 @@ +client: + files: + ssh_config: > + Host test + HostName 127.0.0.1 + Port 2222 + User openremotessh + Password Open-Remote-SSH-1 + product.json: > + { + "nameShort": "VSCodium", + "nameLong": "VSCodium", + "applicationName": "codium", + "quality": "stable", + "commit": "4c0b0c6cc561d2d3636d1ec250935431876ce4dc", + "version": "1.126.04524", + "serverApplicationName": "codium-server", + "serverDataFolderName": ".vscodium-server", + "serverDownloadUrlTemplate": "https://github.com/VSCodium/vscodium/releases/download/1.126.04524/vscodium-reh-${os}-${arch}-1.126.04524.tar.gz" + } + +server: + image: local-windows-en-msys2-bash-nalias + platform: windows + username: openremotessh + password: Open-Remote-SSH-1 + +test: + output: |- + Detected platform: windows, bash diff --git a/test/fixtures/native/shell-windows-en-msys2-bash-walias.yml b/test/fixtures/native/shell-windows-en-msys2-bash-walias.yml new file mode 100644 index 00000000..64305947 --- /dev/null +++ b/test/fixtures/native/shell-windows-en-msys2-bash-walias.yml @@ -0,0 +1,30 @@ +client: + files: + ssh_config: > + Host test + HostName 127.0.0.1 + Port 2222 + User openremotessh + Password Open-Remote-SSH-1 + product.json: > + { + "nameShort": "VSCodium", + "nameLong": "VSCodium", + "applicationName": "codium", + "quality": "stable", + "commit": "4c0b0c6cc561d2d3636d1ec250935431876ce4dc", + "version": "1.126.04524", + "serverApplicationName": "codium-server", + "serverDataFolderName": ".vscodium-server", + "serverDownloadUrlTemplate": "https://github.com/VSCodium/vscodium/releases/download/1.126.04524/vscodium-reh-${os}-${arch}-1.126.04524.tar.gz" + } + +server: + image: local-windows-en-msys2-bash-walias + platform: windows + username: openremotessh + password: Open-Remote-SSH-1 + +test: + output: |- + Detected platform: windows, bash diff --git a/test/fixtures/native/shell-windows-en-powershell-cmd.yml b/test/fixtures/native/shell-windows-en-powershell-cmd.yml new file mode 100644 index 00000000..89462993 --- /dev/null +++ b/test/fixtures/native/shell-windows-en-powershell-cmd.yml @@ -0,0 +1,30 @@ +client: + files: + ssh_config: > + Host test + HostName 127.0.0.1 + Port 2222 + User openremotessh + Password Open-Remote-SSH-1 + product.json: > + { + "nameShort": "VSCodium", + "nameLong": "VSCodium", + "applicationName": "codium", + "quality": "stable", + "commit": "4c0b0c6cc561d2d3636d1ec250935431876ce4dc", + "version": "1.126.04524", + "serverApplicationName": "codium-server", + "serverDataFolderName": ".vscodium-server", + "serverDownloadUrlTemplate": "https://github.com/VSCodium/vscodium/releases/download/1.126.04524/vscodium-reh-${os}-${arch}-1.126.04524.tar.gz" + } + +server: + image: local-windows-en-powershell-cmd + platform: windows + username: openremotessh + password: Open-Remote-SSH-1 + +test: + output: |- + Detected platform: windows, cmd diff --git a/test/fixtures/native/shell-windows-en-powershell-pwsh.yml b/test/fixtures/native/shell-windows-en-powershell-pwsh.yml new file mode 100644 index 00000000..f6f74510 --- /dev/null +++ b/test/fixtures/native/shell-windows-en-powershell-pwsh.yml @@ -0,0 +1,30 @@ +client: + files: + ssh_config: > + Host test + HostName 127.0.0.1 + Port 2222 + User openremotessh + Password Open-Remote-SSH-1 + product.json: > + { + "nameShort": "VSCodium", + "nameLong": "VSCodium", + "applicationName": "codium", + "quality": "stable", + "commit": "4c0b0c6cc561d2d3636d1ec250935431876ce4dc", + "version": "1.126.04524", + "serverApplicationName": "codium-server", + "serverDataFolderName": ".vscodium-server", + "serverDownloadUrlTemplate": "https://github.com/VSCodium/vscodium/releases/download/1.126.04524/vscodium-reh-${os}-${arch}-1.126.04524.tar.gz" + } + +server: + image: local-windows-en-powershell-pwsh + platform: windows + username: openremotessh + password: Open-Remote-SSH-1 + +test: + output: |- + Detected platform: windows, powershell diff --git a/test/fixtures/native/shell-windows-fr-builtin-cmd.yml b/test/fixtures/native/shell-windows-fr-builtin-cmd.yml new file mode 100644 index 00000000..0139492e --- /dev/null +++ b/test/fixtures/native/shell-windows-fr-builtin-cmd.yml @@ -0,0 +1,30 @@ +client: + files: + ssh_config: > + Host test + HostName 127.0.0.1 + Port 2222 + User openremotessh + Password Open-Remote-SSH-1 + product.json: > + { + "nameShort": "VSCodium", + "nameLong": "VSCodium", + "applicationName": "codium", + "quality": "stable", + "commit": "4c0b0c6cc561d2d3636d1ec250935431876ce4dc", + "version": "1.126.04524", + "serverApplicationName": "codium-server", + "serverDataFolderName": ".vscodium-server", + "serverDownloadUrlTemplate": "https://github.com/VSCodium/vscodium/releases/download/1.126.04524/vscodium-reh-${os}-${arch}-1.126.04524.tar.gz" + } + +server: + image: local-windows-fr-builtin-cmd + platform: windows + username: openremotessh + password: Open-Remote-SSH-1 + +test: + output: |- + Detected platform: windows, cmd From 7229143aa136dd8848e005b62d2a3952a9916b31 Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Sat, 31 Oct 2026 23:30:19 -0500 Subject: [PATCH 25/26] make fixture test use random port --- package.json | 2 +- test/fixtures.test.ts | 18 +++++++++++++--- .../default/feat-checkcommit-err-linux.yml | 1 - .../default/feat-checkcommit-err-windows.yml | 1 - test/fixtures/default/feat-gssapi-notused.yml | 1 - test/fixtures/default/feat-hosts-default.yml | 1 - test/fixtures/default/feat-hosts-include.yml | 1 - test/fixtures/default/feat-nolock-linux.yml | 1 - .../default/feat-proxycmd-linux-nc-nsh.yml | 3 +-- .../default/feat-proxycmd-linux-nc-p22.yml | 1 - .../default/feat-proxycmd-linux-nc-unsafe.yml | 1 - .../default/feat-proxycmd-linux-nc-wsh.yml | 3 +-- .../default/feat-proxycmd-linux-nc-wspace.yml | 3 +-- .../feat-proxycmd-windows-connect-nesc.yml | 1 - .../feat-proxycmd-windows-connect-unsafe.yml | 1 - .../feat-proxycmd-windows-connect-wesc.yml | 1 - .../feat-proxycmd-windows-connect-wsh.yml | 1 - test/fixtures/default/feat-sendenv-linux.yml | 1 - .../fixtures/default/feat-sendenv-windows.yml | 1 - test/fixtures/default/shell-alpine-bash.yml | 1 - test/fixtures/default/shell-linux-bash.yml | 1 - test/fixtures/default/shell-linux-fish.yml | 1 - .../default/shell-windows-en-builtin-bash.yml | 1 - .../default/shell-windows-en-builtin-cmd.yml | 1 - .../default/shell-windows-en-builtin-pwsh.yml | 1 - .../shell-windows-en-msys2-bash-nalias.yml | 1 - .../shell-windows-en-msys2-bash-walias.yml | 1 - .../shell-windows-en-powershell-cmd.yml | 1 - .../shell-windows-en-powershell-pwsh.yml | 1 - .../default/shell-windows-fr-builtin-cmd.yml | 1 - test/fixtures/native/shell-alpine-bash.yml | 1 - test/fixtures/native/shell-linux-bash.yml | 1 - test/fixtures/native/shell-linux-fish.yml | 1 - .../native/shell-windows-en-builtin-bash.yml | 1 - .../native/shell-windows-en-builtin-cmd.yml | 1 - .../native/shell-windows-en-builtin-pwsh.yml | 1 - .../shell-windows-en-msys2-bash-nalias.yml | 1 - .../shell-windows-en-msys2-bash-walias.yml | 1 - .../shell-windows-en-powershell-cmd.yml | 1 - .../shell-windows-en-powershell-pwsh.yml | 1 - .../native/shell-windows-fr-builtin-cmd.yml | 1 - test/fixturesNative.test.ts | 21 ++++++++++++++++--- 42 files changed, 37 insertions(+), 49 deletions(-) diff --git a/package.json b/package.json index 0a1e44c4..a2444313 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "open-remote-ssh", "displayName": "Open Remote - SSH", "description": "Use any remote machine with a SSH server as your development environment.", - "version": "0.5.2", + "version": "0.5.1", "publisher": "jeanp413", "repository": { "type": "git", diff --git a/test/fixtures.test.ts b/test/fixtures.test.ts index 02a80483..6b8ed2c7 100644 --- a/test/fixtures.test.ts +++ b/test/fixtures.test.ts @@ -10,6 +10,8 @@ import * as vscode from './mocks/vscode'; import { runDocker } from './utils/run-docker'; import { getMappedPort } from './utils/get-mapped-port'; import { waitForSSHReady } from './utils/wait-for-ssh-ready'; +import SSHConfig from 'ssh-config'; +import { defaultSSHConfigPath } from '../src/ssh/sshConfig'; const ROOT = fse.join('.', 'test', 'fixtures', 'default'); const SERVER_SETUP_BASH = fse.readFile('./src/scripts/server-setup.sh', 'utf8').value!; @@ -72,6 +74,8 @@ for (const file of files.value) { } describe(name, async () => { + let hostPort: number | undefined; + beforeAll(async () => { vol.reset(); @@ -88,7 +92,7 @@ for (const file of files.value) { '--name', containerName, '--publish', - '2222:2222', + '2222', '--env', `USER_NAME=${server.username}`, '--env', @@ -102,18 +106,26 @@ for (const file of files.value) { server.image, ]); - const hostPort = getMappedPort(containerName); + hostPort = getMappedPort(containerName); await waitForSSHReady(server.username, server.password, hostPort, 60_000, containerName); }, 120_000); afterAll(() => { runDocker(['rm', '-f', containerName], true); - }); + }, 120_000); it(`test-${name}`, async () => { + const sshConfig = SSHConfig.parse(` +Host * + Port ${hostPort} + User ${server.username} + Password ${server.password} +`); + vol.fromJSON({ ...client.files, + [defaultSSHConfigPath]: sshConfig.toString(), '/data/vscodium/extensions/open-remote-ssh/lib/scripts/server-setup.sh': SERVER_SETUP_BASH, '/data/vscodium/extensions/open-remote-ssh/lib/scripts/server-setup.ps1': SERVER_SETUP_POWERSHELL, }); diff --git a/test/fixtures/default/feat-checkcommit-err-linux.yml b/test/fixtures/default/feat-checkcommit-err-linux.yml index 3fe0e8a8..1c299e13 100644 --- a/test/fixtures/default/feat-checkcommit-err-linux.yml +++ b/test/fixtures/default/feat-checkcommit-err-linux.yml @@ -3,7 +3,6 @@ client: /etc/ssh/ssh_config: > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password openremotessh /bin/vscodium/app/product.json: > diff --git a/test/fixtures/default/feat-checkcommit-err-windows.yml b/test/fixtures/default/feat-checkcommit-err-windows.yml index a1910b1c..e628a4c5 100644 --- a/test/fixtures/default/feat-checkcommit-err-windows.yml +++ b/test/fixtures/default/feat-checkcommit-err-windows.yml @@ -3,7 +3,6 @@ client: 'C:\ProgramData\ssh\ssh_config': > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password Open-Remote-SSH-1 /bin/vscodium/app/product.json: > diff --git a/test/fixtures/default/feat-gssapi-notused.yml b/test/fixtures/default/feat-gssapi-notused.yml index c7a09abd..0ddea46a 100644 --- a/test/fixtures/default/feat-gssapi-notused.yml +++ b/test/fixtures/default/feat-gssapi-notused.yml @@ -3,7 +3,6 @@ client: /etc/ssh/ssh_config: > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password openremotessh GSSAPIAuthentication yes diff --git a/test/fixtures/default/feat-hosts-default.yml b/test/fixtures/default/feat-hosts-default.yml index 402847b4..667dbb06 100644 --- a/test/fixtures/default/feat-hosts-default.yml +++ b/test/fixtures/default/feat-hosts-default.yml @@ -6,7 +6,6 @@ client: Host test alias HostName 127.0.0.1 - Port 2222 User openremotessh Password openremotessh /bin/vscodium/app/product.json: > diff --git a/test/fixtures/default/feat-hosts-include.yml b/test/fixtures/default/feat-hosts-include.yml index 5360bba6..e4eb2b6e 100644 --- a/test/fixtures/default/feat-hosts-include.yml +++ b/test/fixtures/default/feat-hosts-include.yml @@ -8,7 +8,6 @@ client: /etc/ssh/config.d/hosts: | Host test alias HostName 127.0.0.1 - Port 2222 User openremotessh Password openremotessh /etc/ssh/config.d/systemd-ssh-proxy: | diff --git a/test/fixtures/default/feat-nolock-linux.yml b/test/fixtures/default/feat-nolock-linux.yml index 0a884b5b..498e839c 100644 --- a/test/fixtures/default/feat-nolock-linux.yml +++ b/test/fixtures/default/feat-nolock-linux.yml @@ -3,7 +3,6 @@ client: /etc/ssh/ssh_config: > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password openremotessh /bin/vscodium/app/product.json: > diff --git a/test/fixtures/default/feat-proxycmd-linux-nc-nsh.yml b/test/fixtures/default/feat-proxycmd-linux-nc-nsh.yml index cc4a1d46..2cc9c193 100644 --- a/test/fixtures/default/feat-proxycmd-linux-nc-nsh.yml +++ b/test/fixtures/default/feat-proxycmd-linux-nc-nsh.yml @@ -3,7 +3,6 @@ client: /etc/ssh/ssh_config: > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password openremotessh ProxyCommand nc %h %p @@ -27,4 +26,4 @@ server: test: output: |- - Spawning ProxyCommand: nc 127.0.0.1 2222 + Spawning ProxyCommand: nc 127.0.0.1 diff --git a/test/fixtures/default/feat-proxycmd-linux-nc-p22.yml b/test/fixtures/default/feat-proxycmd-linux-nc-p22.yml index 7b308d0b..106a9479 100644 --- a/test/fixtures/default/feat-proxycmd-linux-nc-p22.yml +++ b/test/fixtures/default/feat-proxycmd-linux-nc-p22.yml @@ -3,7 +3,6 @@ client: /etc/ssh/ssh_config: > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password openremotessh ProxyCommand nc %h 22 diff --git a/test/fixtures/default/feat-proxycmd-linux-nc-unsafe.yml b/test/fixtures/default/feat-proxycmd-linux-nc-unsafe.yml index c530f5bd..ed4ae8b7 100644 --- a/test/fixtures/default/feat-proxycmd-linux-nc-unsafe.yml +++ b/test/fixtures/default/feat-proxycmd-linux-nc-unsafe.yml @@ -3,7 +3,6 @@ client: /etc/ssh/ssh_config: > Host test HostName 127.0.0.1;id - Port 2222 User openremotessh Password openremotessh ProxyCommand nc %h %p diff --git a/test/fixtures/default/feat-proxycmd-linux-nc-wsh.yml b/test/fixtures/default/feat-proxycmd-linux-nc-wsh.yml index 0ea3692c..1143d91a 100644 --- a/test/fixtures/default/feat-proxycmd-linux-nc-wsh.yml +++ b/test/fixtures/default/feat-proxycmd-linux-nc-wsh.yml @@ -3,7 +3,6 @@ client: /etc/ssh/ssh_config: > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password openremotessh ProxyCommand sh -c 'nc %h %p' @@ -27,4 +26,4 @@ server: test: output: |- - Spawning ProxyCommand: sh -c 'nc 127.0.0.1 2222' + Spawning ProxyCommand: sh -c 'nc 127.0.0.1 diff --git a/test/fixtures/default/feat-proxycmd-linux-nc-wspace.yml b/test/fixtures/default/feat-proxycmd-linux-nc-wspace.yml index e85ad844..3f127ebf 100644 --- a/test/fixtures/default/feat-proxycmd-linux-nc-wspace.yml +++ b/test/fixtures/default/feat-proxycmd-linux-nc-wspace.yml @@ -3,7 +3,6 @@ client: /etc/ssh/ssh_config: > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password openremotessh ProxyCommand ln -sf "$(command -v nc)" "/tmp/open remote ssh nc" && "/tmp/open remote ssh nc" %h %p @@ -27,4 +26,4 @@ server: test: output: |- - Spawning ProxyCommand: ln -sf "$(command -v nc)" "/tmp/open remote ssh nc" && "/tmp/open remote ssh nc" 127.0.0.1 2222 + Spawning ProxyCommand: ln -sf "$(command -v nc)" "/tmp/open remote ssh nc" && "/tmp/open remote ssh nc" 127.0.0.1 diff --git a/test/fixtures/default/feat-proxycmd-windows-connect-nesc.yml b/test/fixtures/default/feat-proxycmd-windows-connect-nesc.yml index ecb506c1..55e56d66 100644 --- a/test/fixtures/default/feat-proxycmd-windows-connect-nesc.yml +++ b/test/fixtures/default/feat-proxycmd-windows-connect-nesc.yml @@ -3,7 +3,6 @@ client: 'C:\ProgramData\ssh\ssh_config': > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password Open-Remote-SSH-1 ProxyCommand connect.exe %h %p diff --git a/test/fixtures/default/feat-proxycmd-windows-connect-unsafe.yml b/test/fixtures/default/feat-proxycmd-windows-connect-unsafe.yml index 296161fc..fd904291 100644 --- a/test/fixtures/default/feat-proxycmd-windows-connect-unsafe.yml +++ b/test/fixtures/default/feat-proxycmd-windows-connect-unsafe.yml @@ -3,7 +3,6 @@ client: 'C:\ProgramData\ssh\ssh_config': > Host test HostName 127.0.0.1&whoami - Port 2222 User openremotessh Password Open-Remote-SSH-1 ProxyCommand "C:\Program Files\Git\mingw64\bin\connect.exe" %h %p diff --git a/test/fixtures/default/feat-proxycmd-windows-connect-wesc.yml b/test/fixtures/default/feat-proxycmd-windows-connect-wesc.yml index fd9f200f..c03cc8d7 100644 --- a/test/fixtures/default/feat-proxycmd-windows-connect-wesc.yml +++ b/test/fixtures/default/feat-proxycmd-windows-connect-wesc.yml @@ -3,7 +3,6 @@ client: 'C:\ProgramData\ssh\ssh_config': > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password Open-Remote-SSH-1 ProxyCommand "C:\Program Files\Git\mingw64\bin\connect.exe" %h %p diff --git a/test/fixtures/default/feat-proxycmd-windows-connect-wsh.yml b/test/fixtures/default/feat-proxycmd-windows-connect-wsh.yml index 468bb893..7b701919 100644 --- a/test/fixtures/default/feat-proxycmd-windows-connect-wsh.yml +++ b/test/fixtures/default/feat-proxycmd-windows-connect-wsh.yml @@ -3,7 +3,6 @@ client: 'C:\ProgramData\ssh\ssh_config': > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password Open-Remote-SSH-1 ProxyCommand sh.exe -c "connect.exe %h %p" diff --git a/test/fixtures/default/feat-sendenv-linux.yml b/test/fixtures/default/feat-sendenv-linux.yml index c5d48809..a3f8de60 100644 --- a/test/fixtures/default/feat-sendenv-linux.yml +++ b/test/fixtures/default/feat-sendenv-linux.yml @@ -6,7 +6,6 @@ client: /etc/ssh/ssh_config: > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password openremotessh SendEnv LANG LC_* diff --git a/test/fixtures/default/feat-sendenv-windows.yml b/test/fixtures/default/feat-sendenv-windows.yml index 8ba243a9..ec4adcb1 100644 --- a/test/fixtures/default/feat-sendenv-windows.yml +++ b/test/fixtures/default/feat-sendenv-windows.yml @@ -6,7 +6,6 @@ client: 'C:\ProgramData\ssh\ssh_config': > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password Open-Remote-SSH-1 SendEnv COMPUTERNAME USERNAME diff --git a/test/fixtures/default/shell-alpine-bash.yml b/test/fixtures/default/shell-alpine-bash.yml index 0b6f5667..3889afd0 100644 --- a/test/fixtures/default/shell-alpine-bash.yml +++ b/test/fixtures/default/shell-alpine-bash.yml @@ -3,7 +3,6 @@ client: /etc/ssh/ssh_config: > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password openremotessh /bin/vscodium/app/product.json: > diff --git a/test/fixtures/default/shell-linux-bash.yml b/test/fixtures/default/shell-linux-bash.yml index 3d230275..784f5d34 100644 --- a/test/fixtures/default/shell-linux-bash.yml +++ b/test/fixtures/default/shell-linux-bash.yml @@ -3,7 +3,6 @@ client: /etc/ssh/ssh_config: > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password openremotessh /bin/vscodium/app/product.json: > diff --git a/test/fixtures/default/shell-linux-fish.yml b/test/fixtures/default/shell-linux-fish.yml index 043a3df0..8c4d0947 100644 --- a/test/fixtures/default/shell-linux-fish.yml +++ b/test/fixtures/default/shell-linux-fish.yml @@ -3,7 +3,6 @@ client: /etc/ssh/ssh_config: > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password openremotessh /bin/vscodium/app/product.json: > diff --git a/test/fixtures/default/shell-windows-en-builtin-bash.yml b/test/fixtures/default/shell-windows-en-builtin-bash.yml index 3e76d637..09932d93 100644 --- a/test/fixtures/default/shell-windows-en-builtin-bash.yml +++ b/test/fixtures/default/shell-windows-en-builtin-bash.yml @@ -3,7 +3,6 @@ client: 'C:\ProgramData\ssh\ssh_config': > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password Open-Remote-SSH-1 /bin/vscodium/app/product.json: > diff --git a/test/fixtures/default/shell-windows-en-builtin-cmd.yml b/test/fixtures/default/shell-windows-en-builtin-cmd.yml index b6ab2ac4..bc2f1cc9 100644 --- a/test/fixtures/default/shell-windows-en-builtin-cmd.yml +++ b/test/fixtures/default/shell-windows-en-builtin-cmd.yml @@ -3,7 +3,6 @@ client: 'C:\ProgramData\ssh\ssh_config': > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password Open-Remote-SSH-1 /bin/vscodium/app/product.json: > diff --git a/test/fixtures/default/shell-windows-en-builtin-pwsh.yml b/test/fixtures/default/shell-windows-en-builtin-pwsh.yml index 53e32e26..a2b85bd2 100644 --- a/test/fixtures/default/shell-windows-en-builtin-pwsh.yml +++ b/test/fixtures/default/shell-windows-en-builtin-pwsh.yml @@ -3,7 +3,6 @@ client: 'C:\ProgramData\ssh\ssh_config': > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password Open-Remote-SSH-1 /bin/vscodium/app/product.json: > diff --git a/test/fixtures/default/shell-windows-en-msys2-bash-nalias.yml b/test/fixtures/default/shell-windows-en-msys2-bash-nalias.yml index f88245a4..8854f5c4 100644 --- a/test/fixtures/default/shell-windows-en-msys2-bash-nalias.yml +++ b/test/fixtures/default/shell-windows-en-msys2-bash-nalias.yml @@ -3,7 +3,6 @@ client: 'C:\ProgramData\ssh\ssh_config': > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password Open-Remote-SSH-1 /bin/vscodium/app/product.json: > diff --git a/test/fixtures/default/shell-windows-en-msys2-bash-walias.yml b/test/fixtures/default/shell-windows-en-msys2-bash-walias.yml index 51c4518e..c88a401c 100644 --- a/test/fixtures/default/shell-windows-en-msys2-bash-walias.yml +++ b/test/fixtures/default/shell-windows-en-msys2-bash-walias.yml @@ -3,7 +3,6 @@ client: 'C:\ProgramData\ssh\ssh_config': > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password Open-Remote-SSH-1 /bin/vscodium/app/product.json: > diff --git a/test/fixtures/default/shell-windows-en-powershell-cmd.yml b/test/fixtures/default/shell-windows-en-powershell-cmd.yml index 6f3839e3..021d2df6 100644 --- a/test/fixtures/default/shell-windows-en-powershell-cmd.yml +++ b/test/fixtures/default/shell-windows-en-powershell-cmd.yml @@ -3,7 +3,6 @@ client: 'C:\ProgramData\ssh\ssh_config': > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password Open-Remote-SSH-1 /bin/vscodium/app/product.json: > diff --git a/test/fixtures/default/shell-windows-en-powershell-pwsh.yml b/test/fixtures/default/shell-windows-en-powershell-pwsh.yml index dd6aa5ee..0f249515 100644 --- a/test/fixtures/default/shell-windows-en-powershell-pwsh.yml +++ b/test/fixtures/default/shell-windows-en-powershell-pwsh.yml @@ -3,7 +3,6 @@ client: 'C:\ProgramData\ssh\ssh_config': > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password Open-Remote-SSH-1 /bin/vscodium/app/product.json: > diff --git a/test/fixtures/default/shell-windows-fr-builtin-cmd.yml b/test/fixtures/default/shell-windows-fr-builtin-cmd.yml index 3b7fc227..c02a542f 100644 --- a/test/fixtures/default/shell-windows-fr-builtin-cmd.yml +++ b/test/fixtures/default/shell-windows-fr-builtin-cmd.yml @@ -3,7 +3,6 @@ client: 'C:\ProgramData\ssh\ssh_config': > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password Open-Remote-SSH-1 /bin/vscodium/app/product.json: > diff --git a/test/fixtures/native/shell-alpine-bash.yml b/test/fixtures/native/shell-alpine-bash.yml index b4c81ce7..614ad5e7 100644 --- a/test/fixtures/native/shell-alpine-bash.yml +++ b/test/fixtures/native/shell-alpine-bash.yml @@ -3,7 +3,6 @@ client: ssh_config: > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password openremotessh product.json: > diff --git a/test/fixtures/native/shell-linux-bash.yml b/test/fixtures/native/shell-linux-bash.yml index 2ad03023..b0de2b4b 100644 --- a/test/fixtures/native/shell-linux-bash.yml +++ b/test/fixtures/native/shell-linux-bash.yml @@ -3,7 +3,6 @@ client: ssh_config: > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password openremotessh product.json: > diff --git a/test/fixtures/native/shell-linux-fish.yml b/test/fixtures/native/shell-linux-fish.yml index d25ec6a7..037005c5 100644 --- a/test/fixtures/native/shell-linux-fish.yml +++ b/test/fixtures/native/shell-linux-fish.yml @@ -3,7 +3,6 @@ client: ssh_config: > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password openremotessh product.json: > diff --git a/test/fixtures/native/shell-windows-en-builtin-bash.yml b/test/fixtures/native/shell-windows-en-builtin-bash.yml index 22d5a116..5831d3b3 100644 --- a/test/fixtures/native/shell-windows-en-builtin-bash.yml +++ b/test/fixtures/native/shell-windows-en-builtin-bash.yml @@ -3,7 +3,6 @@ client: ssh_config: > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password Open-Remote-SSH-1 product.json: > diff --git a/test/fixtures/native/shell-windows-en-builtin-cmd.yml b/test/fixtures/native/shell-windows-en-builtin-cmd.yml index f388559a..f9f1f1f4 100644 --- a/test/fixtures/native/shell-windows-en-builtin-cmd.yml +++ b/test/fixtures/native/shell-windows-en-builtin-cmd.yml @@ -3,7 +3,6 @@ client: ssh_config: > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password Open-Remote-SSH-1 product.json: > diff --git a/test/fixtures/native/shell-windows-en-builtin-pwsh.yml b/test/fixtures/native/shell-windows-en-builtin-pwsh.yml index a715a214..d0946cab 100644 --- a/test/fixtures/native/shell-windows-en-builtin-pwsh.yml +++ b/test/fixtures/native/shell-windows-en-builtin-pwsh.yml @@ -3,7 +3,6 @@ client: ssh_config: > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password Open-Remote-SSH-1 product.json: > diff --git a/test/fixtures/native/shell-windows-en-msys2-bash-nalias.yml b/test/fixtures/native/shell-windows-en-msys2-bash-nalias.yml index c6ce46ce..b02fba39 100644 --- a/test/fixtures/native/shell-windows-en-msys2-bash-nalias.yml +++ b/test/fixtures/native/shell-windows-en-msys2-bash-nalias.yml @@ -3,7 +3,6 @@ client: ssh_config: > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password Open-Remote-SSH-1 product.json: > diff --git a/test/fixtures/native/shell-windows-en-msys2-bash-walias.yml b/test/fixtures/native/shell-windows-en-msys2-bash-walias.yml index 64305947..01110308 100644 --- a/test/fixtures/native/shell-windows-en-msys2-bash-walias.yml +++ b/test/fixtures/native/shell-windows-en-msys2-bash-walias.yml @@ -3,7 +3,6 @@ client: ssh_config: > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password Open-Remote-SSH-1 product.json: > diff --git a/test/fixtures/native/shell-windows-en-powershell-cmd.yml b/test/fixtures/native/shell-windows-en-powershell-cmd.yml index 89462993..a90f69e4 100644 --- a/test/fixtures/native/shell-windows-en-powershell-cmd.yml +++ b/test/fixtures/native/shell-windows-en-powershell-cmd.yml @@ -3,7 +3,6 @@ client: ssh_config: > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password Open-Remote-SSH-1 product.json: > diff --git a/test/fixtures/native/shell-windows-en-powershell-pwsh.yml b/test/fixtures/native/shell-windows-en-powershell-pwsh.yml index f6f74510..4c35f07a 100644 --- a/test/fixtures/native/shell-windows-en-powershell-pwsh.yml +++ b/test/fixtures/native/shell-windows-en-powershell-pwsh.yml @@ -3,7 +3,6 @@ client: ssh_config: > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password Open-Remote-SSH-1 product.json: > diff --git a/test/fixtures/native/shell-windows-fr-builtin-cmd.yml b/test/fixtures/native/shell-windows-fr-builtin-cmd.yml index 0139492e..7b5e8598 100644 --- a/test/fixtures/native/shell-windows-fr-builtin-cmd.yml +++ b/test/fixtures/native/shell-windows-fr-builtin-cmd.yml @@ -3,7 +3,6 @@ client: ssh_config: > Host test HostName 127.0.0.1 - Port 2222 User openremotessh Password Open-Remote-SSH-1 product.json: > diff --git a/test/fixturesNative.test.ts b/test/fixturesNative.test.ts index eb829f20..228b0a27 100644 --- a/test/fixturesNative.test.ts +++ b/test/fixturesNative.test.ts @@ -12,6 +12,7 @@ import { getMappedPort } from './utils/get-mapped-port'; import { waitForSSHReady } from './utils/wait-for-ssh-ready'; import { getRemoteAuthority, RemoteSSHResolver } from '../src/authResolver'; import { fileURLToPath } from 'node:url'; +import SSHConfig from 'ssh-config'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, '..'); @@ -90,6 +91,8 @@ for (const file of files.value) { const rootDir = path.join(os.tmpdir(), `test-${name}`); describe(name, async () => { + let hostPort: number | undefined; + beforeAll(async () => { vscode.workspace.resetConfiguration(); @@ -106,7 +109,7 @@ for (const file of files.value) { '--name', containerName, '--publish', - '2222:2222', + '2222', '--env', `USER_NAME=${server.username}`, '--env', @@ -120,7 +123,7 @@ for (const file of files.value) { server.image, ]); - const hostPort = getMappedPort(containerName); + hostPort = getMappedPort(containerName); await waitForSSHReady(server.username, server.password, hostPort, 60_000, containerName); }, 120_000); @@ -131,13 +134,25 @@ for (const file of files.value) { }, 120_000); it(`test-${name}`, async () => { + const sshConfig = SSHConfig.parse(` +Host * + HostName 127.0.0.1 + Port ${hostPort} + User ${server.username} + Password ${server.password} +`); + vscode.setRootDir(rootDir); fse.mkdir(vscode.env.appRoot, { recursive: true}); for (const [k,v] of Object.entries(client.files)) { if (k === 'product.json') { fse.writeFile(path.join(vscode.env.appRoot, k), v); - } else { + } else if (k === 'ssh_config') { + const testSshConfig = SSHConfig.parse(v); + testSshConfig.push(...sshConfig); + fse.writeFile(path.join(rootDir, k), testSshConfig.toString()); + }else { fse.writeFile(path.join(rootDir, k), v); } } From a48ebeb150ceef1652180054283b5df90c856967 Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Sun, 1 Nov 2026 01:23:21 -0500 Subject: [PATCH 26/26] fix windows tests --- test/fixtures/default/feat-proxycmd-windows-connect-nesc.yml | 2 +- test/fixtures/default/feat-proxycmd-windows-connect-wesc.yml | 2 +- test/fixtures/default/feat-proxycmd-windows-connect-wsh.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/fixtures/default/feat-proxycmd-windows-connect-nesc.yml b/test/fixtures/default/feat-proxycmd-windows-connect-nesc.yml index 55e56d66..43ba0f0d 100644 --- a/test/fixtures/default/feat-proxycmd-windows-connect-nesc.yml +++ b/test/fixtures/default/feat-proxycmd-windows-connect-nesc.yml @@ -27,4 +27,4 @@ server: test: output: |- - Spawning ProxyCommand: connect.exe 127.0.0.1 2222 + Spawning ProxyCommand: connect.exe 127.0.0.1 diff --git a/test/fixtures/default/feat-proxycmd-windows-connect-wesc.yml b/test/fixtures/default/feat-proxycmd-windows-connect-wesc.yml index c03cc8d7..90b658be 100644 --- a/test/fixtures/default/feat-proxycmd-windows-connect-wesc.yml +++ b/test/fixtures/default/feat-proxycmd-windows-connect-wesc.yml @@ -27,4 +27,4 @@ server: test: either: |- - Spawning ProxyCommand: "C:\Program Files\Git\mingw64\bin\connect.exe" 127.0.0.1 2222 + Spawning ProxyCommand: "C:\Program Files\Git\mingw64\bin\connect.exe" 127.0.0.1 diff --git a/test/fixtures/default/feat-proxycmd-windows-connect-wsh.yml b/test/fixtures/default/feat-proxycmd-windows-connect-wsh.yml index 7b701919..ed6e2f2b 100644 --- a/test/fixtures/default/feat-proxycmd-windows-connect-wsh.yml +++ b/test/fixtures/default/feat-proxycmd-windows-connect-wsh.yml @@ -27,4 +27,4 @@ server: test: output: |- - Spawning ProxyCommand: sh.exe -c connect.exe 127.0.0.1 2222 + Spawning ProxyCommand: sh.exe -c connect.exe 127.0.0.1