Skip to content

Commit ad87da4

Browse files
committed
feat(sandbox): persist startup command across gateway stop/start cycles
The user-provided command from `sandbox create -- <command>` was previously ephemeral—executed via SSH on first connect but lost when the sandbox pod was recreated after a gateway restart. This change persists the command in the SandboxSpec protobuf, stores it in both SQLite and the Kubernetes CRD, and sets it as OPENSHELL_SANDBOX_COMMAND in the pod spec. When the CRD controller recreates the pod after a gateway stop/start, the supervisor re-executes the stored command instead of falling back to `sleep infinity`.
1 parent 491c5d8 commit ad87da4

File tree

4 files changed

+95
-43
lines changed

4 files changed

+95
-43
lines changed

crates/openshell-cli/src/run.rs

Lines changed: 10 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1936,7 +1936,7 @@ pub async fn sandbox_create(
19361936
policy: Option<&str>,
19371937
forward: Option<openshell_core::forward::ForwardSpec>,
19381938
command: &[String],
1939-
tty_override: Option<bool>,
1939+
_tty_override: Option<bool>,
19401940
bootstrap_override: Option<bool>,
19411941
auto_providers_override: Option<bool>,
19421942
tls: &TlsOptions,
@@ -2038,6 +2038,7 @@ pub async fn sandbox_create(
20382038
policy,
20392039
providers: configured_providers,
20402040
template,
2041+
command: command.to_vec(),
20412042
..SandboxSpec::default()
20422043
}),
20432044
name: name.unwrap_or_default().to_string(),
@@ -2374,49 +2375,16 @@ pub async fn sandbox_create(
23742375
return Ok(());
23752376
}
23762377

2377-
if command.is_empty() {
2378-
let connect_result = if persist {
2379-
sandbox_connect(&effective_server, &sandbox_name, &effective_tls).await
2380-
} else {
2381-
crate::ssh::sandbox_connect_without_exec(
2382-
&effective_server,
2383-
&sandbox_name,
2384-
&effective_tls,
2385-
)
2386-
.await
2387-
};
2388-
2389-
return finalize_sandbox_create_session(
2390-
&effective_server,
2391-
&sandbox_name,
2392-
persist,
2393-
connect_result,
2394-
&effective_tls,
2395-
gateway_name,
2396-
)
2397-
.await;
2398-
}
2399-
2400-
// Resolve TTY mode: explicit --tty / --no-tty wins, otherwise
2401-
// auto-detect from the local terminal.
2402-
let tty = tty_override.unwrap_or_else(|| {
2403-
std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
2404-
});
2405-
let exec_result = if persist {
2406-
sandbox_exec(
2407-
&effective_server,
2408-
&sandbox_name,
2409-
command,
2410-
tty,
2411-
&effective_tls,
2412-
)
2413-
.await
2378+
// When a command is provided it is persisted in the sandbox spec
2379+
// and runs as the entrypoint via OPENSHELL_SANDBOX_COMMAND. We
2380+
// always open an interactive shell here so the user can inspect
2381+
// the sandbox without executing the command a second time.
2382+
let connect_result = if persist {
2383+
sandbox_connect(&effective_server, &sandbox_name, &effective_tls).await
24142384
} else {
2415-
crate::ssh::sandbox_exec_without_exec(
2385+
crate::ssh::sandbox_connect_without_exec(
24162386
&effective_server,
24172387
&sandbox_name,
2418-
command,
2419-
tty,
24202388
&effective_tls,
24212389
)
24222390
.await
@@ -2426,7 +2394,7 @@ pub async fn sandbox_create(
24262394
&effective_server,
24272395
&sandbox_name,
24282396
persist,
2429-
exec_result,
2397+
connect_result,
24302398
&effective_tls,
24312399
gateway_name,
24322400
)

crates/openshell-cli/src/ssh.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,7 @@ pub async fn sandbox_exec(
437437
sandbox_exec_with_mode(server, name, command, tty, tls, true).await
438438
}
439439

440+
#[allow(dead_code)]
440441
pub(crate) async fn sandbox_exec_without_exec(
441442
server: &str,
442443
name: &str,

crates/openshell-server/src/sandbox/mod.rs

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -920,6 +920,7 @@ fn sandbox_to_k8s_spec(
920920
sandbox_id,
921921
sandbox_name,
922922
grpc_endpoint,
923+
&spec.command,
923924
ssh_listen_addr,
924925
ssh_handshake_secret,
925926
ssh_handshake_skew_secs,
@@ -953,7 +954,11 @@ fn sandbox_to_k8s_spec(
953954
// podTemplate is required by the Kubernetes CRD - ensure it's always present
954955
if !root.contains_key("podTemplate") {
955956
let empty_env = std::collections::HashMap::new();
957+
let empty_cmd: Vec<String> = Vec::new();
956958
let spec_env = spec.as_ref().map_or(&empty_env, |s| &s.environment);
959+
let spec_cmd = spec
960+
.as_ref()
961+
.map_or(empty_cmd.as_slice(), |s| s.command.as_slice());
957962
root.insert(
958963
"podTemplate".to_string(),
959964
sandbox_template_to_k8s(
@@ -964,6 +969,7 @@ fn sandbox_to_k8s_spec(
964969
sandbox_id,
965970
sandbox_name,
966971
grpc_endpoint,
972+
spec_cmd,
967973
ssh_listen_addr,
968974
ssh_handshake_secret,
969975
ssh_handshake_skew_secs,
@@ -989,6 +995,7 @@ fn sandbox_template_to_k8s(
989995
sandbox_id: &str,
990996
sandbox_name: &str,
991997
grpc_endpoint: &str,
998+
sandbox_command: &[String],
992999
ssh_listen_addr: &str,
9931000
ssh_handshake_secret: &str,
9941001
ssh_handshake_skew_secs: u64,
@@ -1045,6 +1052,7 @@ fn sandbox_template_to_k8s(
10451052
sandbox_id,
10461053
sandbox_name,
10471054
grpc_endpoint,
1055+
sandbox_command,
10481056
ssh_listen_addr,
10491057
ssh_handshake_secret,
10501058
ssh_handshake_skew_secs,
@@ -1176,6 +1184,7 @@ fn build_env_list(
11761184
sandbox_id: &str,
11771185
sandbox_name: &str,
11781186
grpc_endpoint: &str,
1187+
sandbox_command: &[String],
11791188
ssh_listen_addr: &str,
11801189
ssh_handshake_secret: &str,
11811190
ssh_handshake_skew_secs: u64,
@@ -1189,6 +1198,7 @@ fn build_env_list(
11891198
sandbox_id,
11901199
sandbox_name,
11911200
grpc_endpoint,
1201+
sandbox_command,
11921202
ssh_listen_addr,
11931203
ssh_handshake_secret,
11941204
ssh_handshake_skew_secs,
@@ -1211,6 +1221,7 @@ fn apply_required_env(
12111221
sandbox_id: &str,
12121222
sandbox_name: &str,
12131223
grpc_endpoint: &str,
1224+
sandbox_command: &[String],
12141225
ssh_listen_addr: &str,
12151226
ssh_handshake_secret: &str,
12161227
ssh_handshake_skew_secs: u64,
@@ -1219,7 +1230,14 @@ fn apply_required_env(
12191230
upsert_env(env, "OPENSHELL_SANDBOX_ID", sandbox_id);
12201231
upsert_env(env, "OPENSHELL_SANDBOX", sandbox_name);
12211232
upsert_env(env, "OPENSHELL_ENDPOINT", grpc_endpoint);
1222-
upsert_env(env, "OPENSHELL_SANDBOX_COMMAND", "sleep infinity");
1233+
// Use the user-provided command if present, otherwise fall back to
1234+
// `sleep infinity` so the sandbox pod stays alive for interactive SSH.
1235+
let command_value = if sandbox_command.is_empty() {
1236+
"sleep infinity".to_string()
1237+
} else {
1238+
sandbox_command.join(" ")
1239+
};
1240+
upsert_env(env, "OPENSHELL_SANDBOX_COMMAND", &command_value);
12231241
if !ssh_listen_addr.is_empty() {
12241242
upsert_env(env, "OPENSHELL_SSH_LISTEN_ADDR", ssh_listen_addr);
12251243
}
@@ -1617,6 +1635,7 @@ mod tests {
16171635
"sandbox-1",
16181636
"my-sandbox",
16191637
"https://endpoint:8080",
1638+
&[],
16201639
"0.0.0.0:2222",
16211640
"my-secret-value",
16221641
300,
@@ -1635,6 +1654,58 @@ mod tests {
16351654
);
16361655
}
16371656

1657+
#[test]
1658+
fn apply_required_env_uses_sleep_infinity_when_no_command() {
1659+
let mut env = Vec::new();
1660+
apply_required_env(
1661+
&mut env,
1662+
"sandbox-1",
1663+
"my-sandbox",
1664+
"https://endpoint:8080",
1665+
&[],
1666+
"0.0.0.0:2222",
1667+
"secret",
1668+
300,
1669+
false,
1670+
);
1671+
1672+
let cmd_entry = env
1673+
.iter()
1674+
.find(|e| e.get("name").and_then(|v| v.as_str()) == Some("OPENSHELL_SANDBOX_COMMAND"))
1675+
.expect("OPENSHELL_SANDBOX_COMMAND must be present in env");
1676+
assert_eq!(
1677+
cmd_entry.get("value").and_then(|v| v.as_str()),
1678+
Some("sleep infinity"),
1679+
"default sandbox command should be 'sleep infinity'"
1680+
);
1681+
}
1682+
1683+
#[test]
1684+
fn apply_required_env_uses_user_command_when_provided() {
1685+
let mut env = Vec::new();
1686+
apply_required_env(
1687+
&mut env,
1688+
"sandbox-1",
1689+
"my-sandbox",
1690+
"https://endpoint:8080",
1691+
&["python".to_string(), "app.py".to_string()],
1692+
"0.0.0.0:2222",
1693+
"secret",
1694+
300,
1695+
false,
1696+
);
1697+
1698+
let cmd_entry = env
1699+
.iter()
1700+
.find(|e| e.get("name").and_then(|v| v.as_str()) == Some("OPENSHELL_SANDBOX_COMMAND"))
1701+
.expect("OPENSHELL_SANDBOX_COMMAND must be present in env");
1702+
assert_eq!(
1703+
cmd_entry.get("value").and_then(|v| v.as_str()),
1704+
Some("python app.py"),
1705+
"sandbox command should reflect user-provided command"
1706+
);
1707+
}
1708+
16381709
#[test]
16391710
fn supervisor_sideload_injects_run_as_user_zero() {
16401711
let mut pod_template = serde_json::json!({
@@ -1747,6 +1818,7 @@ mod tests {
17471818
"sandbox-1",
17481819
"my-sandbox",
17491820
"https://endpoint:8080",
1821+
&[],
17501822
"0.0.0.0:2222",
17511823
"secret",
17521824
300,
@@ -1795,6 +1867,7 @@ mod tests {
17951867
"sandbox-id",
17961868
"sandbox-name",
17971869
"https://gateway.example.com",
1870+
&[],
17981871
"0.0.0.0:2222",
17991872
"secret",
18001873
300,
@@ -1829,6 +1902,7 @@ mod tests {
18291902
"sandbox-id",
18301903
"sandbox-name",
18311904
"https://gateway.example.com",
1905+
&[],
18321906
"0.0.0.0:2222",
18331907
"secret",
18341908
300,
@@ -1859,6 +1933,7 @@ mod tests {
18591933
"sandbox-id",
18601934
"sandbox-name",
18611935
"https://gateway.example.com",
1936+
&[],
18621937
"0.0.0.0:2222",
18631938
"secret",
18641939
300,
@@ -1902,6 +1977,7 @@ mod tests {
19021977
"sandbox-id",
19031978
"sandbox-name",
19041979
"https://gateway.example.com",
1980+
&[],
19051981
"0.0.0.0:2222",
19061982
"secret",
19071983
300,
@@ -1929,6 +2005,7 @@ mod tests {
19292005
"sandbox-id",
19302006
"sandbox-name",
19312007
"https://gateway.example.com",
2008+
&[],
19322009
"0.0.0.0:2222",
19332010
"secret",
19342011
300,
@@ -1960,6 +2037,7 @@ mod tests {
19602037
"sandbox-id",
19612038
"sandbox-name",
19622039
"https://gateway.example.com",
2040+
&[],
19632041
"0.0.0.0:2222",
19642042
"secret",
19652043
300,
@@ -1986,6 +2064,7 @@ mod tests {
19862064
"sandbox-id",
19872065
"sandbox-name",
19882066
"https://gateway.example.com",
2067+
&[],
19892068
"0.0.0.0:2222",
19902069
"secret",
19912070
300,
@@ -2126,6 +2205,7 @@ mod tests {
21262205
"sandbox-id",
21272206
"sandbox-name",
21282207
"https://gateway.example.com",
2208+
&[],
21292209
"0.0.0.0:2222",
21302210
"secret",
21312211
300,

proto/datamodel.proto

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ message SandboxSpec {
3333
repeated string providers = 8;
3434
// Request NVIDIA GPU resources for this sandbox.
3535
bool gpu = 9;
36+
// User-provided startup command. Persisted so it is re-executed when the
37+
// sandbox pod is recreated (e.g. after gateway stop/start).
38+
repeated string command = 10;
3639
}
3740

3841
// Sandbox template mapped onto Kubernetes pod template inputs.

0 commit comments

Comments
 (0)