diff --git a/crates/navigator-cli/src/main.rs b/crates/navigator-cli/src/main.rs index 4f458606a..07f663ad3 100644 --- a/crates/navigator-cli/src/main.rs +++ b/crates/navigator-cli/src/main.rs @@ -128,18 +128,39 @@ enum Commands { }, /// SSH proxy (used by `ProxyCommand`). + /// + /// Two mutually exclusive modes: + /// + /// **Token mode** (used internally by `sandbox connect`): + /// `nav ssh-proxy --gateway --sandbox-id --token ` + /// + /// **Name mode** (for use in `~/.ssh/config`): + /// `nav ssh-proxy --cluster --name ` SshProxy { /// Gateway URL (e.g., ). + /// Required in token mode. #[arg(long)] - gateway: String, + gateway: Option, - /// Sandbox id. + /// Sandbox id. Required in token mode. #[arg(long)] - sandbox_id: String, + sandbox_id: Option, - /// SSH session token. + /// SSH session token. Required in token mode. #[arg(long)] - token: String, + token: Option, + + /// Cluster endpoint URL. Used in name mode. Deprecated: prefer --cluster. + #[arg(long)] + server: Option, + + /// Cluster name (resolves endpoint from stored metadata). Used in name mode. + #[arg(long, short)] + cluster: Option, + + /// Sandbox name. Used in name mode. + #[arg(long)] + name: Option, }, } @@ -510,6 +531,15 @@ enum SandboxCommands { #[arg(long, default_value = "")] level: String, }, + + /// Print an SSH config entry for a sandbox. + /// + /// Outputs a Host block suitable for appending to ~/.ssh/config, + /// enabling tools like `VSCode` Remote-SSH to connect to the sandbox. + SshConfig { + /// Sandbox name. + name: String, + }, } #[derive(Subcommand, Debug)] @@ -1006,6 +1036,9 @@ async fn main() -> Result<()> { ) .await?; } + SandboxCommands::SshConfig { name } => { + run::print_ssh_config(&ctx.name, &name); + } } } } @@ -1129,8 +1162,42 @@ async fn main() -> Result<()> { gateway, sandbox_id, token, + server, + cluster, + name, }) => { - run::sandbox_ssh_proxy(&gateway, &sandbox_id, &token, &tls).await?; + match (gateway, sandbox_id, token, server, cluster, name) { + // Token mode (existing behavior): pre-created session credentials. + (Some(gw), Some(sid), Some(tok), _, _, _) => { + run::sandbox_ssh_proxy(&gw, &sid, &tok, &tls).await?; + } + // Name mode with --cluster: resolve endpoint from metadata. + (_, _, _, server_override, Some(c), Some(n)) => { + let endpoint = if let Some(srv) = server_override { + srv + } else { + let meta = load_cluster_metadata(&c).map_err(|_| { + miette::miette!( + "Unknown cluster '{c}'.\n\ + Deploy it first: nav cluster admin deploy --name {c}\n\ + Or list available clusters: nav cluster list" + ) + })?; + meta.gateway_endpoint + }; + let tls = tls.with_cluster_name(&c); + run::sandbox_ssh_proxy_by_name(&endpoint, &n, &tls).await?; + } + // Legacy name mode with --server only (no --cluster). + (_, _, _, Some(srv), None, Some(n)) => { + run::sandbox_ssh_proxy_by_name(&srv, &n, &tls).await?; + } + _ => { + return Err(miette::miette!( + "provide either --gateway/--sandbox-id/--token or --cluster/--name (or --server/--name)" + )); + } + } } None => { Cli::command().print_help().expect("Failed to print help"); diff --git a/crates/navigator-cli/src/run.rs b/crates/navigator-cli/src/run.rs index e71b6f5a4..658146b0e 100644 --- a/crates/navigator-cli/src/run.rs +++ b/crates/navigator-cli/src/run.rs @@ -42,10 +42,11 @@ use std::time::{Duration, Instant}; use tonic::{Code, transport::Channel}; // Re-export SSH functions for backward compatibility +pub use crate::ssh::print_ssh_config; pub use crate::ssh::{list_forwards, stop_forward, stop_forwards_for_sandbox}; pub use crate::ssh::{ - sandbox_connect, sandbox_exec, sandbox_forward, sandbox_ssh_proxy, sandbox_sync_down, - sandbox_sync_up, sandbox_sync_up_files, + sandbox_connect, sandbox_exec, sandbox_forward, sandbox_ssh_proxy, sandbox_ssh_proxy_by_name, + sandbox_sync_down, sandbox_sync_up, sandbox_sync_up_files, }; /// Convert a sandbox phase integer to a human-readable string. diff --git a/crates/navigator-cli/src/ssh.rs b/crates/navigator-cli/src/ssh.rs index 59c28b063..f20d5094b 100644 --- a/crates/navigator-cli/src/ssh.rs +++ b/crates/navigator-cli/src/ssh.rs @@ -18,6 +18,8 @@ use tokio_rustls::TlsConnector; struct SshSessionConfig { proxy_command: String, sandbox_id: String, + gateway_url: String, + token: String, } async fn ssh_session_config( @@ -70,7 +72,9 @@ async fn ssh_session_config( Ok(SshSessionConfig { proxy_command, - sandbox_id: session.sandbox_id, + sandbox_id: session.sandbox_id.clone(), + gateway_url, + token: session.token, }) } @@ -768,6 +772,46 @@ pub async fn sandbox_ssh_proxy( Ok(()) } +/// Run the SSH proxy in "name mode": create a session on the fly, then proxy. +/// +/// This is equivalent to [`sandbox_ssh_proxy`] but accepts a cluster endpoint +/// and sandbox name instead of pre-created gateway/token credentials. It is +/// suitable for use as an SSH `ProxyCommand` in `~/.ssh/config` because it +/// creates a fresh session on every invocation. +pub async fn sandbox_ssh_proxy_by_name(server: &str, name: &str, tls: &TlsOptions) -> Result<()> { + let session = ssh_session_config(server, name, tls).await?; + sandbox_ssh_proxy( + &session.gateway_url, + &session.sandbox_id, + &session.token, + tls, + ) + .await +} + +/// Print an SSH config `Host` block for a sandbox to stdout. +/// +/// The output is suitable for appending to `~/.ssh/config` so that tools like +/// `VSCode` Remote-SSH can connect to the sandbox by host alias. +/// +/// The `ProxyCommand` uses `--cluster` so that `ssh-proxy` resolves the +/// gateway endpoint and TLS certificates from the cluster metadata directory +/// (`~/.config/navigator/clusters//mtls/`). +pub fn print_ssh_config(cluster: &str, name: &str) { + let exe = std::env::current_exe().expect("failed to resolve navigator executable"); + let exe = shell_escape(&exe.to_string_lossy()); + + let proxy_cmd = format!("{exe} ssh-proxy --cluster {cluster} --name {name}"); + + println!("Host nav-{name}"); + println!(" User sandbox"); + println!(" StrictHostKeyChecking no"); + println!(" UserKnownHostsFile /dev/null"); + println!(" GlobalKnownHostsFile /dev/null"); + println!(" LogLevel ERROR"); + println!(" ProxyCommand {proxy_cmd}"); +} + /// Copy all bytes from `reader` to `writer`, flushing on completion. /// Errors are intentionally discarded – connection teardown errors are /// expected during normal SSH session shutdown. diff --git a/crates/navigator-sandbox/src/ssh.rs b/crates/navigator-sandbox/src/ssh.rs index 448a2ae7d..eb75764ca 100644 --- a/crates/navigator-sandbox/src/ssh.rs +++ b/crates/navigator-sandbox/src/ssh.rs @@ -17,7 +17,7 @@ use std::io::{Read, Write}; use std::net::SocketAddr; use std::os::fd::{AsRawFd, RawFd}; use std::path::PathBuf; -use std::process::{Command, Stdio}; +use std::process::Command; use std::sync::{Arc, mpsc}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -334,6 +334,12 @@ impl russh::server::Handler for SshHandler { session: &mut Session, ) -> Result<(), Self::Error> { session.channel_success(channel)?; + // Only allocate a PTY when the client explicitly requested one via + // pty_request. VS Code Remote-SSH sends shell_request *without* a + // preceding pty_request and expects pipe-based I/O with clean LF line + // endings. Forcing a PTY here caused CRLF translation which made + // VS Code misdetect the platform as Windows (and then try to run + // `powershell`). self.start_shell(channel, session.handle(), None)?; Ok(()) } @@ -353,6 +359,21 @@ impl russh::server::Handler for SshHandler { Ok(()) } + async fn env_request( + &mut self, + channel: ChannelId, + variable_name: &str, + variable_value: &str, + session: &mut Session, + ) -> Result<(), Self::Error> { + // Accept the env request so the client knows we handled it, but we + // don't actually propagate the variables — the sandbox environment is + // controlled via policy. We must reply so VSCode doesn't stall. + let _ = (variable_name, variable_value); + session.channel_success(channel)?; + Ok(()) + } + async fn data( &mut self, _channel: ChannelId, @@ -364,18 +385,6 @@ impl russh::server::Handler for SshHandler { } Ok(()) } - - async fn channel_eof( - &mut self, - _channel: ChannelId, - _session: &mut Session, - ) -> Result<(), Self::Error> { - // Drop the input sender so the writer thread exits and the child's - // stdin pipe is closed. Without this, commands like `tar xf -` that - // read until EOF on stdin would hang forever in pipe-based sessions. - self.input_sender.take(); - Ok(()) - } } impl SshHandler { @@ -386,6 +395,8 @@ impl SshHandler { command: Option, ) -> anyhow::Result<()> { if let Some(pty) = self.pty_request.take() { + // PTY was requested — allocate a real PTY (interactive shell or + // exec that explicitly asked for a terminal). let (pty_master, input_sender) = spawn_pty_shell( &self.policy, self.workdir.clone(), @@ -401,7 +412,10 @@ impl SshHandler { self.pty_master = Some(pty_master); self.input_sender = Some(input_sender); } else { - let input_sender = spawn_pipe_shell( + // No PTY requested — use plain pipes so stdout/stderr are + // separate and output has clean LF line endings. This is the + // path VSCode Remote-SSH exec commands take. + let input_sender = spawn_pipe_exec( &self.policy, self.workdir.clone(), command, @@ -537,12 +551,15 @@ fn spawn_pty_shell( pty.term.as_str() }; - cmd.stdin(stdin) + cmd.env_clear() + .stdin(stdin) .stdout(stdout) .stderr(stderr) .env("NAVIGATOR_SANDBOX", "1") .env("HOME", "/sandbox") .env("USER", "sandbox") + .env("SHELL", "/bin/bash") + .env("PATH", "/app/.venv/bin:/usr/local/bin:/usr/bin:/bin") .env("TERM", term); // Set proxy environment variables so cooperative tools (curl, wget, etc.) @@ -660,14 +677,13 @@ fn spawn_pty_shell( Ok((master_file, sender)) } -/// Spawn a shell process with plain pipes (no PTY) for non-interactive sessions. +/// Spawn a command using plain pipes (no PTY). /// -/// This is used when the SSH client did not request a PTY (e.g. `ssh -T`). -/// Unlike [`spawn_pty_shell`], stdout and stderr are separate streams and no -/// terminal output processing (LF→CRLF) is applied, making this safe for -/// binary data transfer (e.g. tar archives). +/// stdout is forwarded as SSH channel data and stderr as SSH extended data +/// (type 1), preserving the separation that clients like `VSCode` Remote-SSH +/// expect. Output retains clean LF line endings (no CRLF translation). #[allow(clippy::too_many_arguments)] -fn spawn_pipe_shell( +fn spawn_pipe_exec( policy: &SandboxPolicy, workdir: Option, command: Option, @@ -680,23 +696,30 @@ fn spawn_pipe_shell( ) -> anyhow::Result>> { let mut cmd = command.map_or_else( || { - let mut c = Command::new("/bin/bash"); - c.arg("-i"); - c + // No command — read from stdin. Do *not* pass `-i`; interactive + // mode reads .bashrc, writes prompts to stderr, and can introduce + // just enough latency for VS Code Remote-SSH's platform detection + // to time out and fall back to "windows". Plain `bash` with piped + // stdin already reads commands line-by-line (script mode), which is + // exactly what VS Code's local server expects. + Command::new("/bin/bash") }, |command| { let mut c = Command::new("/bin/bash"); - c.arg("-lc").arg(command); + c.arg("-c").arg(command); c }, ); - cmd.stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) + cmd.env_clear() + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) .env("NAVIGATOR_SANDBOX", "1") .env("HOME", "/sandbox") .env("USER", "sandbox") + .env("SHELL", "/bin/bash") + .env("PATH", "/app/.venv/bin:/usr/local/bin:/usr/bin:/bin") .env("TERM", "dumb"); if let Some(ref url) = proxy_url { @@ -726,7 +749,7 @@ fn spawn_pipe_shell( #[cfg(unix)] { - unsafe_pipe::install_pre_exec(&mut cmd, policy.clone(), workdir.clone(), netns_fd); + unsafe_pty::install_pre_exec_no_pty(&mut cmd, policy.clone(), workdir.clone(), netns_fd); } let mut child = cmd.spawn()?; @@ -735,88 +758,74 @@ fn spawn_pipe_shell( #[cfg(target_os = "linux")] register_managed_child(child_pid); - let child_stdin = child.stdin.take().expect("stdin was piped"); - let child_stdout = child.stdout.take().expect("stdout was piped"); - let child_stderr = child.stderr.take().expect("stderr was piped"); + let child_stdin = child.stdin.take(); + let child_stdout = child.stdout.take().expect("stdout must be piped"); + let child_stderr = child.stderr.take().expect("stderr must be piped"); - // Writer thread: forwards SSH channel data to the child's stdin. + // stdin writer thread let (sender, receiver) = mpsc::channel::>(); - let mut writer = child_stdin; std::thread::spawn(move || { + let Some(mut stdin) = child_stdin else { + return; + }; while let Ok(bytes) = receiver.recv() { - if writer.write_all(&bytes).is_err() { + if stdin.write_all(&bytes).is_err() { break; } - let _ = writer.flush(); + let _ = stdin.flush(); } }); let runtime = tokio::runtime::Handle::current(); - // We need both stdout and stderr readers to finish before sending EOF. - // Use an Arc to count completions. - let readers_remaining = Arc::new(std::sync::atomic::AtomicUsize::new(2)); + // Signal from the reader threads to the exit thread that all output has + // been forwarded. let (reader_done_tx, reader_done_rx) = mpsc::channel::<()>(); - // Stdout reader thread: forwards child stdout to the SSH channel. - let handle_stdout = handle.clone(); - let runtime_stdout = runtime.clone(); - let readers_remaining_stdout = readers_remaining.clone(); - let reader_done_tx_stdout = reader_done_tx.clone(); + // stdout reader + let stdout_handle = handle.clone(); + let stdout_runtime = runtime.clone(); + let reader_done_stdout = reader_done_tx.clone(); std::thread::spawn(move || { - let mut buf = [0u8; 4096]; let mut reader = child_stdout; + let mut buf = [0u8; 4096]; loop { match reader.read(&mut buf) { Ok(0) | Err(_) => break, Ok(n) => { let data = CryptoVec::from_slice(&buf[..n]); - let h = handle_stdout.clone(); - drop(runtime_stdout.spawn(async move { + let h = stdout_handle.clone(); + drop(stdout_runtime.spawn(async move { let _ = h.data(channel, data).await; })); } } } - if readers_remaining_stdout.fetch_sub(1, std::sync::atomic::Ordering::AcqRel) == 1 { - let eof_handle = handle_stdout.clone(); - drop(runtime_stdout.spawn(async move { - let _ = eof_handle.eof(channel).await; - })); - let _ = reader_done_tx_stdout.send(()); - } + let _ = reader_done_stdout.send(()); }); - // Stderr reader thread: forwards child stderr to the SSH channel as - // extended data (type 1 = stderr). - let handle_stderr = handle.clone(); - let runtime_stderr = runtime.clone(); - let readers_remaining_stderr = readers_remaining; + // stderr reader — sends as extended data (type 1) + let stderr_handle = handle.clone(); + let stderr_runtime = runtime.clone(); std::thread::spawn(move || { - let mut buf = [0u8; 4096]; let mut reader = child_stderr; + let mut buf = [0u8; 4096]; loop { match reader.read(&mut buf) { Ok(0) | Err(_) => break, Ok(n) => { let data = CryptoVec::from_slice(&buf[..n]); - let h = handle_stderr.clone(); - drop(runtime_stderr.spawn(async move { + let h = stderr_handle.clone(); + drop(stderr_runtime.spawn(async move { let _ = h.extended_data(channel, 1, data).await; })); } } } - if readers_remaining_stderr.fetch_sub(1, std::sync::atomic::Ordering::AcqRel) == 1 { - let eof_handle = handle_stderr.clone(); - drop(runtime_stderr.spawn(async move { - let _ = eof_handle.eof(channel).await; - })); - let _ = reader_done_tx.send(()); - } + let _ = reader_done_tx.send(()); }); - // Exit thread: waits for process exit, then sends exit-status and close. + // Exit waiter thread let handle_exit = handle; let runtime_exit = runtime; std::thread::spawn(move || { @@ -824,10 +833,11 @@ fn spawn_pipe_shell( #[cfg(target_os = "linux")] unregister_managed_child(child_pid); let code = status.and_then(|s| s.code()).unwrap_or(1).unsigned_abs(); - // Wait for reader threads to finish forwarding output before sending - // exit-status and close. + // Wait for both reader threads. let _ = reader_done_rx.recv_timeout(Duration::from_secs(2)); + let _ = reader_done_rx.recv_timeout(Duration::from_secs(1)); drop(runtime_exit.spawn(async move { + let _ = handle_exit.eof(channel).await; let _ = handle_exit.exit_status_request(channel, code).await; let _ = handle_exit.close(channel).await; })); @@ -836,41 +846,6 @@ fn spawn_pipe_shell( Ok(sender) } -mod unsafe_pipe { - use super::{Command, RawFd, SandboxPolicy, drop_privileges, sandbox}; - #[cfg(unix)] - use std::os::unix::process::CommandExt; - - #[allow(unsafe_code)] - pub fn install_pre_exec( - cmd: &mut Command, - policy: SandboxPolicy, - workdir: Option, - netns_fd: Option, - ) { - unsafe { - cmd.pre_exec(move || { - // Enter network namespace before dropping privileges. - #[cfg(target_os = "linux")] - if let Some(fd) = netns_fd { - let result = libc::setns(fd, libc::CLONE_NEWNET); - if result != 0 { - return Err(std::io::Error::last_os_error()); - } - } - - #[cfg(not(target_os = "linux"))] - let _ = netns_fd; - - drop_privileges(&policy).map_err(|err| std::io::Error::other(err.to_string()))?; - sandbox::apply(&policy, workdir.as_deref()) - .map_err(|err| std::io::Error::other(err.to_string()))?; - Ok(()) - }); - } - } -} - mod unsafe_pty { use super::{Command, RawFd, SandboxPolicy, Winsize, drop_privileges, sandbox, setsid}; #[cfg(unix)] @@ -907,31 +882,54 @@ mod unsafe_pty { setsid().map_err(|err| std::io::Error::other(err.to_string()))?; set_controlling_tty(slave_fd)?; - // Enter network namespace before dropping privileges. - // This ensures SSH shell processes are isolated to the same - // network namespace as the entrypoint, forcing all traffic - // through the veth pair and CONNECT proxy. - #[cfg(target_os = "linux")] - if let Some(fd) = netns_fd { - let result = libc::setns(fd, libc::CLONE_NEWNET); - if result != 0 { - return Err(std::io::Error::last_os_error()); - } - } - - #[cfg(not(target_os = "linux"))] - let _ = netns_fd; - - // Drop privileges before applying sandbox restrictions. - // initgroups/setgid/setuid need access to /etc/group and /etc/passwd - // which may be blocked by Landlock. - drop_privileges(&policy).map_err(|err| std::io::Error::other(err.to_string()))?; - sandbox::apply(&policy, workdir.as_deref()) - .map_err(|err| std::io::Error::other(err.to_string()))?; - Ok(()) + enter_netns_and_sandbox(netns_fd, &policy, workdir.as_deref()) }); } } + + /// Pre-exec hook for pipe-based (non-PTY) exec. + /// + /// Skips `setsid` and `TIOCSCTTY` since there is no controlling terminal. + #[allow(unsafe_code)] + pub fn install_pre_exec_no_pty( + cmd: &mut Command, + policy: SandboxPolicy, + workdir: Option, + netns_fd: Option, + ) { + unsafe { + cmd.pre_exec(move || enter_netns_and_sandbox(netns_fd, &policy, workdir.as_deref())); + } + } + + fn enter_netns_and_sandbox( + netns_fd: Option, + policy: &SandboxPolicy, + workdir: Option<&str>, + ) -> std::io::Result<()> { + // Enter network namespace before dropping privileges. + // This ensures SSH shell processes are isolated to the same + // network namespace as the entrypoint, forcing all traffic + // through the veth pair and CONNECT proxy. + #[cfg(target_os = "linux")] + if let Some(fd) = netns_fd { + #[allow(unsafe_code)] + let result = unsafe { libc::setns(fd, libc::CLONE_NEWNET) }; + if result != 0 { + return Err(std::io::Error::last_os_error()); + } + } + + #[cfg(not(target_os = "linux"))] + let _ = netns_fd; + + // Drop privileges before applying sandbox restrictions. + // initgroups/setgid/setuid need access to /etc/group and /etc/passwd + // which may be blocked by Landlock. + drop_privileges(policy).map_err(|err| std::io::Error::other(err.to_string()))?; + sandbox::apply(policy, workdir).map_err(|err| std::io::Error::other(err.to_string()))?; + Ok(()) + } } fn to_u16(value: u32) -> u16 { diff --git a/deploy/docker/sandbox/Dockerfile.base b/deploy/docker/sandbox/Dockerfile.base index 623d38988..bfd56c6c8 100644 --- a/deploy/docker/sandbox/Dockerfile.base +++ b/deploy/docker/sandbox/Dockerfile.base @@ -53,7 +53,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Create supervisor and sandbox users/groups RUN groupadd -r supervisor && useradd -r -g supervisor -s /usr/sbin/nologin supervisor && \ - groupadd -r sandbox && useradd -r -g sandbox -s /usr/sbin/nologin sandbox + groupadd -r sandbox && useradd -r -g sandbox -d /sandbox -s /bin/bash sandbox # Stage 3: Python dependencies builder FROM base AS builder @@ -142,6 +142,14 @@ RUN mkdir -p /var/navigator /sandbox /var/log && \ chown supervisor:supervisor /var/log/navigator.log && \ chmod 0664 /var/log/navigator.log && \ chown sandbox:sandbox /sandbox && \ + # Minimal shell init files so interactive and non-interactive shells + # get a sane PATH and prompt. Without these, bash sources nothing + # under /sandbox and tools like VS Code Remote-SSH may mis-detect + # the platform. + printf 'export PATH="/app/.venv/bin:$PATH"\nexport PS1="\\u@\\h:\\w\\$ "\n' \ + > /sandbox/.bashrc && \ + printf '[ -f ~/.bashrc ] && . ~/.bashrc\n' > /sandbox/.profile && \ + chown sandbox:sandbox /sandbox/.bashrc /sandbox/.profile && \ # Install agent skills into the sandbox user's home directory # so Claude and other agents can discover them at runtime. # Sources: ai-pim-utils (.deb) and custom navigator skills. diff --git a/dev-sandbox-policy.rego b/dev-sandbox-policy.rego index 7a2672cad..bb2235043 100644 --- a/dev-sandbox-policy.rego +++ b/dev-sandbox-policy.rego @@ -120,12 +120,10 @@ binary_allowed(policy, exec) if { # Binary matching: glob pattern against path, any ancestor, or any cmdline path. binary_allowed(policy, exec) if { - some b - b := policy.binaries[_] + some b in policy.binaries contains(b.path, "*") all_paths := array.concat(array.concat([exec.path], exec.ancestors), exec.cmdline_paths) - some p - p := all_paths[_] + some p in all_paths glob.match(b.path, ["/"], p) } diff --git a/dev-sandbox-policy.yaml b/dev-sandbox-policy.yaml index 7d81be7fb..6848b3ba5 100644 --- a/dev-sandbox-policy.yaml +++ b/dev-sandbox-policy.yaml @@ -132,6 +132,20 @@ network_policies: - { path: /usr/local/bin/claude } - { path: /usr/bin/gh } + vscode: + name: vscode + endpoints: + - { host: update.code.visualstudio.com, port: 443 } + - { host: "*.vo.msecnd.net", port: 443 } + - { host: vscode.download.prss.microsoft.com, port: 443 } + - { host: marketplace.visualstudio.com, port: 443 } + - { host: "*.gallerycdn.vsassets.io", port: 443 } + binaries: + - { path: /usr/bin/curl } + - { path: /usr/bin/wget } + - { path: "/sandbox/.vscode-server/**" } + - { path: "/sandbox/.vscode-remote-containers/**" } + inference: allowed_routes: - local diff --git a/examples/vscode-remote-sandbox.md b/examples/vscode-remote-sandbox.md new file mode 100644 index 000000000..b1f77d795 --- /dev/null +++ b/examples/vscode-remote-sandbox.md @@ -0,0 +1,64 @@ +# Open VSCode in a Sandbox + +Connect VSCode to a running sandbox using the +[Remote - SSH](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-ssh) +extension so you get a full IDE experience inside the sandbox environment. + +## Prerequisites + +- A running navigator cluster (`navigator cluster admin deploy`) +- [VSCode](https://code.visualstudio.com/) with the + [Remote - SSH](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-ssh) + extension installed +- The `navigator` CLI on your `PATH` + +## Quick start + +### 1. Create a sandbox + +```bash +navigator sandbox create --keep --name my-sandbox +``` + +`--keep` prevents the sandbox from being cleaned up when the shell exits so +VSCode can reconnect to it later. + +### 2. Generate an SSH config entry + +```bash +navigator sandbox ssh-config my-sandbox >> ~/.ssh/config +``` + +This will append a block like: + +```text +Host nav-my-sandbox + User sandbox + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + GlobalKnownHostsFile /dev/null + LogLevel ERROR + ProxyCommand navigator ssh-proxy --cluster --name my-sandbox +``` + +### 3. Open VSCode + +Open VSCode and run **Remote-SSH: Connect to Host...** from the command +palette (`Cmd+Shift+P` / `Ctrl+Shift+P`). Select `nav-my-sandbox` from the +list. VSCode will open a remote window connected to the sandbox. + +Alternatively, from the terminal: + +```bash +code --remote ssh-remote+nav-my-sandbox /sandbox +``` + +### 4. Clean up + +When you are done, delete the sandbox: + +```bash +navigator sandbox delete my-sandbox +``` + +This also removes any active port forwards.