Skip to content
Merged
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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…
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: <harness>-<directory>)
#[arg(long, value_name = "NAME")]
pub name: Option<String>,
Expand Down Expand Up @@ -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]
Expand All @@ -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:?}");
Expand Down
28 changes: 28 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<bool>,
/// What a session opens once the sandbox is set up (default: harness).
/// `[harness.<name>] attach` overrides it for one harness; `--shell` and
/// `--no-shell` win over both.
pub attach: Option<Attach>,
/// Overrides for built-in harnesses and definitions of additional ones.
#[serde(default)]
pub harness: BTreeMap<String, HarnessOverride>,
Expand Down Expand Up @@ -169,6 +174,8 @@ pub struct HarnessOverride {
pub import_skills: Option<bool>,
/// Ask in the agent's UI whether to allow hosts the network policy blocked.
pub network_prompts: Option<bool>,
/// What a session opens once the sandbox is set up; overrides the top-level `attach`.
pub attach: Option<Attach>,
/// How the agent authenticates to its model API (default: sbx).
pub auth: Option<Auth>,
/// Settings merged over the overlay when `auth = "vertex"`. In strings,
Expand All @@ -179,6 +186,16 @@ pub struct HarnessOverride {
pub vertex_token_env: Option<String>,
}

#[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 {
Expand Down Expand Up @@ -271,6 +288,12 @@ impl Config {
self.default_harness.as_deref().unwrap_or("claude")
}

/// What a session of `harness` opens: its `[harness.<name>] 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('/')
}
Expand All @@ -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" }
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -361,5 +388,6 @@ mod tests {
assert!(toml::from_str::<Config>("[harness.claude]\nhistroy = []").is_err());
assert!(toml::from_str::<Config>("[harness.claude]\nauth = \"bedrock\"").is_err());
assert!(toml::from_str::<Config>("[vertex]\nprojct = \"x\"").is_err());
assert!(toml::from_str::<Config>("attach = \"tui\"").is_err());
}
}
67 changes: 60 additions & 7 deletions src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -917,8 +917,9 @@ pub fn run(args: RunArgs, dirs: &Dirs, config: &Config) -> Result<i32> {

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.
Expand Down Expand Up @@ -1177,7 +1178,7 @@ pub fn run(args: RunArgs, dirs: &Dirs, config: &Config) -> Result<i32> {
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",
Expand All @@ -1189,7 +1190,9 @@ pub fn run(args: RunArgs, dirs: &Dirs, config: &Config) -> Result<i32> {
}

// --- 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);
Expand All @@ -1206,11 +1209,12 @@ pub fn run(args: RunArgs, dirs: &Dirs, config: &Config) -> Result<i32> {

/// 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)");
}
Expand All @@ -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.
Expand Down Expand Up @@ -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<String>) -> Vec<OsString> {
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<ExitStatus> {
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<String> = 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(
Expand Down
30 changes: 30 additions & 0 deletions tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
5 changes: 5 additions & 0 deletions tests/fake_sbx.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading