diff --git a/README.md b/README.md index c662a57..056cc7e 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ the same. | `--mcp a,b` | Expose these MCP servers (names from `sbx mcp ls`). | | `--resume` | Attach to the existing sandbox without the "different setup" question: mounts and MCP servers on the command line are added, nothing is removed. Fails if there is none. | | `--no-attach` | Create or update the sandbox and start it, then exit without opening the agent. `sand [HARNESS] --resume` attaches later. | +| `--shell`, `--no-shell` | Set the sandbox up as usual, then open a login shell in it (in the primary workspace) instead of the agent; exiting the shell ends the session. `--no-shell` opens the agent even with `attach = "shell"` in the config. | | `--recreate` | Remove the sandbox and create it again, asking what to keep of its conversation history, mounts and MCP servers (see [Recreating a sandbox](#recreating-a-sandbox)). | | `--force-recreate` | Remove the sandbox and create it again from the command line alone, without asking: its history, and mounts and MCP servers the command line does not name, are dropped. | | `--scratch` | No project: use the shared scratch directory, which persists and is shared by all harnesses. | @@ -638,6 +639,7 @@ default_harness = "codex" nightly = true # use nightly harness images (see Templates) tern_native = false # keep the agent's terminal UI in Tern usage_limits = false # do not ask Anthropic for subscription limits (see Tern) +attach = "shell" # open a shell in the sandbox instead of the agent ("harness" is the default) sandbox_home = "/home/agent" # the agent user's home in sandboxes (the default) # Change a built-in harness… @@ -669,7 +671,7 @@ All harness keys: `agent`, `host_config_dir`, `sandbox_config_dir`, `copy`, `extra_copy`, `exclude`, `settings_files`, `settings_format`, `strip_keys`, `overlay`, `history`, `bypass_args`, `statusline`, `acp_command`, `api_check_url`, `template`, `nightly`, `import_skills`, `network_prompts`, -`auth`, `vertex_overlay`, `vertex_token_env`. +`attach` (overrides the top-level one for that harness), `auth`, `vertex_overlay`, `vertex_token_env`. State lives in the platform data directory (`~/.local/share/sand`, `~/Library/Application Support/sand`, `%LOCALAPPDATA%\sand`), or diff --git a/src/cli.rs b/src/cli.rs index 87012e4..29292e0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -16,6 +16,7 @@ Examples: sand claude --recreate rebuild the sandbox, asking what to keep sand claude --force-recreate rebuild it from the command line alone sand codex --no-attach set the sandbox up without opening the agent + sand claude --shell set the sandbox up and open a shell in it sand codex --scratch shared scratch space, no project sand --acp ACP agent on stdio (for editors) sand codex --install-acp zed register `sand codex --acp` in Zed @@ -136,6 +137,14 @@ pub struct RunArgs { #[arg(long, conflicts_with_all = ["acp", "dedicated_scratch", "agent_args"])] pub no_attach: bool, + /// Open a login shell in the sandbox instead of the agent, whatever `attach` says + #[arg(long, conflicts_with_all = ["no_shell", "acp", "native", "no_attach", "agent_args"])] + pub shell: bool, + + /// Open the agent, even with `attach = "shell"` in the config + #[arg(long)] + pub no_shell: bool, + /// Sandbox name (default: -) #[arg(long, value_name = "NAME")] pub name: Option, @@ -272,6 +281,7 @@ mod tests { assert_eq!(run(&["claude", "--install-acp", "tern"]).install_acp, Some(AcpClient::Tern)); assert!(run(&["--open-config"]).open_config && !run(&["codex"]).open_config); assert!(run(&["codex", "--native"]).native && run(&["codex", "--no-native"]).no_native); + assert!(run(&["codex", "--shell"]).shell && run(&["codex", "--no-shell"]).no_shell); } #[test] @@ -298,6 +308,11 @@ mod tests { &["codex", "--native", "--no-native"], &["codex", "--native", "--acp"], &["codex", "--native", "--", "--continue"], + &["codex", "--shell", "--no-shell"], + &["codex", "--shell", "--acp"], + &["codex", "--shell", "--native"], + &["codex", "--shell", "--no-attach"], + &["codex", "--shell", "--", "--continue"], ]; for args in bad { assert!(Cli::try_parse_from(std::iter::once("sand").chain(args.iter().copied())).is_err(), "{args:?}"); diff --git a/src/config.rs b/src/config.rs index 8fdf368..474a9bd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -20,6 +20,7 @@ const STARTER: &str = r#"# sand configuration. Everything is optional; the READM # nightly = true # create sandboxes from nightly harness images # tern_native = false # keep the agent's terminal UI in Tern # usage_limits = false # don't read the host's Claude login for the 5h/7d limits +# attach = "shell" # open a shell in the sandbox instead of the agent # [harness.claude] # template = "rust" @@ -132,6 +133,10 @@ pub struct Config { /// Anthropic from the host with the host's Claude login (default: true). /// The token never enters the sandbox. pub usage_limits: Option, + /// What a session opens once the sandbox is set up (default: harness). + /// `[harness.] attach` overrides it for one harness; `--shell` and + /// `--no-shell` win over both. + pub attach: Option, /// Overrides for built-in harnesses and definitions of additional ones. #[serde(default)] pub harness: BTreeMap, @@ -169,6 +174,8 @@ pub struct HarnessOverride { pub import_skills: Option, /// Ask in the agent's UI whether to allow hosts the network policy blocked. pub network_prompts: Option, + /// What a session opens once the sandbox is set up; overrides the top-level `attach`. + pub attach: Option, /// How the agent authenticates to its model API (default: sbx). pub auth: Option, /// Settings merged over the overlay when `auth = "vertex"`. In strings, @@ -179,6 +186,16 @@ pub struct HarnessOverride { pub vertex_token_env: Option, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Attach { + /// The agent: its terminal UI, or the native surface in Tern. + #[default] + Harness, + /// An interactive login shell in the sandbox, in the primary workspace. + Shell, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Auth { @@ -271,6 +288,12 @@ impl Config { self.default_harness.as_deref().unwrap_or("claude") } + /// What a session of `harness` opens: its `[harness.] attach`, else + /// the top-level `attach`, else the agent. + pub fn attach(&self, harness: &str) -> Attach { + self.harness.get(harness).and_then(|h| h.attach).or(self.attach).unwrap_or_default() + } + pub fn sandbox_home(&self) -> &str { self.sandbox_home.as_deref().unwrap_or(crate::paths::DEFAULT_SANDBOX_HOME).trim_end_matches('/') } @@ -288,9 +311,11 @@ mod tests { nightly = true tern_native = false usage_limits = false + attach = "shell" [harness.claude] template = "rust" + attach = "harness" nightly = false history = [".claude/projects"] overlay = { model = "opus" } @@ -333,6 +358,8 @@ mod tests { assert_eq!(config.mcp["pw"].args, vec!["@playwright/mcp@latest"]); assert_eq!((config.vertex.project.as_deref(), config.vertex.refresh.as_deref()), (Some("acme"), Some("30m"))); assert_eq!(config.harness["claude"].auth, None); + assert_eq!((config.attach("claude"), config.attach("aider")), (Attach::Harness, Attach::Shell)); + assert_eq!(Config::default().attach("claude"), Attach::Harness); } #[test] @@ -361,5 +388,6 @@ mod tests { assert!(toml::from_str::("[harness.claude]\nhistroy = []").is_err()); assert!(toml::from_str::("[harness.claude]\nauth = \"bedrock\"").is_err()); assert!(toml::from_str::("[vertex]\nprojct = \"x\"").is_err()); + assert!(toml::from_str::("attach = \"tui\"").is_err()); } } diff --git a/src/session.rs b/src/session.rs index de1901c..aecddab 100644 --- a/src/session.rs +++ b/src/session.rs @@ -18,7 +18,7 @@ use dialoguer::{MultiSelect, Select}; use crate::acp; use crate::args; use crate::cli::{ConflictChoice, MountArgs, RunArgs}; -use crate::config::{Config, StatuslineKind}; +use crate::config::{Attach, Config, StatuslineKind}; use crate::harness::{self, Profile}; use crate::lock; use crate::log; @@ -917,8 +917,9 @@ pub fn run(args: RunArgs, dirs: &Dirs, config: &Config) -> Result { let mut req = build_request(&args, dirs, config, harness, specs)?; warn_exposing(dirs, req.mounts.all()); - let native = native_eligible(&args, &req.profile, config); - let handoff = tern_handoff(&args, &req, dirs, config, &cwd, native); + let shell = opens_shell(&args, config, &req.profile.name); + let native = native_eligible(&args, &req.profile, config, shell); + let handoff = if shell { None } else { tern_handoff(&args, &req, dirs, config, &cwd, native) }; let profile = &req.profile; // Before sbx lists the sandboxes, so that what it lists stays true. @@ -1177,7 +1178,7 @@ pub fn run(args: RunArgs, dirs: &Dirs, config: &Config) -> Result { log::ok(format!("sandbox '{}' is ready; `sand {} --resume` attaches to it", state.name, profile.name)); return Ok(0); } - if profile.statusline != StatuslineKind::Claude && !args.acp && !native::active() { + if profile.statusline != StatuslineKind::Claude && !args.acp && !shell && !native::active() { let shows = match profile.statusline { StatuslineKind::Codex => "status line shows model, branch, context and limits, but not the sandbox", _ => "cannot run a custom status line", @@ -1189,7 +1190,9 @@ pub fn run(args: RunArgs, dirs: &Dirs, config: &Config) -> Result { } // --- hand over to the agent ------------------------------------------------- - let code = if native::active() { + let code = if shell { + attach_shell(&sbx, profile, &state, keep_alive)?.code().unwrap_or(1) + } else if native::active() { attach_native(&sbx, dirs, config, profile, &state, keep_alive)? } else if let Some(handoff) = handoff { return open_in_tern(handoff, dirs, profile, &state, lock, keep_alive); @@ -1206,11 +1209,12 @@ pub fn run(args: RunArgs, dirs: &Dirs, config: &Config) -> Result { /// Whether the session may be drawn natively in Tern, should the terminal /// turn out to speak the Tern Surface Protocol. -fn native_eligible(args: &RunArgs, profile: &Profile, config: &Config) -> bool { +fn native_eligible(args: &RunArgs, profile: &Profile, config: &Config, shell: bool) -> bool { let tty = std::io::stdin().is_terminal() && std::io::stdout().is_terminal(); let forced = args.forced_native(); let configured = config.tern_native.unwrap_or(true); - let eligible = tty && native::eligible(args.acp, args.no_attach, profile, &args.agent_args, forced, configured); + let no_agent = args.no_attach || shell; + let eligible = tty && native::eligible(args.acp, no_agent, profile, &args.agent_args, forced, configured); if forced == Some(true) && !eligible { log::warn("--native: not drawing natively (it needs a terminal and an agent with an ACP command)"); } @@ -1220,6 +1224,20 @@ fn native_eligible(args: &RunArgs, profile: &Profile, config: &Config) -> bool { eligible } +/// Whether the session opens a shell in the sandbox instead of the agent: +/// `--shell`, else the `attach` config key. Anything on the command line that +/// asks for the agent (`--no-shell`, `--native`, `--acp`, agent arguments) +/// wins over the config; so does `--no-attach`, which opens nothing. +fn opens_shell(args: &RunArgs, config: &Config, harness: &str) -> bool { + if args.shell { + return true; + } + if args.no_shell || args.native || args.acp || args.no_attach || !args.agent_args.is_empty() { + return false; + } + config.attach(harness) == Attach::Shell +} + /// The host's Claude login, for the native surface to ask how much of the /// subscription's limits is spent. Only for Claude Code signed in through sbx: /// Vertex AI has no such limits. The login stays on the host. @@ -1336,6 +1354,41 @@ fn log_summary(summary: &network::Summary) { } } +/// `sbx exec` of an interactive login shell, in the primary workspace, with +/// the environment the agent would get (`env` pairs). +fn shell_launch(state: &SandboxState, env: Vec) -> Vec { + let mut cmd = args!["exec", "-it"]; + if let Some(primary) = &state.mounts.primary { + cmd.extend(args!["--workdir", container_path(&primary.path.to_string_lossy())]); + } + cmd.push(OsString::from(&state.name)); + if !env.is_empty() { + cmd.push(OsString::from("env")); + cmd.extend(env.into_iter().map(OsString::from)); + } + cmd.extend(args!["bash", "-l"]); + cmd +} + +/// Opens a shell in the sandbox instead of the agent and waits for it to +/// exit. `keep_alive` is let go once it does. +fn attach_shell(sbx: &Sbx, profile: &Profile, state: &SandboxState, keep_alive: KeepAlive) -> Result { + log::step(format!("Opening a shell in sandbox '{}'", state.name)); + log::info(format!( + "the agent is not started; exit the shell to end the session, or `sand {} --resume --no-shell` to open the agent", + profile.name + )); + let mut env: Vec = vertex_env(profile, &state.name).into_iter().collect(); + env.extend(terminal_env(|name| env::var(name).ok())); + IN_SESSION.store(true, Ordering::SeqCst); + let status = sbx.attach(shell_launch(state, env)); + IN_SESSION.store(false, Ordering::SeqCst); + drop(keep_alive); + let status = status?; + log::step(format!("Session ended ({status})")); + Ok(status) +} + /// Starts the agent (its terminal UI, or its ACP server in ACP mode) and /// waits for it. `keep_alive` is let go once the agent ends. fn attach( diff --git a/tests/e2e.rs b/tests/e2e.rs index f300fa1..d25e784 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -539,6 +539,36 @@ fn no_attach_sets_the_sandbox_up_without_running_the_agent() { assert_eq!(log.last().unwrap(), "run --name codex-proj"); } +#[test] +fn shell_opens_a_shell_in_the_sandbox_instead_of_the_agent() { + let env = Env::new(); + let shell = |log: &[String]| { + let line = log.last().unwrap().clone(); + line.starts_with(&format!("exec -it --workdir {} codex-proj ", env.p(&env.proj))) && line.ends_with(" bash -l") + }; + let out = env.ok(&["codex", "--shell"]); + let log = env.take_log(); + assert_eq!(starting(&log, "create").len(), 1); + assert!(has(&log, "exec codex-proj sh /home/agent/.sand/apply.sh")); + assert!(starting(&log, "run").is_empty() && shell(&log), "{log:#?}"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("SHELL-STARTED") && !stdout.contains("AGENT-STARTED")); + assert!(stderr(&out).contains("Opening a shell in sandbox 'codex-proj'")); + + // The config makes it the default, per harness or for all of them; --no-shell and agent arguments win. + env.config("attach = \"shell\"\n[harness.claude]\nattach = \"harness\"\n"); + env.ok(&["codex"]); + assert!(shell(&env.take_log())); + env.ok(&["codex", "--no-shell"]); + assert_eq!(env.take_log().last().unwrap(), "run --name codex-proj"); + env.ok(&["codex", "--", "--continue"]); + assert!(env.take_log().last().unwrap().starts_with("run --name codex-proj")); + env.ok(&["claude"]); + assert!(env.take_log().last().unwrap().starts_with("run --name claude-proj")); + env.ok(&["codex", "--no-attach"]); + assert!(!shell(&env.take_log())); +} + #[test] fn recreate_asks_what_to_keep_and_force_recreate_keeps_nothing() { let env = Env::new(); diff --git a/tests/fake_sbx.sh b/tests/fake_sbx.sh index 804df5f..f02d541 100644 --- a/tests/fake_sbx.sh +++ b/tests/fake_sbx.sh @@ -53,6 +53,11 @@ case "$cmd" in done sandbox="$1"; shift [ -e "$root/sandboxes/$sandbox" ] || { echo "fake sbx: no sandbox $sandbox" >&2; exit 1; } + if [ "$1" = env ]; then + shift + while [ $# -gt 0 ]; do case "$1" in *=*) shift ;; *) break ;; esac; done + fi + [ "$*" = "bash -l" ] && { echo "SHELL-STARTED"; exit "${FAKE_SBX_RUN_EXIT:-0}"; } case "$1" in true) exit 0 ;; cat)