mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-13 04:51:00 +08:00
feat(sandbox): VS Code Remote-SSH support with platform detection fix and network policy (!42)
Closes #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
This commit is contained in:
@@ -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 <url> --sandbox-id <id> --token <token>`
|
||||
///
|
||||
/// **Name mode** (for use in `~/.ssh/config`):
|
||||
/// `nav ssh-proxy --cluster <name> --name <sandbox-name>`
|
||||
SshProxy {
|
||||
/// Gateway URL (e.g., <https://gw.example.com:443/proxy/connect>).
|
||||
/// Required in token mode.
|
||||
#[arg(long)]
|
||||
gateway: String,
|
||||
gateway: Option<String>,
|
||||
|
||||
/// Sandbox id.
|
||||
/// Sandbox id. Required in token mode.
|
||||
#[arg(long)]
|
||||
sandbox_id: String,
|
||||
sandbox_id: Option<String>,
|
||||
|
||||
/// SSH session token.
|
||||
/// SSH session token. Required in token mode.
|
||||
#[arg(long)]
|
||||
token: String,
|
||||
token: Option<String>,
|
||||
|
||||
/// Cluster endpoint URL. Used in name mode. Deprecated: prefer --cluster.
|
||||
#[arg(long)]
|
||||
server: Option<String>,
|
||||
|
||||
/// Cluster name (resolves endpoint from stored metadata). Used in name mode.
|
||||
#[arg(long, short)]
|
||||
cluster: Option<String>,
|
||||
|
||||
/// Sandbox name. Used in name mode.
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/<name>/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.
|
||||
|
||||
+127
-129
@@ -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<String>,
|
||||
) -> 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<String>,
|
||||
command: Option<String>,
|
||||
@@ -680,23 +696,30 @@ fn spawn_pipe_shell(
|
||||
) -> anyhow::Result<mpsc::Sender<Vec<u8>>> {
|
||||
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::<Vec<u8>>();
|
||||
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<AtomicUsize> 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<String>,
|
||||
netns_fd: Option<RawFd>,
|
||||
) {
|
||||
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<String>,
|
||||
netns_fd: Option<RawFd>,
|
||||
) {
|
||||
unsafe {
|
||||
cmd.pre_exec(move || enter_netns_and_sandbox(netns_fd, &policy, workdir.as_deref()));
|
||||
}
|
||||
}
|
||||
|
||||
fn enter_netns_and_sandbox(
|
||||
netns_fd: Option<RawFd>,
|
||||
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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <cluster-name> --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.
|
||||
Reference in New Issue
Block a user