diff --git a/.agents/skills/openshell-cli/SKILL.md b/.agents/skills/openshell-cli/SKILL.md index 8aae80fd0a..98f55a4b5a 100644 --- a/.agents/skills/openshell-cli/SKILL.md +++ b/.agents/skills/openshell-cli/SKILL.md @@ -70,7 +70,9 @@ The simplest way to get a sandbox running: openshell sandbox create ``` -This creates a sandbox with defaults and drops you into an interactive shell. +This creates a sandbox whose canonical main process is `/bin/bash -l` and +attaches your terminal to that retained process. Add `--detach` to return after +the sandbox becomes ready without attaching. 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. @@ -230,13 +232,19 @@ Key flags: - `--driver-config-json`: Pass experimental driver-specific sandbox configuration - `--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 - `--approval-mode manual|auto`: Control handling of agent-authored policy proposals; `manual` is the default - `--upload [:]`: Upload local files into the container working directory or an explicit destination - `--no-git-ignore`: Disable `.gitignore` filtering for uploads - `--no-keep`: Delete the sandbox after the initial command or shell exits +- `--detach`: Start the canonical main process without attaching - `--forward [BIND_ADDRESS:]PORT`: Forward a local port and keep the sandbox alive - `--editor vscode|cursor`: Open a remote editor after creation and keep the sandbox alive +Do not combine `--upload` with a trailing main command. Uploads currently finish +after the canonical process starts; create a scratch sandbox and use +`sandbox exec`, or build the files into the image. + ### List and inspect sandboxes ```bash @@ -254,7 +262,12 @@ openshell sandbox connect my-sandbox openshell sandbox connect my-sandbox --editor vscode ``` -Opens an interactive SSH shell. To configure VS Code Remote-SSH: +Attaches to the sandbox's existing canonical main process. Disconnecting leaves +that process running; reconnecting targets the same process instance and replays +recent output. Use `sandbox exec --tty -- /bin/bash -l` for a new shell. To +For a TTY main process, `Ctrl-C` interrupts a foreground job; when the canonical +main process owns the terminal foreground, `Ctrl-C` disconnects the attachment +without terminating main. Configure VS Code Remote-SSH with: ```bash openshell sandbox ssh-config my-sandbox >> ~/.ssh/config @@ -287,7 +300,9 @@ openshell sandbox exec --name my-sandbox --workdir /workspace -- ls -la openshell sandbox exec --name my-sandbox --env MODE=test -- cargo test ``` -`sandbox exec` streams output and exits with the remote command's exit code. Use `sandbox connect` for an interactive shell. +`sandbox exec` starts an independent sibling process, streams output, and exits +with the remote command's exit code. Use `sandbox connect` to attach to the +canonical main process. Use `--env` only for non-secret values. Attach credentials to the sandbox with a provider instead of passing API keys, tokens, or other secrets to `sandbox exec`. diff --git a/.agents/skills/openshell-cli/cli-reference.md b/.agents/skills/openshell-cli/cli-reference.md index 2cd5881ab9..d9d143b7a7 100644 --- a/.agents/skills/openshell-cli/cli-reference.md +++ b/.agents/skills/openshell-cli/cli-reference.md @@ -204,13 +204,17 @@ identity provider. Requires an authenticated gateway connection. ### `openshell sandbox create [OPTIONS] [-- COMMAND...]` -Create a sandbox through the selected gateway, wait for readiness, then connect, open an editor, or execute the trailing command. +Create a sandbox through the selected gateway and launch its canonical main +process. By default, the CLI attaches to that retained process after the +sandbox becomes ready. A trailing command defines the canonical main process; +without one, the default is `/bin/bash -l` with a PTY. | Flag | Description | |------|-------------| | `--name ` | Sandbox name (auto-generated if omitted) | | `--from ` | 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 | | `--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 ` | CPU limit (for example: `500m`, `1`, `2.5`) | @@ -227,7 +231,12 @@ Create a sandbox through the selected gateway, wait for readiness, then connect, | `--approval-mode manual|auto` | Handle agent-authored policy proposals; default: `manual` | | `--upload [:]` | Upload local files to the working directory or an explicit destination (repeatable) | | `--no-git-ignore` | Disable `.gitignore` filtering for `--upload` | -| `[-- COMMAND...]` | Initial command (defaults to an interactive shell) | +| `[-- COMMAND...]` | Canonical main command (defaults to `/bin/bash -l`) | + +`--upload` cannot be combined with a trailing main command because uploads +currently complete after the canonical process starts. Create the default +scratch sandbox, upload files, then use `sandbox exec`, or build the files into +the image. ### `openshell sandbox get [name]` @@ -277,7 +286,13 @@ Execute a command through the gRPC exec endpoint, stream its output, and exit wi ### `openshell sandbox connect [name]` -Open an interactive SSH shell. The name defaults to the last-used sandbox. `--editor vscode|cursor` launches a supported remote editor instead. +Attach to the sandbox's retained canonical main process. Disconnecting leaves +the process running. Reconnecting targets the same process instance and +replays recent output. Use `sandbox exec --tty -- /bin/bash -l` when you need a +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. ### `openshell sandbox upload [dest]` diff --git a/Cargo.lock b/Cargo.lock index c30f890914..ce8b9ac6ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4057,6 +4057,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "uuid", ] [[package]] @@ -4275,6 +4276,7 @@ version = "0.0.0" dependencies = [ "anyhow", "base64 0.22.1", + "bytes", "capctl", "hex", "landlock", diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index 9c91df496e..6fa0416bfe 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -13,6 +13,9 @@ Each runtime receives a sandbox spec from the gateway and is responsible for: - Injecting sandbox identity and gateway callback configuration. - Supplying TLS or secret material for supervisor callbacks. - Providing the supervisor binary or image in the workload. +- Forwarding the exact canonical main-process argv and TTY mode without shell + reconstruction. The sandbox-level environment and policy workspace apply to + the main process. - Reporting lifecycle and platform events back to the gateway. - Cleaning up runtime-owned resources. @@ -23,6 +26,10 @@ references to gateway-internal types. The gateway owns the public `SandboxPhase::Ready` decision. This applies equally to extension drivers implementing `ComputeDriver` out of tree. +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. + 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 diff --git a/architecture/gateway.md b/architecture/gateway.md index 0c428526d8..32bca6a1f6 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -15,11 +15,19 @@ 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. 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 identity. +The live supervisor session is the readiness authority for its main-process +instance. The supervisor reports its normalized result through the +sandbox-authenticated `ReportMainProcessExit` RPC, and the gateway rejects +results from stale instance IDs. + ## Protocol and Auth The gateway listens on one service port and multiplexes gRPC and HTTP traffic. diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 698f88a80a..c966f5371e 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -29,10 +29,11 @@ only when the set is already empty; any other outcome fails the spawn. gateway, depending on mode. 3. It prepares filesystem access, process restrictions, network namespace routing, trust stores, provider credential resolution, and inference routes. -4. It starts the policy proxy and local SSH server. -5. It opens a supervisor session back to the gateway for connect, exec, file +4. It launches the persisted canonical main-process argv and retains its PTY + or pipes in the main-session multiplexer. +5. It starts the policy proxy and local SSH server. +6. It opens a supervisor session back to the gateway for connect, exec, file sync, config polling, and log push. -6. It launches the agent command as the resolved restricted identity. ## Isolation Layers @@ -326,8 +327,10 @@ The supervisor runs an SSH server on a Unix socket inside the sandbox. The gateway reaches it through the outbound supervisor relay, not by dialing the sandbox workload directly. The relay supports: -- Interactive shell sessions. -- Command execution. +- Attachment to the canonical main process through the `openshell-main` SSH + subsystem. The supervisor owns its retained PTY or pipes, a 1 MiB replay + buffer, and a single stdin lease across client disconnects. +- Independent shell and command execution sessions. - Tar-based file sync. - Port forwarding where supported by the CLI/TUI surface. @@ -403,3 +406,7 @@ 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. diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index fc7728c15d..54394344d4 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1351,7 +1351,12 @@ enum SandboxCommands { /// working directory. /// `.gitignore` rules are applied by default; use `--no-git-ignore` to /// upload everything. - #[arg(long, value_hint = ValueHint::AnyPath, help_heading = "UPLOAD FLAGS")] + #[arg( + long, + value_hint = ValueHint::AnyPath, + help_heading = "UPLOAD FLAGS", + conflicts_with = "command" + )] upload: Vec, /// Disable `.gitignore` filtering for `--upload`. @@ -1421,6 +1426,10 @@ enum SandboxCommands { #[arg(long, overrides_with = "tty")] no_tty: bool, + /// Start the canonical main process without attaching to it. + #[arg(long, conflicts_with_all = ["editor", "no_keep"])] + detach: bool, + /// Auto-create missing providers from local credentials. /// /// Without this flag, an interactive prompt asks per-provider; @@ -1594,6 +1603,8 @@ enum SandboxCommands { /// Connect to a sandbox. /// /// When no name is given, reconnects to the last-used sandbox. + /// For a TTY main process, Ctrl-C interrupts a foreground job or disconnects + /// when the canonical main process owns the terminal foreground. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Connect { /// Sandbox name (defaults to last-used sandbox). @@ -2980,6 +2991,7 @@ async fn run_async() -> Result<()> { forward, tty, no_tty, + detach, auto_providers, no_auto_providers, labels, @@ -3075,6 +3087,7 @@ async fn run_async() -> Result<()> { environment: env_map, approval_mode: &approval_mode, output: output.as_str(), + detach, }, &cli.workspace, &tls, @@ -5128,6 +5141,55 @@ mod tests { } } + #[test] + fn sandbox_create_detach_parses_with_main_command() { + let cli = Cli::try_parse_from([ + "openshell", + "sandbox", + "create", + "--detach", + "--", + "worker", + "--serve", + ]) + .expect("sandbox create --detach should parse"); + + match cli.command { + Some(Commands::Sandbox { + command: + Some(SandboxCommands::Create { + detach, command, .. + }), + .. + }) => { + assert!(detach); + assert_eq!(command, ["worker", "--serve"]); + } + other => panic!("expected SandboxCommands::Create, got: {other:?}"), + } + } + + #[test] + fn sandbox_create_detach_rejects_ephemeral_sandbox() { + let result = + Cli::try_parse_from(["openshell", "sandbox", "create", "--detach", "--no-keep"]); + assert!(result.is_err()); + } + + #[test] + fn sandbox_create_rejects_upload_with_main_command() { + let result = Cli::try_parse_from([ + "openshell", + "sandbox", + "create", + "--upload", + ".", + "--", + "./run-uploaded-app", + ]); + assert!(result.is_err()); + } + /// `sandbox create` defaults `--approval-mode` to `"manual"`. The CLI /// always sends an explicit value so the wire form is human-readable /// (the gateway treats `""` as `"manual"` too, but the CLI's job is to diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 47fc268080..fd0e585068 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -383,6 +383,7 @@ pub struct SandboxCreateConfig<'a> { pub environment: HashMap, pub approval_mode: &'a str, pub output: &'a str, + pub detach: bool, } impl Default for SandboxCreateConfig<'_> { @@ -407,6 +408,7 @@ impl Default for SandboxCreateConfig<'_> { environment: HashMap::new(), approval_mode: "manual", output: "table", + detach: false, } } } @@ -439,6 +441,7 @@ pub async fn sandbox_create( environment, approval_mode, output, + detach, } = config; if editor.is_some() && !command.is_empty() { @@ -446,6 +449,11 @@ pub async fn sandbox_create( "--editor cannot be used with a trailing command; use `openshell sandbox connect --editor ...` after the sandbox is ready" )); } + if !uploads.is_empty() && !command.is_empty() { + return Err(miette::miette!( + "--upload cannot be combined with a trailing main command yet because uploads complete after the canonical process starts" + )); + } // Check port availability *before* creating the sandbox so we don't // leave an orphaned sandbox behind when the forward would fail. @@ -521,6 +529,13 @@ pub async fn sandbox_create( let resource_requirements = gpu_requirements.map(|gpu| ResourceRequirements { gpu: Some(gpu) }); + let main_terminal = tty_override + .unwrap_or_else(|| std::io::stdin().is_terminal() && std::io::stdout().is_terminal()); + let main_command = if command.is_empty() { + vec!["/bin/bash".to_string(), "-l".to_string()] + } else { + command.to_vec() + }; let request = CreateSandboxRequest { spec: Some(SandboxSpec { resource_requirements, @@ -528,6 +543,8 @@ pub async fn sandbox_create( policy, providers: configured_providers, template, + command: main_command, + tty: main_terminal, ..SandboxSpec::default() }), name: name.unwrap_or_default().to_string(), @@ -939,53 +956,23 @@ pub async fn sandbox_create( return Ok(()); } - if command.is_empty() { - let connect_result = if persist { - sandbox_connect(&effective_server, &sandbox_name, &effective_tls, workspace) - .await - } else { - crate::ssh::sandbox_connect_without_exec( - &effective_server, - &sandbox_name, - &effective_tls, - workspace, - ) - .await - }; - - return finalize_sandbox_create_session( - &effective_server, - &sandbox_name, - persist, - connect_result, - workspace, - &effective_tls, - gateway_name, - ) - .await; + // Persistent non-interactive creates detach implicitly. An + // explicitly ephemeral (`--no-keep`) create must still attach so + // it can observe the canonical process and delete the sandbox when + // that session ends. + if detach + || (persist + && (!std::io::stdin().is_terminal() || !std::io::stdout().is_terminal())) + { + return Ok(()); } - // 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, - workspace, - ) - .await + let connect_result = if persist { + sandbox_connect(&effective_server, &sandbox_name, &effective_tls, workspace).await } else { - crate::ssh::sandbox_exec_without_exec( + crate::ssh::sandbox_connect_without_exec( &effective_server, &sandbox_name, - command, - tty, &effective_tls, workspace, ) @@ -996,7 +983,7 @@ pub async fn sandbox_create( &effective_server, &sandbox_name, persist, - exec_result, + connect_result, workspace, &effective_tls, gateway_name, @@ -1004,7 +991,9 @@ pub async fn sandbox_create( .await } SandboxPhase::Error => { - if last_error_reason.is_empty() { + drop(stream); + drop(client); + let create_result = if last_error_reason.is_empty() { Err(miette::miette!( "sandbox entered error phase while provisioning" )) @@ -1013,7 +1002,17 @@ pub async fn sandbox_create( "sandbox entered error phase while provisioning: {}", last_error_reason )) - } + }; + finalize_sandbox_create_session( + &effective_server, + &sandbox_name, + persist, + create_result, + workspace, + &effective_tls, + gateway_name, + ) + .await } _ => Err(miette::miette!( "sandbox provisioning stream ended before reaching terminal phase" diff --git a/crates/openshell-cli/src/ssh.rs b/crates/openshell-cli/src/ssh.rs index 7e5f1e7ee1..4768dc27f2 100644 --- a/crates/openshell-cli/src/ssh.rs +++ b/crates/openshell-cli/src/ssh.rs @@ -71,6 +71,7 @@ struct SshSessionConfig { sandbox_id: String, gateway_url: String, token: String, + main_terminal: bool, } async fn ssh_session_config( @@ -139,6 +140,7 @@ async fn ssh_session_config( sandbox_id: session.sandbox_id.clone(), gateway_url, token: session.token, + main_terminal: sandbox.spec.as_ref().is_none_or(|spec| spec.tty), }) } @@ -263,13 +265,17 @@ async fn sandbox_connect_with_mode( let session = ssh_session_config(server, name, tls, workspace).await?; let mut command = ssh_base_command(&session.proxy_command); + if session.main_terminal { + command.arg("-tt").arg("-o").arg("RequestTTY=force"); + } else { + command.arg("-T"); + } command - .arg("-tt") - .arg("-o") - .arg("RequestTTY=force") .arg("-o") .arg("SetEnv=TERM=xterm-256color") + .arg("-s") .arg("sandbox") + .arg("openshell-main") .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()); @@ -596,17 +602,6 @@ pub async fn sandbox_exec( sandbox_exec_with_mode(server, name, command, tty, tls, true, workspace).await } -pub(crate) async fn sandbox_exec_without_exec( - server: &str, - name: &str, - command: &[String], - tty: bool, - tls: &TlsOptions, - workspace: &str, -) -> Result<()> { - sandbox_exec_with_mode(server, name, command, tty, tls, false, workspace).await -} - /// What to pack into the tar archive streamed to the sandbox. enum UploadSource { /// A single local file or directory. `tar_name` controls the entry name diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 3d628f2c10..831f9912bb 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -80,6 +80,13 @@ impl TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn report_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn get_current_user( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs index 60ffbd61f8..1a2a159b56 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -33,6 +33,13 @@ struct TestOpenShell; #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn report_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn get_current_user( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index a87ff0a6d8..f810c52d54 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -98,6 +98,13 @@ struct TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn report_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn get_current_user( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 102cde3714..c7ac19ce93 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -60,6 +60,13 @@ struct TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn report_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn get_current_user( &self, _request: tonic::Request, @@ -1290,6 +1297,44 @@ async fn sandbox_create_sends_cpu_and_memory_limits_only() { assert!(!resources.fields.contains_key("requests")); } +#[tokio::test] +async fn sandbox_create_persists_exact_trailing_argv_as_main_process() { + let server = run_server().await; + let fake_ssh_dir = tempfile::tempdir().unwrap(); + let xdg_dir = tempfile::tempdir().unwrap(); + let _env = test_env(&fake_ssh_dir, &xdg_dir); + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + let command = vec![ + "/opt/agent binary".to_string(), + "--prompt=keep spaces".to_string(), + "literal * $HOME".to_string(), + ]; + + run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("canonical-main"), + command: &command, + tty_override: Some(false), + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("sandbox create should succeed"); + + let requests = create_requests(&server).await; + let spec = requests[0] + .spec + .as_ref() + .expect("sandbox spec should be persisted at create time"); + assert_eq!(spec.command, command); + assert!(!spec.tty); +} + #[tokio::test] async fn sandbox_create_sends_driver_config_json() { let server = run_server().await; diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index 41b93bab82..acc2909972 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -48,6 +48,13 @@ struct TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn report_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn get_current_user( &self, _request: tonic::Request, diff --git a/crates/openshell-core/src/sandbox_env.rs b/crates/openshell-core/src/sandbox_env.rs index 1549258fa3..7f1b968b1f 100644 --- a/crates/openshell-core/src/sandbox_env.rs +++ b/crates/openshell-core/src/sandbox_env.rs @@ -8,6 +8,8 @@ //! supervisor process (which reads them on startup). Using constants here //! prevents typos from producing silently broken sandboxes. +use serde::{Deserialize, Serialize}; + /// Name of the sandbox (used for policy sync and identification). pub const SANDBOX: &str = "OPENSHELL_SANDBOX"; @@ -23,8 +25,64 @@ pub const SSH_SOCKET_PATH: &str = "OPENSHELL_SSH_SOCKET_PATH"; /// Log level for the sandbox supervisor (e.g. `"debug"`, `"info"`, `"warn"`). pub const LOG_LEVEL: &str = "OPENSHELL_LOG_LEVEL"; -/// Shell command to run inside the sandbox. -pub const SANDBOX_COMMAND: &str = "OPENSHELL_SANDBOX_COMMAND"; +/// Versioned JSON specification for the exact canonical main process. +pub const MAIN_PROCESS_SPEC: &str = "OPENSHELL_MAIN_PROCESS_SPEC"; + +/// Lossless driver-to-supervisor representation of the canonical process. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct MainProcessConfig { + pub version: u32, + pub command: Vec, + pub tty: bool, +} + +impl MainProcessConfig { + pub const VERSION: u32 = 1; + + #[must_use] + pub fn scratch() -> Self { + Self { + version: Self::VERSION, + command: vec!["/bin/bash".to_string(), "-l".to_string()], + tty: true, + } + } + + #[must_use] + pub fn from_driver_spec(spec: Option<&crate::proto::compute::v1::DriverSandboxSpec>) -> Self { + match spec { + Some(spec) if !spec.command.is_empty() => Self { + version: Self::VERSION, + command: spec.command.clone(), + tty: spec.tty, + }, + None | Some(_) => Self::scratch(), + } + } + + /// Decode the versioned transport without shell interpretation. + pub fn decode(json: &str) -> Result { + let config: Self = serde_json::from_str(json) + .map_err(|error| format!("invalid {MAIN_PROCESS_SPEC}: {error}"))?; + if config.version != Self::VERSION { + return Err(format!( + "unsupported {MAIN_PROCESS_SPEC} version {}", + config.version + )); + } + if config.command.is_empty() || config.command[0].is_empty() { + return Err(format!("{MAIN_PROCESS_SPEC} command must not be empty")); + } + Ok(config) + } + + /// Encode the versioned driver-to-supervisor transport. + pub fn encode_driver_spec( + spec: Option<&crate::proto::compute::v1::DriverSandboxSpec>, + ) -> Result { + serde_json::to_string(&Self::from_driver_spec(spec)) + } +} /// Deployment-controlled telemetry toggle propagated to the sandbox supervisor. pub const TELEMETRY_ENABLED: &str = "OPENSHELL_TELEMETRY_ENABLED"; @@ -130,3 +188,39 @@ pub const OCI_IMAGE_USER: &str = "OPENSHELL_OCI_IMAGE_USER"; // environment variables: it travels on the supervisor's argv // (`--upstream-proxy` and friends), which a sandbox image cannot forge the // way it could bake `ENV` values. + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn main_process_transport_preserves_argument_boundaries() { + let spec = crate::proto::compute::v1::DriverSandboxSpec { + command: vec!["/bin/sh".into(), "-c".into(), "printf '%s' 'a b'".into()], + tty: false, + ..Default::default() + }; + let encoded = MainProcessConfig::encode_driver_spec(Some(&spec)).unwrap(); + let decoded = MainProcessConfig::decode(&encoded).unwrap(); + assert_eq!(decoded.command, spec.command); + assert!(!decoded.tty); + } + + #[test] + fn main_process_transport_rejects_unknown_version() { + let error = + MainProcessConfig::decode(r#"{"version":2,"command":["/bin/true"],"tty":false}"#) + .unwrap_err(); + assert!(error.contains("unsupported")); + } + + #[test] + fn legacy_driver_spec_without_command_uses_scratch_main() { + let legacy = crate::proto::compute::v1::DriverSandboxSpec::default(); + let config = MainProcessConfig::from_driver_spec(Some(&legacy)); + + assert_eq!(config, MainProcessConfig::scratch()); + let encoded = serde_json::to_string(&config).unwrap(); + assert_eq!(MainProcessConfig::decode(&encoded).unwrap(), config); + } +} diff --git a/crates/openshell-driver-docker/README.md b/crates/openshell-driver-docker/README.md index 48e56de7bc..915dbf90a7 100644 --- a/crates/openshell-driver-docker/README.md +++ b/crates/openshell-driver-docker/README.md @@ -83,7 +83,7 @@ contract: | `network_mode = openshell` | Places the supervisor on the managed Docker bridge network. | | `cap_add` | Grants supervisor-only capabilities required for namespace setup and process inspection. | | `apparmor=unconfined` | Avoids Docker's default profile blocking required mount operations. | -| `restart_policy = unless-stopped` | Keeps managed sandboxes resumable across daemon or gateway restarts. | +| `restart_policy = no` | A canonical main-process exit remains terminal and is not silently restarted by Docker. | | `PidsLimit` | Enforces the sandbox PID budget at the Docker cgroup layer. Set `[openshell.drivers.docker].sandbox_pids_limit = 0` to inherit the Docker/runtime default. | | CDI GPU request | Uses opaque `driver_config.cdi_devices` values when set; otherwise selects the requested count of NVIDIA CDI GPUs in round-robin order when daemon CDI support is detected. Docker daemon `/info` can permit `nvidia.com/gpu=all` as a WSL2 all-only compatibility fallback, where it counts as one selectable device. Exact CDI device lists must not contain duplicates and must match the effective GPU count. | @@ -175,7 +175,7 @@ overwrites security-critical keys: - `OPENSHELL_SANDBOX_ID` - `OPENSHELL_SANDBOX` - `OPENSHELL_SSH_SOCKET_PATH` -- `OPENSHELL_SANDBOX_COMMAND` +- `OPENSHELL_MAIN_PROCESS_SPEC` - TLS path variables when HTTPS is enabled Do not allow sandbox images or templates to override these values. diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index b1fb5ec224..7ccf3c7693 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -11,7 +11,7 @@ use bollard::models::{ ContainerCreateBody, ContainerState, ContainerStateStatusEnum, ContainerSummary, ContainerSummaryStateEnum, CreateImageInfo, DeviceRequest, EndpointSettings, HostConfig, Mount, MountTmpfsOptions, MountTypeEnum, MountVolumeOptions, NetworkCreateRequest, NetworkingConfig, - ProgressDetail, RestartPolicy, RestartPolicyNameEnum, SystemInfo, + ProgressDetail, SystemInfo, }; use bollard::query_parameters::{ CreateContainerOptionsBuilder, CreateImageOptions, DownloadFromContainerOptionsBuilder, @@ -77,7 +77,6 @@ const TLS_CA_MOUNT_PATH: &str = openshell_core::driver_utils::TLS_CA_MOUNT_PATH; const TLS_CERT_MOUNT_PATH: &str = openshell_core::driver_utils::TLS_CERT_MOUNT_PATH; const TLS_KEY_MOUNT_PATH: &str = openshell_core::driver_utils::TLS_KEY_MOUNT_PATH; const SANDBOX_TOKEN_MOUNT_PATH: &str = openshell_core::driver_utils::SANDBOX_TOKEN_MOUNT_PATH; -const SANDBOX_COMMAND: &str = "sleep infinity"; const SUPERVISOR_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; const HOST_OPENSHELL_INTERNAL: &str = "host.openshell.internal"; const HOST_DOCKER_INTERNAL: &str = "host.docker.internal"; @@ -2448,9 +2447,12 @@ fn build_environment_for_oci_user( openshell_core::sandbox_env::SSH_SOCKET_PATH.to_string(), config.ssh_socket_path.clone(), ); + let main_process = + openshell_core::sandbox_env::MainProcessConfig::encode_driver_spec(sandbox.spec.as_ref()) + .expect("main process config serialization cannot fail"); environment.insert( - openshell_core::sandbox_env::SANDBOX_COMMAND.to_string(), - SANDBOX_COMMAND.to_string(), + openshell_core::sandbox_env::MAIN_PROCESS_SPEC.to_string(), + main_process, ); environment.insert( openshell_core::sandbox_env::TELEMETRY_ENABLED.to_string(), @@ -2696,10 +2698,9 @@ fn build_container_create_body_for_image( Some(binds) }, mounts: Some(user_mounts), - restart_policy: Some(RestartPolicy { - name: Some(RestartPolicyNameEnum::UNLESS_STOPPED), - maximum_retry_count: None, - }), + // Canonical main-process exit is terminal. Runtime restart would + // silently create a new process generation behind the gateway. + restart_policy: None, cap_add: Some(vec![ "SYS_ADMIN".to_string(), "NET_ADMIN".to_string(), diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index eddfd778bd..00b96baa76 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -45,6 +45,8 @@ fn test_sandbox() -> DriverSandbox { }), resource_requirements: None, sandbox_token: String::new(), + command: Vec::new(), + tty: false, }), status: None, workspace: String::new(), @@ -618,7 +620,17 @@ fn build_environment_sets_docker_tls_paths() { assert!(env.contains(&format!("OPENSHELL_TLS_KEY={TLS_KEY_MOUNT_PATH}"))); assert!(env.contains(&"TEMPLATE_ENV=template".to_string())); assert!(env.contains(&"SPEC_ENV=spec".to_string())); - assert!(env.contains(&"OPENSHELL_SANDBOX_COMMAND=sleep infinity".to_string())); + let encoded = env + .iter() + .find_map(|entry| { + entry + .strip_prefix("OPENSHELL_MAIN_PROCESS_SPEC=") + .map(str::to_string) + }) + .expect("main-process transport"); + let main = openshell_core::sandbox_env::MainProcessConfig::decode(&encoded).unwrap(); + assert_eq!(main.command, vec!["/bin/bash", "-l"]); + assert!(main.tty); } #[test] diff --git a/crates/openshell-driver-kubernetes/README.md b/crates/openshell-driver-kubernetes/README.md index ec37c4e67a..fac220c83b 100644 --- a/crates/openshell-driver-kubernetes/README.md +++ b/crates/openshell-driver-kubernetes/README.md @@ -93,6 +93,11 @@ bootstrap exchange. The gateway uses the supervisor relay for connect, exec, and file sync. Sandbox pods do not need direct external ingress for SSH. +The driver forwards the canonical main-process specification to the process +supervisor and sets pod `restartPolicy: Never`. Main-process environment +overrides stay local to that child; the sidecar bootstrap retains the unmodified +provider environment used by later exec, editor, and SFTP sessions. + ## Container Security Context The default `combined` supervisor topology grants the sandbox agent container diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index ddc7fe2a4f..56dbe744a4 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -3336,6 +3336,7 @@ fn sandbox_to_k8s_spec( template, driver_gpu_requirements(spec.resource_requirements.as_ref()), &pod_env, + Some(spec), &driver_config, inject_workspace, params, @@ -3369,6 +3370,7 @@ fn sandbox_to_k8s_spec( &SandboxTemplate::default(), driver_gpu_requirements(spec.and_then(|s| s.resource_requirements.as_ref())), &pod_env, + spec, &driver_config, inject_workspace, params, @@ -3396,6 +3398,7 @@ fn sandbox_template_to_k8s( template, gpu_requirements.as_ref(), spec_environment, + None, &driver_config, inject_workspace, params, @@ -3416,6 +3419,7 @@ fn sandbox_template_to_k8s_with_gpu_requirements( template, gpu_requirements, spec_environment, + None, &driver_config, inject_workspace, params, @@ -3426,6 +3430,7 @@ fn sandbox_template_to_k8s_with_validated_config( template: &SandboxTemplate, gpu_requirements: Option<&GpuResourceRequirements>, spec_environment: &std::collections::HashMap, + sandbox_spec: Option<&openshell_core::proto::compute::v1::DriverSandboxSpec>, driver_config: &KubernetesSandboxDriverConfig, inject_workspace: bool, params: &SandboxPodParams<'_>, @@ -3535,6 +3540,9 @@ fn sandbox_template_to_k8s_with_validated_config( "automountServiceAccountToken".to_string(), serde_json::json!(false), ); + // Do not let kubelet replace the canonical main-process generation after + // the supervisor exits. The gateway records that exit as terminal Error. + spec.insert("restartPolicy".to_string(), serde_json::json!("Never")); let mut container = serde_json::Map::new(); container.insert("name".to_string(), serde_json::json!("agent")); @@ -3559,6 +3567,7 @@ fn sandbox_template_to_k8s_with_validated_config( None, &template.environment, spec_environment, + sandbox_spec, params.sandbox_id, params.sandbox_name, params.grpc_endpoint, @@ -3928,6 +3937,7 @@ fn build_env_list( existing_env: Option<&Vec>, template_environment: &std::collections::HashMap, spec_environment: &std::collections::HashMap, + sandbox_spec: Option<&openshell_core::proto::compute::v1::DriverSandboxSpec>, sandbox_id: &str, sandbox_name: &str, grpc_endpoint: &str, @@ -3949,6 +3959,14 @@ fn build_env_list( &json, ); } + let main_process = + openshell_core::sandbox_env::MainProcessConfig::encode_driver_spec(sandbox_spec) + .expect("main process config serialization cannot fail"); + upsert_env( + &mut env, + openshell_core::sandbox_env::MAIN_PROCESS_SPEC, + &main_process, + ); apply_required_env( &mut env, sandbox_id, @@ -3984,11 +4002,6 @@ fn apply_required_env( upsert_env(env, openshell_core::sandbox_env::SANDBOX_ID, sandbox_id); upsert_env(env, openshell_core::sandbox_env::SANDBOX, sandbox_name); upsert_env(env, openshell_core::sandbox_env::ENDPOINT, grpc_endpoint); - upsert_env( - env, - openshell_core::sandbox_env::SANDBOX_COMMAND, - "sleep infinity", - ); upsert_env( env, openshell_core::sandbox_env::TELEMETRY_ENABLED, diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index ffd04132c9..0805a539a5 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -286,7 +286,7 @@ via sandbox templates: - `OPENSHELL_ENDPOINT` - `OPENSHELL_SSH_SOCKET_PATH` - `OPENSHELL_CONTAINER_IMAGE` -- `OPENSHELL_SANDBOX_COMMAND` +- `OPENSHELL_MAIN_PROCESS_SPEC` ## Sandbox Lifecycle diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index cae477618c..ea22a757b1 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -516,9 +516,11 @@ fn build_env( config.sandbox_ssh_socket_path.clone(), ); env.insert("OPENSHELL_CONTAINER_IMAGE".into(), image.to_string()); + let main_process = openshell_core::sandbox_env::MainProcessConfig::encode_driver_spec(spec) + .expect("main process config serialization cannot fail"); env.insert( - openshell_core::sandbox_env::SANDBOX_COMMAND.into(), - "sleep infinity".into(), + openshell_core::sandbox_env::MAIN_PROCESS_SPEC.into(), + main_process, ); env.insert( openshell_core::sandbox_env::TELEMETRY_ENABLED.into(), diff --git a/crates/openshell-driver-vm/README.md b/crates/openshell-driver-vm/README.md index 10ffac2ba1..c906b3b074 100644 --- a/crates/openshell-driver-vm/README.md +++ b/crates/openshell-driver-vm/README.md @@ -206,6 +206,10 @@ marked sandboxes without launching compute. Start removes the marker and uses the normal persisted restore path with the existing overlay. Delete removes the entire sandbox state directory, including a stop marker and overlay. +The driver records a terminal tombstone when the canonical main process exits. +Driver startup reports that sandbox as terminal instead of relaunching the VM, +even when the process exited successfully. + ## Logs and debugging Raise log verbosity for both processes: diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index 982d678065..ad6d1eecbd 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -169,6 +169,9 @@ const OVERLAY_TEMPLATE_CACHE_LAYOUT_VERSION: &str = "sandbox-overlay-ext4-v1"; const SANDBOX_OVERLAY_IMAGE: &str = "overlay.ext4"; const SANDBOX_REQUEST_FILE: &str = "sandbox.pb"; const SANDBOX_STOPPED_FILE: &str = "stopped"; +/// Durable tombstone preventing driver restart from relaunching a sandbox +/// whose canonical main process already terminated. +const MAIN_PROCESS_EXITED_FILE: &str = "main-process-exited"; const GUEST_IMAGE_CONFIG_DIR: &str = "openshell-image"; const GUEST_IMAGE_OCI_LAYOUT_DIR: &str = "oci"; const GUEST_IMAGE_OCI_REF: &str = "openshell"; @@ -1411,6 +1414,37 @@ impl VmDriver { continue; } + if tokio::fs::try_exists(state_dir.join(MAIN_PROCESS_EXITED_FILE)) + .await + .unwrap_or(false) + { + let snapshot = sandbox_snapshot( + &sandbox, + error_condition( + "ProcessExited", + "Canonical main process exited before VM driver restart", + ), + false, + ); + let mut registry = self.registry.lock().await; + registry.entry(sandbox.id.clone()).or_insert(SandboxRecord { + snapshot: snapshot.clone(), + state_dir: state_dir.clone(), + process: None, + provisioning_task: None, + gpu_bdf: None, + qemu_network_allocated: false, + deleting: false, + }); + drop(registry); + self.publish_snapshot(snapshot); + info!( + sandbox_id = %sandbox.id, + "vm driver: preserved terminal sandbox without restarting canonical process" + ); + continue; + } + self.restore_persisted_sandbox(sandbox, state_dir, false, &tracing::Span::current()) .await; } @@ -3164,6 +3198,25 @@ impl VmDriver { }; if let Some(status) = exit_status { + let state_dir = { + let registry = self.registry.lock().await; + registry + .get(&sandbox_id) + .map(|record| record.state_dir.clone()) + }; + if let Some(state_dir) = state_dir + && let Err(error) = write_private_file( + &state_dir.join(MAIN_PROCESS_EXITED_FILE), + b"terminal\n".to_vec(), + ) + .await + { + warn!( + sandbox_id = %sandbox_id, + %error, + "vm driver: failed to persist canonical-process exit tombstone" + ); + } let message = status.code().map_or_else( || "VM process exited".to_string(), |code| format!("VM process exited with status {code}"), @@ -4435,9 +4488,12 @@ fn build_guest_environment( openshell_core::sandbox_env::SSH_SOCKET_PATH.to_string(), GUEST_SSH_SOCKET_PATH.to_string(), ); + let main_process = + openshell_core::sandbox_env::MainProcessConfig::encode_driver_spec(sandbox.spec.as_ref()) + .expect("main process config serialization cannot fail"); environment.insert( - openshell_core::sandbox_env::SANDBOX_COMMAND.to_string(), - "tail -f /dev/null".to_string(), + openshell_core::sandbox_env::MAIN_PROCESS_SPEC.to_string(), + main_process, ); environment.insert( openshell_core::sandbox_env::LOG_LEVEL.to_string(), @@ -5909,6 +5965,45 @@ mod tests { } } + #[tokio::test] + async fn startup_does_not_restore_terminal_canonical_process() { + let temp = tempfile::tempdir().unwrap(); + let mut driver = test_driver_with_extensions(LifecycleExtensionRegistry::new()); + driver.config.state_dir = temp.path().to_path_buf(); + let sandbox = Sandbox { + id: "sb-terminal-main".to_string(), + name: "terminal-main".to_string(), + spec: Some(SandboxSpec { + template: Some(SandboxTemplate { + image: "unused-image".to_string(), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }; + let state_dir = temp.path().join("sandboxes").join(&sandbox.id); + tokio::fs::create_dir_all(&state_dir).await.unwrap(); + write_sandbox_request(&state_dir, &sandbox).await.unwrap(); + write_private_file( + &state_dir.join(MAIN_PROCESS_EXITED_FILE), + b"terminal\n".to_vec(), + ) + .await + .unwrap(); + + driver.restore_persisted_sandboxes().await; + + let registry = driver.registry.lock().await; + let record = registry.get(&sandbox.id).expect("terminal record"); + assert!(record.process.is_none()); + assert!(record.provisioning_task.is_none()); + let status = record.snapshot.status.as_ref().expect("terminal status"); + assert!(status.conditions.iter().any(|condition| { + condition.reason == "ProcessExited" && condition.status == "False" + })); + } + #[tokio::test] async fn background_provisioning_does_not_extend_the_rpc_span_lifetime() { let traced = TestTracing::new(); @@ -6960,6 +7055,40 @@ mod tests { assert!(config.validate_sandbox_identity().is_ok()); } + #[test] + fn persisted_legacy_sandbox_without_command_uses_scratch_main() { + let config = VmDriverConfig { + openshell_endpoint: "http://127.0.0.1:8080".to_string(), + ..Default::default() + }; + // Requests persisted before the canonical-main contract have a + // present DriverSandboxSpec but no command or tty fields. + let sandbox = Sandbox { + id: "legacy-sandbox".to_string(), + name: "legacy-sandbox".to_string(), + spec: Some(SandboxSpec::default()), + ..Default::default() + }; + + let env = build_guest_environment(&sandbox, &config, None); + let encoded = env + .iter() + .find_map(|entry| { + entry.strip_prefix(&format!( + "{}=", + openshell_core::sandbox_env::MAIN_PROCESS_SPEC + )) + }) + .expect("main process environment"); + let main = openshell_core::sandbox_env::MainProcessConfig::decode(encoded) + .expect("legacy persisted request should produce a valid main config"); + + assert_eq!( + main, + openshell_core::sandbox_env::MainProcessConfig::scratch() + ); + } + #[test] fn build_guest_environment_uses_token_file_without_raw_token_env() { let config = VmDriverConfig { diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index 70603524a8..c653db84dd 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -50,6 +50,7 @@ prost = { workspace = true } # Logging tracing = { workspace = true } +uuid = { workspace = true } tracing-subscriber = { workspace = true } tracing-appender = { workspace = true } diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index c1dbada149..caa9de9994 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -83,6 +83,7 @@ const SIDECAR_READY_TIMEOUT_SECS: u64 = 120; /// Returns an error if the command fails to start or encounters a fatal error. #[allow( clippy::too_many_arguments, + clippy::implicit_hasher, clippy::similar_names, clippy::fn_params_excessive_bools )] @@ -340,6 +341,10 @@ pub async fn run_sandbox( report_credential_gating_unavailable(); } + // Canonical-process overrides are deliberately applied only to the main + // child. Keep the provider snapshot pristine because Kubernetes forwards + // it to the process sidecar for later exec/editor/SFTP children. + // Shared agent-proposals feature flag. Seed from the same initial settings // snapshot that produced the policy so networking and process setup agree // before the poll loop starts reconciling later changes. @@ -348,6 +353,7 @@ pub async fn run_sandbox( let process_control_writer = process_control_connection .as_ref() .map(|connection| connection.writer.clone()); + let process_exit_ack = Arc::new(tokio::sync::Mutex::new(None)); let mut process_control_closed = None; if let Some(connection) = process_control_connection { process_control_closed = Some(connection.closed); @@ -355,6 +361,7 @@ pub async fn run_sandbox( connection.updates, provider_credentials.clone(), agent_proposals.clone(), + Arc::clone(&process_exit_ack), ); } @@ -505,12 +512,15 @@ pub async fn run_sandbox( sidecar_control_task = Some(connection_task); spawn_sidecar_entrypoint_handler( entrypoint_rx, - entrypoint_pid.clone(), - opa_engine.clone(), - retained_proto.clone(), - openshell_endpoint.clone(), - sandbox_id.clone(), - std::path::PathBuf::from(trusted_ssh_socket_path), + SidecarEntrypointHandler { + entrypoint_pid: entrypoint_pid.clone(), + opa_engine: opa_engine.clone(), + retained_proto: retained_proto.clone(), + openshell_endpoint: openshell_endpoint.clone(), + sandbox_id: sandbox_id.clone(), + trusted_ssh_socket_path: std::path::PathBuf::from(trusted_ssh_socket_path), + control_publisher: sidecar_control_publisher.clone(), + }, ); } @@ -704,6 +714,7 @@ pub async fn run_sandbox( } let process_policy = process_policy_for_topology(&policy, sidecar_network_enforcement)?; + let main_env = provider_env.clone(); let sidecar_bootstrap_ca_file_paths = sidecar_bootstrap.as_ref().and_then(|bootstrap| { bootstrap .proxy_ca_cert_path @@ -730,9 +741,10 @@ pub async fn run_sandbox( let (tx, rx) = tokio::sync::oneshot::channel(); tokio::spawn(async move { match rx.await { - Ok(pid) => { + Ok((pid, instance_id)) => { if let Err(err) = - sidecar_control::send_entrypoint_started(&writer, pid).await + sidecar_control::send_entrypoint_started(&writer, pid, instance_id) + .await { warn!(error = %err, "Failed to send sidecar entrypoint event"); } @@ -746,6 +758,35 @@ pub async fn run_sandbox( } else { None }; + let sidecar_exit_tx = + if process_uses_sidecar_control && let Some(writer) = process_control_writer.clone() { + let exit_ack = Arc::clone(&process_exit_ack); + let (tx, mut rx) = tokio::sync::mpsc::channel::< + openshell_supervisor_process::run::SidecarExitReport, + >(1); + tokio::spawn(async move { + while let Some((instance_id, exit_code, ack)) = rx.recv().await { + let (durable_tx, durable_rx) = tokio::sync::oneshot::channel(); + *exit_ack.lock().await = Some((instance_id.clone(), durable_tx)); + let result = match sidecar_control::send_main_process_exited( + &writer, + instance_id, + exit_code, + ) + .await + { + Ok(()) => durable_rx.await.map_err(|_| { + "sidecar durable exit acknowledgement closed".to_string() + }), + Err(error) => Err(error.to_string()), + }; + let _ = ack.send(result); + } + }); + Some(tx) + } else { + None + }; let process = openshell_supervisor_process::run::run_process( program, @@ -762,8 +803,9 @@ pub async fn run_sandbox( process_enforcement_mode, entrypoint_pid, entrypoint_started_tx, + sidecar_exit_tx, provider_credentials, - provider_env, + main_env, ca_file_paths, agent_proposals.clone(), #[cfg(target_os = "linux")] @@ -908,6 +950,9 @@ type LoadedPolicyBundle = ( LoadedPolicyOrigin, ); +type MainProcessExitAckWaiter = + Arc)>>>; + fn load_policy_from_sidecar_bootstrap( bootstrap: &sidecar_control::BootstrapData, ) -> Result { @@ -930,6 +975,7 @@ fn spawn_sidecar_control_update_watcher( mut updates: tokio::sync::mpsc::UnboundedReceiver, provider_credentials: ProviderCredentialState, agent_proposals: AgentProposals, + exit_ack: MainProcessExitAckWaiter, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { while let Some(update) = updates.recv().await { @@ -980,26 +1026,80 @@ fn spawn_sidecar_control_update_watcher( skills::install_static_skills, ); } + sidecar_control::ControlUpdate::MainProcessExitAck { instance_id } => { + let mut waiter = exit_ack.lock().await; + if waiter + .as_ref() + .is_some_and(|(expected, _)| expected == &instance_id) + && let Some((_, ack)) = waiter.take() + { + let _ = ack.send(()); + } + } } } }) } #[cfg(target_os = "linux")] -fn spawn_sidecar_entrypoint_handler( - mut entrypoint_rx: tokio::sync::mpsc::Receiver, +struct SidecarEntrypointHandler { entrypoint_pid: Arc, opa_engine: Option>, retained_proto: Option, openshell_endpoint: Option, sandbox_id: Option, trusted_ssh_socket_path: std::path::PathBuf, + control_publisher: Option, +} + +#[cfg(target_os = "linux")] +fn spawn_sidecar_entrypoint_handler( + mut entrypoint_rx: tokio::sync::mpsc::Receiver, + handler: SidecarEntrypointHandler, ) { tokio::spawn(async move { + let SidecarEntrypointHandler { + entrypoint_pid, + opa_engine, + retained_proto, + openshell_endpoint, + sandbox_id, + trusted_ssh_socket_path, + control_publisher, + } = handler; let mut session_started = false; let mut trusted_supervisor_pid = None; let terminating = Arc::new(AtomicBool::new(false)); while let Some(started) = entrypoint_rx.recv().await { + if let Some(exit_code) = started.exit_code { + terminating.store(true, Ordering::Release); + if let (Some(endpoint), Some(id)) = + (openshell_endpoint.as_ref(), sandbox_id.as_ref()) + { + let mut delay = Duration::from_millis(250); + loop { + match openshell_supervisor_process::supervisor_session::report_main_process_exit( + endpoint, + id, + &started.instance_id, + exit_code, + ) + .await + { + Ok(()) => break, + Err(error) => { + warn!(%error, "sidecar main-process exit report failed; retrying"); + tokio::time::sleep(delay).await; + delay = (delay * 2).min(Duration::from_secs(2)); + } + } + } + if let Some(publisher) = control_publisher.as_ref() { + publisher.publish_main_process_exit_ack(started.instance_id.clone()); + } + } + break; + } entrypoint_pid.store(started.pid, Ordering::Release); if started.start_session { info!( @@ -1048,6 +1148,7 @@ fn spawn_sidecar_entrypoint_handler( None, Some(supervisor_pid), Arc::clone(&terminating), + started.instance_id.clone(), ); session_started = true; info!("sidecar supervisor session task spawned"); @@ -4047,6 +4148,7 @@ mod tests { rx, provider_credentials.clone(), AgentProposals::default(), + Arc::new(tokio::sync::Mutex::new(None)), ); tx.send(sidecar_control::ControlUpdate::ProviderEnv { @@ -4101,8 +4203,12 @@ mod tests { let provider_credentials = ProviderCredentialState::from_child_env_snapshot(0, std::collections::HashMap::new()); let agent_proposals = AgentProposals::new(true); - let handle = - spawn_sidecar_control_update_watcher(rx, provider_credentials, agent_proposals.clone()); + let handle = spawn_sidecar_control_update_watcher( + rx, + provider_credentials, + agent_proposals.clone(), + Arc::new(tokio::sync::Mutex::new(None)), + ); tx.send(sidecar_control::ControlUpdate::AgentProposals { enabled: false, diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index a294f0a663..64e77ef600 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -111,8 +111,7 @@ impl std::str::FromStr for Mode { #[command(about = "Process sandbox and monitor", long_about = None)] struct Args { /// Command to execute in the sandbox. - /// Can also be provided via `OPENSHELL_SANDBOX_COMMAND` environment variable. - /// Defaults to `/bin/bash` if neither is provided. + /// Defaults to `/bin/bash -l` if neither this nor the driver specification is provided. #[arg(trailing_var_arg = true)] command: Vec, @@ -650,14 +649,19 @@ fn main() -> Result<()> { (None, None) }; - // Get command - either from CLI args, environment variable, or default to /bin/bash - let command = if !args.command.is_empty() { - args.command - } else if let Ok(c) = std::env::var(openshell_core::sandbox_env::SANDBOX_COMMAND) { - // Simple shell-like splitting on whitespace - c.split_whitespace().map(String::from).collect() + // Resolve an exact canonical process. Explicit offline/test argv wins; + // drivers otherwise provide a versioned JSON transport so argument + // boundaries are never reconstructed with shell parsing. + let workdir = args.workdir.clone(); + let (command, interactive) = if !args.command.is_empty() { + (args.command, args.interactive) + } else if let Ok(json) = std::env::var(openshell_core::sandbox_env::MAIN_PROCESS_SPEC) { + let config = openshell_core::sandbox_env::MainProcessConfig::decode(&json) + .map_err(|error| miette::miette!("{error}"))?; + (config.command, config.tty) } else { - vec!["/bin/bash".to_string()] + let config = openshell_core::sandbox_env::MainProcessConfig::scratch(); + (config.command, config.tty) }; info!(command = ?command, "Starting sandbox"); @@ -675,9 +679,9 @@ fn main() -> Result<()> { run_sandbox( command, - args.workdir, + workdir, args.timeout, - args.interactive, + interactive, args.sandbox_id, args.sandbox, args.openshell_endpoint, diff --git a/crates/openshell-sandbox/src/sidecar_control.rs b/crates/openshell-sandbox/src/sidecar_control.rs index 8cc2854e41..2bd28d5791 100644 --- a/crates/openshell-sandbox/src/sidecar_control.rs +++ b/crates/openshell-sandbox/src/sidecar_control.rs @@ -35,6 +35,8 @@ pub struct BootstrapData { pub struct EntrypointStarted { pub pid: u32, pub start_session: bool, + pub instance_id: String, + pub exit_code: Option, } #[derive(Debug, Clone, Copy)] @@ -58,6 +60,9 @@ pub enum ControlUpdate { enabled: bool, config_revision: u64, }, + MainProcessExitAck { + instance_id: String, + }, } #[derive(Clone)] @@ -115,6 +120,13 @@ impl Publisher { config_revision, }); } + + #[cfg(any(target_os = "linux", test))] + pub fn publish_main_process_exit_ack(&self, instance_id: String) { + let _ = self + .updates + .send(WireServerMessage::MainProcessExitAck { instance_id }); + } } pub struct ServerHandle { @@ -155,7 +167,8 @@ pub struct ProcessConnection { #[serde(tag = "type", rename_all = "snake_case")] enum WireClientMessage { BootstrapRequest { supervisor_pid: u32 }, - EntrypointStarted { pid: u32 }, + EntrypointStarted { pid: u32, instance_id: String }, + MainProcessExited { instance_id: String, exit_code: i32 }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -182,6 +195,9 @@ enum WireServerMessage { enabled: bool, config_revision: u64, }, + MainProcessExitAck { + instance_id: String, + }, } impl BootstrapData { @@ -271,6 +287,9 @@ impl TryFrom for ControlUpdate { enabled, config_revision, }), + WireServerMessage::MainProcessExitAck { instance_id } => { + Ok(Self::MainProcessExitAck { instance_id }) + } WireServerMessage::BootstrapResponse { .. } => Err(miette::miette!( "unexpected sidecar bootstrap response after initial handshake" )), @@ -431,11 +450,14 @@ async fn handle_connection( .send(EntrypointStarted { pid: supervisor_pid, start_session: false, + instance_id: String::new(), + exit_code: None, }) .await .map_err(|_| miette::miette!("sidecar entrypoint receiver closed"))?; } - WireClientMessage::EntrypointStarted { .. } => { + WireClientMessage::EntrypointStarted { .. } + | WireClientMessage::MainProcessExited { .. } => { return Err(miette::miette!( "sidecar control client sent entrypoint event before bootstrap" )); @@ -462,7 +484,7 @@ async fn handle_connection( WireClientMessage::BootstrapRequest { .. } => { debug!("Ignoring duplicate sidecar bootstrap request"); } - WireClientMessage::EntrypointStarted { pid } => { + WireClientMessage::EntrypointStarted { pid, instance_id } => { if pid == 0 { warn!("Ignoring sidecar entrypoint event with pid=0"); continue; @@ -471,6 +493,22 @@ async fn handle_connection( .send(EntrypointStarted { pid, start_session: true, + instance_id, + exit_code: None, + }) + .await + .map_err(|_| miette::miette!("sidecar entrypoint receiver closed"))?; + } + WireClientMessage::MainProcessExited { + instance_id, + exit_code, + } => { + entrypoint_tx + .send(EntrypointStarted { + pid: 0, + start_session: false, + instance_id, + exit_code: Some(exit_code), }) .await .map_err(|_| miette::miette!("sidecar entrypoint receiver closed"))?; @@ -565,8 +603,25 @@ async fn connect_with_retry(path: &Path, timeout: Duration) -> Result>, pid: u32) -> Result<()> { - let message = WireClientMessage::EntrypointStarted { pid }; +pub async fn send_entrypoint_started( + writer: &Arc>, + pid: u32, + instance_id: String, +) -> Result<()> { + let message = WireClientMessage::EntrypointStarted { pid, instance_id }; + let mut writer = writer.lock().await; + write_json_line(&mut *writer, &message).await +} + +pub async fn send_main_process_exited( + writer: &Arc>, + instance_id: String, + exit_code: i32, +) -> Result<()> { + let message = WireClientMessage::MainProcessExited { + instance_id, + exit_code, + }; let mut writer = writer.lock().await; write_json_line(&mut *writer, &message).await } @@ -702,8 +757,9 @@ mod tests { current_peer(), ) .unwrap(); + let publisher = server.publisher(); let mut entrypoint_rx = server.into_entrypoint_receiver(); - let (_bootstrap, connection) = connect_process_client(&socket, Duration::from_secs(1)) + let (_bootstrap, mut connection) = connect_process_client(&socket, Duration::from_secs(1)) .await .unwrap(); @@ -714,7 +770,7 @@ mod tests { assert_eq!(anchor.pid, std::process::id()); assert!(!anchor.start_session); - send_entrypoint_started(&connection.writer, 4242) + send_entrypoint_started(&connection.writer, 4242, "instance-1".to_string()) .await .unwrap(); @@ -724,6 +780,33 @@ mod tests { .unwrap(); assert_eq!(started.pid, 4242); assert!(started.start_session); + assert_eq!(started.instance_id, "instance-1"); + assert!(started.exit_code.is_none()); + + send_main_process_exited(&connection.writer, "instance-1".to_string(), 0) + .await + .unwrap(); + let terminal = tokio::time::timeout(Duration::from_secs(1), entrypoint_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(terminal.exit_code, Some(0)); + + assert!( + tokio::time::timeout(Duration::from_millis(20), connection.updates.recv()) + .await + .is_err(), + "process side must not observe a durable ACK before gateway persistence" + ); + publisher.publish_main_process_exit_ack("instance-1".to_string()); + let ack = tokio::time::timeout(Duration::from_secs(1), connection.updates.recv()) + .await + .unwrap() + .unwrap(); + assert!(matches!( + ack, + ControlUpdate::MainProcessExitAck { instance_id } if instance_id == "instance-1" + )); } #[tokio::test] diff --git a/crates/openshell-sdk/src/client.rs b/crates/openshell-sdk/src/client.rs index c67e91e219..f95b7ee111 100644 --- a/crates/openshell-sdk/src/client.rs +++ b/crates/openshell-sdk/src/client.rs @@ -799,6 +799,8 @@ fn create_sandbox_request(spec: SandboxSpec) -> proto::CreateSandboxRequest { environment, providers, gpu, + command, + tty, } = spec; let template = image.map(|image| proto::SandboxTemplate { image, @@ -813,6 +815,8 @@ fn create_sandbox_request(spec: SandboxSpec) -> proto::CreateSandboxRequest { template, providers, resource_requirements, + command, + tty, ..proto::SandboxSpec::default() }), name: name.unwrap_or_default(), @@ -992,4 +996,17 @@ mod tests { let current = slot.read().unwrap().clone().unwrap(); assert_eq!(current.to_str().unwrap(), "Bearer good-token"); } + + #[test] + fn create_request_preserves_canonical_main_process() { + let request = create_sandbox_request(SandboxSpec { + command: vec!["/opt/agent binary".into(), "--serve exactly".into()], + tty: false, + ..SandboxSpec::default() + }); + + let spec = request.spec.expect("sandbox spec should be present"); + assert_eq!(spec.command, ["/opt/agent binary", "--serve exactly"]); + assert!(!spec.tty); + } } diff --git a/crates/openshell-sdk/src/types.rs b/crates/openshell-sdk/src/types.rs index 6f179499c9..3715c7fdd6 100644 --- a/crates/openshell-sdk/src/types.rs +++ b/crates/openshell-sdk/src/types.rs @@ -109,6 +109,10 @@ pub struct SandboxSpec { /// Request a GPU. Driver-specific device selection is configured via /// driver config on the raw proto surface (see [`crate::raw`]). pub gpu: bool, + /// Exact canonical command. Empty selects the gateway's scratch login shell. + pub command: Vec, + /// Allocate a retained pseudo-terminal for the canonical command. + pub tty: bool, } /// Reference to a sandbox owned by the gateway. @@ -121,11 +125,13 @@ pub struct SandboxRef { pub phase: SandboxPhase, pub labels: HashMap, pub resource_version: u64, + pub exit_code: Option, } impl SandboxRef { pub(crate) fn from_proto(sandbox: proto::Sandbox) -> Self { let phase = sandbox.phase().into(); + let exit_code = sandbox.status.as_ref().and_then(|status| status.exit_code); let meta = sandbox.metadata.unwrap_or_default(); Self { id: meta.id, @@ -134,6 +140,7 @@ impl SandboxRef { phase, labels: meta.labels, resource_version: meta.resource_version, + exit_code, } } } diff --git a/crates/openshell-sdk/tests/client_mock.rs b/crates/openshell-sdk/tests/client_mock.rs index 09e91330ce..c5467b809d 100644 --- a/crates/openshell-sdk/tests/client_mock.rs +++ b/crates/openshell-sdk/tests/client_mock.rs @@ -100,6 +100,13 @@ fn workspace_proto(name: &str, phase: proto::datamodel::v1::WorkspacePhase) -> p #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn report_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn get_current_user( &self, _request: tonic::Request, diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index e09b63de09..c59a4d025a 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -1441,6 +1441,11 @@ impl ComputeRuntime { move |sandbox| { sandbox.set_phase(phase as i32); let name = sandbox.object_name().to_string(); + if matches!(phase, SandboxPhase::Stopping | SandboxPhase::Starting) { + let status = sandbox.status.get_or_insert_with(Default::default); + status.main_process_instance_id.clear(); + status.exit_code = None; + } upsert_ready_condition( &mut sandbox.status, &name, @@ -2699,18 +2704,25 @@ impl ComputeRuntime { Ok(()) } - pub async fn supervisor_session_connected(&self, sandbox_id: &str) -> Result<(), String> { - self.set_supervisor_session_state(sandbox_id, true).await + pub async fn supervisor_session_connected( + &self, + sandbox_id: &str, + instance_id: &str, + ) -> Result<(), String> { + self.set_supervisor_session_state(sandbox_id, true, Some(instance_id)) + .await } pub async fn supervisor_session_disconnected(&self, sandbox_id: &str) -> Result<(), String> { - self.set_supervisor_session_state(sandbox_id, false).await + self.set_supervisor_session_state(sandbox_id, false, None) + .await } async fn set_supervisor_session_state( &self, sandbox_id: &str, connected: bool, + instance_id: Option<&str>, ) -> Result<(), String> { let _guard = self.sync_lock.lock().await; @@ -2745,6 +2757,9 @@ impl ComputeRuntime { let sandbox_name = sandbox.object_name().to_string(); if connected { ensure_supervisor_ready_status(&mut sandbox.status, &sandbox_name); + let status = sandbox.status.get_or_insert_with(Default::default); + status.main_process_instance_id = instance_id.unwrap_or_default().to_string(); + status.exit_code = None; sandbox.set_phase(SandboxPhase::Ready as i32); } else { ensure_supervisor_not_ready_status(&mut sandbox.status, &sandbox_name); @@ -2778,6 +2793,68 @@ impl ComputeRuntime { Ok(()) } + /// Persist a terminal canonical-process result. Exit code zero is still a + /// sandbox error because the canonical process defines sandbox health. + pub async fn main_process_exited( + &self, + sandbox_id: &str, + instance_id: &str, + exit_code: i32, + ) -> Result<(), String> { + let _guard = self.sync_lock.lock().await; + let Some(existing) = self + .store + .get_message::(sandbox_id) + .await + .map_err(|error| error.to_string())? + else { + return Ok(()); + }; + let phase = SandboxPhase::try_from(existing.phase()).unwrap_or(SandboxPhase::Unknown); + if matches!( + phase, + SandboxPhase::Deleting | SandboxPhase::Stopping | SandboxPhase::Stopped + ) { + return Ok(()); + } + if let Some(status) = existing.status.as_ref() { + if !status.main_process_instance_id.is_empty() + && status.main_process_instance_id != instance_id + { + tracing::warn!( + sandbox_id, + instance_id, + active_instance_id = %status.main_process_instance_id, + "ignoring stale main-process exit report" + ); + return Ok(()); + } + if let Some(current_exit_code) = status.exit_code { + if current_exit_code != exit_code { + tracing::warn!( + sandbox_id, + instance_id, + current_exit_code, + reported_exit_code = exit_code, + "ignoring conflicting duplicate main-process exit report" + ); + } + return Ok(()); + } + } + let expected_resource_version = sandbox_resource_version(&existing); + let sandbox = self + .store + .update_message_cas::(sandbox_id, expected_resource_version, |sandbox| { + apply_main_process_exit(sandbox, instance_id, exit_code); + }) + .await + .map_err(|error| error.to_string())?; + self.sandbox_index.update_from_sandbox(&sandbox); + self.sandbox_watch_bus.notify(sandbox_id); + Ok(()) + } + async fn apply_deleted(&self, sandbox_id: &str) -> Result<(), String> { let _guard = self.sync_lock.lock().await; self.apply_deleted_locked(sandbox_id).await @@ -3132,6 +3209,28 @@ impl ComputeRuntime { } } +fn apply_main_process_exit(sandbox: &mut Sandbox, instance_id: &str, exit_code: i32) { + let sandbox_name = sandbox.object_name().to_string(); + let status = sandbox.status.get_or_insert_with(|| SandboxStatus { + sandbox_name: sandbox_name.clone(), + ..Default::default() + }); + status.main_process_instance_id = instance_id.to_string(); + status.exit_code = Some(exit_code); + upsert_ready_condition( + &mut sandbox.status, + &sandbox_name, + SandboxCondition { + r#type: "Ready".to_string(), + status: "False".to_string(), + reason: "MainProcessExited".to_string(), + message: "Canonical main process exited".to_string(), + last_transition_time: String::new(), + }, + ); + sandbox.set_phase(SandboxPhase::Error as i32); +} + /// Connect to an unmanaged remote compute driver that is already listening on /// `socket_path` and return the acquired endpoint. /// @@ -3210,6 +3309,8 @@ fn driver_sandbox_spec_from_public( } }), sandbox_token: String::new(), + command: spec.command.clone(), + tty: spec.tty, }) } @@ -3481,6 +3582,8 @@ fn public_status_from_driver( .collect(), phase: phase as i32, current_policy_version, + main_process_instance_id: String::new(), + exit_code: None, } } @@ -3488,6 +3591,16 @@ fn apply_driver_snapshot(sandbox: &mut Sandbox, incoming: &DriverSandbox, sessio let old_phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown); let sandbox_name = &incoming.name; + // Error is terminal until an explicit future lifecycle operation changes + // desired state. In particular, a still-running backend snapshot must not + // revive a sandbox whose canonical process has exited. + if old_phase == SandboxPhase::Error { + if let Some(metadata) = sandbox.metadata.as_mut() { + metadata.name.clone_from(sandbox_name); + } + return; + } + let cpv = sandbox.current_policy_version(); let (mut phase, mut status) = incoming.status.as_ref().map_or_else( || { @@ -3543,7 +3656,12 @@ fn apply_driver_snapshot(sandbox: &mut Sandbox, incoming: &DriverSandbox, sessio { status.sandbox_name.clone_from(sandbox_name); } - + if let (Some(status), Some(current_status)) = (status.as_mut(), sandbox.status.as_ref()) { + status + .main_process_instance_id + .clone_from(¤t_status.main_process_instance_id); + status.exit_code = current_status.exit_code; + } if old_phase != phase { info!( sandbox_id = %incoming.id, @@ -4691,6 +4809,173 @@ mod tests { sandbox } + #[test] + fn main_process_exit_zero_is_terminal_error() { + let mut sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready); + apply_main_process_exit(&mut sandbox, "instance-1", 0); + + assert_eq!( + SandboxPhase::try_from(sandbox.phase()), + Ok(SandboxPhase::Error) + ); + let status = sandbox.status.as_ref().unwrap(); + assert_eq!(status.exit_code, Some(0)); + assert_eq!(status.main_process_instance_id, "instance-1"); + assert!(status.conditions.iter().any(|condition| { + condition.r#type == "Ready" + && condition.status == "False" + && condition.reason == "MainProcessExited" + })); + } + + #[tokio::test] + async fn stale_main_process_exit_is_acknowledged_without_replacing_active_instance() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Provisioning); + runtime.store.put_message(&sandbox).await.unwrap(); + runtime + .supervisor_session_connected("sb-1", "instance-2") + .await + .unwrap(); + + runtime + .main_process_exited("sb-1", "instance-1", 0) + .await + .unwrap(); + let stored = runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .unwrap(); + assert_eq!(stored.phase(), SandboxPhase::Ready as i32); + assert_eq!( + stored.status.unwrap().main_process_instance_id, + "instance-2" + ); + } + + #[tokio::test] + async fn missing_sandbox_main_process_exit_is_acknowledged() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + + runtime + .main_process_exited("missing", "instance-1", 0) + .await + .unwrap(); + } + + #[tokio::test] + async fn duplicate_main_process_exit_is_idempotent() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Provisioning); + runtime.store.put_message(&sandbox).await.unwrap(); + runtime + .supervisor_session_connected("sb-1", "instance-1") + .await + .unwrap(); + runtime + .main_process_exited("sb-1", "instance-1", 9) + .await + .unwrap(); + runtime + .main_process_exited("sb-1", "instance-1", 9) + .await + .unwrap(); + let stored = runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .unwrap(); + assert_eq!(stored.phase(), SandboxPhase::Error as i32); + assert_eq!(stored.status.unwrap().exit_code, Some(9)); + } + + #[tokio::test] + async fn conflicting_duplicate_main_process_exit_is_acknowledged() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Provisioning); + runtime.store.put_message(&sandbox).await.unwrap(); + runtime + .supervisor_session_connected("sb-1", "instance-1") + .await + .unwrap(); + runtime + .main_process_exited("sb-1", "instance-1", 9) + .await + .unwrap(); + runtime + .main_process_exited("sb-1", "instance-1", 7) + .await + .unwrap(); + + let stored = runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .unwrap(); + assert_eq!(stored.status.unwrap().exit_code, Some(9)); + } + + #[tokio::test] + async fn precise_exit_enriches_driver_terminal_fallback() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + let mut sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Error); + sandbox.status = Some(SandboxStatus { + phase: SandboxPhase::Error as i32, + main_process_instance_id: "instance-1".into(), + ..Default::default() + }); + runtime.store.put_message(&sandbox).await.unwrap(); + + runtime + .main_process_exited("sb-1", "instance-1", 7) + .await + .unwrap(); + + let stored = runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .unwrap(); + assert_eq!(stored.phase(), SandboxPhase::Error as i32); + assert_eq!(stored.status.unwrap().exit_code, Some(7)); + } + + #[tokio::test] + async fn intentional_stop_ignores_main_process_exit_report() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + for (id, phase) in [ + ("sb-stopping", SandboxPhase::Stopping), + ("sb-stopped", SandboxPhase::Stopped), + ] { + let mut sandbox = sandbox_record(id, id, phase); + sandbox.status = Some(SandboxStatus { + phase: phase as i32, + main_process_instance_id: "instance-1".into(), + ..Default::default() + }); + runtime.store.put_message(&sandbox).await.unwrap(); + + runtime + .main_process_exited(id, "instance-1", 143) + .await + .unwrap(); + + let stored = runtime + .store + .get_message::(id) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.phase(), phase as i32); + assert_eq!(stored.status.unwrap().exit_code, None); + } + } + fn ssh_session_record(id: &str, sandbox_id: &str) -> SshSession { SshSession { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { @@ -7169,7 +7454,12 @@ mod tests { #[tokio::test] async fn non_deleting_container_exit_still_transitions_to_error() { let runtime = test_runtime(Arc::new(TestDriver::default())).await; - let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready); + let mut sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready); + sandbox.status = Some(SandboxStatus { + sandbox_name: "sandbox-a".to_string(), + main_process_instance_id: "instance-1".to_string(), + ..Default::default() + }); runtime.store.put_message(&sandbox).await.unwrap(); let mut exited = ready_driver_sandbox("sb-1", "sandbox-a"); exited.status = Some(make_driver_status(make_driver_condition( @@ -7189,6 +7479,9 @@ mod tests { SandboxPhase::try_from(stored.phase()).unwrap(), SandboxPhase::Error ); + let status = stored.status.unwrap(); + assert_eq!(status.main_process_instance_id, "instance-1"); + assert_eq!(status.exit_code, None); } #[tokio::test] @@ -7302,7 +7595,10 @@ mod tests { let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Provisioning); runtime.store.put_message(&sandbox).await.unwrap(); - runtime.supervisor_session_connected("sb-1").await.unwrap(); + runtime + .supervisor_session_connected("sb-1", "test-generation") + .await + .unwrap(); let stored = runtime .store @@ -7545,6 +7841,10 @@ mod tests { SandboxPhase::try_from(stored.phase()).unwrap(), SandboxPhase::Error ); + assert!( + stored.status.unwrap().main_process_instance_id.is_empty(), + "a provisioning failure must not fabricate a main-process exit" + ); } #[tokio::test] @@ -7557,7 +7857,10 @@ mod tests { // Promote to Ready via supervisor session connect. register_test_supervisor_session(&runtime, "sb-1"); - runtime.supervisor_session_connected("sb-1").await.unwrap(); + runtime + .supervisor_session_connected("sb-1", "test-generation") + .await + .unwrap(); let stored = runtime .store .get_message::("sb-1") diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index 2c52acbe12..2f9b2a5ef5 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -43,14 +43,15 @@ use openshell_core::proto::{ ProviderProfileResponse, ProviderResponse, PushSandboxLogsRequest, PushSandboxLogsResponse, RefreshSandboxTokenRequest, RefreshSandboxTokenResponse, RejectDraftChunkRequest, RejectDraftChunkResponse, RelayFrame, RemoveWorkspaceMemberRequest, - RemoveWorkspaceMemberResponse, ReportPolicyStatusRequest, ReportPolicyStatusResponse, - RevokeSshSessionRequest, RevokeSshSessionResponse, RotateProviderCredentialRequest, - RotateProviderCredentialResponse, SandboxResponse, ServiceEndpointResponse, ServiceStatus, - StartSandboxRequest, StopSandboxRequest, SubmitPolicyAnalysisRequest, - SubmitPolicyAnalysisResponse, SupervisorMessage, TcpForwardFrame, UndoDraftChunkRequest, - UndoDraftChunkResponse, UpdateConfigRequest, UpdateConfigResponse, - UpdateProviderProfilesRequest, UpdateProviderProfilesResponse, UpdateProviderRequest, - WatchSandboxRequest, open_shell_server::OpenShell, + RemoveWorkspaceMemberResponse, ReportMainProcessExitRequest, ReportMainProcessExitResponse, + ReportPolicyStatusRequest, ReportPolicyStatusResponse, RevokeSshSessionRequest, + RevokeSshSessionResponse, RotateProviderCredentialRequest, RotateProviderCredentialResponse, + SandboxResponse, ServiceEndpointResponse, ServiceStatus, StartSandboxRequest, + StopSandboxRequest, SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, + SupervisorMessage, TcpForwardFrame, UndoDraftChunkRequest, UndoDraftChunkResponse, + UpdateConfigRequest, UpdateConfigResponse, UpdateProviderProfilesRequest, + UpdateProviderProfilesResponse, UpdateProviderRequest, WatchSandboxRequest, + open_shell_server::OpenShell, }; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -679,6 +680,13 @@ impl OpenShell for OpenShellService { crate::supervisor_session::handle_connect_supervisor(&self.state, request).await } + async fn report_main_process_exit( + &self, + request: Request, + ) -> Result, Status> { + crate::supervisor_session::handle_report_main_process_exit(&self.state, request).await + } + type RelayStreamStream = Pin> + Send + 'static>>; diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index d5dd4e04e4..1cd1c4a0e6 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -217,10 +217,18 @@ async fn handle_create_sandbox_inner( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let request = request.into_inner(); - let spec = request + let mut spec = request .spec .ok_or_else(|| Status::invalid_argument("spec is required"))?; + // Every newly persisted sandbox has one explicit canonical process. This + // portable default also preserves compatibility with callers compiled + // before the main-process field was introduced. + if spec.command.is_empty() { + spec.command = vec!["/bin/bash".to_string(), "-l".to_string()]; + spec.tty = true; + } + // Validate field sizes before any I/O (fail fast on oversized payloads). validate_sandbox_spec(&request.name, &spec)?; @@ -262,7 +270,6 @@ async fn handle_create_sandbox_inner( .await?; // Ensure the template always carries the resolved image. - let mut spec = spec; let template = spec.template.get_or_insert_with(SandboxTemplate::default); if template.image.is_empty() { template.image = state.compute.default_image().to_string(); diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index f71623fa3d..92dcaeb4fe 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -51,6 +51,10 @@ pub(super) const MAX_EXEC_COMMAND_ARGS: usize = 1024; pub(super) const MAX_EXEC_ARG_LEN: usize = 32 * 1024; // 32 KiB /// Maximum length of the workdir field (bytes). pub(super) const MAX_EXEC_WORKDIR_LEN: usize = 4096; +/// Maximum number of entries in the canonical main-process argv. +pub(super) const MAX_MAIN_PROCESS_ARGS: usize = 256; +/// Maximum aggregate byte size of the canonical main-process argv. +pub(super) const MAX_MAIN_PROCESS_ARGV_SIZE: usize = 256 * 1024; /// Validate exec request size limits and field-specific character constraints. /// @@ -212,6 +216,10 @@ pub(super) fn validate_sandbox_spec( // --- spec.resource_requirements.gpu --- validate_gpu_request_fields(spec)?; + if !spec.command.is_empty() { + validate_main_process_command(&spec.command)?; + } + // --- spec.policy serialized size --- if let Some(ref policy) = spec.policy { let size = policy.encoded_len(); @@ -225,6 +233,35 @@ pub(super) fn validate_sandbox_spec( Ok(()) } +fn validate_main_process_command(command: &[String]) -> Result<(), Status> { + if command.len() > MAX_MAIN_PROCESS_ARGS { + return Err(Status::invalid_argument(format!( + "spec.command exceeds {MAX_MAIN_PROCESS_ARGS} argument limit" + ))); + } + if command[0].is_empty() { + return Err(Status::invalid_argument( + "spec.command[0] must not be empty", + )); + } + let argv_size: usize = command.iter().map(String::len).sum(); + if argv_size > MAX_MAIN_PROCESS_ARGV_SIZE { + return Err(Status::invalid_argument(format!( + "spec.command total size exceeds {MAX_MAIN_PROCESS_ARGV_SIZE} byte limit" + ))); + } + for (index, argument) in command.iter().enumerate() { + if argument.len() > MAX_EXEC_ARG_LEN { + return Err(Status::invalid_argument(format!( + "spec.command[{index}] exceeds {MAX_EXEC_ARG_LEN} byte limit" + ))); + } + reject_null_char(argument, &format!("spec.command[{index}]"))?; + } + + Ok(()) +} + fn validate_gpu_request_fields(spec: &openshell_core::proto::SandboxSpec) -> Result<(), Status> { if openshell_core::gpu::sandbox_gpu_count(spec.resource_requirements.as_ref()) == Some(0) { return Err(Status::invalid_argument("gpu count must be greater than 0")); @@ -1020,6 +1057,16 @@ mod tests { assert!(validate_sandbox_spec("", &default_spec()).is_ok()); } + #[test] + fn validate_sandbox_spec_accepts_exact_main_process_argv() { + let spec = SandboxSpec { + command: vec!["/bin/sh".into(), "-c".into(), "printf 'a b'".into()], + tty: false, + ..Default::default() + }; + validate_sandbox_spec("", &spec).unwrap(); + } + #[test] fn validate_sandbox_spec_accepts_at_limit_name() { let name = "a".repeat(MAX_ROUTABLE_NAME_LEN); diff --git a/crates/openshell-server/src/supervisor_session.rs b/crates/openshell-server/src/supervisor_session.rs index 11ef55978b..fbff0e276c 100644 --- a/crates/openshell-server/src/supervisor_session.rs +++ b/crates/openshell-server/src/supervisor_session.rs @@ -13,8 +13,9 @@ use tracing::{debug, info, warn}; use uuid::Uuid; use openshell_core::proto::{ - GatewayMessage, RelayFrame, RelayInit, RelayOpen, Sandbox, SandboxPhase, SessionAccepted, - SshRelayTarget, SupervisorMessage, gateway_message, relay_open, supervisor_message, + GatewayMessage, RelayFrame, RelayInit, RelayOpen, ReportMainProcessExitRequest, + ReportMainProcessExitResponse, Sandbox, SandboxPhase, SessionAccepted, SshRelayTarget, + SupervisorMessage, gateway_message, relay_open, supervisor_message, }; use openshell_core::transport_errors::is_expected_transport_close_status; @@ -713,7 +714,7 @@ pub async fn handle_connect_supervisor( "supervisor session: accepted" ); - // Step 2: Create the outbound channel and register the session. + // Step 2: Create and register the outbound channel. let (tx, rx) = mpsc::channel::(64); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); let superseded = state.supervisor_sessions.register( @@ -755,7 +756,7 @@ pub async fn handle_connect_supervisor( if let Err(err) = state .compute - .supervisor_session_connected(&sandbox_id) + .supervisor_session_connected(&sandbox_id, &hello.instance_id) .await { warn!( @@ -815,6 +816,29 @@ pub async fn handle_connect_supervisor( Ok(Response::new(stream)) } +pub async fn handle_report_main_process_exit( + state: &Arc, + request: Request, +) -> Result, Status> { + let principal = request.extensions().get::().cloned(); + let report = request.into_inner(); + if report.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); + } + if report.instance_id.is_empty() { + return Err(Status::invalid_argument("instance_id is required")); + } + if let Some(principal) = principal.as_ref() { + crate::auth::guard::ensure_sandbox_principal_scope(principal, &report.sandbox_id)?; + } + state + .compute + .main_process_exited(&report.sandbox_id, &report.instance_id, report.exit_code) + .await + .map_err(Status::failed_precondition)?; + Ok(Response::new(ReportMainProcessExitResponse {})) +} + async fn run_session_loop( state: &Arc, sandbox_id: &str, diff --git a/crates/openshell-server/tests/common/mod.rs b/crates/openshell-server/tests/common/mod.rs index a2df4755f0..13c4294c0b 100644 --- a/crates/openshell-server/tests/common/mod.rs +++ b/crates/openshell-server/tests/common/mod.rs @@ -52,6 +52,13 @@ pub struct TestOpenShell; #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn report_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn get_current_user( &self, _request: tonic::Request, diff --git a/crates/openshell-server/tests/supervisor_relay_integration.rs b/crates/openshell-server/tests/supervisor_relay_integration.rs index 86c7354647..987d43ba4a 100644 --- a/crates/openshell-server/tests/supervisor_relay_integration.rs +++ b/crates/openshell-server/tests/supervisor_relay_integration.rs @@ -48,6 +48,13 @@ struct RelayGateway { #[tonic::async_trait] impl OpenShell for RelayGateway { + async fn report_main_process_exit( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn get_current_user( &self, _request: tonic::Request, diff --git a/crates/openshell-supervisor-process/Cargo.toml b/crates/openshell-supervisor-process/Cargo.toml index 7b91887588..e1708113fb 100644 --- a/crates/openshell-supervisor-process/Cargo.toml +++ b/crates/openshell-supervisor-process/Cargo.toml @@ -17,6 +17,7 @@ openshell-policy = { path = "../openshell-policy" } anyhow = { workspace = true } base64 = { workspace = true } +bytes = { workspace = true } hex = "0.4" miette = { workspace = true } nix = { workspace = true } diff --git a/crates/openshell-supervisor-process/src/lib.rs b/crates/openshell-supervisor-process/src/lib.rs index ca93230929..743942faa4 100644 --- a/crates/openshell-supervisor-process/src/lib.rs +++ b/crates/openshell-supervisor-process/src/lib.rs @@ -13,6 +13,7 @@ pub mod debug_rpc; #[cfg(unix)] pub mod identity; pub mod log_push; +pub mod main_session; pub mod managed_children; pub mod process; pub mod run; diff --git a/crates/openshell-supervisor-process/src/main_session.rs b/crates/openshell-supervisor-process/src/main_session.rs new file mode 100644 index 0000000000..34689178d9 --- /dev/null +++ b/crates/openshell-supervisor-process/src/main_session.rs @@ -0,0 +1,591 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Retained I/O multiplexer for the canonical sandbox process. + +use std::collections::VecDeque; +use std::io::{Read, Write}; +use std::os::fd::AsRawFd; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use bytes::Bytes; +use nix::fcntl::{FcntlArg, OFlag, fcntl}; +use nix::pty::Winsize; +use tokio::io::unix::AsyncFd; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::Notify; +use tokio::sync::watch; + +use crate::process::ProcessIo; + +const OUTPUT_BUFFER_BYTES: usize = 1024 * 1024; + +#[derive(Clone, Debug)] +pub enum MainOutput { + Stdout(Bytes), + Stderr(Bytes), + Exit(i32), +} + +impl MainOutput { + fn len(&self) -> usize { + match self { + Self::Stdout(data) | Self::Stderr(data) => data.len(), + Self::Exit(_) => 0, + } + } +} + +#[derive(Clone, Debug)] +struct SequencedOutput { + sequence: u64, + event: MainOutput, +} + +#[derive(Debug)] +struct OutputLogState { + events: VecDeque, + retained_bytes: usize, + next_sequence: u64, +} + +#[derive(Debug)] +struct OutputLog { + state: Mutex, + version: watch::Sender, +} + +impl OutputLog { + fn new() -> Arc { + let (version, _) = watch::channel(0); + Arc::new(Self { + state: Mutex::new(OutputLogState { + events: VecDeque::new(), + retained_bytes: 0, + next_sequence: 0, + }), + version, + }) + } + + fn publish(&self, event: MainOutput) { + let version = { + let mut state = self.state.lock().expect("main output log lock poisoned"); + let sequence = state.next_sequence; + state.next_sequence = state + .next_sequence + .checked_add(1) + .expect("main output sequence exhausted"); + state.retained_bytes += event.len(); + state.events.push_back(SequencedOutput { sequence, event }); + while state.retained_bytes > OUTPUT_BUFFER_BYTES { + let Some(removed) = state.events.pop_front() else { + break; + }; + state.retained_bytes = state.retained_bytes.saturating_sub(removed.event.len()); + } + state.next_sequence + }; + self.version.send_replace(version); + } + + fn subscribe(self: &Arc) -> MainOutputCursor { + let version = self.version.subscribe(); + let state = self.state.lock().expect("main output log lock poisoned"); + let next_sequence = state + .events + .front() + .map_or(state.next_sequence, |retained| retained.sequence); + drop(state); + MainOutputCursor { + output: Arc::clone(self), + next_sequence, + version, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct MainOutputLagged { + pub skipped: u64, +} + +pub struct MainOutputCursor { + output: Arc, + next_sequence: u64, + version: watch::Receiver, +} + +impl MainOutputCursor { + pub async fn recv(&mut self) -> Result { + loop { + let next = { + let state = self + .output + .state + .lock() + .expect("main output log lock poisoned"); + let oldest = state + .events + .front() + .map_or(state.next_sequence, |event| event.sequence); + if self.next_sequence < oldest { + let skipped = oldest - self.next_sequence; + self.next_sequence = oldest; + return Err(MainOutputLagged { skipped }); + } + if self.next_sequence >= state.next_sequence { + None + } else { + let offset = usize::try_from(self.next_sequence - oldest) + .expect("main output cursor offset exceeds usize"); + let event = state + .events + .get(offset) + .expect("main output cursor references retained event") + .event + .clone(); + self.next_sequence += 1; + Some(event) + } + }; + if let Some(event) = next { + return Ok(event); + } + // The log owns a sender for the cursor lifetime, so closure is not + // expected. A changed version means there is another event to read. + let _ = self.version.changed().await; + } + } +} + +pub struct MainSession { + pid: u32, + terminal: bool, + input: tokio::sync::mpsc::Sender>, + output: Arc, + input_owner: Mutex>, + next_owner: AtomicU64, + pty_master: Option>, + readers_remaining: AtomicUsize, + readers_done: Notify, +} + +impl MainSession { + #[cfg(test)] + pub fn inert() -> Arc { + let (input, _input_rx) = tokio::sync::mpsc::channel(64); + Arc::new(Self { + pid: 1, + terminal: false, + input, + output: OutputLog::new(), + input_owner: Mutex::new(None), + next_owner: AtomicU64::new(1), + pty_master: None, + readers_remaining: AtomicUsize::new(0), + readers_done: Notify::new(), + }) + } + + #[cfg(test)] + pub fn terminal_for_test() -> (Arc, std::fs::File) { + let pty = nix::pty::openpty(None, None).expect("open test PTY"); + let slave = std::fs::File::from(pty.slave); + ( + Self::new(ProcessIo::Pty(std::fs::File::from(pty.master)), 1), + slave, + ) + } + + #[cfg(test)] + #[allow(unsafe_code)] + pub fn terminal_size_for_test(&self) -> (u16, u16) { + let master = self.pty_master.as_ref().expect("terminal PTY master"); + let mut winsize: libc::winsize = unsafe { std::mem::zeroed() }; + let result = unsafe { libc::ioctl(master.as_raw_fd(), libc::TIOCGWINSZ, &mut winsize) }; + assert_eq!(result, 0, "read terminal dimensions"); + (winsize.ws_col, winsize.ws_row) + } + + #[must_use] + pub fn new(io: ProcessIo, pid: u32) -> Arc { + let terminal = matches!(io, ProcessIo::Pty(_)); + let (input, input_rx) = tokio::sync::mpsc::channel::>(64); + let pty_master = match &io { + ProcessIo::Pty(master) => { + set_nonblocking(master).expect("set canonical PTY master nonblocking"); + master.try_clone().ok().map(Arc::new) + } + ProcessIo::Pipes { .. } => None, + }; + let session = Arc::new(Self { + pid, + terminal, + input, + output: OutputLog::new(), + input_owner: Mutex::new(None), + next_owner: AtomicU64::new(1), + pty_master, + readers_remaining: AtomicUsize::new(if terminal { 1 } else { 2 }), + readers_done: Notify::new(), + }); + Self::start_io(&session, io, input_rx); + session + } + + fn start_io( + this: &Arc, + io: ProcessIo, + mut input_rx: tokio::sync::mpsc::Receiver>, + ) { + match io { + ProcessIo::Pty(master) => { + let master = Arc::new(AsyncFd::new(master).expect("register canonical PTY master")); + let reader = Arc::clone(&master); + let output = Arc::clone(this); + tokio::spawn(async move { + let mut buffer = [0u8; 4096]; + loop { + let Ok(mut ready) = reader.readable().await else { + break; + }; + match ready.try_io(|inner| { + let mut file = inner.get_ref(); + file.read(&mut buffer) + }) { + Ok(Ok(0) | Err(_)) => break, + Ok(Ok(read)) => output.publish(MainOutput::Stdout( + Bytes::copy_from_slice(&buffer[..read]), + )), + Err(_would_block) => {} + } + } + output.reader_finished(); + }); + tokio::spawn(async move { + while let Some(data) = input_rx.recv().await { + let mut remaining = data.as_slice(); + while !remaining.is_empty() { + let Ok(mut ready) = master.writable().await else { + return; + }; + match ready.try_io(|inner| { + let mut file = inner.get_ref(); + file.write(remaining) + }) { + Ok(Ok(0) | Err(_)) => return, + Ok(Ok(written)) => remaining = &remaining[written..], + Err(_would_block) => {} + } + } + } + }); + } + ProcessIo::Pipes { + mut stdin, + mut stdout, + mut stderr, + } => { + let stdout_session = Arc::clone(this); + tokio::spawn(async move { + let mut buffer = [0u8; 4096]; + loop { + match stdout.read(&mut buffer).await { + Ok(0) | Err(_) => break, + Ok(read) => { + stdout_session.publish(MainOutput::Stdout(Bytes::copy_from_slice( + &buffer[..read], + ))); + } + } + } + stdout_session.reader_finished(); + }); + let stderr_session = Arc::clone(this); + tokio::spawn(async move { + let mut buffer = [0u8; 4096]; + loop { + match stderr.read(&mut buffer).await { + Ok(0) | Err(_) => break, + Ok(read) => { + stderr_session.publish(MainOutput::Stderr(Bytes::copy_from_slice( + &buffer[..read], + ))); + } + } + } + stderr_session.reader_finished(); + }); + tokio::spawn(async move { + while let Some(data) = input_rx.recv().await { + if stdin.write_all(&data).await.is_err() { + break; + } + let _ = stdin.flush().await; + } + }); + } + } + } + + fn publish(&self, event: MainOutput) { + self.output.publish(event); + } + + fn reader_finished(&self) { + if self.readers_remaining.fetch_sub(1, Ordering::AcqRel) == 1 { + self.readers_done.notify_waiters(); + } + } + + pub async fn finish(&self, exit_code: i32) { + let notified = self.readers_done.notified(); + if self.readers_remaining.load(Ordering::Acquire) != 0 { + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), notified).await; + } + self.publish(MainOutput::Exit(exit_code)); + } + + pub fn subscribe(&self) -> MainOutputCursor { + self.output.subscribe() + } + + pub fn acquire_input(&self) -> Result<(u64, tokio::sync::mpsc::Sender>), &'static str> { + let mut owner = self.input_owner.lock().expect("main input lock poisoned"); + if owner.is_some() { + return Err("canonical main process already has an input owner"); + } + let id = self.next_owner.fetch_add(1, Ordering::Relaxed); + *owner = Some(id); + Ok((id, self.input.clone())) + } + + pub fn release_input(&self, id: u64) { + let mut owner = self.input_owner.lock().expect("main input lock poisoned"); + if *owner == Some(id) { + *owner = None; + } + } + + pub fn resize(&self, columns: u32, rows: u32, pixel_width: u32, pixel_height: u32) { + let Some(master) = self.pty_master.as_ref() else { + return; + }; + let winsize = Winsize { + ws_row: u16::try_from(rows.max(1)).unwrap_or(u16::MAX), + ws_col: u16::try_from(columns.max(1)).unwrap_or(u16::MAX), + ws_xpixel: u16::try_from(pixel_width).unwrap_or(u16::MAX), + ws_ypixel: u16::try_from(pixel_height).unwrap_or(u16::MAX), + }; + #[allow(unsafe_code)] + unsafe { + libc::ioctl(master.as_raw_fd(), libc::TIOCSWINSZ, &winsize); + } + } + + /// Return whether the canonical process group currently owns the PTY + /// foreground. Interactive shells give foreground jobs their own process + /// groups and reclaim the foreground after those jobs exit. + /// + /// A pipe-backed process has no terminal foreground and therefore never + /// qualifies for Ctrl-C detach behavior. + pub fn main_owns_terminal_foreground(&self) -> Result { + let Some(master) = self.pty_master.as_ref() else { + return Ok(false); + }; + let foreground = nix::unistd::tcgetpgrp(master.as_ref())?; + let main_pid = i32::try_from(self.pid).unwrap_or(i32::MAX); + Ok(foreground == nix::unistd::Pid::from_raw(main_pid)) + } + + pub fn signal_group(&self, signal: nix::sys::signal::Signal) -> Result<(), nix::errno::Errno> { + let pid = i32::try_from(self.pid).unwrap_or(i32::MAX); + nix::sys::signal::kill(nix::unistd::Pid::from_raw(-pid), signal) + } + + #[must_use] + pub const fn terminal(&self) -> bool { + self.terminal + } +} + +fn set_nonblocking(file: &std::fs::File) -> Result<(), nix::errno::Errno> { + let flags = fcntl(file.as_raw_fd(), FcntlArg::F_GETFL)?; + let flags = OFlag::from_bits_truncate(flags); + fcntl( + file.as_raw_fd(), + FcntlArg::F_SETFL(flags | OFlag::O_NONBLOCK), + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn input_lease_has_one_owner_and_can_be_reacquired() { + let session = MainSession::inert(); + let (first, _) = session.acquire_input().expect("first owner"); + assert!(session.acquire_input().is_err()); + + session.release_input(first); + let (second, _) = session.acquire_input().expect("replacement owner"); + assert_ne!(first, second); + } + + #[test] + fn pipe_main_never_owns_a_terminal_foreground() { + let session = MainSession::inert(); + assert_eq!(session.main_owns_terminal_foreground(), Ok(false)); + } + + #[cfg(unix)] + #[tokio::test] + #[allow( + unsafe_code, + clippy::useless_conversion, + reason = "TIOCSCTTY request type differs across Unix libc targets" + )] + async fn terminal_session_leader_owns_foreground() { + use std::os::unix::process::CommandExt; + use std::process::{Command, Stdio}; + + let pty = nix::pty::openpty(None, None).expect("open test PTY"); + let master = std::fs::File::from(pty.master); + let slave = std::fs::File::from(pty.slave); + let slave_fd = slave.as_raw_fd(); + let mut command = Command::new("/bin/sh"); + command + .arg("-c") + .arg("sleep 30") + .stdin(Stdio::from(slave.try_clone().expect("clone PTY stdin"))) + .stdout(Stdio::from(slave.try_clone().expect("clone PTY stdout"))) + .stderr(Stdio::from(slave.try_clone().expect("clone PTY stderr"))); + unsafe { + command.pre_exec(move || { + if libc::setsid() < 0 { + return Err(std::io::Error::last_os_error()); + } + if libc::ioctl(slave_fd, libc::TIOCSCTTY.into(), 0) < 0 { + return Err(std::io::Error::last_os_error()); + } + if libc::tcsetpgrp(slave_fd, libc::getpid()) < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut child = command.spawn().expect("spawn PTY session leader"); + drop(slave); + + let session = MainSession::new(ProcessIo::Pty(master), child.id()); + let observed = tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if session.main_owns_terminal_foreground() == Ok(true) { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .is_ok(); + + let _ = child.kill(); + let _ = child.wait(); + assert!( + observed, + "canonical process group did not own PTY foreground" + ); + } + + #[tokio::test] + async fn subscribers_receive_replay_then_live_output() { + let session = MainSession::inert(); + session.publish(MainOutput::Stdout(Bytes::from_static(b"before"))); + + let mut output = session.subscribe(); + assert!(matches!( + output.recv().await.expect("replayed output"), + MainOutput::Stdout(data) if data == b"before"[..] + )); + + session.publish(MainOutput::Stderr(Bytes::from_static(b"after"))); + assert!(matches!( + output.recv().await.expect("live output"), + MainOutput::Stderr(data) if data == b"after"[..] + )); + } + + #[tokio::test] + async fn exit_is_replayed_once_to_late_subscribers() { + let session = MainSession::inert(); + session.finish(0).await; + + let mut output = session.subscribe(); + assert!(matches!( + output.recv().await.expect("replayed exit"), + MainOutput::Exit(0) + )); + } + + #[tokio::test] + async fn slow_subscriber_reports_evicted_events_then_resumes() { + let session = MainSession::inert(); + let mut output = session.subscribe(); + let chunk = Bytes::from(vec![0; 4096]); + for _ in 0..=(OUTPUT_BUFFER_BYTES / chunk.len()) { + session.publish(MainOutput::Stdout(chunk.clone())); + } + + let lag = output.recv().await.expect_err("oldest event was evicted"); + assert_eq!(lag.skipped, 1); + assert!(matches!( + output.recv().await.expect("resume at oldest retained event"), + MainOutput::Stdout(data) if data.len() == chunk.len() + )); + } + + #[tokio::test] + async fn terminal_pump_reads_output_and_writes_input() { + let (session, mut slave) = MainSession::terminal_for_test(); + set_nonblocking(&slave).expect("set test PTY slave nonblocking"); + let mut output = session.subscribe(); + + slave + .write_all(b"process output") + .expect("write PTY output"); + let event = tokio::time::timeout(std::time::Duration::from_secs(1), output.recv()) + .await + .expect("PTY output timed out") + .expect("PTY output was retained"); + assert!(matches!( + event, + MainOutput::Stdout(data) if data == b"process output"[..] + )); + + let (owner, input) = session.acquire_input().expect("acquire PTY input"); + input + .send(b"client input\n".to_vec()) + .await + .expect("queue PTY input"); + let mut received = [0; 64]; + let read = tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + match slave.read(&mut received) { + Ok(read) => break read, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + tokio::task::yield_now().await; + } + Err(error) => panic!("read PTY input: {error}"), + } + } + }) + .await + .expect("PTY input timed out"); + assert_eq!(&received[..read], b"client input\n"); + session.release_input(owner); + } +} diff --git a/crates/openshell-supervisor-process/src/process.rs b/crates/openshell-supervisor-process/src/process.rs index 9270681f56..aebab0c0fe 100644 --- a/crates/openshell-supervisor-process/src/process.rs +++ b/crates/openshell-supervisor-process/src/process.rs @@ -15,8 +15,10 @@ use nix::unistd::{Gid, Group, Pid, Uid, User}; use openshell_core::policy::{NetworkMode, SandboxPolicy}; use std::collections::HashMap; use std::ffi::CString; +#[cfg(unix)] +use std::os::fd::AsRawFd; #[cfg(target_os = "linux")] -use std::os::fd::{AsRawFd, OwnedFd, RawFd}; +use std::os::fd::{OwnedFd, RawFd}; #[cfg(target_os = "linux")] use std::os::unix::ffi::OsStrExt; #[cfg(unix)] @@ -27,9 +29,21 @@ use std::path::PathBuf; use std::process::Stdio; #[cfg(target_os = "linux")] use std::sync::OnceLock; -use tokio::process::{Child, Command}; +use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command}; use tracing::{debug, info}; +// `libc::TIOCSCTTY` and the request parameter accepted by `ioctl` vary across +// glibc, musl, and BSD targets. The conversion is a no-op on some targets but +// is required on others. +#[cfg(unix)] +#[allow(unsafe_code, clippy::useless_conversion)] +fn set_controlling_tty(fd: libc::c_int) -> std::io::Result<()> { + if unsafe { libc::ioctl(fd, libc::TIOCSCTTY.into(), 0) } < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) +} + /// Process/filesystem enforcement performed by the process supervisor. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProcessEnforcementMode { @@ -172,6 +186,67 @@ fn inject_provider_env(cmd: &mut Command, provider_env: &HashMap } } +/// Derive the child USER and HOME from the policy's sandbox identity. +/// +/// Name-based identities use their passwd entry. Numeric identities have no +/// reliable passwd entry, so their workspace remains the portable fallback. +pub(crate) fn session_user_and_home( + policy: &SandboxPolicy, + workdir_home: Option<&str>, +) -> (String, String) { + let (user, default_home) = match policy.process.run_as_user.as_deref() { + Some(user) if !user.is_empty() => { + if user.parse::().is_ok() { + (user.to_string(), "/sandbox".to_string()) + } else { + let home = User::from_name(user).ok().flatten().map_or_else( + || format!("/home/{user}"), + |entry| entry.dir.to_string_lossy().into_owned(), + ); + (user.to_string(), home) + } + } + _ => ("sandbox".to_string(), "/sandbox".to_string()), + }; + let home = workdir_home.map_or(default_home, str::to_string); + (user, home) +} + +fn apply_canonical_process_environment( + cmd: &mut Command, + policy: &SandboxPolicy, + workspace: &ResolvedWorkspace, + interactive: bool, + user_environment: &HashMap, +) { + let (session_user, session_home) = session_user_and_home(policy, workspace.home()); + + for (key, value) in [ + ("HOME", session_home.as_str()), + ("USER", session_user.as_str()), + ("SHELL", "/bin/bash"), + ( + "TERM", + if interactive { + "xterm-256color" + } else { + "dumb" + }, + ), + ] { + if !user_environment.contains_key(key) { + cmd.env(key, value); + } + } +} + +fn configured_user_environment() -> HashMap { + std::env::var(openshell_core::sandbox_env::USER_ENVIRONMENT) + .ok() + .and_then(|json| serde_json::from_str(&json).ok()) + .unwrap_or_default() +} + #[cfg(unix)] pub fn harden_child_process() -> Result<()> { use rustix::process::{Resource, Rlimit, setrlimit}; @@ -545,6 +620,18 @@ fn mount_empty_tmpfs(target: &CString) -> std::io::Result<()> { pub struct ProcessHandle { child: Child, pid: u32, + io: Option, +} + +/// Supervisor-owned canonical-process I/O. These handles outlive individual +/// SSH attachments and are consumed by the main-session multiplexer. +pub enum ProcessIo { + Pty(std::fs::File), + Pipes { + stdin: ChildStdin, + stdout: ChildStdout, + stderr: ChildStderr, + }, } impl ProcessHandle { @@ -628,12 +715,32 @@ impl ProcessHandle { ) -> Result { let mut cmd = Command::new(program); cmd.args(args) - .stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) .kill_on_drop(true) .env(openshell_core::sandbox_env::SANDBOX, "1"); + let mut pty_master = None; + let mut terminal_slave_fd = None; + if interactive { + let winsize = nix::pty::Winsize { + ws_row: 24, + ws_col: 80, + ws_xpixel: 0, + ws_ypixel: 0, + }; + let pty = nix::pty::openpty(Some(&winsize), None).into_diagnostic()?; + let master = std::fs::File::from(pty.master); + let slave = std::fs::File::from(pty.slave); + terminal_slave_fd = Some(slave.as_raw_fd()); + cmd.stdin(slave.try_clone().into_diagnostic()?) + .stdout(slave.try_clone().into_diagnostic()?) + .stderr(slave); + pty_master = Some(master); + } else { + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + } + // Strip supervisor-only identity material from the entrypoint's // inherited environment. The entrypoint drops to the sandbox user // before `exec`; without this strip, sandbox code could recover @@ -641,13 +748,17 @@ impl ProcessHandle { strip_supervisor_only_env(&mut cmd); inject_provider_env(&mut cmd, provider_env); + apply_canonical_process_environment( + &mut cmd, + policy, + workspace, + interactive, + &configured_user_environment(), + ); if let Some(dir) = workspace.root() { cmd.current_dir(dir); } - if let Some(home) = workspace.home() { - cmd.env("HOME", home); - } if matches!(policy.network.mode, NetworkMode::Proxy) { let proxy = policy.network.proxy.as_ref().ok_or_else(|| { @@ -719,9 +830,13 @@ impl ProcessHandle { #[allow(unsafe_code)] unsafe { cmd.pre_exec(move || { - if !interactive { - // Create new process group - libc::setpgid(0, 0); + if let Some(slave_fd) = terminal_slave_fd { + if libc::setsid() < 0 { + return Err(std::io::Error::last_os_error()); + } + set_controlling_tty(slave_fd)?; + } else if libc::setpgid(0, 0) < 0 { + return Err(std::io::Error::last_os_error()); } // Enter network namespace before applying other restrictions @@ -761,13 +876,27 @@ impl ProcessHandle { } } - let child = cmd.spawn().into_diagnostic()?; + let mut child = cmd.spawn().into_diagnostic()?; let pid = child.id().unwrap_or(0); managed_children::register(pid); + let io = if let Some(master) = pty_master { + ProcessIo::Pty(master) + } else { + ProcessIo::Pipes { + stdin: child.stdin.take().expect("canonical stdin must be piped"), + stdout: child.stdout.take().expect("canonical stdout must be piped"), + stderr: child.stderr.take().expect("canonical stderr must be piped"), + } + }; + debug!(pid, program, "Process spawned"); - Ok(Self { child, pid }) + Ok(Self { + child, + pid, + io: Some(io), + }) } #[cfg(not(target_os = "linux"))] @@ -785,24 +914,50 @@ impl ProcessHandle { ) -> Result { let mut cmd = Command::new(program); cmd.args(args) - .stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) .kill_on_drop(true) .env(openshell_core::sandbox_env::SANDBOX, "1"); + let mut pty_master = None; + let mut terminal_slave_fd = None; + #[cfg(unix)] + if interactive { + let winsize = nix::pty::Winsize { + ws_row: 24, + ws_col: 80, + ws_xpixel: 0, + ws_ypixel: 0, + }; + let pty = nix::pty::openpty(Some(&winsize), None).into_diagnostic()?; + let master = std::fs::File::from(pty.master); + let slave = std::fs::File::from(pty.slave); + terminal_slave_fd = Some(slave.as_raw_fd()); + cmd.stdin(slave.try_clone().into_diagnostic()?) + .stdout(slave.try_clone().into_diagnostic()?) + .stderr(slave); + pty_master = Some(master); + } + if !interactive { + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + } + // Strip supervisor-only identity material from the entrypoint's // inherited environment. strip_supervisor_only_env(&mut cmd); inject_provider_env(&mut cmd, provider_env); + apply_canonical_process_environment( + &mut cmd, + policy, + workspace, + interactive, + &configured_user_environment(), + ); if let Some(dir) = workspace.root() { cmd.current_dir(dir); } - if let Some(home) = workspace.home() { - cmd.env("HOME", home); - } if matches!(policy.network.mode, NetworkMode::Proxy) { let proxy = policy.network.proxy.as_ref().ok_or_else(|| { @@ -825,9 +980,9 @@ impl ProcessHandle { } } - // Set up process group for signal handling (non-interactive mode only). - // In interactive mode, we inherit the parent's process group to maintain - // proper terminal control for shells and interactive programs. + // Create a dedicated session for PTY children and a dedicated process + // group for pipe children so attachment signals target only the + // canonical workload tree. // SAFETY: pre_exec runs after fork but before exec in the child process. // setpgid is async-signal-safe and safe to call in this context. #[cfg(unix)] @@ -837,9 +992,13 @@ impl ProcessHandle { #[allow(unsafe_code)] unsafe { cmd.pre_exec(move || { - if !interactive { - // Create new process group - libc::setpgid(0, 0); + if let Some(slave_fd) = terminal_slave_fd { + if libc::setsid() < 0 { + return Err(std::io::Error::last_os_error()); + } + set_controlling_tty(slave_fd)?; + } else if libc::setpgid(0, 0) < 0 { + return Err(std::io::Error::last_os_error()); } // Drop privileges before applying sandbox restrictions. @@ -862,14 +1021,28 @@ impl ProcessHandle { } } - let child = cmd.spawn().into_diagnostic()?; + let mut child = cmd.spawn().into_diagnostic()?; let pid = child.id().unwrap_or(0); #[cfg(target_os = "linux")] managed_children::register(pid); debug!(pid, program, "Process spawned"); - Ok(Self { child, pid }) + let io = if let Some(master) = pty_master { + ProcessIo::Pty(master) + } else { + ProcessIo::Pipes { + stdin: child.stdin.take().expect("canonical stdin must be piped"), + stdout: child.stdout.take().expect("canonical stdout must be piped"), + stderr: child.stderr.take().expect("canonical stderr must be piped"), + } + }; + + Ok(Self { + child, + pid, + io: Some(io), + }) } /// Get the process ID. @@ -878,6 +1051,11 @@ impl ProcessHandle { self.pid } + /// Transfer retained stdio to the main-session multiplexer. + pub fn take_io(&mut self) -> ProcessIo { + self.io.take().expect("canonical process I/O already taken") + } + /// Wait for the process to exit. /// /// # Errors @@ -891,6 +1069,16 @@ impl ProcessHandle { Ok(ProcessStatus::from(status)) } + /// Observe an already-terminated child without blocking. + pub fn try_wait(&mut self) -> std::io::Result> { + let status = self.child.try_wait()?; + if status.is_some() { + #[cfg(target_os = "linux")] + managed_children::unregister(self.pid); + } + Ok(status.map(ProcessStatus::from)) + } + /// Send a signal to the process. /// /// # Errors @@ -2150,6 +2338,12 @@ pub struct ProcessStatus { } impl ProcessStatus { + /// Get the conventional exit code when the process exited normally. + #[must_use] + pub const fn exit_code(&self) -> Option { + self.code + } + /// Get the exit code, or 128 + signal number if killed by signal. #[must_use] pub fn code(&self) -> i32 { @@ -2217,6 +2411,42 @@ mod tests { } } + #[cfg(unix)] + #[tokio::test] + async fn canonical_tty_environment_replaces_supervisor_identity_defaults() { + let current_user = User::from_uid(nix::unistd::geteuid()) + .expect("look up current user") + .expect("current user entry"); + let policy = policy_with_process(ProcessPolicy { + run_as_user: Some(current_user.name.clone()), + run_as_group: None, + }); + let workspace = ResolvedWorkspace::default(); + let mut cmd = Command::new("/usr/bin/env"); + cmd.env_clear() + .env("HOME", "/root") + .env("TERM", "dumb") + .stdout(StdStdio::piped()); + + apply_canonical_process_environment(&mut cmd, &policy, &workspace, true, &HashMap::new()); + + let output = cmd.output().await.expect("run environment probe"); + assert!(output.status.success()); + let environment = String::from_utf8(output.stdout).expect("environment is UTF-8"); + let variables: HashMap<_, _> = environment + .lines() + .filter_map(|line| line.split_once('=')) + .collect(); + + assert_eq!( + variables.get("HOME"), + Some(¤t_user.dir.to_string_lossy().as_ref()) + ); + assert_eq!(variables.get("USER"), Some(¤t_user.name.as_str())); + assert_eq!(variables.get("SHELL"), Some(&"/bin/bash")); + assert_eq!(variables.get("TERM"), Some(&"xterm-256color")); + } + /// Unknown names may yield `Ok(None)` (`… not found …`) or `Err` when NSS fails first /// (e.g. `ENOENT: No such file or directory`). fn assert_unknown_identity_lookup_failed(msg: &str) { diff --git a/crates/openshell-supervisor-process/src/run.rs b/crates/openshell-supervisor-process/src/run.rs index 91e56b7ec8..34b0001110 100644 --- a/crates/openshell-supervisor-process/src/run.rs +++ b/crates/openshell-supervisor-process/src/run.rs @@ -39,6 +39,12 @@ use crate::process::{ ResolvedWorkspace, }; +pub type SidecarExitReport = ( + String, + i32, + tokio::sync::oneshot::Sender>, +); + fn ocsf_ctx() -> &'static openshell_ocsf::SandboxContext { openshell_ocsf::ctx::ctx() } @@ -65,7 +71,8 @@ pub async fn run_process( resolved_process_identity: ResolvedProcessIdentity, enforcement_mode: ProcessEnforcementMode, entrypoint_pid: Arc, - entrypoint_started_tx: Option>, + entrypoint_started_tx: Option>, + sidecar_exit_tx: Option>, provider_credentials: ProviderCredentialState, provider_env: std::collections::HashMap, ca_file_paths: Option<(std::path::PathBuf, std::path::PathBuf)>, @@ -219,6 +226,37 @@ pub async fn run_process( #[cfg(not(target_os = "linux"))] let ssh_netns_fd: Option = None; + #[cfg(target_os = "linux")] + let mut handle = ProcessHandle::spawn( + program, + args, + &workspace, + interactive, + policy, + resolved_process_identity, + enforcement_mode, + netns, + ca_file_paths.as_ref(), + &provider_env, + )?; + + #[cfg(not(target_os = "linux"))] + let mut handle = ProcessHandle::spawn( + program, + args, + &workspace, + interactive, + policy, + resolved_process_identity, + enforcement_mode, + ca_file_paths.as_ref(), + &provider_env, + )?; + + let main_pid = handle.pid(); + let main_session = crate::main_session::MainSession::new(handle.take_io(), main_pid); + let main_instance_id = uuid::Uuid::new_v4().to_string(); + // SSH-spawned shells get http_proxy=http://: exported into // their env so cooperative tools (curl, npm, Node) route through the // CONNECT proxy. Linux uses the netns host_ip; on other targets fall back @@ -236,6 +274,7 @@ pub async fn run_process( let netns_fd = ssh_netns_fd; let ca_paths = ca_file_paths.clone(); let provider_credentials_clone = provider_credentials.clone(); + let main_session_clone = Arc::clone(&main_session); let user_env_clone: std::collections::HashMap = std::env::var(openshell_core::sandbox_env::USER_ENVIRONMENT) .ok() @@ -258,6 +297,7 @@ pub async fn run_process( resolved_process_identity, enforcement_mode, shared_ssh_socket, + main_session_clone, ) .await { @@ -303,55 +343,39 @@ pub async fn run_process( } let supervisor_terminating = Arc::new(AtomicBool::new(false)); + // A canonical process may have completed while the SSH socket was being + // prepared. Never open a readiness-bearing supervisor session for a child + // that is already terminal. + let early_exit = handle.try_wait().into_diagnostic()?; // Spawn the persistent supervisor session if we have a gateway endpoint // and sandbox identity. The session provides relay channels for SSH // connect and ExecSandbox through the gateway. - if let (Some(endpoint), Some(id), Some(socket)) = - (openshell_endpoint, sandbox_id, ssh_socket_path.as_ref()) + let supervisor_session_task = if early_exit.is_none() + && let (Some(endpoint), Some(id), Some(socket)) = + (openshell_endpoint, sandbox_id, ssh_socket_path.as_ref()) { - crate::supervisor_session::spawn( + let task = crate::supervisor_session::spawn( endpoint.to_string(), id.to_string(), socket.clone(), ssh_netns_fd, None, Arc::clone(&supervisor_terminating), + main_instance_id.clone(), ); info!("supervisor session task spawned"); - } - - #[cfg(target_os = "linux")] - let mut handle = ProcessHandle::spawn( - program, - args, - &workspace, - interactive, - policy, - resolved_process_identity, - enforcement_mode, - netns, - ca_file_paths.as_ref(), - &provider_env, - )?; - - #[cfg(not(target_os = "linux"))] - let mut handle = ProcessHandle::spawn( - program, - args, - &workspace, - interactive, - policy, - resolved_process_identity, - enforcement_mode, - ca_file_paths.as_ref(), - &provider_env, - )?; + Some(task) + } else { + None + }; // Store the entrypoint PID so the proxy can resolve TCP peer identity entrypoint_pid.store(handle.pid(), Ordering::Release); - if let Some(tx) = entrypoint_started_tx { - let _ = tx.send(handle.pid()); + if early_exit.is_none() + && let Some(tx) = entrypoint_started_tx + { + let _ = tx.send((handle.pid(), main_instance_id.clone())); } ocsf_emit!( ProcessActivityBuilder::new(ocsf_ctx()) @@ -366,12 +390,15 @@ pub async fn run_process( .build() ); - let outcome = + let outcome = if let Some(status) = early_exit { + ProcessWaitOutcome::Exited(status) + } else { wait_for_process_exit_or_shutdown(&mut handle, timeout_secs, &supervisor_terminating) - .await?; + .await? + }; - let status = match outcome { - ProcessWaitOutcome::Exited(status) => status, + let rendered_code = match outcome { + ProcessWaitOutcome::Exited(status) => status.code(), ProcessWaitOutcome::TimedOut => { ocsf_emit!( ProcessActivityBuilder::new(ocsf_ctx()) @@ -383,7 +410,7 @@ pub async fn run_process( .message("Process timed out, killing") .build() ); - return Ok(124); // Standard timeout exit code + 124 } ProcessWaitOutcome::ShutdownSignal { signal, status } => { info!( @@ -391,10 +418,11 @@ pub async fn run_process( exit_code = status.code(), "Entrypoint exited after supervisor shutdown signal" ); - status + status.code() } }; supervisor_terminating.store(true, Ordering::Release); + main_session.finish(rendered_code).await; ocsf_emit!( ProcessActivityBuilder::new(ocsf_ctx()) @@ -403,12 +431,55 @@ pub async fn run_process( .disposition(DispositionId::Allowed) .severity(SeverityId::Informational) .status(StatusId::Success) - .exit_code(status.code()) - .message(format!("Process exited with code {}", status.code())) + .exit_code(rendered_code) + .message(format!("Process exited with code {rendered_code}")) .build() ); - Ok(status.code()) + if let Some(task) = supervisor_session_task { + task.abort(); + } + if let Some(tx) = sidecar_exit_tx { + let (ack_tx, ack_rx) = tokio::sync::oneshot::channel(); + tx.send((main_instance_id.clone(), rendered_code, ack_tx)) + .await + .map_err(|_| miette::miette!("sidecar exit reporter closed"))?; + ack_rx + .await + .map_err(|_| miette::miette!("sidecar exit reporter dropped acknowledgement"))? + .map_err(|error| miette::miette!(error))?; + } else if let (Some(endpoint), Some(id)) = (openshell_endpoint, sandbox_id) { + report_main_process_exit_until_ack(endpoint, id, &main_instance_id, rendered_code).await; + info!(instance_id = %main_instance_id, "main-process exit acknowledged"); + } + + Ok(rendered_code) +} + +async fn report_main_process_exit_until_ack( + endpoint: &str, + sandbox_id: &str, + instance_id: &str, + exit_code: i32, +) { + let mut retry_delay = Duration::from_millis(250); + loop { + match crate::supervisor_session::report_main_process_exit( + endpoint, + sandbox_id, + instance_id, + exit_code, + ) + .await + { + Ok(()) => return, + Err(error) => { + tracing::warn!(%error, "main-process exit report failed; retrying"); + tokio::time::sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(Duration::from_secs(2)); + } + } + } } enum ProcessWaitOutcome { @@ -486,13 +557,13 @@ fn signal_entrypoint_for_shutdown(_pid: u32, _signal: &'static str) {} #[cfg(unix)] fn signal_pid(pid: u32, signal: nix::sys::signal::Signal, reason: &'static str) { let raw_pid = i32::try_from(pid).unwrap_or(i32::MAX); - if let Err(error) = nix::sys::signal::kill(nix::unistd::Pid::from_raw(raw_pid), signal) { + if let Err(error) = nix::sys::signal::kill(nix::unistd::Pid::from_raw(-raw_pid), signal) { tracing::warn!( pid, signal = ?signal, reason, error = %error, - "failed to signal entrypoint process" + "failed to signal entrypoint process group" ); } } diff --git a/crates/openshell-supervisor-process/src/ssh.rs b/crates/openshell-supervisor-process/src/ssh.rs index 07302da953..bb5ce1546b 100644 --- a/crates/openshell-supervisor-process/src/ssh.rs +++ b/crates/openshell-supervisor-process/src/ssh.rs @@ -4,11 +4,12 @@ //! Embedded SSH server for sandbox access. use crate::child_env; +use crate::main_session::{MainOutput, MainSession}; #[cfg(target_os = "linux")] use crate::managed_children; use crate::process::{ ProcessEnforcementMode, ResolvedProcessIdentity, ResolvedWorkspace, - drop_privileges_with_identity, is_supervisor_only_env_var, + drop_privileges_with_identity, is_supervisor_only_env_var, session_user_and_home, }; use crate::sandbox; use miette::{IntoDiagnostic, Result}; @@ -22,7 +23,7 @@ use openshell_ocsf::{ }; use russh::keys::{Algorithm, PrivateKey}; use russh::server::{Auth, ChannelOpenHandle, Handle, Session}; -use russh::{ChannelId, ChannelOpenFailure}; +use russh::{ChannelId, ChannelOpenFailure, Sig}; use std::collections::HashMap; use std::io::{Read, Write}; use std::os::fd::{AsRawFd, RawFd}; @@ -120,6 +121,7 @@ pub async fn run_ssh_server( resolved_identity: ResolvedProcessIdentity, enforcement_mode: ProcessEnforcementMode, shared_socket: bool, + main_session: Arc, ) -> Result<()> { let (listener, config, ca_paths) = match ssh_server_init( &listen_path, @@ -150,6 +152,7 @@ pub async fn run_ssh_server( let ca_paths = ca_paths.clone(); let provider_credentials = provider_credentials.clone(); let user_environment = user_environment.clone(); + let main_session = Arc::clone(&main_session); tokio::spawn(async move { if let Err(err) = handle_connection( @@ -164,6 +167,7 @@ pub async fn run_ssh_server( user_environment, resolved_identity, enforcement_mode, + main_session, ) .await { @@ -193,6 +197,7 @@ async fn handle_connection( user_environment: HashMap, resolved_identity: ResolvedProcessIdentity, enforcement_mode: ProcessEnforcementMode, + main_session: Arc, ) -> Result<()> { // Access is gated by the Unix-socket filesystem permissions (root-only), // not by an application-level preface. The supervisor bridges the @@ -218,6 +223,7 @@ async fn handle_connection( user_environment, resolved_identity, enforcement_mode, + main_session, ); russh::server::run_stream(config, stream, handler) .await @@ -233,9 +239,32 @@ async fn handle_connection( /// sftp, etc.). #[derive(Default)] struct ChannelState { - input_sender: Option>>, + input_sender: Option, pty_master: Option, pty_request: Option, + main_input_owner: Option, + main_attached: bool, + main_read_only: bool, + main_output_task: Option, +} + +enum InputSender { + Process(mpsc::Sender>), + Main(tokio::sync::mpsc::Sender>), +} + +impl InputSender { + fn send(&self, data: Vec) -> Result<(), &'static str> { + match self { + Self::Process(sender) => sender.send(data).map_err(|_| "process stdin closed"), + Self::Main(sender) => sender.try_send(data).map_err(|error| match error { + tokio::sync::mpsc::error::TrySendError::Full(_) => "canonical stdin buffer is full", + tokio::sync::mpsc::error::TrySendError::Closed(_) => { + "canonical process stdin closed" + } + }), + } + } } struct SshHandler { @@ -248,9 +277,23 @@ struct SshHandler { user_environment: HashMap, resolved_identity: ResolvedProcessIdentity, enforcement_mode: ProcessEnforcementMode, + main_session: Arc, channels: HashMap, } +impl Drop for SshHandler { + fn drop(&mut self) { + for state in self.channels.values_mut() { + if let Some(owner) = state.main_input_owner.take() { + self.main_session.release_input(owner); + } + if let Some(task) = state.main_output_task.take() { + task.abort(); + } + } + } +} + impl SshHandler { #[allow(clippy::too_many_arguments)] fn new( @@ -263,6 +306,7 @@ impl SshHandler { user_environment: HashMap, resolved_identity: ResolvedProcessIdentity, enforcement_mode: ProcessEnforcementMode, + main_session: Arc, ) -> Self { Self { policy, @@ -274,6 +318,7 @@ impl SshHandler { user_environment, resolved_identity, enforcement_mode, + main_session, channels: HashMap::new(), } } @@ -315,7 +360,14 @@ impl russh::server::Handler for SshHandler { channel: ChannelId, _session: &mut Session, ) -> Result<(), Self::Error> { - self.channels.remove(&channel); + if let Some(state) = self.channels.remove(&channel) { + if let Some(owner) = state.main_input_owner { + self.main_session.release_input(owner); + } + if let Some(task) = state.main_output_task { + task.abort(); + } + } Ok(()) } @@ -442,7 +494,10 @@ impl russh::server::Handler for SshHandler { warn!("window_change_request on unknown channel {channel:?}"); return Ok(()); }; - if let Some(master) = state.pty_master.as_ref() { + if state.main_attached { + self.main_session + .resize(col_width, row_height, pixel_width, pixel_height); + } else if let Some(master) = state.pty_master.as_ref() { let winsize = Winsize { ws_row: to_u16(row_height.max(1)), ws_col: to_u16(col_width.max(1)), @@ -493,7 +548,78 @@ impl russh::server::Handler for SshHandler { name: &str, session: &mut Session, ) -> Result<(), Self::Error> { - if name == "sftp" { + if name == "openshell-main" { + let state = self.channels.get_mut(&channel).ok_or_else(|| { + anyhow::anyhow!("subsystem_request on unknown channel {channel:?}") + })?; + if let Some(pty) = state.pty_request.take() { + self.main_session.resize( + pty.col_width, + pty.row_height, + pty.pixel_width, + pty.pixel_height, + ); + } + let (input, input_warning) = if state.main_read_only { + (None, None) + } else { + match self.main_session.acquire_input() { + Ok((owner, input)) => { + state.main_input_owner = Some(owner); + (Some(InputSender::Main(input)), None) + } + Err(error) => { + warn!(%error, "main process input lease unavailable; attaching read-only"); + (None, Some(error)) + } + } + }; + state.main_attached = true; + state.input_sender = input; + let mut output = self.main_session.subscribe(); + let handle = session.handle(); + session.channel_success(channel)?; + if let Some(error) = input_warning { + let _ = handle + .extended_data( + channel, + 1, + format!("openshell: {error}; attached read-only\n").into_bytes(), + ) + .await; + } + let output_task = tokio::spawn(async move { + loop { + match output.recv().await { + Ok(event) => { + let exited = matches!(event, MainOutput::Exit(_)); + send_main_output(&handle, channel, event).await; + if exited { + break; + } + } + Err(error) => { + let _ = handle + .extended_data( + channel, + 1, + format!( + "openshell: attachment fell behind by {} output chunks; reconnect for buffered output\n", + error.skipped + ) + .into_bytes(), + ) + .await; + let _ = handle.close(channel).await; + break; + } + } + } + }); + if let Some(state) = self.channels.get_mut(&channel) { + state.main_output_task = Some(output_task.abort_handle()); + } + } else if name == "sftp" { session.channel_success(channel)?; // sftp-server speaks the SFTP binary protocol over stdin/stdout, // which is exactly what spawn_pipe_exec wires up. This enables @@ -516,7 +642,7 @@ impl russh::server::Handler for SshHandler { let state = self.channels.get_mut(&channel).ok_or_else(|| { anyhow::anyhow!("subsystem_request on unknown channel {channel:?}") })?; - state.input_sender = Some(input_sender); + state.input_sender = Some(InputSender::Process(input_sender)); } else { ocsf_emit!( SshActivityBuilder::new(openshell_ocsf::ctx::ctx()) @@ -542,7 +668,12 @@ impl russh::server::Handler for SshHandler { // Accept the env request so the client knows we handled it, but we // don't actually propagate the variables — the sandbox environment is // controlled via policy. We must reply so VSCode doesn't stall. - let _ = (variable_name, variable_value); + if variable_name == "OPENSHELL_MAIN_READ_ONLY" + && variable_value == "1" + && let Some(state) = self.channels.get_mut(&channel) + { + state.main_read_only = true; + } session.channel_success(channel)?; Ok(()) } @@ -551,14 +682,62 @@ impl russh::server::Handler for SshHandler { &mut self, channel: ChannelId, data: &[u8], - _session: &mut Session, + session: &mut Session, ) -> Result<(), Self::Error> { let Some(state) = self.channels.get(&channel) else { warn!("data on unknown channel {channel:?}"); return Ok(()); }; - if let Some(sender) = state.input_sender.as_ref() { - let _ = sender.send(data.to_vec()); + + // OpenSSH sends Ctrl-C as the terminal interrupt byte for a PTY + // session. Preserve normal job-control behavior while a foreground + // child owns the PTY, but treat Ctrl-C as detach once the canonical + // main process group has reclaimed the foreground. This lets a user + // interrupt `tail -f`, then press Ctrl-C again at the main shell to + // disconnect without terminating the sandbox. + let detach_at = state + .main_attached + .then(|| data.iter().position(|byte| *byte == 0x03)) + .flatten() + .filter( + |_| match self.main_session.main_owns_terminal_foreground() { + Ok(owns_foreground) => owns_foreground, + Err(error) => { + tracing::debug!(%error, "failed to read canonical PTY foreground group"); + false + } + }, + ); + + if let Some(detach_at) = detach_at { + // Preserve bytes preceding Ctrl-C in the same SSH data frame, but + // suppress the interrupt byte and anything after it because the + // attachment is closing. + if detach_at > 0 + && let Some(sender) = state.input_sender.as_ref() + && let Err(error) = sender.send(data[..detach_at].to_vec()) + { + self.close_main_attachment(channel, session.handle(), Some(error)) + .await; + return Ok(()); + } + self.close_main_attachment(channel, session.handle(), None) + .await; + return Ok(()); + } + + if let Some(sender) = state.input_sender.as_ref() + && let Err(error) = sender.send(data.to_vec()) + { + let handle = session.handle(); + let _ = handle + .extended_data( + channel, + 1, + format!("openshell: {error}; closing attachment\n").into_bytes(), + ) + .await; + let _ = handle.close(channel).await; } Ok(()) } @@ -573,15 +752,97 @@ impl russh::server::Handler for SshHandler { // is essential for commands like `cat | tar xf -` which need // stdin EOF to know the input stream is complete. if let Some(state) = self.channels.get_mut(&channel) { + if state.main_attached + && let Some(owner) = state.main_input_owner.take() + { + self.main_session.release_input(owner); + } state.input_sender.take(); } else { warn!("channel_eof on unknown channel {channel:?}"); } Ok(()) } + + async fn signal( + &mut self, + channel: ChannelId, + signal: Sig, + _session: &mut Session, + ) -> Result<(), Self::Error> { + if !self + .channels + .get(&channel) + .is_some_and(|state| state.main_attached) + { + return Ok(()); + } + let signal = match signal { + Sig::HUP => Some(nix::sys::signal::Signal::SIGHUP), + Sig::INT => Some(nix::sys::signal::Signal::SIGINT), + Sig::KILL => Some(nix::sys::signal::Signal::SIGKILL), + Sig::QUIT => Some(nix::sys::signal::Signal::SIGQUIT), + Sig::TERM => Some(nix::sys::signal::Signal::SIGTERM), + _ => None, + }; + if let Some(signal) = signal + && let Err(error) = self.main_session.signal_group(signal) + { + warn!(%error, ?signal, "failed to signal canonical main process group"); + } + Ok(()) + } +} + +async fn send_main_output(handle: &Handle, channel: ChannelId, event: MainOutput) { + match event { + MainOutput::Stdout(data) => { + let _ = handle.data(channel, data).await; + } + MainOutput::Stderr(data) => { + let _ = handle.extended_data(channel, 1, data).await; + } + MainOutput::Exit(code) => { + let _ = handle.eof(channel).await; + let _ = handle + .exit_status_request(channel, code.max(0).unsigned_abs()) + .await; + let _ = handle.close(channel).await; + } + } } impl SshHandler { + async fn close_main_attachment( + &mut self, + channel: ChannelId, + handle: Handle, + error: Option<&str>, + ) { + if let Some(state) = self.channels.get_mut(&channel) { + if let Some(owner) = state.main_input_owner.take() { + self.main_session.release_input(owner); + } + state.input_sender.take(); + if let Some(task) = state.main_output_task.take() { + task.abort(); + } + state.main_attached = false; + } + if let Some(error) = error { + let _ = handle + .extended_data( + channel, + 1, + format!("openshell: {error}; closing attachment\n").into_bytes(), + ) + .await; + } + let _ = handle.eof(channel).await; + let _ = handle.exit_status_request(channel, 0).await; + let _ = handle.close(channel).await; + } + fn start_shell( &mut self, channel: ChannelId, @@ -612,7 +873,7 @@ impl SshHandler { self.enforcement_mode, )?; state.pty_master = Some(pty_master); - state.input_sender = Some(input_sender); + state.input_sender = Some(InputSender::Process(input_sender)); } else { // No PTY requested — use plain pipes so stdout/stderr are // separate and output has clean LF line endings. This is the @@ -631,7 +892,7 @@ impl SshHandler { self.resolved_identity, self.enforcement_mode, )?; - state.input_sender = Some(input_sender); + state.input_sender = Some(InputSender::Process(input_sender)); } Ok(()) } @@ -711,38 +972,6 @@ impl Default for PtyRequest { } } -/// Derive the session USER and HOME from the policy's `run_as_user`. -/// -/// For name-based identities, looks up the home directory via `/etc/passwd` -/// (or defaults to `/home/{user}`). -/// -/// For numeric UIDs, there is no passwd entry, so the default remains -/// `("{uid}", "/sandbox")`. Docker replaces that default with its resolved -/// image workspace. -fn session_user_and_home(policy: &SandboxPolicy, workdir_home: Option<&str>) -> (String, String) { - let (user, default_home) = match policy.process.run_as_user.as_deref() { - Some(user) if !user.is_empty() => { - // Numeric UID — no passwd entry expected; use default HOME. - if user.parse::().is_ok() { - (user.to_string(), "/sandbox".to_string()) - } else { - // Name-based identity — look up home from /etc/passwd. - let home = nix::unistd::User::from_name(user) - .ok() - .flatten() - .map_or_else( - || format!("/home/{user}"), - |u| u.dir.to_string_lossy().into_owned(), - ); - (user.to_string(), home) - } - } - _ => ("sandbox".to_string(), "/sandbox".to_string()), - }; - let home = workdir_home.map_or(default_home, str::to_string); - (user, home) -} - #[allow(clippy::too_many_arguments)] fn apply_child_env( cmd: &mut Command, @@ -1665,11 +1894,11 @@ mod tests { let (tx_b, rx_b) = mpsc::channel::>(); let mut state_a = ChannelState { - input_sender: Some(tx_a), + input_sender: Some(InputSender::Process(tx_a)), ..Default::default() }; let state_b = ChannelState { - input_sender: Some(tx_b), + input_sender: Some(InputSender::Process(tx_b)), ..Default::default() }; @@ -2012,7 +2241,9 @@ mod tests { /// The handler gets `netns_fd: None` so `connect_in_netns` performs a plain /// TCP connect, making the forwarding path reachable without a network /// namespace. - async fn authenticated_test_client() -> russh::client::Handle { + async fn authenticated_test_client_with_main( + main_session: Arc, + ) -> russh::client::Handle { // Scoped so the `!Send` ThreadRng is dropped before the first await. let host_key = { let mut rng = rand::rng(); @@ -2034,6 +2265,7 @@ mod tests { HashMap::new(), ResolvedProcessIdentity::default(), ProcessEnforcementMode::NetworkOnly, + main_session, ); let (server_stream, client_stream) = tokio::io::duplex(64 * 1024); @@ -2065,6 +2297,71 @@ mod tests { client } + async fn authenticated_test_client() -> russh::client::Handle { + authenticated_test_client_with_main(MainSession::inert()).await + } + + #[tokio::test] + async fn abrupt_transport_drop_releases_main_input_lease() { + let main_session = MainSession::inert(); + let client = authenticated_test_client_with_main(Arc::clone(&main_session)).await; + let channel = client.channel_open_session().await.expect("open session"); + channel + .request_subsystem(true, "openshell-main") + .await + .expect("attach main subsystem"); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + match main_session.acquire_input() { + Err(_) => break, + Ok((owner, _)) => main_session.release_input(owner), + } + tokio::task::yield_now().await; + } + }) + .await + .expect("main subsystem should acquire canonical input lease"); + + drop(channel); + drop(client); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if main_session.acquire_input().is_ok() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("handler drop should release canonical input lease"); + } + + #[tokio::test] + async fn main_subsystem_applies_initial_pty_dimensions() { + let (main_session, _slave) = MainSession::terminal_for_test(); + let client = authenticated_test_client_with_main(Arc::clone(&main_session)).await; + let channel = client.channel_open_session().await.expect("open session"); + channel + .request_pty(true, "xterm-256color", 200, 60, 1600, 900, &[]) + .await + .expect("request PTY"); + channel + .request_subsystem(true, "openshell-main") + .await + .expect("attach main subsystem"); + + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if main_session.terminal_size_for_test() == (200, 60) { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("main subsystem should apply the initial PTY dimensions"); + } + #[tokio::test] async fn direct_tcpip_rejects_non_loopback_destination() { let client = authenticated_test_client().await; diff --git a/crates/openshell-supervisor-process/src/supervisor_session.rs b/crates/openshell-supervisor-process/src/supervisor_session.rs index 6cdc9e7d6c..e8a140e483 100644 --- a/crates/openshell-supervisor-process/src/supervisor_session.rs +++ b/crates/openshell-supervisor-process/src/supervisor_session.rs @@ -19,9 +19,9 @@ use std::time::Duration; use openshell_core::proto::open_shell_client::OpenShellClient; use openshell_core::proto::{ - GatewayMessage, RelayFrame, RelayInit, RelayOpen, RelayOpenResult, SupervisorHeartbeat, - SupervisorHello, SupervisorMessage, TcpRelayTarget, gateway_message, relay_open, - supervisor_message, + GatewayMessage, RelayFrame, RelayInit, RelayOpen, RelayOpenResult, + ReportMainProcessExitRequest, SupervisorHeartbeat, SupervisorHello, SupervisorMessage, + TcpRelayTarget, gateway_message, relay_open, supervisor_message, }; use openshell_ocsf::{ ActivityId, ConnectionInfo, Endpoint, NetworkActivityBuilder, OcsfEvent, SandboxContext, @@ -281,6 +281,7 @@ pub fn spawn( netns_fd: Option, expected_ssh_peer_pid: Option, terminating: Arc, + instance_id: String, ) -> tokio::task::JoinHandle<()> { tokio::spawn(run_session_loop( endpoint, @@ -289,6 +290,7 @@ pub fn spawn( netns_fd, expected_ssh_peer_pid, terminating, + instance_id, )) } @@ -299,6 +301,7 @@ async fn run_session_loop( netns_fd: Option, expected_ssh_peer_pid: Option, terminating: Arc, + instance_id: String, ) { let mut backoff = INITIAL_BACKOFF; let mut attempt: u64 = 0; @@ -313,6 +316,7 @@ async fn run_session_loop( netns_fd, expected_ssh_peer_pid, Arc::clone(&terminating), + &instance_id, ) .await { @@ -344,6 +348,7 @@ async fn run_single_session( netns_fd: Option, expected_ssh_peer_pid: Option, terminating: Arc, + instance_id: &str, ) -> Result<(), Box> { // Connect to the gateway. The same `Channel` is used for both the // long-lived control stream and all data-plane `RelayStream` calls, so @@ -359,11 +364,10 @@ async fn run_single_session( let outbound = tokio_stream::wrappers::ReceiverStream::new(rx); // Send hello as the first message. - let instance_id = uuid::Uuid::new_v4().to_string(); tx.send(SupervisorMessage { payload: Some(supervisor_message::Payload::Hello(SupervisorHello { sandbox_id: sandbox_id.to_string(), - instance_id: instance_id.clone(), + instance_id: instance_id.to_string(), })), }) .await @@ -443,6 +447,27 @@ async fn run_single_session( } } +/// Report the canonical process result and wait for durable handling. +pub async fn report_main_process_exit( + endpoint: &str, + sandbox_id: &str, + instance_id: &str, + exit_code: i32, +) -> Result<(), Box> { + let channel = grpc_client::connect_channel_pub(endpoint) + .await + .map_err(|error| format!("connect failed: {error}"))?; + let mut client = OpenShellClient::new(channel); + client + .report_main_process_exit(ReportMainProcessExitRequest { + sandbox_id: sandbox_id.to_string(), + instance_id: instance_id.to_string(), + exit_code, + }) + .await?; + Ok(()) +} + struct GatewayMessageContext<'a> { sandbox_id: &'a str, ssh_socket_path: &'a std::path::Path, diff --git a/docs/observability/accessing-logs.mdx b/docs/observability/accessing-logs.mdx index e96dfeab2f..4b755f74cc 100644 --- a/docs/observability/accessing-logs.mdx +++ b/docs/observability/accessing-logs.mdx @@ -41,19 +41,22 @@ For durable log storage, use the log files inside the sandbox or enable [OCSF JS ## Direct Filesystem Access -Use `openshell sandbox connect` to open a shell inside the sandbox and read the log files directly: +Start an independent shell with `sandbox exec` to read log files directly: ```text -openshell sandbox connect my-sandbox +openshell sandbox exec --name my-sandbox --tty -- /bin/bash -l sandbox@my-sandbox:~$ cat /var/log/openshell.2026-04-01.log ``` -You can also run a one-off command without an interactive shell: +Or run a one-off command without an interactive shell: ```shell -openshell sandbox connect my-sandbox -- cat /var/log/openshell.2026-04-01.log +openshell sandbox exec --name my-sandbox -- cat /var/log/openshell.2026-04-01.log ``` +`sandbox connect` attaches to the sandbox's existing canonical main process; it +does not start a new shell. + The log files inside the sandbox contain the complete record, including events that the gRPC push channel can drop under load. The push channel is bounded and drops events rather than blocking. ## Filtering by Event Type diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index 7c6739d263..8ac5354894 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -18,6 +18,12 @@ Delete remains independent and removes compute plus driver-owned persistent state. While a sandbox is stopped, gateway access paths and exposed services remain unavailable. +The gateway forwards one exact, persisted main-process specification to every +driver. Drivers serialize that specification in +`OPENSHELL_MAIN_PROCESS_SPEC`; they do not install an idle `sleep` workload or +reconstruct argv with shell parsing. Runtime restart policies are disabled so +an exited canonical process remains a terminal sandbox error. + ## Configure a Compute Driver Configure the compute driver on the gateway. Current releases accept one driver per gateway. Set `compute_drivers` in the gateway TOML file: diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index abd95d130a..a7bf69154b 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -20,6 +20,20 @@ Create a sandbox with a single command. For example, to create a sandbox with Cl openshell sandbox create -- claude ``` +The trailing command is the sandbox's canonical main process. OpenShell starts +it once and attaches your terminal to it. With no trailing command, OpenShell +starts `/bin/bash -l` in a retained pseudo-terminal. Add `--detach` to create +the sandbox without attaching: + +```shell +openshell sandbox create --name worker --detach -- ./worker +``` + +`--upload` cannot yet be combined with a trailing main command because uploads +finish after the canonical process starts. Create a scratch sandbox, upload the +files, then launch the workload with `sandbox exec`, or build the files into the +sandbox image. + For automation, use `--output json` or `--output yaml` to get machine-readable sandbox metadata after creation: ```shell @@ -142,12 +156,22 @@ For default policy coverage by agent, refer to [Default Policy](/reference/defau ## Connect to a Sandbox -Open an SSH session into a running sandbox: +Attach to the canonical main process in a running sandbox: ```shell openshell sandbox connect my-sandbox ``` +Disconnecting does not stop the process or close its stdin. A later `connect` +attaches to the same process instance and replays up to 1 MiB of recent +output. One attachment owns stdin at a time. Use `sandbox exec --tty -- +/bin/bash -l` when you want a new independent shell instead. + +For a TTY main process, `Ctrl-C` interrupts a foreground job such as `tail -f`. +When the canonical main process itself owns the terminal foreground, `Ctrl-C` +disconnects the attachment without terminating the main process. Non-TTY +connections retain normal signal behavior. + Launch VS Code or Cursor directly into the sandbox workspace: ```shell @@ -477,7 +501,7 @@ Every sandbox moves through a defined set of phases: | Stopping | The gateway accepted a stop request and is stopping compute while retaining persistent state. | | Stopped | Compute is stopped and access is unavailable. The sandbox record and driver-owned persistent workspace remain. | | Starting | Compute is starting. The sandbox becomes usable only after a fresh supervisor session connects. | -| Error | Something went wrong during provisioning or execution. Check logs with `openshell logs` for details. | +| Error | Provisioning failed or the canonical main process exited unexpectedly. Main-process exit is terminal even with exit code 0. Check logs with `openshell logs`. | | Deleting | The sandbox is being torn down. The system releases resources and purges credentials. | The compute backend can become ready before the sandbox supervisor connects to @@ -487,6 +511,11 @@ After a gateway restart, an existing sandbox can return to `Provisioning` temporarily while its supervisor reconnects. Wait for the phase to return to `Ready` before you connect to the sandbox or execute commands. +The gateway records a canonical main-process exit as `Ready=False` with reason +`MainProcessExited`. It also sets `status.exit_code`; signal exits use the +standard `128 + signal` convention. Compute runtimes do not automatically +restart that process. + ## Sandbox Compute Drivers The gateway's configured compute driver determines how OpenShell creates each sandbox. The CLI workflow stays the same across drivers: you create, connect to, inspect, and delete sandboxes through the gateway API. diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index 83150c39d1..34cdd8b0ab 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -121,7 +121,9 @@ Pass a policy YAML file when creating the sandbox: openshell sandbox create --policy ./my-policy.yaml -- claude ``` -`openshell sandbox create` keeps the sandbox running after the initial command exits, which is useful when you plan to iterate on the policy. Add `--no-keep` if you want the sandbox deleted automatically instead. +The trailing command is the sandbox's canonical main process. If it exits, the +sandbox enters `Error`; use `sandbox exec` for one-shot commands that should not +define sandbox health. To avoid passing `--policy` every time, set a default policy with an environment variable: diff --git a/e2e/rust/src/harness/sandbox.rs b/e2e/rust/src/harness/sandbox.rs index 3475353041..16839ee6b7 100644 --- a/e2e/rust/src/harness/sandbox.rs +++ b/e2e/rust/src/harness/sandbox.rs @@ -51,11 +51,12 @@ pub struct SandboxGuard { } impl SandboxGuard { - /// Create a sandbox that runs a command to completion (no `--keep`). + /// Create a persistent scratch sandbox and optionally run a command in it. /// - /// Captures the full CLI output and parses the sandbox name from it. - /// The sandbox is created synchronously (the CLI blocks until the command - /// finishes). + /// Arguments before `--` are forwarded to `sandbox create`; arguments after + /// `--` are run with `sandbox exec`. This keeps generic E2E tests focused on + /// the behavior of their one-shot payload now that a trailing create command + /// is the sandbox's canonical main process and its exit is terminal. /// /// # Arguments /// @@ -67,10 +68,19 @@ impl SandboxGuard { /// Returns an error if the CLI exits with a non-zero status or the sandbox /// name cannot be parsed from the output. pub async fn create(args: &[&str]) -> Result { + let separator = args.iter().position(|arg| *arg == "--"); + let (create_args, command) = separator.map_or((args, &[][..]), |index| { + (&args[..index], &args[index + 1..]) + }); + let mut cmd = openshell_cmd(); - cmd.arg("sandbox").arg("create"); - for arg in args { - cmd.arg(arg); + cmd.arg("sandbox").arg("create").arg("--detach"); + for arg in create_args { + // `--no-keep` described the old disposable-exec create flow and + // conflicts with the detached scratch sandbox used by this helper. + if *arg != "--no-keep" { + cmd.arg(arg); + } } cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); @@ -94,20 +104,32 @@ impl SandboxGuard { format!("could not parse sandbox name from create output:\n{combined}") })?; - Ok(Self { + let mut guard = Self { name, create_output: combined, child: None, cleaned_up: false, - }) + }; + + if !command.is_empty() { + match guard.exec(command).await { + Ok(exec_output) => guard.create_output.push_str(&exec_output), + Err(err) => { + guard.cleanup().await; + return Err(err); + } + } + } + + Ok(guard) } - /// Create a sandbox with `--keep` that runs a long-lived background - /// command. + /// Create a sandbox with a long-lived canonical main command and connect + /// to it in the background. /// - /// The CLI process runs in the background. This method polls its stdout - /// for `ready_marker` (a string the background command prints when it is - /// ready to accept work). Sandbox name is parsed from the output header. + /// Creation is detached because the harness captures output. This method + /// then runs `sandbox connect` and polls the retained main-process output + /// for `ready_marker`. /// /// # Arguments /// @@ -125,6 +147,46 @@ impl SandboxGuard { Self::create_keep_with_args(&[], command, ready_marker).await } + /// Create a sandbox with a detached canonical main command. + /// + /// Unlike [`SandboxGuard::create_keep`], this does not open an attachment, + /// which lets tests control competing and reconnecting clients directly. + pub async fn create_detached_main(command: &[&str]) -> Result { + let mut cmd = openshell_cmd(); + cmd.arg("sandbox") + .arg("create") + .arg("--detach") + .arg("--") + .args(command) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let output = timeout(SANDBOX_READY_TIMEOUT, cmd.output()) + .await + .map_err(|_| format!("sandbox create timed out after {SANDBOX_READY_TIMEOUT:?}"))? + .map_err(|e| format!("failed to spawn openshell: {e}"))?; + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let combined = format!("{stdout}{stderr}"); + + if !output.status.success() { + return Err(format!( + "sandbox create failed (exit {:?}):\n{combined}", + output.status.code() + )); + } + + let name = extract_sandbox_name(&combined).ok_or_else(|| { + format!("could not parse sandbox name from create output:\n{combined}") + })?; + Ok(Self { + name, + create_output: combined, + child: None, + cleaned_up: false, + }) + } + /// Like [`SandboxGuard::create_keep`], but forwards extra flags to /// `sandbox create` (e.g. `--policy `, `--name `) before the /// `-- ` separator. @@ -139,17 +201,44 @@ impl SandboxGuard { command: &[&str], ready_marker: &str, ) -> Result { - let mut cmd = openshell_cmd(); - cmd.arg("sandbox").arg("create").arg("--keep"); + let mut create_cmd = openshell_cmd(); + create_cmd.arg("sandbox").arg("create").arg("--detach"); for arg in create_args { - cmd.arg(arg); + create_cmd.arg(arg); } - cmd.arg("--").args(command); - cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + create_cmd.arg("--").args(command); + create_cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); - let mut child = cmd - .spawn() + let create_output = timeout(SANDBOX_READY_TIMEOUT, create_cmd.output()) + .await + .map_err(|_| format!("sandbox create timed out after {SANDBOX_READY_TIMEOUT:?}"))? .map_err(|e| format!("failed to spawn openshell: {e}"))?; + let create_stdout = String::from_utf8_lossy(&create_output.stdout).to_string(); + let create_stderr = String::from_utf8_lossy(&create_output.stderr).to_string(); + let create_combined = format!("{create_stdout}{create_stderr}"); + + if !create_output.status.success() { + return Err(format!( + "sandbox create failed (exit {:?}):\n{create_combined}", + create_output.status.code() + )); + } + + let sandbox_name = extract_sandbox_name(&create_combined).ok_or_else(|| { + format!("could not parse sandbox name from create output:\n{create_combined}") + })?; + + let mut connect_cmd = openshell_cmd(); + connect_cmd + .arg("sandbox") + .arg("connect") + .arg(&sandbox_name) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = connect_cmd + .spawn() + .map_err(|e| format!("failed to spawn openshell connect: {e}"))?; let stdout = child.stdout.take().expect("stdout must be piped"); let mut reader = BufReader::new(stdout).lines(); @@ -167,8 +256,7 @@ impl SandboxGuard { } }); - let mut accumulated = String::new(); - let mut name: Option = None; + let mut accumulated = create_combined; let mut ready = false; let poll_result = timeout(SANDBOX_READY_TIMEOUT, async { @@ -177,13 +265,6 @@ impl SandboxGuard { accumulated.push_str(&clean); accumulated.push('\n'); - // Try to extract the sandbox name from the header. - if name.is_none() - && let Some(n) = extract_sandbox_name(&accumulated) - { - name = Some(n); - } - // Check for the ready marker. if clean.contains(ready_marker) { ready = true; @@ -214,19 +295,11 @@ impl SandboxGuard { let _ = child.kill().await; let stderr_output = collect_stderr(); return Err(format!( - "sandbox create exited before ready marker '{ready_marker}' was seen.\n\ + "sandbox connect exited before ready marker '{ready_marker}' was seen.\n\ Stdout:\n{accumulated}\nStderr:\n{stderr_output}" )); } - let sandbox_name = name.ok_or_else(|| { - let stderr_output = collect_stderr(); - format!( - "could not parse sandbox name from create output:\n\ - Stdout:\n{accumulated}\nStderr:\n{stderr_output}" - ) - })?; - Ok(Self { name: sandbox_name, create_output: accumulated, @@ -235,11 +308,13 @@ impl SandboxGuard { }) } - /// Create a sandbox that runs a command, with `--upload` to pre-load files. + /// Create a detached scratch sandbox with pre-loaded files, then exec a + /// command in it. /// /// Equivalent to: /// ```text - /// openshell sandbox create --upload : [extra_args...] -- + /// openshell sandbox create --detach --upload : [extra_args...] + /// openshell sandbox exec -- /// ``` /// /// The `--no-git-ignore` flag is passed to avoid needing a git repository. @@ -265,11 +340,11 @@ impl SandboxGuard { command: &[&str], ) -> Result { let mut cmd = openshell_cmd(); - cmd.arg("sandbox").arg("create"); + cmd.arg("sandbox").arg("create").arg("--detach"); for (local, dest) in uploads { cmd.arg("--upload").arg(format!("{local}:{dest}")); } - cmd.arg("--no-git-ignore").arg("--").args(command); + cmd.arg("--no-git-ignore"); cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); let output = timeout(SANDBOX_READY_TIMEOUT, cmd.output()) @@ -294,12 +369,22 @@ impl SandboxGuard { format!("could not parse sandbox name from create output:\n{combined}") })?; - Ok(Self { + let mut guard = Self { name, create_output: combined, child: None, cleaned_up: false, - }) + }; + + match guard.exec(command).await { + Ok(exec_output) => guard.create_output.push_str(&exec_output), + Err(err) => { + guard.cleanup().await; + return Err(err); + } + } + + Ok(guard) } /// Upload local files to the sandbox via `openshell sandbox upload`. diff --git a/e2e/rust/tests/local_driver_token_restart.rs b/e2e/rust/tests/local_driver_token_restart.rs index 54e354833d..5223e3a704 100644 --- a/e2e/rust/tests/local_driver_token_restart.rs +++ b/e2e/rust/tests/local_driver_token_restart.rs @@ -3,11 +3,11 @@ #![cfg(feature = "e2e")] -//! Local-driver E2E regression for sandbox supervisor restart from bootstrap -//! JWT material. Docker and Podman supervisors reload their mounted token file -//! after a container restart. VM sandboxes reboot from persisted driver state -//! after the VM driver restarts. Local single-player gateway configs should -//! mint that token with `exp = 0` so reconnect does not depend on token refresh. +//! Local-driver E2E regression for sandbox bootstrap JWT material and terminal +//! main-process semantics. Local single-player gateways mint non-expiring +//! bootstrap tokens. Stopping a Docker or Podman sandbox container is terminal +//! and must not relaunch its canonical process; restarting the VM gateway still +//! exercises driver recovery without deliberately stopping the sandbox. use std::fs; use std::path::PathBuf; @@ -18,6 +18,7 @@ use base64::Engine as _; use openshell_e2e::harness::cli::{wait_for_healthy, wait_for_sandbox_exec_contains}; use openshell_e2e::harness::container::{ContainerEngine, e2e_driver}; use openshell_e2e::harness::gateway::ManagedGateway; +use openshell_e2e::harness::output::strip_ansi; use openshell_e2e::harness::sandbox::SandboxGuard; use prost::Message; use tokio::time::sleep; @@ -266,7 +267,7 @@ fn require_non_expiring_token(token: &str, context: &str) -> Result<(), String> Ok(()) } -async fn restart_container_sandbox( +async fn stop_container_sandbox( engine: &ContainerEngine, driver: LocalDriver, namespace: &str, @@ -277,10 +278,34 @@ async fn restart_container_sandbox( require_non_expiring_token(&token, "local-driver bootstrap JWT")?; run_engine(engine, &["stop".to_string(), container_id.clone()])?; - wait_for_container_running(engine, &container_id, false, Duration::from_secs(60)).await?; + wait_for_container_running(engine, &container_id, false, Duration::from_secs(60)).await +} - run_engine(engine, &["start".to_string(), container_id.clone()])?; - wait_for_container_running(engine, &container_id, true, Duration::from_secs(60)).await +async fn wait_for_sandbox_error(sandbox_name: &str, timeout: Duration) -> Result<(), String> { + let deadline = Instant::now() + timeout; + let mut last_output = String::new(); + while Instant::now() < deadline { + let mut cmd = openshell_e2e::harness::binary::openshell_cmd(); + cmd.args(["sandbox", "get", sandbox_name]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let output = cmd + .output() + .await + .map_err(|err| format!("failed to run sandbox get: {err}"))?; + last_output = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + if output.status.success() && strip_ansi(&last_output).contains("Phase: Error") { + return Ok(()); + } + sleep(Duration::from_millis(250)).await; + } + Err(format!( + "sandbox {sandbox_name} did not enter Error within {timeout:?}:\n{last_output}" + )) } async fn restart_vm_sandbox(gateway: &ManagedGateway, sandbox_name: &str) -> Result<(), String> { @@ -316,11 +341,9 @@ async fn wait_for_driver_reconnect(driver: LocalDriver, sandbox_name: &str) -> R } #[tokio::test] -async fn local_driver_sandbox_restarts_with_non_expiring_bootstrap_jwt() { +async fn local_driver_restart_or_stop_respects_main_process_lifecycle() { let Some(driver) = LocalDriver::from_env() else { - eprintln!( - "Skipping local-driver token restart test: e2e driver is not Docker, Podman, or VM" - ); + eprintln!("Skipping local-driver lifecycle test: e2e driver is not Docker, Podman, or VM"); return; }; let namespace = if driver.is_container() { @@ -329,7 +352,7 @@ async fn local_driver_sandbox_restarts_with_non_expiring_bootstrap_jwt() { .filter(|value| !value.trim().is_empty()) else { eprintln!( - "Skipping local-driver token restart test: OPENSHELL_E2E_SANDBOX_NAMESPACE is unavailable" + "Skipping local-driver lifecycle test: OPENSHELL_E2E_SANDBOX_NAMESPACE is unavailable" ); return; }; @@ -349,7 +372,7 @@ async fn local_driver_sandbox_restarts_with_non_expiring_bootstrap_jwt() { let Some(gateway) = ManagedGateway::from_env().expect("load managed e2e gateway metadata") else { eprintln!( - "Skipping local-driver token restart test: VM e2e gateway is not managed by this test run" + "Skipping local-driver lifecycle test: VM e2e gateway is not managed by this test run" ); return; }; @@ -375,9 +398,12 @@ async fn local_driver_sandbox_restarts_with_non_expiring_bootstrap_jwt() { let namespace = namespace .as_deref() .expect("container namespace should be set"); - restart_container_sandbox(engine, driver, namespace, &sandbox.name) + stop_container_sandbox(engine, driver, namespace, &sandbox.name) + .await + .expect("stop sandbox container"); + wait_for_sandbox_error(&sandbox.name, Duration::from_secs(30)) .await - .expect("restart sandbox container"); + .expect("stopped canonical process should make sandbox terminal"); } LocalDriver::Vm => { let gateway = gateway.as_ref().expect("managed VM gateway should be set"); @@ -387,9 +413,11 @@ async fn local_driver_sandbox_restarts_with_non_expiring_bootstrap_jwt() { } } - wait_for_driver_reconnect(driver, &sandbox.name) - .await - .expect("sandbox supervisor should reconnect after local-driver restart"); + if driver == LocalDriver::Vm { + wait_for_driver_reconnect(driver, &sandbox.name) + .await + .expect("sandbox supervisor should reconnect after VM driver restart"); + } sandbox.cleanup().await; } diff --git a/e2e/rust/tests/provider_auto_create.rs b/e2e/rust/tests/provider_auto_create.rs index 45729776ea..194c7fbf8b 100644 --- a/e2e/rust/tests/provider_auto_create.rs +++ b/e2e/rust/tests/provider_auto_create.rs @@ -89,12 +89,10 @@ async fn auto_created_provider_credential_available_in_sandbox() { let mut cmd = openshell_cmd(); cmd.arg("sandbox") .arg("create") + .arg("--detach") .arg("--provider") .arg("claude-code") .arg("--auto-providers") - .arg("--") - .arg("printenv") - .arg("ANTHROPIC_API_KEY") .env("ANTHROPIC_API_KEY", TEST_API_KEY) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -110,7 +108,26 @@ async fn auto_created_provider_credential_available_in_sandbox() { let clean = strip_ansi(&combined); // Parse sandbox name for cleanup. - let sandbox_name = extract_field(&combined, "Name"); + let sandbox_name = extract_field(&combined, "Created sandbox"); + let exec_output = if let Some(ref name) = sandbox_name { + let mut exec_cmd = openshell_cmd(); + exec_cmd + .args([ + "sandbox", + "exec", + "--name", + name, + "--no-tty", + "--", + "printenv", + "ANTHROPIC_API_KEY", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + Some(exec_cmd.output().await.expect("failed to run sandbox exec")) + } else { + None + }; // Always clean up, even if assertions fail. if let Some(ref name) = sandbox_name { @@ -125,18 +142,29 @@ async fn auto_created_provider_credential_available_in_sandbox() { output.status.code() ); + let exec_output = exec_output.expect("sandbox name should be present"); + let exec_clean = strip_ansi(&format!( + "{}{}", + String::from_utf8_lossy(&exec_output.stdout), + String::from_utf8_lossy(&exec_output.stderr) + )); + assert!( + exec_output.status.success(), + "sandbox exec should succeed:\n{exec_clean}" + ); + assert!( clean.contains("Created provider claude-code"), "output should confirm provider auto-creation:\n{clean}" ); assert!( - contains_placeholder_for_env_key(&clean, "ANTHROPIC_API_KEY"), - "sandbox should have placeholder ANTHROPIC_API_KEY in its environment:\n{clean}" + contains_placeholder_for_env_key(&exec_clean, "ANTHROPIC_API_KEY"), + "sandbox should have placeholder ANTHROPIC_API_KEY in its environment:\n{exec_clean}" ); assert!( - !clean.contains(TEST_API_KEY), - "sandbox should not expose the raw ANTHROPIC_API_KEY secret:\n{clean}" + !exec_clean.contains(TEST_API_KEY), + "sandbox should not expose the raw ANTHROPIC_API_KEY secret:\n{exec_clean}" ); } diff --git a/e2e/rust/tests/sandbox_labels.rs b/e2e/rust/tests/sandbox_labels.rs index 890b1ad960..4de545d7bb 100644 --- a/e2e/rust/tests/sandbox_labels.rs +++ b/e2e/rust/tests/sandbox_labels.rs @@ -33,15 +33,13 @@ fn extract_sandbox_name(output: &str) -> Option { async fn create_sandbox_with_labels(name: &str, labels: &[(&str, &str)]) -> String { let mut cmd = openshell_cmd(); - cmd.args(["sandbox", "create", "--name", name]); + cmd.args(["sandbox", "create", "--detach", "--name", name]); for (key, value) in labels { cmd.arg("--label").arg(format!("{key}={value}")); } - cmd.args(["--", "echo", "test"]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); let output = cmd.output().await.expect("spawn openshell sandbox create"); let stdout = String::from_utf8_lossy(&output.stdout).to_string(); diff --git a/e2e/rust/tests/sandbox_lifecycle.rs b/e2e/rust/tests/sandbox_lifecycle.rs index 75e34b3b4f..0b0f4e0e66 100644 --- a/e2e/rust/tests/sandbox_lifecycle.rs +++ b/e2e/rust/tests/sandbox_lifecycle.rs @@ -9,6 +9,7 @@ use std::time::Duration; use openshell_e2e::harness::binary::{openshell_cmd, openshell_tty_cmd}; use openshell_e2e::harness::output::{extract_field, strip_ansi}; use openshell_e2e::harness::sandbox::SandboxGuard; +use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::time::{Instant, sleep}; const SANDBOX_PRESENCE_TIMEOUT: Duration = Duration::from_secs(30); @@ -219,7 +220,7 @@ async fn sandbox_can_be_deleted_while_stopped() { } #[tokio::test] -async fn sandbox_create_keeps_sandbox_after_tty_command_by_default() { +async fn canonical_main_exit_transitions_persistent_sandbox_to_error() { let mut cmd = openshell_tty_cmd(&["sandbox", "create", "--", "echo", "OK"]); cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); @@ -229,15 +230,9 @@ async fn sandbox_create_keeps_sandbox_after_tty_command_by_default() { let combined = normalize_output(&format!("{stdout}{stderr}")); assert!( - output.status.success(), - "sandbox create should succeed (exit {:?}):\n{combined}", - output.status.code() + !output.status.success(), + "main-process exit must fail create" ); - assert!( - combined.contains("OK"), - "expected command output in:\n{combined}" - ); - let sandbox_name = extract_sandbox_name(&combined).expect("sandbox name should be present in output"); @@ -249,9 +244,183 @@ async fn sandbox_create_keeps_sandbox_after_tty_command_by_default() { ); } + let mut get_cmd = openshell_cmd(); + get_cmd + .args(["sandbox", "get", &sandbox_name]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let get_output = get_cmd.output().await.expect("spawn openshell sandbox get"); + let details = normalize_output(&format!( + "{}{}", + String::from_utf8_lossy(&get_output.stdout), + String::from_utf8_lossy(&get_output.stderr), + )); + assert!( + get_output.status.success(), + "sandbox get failed:\n{details}" + ); + assert!( + details.contains("Phase: Error"), + "expected terminal sandbox phase:\n{details}" + ); + delete_sandbox(&sandbox_name).await; } +#[tokio::test] +async fn canonical_tty_main_uses_sandbox_environment() { + let script = r#"printf 'canonical_env home=%s user=%s term=%s\n' "$HOME" "$USER" "$TERM"; while true; do sleep 1; done"#; + let mut sandbox = + SandboxGuard::create_keep_with_args(&["--tty"], &["sh", "-lc", script], "canonical_env") + .await + .expect("create canonical TTY process"); + + let output = normalize_output(&sandbox.create_output); + let environment = output + .lines() + .find(|line| line.contains("canonical_env")) + .expect("canonical environment output"); + let field = |name: &str| { + environment + .split_whitespace() + .find_map(|value| value.strip_prefix(&format!("{name}="))) + .unwrap_or_default() + }; + + assert!( + !field("home").is_empty() && field("home") != "/root", + "canonical process must not inherit the supervisor HOME: {environment}" + ); + assert!( + !field("user").is_empty(), + "canonical process USER must identify the sandbox user: {environment}" + ); + assert!( + !field("term").is_empty() && field("term") != "dumb", + "canonical TTY process must receive a usable TERM: {environment}" + ); + + sandbox.cleanup().await; +} + +#[tokio::test] +async fn canonical_main_disconnect_reconnect_replays_history_for_same_process() { + const FIRST_MARKER: &str = "sequence=0001"; + let script = r#"n=1; while true; do printf 'main_pid=%s sequence=%04d\n' "$$" "$n"; n=$((n + 1)); sleep 0.2; done"#; + let mut sandbox = SandboxGuard::create_detached_main(&["sh", "-lc", script]) + .await + .expect("create retained canonical main process"); + + let mut owner_cmd = openshell_cmd(); + owner_cmd + .args(["sandbox", "connect", &sandbox.name]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut owner = owner_cmd.spawn().expect("spawn input-owning attachment"); + let owner_stdout = owner.stdout.take().expect("owner stdout"); + let mut owner_lines = BufReader::new(owner_stdout).lines(); + let owner_line = tokio::time::timeout(Duration::from_secs(30), owner_lines.next_line()) + .await + .expect("owner output timeout") + .expect("read owner output") + .expect("owner output should remain open"); + assert!( + owner_line.contains("main_pid="), + "unexpected owner output: {owner_line}" + ); + + let mut observer_cmd = openshell_cmd(); + observer_cmd + .args(["sandbox", "connect", &sandbox.name]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut observer = observer_cmd.spawn().expect("spawn competing attachment"); + let observer_stderr = observer.stderr.take().expect("observer stderr"); + let mut observer_errors = BufReader::new(observer_stderr).lines(); + let warning = tokio::time::timeout(Duration::from_secs(30), observer_errors.next_line()) + .await + .expect("observer warning timeout") + .expect("read observer warning") + .expect("observer warning should remain open"); + assert!( + warning.contains("already has an input owner") && warning.contains("read-only"), + "competing attachment should become read-only: {warning}" + ); + let observer_stdout = observer.stdout.take().expect("observer stdout"); + let mut observer_lines = BufReader::new(observer_stdout).lines(); + let observed = tokio::time::timeout(Duration::from_secs(30), observer_lines.next_line()) + .await + .expect("observer output timeout") + .expect("read observer output") + .expect("observer output should remain open"); + assert!( + observed.contains("main_pid="), + "read-only attachment should observe output: {observed}" + ); + + owner.kill().await.expect("disconnect input owner"); + owner.wait().await.expect("wait for input owner disconnect"); + observer.kill().await.expect("disconnect observer"); + observer.wait().await.expect("wait for observer disconnect"); + sleep(Duration::from_millis(600)).await; + + let mut reconnect_cmd = openshell_cmd(); + reconnect_cmd + .args(["sandbox", "connect", &sandbox.name]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut reconnect = reconnect_cmd.spawn().expect("spawn reconnect attachment"); + let reconnect_stdout = reconnect.stdout.take().expect("reconnect stdout"); + let mut reconnect_lines = BufReader::new(reconnect_stdout).lines(); + let mut replay = Vec::new(); + tokio::time::timeout(Duration::from_secs(30), async { + while replay.len() < 5 { + let line = reconnect_lines + .next_line() + .await + .expect("read reconnect output") + .expect("reconnect output should remain open"); + if line.contains("main_pid=") { + replay.push(line); + } + } + }) + .await + .expect("reconnect history timeout"); + + let first_pid = owner_line + .split_whitespace() + .find_map(|field| field.strip_prefix("main_pid=")) + .expect("owner output pid"); + assert!( + replay.iter().any(|line| line.contains(FIRST_MARKER)), + "reconnect should replay the beginning of retained history: {replay:?}" + ); + assert!( + replay + .iter() + .all(|line| line.contains(&format!("main_pid={first_pid}"))), + "reconnect should target the same canonical process: {replay:?}" + ); + assert!( + replay.iter().any(|line| !line.contains(FIRST_MARKER)), + "reconnect should observe output beyond the first record: {replay:?}" + ); + + reconnect + .kill() + .await + .expect("disconnect reconnect attachment"); + reconnect + .wait() + .await + .expect("wait for reconnect disconnect"); + sandbox.cleanup().await; +} + #[tokio::test] async fn sandbox_create_with_no_keep_cleans_up_after_tty_command() { let mut cmd = openshell_tty_cmd(&["sandbox", "create", "--no-keep", "--", "echo", "OK"]); @@ -263,15 +432,9 @@ async fn sandbox_create_with_no_keep_cleans_up_after_tty_command() { let combined = normalize_output(&format!("{stdout}{stderr}")); assert!( - output.status.success(), - "sandbox create should succeed (exit {:?}):\n{combined}", - output.status.code() - ); - assert!( - combined.contains("OK"), - "expected command output in:\n{combined}" + !output.status.success(), + "main-process exit must fail create" ); - let sandbox_name = extract_sandbox_name(&combined).expect("sandbox name should be present in output"); diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index 0ce4f61539..5fe633a584 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -142,6 +142,10 @@ message DriverSandboxSpec { // ServiceAccount token bootstrap instead). Never echoed to the public // Sandbox proto. string sandbox_token = 11 [(openshell.options.v1.secret) = true]; + // Exact canonical command forwarded to the supervisor without shell parsing. + repeated string command = 12; + // Allocate a retained pseudo-terminal for the canonical process. + bool tty = 13; } message ResourceRequirements { diff --git a/proto/openshell.proto b/proto/openshell.proto index 4dd290090d..2dc70eec01 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -455,6 +455,13 @@ service OpenShell { }; } + // Persist the canonical main process result before the supervisor exits. + rpc ReportMainProcessExit(ReportMainProcessExitRequest) returns (ReportMainProcessExitResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "sandbox" + }; + } + // Raw byte relay between supervisor and gateway. // // The supervisor initiates this call after receiving a RelayOpen message @@ -772,6 +779,7 @@ message ComputeDriverCapabilities { // Driver-reported implementation version from the startup capability snapshot. string driver_version = 2; + } // Public sandbox resource exposed by the OpenShell API. @@ -817,6 +825,12 @@ message SandboxSpec { // managed fleet-wide. reserved 11; reserved "proposal_approval_mode"; + // Canonical command launched once by the sandbox supervisor. No shell + // parsing is performed. The gateway normalizes an omitted command to the + // portable scratch login shell before persistence. + repeated string command = 12; + // Allocate a retained pseudo-terminal for the main process. + bool tty = 13; } message ResourceRequirements { @@ -880,6 +894,12 @@ message SandboxStatus { SandboxPhase phase = 6; // Currently active policy version (updated when sandbox reports loaded). uint32 current_policy_version = 7; + // Supervisor instance currently associated with the canonical main process. + // The gateway uses this to reject stale exit reports after a restart. + string main_process_instance_id = 8; + // Normalized main process result. Signal exits use 128 + signal number. + // Presence indicates that the main process exited and the sandbox is in Error. + optional int32 exit_code = 9; } // User-facing sandbox condition derived from driver-native conditions. @@ -2128,6 +2148,17 @@ message SupervisorHeartbeat {} // Gateway heartbeat. message GatewayHeartbeat {} +// Terminal result reported before the supervisor shuts down. A successful RPC +// response confirms that the result was durably handled by the gateway. +message ReportMainProcessExitRequest { + string sandbox_id = 1; + string instance_id = 2; + // Normalized process result. Signal exits use 128 + signal number. + int32 exit_code = 3; +} + +message ReportMainProcessExitResponse {} + // Gateway requests the supervisor to open a relay channel. // // On receiving this, the supervisor should initiate a RelayStream RPC to diff --git a/python/openshell/_proto/__init__.py b/python/openshell/_proto/__init__.py index 3ace22421c..ee4cf2db8b 100644 --- a/python/openshell/_proto/__init__.py +++ b/python/openshell/_proto/__init__.py @@ -2,7 +2,11 @@ # Sandbox messages and phase enums moved into openshell.proto. Keep aliases on # datamodel_pb2 so existing Python callers and E2E tests continue to work. -for _name in ("Sandbox", "SandboxSpec", "SandboxTemplate"): +for _name in ( + "Sandbox", + "SandboxSpec", + "SandboxTemplate", +): if not hasattr(datamodel_pb2, _name): setattr(datamodel_pb2, _name, getattr(openshell_pb2, _name)) diff --git a/python/openshell/sandbox.py b/python/openshell/sandbox.py index a76be8dd13..b66ed936b6 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -130,6 +130,7 @@ def _normalize_bearer( class SandboxStatusRef: phase: int current_policy_version: int + exit_code: int | None = None class _ImmutableLabels(dict[str, str]): @@ -1092,6 +1093,9 @@ def _sandbox_ref(sandbox: openshell_pb2.Sandbox) -> SandboxRef: status=SandboxStatusRef( phase=status.phase if status else 0, current_policy_version=status.current_policy_version if status else 0, + exit_code=status.exit_code + if status is not None and status.HasField("exit_code") + else None, ), labels=sandbox.metadata.labels if sandbox.metadata else {}, ) diff --git a/python/openshell/sandbox_test.py b/python/openshell/sandbox_test.py index 9ff84341e7..a059192460 100644 --- a/python/openshell/sandbox_test.py +++ b/python/openshell/sandbox_test.py @@ -1772,6 +1772,15 @@ def test_sandbox_ref_retains_gateway_labels() -> None: assert dict(ref.labels) == {"aiq": "deep-research", "env": "dev"} +def test_sandbox_ref_includes_main_process_result() -> None: + proto = _make_sandbox_proto("sandbox-1", "job-1") + proto.status.exit_code = 0 + + status = _sandbox_ref(proto).status + + assert status.exit_code == 0 + + def test_returned_labels_are_immutable() -> None: proto = _make_sandbox_proto("sandbox-1", "job-1", {"aiq": "deep-research"}) ref = _sandbox_ref(proto) diff --git a/sdk/go/openshell/v1/internal/converter/copy.go b/sdk/go/openshell/v1/internal/converter/copy.go index e2523a61c3..dc98588fdd 100644 --- a/sdk/go/openshell/v1/internal/converter/copy.go +++ b/sdk/go/openshell/v1/internal/converter/copy.go @@ -26,6 +26,15 @@ func CopyBoolPtr(p *bool) *bool { return &v } +// CopyInt32Ptr returns a copy of an *int32 pointer. +func CopyInt32Ptr(p *int32) *int32 { + if p == nil { + return nil + } + v := *p + return &v +} + // CopyStringSlice returns a copy of a string slice. // Returns nil for nil input. func CopyStringSlice(s []string) []string { diff --git a/sdk/go/openshell/v1/internal/converter/coverage_test.go b/sdk/go/openshell/v1/internal/converter/coverage_test.go index 33edebf64f..49532f59be 100644 --- a/sdk/go/openshell/v1/internal/converter/coverage_test.go +++ b/sdk/go/openshell/v1/internal/converter/coverage_test.go @@ -29,6 +29,8 @@ func TestConverterCoversAllProtoFields_SandboxSpec(t *testing.T) { "policy": true, "providers": true, "resource_requirements": true, + "command": true, + "tty": true, } assertAllFieldsCovered(t, (&pb.SandboxSpec{}).ProtoReflect().Descriptor(), handled, nil) @@ -59,9 +61,13 @@ func TestConverterCoversAllProtoFields_SandboxStatus(t *testing.T) { "phase": true, "conditions": true, "current_policy_version": true, + "exit_code": true, } + // The instance ID is an internal gateway/supervisor fencing token exposed + // only through the raw protobuf API. + skipped := fieldSet{"main_process_instance_id": true} - assertAllFieldsCovered(t, (&pb.SandboxStatus{}).ProtoReflect().Descriptor(), handled, nil) + assertAllFieldsCovered(t, (&pb.SandboxStatus{}).ProtoReflect().Descriptor(), handled, skipped) } func TestConverterCoversAllProtoFields_SandboxCondition(t *testing.T) { diff --git a/sdk/go/openshell/v1/internal/converter/sandbox.go b/sdk/go/openshell/v1/internal/converter/sandbox.go index f44210fd2e..5d26a1a7e2 100644 --- a/sdk/go/openshell/v1/internal/converter/sandbox.go +++ b/sdk/go/openshell/v1/internal/converter/sandbox.go @@ -76,6 +76,8 @@ func sandboxSpecFromProto(spec *pb.SandboxSpec) types.SandboxSpec { result.GPUCount = gpu.Count } } + result.Command = CopyStringSlice(spec.GetCommand()) + result.TTY = spec.GetTty() return result } @@ -99,6 +101,7 @@ func sandboxStatusFromProto(status *pb.SandboxStatus) types.SandboxStatus { LastTransitionTime: c.GetLastTransitionTime(), }) } + result.ExitCode = CopyInt32Ptr(status.ExitCode) return result } @@ -220,6 +223,9 @@ func SandboxSpecToProto(spec *types.SandboxSpec) *pb.SandboxSpec { } } + result.Command = CopyStringSlice(spec.Command) + result.Tty = spec.TTY + return result } diff --git a/sdk/go/openshell/v1/internal/converter/sandbox_test.go b/sdk/go/openshell/v1/internal/converter/sandbox_test.go index b0b721eda1..8293a784c0 100644 --- a/sdk/go/openshell/v1/internal/converter/sandbox_test.go +++ b/sdk/go/openshell/v1/internal/converter/sandbox_test.go @@ -19,6 +19,7 @@ import ( func TestSandboxFromProto(t *testing.T) { userNS := true gpuCount := uint32(2) + exitCode := int32(0) proto := &pb.Sandbox{ Metadata: &dm.ObjectMeta{ Id: "sb-1", @@ -56,14 +57,18 @@ func TestSandboxFromProto(t *testing.T) { Count: &gpuCount, }, }, + Command: []string{"/opt/agent", "--serve"}, + Tty: false, }, Status: &pb.SandboxStatus{ - SandboxName: "sb-compute-1", - AgentPod: "agent-pod-xyz", - AgentFd: "fd-agent", - SandboxFd: "fd-sandbox", - Phase: pb.SandboxPhase_SANDBOX_PHASE_READY, - CurrentPolicyVersion: 7, + SandboxName: "sb-compute-1", + AgentPod: "agent-pod-xyz", + AgentFd: "fd-agent", + SandboxFd: "fd-sandbox", + Phase: pb.SandboxPhase_SANDBOX_PHASE_READY, + CurrentPolicyVersion: 7, + MainProcessInstanceId: "instance-1", + ExitCode: &exitCode, Conditions: []*pb.SandboxCondition{ { Type: "Ready", @@ -95,6 +100,8 @@ func TestSandboxFromProto(t *testing.T) { assert.Equal(t, []string{"claude", "github"}, s.Spec.Providers) require.NotNil(t, s.Spec.GPUCount) assert.Equal(t, uint32(2), *s.Spec.GPUCount) + assert.Equal(t, []string{"/opt/agent", "--serve"}, s.Spec.Command) + assert.False(t, s.Spec.TTY) // Template require.NotNil(t, s.Spec.Template) @@ -125,6 +132,8 @@ func TestSandboxFromProto(t *testing.T) { assert.Equal(t, "AllGood", s.Status.Conditions[0].Reason) assert.Equal(t, "Sandbox is ready", s.Status.Conditions[0].Message) assert.Equal(t, "2024-01-01T00:00:00Z", s.Status.Conditions[0].LastTransitionTime) + require.NotNil(t, s.Status.ExitCode) + assert.Equal(t, int32(0), *s.Status.ExitCode) } func TestSandboxFromProto_TemplateResourcesDeepCopy(t *testing.T) { @@ -243,6 +252,8 @@ func TestSandboxToProto(t *testing.T) { }, Providers: []string{"prov-a"}, GPUCount: &gpuCount, + Command: []string{"/opt/agent", "--serve"}, + TTY: false, }, } @@ -263,6 +274,8 @@ func TestSandboxToProto(t *testing.T) { assert.Equal(t, "info", p.Spec.LogLevel) assert.Equal(t, map[string]string{"KEY": "val"}, p.Spec.Environment) assert.Equal(t, []string{"prov-a"}, p.Spec.Providers) + assert.Equal(t, []string{"/opt/agent", "--serve"}, p.Spec.Command) + assert.False(t, p.Spec.Tty) require.NotNil(t, p.Spec.ResourceRequirements) require.NotNil(t, p.Spec.ResourceRequirements.Gpu) diff --git a/sdk/go/openshell/v1/types/sandbox.go b/sdk/go/openshell/v1/types/sandbox.go index 5851ff48d7..6ce51d84a1 100644 --- a/sdk/go/openshell/v1/types/sandbox.go +++ b/sdk/go/openshell/v1/types/sandbox.go @@ -27,7 +27,9 @@ type SandboxSpec struct { Providers []string GPUCount *uint32 // Policy is the security policy for the sandbox. Nil means no policy specified. - Policy *SandboxPolicy + Policy *SandboxPolicy + Command []string + TTY bool } // SandboxTemplate defines the container template for a sandbox. @@ -52,6 +54,7 @@ type SandboxStatus struct { Phase SandboxPhase Conditions []SandboxCondition CurrentPolicyVersion uint32 + ExitCode *int32 } // SandboxCondition describes an observed condition of a sandbox. diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index 102688df4f..38e54f419e 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -1113,8 +1113,14 @@ type SandboxSpec struct { // Portable resource requirements used by the gateway for driver selection // and by drivers for provisioning. ResourceRequirements *ResourceRequirements `protobuf:"bytes,9,opt,name=resource_requirements,json=resourceRequirements,proto3" json:"resource_requirements,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Canonical command launched once by the sandbox supervisor. No shell + // parsing is performed. The gateway normalizes an omitted command to the + // portable scratch login shell before persistence. + Command []string `protobuf:"bytes,12,rep,name=command,proto3" json:"command,omitempty"` + // Allocate a retained pseudo-terminal for the main process. + Tty bool `protobuf:"varint,13,opt,name=tty,proto3" json:"tty,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SandboxSpec) Reset() { @@ -1189,6 +1195,20 @@ func (x *SandboxSpec) GetResourceRequirements() *ResourceRequirements { return nil } +func (x *SandboxSpec) GetCommand() []string { + if x != nil { + return x.Command + } + return nil +} + +func (x *SandboxSpec) GetTty() bool { + if x != nil { + return x.Tty + } + return false +} + type ResourceRequirements struct { state protoimpl.MessageState `protogen:"open.v1"` // GPU requirements for the sandbox. Presence indicates a GPU request. @@ -1425,8 +1445,14 @@ type SandboxStatus struct { Phase SandboxPhase `protobuf:"varint,6,opt,name=phase,proto3,enum=openshell.v1.SandboxPhase" json:"phase,omitempty"` // Currently active policy version (updated when sandbox reports loaded). CurrentPolicyVersion uint32 `protobuf:"varint,7,opt,name=current_policy_version,json=currentPolicyVersion,proto3" json:"current_policy_version,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Supervisor instance currently associated with the canonical main process. + // The gateway uses this to reject stale exit reports after a restart. + MainProcessInstanceId string `protobuf:"bytes,8,opt,name=main_process_instance_id,json=mainProcessInstanceId,proto3" json:"main_process_instance_id,omitempty"` + // Normalized main process result. Signal exits use 128 + signal number. + // Presence indicates that the main process exited and the sandbox is in Error. + ExitCode *int32 `protobuf:"varint,9,opt,name=exit_code,json=exitCode,proto3,oneof" json:"exit_code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SandboxStatus) Reset() { @@ -1508,6 +1534,20 @@ func (x *SandboxStatus) GetCurrentPolicyVersion() uint32 { return 0 } +func (x *SandboxStatus) GetMainProcessInstanceId() string { + if x != nil { + return x.MainProcessInstanceId + } + return "" +} + +func (x *SandboxStatus) GetExitCode() int32 { + if x != nil && x.ExitCode != nil { + return *x.ExitCode + } + return 0 +} + // User-facing sandbox condition derived from driver-native conditions. type SandboxCondition struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -9524,6 +9564,105 @@ func (*GatewayHeartbeat) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{133} } +// Terminal result reported before the supervisor shuts down. A successful RPC +// response confirms that the result was durably handled by the gateway. +type ReportMainProcessExitRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + InstanceId string `protobuf:"bytes,2,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + // Normalized process result. Signal exits use 128 + signal number. + ExitCode int32 `protobuf:"varint,3,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReportMainProcessExitRequest) Reset() { + *x = ReportMainProcessExitRequest{} + mi := &file_openshell_proto_msgTypes[134] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReportMainProcessExitRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReportMainProcessExitRequest) ProtoMessage() {} + +func (x *ReportMainProcessExitRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[134] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReportMainProcessExitRequest.ProtoReflect.Descriptor instead. +func (*ReportMainProcessExitRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{134} +} + +func (x *ReportMainProcessExitRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *ReportMainProcessExitRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *ReportMainProcessExitRequest) GetExitCode() int32 { + if x != nil { + return x.ExitCode + } + return 0 +} + +type ReportMainProcessExitResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReportMainProcessExitResponse) Reset() { + *x = ReportMainProcessExitResponse{} + mi := &file_openshell_proto_msgTypes[135] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReportMainProcessExitResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReportMainProcessExitResponse) ProtoMessage() {} + +func (x *ReportMainProcessExitResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[135] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReportMainProcessExitResponse.ProtoReflect.Descriptor instead. +func (*ReportMainProcessExitResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{135} +} + // Gateway requests the supervisor to open a relay channel. // // On receiving this, the supervisor should initiate a RelayStream RPC to @@ -9550,7 +9689,7 @@ type RelayOpen struct { func (x *RelayOpen) Reset() { *x = RelayOpen{} - mi := &file_openshell_proto_msgTypes[134] + mi := &file_openshell_proto_msgTypes[136] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9562,7 +9701,7 @@ func (x *RelayOpen) String() string { func (*RelayOpen) ProtoMessage() {} func (x *RelayOpen) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[134] + mi := &file_openshell_proto_msgTypes[136] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9575,7 +9714,7 @@ func (x *RelayOpen) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayOpen.ProtoReflect.Descriptor instead. func (*RelayOpen) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{134} + return file_openshell_proto_rawDescGZIP(), []int{136} } func (x *RelayOpen) GetChannelId() string { @@ -9642,7 +9781,7 @@ type SshRelayTarget struct { func (x *SshRelayTarget) Reset() { *x = SshRelayTarget{} - mi := &file_openshell_proto_msgTypes[135] + mi := &file_openshell_proto_msgTypes[137] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9654,7 +9793,7 @@ func (x *SshRelayTarget) String() string { func (*SshRelayTarget) ProtoMessage() {} func (x *SshRelayTarget) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[135] + mi := &file_openshell_proto_msgTypes[137] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9667,7 +9806,7 @@ func (x *SshRelayTarget) ProtoReflect() protoreflect.Message { // Deprecated: Use SshRelayTarget.ProtoReflect.Descriptor instead. func (*SshRelayTarget) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{135} + return file_openshell_proto_rawDescGZIP(), []int{137} } // TCP target dialed by the supervisor from inside the sandbox. @@ -9683,7 +9822,7 @@ type TcpRelayTarget struct { func (x *TcpRelayTarget) Reset() { *x = TcpRelayTarget{} - mi := &file_openshell_proto_msgTypes[136] + mi := &file_openshell_proto_msgTypes[138] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9695,7 +9834,7 @@ func (x *TcpRelayTarget) String() string { func (*TcpRelayTarget) ProtoMessage() {} func (x *TcpRelayTarget) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[136] + mi := &file_openshell_proto_msgTypes[138] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9708,7 +9847,7 @@ func (x *TcpRelayTarget) ProtoReflect() protoreflect.Message { // Deprecated: Use TcpRelayTarget.ProtoReflect.Descriptor instead. func (*TcpRelayTarget) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{136} + return file_openshell_proto_rawDescGZIP(), []int{138} } func (x *TcpRelayTarget) GetHost() string { @@ -9736,7 +9875,7 @@ type RelayInit struct { func (x *RelayInit) Reset() { *x = RelayInit{} - mi := &file_openshell_proto_msgTypes[137] + mi := &file_openshell_proto_msgTypes[139] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9748,7 +9887,7 @@ func (x *RelayInit) String() string { func (*RelayInit) ProtoMessage() {} func (x *RelayInit) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[137] + mi := &file_openshell_proto_msgTypes[139] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9761,7 +9900,7 @@ func (x *RelayInit) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayInit.ProtoReflect.Descriptor instead. func (*RelayInit) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{137} + return file_openshell_proto_rawDescGZIP(), []int{139} } func (x *RelayInit) GetChannelId() string { @@ -9788,7 +9927,7 @@ type RelayFrame struct { func (x *RelayFrame) Reset() { *x = RelayFrame{} - mi := &file_openshell_proto_msgTypes[138] + mi := &file_openshell_proto_msgTypes[140] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9800,7 +9939,7 @@ func (x *RelayFrame) String() string { func (*RelayFrame) ProtoMessage() {} func (x *RelayFrame) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[138] + mi := &file_openshell_proto_msgTypes[140] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9813,7 +9952,7 @@ func (x *RelayFrame) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayFrame.ProtoReflect.Descriptor instead. func (*RelayFrame) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{138} + return file_openshell_proto_rawDescGZIP(), []int{140} } func (x *RelayFrame) GetPayload() isRelayFrame_Payload { @@ -9872,7 +10011,7 @@ type RelayOpenResult struct { func (x *RelayOpenResult) Reset() { *x = RelayOpenResult{} - mi := &file_openshell_proto_msgTypes[139] + mi := &file_openshell_proto_msgTypes[141] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9884,7 +10023,7 @@ func (x *RelayOpenResult) String() string { func (*RelayOpenResult) ProtoMessage() {} func (x *RelayOpenResult) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[139] + mi := &file_openshell_proto_msgTypes[141] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9897,7 +10036,7 @@ func (x *RelayOpenResult) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayOpenResult.ProtoReflect.Descriptor instead. func (*RelayOpenResult) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{139} + return file_openshell_proto_rawDescGZIP(), []int{141} } func (x *RelayOpenResult) GetChannelId() string { @@ -9934,7 +10073,7 @@ type RelayClose struct { func (x *RelayClose) Reset() { *x = RelayClose{} - mi := &file_openshell_proto_msgTypes[140] + mi := &file_openshell_proto_msgTypes[142] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9946,7 +10085,7 @@ func (x *RelayClose) String() string { func (*RelayClose) ProtoMessage() {} func (x *RelayClose) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[140] + mi := &file_openshell_proto_msgTypes[142] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9959,7 +10098,7 @@ func (x *RelayClose) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayClose.ProtoReflect.Descriptor instead. func (*RelayClose) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{140} + return file_openshell_proto_rawDescGZIP(), []int{142} } func (x *RelayClose) GetChannelId() string { @@ -9993,7 +10132,7 @@ type L7RequestSample struct { func (x *L7RequestSample) Reset() { *x = L7RequestSample{} - mi := &file_openshell_proto_msgTypes[141] + mi := &file_openshell_proto_msgTypes[143] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10005,7 +10144,7 @@ func (x *L7RequestSample) String() string { func (*L7RequestSample) ProtoMessage() {} func (x *L7RequestSample) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[141] + mi := &file_openshell_proto_msgTypes[143] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10018,7 +10157,7 @@ func (x *L7RequestSample) ProtoReflect() protoreflect.Message { // Deprecated: Use L7RequestSample.ProtoReflect.Descriptor instead. func (*L7RequestSample) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{141} + return file_openshell_proto_rawDescGZIP(), []int{143} } func (x *L7RequestSample) GetMethod() string { @@ -10092,7 +10231,7 @@ type DenialSummary struct { func (x *DenialSummary) Reset() { *x = DenialSummary{} - mi := &file_openshell_proto_msgTypes[142] + mi := &file_openshell_proto_msgTypes[144] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10104,7 +10243,7 @@ func (x *DenialSummary) String() string { func (*DenialSummary) ProtoMessage() {} func (x *DenialSummary) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[142] + mi := &file_openshell_proto_msgTypes[144] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10117,7 +10256,7 @@ func (x *DenialSummary) ProtoReflect() protoreflect.Message { // Deprecated: Use DenialSummary.ProtoReflect.Descriptor instead. func (*DenialSummary) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{142} + return file_openshell_proto_rawDescGZIP(), []int{144} } func (x *DenialSummary) GetSandboxId() string { @@ -10252,7 +10391,7 @@ type DenialGroupCount struct { func (x *DenialGroupCount) Reset() { *x = DenialGroupCount{} - mi := &file_openshell_proto_msgTypes[143] + mi := &file_openshell_proto_msgTypes[145] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10264,7 +10403,7 @@ func (x *DenialGroupCount) String() string { func (*DenialGroupCount) ProtoMessage() {} func (x *DenialGroupCount) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[143] + mi := &file_openshell_proto_msgTypes[145] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10277,7 +10416,7 @@ func (x *DenialGroupCount) ProtoReflect() protoreflect.Message { // Deprecated: Use DenialGroupCount.ProtoReflect.Descriptor instead. func (*DenialGroupCount) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{143} + return file_openshell_proto_rawDescGZIP(), []int{145} } func (x *DenialGroupCount) GetDenyGroup() string { @@ -10310,7 +10449,7 @@ type NetworkActivitySummary struct { func (x *NetworkActivitySummary) Reset() { *x = NetworkActivitySummary{} - mi := &file_openshell_proto_msgTypes[144] + mi := &file_openshell_proto_msgTypes[146] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10322,7 +10461,7 @@ func (x *NetworkActivitySummary) String() string { func (*NetworkActivitySummary) ProtoMessage() {} func (x *NetworkActivitySummary) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[144] + mi := &file_openshell_proto_msgTypes[146] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10335,7 +10474,7 @@ func (x *NetworkActivitySummary) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkActivitySummary.ProtoReflect.Descriptor instead. func (*NetworkActivitySummary) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{144} + return file_openshell_proto_rawDescGZIP(), []int{146} } func (x *NetworkActivitySummary) GetNetworkActivityCount() uint32 { @@ -10409,7 +10548,7 @@ type PolicyChunk struct { func (x *PolicyChunk) Reset() { *x = PolicyChunk{} - mi := &file_openshell_proto_msgTypes[145] + mi := &file_openshell_proto_msgTypes[147] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10421,7 +10560,7 @@ func (x *PolicyChunk) String() string { func (*PolicyChunk) ProtoMessage() {} func (x *PolicyChunk) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[145] + mi := &file_openshell_proto_msgTypes[147] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10434,7 +10573,7 @@ func (x *PolicyChunk) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyChunk.ProtoReflect.Descriptor instead. func (*PolicyChunk) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{145} + return file_openshell_proto_rawDescGZIP(), []int{147} } func (x *PolicyChunk) GetId() string { @@ -10580,7 +10719,7 @@ type DraftPolicyUpdate struct { func (x *DraftPolicyUpdate) Reset() { *x = DraftPolicyUpdate{} - mi := &file_openshell_proto_msgTypes[146] + mi := &file_openshell_proto_msgTypes[148] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10592,7 +10731,7 @@ func (x *DraftPolicyUpdate) String() string { func (*DraftPolicyUpdate) ProtoMessage() {} func (x *DraftPolicyUpdate) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[146] + mi := &file_openshell_proto_msgTypes[148] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10605,7 +10744,7 @@ func (x *DraftPolicyUpdate) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftPolicyUpdate.ProtoReflect.Descriptor instead. func (*DraftPolicyUpdate) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{146} + return file_openshell_proto_rawDescGZIP(), []int{148} } func (x *DraftPolicyUpdate) GetDraftVersion() uint64 { @@ -10663,7 +10802,7 @@ type SubmitPolicyAnalysisRequest struct { func (x *SubmitPolicyAnalysisRequest) Reset() { *x = SubmitPolicyAnalysisRequest{} - mi := &file_openshell_proto_msgTypes[147] + mi := &file_openshell_proto_msgTypes[149] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10675,7 +10814,7 @@ func (x *SubmitPolicyAnalysisRequest) String() string { func (*SubmitPolicyAnalysisRequest) ProtoMessage() {} func (x *SubmitPolicyAnalysisRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[147] + mi := &file_openshell_proto_msgTypes[149] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10688,7 +10827,7 @@ func (x *SubmitPolicyAnalysisRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitPolicyAnalysisRequest.ProtoReflect.Descriptor instead. func (*SubmitPolicyAnalysisRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{147} + return file_openshell_proto_rawDescGZIP(), []int{149} } func (x *SubmitPolicyAnalysisRequest) GetSummaries() []*DenialSummary { @@ -10751,7 +10890,7 @@ type SubmitPolicyAnalysisResponse struct { func (x *SubmitPolicyAnalysisResponse) Reset() { *x = SubmitPolicyAnalysisResponse{} - mi := &file_openshell_proto_msgTypes[148] + mi := &file_openshell_proto_msgTypes[150] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10763,7 +10902,7 @@ func (x *SubmitPolicyAnalysisResponse) String() string { func (*SubmitPolicyAnalysisResponse) ProtoMessage() {} func (x *SubmitPolicyAnalysisResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[148] + mi := &file_openshell_proto_msgTypes[150] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10776,7 +10915,7 @@ func (x *SubmitPolicyAnalysisResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitPolicyAnalysisResponse.ProtoReflect.Descriptor instead. func (*SubmitPolicyAnalysisResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{148} + return file_openshell_proto_rawDescGZIP(), []int{150} } func (x *SubmitPolicyAnalysisResponse) GetAcceptedChunks() uint32 { @@ -10822,7 +10961,7 @@ type GetDraftPolicyRequest struct { func (x *GetDraftPolicyRequest) Reset() { *x = GetDraftPolicyRequest{} - mi := &file_openshell_proto_msgTypes[149] + mi := &file_openshell_proto_msgTypes[151] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10834,7 +10973,7 @@ func (x *GetDraftPolicyRequest) String() string { func (*GetDraftPolicyRequest) ProtoMessage() {} func (x *GetDraftPolicyRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[149] + mi := &file_openshell_proto_msgTypes[151] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10847,7 +10986,7 @@ func (x *GetDraftPolicyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftPolicyRequest.ProtoReflect.Descriptor instead. func (*GetDraftPolicyRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{149} + return file_openshell_proto_rawDescGZIP(), []int{151} } func (x *GetDraftPolicyRequest) GetName() string { @@ -10887,7 +11026,7 @@ type GetDraftPolicyResponse struct { func (x *GetDraftPolicyResponse) Reset() { *x = GetDraftPolicyResponse{} - mi := &file_openshell_proto_msgTypes[150] + mi := &file_openshell_proto_msgTypes[152] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10899,7 +11038,7 @@ func (x *GetDraftPolicyResponse) String() string { func (*GetDraftPolicyResponse) ProtoMessage() {} func (x *GetDraftPolicyResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[150] + mi := &file_openshell_proto_msgTypes[152] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10912,7 +11051,7 @@ func (x *GetDraftPolicyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftPolicyResponse.ProtoReflect.Descriptor instead. func (*GetDraftPolicyResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{150} + return file_openshell_proto_rawDescGZIP(), []int{152} } func (x *GetDraftPolicyResponse) GetChunks() []*PolicyChunk { @@ -10958,7 +11097,7 @@ type ApproveDraftChunkRequest struct { func (x *ApproveDraftChunkRequest) Reset() { *x = ApproveDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[151] + mi := &file_openshell_proto_msgTypes[153] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10970,7 +11109,7 @@ func (x *ApproveDraftChunkRequest) String() string { func (*ApproveDraftChunkRequest) ProtoMessage() {} func (x *ApproveDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[151] + mi := &file_openshell_proto_msgTypes[153] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10983,7 +11122,7 @@ func (x *ApproveDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveDraftChunkRequest.ProtoReflect.Descriptor instead. func (*ApproveDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{151} + return file_openshell_proto_rawDescGZIP(), []int{153} } func (x *ApproveDraftChunkRequest) GetName() string { @@ -11019,7 +11158,7 @@ type ApproveDraftChunkResponse struct { func (x *ApproveDraftChunkResponse) Reset() { *x = ApproveDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[152] + mi := &file_openshell_proto_msgTypes[154] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11031,7 +11170,7 @@ func (x *ApproveDraftChunkResponse) String() string { func (*ApproveDraftChunkResponse) ProtoMessage() {} func (x *ApproveDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[152] + mi := &file_openshell_proto_msgTypes[154] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11044,7 +11183,7 @@ func (x *ApproveDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveDraftChunkResponse.ProtoReflect.Descriptor instead. func (*ApproveDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{152} + return file_openshell_proto_rawDescGZIP(), []int{154} } func (x *ApproveDraftChunkResponse) GetPolicyVersion() uint32 { @@ -11078,7 +11217,7 @@ type RejectDraftChunkRequest struct { func (x *RejectDraftChunkRequest) Reset() { *x = RejectDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[153] + mi := &file_openshell_proto_msgTypes[155] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11090,7 +11229,7 @@ func (x *RejectDraftChunkRequest) String() string { func (*RejectDraftChunkRequest) ProtoMessage() {} func (x *RejectDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[153] + mi := &file_openshell_proto_msgTypes[155] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11103,7 +11242,7 @@ func (x *RejectDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RejectDraftChunkRequest.ProtoReflect.Descriptor instead. func (*RejectDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{153} + return file_openshell_proto_rawDescGZIP(), []int{155} } func (x *RejectDraftChunkRequest) GetName() string { @@ -11142,7 +11281,7 @@ type RejectDraftChunkResponse struct { func (x *RejectDraftChunkResponse) Reset() { *x = RejectDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[154] + mi := &file_openshell_proto_msgTypes[156] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11154,7 +11293,7 @@ func (x *RejectDraftChunkResponse) String() string { func (*RejectDraftChunkResponse) ProtoMessage() {} func (x *RejectDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[154] + mi := &file_openshell_proto_msgTypes[156] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11167,7 +11306,7 @@ func (x *RejectDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RejectDraftChunkResponse.ProtoReflect.Descriptor instead. func (*RejectDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{154} + return file_openshell_proto_rawDescGZIP(), []int{156} } // Approve all pending chunks. @@ -11185,7 +11324,7 @@ type ApproveAllDraftChunksRequest struct { func (x *ApproveAllDraftChunksRequest) Reset() { *x = ApproveAllDraftChunksRequest{} - mi := &file_openshell_proto_msgTypes[155] + mi := &file_openshell_proto_msgTypes[157] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11197,7 +11336,7 @@ func (x *ApproveAllDraftChunksRequest) String() string { func (*ApproveAllDraftChunksRequest) ProtoMessage() {} func (x *ApproveAllDraftChunksRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[155] + mi := &file_openshell_proto_msgTypes[157] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11210,7 +11349,7 @@ func (x *ApproveAllDraftChunksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveAllDraftChunksRequest.ProtoReflect.Descriptor instead. func (*ApproveAllDraftChunksRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{155} + return file_openshell_proto_rawDescGZIP(), []int{157} } func (x *ApproveAllDraftChunksRequest) GetName() string { @@ -11250,7 +11389,7 @@ type ApproveAllDraftChunksResponse struct { func (x *ApproveAllDraftChunksResponse) Reset() { *x = ApproveAllDraftChunksResponse{} - mi := &file_openshell_proto_msgTypes[156] + mi := &file_openshell_proto_msgTypes[158] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11262,7 +11401,7 @@ func (x *ApproveAllDraftChunksResponse) String() string { func (*ApproveAllDraftChunksResponse) ProtoMessage() {} func (x *ApproveAllDraftChunksResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[156] + mi := &file_openshell_proto_msgTypes[158] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11275,7 +11414,7 @@ func (x *ApproveAllDraftChunksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveAllDraftChunksResponse.ProtoReflect.Descriptor instead. func (*ApproveAllDraftChunksResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{156} + return file_openshell_proto_rawDescGZIP(), []int{158} } func (x *ApproveAllDraftChunksResponse) GetPolicyVersion() uint32 { @@ -11323,7 +11462,7 @@ type EditDraftChunkRequest struct { func (x *EditDraftChunkRequest) Reset() { *x = EditDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[157] + mi := &file_openshell_proto_msgTypes[159] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11335,7 +11474,7 @@ func (x *EditDraftChunkRequest) String() string { func (*EditDraftChunkRequest) ProtoMessage() {} func (x *EditDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[157] + mi := &file_openshell_proto_msgTypes[159] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11348,7 +11487,7 @@ func (x *EditDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EditDraftChunkRequest.ProtoReflect.Descriptor instead. func (*EditDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{157} + return file_openshell_proto_rawDescGZIP(), []int{159} } func (x *EditDraftChunkRequest) GetName() string { @@ -11387,7 +11526,7 @@ type EditDraftChunkResponse struct { func (x *EditDraftChunkResponse) Reset() { *x = EditDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[158] + mi := &file_openshell_proto_msgTypes[160] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11399,7 +11538,7 @@ func (x *EditDraftChunkResponse) String() string { func (*EditDraftChunkResponse) ProtoMessage() {} func (x *EditDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[158] + mi := &file_openshell_proto_msgTypes[160] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11412,7 +11551,7 @@ func (x *EditDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EditDraftChunkResponse.ProtoReflect.Descriptor instead. func (*EditDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{158} + return file_openshell_proto_rawDescGZIP(), []int{160} } // Reverse an approval (remove merged rule from active policy). @@ -11430,7 +11569,7 @@ type UndoDraftChunkRequest struct { func (x *UndoDraftChunkRequest) Reset() { *x = UndoDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[159] + mi := &file_openshell_proto_msgTypes[161] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11442,7 +11581,7 @@ func (x *UndoDraftChunkRequest) String() string { func (*UndoDraftChunkRequest) ProtoMessage() {} func (x *UndoDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[159] + mi := &file_openshell_proto_msgTypes[161] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11455,7 +11594,7 @@ func (x *UndoDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UndoDraftChunkRequest.ProtoReflect.Descriptor instead. func (*UndoDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{159} + return file_openshell_proto_rawDescGZIP(), []int{161} } func (x *UndoDraftChunkRequest) GetName() string { @@ -11491,7 +11630,7 @@ type UndoDraftChunkResponse struct { func (x *UndoDraftChunkResponse) Reset() { *x = UndoDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[160] + mi := &file_openshell_proto_msgTypes[162] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11503,7 +11642,7 @@ func (x *UndoDraftChunkResponse) String() string { func (*UndoDraftChunkResponse) ProtoMessage() {} func (x *UndoDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[160] + mi := &file_openshell_proto_msgTypes[162] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11516,7 +11655,7 @@ func (x *UndoDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UndoDraftChunkResponse.ProtoReflect.Descriptor instead. func (*UndoDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{160} + return file_openshell_proto_rawDescGZIP(), []int{162} } func (x *UndoDraftChunkResponse) GetPolicyVersion() uint32 { @@ -11546,7 +11685,7 @@ type ClearDraftChunksRequest struct { func (x *ClearDraftChunksRequest) Reset() { *x = ClearDraftChunksRequest{} - mi := &file_openshell_proto_msgTypes[161] + mi := &file_openshell_proto_msgTypes[163] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11558,7 +11697,7 @@ func (x *ClearDraftChunksRequest) String() string { func (*ClearDraftChunksRequest) ProtoMessage() {} func (x *ClearDraftChunksRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[161] + mi := &file_openshell_proto_msgTypes[163] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11571,7 +11710,7 @@ func (x *ClearDraftChunksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ClearDraftChunksRequest.ProtoReflect.Descriptor instead. func (*ClearDraftChunksRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{161} + return file_openshell_proto_rawDescGZIP(), []int{163} } func (x *ClearDraftChunksRequest) GetName() string { @@ -11598,7 +11737,7 @@ type ClearDraftChunksResponse struct { func (x *ClearDraftChunksResponse) Reset() { *x = ClearDraftChunksResponse{} - mi := &file_openshell_proto_msgTypes[162] + mi := &file_openshell_proto_msgTypes[164] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11610,7 +11749,7 @@ func (x *ClearDraftChunksResponse) String() string { func (*ClearDraftChunksResponse) ProtoMessage() {} func (x *ClearDraftChunksResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[162] + mi := &file_openshell_proto_msgTypes[164] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11623,7 +11762,7 @@ func (x *ClearDraftChunksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ClearDraftChunksResponse.ProtoReflect.Descriptor instead. func (*ClearDraftChunksResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{162} + return file_openshell_proto_rawDescGZIP(), []int{164} } func (x *ClearDraftChunksResponse) GetChunksCleared() uint32 { @@ -11646,7 +11785,7 @@ type GetDraftHistoryRequest struct { func (x *GetDraftHistoryRequest) Reset() { *x = GetDraftHistoryRequest{} - mi := &file_openshell_proto_msgTypes[163] + mi := &file_openshell_proto_msgTypes[165] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11658,7 +11797,7 @@ func (x *GetDraftHistoryRequest) String() string { func (*GetDraftHistoryRequest) ProtoMessage() {} func (x *GetDraftHistoryRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[163] + mi := &file_openshell_proto_msgTypes[165] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11671,7 +11810,7 @@ func (x *GetDraftHistoryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftHistoryRequest.ProtoReflect.Descriptor instead. func (*GetDraftHistoryRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{163} + return file_openshell_proto_rawDescGZIP(), []int{165} } func (x *GetDraftHistoryRequest) GetName() string { @@ -11705,7 +11844,7 @@ type DraftHistoryEntry struct { func (x *DraftHistoryEntry) Reset() { *x = DraftHistoryEntry{} - mi := &file_openshell_proto_msgTypes[164] + mi := &file_openshell_proto_msgTypes[166] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11717,7 +11856,7 @@ func (x *DraftHistoryEntry) String() string { func (*DraftHistoryEntry) ProtoMessage() {} func (x *DraftHistoryEntry) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[164] + mi := &file_openshell_proto_msgTypes[166] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11730,7 +11869,7 @@ func (x *DraftHistoryEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftHistoryEntry.ProtoReflect.Descriptor instead. func (*DraftHistoryEntry) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{164} + return file_openshell_proto_rawDescGZIP(), []int{166} } func (x *DraftHistoryEntry) GetTimestampMs() int64 { @@ -11771,7 +11910,7 @@ type GetDraftHistoryResponse struct { func (x *GetDraftHistoryResponse) Reset() { *x = GetDraftHistoryResponse{} - mi := &file_openshell_proto_msgTypes[165] + mi := &file_openshell_proto_msgTypes[167] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11783,7 +11922,7 @@ func (x *GetDraftHistoryResponse) String() string { func (*GetDraftHistoryResponse) ProtoMessage() {} func (x *GetDraftHistoryResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[165] + mi := &file_openshell_proto_msgTypes[167] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11796,7 +11935,7 @@ func (x *GetDraftHistoryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftHistoryResponse.ProtoReflect.Descriptor instead. func (*GetDraftHistoryResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{165} + return file_openshell_proto_rawDescGZIP(), []int{167} } func (x *GetDraftHistoryResponse) GetEntries() []*DraftHistoryEntry { @@ -11825,7 +11964,7 @@ type PolicyRevisionPayload struct { func (x *PolicyRevisionPayload) Reset() { *x = PolicyRevisionPayload{} - mi := &file_openshell_proto_msgTypes[166] + mi := &file_openshell_proto_msgTypes[168] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11837,7 +11976,7 @@ func (x *PolicyRevisionPayload) String() string { func (*PolicyRevisionPayload) ProtoMessage() {} func (x *PolicyRevisionPayload) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[166] + mi := &file_openshell_proto_msgTypes[168] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11850,7 +11989,7 @@ func (x *PolicyRevisionPayload) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyRevisionPayload.ProtoReflect.Descriptor instead. func (*PolicyRevisionPayload) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{166} + return file_openshell_proto_rawDescGZIP(), []int{168} } func (x *PolicyRevisionPayload) GetPolicy() *sandboxv1.SandboxPolicy { @@ -11923,7 +12062,7 @@ type DraftChunkPayload struct { func (x *DraftChunkPayload) Reset() { *x = DraftChunkPayload{} - mi := &file_openshell_proto_msgTypes[167] + mi := &file_openshell_proto_msgTypes[169] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11935,7 +12074,7 @@ func (x *DraftChunkPayload) String() string { func (*DraftChunkPayload) ProtoMessage() {} func (x *DraftChunkPayload) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[167] + mi := &file_openshell_proto_msgTypes[169] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11948,7 +12087,7 @@ func (x *DraftChunkPayload) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftChunkPayload.ProtoReflect.Descriptor instead. func (*DraftChunkPayload) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{167} + return file_openshell_proto_rawDescGZIP(), []int{169} } func (x *DraftChunkPayload) GetRuleName() string { @@ -12054,7 +12193,7 @@ type StoredPolicyRevision struct { func (x *StoredPolicyRevision) Reset() { *x = StoredPolicyRevision{} - mi := &file_openshell_proto_msgTypes[168] + mi := &file_openshell_proto_msgTypes[170] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12066,7 +12205,7 @@ func (x *StoredPolicyRevision) String() string { func (*StoredPolicyRevision) ProtoMessage() {} func (x *StoredPolicyRevision) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[168] + mi := &file_openshell_proto_msgTypes[170] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12079,7 +12218,7 @@ func (x *StoredPolicyRevision) ProtoReflect() protoreflect.Message { // Deprecated: Use StoredPolicyRevision.ProtoReflect.Descriptor instead. func (*StoredPolicyRevision) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{168} + return file_openshell_proto_rawDescGZIP(), []int{170} } func (x *StoredPolicyRevision) GetId() string { @@ -12182,7 +12321,7 @@ type StoredDraftChunk struct { func (x *StoredDraftChunk) Reset() { *x = StoredDraftChunk{} - mi := &file_openshell_proto_msgTypes[169] + mi := &file_openshell_proto_msgTypes[171] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12194,7 +12333,7 @@ func (x *StoredDraftChunk) String() string { func (*StoredDraftChunk) ProtoMessage() {} func (x *StoredDraftChunk) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[169] + mi := &file_openshell_proto_msgTypes[171] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12207,7 +12346,7 @@ func (x *StoredDraftChunk) ProtoReflect() protoreflect.Message { // Deprecated: Use StoredDraftChunk.ProtoReflect.Descriptor instead. func (*StoredDraftChunk) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{169} + return file_openshell_proto_rawDescGZIP(), []int{171} } func (x *StoredDraftChunk) GetId() string { @@ -12356,7 +12495,7 @@ type CreateWorkspaceRequest struct { func (x *CreateWorkspaceRequest) Reset() { *x = CreateWorkspaceRequest{} - mi := &file_openshell_proto_msgTypes[170] + mi := &file_openshell_proto_msgTypes[172] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12368,7 +12507,7 @@ func (x *CreateWorkspaceRequest) String() string { func (*CreateWorkspaceRequest) ProtoMessage() {} func (x *CreateWorkspaceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[170] + mi := &file_openshell_proto_msgTypes[172] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12381,7 +12520,7 @@ func (x *CreateWorkspaceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateWorkspaceRequest.ProtoReflect.Descriptor instead. func (*CreateWorkspaceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{170} + return file_openshell_proto_rawDescGZIP(), []int{172} } func (x *CreateWorkspaceRequest) GetName() string { @@ -12408,7 +12547,7 @@ type CreateWorkspaceResponse struct { func (x *CreateWorkspaceResponse) Reset() { *x = CreateWorkspaceResponse{} - mi := &file_openshell_proto_msgTypes[171] + mi := &file_openshell_proto_msgTypes[173] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12420,7 +12559,7 @@ func (x *CreateWorkspaceResponse) String() string { func (*CreateWorkspaceResponse) ProtoMessage() {} func (x *CreateWorkspaceResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[171] + mi := &file_openshell_proto_msgTypes[173] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12433,7 +12572,7 @@ func (x *CreateWorkspaceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateWorkspaceResponse.ProtoReflect.Descriptor instead. func (*CreateWorkspaceResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{171} + return file_openshell_proto_rawDescGZIP(), []int{173} } func (x *CreateWorkspaceResponse) GetWorkspace() *datamodelv1.Workspace { @@ -12454,7 +12593,7 @@ type GetWorkspaceRequest struct { func (x *GetWorkspaceRequest) Reset() { *x = GetWorkspaceRequest{} - mi := &file_openshell_proto_msgTypes[172] + mi := &file_openshell_proto_msgTypes[174] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12466,7 +12605,7 @@ func (x *GetWorkspaceRequest) String() string { func (*GetWorkspaceRequest) ProtoMessage() {} func (x *GetWorkspaceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[172] + mi := &file_openshell_proto_msgTypes[174] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12479,7 +12618,7 @@ func (x *GetWorkspaceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetWorkspaceRequest.ProtoReflect.Descriptor instead. func (*GetWorkspaceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{172} + return file_openshell_proto_rawDescGZIP(), []int{174} } func (x *GetWorkspaceRequest) GetName() string { @@ -12499,7 +12638,7 @@ type GetWorkspaceResponse struct { func (x *GetWorkspaceResponse) Reset() { *x = GetWorkspaceResponse{} - mi := &file_openshell_proto_msgTypes[173] + mi := &file_openshell_proto_msgTypes[175] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12511,7 +12650,7 @@ func (x *GetWorkspaceResponse) String() string { func (*GetWorkspaceResponse) ProtoMessage() {} func (x *GetWorkspaceResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[173] + mi := &file_openshell_proto_msgTypes[175] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12524,7 +12663,7 @@ func (x *GetWorkspaceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetWorkspaceResponse.ProtoReflect.Descriptor instead. func (*GetWorkspaceResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{173} + return file_openshell_proto_rawDescGZIP(), []int{175} } func (x *GetWorkspaceResponse) GetWorkspace() *datamodelv1.Workspace { @@ -12547,7 +12686,7 @@ type ListWorkspacesRequest struct { func (x *ListWorkspacesRequest) Reset() { *x = ListWorkspacesRequest{} - mi := &file_openshell_proto_msgTypes[174] + mi := &file_openshell_proto_msgTypes[176] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12559,7 +12698,7 @@ func (x *ListWorkspacesRequest) String() string { func (*ListWorkspacesRequest) ProtoMessage() {} func (x *ListWorkspacesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[174] + mi := &file_openshell_proto_msgTypes[176] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12572,7 +12711,7 @@ func (x *ListWorkspacesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspacesRequest.ProtoReflect.Descriptor instead. func (*ListWorkspacesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{174} + return file_openshell_proto_rawDescGZIP(), []int{176} } func (x *ListWorkspacesRequest) GetLimit() uint32 { @@ -12606,7 +12745,7 @@ type ListWorkspacesResponse struct { func (x *ListWorkspacesResponse) Reset() { *x = ListWorkspacesResponse{} - mi := &file_openshell_proto_msgTypes[175] + mi := &file_openshell_proto_msgTypes[177] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12618,7 +12757,7 @@ func (x *ListWorkspacesResponse) String() string { func (*ListWorkspacesResponse) ProtoMessage() {} func (x *ListWorkspacesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[175] + mi := &file_openshell_proto_msgTypes[177] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12631,7 +12770,7 @@ func (x *ListWorkspacesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspacesResponse.ProtoReflect.Descriptor instead. func (*ListWorkspacesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{175} + return file_openshell_proto_rawDescGZIP(), []int{177} } func (x *ListWorkspacesResponse) GetWorkspaces() []*datamodelv1.Workspace { @@ -12652,7 +12791,7 @@ type DeleteWorkspaceRequest struct { func (x *DeleteWorkspaceRequest) Reset() { *x = DeleteWorkspaceRequest{} - mi := &file_openshell_proto_msgTypes[176] + mi := &file_openshell_proto_msgTypes[178] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12664,7 +12803,7 @@ func (x *DeleteWorkspaceRequest) String() string { func (*DeleteWorkspaceRequest) ProtoMessage() {} func (x *DeleteWorkspaceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[176] + mi := &file_openshell_proto_msgTypes[178] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12677,7 +12816,7 @@ func (x *DeleteWorkspaceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteWorkspaceRequest.ProtoReflect.Descriptor instead. func (*DeleteWorkspaceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{176} + return file_openshell_proto_rawDescGZIP(), []int{178} } func (x *DeleteWorkspaceRequest) GetName() string { @@ -12697,7 +12836,7 @@ type DeleteWorkspaceResponse struct { func (x *DeleteWorkspaceResponse) Reset() { *x = DeleteWorkspaceResponse{} - mi := &file_openshell_proto_msgTypes[177] + mi := &file_openshell_proto_msgTypes[179] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12709,7 +12848,7 @@ func (x *DeleteWorkspaceResponse) String() string { func (*DeleteWorkspaceResponse) ProtoMessage() {} func (x *DeleteWorkspaceResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[177] + mi := &file_openshell_proto_msgTypes[179] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12722,7 +12861,7 @@ func (x *DeleteWorkspaceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteWorkspaceResponse.ProtoReflect.Descriptor instead. func (*DeleteWorkspaceResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{177} + return file_openshell_proto_rawDescGZIP(), []int{179} } func (x *DeleteWorkspaceResponse) GetDeleted() bool { @@ -12746,7 +12885,7 @@ type WorkspaceMember struct { func (x *WorkspaceMember) Reset() { *x = WorkspaceMember{} - mi := &file_openshell_proto_msgTypes[178] + mi := &file_openshell_proto_msgTypes[180] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12758,7 +12897,7 @@ func (x *WorkspaceMember) String() string { func (*WorkspaceMember) ProtoMessage() {} func (x *WorkspaceMember) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[178] + mi := &file_openshell_proto_msgTypes[180] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12771,7 +12910,7 @@ func (x *WorkspaceMember) ProtoReflect() protoreflect.Message { // Deprecated: Use WorkspaceMember.ProtoReflect.Descriptor instead. func (*WorkspaceMember) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{178} + return file_openshell_proto_rawDescGZIP(), []int{180} } func (x *WorkspaceMember) GetMetadata() *datamodelv1.ObjectMeta { @@ -12810,7 +12949,7 @@ type AddWorkspaceMemberRequest struct { func (x *AddWorkspaceMemberRequest) Reset() { *x = AddWorkspaceMemberRequest{} - mi := &file_openshell_proto_msgTypes[179] + mi := &file_openshell_proto_msgTypes[181] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12822,7 +12961,7 @@ func (x *AddWorkspaceMemberRequest) String() string { func (*AddWorkspaceMemberRequest) ProtoMessage() {} func (x *AddWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[179] + mi := &file_openshell_proto_msgTypes[181] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12835,7 +12974,7 @@ func (x *AddWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AddWorkspaceMemberRequest.ProtoReflect.Descriptor instead. func (*AddWorkspaceMemberRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{179} + return file_openshell_proto_rawDescGZIP(), []int{181} } func (x *AddWorkspaceMemberRequest) GetWorkspace() string { @@ -12869,7 +13008,7 @@ type AddWorkspaceMemberResponse struct { func (x *AddWorkspaceMemberResponse) Reset() { *x = AddWorkspaceMemberResponse{} - mi := &file_openshell_proto_msgTypes[180] + mi := &file_openshell_proto_msgTypes[182] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12881,7 +13020,7 @@ func (x *AddWorkspaceMemberResponse) String() string { func (*AddWorkspaceMemberResponse) ProtoMessage() {} func (x *AddWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[180] + mi := &file_openshell_proto_msgTypes[182] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12894,7 +13033,7 @@ func (x *AddWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AddWorkspaceMemberResponse.ProtoReflect.Descriptor instead. func (*AddWorkspaceMemberResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{180} + return file_openshell_proto_rawDescGZIP(), []int{182} } func (x *AddWorkspaceMemberResponse) GetMember() *WorkspaceMember { @@ -12917,7 +13056,7 @@ type RemoveWorkspaceMemberRequest struct { func (x *RemoveWorkspaceMemberRequest) Reset() { *x = RemoveWorkspaceMemberRequest{} - mi := &file_openshell_proto_msgTypes[181] + mi := &file_openshell_proto_msgTypes[183] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12929,7 +13068,7 @@ func (x *RemoveWorkspaceMemberRequest) String() string { func (*RemoveWorkspaceMemberRequest) ProtoMessage() {} func (x *RemoveWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[181] + mi := &file_openshell_proto_msgTypes[183] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12942,7 +13081,7 @@ func (x *RemoveWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveWorkspaceMemberRequest.ProtoReflect.Descriptor instead. func (*RemoveWorkspaceMemberRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{181} + return file_openshell_proto_rawDescGZIP(), []int{183} } func (x *RemoveWorkspaceMemberRequest) GetWorkspace() string { @@ -12969,7 +13108,7 @@ type RemoveWorkspaceMemberResponse struct { func (x *RemoveWorkspaceMemberResponse) Reset() { *x = RemoveWorkspaceMemberResponse{} - mi := &file_openshell_proto_msgTypes[182] + mi := &file_openshell_proto_msgTypes[184] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12981,7 +13120,7 @@ func (x *RemoveWorkspaceMemberResponse) String() string { func (*RemoveWorkspaceMemberResponse) ProtoMessage() {} func (x *RemoveWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[182] + mi := &file_openshell_proto_msgTypes[184] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12994,7 +13133,7 @@ func (x *RemoveWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveWorkspaceMemberResponse.ProtoReflect.Descriptor instead. func (*RemoveWorkspaceMemberResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{182} + return file_openshell_proto_rawDescGZIP(), []int{184} } func (x *RemoveWorkspaceMemberResponse) GetRemoved() bool { @@ -13017,7 +13156,7 @@ type ListWorkspaceMembersRequest struct { func (x *ListWorkspaceMembersRequest) Reset() { *x = ListWorkspaceMembersRequest{} - mi := &file_openshell_proto_msgTypes[183] + mi := &file_openshell_proto_msgTypes[185] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13029,7 +13168,7 @@ func (x *ListWorkspaceMembersRequest) String() string { func (*ListWorkspaceMembersRequest) ProtoMessage() {} func (x *ListWorkspaceMembersRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[183] + mi := &file_openshell_proto_msgTypes[185] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13042,7 +13181,7 @@ func (x *ListWorkspaceMembersRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspaceMembersRequest.ProtoReflect.Descriptor instead. func (*ListWorkspaceMembersRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{183} + return file_openshell_proto_rawDescGZIP(), []int{185} } func (x *ListWorkspaceMembersRequest) GetWorkspace() string { @@ -13076,7 +13215,7 @@ type ListWorkspaceMembersResponse struct { func (x *ListWorkspaceMembersResponse) Reset() { *x = ListWorkspaceMembersResponse{} - mi := &file_openshell_proto_msgTypes[184] + mi := &file_openshell_proto_msgTypes[186] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13088,7 +13227,7 @@ func (x *ListWorkspaceMembersResponse) String() string { func (*ListWorkspaceMembersResponse) ProtoMessage() {} func (x *ListWorkspaceMembersResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[184] + mi := &file_openshell_proto_msgTypes[186] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13101,7 +13240,7 @@ func (x *ListWorkspaceMembersResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspaceMembersResponse.ProtoReflect.Descriptor instead. func (*ListWorkspaceMembersResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{184} + return file_openshell_proto_rawDescGZIP(), []int{186} } func (x *ListWorkspaceMembersResponse) GetMembers() []*WorkspaceMember { @@ -13129,7 +13268,7 @@ type ExtensionServiceCredential struct { func (x *ExtensionServiceCredential) Reset() { *x = ExtensionServiceCredential{} - mi := &file_openshell_proto_msgTypes[185] + mi := &file_openshell_proto_msgTypes[187] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13141,7 +13280,7 @@ func (x *ExtensionServiceCredential) String() string { func (*ExtensionServiceCredential) ProtoMessage() {} func (x *ExtensionServiceCredential) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[185] + mi := &file_openshell_proto_msgTypes[187] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13154,7 +13293,7 @@ func (x *ExtensionServiceCredential) ProtoReflect() protoreflect.Message { // Deprecated: Use ExtensionServiceCredential.ProtoReflect.Descriptor instead. func (*ExtensionServiceCredential) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{185} + return file_openshell_proto_rawDescGZIP(), []int{187} } func (x *ExtensionServiceCredential) GetServiceName() string { @@ -13219,14 +13358,16 @@ const file_openshell_proto_rawDesc = "" + "\aSandbox\x12>\n" + "\bmetadata\x18\x01 \x01(\v2\".openshell.datamodel.v1.ObjectMetaR\bmetadata\x12-\n" + "\x04spec\x18\x02 \x01(\v2\x19.openshell.v1.SandboxSpecR\x04spec\x123\n" + - "\x06status\x18\x03 \x01(\v2\x1b.openshell.v1.SandboxStatusR\x06statusJ\x04\b\x04\x10\x05J\x04\b\x05\x10\x06R\x05phaseR\x16current_policy_version\"\xd7\x03\n" + + "\x06status\x18\x03 \x01(\v2\x1b.openshell.v1.SandboxStatusR\x06statusJ\x04\b\x04\x10\x05J\x04\b\x05\x10\x06R\x05phaseR\x16current_policy_version\"\x83\x04\n" + "\vSandboxSpec\x12\x1b\n" + "\tlog_level\x18\x01 \x01(\tR\blogLevel\x12L\n" + "\venvironment\x18\x05 \x03(\v2*.openshell.v1.SandboxSpec.EnvironmentEntryR\venvironment\x129\n" + "\btemplate\x18\x06 \x01(\v2\x1d.openshell.v1.SandboxTemplateR\btemplate\x12;\n" + "\x06policy\x18\a \x01(\v2#.openshell.sandbox.v1.SandboxPolicyR\x06policy\x12\x1c\n" + "\tproviders\x18\b \x03(\tR\tproviders\x12W\n" + - "\x15resource_requirements\x18\t \x01(\v2\".openshell.v1.ResourceRequirementsR\x14resourceRequirements\x1a>\n" + + "\x15resource_requirements\x18\t \x01(\v2\".openshell.v1.ResourceRequirementsR\x14resourceRequirements\x12\x18\n" + + "\acommand\x18\f \x03(\tR\acommand\x12\x10\n" + + "\x03tty\x18\r \x01(\bR\x03tty\x1a>\n" + "\x10EnvironmentEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\n" + @@ -13258,7 +13399,7 @@ const file_openshell_proto_rawDesc = "" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x12\n" + "\x10_user_namespacesJ\x04\b\t\x10\n" + - "R\x16volume_claim_templates\"\xb1\x02\n" + + "R\x16volume_claim_templates\"\x9a\x03\n" + "\rSandboxStatus\x12!\n" + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12\x1b\n" + "\tagent_pod\x18\x02 \x01(\tR\bagentPod\x12\x19\n" + @@ -13269,7 +13410,11 @@ const file_openshell_proto_rawDesc = "" + "conditions\x18\x05 \x03(\v2\x1e.openshell.v1.SandboxConditionR\n" + "conditions\x120\n" + "\x05phase\x18\x06 \x01(\x0e2\x1a.openshell.v1.SandboxPhaseR\x05phase\x124\n" + - "\x16current_policy_version\x18\a \x01(\rR\x14currentPolicyVersion\"\xa2\x01\n" + + "\x16current_policy_version\x18\a \x01(\rR\x14currentPolicyVersion\x127\n" + + "\x18main_process_instance_id\x18\b \x01(\tR\x15mainProcessInstanceId\x12 \n" + + "\texit_code\x18\t \x01(\x05H\x00R\bexitCode\x88\x01\x01B\f\n" + + "\n" + + "_exit_code\"\xa2\x01\n" + "\x10SandboxCondition\x12\x12\n" + "\x04type\x18\x01 \x01(\tR\x04type\x12\x16\n" + "\x06status\x18\x02 \x01(\tR\x06status\x12\x16\n" + @@ -13886,7 +14031,14 @@ const file_openshell_proto_rawDesc = "" + "\x0fSessionRejected\x12\x16\n" + "\x06reason\x18\x01 \x01(\tR\x06reason\"\x15\n" + "\x13SupervisorHeartbeat\"\x12\n" + - "\x10GatewayHeartbeat\"\xb7\x01\n" + + "\x10GatewayHeartbeat\"{\n" + + "\x1cReportMainProcessExitRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x1f\n" + + "\vinstance_id\x18\x02 \x01(\tR\n" + + "instanceId\x12\x1b\n" + + "\texit_code\x18\x03 \x01(\x05R\bexitCode\"\x1f\n" + + "\x1dReportMainProcessExitResponse\"\xb7\x01\n" + "\tRelayOpen\x12\x1d\n" + "\n" + "channel_id\x18\x01 \x01(\tR\tchannelId\x120\n" + @@ -14227,7 +14379,7 @@ const file_openshell_proto_rawDesc = "" + "\rWorkspaceRole\x12\x1e\n" + "\x1aWORKSPACE_ROLE_UNSPECIFIED\x10\x00\x12\x17\n" + "\x13WORKSPACE_ROLE_USER\x10\x01\x12\x18\n" + - "\x14WORKSPACE_ROLE_ADMIN\x10\x022\x94D\n" + + "\x14WORKSPACE_ROLE_ADMIN\x10\x022\x95E\n" + "\tOpenShell\x12Z\n" + "\x06Health\x12\x1b.openshell.v1.HealthRequest\x1a\x1c.openshell.v1.HealthResponse\"\x15\x82\xb5\x18\x11\n" + "\x0funauthenticated\x12i\n" + @@ -14323,7 +14475,9 @@ const file_openshell_proto_rawDesc = "" + "\x0fPushSandboxLogs\x12$.openshell.v1.PushSandboxLogsRequest\x1a%.openshell.v1.PushSandboxLogsResponse\"\r\x82\xb5\x18\t\n" + "\asandbox(\x01\x12e\n" + "\x11ConnectSupervisor\x12\x1f.openshell.v1.SupervisorMessage\x1a\x1c.openshell.v1.GatewayMessage\"\r\x82\xb5\x18\t\n" + - "\asandbox(\x010\x01\x12T\n" + + "\asandbox(\x010\x01\x12\x7f\n" + + "\x15ReportMainProcessExit\x12*.openshell.v1.ReportMainProcessExitRequest\x1a+.openshell.v1.ReportMainProcessExitResponse\"\r\x82\xb5\x18\t\n" + + "\asandbox\x12T\n" + "\vRelayStream\x12\x18.openshell.v1.RelayFrame\x1a\x18.openshell.v1.RelayFrame\"\r\x82\xb5\x18\t\n" + "\asandbox(\x010\x01\x12w\n" + "\fWatchSandbox\x12!.openshell.v1.WatchSandboxRequest\x1a .openshell.v1.SandboxStreamEvent\" \x82\xb5\x18\x1c\n" + @@ -14378,7 +14532,7 @@ func file_openshell_proto_rawDescGZIP() []byte { } var file_openshell_proto_enumTypes = make([]protoimpl.EnumInfo, 6) -var file_openshell_proto_msgTypes = make([]protoimpl.MessageInfo, 211) +var file_openshell_proto_msgTypes = make([]protoimpl.MessageInfo, 213) var file_openshell_proto_goTypes = []any{ (SandboxPhase)(0), // 0: openshell.v1.SandboxPhase (ProviderCredentialRefreshStrategy)(0), // 1: openshell.v1.ProviderCredentialRefreshStrategy @@ -14520,154 +14674,156 @@ var file_openshell_proto_goTypes = []any{ (*SessionRejected)(nil), // 137: openshell.v1.SessionRejected (*SupervisorHeartbeat)(nil), // 138: openshell.v1.SupervisorHeartbeat (*GatewayHeartbeat)(nil), // 139: openshell.v1.GatewayHeartbeat - (*RelayOpen)(nil), // 140: openshell.v1.RelayOpen - (*SshRelayTarget)(nil), // 141: openshell.v1.SshRelayTarget - (*TcpRelayTarget)(nil), // 142: openshell.v1.TcpRelayTarget - (*RelayInit)(nil), // 143: openshell.v1.RelayInit - (*RelayFrame)(nil), // 144: openshell.v1.RelayFrame - (*RelayOpenResult)(nil), // 145: openshell.v1.RelayOpenResult - (*RelayClose)(nil), // 146: openshell.v1.RelayClose - (*L7RequestSample)(nil), // 147: openshell.v1.L7RequestSample - (*DenialSummary)(nil), // 148: openshell.v1.DenialSummary - (*DenialGroupCount)(nil), // 149: openshell.v1.DenialGroupCount - (*NetworkActivitySummary)(nil), // 150: openshell.v1.NetworkActivitySummary - (*PolicyChunk)(nil), // 151: openshell.v1.PolicyChunk - (*DraftPolicyUpdate)(nil), // 152: openshell.v1.DraftPolicyUpdate - (*SubmitPolicyAnalysisRequest)(nil), // 153: openshell.v1.SubmitPolicyAnalysisRequest - (*SubmitPolicyAnalysisResponse)(nil), // 154: openshell.v1.SubmitPolicyAnalysisResponse - (*GetDraftPolicyRequest)(nil), // 155: openshell.v1.GetDraftPolicyRequest - (*GetDraftPolicyResponse)(nil), // 156: openshell.v1.GetDraftPolicyResponse - (*ApproveDraftChunkRequest)(nil), // 157: openshell.v1.ApproveDraftChunkRequest - (*ApproveDraftChunkResponse)(nil), // 158: openshell.v1.ApproveDraftChunkResponse - (*RejectDraftChunkRequest)(nil), // 159: openshell.v1.RejectDraftChunkRequest - (*RejectDraftChunkResponse)(nil), // 160: openshell.v1.RejectDraftChunkResponse - (*ApproveAllDraftChunksRequest)(nil), // 161: openshell.v1.ApproveAllDraftChunksRequest - (*ApproveAllDraftChunksResponse)(nil), // 162: openshell.v1.ApproveAllDraftChunksResponse - (*EditDraftChunkRequest)(nil), // 163: openshell.v1.EditDraftChunkRequest - (*EditDraftChunkResponse)(nil), // 164: openshell.v1.EditDraftChunkResponse - (*UndoDraftChunkRequest)(nil), // 165: openshell.v1.UndoDraftChunkRequest - (*UndoDraftChunkResponse)(nil), // 166: openshell.v1.UndoDraftChunkResponse - (*ClearDraftChunksRequest)(nil), // 167: openshell.v1.ClearDraftChunksRequest - (*ClearDraftChunksResponse)(nil), // 168: openshell.v1.ClearDraftChunksResponse - (*GetDraftHistoryRequest)(nil), // 169: openshell.v1.GetDraftHistoryRequest - (*DraftHistoryEntry)(nil), // 170: openshell.v1.DraftHistoryEntry - (*GetDraftHistoryResponse)(nil), // 171: openshell.v1.GetDraftHistoryResponse - (*PolicyRevisionPayload)(nil), // 172: openshell.v1.PolicyRevisionPayload - (*DraftChunkPayload)(nil), // 173: openshell.v1.DraftChunkPayload - (*StoredPolicyRevision)(nil), // 174: openshell.v1.StoredPolicyRevision - (*StoredDraftChunk)(nil), // 175: openshell.v1.StoredDraftChunk - (*CreateWorkspaceRequest)(nil), // 176: openshell.v1.CreateWorkspaceRequest - (*CreateWorkspaceResponse)(nil), // 177: openshell.v1.CreateWorkspaceResponse - (*GetWorkspaceRequest)(nil), // 178: openshell.v1.GetWorkspaceRequest - (*GetWorkspaceResponse)(nil), // 179: openshell.v1.GetWorkspaceResponse - (*ListWorkspacesRequest)(nil), // 180: openshell.v1.ListWorkspacesRequest - (*ListWorkspacesResponse)(nil), // 181: openshell.v1.ListWorkspacesResponse - (*DeleteWorkspaceRequest)(nil), // 182: openshell.v1.DeleteWorkspaceRequest - (*DeleteWorkspaceResponse)(nil), // 183: openshell.v1.DeleteWorkspaceResponse - (*WorkspaceMember)(nil), // 184: openshell.v1.WorkspaceMember - (*AddWorkspaceMemberRequest)(nil), // 185: openshell.v1.AddWorkspaceMemberRequest - (*AddWorkspaceMemberResponse)(nil), // 186: openshell.v1.AddWorkspaceMemberResponse - (*RemoveWorkspaceMemberRequest)(nil), // 187: openshell.v1.RemoveWorkspaceMemberRequest - (*RemoveWorkspaceMemberResponse)(nil), // 188: openshell.v1.RemoveWorkspaceMemberResponse - (*ListWorkspaceMembersRequest)(nil), // 189: openshell.v1.ListWorkspaceMembersRequest - (*ListWorkspaceMembersResponse)(nil), // 190: openshell.v1.ListWorkspaceMembersResponse - (*ExtensionServiceCredential)(nil), // 191: openshell.v1.ExtensionServiceCredential - nil, // 192: openshell.v1.SandboxSpec.EnvironmentEntry - nil, // 193: openshell.v1.SandboxTemplate.LabelsEntry - nil, // 194: openshell.v1.SandboxTemplate.AnnotationsEntry - nil, // 195: openshell.v1.SandboxTemplate.EnvironmentEntry - nil, // 196: openshell.v1.PlatformEvent.MetadataEntry - nil, // 197: openshell.v1.CreateSandboxRequest.LabelsEntry - nil, // 198: openshell.v1.CreateSandboxRequest.AnnotationsEntry - nil, // 199: openshell.v1.ExecSandboxRequest.EnvironmentEntry - nil, // 200: openshell.v1.SandboxLogLine.FieldsEntry - nil, // 201: openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry - nil, // 202: openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry - nil, // 203: openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry - nil, // 204: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry - nil, // 205: openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry - nil, // 206: openshell.v1.ProviderProfile.AnnotationsEntry - nil, // 207: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry - nil, // 208: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry - nil, // 209: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry - nil, // 210: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry - nil, // 211: openshell.v1.UpdateConfigRequest.AnnotationsEntry - nil, // 212: openshell.v1.UpdateConfigResponse.AnnotationsEntry - nil, // 213: openshell.v1.SandboxPolicyRevision.ProvenanceEntry - nil, // 214: openshell.v1.PolicyRevisionPayload.ProvenanceEntry - nil, // 215: openshell.v1.StoredPolicyRevision.ProvenanceEntry - nil, // 216: openshell.v1.CreateWorkspaceRequest.LabelsEntry - (*datamodelv1.ObjectMeta)(nil), // 217: openshell.datamodel.v1.ObjectMeta - (*sandboxv1.SandboxPolicy)(nil), // 218: openshell.sandbox.v1.SandboxPolicy - (*structpb.Struct)(nil), // 219: google.protobuf.Struct - (*datamodelv1.Provider)(nil), // 220: openshell.datamodel.v1.Provider - (*datamodelv1.CredentialHandle)(nil), // 221: openshell.datamodel.v1.CredentialHandle - (*sandboxv1.NetworkEndpoint)(nil), // 222: openshell.sandbox.v1.NetworkEndpoint - (*sandboxv1.NetworkBinary)(nil), // 223: openshell.sandbox.v1.NetworkBinary - (*sandboxv1.SettingValue)(nil), // 224: openshell.sandbox.v1.SettingValue - (*sandboxv1.NetworkPolicyRule)(nil), // 225: openshell.sandbox.v1.NetworkPolicyRule - (*sandboxv1.L7DenyRule)(nil), // 226: openshell.sandbox.v1.L7DenyRule - (*sandboxv1.L7Rule)(nil), // 227: openshell.sandbox.v1.L7Rule - (*datamodelv1.Workspace)(nil), // 228: openshell.datamodel.v1.Workspace - (*sandboxv1.GetSandboxConfigRequest)(nil), // 229: openshell.sandbox.v1.GetSandboxConfigRequest - (*sandboxv1.GetGatewayConfigRequest)(nil), // 230: openshell.sandbox.v1.GetGatewayConfigRequest - (*sandboxv1.GetSandboxConfigResponse)(nil), // 231: openshell.sandbox.v1.GetSandboxConfigResponse - (*sandboxv1.GetGatewayConfigResponse)(nil), // 232: openshell.sandbox.v1.GetGatewayConfigResponse + (*ReportMainProcessExitRequest)(nil), // 140: openshell.v1.ReportMainProcessExitRequest + (*ReportMainProcessExitResponse)(nil), // 141: openshell.v1.ReportMainProcessExitResponse + (*RelayOpen)(nil), // 142: openshell.v1.RelayOpen + (*SshRelayTarget)(nil), // 143: openshell.v1.SshRelayTarget + (*TcpRelayTarget)(nil), // 144: openshell.v1.TcpRelayTarget + (*RelayInit)(nil), // 145: openshell.v1.RelayInit + (*RelayFrame)(nil), // 146: openshell.v1.RelayFrame + (*RelayOpenResult)(nil), // 147: openshell.v1.RelayOpenResult + (*RelayClose)(nil), // 148: openshell.v1.RelayClose + (*L7RequestSample)(nil), // 149: openshell.v1.L7RequestSample + (*DenialSummary)(nil), // 150: openshell.v1.DenialSummary + (*DenialGroupCount)(nil), // 151: openshell.v1.DenialGroupCount + (*NetworkActivitySummary)(nil), // 152: openshell.v1.NetworkActivitySummary + (*PolicyChunk)(nil), // 153: openshell.v1.PolicyChunk + (*DraftPolicyUpdate)(nil), // 154: openshell.v1.DraftPolicyUpdate + (*SubmitPolicyAnalysisRequest)(nil), // 155: openshell.v1.SubmitPolicyAnalysisRequest + (*SubmitPolicyAnalysisResponse)(nil), // 156: openshell.v1.SubmitPolicyAnalysisResponse + (*GetDraftPolicyRequest)(nil), // 157: openshell.v1.GetDraftPolicyRequest + (*GetDraftPolicyResponse)(nil), // 158: openshell.v1.GetDraftPolicyResponse + (*ApproveDraftChunkRequest)(nil), // 159: openshell.v1.ApproveDraftChunkRequest + (*ApproveDraftChunkResponse)(nil), // 160: openshell.v1.ApproveDraftChunkResponse + (*RejectDraftChunkRequest)(nil), // 161: openshell.v1.RejectDraftChunkRequest + (*RejectDraftChunkResponse)(nil), // 162: openshell.v1.RejectDraftChunkResponse + (*ApproveAllDraftChunksRequest)(nil), // 163: openshell.v1.ApproveAllDraftChunksRequest + (*ApproveAllDraftChunksResponse)(nil), // 164: openshell.v1.ApproveAllDraftChunksResponse + (*EditDraftChunkRequest)(nil), // 165: openshell.v1.EditDraftChunkRequest + (*EditDraftChunkResponse)(nil), // 166: openshell.v1.EditDraftChunkResponse + (*UndoDraftChunkRequest)(nil), // 167: openshell.v1.UndoDraftChunkRequest + (*UndoDraftChunkResponse)(nil), // 168: openshell.v1.UndoDraftChunkResponse + (*ClearDraftChunksRequest)(nil), // 169: openshell.v1.ClearDraftChunksRequest + (*ClearDraftChunksResponse)(nil), // 170: openshell.v1.ClearDraftChunksResponse + (*GetDraftHistoryRequest)(nil), // 171: openshell.v1.GetDraftHistoryRequest + (*DraftHistoryEntry)(nil), // 172: openshell.v1.DraftHistoryEntry + (*GetDraftHistoryResponse)(nil), // 173: openshell.v1.GetDraftHistoryResponse + (*PolicyRevisionPayload)(nil), // 174: openshell.v1.PolicyRevisionPayload + (*DraftChunkPayload)(nil), // 175: openshell.v1.DraftChunkPayload + (*StoredPolicyRevision)(nil), // 176: openshell.v1.StoredPolicyRevision + (*StoredDraftChunk)(nil), // 177: openshell.v1.StoredDraftChunk + (*CreateWorkspaceRequest)(nil), // 178: openshell.v1.CreateWorkspaceRequest + (*CreateWorkspaceResponse)(nil), // 179: openshell.v1.CreateWorkspaceResponse + (*GetWorkspaceRequest)(nil), // 180: openshell.v1.GetWorkspaceRequest + (*GetWorkspaceResponse)(nil), // 181: openshell.v1.GetWorkspaceResponse + (*ListWorkspacesRequest)(nil), // 182: openshell.v1.ListWorkspacesRequest + (*ListWorkspacesResponse)(nil), // 183: openshell.v1.ListWorkspacesResponse + (*DeleteWorkspaceRequest)(nil), // 184: openshell.v1.DeleteWorkspaceRequest + (*DeleteWorkspaceResponse)(nil), // 185: openshell.v1.DeleteWorkspaceResponse + (*WorkspaceMember)(nil), // 186: openshell.v1.WorkspaceMember + (*AddWorkspaceMemberRequest)(nil), // 187: openshell.v1.AddWorkspaceMemberRequest + (*AddWorkspaceMemberResponse)(nil), // 188: openshell.v1.AddWorkspaceMemberResponse + (*RemoveWorkspaceMemberRequest)(nil), // 189: openshell.v1.RemoveWorkspaceMemberRequest + (*RemoveWorkspaceMemberResponse)(nil), // 190: openshell.v1.RemoveWorkspaceMemberResponse + (*ListWorkspaceMembersRequest)(nil), // 191: openshell.v1.ListWorkspaceMembersRequest + (*ListWorkspaceMembersResponse)(nil), // 192: openshell.v1.ListWorkspaceMembersResponse + (*ExtensionServiceCredential)(nil), // 193: openshell.v1.ExtensionServiceCredential + nil, // 194: openshell.v1.SandboxSpec.EnvironmentEntry + nil, // 195: openshell.v1.SandboxTemplate.LabelsEntry + nil, // 196: openshell.v1.SandboxTemplate.AnnotationsEntry + nil, // 197: openshell.v1.SandboxTemplate.EnvironmentEntry + nil, // 198: openshell.v1.PlatformEvent.MetadataEntry + nil, // 199: openshell.v1.CreateSandboxRequest.LabelsEntry + nil, // 200: openshell.v1.CreateSandboxRequest.AnnotationsEntry + nil, // 201: openshell.v1.ExecSandboxRequest.EnvironmentEntry + nil, // 202: openshell.v1.SandboxLogLine.FieldsEntry + nil, // 203: openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry + nil, // 204: openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry + nil, // 205: openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry + nil, // 206: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry + nil, // 207: openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + nil, // 208: openshell.v1.ProviderProfile.AnnotationsEntry + nil, // 209: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + nil, // 210: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry + nil, // 211: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + nil, // 212: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry + nil, // 213: openshell.v1.UpdateConfigRequest.AnnotationsEntry + nil, // 214: openshell.v1.UpdateConfigResponse.AnnotationsEntry + nil, // 215: openshell.v1.SandboxPolicyRevision.ProvenanceEntry + nil, // 216: openshell.v1.PolicyRevisionPayload.ProvenanceEntry + nil, // 217: openshell.v1.StoredPolicyRevision.ProvenanceEntry + nil, // 218: openshell.v1.CreateWorkspaceRequest.LabelsEntry + (*datamodelv1.ObjectMeta)(nil), // 219: openshell.datamodel.v1.ObjectMeta + (*sandboxv1.SandboxPolicy)(nil), // 220: openshell.sandbox.v1.SandboxPolicy + (*structpb.Struct)(nil), // 221: google.protobuf.Struct + (*datamodelv1.Provider)(nil), // 222: openshell.datamodel.v1.Provider + (*datamodelv1.CredentialHandle)(nil), // 223: openshell.datamodel.v1.CredentialHandle + (*sandboxv1.NetworkEndpoint)(nil), // 224: openshell.sandbox.v1.NetworkEndpoint + (*sandboxv1.NetworkBinary)(nil), // 225: openshell.sandbox.v1.NetworkBinary + (*sandboxv1.SettingValue)(nil), // 226: openshell.sandbox.v1.SettingValue + (*sandboxv1.NetworkPolicyRule)(nil), // 227: openshell.sandbox.v1.NetworkPolicyRule + (*sandboxv1.L7DenyRule)(nil), // 228: openshell.sandbox.v1.L7DenyRule + (*sandboxv1.L7Rule)(nil), // 229: openshell.sandbox.v1.L7Rule + (*datamodelv1.Workspace)(nil), // 230: openshell.datamodel.v1.Workspace + (*sandboxv1.GetSandboxConfigRequest)(nil), // 231: openshell.sandbox.v1.GetSandboxConfigRequest + (*sandboxv1.GetGatewayConfigRequest)(nil), // 232: openshell.sandbox.v1.GetGatewayConfigRequest + (*sandboxv1.GetSandboxConfigResponse)(nil), // 233: openshell.sandbox.v1.GetSandboxConfigResponse + (*sandboxv1.GetGatewayConfigResponse)(nil), // 234: openshell.sandbox.v1.GetGatewayConfigResponse } var file_openshell_proto_depIdxs = []int32{ - 191, // 0: openshell.v1.RefreshSandboxTokenResponse.extension_credentials:type_name -> openshell.v1.ExtensionServiceCredential + 193, // 0: openshell.v1.RefreshSandboxTokenResponse.extension_credentials:type_name -> openshell.v1.ExtensionServiceCredential 4, // 1: openshell.v1.HealthResponse.status:type_name -> openshell.v1.ServiceStatus 4, // 2: openshell.v1.GetGatewayInfoResponse.status:type_name -> openshell.v1.ServiceStatus 16, // 3: openshell.v1.GetGatewayInfoResponse.compute_drivers:type_name -> openshell.v1.ComputeDriverInfo 17, // 4: openshell.v1.ComputeDriverInfo.capabilities:type_name -> openshell.v1.ComputeDriverCapabilities - 217, // 5: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 219, // 5: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta 19, // 6: openshell.v1.Sandbox.spec:type_name -> openshell.v1.SandboxSpec 23, // 7: openshell.v1.Sandbox.status:type_name -> openshell.v1.SandboxStatus - 192, // 8: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry + 194, // 8: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry 22, // 9: openshell.v1.SandboxSpec.template:type_name -> openshell.v1.SandboxTemplate - 218, // 10: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 220, // 10: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy 20, // 11: openshell.v1.SandboxSpec.resource_requirements:type_name -> openshell.v1.ResourceRequirements 21, // 12: openshell.v1.ResourceRequirements.gpu:type_name -> openshell.v1.GpuResourceRequirements - 193, // 13: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry - 194, // 14: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry - 195, // 15: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry - 219, // 16: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct - 219, // 17: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct + 195, // 13: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry + 196, // 14: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry + 197, // 15: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry + 221, // 16: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct + 221, // 17: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct 24, // 18: openshell.v1.SandboxStatus.conditions:type_name -> openshell.v1.SandboxCondition 0, // 19: openshell.v1.SandboxStatus.phase:type_name -> openshell.v1.SandboxPhase - 196, // 20: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry + 198, // 20: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry 19, // 21: openshell.v1.CreateSandboxRequest.spec:type_name -> openshell.v1.SandboxSpec - 197, // 22: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry - 198, // 23: openshell.v1.CreateSandboxRequest.annotations:type_name -> openshell.v1.CreateSandboxRequest.AnnotationsEntry + 199, // 22: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry + 200, // 23: openshell.v1.CreateSandboxRequest.annotations:type_name -> openshell.v1.CreateSandboxRequest.AnnotationsEntry 18, // 24: openshell.v1.SandboxResponse.sandbox:type_name -> openshell.v1.Sandbox 18, // 25: openshell.v1.ListSandboxesResponse.sandboxes:type_name -> openshell.v1.Sandbox - 220, // 26: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 222, // 26: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider 18, // 27: openshell.v1.AttachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox 18, // 28: openshell.v1.DetachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox 50, // 29: openshell.v1.ListServicesResponse.services:type_name -> openshell.v1.ServiceEndpointResponse - 217, // 30: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 219, // 30: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta 49, // 31: openshell.v1.ServiceEndpointResponse.endpoint:type_name -> openshell.v1.ServiceEndpoint - 199, // 32: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry + 201, // 32: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry 54, // 33: openshell.v1.ExecSandboxEvent.stdout:type_name -> openshell.v1.ExecSandboxStdout 55, // 34: openshell.v1.ExecSandboxEvent.stderr:type_name -> openshell.v1.ExecSandboxStderr 56, // 35: openshell.v1.ExecSandboxEvent.exit:type_name -> openshell.v1.ExecSandboxExit - 141, // 36: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget - 142, // 37: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget + 143, // 36: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget + 144, // 37: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget 58, // 38: openshell.v1.TcpForwardFrame.init:type_name -> openshell.v1.TcpForwardInit 53, // 39: openshell.v1.ExecSandboxInput.start:type_name -> openshell.v1.ExecSandboxRequest 61, // 40: openshell.v1.ExecSandboxInput.resize:type_name -> openshell.v1.ExecSandboxWindowResize - 217, // 41: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 219, // 41: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta 18, // 42: openshell.v1.SandboxStreamEvent.sandbox:type_name -> openshell.v1.Sandbox 65, // 43: openshell.v1.SandboxStreamEvent.log:type_name -> openshell.v1.SandboxLogLine 25, // 44: openshell.v1.SandboxStreamEvent.event:type_name -> openshell.v1.PlatformEvent 66, // 45: openshell.v1.SandboxStreamEvent.warning:type_name -> openshell.v1.SandboxStreamWarning - 152, // 46: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate - 200, // 47: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry - 220, // 48: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 220, // 49: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 201, // 50: openshell.v1.UpdateProviderRequest.credential_expires_at_ms:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry - 220, // 51: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider - 220, // 52: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 154, // 46: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate + 202, // 47: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry + 222, // 48: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 222, // 49: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 203, // 50: openshell.v1.UpdateProviderRequest.credential_expires_at_ms:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry + 222, // 51: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider + 222, // 52: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider 96, // 53: openshell.v1.ProviderProfileImportItem.profile:type_name -> openshell.v1.ProviderProfile 78, // 54: openshell.v1.ProviderCredentialTokenGrant.audience_overrides:type_name -> openshell.v1.ProviderCredentialTokenGrantAudienceOverride 83, // 55: openshell.v1.ProviderProfileCredential.refresh:type_name -> openshell.v1.ProviderCredentialRefresh @@ -14676,25 +14832,25 @@ var file_openshell_proto_depIdxs = []int32{ 81, // 58: openshell.v1.ProviderCredentialRefresh.material:type_name -> openshell.v1.ProviderCredentialRefreshMaterial 82, // 59: openshell.v1.ProviderCredentialRefresh.additional_outputs:type_name -> openshell.v1.ProviderCredentialRefreshOutput 1, // 60: openshell.v1.ProviderCredentialRefreshStatus.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 217, // 61: openshell.v1.StoredProviderCredentialRefreshState.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 219, // 61: openshell.v1.StoredProviderCredentialRefreshState.metadata:type_name -> openshell.datamodel.v1.ObjectMeta 1, // 62: openshell.v1.StoredProviderCredentialRefreshState.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 202, // 63: openshell.v1.StoredProviderCredentialRefreshState.material:type_name -> openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry - 203, // 64: openshell.v1.StoredProviderCredentialRefreshState.additional_output_keys:type_name -> openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry - 204, // 65: openshell.v1.StoredProviderCredentialRefreshState.secret_material_handles:type_name -> openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry + 204, // 63: openshell.v1.StoredProviderCredentialRefreshState.material:type_name -> openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry + 205, // 64: openshell.v1.StoredProviderCredentialRefreshState.additional_output_keys:type_name -> openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry + 206, // 65: openshell.v1.StoredProviderCredentialRefreshState.secret_material_handles:type_name -> openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry 87, // 66: openshell.v1.StoredProviderCredentialRefreshState.pending_secret_deletions:type_name -> openshell.v1.StoredRefreshMaterialDeletion - 221, // 67: openshell.v1.StoredRefreshMaterialDeletion.handle:type_name -> openshell.datamodel.v1.CredentialHandle + 223, // 67: openshell.v1.StoredRefreshMaterialDeletion.handle:type_name -> openshell.datamodel.v1.CredentialHandle 84, // 68: openshell.v1.GetProviderRefreshStatusResponse.credentials:type_name -> openshell.v1.ProviderCredentialRefreshStatus 1, // 69: openshell.v1.ConfigureProviderRefreshRequest.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 205, // 70: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + 207, // 70: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry 84, // 71: openshell.v1.ConfigureProviderRefreshResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus 84, // 72: openshell.v1.RotateProviderCredentialResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus 2, // 73: openshell.v1.ProviderProfile.category:type_name -> openshell.v1.ProviderProfileCategory 80, // 74: openshell.v1.ProviderProfile.credentials:type_name -> openshell.v1.ProviderProfileCredential - 222, // 75: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint - 223, // 76: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 224, // 75: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint + 225, // 76: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary 85, // 77: openshell.v1.ProviderProfile.discovery:type_name -> openshell.v1.ProviderProfileDiscovery - 206, // 78: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry - 217, // 79: openshell.v1.StoredProviderProfile.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 208, // 78: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry + 219, // 79: openshell.v1.StoredProviderProfile.metadata:type_name -> openshell.datamodel.v1.ObjectMeta 96, // 80: openshell.v1.StoredProviderProfile.profile:type_name -> openshell.v1.ProviderProfile 96, // 81: openshell.v1.ProviderProfileResponse.profile:type_name -> openshell.v1.ProviderProfile 96, // 82: openshell.v1.ListProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile @@ -14707,67 +14863,67 @@ var file_openshell_proto_depIdxs = []int32{ 76, // 89: openshell.v1.LintProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem 77, // 90: openshell.v1.LintProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic 110, // 91: openshell.v1.StaticCredentialBinding.endpoints:type_name -> openshell.v1.StaticCredentialEndpointBinding - 207, // 92: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry - 208, // 93: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expires_at_ms:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry - 209, // 94: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry - 210, // 95: openshell.v1.GetSandboxProviderEnvironmentResponse.static_credential_bindings:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry - 218, // 96: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 224, // 97: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue + 209, // 92: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + 210, // 93: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expires_at_ms:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry + 211, // 94: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + 212, // 95: openshell.v1.GetSandboxProviderEnvironmentResponse.static_credential_bindings:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry + 220, // 96: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 226, // 97: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue 114, // 98: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation - 211, // 99: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry + 213, // 99: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry 115, // 100: openshell.v1.PolicyMergeOperation.add_rule:type_name -> openshell.v1.AddNetworkRule 116, // 101: openshell.v1.PolicyMergeOperation.remove_endpoint:type_name -> openshell.v1.RemoveNetworkEndpoint 117, // 102: openshell.v1.PolicyMergeOperation.remove_rule:type_name -> openshell.v1.RemoveNetworkRule 118, // 103: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules 119, // 104: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules 120, // 105: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary - 225, // 106: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 226, // 107: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule - 227, // 108: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule - 212, // 109: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry + 227, // 106: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 228, // 107: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 229, // 108: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule + 214, // 109: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry 128, // 110: openshell.v1.GetSandboxPolicyStatusResponse.revision:type_name -> openshell.v1.SandboxPolicyRevision 128, // 111: openshell.v1.ListSandboxPoliciesResponse.revisions:type_name -> openshell.v1.SandboxPolicyRevision 3, // 112: openshell.v1.ReportPolicyStatusRequest.status:type_name -> openshell.v1.PolicyStatus 3, // 113: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus - 218, // 114: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 213, // 115: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry + 220, // 114: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 215, // 115: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry 65, // 116: openshell.v1.PushSandboxLogsRequest.logs:type_name -> openshell.v1.SandboxLogLine 65, // 117: openshell.v1.GetSandboxLogsResponse.logs:type_name -> openshell.v1.SandboxLogLine 135, // 118: openshell.v1.SupervisorMessage.hello:type_name -> openshell.v1.SupervisorHello 138, // 119: openshell.v1.SupervisorMessage.heartbeat:type_name -> openshell.v1.SupervisorHeartbeat - 145, // 120: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult - 146, // 121: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose + 147, // 120: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult + 148, // 121: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose 136, // 122: openshell.v1.GatewayMessage.session_accepted:type_name -> openshell.v1.SessionAccepted 137, // 123: openshell.v1.GatewayMessage.session_rejected:type_name -> openshell.v1.SessionRejected 139, // 124: openshell.v1.GatewayMessage.heartbeat:type_name -> openshell.v1.GatewayHeartbeat - 140, // 125: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen - 146, // 126: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose - 141, // 127: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget - 142, // 128: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget - 143, // 129: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit - 147, // 130: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample - 149, // 131: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount - 225, // 132: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 148, // 133: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary - 151, // 134: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk - 150, // 135: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary - 151, // 136: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk - 225, // 137: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 170, // 138: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry - 218, // 139: openshell.v1.PolicyRevisionPayload.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 214, // 140: openshell.v1.PolicyRevisionPayload.provenance:type_name -> openshell.v1.PolicyRevisionPayload.ProvenanceEntry - 225, // 141: openshell.v1.DraftChunkPayload.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 215, // 142: openshell.v1.StoredPolicyRevision.provenance:type_name -> openshell.v1.StoredPolicyRevision.ProvenanceEntry - 216, // 143: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry - 228, // 144: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 228, // 145: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 228, // 146: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace - 217, // 147: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 142, // 125: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen + 148, // 126: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose + 143, // 127: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget + 144, // 128: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget + 145, // 129: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit + 149, // 130: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample + 151, // 131: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount + 227, // 132: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 150, // 133: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary + 153, // 134: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk + 152, // 135: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary + 153, // 136: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk + 227, // 137: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 172, // 138: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry + 220, // 139: openshell.v1.PolicyRevisionPayload.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 216, // 140: openshell.v1.PolicyRevisionPayload.provenance:type_name -> openshell.v1.PolicyRevisionPayload.ProvenanceEntry + 227, // 141: openshell.v1.DraftChunkPayload.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 217, // 142: openshell.v1.StoredPolicyRevision.provenance:type_name -> openshell.v1.StoredPolicyRevision.ProvenanceEntry + 218, // 143: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry + 230, // 144: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 230, // 145: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 230, // 146: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace + 219, // 147: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta 5, // 148: openshell.v1.WorkspaceMember.role:type_name -> openshell.v1.WorkspaceRole 5, // 149: openshell.v1.AddWorkspaceMemberRequest.role:type_name -> openshell.v1.WorkspaceRole - 184, // 150: openshell.v1.AddWorkspaceMemberResponse.member:type_name -> openshell.v1.WorkspaceMember - 184, // 151: openshell.v1.ListWorkspaceMembersResponse.members:type_name -> openshell.v1.WorkspaceMember - 221, // 152: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry.value:type_name -> openshell.datamodel.v1.CredentialHandle + 186, // 150: openshell.v1.AddWorkspaceMemberResponse.member:type_name -> openshell.v1.WorkspaceMember + 186, // 151: openshell.v1.ListWorkspaceMembersResponse.members:type_name -> openshell.v1.WorkspaceMember + 223, // 152: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry.value:type_name -> openshell.datamodel.v1.CredentialHandle 80, // 153: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential 111, // 154: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry.value:type_name -> openshell.v1.StaticCredentialBinding 10, // 155: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest @@ -14806,8 +14962,8 @@ var file_openshell_proto_depIdxs = []int32{ 94, // 188: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest 71, // 189: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest 107, // 190: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest - 229, // 191: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest - 230, // 192: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest + 231, // 191: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest + 232, // 192: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest 113, // 193: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest 122, // 194: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest 124, // 195: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest @@ -14816,94 +14972,96 @@ var file_openshell_proto_depIdxs = []int32{ 129, // 198: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest 130, // 199: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest 133, // 200: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage - 144, // 201: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame - 63, // 202: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest - 153, // 203: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest - 155, // 204: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest - 157, // 205: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest - 159, // 206: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest - 161, // 207: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest - 163, // 208: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest - 165, // 209: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest - 167, // 210: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest - 169, // 211: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest - 6, // 212: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest - 8, // 213: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest - 176, // 214: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest - 178, // 215: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest - 180, // 216: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest - 182, // 217: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest - 185, // 218: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest - 187, // 219: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest - 189, // 220: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest - 11, // 221: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse - 13, // 222: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse - 15, // 223: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse - 35, // 224: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse - 35, // 225: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse - 36, // 226: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse - 37, // 227: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse - 38, // 228: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse - 39, // 229: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse - 40, // 230: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse - 35, // 231: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse - 35, // 232: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse - 42, // 233: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse - 50, // 234: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse - 50, // 235: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse - 46, // 236: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse - 48, // 237: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse - 52, // 238: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse - 57, // 239: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent - 59, // 240: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame - 57, // 241: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent - 72, // 242: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse - 72, // 243: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse - 73, // 244: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse - 99, // 245: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse - 98, // 246: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse - 101, // 247: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse - 103, // 248: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse - 105, // 249: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse - 72, // 250: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse - 89, // 251: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse - 91, // 252: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse - 93, // 253: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse - 95, // 254: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse - 106, // 255: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse - 108, // 256: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse - 231, // 257: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse - 232, // 258: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse - 121, // 259: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse - 123, // 260: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse - 125, // 261: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse - 127, // 262: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse - 112, // 263: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse - 132, // 264: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse - 131, // 265: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse - 134, // 266: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage - 144, // 267: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame - 64, // 268: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent - 154, // 269: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse - 156, // 270: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse - 158, // 271: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse - 160, // 272: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse - 162, // 273: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse - 164, // 274: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse - 166, // 275: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse - 168, // 276: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse - 171, // 277: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse - 7, // 278: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse - 9, // 279: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse - 177, // 280: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse - 179, // 281: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse - 181, // 282: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse - 183, // 283: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse - 186, // 284: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse - 188, // 285: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse - 190, // 286: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse - 221, // [221:287] is the sub-list for method output_type - 155, // [155:221] is the sub-list for method input_type + 140, // 201: openshell.v1.OpenShell.ReportMainProcessExit:input_type -> openshell.v1.ReportMainProcessExitRequest + 146, // 202: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame + 63, // 203: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest + 155, // 204: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest + 157, // 205: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest + 159, // 206: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest + 161, // 207: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest + 163, // 208: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest + 165, // 209: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest + 167, // 210: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest + 169, // 211: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest + 171, // 212: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest + 6, // 213: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest + 8, // 214: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest + 178, // 215: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest + 180, // 216: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest + 182, // 217: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest + 184, // 218: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest + 187, // 219: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest + 189, // 220: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest + 191, // 221: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest + 11, // 222: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse + 13, // 223: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse + 15, // 224: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse + 35, // 225: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse + 35, // 226: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse + 36, // 227: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse + 37, // 228: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse + 38, // 229: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse + 39, // 230: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse + 40, // 231: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse + 35, // 232: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse + 35, // 233: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse + 42, // 234: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse + 50, // 235: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse + 50, // 236: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse + 46, // 237: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse + 48, // 238: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse + 52, // 239: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse + 57, // 240: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent + 59, // 241: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame + 57, // 242: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent + 72, // 243: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse + 72, // 244: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse + 73, // 245: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse + 99, // 246: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse + 98, // 247: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse + 101, // 248: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse + 103, // 249: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse + 105, // 250: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse + 72, // 251: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse + 89, // 252: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse + 91, // 253: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse + 93, // 254: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse + 95, // 255: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse + 106, // 256: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse + 108, // 257: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse + 233, // 258: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse + 234, // 259: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse + 121, // 260: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse + 123, // 261: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse + 125, // 262: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse + 127, // 263: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse + 112, // 264: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse + 132, // 265: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse + 131, // 266: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse + 134, // 267: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage + 141, // 268: openshell.v1.OpenShell.ReportMainProcessExit:output_type -> openshell.v1.ReportMainProcessExitResponse + 146, // 269: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame + 64, // 270: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent + 156, // 271: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse + 158, // 272: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse + 160, // 273: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse + 162, // 274: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse + 164, // 275: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse + 166, // 276: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse + 168, // 277: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse + 170, // 278: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse + 173, // 279: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse + 7, // 280: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse + 9, // 281: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse + 179, // 282: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse + 181, // 283: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse + 183, // 284: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse + 185, // 285: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse + 188, // 286: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse + 190, // 287: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse + 192, // 288: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse + 222, // [222:289] is the sub-list for method output_type + 155, // [155:222] is the sub-list for method input_type 155, // [155:155] is the sub-list for extension type_name 155, // [155:155] is the sub-list for extension extendee 0, // [0:155] is the sub-list for field type_name @@ -14916,6 +15074,7 @@ func file_openshell_proto_init() { } file_openshell_proto_msgTypes[15].OneofWrappers = []any{} file_openshell_proto_msgTypes[16].OneofWrappers = []any{} + file_openshell_proto_msgTypes[17].OneofWrappers = []any{} file_openshell_proto_msgTypes[51].OneofWrappers = []any{ (*ExecSandboxEvent_Stdout)(nil), (*ExecSandboxEvent_Stderr)(nil), @@ -14963,23 +15122,23 @@ func file_openshell_proto_init() { (*GatewayMessage_RelayOpen)(nil), (*GatewayMessage_RelayClose)(nil), } - file_openshell_proto_msgTypes[134].OneofWrappers = []any{ + file_openshell_proto_msgTypes[136].OneofWrappers = []any{ (*RelayOpen_Ssh)(nil), (*RelayOpen_Tcp)(nil), } - file_openshell_proto_msgTypes[138].OneofWrappers = []any{ + file_openshell_proto_msgTypes[140].OneofWrappers = []any{ (*RelayFrame_Init)(nil), (*RelayFrame_Data)(nil), } - file_openshell_proto_msgTypes[168].OneofWrappers = []any{} - file_openshell_proto_msgTypes[169].OneofWrappers = []any{} + file_openshell_proto_msgTypes[170].OneofWrappers = []any{} + file_openshell_proto_msgTypes[171].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_openshell_proto_rawDesc), len(file_openshell_proto_rawDesc)), NumEnums: 6, - NumMessages: 211, + NumMessages: 213, NumExtensions: 0, NumServices: 1, }, diff --git a/sdk/go/proto/openshellv1/openshell_grpc.pb.go b/sdk/go/proto/openshellv1/openshell_grpc.pb.go index 92c94ef299..2ea9410c3e 100644 --- a/sdk/go/proto/openshellv1/openshell_grpc.pb.go +++ b/sdk/go/proto/openshellv1/openshell_grpc.pb.go @@ -69,6 +69,7 @@ const ( OpenShell_GetSandboxLogs_FullMethodName = "/openshell.v1.OpenShell/GetSandboxLogs" OpenShell_PushSandboxLogs_FullMethodName = "/openshell.v1.OpenShell/PushSandboxLogs" OpenShell_ConnectSupervisor_FullMethodName = "/openshell.v1.OpenShell/ConnectSupervisor" + OpenShell_ReportMainProcessExit_FullMethodName = "/openshell.v1.OpenShell/ReportMainProcessExit" OpenShell_RelayStream_FullMethodName = "/openshell.v1.OpenShell/RelayStream" OpenShell_WatchSandbox_FullMethodName = "/openshell.v1.OpenShell/WatchSandbox" OpenShell_SubmitPolicyAnalysis_FullMethodName = "/openshell.v1.OpenShell/SubmitPolicyAnalysis" @@ -211,6 +212,8 @@ type OpenShellClient interface { // bytes flow over RelayStream calls (separate HTTP/2 streams on the same // connection), not over this stream. ConnectSupervisor(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[SupervisorMessage, GatewayMessage], error) + // Persist the canonical main process result before the supervisor exits. + ReportMainProcessExit(ctx context.Context, in *ReportMainProcessExitRequest, opts ...grpc.CallOption) (*ReportMainProcessExitResponse, error) // Raw byte relay between supervisor and gateway. // // The supervisor initiates this call after receiving a RelayOpen message @@ -766,6 +769,16 @@ func (c *openShellClient) ConnectSupervisor(ctx context.Context, opts ...grpc.Ca // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type OpenShell_ConnectSupervisorClient = grpc.BidiStreamingClient[SupervisorMessage, GatewayMessage] +func (c *openShellClient) ReportMainProcessExit(ctx context.Context, in *ReportMainProcessExitRequest, opts ...grpc.CallOption) (*ReportMainProcessExitResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ReportMainProcessExitResponse) + err := c.cc.Invoke(ctx, OpenShell_ReportMainProcessExit_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *openShellClient) RelayStream(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[RelayFrame, RelayFrame], error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) stream, err := c.cc.NewStream(ctx, &OpenShell_ServiceDesc.Streams[5], OpenShell_RelayStream_FullMethodName, cOpts...) @@ -1098,6 +1111,8 @@ type OpenShellServer interface { // bytes flow over RelayStream calls (separate HTTP/2 streams on the same // connection), not over this stream. ConnectSupervisor(grpc.BidiStreamingServer[SupervisorMessage, GatewayMessage]) error + // Persist the canonical main process result before the supervisor exits. + ReportMainProcessExit(context.Context, *ReportMainProcessExitRequest) (*ReportMainProcessExitResponse, error) // Raw byte relay between supervisor and gateway. // // The supervisor initiates this call after receiving a RelayOpen message @@ -1310,6 +1325,9 @@ func (UnimplementedOpenShellServer) PushSandboxLogs(grpc.ClientStreamingServer[P func (UnimplementedOpenShellServer) ConnectSupervisor(grpc.BidiStreamingServer[SupervisorMessage, GatewayMessage]) error { return status.Error(codes.Unimplemented, "method ConnectSupervisor not implemented") } +func (UnimplementedOpenShellServer) ReportMainProcessExit(context.Context, *ReportMainProcessExitRequest) (*ReportMainProcessExitResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ReportMainProcessExit not implemented") +} func (UnimplementedOpenShellServer) RelayStream(grpc.BidiStreamingServer[RelayFrame, RelayFrame]) error { return status.Error(codes.Unimplemented, "method RelayStream not implemented") } @@ -2168,6 +2186,24 @@ func _OpenShell_ConnectSupervisor_Handler(srv interface{}, stream grpc.ServerStr // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type OpenShell_ConnectSupervisorServer = grpc.BidiStreamingServer[SupervisorMessage, GatewayMessage] +func _OpenShell_ReportMainProcessExit_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ReportMainProcessExitRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ReportMainProcessExit(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ReportMainProcessExit_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ReportMainProcessExit(ctx, req.(*ReportMainProcessExitRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _OpenShell_RelayStream_Handler(srv interface{}, stream grpc.ServerStream) error { return srv.(OpenShellServer).RelayStream(&grpc.GenericServerStream[RelayFrame, RelayFrame]{ServerStream: stream}) } @@ -2681,6 +2717,10 @@ var OpenShell_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetSandboxLogs", Handler: _OpenShell_GetSandboxLogs_Handler, }, + { + MethodName: "ReportMainProcessExit", + Handler: _OpenShell_ReportMainProcessExit_Handler, + }, { MethodName: "SubmitPolicyAnalysis", Handler: _OpenShell_SubmitPolicyAnalysis_Handler,