diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 6274f4b2bf..92a4849936 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -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. diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index de057b5f82..50f6b35cae 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -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. @@ -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 override. @@ -3248,6 +3258,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 fd0e585068..a47abe38f8 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -1404,6 +1404,7 @@ pub async fn sandbox_exec_grpc( timeout_seconds: u32, tty_override: Option, environment: &HashMap, + no_login_shell: bool, tls: &TlsOptions, workspace: &str, ) -> Result { @@ -1467,6 +1468,7 @@ pub async fn sandbox_exec_grpc( workdir, timeout_seconds, environment, + no_login_shell, ) .await; } @@ -1481,6 +1483,7 @@ pub async fn sandbox_exec_grpc( timeout_seconds, stdin: stdin_payload, tty, + no_login_shell, ..Default::default() }) .await @@ -1830,6 +1833,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; @@ -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, diff --git a/crates/openshell-sdk/src/client.rs b/crates/openshell-sdk/src/client.rs index f95b7ee111..b8b1696d02 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 3715c7fdd6..fe5d92e67c 100644 --- a/crates/openshell-sdk/src/types.rs +++ b/crates/openshell-sdk/src/types.rs @@ -195,6 +195,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 40476d43dc..da2d26f7f7 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -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 { @@ -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::>(256); tokio::spawn(async move { // Wait for the supervisor's reverse CONNECT to deliver the relay stream. @@ -1231,6 +1234,7 @@ pub(super) async fn handle_exec_sandbox( stdin_payload, timeout_seconds, request_tty, + no_login_shell, ) .await { @@ -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 }; @@ -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, @@ -1942,6 +1948,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() @@ -1966,6 +1973,7 @@ async fn stream_exec_over_relay( command, stdin_payload, request_tty, + no_login_shell, tx.clone(), ); @@ -2020,6 +2028,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, @@ -2046,6 +2055,7 @@ async fn stream_interactive_exec_over_relay( command, input_stream, request_tty, + no_login_shell, cols, rows, tx.clone(), @@ -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, request_tty: bool, + no_login_shell: bool, cols: u32, rows: u32, tx: mpsc::Sender>, @@ -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 @@ -2283,6 +2302,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. @@ -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 diff --git a/crates/openshell-supervisor-process/src/ssh.rs b/crates/openshell-supervisor-process/src/ssh.rs index 41546f6473..b50a338553 100644 --- a/crates/openshell-supervisor-process/src/ssh.rs +++ b/crates/openshell-supervisor-process/src/ssh.rs @@ -34,6 +34,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. @@ -237,11 +239,15 @@ async fn handle_connection( /// sender. This allows `window_change_request` to resize the correct PTY when /// multiple channels are open simultaneously (e.g. parallel shells, shell + /// sftp, etc.). +// Several independent per-channel boolean flags (login-shell opt-out and the +// main-attachment state bits) legitimately live side by side here. +#[allow(clippy::struct_excessive_bools)] #[derive(Default)] struct ChannelState { input_sender: Option, pty_master: Option, pty_request: Option, + no_login_shell: bool, main_input_owner: Option, main_attached: bool, main_read_only: bool, @@ -662,6 +668,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, @@ -699,8 +706,17 @@ impl russh::server::Handler for SshHandler { 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. + // don't actually propagate arbitrary variables — the sandbox + // environment is controlled via policy. We must reply so VSCode + // doesn't stall. Two exceptions carry supervisor signals the SSH + // protocol has no native field for: + // - OPENSHELL_NO_LOGIN_SHELL: gateway login-shell opt-out. + // - OPENSHELL_MAIN_READ_ONLY: read-only main attachment. + 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; + } if variable_name == "OPENSHELL_MAIN_READ_ONLY" && variable_value == "1" && let Some(state) = self.channels.get_mut(&channel) @@ -871,6 +887,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). @@ -878,6 +895,7 @@ impl SshHandler { &self.policy, &self.workspace, command, + no_login_shell, &pty, handle, channel, @@ -899,6 +917,7 @@ impl SshHandler { &self.policy, &self.workspace, command, + no_login_shell, handle, channel, self.netns_fd, @@ -1036,11 +1055,16 @@ 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, workspace: &ResolvedWorkspace, command: Option, + no_login_shell: bool, pty: &PtyRequest, handle: Handle, channel: ChannelId, @@ -1077,7 +1101,7 @@ fn spawn_pty_shell( }, |command| { let mut c = Command::new("/bin/bash"); - c.arg("-lc").arg(command); + c.arg(login_shell_flag(no_login_shell)).arg(command); c }, ); @@ -1214,6 +1238,7 @@ fn spawn_pipe_exec( policy: &SandboxPolicy, workspace: &ResolvedWorkspace, command: Option, + no_login_shell: bool, handle: Handle, channel: ChannelId, netns_fd: Option, @@ -1236,10 +1261,10 @@ 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(login_shell_flag(no_login_shell)).arg(command); c }, ); @@ -1745,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". diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index ead6533294..7533fb5518 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -206,14 +206,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 2dc70eec01..cccf4bef49 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -1238,6 +1238,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 b66ed936b6..2b9a839f3e 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -216,6 +216,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, @@ -225,6 +226,7 @@ def exec( env=env, stdin=stdin, timeout_seconds=timeout_seconds, + no_login_shell=no_login_shell, ) def exec_python( @@ -639,6 +641,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") @@ -650,6 +653,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. @@ -694,6 +698,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( @@ -703,6 +708,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": @@ -1022,6 +1028,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") @@ -1032,6 +1039,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 38e54f419e..7c68a3efc8 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -3392,7 +3392,13 @@ 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"` + // 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 } @@ -3490,6 +3496,13 @@ func (x *ExecSandboxRequest) GetRows() uint32 { return 0 } +func (x *ExecSandboxRequest) GetNoLoginShell() bool { + if x != nil { + return x.NoLoginShell + } + return false +} + // One stdout chunk from a sandbox exec. type ExecSandboxStdout struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -13540,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\"\xf5\x02\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" + @@ -13551,7 +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\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" + diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index b6db97943f..770bc12972 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -124,6 +124,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; } @@ -161,6 +167,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; } @@ -683,6 +694,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 }, ); @@ -765,6 +777,7 @@ export class SandboxClient { tty: options?.tty ?? true, cols: options?.cols ?? 0, rows: options?.rows ?? 0, + noLoginShell: options?.noLoginShell ?? false, }, }, });