From 13684a2620b7518234862b008373198e71f40239 Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Thu, 20 Aug 2026 17:44:38 +0100 Subject: [PATCH 1/3] feat(sandbox): add --no-login-shell to skip shell startup files on exec Signed-off-by: Artem Lytvyn --- architecture/sandbox.md | 8 ++++- crates/openshell-cli/src/main.rs | 11 +++++++ crates/openshell-cli/src/run.rs | 5 +++ crates/openshell-sdk/src/client.rs | 2 ++ crates/openshell-sdk/src/types.rs | 3 ++ crates/openshell-server/src/grpc/sandbox.rs | 27 +++++++++++++++ .../openshell-supervisor-process/src/ssh.rs | 33 ++++++++++++++----- docs/sandboxes/manage-sandboxes.mdx | 29 +++++++++++----- proto/openshell.proto | 7 ++++ python/openshell/sandbox.py | 8 +++++ .../openshell/v1/internal/converter/exec.go | 1 + sdk/go/openshell/v1/types/options.go | 4 +++ sdk/go/proto/openshellv1/openshell.pb.go | 18 ++++++++-- sdk/typescript/src/client.ts | 13 ++++++++ 14 files changed, 148 insertions(+), 21 deletions(-) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 698f88a80a..fa843dbf54 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -327,7 +327,13 @@ gateway reaches it through the outbound supervisor relay, not by dialing the sandbox workload directly. The relay supports: - Interactive shell sessions. -- Command execution. +- 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. diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index fc7728c15d..e829eb435e 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1580,6 +1580,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. @@ -3217,6 +3226,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 override. @@ -3236,6 +3246,7 @@ async fn run_async() -> Result<()> { timeout, tty_override, &env_map, + no_login_shell, &tls, &cli.workspace, ) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 47fc268080..1d75947431 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -1405,6 +1405,7 @@ pub async fn sandbox_exec_grpc( timeout_seconds: u32, tty_override: Option, environment: &HashMap, + no_login_shell: bool, tls: &TlsOptions, workspace: &str, ) -> Result { @@ -1468,6 +1469,7 @@ pub async fn sandbox_exec_grpc( workdir, timeout_seconds, environment, + no_login_shell, ) .await; } @@ -1482,6 +1484,7 @@ pub async fn sandbox_exec_grpc( timeout_seconds, stdin: stdin_payload, tty, + no_login_shell, ..Default::default() }) .await @@ -1831,6 +1834,7 @@ async fn sandbox_exec_interactive_grpc( workdir: Option<&str>, timeout_seconds: u32, environment: &HashMap, + no_login_shell: bool, ) -> Result { #[cfg(unix)] use openshell_core::proto::ExecSandboxWindowResize; @@ -1849,6 +1853,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, diff --git a/crates/openshell-sdk/src/client.rs b/crates/openshell-sdk/src/client.rs index c67e91e219..d42f565e6a 100644 --- a/crates/openshell-sdk/src/client.rs +++ b/crates/openshell-sdk/src/client.rs @@ -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 @@ -705,6 +706,7 @@ impl WorkspaceScopedClient { tty: false, cols: 0, rows: 0, + no_login_shell: opts.no_login_shell, }; let mut stream = self diff --git a/crates/openshell-sdk/src/types.rs b/crates/openshell-sdk/src/types.rs index 6f179499c9..80b15ad44a 100644 --- a/crates/openshell-sdk/src/types.rs +++ b/crates/openshell-sdk/src/types.rs @@ -188,6 +188,9 @@ pub struct ExecOptions { pub timeout: Option, /// Optional stdin payload. pub stdin: Option>, + /// 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. diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index d5dd4e04e4..667d73421e 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -61,6 +61,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 { @@ -1210,6 +1211,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::>(256); tokio::spawn(async move { // Wait for the supervisor's reverse CONNECT to deliver the relay stream. @@ -1228,6 +1231,7 @@ pub(super) async fn handle_exec_sandbox( stdin_payload, timeout_seconds, request_tty, + no_login_shell, ) .await { @@ -1634,6 +1638,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 }; @@ -1662,6 +1667,7 @@ pub(super) async fn handle_exec_sandbox_interactive( &command_str, input_stream, request_tty, + no_login_shell, timeout_seconds, cols, rows, @@ -1939,6 +1945,7 @@ async fn stream_exec_over_relay( stdin_payload: Vec, timeout_seconds: u32, request_tty: bool, + no_login_shell: bool, ) -> Result<(), Status> { let command_preview: String = command .chars() @@ -1963,6 +1970,7 @@ async fn stream_exec_over_relay( command, stdin_payload, request_tty, + no_login_shell, tx.clone(), ); @@ -2017,6 +2025,7 @@ async fn stream_interactive_exec_over_relay( command: &str, input_stream: tonic::Streaming, request_tty: bool, + no_login_shell: bool, timeout_seconds: u32, cols: u32, rows: u32, @@ -2043,6 +2052,7 @@ async fn stream_interactive_exec_over_relay( command, input_stream, request_tty, + no_login_shell, cols, rows, tx.clone(), @@ -2090,11 +2100,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, request_tty: bool, + no_login_shell: bool, cols: u32, rows: u32, tx: mpsc::Sender>, @@ -2150,6 +2162,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 @@ -2280,6 +2299,7 @@ async fn run_exec_with_russh( command: &str, stdin_payload: Vec, request_tty: bool, + no_shell_login: bool, tx: mpsc::Sender>, ) -> Result { // Defense-in-depth: validate command at the transport boundary. @@ -2331,6 +2351,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 diff --git a/crates/openshell-supervisor-process/src/ssh.rs b/crates/openshell-supervisor-process/src/ssh.rs index 07302da953..9e9350558d 100644 --- a/crates/openshell-supervisor-process/src/ssh.rs +++ b/crates/openshell-supervisor-process/src/ssh.rs @@ -33,6 +33,8 @@ use std::time::Duration; use tokio::net::UnixListener; use tracing::warn; +const NO_LOGIN_SHELL_ENV: (&str, &str) = ("OPENSHELL_NO_LOGIN_SHELL", "1"); + /// Perform SSH server initialization: generate a host key, build the config, /// and bind the Unix socket listener. Extracted so that startup errors can be /// forwarded through the readiness channel rather than being silently logged. @@ -236,6 +238,7 @@ struct ChannelState { input_sender: Option>>, pty_master: Option, pty_request: Option, + no_login_shell: bool, } struct SshHandler { @@ -503,6 +506,7 @@ impl russh::server::Handler for SshHandler { &self.policy, &self.workspace, Some("/usr/lib/openssh/sftp-server".to_string()), + false, session.handle(), channel, self.netns_fd, @@ -539,10 +543,14 @@ impl russh::server::Handler for SshHandler { 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); + // Sandbox env is policy-controlled, so we don't propagate arbitrary vars. + // One exception: the gateway signals login-shell opt-out over this channel + // because SSH has no native carrier for it (unlike PTY requests). + if variable_name == NO_LOGIN_SHELL_ENV.0 + && let Some(state) = self.channels.get_mut(&channel) + { + state.no_login_shell = variable_value == NO_LOGIN_SHELL_ENV.1; + } session.channel_success(channel)?; Ok(()) } @@ -593,6 +601,7 @@ impl SshHandler { .channels .get_mut(&channel) .ok_or_else(|| anyhow::anyhow!("start_shell on unknown channel {channel:?}"))?; + let no_login_shell = state.no_login_shell; if let Some(pty) = state.pty_request.take() { // PTY was requested — allocate a real PTY (interactive shell or // exec that explicitly asked for a terminal). @@ -600,6 +609,7 @@ impl SshHandler { &self.policy, &self.workspace, command, + no_login_shell, &pty, handle, channel, @@ -621,6 +631,7 @@ impl SshHandler { &self.policy, &self.workspace, command, + no_login_shell, handle, channel, self.netns_fd, @@ -795,6 +806,7 @@ fn spawn_pty_shell( policy: &SandboxPolicy, workspace: &ResolvedWorkspace, command: Option, + no_login_shell: bool, pty: &PtyRequest, handle: Handle, channel: ChannelId, @@ -831,7 +843,8 @@ fn spawn_pty_shell( }, |command| { let mut c = Command::new("/bin/bash"); - c.arg("-lc").arg(command); + c.arg(if no_login_shell { "-c" } else { "-lc" }) + .arg(command); c }, ); @@ -968,6 +981,7 @@ fn spawn_pipe_exec( policy: &SandboxPolicy, workspace: &ResolvedWorkspace, command: Option, + no_login_shell: bool, handle: Handle, channel: ChannelId, netns_fd: Option, @@ -990,10 +1004,11 @@ fn spawn_pipe_exec( }, |command| { let mut c = Command::new("/bin/bash"); - // Use login shell (-l) so that .profile/.bashrc are sourced and - // tool-specific env vars (VIRTUAL_ENV, UV_PYTHON_INSTALL_DIR, etc.) - // are available without hardcoding them here. - c.arg("-lc").arg(command); + // Login shell (-l) sources .profile/.bashrc so tool env vars + // (VIRTUAL_ENV, etc.) are available. Callers that need a predictable + // environment opt out via OPENSHELL_NO_LOGIN_SHELL → plain -c. + c.arg(if no_login_shell { "-c" } else { "-lc" }) + .arg(command); c }, ); diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index abd95d130a..95d9bf7dd0 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -183,14 +183,27 @@ openshell sandbox exec -n my-sandbox --tty -- /bin/bash OpenShell allocates a TTY automatically when both stdin and stdout are terminals. Force the behavior with `--tty` or disable it with `--no-tty`. -| Flag | Purpose | -| -------------- | -------------------------------------------------------- | -| `-n`, `--name` | Sandbox to target. | -| `--workdir` | Working directory for the command inside the sandbox. | -| `--timeout` | Command timeout in seconds. `0` disables the timeout. | -| `--tty` | Force TTY allocation. | -| `--no-tty` | Disable TTY allocation even when attached to a terminal. | -| `--env` | Set an environment variable for the command (`KEY=VALUE`, repeatable). | +| Flag | Purpose | +| ----------------- | -------------------------------------------------------- | +| `-n`, `--name` | Sandbox to target. | +| `--workdir` | Working directory for the command inside the sandbox. | +| `--timeout` | Command timeout in seconds. `0` disables the timeout. | +| `--tty` | Force TTY allocation. | +| `--no-tty` | Disable TTY allocation even when attached to a terminal. | +| `--no-login-shell`| Run the command without sourcing shell login startup files. | +| `--env` | Set an environment variable for the command (`KEY=VALUE`, repeatable). | + +### Skip shell startup files + +By default `sandbox exec` runs the command through a login shell (`bash -lc`), so the sandbox user's first available `.bash_profile`, `.bash_login`, or `.profile` is sourced first (and `.bashrc` only if that login file sources it). This makes tool-specific environment configuration available automatically, which suits interactive and tool-discovery use. + +For automation and managed checks that need predictable output and side effects, pass `--no-login-shell` so those startup files are not sourced before the command runs: + +```shell +openshell sandbox exec -n my-sandbox --no-login-shell -- /usr/local/bin/managed-probe +``` + +In this mode a sandbox user's login startup files cannot write to the command's output, create files, or otherwise affect the requested command before it starts. The command still runs under `bash -c`, which reads `BASH_ENV` if it is set in the command's environment. The default (login-shell) behavior is unchanged when the flag is omitted. ## Set Environment Variables diff --git a/proto/openshell.proto b/proto/openshell.proto index 4dd290090d..4284645761 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -1218,6 +1218,13 @@ message ExecSandboxRequest { // Initial terminal rows (used when tty=true, 0 = use default). uint32 rows = 9; + + // Skip sourcing shell login/profile startup files before running the command. + // When false (the default), the command runs through a login shell + // (`bash -lc`) so user startup files (.bash_profile/.profile, and .bashrc if + // sourced by them) are applied. When true, the command runs without those + // files (`bash -c`), for automation that needs predictable startup behavior. + bool no_login_shell = 10; } // One stdout chunk from a sandbox exec. diff --git a/python/openshell/sandbox.py b/python/openshell/sandbox.py index a76be8dd13..0e6f5f544c 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -215,6 +215,7 @@ def exec( env: Mapping[str, str] | None = None, stdin: bytes | None = None, timeout_seconds: int | None = None, + no_login_shell: bool = False, ) -> ExecResult: return self._client.exec( self.sandbox.id, @@ -224,6 +225,7 @@ def exec( env=env, stdin=stdin, timeout_seconds=timeout_seconds, + no_login_shell=no_login_shell, ) def exec_python( @@ -638,6 +640,7 @@ def exec_stream( env: Mapping[str, str] | None = None, stdin: bytes | None = None, timeout_seconds: int | None = None, + no_login_shell: bool = False, ) -> Iterator[ExecChunk | ExecResult]: if not command: raise SandboxError("command must not be empty") @@ -649,6 +652,7 @@ def exec_stream( environment=dict(env or {}), timeout_seconds=timeout_seconds or 0, stdin=stdin or b"", + no_login_shell=no_login_shell, ) # Use whichever is larger: the default client timeout or the command # timeout plus headroom for SSH setup / teardown overhead. @@ -693,6 +697,7 @@ def exec( env: Mapping[str, str] | None = None, stdin: bytes | None = None, timeout_seconds: int | None = None, + no_login_shell: bool = False, ) -> ExecResult: result: ExecResult | None = None for item in self.exec_stream( @@ -702,6 +707,7 @@ def exec( env=env, stdin=stdin, timeout_seconds=timeout_seconds, + no_login_shell=no_login_shell, ): if stream_output and isinstance(item, ExecChunk): if item.stream == "stdout": @@ -1021,6 +1027,7 @@ def exec( env: Mapping[str, str] | None = None, stdin: bytes | None = None, timeout_seconds: int | None = None, + no_login_shell: bool = False, ) -> ExecResult: if self._session is None: raise SandboxError("sandbox context has not been entered") @@ -1031,6 +1038,7 @@ def exec( env=env, stdin=stdin, timeout_seconds=timeout_seconds, + no_login_shell=no_login_shell, ) def exec_python( diff --git a/sdk/go/openshell/v1/internal/converter/exec.go b/sdk/go/openshell/v1/internal/converter/exec.go index 0ca7157265..3c73f0c23a 100644 --- a/sdk/go/openshell/v1/internal/converter/exec.go +++ b/sdk/go/openshell/v1/internal/converter/exec.go @@ -45,6 +45,7 @@ func ExecRequestToProto(sandboxID string, command []string, opts *types.ExecOpti if opts != nil { req.Workdir = opts.WorkDir req.Environment = CopyStringMap(opts.Env) + req.NoLoginShell = opts.NoLoginShell } return req } diff --git a/sdk/go/openshell/v1/types/options.go b/sdk/go/openshell/v1/types/options.go index cfd134b05e..b0f6145999 100644 --- a/sdk/go/openshell/v1/types/options.go +++ b/sdk/go/openshell/v1/types/options.go @@ -34,4 +34,8 @@ type WaitOptions struct { type ExecOptions struct { Env map[string]string WorkDir string + // NoLoginShell skips sourcing shell login/profile startup files before the + // command. The zero value (false) preserves login-shell behavior. Set it + // for automation and managed checks that need predictable startup behavior. + NoLoginShell bool } diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index 102688df4f..d870a60401 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -3352,7 +3352,9 @@ type ExecSandboxRequest struct { // Initial terminal columns (used when tty=true, 0 = use default). Cols uint32 `protobuf:"varint,8,opt,name=cols,proto3" json:"cols,omitempty"` // Initial terminal rows (used when tty=true, 0 = use default). - Rows uint32 `protobuf:"varint,9,opt,name=rows,proto3" json:"rows,omitempty"` + Rows uint32 `protobuf:"varint,9,opt,name=rows,proto3" json:"rows,omitempty"` + // Ability to opt-out shell login. (TODO: rephrase the explanation.) + LoginShell bool `protobuf:"varint,10,opt,name=login_shell,json=loginShell,proto3" json:"login_shell,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -3450,6 +3452,13 @@ func (x *ExecSandboxRequest) GetRows() uint32 { return 0 } +func (x *ExecSandboxRequest) GetLoginShell() bool { + if x != nil { + return x.LoginShell + } + return false +} + // One stdout chunk from a sandbox exec. type ExecSandboxStdout struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -13395,7 +13404,7 @@ const file_openshell_proto_rawDesc = "" + "\x17RevokeSshSessionRequest\x12\x1a\n" + "\x05token\x18\x01 \x01(\tB\x04\x88\xb5\x18\x01R\x05token\"4\n" + "\x18RevokeSshSessionResponse\x12\x18\n" + - "\arevoked\x18\x01 \x01(\bR\arevoked\"\xf5\x02\n" + + "\arevoked\x18\x01 \x01(\bR\arevoked\"\x96\x03\n" + "\x12ExecSandboxRequest\x12\x1d\n" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x18\n" + @@ -13406,7 +13415,10 @@ const file_openshell_proto_rawDesc = "" + "\x05stdin\x18\x06 \x01(\fR\x05stdin\x12\x10\n" + "\x03tty\x18\a \x01(\bR\x03tty\x12\x12\n" + "\x04cols\x18\b \x01(\rR\x04cols\x12\x12\n" + - "\x04rows\x18\t \x01(\rR\x04rows\x1a>\n" + + "\x04rows\x18\t \x01(\rR\x04rows\x12\x1f\n" + + "\vlogin_shell\x18\n" + + " \x01(\bR\n" + + "loginShell\x1a>\n" + "\x10EnvironmentEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"'\n" + diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 4650e3fdde..fa6e4f0ef5 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -118,6 +118,12 @@ export interface ExecOptions { environment?: Record; timeoutSecs?: number; stdin?: Buffer; + /** + * Skip sourcing shell login/profile startup files before the command. + * Defaults to `false`, which preserves login-shell behavior. Set `true` for + * automation and managed checks that need predictable startup behavior. + */ + noLoginShell?: boolean; /** Abort the exec (and the in-flight stream RPC) early. */ signal?: AbortSignal; } @@ -155,6 +161,11 @@ export interface ExecInteractiveOptions { cols?: number; /** Initial terminal rows (0 = server default). */ rows?: number; + /** + * Skip sourcing shell login/profile startup files before the command. + * Defaults to `false`, which preserves login-shell behavior. + */ + noLoginShell?: boolean; /** Abort the interactive exec (and the in-flight stream RPC) early. */ signal?: AbortSignal; } @@ -673,6 +684,7 @@ export class SandboxClient { timeoutSeconds: options?.timeoutSecs ?? 0, stdin: options?.stdin ? new Uint8Array(options.stdin) : new Uint8Array(), tty: false, + noLoginShell: options?.noLoginShell ?? false, }, { signal: options?.signal }, ); @@ -755,6 +767,7 @@ export class SandboxClient { tty: options?.tty ?? true, cols: options?.cols ?? 0, rows: options?.rows ?? 0, + noLoginShell: options?.noLoginShell ?? false, }, }, }); From daad03cfb911ff69c3781513dcbaaee07b8b9abe Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Fri, 21 Aug 2026 12:05:16 +0100 Subject: [PATCH 2/3] chore(sdk/go): regenerate proto bindings for no_login_shell Signed-off-by: Artem Lytvyn --- sdk/go/proto/openshellv1/openshell.pb.go | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index 526d7db513..7c68a3efc8 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -3393,8 +3393,12 @@ type ExecSandboxRequest struct { Cols uint32 `protobuf:"varint,8,opt,name=cols,proto3" json:"cols,omitempty"` // Initial terminal rows (used when tty=true, 0 = use default). Rows uint32 `protobuf:"varint,9,opt,name=rows,proto3" json:"rows,omitempty"` - // Ability to opt-out shell login. (TODO: rephrase the explanation.) - LoginShell bool `protobuf:"varint,10,opt,name=login_shell,json=loginShell,proto3" json:"login_shell,omitempty"` + // Skip sourcing shell login/profile startup files before running the command. + // When false (the default), the command runs through a login shell + // (`bash -lc`) so user startup files (.bash_profile/.profile, and .bashrc if + // sourced by them) are applied. When true, the command runs without those + // files (`bash -c`), for automation that needs predictable startup behavior. + NoLoginShell bool `protobuf:"varint,10,opt,name=no_login_shell,json=noLoginShell,proto3" json:"no_login_shell,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -3492,9 +3496,9 @@ func (x *ExecSandboxRequest) GetRows() uint32 { return 0 } -func (x *ExecSandboxRequest) GetLoginShell() bool { +func (x *ExecSandboxRequest) GetNoLoginShell() bool { if x != nil { - return x.LoginShell + return x.NoLoginShell } return false } @@ -13549,7 +13553,7 @@ const file_openshell_proto_rawDesc = "" + "\x17RevokeSshSessionRequest\x12\x1a\n" + "\x05token\x18\x01 \x01(\tB\x04\x88\xb5\x18\x01R\x05token\"4\n" + "\x18RevokeSshSessionResponse\x12\x18\n" + - "\arevoked\x18\x01 \x01(\bR\arevoked\"\x96\x03\n" + + "\arevoked\x18\x01 \x01(\bR\arevoked\"\x9b\x03\n" + "\x12ExecSandboxRequest\x12\x1d\n" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x18\n" + @@ -13560,10 +13564,9 @@ const file_openshell_proto_rawDesc = "" + "\x05stdin\x18\x06 \x01(\fR\x05stdin\x12\x10\n" + "\x03tty\x18\a \x01(\bR\x03tty\x12\x12\n" + "\x04cols\x18\b \x01(\rR\x04cols\x12\x12\n" + - "\x04rows\x18\t \x01(\rR\x04rows\x12\x1f\n" + - "\vlogin_shell\x18\n" + - " \x01(\bR\n" + - "loginShell\x1a>\n" + + "\x04rows\x18\t \x01(\rR\x04rows\x12$\n" + + "\x0eno_login_shell\x18\n" + + " \x01(\bR\fnoLoginShell\x1a>\n" + "\x10EnvironmentEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"'\n" + From 7d79a247134fd446e9146759b92a6f423b6b7b45 Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Fri, 21 Aug 2026 14:22:55 +0100 Subject: [PATCH 3/3] test(supervisor-process): cover login-shell flag selection Signed-off-by: Artem Lytvyn --- .../openshell-supervisor-process/src/ssh.rs | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/crates/openshell-supervisor-process/src/ssh.rs b/crates/openshell-supervisor-process/src/ssh.rs index a19a644785..b50a338553 100644 --- a/crates/openshell-supervisor-process/src/ssh.rs +++ b/crates/openshell-supervisor-process/src/ssh.rs @@ -1055,6 +1055,10 @@ fn apply_child_env( } } +const fn login_shell_flag(no_login_shell: bool) -> &'static str { + if no_login_shell { "-c" } else { "-lc" } +} + #[allow(clippy::too_many_arguments)] fn spawn_pty_shell( policy: &SandboxPolicy, @@ -1097,8 +1101,7 @@ fn spawn_pty_shell( }, |command| { let mut c = Command::new("/bin/bash"); - c.arg(if no_login_shell { "-c" } else { "-lc" }) - .arg(command); + c.arg(login_shell_flag(no_login_shell)).arg(command); c }, ); @@ -1261,8 +1264,7 @@ fn spawn_pipe_exec( // Login shell (-l) sources .profile/.bashrc so tool env vars // (VIRTUAL_ENV, etc.) are available. Callers that need a predictable // environment opt out via OPENSHELL_NO_LOGIN_SHELL → plain -c. - c.arg(if no_login_shell { "-c" } else { "-lc" }) - .arg(command); + c.arg(login_shell_flag(no_login_shell)).arg(command); c }, ); @@ -1768,6 +1770,38 @@ mod tests { assert_eq!(output.stdout, b"hello"); } + /// Command execution selects a login shell by default and a non-login shell + /// under `--no-login-shell`, so user startup files are sourced only in the + /// default case. + #[cfg(unix)] + #[test] + fn login_shell_flag_controls_profile_sourcing() { + let home = tempfile::tempdir().unwrap(); + std::fs::write(home.path().join(".bash_profile"), "echo LOGIN_MARKER\n").unwrap(); + + let run = |flag: &str| -> String { + let out = Command::new("bash") + .arg(flag) + .arg("true") + .env("HOME", home.path()) + .env_remove("BASH_ENV") // isolate: -c still reads BASH_ENV if set + .output() + .expect("spawn bash"); + String::from_utf8_lossy(&out.stdout).into_owned() + }; + + assert_eq!(login_shell_flag(true), "-c"); + assert_eq!(login_shell_flag(false), "-lc"); + assert!( + run("-lc").contains("LOGIN_MARKER"), + "login shell must source .bash_profile" + ); + assert!( + !run("-c").contains("LOGIN_MARKER"), + "non-login shell must not source it" + ); + } + /// Verify that the stdin writer delivers all buffered data before exiting /// when the sender is dropped. This ensures channel_eof doesn't cause /// data loss — only signals "no more data after this".