Skip to content
Open
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
7 changes: 7 additions & 0 deletions .agents/skills/openshell-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ Key flags:
- `--label KEY=VALUE`: Add labels for later selection (repeatable)
- `--env KEY=VALUE`: Set non-secret sandbox environment variables (repeatable); use `--provider` for credentials
- `--tty`: Allocate a retained PTY for the canonical main process
- `--restart-policy never|on-failure|always`: Select gateway-owned main-process restart behavior; `never` is the default
- `--approval-mode manual|auto`: Control handling of agent-authored policy proposals; `manual` is the default
- `--upload <PATH>[:<DEST>]`: Upload local files into the container working directory or an explicit destination
- `--no-git-ignore`: Disable `.gitignore` filtering for uploads
Expand Down Expand Up @@ -273,6 +274,12 @@ VS Code Remote-SSH with:
openshell sandbox ssh-config my-sandbox >> ~/.ssh/config
```

When the main process exits under `on-failure` or `always`, the sandbox enters
`Restarting` while the gateway applies exponential backoff and recreates the
compute resource. Connect and exec commands are unavailable until the new
supervisor session makes it `Ready`. An explicit `sandbox stop` cancels a
pending restart.

### Upload and download files

```bash
Expand Down
6 changes: 6 additions & 0 deletions .agents/skills/openshell-cli/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ without one, the default is `/bin/bash -l` with a PTY.
| `--from <SOURCE>` | Community name, Dockerfile path, directory, or image reference (BYOC) |
| `--no-keep` | Delete the sandbox after the initial command or shell exits |
| `--detach` | Start the canonical main process without attaching |
| `--restart-policy never|on-failure|always` | Restart behavior for canonical main exit; default: `never` |
| `--editor vscode|cursor` | Launch a remote editor and keep the sandbox alive |
| `--gpu [COUNT]` | Request the driver's default GPU selection or a specific count |
| `--cpu <QUANTITY>` | CPU limit (for example: `500m`, `1`, `2.5`) |
Expand Down Expand Up @@ -294,6 +295,11 @@ new shell. The name defaults to the last-used sandbox.
`--editor vscode|cursor` launches a supported remote editor instead of
attaching to the canonical main process.

With `on-failure` or `always`, a main-process exit moves the sandbox through
`Restarting` while the gateway applies exponential backoff and replaces the
runtime. A successful replacement returns to `Ready` with a new main-process
instance and empty PTY history.

### `openshell sandbox upload <name> <path> [dest]`

Upload files using tar-over-SSH. The CLI discovers the canonical remote working directory when the destination is omitted. A named directory merges into an existing directory of the same name, overwriting matching entries without deleting unrelated entries. `.gitignore` filtering is enabled unless `--no-git-ignore` is passed.
Expand Down
4 changes: 2 additions & 2 deletions .agents/skills/tui-development/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ The `Theme` struct has 16 `Style` fields, accessed at runtime via `app.theme`:
| `border` | EVERGLADE fg | Light sage fg | Unfocused panel borders |
| `border_focused` | NVIDIA_GREEN fg | NVIDIA_GREEN_DARK fg | Focused panel borders |
| `status_ok` | NVIDIA_GREEN fg | NVIDIA_GREEN_DARK fg | Healthy, INFO, Ready |
| `status_warn` | Yellow fg | Dark yellow fg | Degraded, WARN, Provisioning |
| `status_warn` | Yellow fg | Dark yellow fg | Degraded, WARN, Provisioning, Restarting |
| `status_err` | Red fg | Dark red fg | Unhealthy, ERROR |
| `key_hint` | NVIDIA_GREEN fg | NVIDIA_GREEN_DARK fg | Keyboard shortcut labels |
| `log_cursor` | EVERGLADE bg | Light green bg | Selected log line highlight |
Expand Down Expand Up @@ -293,7 +293,7 @@ fn draw_detail_popup(frame: &mut Frame<'_>, data: &MyData, area: Rect, theme: &T

- **Selected row**: Green `▌` left-border marker on the selected row. Active gateway also gets a green `●` dot.
- **Focused panel**: Border changes from `border` to `border_focused` style.
- **Status indicators**: Green for healthy/ready/info, yellow for degraded/provisioning/warn, red for unhealthy/error.
- **Status indicators**: Green for healthy/ready/info, yellow for degraded/provisioning/restarting/warn, red for unhealthy/error.
- **Separators**: Muted `│` characters between title bar segments and nav bar sections.
- **Log source labels**: `"sandbox"` source renders in `accent` (green), `"gateway"` in `muted`.

Expand Down
7 changes: 7 additions & 0 deletions architecture/compute-runtimes.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ Canonical main-process support is part of the `ComputeDriver` contract. Every
in-tree and extension driver must forward the exact specification; it is not an
optional capability that drivers can omit or negotiate.

The gateway also owns canonical main-process restart policy. Drivers disable
native container, pod, or VM restart behavior and implement the existing stop
and start operations. When policy selects a restart, the gateway persists the
`Restarting` phase and backoff deadline, stops compute, starts it again after
the deadline, and waits for a new supervisor session before returning to
`Ready`. Driver snapshots cannot override that gateway-owned transition.

Drivers own runtime-specific platform event interpretation. When an event should
drive client provisioning UI, the driver attaches the shared
`openshell.progress.*` metadata defined in `openshell-core` instead of requiring
Expand Down
7 changes: 4 additions & 3 deletions architecture/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ workloads.
- Resolve provider credentials and inference bundles for sandbox supervisors.
- Coordinate supervisor relay sessions for connect, exec, file sync, and
service forwarding.
- Persist the canonical main-process instance ID and normalized exit code on
sandbox status. Any main process exit transitions the sandbox to `Error`,
including exit code zero.
- Persist the canonical main-process instance ID, normalized exit code, restart
count, and restart deadline on sandbox status. Evaluate the sandbox restart
policy when that process exits and either transition to `Error` or restart
the sandbox compute resource with bounded exponential backoff.

The gateway does not enforce agent network policy at request time. That happens
inside each sandbox, where the supervisor and proxy can observe local process
Expand Down
12 changes: 8 additions & 4 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,11 @@ engine with a gateway policy revision.
re-evaluate.
- If the supervisor relay drops, the sandbox can keep running, but connect and
exec operations fail until the supervisor registers again.
- If the canonical main process exits, including with code 0, the supervisor
reports its normalized exit code before shutdown. The gateway persists the
code on sandbox status, records `MainProcessExited`, and makes the sandbox
terminal `Error`; runtime restart policies must not replace the process.
- If the canonical main process exits, the supervisor reports its normalized
exit code before shutdown. The gateway persists the result and evaluates the
sandbox's `Never`, `OnFailure`, or `Always` restart policy. A selected restart
moves the sandbox to `Restarting` and recreates its compute resource after
bounded exponential backoff; otherwise the sandbox becomes terminal `Error`.
- Compute runtimes keep their native restart mechanisms disabled. This gives
the gateway one durable restart counter, deadline, and policy decision across
Docker, Podman, Kubernetes, and VM drivers.
1 change: 1 addition & 0 deletions crates/openshell-cli/src/commands/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ pub fn phase_name(phase: i32) -> &'static str {
Ok(SandboxPhase::Stopping) => "Stopping",
Ok(SandboxPhase::Stopped) => "Stopped",
Ok(SandboxPhase::Starting) => "Starting",
Ok(SandboxPhase::Restarting) => "Restarting",
Ok(SandboxPhase::Unknown) | Err(_) => "Unknown",
}
}
Expand Down
55 changes: 55 additions & 0 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1430,6 +1430,14 @@ enum SandboxCommands {
#[arg(long, conflicts_with_all = ["editor", "no_keep"])]
detach: bool,

/// Restart behavior after the canonical main process exits.
#[arg(
long,
value_parser = ["never", "on-failure", "always"],
default_value = "never"
)]
restart_policy: String,

/// Auto-create missing providers from local credentials.
///
/// Without this flag, an interactive prompt asks per-provider;
Expand Down Expand Up @@ -2991,6 +2999,7 @@ async fn run_async() -> Result<()> {
tty,
no_tty,
detach,
restart_policy,
auto_providers,
no_auto_providers,
labels,
Expand Down Expand Up @@ -3087,6 +3096,7 @@ async fn run_async() -> Result<()> {
approval_mode: &approval_mode,
output: output.as_str(),
detach,
restart_policy: &restart_policy,
},
&cli.workspace,
&tls,
Expand Down Expand Up @@ -5140,6 +5150,51 @@ mod tests {
}
}

#[test]
fn sandbox_create_restart_policy_defaults_to_never() {
let cli = Cli::try_parse_from(["openshell", "sandbox", "create"]).unwrap();
match cli.command {
Some(Commands::Sandbox {
command: Some(SandboxCommands::Create { restart_policy, .. }),
..
}) => assert_eq!(restart_policy, "never"),
other => panic!("expected SandboxCommands::Create, got: {other:?}"),
}
}

#[test]
fn sandbox_create_restart_policy_accepts_on_failure() {
let cli = Cli::try_parse_from([
"openshell",
"sandbox",
"create",
"--restart-policy",
"on-failure",
])
.unwrap();
match cli.command {
Some(Commands::Sandbox {
command: Some(SandboxCommands::Create { restart_policy, .. }),
..
}) => assert_eq!(restart_policy, "on-failure"),
other => panic!("expected SandboxCommands::Create, got: {other:?}"),
}
}

#[test]
fn sandbox_create_restart_policy_rejects_unknown_value() {
assert!(
Cli::try_parse_from([
"openshell",
"sandbox",
"create",
"--restart-policy",
"unless-stopped",
])
.is_err()
);
}

#[test]
fn sandbox_create_detach_parses_with_main_command() {
let cli = Cli::try_parse_from([
Expand Down
107 changes: 101 additions & 6 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,11 @@ use openshell_core::proto::{
ProviderCredentialRefreshStrategy, ProviderProfile, ProviderProfileDiagnostic,
ProviderProfileImportItem, RejectDraftChunkRequest, ResourceRequirements,
RevokeSshSessionRequest, RotateProviderCredentialRequest, Sandbox, SandboxPhase, SandboxPolicy,
SandboxSpec, SandboxTemplate, ServiceEndpointResponse, SetInferenceRouteRequest, SettingScope,
StartSandboxRequest, StopSandboxRequest, TcpForwardFrame, TcpForwardInit, TcpRelayTarget,
UpdateConfigRequest, UpdateProviderProfilesRequest, UpdateProviderRequest, WatchSandboxRequest,
exec_sandbox_event, setting_value, tcp_forward_init,
SandboxRestartPolicy, SandboxSpec, SandboxTemplate, ServiceEndpointResponse,
SetInferenceRouteRequest, SettingScope, StartSandboxRequest, StopSandboxRequest,
TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest,
UpdateProviderProfilesRequest, UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event,
setting_value, tcp_forward_init,
};
use openshell_core::settings;
use openshell_core::{ObjectId, ObjectName, ObjectWorkspace};
Expand Down Expand Up @@ -384,6 +385,7 @@ pub struct SandboxCreateConfig<'a> {
pub approval_mode: &'a str,
pub output: &'a str,
pub detach: bool,
pub restart_policy: &'a str,
}

impl Default for SandboxCreateConfig<'_> {
Expand All @@ -409,6 +411,7 @@ impl Default for SandboxCreateConfig<'_> {
approval_mode: "manual",
output: "table",
detach: false,
restart_policy: "never",
}
}
}
Expand Down Expand Up @@ -442,6 +445,7 @@ pub async fn sandbox_create(
approval_mode,
output,
detach,
restart_policy,
} = config;

if editor.is_some() && !command.is_empty() {
Expand Down Expand Up @@ -545,6 +549,12 @@ pub async fn sandbox_create(
template,
command: main_command,
tty: main_terminal,
restart_policy: match restart_policy {
"never" => SandboxRestartPolicy::Never as i32,
"on-failure" => SandboxRestartPolicy::OnFailure as i32,
"always" => SandboxRestartPolicy::Always as i32,
value => return Err(miette::miette!("invalid restart policy '{value}'")),
},
..SandboxSpec::default()
}),
name: name.unwrap_or_default().to_string(),
Expand Down Expand Up @@ -1329,6 +1339,38 @@ pub async fn sandbox_get(
"Resource version:".dimmed(),
sandbox.metadata.as_ref().map_or(0, |m| m.resource_version)
);
println!(
" {} {}",
"Restart policy:".dimmed(),
sandbox
.spec
.as_ref()
.map_or("never", |spec| { restart_policy_name(spec.restart_policy) })
);
if let Some(status) = sandbox.status.as_ref() {
println!(
" {} {}",
"Main process instance:".dimmed(),
if status.main_process_instance_id.is_empty() {
"-"
} else {
&status.main_process_instance_id
}
);
println!(
" {} {}",
"Last exit code:".dimmed(),
status
.exit_code
.map_or_else(|| "-".to_string(), |code| code.to_string())
);
println!(" {} {}", "Restart count:".dimmed(), status.restart_count);
println!(
" {} {}",
"Next restart:".dimmed(),
format_optional_epoch_ms(status.next_restart_at_ms)
);
}

// Display labels if present
if let Some(metadata) = &sandbox.metadata
Expand Down Expand Up @@ -2105,6 +2147,14 @@ fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value {
})
}

fn restart_policy_name(policy: i32) -> &'static str {
match SandboxRestartPolicy::try_from(policy) {
Ok(SandboxRestartPolicy::OnFailure) => "on-failure",
Ok(SandboxRestartPolicy::Always) => "always",
Ok(SandboxRestartPolicy::Unspecified | SandboxRestartPolicy::Never) | Err(_) => "never",
}
}

fn sandbox_detail_to_json(
sandbox: &Sandbox,
config: &GetSandboxConfigResponse,
Expand All @@ -2114,6 +2164,31 @@ fn sandbox_detail_to_json(
.as_object_mut()
.expect("sandbox_to_json returns object");

let restart_policy = sandbox
.spec
.as_ref()
.map_or("never", |spec| restart_policy_name(spec.restart_policy));
obj.insert("restart_policy".into(), serde_json::json!(restart_policy));
if let Some(status) = sandbox.status.as_ref() {
obj.insert(
"main_process_instance_id".into(),
serde_json::json!(status.main_process_instance_id),
);
obj.insert("exit_code".into(), serde_json::json!(status.exit_code));
obj.insert(
"restart_count".into(),
serde_json::json!(status.restart_count),
);
obj.insert(
"next_restart_at_ms".into(),
serde_json::json!(status.next_restart_at_ms),
);
obj.insert(
"main_process_started_at_ms".into(),
serde_json::json!(status.main_process_started_at_ms),
);
}

let policy_source = if config.policy_source == PolicySource::Global as i32 {
"global"
} else {
Expand Down Expand Up @@ -7150,7 +7225,8 @@ mod tests {
ProviderCredentialRefresh, ProviderCredentialRefreshStatus,
ProviderCredentialRefreshStrategy, ProviderCredentialTokenGrant, ProviderProfile,
ProviderProfileCredential, ResourceRequirements, Sandbox, SandboxCondition, SandboxPhase,
SandboxPolicyRevision, SandboxStatus, datamodel::v1::ObjectMeta,
SandboxPolicyRevision, SandboxRestartPolicy, SandboxSpec, SandboxStatus,
datamodel::v1::ObjectMeta,
};

#[test]
Expand Down Expand Up @@ -8495,6 +8571,19 @@ mod tests {
};
sandbox.set_phase(SandboxPhase::Ready as i32);
sandbox.set_current_policy_version(2);
sandbox.spec = Some(SandboxSpec {
restart_policy: SandboxRestartPolicy::OnFailure as i32,
..Default::default()
});
sandbox.status = Some(SandboxStatus {
phase: SandboxPhase::Restarting as i32,
main_process_instance_id: "main-2".to_string(),
exit_code: Some(9),
restart_count: 2,
next_restart_at_ms: 1_700_000_000_000,
main_process_started_at_ms: 1_699_999_000_000,
..Default::default()
});

let config = GetSandboxConfigResponse {
policy_source: PolicySource::Global as i32,
Expand All @@ -8506,7 +8595,13 @@ mod tests {

assert_eq!(json["id"], "sb-123");
assert_eq!(json["name"], "test-sb");
assert_eq!(json["phase"], "Ready");
assert_eq!(json["phase"], "Restarting");
assert_eq!(json["restart_policy"], "on-failure");
assert_eq!(json["main_process_instance_id"], "main-2");
assert_eq!(json["exit_code"], 9);
assert_eq!(json["restart_count"], 2);
assert_eq!(json["next_restart_at_ms"], 1_700_000_000_000_i64);
assert_eq!(json["main_process_started_at_ms"], 1_699_999_000_000_i64);
assert_eq!(json["policy_source"], "global");
assert_eq!(json["revision"], 3);
assert!(json["policy"].is_null());
Expand Down
4 changes: 2 additions & 2 deletions crates/openshell-driver-docker/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ fn test_sandbox() -> DriverSandbox {
}),
resource_requirements: None,
sandbox_token: String::new(),
command: Vec::new(),
tty: false,
command: vec!["/bin/bash".to_string(), "-l".to_string()],
tty: true,
}),
status: None,
workspace: String::new(),
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-driver-podman/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ The container spec in `container.rs` sets these security-critical fields:
| `cap_drop` | Selected unneeded defaults | Podman's default capability set is already restricted. The driver drops capabilities the supervisor does not need. |
| `cap_add` | `SYS_ADMIN`, `NET_ADMIN`, `SYS_PTRACE`, `SYSLOG`, `DAC_READ_SEARCH`, `SETPCAP` | Grants supervisor-only capabilities required for namespace setup, process identity, bypass diagnostics, and child bounding-set cleanup. |
| `no_new_privileges` | `true` | Prevents privilege escalation after exec. |
| `restart_policy` | `no` | Keeps the gateway authoritative for canonical-main restart decisions. |
| `seccomp_profile_path` | `unconfined` | The supervisor installs its own policy-aware BPF filter. A container-level profile can block Landlock/seccomp syscalls during setup. |
| `mounts` | Private tmpfs at `/run/netns` | Lets the supervisor create named network namespaces in rootless Podman. |
| CDI GPU devices | Opaque `driver_config.cdi_devices` values when set, otherwise the requested count of NVIDIA CDI GPUs selected in round-robin order. Local `/dev/dxg` permits `nvidia.com/gpu=all` as a WSL2 all-only compatibility fallback, where it counts as one selectable device. | Exposes requested GPUs to GPU-enabled sandbox containers. Exact CDI device lists must not contain duplicates and must match the effective GPU count. |
Expand Down
Loading
Loading