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
27 changes: 27 additions & 0 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 @@ -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 @@ -1942,6 +1948,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 +1973,7 @@ async fn stream_exec_over_relay(
command,
stdin_payload,
request_tty,
no_login_shell,
tx.clone(),
);

Expand Down Expand Up @@ -2020,6 +2028,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 +2055,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 +2103,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 Down Expand Up @@ -2153,6 +2165,13 @@ async fn run_interactive_exec_with_russh(
.map_err(|e| Status::internal(format!("failed to allocate PTY: {e}")))?;
}

if no_login_shell {
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 @@ -2283,6 +2302,7 @@ async fn run_exec_with_russh(
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 Down Expand Up @@ -2334,6 +2354,13 @@ async fn run_exec_with_russh(
.map_err(|e| Status::internal(format!("failed to allocate PTY: {e}")))?;
}

if no_shell_login {
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
Loading
Loading