Skip to content

Commit 5fd4885

Browse files
committed
feat(sandbox): VS Code Remote-SSH support with platform detection fix and network policy (!42)
Closes NVIDIA#34 ## Summary - Add `nav ssh-proxy` name mode (`--cluster`/`--name`) and `nav sandbox ssh-config` for VS Code Remote-SSH integration - Fix VS Code Remote-SSH platform misdetection (was defaulting to Windows/PowerShell) - Fix sandbox network policy for VS Code server connectivity - Fix sandbox user shell configuration ## Problem VS Code Remote-SSH was failing to connect to Navigator sandboxes due to three issues: 1. **Platform misdetection**: VS Code sends `uname -rsv` after a `ready:` marker and expects a response within ~1ms. Forced PTY allocation in `shell_request`, interactive bash (`-i`), and leaked supervisor env vars added enough latency that VS Code timed out and defaulted to "Platform: windows", then tried `powershell`. 2. **Broken network policy**: The `vscode` entry in `dev-sandbox-policy.yaml` was at the YAML top-level instead of nested under `network_policies:` (indentation bug). The VS Code server binary also needed a glob entry. 3. **No login shell**: The sandbox user had `/usr/sbin/nologin` as its shell. ## Changes | File | Change | |------|--------| | `crates/navigator-sandbox/src/ssh.rs` | Remove forced PTY in `shell_request`, remove `-i` flag from pipe exec, add `env_clear()` + explicit `SHELL`/`PATH` to both shell spawn paths | | `deploy/docker/sandbox/Dockerfile.base` | Change sandbox user shell to `/bin/bash`, set home to `/sandbox`, add `.bashrc`/`.profile` | | `dev-sandbox-policy.yaml` | Fix `vscode:` indentation under `network_policies:`, add `/sandbox/.vscode-server/*` binary glob | | `crates/navigator-cli/src/main.rs` | Add `--cluster`/`--name` to `SshProxy`, add `SshConfig`/`Policy`/`Logs` subcommands (rebase merge) | | `crates/navigator-cli/src/ssh.rs` | Update `print_ssh_config` to use `--cluster` flag | | `examples/vscode-remote-sandbox.md` | User-facing example and docs | ## How to use ```bash # Create a persistent sandbox nav sandbox create --keep my-sandbox # Generate SSH config nav sandbox ssh-config my-sandbox >> ~/.ssh/config # Open VS Code code --remote ssh-remote+nav-my-sandbox /sandbox ``` ## Testing - All 307 tests pass - Clippy clean on modified crates
1 parent 757217f commit 5fd4885

8 files changed

Lines changed: 336 additions & 142 deletions

File tree

crates/navigator-cli/src/main.rs

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -128,18 +128,39 @@ enum Commands {
128128
},
129129

130130
/// SSH proxy (used by `ProxyCommand`).
131+
///
132+
/// Two mutually exclusive modes:
133+
///
134+
/// **Token mode** (used internally by `sandbox connect`):
135+
/// `nav ssh-proxy --gateway <url> --sandbox-id <id> --token <token>`
136+
///
137+
/// **Name mode** (for use in `~/.ssh/config`):
138+
/// `nav ssh-proxy --cluster <name> --name <sandbox-name>`
131139
SshProxy {
132140
/// Gateway URL (e.g., <https://gw.example.com:443/proxy/connect>).
141+
/// Required in token mode.
133142
#[arg(long)]
134-
gateway: String,
143+
gateway: Option<String>,
135144

136-
/// Sandbox id.
145+
/// Sandbox id. Required in token mode.
137146
#[arg(long)]
138-
sandbox_id: String,
147+
sandbox_id: Option<String>,
139148

140-
/// SSH session token.
149+
/// SSH session token. Required in token mode.
141150
#[arg(long)]
142-
token: String,
151+
token: Option<String>,
152+
153+
/// Cluster endpoint URL. Used in name mode. Deprecated: prefer --cluster.
154+
#[arg(long)]
155+
server: Option<String>,
156+
157+
/// Cluster name (resolves endpoint from stored metadata). Used in name mode.
158+
#[arg(long, short)]
159+
cluster: Option<String>,
160+
161+
/// Sandbox name. Used in name mode.
162+
#[arg(long)]
163+
name: Option<String>,
143164
},
144165
}
145166

@@ -510,6 +531,15 @@ enum SandboxCommands {
510531
#[arg(long, default_value = "")]
511532
level: String,
512533
},
534+
535+
/// Print an SSH config entry for a sandbox.
536+
///
537+
/// Outputs a Host block suitable for appending to ~/.ssh/config,
538+
/// enabling tools like `VSCode` Remote-SSH to connect to the sandbox.
539+
SshConfig {
540+
/// Sandbox name.
541+
name: String,
542+
},
513543
}
514544

515545
#[derive(Subcommand, Debug)]
@@ -1006,6 +1036,9 @@ async fn main() -> Result<()> {
10061036
)
10071037
.await?;
10081038
}
1039+
SandboxCommands::SshConfig { name } => {
1040+
run::print_ssh_config(&ctx.name, &name);
1041+
}
10091042
}
10101043
}
10111044
}
@@ -1129,8 +1162,42 @@ async fn main() -> Result<()> {
11291162
gateway,
11301163
sandbox_id,
11311164
token,
1165+
server,
1166+
cluster,
1167+
name,
11321168
}) => {
1133-
run::sandbox_ssh_proxy(&gateway, &sandbox_id, &token, &tls).await?;
1169+
match (gateway, sandbox_id, token, server, cluster, name) {
1170+
// Token mode (existing behavior): pre-created session credentials.
1171+
(Some(gw), Some(sid), Some(tok), _, _, _) => {
1172+
run::sandbox_ssh_proxy(&gw, &sid, &tok, &tls).await?;
1173+
}
1174+
// Name mode with --cluster: resolve endpoint from metadata.
1175+
(_, _, _, server_override, Some(c), Some(n)) => {
1176+
let endpoint = if let Some(srv) = server_override {
1177+
srv
1178+
} else {
1179+
let meta = load_cluster_metadata(&c).map_err(|_| {
1180+
miette::miette!(
1181+
"Unknown cluster '{c}'.\n\
1182+
Deploy it first: nav cluster admin deploy --name {c}\n\
1183+
Or list available clusters: nav cluster list"
1184+
)
1185+
})?;
1186+
meta.gateway_endpoint
1187+
};
1188+
let tls = tls.with_cluster_name(&c);
1189+
run::sandbox_ssh_proxy_by_name(&endpoint, &n, &tls).await?;
1190+
}
1191+
// Legacy name mode with --server only (no --cluster).
1192+
(_, _, _, Some(srv), None, Some(n)) => {
1193+
run::sandbox_ssh_proxy_by_name(&srv, &n, &tls).await?;
1194+
}
1195+
_ => {
1196+
return Err(miette::miette!(
1197+
"provide either --gateway/--sandbox-id/--token or --cluster/--name (or --server/--name)"
1198+
));
1199+
}
1200+
}
11341201
}
11351202
None => {
11361203
Cli::command().print_help().expect("Failed to print help");

crates/navigator-cli/src/run.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,11 @@ use std::time::{Duration, Instant};
4242
use tonic::{Code, transport::Channel};
4343

4444
// Re-export SSH functions for backward compatibility
45+
pub use crate::ssh::print_ssh_config;
4546
pub use crate::ssh::{list_forwards, stop_forward, stop_forwards_for_sandbox};
4647
pub use crate::ssh::{
47-
sandbox_connect, sandbox_exec, sandbox_forward, sandbox_ssh_proxy, sandbox_sync_down,
48-
sandbox_sync_up, sandbox_sync_up_files,
48+
sandbox_connect, sandbox_exec, sandbox_forward, sandbox_ssh_proxy, sandbox_ssh_proxy_by_name,
49+
sandbox_sync_down, sandbox_sync_up, sandbox_sync_up_files,
4950
};
5051

5152
/// Convert a sandbox phase integer to a human-readable string.

crates/navigator-cli/src/ssh.rs

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ use tokio_rustls::TlsConnector;
1818
struct SshSessionConfig {
1919
proxy_command: String,
2020
sandbox_id: String,
21+
gateway_url: String,
22+
token: String,
2123
}
2224

2325
async fn ssh_session_config(
@@ -70,7 +72,9 @@ async fn ssh_session_config(
7072

7173
Ok(SshSessionConfig {
7274
proxy_command,
73-
sandbox_id: session.sandbox_id,
75+
sandbox_id: session.sandbox_id.clone(),
76+
gateway_url,
77+
token: session.token,
7478
})
7579
}
7680

@@ -768,6 +772,46 @@ pub async fn sandbox_ssh_proxy(
768772
Ok(())
769773
}
770774

775+
/// Run the SSH proxy in "name mode": create a session on the fly, then proxy.
776+
///
777+
/// This is equivalent to [`sandbox_ssh_proxy`] but accepts a cluster endpoint
778+
/// and sandbox name instead of pre-created gateway/token credentials. It is
779+
/// suitable for use as an SSH `ProxyCommand` in `~/.ssh/config` because it
780+
/// creates a fresh session on every invocation.
781+
pub async fn sandbox_ssh_proxy_by_name(server: &str, name: &str, tls: &TlsOptions) -> Result<()> {
782+
let session = ssh_session_config(server, name, tls).await?;
783+
sandbox_ssh_proxy(
784+
&session.gateway_url,
785+
&session.sandbox_id,
786+
&session.token,
787+
tls,
788+
)
789+
.await
790+
}
791+
792+
/// Print an SSH config `Host` block for a sandbox to stdout.
793+
///
794+
/// The output is suitable for appending to `~/.ssh/config` so that tools like
795+
/// `VSCode` Remote-SSH can connect to the sandbox by host alias.
796+
///
797+
/// The `ProxyCommand` uses `--cluster` so that `ssh-proxy` resolves the
798+
/// gateway endpoint and TLS certificates from the cluster metadata directory
799+
/// (`~/.config/navigator/clusters/<name>/mtls/`).
800+
pub fn print_ssh_config(cluster: &str, name: &str) {
801+
let exe = std::env::current_exe().expect("failed to resolve navigator executable");
802+
let exe = shell_escape(&exe.to_string_lossy());
803+
804+
let proxy_cmd = format!("{exe} ssh-proxy --cluster {cluster} --name {name}");
805+
806+
println!("Host nav-{name}");
807+
println!(" User sandbox");
808+
println!(" StrictHostKeyChecking no");
809+
println!(" UserKnownHostsFile /dev/null");
810+
println!(" GlobalKnownHostsFile /dev/null");
811+
println!(" LogLevel ERROR");
812+
println!(" ProxyCommand {proxy_cmd}");
813+
}
814+
771815
/// Copy all bytes from `reader` to `writer`, flushing on completion.
772816
/// Errors are intentionally discarded – connection teardown errors are
773817
/// expected during normal SSH session shutdown.

0 commit comments

Comments
 (0)