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
52 changes: 10 additions & 42 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1936,7 +1936,7 @@ pub async fn sandbox_create(
policy: Option<&str>,
forward: Option<openshell_core::forward::ForwardSpec>,
command: &[String],
tty_override: Option<bool>,
_tty_override: Option<bool>,
bootstrap_override: Option<bool>,
auto_providers_override: Option<bool>,
tls: &TlsOptions,
Expand Down Expand Up @@ -2038,6 +2038,7 @@ pub async fn sandbox_create(
policy,
providers: configured_providers,
template,
command: command.to_vec(),
..SandboxSpec::default()
}),
name: name.unwrap_or_default().to_string(),
Expand Down Expand Up @@ -2374,49 +2375,16 @@ pub async fn sandbox_create(
return Ok(());
}

if command.is_empty() {
let connect_result = if persist {
sandbox_connect(&effective_server, &sandbox_name, &effective_tls).await
} else {
crate::ssh::sandbox_connect_without_exec(
&effective_server,
&sandbox_name,
&effective_tls,
)
.await
};

return finalize_sandbox_create_session(
&effective_server,
&sandbox_name,
persist,
connect_result,
&effective_tls,
gateway_name,
)
.await;
}

// Resolve TTY mode: explicit --tty / --no-tty wins, otherwise
// auto-detect from the local terminal.
let tty = tty_override.unwrap_or_else(|| {
std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
});
let exec_result = if persist {
sandbox_exec(
&effective_server,
&sandbox_name,
command,
tty,
&effective_tls,
)
.await
// When a command is provided it is persisted in the sandbox spec
// and runs as the entrypoint via OPENSHELL_SANDBOX_COMMAND. We
// always open an interactive shell here so the user can inspect
// the sandbox without executing the command a second time.
let connect_result = if persist {
sandbox_connect(&effective_server, &sandbox_name, &effective_tls).await
} else {
crate::ssh::sandbox_exec_without_exec(
crate::ssh::sandbox_connect_without_exec(
&effective_server,
&sandbox_name,
command,
tty,
&effective_tls,
)
.await
Expand All @@ -2426,7 +2394,7 @@ pub async fn sandbox_create(
&effective_server,
&sandbox_name,
persist,
exec_result,
connect_result,
&effective_tls,
gateway_name,
)
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-cli/src/ssh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,7 @@ pub async fn sandbox_exec(
sandbox_exec_with_mode(server, name, command, tty, tls, true).await
}

#[allow(dead_code)]
pub(crate) async fn sandbox_exec_without_exec(
server: &str,
name: &str,
Expand Down
82 changes: 81 additions & 1 deletion crates/openshell-server/src/sandbox/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,7 @@ fn sandbox_to_k8s_spec(
sandbox_id,
sandbox_name,
grpc_endpoint,
&spec.command,
ssh_listen_addr,
ssh_handshake_secret,
ssh_handshake_skew_secs,
Expand Down Expand Up @@ -953,7 +954,11 @@ fn sandbox_to_k8s_spec(
// podTemplate is required by the Kubernetes CRD - ensure it's always present
if !root.contains_key("podTemplate") {
let empty_env = std::collections::HashMap::new();
let empty_cmd: Vec<String> = Vec::new();
let spec_env = spec.as_ref().map_or(&empty_env, |s| &s.environment);
let spec_cmd = spec
.as_ref()
.map_or(empty_cmd.as_slice(), |s| s.command.as_slice());
root.insert(
"podTemplate".to_string(),
sandbox_template_to_k8s(
Expand All @@ -964,6 +969,7 @@ fn sandbox_to_k8s_spec(
sandbox_id,
sandbox_name,
grpc_endpoint,
spec_cmd,
ssh_listen_addr,
ssh_handshake_secret,
ssh_handshake_skew_secs,
Expand All @@ -989,6 +995,7 @@ fn sandbox_template_to_k8s(
sandbox_id: &str,
sandbox_name: &str,
grpc_endpoint: &str,
sandbox_command: &[String],
ssh_listen_addr: &str,
ssh_handshake_secret: &str,
ssh_handshake_skew_secs: u64,
Expand Down Expand Up @@ -1045,6 +1052,7 @@ fn sandbox_template_to_k8s(
sandbox_id,
sandbox_name,
grpc_endpoint,
sandbox_command,
ssh_listen_addr,
ssh_handshake_secret,
ssh_handshake_skew_secs,
Expand Down Expand Up @@ -1176,6 +1184,7 @@ fn build_env_list(
sandbox_id: &str,
sandbox_name: &str,
grpc_endpoint: &str,
sandbox_command: &[String],
ssh_listen_addr: &str,
ssh_handshake_secret: &str,
ssh_handshake_skew_secs: u64,
Expand All @@ -1189,6 +1198,7 @@ fn build_env_list(
sandbox_id,
sandbox_name,
grpc_endpoint,
sandbox_command,
ssh_listen_addr,
ssh_handshake_secret,
ssh_handshake_skew_secs,
Expand All @@ -1211,6 +1221,7 @@ fn apply_required_env(
sandbox_id: &str,
sandbox_name: &str,
grpc_endpoint: &str,
sandbox_command: &[String],
ssh_listen_addr: &str,
ssh_handshake_secret: &str,
ssh_handshake_skew_secs: u64,
Expand All @@ -1219,7 +1230,14 @@ fn apply_required_env(
upsert_env(env, "OPENSHELL_SANDBOX_ID", sandbox_id);
upsert_env(env, "OPENSHELL_SANDBOX", sandbox_name);
upsert_env(env, "OPENSHELL_ENDPOINT", grpc_endpoint);
upsert_env(env, "OPENSHELL_SANDBOX_COMMAND", "sleep infinity");
// Use the user-provided command if present, otherwise fall back to
// `sleep infinity` so the sandbox pod stays alive for interactive SSH.
let command_value = if sandbox_command.is_empty() {
"sleep infinity".to_string()
} else {
sandbox_command.join(" ")
};
upsert_env(env, "OPENSHELL_SANDBOX_COMMAND", &command_value);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we run a risk of accidentally mangling the command when we retrieve it for execution and split on whitespace?

i.e. "python -c print('hello world')". => ["python", "-c", "print('hello", "world')"] due to

} else if let Ok(c) = std::env::var("OPENSHELL_SANDBOX_COMMAND") {
    // Simple shell-like splitting on whitespace
    c.split_whitespace().map(String::from).collect()

We basically only had sleep infinity as the sandbox entrypoint I think?

if !ssh_listen_addr.is_empty() {
upsert_env(env, "OPENSHELL_SSH_LISTEN_ADDR", ssh_listen_addr);
}
Expand Down Expand Up @@ -1617,6 +1635,7 @@ mod tests {
"sandbox-1",
"my-sandbox",
"https://endpoint:8080",
&[],
"0.0.0.0:2222",
"my-secret-value",
300,
Expand All @@ -1635,6 +1654,58 @@ mod tests {
);
}

#[test]
fn apply_required_env_uses_sleep_infinity_when_no_command() {
let mut env = Vec::new();
apply_required_env(
&mut env,
"sandbox-1",
"my-sandbox",
"https://endpoint:8080",
&[],
"0.0.0.0:2222",
"secret",
300,
false,
);

let cmd_entry = env
.iter()
.find(|e| e.get("name").and_then(|v| v.as_str()) == Some("OPENSHELL_SANDBOX_COMMAND"))
.expect("OPENSHELL_SANDBOX_COMMAND must be present in env");
assert_eq!(
cmd_entry.get("value").and_then(|v| v.as_str()),
Some("sleep infinity"),
"default sandbox command should be 'sleep infinity'"
);
}

#[test]
fn apply_required_env_uses_user_command_when_provided() {
let mut env = Vec::new();
apply_required_env(
&mut env,
"sandbox-1",
"my-sandbox",
"https://endpoint:8080",
&["python".to_string(), "app.py".to_string()],
"0.0.0.0:2222",
"secret",
300,
false,
);

let cmd_entry = env
.iter()
.find(|e| e.get("name").and_then(|v| v.as_str()) == Some("OPENSHELL_SANDBOX_COMMAND"))
.expect("OPENSHELL_SANDBOX_COMMAND must be present in env");
assert_eq!(
cmd_entry.get("value").and_then(|v| v.as_str()),
Some("python app.py"),
"sandbox command should reflect user-provided command"
);
}

#[test]
fn supervisor_sideload_injects_run_as_user_zero() {
let mut pod_template = serde_json::json!({
Expand Down Expand Up @@ -1747,6 +1818,7 @@ mod tests {
"sandbox-1",
"my-sandbox",
"https://endpoint:8080",
&[],
"0.0.0.0:2222",
"secret",
300,
Expand Down Expand Up @@ -1795,6 +1867,7 @@ mod tests {
"sandbox-id",
"sandbox-name",
"https://gateway.example.com",
&[],
"0.0.0.0:2222",
"secret",
300,
Expand Down Expand Up @@ -1829,6 +1902,7 @@ mod tests {
"sandbox-id",
"sandbox-name",
"https://gateway.example.com",
&[],
"0.0.0.0:2222",
"secret",
300,
Expand Down Expand Up @@ -1859,6 +1933,7 @@ mod tests {
"sandbox-id",
"sandbox-name",
"https://gateway.example.com",
&[],
"0.0.0.0:2222",
"secret",
300,
Expand Down Expand Up @@ -1902,6 +1977,7 @@ mod tests {
"sandbox-id",
"sandbox-name",
"https://gateway.example.com",
&[],
"0.0.0.0:2222",
"secret",
300,
Expand Down Expand Up @@ -1929,6 +2005,7 @@ mod tests {
"sandbox-id",
"sandbox-name",
"https://gateway.example.com",
&[],
"0.0.0.0:2222",
"secret",
300,
Expand Down Expand Up @@ -1960,6 +2037,7 @@ mod tests {
"sandbox-id",
"sandbox-name",
"https://gateway.example.com",
&[],
"0.0.0.0:2222",
"secret",
300,
Expand All @@ -1986,6 +2064,7 @@ mod tests {
"sandbox-id",
"sandbox-name",
"https://gateway.example.com",
&[],
"0.0.0.0:2222",
"secret",
300,
Expand Down Expand Up @@ -2126,6 +2205,7 @@ mod tests {
"sandbox-id",
"sandbox-name",
"https://gateway.example.com",
&[],
"0.0.0.0:2222",
"secret",
300,
Expand Down
3 changes: 3 additions & 0 deletions proto/datamodel.proto
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ message SandboxSpec {
repeated string providers = 8;
// Request NVIDIA GPU resources for this sandbox.
bool gpu = 9;
// User-provided startup command. Persisted so it is re-executed when the
// sandbox pod is recreated (e.g. after gateway stop/start).
repeated string command = 10;
}

// Sandbox template mapped onto Kubernetes pod template inputs.
Expand Down
Loading