Skip to content

Commit f3635ea

Browse files
authored
feat(sandbox): add canonical main process
Closes #2710 Persist and supervise one canonical workload per sandbox, attach sandbox connect to its retained session, and make every unexpected main-process exit terminal. Signed-off-by: Drew Newberry <[email protected]>
1 parent 9ae3760 commit f3635ea

57 files changed

Lines changed: 4391 additions & 1465 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ The simplest way to get a sandbox running:
7070
openshell sandbox create
7171
```
7272

73-
This creates a sandbox with defaults and drops you into an interactive shell.
73+
This creates a sandbox whose canonical main process is `/bin/bash -l` and
74+
attaches your terminal to that retained process. Add `--detach` to return after
75+
the sandbox becomes ready without attaching.
7476

7577
When supplying `--name`, use a portable DNS-1123 label: at most 63 lowercase alphanumeric or `-` characters, beginning and ending with an alphanumeric character. The Kubernetes driver rejects uppercase letters, underscores, dots, and other names that cannot become Kubernetes resource labels.
7678

@@ -234,9 +236,14 @@ Key flags:
234236
- `--upload <PATH>[:<DEST>]`: Upload local files into the container working directory or an explicit destination
235237
- `--no-git-ignore`: Disable `.gitignore` filtering for uploads
236238
- `--no-keep`: Delete the sandbox after the initial command or shell exits
239+
- `--detach`: Start the canonical main process without attaching
237240
- `--forward [BIND_ADDRESS:]PORT`: Forward a local port and keep the sandbox alive
238241
- `--editor vscode|cursor`: Open a remote editor after creation and keep the sandbox alive
239242

243+
Do not combine `--upload` with a trailing main command. Uploads currently finish
244+
after the canonical process starts; create a scratch sandbox and use
245+
`sandbox exec`, or build the files into the image.
246+
240247
### List and inspect sandboxes
241248

242249
```bash
@@ -254,7 +261,10 @@ openshell sandbox connect my-sandbox
254261
openshell sandbox connect my-sandbox --editor vscode
255262
```
256263

257-
Opens an interactive SSH shell. To configure VS Code Remote-SSH:
264+
Attaches to the sandbox's existing canonical main process. Disconnecting leaves
265+
that process running; reconnecting targets the same generation and replays
266+
recent output. Use `sandbox exec --tty -- /bin/bash -l` for a new shell. To
267+
configure VS Code Remote-SSH:
258268

259269
```bash
260270
openshell sandbox ssh-config my-sandbox >> ~/.ssh/config
@@ -287,7 +297,9 @@ openshell sandbox exec --name my-sandbox --workdir /workspace -- ls -la
287297
openshell sandbox exec --name my-sandbox --env MODE=test -- cargo test
288298
```
289299

290-
`sandbox exec` streams output and exits with the remote command's exit code. Use `sandbox connect` for an interactive shell.
300+
`sandbox exec` starts an independent sibling process, streams output, and exits
301+
with the remote command's exit code. Use `sandbox connect` to attach to the
302+
canonical main process.
291303
Use `--env` only for non-secret values. Attach credentials to the sandbox with a
292304
provider instead of passing API keys, tokens, or other secrets to `sandbox exec`.
293305

‎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/compute-runtimes.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ Each runtime receives a sandbox spec from the gateway and is responsible for:
1313
- Injecting sandbox identity and gateway callback configuration.
1414
- Supplying TLS or secret material for supervisor callbacks.
1515
- Providing the supervisor binary or image in the workload.
16+
- Forwarding the exact canonical main-process argv, environment, working
17+
directory, and terminal mode without shell reconstruction.
1618
- Reporting lifecycle and platform events back to the gateway.
1719
- Cleaning up runtime-owned resources.
1820

‎architecture/gateway.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,17 @@ workloads.
1515
- Resolve provider credentials and inference bundles for sandbox supervisors.
1616
- Coordinate supervisor relay sessions for connect, exec, file sync, and
1717
service forwarding.
18+
- Persist the canonical main-process generation and terminal result. Any main
19+
process exit transitions the sandbox to `Error`, including exit code zero.
1820

1921
The gateway does not enforce agent network policy at request time. That happens
2022
inside each sandbox, where the supervisor and proxy can observe local process
2123
identity.
2224

25+
The live supervisor session is the readiness authority for its main-process
26+
generation. Short-lived exit-report sessions never replace that session or
27+
mark the sandbox ready, and the gateway rejects stale generation results.
28+
2329
## Protocol and Auth
2430

2531
The gateway listens on one service port and multiplexes gRPC and HTTP traffic.

‎architecture/sandbox.md‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,11 @@ only when the set is already empty; any other outcome fails the spawn.
2929
gateway, depending on mode.
3030
3. It prepares filesystem access, process restrictions, network namespace
3131
routing, trust stores, provider credential resolution, and inference routes.
32-
4. It starts the policy proxy and local SSH server.
33-
5. It opens a supervisor session back to the gateway for connect, exec, file
32+
4. It launches the persisted canonical main-process argv and retains its PTY
33+
or pipes in the main-session multiplexer.
34+
5. It starts the policy proxy and local SSH server.
35+
6. It opens a supervisor session back to the gateway for connect, exec, file
3436
sync, config polling, and log push.
35-
6. It launches the agent command as the resolved restricted identity.
3637

3738
## Isolation Layers
3839

@@ -359,8 +360,10 @@ The supervisor runs an SSH server on a Unix socket inside the sandbox. The
359360
gateway reaches it through the outbound supervisor relay, not by dialing the
360361
sandbox workload directly. The relay supports:
361362

362-
- Interactive shell sessions.
363-
- Command execution.
363+
- Attachment to the canonical main process through the `openshell-main` SSH
364+
subsystem. The supervisor owns its retained PTY or pipes, a 1 MiB replay
365+
buffer, and a single stdin lease across client disconnects.
366+
- Independent shell and command execution sessions.
364367
- Tar-based file sync.
365368
- Port forwarding where supported by the CLI/TUI surface.
366369

@@ -436,3 +439,7 @@ engine with a gateway policy revision.
436439
re-evaluate.
437440
- If the supervisor relay drops, the sandbox can keep running, but connect and
438441
exec operations fail until the supervisor registers again.
442+
- If the canonical main process exits, including with code 0, the supervisor
443+
reports its result before shutdown. The gateway persists `MainProcessExited`
444+
and makes the sandbox terminal `Error`; runtime restart policies must not
445+
replace the process generation.

‎crates/openshell-cli/src/main.rs‎

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1351,7 +1351,12 @@ enum SandboxCommands {
13511351
/// working directory.
13521352
/// `.gitignore` rules are applied by default; use `--no-git-ignore` to
13531353
/// upload everything.
1354-
#[arg(long, value_hint = ValueHint::AnyPath, help_heading = "UPLOAD FLAGS")]
1354+
#[arg(
1355+
long,
1356+
value_hint = ValueHint::AnyPath,
1357+
help_heading = "UPLOAD FLAGS",
1358+
conflicts_with = "command"
1359+
)]
13551360
upload: Vec<String>,
13561361

13571362
/// Disable `.gitignore` filtering for `--upload`.
@@ -1421,6 +1426,10 @@ enum SandboxCommands {
14211426
#[arg(long, overrides_with = "tty")]
14221427
no_tty: bool,
14231428

1429+
/// Start the canonical main process without attaching to it.
1430+
#[arg(long, conflicts_with_all = ["editor", "no_keep"])]
1431+
detach: bool,
1432+
14241433
/// Auto-create missing providers from local credentials.
14251434
///
14261435
/// Without this flag, an interactive prompt asks per-provider;
@@ -2980,6 +2989,7 @@ async fn run_async() -> Result<()> {
29802989
forward,
29812990
tty,
29822991
no_tty,
2992+
detach,
29832993
auto_providers,
29842994
no_auto_providers,
29852995
labels,
@@ -3075,6 +3085,7 @@ async fn run_async() -> Result<()> {
30753085
environment: env_map,
30763086
approval_mode: &approval_mode,
30773087
output: output.as_str(),
3088+
detach,
30783089
},
30793090
&cli.workspace,
30803091
&tls,
@@ -5128,6 +5139,55 @@ mod tests {
51285139
}
51295140
}
51305141

5142+
#[test]
5143+
fn sandbox_create_detach_parses_with_main_command() {
5144+
let cli = Cli::try_parse_from([
5145+
"openshell",
5146+
"sandbox",
5147+
"create",
5148+
"--detach",
5149+
"--",
5150+
"worker",
5151+
"--serve",
5152+
])
5153+
.expect("sandbox create --detach should parse");
5154+
5155+
match cli.command {
5156+
Some(Commands::Sandbox {
5157+
command:
5158+
Some(SandboxCommands::Create {
5159+
detach, command, ..
5160+
}),
5161+
..
5162+
}) => {
5163+
assert!(detach);
5164+
assert_eq!(command, ["worker", "--serve"]);
5165+
}
5166+
other => panic!("expected SandboxCommands::Create, got: {other:?}"),
5167+
}
5168+
}
5169+
5170+
#[test]
5171+
fn sandbox_create_detach_rejects_ephemeral_sandbox() {
5172+
let result =
5173+
Cli::try_parse_from(["openshell", "sandbox", "create", "--detach", "--no-keep"]);
5174+
assert!(result.is_err());
5175+
}
5176+
5177+
#[test]
5178+
fn sandbox_create_rejects_upload_with_main_command() {
5179+
let result = Cli::try_parse_from([
5180+
"openshell",
5181+
"sandbox",
5182+
"create",
5183+
"--upload",
5184+
".",
5185+
"--",
5186+
"./run-uploaded-app",
5187+
]);
5188+
assert!(result.is_err());
5189+
}
5190+
51315191
/// `sandbox create` defaults `--approval-mode` to `"manual"`. The CLI
51325192
/// always sends an explicit value so the wire form is human-readable
51335193
/// (the gateway treats `""` as `"manual"` too, but the CLI's job is to

‎crates/openshell-cli/src/run.rs‎

Lines changed: 47 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,7 @@ pub struct SandboxCreateConfig<'a> {
383383
pub environment: HashMap<String, String>,
384384
pub approval_mode: &'a str,
385385
pub output: &'a str,
386+
pub detach: bool,
386387
}
387388

388389
impl Default for SandboxCreateConfig<'_> {
@@ -407,6 +408,7 @@ impl Default for SandboxCreateConfig<'_> {
407408
environment: HashMap::new(),
408409
approval_mode: "manual",
409410
output: "table",
411+
detach: false,
410412
}
411413
}
412414
}
@@ -439,13 +441,19 @@ pub async fn sandbox_create(
439441
environment,
440442
approval_mode,
441443
output,
444+
detach,
442445
} = config;
443446

444447
if editor.is_some() && !command.is_empty() {
445448
return Err(miette::miette!(
446449
"--editor cannot be used with a trailing command; use `openshell sandbox connect <name> --editor ...` after the sandbox is ready"
447450
));
448451
}
452+
if !uploads.is_empty() && !command.is_empty() {
453+
return Err(miette::miette!(
454+
"--upload cannot be combined with a trailing main command yet because uploads complete after the canonical process starts"
455+
));
456+
}
449457

450458
// Check port availability *before* creating the sandbox so we don't
451459
// leave an orphaned sandbox behind when the forward would fail.
@@ -521,13 +529,25 @@ pub async fn sandbox_create(
521529

522530
let resource_requirements = gpu_requirements.map(|gpu| ResourceRequirements { gpu: Some(gpu) });
523531

532+
let main_terminal = tty_override
533+
.unwrap_or_else(|| std::io::stdin().is_terminal() && std::io::stdout().is_terminal());
534+
let main_command = if command.is_empty() {
535+
vec!["/bin/bash".to_string(), "-l".to_string()]
536+
} else {
537+
command.to_vec()
538+
};
524539
let request = CreateSandboxRequest {
525540
spec: Some(SandboxSpec {
526541
resource_requirements,
527542
environment,
528543
policy,
529544
providers: configured_providers,
530545
template,
546+
main_process: Some(openshell_core::proto::MainProcessSpec {
547+
command: main_command,
548+
terminal: main_terminal,
549+
..Default::default()
550+
}),
531551
..SandboxSpec::default()
532552
}),
533553
name: name.unwrap_or_default().to_string(),
@@ -939,53 +959,23 @@ pub async fn sandbox_create(
939959
return Ok(());
940960
}
941961

942-
if command.is_empty() {
943-
let connect_result = if persist {
944-
sandbox_connect(&effective_server, &sandbox_name, &effective_tls, workspace)
945-
.await
946-
} else {
947-
crate::ssh::sandbox_connect_without_exec(
948-
&effective_server,
949-
&sandbox_name,
950-
&effective_tls,
951-
workspace,
952-
)
953-
.await
954-
};
955-
956-
return finalize_sandbox_create_session(
957-
&effective_server,
958-
&sandbox_name,
959-
persist,
960-
connect_result,
961-
workspace,
962-
&effective_tls,
963-
gateway_name,
964-
)
965-
.await;
962+
// Persistent non-interactive creates detach implicitly. An
963+
// explicitly ephemeral (`--no-keep`) create must still attach so
964+
// it can observe the canonical process and delete the sandbox when
965+
// that session ends.
966+
if detach
967+
|| (persist
968+
&& (!std::io::stdin().is_terminal() || !std::io::stdout().is_terminal()))
969+
{
970+
return Ok(());
966971
}
967972

968-
// Resolve TTY mode: explicit --tty / --no-tty wins, otherwise
969-
// auto-detect from the local terminal.
970-
let tty = tty_override.unwrap_or_else(|| {
971-
std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
972-
});
973-
let exec_result = if persist {
974-
sandbox_exec(
975-
&effective_server,
976-
&sandbox_name,
977-
command,
978-
tty,
979-
&effective_tls,
980-
workspace,
981-
)
982-
.await
973+
let connect_result = if persist {
974+
sandbox_connect(&effective_server, &sandbox_name, &effective_tls, workspace).await
983975
} else {
984-
crate::ssh::sandbox_exec_without_exec(
976+
crate::ssh::sandbox_connect_without_exec(
985977
&effective_server,
986978
&sandbox_name,
987-
command,
988-
tty,
989979
&effective_tls,
990980
workspace,
991981
)
@@ -996,15 +986,17 @@ pub async fn sandbox_create(
996986
&effective_server,
997987
&sandbox_name,
998988
persist,
999-
exec_result,
989+
connect_result,
1000990
workspace,
1001991
&effective_tls,
1002992
gateway_name,
1003993
)
1004994
.await
1005995
}
1006996
SandboxPhase::Error => {
1007-
if last_error_reason.is_empty() {
997+
drop(stream);
998+
drop(client);
999+
let create_result = if last_error_reason.is_empty() {
10081000
Err(miette::miette!(
10091001
"sandbox entered error phase while provisioning"
10101002
))
@@ -1013,7 +1005,17 @@ pub async fn sandbox_create(
10131005
"sandbox entered error phase while provisioning: {}",
10141006
last_error_reason
10151007
))
1016-
}
1008+
};
1009+
finalize_sandbox_create_session(
1010+
&effective_server,
1011+
&sandbox_name,
1012+
persist,
1013+
create_result,
1014+
workspace,
1015+
&effective_tls,
1016+
gateway_name,
1017+
)
1018+
.await
10171019
}
10181020
_ => Err(miette::miette!(
10191021
"sandbox provisioning stream ended before reaching terminal phase"

0 commit comments

Comments
 (0)