Skip to content

Commit f192858

Browse files
authored
fix(cli): add --no-keep for ephemeral sandbox create cleanup (#258)
1 parent c46a516 commit f192858

File tree

18 files changed

+1210
-101
lines changed

18 files changed

+1210
-101
lines changed

.agents/skills/openshell-cli/SKILL.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,8 @@ Key flags:
147147
- `--provider`: Attach one or more providers (repeatable)
148148
- `--policy`: Custom policy YAML (otherwise uses built-in default or `OPENSHELL_SANDBOX_POLICY` env var)
149149
- `--upload <PATH>[:<DEST>]`: Upload local files into the sandbox (default dest: `/sandbox`)
150-
- `--keep`: Keep sandbox alive after the command exits (useful for non-interactive commands)
151-
- `--forward <PORT>`: Forward a local port (implies `--keep`)
150+
- `--no-keep`: Delete the sandbox after the initial command or shell exits
151+
- `--forward <PORT>`: Forward a local port and keep the sandbox alive
152152

153153
### List and inspect sandboxes
154154

@@ -236,10 +236,10 @@ Create sandbox with initial policy
236236
### Step 1: Create sandbox with initial policy
237237

238238
```bash
239-
openshell sandbox create --name dev --policy ./initial-policy.yaml --keep -- claude
239+
openshell sandbox create --name dev --policy ./initial-policy.yaml -- claude
240240
```
241241

242-
Use `--keep` so the sandbox stays alive for iteration. The user can work in the sandbox via a separate shell.
242+
Sandboxes stay alive by default for iteration. Add `--no-keep` only when the sandbox should be deleted automatically after the initial session.
243243

244244
### Step 2: Monitor logs for denied actions
245245

@@ -320,7 +320,7 @@ Build a custom container image and run it as a sandbox.
320320
### Step 1: Create a sandbox from a Dockerfile
321321

322322
```bash
323-
openshell sandbox create --from ./Dockerfile --keep --name my-app
323+
openshell sandbox create --from ./Dockerfile --name my-app
324324
```
325325

326326
The `--from` flag accepts a Dockerfile path, a directory containing a Dockerfile, a full image reference (e.g. `myregistry.com/img:tag`), or a community sandbox name (e.g. `openclaw`).
@@ -359,13 +359,13 @@ To update the container:
359359

360360
```bash
361361
openshell sandbox delete my-app
362-
openshell sandbox create --from ./Dockerfile --keep --name my-app --forward 8080
362+
openshell sandbox create --from ./Dockerfile --name my-app --forward 8080
363363
```
364364

365365
### Shortcut: Create with port forward in one command
366366

367367
```bash
368-
openshell sandbox create --from ./Dockerfile --forward 8080 --keep -- ./start-server.sh
368+
openshell sandbox create --from ./Dockerfile --forward 8080 -- ./start-server.sh
369369
```
370370

371371
The `--forward` flag starts a background port forward before the command runs, so the service is reachable immediately.
@@ -389,7 +389,7 @@ openshell sandbox create \
389389
--provider github \
390390
--provider claude \
391391
--policy ./dev-policy.yaml \
392-
--keep
392+
# sandbox create keeps the sandbox alive by default
393393
```
394394

395395
### Step 2: User connects in a separate shell
@@ -540,13 +540,13 @@ $ openshell sandbox upload --help
540540
| List/switch gateways | `openshell gateway select [name]` |
541541
| Create sandbox (interactive) | `openshell sandbox create` |
542542
| Create sandbox with tool | `openshell sandbox create -- claude` |
543-
| Create with custom policy | `openshell sandbox create --policy ./p.yaml --keep` |
543+
| Create with custom policy | `openshell sandbox create --policy ./p.yaml` |
544544
| Connect to sandbox | `openshell sandbox connect <name>` |
545545
| Stream live logs | `openshell logs <name> --tail` |
546546
| Pull current policy | `openshell policy get <name> --full > p.yaml` |
547547
| Push updated policy | `openshell policy set <name> --policy p.yaml --wait` |
548548
| Policy revision history | `openshell policy list <name>` |
549-
| Create sandbox from Dockerfile | `openshell sandbox create --from ./Dockerfile --keep` |
549+
| Create sandbox from Dockerfile | `openshell sandbox create --from ./Dockerfile` |
550550
| Forward a port | `openshell forward start <port> <name> -d` |
551551
| Upload files to sandbox | `openshell sandbox upload <name> <path>` |
552552
| Download files from sandbox | `openshell sandbox download <name> <path>` |

.agents/skills/openshell-cli/cli-reference.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,10 @@ Create a sandbox, wait for readiness, then connect or execute the trailing comma
165165
| `--name <NAME>` | Sandbox name (auto-generated if omitted) |
166166
| `--from <SOURCE>` | Sandbox source: community name, Dockerfile path, directory, or image reference (BYOC) |
167167
| `--upload <PATH>[:<DEST>]` | Upload local files into sandbox (default dest: `/sandbox`) |
168-
| `--keep` | Keep sandbox alive after non-interactive commands finish |
168+
| `--no-keep` | Delete sandbox after the initial command or shell exits |
169169
| `--provider <NAME>` | Provider to attach (repeatable) |
170170
| `--policy <PATH>` | Path to custom policy YAML |
171-
| `--forward <PORT>` | Forward local port to sandbox (implies `--keep`) |
171+
| `--forward <PORT>` | Forward local port to sandbox (keeps the sandbox alive) |
172172
| `--remote <USER@HOST>` | SSH destination for auto-bootstrap |
173173
| `--ssh-key <PATH>` | SSH private key for auto-bootstrap |
174174
| `--tty` | Force pseudo-terminal allocation |

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

architecture/sandbox-connect.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,8 @@ The `sandbox exec` path is identical to interactive connect except:
153153
- The command string is passed as the final SSH argument
154154
- The sandbox daemon routes it through `exec_request()` instead of `shell_request()`, spawning `/bin/bash -lc <command>`
155155

156+
When `openshell sandbox create` launches a `--no-keep` command or shell, it keeps the CLI process alive instead of `exec()`-ing into SSH so it can delete the sandbox after SSH exits. The default create flow, along with `--forward`, keeps the sandbox running.
157+
156158
### Port Forwarding (`forward start`)
157159

158160
`openshell forward start <port> <name>` opens a local SSH tunnel so connections to `127.0.0.1:<port>`

crates/navigator-cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ tokio-tungstenite = { workspace = true }
6262

6363
# Streams
6464
futures = { workspace = true }
65+
nix = { workspace = true }
6566

6667
# URL parsing
6768
url = { workspace = true }

crates/navigator-cli/src/main.rs

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1038,13 +1038,17 @@ enum SandboxCommands {
10381038
#[arg(long, requires = "upload", help_heading = "UPLOAD FLAGS")]
10391039
no_git_ignore: bool,
10401040

1041-
/// Keep the sandbox alive after non-interactive commands.
1042-
#[arg(long)]
1041+
/// Deprecated compatibility flag. Sandboxes are kept by default.
1042+
#[arg(long, hide = true, conflicts_with = "no_keep")]
10431043
keep: bool,
10441044

1045+
/// Delete the sandbox after the initial command or shell exits.
1046+
#[arg(long, conflicts_with_all = ["keep", "editor", "forward"])]
1047+
no_keep: bool,
1048+
10451049
/// Launch a remote editor after the sandbox is ready.
1046-
/// Implies `--keep` and installs OpenShell-managed SSH config.
1047-
#[arg(long, value_enum)]
1050+
/// Keeps the sandbox alive and installs OpenShell-managed SSH config.
1051+
#[arg(long, value_enum, conflicts_with = "no_keep")]
10481052
editor: Option<CliEditor>,
10491053

10501054
/// SSH destination for remote bootstrap (e.g., user@hostname).
@@ -1066,9 +1070,9 @@ enum SandboxCommands {
10661070
#[arg(long, value_hint = ValueHint::FilePath)]
10671071
policy: Option<String>,
10681072

1069-
/// Forward a local port to the sandbox after the command finishes.
1070-
/// Implies --keep for non-interactive commands.
1071-
#[arg(long)]
1073+
/// Forward a local port to the sandbox before the initial command or shell starts.
1074+
/// Keeps the sandbox alive.
1075+
#[arg(long, conflicts_with = "no_keep")]
10721076
forward: Option<u16>,
10731077

10741078
/// Allocate a pseudo-terminal for the remote command.
@@ -1785,6 +1789,7 @@ async fn main() -> Result<()> {
17851789
upload,
17861790
no_git_ignore,
17871791
keep,
1792+
no_keep,
17881793
editor,
17891794
remote,
17901795
ssh_key,
@@ -1834,7 +1839,7 @@ async fn main() -> Result<()> {
18341839
});
18351840

18361841
let editor = editor.map(Into::into);
1837-
let keep = keep || editor.is_some();
1842+
let keep = keep || !no_keep || editor.is_some() || forward.is_some();
18381843

18391844
// For `sandbox create`, a missing cluster is not fatal — the
18401845
// bootstrap flow inside `sandbox_create` can deploy one.

crates/navigator-cli/src/run.rs

Lines changed: 88 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1743,6 +1743,32 @@ pub async fn sandbox_create_with_bootstrap(
17431743
.await
17441744
}
17451745

1746+
fn sandbox_should_persist(keep: bool, forward: Option<u16>) -> bool {
1747+
keep || forward.is_some()
1748+
}
1749+
1750+
async fn finalize_sandbox_create_session(
1751+
server: &str,
1752+
sandbox_name: &str,
1753+
persist: bool,
1754+
session_result: Result<()>,
1755+
tls: &TlsOptions,
1756+
) -> Result<()> {
1757+
if persist {
1758+
return session_result;
1759+
}
1760+
1761+
let names = [sandbox_name.to_string()];
1762+
if let Err(err) = sandbox_delete(server, &names, false, tls).await {
1763+
if session_result.is_ok() {
1764+
return Err(err);
1765+
}
1766+
eprintln!("Failed to delete sandbox {sandbox_name}: {err}");
1767+
}
1768+
1769+
session_result
1770+
}
1771+
17461772
/// Create a sandbox with default settings.
17471773
#[allow(clippy::too_many_arguments)]
17481774
pub async fn sandbox_create(
@@ -1863,10 +1889,12 @@ pub async fn sandbox_create(
18631889
.ok_or_else(|| miette::miette!("sandbox missing from response"))?;
18641890

18651891
let interactive = std::io::stdout().is_terminal();
1892+
let persist = sandbox_should_persist(keep, forward);
18661893
let sandbox_name = sandbox.name.clone();
18671894

1868-
// Record this sandbox as the last-used for the active gateway.
1869-
if let Some(gateway) = effective_tls.gateway_name() {
1895+
// Record this sandbox as the last-used for the active gateway only when it
1896+
// is expected to persist beyond the initial session.
1897+
if persist && let Some(gateway) = effective_tls.gateway_name() {
18701898
let _ = save_last_sandbox(gateway, &sandbox_name);
18711899
}
18721900

@@ -2171,45 +2199,60 @@ pub async fn sandbox_create(
21712199
}
21722200

21732201
if command.is_empty() {
2174-
return sandbox_connect(&effective_server, &sandbox_name, &effective_tls).await;
2202+
let connect_result = if persist {
2203+
sandbox_connect(&effective_server, &sandbox_name, &effective_tls).await
2204+
} else {
2205+
crate::ssh::sandbox_connect_without_exec(
2206+
&effective_server,
2207+
&sandbox_name,
2208+
&effective_tls,
2209+
)
2210+
.await
2211+
};
2212+
2213+
return finalize_sandbox_create_session(
2214+
&effective_server,
2215+
&sandbox_name,
2216+
persist,
2217+
connect_result,
2218+
&effective_tls,
2219+
)
2220+
.await;
21752221
}
21762222

21772223
// Resolve TTY mode: explicit --tty / --no-tty wins, otherwise
21782224
// auto-detect from the local terminal.
21792225
let tty = tty_override.unwrap_or_else(|| {
21802226
std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
21812227
});
2182-
let exec_result = sandbox_exec(
2183-
&effective_server,
2184-
&sandbox_name,
2185-
command,
2186-
tty,
2187-
&effective_tls,
2188-
)
2189-
.await;
2190-
2191-
if forward.is_some() {
2192-
exec_result?;
2193-
return Ok(());
2194-
}
2195-
2196-
if !interactive
2197-
&& !keep
2198-
&& let Err(err) = sandbox_delete(
2228+
let exec_result = if persist {
2229+
sandbox_exec(
21992230
&effective_server,
2200-
std::slice::from_ref(&sandbox_name),
2201-
false,
2231+
&sandbox_name,
2232+
command,
2233+
tty,
22022234
&effective_tls,
22032235
)
22042236
.await
2205-
{
2206-
if exec_result.is_ok() {
2207-
return Err(err);
2208-
}
2209-
eprintln!("Failed to delete sandbox {sandbox_name}: {err}");
2210-
}
2237+
} else {
2238+
crate::ssh::sandbox_exec_without_exec(
2239+
&effective_server,
2240+
&sandbox_name,
2241+
command,
2242+
tty,
2243+
&effective_tls,
2244+
)
2245+
.await
2246+
};
22112247

2212-
exec_result
2248+
finalize_sandbox_create_session(
2249+
&effective_server,
2250+
&sandbox_name,
2251+
persist,
2252+
exec_result,
2253+
&effective_tls,
2254+
)
2255+
.await
22132256
}
22142257
SandboxPhase::Error => {
22152258
if last_error_reason.is_empty() {
@@ -4158,7 +4201,7 @@ mod tests {
41584201
GatewayControlTarget, TlsOptions, format_gateway_select_header,
41594202
format_gateway_select_items, gateway_auth_label, gateway_select_with, gateway_type_label,
41604203
git_sync_files, http_health_check, inferred_provider_type, parse_credential_pairs,
4161-
resolve_gateway_control_target_from,
4204+
resolve_gateway_control_target_from, sandbox_should_persist,
41624205
};
41634206
use crate::TEST_ENV_LOCK;
41644207
use hyper::StatusCode;
@@ -4312,6 +4355,21 @@ mod tests {
43124355
assert_eq!(result, Some("claude".to_string()));
43134356
}
43144357

4358+
#[test]
4359+
fn sandbox_should_persist_defaults_to_persistent() {
4360+
assert!(sandbox_should_persist(true, None));
4361+
}
4362+
4363+
#[test]
4364+
fn sandbox_should_not_persist_when_no_keep_is_set() {
4365+
assert!(!sandbox_should_persist(false, None));
4366+
}
4367+
4368+
#[test]
4369+
fn sandbox_should_persist_when_forward_is_requested() {
4370+
assert!(sandbox_should_persist(false, Some(8080)));
4371+
}
4372+
43154373
fn init_git_repo(path: &Path) {
43164374
let status = Command::new("git")
43174375
.args(["init"])

0 commit comments

Comments
 (0)