Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,14 @@ sandbox workload directly. The relay supports:
- Attachment to the canonical main process through the `openshell-main` SSH
subsystem. The supervisor owns its retained PTY or pipes, a 1 MiB replay
buffer, and a single stdin lease across client disconnects.
- Independent shell and command execution sessions.
- Independent interactive shell sessions.
- Command execution. Commands run through a login shell (`bash -lc`) by default,
so the first of the user's `.bash_profile`, `.bash_login`, or `.profile` is
sourced (and `.bashrc` only if that file sources it). Callers set
`ExecSandboxRequest.no_login_shell` to skip those files; the gateway signals
this to the supervisor over the SSH `OPENSHELL_NO_LOGIN_SHELL` env request,
which selects `bash -c` instead of `bash -lc`. Note `bash -c` still reads
`BASH_ENV` when the child environment sets it.
- Tar-based file sync.
- Port forwarding where supported by the CLI/TUI surface.

Expand Down
11 changes: 11 additions & 0 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1589,6 +1589,15 @@ enum SandboxCommands {
#[arg(long, overrides_with = "tty")]
no_tty: bool,

/// Run the command without sourcing shell login/profile startup files.
///
/// Default sources them so tool-specific env (`VIRTUAL_ENV`, etc.) is
/// available. Use this for automation and managed checks that need
/// predictable startup behavior — sandbox-user startup files cannot run
/// before the requested command.
#[arg(long)]
no_login_shell: bool,

/// Set a non-secret environment variable for the command.
/// Do not use this option for API keys, tokens, or other secrets; attach
/// a provider to the sandbox instead. Repeatable.
Expand Down Expand Up @@ -3229,6 +3238,7 @@ async fn run_async() -> Result<()> {
no_tty,
envs,
command,
no_login_shell,
} => {
let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?;
// Resolve --tty / --no-tty into an Option<bool> override.
Expand All @@ -3248,6 +3258,7 @@ async fn run_async() -> Result<()> {
timeout,
tty_override,
&env_map,
no_login_shell,
&tls,
&cli.workspace,
)
Expand Down
5 changes: 5 additions & 0 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1404,6 +1404,7 @@ pub async fn sandbox_exec_grpc(
timeout_seconds: u32,
tty_override: Option<bool>,
environment: &HashMap<String, String>,
no_login_shell: bool,
tls: &TlsOptions,
workspace: &str,
) -> Result<i32> {
Expand Down Expand Up @@ -1467,6 +1468,7 @@ pub async fn sandbox_exec_grpc(
workdir,
timeout_seconds,
environment,
no_login_shell,
)
.await;
}
Expand All @@ -1481,6 +1483,7 @@ pub async fn sandbox_exec_grpc(
timeout_seconds,
stdin: stdin_payload,
tty,
no_login_shell,
..Default::default()
})
.await
Expand Down Expand Up @@ -1830,6 +1833,7 @@ async fn sandbox_exec_interactive_grpc(
workdir: Option<&str>,
timeout_seconds: u32,
environment: &HashMap<String, String>,
no_login_shell: bool,
) -> Result<i32> {
#[cfg(unix)]
use openshell_core::proto::ExecSandboxWindowResize;
Expand All @@ -1848,6 +1852,7 @@ async fn sandbox_exec_interactive_grpc(
command: command.to_vec(),
workdir: workdir.unwrap_or_default().to_string(),
environment: environment.clone(),
no_login_shell,
timeout_seconds,
stdin: Vec::new(),
tty: true,
Expand Down
2 changes: 2 additions & 0 deletions crates/openshell-sdk/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,7 @@ impl OpenShellClient {
tty: false,
cols: 0,
rows: 0,
no_login_shell: opts.no_login_shell,
};

// Open the stream under the same OIDC-aware auth policy as unary RPCs
Expand Down Expand Up @@ -705,6 +706,7 @@ impl WorkspaceScopedClient {
tty: false,
cols: 0,
rows: 0,
no_login_shell: opts.no_login_shell,
};

let mut stream = self
Expand Down
3 changes: 3 additions & 0 deletions crates/openshell-sdk/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,9 @@ pub struct ExecOptions {
pub timeout: Option<Duration>,
/// Optional stdin payload.
pub stdin: Option<Vec<u8>>,
/// Skip sourcing shell login/profile startup files before the command.
/// Default (`false`) preserves login-shell behavior.
pub no_login_shell: bool,
}

/// Result of a non-streaming exec call.
Expand Down
117 changes: 112 additions & 5 deletions crates/openshell-server/src/grpc/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ use super::{MAX_PAGE_SIZE, MAX_PROVIDERS, MAX_ROUTABLE_NAME_LEN, clamp_limit};
use crate::persistence::current_time_ms;

const TCP_FORWARD_CHUNK_SIZE: usize = 64 * 1024;
const NO_LOGIN_SHELL_ENV: (&str, &str) = ("OPENSHELL_NO_LOGIN_SHELL", "1");

#[derive(Debug)]
pub struct WatchSandboxStream {
Expand Down Expand Up @@ -1213,6 +1214,8 @@ pub(super) async fn handle_exec_sandbox(

let sandbox_id = sandbox.object_id().to_string();

let no_login_shell = req.no_login_shell;

let (tx, rx) = mpsc::channel::<Result<ExecSandboxEvent, Status>>(256);
tokio::spawn(async move {
// Wait for the supervisor's reverse CONNECT to deliver the relay stream.
Expand All @@ -1231,6 +1234,7 @@ pub(super) async fn handle_exec_sandbox(
stdin_payload,
timeout_seconds,
request_tty,
no_login_shell,
)
.await
{
Expand Down Expand Up @@ -1379,7 +1383,7 @@ async fn acquire_forward_connection_guard(
Ok(ForwardConnectionGuard {
state: state.clone(),
token: Some(token.to_string()),
sandbox_id,
sandbox_id: sandbox_id.clone(),
})
}

Expand Down Expand Up @@ -1637,6 +1641,7 @@ pub(super) async fn handle_exec_sandbox_interactive(
let command_str = build_remote_exec_command(&req)
.map_err(|e| Status::invalid_argument(format!("command construction failed: {e}")))?;
let request_tty = req.tty;
let no_login_shell = req.no_login_shell;
let timeout_seconds = req.timeout_seconds;
let cols = if req.cols == 0 { 80 } else { req.cols };
let rows = if req.rows == 0 { 24 } else { req.rows };
Expand Down Expand Up @@ -1665,6 +1670,7 @@ pub(super) async fn handle_exec_sandbox_interactive(
&command_str,
input_stream,
request_tty,
no_login_shell,
timeout_seconds,
cols,
rows,
Expand Down Expand Up @@ -1882,6 +1888,16 @@ const EXEC_KEEPALIVE_MAX: usize = 4;
/// Max wait for a trailing `Close` after `ExitStatus`.
const EXEC_POST_EXIT_CLOSE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);

/// Supervisor SSH banner software token that signals no-login-shell support.
const OPENSHELL_SSHID_PREFIX: &[u8] = b"SSH-2.0-OpenShell_";

/// A supervisor honors `OPENSHELL_NO_LOGIN_SHELL` only if it identifies as
/// `OpenShell`. Older sandboxes present russh's default banner and silently
/// ignore the env request, so gate the opt-out on the `OpenShell` identity.
fn supervisor_supports_no_login_shell(remote_sshid: &[u8]) -> bool {
remote_sshid.starts_with(OPENSHELL_SSHID_PREFIX)
}

/// russh client config for exec relays.
fn exec_ssh_client_config() -> russh::client::Config {
russh::client::Config {
Expand Down Expand Up @@ -1942,6 +1958,7 @@ async fn stream_exec_over_relay(
stdin_payload: Vec<u8>,
timeout_seconds: u32,
request_tty: bool,
no_login_shell: bool,
) -> Result<(), Status> {
let command_preview: String = command
.chars()
Expand All @@ -1966,6 +1983,7 @@ async fn stream_exec_over_relay(
command,
stdin_payload,
request_tty,
no_login_shell,
tx.clone(),
);

Expand Down Expand Up @@ -2020,6 +2038,7 @@ async fn stream_interactive_exec_over_relay(
command: &str,
input_stream: tonic::Streaming<ExecSandboxInput>,
request_tty: bool,
no_login_shell: bool,
timeout_seconds: u32,
cols: u32,
rows: u32,
Expand All @@ -2046,6 +2065,7 @@ async fn stream_interactive_exec_over_relay(
command,
input_stream,
request_tty,
no_login_shell,
cols,
rows,
tx.clone(),
Expand Down Expand Up @@ -2093,11 +2113,13 @@ async fn stream_interactive_exec_over_relay(
Ok(())
}

#[allow(clippy::too_many_arguments)]
async fn run_interactive_exec_with_russh(
local_proxy_port: u16,
command: &str,
mut input_stream: tonic::Streaming<ExecSandboxInput>,
request_tty: bool,
no_login_shell: bool,
cols: u32,
rows: u32,
tx: mpsc::Sender<Result<ExecSandboxEvent, Status>>,
Expand All @@ -2124,7 +2146,11 @@ async fn run_interactive_exec_with_russh(
set_tcp_nodelay_best_effort(&stream);

let config = Arc::new(exec_ssh_client_config());
let mut client = russh::client::connect_stream(config, stream, SandboxSshClientHandler)
let remote_sshid = Arc::new(std::sync::Mutex::new(None));
let handler = SandboxSshClientHandler {
remote_sshid: remote_sshid.clone(),
};
let mut client = russh::client::connect_stream(config, stream, handler)
.await
.map_err(|e| Status::internal(format!("failed to establish ssh transport: {e}")))?;

Expand Down Expand Up @@ -2153,6 +2179,19 @@ async fn run_interactive_exec_with_russh(
.map_err(|e| Status::internal(format!("failed to allocate PTY: {e}")))?;
}

if no_login_shell {
let banner = remote_sshid.lock().unwrap().clone().unwrap_or_default();
if !supervisor_supports_no_login_shell(&banner) {
return Err(Status::failed_precondition(
"sandbox supervisor is too old to honor --no-login-shell; recreate the sandbox on a current gateway",
));
}
channel
.set_env(false, NO_LOGIN_SHELL_ENV.0, NO_LOGIN_SHELL_ENV.1)
Comment thread
johntmyers marked this conversation as resolved.
.await
.map_err(|e| Status::internal(format!("failed to set login-shell env: {e}")))?;
}

channel
.exec(true, command.as_bytes())
.await
Expand Down Expand Up @@ -2264,8 +2303,10 @@ async fn start_single_use_ssh_proxy_over_relay(
Ok((port, task))
}

#[derive(Debug, Clone, Copy)]
struct SandboxSshClientHandler;
#[derive(Debug, Clone)]
struct SandboxSshClientHandler {
remote_sshid: Arc<std::sync::Mutex<Option<Vec<u8>>>>,
}

impl russh::client::Handler for SandboxSshClientHandler {
type Error = russh::Error;
Expand All @@ -2276,13 +2317,24 @@ impl russh::client::Handler for SandboxSshClientHandler {
) -> Result<bool, Self::Error> {
Ok(true)
}

async fn kex_done(
&mut self,
_shared_secret: Option<&[u8]>,
_names: &russh::Names,
session: &mut russh::client::Session,
) -> Result<(), Self::Error> {
*self.remote_sshid.lock().unwrap() = Some(session.remote_sshid().to_vec());
Ok(())
}
}

async fn run_exec_with_russh(
local_proxy_port: u16,
command: &str,
stdin_payload: Vec<u8>,
request_tty: bool,
no_shell_login: bool,
tx: mpsc::Sender<Result<ExecSandboxEvent, Status>>,
) -> Result<i32, Status> {
// Defense-in-depth: validate command at the transport boundary.
Expand All @@ -2305,7 +2357,11 @@ async fn run_exec_with_russh(
set_tcp_nodelay_best_effort(&stream);

let config = Arc::new(exec_ssh_client_config());
let mut client = russh::client::connect_stream(config, stream, SandboxSshClientHandler)
let remote_sshid = Arc::new(std::sync::Mutex::new(None));
let handler = SandboxSshClientHandler {
remote_sshid: remote_sshid.clone(),
};
let mut client = russh::client::connect_stream(config, stream, handler)
.await
.map_err(|e| Status::internal(format!("failed to establish ssh transport: {e}")))?;

Expand Down Expand Up @@ -2334,6 +2390,19 @@ async fn run_exec_with_russh(
.map_err(|e| Status::internal(format!("failed to allocate PTY: {e}")))?;
}

if no_shell_login {
let banner = remote_sshid.lock().unwrap().clone().unwrap_or_default();
if !supervisor_supports_no_login_shell(&banner) {
return Err(Status::failed_precondition(
"sandbox supervisor is too old to honor --no-login-shell; recreate the sandbox on a current gateway",
));
}
channel
.set_env(false, NO_LOGIN_SHELL_ENV.0, NO_LOGIN_SHELL_ENV.1)
.await
.map_err(|e| Status::internal(format!("failed to set login-shell env: {e}")))?;
}

channel
.exec(true, command.as_bytes())
.await
Expand Down Expand Up @@ -4659,4 +4728,42 @@ mod tests {
assert!(session.revoked);
assert_eq!(session.object_workspace(), "default");
}

// ---- supervisor_supports_no_login_shell ----

/// A current supervisor identifies itself with the `OpenShell` banner, so the
/// gateway may forward the login-shell opt-out.
#[test]
fn no_login_shell_gate_accepts_openshell_banner() {
assert!(supervisor_supports_no_login_shell(
b"SSH-2.0-OpenShell_0.0.4-dev.3+g2bf9969"
));
assert!(supervisor_supports_no_login_shell(
b"SSH-2.0-OpenShell_0.1.0"
));
}

/// A supervisor predating this feature presents russh's default banner and
/// silently ignores the env request, so the gate must reject the opt-out.
#[test]
fn no_login_shell_gate_rejects_pre_feature_banner() {
assert!(!supervisor_supports_no_login_shell(b"SSH-2.0-Russh_0.62.5"));
assert!(!supervisor_supports_no_login_shell(b"SSH-2.0-OpenSSH_9.6"));
}

/// A missing banner (kex callback never populated the slot) must fail
/// closed rather than forwarding the opt-out to an unknown supervisor.
#[test]
fn no_login_shell_gate_rejects_empty_banner() {
assert!(!supervisor_supports_no_login_shell(b""));
}

/// The banner prefix must match at the start; an `OpenShell` token appearing
/// only in the comment tail does not signal support.
#[test]
fn no_login_shell_gate_requires_prefix_position() {
assert!(!supervisor_supports_no_login_shell(
b"SSH-2.0-Russh_0.62.5 OpenShell_0.1.0"
));
}
}
Loading
Loading