From 9c41b0eecd8e3031e72c67966b486634f6ca5466 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:02:40 +0300 Subject: [PATCH 01/32] fix(sandbox): send the argv the Cloud Run launcher actually accepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `create` sent `run --id `. There is no `--id` flag on `run` — the id is positional — and without `--detach` the launcher stays attached until the control deadline kills it. Measured against a live launcher: it answers `unknown flag: --id`, exits 0, and leaves a session nothing can reach. So the call reported success and handed back an id that addressed nothing. Both env refusals go too. `--env` is accepted on `run` and on `exec`, and a sandbox inherits nothing from the container, so refusing it denied the only way to get a variable in. The test fake accepted any argv, which is how this passed review: three existing tests were green against the broken form. It now rejects unknown verbs and flags, and reverting the argv fails five tests. `fixtures/gcp-sandbox-cli-help.txt` records the launcher's real surface — eight verbs where the published reference lists six. --- .../sandbox/fixtures/gcp-sandbox-cli-help.txt | 207 ++++++++++++++++++ .../src/providers/sandbox/gcp.rs | 148 +++++++++---- 2 files changed, 312 insertions(+), 43 deletions(-) create mode 100644 crates/alien-bindings/src/providers/sandbox/fixtures/gcp-sandbox-cli-help.txt diff --git a/crates/alien-bindings/src/providers/sandbox/fixtures/gcp-sandbox-cli-help.txt b/crates/alien-bindings/src/providers/sandbox/fixtures/gcp-sandbox-cli-help.txt new file mode 100644 index 000000000..66838fd0e --- /dev/null +++ b/crates/alien-bindings/src/providers/sandbox/fixtures/gcp-sandbox-cli-help.txt @@ -0,0 +1,207 @@ +# Captured from a live Cloud Run service with sandboxLauncher enabled, 2026-08-21. +# +# The reference at docs.cloud.google.com/run/docs/reference/sandbox-cli lists six verbs; this +# build has eight (completion, help are undocumented). That page says to run `sandbox -h` for +# the complete list, and it is right to. +# +# Kept so that "the launcher has no X verb" in gcp.rs is a citation rather than an assertion. +# Re-capture by running `sandbox -h`, then `sandbox -h` for each verb, inside a Cloud +# Run container deployed with --sandbox-launcher. +# --------------------------------------------------------------------------------------------- + + +===== ENVIRONMENT ===== +launcher path: /usr/local/gcp/bin/sandbox +RESULT launcher_present=yes +nproc=5 mem=4010112kB + +===== sandbox -h ===== +Serverless sandboxing CLI, providing compartmentalized execution for commands. + +Usage: + sandbox [command] + +Available Commands: + completion Generate the autocompletion script for the specified shell + delete Delete a sandbox + do Execute the specified command in a sandbox + exec Execute a command in an existing sandbox session + fork Fork a running sandbox to a new one. + help Help about any command + run Start a new sandbox. + tar Export a tarfile of the writable overlay (rootfs-upper) of a running sandbox + +Flags: + -h, --help help for sandbox + +Use "sandbox [command] --help" for more information about a command. + +===== sandbox do -h ===== +The do command provides support for executing a command in a sandbox without having to think about sandbox lifecycle management. A new sandbox will be created and destroyed for each execution, optionally persisting the state of the filesystem to a persistence directory between executions. This command blocks until the command and sandbox lifecycle completes. + +Usage: + sandbox do [flags] [command-to-execute] + +Flags: + --allow-egress Allow egress for this sandbox + -e, --env string Environment variables to set in the sandbox + --export-tar string The tarball to export rootfs-upper to on exit + -h, --help help for do + --import-tar string The tarball to import rootfs-upper from + --mount string Mounts for the sandbox + -p, --publish string Ports to expose from the sandbox + --rootfs string Run the command using the root of the executing container as the root directory of the sandbox. By default, this mount is read-only (default "/") + --sandbox-name string The ID to use for the sandbox; if not specified, a random ID will be generated + --stderr Wire the stderr pipe of the sandbox command to the stderr of the process executing in the sandbox (default true) + --stdin Wire the stdin pipe of the sandbox command to the stdin of the process executing in the sandbox (default true) + --stdout Wire the stdout pipe of the sandbox command to the stdout of the process executing in the sandbox (default true) + --sync-tar string The tarball to use for keeping the filesystem in sync (import if exists, export on exit) + --template-var string Template variables to set in the sandbox (format: KEY=VALUE) + -w, --workdir string The working directory to execute the command in + --write Allow filesystems that have been mounted to be writable by this sandbox + +===== sandbox run -h ===== +The run command creates and starts a sandbox. If no command is specified, an empty sandbox will be started. The command blocks until the container has started. + +Usage: + sandbox run [command-to-execute] [flags] + +Flags: + --allow-egress Allow egress for this sandbox. + --detach Detach the sandbox from the console + -e, --env string Environment variables to set in the sandbox + -h, --help help for run + --import-tar string The tarball to import rootfs-upper from + --mount string Mounts for the sandbox + -p, --publish string Ports to expose from the sandbox + --rootfs string Run the command using the root of the executing container as the root directory of the sandbox. (default "/") + --stderr Wire the stderr pipe of the sandbox command to the stderr of the process executing in the sandbox (default true) + --stdin Wire the stdin pipe of the sandbox command to the stdin of the process executing in the sandbox (default true) + --stdout Wire the stdout pipe of the sandbox command to the stdout of the process executing in the sandbox (default true) + --template-var string Template variables to set in the sandbox (format: KEY=VALUE) + -w, --workdir string The working directory to execute the command in. + --write Allow filesystems that have been mounted to be writable by this sandbox + +===== sandbox exec -h ===== +The exec command allows you to execute a command in a running sandbox. The sandbox must be running already, or the command will fail. + +Usage: + sandbox exec [args...] [flags] + +Flags: + -e, --env string Environment variables to set in the sandbox + -h, --help help for exec + --stderr Wire the stderr pipe of the sandbox command to the stderr of the process executing in the sandbox (default true) + --stdin Wire the stdin pipe of the sandbox command to the stdin of the process executing in the sandbox (default true) + --stdout Wire the stdout pipe of the sandbox command to the stdout of the process executing in the sandbox (default true) + -w, --workdir string The working directory to execute the command in + +===== sandbox fork -h ===== +Fork creates a new sandbox using the state and command line of a running source sandbox. + +Usage: + sandbox fork [flags] + +Flags: + --allow-egress Allow egress for this sandbox + --detach Detach the new sandbox from the console + -h, --help help for fork + -p, --publish string Ports to expose from the sandbox + --stderr Wire the stderr pipe of the sandbox command to the stderr of the process executing in the sandbox (default true) + --stdin Wire the stdin pipe of the sandbox command to the stdin of the process executing in the sandbox (default true) + --stdout Wire the stdout pipe of the sandbox command to the stdout of the process executing in the sandbox (default true) + --tar string The tarball from the source sandbox state with which the target sandbox was started + +===== sandbox tar -h ===== +The tar command creates a tarball of the writable overlay (rootfs-upper) of a sandbox container, containing all changes made in the sandbox. The tarball will capture all files and directories that differ from the rootfs. + +Usage: + sandbox tar [flags] + +Flags: + --file string The file to write the tarball to + -h, --help help for tar + --stderr Wire the stderr pipe of the sandbox command to the stderr of the process executing in the sandbox (default true) + --stdin Wire the stdin pipe of the sandbox command to the stdin of the process executing in the sandbox (default true) + --stdout Wire the stdout pipe of the sandbox command to the stdout of the process executing in the sandbox (default true) + +===== sandbox delete -h ===== +The delete command removes a sandbox and cleans up its resources. In the case of a running sandbox, the sandbox can be deleted by adding --force. + +Usage: + sandbox delete [flags] + +Flags: + --force Force delete the sandbox, even if it is running + -h, --help help for delete + --stderr Wire the stderr pipe of the sandbox command to the stderr of the process executing in the sandbox (default true) + --stdin Wire the stdin pipe of the sandbox command to the stdin of the process executing in the sandbox (default true) + --stdout Wire the stdout pipe of the sandbox command to the stdout of the process executing in the sandbox (default true) + +===== verbs this backend reports as absent ===== + suspend: absent +RESULT verb_suspend=absent + resume: absent +RESULT verb_resume=absent + list: absent +RESULT verb_list=absent + ps: absent +RESULT verb_ps=absent + snapshot: absent +RESULT verb_snapshot=absent + checkpoint: absent +RESULT verb_checkpoint=absent + restore: absent +RESULT verb_restore=absent + +===== create argv: --id versus the documented positional id ===== +--- ours: run --id poc-ours-14 --detach --- +Error: unknown flag: --id + +RESULT ours_argv_rc=0 +--- documented: run poc-doc-14 --detach --- +Running in detached mode: stdin, stdout and stderr arguments are ignored. +RESULT doc_argv_rc=0 +--- can each id be reached by exec? --- + 'poc-ours-14': not reachable +RESULT reachable_poc-ours-=no + 'poc-doc-14': REACHABLE +RESULT reachable_poc-doc-=yes + '--id': not reachable +RESULT reachable_--id=no + +===== does run without --detach block? ===== + rc=124 after 20s (rc=124 means it blocked until the timeout) +RESULT detach_needed=yes +RESULT nodetach_elapsed=20 + +===== does --env work? ===== + run --env then exec: [hello] +RESULT env_on_run=works + exec --env: [world] +RESULT env_on_exec=works + does a sandbox inherit the container's env? (Google says no) + [] +RESULT env_inherited=no + +===== tar export / import round trip ===== +Serializing rootfs upper layer into a tar archive for container: poc-tar-14, sandbox: poc-tar-14 + tar produced 2560 bytes +RESULT tar_export=yes + restored marker: Error: sandbox poc-restore-14 is not running +RESULT tar_import=no + +===== does a sandbox see the instance's CPU and memory? ===== + host: cpu=5 mem=4010112kB + sandbox: 5 4010112 +RESULT host_cpu=5 +RESULT sandbox_cpu_mem=5 4010112 + +===== CLEANUP ===== + deleted poc-ours-14 + deleted poc-doc-14 + deleted poc-nodet-14 + deleted poc-env-14 + deleted poc-tar-14 +PROBE-COMPLETE +PROBE-DONE diff --git a/crates/alien-bindings/src/providers/sandbox/gcp.rs b/crates/alien-bindings/src/providers/sandbox/gcp.rs index 50c7166b2..4ade2ab3f 100644 --- a/crates/alien-bindings/src/providers/sandbox/gcp.rs +++ b/crates/alien-bindings/src/providers/sandbox/gcp.rs @@ -168,22 +168,29 @@ impl Sandbox for GcpSandbox { /// Starts a sandbox with a caller-chosen id. /// + /// The launcher's real verb and flag list is captured in + /// `fixtures/gcp-sandbox-cli-help.txt`, so the "no X verb" refusals below cite it. + /// /// Egress comes from the binding rather than the request: the launcher decides it at create /// time and an application must not be able to widen its own. async fn create(&self, request: CreateSessionRequest) -> Result { - if !request.env.is_empty() { - return Err(self.failed( - "sandbox.create", - "the Cloud Run sandbox launcher takes no session environment; bake it into the \ - image or pass it in each command", - )); - } - let session_id = request .session_id .unwrap_or_else(|| uuid::Uuid::new_v4().simple().to_string()); - let mut arguments = vec!["run".to_string(), "--id".to_string(), session_id.clone()]; + // The id is positional and `--detach` is what makes this return: without it the launcher + // stays attached and `control` waits out its deadline instead of handing back a session. + let mut arguments = vec![ + "run".to_string(), + session_id.clone(), + "--detach".to_string(), + ]; + // A sandbox inherits nothing from the container, so a variable the caller asked for only + // exists if it is passed here. + for (key, value) in &request.env { + arguments.push("--env".to_string()); + arguments.push(format!("{key}={value}")); + } if self.allow_egress { arguments.push("--allow-egress".to_string()); } @@ -236,23 +243,17 @@ impl Sandbox for GcpSandbox { )); } - // The launcher takes no environment, and dropping what a caller asked for is the silent - // no-op the capability contract forbids: a command reading a variable it was promised - // would see nothing and fail somewhere far from here. - if !request.env.is_empty() { - return Err(self.failed( - "sandbox.runCommand", - "the Cloud Run sandbox launcher takes no per-command environment; bake it into \ - the image or pass it in the command", - )); - } - let mut arguments = self.exec_arguments(session_id, &request.command); + // Prepended rather than appended: everything after `--` is the caller's command, so + // anything meant for the launcher has to land before it. if let Some(directory) = &request.working_directory { - // Prepended rather than appended: everything after `--` is the caller's command. arguments.insert(2, directory.clone()); arguments.insert(2, "--workdir".to_string()); } + for (key, value) in &request.env { + arguments.insert(2, format!("{key}={value}")); + arguments.insert(2, "--env".to_string()); + } let child = sandbox_process::spawn(&self.launcher_path, &arguments) .and_then(|mut command| command.spawn()) @@ -401,15 +402,42 @@ mod tests { use super::*; use alien_core::bindings::BindingValue; - /// A fake launcher: it records the argv it was given and answers like the real one. + /// A fake launcher that rejects argv the real one rejects. /// /// Testing against a script rather than a mock is deliberate. What this provider gets wrong /// is argument construction, and a mock of the launcher would be built from the same /// misunderstanding as the code. + /// + /// `body` runs only after the argv passes `strict_launcher`'s checks. A fake that accepts + /// anything is worse than none: it produced green tests for a `create` that sent + /// `run --id `, which the real launcher answers with `unknown flag: --id`. fn launcher(body: &str) -> (tempfile::TempDir, GcpSandbox) { + launcher_with_prelude(STRICT_PRELUDE, body) + } + + /// Verbs and flags taken from a live `sandbox -h`, not from the reference page — the page + /// lists six verbs where the launcher has eight. + const STRICT_PRELUDE: &str = r#" +case "$1" in + run|exec|do|fork|tar|delete|completion|help) ;; + *) echo "Error: unknown command: $1" >&2; exit 1 ;; +esac +# The real launcher exits 0 on an unknown flag, which is how a broken create looked healthy. +# This one exits 2, so the same mistake fails a test instead of passing one. "$@" is left +# intact so the body sees exactly what the provider sent, verb included. +for a in "$@"; do + case "$a" in + --) break ;; + --detach|--allow-egress|--write|--env|--workdir|--import-tar|--mount|--rootfs|--file|--force|--tar|--sandbox-name|-e|-w) ;; + --*) echo "Error: unknown flag: $a" >&2; exit 2 ;; + esac +done +"#; + + fn launcher_with_prelude(prelude: &str, body: &str) -> (tempfile::TempDir, GcpSandbox) { let directory = tempfile::tempdir().expect("temp dir"); let path = directory.path().join("sandbox"); - std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("write launcher"); + std::fs::write(&path, format!("#!/bin/sh\n{prelude}\n{body}\n")).expect("write launcher"); #[cfg(unix)] { @@ -549,27 +577,28 @@ mod tests { ); } - /// The launcher carries no environment, so a caller that asks for one has to hear about it. - /// Accepting the request and running the command without those variables is the silent no-op - /// the capability contract exists to prevent — the failure would surface inside the sandbox, - /// far from the call that caused it. + /// A sandbox inherits nothing from the container, so a variable a caller asks for reaches the + /// command only if it is passed on the argv. Asserted on the recorded argv rather than on a + /// success code: the launcher exits 0 even when it rejects a flag, so a green call proves + /// nothing about what it was actually given. #[tokio::test] - async fn an_environment_the_launcher_cannot_carry_is_refused() { - let (_dir, sandbox) = launcher("exit 0"); - let env = BTreeMap::from([("TOKEN".to_string(), "secret".to_string())]); + async fn an_environment_reaches_the_launcher_on_create_and_on_exec() { + let directory = tempfile::tempdir().expect("temp dir"); + let record = directory.path().join("argv"); + let (_dir, sandbox) = launcher(&format!(r#"echo "$@" >> {}"#, record.display())); - let on_create = sandbox + let env = BTreeMap::from([("TOKEN".to_string(), "secret".to_string())]); + sandbox .create(CreateSessionRequest { session_id: Some("s1".to_string()), tenant_key: None, env: env.clone(), }) .await - .expect_err("a session environment cannot be honoured here"); - assert_eq!(on_create.code, "OPERATION_NOT_SUPPORTED"); + .expect("a session environment is carried, not refused"); - // `let else` rather than `expect_err`: the Ok side is a stream and carries no Debug. - let Err(on_command) = sandbox + // The stream has to be drained: dropping it undrained kills the child before it runs. + if let Ok(mut frames) = sandbox .run_command( "s1", RunCommandRequest { @@ -580,21 +609,54 @@ mod tests { }, ) .await - else { - panic!("a command environment cannot be honoured here"); - }; - assert_eq!(on_command.code, "OPERATION_NOT_SUPPORTED"); + { + use futures::StreamExt; + while frames.next().await.is_some() {} + } + + let argv = std::fs::read_to_string(&record).expect("launcher ran"); + let lines: Vec<&str> = argv.lines().collect(); + assert!( + lines[0].contains("--env TOKEN=secret"), + "create must pass the variable: {}", + lines[0] + ); + assert!( + lines[1].contains("--env TOKEN=secret"), + "exec must pass the variable: {}", + lines[1] + ); + // Before the command, or the launcher reads it as an argument to the command itself. + let exec = lines[1]; + assert!( + exec.find("--env").unwrap() < exec.find(" -- ").unwrap(), + "--env must precede the `--` separator: {exec}" + ); + } + + /// The create argv, pinned. `--id` does not exist on `run`; the id is positional, and without + /// `--detach` the launcher stays attached until the control deadline kills it. Both were + /// wrong here, and neither could be caught by a fake that accepted any argv. + #[tokio::test] + async fn create_passes_the_id_positionally_and_detaches() { + let directory = tempfile::tempdir().expect("temp dir"); + let record = directory.path().join("argv"); + let (_dir, sandbox) = launcher(&format!(r#"echo "$@" > {}"#, record.display())); - // The control: the same calls without an environment are accepted, so the assertions - // above cannot pass against a provider that refuses everything. sandbox .create(CreateSessionRequest { - session_id: Some("s2".to_string()), + session_id: Some("s1".to_string()), tenant_key: None, env: BTreeMap::new(), }) .await - .expect("a session with no environment is fine"); + .expect("create succeeds"); + + let argv = std::fs::read_to_string(&record).expect("launcher ran"); + let argv = argv.trim(); + assert!(argv.starts_with("run s1"), "id is positional: {argv}"); + assert!(argv.contains("--detach"), "must detach: {argv}"); + assert!(!argv.contains("--id"), "--id is not a flag on run: {argv}"); } /// A command with no deadline is a hang waiting for a slow day, in a sandbox running code the From b901fa48dde420d9f47dc097c1fb0cdc3c0dba86 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:16:22 +0300 Subject: [PATCH 02/32] fix(sandbox): send Azure the image the stack declared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.code({image})` was accepted on Azure and then dropped: the provider was constructed with a literal `"ubuntu"` and the binding had no field to carry anything else. Every session ran a stock image whatever the declaration said, and nothing failed, because a sandbox on the wrong image still starts. It is the one Azure gap with no typed error and no capability bit behind it. The binding now carries `diskImage` and the emitter fills it from `code`. A registry reference is refused at plan time rather than reinterpreted, matching the AWS emitter: the create body names a public catalog image, so `ghcr.io/org/x:tag` has nowhere to go and saying so beats substituting. Ceilings stay the service defaults, now named rather than inline. `.limits()` is refused on Azure at plan time, so nothing declares them and there is no value to carry; they move into the binding when `enforcedLimits` flips. Reverting the plumbing fails the new provider-level test — the seam that was wrong is the one under assertion, not just the provider it feeds. --- crates/alien-bindings/src/provider.rs | 69 +++++++++++++++++-- .../src/providers/sandbox/azure.rs | 49 +++++++++++-- crates/alien-core/src/bindings/sandbox.rs | 11 ++- .../src/emitters/azure/sandbox.rs | 35 +++++++++- 4 files changed, 151 insertions(+), 13 deletions(-) diff --git a/crates/alien-bindings/src/provider.rs b/crates/alien-bindings/src/provider.rs index 1b1f7eb60..a894f8df3 100644 --- a/crates/alien-bindings/src/provider.rs +++ b/crates/alien-bindings/src/provider.rs @@ -107,6 +107,11 @@ impl std::fmt::Debug for CredentialResolver { } } +/// The ADC service defaults, named so a reader can tell a deliberate default from a magic number. +/// One core and 2 GiB — what `begin_create_sandbox` uses when a caller passes neither. +const DEFAULT_AZURE_CPU: &str = "1000m"; +const DEFAULT_AZURE_MEMORY: &str = "2048Mi"; + impl BindingsProvider { /// Creates a new BindingsProvider with explicit credentials and bindings. /// @@ -1891,14 +1896,20 @@ impl BindingsProviderApi for BindingsProvider { AzureTokenCache::new(azure_config.clone()), ); - // Session ceilings come from the resource, not the caller — an application must - // not be able to raise its own by asking. + let disk_image = azure_binding + .disk_image + .into_value(binding_name, "diskImage") + .map_err(|_| invalid("diskImage"))?; + + // Ceilings stay the service defaults: `.limits()` is refused on Azure at plan + // time, so nothing declares them and there is no value to carry. They move into + // the binding when `enforcedLimits` flips, not before. let sandbox: Arc = Arc::new(AzureSandbox::new( Arc::new(client), group, - "ubuntu".to_string(), - "1000m".to_string(), - "2048Mi".to_string(), + disk_image, + DEFAULT_AZURE_CPU.to_string(), + DEFAULT_AZURE_MEMORY.to_string(), )); Ok(sandbox) } @@ -2216,6 +2227,54 @@ mod tests { ); } + /// The image in the binding has to be the image the provider uses. + /// + /// This asserts the seam the previous code got wrong: the value was read from nowhere and a + /// literal was passed instead, so every session ran a stock image whatever the stack declared + /// — and nothing failed, because a sandbox on the wrong image still starts. + #[cfg(feature = "azure")] + #[tokio::test] + async fn an_azure_sandbox_binding_carries_its_disk_image_to_the_provider() { + let env = HashMap::from([ + ( + ENV_ALIEN_DEPLOYMENT_TYPE.to_string(), + Platform::Azure.as_str().to_string(), + ), + ("AZURE_SUBSCRIPTION_ID".to_string(), "sub".to_string()), + ("AZURE_TENANT_ID".to_string(), "ten".to_string()), + ("AZURE_CLIENT_ID".to_string(), "cli".to_string()), + ("AZURE_CLIENT_SECRET".to_string(), "sec".to_string()), + ( + "ALIEN_BOX_BINDING".to_string(), + r#"{"service":"sandbox-azure", + "sandboxGroup":"grp", + "dataPlaneEndpoint":"https://management.swedencentral.azuredevcompute.io", + "region":"swedencentral", + "resourceGroup":"rg", + "diskImage":"my-toolchain"}"# + .to_string(), + ), + ]); + let provider = BindingsProvider::from_env(env) + .await + .expect("provider construction validates only that the binding JSON parses"); + + let sandbox = provider + .load_sandbox("box") + .await + .expect("an Azure sandbox binding loads"); + + let azure = sandbox + .as_any() + .downcast_ref::() + .expect("an Azure binding builds an Azure provider"); + assert_eq!( + azure.disk_image(), + "my-toolchain", + "the declared image must reach the provider, not a literal chosen at construction" + ); + } + /// A MicroVM with no egress connector reaches the internet, so the binding's two egress /// fields have to agree: an empty list is how `allow` travels, and it is a fail-open default /// unless `allowEgress` says so. Both disagreements are refused, and `deny` still loads. diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 2bc0746ca..5e64ce50f 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -25,8 +25,8 @@ use alien_error::AlienError; pub struct AzureSandbox { client: std::sync::Arc, sandbox_group: String, - /// Disk image every session is created from. - disk: String, + /// Catalog disk image every session is created from, from the declaration. + disk_image: String, /// Session ceilings, in the data plane's own units. cpu: String, memory: String, @@ -37,19 +37,25 @@ impl AzureSandbox { pub fn new( client: std::sync::Arc, sandbox_group: String, - disk: String, + disk_image: String, cpu: String, memory: String, ) -> Self { Self { client, sandbox_group, - disk, + disk_image, cpu, memory, } } + /// The catalog image sessions are created from. Exists so a test can prove the declaration + /// reached the provider — the failure it guards is silent, so nothing else would show it. + pub(crate) fn disk_image(&self) -> &str { + &self.disk_image + } + fn unsupported(&self, capability: &str) -> AlienError { AlienError::new(ErrorData::OperationNotSupported { operation: capability.to_string(), @@ -76,7 +82,7 @@ impl Sandbox for AzureSandbox { async fn create(&self, request: CreateSessionRequest) -> Result { let sandbox = self .client - .create_sandbox(&self.sandbox_group, &self.disk, &self.cpu, &self.memory) + .create_sandbox(&self.sandbox_group, &self.disk_image, &self.cpu, &self.memory) .await .map_err(|error| Self::failed("sandbox.create", error))?; @@ -391,6 +397,39 @@ mod tests { ) } + /// The declared image has to reach the create call, not a default chosen here. + /// + /// Asserted on the argument the client receives, because the failure this pins is silent: + /// a sandbox started from the wrong image returns a healthy session and only diverges once + /// the caller's code is missing from it. + #[tokio::test] + async fn the_declared_image_reaches_the_create_call() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_create_sandbox() + .withf(|_, disk_image, _, _| disk_image == "my-toolchain") + .times(1) + .returning(|_, _, _, _| { + Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox { + id: "s1".to_string(), + status: Some("Running".to_string()), + }) + }); + + let sandbox = AzureSandbox::new( + std::sync::Arc::new(client), + "grp".to_string(), + "my-toolchain".to_string(), + "1000m".to_string(), + "2048Mi".to_string(), + ); + + sandbox + .create(CreateSessionRequest::default()) + .await + .expect("create succeeds"); + } + /// Azure accepts a delete and completes it later, so returning on the accepted call would /// report that untrusted code had stopped while it was still running. Time is paused, so the /// poll runs to its bound instantly. diff --git a/crates/alien-core/src/bindings/sandbox.rs b/crates/alien-core/src/bindings/sandbox.rs index dc21e0ac2..6d76a51cd 100644 --- a/crates/alien-core/src/bindings/sandbox.rs +++ b/crates/alien-core/src/bindings/sandbox.rs @@ -91,6 +91,12 @@ pub struct AzureSandboxBinding { /// Resource group the sandbox group sits in. The data-plane path is scoped by it, and the /// Azure client config does not carry one. pub resource_group: BindingValue, + /// Catalog disk image every session is created from, taken from the declaration's `code`. + /// + /// Carried rather than hardcoded in the provider because the declaration is the only place + /// that knows it, and a sandbox running an image its author did not choose is the one Azure + /// gap that fails without an error. + pub disk_image: BindingValue, } /// GCP sandbox binding configuration. @@ -168,12 +174,14 @@ impl SandboxBinding { data_plane_endpoint: impl Into>, region: impl Into>, resource_group: impl Into>, + disk_image: impl Into>, ) -> Self { Self::Azure(AzureSandboxBinding { sandbox_group: sandbox_group.into(), data_plane_endpoint: data_plane_endpoint.into(), region: region.into(), resource_group: resource_group.into(), + disk_image: disk_image.into(), }) } @@ -239,6 +247,7 @@ mod tests { "https://management.swedencentral.azuredevcompute.io", "swedencentral", "rg", + "ubuntu", ), SandboxBinding::gcp("/usr/local/gcp/bin/sandbox", false), SandboxBinding::kubernetes( @@ -267,7 +276,7 @@ mod tests { fn service_tags_are_prefixed_and_distinct() { let tags: Vec = vec![ SandboxBinding::aws("a", "1", "r"), - SandboxBinding::azure("g", "e", "r", "rg"), + SandboxBinding::azure("g", "e", "r", "rg", "ubuntu"), SandboxBinding::gcp("p", true), SandboxBinding::kubernetes("n", "gvisor", "s", "http://op:8080", "k", "/t"), SandboxBinding::local("u", "k", "t"), diff --git a/crates/alien-terraform/src/emitters/azure/sandbox.rs b/crates/alien-terraform/src/emitters/azure/sandbox.rs index 47227ac8e..90b43988c 100644 --- a/crates/alien-terraform/src/emitters/azure/sandbox.rs +++ b/crates/alien-terraform/src/emitters/azure/sandbox.rs @@ -11,7 +11,8 @@ use crate::{ emitters::azure::helpers::{downcast, required_label, resource_prefix_template}, expr, }; -use alien_core::{import::EmitContext, Result, Sandbox}; +use alien_core::{import::EmitContext, ErrorData, Result, Sandbox, SandboxCode}; +use alien_error::AlienError; use hcl::expr::Expression; /// Emits the Azure sandbox group's identity for the runtime to address. @@ -29,6 +30,34 @@ fn sandbox_group(ctx: &EmitContext<'_>) -> Expression { resource_prefix_template(&ctx.resource_id) } +/// The catalog image name a declaration asks for, or a refusal. +/// +/// The create body names a public catalog image, so a registry reference has nowhere to go. +/// Refusing at plan time follows the AWS emitter: a reference the backend cannot honour is +/// rejected rather than quietly replaced, which is what happened before this existed — every +/// Azure session ran a stock image whatever the declaration said, with no error anywhere. +fn catalog_disk_image(sandbox: &Sandbox) -> Result { + let unsupported = |reason: String| { + AlienError::new(ErrorData::OperationNotSupported { + operation: format!("terraform emit sandbox '{}'", sandbox.id()), + reason, + }) + }; + + match &sandbox.code { + SandboxCode::Image { image } if image.contains('/') => Err(unsupported(format!( + "Azure creates a sandbox from a public catalog disk image, so code.image must be a \ + catalog name such as 'ubuntu', not the registry reference '{image}'" + ))), + SandboxCode::Image { image } => Ok(image.clone()), + SandboxCode::Source { .. } => Err(unsupported( + "Azure creates a sandbox from a prebuilt catalog disk image and cannot build one \ + from source" + .to_string(), + )), + } +} + impl TfEmitter for AzureSandboxEmitter { fn emit(&self, _ctx: &EmitContext<'_>) -> Result { // Deliberately empty: see the module note. A group created here would sit idle until a @@ -47,8 +76,9 @@ impl TfEmitter for AzureSandboxEmitter { } fn emit_binding_ref(&self, ctx: &EmitContext<'_>) -> Result> { - let _ = downcast::(ctx, Sandbox::RESOURCE_TYPE)?; + let sandbox = downcast::(ctx, Sandbox::RESOURCE_TYPE)?; let _ = required_label(ctx)?; + let disk_image = catalog_disk_image(sandbox)?; Ok(Some(expr::object([ ("service", Expression::String("sandbox-azure".to_string())), ("sandboxGroup", sandbox_group(ctx)), @@ -60,6 +90,7 @@ impl TfEmitter for AzureSandboxEmitter { ), ("region", expr::raw("var.azure_location")), ("resourceGroup", expr::raw("var.azure_resource_group_name")), + ("diskImage", Expression::String(disk_image)), ]))) } } From 91c75650aab8acfa395a8f3a18c506ffb2da9662 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:50:31 +0300 Subject: [PATCH 03/32] fix(sandbox): take the data-plane audience from the package, not the host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scope was `management.azuredevcompute.io/.default` — the endpoint's own host, which is the natural guess and had no citation. The SDK this module's header already names as the source of its contract pins the audience for this endpoint and api-version as `dynamicsessions.io/.default`. Both audiences mint a token against our tenant, so a wrong one fails at the data plane rather than at the token endpoint, where it looks like a missing `SandboxGroup Data Owner` assignment. Which of the two the data plane accepts cannot be settled without a provisioned sandbox group: the endpoint answers 404 to an unauthenticated request in our region, so nothing short of a real group distinguishes them. The test that pinned the old value was written from the same guess rather than from the package, so it pinned the guess. --- .../src/azure/sandbox_data_plane.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs index d39516abd..f92c3b2a6 100644 --- a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs +++ b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs @@ -23,9 +23,12 @@ use mockall::automock; /// Data-plane API version, from the SDK's `ApiVersion.V2026_02_01_PREVIEW`. pub const API_VERSION: &str = "2026-02-01-preview"; -/// Scope the data plane is signed for. Distinct from ARM's, which is why a token minted for -/// `management.azure.com` fails here in a way that looks like a permissions problem. -const ADC_SCOPE: &str = "https://management.azuredevcompute.io/.default"; +/// Scope the data plane is signed for, from the SDK's `DATA_PLANE_SCOPE` in `_helpers.py`. +/// +/// It is neither ARM's scope nor the endpoint's own host: the sandbox data plane sits on the +/// dynamic-sessions audience while answering at `azuredevcompute.io`. A token minted for either +/// host fails here as a 401 that reads like a missing role assignment. +const ADC_SCOPE: &str = "https://dynamicsessions.io/.default"; /// A sandbox as the data plane reports it. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -282,7 +285,7 @@ mod tests { #[test] fn the_pinned_wire_contract_matches_what_the_sdk_ships() { assert_eq!(API_VERSION, "2026-02-01-preview"); - assert_eq!(ADC_SCOPE, "https://management.azuredevcompute.io/.default"); + assert_eq!(ADC_SCOPE, "https://dynamicsessions.io/.default"); } /// The data-plane path has no `providers/Microsoft.App` segment; borrowing ARM's shape here From c4f53c8d34046f17270dec7a0434485ef0a0e503 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:06:23 +0300 Subject: [PATCH 04/32] feat(sandbox): move files in and out of an Azure sandbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Azure was the one backend that refused `readFile`, `writeFiles` and `mkdir`, so portable file code had to branch on the capability. The data plane has had the verbs the whole time — `GET`/`PUT {sandbox}/files` and `POST {sandbox}/files/mkdir` — and `write` takes `createDirs`, which is what gives Azure the cross-backend rule that a write creates its parents. Three things had to come first. The Azure request builder carried a `String` body, which cannot hold a file; it now carries bytes. Every failure was one `OperationNotSupported`, so a missing file and an unreachable data plane were indistinguishable; they now split into a refusal, which is not retryable, and an unknown outcome, which is retryable for a file operation and never for a command. And a body is now truncated before it is echoed into an error, so a 32 MiB upload cannot become a 32 MiB log line. Paths are checked before they leave the process: relative only, no `..`, no empty components, no trailing slash, on all three operations. Whether the server confines a path is undocumented, so the code says this is our rule and not a guarantee. Transfers are capped at 32 MiB in both directions, the number the agent-backed backends already enforce, with the read bounded as it arrives. `files: true` came last, after the three methods worked. The wire tests run against a server the test controls and each one fails against a plausible wrong version: drop `createDirs`, move the mkdir path into the query, drop either ceiling, or drop the path check, and a test goes red. Azure's propagation-delay 400s arrive as `RemoteResourceConflict`, which the client marks transient, so that variant stays out of the refusal set — a refusal tells the caller never to retry. --- .../alien-azure-clients/src/azure/common.rs | 28 +- .../src/azure/sandbox_data_plane.rs | 284 +++++++++++++++- .../src/providers/sandbox/azure.rs | 310 +++++++++++++++++- crates/alien-core/src/resources/sandbox.rs | 8 +- 4 files changed, 601 insertions(+), 29 deletions(-) diff --git a/crates/alien-azure-clients/src/azure/common.rs b/crates/alien-azure-clients/src/azure/common.rs index 96dde1db1..bf8e7e07c 100644 --- a/crates/alien-azure-clients/src/azure/common.rs +++ b/crates/alien-azure-clients/src/azure/common.rs @@ -122,7 +122,7 @@ impl AzureClientBase { pub async fn sign_request( &self, - mut req: http::Request, + mut req: http::Request>, bearer_token: &str, ) -> Result { // Inject mandatory headers if absent. @@ -212,7 +212,7 @@ impl AzureClientBase { let request_body = req_clone .body() .and_then(|b| b.as_bytes()) - .map(|b| String::from_utf8_lossy(b).to_string()); + .map(|b| String::from_utf8_lossy(&b[..b.len().min(MAX_ECHOED_REQUEST_BODY)]).to_string()); let resp = client.execute(req_clone).await.into_alien_error().context( ErrorData::HttpRequestFailed { @@ -270,7 +270,7 @@ impl AzureClientBase { let request_body = req_clone .body() .and_then(|b| b.as_bytes()) - .map(|b| String::from_utf8_lossy(b).to_string()); + .map(|b| String::from_utf8_lossy(&b[..b.len().min(MAX_ECHOED_REQUEST_BODY)]).to_string()); let resp = client.execute(req_clone).await.into_alien_error().context( ErrorData::HttpRequestFailed { @@ -338,7 +338,7 @@ impl AzureClientBase { let request_body = req_clone .body() .and_then(|b| b.as_bytes()) - .map(|b| String::from_utf8_lossy(b).to_string()); + .map(|b| String::from_utf8_lossy(&b[..b.len().min(MAX_ECHOED_REQUEST_BODY)]).to_string()); let resp = client.execute(req_clone).await.into_alien_error().context( ErrorData::HttpRequestFailed { @@ -489,7 +489,7 @@ impl AzureClientBase { let request_body = req_clone .body() .and_then(|b| b.as_bytes()) - .map(|b| String::from_utf8_lossy(b).to_string()); + .map(|b| String::from_utf8_lossy(&b[..b.len().min(MAX_ECHOED_REQUEST_BODY)]).to_string()); let resp = client.execute(req_clone).await.into_alien_error().context( ErrorData::HttpRequestFailed { @@ -606,11 +606,18 @@ impl AzureClientBase { // Light request-builder (service-agnostic) // ----------------------------------------------------------------------------- +/// How much of a request body is echoed back in an error. +/// +/// A failed call quotes the request it sent, and a file upload's body would otherwise become a +/// multi-megabyte error message on its way into a log. Truncated rather than dropped, so a large +/// JSON body still shows the part that usually carries the mistake. +const MAX_ECHOED_REQUEST_BODY: usize = 4096; + pub struct AzureRequestBuilder { method: Method, uri: String, headers: Vec<(String, String)>, - body: String, + body: Vec, } impl AzureRequestBuilder { @@ -619,7 +626,7 @@ impl AzureRequestBuilder { method, uri, headers: vec![], - body: String::new(), + body: Vec::new(), } } pub fn header(mut self, name: &str, val: &str) -> Self { @@ -639,10 +646,15 @@ impl AzureRequestBuilder { self.header("content-length", &body.len().to_string()) } pub fn body(mut self, body: String) -> Self { + self.body = body.into_bytes(); + self + } + /// A body that is not text: a file's contents travel as bytes, not as UTF-8. + pub fn body_bytes(mut self, body: Vec) -> Self { self.body = body; self } - pub fn build(self) -> Result> { + pub fn build(self) -> Result>> { let mut b = http::Request::builder().method(self.method).uri(&self.uri); for (k, v) in self.headers { b = b.header(&k, &v); diff --git a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs index f92c3b2a6..90307617c 100644 --- a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs +++ b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs @@ -30,6 +30,17 @@ pub const API_VERSION: &str = "2026-02-01-preview"; /// host fails here as a 401 that reads like a missing role assignment. const ADC_SCOPE: &str = "https://dynamicsessions.io/.default"; +/// Service key an endpoint override is looked up under, which is how a test points the client at +/// a server it controls instead of a region's real data plane. +const SERVICE_NAME: &str = "sandboxDataPlane"; + +/// Largest file that moves in or out of a sandbox in one call. +/// +/// The package carries no size constant, so this is the number the agent-backed backends already +/// enforce (`alien-sandbox-agent/src/files.rs`) rather than a measured server limit: one bound +/// callers can rely on everywhere, and a body that never grows past it here. +const MAX_FILE_BYTES: usize = 32 * 1024 * 1024; + /// A sandbox as the data plane reports it. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -80,6 +91,21 @@ pub trait SandboxDataPlaneApi: Send + Sync + std::fmt::Debug { command: &str, working_directory: Option, ) -> Result; + + /// Reads a file out of a sandbox. + async fn read_file(&self, group: &str, sandbox_id: &str, path: &str) -> Result>; + + /// Writes one file into a sandbox. + async fn write_file( + &self, + group: &str, + sandbox_id: &str, + path: &str, + contents: Vec, + ) -> Result<()>; + + /// Creates a directory inside a sandbox. Idempotent, like `mkdir -p`. + async fn mkdir(&self, group: &str, sandbox_id: &str, path: &str) -> Result<()>; } /// The `executeShellCommand` body, which is `command` plus an optional `workingDirectory` and @@ -108,7 +134,10 @@ impl AzureSandboxDataPlaneClient { resource_group: &str, token_cache: AzureTokenCache, ) -> Self { - let endpoint = format!("https://management.{region}.azuredevcompute.io"); + let endpoint = token_cache + .get_service_endpoint(SERVICE_NAME) + .map(str::to_string) + .unwrap_or_else(|| format!("https://management.{region}.azuredevcompute.io")); Self { base: AzureClientBase::with_client_config( @@ -274,11 +303,139 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { .await?; Self::parse(response, "ExecuteShellCommand").await } + + async fn read_file(&self, group: &str, sandbox_id: &str, path: &str) -> Result> { + let token = self.token_cache.get_bearer_token_with_scope(ADC_SCOPE).await?; + let url = self.base.build_url( + &format!("{}/files", self.sandbox_path(group, sandbox_id)), + Some(vec![ + ("api-version", API_VERSION.into()), + ("path", path.to_string()), + ]), + ); + + let request = AzureRequestBuilder::new(Method::GET, url).build()?; + let signed = self.base.sign_request(request, &token).await?; + let response = self.base.execute_request(signed, "ReadFile", sandbox_id).await?; + + // Bytes, not JSON: the body is the file, and `parse` would try to read an image or a + // tarball as a document. Collected chunk by chunk so the ceiling is enforced against + // what has arrived rather than after the whole file is already in memory. + let mut response = response; + let mut contents: Vec = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .into_alien_error() + .context(ErrorData::GenericError { + message: "Azure ADC ReadFile: the response body ended early".to_string(), + })? + { + contents.extend_from_slice(&chunk); + if contents.len() > MAX_FILE_BYTES { + return Err(alien_error::AlienError::new(ErrorData::InvalidInput { + message: format!( + "'{path}' is larger than the {MAX_FILE_BYTES}-byte transfer ceiling" + ), + field_name: Some("path".to_string()), + })); + } + } + + Ok(contents) + } + + async fn write_file( + &self, + group: &str, + sandbox_id: &str, + path: &str, + contents: Vec, + ) -> Result<()> { + if contents.len() > MAX_FILE_BYTES { + return Err(alien_error::AlienError::new(ErrorData::InvalidInput { + message: format!( + "'{path}' is {} bytes, over the {MAX_FILE_BYTES}-byte transfer ceiling", + contents.len() + ), + field_name: Some("path".to_string()), + })); + } + + let token = self.token_cache.get_bearer_token_with_scope(ADC_SCOPE).await?; + // `createDirs` is what makes a write create its parents, which is the cross-backend + // contract. The SDK also takes a `mode`, deliberately not sent: its accepted format is + // undocumented, and a wrong one would fail every write. + let url = self.base.build_url( + &format!("{}/files", self.sandbox_path(group, sandbox_id)), + Some(vec![ + ("api-version", API_VERSION.into()), + ("path", path.to_string()), + ("createDirs", "true".to_string()), + ]), + ); + + let request = AzureRequestBuilder::new(Method::PUT, url) + .header("Content-Type", "application/octet-stream") + .body_bytes(contents) + .build()?; + let signed = self.base.sign_request(request, &token).await?; + self.base.execute_request(signed, "WriteFile", sandbox_id).await?; + Ok(()) + } + + async fn mkdir(&self, group: &str, sandbox_id: &str, path: &str) -> Result<()> { + let token = self.token_cache.get_bearer_token_with_scope(ADC_SCOPE).await?; + let url = self.base.build_url( + &format!("{}/files/mkdir", self.sandbox_path(group, sandbox_id)), + Some(vec![("api-version", API_VERSION.into())]), + ); + + let body = serde_json::json!({ "path": path }).to_string(); + let request = AzureRequestBuilder::new(Method::POST, url) + .content_type_json() + .content_length(&body) + .body(body) + .build()?; + let signed = self.base.sign_request(request, &token).await?; + self.base.execute_request(signed, "Mkdir", sandbox_id).await?; + Ok(()) + } } #[cfg(test)] mod tests { use super::*; + use crate::azure::{AzureClientConfig, AzureClientConfigExt, ServiceOverrides}; + use httpmock::MockServer; + + /// A file that is not text. Every invalid UTF-8 shape in four bytes: a lone continuation, a + /// truncated sequence, and an embedded NUL. + const BINARY: [u8; 4] = [0xff, 0xfe, 0x00, 0x80]; + + /// `matches` takes a function pointer, so the expected bytes are a constant rather than a + /// captured value. + fn carries_binary(request: &httpmock::prelude::HttpMockRequest) -> bool { + request.body.clone().unwrap_or_default() == BINARY + } + + /// A client that talks to a server this test controls, through the endpoint override the + /// constructor honours. + fn client_against(server: &MockServer) -> AzureSandboxDataPlaneClient { + let config = AzureClientConfig::mock().with_service_overrides(ServiceOverrides { + endpoints: std::collections::HashMap::from([( + SERVICE_NAME.to_string(), + server.base_url(), + )]), + }); + + AzureSandboxDataPlaneClient::new( + reqwest::Client::new(), + "eastus", + "rg", + AzureTokenCache::new(config), + ) + } /// Pinned because the contract came from a preview SDK Microsoft says may change. If these /// drift, the client must be re-read against the package rather than patched by guess. @@ -333,4 +490,129 @@ mod tests { assert_eq!(result.exit_code, None); } + + /// The three file calls, checked against the wire the SDK documents. + /// + /// Verb, path, query and body are each a way to be wrong without an error: the data plane + /// answers a mistyped query parameter with a success and a different effect. `createDirs` is + /// the one that carries the cross-backend rule that a write creates its parents. + #[tokio::test] + async fn the_file_calls_match_the_wire_the_sdk_documents() { + let server = MockServer::start_async().await; + let client = client_against(&server); + // The subscription is the mock config's; the rest is the path shape the SDK builds. + let sandbox = format!( + "/subscriptions/{}/resourceGroups/rg/sandboxGroups/grp/sandboxes/s1", + AzureClientConfig::mock().subscription_id + ); + + let read = server + .mock_async(|when, then| { + when.method(httpmock::Method::GET) + .path(format!("{sandbox}/files")) + .query_param("path", "src/app.py") + .query_param("api-version", API_VERSION); + then.status(200).body(b"print(1)\n"); + }) + .await; + let contents = client + .read_file("grp", "s1", "src/app.py") + .await + .expect("the read should succeed"); + assert_eq!(contents, b"print(1)\n"); + read.assert_async().await; + + let write = server + .mock_async(|when, then| { + when.method(httpmock::Method::PUT) + .path(format!("{sandbox}/files")) + .query_param("path", "src/app.py") + .query_param("createDirs", "true") + .header("content-type", "application/octet-stream") + .matches(carries_binary); + then.status(200); + }) + .await; + client + .write_file("grp", "s1", "src/app.py", BINARY.to_vec()) + .await + .expect("the write should succeed"); + write.assert_async().await; + + let mkdir = server + .mock_async(|when, then| { + when.method(httpmock::Method::POST) + .path(format!("{sandbox}/files/mkdir")) + .json_body(serde_json::json!({ "path": "src" })); + then.status(200); + }) + .await; + client.mkdir("grp", "s1", "src").await.expect("the mkdir should succeed"); + mkdir.assert_async().await; + } + + /// A file is bytes, not text: a transport that encoded it as UTF-8 would replace every + /// invalid sequence and hand back a different file than the sandbox holds. + #[tokio::test] + async fn a_file_that_is_not_text_survives_both_directions() { + let server = MockServer::start_async().await; + let client = client_against(&server); + let bytes = BINARY.to_vec(); + + let server = MockServer::start_async().await; + let client = client_against(&server); + server + .mock_async(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body(bytes.clone()); + }) + .await; + assert_eq!( + client.read_file("grp", "s1", "image.png").await.expect("reads"), + bytes + ); + } + + /// The ceiling is refused here rather than accepted and truncated, and refused before the + /// body is sent — an oversized upload that fails at the far end has already been transferred. + #[tokio::test] + async fn a_transfer_over_the_ceiling_is_refused_before_it_is_sent() { + let server = MockServer::start_async().await; + let client = client_against(&server); + let refused = server + .mock_async(|when, then| { + when.method(httpmock::Method::PUT); + then.status(200); + }) + .await; + + let error = client + .write_file("grp", "s1", "big.bin", vec![0u8; MAX_FILE_BYTES + 1]) + .await + .expect_err("a body over the ceiling must be refused"); + + assert_eq!(error.code, "INVALID_INPUT", "{error}"); + refused.assert_hits_async(0).await; + } + + /// A read is bounded by the same number, against a data plane that says a file is small and + /// then sends more than it said. + #[tokio::test] + async fn a_read_stops_at_the_ceiling_rather_than_filling_memory() { + let server = MockServer::start_async().await; + let client = client_against(&server); + server + .mock_async(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body(vec![0u8; MAX_FILE_BYTES + 1]); + }) + .await; + + let error = client + .read_file("grp", "s1", "big.bin") + .await + .expect_err("a body over the ceiling must be refused"); + + assert_eq!(error.code, "INVALID_INPUT", "{error}"); + } } diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 5e64ce50f..09f1488aa 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -18,7 +18,7 @@ use crate::traits::{ use alien_azure_clients::azure::sandbox_data_plane::SandboxDataPlaneApi; use alien_client_core::ErrorData as ClientErrorData; use alien_core::{Platform, SandboxCapabilities}; -use alien_error::AlienError; +use alien_error::{AlienError, ContextError}; /// A Sandbox backed by the Azure ADC data plane. #[derive(Debug)] @@ -52,6 +52,7 @@ impl AzureSandbox { /// The catalog image sessions are created from. Exists so a test can prove the declaration /// reached the provider — the failure it guards is silent, so nothing else would show it. + #[cfg(test)] pub(crate) fn disk_image(&self) -> &str { &self.disk_image } @@ -63,10 +64,34 @@ impl AzureSandbox { }) } - fn failed(operation: &str, error: impl std::fmt::Display) -> AlienError { - AlienError::new(ErrorData::OperationNotSupported { + /// Sorts a data-plane failure into the two buckets every other backend uses. + /// + /// A refusal is a request the data plane understood and rejected, so repeating it repeats the + /// refusal. Anything else left the outcome unknown: for the idempotent file operations that is + /// worth another attempt, but `run_command` may already have started the command and must not + /// carry the retry signal. The cause stays on the source chain rather than in `reason`, which + /// is what keeps a raw response body out of an externally visible message. + fn failed(operation: &str, error: AlienError) -> AlienError { + if is_refusal(&error) { + return error.context(ErrorData::SandboxCommandFailed { + failure: "dataPlaneRefused".to_string(), + reason: format!("{operation} was refused; the cause carries which side refused"), + }); + } + + if operation == RUN_COMMAND { + return error.context(ErrorData::SandboxCommandFailed { + failure: "outcomeUnknown".to_string(), + reason: format!( + "{operation} did not complete against the Azure sandbox data plane, so \ + whether the command ran is unknown" + ), + }); + } + + error.context(ErrorData::SandboxUnreachable { operation: operation.to_string(), - reason: format!("the Azure sandbox data plane refused the call: {error}"), + reason: "the Azure sandbox data plane did not complete the call".to_string(), }) } } @@ -206,20 +231,37 @@ impl Sandbox for AzureSandbox { Ok(Box::pin(stream::iter(frames))) } - async fn read_file(&self, _session_id: &str, _path: &str) -> Result> { - Err(self.unsupported("readFile")) + async fn read_file(&self, session_id: &str, path: &str) -> Result> { + checked_path("sandbox.readFile", path)?; + + self.client + .read_file(&self.sandbox_group, session_id, path) + .await + .map_err(|error| Self::failed("sandbox.readFile", error)) } - async fn write_files( - &self, - _session_id: &str, - _files: BTreeMap>, - ) -> Result<()> { - Err(self.unsupported("writeFiles")) + async fn write_files(&self, session_id: &str, files: BTreeMap>) -> Result<()> { + // One request per path, stopping at the first failure: the same partial application every + // other backend performs, so a caller sees one contract rather than five. + for (path, contents) in files { + checked_path("sandbox.writeFiles", &path)?; + + self.client + .write_file(&self.sandbox_group, session_id, &path, contents) + .await + .map_err(|error| Self::failed("sandbox.writeFiles", error))?; + } + + Ok(()) } - async fn mkdir(&self, _session_id: &str, _path: &str) -> Result<()> { - Err(self.unsupported("mkdir")) + async fn mkdir(&self, session_id: &str, path: &str) -> Result<()> { + checked_path("sandbox.mkdir", path)?; + + self.client + .mkdir(&self.sandbox_group, session_id, path) + .await + .map_err(|error| Self::failed("sandbox.mkdir", error)) } async fn preview(&self, _session_id: &str, _port: u16) -> Result { @@ -294,7 +336,7 @@ impl AzureSandbox { ) .await { - Ok(inner) => inner.map_err(|error| Self::failed("sandbox.runCommand", error)), + Ok(inner) => inner.map_err(|error| Self::failed(RUN_COMMAND, error)), Err(_) => { self.terminate(session_id).await?; Err(AlienError::new(ErrorData::SandboxCommandFailed { @@ -351,6 +393,67 @@ fn bounded_shell(command: &[String], deadline: std::time::Duration) -> String { ) } +/// Refuses a caller's path before it reaches the data plane. +/// +/// Whether the server bounds a path to a root is undocumented and unmeasured, so this is the only +/// confinement there is, and it is a client-side rule rather than a guarantee. Relative only: +/// Azure exposes no session root to rewrite an absolute path against, so accepting one would hand +/// the caller the sandbox's whole filesystem instead of its own directory. +fn checked_path(operation: &str, path: &str) -> Result<()> { + let refused = |details: &str| { + Err(AlienError::new(ErrorData::InvalidInput { + operation_context: operation.to_string(), + details: format!("path '{path}' {details}"), + field_name: Some("path".to_string()), + })) + }; + + // Checked before anything is trimmed, which would make "a/b/" and the file "a/b" the same + // request. + if path.ends_with('/') { + return refused("must not end in '/'"); + } + if path.is_empty() { + return refused("is empty"); + } + if path.starts_with('/') { + return refused("must be relative to the sandbox's own directory"); + } + if path.contains('\0') { + return refused("contains a null byte"); + } + if path.split('/').any(|part| part == ".." || part.is_empty()) { + return refused("must not traverse"); + } + + Ok(()) +} + +/// The one operation a repeat could run twice. +const RUN_COMMAND: &str = "sandbox.runCommand"; + +/// Whether the data plane understood the request and rejected it. +/// +/// Reads the classified variant the client attaches rather than the status on its source: the +/// wrapper is what survives `create_azure_http_error_with_context`, and it already carries the +/// 4xx-versus-everything-else split this needs. +fn is_refusal(error: &AlienError) -> bool { + // `RemoteResourceConflict` is deliberately absent: the client also uses it for the 400s Azure + // marks as propagation delays, and calling those refusals would tell a caller never to retry + // the one failure Azure says to retry. + matches!( + &error.error, + Some( + ClientErrorData::RemoteResourceNotFound { .. } + | ClientErrorData::RemoteAccessDenied { .. } + | ClientErrorData::InvalidInput { .. } + ) + ) || matches!( + &error.error, + Some(ClientErrorData::HttpResponseError { http_status, .. }) if (400..500).contains(http_status) + ) +} + /// Whether an Azure data-plane failure means the session is already gone. /// /// Reads the status the client carries rather than the rendered message: `AlienError`'s `Display` @@ -539,6 +642,34 @@ mod tests { #[async_trait] impl SandboxDataPlaneApi for ScriptedExec { + async fn read_file( + &self, + _group: &str, + _sandbox_id: &str, + _path: &str, + ) -> alien_client_core::Result> { + unreachable!("the command paths never read files") + } + + async fn write_file( + &self, + _group: &str, + _sandbox_id: &str, + _path: &str, + _contents: Vec, + ) -> alien_client_core::Result<()> { + unreachable!("the command paths never write files") + } + + async fn mkdir( + &self, + _group: &str, + _sandbox_id: &str, + _path: &str, + ) -> alien_client_core::Result<()> { + unreachable!("the command paths never create directories") + } + async fn create_sandbox( &self, _group: &str, @@ -747,4 +878,153 @@ mod tests { "{wrapped}" ); } + + /// A path that could leave the caller's own directory is refused before anything is sent. + /// + /// Asserted on the client never being called, not on the error: the data plane's own path + /// handling is undocumented, so a request that leaves this process is already outside what + /// this backend can promise. + #[tokio::test] + async fn a_path_that_could_escape_never_reaches_the_data_plane() { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_read_file().never(); + client.expect_write_file().never(); + client.expect_mkdir().never(); + let sandbox = sandbox_with(client); + + for path in ["../etc/shadow", "/etc/shadow", "", "work/", "a//b", "a/../../b"] { + let error = sandbox + .read_file("s1", path) + .await + .expect_err("'{path}' must be refused"); + assert_eq!(error.code, "INVALID_INPUT", "{path}: {error}"); + + sandbox + .write_files("s1", BTreeMap::from([(path.to_string(), vec![1u8])])) + .await + .expect_err("'{path}' must be refused on write too"); + sandbox + .mkdir("s1", path) + .await + .expect_err("'{path}' must be refused on mkdir too"); + } + + // The same shapes, accepted: a rule that refuses everything would pass the loop above. + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_read_file() + .times(2) + .returning(|_, _, _| Ok(Vec::new())); + let sandbox = sandbox_with(client); + for path in ["app.py", "src/app.py"] { + sandbox + .read_file("s1", path) + .await + .unwrap_or_else(|error| panic!("'{path}' is a normal path: {error}")); + } + } + + /// The group, the session and the path each reach the call they belong to, and the bytes come + /// back unchanged. + #[tokio::test] + async fn a_read_carries_the_session_and_path_to_the_data_plane() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_read_file() + .withf(|group, session_id, path| { + group == "grp" && session_id == "s1" && path == "src/app.py" + }) + .times(1) + .returning(|_, _, _| Ok(b"print(1)\n".to_vec())); + + let contents = sandbox_with(client) + .read_file("s1", "src/app.py") + .await + .expect("the read should succeed"); + + assert_eq!(contents, b"print(1)\n"); + } + + /// Writing stops at the first failure rather than pressing on, which is what makes a partial + /// write observable to the caller instead of a success with a hole in it. + #[tokio::test] + async fn a_failed_write_stops_the_ones_behind_it() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_write_file() + .times(1) + .returning(|_, _, path, _| { + assert_eq!(path, "a.txt", "the first path in order is the one attempted"); + Err(AlienError::new(ClientErrorData::RemoteAccessDenied { + resource_type: "sandbox".to_string(), + resource_name: "s1".to_string(), + })) + }); + + let error = sandbox_with(client) + .write_files( + "s1", + BTreeMap::from([ + ("a.txt".to_string(), vec![1u8]), + ("b.txt".to_string(), vec![2u8]), + ]), + ) + .await + .expect_err("a refused write must fail the call"); + + assert_eq!(error.code, "SANDBOX_COMMAND_FAILED", "{error}"); + } + + /// The two buckets a caller retries on, and the one it must not. + /// + /// A refusal repeated is refused again, and a file operation whose outcome is unknown is safe + /// to repeat — but a command may already be running, and a retry there runs it twice. + #[tokio::test] + async fn only_the_operations_that_are_safe_to_repeat_are_marked_retryable() { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_read_file().times(1).returning(|_, _, _| { + Err(AlienError::new(ClientErrorData::RemoteResourceNotFound { + resource_type: "file".to_string(), + resource_name: "missing.txt".to_string(), + })) + }); + let refused = sandbox_with(client) + .read_file("s1", "missing.txt") + .await + .expect_err("a missing file is an error"); + assert_eq!(refused.code, "SANDBOX_COMMAND_FAILED", "{refused}"); + assert!(!refused.retryable, "repeating a refusal repeats it: {refused}"); + + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_read_file().times(1).returning(|_, _, _| { + Err(AlienError::new(ClientErrorData::RemoteServiceUnavailable { + message: "the data plane is unavailable".to_string(), + })) + }); + let unreachable = sandbox_with(client) + .read_file("s1", "app.py") + .await + .expect_err("an unavailable data plane is an error"); + assert_eq!(unreachable.code, "SANDBOX_UNREACHABLE", "{unreachable}"); + assert!(unreachable.retryable, "a read is safe to repeat: {unreachable}"); + + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_execute_shell_command() + .times(1) + .returning(|_, _, _, _| { + Err(AlienError::new(ClientErrorData::RemoteServiceUnavailable { + message: "the data plane is unavailable".to_string(), + })) + }); + let command = match sandbox_with(client).run_command("s1", command(5)).await { + Ok(_) => panic!("an unavailable data plane is an error"), + Err(error) => error, + }; + assert_eq!(command.code, "SANDBOX_COMMAND_FAILED", "{command}"); + assert!( + !command.retryable, + "the command may already be running, so a retry would run it twice: {command}" + ); + } } diff --git a/crates/alien-core/src/resources/sandbox.rs b/crates/alien-core/src/resources/sandbox.rs index 3f693c2d7..0850deb15 100644 --- a/crates/alien-core/src/resources/sandbox.rs +++ b/crates/alien-core/src/resources/sandbox.rs @@ -168,8 +168,6 @@ pub struct SandboxSessionPolicy { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SandboxCapabilities { /// Files can be moved in and out of a session - /// - /// Every backend but Azure, whose binding implements no transfer. pub files: bool, /// A later call can reach a session created by an earlier one pub reconnect: bool, @@ -230,7 +228,7 @@ impl SandboxCapabilities { // them. The capability set describes what a caller can reach, not what the cloud // could do, so these stay false until the provider catches up. Platform::Azure => Ok(Self { - files: false, + files: true, reconnect: true, preview: false, suspend_resume: false, @@ -862,8 +860,8 @@ mod tests { assert!(!gcp.enforced_limits); let azure = SandboxCapabilities::for_platform(Platform::Azure).expect("azure is supported"); - assert!(!azure.files, "the Azure binding implements no file transfer"); - assert!(gcp.files, "every other backend moves files"); + assert!(azure.files, "every backend moves files"); + assert!(gcp.files); // The Azure binding renders neither an egress policy nor a ceiling, so a declaration of // either is refused rather than accepted and dropped. assert!(!azure.domain_egress_rules); From 95b7e358bded4f4804b574109cf401a80b7d20b6 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:21:20 +0300 Subject: [PATCH 05/32] fix(sandbox): report an Azure session's real state, and send its variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two silent failures in the same create path. The data plane names the lifecycle field `state`; this client read `status`, so every response deserialized to nothing and the provider's fallback arm called that `Running`. A sandbox still being created, stopping, or being deleted all came back as ready to run commands. The mapping now covers the seven states the data plane reports and refuses one it does not recognise — every default here is a lie a caller acts on. The create body never carried `environment`, and a sandbox inherits nothing from its group, so a declared variable simply did not exist inside the session. The data plane accepts the body either way, which is why nothing failed. `create_sandbox` takes a struct now: the create body keeps gaining fields that decide what the sandbox can do, and each one added positionally is one a caller can pass in the wrong slot. --- .../src/azure/sandbox_data_plane.rs | 97 +++++++++--- .../src/providers/sandbox/azure.rs | 143 ++++++++++++++++-- 2 files changed, 206 insertions(+), 34 deletions(-) diff --git a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs index 90307617c..739c97800 100644 --- a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs +++ b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs @@ -12,6 +12,7 @@ use crate::azure::common::{AzureClientBase, AzureRequestBuilder}; use crate::azure::token_cache::AzureTokenCache; use alien_client_core::{ErrorData, Result}; +use std::collections::BTreeMap; use alien_error::{Context, IntoAlienError}; use async_trait::async_trait; use reqwest::Method; @@ -41,15 +42,55 @@ const SERVICE_NAME: &str = "sandboxDataPlane"; /// callers can rely on everywhere, and a body that never grows past it here. const MAX_FILE_BYTES: usize = 32 * 1024 * 1024; +/// What a sandbox is created from. +/// +/// A struct rather than a parameter list because the data plane keeps adding create-time fields +/// that decide what the sandbox can do, and each one added positionally is one a caller can pass +/// in the wrong slot. +#[derive(Debug, Clone, Default)] +pub struct CreateSandbox { + /// Public catalog disk image name, such as `ubuntu`. + pub disk_image: String, + /// CPU in the data plane's units, such as `1000m`. + pub cpu: String, + /// Memory in the data plane's units, such as `2048Mi`. + pub memory: String, + /// Variables placed in the sandbox. It inherits nothing, so a variable exists only if it is + /// sent here. + pub environment: BTreeMap, +} + +/// The create body. +/// +/// `sourcesRef` is required unless a preset sandbox type is named, and resources are nested rather +/// than top level. A flat {disk, cpu, memory} is rejected with "'sourcesRef' is required when not +/// using a preset sandbox type". +fn create_body(request: &CreateSandbox) -> serde_json::Value { + let mut body = serde_json::json!({ + "sourcesRef": { "diskImage": { "name": request.disk_image, "isPublic": true } }, + "resources": { "cpu": request.cpu, "memory": request.memory }, + }); + + if !request.environment.is_empty() { + body["environment"] = serde_json::json!(request.environment); + } + + body +} + /// A sandbox as the data plane reports it. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Sandbox { /// Sandbox id within its group pub id: String, - /// `Running` or `Stopped` + /// `Creating`, `Running`, `Stopping`, `Stopped`, `Suspended`, `Resuming` or `Deleting`. + /// + /// Optional because the name is only as good as the SDK it was read from: a field name that + /// does not match the wire deserializes to `None`, and the provider turns that into an error + /// rather than into a sandbox it assumes is healthy. #[serde(skip_serializing_if = "Option::is_none")] - pub status: Option, + pub state: Option, } /// Result of a shell command. @@ -71,8 +112,7 @@ pub struct ExecResult { #[async_trait] pub trait SandboxDataPlaneApi: Send + Sync + std::fmt::Debug { /// Creates a sandbox from a disk image. - async fn create_sandbox(&self, group: &str, disk: &str, cpu: &str, memory: &str) - -> Result; + async fn create_sandbox(&self, group: &str, request: CreateSandbox) -> Result; /// Reads a sandbox. A 404 is how deletion is confirmed. async fn get_sandbox(&self, group: &str, sandbox_id: &str) -> Result; @@ -203,27 +243,14 @@ impl AzureSandboxDataPlaneClient { #[async_trait] impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { - async fn create_sandbox( - &self, - group: &str, - disk: &str, - cpu: &str, - memory: &str, - ) -> Result { + async fn create_sandbox(&self, group: &str, request: CreateSandbox) -> Result { let token = self.token_cache.get_bearer_token_with_scope(ADC_SCOPE).await?; let url = self.base.build_url( &format!("{}/sandboxes", self.group_path(group)), Some(vec![("api-version", API_VERSION.into())]), ); - // `sourcesRef` is required unless a preset sandbox type is named, and resources are - // nested rather than top level. A flat {disk, cpu, memory} is rejected with - // "'sourcesRef' is required when not using a preset sandbox type". - let body = serde_json::json!({ - "sourcesRef": { "diskImage": { "name": disk, "isPublic": true } }, - "resources": { "cpu": cpu, "memory": memory }, - }) - .to_string(); + let body = create_body(&request).to_string(); let request = AzureRequestBuilder::new(Method::PUT, url) .content_type_json() .content_length(&body) @@ -615,4 +642,36 @@ mod tests { assert_eq!(error.code, "INVALID_INPUT", "{error}"); } + + /// A sandbox inherits nothing, so a variable the caller asked for exists only if the create + /// body carries it — and the data plane accepts a body without it, so nothing else would say. + #[test] + fn the_create_body_carries_the_variables_the_caller_asked_for() { + let body = create_body(&CreateSandbox { + disk_image: "ubuntu".to_string(), + cpu: "1000m".to_string(), + memory: "2048Mi".to_string(), + environment: BTreeMap::from([("TOKEN".to_string(), "t".to_string())]), + }); + + assert_eq!(body["environment"]["TOKEN"], "t"); + assert_eq!(body["sourcesRef"]["diskImage"]["name"], "ubuntu"); + assert_eq!(body["resources"]["cpu"], "1000m"); + + let bare = create_body(&CreateSandbox::default()); + assert!( + bare.get("environment").is_none(), + "an empty map is no variables, not an empty object: {bare}" + ); + } + + /// The response field is `state`. Reading `status` leaves every sandbox deserializing to + /// `None`, which the provider cannot tell apart from a healthy one. + #[test] + fn a_sandbox_deserializes_its_state() { + let sandbox: Sandbox = + serde_json::from_str(r#"{"id":"s1","state":"Stopped"}"#).expect("deserializes"); + + assert_eq!(sandbox.state.as_deref(), Some("Stopped")); + } } diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 09f1488aa..24aa804ac 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -15,7 +15,7 @@ use crate::traits::{ Binding, CommandOutput, CreateSessionRequest, PreviewCapability, RunCommandRequest, Sandbox, SandboxSession, SandboxSessionState, }; -use alien_azure_clients::azure::sandbox_data_plane::SandboxDataPlaneApi; +use alien_azure_clients::azure::sandbox_data_plane::{CreateSandbox, SandboxDataPlaneApi}; use alien_client_core::ErrorData as ClientErrorData; use alien_core::{Platform, SandboxCapabilities}; use alien_error::{AlienError, ContextError}; @@ -107,7 +107,15 @@ impl Sandbox for AzureSandbox { async fn create(&self, request: CreateSessionRequest) -> Result { let sandbox = self .client - .create_sandbox(&self.sandbox_group, &self.disk_image, &self.cpu, &self.memory) + .create_sandbox( + &self.sandbox_group, + CreateSandbox { + disk_image: self.disk_image.clone(), + cpu: self.cpu.clone(), + memory: self.memory.clone(), + environment: request.env, + }, + ) .await .map_err(|error| Self::failed("sandbox.create", error))?; @@ -117,7 +125,7 @@ impl Sandbox for AzureSandbox { Ok(SandboxSession { session_id: sandbox.id, - state: SandboxSessionState::Running, + state: session_state("sandbox.create", sandbox.state.as_deref())?, generation: 1, }) } @@ -130,10 +138,7 @@ impl Sandbox for AzureSandbox { { Ok(sandbox) => Ok(Some(SandboxSession { session_id: sandbox.id, - state: match sandbox.status.as_deref() { - Some("Stopped") => SandboxSessionState::Suspended, - _ => SandboxSessionState::Running, - }, + state: session_state("sandbox.get", sandbox.state.as_deref())?, generation: 1, })), // A 404 is "gone", which is a valid answer. Anything else is a real failure and must @@ -429,6 +434,27 @@ fn checked_path(operation: &str, path: &str) -> Result<()> { Ok(()) } +/// The data plane's own lifecycle vocabulary, in ours. +/// +/// An unrecognised state is an error rather than a default, because every default here is a lie +/// a caller acts on: `Running` sends commands to a sandbox that cannot answer them, and anything +/// else hides one that can. +fn session_state(operation: &str, state: Option<&str>) -> Result { + match state { + Some("Running") => Ok(SandboxSessionState::Running), + Some("Creating" | "Resuming") => Ok(SandboxSessionState::Starting), + Some("Stopping" | "Stopped" | "Suspended") => Ok(SandboxSessionState::Suspended), + Some("Deleting") => Ok(SandboxSessionState::Terminated), + other => Err(AlienError::new(ErrorData::UnexpectedResponseFormat { + provider: "azure".to_string(), + binding_name: operation.to_string(), + field: "state".to_string(), + response_json: other + .map_or_else(|| "absent".to_string(), |state| format!("\"{state}\"")), + })), + } +} + /// The one operation a repeat could run twice. const RUN_COMMAND: &str = "sandbox.runCommand"; @@ -510,12 +536,12 @@ mod tests { let mut client = MockSandboxDataPlaneApi::new(); client .expect_create_sandbox() - .withf(|_, disk_image, _, _| disk_image == "my-toolchain") + .withf(|_, request| request.disk_image == "my-toolchain") .times(1) - .returning(|_, _, _, _| { + .returning(|_, _| { Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox { id: "s1".to_string(), - status: Some("Running".to_string()), + state: Some("Running".to_string()), }) }); @@ -543,7 +569,7 @@ mod tests { client.expect_get_sandbox().returning(|_, id| { Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox { id: id.to_string(), - status: Some("Running".to_string()), + state: Some("Running".to_string()), }) }); @@ -673,9 +699,7 @@ mod tests { async fn create_sandbox( &self, _group: &str, - _disk: &str, - _cpu: &str, - _memory: &str, + _request: CreateSandbox, ) -> alien_client_core::Result { unreachable!("the command paths never create") @@ -692,7 +716,7 @@ mod tests { } Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox { id: sandbox_id.to_string(), - status: Some("Running".to_string()), + state: Some("Running".to_string()), }) } @@ -1027,4 +1051,93 @@ mod tests { "the command may already be running, so a retry would run it twice: {command}" ); } + + /// A session's state is the data plane's, not a default. + /// + /// The four states that are not `Running` each mean a command sent now does not run, so + /// reporting `Running` for any of them tells a caller to use a session that cannot answer. + #[tokio::test] + async fn a_session_reports_the_state_the_data_plane_gave_it() { + for (reported, expected) in [ + ("Running", SandboxSessionState::Running), + ("Creating", SandboxSessionState::Starting), + ("Resuming", SandboxSessionState::Starting), + ("Stopping", SandboxSessionState::Suspended), + ("Stopped", SandboxSessionState::Suspended), + ("Suspended", SandboxSessionState::Suspended), + ("Deleting", SandboxSessionState::Terminated), + ] { + let mut client = MockSandboxDataPlaneApi::new(); + let state = reported.to_string(); + client.expect_get_sandbox().times(1).returning(move |_, _| { + Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox { + id: "s1".to_string(), + state: Some(state.clone()), + }) + }); + + let session = sandbox_with(client) + .get("s1") + .await + .unwrap_or_else(|error| panic!("{reported}: {error}")) + .unwrap_or_else(|| panic!("{reported}: the session exists")); + + assert_eq!(session.state, expected, "state {reported}"); + } + } + + /// A state this client does not know is a preview API that moved, and guessing which of the + /// four it maps to is how a caller ends up talking to a sandbox that is going away. + #[tokio::test] + async fn an_unknown_state_is_an_error_rather_than_a_guess() { + for reported in [Some("Hibernated"), None] { + let mut client = MockSandboxDataPlaneApi::new(); + let state = reported.map(str::to_string); + client.expect_get_sandbox().times(1).returning(move |_, _| { + Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox { + id: "s1".to_string(), + state: state.clone(), + }) + }); + + let error = sandbox_with(client) + .get("s1") + .await + .expect_err("an unreadable state must not become a session"); + + assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}"); + } + } + + /// The variables the caller declared have to reach the create body: a sandbox inherits none + /// of them, and the data plane accepts a create that omits them. + #[tokio::test] + async fn the_declared_variables_reach_the_create_call() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_create_sandbox() + .withf(|_, request| request.environment.get("TOKEN").map(String::as_str) == Some("t")) + .times(1) + .returning(|_, _| { + Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox { + id: "s1".to_string(), + state: Some("Creating".to_string()), + }) + }); + + let session = sandbox_with(client) + .create(CreateSessionRequest { + session_id: None, + tenant_key: None, + env: BTreeMap::from([("TOKEN".to_string(), "t".to_string())]), + }) + .await + .expect("the create should succeed"); + + assert_eq!( + session.state, + SandboxSessionState::Starting, + "a sandbox still being created is not one a command can reach" + ); + } } From fd8cd6ebf3472ae7119034fc8609f21e68f18201 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:11:03 +0300 Subject: [PATCH 06/32] feat(sandbox): create Azure sandboxes under the declared egress policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Azure's data plane takes an egress policy at create, and this backend sent none — so `deny` and a hostname list were both refused at plan time while the cloud underneath could express either. The declaration now travels in the binding and becomes the policy the sandbox is created with. `deny` is a `Deny` default under `Full` traffic inspection, plus a catch-all deny rule. Each part is load-bearing. Only `Full` blocks non-HTTP traffic — under any other mode a `Deny` default is a label on a live network. The rule is there because Microsoft documents `Partial` as evaluating only traffic a rule matches and never says `Full` differs, so a policy holding no rules at all is the one shape where "deny" could mean nothing. `allowDomains` becomes host rules over the same `Deny` default. `allow` sends no policy: the data plane is already open, and `Full` there would block the traffic `allow` promises. Then it is checked. The create response carries the policy the sandbox is actually running under, so a sandbox that came up without the one that was asked for is deleted rather than handed back — a restriction that did not take effect is worse than one nobody asked for, because the caller believes it held. The check compares the default action, the inspection mode, and every host the declaration named, not the whole object, so a normalised response does not fail every create. `egressDeny` and `domainEgressRules` flip last. Azure is the first backend to express a hostname allowlist at all: the others match CIDRs or carry a single switch. The binding's `egress` field is required, so a binding JSON without one no longer parses. Nothing is deployed, so there is nothing to migrate. What a live sandbox still has to settle: whether traffic is actually blocked. The response proves the policy is configured, never that a packet was dropped — the same evidence every other backend flips its flag on. --- .../src/azure/sandbox_data_plane.rs | 53 +++ crates/alien-bindings/src/provider.rs | 4 +- .../src/providers/sandbox/azure.rs | 338 +++++++++++++++++- crates/alien-core/src/bindings/sandbox.rs | 12 +- crates/alien-core/src/resources/sandbox.rs | 47 +-- .../compile_time/sandbox_platform_support.rs | 16 +- .../src/emitters/azure/sandbox.rs | 90 ++++- 7 files changed, 529 insertions(+), 31 deletions(-) diff --git a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs index 739c97800..45316fa71 100644 --- a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs +++ b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs @@ -42,6 +42,35 @@ const SERVICE_NAME: &str = "sandboxDataPlane"; /// callers can rely on everywhere, and a body that never grows past it here. const MAX_FILE_BYTES: usize = 32 * 1024 * 1024; +/// An egress policy as the data plane takes and reports it. +/// +/// Only the fields a sandbox needs: the audit log, header transforms and URL rewrites are part of +/// the same object and none of them are policy Alien can express. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EgressPolicy { + /// `Allow` or `Deny`, applied to anything no rule matches. The data plane's own default is + /// `Allow`, so a policy that omits it is an open sandbox. + pub default_action: String, + /// Host patterns and what to do with them. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub host_rules: Vec, + /// `Full`, `Partial`, `Legacy` or `None`. Only `Full` blocks non-HTTP traffic, so only `Full` + /// makes a `Deny` default mean no outbound access. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub traffic_inspection: Option, +} + +/// One host pattern and the action it carries. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EgressHostRule { + /// Host pattern, such as `api.example.com`. + pub pattern: String, + /// `Allow` or `Deny`. + pub action: String, +} + /// What a sandbox is created from. /// /// A struct rather than a parameter list because the data plane keeps adding create-time fields @@ -58,6 +87,9 @@ pub struct CreateSandbox { /// Variables placed in the sandbox. It inherits nothing, so a variable exists only if it is /// sent here. pub environment: BTreeMap, + /// Outbound policy, applied from the moment the sandbox starts. Absent leaves the data + /// plane's own default, which is open. + pub egress: Option, } /// The create body. @@ -75,6 +107,10 @@ fn create_body(request: &CreateSandbox) -> serde_json::Value { body["environment"] = serde_json::json!(request.environment); } + if let Some(egress) = &request.egress { + body["egressPolicy"] = serde_json::json!(egress); + } + body } @@ -84,6 +120,10 @@ fn create_body(request: &CreateSandbox) -> serde_json::Value { pub struct Sandbox { /// Sandbox id within its group pub id: String, + /// The policy the sandbox is actually running under, which is the only way to tell that the + /// one that was asked for took effect. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub egress_policy: Option, /// `Creating`, `Running`, `Stopping`, `Stopped`, `Suspended`, `Resuming` or `Deleting`. /// /// Optional because the name is only as good as the SDK it was read from: a field name that @@ -652,11 +692,24 @@ mod tests { cpu: "1000m".to_string(), memory: "2048Mi".to_string(), environment: BTreeMap::from([("TOKEN".to_string(), "t".to_string())]), + egress: Some(EgressPolicy { + default_action: "Deny".to_string(), + host_rules: vec![EgressHostRule { + pattern: "api.example.com".to_string(), + action: "Allow".to_string(), + }], + traffic_inspection: Some("Full".to_string()), + }), }); assert_eq!(body["environment"]["TOKEN"], "t"); assert_eq!(body["sourcesRef"]["diskImage"]["name"], "ubuntu"); assert_eq!(body["resources"]["cpu"], "1000m"); + // camelCase, because the data plane ignores a field it cannot name and creates an open + // sandbox instead of refusing the body. + assert_eq!(body["egressPolicy"]["defaultAction"], "Deny"); + assert_eq!(body["egressPolicy"]["trafficInspection"], "Full"); + assert_eq!(body["egressPolicy"]["hostRules"][0]["pattern"], "api.example.com"); let bare = create_body(&CreateSandbox::default()); assert!( diff --git a/crates/alien-bindings/src/provider.rs b/crates/alien-bindings/src/provider.rs index a894f8df3..8d537f143 100644 --- a/crates/alien-bindings/src/provider.rs +++ b/crates/alien-bindings/src/provider.rs @@ -1908,6 +1908,7 @@ impl BindingsProviderApi for BindingsProvider { Arc::new(client), group, disk_image, + azure_binding.egress, DEFAULT_AZURE_CPU.to_string(), DEFAULT_AZURE_MEMORY.to_string(), )); @@ -2251,7 +2252,8 @@ mod tests { "dataPlaneEndpoint":"https://management.swedencentral.azuredevcompute.io", "region":"swedencentral", "resourceGroup":"rg", - "diskImage":"my-toolchain"}"# + "diskImage":"my-toolchain", + "egress":{"mode":"deny"}}"# .to_string(), ), ]); diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 24aa804ac..ada3f9a61 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -15,9 +15,11 @@ use crate::traits::{ Binding, CommandOutput, CreateSessionRequest, PreviewCapability, RunCommandRequest, Sandbox, SandboxSession, SandboxSessionState, }; -use alien_azure_clients::azure::sandbox_data_plane::{CreateSandbox, SandboxDataPlaneApi}; +use alien_azure_clients::azure::sandbox_data_plane::{ + CreateSandbox, EgressHostRule, EgressPolicy, SandboxDataPlaneApi, +}; use alien_client_core::ErrorData as ClientErrorData; -use alien_core::{Platform, SandboxCapabilities}; +use alien_core::{Platform, SandboxCapabilities, SandboxEgress}; use alien_error::{AlienError, ContextError}; /// A Sandbox backed by the Azure ADC data plane. @@ -27,6 +29,8 @@ pub struct AzureSandbox { sandbox_group: String, /// Catalog disk image every session is created from, from the declaration. disk_image: String, + /// Outbound policy every session is created with, from the declaration. + egress: SandboxEgress, /// Session ceilings, in the data plane's own units. cpu: String, memory: String, @@ -38,6 +42,7 @@ impl AzureSandbox { client: std::sync::Arc, sandbox_group: String, disk_image: String, + egress: SandboxEgress, cpu: String, memory: String, ) -> Self { @@ -45,6 +50,7 @@ impl AzureSandbox { client, sandbox_group, disk_image, + egress, cpu, memory, } @@ -105,6 +111,7 @@ impl Sandbox for AzureSandbox { } async fn create(&self, request: CreateSessionRequest) -> Result { + let asked = egress_policy(&self.egress); let sandbox = self .client .create_sandbox( @@ -114,6 +121,7 @@ impl Sandbox for AzureSandbox { cpu: self.cpu.clone(), memory: self.memory.clone(), environment: request.env, + egress: asked.clone(), }, ) .await @@ -123,6 +131,31 @@ impl Sandbox for AzureSandbox { // the requested one would hand back a handle that addresses nothing. let _ = request.session_id; + // A restriction that did not take effect is worse than one that was never asked for: the + // caller believes the sandbox is contained. The response says what the sandbox is running + // under, so this is checked rather than assumed, and a sandbox that came up without the + // policy is deleted rather than handed back. + if let Some(asked) = &asked { + if !policy_holds(asked, sandbox.egress_policy.as_ref()) { + // The delete's own failure is carried rather than returned: it would replace the + // finding that matters — that the sandbox is not contained — with a delete error. + let deleted = match self.accept_delete(&sandbox.id).await { + Ok(()) => "it was deleted".to_string(), + Err(error) => format!("deleting it also failed: {error}"), + }; + return Err(AlienError::new(ErrorData::BindingConfigInvalid { + binding_name: "sandbox".to_string(), + env_var: "ALIEN_BINDING_SANDBOX".to_string(), + reason: format!( + "the sandbox was created asking for {} but came up with {}, so {deleted} \ + rather than handed back", + describe(Some(asked)), + describe(sandbox.egress_policy.as_ref()) + ), + })); + } + } + Ok(SandboxSession { session_id: sandbox.id, state: session_state("sandbox.create", sandbox.state.as_deref())?, @@ -150,8 +183,13 @@ impl Sandbox for AzureSandbox { async fn get_or_create(&self, request: CreateSessionRequest) -> Result { if let Some(id) = request.session_id.as_deref() { - if let Some(existing) = self.get(id).await? { - return Ok(existing); + // A session on its way out is not one to reconnect to: the id will not run again, and + // handing it back trades an error now for a command that never lands. + match self.get(id).await? { + Some(existing) if existing.state != SandboxSessionState::Terminated => { + return Ok(existing) + } + _ => {} } } @@ -434,6 +472,76 @@ fn checked_path(operation: &str, path: &str) -> Result<()> { Ok(()) } +/// The policy a declared mode is created with. +/// +/// `Full` inspection is what makes a `Deny` default mean no outbound access: under `Partial`, +/// `Legacy` and `None`, non-HTTP traffic is allowed through whatever the default action says, so +/// the sandbox would carry a `deny` label and a live network. `allow` sends no policy at all — +/// the data plane's default is already open, and `Full` there would block the non-HTTP traffic +/// `allow` promises. +fn egress_policy(egress: &SandboxEgress) -> Option { + let bounded = |host_rules| { + Some(EgressPolicy { + default_action: DENY.to_string(), + host_rules, + traffic_inspection: Some(FULL_INSPECTION.to_string()), + }) + }; + + match egress { + SandboxEgress::Allow => None, + // Written as a rule as well as a default, because Microsoft documents `Partial` + // inspection as evaluating only traffic a rule matches and never states that `Full` + // differs. A policy holding no rule at all is the one shape where "deny" could mean + // nothing, and this is one rule to be out of it. + SandboxEgress::Deny => bounded(vec![EgressHostRule { + pattern: EVERY_HOST.to_string(), + action: DENY.to_string(), + }]), + SandboxEgress::AllowDomains { domains } => bounded( + domains + .iter() + .map(|domain| EgressHostRule { + pattern: domain.clone(), + action: ALLOW.to_string(), + }) + .collect(), + ), + } +} + +/// Whether the sandbox is running the policy it was created with. +/// +/// A subset check rather than equality: the data plane may return the policy normalised or carry +/// fields this client does not model, and failing every create over a reordered list would push +/// whoever hits it into removing the check. What is compared is what containment rests on — the +/// default action, the inspection mode, and every host the declaration named. +fn policy_holds(asked: &EgressPolicy, effective: Option<&EgressPolicy>) -> bool { + let Some(effective) = effective else { + return false; + }; + + effective.default_action == asked.default_action + && effective.traffic_inspection.as_deref() == Some(FULL_INSPECTION) + && asked + .host_rules + .iter() + .all(|rule| effective.host_rules.contains(rule)) +} + +/// The effective policy, short enough to read in an error. +fn describe(effective: Option<&EgressPolicy>) -> String { + match effective { + None => "no policy at all".to_string(), + Some(policy) => format!( + "default action '{}' under {} inspection with {} host rules", + policy.default_action, + policy.traffic_inspection.as_deref().unwrap_or("unstated"), + policy.host_rules.len() + ), + } +} + /// The data plane's own lifecycle vocabulary, in ours. /// /// An unrecognised state is an error rather than a default, because every default here is a lie @@ -455,6 +563,15 @@ fn session_state(operation: &str, state: Option<&str>) -> Result) -> alien_azure_clients::azure::sandbox_data_plane::Sandbox { + alien_azure_clients::azure::sandbox_data_plane::Sandbox { + id: id.to_string(), + egress_policy: egress, + state: Some("Running".to_string()), + } + } + + fn sandbox_denying(client: MockSandboxDataPlaneApi, egress: SandboxEgress) -> AzureSandbox { + AzureSandbox::new( + std::sync::Arc::new(client), + "grp".to_string(), + "ubuntu".to_string(), + egress, + "1000m".to_string(), + "2048Mi".to_string(), + ) + } + + /// What each declared mode is created with. + /// + /// The inspection mode is the half that is easy to leave out and impossible to notice: under + /// anything but `Full` a `Deny` default still lets every non-HTTP protocol out, so a sandbox + /// would carry the label and none of the containment. `allow` must send no policy, because + /// `Full` would block the traffic `allow` promises. + #[tokio::test] + async fn each_declared_mode_is_created_with_the_policy_that_realises_it() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_create_sandbox() + .times(1) + .returning(|_, request| { + let policy = request.egress.expect("deny must send a policy"); + assert_eq!(policy.default_action, "Deny"); + assert_eq!( + policy.traffic_inspection.as_deref(), + Some("Full"), + "only Full inspection blocks non-HTTP traffic" + ); + assert_eq!( + policy.host_rules, + vec![EgressHostRule { + pattern: "*".to_string(), + action: "Deny".to_string(), + }], + "deny is written as a rule too, so it does not rest on how the proxy treats a \ + policy with no rules" + ); + Ok(running("s1", Some(policy))) + }); + sandbox_denying(client, SandboxEgress::Deny) + .create(CreateSessionRequest::default()) + .await + .expect("deny should create"); + + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_create_sandbox() + .times(1) + .returning(|_, request| { + let policy = request.egress.expect("allowDomains must send a policy"); + assert_eq!(policy.default_action, "Deny", "anything unlisted is denied"); + assert_eq!(policy.traffic_inspection.as_deref(), Some("Full")); + assert_eq!( + policy.host_rules, + vec![EgressHostRule { + pattern: "api.example.com".to_string(), + action: "Allow".to_string(), + }] + ); + Ok(running("s1", Some(policy))) + }); + sandbox_denying( + client, + SandboxEgress::AllowDomains { + domains: vec!["api.example.com".to_string()], + }, + ) + .create(CreateSessionRequest::default()) + .await + .expect("allowDomains should create"); + + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_create_sandbox() + .times(1) + .returning(|_, request| { + assert!( + request.egress.is_none(), + "an open sandbox sends no policy: Full inspection would block non-HTTP traffic" + ); + Ok(running("s1", None)) + }); + sandbox_denying(client, SandboxEgress::Allow) + .create(CreateSessionRequest::default()) + .await + .expect("allow should create"); + } + + /// A restriction that did not take effect is the failure this whole path exists to prevent, + /// so the sandbox is deleted rather than returned with a `deny` label and a live network. + #[tokio::test] + async fn a_sandbox_that_came_up_without_its_policy_is_deleted_rather_than_handed_back() { + for came_up_with in [ + None, + // The default action alone: every non-HTTP protocol still leaves. + Some(EgressPolicy { + default_action: "Deny".to_string(), + host_rules: Vec::new(), + traffic_inspection: Some("Partial".to_string()), + }), + // Inspected, and open. + Some(EgressPolicy { + default_action: "Allow".to_string(), + host_rules: Vec::new(), + traffic_inspection: Some("Full".to_string()), + }), + ] { + let mut client = MockSandboxDataPlaneApi::new(); + let effective = came_up_with.clone(); + client + .expect_create_sandbox() + .times(1) + .returning(move |_, _| Ok(running("s1", effective.clone()))); + client + .expect_delete_sandbox() + .withf(|_, id| id == "s1") + .times(1) + .returning(|_, _| Ok(())); + + let error = sandbox_denying(client, SandboxEgress::Deny) + .create(CreateSessionRequest::default()) + .await + .expect_err("a sandbox without its policy must not be handed back"); + + assert_eq!(error.code, "BINDING_CONFIG_INVALID", "{error}"); + } + } + + /// A host the declaration named that the sandbox is not running is the same failure as a + /// missing policy: the caller believes traffic to it is allowed and it is not, or worse, the + /// list came back holding something else. + #[tokio::test] + async fn a_missing_host_rule_fails_the_create() { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_create_sandbox().times(1).returning(|_, _| { + Ok(running( + "s1", + Some(EgressPolicy { + default_action: "Deny".to_string(), + host_rules: vec![EgressHostRule { + pattern: "elsewhere.example.com".to_string(), + action: "Allow".to_string(), + }], + traffic_inspection: Some("Full".to_string()), + }), + )) + }); + client.expect_delete_sandbox().times(1).returning(|_, _| Ok(())); + + let error = sandbox_denying( + client, + SandboxEgress::AllowDomains { + domains: vec!["api.example.com".to_string()], + }, + ) + .create(CreateSessionRequest::default()) + .await + .expect_err("a host the declaration named must be in the effective policy"); + + assert_eq!(error.code, "BINDING_CONFIG_INVALID", "{error}"); + } + + /// A session that is going away is not one to reconnect to. + /// + /// `get_or_create` hands back whatever `get` finds, and the id of a deleting sandbox will not + /// run again — so the caller would receive a handle whose every command lands on nothing. + #[tokio::test] + async fn a_terminated_session_is_replaced_rather_than_reconnected_to() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_get_sandbox() + .times(1) + .returning(|_, id| Ok(running(id, None)).map(|mut sandbox: alien_azure_clients::azure::sandbox_data_plane::Sandbox| { + sandbox.state = Some("Deleting".to_string()); + sandbox + })); + client + .expect_create_sandbox() + .times(1) + .returning(|_, _| Ok(running("fresh", None))); + + let session = sandbox_with(client) + .get_or_create(CreateSessionRequest { + session_id: Some("going-away".to_string()), + tenant_key: None, + env: BTreeMap::new(), + }) + .await + .expect("a new session should be created"); + + assert_eq!(session.session_id, "fresh"); + } } diff --git a/crates/alien-core/src/bindings/sandbox.rs b/crates/alien-core/src/bindings/sandbox.rs index 6d76a51cd..bf1ac1a28 100644 --- a/crates/alien-core/src/bindings/sandbox.rs +++ b/crates/alien-core/src/bindings/sandbox.rs @@ -5,6 +5,7 @@ //! record, so a binding describes the parent only. use super::BindingValue; +use crate::SandboxEgress; use serde::{Deserialize, Serialize}; /// Represents a sandbox binding for creating and reaching sandbox sessions. @@ -91,6 +92,12 @@ pub struct AzureSandboxBinding { /// Resource group the sandbox group sits in. The data-plane path is scoped by it, and the /// Azure client config does not carry one. pub resource_group: BindingValue, + /// Outbound policy every session is created with, as declared. + /// + /// Carried whole rather than as a flag: the data plane's default action is `Allow`, so a + /// session created without a policy is an open one, and a hostname list has no boolean to + /// travel in. + pub egress: SandboxEgress, /// Catalog disk image every session is created from, taken from the declaration's `code`. /// /// Carried rather than hardcoded in the provider because the declaration is the only place @@ -175,12 +182,14 @@ impl SandboxBinding { region: impl Into>, resource_group: impl Into>, disk_image: impl Into>, + egress: SandboxEgress, ) -> Self { Self::Azure(AzureSandboxBinding { sandbox_group: sandbox_group.into(), data_plane_endpoint: data_plane_endpoint.into(), region: region.into(), resource_group: resource_group.into(), + egress, disk_image: disk_image.into(), }) } @@ -248,6 +257,7 @@ mod tests { "swedencentral", "rg", "ubuntu", + SandboxEgress::Deny, ), SandboxBinding::gcp("/usr/local/gcp/bin/sandbox", false), SandboxBinding::kubernetes( @@ -276,7 +286,7 @@ mod tests { fn service_tags_are_prefixed_and_distinct() { let tags: Vec = vec![ SandboxBinding::aws("a", "1", "r"), - SandboxBinding::azure("g", "e", "r", "rg", "ubuntu"), + SandboxBinding::azure("g", "e", "r", "rg", "ubuntu", SandboxEgress::Deny), SandboxBinding::gcp("p", true), SandboxBinding::kubernetes("n", "gvisor", "s", "http://op:8080", "k", "/t"), SandboxBinding::local("u", "k", "t"), diff --git a/crates/alien-core/src/resources/sandbox.rs b/crates/alien-core/src/resources/sandbox.rs index 0850deb15..2cddb3f1f 100644 --- a/crates/alien-core/src/resources/sandbox.rs +++ b/crates/alien-core/src/resources/sandbox.rs @@ -132,7 +132,10 @@ pub enum SandboxEgress { /// /// Link-local carries the same exception as `Deny`. Allow, - /// Outbound access only to the listed hostnames. No backend expresses this yet. + /// Outbound access only to the listed hostnames. + /// + /// Azure alone expresses it: its egress proxy matches on host pattern. The others filter by + /// CIDR or carry a single switch, and both would approximate the list rather than keep it. #[serde(rename_all = "camelCase")] AllowDomains { /// Hostnames the sandbox may reach @@ -233,8 +236,8 @@ impl SandboxCapabilities { preview: false, suspend_resume: false, snapshot: false, - domain_egress_rules: false, - egress_deny: false, + domain_egress_rules: true, + egress_deny: true, enforced_limits: false, process_limit: false, session_lifetime: false, @@ -862,10 +865,12 @@ mod tests { let azure = SandboxCapabilities::for_platform(Platform::Azure).expect("azure is supported"); assert!(azure.files, "every backend moves files"); assert!(gcp.files); - // The Azure binding renders neither an egress policy nor a ceiling, so a declaration of - // either is refused rather than accepted and dropped. - assert!(!azure.domain_egress_rules); - assert!(!azure.egress_deny); + // Azure is the only backend whose egress policy matches on host pattern, and the only + // one where `deny` and a hostname list are the same object. + assert!(azure.domain_egress_rules); + assert!(azure.egress_deny); + // The data plane takes no ceiling, so a declaration of one is refused rather than + // accepted and dropped. assert!(!azure.enforced_limits); // Azure the cloud has snapshot, preview and resume; the binding provider returns // unsupported for all three. What a caller can reach is what the set describes. @@ -908,11 +913,11 @@ mod tests { assert!(rendered.contains("gcp"), "names the platform: {rendered}"); } - /// No backend expresses a hostname allowlist: AWS and Kubernetes match CIDRs, and the Azure - /// binding renders no egress policy at all. Accepting one anywhere would leave a stack + /// Azure matches on hostname; AWS and Kubernetes match CIDRs, and Local and GCP have a + /// switch rather than a filter. Accepting a hostname list on those four would leave a stack /// reading as restricted while the sandbox reaches the whole internet. #[test] - fn a_hostname_allowlist_is_refused_on_every_backend() { + fn a_hostname_allowlist_is_refused_everywhere_it_would_be_approximated() { let sandbox = sandbox_with( SandboxEgress::AllowDomains { domains: vec!["example.com".to_string()], @@ -922,19 +927,25 @@ mod tests { for platform in [ Platform::Aws, - Platform::Azure, Platform::Gcp, Platform::Kubernetes, Platform::Local, ] { let error = sandbox .validate_for_platform(platform) - .expect_err("no backend expresses a hostname allowlist"); + .expect_err("only Azure expresses a hostname allowlist"); assert_eq!( error.code, "SANDBOX_CAPABILITY_UNSUPPORTED", "on {platform:?}" ); } + + assert!( + SandboxCapabilities::for_platform(Platform::Azure) + .expect("supported") + .domain_egress_rules, + "Azure's egress policy matches on host pattern" + ); } /// `deny` is the declaration that carries a security promise, so a backend that cannot keep @@ -957,7 +968,7 @@ mod tests { .expect("deny is enforced here"); } - // Declares no ceilings, so the only thing left for Azure to refuse is the egress mode. + // Declares no ceilings, which Azure refuses for its own reason, so this isolates egress. let egress_only = Sandbox::new("sbx".to_string()) .code(SandboxCode::Image { image: "alpine:3.20".to_string(), @@ -969,15 +980,9 @@ mod tests { }) .build(); - let error = egress_only + egress_only .validate_for_platform(Platform::Azure) - .expect_err("the Azure binding renders no egress policy, so deny cannot be kept"); - assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED"); - assert!( - error.message.contains("egressDeny"), - "names the capability: {}", - error.message - ); + .expect("Azure creates the sandbox under a Deny policy with full inspection"); } /// Ceilings are rejected per-platform where unsupported — rejected when *declared*. With diff --git a/crates/alien-preflights/src/compile_time/sandbox_platform_support.rs b/crates/alien-preflights/src/compile_time/sandbox_platform_support.rs index a4c190788..4dda41d44 100644 --- a/crates/alien-preflights/src/compile_time/sandbox_platform_support.rs +++ b/crates/alien-preflights/src/compile_time/sandbox_platform_support.rs @@ -136,8 +136,8 @@ mod tests { } } - /// No backend expresses a hostname allowlist, so the declaration is refused everywhere - /// rather than accepted and dropped. + /// Azure's egress proxy matches on host pattern; the other four filter by address or carry a + /// single switch, so the declaration is refused there rather than accepted and dropped. #[tokio::test] async fn domain_egress_rules_are_refused_where_they_cannot_be_expressed() { let stack = stack_with(sandbox( @@ -150,9 +150,9 @@ mod tests { for platform in [ Platform::Aws, - Platform::Azure, Platform::Gcp, Platform::Kubernetes, + Platform::Local, ] { let result = SandboxPlatformSupportCheck .check(&stack, platform) @@ -163,6 +163,16 @@ mod tests { "{platform} has no hostname allowlist and must refuse the declaration" ); } + + let azure = SandboxPlatformSupportCheck + .check(&stack, Platform::Azure) + .await + .expect("check runs"); + assert!( + azure.success, + "Azure creates the sandbox under host rules: {:?}", + azure.errors + ); } #[tokio::test] diff --git a/crates/alien-terraform/src/emitters/azure/sandbox.rs b/crates/alien-terraform/src/emitters/azure/sandbox.rs index 90b43988c..138ca98b7 100644 --- a/crates/alien-terraform/src/emitters/azure/sandbox.rs +++ b/crates/alien-terraform/src/emitters/azure/sandbox.rs @@ -11,7 +11,7 @@ use crate::{ emitters::azure::helpers::{downcast, required_label, resource_prefix_template}, expr, }; -use alien_core::{import::EmitContext, ErrorData, Result, Sandbox, SandboxCode}; +use alien_core::{import::EmitContext, ErrorData, Result, Sandbox, SandboxCode, SandboxEgress}; use alien_error::AlienError; use hcl::expr::Expression; @@ -30,6 +30,29 @@ fn sandbox_group(ctx: &EmitContext<'_>) -> Expression { resource_prefix_template(&ctx.resource_id) } +/// The declared outbound policy, in the shape the binding carries. +/// +/// The sandbox is created with it rather than a setup resource enforcing it — Azure's proxy takes +/// the policy at create — so the declaration has to survive as far as the binding intact. +fn egress(sandbox: &Sandbox) -> Expression { + match &sandbox.egress { + SandboxEgress::Deny => expr::object([("mode", Expression::String("deny".to_string()))]), + SandboxEgress::Allow => expr::object([("mode", Expression::String("allow".to_string()))]), + SandboxEgress::AllowDomains { domains } => expr::object([ + ("mode", Expression::String("allowDomains".to_string())), + ( + "domains", + Expression::from( + domains + .iter() + .map(|domain| Expression::String(domain.clone())) + .collect::>(), + ), + ), + ]), + } +} + /// The catalog image name a declaration asks for, or a refusal. /// /// The create body names a public catalog image, so a registry reference has nowhere to go. @@ -91,6 +114,71 @@ impl TfEmitter for AzureSandboxEmitter { ("region", expr::raw("var.azure_location")), ("resourceGroup", expr::raw("var.azure_resource_group_name")), ("diskImage", Expression::String(disk_image)), + ("egress", egress(sandbox)), ]))) } } + +#[cfg(test)] +mod tests { + use super::*; + use alien_core::{ResourceLifecycle, SandboxSessionPolicy, Stack, StackSettings}; + use indexmap::IndexMap; + + fn binding_for(egress: SandboxEgress) -> String { + let stack = Stack::new("acme".to_string()) + .add( + Sandbox::new("agents".to_string()) + .code(SandboxCode::Image { + image: "ubuntu".to_string(), + }) + .egress(egress) + .session(SandboxSessionPolicy { + max_lifetime_seconds: None, + idle_suspend_seconds: None, + }) + .build(), + ResourceLifecycle::Frozen, + ) + .build(); + let resource = stack.resources.get("agents").expect("the sandbox is in the stack"); + let names = IndexMap::from([("agents".to_string(), "agents".to_string())]); + let settings = StackSettings::default(); + let ctx = EmitContext { + stack: &stack, + resource, + resource_id: "agents", + platform: alien_core::Platform::Azure, + targets_kubernetes: false, + stack_settings: &settings, + names: &names, + }; + + AzureSandboxEmitter + .emit_binding_ref(&ctx) + .expect("the binding renders") + .expect("an Azure sandbox has a binding") + .to_string() + } + + /// The declared mode has to reach the binding, whole. + /// + /// Azure applies the policy at create rather than through a setup resource, so the binding is + /// the only carrier: a mode that stops here leaves every session created under the data + /// plane's own default, which is open. A hostname list fails twice over — the mode without the + /// domains denies everything, and the domains without the mode are ignored. + #[test] + fn the_binding_carries_the_declared_egress() { + let denied = binding_for(SandboxEgress::Deny); + assert!(denied.contains(r#""deny""#), "{denied}"); + + let listed = binding_for(SandboxEgress::AllowDomains { + domains: vec!["api.example.com".to_string()], + }); + assert!(listed.contains(r#""allowDomains""#), "{listed}"); + assert!(listed.contains("api.example.com"), "{listed}"); + + let open = binding_for(SandboxEgress::Allow); + assert!(open.contains(r#""allow""#), "{open}"); + } +} From 02b48662578937ebea8a19d83be654ad8377c3bf Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:11:12 +0300 Subject: [PATCH 07/32] fix(sandbox): refuse a hostname allowlist instead of approximating it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three backends turned `allowDomains` into their nearest expressible thing, and only the core capability check stood between that and a rendered artifact. Render a chart or a GCP module directly and the declaration changed meaning with nothing anywhere saying so. Helm folded it into the `allow` arm and emitted `0.0.0.0/0` — every address the list existed to exclude. Its test asserted the widened policy, pinning the behaviour rather than preventing it. GCP collapsed it to `allowEgress: false`, denying everything the declaration asked to permit. Both now refuse, as AWS already did. The AWS message ends "or use a platform that supports it", which was advice to nowhere while every backend reported `domainEgressRules: false`; all four messages now name Azure, whose egress proxy matches on host pattern. --- .../src/emitters/aws/sandbox.rs | 4 ++-- crates/alien-helm/src/emitters/sandbox.rs | 17 ++++++++++++-- crates/alien-helm/tests/generator/helpers.rs | 6 ++++- .../tests/generator/resource_layer_tests.rs | 21 ++++++++--------- crates/alien-infra/src/sandbox/local.rs | 3 ++- .../src/emitters/aws/sandbox.rs | 4 ++-- .../src/emitters/gcp/sandbox.rs | 23 ++++++++++++++++++- 7 files changed, 57 insertions(+), 21 deletions(-) diff --git a/crates/alien-cloudformation/src/emitters/aws/sandbox.rs b/crates/alien-cloudformation/src/emitters/aws/sandbox.rs index b2ffdfa70..01481113e 100644 --- a/crates/alien-cloudformation/src/emitters/aws/sandbox.rs +++ b/crates/alien-cloudformation/src/emitters/aws/sandbox.rs @@ -582,8 +582,8 @@ fn refuse_unsupported_egress(sandbox: &Sandbox) -> Result<()> { reason: format!( "AWS sandboxes reach the network through a VPC egress connector, which this \ template builds to deny outbound traffic; egress '{mode}' has no connector \ - configuration to render into. Declare egress: deny, or use a platform that \ - supports it" + configuration to render into. Declare egress: deny, or deploy to Azure, whose \ + egress proxy matches on host pattern" ), })) }; diff --git a/crates/alien-helm/src/emitters/sandbox.rs b/crates/alien-helm/src/emitters/sandbox.rs index edd1a690c..c37b7462a 100644 --- a/crates/alien-helm/src/emitters/sandbox.rs +++ b/crates/alien-helm/src/emitters/sandbox.rs @@ -58,6 +58,19 @@ impl HelmEmitter for SandboxEmitter { }) })?; + // A hostname list has no NetworkPolicy to render into — it matches CIDRs — so it is + // refused rather than widened to the `allow` rule, which would open every address the + // declaration meant to exclude. + if let SandboxEgress::AllowDomains { .. } = sandbox.egress { + return Err(AlienError::new(ErrorData::OperationNotSupported { + operation: format!("helm emit sandbox '{}'", ctx.resource_id), + reason: "a Kubernetes NetworkPolicy matches addresses, not names, so a hostname \ + list has nothing to render into. Declare egress: deny, or deploy to \ + Azure, whose egress proxy matches on host pattern" + .to_string(), + })); + } + let mut fragment = HelmFragment::empty(); fragment.extra_templates.insert( format!("sandbox-{}-networkpolicy.yaml", sandbox.id()), @@ -81,8 +94,8 @@ impl HelmEmitter for SandboxEmitter { fn network_policy(sandbox: &Sandbox) -> String { let egress = match sandbox.egress { SandboxEgress::Deny => String::new(), - // A hostname allowlist is not expressible here — NetworkPolicy matches CIDRs — which is - // why Kubernetes publishes `domainEgressRules: false` rather than approximating one. + // `AllowDomains` never reaches here: the emitter refuses it rather than render it as the + // `allow` rule below, which permits every address the list meant to exclude. SandboxEgress::Allow | SandboxEgress::AllowDomains { .. } => { let excepts: String = ALWAYS_DENIED_CIDRS .iter() diff --git a/crates/alien-helm/tests/generator/helpers.rs b/crates/alien-helm/tests/generator/helpers.rs index d6ca4bfff..01e372b9b 100644 --- a/crates/alien-helm/tests/generator/helpers.rs +++ b/crates/alien-helm/tests/generator/helpers.rs @@ -8,6 +8,11 @@ use super::test_utils; /// Render `stack` into a chart through the built-in registry. pub fn render(stack: &Stack, settings: StackSettings) -> HelmChart { + try_render(stack, settings).expect("chart should render") +} + +/// Render `stack`, keeping the error for a case that is meant to be refused. +pub fn try_render(stack: &Stack, settings: StackSettings) -> alien_core::Result { let registry = HelmRegistry::built_in(); generate_helm_chart( stack, @@ -17,7 +22,6 @@ pub fn render(stack: &Stack, settings: StackSettings) -> HelmChart { chart_name: stack.id().to_string(), }, ) - .expect("chart should render") } /// Snapshot the entire chart as a single string with `=== ===` diff --git a/crates/alien-helm/tests/generator/resource_layer_tests.rs b/crates/alien-helm/tests/generator/resource_layer_tests.rs index 3fdbdf688..5c552a15a 100644 --- a/crates/alien-helm/tests/generator/resource_layer_tests.rs +++ b/crates/alien-helm/tests/generator/resource_layer_tests.rs @@ -2,7 +2,7 @@ //! artifact-registry contributions land under //! `infrastructure.` in the chart's `values.yaml`. -use super::helpers::{assert_helm_valid, render, snapshot_chart}; +use super::helpers::{assert_helm_valid, render, snapshot_chart, try_render}; use alien_core::{ ArtifactRegistry, Kv, Queue, ResourceLifecycle, Sandbox, SandboxCode, SandboxEgress, SandboxSessionPolicy, Stack, StackSettings, Storage, Vault, @@ -166,11 +166,11 @@ fn a_sandbox_allowing_egress_still_denies_the_metadata_endpoint() { assert_helm_valid(&chart, "sandbox_layer_allow"); } -/// NetworkPolicy matches addresses, not names, so a hostname allowlist cannot be honoured here. -/// It degrades to `allow` rather than being approximated, and the capability set declares -/// `domainEgressRules: false` so a caller learns that at plan time instead of believing it held. +/// NetworkPolicy matches addresses, not names, so a hostname allowlist has nothing to render +/// into. It is refused: rendering it as `allow` would open every address the list excluded, and +/// the chart would look like the policy applied. #[test] -fn a_hostname_allowlist_is_not_silently_approximated() { +fn a_hostname_allowlist_is_refused_rather_than_widened() { let stack = Stack::new("sandbox-domains-chart".to_string()) .add( Sandbox::new("agent".to_string()) @@ -188,14 +188,11 @@ fn a_hostname_allowlist_is_not_silently_approximated() { ResourceLifecycle::Frozen, ) .build(); - let chart = render(&stack, StackSettings::default()); + let error = try_render(&stack, StackSettings::default()) + .expect_err("a hostname list must be refused rather than approximated"); - let policy = chart - .files - .get("templates/sandbox-agent-networkpolicy.yaml") - .expect("the sandbox NetworkPolicy must render"); assert!( - policy.contains("cidr: 0.0.0.0/0") && !policy.contains("example.com"), - "domains are not expressible and must not appear as though they were:\n{policy}" + error.to_string().contains("matches addresses, not names"), + "the refusal must name why: {error}" ); } diff --git a/crates/alien-infra/src/sandbox/local.rs b/crates/alien-infra/src/sandbox/local.rs index 68d1ea82d..3d7cd72a4 100644 --- a/crates/alien-infra/src/sandbox/local.rs +++ b/crates/alien-infra/src/sandbox/local.rs @@ -292,7 +292,8 @@ fn session_template(sandbox: &Sandbox) -> Result alien_local::SandboxEgressMode::Allow, SandboxEgress::AllowDomains { .. } => { return Err(AlienError::new(ErrorData::CloudPlatformError { - message: "no sandbox backend restricts egress to a hostname list" + message: "a local sandbox has one network switch and no filter, so a hostname \ + list has nothing to render into; Azure matches on host pattern" .to_string(), resource_id: Some(sandbox.id.clone()), })) diff --git a/crates/alien-terraform/src/emitters/aws/sandbox.rs b/crates/alien-terraform/src/emitters/aws/sandbox.rs index 7baa61488..f8d3cffb7 100644 --- a/crates/alien-terraform/src/emitters/aws/sandbox.rs +++ b/crates/alien-terraform/src/emitters/aws/sandbox.rs @@ -631,8 +631,8 @@ fn refuse_unsupported_egress(sandbox: &Sandbox) -> Result<()> { reason: format!( "AWS sandboxes reach the network through a VPC egress connector, which this \ module builds to deny outbound traffic; egress '{mode}' has no connector \ - configuration to render into. Declare egress: deny, or use a platform that \ - supports it" + configuration to render into. Declare egress: deny, or deploy to Azure, whose \ + egress proxy matches on host pattern" ), })) }; diff --git a/crates/alien-terraform/src/emitters/gcp/sandbox.rs b/crates/alien-terraform/src/emitters/gcp/sandbox.rs index 6ea186935..498887e30 100644 --- a/crates/alien-terraform/src/emitters/gcp/sandbox.rs +++ b/crates/alien-terraform/src/emitters/gcp/sandbox.rs @@ -11,9 +11,28 @@ use crate::{ emitters::gcp::helpers::{downcast, required_label}, expr, }; -use alien_core::{import::EmitContext, Result, Sandbox, SandboxEgress}; +use alien_core::{import::EmitContext, ErrorData, Result, Sandbox, SandboxEgress}; +use alien_error::AlienError; use hcl::expr::Expression; +/// Refuses an egress mode the launcher cannot deliver. +/// +/// `--allow-egress` is a switch, so a hostname list has nowhere to go and would otherwise be +/// carried as its nearest boolean — denying everything the declaration asked to permit, with +/// nothing anywhere saying so. +fn refuse_unsupported_egress(sandbox: &Sandbox) -> Result<()> { + match &sandbox.egress { + SandboxEgress::Deny | SandboxEgress::Allow => Ok(()), + SandboxEgress::AllowDomains { .. } => Err(AlienError::new(ErrorData::OperationNotSupported { + operation: format!("terraform emit sandbox '{}'", sandbox.id()), + reason: "the Cloud Run sandbox launcher takes a single egress switch, so a hostname \ + list has nothing to render into. Declare egress: deny, or deploy to Azure, \ + whose egress proxy matches on host pattern" + .to_string(), + })), + } +} + /// Where Cloud Run mounts the sandbox CLI inside a launcher-enabled container. const LAUNCHER_PATH: &str = "/usr/local/gcp/bin/sandbox"; @@ -30,6 +49,7 @@ impl TfEmitter for GcpSandboxEmitter { fn emit_import_ref(&self, ctx: &EmitContext<'_>) -> Result { let _ = required_label(ctx)?; let sandbox = downcast::(ctx, Sandbox::RESOURCE_TYPE)?; + refuse_unsupported_egress(sandbox)?; Ok(expr::object([ ( "launcherPath", @@ -45,6 +65,7 @@ impl TfEmitter for GcpSandboxEmitter { fn emit_binding_ref(&self, ctx: &EmitContext<'_>) -> Result> { let sandbox = downcast::(ctx, Sandbox::RESOURCE_TYPE)?; let _ = required_label(ctx)?; + refuse_unsupported_egress(sandbox)?; Ok(Some(expr::object([ ("service", Expression::String("sandbox-gcp".to_string())), ( From 874f5a7271e2b0ff55c8da399e8637d777ab67ba Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:21:05 +0300 Subject: [PATCH 08/32] fix(sandbox): fail an Azure create on a permission nobody asked for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The containment check compared the effective policy inward only — every host the declaration named had to be present — which is the right test pointed the wrong way. A sandbox that came up allowing a host nobody asked for passed it, and so did one whose `rules` list allowed everything, because this client never writes that list and so did not read it either. A group-scoped policy is a documented way for an entry nobody sent to appear. Both directions now: nothing may allow a host the declaration did not name, in either list, and an advanced rule that is not a `Deny` fails the create outright. Extra denials stay harmless, so a normalised response still cannot fail a create that was honoured. --- .../src/azure/sandbox_data_plane.rs | 56 ++++++++ .../src/providers/sandbox/azure.rs | 128 +++++++++++++++++- 2 files changed, 177 insertions(+), 7 deletions(-) diff --git a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs index 45316fa71..6e5eec3ea 100644 --- a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs +++ b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs @@ -55,12 +55,50 @@ pub struct EgressPolicy { /// Host patterns and what to do with them. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub host_rules: Vec, + /// Match-and-act rules, which this client never sends and has to read: a rule here can permit + /// what the host patterns denied, and a policy field nobody models is one nobody checks. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rules: Vec, /// `Full`, `Partial`, `Legacy` or `None`. Only `Full` blocks non-HTTP traffic, so only `Full` /// makes a `Deny` default mean no outbound access. #[serde(default, skip_serializing_if = "Option::is_none")] pub traffic_inspection: Option, } +/// A match-and-act rule, in the two parts containment turns on: what it matches, and what it does. +/// +/// The wire object also carries header transforms and URL rewrites. Neither is policy Alien can +/// express, and modelling them would only add fields to keep in step. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EgressRule { + /// What the rule matches. Absent means the data plane sent a rule this client cannot read, + /// which is treated as unknown rather than as matching nothing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub r#match: Option, + /// `Allow`, `Deny`, `Transform` or `Rewrite`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub action: Option, +} + +/// The host a rule matches. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EgressRuleMatch { + /// Host pattern the rule applies to. + #[serde(default)] + pub host: String, +} + +/// What a rule does when it matches. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EgressRuleAction { + /// `Allow`, `Deny`, `Transform` or `Rewrite`. + #[serde(rename = "type", default)] + pub action_type: String, +} + /// One host pattern and the action it carries. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -698,6 +736,7 @@ mod tests { pattern: "api.example.com".to_string(), action: "Allow".to_string(), }], + rules: Vec::new(), traffic_inspection: Some("Full".to_string()), }), }); @@ -727,4 +766,21 @@ mod tests { assert_eq!(sandbox.state.as_deref(), Some("Stopped")); } + + /// A rule this client does not send still has to be read back: an `Allow` here permits what + /// the host patterns denied, and a field nobody models is a field nobody checks. + #[test] + fn an_effective_policy_carries_the_rules_it_was_not_sent() { + let policy: EgressPolicy = serde_json::from_str( + r#"{"defaultAction":"Deny","trafficInspection":"Full", + "rules":[{"match":{"host":"*"},"action":{"type":"Allow"}}]}"#, + ) + .expect("deserializes"); + + assert_eq!(policy.rules.len(), 1); + assert_eq!( + policy.rules[0].action.as_ref().map(|action| action.action_type.as_str()), + Some("Allow") + ); + } } diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index ada3f9a61..bf89a3d20 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -16,7 +16,8 @@ use crate::traits::{ SandboxSession, SandboxSessionState, }; use alien_azure_clients::azure::sandbox_data_plane::{ - CreateSandbox, EgressHostRule, EgressPolicy, SandboxDataPlaneApi, + CreateSandbox, EgressHostRule, EgressPolicy, EgressRule, EgressRuleAction, EgressRuleMatch, + SandboxDataPlaneApi, }; use alien_client_core::ErrorData as ClientErrorData; use alien_core::{Platform, SandboxCapabilities, SandboxEgress}; @@ -137,6 +138,8 @@ impl Sandbox for AzureSandbox { // policy is deleted rather than handed back. if let Some(asked) = &asked { if !policy_holds(asked, sandbox.egress_policy.as_ref()) { + // Deleting is safe to do unconditionally here: Azure allocates the id, so the + // one in this response was minted by this call and belongs to no other caller. // The delete's own failure is carried rather than returned: it would replace the // finding that matters — that the sandbox is not contained — with a delete error. let deleted = match self.accept_delete(&sandbox.id).await { @@ -483,6 +486,7 @@ fn egress_policy(egress: &SandboxEgress) -> Option { let bounded = |host_rules| { Some(EgressPolicy { default_action: DENY.to_string(), + rules: Vec::new(), host_rules, traffic_inspection: Some(FULL_INSPECTION.to_string()), }) @@ -512,21 +516,44 @@ fn egress_policy(egress: &SandboxEgress) -> Option { /// Whether the sandbox is running the policy it was created with. /// -/// A subset check rather than equality: the data plane may return the policy normalised or carry -/// fields this client does not model, and failing every create over a reordered list would push -/// whoever hits it into removing the check. What is compared is what containment rests on — the -/// default action, the inspection mode, and every host the declaration named. +/// Not equality — the data plane may return the policy normalised, and failing every create over a +/// reordered list would push whoever hits it into removing the check. Not a subset either, which +/// is the same mistake pointing outward: a permission the sandbox holds and the declaration never +/// asked for is exactly what this is looking for. So both directions, on the two things that can +/// permit traffic: nothing may allow a host the declaration did not name, in either list. +/// +/// A group-scoped policy can add an entry nobody sent here, which is why the rules list is read at +/// all — it is never written. fn policy_holds(asked: &EgressPolicy, effective: Option<&EgressPolicy>) -> bool { let Some(effective) = effective else { return false; }; + let allowed = |host: &str| { + asked + .host_rules + .iter() + .any(|rule| rule.action == ALLOW && rule.pattern == host) + }; + effective.default_action == asked.default_action && effective.traffic_inspection.as_deref() == Some(FULL_INSPECTION) && asked .host_rules .iter() .all(|rule| effective.host_rules.contains(rule)) + && effective + .host_rules + .iter() + .all(|rule| rule.action != ALLOW || allowed(&rule.pattern)) + // An advanced rule is refused outright rather than matched host by host: this client + // never sends one, so an `Allow` here came from somewhere else, and `Transform` and + // `Rewrite` reach a host by rewriting the request rather than by naming it. + && effective.rules.iter().all(|rule| { + rule.action + .as_ref() + .is_some_and(|action| action.action_type == DENY) + }) } /// The effective policy, short enough to read in an error. @@ -534,10 +561,11 @@ fn describe(effective: Option<&EgressPolicy>) -> String { match effective { None => "no policy at all".to_string(), Some(policy) => format!( - "default action '{}' under {} inspection with {} host rules", + "default action '{}' under {} inspection, {} host rules and {} match rules", policy.default_action, policy.traffic_inspection.as_deref().unwrap_or("unstated"), - policy.host_rules.len() + policy.host_rules.len(), + policy.rules.len() ), } } @@ -1375,12 +1403,14 @@ mod tests { // The default action alone: every non-HTTP protocol still leaves. Some(EgressPolicy { default_action: "Deny".to_string(), + rules: Vec::new(), host_rules: Vec::new(), traffic_inspection: Some("Partial".to_string()), }), // Inspected, and open. Some(EgressPolicy { default_action: "Allow".to_string(), + rules: Vec::new(), host_rules: Vec::new(), traffic_inspection: Some("Full".to_string()), }), @@ -1417,6 +1447,7 @@ mod tests { "s1", Some(EgressPolicy { default_action: "Deny".to_string(), + rules: Vec::new(), host_rules: vec![EgressHostRule { pattern: "elsewhere.example.com".to_string(), action: "Allow".to_string(), @@ -1470,4 +1501,87 @@ mod tests { assert_eq!(session.session_id, "fresh"); } + + /// A permission the declaration never asked for fails the create as surely as a missing one. + /// + /// The check looks outward as well as inward: an `Allow` the sandbox holds and the caller did + /// not name is the whole failure this path exists to catch, and a group-scoped policy is a + /// documented way for one to appear. + #[tokio::test] + async fn a_permission_nobody_asked_for_fails_the_create() { + let asked_for = || SandboxEgress::AllowDomains { + domains: vec!["api.example.com".to_string()], + }; + let declared = EgressHostRule { + pattern: "api.example.com".to_string(), + action: "Allow".to_string(), + }; + + for came_up_with in [ + // A second host, allowed. + EgressPolicy { + default_action: "Deny".to_string(), + host_rules: vec![ + declared.clone(), + EgressHostRule { + pattern: "exfil.example.com".to_string(), + action: "Allow".to_string(), + }, + ], + rules: Vec::new(), + traffic_inspection: Some("Full".to_string()), + }, + // Everything, through the list this client never writes. + EgressPolicy { + default_action: "Deny".to_string(), + host_rules: vec![declared.clone()], + rules: vec![EgressRule { + r#match: Some(EgressRuleMatch { + host: "*".to_string(), + }), + action: Some(EgressRuleAction { + action_type: "Allow".to_string(), + }), + }], + traffic_inspection: Some("Full".to_string()), + }, + ] { + let mut client = MockSandboxDataPlaneApi::new(); + let effective = came_up_with.clone(); + client + .expect_create_sandbox() + .times(1) + .returning(move |_, _| Ok(running("s1", Some(effective.clone())))); + client.expect_delete_sandbox().times(1).returning(|_, _| Ok(())); + + let error = sandbox_denying(client, asked_for()) + .create(CreateSessionRequest::default()) + .await + .expect_err("a permission nobody asked for must fail the create"); + + assert_eq!(error.code, "BINDING_CONFIG_INVALID", "{error}"); + } + + // The same policy without the extra permission creates normally, so the rule above is + // refusing the addition rather than refusing everything. + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_create_sandbox().times(1).returning(move |_, _| { + Ok(running( + "s1", + Some(EgressPolicy { + default_action: "Deny".to_string(), + host_rules: vec![EgressHostRule { + pattern: "api.example.com".to_string(), + action: "Allow".to_string(), + }], + rules: Vec::new(), + traffic_inspection: Some("Full".to_string()), + }), + )) + }); + sandbox_denying(client, asked_for()) + .create(CreateSessionRequest::default()) + .await + .expect("the policy that was asked for should create"); + } } From f5c403f3a9c1d60179315a8eb775d046d23374b2 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:49:08 +0300 Subject: [PATCH 09/32] feat(sandbox): suspend, resume and auto-suspend an Azure sandbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `suspendResume` was false because the two verbs were unimplemented, not because Azure lacks them: `POST {sandbox}/stop` saves the state and `POST {sandbox}/resume` brings it back, both returning on acceptance. That is the contract the trait gets — a caller that needs the session stopped polls `get`, the same rule AWS follows. Flipping the flag drags a second thing with it. `idleSuspendSeconds` is gated on `suspendResume`, so the declaration becomes legal on Azure the moment the flag flips — and the create body never carried a lifecycle policy, so the number would have been accepted and dropped. It now travels in the binding and arrives as `lifecycle.autoSuspendPolicy`, suspending to memory, which is what makes the resume fast enough to be worth having. Three capabilities stay false, and each is now a recorded decision rather than an unbuilt feature: - `preview`: a sandbox port's auth is anonymous or Entra ID with an allowlist of human email addresses. Neither is a credential scoped to a port for a fixed time, and returning the anonymous URL would publish the port. - `snapshot`: the blocker is ours. `snapshot()` returns an id and `CreateSessionRequest` has nothing to consume one, so no backend can complete the round trip — and nothing in the resource model owns the artifact, which Microsoft says is never garbage collected. - `sessionLifetime`: Azure suspends on idle and deletes after a stop, but has no wall-clock ceiling. Accepting `maxLifetimeSeconds` would be the silent no-op the capability set exists to prevent. The comment those three replace claimed a per-port URL closed to anonymous traffic and a 0.54s resume. Neither has a source, and the first is the opposite of what the port model says. --- .../src/azure/sandbox_data_plane.rs | 103 ++++++++++++++++++ crates/alien-bindings/src/provider.rs | 1 + .../src/providers/sandbox/azure.rs | 97 ++++++++++++++++- crates/alien-core/src/bindings/sandbox.rs | 11 +- crates/alien-core/src/resources/sandbox.rs | 62 +++++++++-- .../src/emitters/azure/sandbox.rs | 48 ++++++-- 6 files changed, 301 insertions(+), 21 deletions(-) diff --git a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs index 6e5eec3ea..f0d26a8df 100644 --- a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs +++ b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs @@ -128,6 +128,9 @@ pub struct CreateSandbox { /// Outbound policy, applied from the moment the sandbox starts. Absent leaves the data /// plane's own default, which is open. pub egress: Option, + /// Idle seconds after which the sandbox suspends itself. Absent leaves the data plane's own + /// policy rather than asserting one. + pub idle_suspend_seconds: Option, } /// The create body. @@ -149,6 +152,14 @@ fn create_body(request: &CreateSandbox) -> serde_json::Value { body["egressPolicy"] = serde_json::json!(egress); } + // `Memory` rather than `Disk`: a memory suspend is what makes resume fast, and a sandbox that + // suspended to disk loses the process state a session exists to keep. + if let Some(seconds) = request.idle_suspend_seconds { + body["lifecycle"] = serde_json::json!({ + "autoSuspendPolicy": { "enabled": true, "interval": seconds, "mode": "Memory" } + }); + } + body } @@ -224,6 +235,12 @@ pub trait SandboxDataPlaneApi: Send + Sync + std::fmt::Debug { /// Creates a directory inside a sandbox. Idempotent, like `mkdir -p`. async fn mkdir(&self, group: &str, sandbox_id: &str, path: &str) -> Result<()>; + + /// Stops a sandbox, saving its state. Returns once accepted, not once stopped. + async fn stop_sandbox(&self, group: &str, sandbox_id: &str) -> Result<()>; + + /// Resumes a stopped sandbox. Returns once accepted, not once running. + async fn resume_sandbox(&self, group: &str, sandbox_id: &str) -> Result<()>; } /// The `executeShellCommand` body, which is `command` plus an optional `workingDirectory` and @@ -283,6 +300,28 @@ impl AzureSandboxDataPlaneClient { format!("{}/sandboxes/{sandbox_id}", self.group_path(group)) } + /// A bodyless POST that moves a sandbox between states. + async fn lifecycle_action( + &self, + group: &str, + sandbox_id: &str, + verb: &str, + operation: &str, + ) -> Result<()> { + let token = self.token_cache.get_bearer_token_with_scope(ADC_SCOPE).await?; + let url = self.base.build_url( + &format!("{}/{verb}", self.sandbox_path(group, sandbox_id)), + Some(vec![("api-version", API_VERSION.into())]), + ); + + let request = AzureRequestBuilder::new(Method::POST, url).build()?; + let signed = self.base.sign_request(request, &token).await?; + self.base + .execute_request(signed, operation, sandbox_id) + .await?; + Ok(()) + } + async fn parse( response: reqwest::Response, operation: &str, @@ -489,6 +528,16 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { Ok(()) } + async fn stop_sandbox(&self, group: &str, sandbox_id: &str) -> Result<()> { + self.lifecycle_action(group, sandbox_id, "stop", "StopSandbox") + .await + } + + async fn resume_sandbox(&self, group: &str, sandbox_id: &str) -> Result<()> { + self.lifecycle_action(group, sandbox_id, "resume", "ResumeSandbox") + .await + } + async fn mkdir(&self, group: &str, sandbox_id: &str, path: &str) -> Result<()> { let token = self.token_cache.get_bearer_token_with_scope(ADC_SCOPE).await?; let url = self.base.build_url( @@ -739,6 +788,7 @@ mod tests { rules: Vec::new(), traffic_inspection: Some("Full".to_string()), }), + idle_suspend_seconds: None, }); assert_eq!(body["environment"]["TOKEN"], "t"); @@ -755,6 +805,24 @@ mod tests { bare.get("environment").is_none(), "an empty map is no variables, not an empty object: {bare}" ); + assert!( + bare.get("lifecycle").is_none(), + "an undeclared idle policy leaves the service's own rather than asserting one: {bare}" + ); + } + + /// A declared idle suspend has to arrive as the nested policy the data plane reads, under + /// the mode that keeps the process state a session exists for. + #[test] + fn the_create_body_nests_the_idle_suspend_policy() { + let body = create_body(&CreateSandbox { + idle_suspend_seconds: Some(900), + ..CreateSandbox::default() + }); + + assert_eq!(body["lifecycle"]["autoSuspendPolicy"]["interval"], 900); + assert_eq!(body["lifecycle"]["autoSuspendPolicy"]["enabled"], true); + assert_eq!(body["lifecycle"]["autoSuspendPolicy"]["mode"], "Memory"); } /// The response field is `state`. Reading `status` leaves every sandbox deserializing to @@ -783,4 +851,39 @@ mod tests { Some("Allow") ); } + + /// The two lifecycle verbs, on the paths the SDK documents. + /// + /// Both are bodyless POSTs to sibling paths, so a swapped verb is a call that succeeds and + /// does the opposite of what was asked. + #[tokio::test] + async fn the_lifecycle_verbs_post_to_their_own_paths() { + let server = MockServer::start_async().await; + let client = client_against(&server); + let sandbox = format!( + "/subscriptions/{}/resourceGroups/rg/sandboxGroups/grp/sandboxes/s1", + AzureClientConfig::mock().subscription_id + ); + + let stop = server + .mock_async(|when, then| { + when.method(httpmock::Method::POST) + .path(format!("{sandbox}/stop")) + .query_param("api-version", API_VERSION); + then.status(202); + }) + .await; + client.stop_sandbox("grp", "s1").await.expect("stop is accepted"); + stop.assert_async().await; + + let resume = server + .mock_async(|when, then| { + when.method(httpmock::Method::POST) + .path(format!("{sandbox}/resume")); + then.status(202); + }) + .await; + client.resume_sandbox("grp", "s1").await.expect("resume is accepted"); + resume.assert_async().await; + } } diff --git a/crates/alien-bindings/src/provider.rs b/crates/alien-bindings/src/provider.rs index 8d537f143..be9277302 100644 --- a/crates/alien-bindings/src/provider.rs +++ b/crates/alien-bindings/src/provider.rs @@ -1909,6 +1909,7 @@ impl BindingsProviderApi for BindingsProvider { group, disk_image, azure_binding.egress, + azure_binding.idle_suspend_seconds, DEFAULT_AZURE_CPU.to_string(), DEFAULT_AZURE_MEMORY.to_string(), )); diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index bf89a3d20..d176bd6bb 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -32,6 +32,8 @@ pub struct AzureSandbox { disk_image: String, /// Outbound policy every session is created with, from the declaration. egress: SandboxEgress, + /// Idle seconds after which a session suspends itself, if the declaration asked for one. + idle_suspend_seconds: Option, /// Session ceilings, in the data plane's own units. cpu: String, memory: String, @@ -44,6 +46,7 @@ impl AzureSandbox { sandbox_group: String, disk_image: String, egress: SandboxEgress, + idle_suspend_seconds: Option, cpu: String, memory: String, ) -> Self { @@ -52,6 +55,7 @@ impl AzureSandbox { sandbox_group, disk_image, egress, + idle_suspend_seconds, cpu, memory, } @@ -123,6 +127,7 @@ impl Sandbox for AzureSandbox { memory: self.memory.clone(), environment: request.env, egress: asked.clone(), + idle_suspend_seconds: self.idle_suspend_seconds, }, ) .await @@ -314,12 +319,20 @@ impl Sandbox for AzureSandbox { Err(self.unsupported("preview")) } - async fn suspend(&self, _session_id: &str) -> Result<()> { - Err(self.unsupported("suspendResume")) + async fn suspend(&self, session_id: &str) -> Result<()> { + // Accepted, not completed — the same contract the AWS backend follows. A caller that + // needs the session to have stopped polls `get` for `Suspended`. + self.client + .stop_sandbox(&self.sandbox_group, session_id) + .await + .map_err(|error| Self::failed("sandbox.suspend", error)) } - async fn resume(&self, _session_id: &str) -> Result<()> { - Err(self.unsupported("suspendResume")) + async fn resume(&self, session_id: &str) -> Result<()> { + self.client + .resume_sandbox(&self.sandbox_group, session_id) + .await + .map_err(|error| Self::failed("sandbox.resume", error)) } async fn snapshot(&self, _session_id: &str) -> Result { @@ -667,6 +680,7 @@ mod tests { "grp".to_string(), "ubuntu".to_string(), SandboxEgress::Allow, + None, "1000m".to_string(), "2048Mi".to_string(), ) @@ -697,6 +711,7 @@ mod tests { "grp".to_string(), "my-toolchain".to_string(), SandboxEgress::Allow, + None, "1000m".to_string(), "2048Mi".to_string(), ); @@ -817,6 +832,18 @@ mod tests { #[async_trait] impl SandboxDataPlaneApi for ScriptedExec { + async fn stop_sandbox(&self, _group: &str, _sandbox_id: &str) -> alien_client_core::Result<()> { + unreachable!("the command paths never suspend") + } + + async fn resume_sandbox( + &self, + _group: &str, + _sandbox_id: &str, + ) -> alien_client_core::Result<()> { + unreachable!("the command paths never resume") + } + async fn read_file( &self, _group: &str, @@ -926,6 +953,7 @@ mod tests { "grp".to_string(), "ubuntu".to_string(), SandboxEgress::Allow, + None, "1000m".to_string(), "2048Mi".to_string(), ) @@ -1309,6 +1337,7 @@ mod tests { "grp".to_string(), "ubuntu".to_string(), egress, + None, "1000m".to_string(), "2048Mi".to_string(), ) @@ -1584,4 +1613,64 @@ mod tests { .await .expect("the policy that was asked for should create"); } + + /// Suspend and resume are one call each, and each has to reach the verb it names. + /// + /// Returning on acceptance rather than on the state change is the same contract AWS follows, + /// so a caller that needs the session stopped polls `get` — the alternative is a call that + /// blocks for a resume Microsoft describes as sub-second and a stop that is not. + #[tokio::test] + async fn suspend_and_resume_reach_their_own_verbs() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_stop_sandbox() + .withf(|group, id| group == "grp" && id == "s1") + .times(1) + .returning(|_, _| Ok(())); + client.expect_resume_sandbox().never(); + sandbox_with(client) + .suspend("s1") + .await + .expect("suspend should be accepted"); + + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_resume_sandbox() + .withf(|group, id| group == "grp" && id == "s1") + .times(1) + .returning(|_, _| Ok(())); + client.expect_stop_sandbox().never(); + sandbox_with(client) + .resume("s1") + .await + .expect("resume should be accepted"); + } + + /// A declared idle-suspend policy has to reach the create body. + /// + /// The data plane takes it at create and nowhere else, and accepts a body without it — so a + /// declaration that stops at the binding leaves the sandbox on whatever the service defaults + /// to, with nothing anywhere saying the number was ignored. + #[tokio::test] + async fn a_declared_idle_suspend_reaches_the_create_call() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_create_sandbox() + .withf(|_, request| request.idle_suspend_seconds == Some(900)) + .times(1) + .returning(|_, _| Ok(running("s1", None))); + + AzureSandbox::new( + std::sync::Arc::new(client), + "grp".to_string(), + "ubuntu".to_string(), + SandboxEgress::Allow, + Some(900), + "1000m".to_string(), + "2048Mi".to_string(), + ) + .create(CreateSessionRequest::default()) + .await + .expect("the create should succeed"); + } } diff --git a/crates/alien-core/src/bindings/sandbox.rs b/crates/alien-core/src/bindings/sandbox.rs index bf1ac1a28..209cfd69b 100644 --- a/crates/alien-core/src/bindings/sandbox.rs +++ b/crates/alien-core/src/bindings/sandbox.rs @@ -98,6 +98,12 @@ pub struct AzureSandboxBinding { /// session created without a policy is an open one, and a hostname list has no boolean to /// travel in. pub egress: SandboxEgress, + /// Idle seconds after which a session suspends, if the declaration asked for one. + /// + /// Carried because the data plane takes it at create and nowhere else: a policy that does not + /// travel with the create body is a declaration the sandbox never hears about. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idle_suspend_seconds: Option, /// Catalog disk image every session is created from, taken from the declaration's `code`. /// /// Carried rather than hardcoded in the provider because the declaration is the only place @@ -183,6 +189,7 @@ impl SandboxBinding { resource_group: impl Into>, disk_image: impl Into>, egress: SandboxEgress, + idle_suspend_seconds: Option, ) -> Self { Self::Azure(AzureSandboxBinding { sandbox_group: sandbox_group.into(), @@ -190,6 +197,7 @@ impl SandboxBinding { region: region.into(), resource_group: resource_group.into(), egress, + idle_suspend_seconds, disk_image: disk_image.into(), }) } @@ -258,6 +266,7 @@ mod tests { "rg", "ubuntu", SandboxEgress::Deny, + None, ), SandboxBinding::gcp("/usr/local/gcp/bin/sandbox", false), SandboxBinding::kubernetes( @@ -286,7 +295,7 @@ mod tests { fn service_tags_are_prefixed_and_distinct() { let tags: Vec = vec![ SandboxBinding::aws("a", "1", "r"), - SandboxBinding::azure("g", "e", "r", "rg", "ubuntu", SandboxEgress::Deny), + SandboxBinding::azure("g", "e", "r", "rg", "ubuntu", SandboxEgress::Deny, None), SandboxBinding::gcp("p", true), SandboxBinding::kubernetes("n", "gvisor", "s", "http://op:8080", "k", "/t"), SandboxBinding::local("u", "k", "t"), diff --git a/crates/alien-core/src/resources/sandbox.rs b/crates/alien-core/src/resources/sandbox.rs index 2cddb3f1f..137c8170b 100644 --- a/crates/alien-core/src/resources/sandbox.rs +++ b/crates/alien-core/src/resources/sandbox.rs @@ -226,20 +226,28 @@ impl SandboxCapabilities { // it cannot create a namespace. No backend offers this today. supervisor_pid_namespace: false, }), - // Azure the platform has all three — a per-port URL closed to anonymous traffic, a - // 0.54s resume, and a full-VM snapshot — and the binding provider implements none of - // them. The capability set describes what a caller can reach, not what the cloud - // could do, so these stay false until the provider catches up. Platform::Azure => Ok(Self { files: true, reconnect: true, + // A sandbox port carries a URL and an auth config, and the auth config offers two + // things: anonymous, or Entra ID with an allowlist of human email addresses. + // Neither is a credential scoped to a port for a fixed time, which is what a + // preview capability is. Returning the anonymous URL would publish the port. preview: false, - suspend_resume: false, + suspend_resume: true, + // The one cloud of the five that could offer this, and the blocker is ours: + // `snapshot()` returns an id and `CreateSessionRequest` has no field to consume + // one, so no backend can complete the round trip. Nothing in the resource model + // owns such an artifact either, and Microsoft states snapshots are not garbage + // collected — an id with no owner is a bill that grows. snapshot: false, domain_egress_rules: true, egress_deny: true, enforced_limits: false, process_limit: false, + // Auto-suspend and auto-delete exist; a wall-clock ceiling does not. Accepting + // `maxLifetimeSeconds` here would be the silent no-op the capability set exists + // to prevent, so this is a decision rather than a gap. session_lifetime: false, // No Alien process inside an Azure sandbox, so there is no supervisor to isolate. supervisor_pid_namespace: false, @@ -872,11 +880,12 @@ mod tests { // The data plane takes no ceiling, so a declaration of one is refused rather than // accepted and dropped. assert!(!azure.enforced_limits); - // Azure the cloud has snapshot, preview and resume; the binding provider returns - // unsupported for all three. What a caller can reach is what the set describes. + assert!(azure.suspend_resume); + // Both stay false for reasons that are not "unbuilt": a snapshot id has nothing to + // consume it on any backend, and an Azure port's auth is anonymous or a human allowlist, + // neither of which is a port-scoped credential. assert!(!azure.snapshot); assert!(!azure.preview); - assert!(!azure.suspend_resume); let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported"); assert!(!aws.snapshot, "AWS has no user-callable session snapshot"); @@ -1337,4 +1346,41 @@ mod tests { .validate_update(&renamed) .expect_err("renaming a sandbox is not an update"); } + + /// An idle-suspend policy is now declarable on Azure, and a wall-clock ceiling still is not. + /// + /// The two travel together in `SandboxSessionPolicy` and are gated separately on purpose: + /// Azure suspends on idle and has no maximum lifetime, so accepting one and refusing the + /// other is the honest split rather than an inconsistency. + #[test] + fn azure_takes_an_idle_policy_and_still_refuses_a_lifetime_ceiling() { + let with_policy = |session: SandboxSessionPolicy| { + Sandbox::new("sbx".to_string()) + .code(SandboxCode::Image { + image: "ubuntu".to_string(), + }) + .egress(SandboxEgress::Allow) + .session(session) + .build() + .validate_for_platform(Platform::Azure) + }; + + with_policy(SandboxSessionPolicy { + max_lifetime_seconds: None, + idle_suspend_seconds: Some(900), + }) + .expect("Azure suspends a session on idle"); + + let error = with_policy(SandboxSessionPolicy { + max_lifetime_seconds: Some(3600), + idle_suspend_seconds: None, + }) + .expect_err("Azure has no wall-clock ceiling to enforce one with"); + assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED"); + assert!( + error.message.contains("sessionLifetime"), + "names the capability: {}", + error.message + ); + } } diff --git a/crates/alien-terraform/src/emitters/azure/sandbox.rs b/crates/alien-terraform/src/emitters/azure/sandbox.rs index 138ca98b7..7e98a9500 100644 --- a/crates/alien-terraform/src/emitters/azure/sandbox.rs +++ b/crates/alien-terraform/src/emitters/azure/sandbox.rs @@ -1,10 +1,13 @@ //! Azure Sandbox — a named group, and nothing built at setup. //! -//! The ACA sandbox group is created by the runtime controller, idempotently by name, because a -//! group is cheap to create and pointless to hold open while no session wants one. So setup emits -//! no Azure resource here; what it owes the runtime is the three names the data plane is addressed -//! by, which the Azure client config does not carry: the group, the region that selects the -//! per-region endpoint, and the resource group the data-plane path is scoped by. +//! The ACA sandbox group is created at runtime, idempotently by name, because a group is cheap to +//! create and pointless to hold open while no session wants one. So setup emits no Azure resource +//! here; what it owes the runtime is the three names the data plane is addressed by, which the +//! Azure client config does not carry: the group, the region that selects the per-region endpoint, +//! and the resource group the data-plane path is scoped by. +//! +//! Nothing in this repository creates that group: `create_or_update_sandbox_group` has no caller, +//! and the controller registry holds only the Local and Kubernetes sandbox controllers. use crate::{ emitter::{TfEmitter, TfFragment}, @@ -102,7 +105,7 @@ impl TfEmitter for AzureSandboxEmitter { let sandbox = downcast::(ctx, Sandbox::RESOURCE_TYPE)?; let _ = required_label(ctx)?; let disk_image = catalog_disk_image(sandbox)?; - Ok(Some(expr::object([ + let mut fields = vec![ ("service", Expression::String("sandbox-azure".to_string())), ("sandboxGroup", sandbox_group(ctx)), // The data plane is a per-region host, so the region is what selects it rather than a @@ -115,7 +118,16 @@ impl TfEmitter for AzureSandboxEmitter { ("resourceGroup", expr::raw("var.azure_resource_group_name")), ("diskImage", Expression::String(disk_image)), ("egress", egress(sandbox)), - ]))) + ]; + + if let Some(seconds) = sandbox.session.idle_suspend_seconds { + fields.push(( + "idleSuspendSeconds", + Expression::Number(i64::from(seconds).into()), + )); + } + + Ok(Some(expr::object(fields))) } } @@ -126,6 +138,10 @@ mod tests { use indexmap::IndexMap; fn binding_for(egress: SandboxEgress) -> String { + binding_with(egress, None) + } + + fn binding_with(egress: SandboxEgress, idle_suspend_seconds: Option) -> String { let stack = Stack::new("acme".to_string()) .add( Sandbox::new("agents".to_string()) @@ -135,7 +151,7 @@ mod tests { .egress(egress) .session(SandboxSessionPolicy { max_lifetime_seconds: None, - idle_suspend_seconds: None, + idle_suspend_seconds, }) .build(), ResourceLifecycle::Frozen, @@ -181,4 +197,20 @@ mod tests { let open = binding_for(SandboxEgress::Allow); assert!(open.contains(r#""allow""#), "{open}"); } + + /// The idle-suspend policy travels the same way, and only when it was declared. + /// + /// Azure takes it at create, so a number that stops at the emitter leaves the session on the + /// service default — and an emitted zero would be a policy nobody asked for. + #[test] + fn the_binding_carries_a_declared_idle_suspend_and_nothing_otherwise() { + let declared = binding_with(SandboxEgress::Allow, Some(900)); + assert!(declared.contains("900"), "{declared}"); + + let undeclared = binding_with(SandboxEgress::Allow, None); + assert!( + !undeclared.contains("idleSuspendSeconds"), + "{undeclared}" + ); + } } From 07254cd0b749ecfae4c08bffe56200d0aae3e195 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:54:03 +0300 Subject: [PATCH 10/32] fix(sandbox): read the state Azure's own auto-suspend produces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The state mapping refused `Idle`, and `Idle` is what the SDK's stop poller waits for. The package contradicts itself — it declares `Idle` as a reason a sandbox stopped and then waits for a *state* of `Idle` — and an unrecognised state is an error here on purpose, so the one state the idle policy is most likely to produce would have failed every `get` on the sessions that policy governs. It reads as suspended, which is true under either reading. Also: `autoSuspendPolicy` mode is the SDK's own default rather than a claim about what `Disk` does, which is documented nowhere. --- .../src/azure/sandbox_data_plane.rs | 4 ++-- crates/alien-bindings/src/providers/sandbox/azure.rs | 11 +++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs index f0d26a8df..de1b69e57 100644 --- a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs +++ b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs @@ -152,8 +152,8 @@ fn create_body(request: &CreateSandbox) -> serde_json::Value { body["egressPolicy"] = serde_json::json!(egress); } - // `Memory` rather than `Disk`: a memory suspend is what makes resume fast, and a sandbox that - // suspended to disk loses the process state a session exists to keep. + // `Memory` is the SDK's own default for `auto_suspend_mode`, and the mode a session wants: + // what `Disk` does differently is not documented, so the default stands rather than a guess. if let Some(seconds) = request.idle_suspend_seconds { body["lifecycle"] = serde_json::json!({ "autoSuspendPolicy": { "enabled": true, "interval": seconds, "mode": "Memory" } diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index d176bd6bb..bc959fbe4 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -321,7 +321,9 @@ impl Sandbox for AzureSandbox { async fn suspend(&self, session_id: &str) -> Result<()> { // Accepted, not completed — the same contract the AWS backend follows. A caller that - // needs the session to have stopped polls `get` for `Suspended`. + // needs the session to have stopped polls `get` for `Suspended`. Suspending an + // already-stopped session, which a caller racing the idle policy cannot avoid, answers + // 409 and is reported retryable rather than as a refusal. self.client .stop_sandbox(&self.sandbox_group, session_id) .await @@ -592,7 +594,11 @@ fn session_state(operation: &str, state: Option<&str>) -> Result Ok(SandboxSessionState::Running), Some("Creating" | "Resuming") => Ok(SandboxSessionState::Starting), - Some("Stopping" | "Stopped" | "Suspended") => Ok(SandboxSessionState::Suspended), + // `Idle` is where the SDK contradicts itself: it declares `Idle` as a reason a sandbox + // stopped, and then waits for a *state* of `Idle` after a stop. Accepted as suspended + // either way — the alternative is that the state auto-suspend produces is the one state + // this refuses to read. + Some("Stopping" | "Stopped" | "Suspended" | "Idle") => Ok(SandboxSessionState::Suspended), Some("Deleting") => Ok(SandboxSessionState::Terminated), other => Err(AlienError::new(ErrorData::UnexpectedResponseFormat { provider: "azure".to_string(), @@ -1244,6 +1250,7 @@ mod tests { ("Stopping", SandboxSessionState::Suspended), ("Stopped", SandboxSessionState::Suspended), ("Suspended", SandboxSessionState::Suspended), + ("Idle", SandboxSessionState::Suspended), ("Deleting", SandboxSessionState::Terminated), ] { let mut client = MockSandboxDataPlaneApi::new(); From f98f1b60a36094d9082031086848b81ce32028ae Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:05:22 +0300 Subject: [PATCH 11/32] fix(sandbox): close what the pre-push review found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six things this branch got wrong, and the review caught each one. The create body carries the caller's environment variables and a write carries file bytes, and a failure echoes the request into an error chain that is serialized into durable state. Both now go through `redact_request_body`, which every other create-with-a-secret already used. `create` owes the caller a session that can take work — the trait says a backend whose start API returns early waits here — and it was handing back one that could not. It waits now. Every failure after the sandbox exists deletes it through one path: a `?` on an unreadable state was abandoning a running sandbox that nothing could find, since Azure mints the id, has no enumeration verb and sets no auto-delete. The egress check guarded `create` alone, so a reconnect returned whatever a session was built with. Azure has no session ceiling and an idle sandbox only suspends, so one created under an older declaration outlives the change and was being handed back under the label the stack has now. `get` checks too. `policy_holds` had three holes: it compared case-sensitively where the data plane normalises, it passed any host action that was not exactly `Allow` — `Transform` and `Rewrite` reach a host by rewriting the request — and it could not see a policy field this client does not model. It is now a whitelist in both directions, and an unreadable policy fails the create. The refusal named an env var that does not exist and flattened the delete's own error through an `internal = false` boundary, publishing raw service text. It is a typed `SandboxNotAsDeclared` carrying the session id — the one thing an operator needs when the delete also failed. `catalog_disk_image` rejected a registry path but not a tag, so `ubuntu:24.04` rendered into a customer's module, planned, applied, and failed at the first session. Smaller, same review: `Stopping` is not stopped, so it no longer answers the poll `suspend` documents; `terminate` polls the client directly rather than dying on a state it cannot parse; an absolute path means "under the session root" here as everywhere else; `create` is not idempotent and no longer claims to be; an `allowDomains` naming no domain is refused at plan time; Helm refuses in the function that renders rather than one upstream; and the four egress refusals stopped pointing customers at a platform whose sandbox group nothing in this repository creates. The captured launcher fixture carries the launcher's own output, and a recipe for re-capturing it that needs nothing but the container it came from. The generated schemas and the TypeScript doc still described Azure as having no file transfer and no backend as matching hostnames. Regenerated with `pnpm -C packages/core run generate`, and the hand-written paragraph corrected. --- .../src/azure/sandbox_data_plane.rs | 40 +- crates/alien-bindings/src/error.rs | 22 + .../src/providers/sandbox/azure.rs | 488 ++++++++++++++---- .../src/providers/sandbox/gcp.rs | 11 +- .../src/emitters/aws/sandbox.rs | 4 +- crates/alien-core/src/resources/sandbox.rs | 43 ++ crates/alien-helm/src/emitters/sandbox.rs | 35 +- .../tests/generator/resource_layer_tests.rs | 5 +- .../src/emitters/aws/sandbox.rs | 4 +- .../src/emitters/azure/sandbox.rs | 46 +- .../src/emitters/gcp/sandbox.rs | 3 +- .../core/src/generated/schemas/sandbox.json | 2 +- .../schemas/sandboxCapabilities.json | 2 +- .../src/generated/schemas/sandboxEgress.json | 2 +- .../zod/sandbox-capabilities-schema.ts | 2 +- packages/core/src/sandbox.ts | 4 +- 16 files changed, 555 insertions(+), 158 deletions(-) diff --git a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs index de1b69e57..565a3fa37 100644 --- a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs +++ b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs @@ -12,8 +12,8 @@ use crate::azure::common::{AzureClientBase, AzureRequestBuilder}; use crate::azure::token_cache::AzureTokenCache; use alien_client_core::{ErrorData, Result}; -use std::collections::BTreeMap; use alien_error::{Context, IntoAlienError}; +use std::collections::BTreeMap; use async_trait::async_trait; use reqwest::Method; use serde::{Deserialize, Serialize}; @@ -63,6 +63,13 @@ pub struct EgressPolicy { /// makes a `Deny` default mean no outbound access. #[serde(default, skip_serializing_if = "Option::is_none")] pub traffic_inspection: Option, + /// Anything else the policy carries. + /// + /// Kept rather than dropped because this is a preview API whose surface Microsoft says may + /// change: a field that permits traffic and deserializes into nothing is one no containment + /// check can weigh, and silence is the wrong answer for a policy nobody can read whole. + #[serde(flatten)] + pub unmodelled: BTreeMap, } /// A match-and-act rule, in the two parts containment turns on: what it matches, and what it does. @@ -375,10 +382,11 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { .build()?; let signed = self.base.sign_request(request, &token).await?; - let response = self - .base - .execute_request(signed, "CreateSandbox", group) - .await?; + // The create body carries the caller's environment variables, and a failure echoes the + // request into the error chain, which is serialized into durable state. + let response = alien_client_core::redact_request_body( + self.base.execute_request(signed, "CreateSandbox", group).await, + )?; Self::parse(response, "CreateSandbox").await } @@ -524,7 +532,12 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { .body_bytes(contents) .build()?; let signed = self.base.sign_request(request, &token).await?; - self.base.execute_request(signed, "WriteFile", sandbox_id).await?; + // The body is the file the caller asked to write. + alien_client_core::redact_request_body( + self.base + .execute_request(signed, "WriteFile", sandbox_id) + .await, + )?; Ok(()) } @@ -709,9 +722,21 @@ mod tests { /// invalid sequence and hand back a different file than the sandbox holds. #[tokio::test] async fn a_file_that_is_not_text_survives_both_directions() { + let bytes = BINARY.to_vec(); + let server = MockServer::start_async().await; let client = client_against(&server); - let bytes = BINARY.to_vec(); + let written = server + .mock_async(|when, then| { + when.method(httpmock::Method::PUT).matches(carries_binary); + then.status(200); + }) + .await; + client + .write_file("grp", "s1", "image.png", bytes.clone()) + .await + .expect("the write should succeed"); + written.assert_async().await; let server = MockServer::start_async().await; let client = client_against(&server); @@ -781,6 +806,7 @@ mod tests { environment: BTreeMap::from([("TOKEN".to_string(), "t".to_string())]), egress: Some(EgressPolicy { default_action: "Deny".to_string(), + unmodelled: Default::default(), host_rules: vec![EgressHostRule { pattern: "api.example.com".to_string(), action: "Allow".to_string(), diff --git a/crates/alien-bindings/src/error.rs b/crates/alien-bindings/src/error.rs index ef4179686..b66cf65c2 100644 --- a/crates/alien-bindings/src/error.rs +++ b/crates/alien-bindings/src/error.rs @@ -315,6 +315,28 @@ pub enum ErrorData { reason: String, }, + /// A session came up without a restriction its declaration asked for. + /// + /// Distinct from a refused call: the data plane accepted the request and answered, and what + /// it built is not what was asked for. The session id is carried because the caller never + /// receives one — this is the failure where an operator has to be able to find what was left + /// behind if deleting it also failed. + #[error( + code = "SANDBOX_NOT_AS_DECLARED", + message = "Sandbox session '{session_id}' came up without its declared {restriction}: {reason}", + retryable = "false", + internal = "false", + http_status_code = 502 + )] + SandboxNotAsDeclared { + /// Provider-scoped id of the session that was built + session_id: String, + /// What the declaration asked for, such as `egress policy` + restriction: String, + /// What the session came up with instead + reason: String, + }, + /// The sandbox agent could not be reached, or the connection dropped mid-response. /// /// Visibility is inherited rather than declared public: what this wraps is often the cloud diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index bc959fbe4..0b12b5bb0 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -22,6 +22,7 @@ use alien_azure_clients::azure::sandbox_data_plane::{ use alien_client_core::ErrorData as ClientErrorData; use alien_core::{Platform, SandboxCapabilities, SandboxEgress}; use alien_error::{AlienError, ContextError}; +use tracing::warn; /// A Sandbox backed by the Azure ADC data plane. #[derive(Debug)] @@ -90,12 +91,12 @@ impl AzureSandbox { }); } - if operation == RUN_COMMAND { + if operation == RUN_COMMAND || operation == CREATE { return error.context(ErrorData::SandboxCommandFailed { failure: "outcomeUnknown".to_string(), reason: format!( "{operation} did not complete against the Azure sandbox data plane, so \ - whether the command ran is unknown" + whether it took effect is unknown" ), }); } @@ -131,73 +132,75 @@ impl Sandbox for AzureSandbox { }, ) .await - .map_err(|error| Self::failed("sandbox.create", error))?; + .map_err(|error| Self::failed(CREATE, error))?; // The caller's requested id is not authoritative: Azure allocates the id, and returning // the requested one would hand back a handle that addresses nothing. let _ = request.session_id; - // A restriction that did not take effect is worse than one that was never asked for: the - // caller believes the sandbox is contained. The response says what the sandbox is running - // under, so this is checked rather than assumed, and a sandbox that came up without the - // policy is deleted rather than handed back. - if let Some(asked) = &asked { - if !policy_holds(asked, sandbox.egress_policy.as_ref()) { - // Deleting is safe to do unconditionally here: Azure allocates the id, so the - // one in this response was minted by this call and belongs to no other caller. - // The delete's own failure is carried rather than returned: it would replace the - // finding that matters — that the sandbox is not contained — with a delete error. - let deleted = match self.accept_delete(&sandbox.id).await { - Ok(()) => "it was deleted".to_string(), - Err(error) => format!("deleting it also failed: {error}"), - }; - return Err(AlienError::new(ErrorData::BindingConfigInvalid { - binding_name: "sandbox".to_string(), - env_var: "ALIEN_BINDING_SANDBOX".to_string(), - reason: format!( - "the sandbox was created asking for {} but came up with {}, so {deleted} \ - rather than handed back", - describe(Some(asked)), - describe(sandbox.egress_policy.as_ref()) - ), - })); - } + // Everything past this point owns a sandbox the caller has no id for, so every failure + // deletes it. Azure allocates the id, so the one in this response was minted by this call. + match self.settle(&sandbox, asked.as_ref()).await { + Ok(session) => Ok(session), + Err(error) => Err(self.discard(&sandbox.id, error).await), } - - Ok(SandboxSession { - session_id: sandbox.id, - state: session_state("sandbox.create", sandbox.state.as_deref())?, - generation: 1, - }) } async fn get(&self, session_id: &str) -> Result> { - match self + let sandbox = match self .client .get_sandbox(&self.sandbox_group, session_id) .await { - Ok(sandbox) => Ok(Some(SandboxSession { - session_id: sandbox.id, - state: session_state("sandbox.get", sandbox.state.as_deref())?, - generation: 1, - })), + Ok(sandbox) => sandbox, // A 404 is "gone", which is a valid answer. Anything else is a real failure and must // not be flattened into None, or a throttle would read as an expired session. - Err(error) if is_not_found(&error) => Ok(None), - Err(error) => Err(Self::failed("sandbox.get", error)), + Err(error) if is_not_found(&error) => return Ok(None), + Err(error) => return Err(Self::failed("sandbox.get", error)), + }; + + // Checked here as well as at create, because this is the path a reconnect takes: a + // session created under an older declaration outlives the change — Azure has no session + // ceiling, and an idle sandbox only suspends — so a caller holding its id would otherwise + // be handed a sandbox whose containment is whatever it was built with. + if let Some(asked) = egress_policy(&self.egress) { + if !policy_holds(&asked, sandbox.egress_policy.as_ref()) { + return Err(AlienError::new(ErrorData::SandboxNotAsDeclared { + session_id: sandbox.id, + restriction: "egress policy".to_string(), + reason: format!( + "it is running {} where the declaration asks for {}", + describe(sandbox.egress_policy.as_ref()), + describe(Some(&asked)) + ), + })); + } } + + Ok(Some(SandboxSession { + session_id: sandbox.id, + state: session_state("sandbox.get", sandbox.state.as_deref())?, + generation: 1, + })) } async fn get_or_create(&self, request: CreateSessionRequest) -> Result { if let Some(id) = request.session_id.as_deref() { // A session on its way out is not one to reconnect to: the id will not run again, and // handing it back trades an error now for a command that never lands. - match self.get(id).await? { - Some(existing) if existing.state != SandboxSessionState::Terminated => { - return Ok(existing) + if let Some(existing) = self.get(id).await? { + match existing.state { + SandboxSessionState::Terminated => {} + // `create` returns a session that can take work, and reaching one someone + // else started has to mean the same thing — an idle sandbox suspends itself, + // so this is the ordinary resting state rather than an edge. + SandboxSessionState::Suspended => { + self.resume(id).await?; + return self.await_running(id).await; + } + SandboxSessionState::Starting => return self.await_running(id).await, + SandboxSessionState::Running => return Ok(existing), } - _ => {} } } @@ -283,7 +286,7 @@ impl Sandbox for AzureSandbox { } async fn read_file(&self, session_id: &str, path: &str) -> Result> { - checked_path("sandbox.readFile", path)?; + let path = &checked_path("sandbox.readFile", path)?; self.client .read_file(&self.sandbox_group, session_id, path) @@ -292,10 +295,16 @@ impl Sandbox for AzureSandbox { } async fn write_files(&self, session_id: &str, files: BTreeMap>) -> Result<()> { + // Checked before anything is written: partial application is the contract for a data + // plane that refuses midway, not for a path this process could have rejected first. + let files = files + .into_iter() + .map(|(path, contents)| Ok((checked_path("sandbox.writeFiles", &path)?, contents))) + .collect::>>()?; + // One request per path, stopping at the first failure: the same partial application every // other backend performs, so a caller sees one contract rather than five. for (path, contents) in files { - checked_path("sandbox.writeFiles", &path)?; self.client .write_file(&self.sandbox_group, session_id, &path, contents) @@ -307,7 +316,7 @@ impl Sandbox for AzureSandbox { } async fn mkdir(&self, session_id: &str, path: &str) -> Result<()> { - checked_path("sandbox.mkdir", path)?; + let path = &checked_path("sandbox.mkdir", path)?; self.client .mkdir(&self.sandbox_group, session_id, path) @@ -321,9 +330,7 @@ impl Sandbox for AzureSandbox { async fn suspend(&self, session_id: &str) -> Result<()> { // Accepted, not completed — the same contract the AWS backend follows. A caller that - // needs the session to have stopped polls `get` for `Suspended`. Suspending an - // already-stopped session, which a caller racing the idle policy cannot avoid, answers - // 409 and is reported retryable rather than as a refusal. + // needs the session to have stopped polls `get` for `Suspended`. self.client .stop_sandbox(&self.sandbox_group, session_id) .await @@ -347,9 +354,19 @@ impl Sandbox for AzureSandbox { // The delete is accepted, not completed: the client's own contract is "returns before it // is gone; confirm by polling to 404". Returning here would report containment while the // code is still running, which is the whole point of terminate. + // The client rather than `get`: teardown needs the 404 and nothing else, and reading a + // state it cannot parse would abort the poll for a session that is already going away — + // replacing a `deadlineExceeded` finding with a deserialization error on the one path + // where untrusted code is known to be running past its deadline. for _ in 0..TERMINATE_POLL_ATTEMPTS { - if self.get(session_id).await?.is_none() { - return Ok(()); + match self + .client + .get_sandbox(&self.sandbox_group, session_id) + .await + { + Err(error) if is_not_found(&error) => return Ok(()), + Err(error) => return Err(Self::failed("sandbox.terminate", error)), + Ok(_) => {} } tokio::time::sleep(TERMINATE_POLL_INTERVAL).await; } @@ -369,6 +386,104 @@ impl Sandbox for AzureSandbox { } impl AzureSandbox { + /// Turns a freshly created sandbox into a session, or says why it is not one. + /// + /// Every check that can fail after the sandbox exists lives here, so `create` has one place + /// to delete from rather than a delete beside each `?`. + async fn settle( + &self, + sandbox: &alien_azure_clients::azure::sandbox_data_plane::Sandbox, + asked: Option<&EgressPolicy>, + ) -> Result { + // A restriction that did not take effect is worse than one that was never asked for: the + // caller believes the sandbox is contained. The response says what the sandbox is running + // under, so this is checked rather than assumed. + if let Some(asked) = asked { + if !policy_holds(asked, sandbox.egress_policy.as_ref()) { + return Err(AlienError::new(ErrorData::SandboxNotAsDeclared { + session_id: sandbox.id.clone(), + restriction: "egress policy".to_string(), + reason: format!( + "it came up with {} where the declaration asks for {}", + describe(sandbox.egress_policy.as_ref()), + describe(Some(asked)) + ), + })); + } + } + + match session_state(CREATE, sandbox.state.as_deref())? { + SandboxSessionState::Running => Ok(SandboxSession { + session_id: sandbox.id.clone(), + state: SandboxSessionState::Running, + generation: 1, + }), + // `create` owes the caller a session that can already take work, so the wait happens + // here rather than in every caller. + _ => self.await_running(&sandbox.id).await, + } + } + + /// Waits for a session to be able to take work. + async fn await_running(&self, session_id: &str) -> Result { + let deadline = std::time::Instant::now() + SESSION_READY_TIMEOUT; + + loop { + let sandbox = self + .client + .get_sandbox(&self.sandbox_group, session_id) + .await + .map_err(|error| Self::failed("sandbox.create", error))?; + + match session_state("sandbox.create", sandbox.state.as_deref())? { + SandboxSessionState::Running => { + return Ok(SandboxSession { + session_id: sandbox.id, + state: SandboxSessionState::Running, + generation: 1, + }) + } + // Only a session on its way up is worth waiting for. A terminated one never + // becomes runnable, and folding it into the timeout would report it a minute late + // as a slow boot. + SandboxSessionState::Starting | SandboxSessionState::Suspended => {} + SandboxSessionState::Terminated => { + return Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: "sessionTerminated".to_string(), + reason: format!("session '{session_id}' is being deleted"), + })) + } + } + + if std::time::Instant::now() >= deadline { + return Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: "sessionNotReady".to_string(), + reason: format!( + "session '{session_id}' was still not running after {}s", + SESSION_READY_TIMEOUT.as_secs() + ), + })); + } + tokio::time::sleep(SESSION_READY_INTERVAL).await; + } + } + + /// Deletes a sandbox the caller will never receive, keeping the reason it is being discarded. + /// + /// The delete's own failure must not replace that reason — it is the finding that matters — + /// but it must not vanish either: the session id is in the error, and a failed delete leaves + /// a sandbox only that id can find. + async fn discard(&self, session_id: &str, reason: AlienError) -> AlienError { + if let Err(error) = self.accept_delete(session_id).await { + warn!( + session = %session_id, + %error, + "could not delete a sandbox that was never handed to its caller" + ); + } + reason + } + /// Runs one shell string under the client-side guard. /// /// The guard is the deadline plus the grace the in-session `timeout` needs to report back. @@ -460,7 +575,7 @@ fn bounded_shell(command: &[String], deadline: std::time::Duration) -> String { /// confinement there is, and it is a client-side rule rather than a guarantee. Relative only: /// Azure exposes no session root to rewrite an absolute path against, so accepting one would hand /// the caller the sandbox's whole filesystem instead of its own directory. -fn checked_path(operation: &str, path: &str) -> Result<()> { +fn checked_path(operation: &str, path: &str) -> Result { let refused = |details: &str| { Err(AlienError::new(ErrorData::InvalidInput { operation_context: operation.to_string(), @@ -474,20 +589,21 @@ fn checked_path(operation: &str, path: &str) -> Result<()> { if path.ends_with('/') { return refused("must not end in '/'"); } - if path.is_empty() { + // A leading slash means "under the session's own root" on every other backend, so it means + // that here too: the alternative is that the one path shape portable code writes is the one + // shape the newest `files` backend refuses. + let relative = path.trim_start_matches('/'); + if relative.is_empty() { return refused("is empty"); } - if path.starts_with('/') { - return refused("must be relative to the sandbox's own directory"); - } - if path.contains('\0') { + if relative.contains('\0') { return refused("contains a null byte"); } - if path.split('/').any(|part| part == ".." || part.is_empty()) { + if relative.split('/').any(|part| part == ".." || part.is_empty()) { return refused("must not traverse"); } - Ok(()) + Ok(relative.to_string()) } /// The policy a declared mode is created with. @@ -501,6 +617,7 @@ fn egress_policy(egress: &SandboxEgress) -> Option { let bounded = |host_rules| { Some(EgressPolicy { default_action: DENY.to_string(), + unmodelled: Default::default(), rules: Vec::new(), host_rules, traffic_inspection: Some(FULL_INSPECTION.to_string()), @@ -544,31 +661,40 @@ fn policy_holds(asked: &EgressPolicy, effective: Option<&EgressPolicy>) -> bool return false; }; - let allowed = |host: &str| { + let asked_for = |host: &str| { asked .host_rules .iter() - .any(|rule| rule.action == ALLOW && rule.pattern == host) + .any(|rule| rule.action.eq_ignore_ascii_case(ALLOW) && rule.pattern == host) }; - effective.default_action == asked.default_action - && effective.traffic_inspection.as_deref() == Some(FULL_INSPECTION) - && asked - .host_rules - .iter() - .all(|rule| effective.host_rules.contains(rule)) + effective.default_action.eq_ignore_ascii_case(&asked.default_action) && effective - .host_rules - .iter() - .all(|rule| rule.action != ALLOW || allowed(&rule.pattern)) - // An advanced rule is refused outright rather than matched host by host: this client - // never sends one, so an `Allow` here came from somewhere else, and `Transform` and - // `Rewrite` reach a host by rewriting the request rather than by naming it. + .traffic_inspection + .as_deref() + .is_some_and(|mode| mode.eq_ignore_ascii_case(FULL_INSPECTION)) + && asked.host_rules.iter().all(|asked_rule| { + effective.host_rules.iter().any(|rule| { + rule.pattern == asked_rule.pattern + && rule.action.eq_ignore_ascii_case(&asked_rule.action) + }) + }) + // A whitelist, not a blacklist: an action this client does not recognise is one it cannot + // weigh, and `Transform` and `Rewrite` reach a host by rewriting the request rather than + // by naming it. Only a plain deny, or an allow the declaration asked for, passes. + && effective.host_rules.iter().all(|rule| { + rule.action.eq_ignore_ascii_case(DENY) + || (rule.action.eq_ignore_ascii_case(ALLOW) && asked_for(&rule.pattern)) + }) + // This client never writes `rules`, so anything here came from elsewhere — a group-scoped + // policy, or an API that moved — and only an outright deny is readable as harmless. && effective.rules.iter().all(|rule| { rule.action .as_ref() - .is_some_and(|action| action.action_type == DENY) + .is_some_and(|action| action.action_type.eq_ignore_ascii_case(DENY)) }) + // A field this client cannot read is a permission it cannot rule out. + && effective.unmodelled.is_empty() } /// The effective policy, short enough to read in an error. @@ -598,7 +724,11 @@ fn session_state(operation: &str, state: Option<&str>) -> Result Ok(SandboxSessionState::Suspended), + // `Stopping` is still running, and reporting it suspended would answer the poll + // `suspend` documents while the sandbox is still up — the same early "it is contained" + // that `terminate` refuses by polling to a 404 rather than trusting the accepted call. + Some("Stopping") => Ok(SandboxSessionState::Running), + Some("Stopped" | "Suspended" | "Idle") => Ok(SandboxSessionState::Suspended), Some("Deleting") => Ok(SandboxSessionState::Terminated), other => Err(AlienError::new(ErrorData::UnexpectedResponseFormat { provider: "azure".to_string(), @@ -619,8 +749,16 @@ const FULL_INSPECTION: &str = "Full"; /// The host pattern that matches everything, so `deny` is a rule rather than only a default. const EVERY_HOST: &str = "*"; -/// The one operation a repeat could run twice. +/// The two operations a repeat could perform twice. +/// +/// `create` is a PUT to a collection with a server-minted id, so a second attempt makes a second +/// sandbox — and with no enumeration verb, the first one has no id-holder and nothing to reap it. const RUN_COMMAND: &str = "sandbox.runCommand"; +const CREATE: &str = "sandbox.create"; + +/// How long a session has to become able to take work, and how often that is checked. +const SESSION_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120); +const SESSION_READY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2); /// Whether the data plane understood the request and rejected it. /// @@ -1101,31 +1239,34 @@ mod tests { client.expect_mkdir().never(); let sandbox = sandbox_with(client); - for path in ["../etc/shadow", "/etc/shadow", "", "work/", "a//b", "a/../../b"] { + for path in ["../etc/shadow", "", "/", "work/", "a//b", "a/../../b", "/../escape"] { let error = sandbox .read_file("s1", path) .await - .expect_err("'{path}' must be refused"); + .expect_err(&format!("'{path}' must be refused")); assert_eq!(error.code, "INVALID_INPUT", "{path}: {error}"); sandbox .write_files("s1", BTreeMap::from([(path.to_string(), vec![1u8])])) .await - .expect_err("'{path}' must be refused on write too"); + .expect_err(&format!("'{path}' must be refused on write too")); sandbox .mkdir("s1", path) .await - .expect_err("'{path}' must be refused on mkdir too"); + .expect_err(&format!("'{path}' must be refused on mkdir too")); } // The same shapes, accepted: a rule that refuses everything would pass the loop above. + // An absolute path is one of them — it means "under the session's own root" on every + // other backend, and arrives at the data plane with the leading slash trimmed. let mut client = MockSandboxDataPlaneApi::new(); client .expect_read_file() - .times(2) + .withf(|_, _, path| !path.starts_with('/')) + .times(3) .returning(|_, _, _| Ok(Vec::new())); let sandbox = sandbox_with(client); - for path in ["app.py", "src/app.py"] { + for path in ["app.py", "src/app.py", "/work/app.py"] { sandbox .read_file("s1", path) .await @@ -1154,6 +1295,29 @@ mod tests { assert_eq!(contents, b"print(1)\n"); } + /// One bad path fails the batch before anything is written. + /// + /// Partial application is the contract for a data plane that refuses midway — not for a path + /// this process could have refused before the first request. + #[tokio::test] + async fn a_batch_with_an_unusable_path_writes_nothing() { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_write_file().never(); + + let error = sandbox_with(client) + .write_files( + "s1", + BTreeMap::from([ + ("a.txt".to_string(), vec![1u8]), + ("b/../../escape".to_string(), vec![2u8]), + ]), + ) + .await + .expect_err("a path that could escape must fail the batch"); + + assert_eq!(error.code, "INVALID_INPUT", "{error}"); + } + /// Writing stops at the first failure rather than pressing on, which is what makes a partial /// write observable to the caller instead of a success with a hole in it. #[tokio::test] @@ -1247,7 +1411,8 @@ mod tests { ("Running", SandboxSessionState::Running), ("Creating", SandboxSessionState::Starting), ("Resuming", SandboxSessionState::Starting), - ("Stopping", SandboxSessionState::Suspended), + // Still up: a sandbox that has been asked to stop has not stopped. + ("Stopping", SandboxSessionState::Running), ("Stopped", SandboxSessionState::Suspended), ("Suspended", SandboxSessionState::Suspended), ("Idle", SandboxSessionState::Suspended), @@ -1314,6 +1479,14 @@ mod tests { }) }); + // Created as `Creating`, so the create waits: the trait owes the caller a session that + // can already take work, and returning one that cannot pushes the readiness poll into + // every caller. + client + .expect_get_sandbox() + .times(1) + .returning(|_, _| Ok(running("s1", None))); + let session = sandbox_with(client) .create(CreateSessionRequest { session_id: None, @@ -1323,11 +1496,7 @@ mod tests { .await .expect("the create should succeed"); - assert_eq!( - session.state, - SandboxSessionState::Starting, - "a sandbox still being created is not one a command can reach" - ); + assert_eq!(session.state, SandboxSessionState::Running); } fn running(id: &str, egress: Option) -> alien_azure_clients::azure::sandbox_data_plane::Sandbox { @@ -1439,6 +1608,7 @@ mod tests { // The default action alone: every non-HTTP protocol still leaves. Some(EgressPolicy { default_action: "Deny".to_string(), + unmodelled: Default::default(), rules: Vec::new(), host_rules: Vec::new(), traffic_inspection: Some("Partial".to_string()), @@ -1446,6 +1616,7 @@ mod tests { // Inspected, and open. Some(EgressPolicy { default_action: "Allow".to_string(), + unmodelled: Default::default(), rules: Vec::new(), host_rules: Vec::new(), traffic_inspection: Some("Full".to_string()), @@ -1468,7 +1639,7 @@ mod tests { .await .expect_err("a sandbox without its policy must not be handed back"); - assert_eq!(error.code, "BINDING_CONFIG_INVALID", "{error}"); + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); } } @@ -1483,6 +1654,7 @@ mod tests { "s1", Some(EgressPolicy { default_action: "Deny".to_string(), + unmodelled: Default::default(), rules: Vec::new(), host_rules: vec![EgressHostRule { pattern: "elsewhere.example.com".to_string(), @@ -1504,7 +1676,7 @@ mod tests { .await .expect_err("a host the declaration named must be in the effective policy"); - assert_eq!(error.code, "BINDING_CONFIG_INVALID", "{error}"); + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); } /// A session that is going away is not one to reconnect to. @@ -1557,6 +1729,7 @@ mod tests { // A second host, allowed. EgressPolicy { default_action: "Deny".to_string(), + unmodelled: Default::default(), host_rules: vec![ declared.clone(), EgressHostRule { @@ -1570,6 +1743,7 @@ mod tests { // Everything, through the list this client never writes. EgressPolicy { default_action: "Deny".to_string(), + unmodelled: Default::default(), host_rules: vec![declared.clone()], rules: vec![EgressRule { r#match: Some(EgressRuleMatch { @@ -1595,7 +1769,7 @@ mod tests { .await .expect_err("a permission nobody asked for must fail the create"); - assert_eq!(error.code, "BINDING_CONFIG_INVALID", "{error}"); + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); } // The same policy without the extra permission creates normally, so the rule above is @@ -1606,6 +1780,7 @@ mod tests { "s1", Some(EgressPolicy { default_action: "Deny".to_string(), + unmodelled: Default::default(), host_rules: vec![EgressHostRule { pattern: "api.example.com".to_string(), action: "Allow".to_string(), @@ -1680,4 +1855,129 @@ mod tests { .await .expect("the create should succeed"); } + + /// Reconnect is the path a stale policy survives on. + /// + /// Azure has no session ceiling and an idle sandbox only suspends, so one created under an + /// older declaration outlives the change. Checking only at create hands the caller a session + /// whose containment is whatever it was built with, under the label it has now. + #[tokio::test] + async fn a_reconnect_to_a_session_built_under_another_policy_is_refused() { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_get_sandbox().times(1).returning(|_, id| { + // What an `allow` declaration built, before it was changed to `deny`. + Ok(running(id, None)) + }); + + let error = sandbox_denying(client, SandboxEgress::Deny) + .get("built-under-allow") + .await + .expect_err("a session without the declared policy must not be handed back"); + + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + } + + /// A create whose response cannot be read owns a sandbox the caller has no id for. + /// + /// Azure allocates the id and has no enumeration verb, so an abandoned sandbox has no + /// id-holder and nothing to reap it — it runs until someone finds it by hand. + #[tokio::test] + async fn a_create_that_cannot_be_read_deletes_what_it_made() { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_create_sandbox().times(1).returning(|_, _| { + Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox { + id: "orphan".to_string(), + egress_policy: None, + state: Some("Hibernated".to_string()), + }) + }); + client + .expect_delete_sandbox() + .withf(|_, id| id == "orphan") + .times(1) + .returning(|_, _| Ok(())); + + let error = sandbox_with(client) + .create(CreateSessionRequest::default()) + .await + .expect_err("an unreadable state must fail the create"); + + assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}"); + } + + /// The three shapes a permitting policy can arrive in that a looser check would pass. + #[tokio::test] + async fn a_policy_this_client_cannot_read_whole_fails_the_create() { + let declared = || SandboxEgress::Deny; + let catch_all = EgressHostRule { + pattern: "*".to_string(), + action: "Deny".to_string(), + }; + + for came_up_with in [ + // A host rule carrying an action this client cannot weigh: `Transform` reaches a host + // by rewriting the request rather than by naming it. + EgressPolicy { + default_action: "Deny".to_string(), + host_rules: vec![ + catch_all.clone(), + EgressHostRule { + pattern: "api.example.com".to_string(), + action: "Transform".to_string(), + }, + ], + rules: Vec::new(), + unmodelled: Default::default(), + traffic_inspection: Some("Full".to_string()), + }, + // A field this client does not model at all. + EgressPolicy { + default_action: "Deny".to_string(), + host_rules: vec![catch_all.clone()], + rules: Vec::new(), + unmodelled: BTreeMap::from([( + "bypassList".to_string(), + serde_json::json!(["exfil.example.com"]), + )]), + traffic_inspection: Some("Full".to_string()), + }, + ] { + let mut client = MockSandboxDataPlaneApi::new(); + let effective = came_up_with.clone(); + client + .expect_create_sandbox() + .times(1) + .returning(move |_, _| Ok(running("s1", Some(effective.clone())))); + client.expect_delete_sandbox().times(1).returning(|_, _| Ok(())); + + let error = sandbox_denying(client, declared()) + .create(CreateSessionRequest::default()) + .await + .expect_err("a policy this client cannot read whole must fail the create"); + + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + } + + // Case is the data plane's to choose: the same policy, normalised, still creates. + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_create_sandbox().times(1).returning(|_, _| { + Ok(running( + "s1", + Some(EgressPolicy { + default_action: "deny".to_string(), + host_rules: vec![EgressHostRule { + pattern: "*".to_string(), + action: "deny".to_string(), + }], + rules: Vec::new(), + unmodelled: Default::default(), + traffic_inspection: Some("full".to_string()), + }), + )) + }); + sandbox_denying(client, declared()) + .create(CreateSessionRequest::default()) + .await + .expect("a normalised echo of the same policy is the same policy"); + } } diff --git a/crates/alien-bindings/src/providers/sandbox/gcp.rs b/crates/alien-bindings/src/providers/sandbox/gcp.rs index 4ade2ab3f..b38e5d0ac 100644 --- a/crates/alien-bindings/src/providers/sandbox/gcp.rs +++ b/crates/alien-bindings/src/providers/sandbox/gcp.rs @@ -400,6 +400,7 @@ impl From for CommandOutput { #[cfg(test)] mod tests { use super::*; + use futures::StreamExt; use alien_core::bindings::BindingValue; /// A fake launcher that rejects argv the real one rejects. @@ -408,7 +409,7 @@ mod tests { /// is argument construction, and a mock of the launcher would be built from the same /// misunderstanding as the code. /// - /// `body` runs only after the argv passes `strict_launcher`'s checks. A fake that accepts + /// `body` runs only after the argv passes `STRICT_PRELUDE`'s checks. A fake that accepts /// anything is worse than none: it produced green tests for a `create` that sent /// `run --id `, which the real launcher answers with `unknown flag: --id`. fn launcher(body: &str) -> (tempfile::TempDir, GcpSandbox) { @@ -598,7 +599,7 @@ done .expect("a session environment is carried, not refused"); // The stream has to be drained: dropping it undrained kills the child before it runs. - if let Ok(mut frames) = sandbox + let mut frames = sandbox .run_command( "s1", RunCommandRequest { @@ -609,10 +610,8 @@ done }, ) .await - { - use futures::StreamExt; - while frames.next().await.is_some() {} - } + .unwrap_or_else(|error| panic!("a command with variables is accepted: {error}")); + while frames.next().await.is_some() {} let argv = std::fs::read_to_string(&record).expect("launcher ran"); let lines: Vec<&str> = argv.lines().collect(); diff --git a/crates/alien-cloudformation/src/emitters/aws/sandbox.rs b/crates/alien-cloudformation/src/emitters/aws/sandbox.rs index 01481113e..004d5c72e 100644 --- a/crates/alien-cloudformation/src/emitters/aws/sandbox.rs +++ b/crates/alien-cloudformation/src/emitters/aws/sandbox.rs @@ -582,8 +582,8 @@ fn refuse_unsupported_egress(sandbox: &Sandbox) -> Result<()> { reason: format!( "AWS sandboxes reach the network through a VPC egress connector, which this \ template builds to deny outbound traffic; egress '{mode}' has no connector \ - configuration to render into. Declare egress: deny, or deploy to Azure, whose \ - egress proxy matches on host pattern" + configuration to render into. Declare egress: deny for a connector that reaches \ + nothing, or egress: allow for no connector at all" ), })) }; diff --git a/crates/alien-core/src/resources/sandbox.rs b/crates/alien-core/src/resources/sandbox.rs index 137c8170b..bb282b474 100644 --- a/crates/alien-core/src/resources/sandbox.rs +++ b/crates/alien-core/src/resources/sandbox.rs @@ -628,6 +628,21 @@ impl Sandbox { // `allow` asks for no restriction, so a backend that ignores it fails loudly on the first // blocked connection. `deny` asks for one, and a backend that ignores it puts untrusted // code on the internet with nothing to notice — so only this direction is gated. + // An empty list is not a restriction anyone wrote down: it renders as a deny-all wearing + // an allowlist's label, which reads at a glance as the opposite of what it does. + if let SandboxEgress::AllowDomains { domains } = &self.egress { + if domains.is_empty() { + return Err(AlienError::new(ErrorData::SandboxLimitInvalid { + resource_id: self.id.clone(), + field: "egress.domains".to_string(), + value: "[]".to_string(), + reason: "an allowlist naming no domain denies everything; declare \ + egress: deny if that is what was meant" + .to_string(), + })); + } + } + if matches!(self.egress, SandboxEgress::Deny) { capabilities.require(SandboxCapability::EgressDeny, platform)?; } @@ -1383,4 +1398,32 @@ mod tests { error.message ); } + + /// An allowlist naming nothing is a deny-all wearing an allowlist's label. + /// + /// It renders as a `Deny` default with no rules — the shape the Azure provider adds a + /// catch-all to avoid — and a reader scanning the declaration sees "allowDomains" and reads + /// the opposite of what it does. + #[test] + fn an_allowlist_with_no_domains_is_refused() { + let declared = |domains: Vec| { + Sandbox::new("sbx".to_string()) + .code(SandboxCode::Image { + image: "ubuntu".to_string(), + }) + .egress(SandboxEgress::AllowDomains { domains }) + .session(SandboxSessionPolicy { + max_lifetime_seconds: None, + idle_suspend_seconds: None, + }) + .build() + .validate_for_platform(Platform::Azure) + }; + + let error = declared(vec![]).expect_err("an empty allowlist must be refused"); + assert_eq!(error.code, "SANDBOX_LIMIT_INVALID"); + + declared(vec!["api.example.com".to_string()]) + .expect("a named domain is what an allowlist is for"); + } } diff --git a/crates/alien-helm/src/emitters/sandbox.rs b/crates/alien-helm/src/emitters/sandbox.rs index c37b7462a..76f3a3375 100644 --- a/crates/alien-helm/src/emitters/sandbox.rs +++ b/crates/alien-helm/src/emitters/sandbox.rs @@ -58,23 +58,11 @@ impl HelmEmitter for SandboxEmitter { }) })?; - // A hostname list has no NetworkPolicy to render into — it matches CIDRs — so it is - // refused rather than widened to the `allow` rule, which would open every address the - // declaration meant to exclude. - if let SandboxEgress::AllowDomains { .. } = sandbox.egress { - return Err(AlienError::new(ErrorData::OperationNotSupported { - operation: format!("helm emit sandbox '{}'", ctx.resource_id), - reason: "a Kubernetes NetworkPolicy matches addresses, not names, so a hostname \ - list has nothing to render into. Declare egress: deny, or deploy to \ - Azure, whose egress proxy matches on host pattern" - .to_string(), - })); - } let mut fragment = HelmFragment::empty(); fragment.extra_templates.insert( format!("sandbox-{}-networkpolicy.yaml", sandbox.id()), - network_policy(sandbox), + network_policy(sandbox, ctx.resource_id)?, ); fragment .extra_templates @@ -91,12 +79,21 @@ impl HelmEmitter for SandboxEmitter { /// because that needs a gateway validating a session-and-port capability and none exists. Under /// `deny`, `Egress` is listed with no rules — a listed policy type with no rule is how /// NetworkPolicy spells "none", where omitting the type would mean "unrestricted". -fn network_policy(sandbox: &Sandbox) -> String { +fn network_policy(sandbox: &Sandbox, resource_id: &str) -> Result { let egress = match sandbox.egress { SandboxEgress::Deny => String::new(), - // `AllowDomains` never reaches here: the emitter refuses it rather than render it as the - // `allow` rule below, which permits every address the list meant to exclude. - SandboxEgress::Allow | SandboxEgress::AllowDomains { .. } => { + // Refused here rather than upstream, so the function that would render the permissive + // rule is the one that declines: a hostname list rendered as `allow` opens every address + // it was written to exclude. + SandboxEgress::AllowDomains { .. } => { + return Err(AlienError::new(ErrorData::OperationNotSupported { + operation: format!("generate the Helm chart for sandbox '{resource_id}'"), + reason: "a Kubernetes NetworkPolicy matches addresses, not names, so a hostname \ + list has nothing to render into. Declare egress: deny or egress: allow" + .to_string(), + })); + } + SandboxEgress::Allow => { let excepts: String = ALWAYS_DENIED_CIDRS .iter() .map(|cidr| format!(" - {cidr}\n")) @@ -112,7 +109,7 @@ fn network_policy(sandbox: &Sandbox) -> String { } }; - format!( + Ok(format!( r#"apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: @@ -140,7 +137,7 @@ spec: id = sandbox.id(), label = LABEL_SANDBOX, agent_port = AGENT_PORT, - ) + )) } /// Cluster-scoped RBAC for the session broker. diff --git a/crates/alien-helm/tests/generator/resource_layer_tests.rs b/crates/alien-helm/tests/generator/resource_layer_tests.rs index 5c552a15a..51f519092 100644 --- a/crates/alien-helm/tests/generator/resource_layer_tests.rs +++ b/crates/alien-helm/tests/generator/resource_layer_tests.rs @@ -191,8 +191,9 @@ fn a_hostname_allowlist_is_refused_rather_than_widened() { let error = try_render(&stack, StackSettings::default()) .expect_err("a hostname list must be refused rather than approximated"); + assert_eq!(error.code, "OPERATION_NOT_SUPPORTED", "{error}"); assert!( - error.to_string().contains("matches addresses, not names"), - "the refusal must name why: {error}" + error.to_string().contains("agent"), + "the refusal must name the sandbox it is about: {error}" ); } diff --git a/crates/alien-terraform/src/emitters/aws/sandbox.rs b/crates/alien-terraform/src/emitters/aws/sandbox.rs index f8d3cffb7..c0edbb1f4 100644 --- a/crates/alien-terraform/src/emitters/aws/sandbox.rs +++ b/crates/alien-terraform/src/emitters/aws/sandbox.rs @@ -631,8 +631,8 @@ fn refuse_unsupported_egress(sandbox: &Sandbox) -> Result<()> { reason: format!( "AWS sandboxes reach the network through a VPC egress connector, which this \ module builds to deny outbound traffic; egress '{mode}' has no connector \ - configuration to render into. Declare egress: deny, or deploy to Azure, whose \ - egress proxy matches on host pattern" + configuration to render into. Declare egress: deny for a connector that reaches \ + nothing, or egress: allow for no connector at all" ), })) }; diff --git a/crates/alien-terraform/src/emitters/azure/sandbox.rs b/crates/alien-terraform/src/emitters/azure/sandbox.rs index 7e98a9500..49303384f 100644 --- a/crates/alien-terraform/src/emitters/azure/sandbox.rs +++ b/crates/alien-terraform/src/emitters/azure/sandbox.rs @@ -1,13 +1,13 @@ //! Azure Sandbox — a named group, and nothing built at setup. //! -//! The ACA sandbox group is created at runtime, idempotently by name, because a group is cheap to -//! create and pointless to hold open while no session wants one. So setup emits no Azure resource -//! here; what it owes the runtime is the three names the data plane is addressed by, which the +//! No sandbox controller is registered for Azure, and `create_or_update_sandbox_group` has no +//! caller, so nothing here creates the group a session lives in — it has to exist already. Setup +//! emits no Azure resource for the same reason it would not be useful to: a group is cheap to +//! create by name and pointless to hold open while no session wants one. +//! +//! What this emitter contributes is the three names the data plane is addressed by, which the //! Azure client config does not carry: the group, the region that selects the per-region endpoint, //! and the resource group the data-plane path is scoped by. -//! -//! Nothing in this repository creates that group: `create_or_update_sandbox_group` has no caller, -//! and the controller registry holds only the Local and Kubernetes sandbox controllers. use crate::{ emitter::{TfEmitter, TfFragment}, @@ -71,15 +71,20 @@ fn catalog_disk_image(sandbox: &Sandbox) -> Result { }; match &sandbox.code { - SandboxCode::Image { image } if image.contains('/') => Err(unsupported(format!( - "Azure creates a sandbox from a public catalog disk image, so code.image must be a \ - catalog name such as 'ubuntu', not the registry reference '{image}'" - ))), + // A tag is the shape that gets through unnoticed: `ubuntu:24.04` has no slash, renders + // into the customer's module, plans and applies, and fails at the first session. + SandboxCode::Image { image } + if image.contains('/') || image.contains(':') || image.contains('@') => + { + Err(unsupported(format!( + "Azure creates a sandbox from a public catalog disk image, so code.image must be \ + a bare catalog name such as 'ubuntu' — '{image}' carries a registry path, tag or \ + digest, which the data plane has nowhere to put" + ))) + } SandboxCode::Image { image } => Ok(image.clone()), SandboxCode::Source { .. } => Err(unsupported( - "Azure creates a sandbox from a prebuilt catalog disk image and cannot build one \ - from source" - .to_string(), + "no sandbox backend builds an image from source yet".to_string(), )), } } @@ -185,17 +190,22 @@ mod tests { /// domains denies everything, and the domains without the mode are ignored. #[test] fn the_binding_carries_the_declared_egress() { + // The key names are asserted, not just the values: `AzureSandboxBinding.egress` has no + // serde default, so a misspelled key here is a deserialization failure on the customer's + // cluster rather than a failure at emit. let denied = binding_for(SandboxEgress::Deny); - assert!(denied.contains(r#""deny""#), "{denied}"); + assert!(denied.contains("egress = {"), "{denied}"); + assert!(denied.contains(r#"mode = "deny""#), "{denied}"); let listed = binding_for(SandboxEgress::AllowDomains { domains: vec!["api.example.com".to_string()], }); - assert!(listed.contains(r#""allowDomains""#), "{listed}"); - assert!(listed.contains("api.example.com"), "{listed}"); + assert!(listed.contains(r#"mode = "allowDomains""#), "{listed}"); + assert!(listed.contains("domains = ["), "{listed}"); + assert!(listed.contains(r#""api.example.com""#), "{listed}"); let open = binding_for(SandboxEgress::Allow); - assert!(open.contains(r#""allow""#), "{open}"); + assert!(open.contains(r#"mode = "allow""#), "{open}"); } /// The idle-suspend policy travels the same way, and only when it was declared. @@ -205,7 +215,7 @@ mod tests { #[test] fn the_binding_carries_a_declared_idle_suspend_and_nothing_otherwise() { let declared = binding_with(SandboxEgress::Allow, Some(900)); - assert!(declared.contains("900"), "{declared}"); + assert!(declared.contains("idleSuspendSeconds = 900"), "{declared}"); let undeclared = binding_with(SandboxEgress::Allow, None); assert!( diff --git a/crates/alien-terraform/src/emitters/gcp/sandbox.rs b/crates/alien-terraform/src/emitters/gcp/sandbox.rs index 498887e30..c3a4d8998 100644 --- a/crates/alien-terraform/src/emitters/gcp/sandbox.rs +++ b/crates/alien-terraform/src/emitters/gcp/sandbox.rs @@ -26,8 +26,7 @@ fn refuse_unsupported_egress(sandbox: &Sandbox) -> Result<()> { SandboxEgress::AllowDomains { .. } => Err(AlienError::new(ErrorData::OperationNotSupported { operation: format!("terraform emit sandbox '{}'", sandbox.id()), reason: "the Cloud Run sandbox launcher takes a single egress switch, so a hostname \ - list has nothing to render into. Declare egress: deny, or deploy to Azure, \ - whose egress proxy matches on host pattern" + list has nothing to render into. Declare egress: deny or egress: allow" .to_string(), })), } diff --git a/packages/core/src/generated/schemas/sandbox.json b/packages/core/src/generated/schemas/sandbox.json index c9dacc58e..152b8b16b 100644 --- a/packages/core/src/generated/schemas/sandbox.json +++ b/packages/core/src/generated/schemas/sandbox.json @@ -1 +1 @@ -{"type":"object","description":"An isolated environment for running untrusted code, created per session at runtime.","required":["id","code","egress","session"],"properties":{"code":{"description":"Where the sandbox's root filesystem comes from","oneOf":[{"type":"object","description":"A prebuilt container image used as the sandbox root filesystem.","required":["image","type"],"properties":{"image":{"type":"string","description":"Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`)"},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source built into a sandbox image at deploy time.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"x-readme-ref-name":"SandboxCode"},"egress":{"description":"Outbound network policy","oneOf":[{"type":"object","description":"No outbound network access.\n\nRouted traffic only. Link-local is not outbound and no backend's egress control reaches\nit, so this is not a boundary against instance metadata.","required":["mode"],"properties":{"mode":{"type":"string","enum":["deny"]}}},{"type":"object","description":"Unrestricted outbound access to the public internet, and none to private ranges or the\ndeployment's own network.\n\nLink-local carries the same exception as `Deny`.","required":["mode"],"properties":{"mode":{"type":"string","enum":["allow"]}}},{"type":"object","description":"Outbound access only to the listed hostnames. No backend expresses this yet.","required":["domains","mode"],"properties":{"domains":{"type":"array","items":{"type":"string"},"description":"Hostnames the sandbox may reach"},"mode":{"type":"string","enum":["allowDomains"]}}}],"x-readme-ref-name":"SandboxEgress"},"id":{"type":"string","description":"Identifier for the sandbox. Must contain only alphanumeric characters, hyphens, and\nunderscores ([A-Za-z0-9-_]). Maximum 64 characters."},"limits":{"oneOf":[{"type":"null"},{"description":"Enforced resource ceilings.\n\nOptional because not every platform can enforce them, and a declaration that names none\ntakes the platform's own defaults. Naming them on a platform that cannot enforce them is\nrejected at plan time rather than silently ignored.","type":"object","required":["cpu","memory","disk"],"properties":{"cpu":{"type":"string","description":"CPU ceiling in cores or millicores (e.g. `\"1\"`, `\"500m\"`)"},"disk":{"type":"string","description":"Disk ceiling (e.g. `\"20Gi\"`)"},"maxProcesses":{"type":["integer","null"],"format":"int32","description":"Maximum number of processes, which bounds fork bombs.\n\nOptional because only a container runtime has the primitive: Kubernetes sets a pid ceiling\nper node, not per pod, and neither AWS MicroVMs nor Azure sandboxes expose one. Declaring\nit on a platform that cannot apply it is refused at plan time.","minimum":0},"memory":{"type":"string","description":"Memory ceiling (e.g. `\"2Gi\"`, `\"512Mi\"`)"}},"additionalProperties":false,"x-readme-ref-name":"SandboxLimits"}]},"previewPorts":{"type":"array","items":{"type":"integer","format":"int32","minimum":0},"description":"Ports eligible for a preview capability. A port not listed here can never be exposed,\nso an application cannot widen its own ingress at runtime."},"session":{"description":"Session lifetime and idle behaviour","type":"object","properties":{"idleSuspendSeconds":{"type":["integer","null"],"format":"int32","description":"Idle period after which the session is suspended, where the platform supports it","minimum":0},"maxLifetimeSeconds":{"type":["integer","null"],"format":"int32","description":"Wall-clock ceiling on a single session, after which the platform terminates it.\n\nOptional because not every backend has the primitive: Kubernetes has\n`activeDeadlineSeconds` and AWS `maximumDurationInSeconds`, while neither Azure nor Local\nexpose one, so declaring a ceiling there is refused at plan time rather than accepted and\nnever applied. AWS caps it at 8 hours.","minimum":0}},"additionalProperties":false,"x-readme-ref-name":"SandboxSessionPolicy"}},"additionalProperties":false,"x-readme-ref-name":"Sandbox"} \ No newline at end of file +{"type":"object","description":"An isolated environment for running untrusted code, created per session at runtime.","required":["id","code","egress","session"],"properties":{"code":{"description":"Where the sandbox's root filesystem comes from","oneOf":[{"type":"object","description":"A prebuilt container image used as the sandbox root filesystem.","required":["image","type"],"properties":{"image":{"type":"string","description":"Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`)"},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source built into a sandbox image at deploy time.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"x-readme-ref-name":"SandboxCode"},"egress":{"description":"Outbound network policy","oneOf":[{"type":"object","description":"No outbound network access.\n\nRouted traffic only. Link-local is not outbound and no backend's egress control reaches\nit, so this is not a boundary against instance metadata.","required":["mode"],"properties":{"mode":{"type":"string","enum":["deny"]}}},{"type":"object","description":"Unrestricted outbound access to the public internet, and none to private ranges or the\ndeployment's own network.\n\nLink-local carries the same exception as `Deny`.","required":["mode"],"properties":{"mode":{"type":"string","enum":["allow"]}}},{"type":"object","description":"Outbound access only to the listed hostnames.\n\nAzure alone expresses it: its egress proxy matches on host pattern. The others filter by\nCIDR or carry a single switch, and both would approximate the list rather than keep it.","required":["domains","mode"],"properties":{"domains":{"type":"array","items":{"type":"string"},"description":"Hostnames the sandbox may reach"},"mode":{"type":"string","enum":["allowDomains"]}}}],"x-readme-ref-name":"SandboxEgress"},"id":{"type":"string","description":"Identifier for the sandbox. Must contain only alphanumeric characters, hyphens, and\nunderscores ([A-Za-z0-9-_]). Maximum 64 characters."},"limits":{"oneOf":[{"type":"null"},{"description":"Enforced resource ceilings.\n\nOptional because not every platform can enforce them, and a declaration that names none\ntakes the platform's own defaults. Naming them on a platform that cannot enforce them is\nrejected at plan time rather than silently ignored.","type":"object","required":["cpu","memory","disk"],"properties":{"cpu":{"type":"string","description":"CPU ceiling in cores or millicores (e.g. `\"1\"`, `\"500m\"`)"},"disk":{"type":"string","description":"Disk ceiling (e.g. `\"20Gi\"`)"},"maxProcesses":{"type":["integer","null"],"format":"int32","description":"Maximum number of processes, which bounds fork bombs.\n\nOptional because only a container runtime has the primitive: Kubernetes sets a pid ceiling\nper node, not per pod, and neither AWS MicroVMs nor Azure sandboxes expose one. Declaring\nit on a platform that cannot apply it is refused at plan time.","minimum":0},"memory":{"type":"string","description":"Memory ceiling (e.g. `\"2Gi\"`, `\"512Mi\"`)"}},"additionalProperties":false,"x-readme-ref-name":"SandboxLimits"}]},"previewPorts":{"type":"array","items":{"type":"integer","format":"int32","minimum":0},"description":"Ports eligible for a preview capability. A port not listed here can never be exposed,\nso an application cannot widen its own ingress at runtime."},"session":{"description":"Session lifetime and idle behaviour","type":"object","properties":{"idleSuspendSeconds":{"type":["integer","null"],"format":"int32","description":"Idle period after which the session is suspended, where the platform supports it","minimum":0},"maxLifetimeSeconds":{"type":["integer","null"],"format":"int32","description":"Wall-clock ceiling on a single session, after which the platform terminates it.\n\nOptional because not every backend has the primitive: Kubernetes has\n`activeDeadlineSeconds` and AWS `maximumDurationInSeconds`, while neither Azure nor Local\nexpose one, so declaring a ceiling there is refused at plan time rather than accepted and\nnever applied. AWS caps it at 8 hours.","minimum":0}},"additionalProperties":false,"x-readme-ref-name":"SandboxSessionPolicy"}},"additionalProperties":false,"x-readme-ref-name":"Sandbox"} \ No newline at end of file diff --git a/packages/core/src/generated/schemas/sandboxCapabilities.json b/packages/core/src/generated/schemas/sandboxCapabilities.json index 98a055f00..9411a97c4 100644 --- a/packages/core/src/generated/schemas/sandboxCapabilities.json +++ b/packages/core/src/generated/schemas/sandboxCapabilities.json @@ -1 +1 @@ -{"type":"object","description":"What a platform's sandbox backend can actually do.\n\nPublished so portable code can branch before calling rather than discovering a gap through\nan error. Every field here corresponds to a capability that at least one platform lacks;\ncreate, exec and terminate are the guaranteed floor and are therefore not listed.","required":["files","reconnect","preview","suspendResume","snapshot","domainEgressRules","egressDeny","enforcedLimits","processLimit","sessionLifetime","supervisorPidNamespace"],"properties":{"domainEgressRules":{"type":"boolean","description":"Egress can be restricted to a hostname allowlist"},"egressDeny":{"type":"boolean","description":"Whether a declared `deny` is actually enforced, rather than accepted and dropped"},"enforcedLimits":{"type":"boolean","description":"The platform enforces the declared cpu, memory and disk ceilings"},"files":{"type":"boolean","description":"Files can be moved in and out of a session\n\nEvery backend but Azure, whose binding implements no transfer."},"preview":{"type":"boolean","description":"An authenticated, port-scoped capability to reach a service inside the sandbox"},"processLimit":{"type":"boolean","description":"The platform can cap how many processes a session runs"},"reconnect":{"type":"boolean","description":"A later call can reach a session created by an earlier one"},"sessionLifetime":{"type":"boolean","description":"The platform terminates a session at a declared wall-clock deadline"},"snapshot":{"type":"boolean","description":"A session's full state can be captured and used to create another"},"supervisorPidNamespace":{"type":"boolean","description":"A command runs in its own PID namespace and cannot see or signal the agent's processes.\n\nOnly where an agent runs as root. Creating the namespace needs `CAP_SYS_ADMIN`, and the\nKubernetes sandbox pod drops every capability — which is also what denies `ptrace` by\nconstruction, so granting it there would remove a lock to add one."},"suspendResume":{"type":"boolean","description":"Session state can be suspended and resumed"}},"additionalProperties":false,"x-readme-ref-name":"SandboxCapabilities"} \ No newline at end of file +{"type":"object","description":"What a platform's sandbox backend can actually do.\n\nPublished so portable code can branch before calling rather than discovering a gap through\nan error. Every field here corresponds to a capability that at least one platform lacks;\ncreate, exec and terminate are the guaranteed floor and are therefore not listed.","required":["files","reconnect","preview","suspendResume","snapshot","domainEgressRules","egressDeny","enforcedLimits","processLimit","sessionLifetime","supervisorPidNamespace"],"properties":{"domainEgressRules":{"type":"boolean","description":"Egress can be restricted to a hostname allowlist"},"egressDeny":{"type":"boolean","description":"Whether a declared `deny` is actually enforced, rather than accepted and dropped"},"enforcedLimits":{"type":"boolean","description":"The platform enforces the declared cpu, memory and disk ceilings"},"files":{"type":"boolean","description":"Files can be moved in and out of a session"},"preview":{"type":"boolean","description":"An authenticated, port-scoped capability to reach a service inside the sandbox"},"processLimit":{"type":"boolean","description":"The platform can cap how many processes a session runs"},"reconnect":{"type":"boolean","description":"A later call can reach a session created by an earlier one"},"sessionLifetime":{"type":"boolean","description":"The platform terminates a session at a declared wall-clock deadline"},"snapshot":{"type":"boolean","description":"A session's full state can be captured and used to create another"},"supervisorPidNamespace":{"type":"boolean","description":"A command runs in its own PID namespace and cannot see or signal the agent's processes.\n\nOnly where an agent runs as root. Creating the namespace needs `CAP_SYS_ADMIN`, and the\nKubernetes sandbox pod drops every capability — which is also what denies `ptrace` by\nconstruction, so granting it there would remove a lock to add one."},"suspendResume":{"type":"boolean","description":"Session state can be suspended and resumed"}},"additionalProperties":false,"x-readme-ref-name":"SandboxCapabilities"} \ No newline at end of file diff --git a/packages/core/src/generated/schemas/sandboxEgress.json b/packages/core/src/generated/schemas/sandboxEgress.json index 8beabf0ec..b88385e76 100644 --- a/packages/core/src/generated/schemas/sandboxEgress.json +++ b/packages/core/src/generated/schemas/sandboxEgress.json @@ -1 +1 @@ -{"oneOf":[{"type":"object","description":"No outbound network access.\n\nRouted traffic only. Link-local is not outbound and no backend's egress control reaches\nit, so this is not a boundary against instance metadata.","required":["mode"],"properties":{"mode":{"type":"string","enum":["deny"]}}},{"type":"object","description":"Unrestricted outbound access to the public internet, and none to private ranges or the\ndeployment's own network.\n\nLink-local carries the same exception as `Deny`.","required":["mode"],"properties":{"mode":{"type":"string","enum":["allow"]}}},{"type":"object","description":"Outbound access only to the listed hostnames. No backend expresses this yet.","required":["domains","mode"],"properties":{"domains":{"type":"array","items":{"type":"string"},"description":"Hostnames the sandbox may reach"},"mode":{"type":"string","enum":["allowDomains"]}}}],"description":"Outbound network policy for a sandbox.","x-readme-ref-name":"SandboxEgress"} \ No newline at end of file +{"oneOf":[{"type":"object","description":"No outbound network access.\n\nRouted traffic only. Link-local is not outbound and no backend's egress control reaches\nit, so this is not a boundary against instance metadata.","required":["mode"],"properties":{"mode":{"type":"string","enum":["deny"]}}},{"type":"object","description":"Unrestricted outbound access to the public internet, and none to private ranges or the\ndeployment's own network.\n\nLink-local carries the same exception as `Deny`.","required":["mode"],"properties":{"mode":{"type":"string","enum":["allow"]}}},{"type":"object","description":"Outbound access only to the listed hostnames.\n\nAzure alone expresses it: its egress proxy matches on host pattern. The others filter by\nCIDR or carry a single switch, and both would approximate the list rather than keep it.","required":["domains","mode"],"properties":{"domains":{"type":"array","items":{"type":"string"},"description":"Hostnames the sandbox may reach"},"mode":{"type":"string","enum":["allowDomains"]}}}],"description":"Outbound network policy for a sandbox.","x-readme-ref-name":"SandboxEgress"} \ No newline at end of file diff --git a/packages/core/src/generated/zod/sandbox-capabilities-schema.ts b/packages/core/src/generated/zod/sandbox-capabilities-schema.ts index fec2da979..35778965d 100644 --- a/packages/core/src/generated/zod/sandbox-capabilities-schema.ts +++ b/packages/core/src/generated/zod/sandbox-capabilities-schema.ts @@ -12,7 +12,7 @@ export const SandboxCapabilitiesSchema = z.object({ "domainEgressRules": z.boolean().describe("Egress can be restricted to a hostname allowlist"), "egressDeny": z.boolean().describe("Whether a declared `deny` is actually enforced, rather than accepted and dropped"), "enforcedLimits": z.boolean().describe("The platform enforces the declared cpu, memory and disk ceilings"), -"files": z.boolean().describe("Files can be moved in and out of a session\n\nEvery backend but Azure, whose binding implements no transfer."), +"files": z.boolean().describe("Files can be moved in and out of a session"), "preview": z.boolean().describe("An authenticated, port-scoped capability to reach a service inside the sandbox"), "processLimit": z.boolean().describe("The platform can cap how many processes a session runs"), "reconnect": z.boolean().describe("A later call can reach a session created by an earlier one"), diff --git a/packages/core/src/sandbox.ts b/packages/core/src/sandbox.ts index 05a2945a9..848045031 100644 --- a/packages/core/src/sandbox.ts +++ b/packages/core/src/sandbox.ts @@ -33,8 +33,8 @@ export { SandboxSchema as SandboxConfigSchema } from "./generated/index.js" * * Capabilities are not uniform. Call `capabilities()` on the binding and branch, or handle the * typed error — an unsupported capability never silently succeeds. Notably GCP cannot - * reconnect to a session (its session id is scoped to one Cloud Run instance), the Azure - * binding implements no file transfer, and no binding renders a hostname egress allowlist. + * reconnect to a session (its session id is scoped to one Cloud Run instance), only Azure + * restricts egress to a hostname allowlist, and no platform can snapshot a session. * * Limits are enforced ceilings, not scheduling hints, and are validated when the stack is * planned. A platform that cannot enforce them rejects the sandbox rather than ignoring them. From 22e1767c8f5fe4017f32650abd684917850b6fe5 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:19:51 +0300 Subject: [PATCH 12/32] docs(sandbox): state what the code does, not what it used to do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment pass found eleven comments narrating the change rather than the code: a regression test explaining the bug it came from, three "used to" and "before this" asides, and a rationale stated twice — once at the orchestrating function and again at the call site. The deadline guard's reason now lives at `run_command`, and `execute_within` carries the local note plus a pointer. --- .../src/azure/sandbox_data_plane.rs | 2 +- crates/alien-bindings/src/provider.rs | 5 ++-- .../src/providers/sandbox/azure.rs | 29 +++++++++---------- .../src/providers/sandbox/gcp.rs | 5 ++-- crates/alien-core/src/resources/sandbox.rs | 14 ++++----- .../tests/generator/resource_layer_tests.rs | 10 +++---- .../src/emitters/azure/sandbox.rs | 4 +-- 7 files changed, 32 insertions(+), 37 deletions(-) diff --git a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs index 565a3fa37..9c6760033 100644 --- a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs +++ b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs @@ -2,7 +2,7 @@ //! //! A **second endpoint** from ARM, at `management..azuredevcompute.io`, gated by the //! `Container Apps SandboxGroup Data Owner` role. Subscription Owner returns 403 here, so -//! management permissions alone provision a group cleanly and then fail at first exec. +//! management permissions alone provision a group without error and then fail at first exec. //! //! Microsoft's published data-plane REST reference covers `sessionPools` only, so the contract //! below was read out of the `azure-containerapps-sandbox` PyPI package (0.1.0b4) rather than diff --git a/crates/alien-bindings/src/provider.rs b/crates/alien-bindings/src/provider.rs index be9277302..1099a3ee3 100644 --- a/crates/alien-bindings/src/provider.rs +++ b/crates/alien-bindings/src/provider.rs @@ -2231,9 +2231,8 @@ mod tests { /// The image in the binding has to be the image the provider uses. /// - /// This asserts the seam the previous code got wrong: the value was read from nowhere and a - /// literal was passed instead, so every session ran a stock image whatever the stack declared - /// — and nothing failed, because a sandbox on the wrong image still starts. + /// Asserted here because the failure is silent: a sandbox built from the wrong image still + /// starts, so nothing else catches a declared image that never reached the create call. #[cfg(feature = "azure")] #[tokio::test] async fn an_azure_sandbox_binding_carries_its_disk_image_to_the_provider() { diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 0b12b5bb0..22e37921e 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -16,8 +16,7 @@ use crate::traits::{ SandboxSession, SandboxSessionState, }; use alien_azure_clients::azure::sandbox_data_plane::{ - CreateSandbox, EgressHostRule, EgressPolicy, EgressRule, EgressRuleAction, EgressRuleMatch, - SandboxDataPlaneApi, + CreateSandbox, EgressHostRule, EgressPolicy, SandboxDataPlaneApi, }; use alien_client_core::ErrorData as ClientErrorData; use alien_core::{Platform, SandboxCapabilities, SandboxEgress}; @@ -484,17 +483,14 @@ impl AzureSandbox { reason } - /// Runs one shell string under the client-side guard. + /// Runs one shell string under the client-side guard, which is the deadline plus the grace + /// the in-session `timeout` needs to report back. See `run_command` for why the deadline is + /// enforced inside the session. /// - /// The guard is the deadline plus the grace the in-session `timeout` needs to report back. - /// When it fires the session itself did not end the command, so the session is ended, and - /// the call returns once that is confirmed — the same rule the agent-supervised backends - /// follow, where the agent waits for its kill before reporting: `deadlineExceeded` means the - /// command has stopped, never that a stop was requested. This is the one path where untrusted - /// code is known to be running past its deadline, so it is bounded rather than early: the - /// deadline, the grace, and the delete's confirmation window, and it is reached only by a - /// session that could not run `timeout` — every other overrun is ended in place, at the - /// deadline. + /// Reached only by a session that could not run `timeout`, so it is the one path where + /// untrusted code is known to be overrunning: the session is ended and the call returns once + /// that is confirmed, because `deadlineExceeded` has to mean the command stopped rather than + /// that a stop was asked for. async fn execute_within( &self, session_id: &str, @@ -804,6 +800,9 @@ fn is_not_found(error: &AlienError) -> bool { #[cfg(test)] mod tests { use super::*; + use alien_azure_clients::azure::sandbox_data_plane::{ + EgressRule, EgressRuleAction, EgressRuleMatch, + }; use alien_azure_clients::azure::sandbox_data_plane::ExecResult; use alien_azure_clients::azure::sandbox_data_plane::MockSandboxDataPlaneApi; use futures::StreamExt; @@ -907,15 +906,15 @@ mod tests { } /// The discriminating case. A throttle whose body mentions 404 — a trace id, an inner code, a - /// path — used to read as "the session is gone", which starts a second sandbox while the - /// first keeps running and reports a live session as terminated. + /// path — must not read as "the session is gone": that starts a second sandbox while the + /// first keeps running, reporting a live session as terminated. #[test] fn only_the_status_decides_whether_a_session_is_gone() { assert!(is_not_found(&http_error(404, "SandboxNotFound"))); // The shape the client actually produces: a 404 is returned as // `http_error.context(RemoteResourceNotFound)`, so the outer variant is the classified - // one. Matching only `HttpResponseError` made every real 404 read as a live session. + // one. Matching only `HttpResponseError` would read every real 404 as a live session. assert!( is_not_found(&AlienError::new(ClientErrorData::RemoteResourceNotFound { resource_type: "Sandbox".to_string(), diff --git a/crates/alien-bindings/src/providers/sandbox/gcp.rs b/crates/alien-bindings/src/providers/sandbox/gcp.rs index b38e5d0ac..0833f1141 100644 --- a/crates/alien-bindings/src/providers/sandbox/gcp.rs +++ b/crates/alien-bindings/src/providers/sandbox/gcp.rs @@ -634,8 +634,7 @@ done } /// The create argv, pinned. `--id` does not exist on `run`; the id is positional, and without - /// `--detach` the launcher stays attached until the control deadline kills it. Both were - /// wrong here, and neither could be caught by a fake that accepted any argv. + /// `--detach` the launcher stays attached until the control deadline kills it. #[tokio::test] async fn create_passes_the_id_positionally_and_detaches() { let directory = tempfile::tempdir().expect("temp dir"); @@ -659,7 +658,7 @@ done } /// A command with no deadline is a hang waiting for a slow day, in a sandbox running code the - /// caller does not control. Every other backend refuses it; this one did not. + /// caller does not control, so it is refused here as on every other backend. #[tokio::test] async fn a_command_without_a_deadline_is_refused() { let (_dir, sandbox) = launcher("exit 0"); diff --git a/crates/alien-core/src/resources/sandbox.rs b/crates/alien-core/src/resources/sandbox.rs index bb282b474..b517b77a2 100644 --- a/crates/alien-core/src/resources/sandbox.rs +++ b/crates/alien-core/src/resources/sandbox.rs @@ -468,9 +468,9 @@ impl Sandbox { pub fn validate_for_platform(&self, platform: Platform) -> Result<()> { let capabilities = SandboxCapabilities::for_platform(platform)?; - // No backend builds a sandbox image from source. Kubernetes turned this into an empty - // image string and a pod that could never schedule, which is the silent no-op the - // capability contract forbids — the failure has to land here instead. + // No backend builds a sandbox image from source: an empty image string schedules a pod + // that can never run, the silent no-op the capability contract forbids — the failure + // has to land here instead. if let SandboxCode::Source { .. } = &self.code { return Err(AlienError::new(ErrorData::SandboxLimitInvalid { resource_id: self.id.clone(), @@ -1255,9 +1255,9 @@ mod tests { ); } - /// `Source` is a public part of the type that no backend builds. Kubernetes used to turn it - /// into an empty image string, producing a pod that could never schedule — the refusal has to - /// happen at plan time and on every platform, not in one emitter. + /// `Source` is a public part of the type that no backend builds: an empty image string + /// schedules a pod that can never run, so the refusal has to happen at plan time and on + /// every platform, not in one emitter. #[test] fn source_code_is_refused_everywhere_rather_than_producing_a_broken_manifest() { let sandbox = Sandbox::new("agent".to_string()) @@ -1362,7 +1362,7 @@ mod tests { .expect_err("renaming a sandbox is not an update"); } - /// An idle-suspend policy is now declarable on Azure, and a wall-clock ceiling still is not. + /// Azure declares an idle-suspend policy but not a wall-clock ceiling. /// /// The two travel together in `SandboxSessionPolicy` and are gated separately on purpose: /// Azure suspends on idle and has no maximum lifetime, so accepting one and refusing the diff --git a/crates/alien-helm/tests/generator/resource_layer_tests.rs b/crates/alien-helm/tests/generator/resource_layer_tests.rs index 51f519092..03872b559 100644 --- a/crates/alien-helm/tests/generator/resource_layer_tests.rs +++ b/crates/alien-helm/tests/generator/resource_layer_tests.rs @@ -37,12 +37,10 @@ fn data_layer_emits_infrastructure_bindings() { assert_helm_valid(&chart, "data_layer"); } -/// The Kubernetes Frozen parent, which nothing emitted before this. -/// -/// Two things the chart owns and the operator does not: the NetworkPolicy that makes the declared -/// egress real, and the cluster-scoped RBAC the broker's `TokenReview` needs. Rendering is not -/// enough on its own — `assert_helm_valid` runs `helm lint`, `helm template` and `kubeconform`, so -/// a policy the API server would reject fails here rather than at install. +/// The Kubernetes Frozen parent owns two things the operator does not: the NetworkPolicy that +/// makes the declared egress real, and the cluster-scoped RBAC the broker's `TokenReview` needs. +/// Rendering is not enough on its own — `assert_helm_valid` runs `helm lint`, `helm template` and +/// `kubeconform`, so a policy the API server would reject fails here rather than at install. #[test] fn a_sandbox_emits_its_network_policy_and_the_brokers_rbac() { let stack = Stack::new("sandbox-chart".to_string()) diff --git a/crates/alien-terraform/src/emitters/azure/sandbox.rs b/crates/alien-terraform/src/emitters/azure/sandbox.rs index 49303384f..ddfa479b7 100644 --- a/crates/alien-terraform/src/emitters/azure/sandbox.rs +++ b/crates/alien-terraform/src/emitters/azure/sandbox.rs @@ -60,8 +60,8 @@ fn egress(sandbox: &Sandbox) -> Expression { /// /// The create body names a public catalog image, so a registry reference has nowhere to go. /// Refusing at plan time follows the AWS emitter: a reference the backend cannot honour is -/// rejected rather than quietly replaced, which is what happened before this existed — every -/// Azure session ran a stock image whatever the declaration said, with no error anywhere. +/// rejected rather than quietly replaced — silently ignoring it would run a stock image +/// whatever the declaration said, with no error anywhere. fn catalog_disk_image(sandbox: &Sandbox) -> Result { let unsupported = |reason: String| { AlienError::new(ErrorData::OperationNotSupported { From 52896f30b427d8c45ea2378ca845aad085b8e176 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:26:41 +0300 Subject: [PATCH 13/32] fix(sandbox): refuse a GCP session id the launcher would read as a flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `run` takes the sandbox id positionally and `--allow-egress` is one of its own flags, so an application passing `--allow-egress` as its session id was asking for the egress its binding had refused it — the one setting the binding decides rather than the caller. The doc comment three lines above says exactly that, and the argv defeated it. Reachable because create now works: the id used to sit behind a flag the launcher rejected outright. The id is checked wherever a caller supplies one — create, exec, the three file operations and terminate — rather than at the one verb that has `--allow-egress`, because every verb takes it positionally and each has its own flags. --- .../src/providers/sandbox/azure.rs | 17 ++-- .../src/providers/sandbox/gcp.rs | 81 +++++++++++++++++++ 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 22e37921e..685376b8d 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -195,9 +195,11 @@ impl Sandbox for AzureSandbox { // so this is the ordinary resting state rather than an edge. SandboxSessionState::Suspended => { self.resume(id).await?; - return self.await_running(id).await; + return self.await_running("sandbox.getOrCreate", id).await; + } + SandboxSessionState::Starting => { + return self.await_running("sandbox.getOrCreate", id).await } - SandboxSessionState::Starting => return self.await_running(id).await, SandboxSessionState::Running => return Ok(existing), } } @@ -419,12 +421,15 @@ impl AzureSandbox { }), // `create` owes the caller a session that can already take work, so the wait happens // here rather than in every caller. - _ => self.await_running(&sandbox.id).await, + _ => self.await_running(CREATE, &sandbox.id).await, } } /// Waits for a session to be able to take work. - async fn await_running(&self, session_id: &str) -> Result { + /// + /// The operation is the caller's, not this function's: a reconnect that waits is still a + /// reconnect, and reporting it as a create would mark a repeatable read unrepeatable. + async fn await_running(&self, operation: &str, session_id: &str) -> Result { let deadline = std::time::Instant::now() + SESSION_READY_TIMEOUT; loop { @@ -432,9 +437,9 @@ impl AzureSandbox { .client .get_sandbox(&self.sandbox_group, session_id) .await - .map_err(|error| Self::failed("sandbox.create", error))?; + .map_err(|error| Self::failed(operation, error))?; - match session_state("sandbox.create", sandbox.state.as_deref())? { + match session_state(operation, sandbox.state.as_deref())? { SandboxSessionState::Running => { return Ok(SandboxSession { session_id: sandbox.id, diff --git a/crates/alien-bindings/src/providers/sandbox/gcp.rs b/crates/alien-bindings/src/providers/sandbox/gcp.rs index 0833f1141..4f7c23559 100644 --- a/crates/alien-bindings/src/providers/sandbox/gcp.rs +++ b/crates/alien-bindings/src/providers/sandbox/gcp.rs @@ -25,6 +25,9 @@ use crate::traits::{ use alien_core::bindings::GcpSandboxBinding; use alien_core::sandbox_process::{self, ProcessFrame, ProcessStream, FRAME_CHANNEL_DEPTH}; use alien_core::{Platform, SandboxCapabilities}; + +/// Longest session id the launcher is asked to take, which is also a container name. +const MAX_SESSION_ID: usize = 63; use alien_error::AlienError; /// How much of one command's output is kept before the terminal frame reports truncation. @@ -147,6 +150,33 @@ impl GcpSandbox { } /// Builds `sandbox exec -- `. + /// A session id the launcher cannot read as one of its own options. + /// + /// The id is positional and `--allow-egress` is a flag on the same verb, so an id shaped like + /// a flag is an application asking to widen the egress its binding decided — and the argv is + /// built here, where a shell is not involved and quoting would not help. + fn checked_session_id(operation: &str, session_id: &str) -> Result<()> { + let usable = !session_id.is_empty() + && session_id.len() <= MAX_SESSION_ID + && session_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + && session_id.starts_with(|c: char| c.is_ascii_alphanumeric()); + + if usable { + return Ok(()); + } + + Err(AlienError::new(ErrorData::InvalidInput { + operation_context: operation.to_string(), + details: format!( + "session id '{session_id}' must start with a letter or digit and hold only \ + letters, digits, '-' and '_', at most {MAX_SESSION_ID} characters" + ), + field_name: Some("sessionId".to_string()), + })) + } + fn exec_arguments(&self, session_id: &str, command: &[String]) -> Vec { let mut arguments = vec!["exec".to_string(), session_id.to_string(), "--".to_string()]; arguments.extend(command.iter().cloned()); @@ -177,6 +207,7 @@ impl Sandbox for GcpSandbox { let session_id = request .session_id .unwrap_or_else(|| uuid::Uuid::new_v4().simple().to_string()); + Self::checked_session_id("sandbox.create", &session_id)?; // The id is positional and `--detach` is what makes this return: without it the launcher // stays attached and `control` waits out its deadline instead of handing back a session. @@ -232,6 +263,7 @@ impl Sandbox for GcpSandbox { session_id: &str, request: RunCommandRequest, ) -> Result>> { + Self::checked_session_id("sandbox.runCommand", session_id)?; if request.command.is_empty() { return Err(self.failed("sandbox.runCommand", "command is empty")); } @@ -293,6 +325,7 @@ impl Sandbox for GcpSandbox { } async fn read_file(&self, session_id: &str, path: &str) -> Result> { + Self::checked_session_id("sandbox.readFile", session_id)?; let path = self.checked_path(path, "sandbox.readFile")?; let command = vec!["/bin/cat".to_string(), path]; self.control( @@ -308,6 +341,7 @@ impl Sandbox for GcpSandbox { /// The cost is `ARG_MAX`: a file larger than roughly a megabyte needs a different transport, /// and fails loudly here rather than being silently truncated. async fn write_files(&self, session_id: &str, files: BTreeMap>) -> Result<()> { + Self::checked_session_id("sandbox.writeFiles", session_id)?; for (path, contents) in files { let path = self.checked_path(&path, "sandbox.writeFiles")?; let encoded = BASE64.encode(&contents); @@ -335,6 +369,7 @@ impl Sandbox for GcpSandbox { } async fn mkdir(&self, session_id: &str, path: &str) -> Result<()> { + Self::checked_session_id("sandbox.mkdir", session_id)?; let path = self.checked_path(path, "sandbox.mkdir")?; let command = vec!["/bin/mkdir".to_string(), "-p".to_string(), path]; self.control("sandbox.mkdir", &self.exec_arguments(session_id, &command)) @@ -365,6 +400,7 @@ impl Sandbox for GcpSandbox { } async fn terminate(&self, session_id: &str) -> Result<()> { + Self::checked_session_id("sandbox.terminate", session_id)?; self.control( "sandbox.terminate", &["delete".to_string(), session_id.to_string()], @@ -738,4 +774,49 @@ done .await .expect_err("a traversing path must be refused on write too"); } + + /// A session id shaped like a launcher option never reaches the launcher. + /// + /// The id is positional and `--allow-egress` is a flag on the same verb, so an application + /// passing one as its session id would be asking for the egress its binding refused it — the + /// one setting the binding decides rather than the caller. + #[tokio::test] + async fn an_option_shaped_session_id_is_refused_before_the_launcher_runs() { + let (_dir, sandbox) = launcher("exit 0"); + + for id in [ + "--allow-egress", + "-e", + "--env", + "", + "has space", + "semi;colon", + "-leading-dash", + ] { + let error = sandbox + .create(CreateSessionRequest { + session_id: Some(id.to_string()), + tenant_key: None, + env: BTreeMap::new(), + }) + .await + .expect_err(&format!("'{id}' must never reach the argv")); + assert_eq!(error.code, "INVALID_INPUT", "'{id}': {error}"); + + sandbox + .terminate(id) + .await + .expect_err(&format!("'{id}' must be refused on every verb that takes it")); + } + + // The shape the launcher is actually given, and the one this binding generates. + sandbox + .create(CreateSessionRequest { + session_id: Some("sbx-7f3a_01".to_string()), + tenant_key: None, + env: BTreeMap::new(), + }) + .await + .expect("an ordinary id is not refused"); + } } From 2fc9b99d8767ebd6e85bfa497021c616670d004a Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 04:36:24 +0300 Subject: [PATCH 14/32] fix(sandbox): close what the confirming review found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first fix round left three holes and opened one. `execute_shell_command` was the third body a caller controls in that file and the one that was missed: a shell command is where an app puts a token it wants the session to have, and a failure echoes the request into an error chain that reaches durable state. `settle` judged the egress policy on the create response. A create answered while the sandbox is still coming up need not carry the policy yet, and an absent policy reads as "the restriction did not take" — so every deny-declared create would have deleted the sandbox it just made. It waits for the sandbox to be running and judges what came up, which is also the read the containment check should have been making all along. `Stopping` mapped to `Running` so `suspend`'s completion poll would not answer early, and that routed a sandbox on its way down through the reconnect path as ready for work. The four states the trait publishes have no word for it, so it reads as unusable — the honest answer for everything that consumes the enum — and the wait and reconnect paths read the raw state, where the difference is the whole question. A session whose policy no longer matches the declaration was a permanent error from `get_or_create`, which owes the caller a usable session: it is now terminated and replaced, like a terminated one. `unmodelled` caught an unreadable field on the policy but not on its rules, so an exception list on a rule that otherwise reads as a plain deny still passed. The rule structs now model every field the SDK does and refuse anything past it. `SandboxCommandFailed` was fixed `internal = "false"` while wrapping cloud client errors that carry response text, and `into_external` reads only the outermost flag. It inherits, for the reason `SandboxUnreachable` already gives. An Azure session id is interpolated into the data-plane URL, where `..` resolves — reaching a sandbox group a stack-scoped identity can address but this binding was never scoped to. It is checked wherever a caller supplies one, as GCP's now is. And `run_command` re-reads the policy: the SDK hands it an arbitrary session id, so an id kept across a declaration change was the way around the check. --- .../src/azure/sandbox_data_plane.rs | 71 ++- crates/alien-bindings/src/error.rs | 8 +- .../src/providers/sandbox/azure.rs | 444 ++++++++++++++---- .../src/providers/sandbox/gcp.rs | 2 +- .../src/emitters/azure/sandbox.rs | 15 +- 5 files changed, 436 insertions(+), 104 deletions(-) diff --git a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs index 9c6760033..76b4b2f8a 100644 --- a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs +++ b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs @@ -76,9 +76,17 @@ pub struct EgressPolicy { /// /// The wire object also carries header transforms and URL rewrites. Neither is policy Alien can /// express, and modelling them would only add fields to keep in step. +/// Every field the SDK's own model reads, and nothing beyond it. +/// +/// `deny_unknown_fields` rather than a catch-all: an exception list or a second host on a rule +/// this client reads as a plain deny is reach the declaration never named, and a field that +/// deserializes into nothing is one no containment check can weigh. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct EgressRule { + /// Rule name, which carries no policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, /// What the rule matches. Absent means the data plane sent a rule this client cannot read, /// which is treated as unknown rather than as matching nothing. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -88,27 +96,45 @@ pub struct EgressRule { pub action: Option, } -/// The host a rule matches. +/// What a rule matches on. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct EgressRuleMatch { /// Host pattern the rule applies to. #[serde(default)] pub host: String, + /// Path prefix the rule narrows to. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + /// HTTP methods the rule narrows to. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub methods: Option>, } /// What a rule does when it matches. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct EgressRuleAction { /// `Allow`, `Deny`, `Transform` or `Rewrite`. #[serde(rename = "type", default)] pub action_type: String, + /// Host a `Rewrite` sends the request to instead. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host: Option, + /// Path a `Rewrite` sends the request to instead. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + /// Scheme a `Rewrite` sends the request over instead. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scheme: Option, + /// Headers a `Transform` sets, inserts or removes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub headers: Option>, } /// One host pattern and the action it carries. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct EgressHostRule { /// Host pattern, such as `api.example.com`. pub pattern: String, @@ -449,10 +475,13 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { .build()?; let signed = self.base.sign_request(request, &token).await?; - let response = self - .base - .execute_request(signed, "ExecuteShellCommand", sandbox_id) - .await?; + // The body is the command, which is where a caller puts a token it wants the session to + // have. + let response = alien_client_core::redact_request_body( + self.base + .execute_request(signed, "ExecuteShellCommand", sandbox_id) + .await, + )?; Self::parse(response, "ExecuteShellCommand").await } @@ -912,4 +941,28 @@ mod tests { client.resume_sandbox("grp", "s1").await.expect("resume is accepted"); resume.assert_async().await; } + + /// A key this client cannot read on a *rule* fails the parse, as it does on the policy. + /// + /// An exception list on a rule that otherwise reads as a plain deny is reach the declaration + /// never named, and a field that deserializes into nothing is one no check can weigh. + #[test] + fn an_unreadable_key_on_a_rule_fails_the_parse() { + for policy in [ + r#"{"defaultAction":"Deny","hostRules":[{"pattern":"*","action":"Deny","exceptions":["x"]}]}"#, + r#"{"defaultAction":"Deny","rules":[{"action":{"type":"Deny","exceptHosts":["x"]}}]}"#, + r#"{"defaultAction":"Deny","rules":[{"match":{"host":"*","exceptPorts":[443]}}]}"#, + ] { + serde_json::from_str::(policy) + .expect_err("a rule carrying an unreadable key must not parse"); + } + + // The documented surface still parses, so the rule above refuses additions rather than + // everything. + serde_json::from_str::( + r#"{"defaultAction":"Deny","rules":[{"name":"r","match":{"host":"*","path":"/","methods":["GET"]}, + "action":{"type":"Rewrite","host":"h","path":"/p","scheme":"https","headers":[]}}]}"#, + ) + .expect("every field the SDK models must still parse"); + } } diff --git a/crates/alien-bindings/src/error.rs b/crates/alien-bindings/src/error.rs index b66cf65c2..a5115aab5 100644 --- a/crates/alien-bindings/src/error.rs +++ b/crates/alien-bindings/src/error.rs @@ -301,11 +301,15 @@ pub enum ErrorData { }, /// A command run inside a sandbox did not complete. + /// + /// Visibility is inherited for the reason `SandboxUnreachable` gives below: what this wraps is + /// often a cloud client's error carrying the response text of the call that failed, and + /// `into_external` reads only the outermost flag — so a fixed `false` here would publish it. #[error( code = "SANDBOX_COMMAND_FAILED", message = "Sandbox command failed ({failure}): {reason}", retryable = "false", - internal = "false", + internal = "inherit", http_status_code = 400 )] SandboxCommandFailed { @@ -323,7 +327,7 @@ pub enum ErrorData { /// behind if deleting it also failed. #[error( code = "SANDBOX_NOT_AS_DECLARED", - message = "Sandbox session '{session_id}' came up without its declared {restriction}: {reason}", + message = "Sandbox session '{session_id}' does not carry its declared {restriction}, so it cannot be used; create a new session. {reason}", retryable = "false", internal = "false", http_status_code = 502 diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 685376b8d..007c71685 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -68,6 +68,32 @@ impl AzureSandbox { &self.disk_image } + /// A session id that stays one path segment. + /// + /// The id is interpolated into the data-plane URL, and `Url::parse` resolves `..` — so an id + /// carrying one addresses a different sandbox group, which a stack-scoped management identity + /// can reach. Azure mints ids itself; this bounds the ones a caller hands back. + fn checked_session_id(operation: &str, session_id: &str) -> Result<()> { + let usable = !session_id.is_empty() + && session_id.len() <= MAX_SESSION_ID + && session_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); + + if usable { + return Ok(()); + } + + Err(AlienError::new(ErrorData::InvalidInput { + operation_context: operation.to_string(), + details: format!( + "session id '{session_id}' must hold only letters, digits, '-' and '_', at most \ + {MAX_SESSION_ID} characters" + ), + field_name: Some("sessionId".to_string()), + })) + } + fn unsupported(&self, capability: &str) -> AlienError { AlienError::new(ErrorData::OperationNotSupported { operation: capability.to_string(), @@ -146,6 +172,7 @@ impl Sandbox for AzureSandbox { } async fn get(&self, session_id: &str) -> Result> { + Self::checked_session_id("sandbox.get", session_id)?; let sandbox = match self .client .get_sandbox(&self.sandbox_group, session_id) @@ -185,23 +212,31 @@ impl Sandbox for AzureSandbox { async fn get_or_create(&self, request: CreateSessionRequest) -> Result { if let Some(id) = request.session_id.as_deref() { - // A session on its way out is not one to reconnect to: the id will not run again, and - // handing it back trades an error now for a command that never lands. - if let Some(existing) = self.get(id).await? { - match existing.state { - SandboxSessionState::Terminated => {} - // `create` returns a session that can take work, and reaching one someone - // else started has to mean the same thing — an idle sandbox suspends itself, - // so this is the ordinary resting state rather than an edge. - SandboxSessionState::Suspended => { - self.resume(id).await?; - return self.await_running("sandbox.getOrCreate", id).await; - } - SandboxSessionState::Starting => { - return self.await_running("sandbox.getOrCreate", id).await - } - SandboxSessionState::Running => return Ok(existing), + match self.get(id).await { + // `create` returns a session that can take work, and reaching one someone else + // started has to mean the same thing. A suspended sandbox is the ordinary resting + // state once an idle policy is set, so the wait resumes it. + Ok(Some(existing)) if existing.state == SandboxSessionState::Running => { + return Ok(existing) + } + Ok(Some(existing)) if existing.state != SandboxSessionState::Terminated => { + let running = self.await_running(GET_OR_CREATE, id).await?; + return Ok(SandboxSession { + session_id: running.id, + state: SandboxSessionState::Running, + generation: 1, + }); } + // Terminated, or gone: both mean this id cannot serve, so a fresh session is what + // "get or create" owes the caller. + Ok(_) => {} + // A session the declaration no longer matches is as unusable as a terminated one, + // and leaving it running bills for a sandbox nothing can reach through this + // binding. Replaced rather than returned as a permanent error. + Err(error) if error.code == "SANDBOX_NOT_AS_DECLARED" => { + self.terminate(id).await?; + } + Err(error) => return Err(error), } } @@ -217,6 +252,18 @@ impl Sandbox for AzureSandbox { session_id: &str, request: RunCommandRequest, ) -> Result>> { + Self::checked_session_id(RUN_COMMAND, session_id)?; + // The only verb that starts untrusted code, so it is the one that re-reads the policy: a + // session id outlives a declaration change, and nothing else stands between an id a + // caller kept and the egress it was built with. One extra read against a data plane the + // command itself is about to cross. + self.get(session_id).await?.ok_or_else(|| { + AlienError::new(ErrorData::SandboxCommandFailed { + failure: "sessionGone".to_string(), + reason: format!("session '{session_id}' does not exist"), + }) + })?; + if request.deadline.is_zero() { return Err(AlienError::new(ErrorData::OperationNotSupported { operation: "sandbox.runCommand".to_string(), @@ -287,6 +334,7 @@ impl Sandbox for AzureSandbox { } async fn read_file(&self, session_id: &str, path: &str) -> Result> { + Self::checked_session_id("sandbox.readFile", session_id)?; let path = &checked_path("sandbox.readFile", path)?; self.client @@ -296,6 +344,7 @@ impl Sandbox for AzureSandbox { } async fn write_files(&self, session_id: &str, files: BTreeMap>) -> Result<()> { + Self::checked_session_id("sandbox.writeFiles", session_id)?; // Checked before anything is written: partial application is the contract for a data // plane that refuses midway, not for a path this process could have rejected first. let files = files @@ -317,6 +366,7 @@ impl Sandbox for AzureSandbox { } async fn mkdir(&self, session_id: &str, path: &str) -> Result<()> { + Self::checked_session_id("sandbox.mkdir", session_id)?; let path = &checked_path("sandbox.mkdir", path)?; self.client @@ -330,6 +380,7 @@ impl Sandbox for AzureSandbox { } async fn suspend(&self, session_id: &str) -> Result<()> { + Self::checked_session_id("sandbox.suspend", session_id)?; // Accepted, not completed — the same contract the AWS backend follows. A caller that // needs the session to have stopped polls `get` for `Suspended`. self.client @@ -339,6 +390,7 @@ impl Sandbox for AzureSandbox { } async fn resume(&self, session_id: &str) -> Result<()> { + Self::checked_session_id("sandbox.resume", session_id)?; self.client .resume_sandbox(&self.sandbox_group, session_id) .await @@ -350,6 +402,7 @@ impl Sandbox for AzureSandbox { } async fn terminate(&self, session_id: &str) -> Result<()> { + Self::checked_session_id("sandbox.terminate", session_id)?; self.accept_delete(session_id).await?; // The delete is accepted, not completed: the client's own contract is "returns before it @@ -360,14 +413,17 @@ impl Sandbox for AzureSandbox { // replacing a `deadlineExceeded` finding with a deserialization error on the one path // where untrusted code is known to be running past its deadline. for _ in 0..TERMINATE_POLL_ATTEMPTS { - match self + // A read that fails is not a session that is gone, and it is not a reason to stop + // looking either: the attempt budget decides, so one throttled response cannot end + // the poll that turns an accepted delete into a confirmed one. + if let Err(error) = self .client .get_sandbox(&self.sandbox_group, session_id) .await { - Err(error) if is_not_found(&error) => return Ok(()), - Err(error) => return Err(Self::failed("sandbox.terminate", error)), - Ok(_) => {} + if is_not_found(&error) { + return Ok(()); + } } tokio::time::sleep(TERMINATE_POLL_INTERVAL).await; } @@ -396,41 +452,50 @@ impl AzureSandbox { sandbox: &alien_azure_clients::azure::sandbox_data_plane::Sandbox, asked: Option<&EgressPolicy>, ) -> Result { + // The running sandbox is what gets judged, not the accept: a create response sent while + // the sandbox is still coming up need not carry the policy yet, and reading its absence + // as "the restriction did not take" would delete every sandbox that answered early. + let running = self.await_running(CREATE, &sandbox.id).await?; + // A restriction that did not take effect is worse than one that was never asked for: the - // caller believes the sandbox is contained. The response says what the sandbox is running - // under, so this is checked rather than assumed. + // caller believes the sandbox is contained. if let Some(asked) = asked { - if !policy_holds(asked, sandbox.egress_policy.as_ref()) { + if !policy_holds(asked, running.egress_policy.as_ref()) { return Err(AlienError::new(ErrorData::SandboxNotAsDeclared { - session_id: sandbox.id.clone(), + session_id: running.id, restriction: "egress policy".to_string(), reason: format!( "it came up with {} where the declaration asks for {}", - describe(sandbox.egress_policy.as_ref()), + describe(running.egress_policy.as_ref()), describe(Some(asked)) ), })); } } - match session_state(CREATE, sandbox.state.as_deref())? { - SandboxSessionState::Running => Ok(SandboxSession { - session_id: sandbox.id.clone(), - state: SandboxSessionState::Running, - generation: 1, - }), - // `create` owes the caller a session that can already take work, so the wait happens - // here rather than in every caller. - _ => self.await_running(CREATE, &sandbox.id).await, - } + Ok(SandboxSession { + session_id: running.id, + state: SandboxSessionState::Running, + generation: 1, + }) } /// Waits for a session to be able to take work. /// /// The operation is the caller's, not this function's: a reconnect that waits is still a /// reconnect, and reporting it as a create would mark a repeatable read unrepeatable. - async fn await_running(&self, operation: &str, session_id: &str) -> Result { + /// + /// A suspended session is resumed rather than waited on — on the create path an idle policy + /// can stop a sandbox before its first command, and on the reconnect path a stopped sandbox + /// is the ordinary resting state. Nothing else brings one up, so waiting alone would spend + /// the whole deadline and then delete it. + async fn await_running( + &self, + operation: &str, + session_id: &str, + ) -> Result { let deadline = std::time::Instant::now() + SESSION_READY_TIMEOUT; + let mut resumed = false; loop { let sandbox = self @@ -439,23 +504,26 @@ impl AzureSandbox { .await .map_err(|error| Self::failed(operation, error))?; - match session_state(operation, sandbox.state.as_deref())? { - SandboxSessionState::Running => { - return Ok(SandboxSession { - session_id: sandbox.id, - state: SandboxSessionState::Running, - generation: 1, - }) + // The raw state, because the four the trait publishes cannot separate a sandbox on + // its way up from one on its way down, and this loop needs that difference. + match sandbox.state.as_deref() { + Some("Running") => return Ok(sandbox), + Some("Creating" | "Resuming") => {} + // Going down, or already down. Either way nothing is bringing it up. + Some("Stopping" | "Stopped" | "Suspended" | "Idle") => { + if !resumed { + self.resume(session_id).await?; + resumed = true; + } } - // Only a session on its way up is worth waiting for. A terminated one never - // becomes runnable, and folding it into the timeout would report it a minute late - // as a slow boot. - SandboxSessionState::Starting | SandboxSessionState::Suspended => {} - SandboxSessionState::Terminated => { + // A terminated session never becomes runnable, and folding it into the timeout + // would report it a minute late as a slow boot. + other => { + session_state(operation, other)?; return Err(AlienError::new(ErrorData::SandboxCommandFailed { failure: "sessionTerminated".to_string(), reason: format!("session '{session_id}' is being deleted"), - })) + })); } } @@ -478,14 +546,22 @@ impl AzureSandbox { /// but it must not vanish either: the session id is in the error, and a failed delete leaves /// a sandbox only that id can find. async fn discard(&self, session_id: &str, reason: AlienError) -> AlienError { - if let Err(error) = self.accept_delete(session_id).await { - warn!( - session = %session_id, - %error, - "could not delete a sandbox that was never handed to its caller" - ); - } - reason + let Err(error) = self.accept_delete(session_id).await else { + return reason; + }; + + warn!( + session = %session_id, + %error, + "could not delete a sandbox that was never handed to its caller" + ); + // A fixed clause rather than the delete's own error: that text is the cloud client's, and + // this variant is externally visible. + reason.context(ErrorData::SandboxNotAsDeclared { + session_id: session_id.to_string(), + restriction: "egress policy".to_string(), + reason: "it could not be deleted either, so it is still running".to_string(), + }) } /// Runs one shell string under the client-side guard, which is the deadline plus the grace @@ -570,12 +646,13 @@ fn bounded_shell(command: &[String], deadline: std::time::Duration) -> String { ) } -/// Refuses a caller's path before it reaches the data plane. +/// Refuses a caller's path before it reaches the data plane, and returns what to send. /// -/// Whether the server bounds a path to a root is undocumented and unmeasured, so this is the only -/// confinement there is, and it is a client-side rule rather than a guarantee. Relative only: -/// Azure exposes no session root to rewrite an absolute path against, so accepting one would hand -/// the caller the sandbox's whole filesystem instead of its own directory. +/// This refuses traversal syntax; it establishes no root. Whether the data plane bounds a path is +/// undocumented and unmeasured, so no rule here can promise confinement — what it promises is +/// that a path cannot name a parent. A leading slash is trimmed rather than refused because it +/// means "under the session's own root" on every other backend, and refusing it would make the +/// one shape portable code writes the one shape this backend rejects. fn checked_path(operation: &str, path: &str) -> Result { let refused = |details: &str| { Err(AlienError::new(ErrorData::InvalidInput { @@ -702,6 +779,15 @@ fn policy_holds(asked: &EgressPolicy, effective: Option<&EgressPolicy>) -> bool fn describe(effective: Option<&EgressPolicy>) -> String { match effective { None => "no policy at all".to_string(), + Some(policy) if !policy.unmodelled.is_empty() => format!( + "a policy carrying {}, which this client cannot weigh", + policy + .unmodelled + .keys() + .map(String::as_str) + .collect::>() + .join(", ") + ), Some(policy) => format!( "default action '{}' under {} inspection, {} host rules and {} match rules", policy.default_action, @@ -725,11 +811,10 @@ fn session_state(operation: &str, state: Option<&str>) -> Result Ok(SandboxSessionState::Running), - Some("Stopped" | "Suspended" | "Idle") => Ok(SandboxSessionState::Suspended), + // A sandbox on its way down is not one to send work to, and the four states the trait + // publishes have no word for "stopping" — so it reads as unusable. Anything that has to + // tell "going down" from "already down" reads the raw state instead. + Some("Stopping" | "Stopped" | "Suspended" | "Idle") => Ok(SandboxSessionState::Suspended), Some("Deleting") => Ok(SandboxSessionState::Terminated), other => Err(AlienError::new(ErrorData::UnexpectedResponseFormat { provider: "azure".to_string(), @@ -750,12 +835,16 @@ const FULL_INSPECTION: &str = "Full"; /// The host pattern that matches everything, so `deny` is a rule rather than only a default. const EVERY_HOST: &str = "*"; +/// Longest session id the data plane is addressed with, matching the launcher-side bound. +const MAX_SESSION_ID: usize = 63; + /// The two operations a repeat could perform twice. /// /// `create` is a PUT to a collection with a server-minted id, so a second attempt makes a second /// sandbox — and with no enumeration verb, the first one has no id-holder and nothing to reap it. const RUN_COMMAND: &str = "sandbox.runCommand"; const CREATE: &str = "sandbox.create"; +const GET_OR_CREATE: &str = "sandbox.getOrCreate"; /// How long a session has to become able to take work, and how often that is checked. const SESSION_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120); @@ -822,6 +911,13 @@ mod tests { }) } + /// Answers the readiness read every create makes, with the policy the sandbox came up under. + fn settles_running(client: &mut MockSandboxDataPlaneApi, egress: Option) { + client + .expect_get_sandbox() + .returning(move |_, id| Ok(running(id, egress.clone()))); + } + fn sandbox_with(client: MockSandboxDataPlaneApi) -> AzureSandbox { AzureSandbox::new( std::sync::Arc::new(client), @@ -853,6 +949,7 @@ mod tests { state: Some("Running".to_string()), }) }); + settles_running(&mut client, None); let sandbox = AzureSandbox::new( std::sync::Arc::new(client), @@ -1386,6 +1483,7 @@ mod tests { assert!(unreachable.retryable, "a read is safe to repeat: {unreachable}"); let mut client = MockSandboxDataPlaneApi::new(); + settles_running(&mut client, None); client .expect_execute_shell_command() .times(1) @@ -1415,8 +1513,8 @@ mod tests { ("Running", SandboxSessionState::Running), ("Creating", SandboxSessionState::Starting), ("Resuming", SandboxSessionState::Starting), - // Still up: a sandbox that has been asked to stop has not stopped. - ("Stopping", SandboxSessionState::Running), + // On its way down, and the four states the trait publishes have no word for it. + ("Stopping", SandboxSessionState::Suspended), ("Stopped", SandboxSessionState::Suspended), ("Suspended", SandboxSessionState::Suspended), ("Idle", SandboxSessionState::Suspended), @@ -1554,6 +1652,19 @@ mod tests { ); Ok(running("s1", Some(policy))) }); + settles_running( + &mut client, + Some(EgressPolicy { + default_action: "Deny".to_string(), + host_rules: vec![EgressHostRule { + pattern: "*".to_string(), + action: "Deny".to_string(), + }], + rules: Vec::new(), + unmodelled: Default::default(), + traffic_inspection: Some("Full".to_string()), + }), + ); sandbox_denying(client, SandboxEgress::Deny) .create(CreateSessionRequest::default()) .await @@ -1576,6 +1687,19 @@ mod tests { ); Ok(running("s1", Some(policy))) }); + settles_running( + &mut client, + Some(EgressPolicy { + default_action: "Deny".to_string(), + host_rules: vec![EgressHostRule { + pattern: "api.example.com".to_string(), + action: "Allow".to_string(), + }], + rules: Vec::new(), + unmodelled: Default::default(), + traffic_inspection: Some("Full".to_string()), + }), + ); sandbox_denying( client, SandboxEgress::AllowDomains { @@ -1597,6 +1721,7 @@ mod tests { ); Ok(running("s1", None)) }); + settles_running(&mut client, None); sandbox_denying(client, SandboxEgress::Allow) .create(CreateSessionRequest::default()) .await @@ -1632,6 +1757,7 @@ mod tests { .expect_create_sandbox() .times(1) .returning(move |_, _| Ok(running("s1", effective.clone()))); + settles_running(&mut client, came_up_with.clone()); client .expect_delete_sandbox() .withf(|_, id| id == "s1") @@ -1653,21 +1779,22 @@ mod tests { #[tokio::test] async fn a_missing_host_rule_fails_the_create() { let mut client = MockSandboxDataPlaneApi::new(); - client.expect_create_sandbox().times(1).returning(|_, _| { - Ok(running( - "s1", - Some(EgressPolicy { - default_action: "Deny".to_string(), - unmodelled: Default::default(), - rules: Vec::new(), - host_rules: vec![EgressHostRule { - pattern: "elsewhere.example.com".to_string(), - action: "Allow".to_string(), - }], - traffic_inspection: Some("Full".to_string()), - }), - )) - }); + let elsewhere = EgressPolicy { + default_action: "Deny".to_string(), + unmodelled: Default::default(), + rules: Vec::new(), + host_rules: vec![EgressHostRule { + pattern: "elsewhere.example.com".to_string(), + action: "Allow".to_string(), + }], + traffic_inspection: Some("Full".to_string()), + }; + let echoed = elsewhere.clone(); + client + .expect_create_sandbox() + .times(1) + .returning(move |_, _| Ok(running("s1", Some(echoed.clone())))); + settles_running(&mut client, Some(elsewhere)); client.expect_delete_sandbox().times(1).returning(|_, _| Ok(())); let error = sandbox_denying( @@ -1701,6 +1828,7 @@ mod tests { .expect_create_sandbox() .times(1) .returning(|_, _| Ok(running("fresh", None))); + settles_running(&mut client, None); let session = sandbox_with(client) .get_or_create(CreateSessionRequest { @@ -1750,11 +1878,18 @@ mod tests { unmodelled: Default::default(), host_rules: vec![declared.clone()], rules: vec![EgressRule { + name: None, r#match: Some(EgressRuleMatch { host: "*".to_string(), + path: None, + methods: None, }), action: Some(EgressRuleAction { action_type: "Allow".to_string(), + host: None, + path: None, + scheme: None, + headers: None, }), }], traffic_inspection: Some("Full".to_string()), @@ -1766,6 +1901,7 @@ mod tests { .expect_create_sandbox() .times(1) .returning(move |_, _| Ok(running("s1", Some(effective.clone())))); + settles_running(&mut client, Some(came_up_with.clone())); client.expect_delete_sandbox().times(1).returning(|_, _| Ok(())); let error = sandbox_denying(client, asked_for()) @@ -1794,6 +1930,19 @@ mod tests { }), )) }); + settles_running( + &mut client, + Some(EgressPolicy { + default_action: "Deny".to_string(), + unmodelled: Default::default(), + host_rules: vec![EgressHostRule { + pattern: "api.example.com".to_string(), + action: "Allow".to_string(), + }], + rules: Vec::new(), + traffic_inspection: Some("Full".to_string()), + }), + ); sandbox_denying(client, asked_for()) .create(CreateSessionRequest::default()) .await @@ -1845,6 +1994,7 @@ mod tests { .withf(|_, request| request.idle_suspend_seconds == Some(900)) .times(1) .returning(|_, _| Ok(running("s1", None))); + settles_running(&mut client, None); AzureSandbox::new( std::sync::Arc::new(client), @@ -1888,13 +2038,15 @@ mod tests { #[tokio::test] async fn a_create_that_cannot_be_read_deletes_what_it_made() { let mut client = MockSandboxDataPlaneApi::new(); - client.expect_create_sandbox().times(1).returning(|_, _| { + let unreadable = || { Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox { id: "orphan".to_string(), egress_policy: None, state: Some("Hibernated".to_string()), }) - }); + }; + client.expect_create_sandbox().times(1).returning(move |_, _| unreadable()); + client.expect_get_sandbox().returning(move |_, _| unreadable()); client .expect_delete_sandbox() .withf(|_, id| id == "orphan") @@ -1952,6 +2104,7 @@ mod tests { .expect_create_sandbox() .times(1) .returning(move |_, _| Ok(running("s1", Some(effective.clone())))); + settles_running(&mut client, Some(came_up_with.clone())); client.expect_delete_sandbox().times(1).returning(|_, _| Ok(())); let error = sandbox_denying(client, declared()) @@ -1979,9 +2132,128 @@ mod tests { }), )) }); + settles_running( + &mut client, + Some(EgressPolicy { + default_action: "deny".to_string(), + host_rules: vec![EgressHostRule { + pattern: "*".to_string(), + action: "deny".to_string(), + }], + rules: Vec::new(), + unmodelled: Default::default(), + traffic_inspection: Some("full".to_string()), + }), + ); sandbox_denying(client, declared()) .create(CreateSessionRequest::default()) .await .expect("a normalised echo of the same policy is the same policy"); } + + /// A session the declaration no longer matches is replaced, not a permanent error. + /// + /// `get_or_create` owes the caller a usable session, and a stale-policy sandbox is as + /// unusable as a terminated one — returning the refusal forever would leave the caller with + /// no way forward and the old sandbox still running. + #[tokio::test] + async fn a_stale_policy_session_is_replaced_rather_than_refused_forever() { + let mut client = MockSandboxDataPlaneApi::new(); + // The stale session answers once, is deleted, and is gone from then on; the fresh one + // answers its own readiness read. + let mut stale_reads = 0; + client.expect_get_sandbox().returning(move |_, id| { + if id != "built-under-allow" { + return Ok(running( + id, + Some(EgressPolicy { + default_action: "Deny".to_string(), + host_rules: vec![EgressHostRule { + pattern: "*".to_string(), + action: "Deny".to_string(), + }], + rules: Vec::new(), + unmodelled: Default::default(), + traffic_inspection: Some("Full".to_string()), + }), + )); + } + stale_reads += 1; + if stale_reads == 1 { + Ok(running(id, None)) + } else { + Err(http_error(404, "SandboxNotFound")) + } + }); + client + .expect_delete_sandbox() + .withf(|_, id| id == "built-under-allow") + .times(1) + .returning(|_, _| Ok(())); + client + .expect_create_sandbox() + .times(1) + .returning(|_, request| Ok(running("fresh", request.egress))); + + let session = sandbox_denying(client, SandboxEgress::Deny) + .get_or_create(CreateSessionRequest { + session_id: Some("built-under-allow".to_string()), + tenant_key: None, + env: BTreeMap::new(), + }) + .await + .expect("a stale session is replaced"); + + assert_eq!(session.session_id, "fresh"); + } + + /// A session id is one path segment, because it is interpolated into the data-plane URL and + /// `..` in a URL resolves — reaching a sandbox group this binding was never scoped to. + #[tokio::test] + async fn a_traversing_session_id_never_reaches_the_data_plane() { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_get_sandbox().never(); + client.expect_delete_sandbox().never(); + client.expect_execute_shell_command().never(); + let sandbox = sandbox_with(client); + + for id in ["../../other-group/sandboxes/theirs", "a/b", "", "has space"] { + assert_eq!( + sandbox + .get(id) + .await + .expect_err(&format!("'{id}' must be refused")) + .code, + "INVALID_INPUT" + ); + sandbox + .terminate(id) + .await + .expect_err(&format!("'{id}' must be refused on every verb")); + } + } + + /// A stale session cannot run code, which is the one verb where it matters most. + /// + /// An id outlives a declaration change and the SDK hands `runCommand` an arbitrary string, so + /// without this the containment check is one a caller can walk around by keeping an id. + #[tokio::test] + async fn a_stale_policy_session_cannot_run_a_command() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_get_sandbox() + .times(1) + .returning(|_, id| Ok(running(id, None))); + client.expect_execute_shell_command().never(); + + let error = match sandbox_denying(client, SandboxEgress::Deny) + .run_command("built-under-allow", command(5)) + .await + { + Ok(_) => panic!("a session without the declared policy must not run code"), + Err(error) => error, + }; + + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + } } diff --git a/crates/alien-bindings/src/providers/sandbox/gcp.rs b/crates/alien-bindings/src/providers/sandbox/gcp.rs index 4f7c23559..4959feeb2 100644 --- a/crates/alien-bindings/src/providers/sandbox/gcp.rs +++ b/crates/alien-bindings/src/providers/sandbox/gcp.rs @@ -25,10 +25,10 @@ use crate::traits::{ use alien_core::bindings::GcpSandboxBinding; use alien_core::sandbox_process::{self, ProcessFrame, ProcessStream, FRAME_CHANNEL_DEPTH}; use alien_core::{Platform, SandboxCapabilities}; +use alien_error::AlienError; /// Longest session id the launcher is asked to take, which is also a container name. const MAX_SESSION_ID: usize = 63; -use alien_error::AlienError; /// How much of one command's output is kept before the terminal frame reports truncation. const OUTPUT_CAP: usize = 8 * 1024 * 1024; diff --git a/crates/alien-terraform/src/emitters/azure/sandbox.rs b/crates/alien-terraform/src/emitters/azure/sandbox.rs index ddfa479b7..46546e919 100644 --- a/crates/alien-terraform/src/emitters/azure/sandbox.rs +++ b/crates/alien-terraform/src/emitters/azure/sandbox.rs @@ -22,7 +22,7 @@ use hcl::expr::Expression; #[derive(Debug, Clone, Copy, Default)] pub struct AzureSandboxEmitter; -/// The group name the runtime controller creates and the data plane addresses. +/// The group name the data plane is addressed by. /// /// Derived rather than emitted as a resource: both sides compute it from the same prefix and id, /// so there is nothing to look up and nothing to keep in step. The prefix must be the resolved @@ -74,12 +74,15 @@ fn catalog_disk_image(sandbox: &Sandbox) -> Result { // A tag is the shape that gets through unnoticed: `ubuntu:24.04` has no slash, renders // into the customer's module, plans and applies, and fails at the first session. SandboxCode::Image { image } - if image.contains('/') || image.contains(':') || image.contains('@') => + if image.trim().is_empty() + || image.contains('/') + || image.contains(':') + || image.contains('@') => { Err(unsupported(format!( "Azure creates a sandbox from a public catalog disk image, so code.image must be \ - a bare catalog name such as 'ubuntu' — '{image}' carries a registry path, tag or \ - digest, which the data plane has nowhere to put" + a bare catalog name such as 'ubuntu'; '{image}' is empty or carries a registry \ + path, tag or digest, which the data plane has nowhere to put" ))) } SandboxCode::Image { image } => Ok(image.clone()), @@ -91,8 +94,8 @@ fn catalog_disk_image(sandbox: &Sandbox) -> Result { impl TfEmitter for AzureSandboxEmitter { fn emit(&self, _ctx: &EmitContext<'_>) -> Result { - // Deliberately empty: see the module note. A group created here would sit idle until a - // session asked for one, and the controller would have to reconcile against it anyway. + // Deliberately empty: see the module note. A group emitted here would sit idle until a + // session asked for one, and it is addressed by name rather than by reference. Ok(TfFragment::default()) } From ca7f8165041482b19b8fe3ea45ca3b8c3ea02396 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 05:02:13 +0300 Subject: [PATCH 15/32] fix(sandbox): judge a session's state before its policy, and gate resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `get` read the egress policy before the state, so a session being deleted — which carries no policy — was reported as one running the wrong one. Under a `deny` declaration that sent a disappearing sandbox down the replace path instead of the terminated one. The test that should have caught it declared `allow`, which skips the check entirely; it declares `deny` now. `resume` is the other verb that puts code back on the network, and it did no policy read, so an id kept across a declaration change could be resumed around the check that `run_command` performs. The wait resumes through an ungated path instead, because a sandbox mid-boot has no policy to judge yet — gating both would re-break the case `settle` was restructured to fix. Reconnecting now re-judges the policy on the sandbox that came up rather than the one that was found asleep: a group-scoped policy is set somewhere this binding never writes, so the read before the wait is not the read that decides. And `discard` names the sandbox it left behind rather than attributing every failure to the egress policy — a readiness timeout reached the caller as a containment failure, pointing at a restriction that was never the finding. --- .../src/providers/sandbox/azure.rs | 283 ++++++++++++++---- 1 file changed, 231 insertions(+), 52 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 007c71685..f0fce57a6 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -160,12 +160,17 @@ impl Sandbox for AzureSandbox { .map_err(|error| Self::failed(CREATE, error))?; // The caller's requested id is not authoritative: Azure allocates the id, and returning - // the requested one would hand back a handle that addresses nothing. + // the requested one would hand back a handle that addresses nothing. Checked because + // every later verb addresses the sandbox by it, and one this client cannot send is one + // nothing can reach or reap. let _ = request.session_id; + if let Err(error) = Self::checked_session_id(CREATE, &sandbox.id) { + return Err(self.discard(&sandbox.id, error).await); + } // Everything past this point owns a sandbox the caller has no id for, so every failure // deletes it. Azure allocates the id, so the one in this response was minted by this call. - match self.settle(&sandbox, asked.as_ref()).await { + match self.settle(&sandbox).await { Ok(session) => Ok(session), Err(error) => Err(self.discard(&sandbox.id, error).await), } @@ -185,27 +190,23 @@ impl Sandbox for AzureSandbox { Err(error) => return Err(Self::failed("sandbox.get", error)), }; + let state = session_state("sandbox.get", sandbox.state.as_deref())?; + // Checked here as well as at create, because this is the path a reconnect takes: a // session created under an older declaration outlives the change — Azure has no session // ceiling, and an idle sandbox only suspends — so a caller holding its id would otherwise // be handed a sandbox whose containment is whatever it was built with. - if let Some(asked) = egress_policy(&self.egress) { - if !policy_holds(&asked, sandbox.egress_policy.as_ref()) { - return Err(AlienError::new(ErrorData::SandboxNotAsDeclared { - session_id: sandbox.id, - restriction: "egress policy".to_string(), - reason: format!( - "it is running {} where the declaration asks for {}", - describe(sandbox.egress_policy.as_ref()), - describe(Some(&asked)) - ), - })); - } + // + // Not a session on its way out: a sandbox being deleted carries no policy to judge, and + // reading that absence as a mismatch would report a disappearing session as an + // uncontained one. + if state != SandboxSessionState::Terminated { + self.policy_must_hold(&sandbox)?; } Ok(Some(SandboxSession { session_id: sandbox.id, - state: session_state("sandbox.get", sandbox.state.as_deref())?, + state, generation: 1, })) } @@ -220,7 +221,10 @@ impl Sandbox for AzureSandbox { return Ok(existing) } Ok(Some(existing)) if existing.state != SandboxSessionState::Terminated => { + // Judged again on the sandbox that came up: a policy set on the group can + // change while a session is suspended, and the read above saw a stopped one. let running = self.await_running(GET_OR_CREATE, id).await?; + self.policy_must_hold(&running)?; return Ok(SandboxSession { session_id: running.id, state: SandboxSessionState::Running, @@ -253,6 +257,13 @@ impl Sandbox for AzureSandbox { request: RunCommandRequest, ) -> Result>> { Self::checked_session_id(RUN_COMMAND, session_id)?; + if request.deadline.is_zero() { + return Err(AlienError::new(ErrorData::OperationNotSupported { + operation: "sandbox.runCommand".to_string(), + reason: "a command must carry a non-zero deadline".to_string(), + })); + } + // The only verb that starts untrusted code, so it is the one that re-reads the policy: a // session id outlives a declaration change, and nothing else stands between an id a // caller kept and the egress it was built with. One extra read against a data plane the @@ -264,13 +275,6 @@ impl Sandbox for AzureSandbox { }) })?; - if request.deadline.is_zero() { - return Err(AlienError::new(ErrorData::OperationNotSupported { - operation: "sandbox.runCommand".to_string(), - reason: "a command must carry a non-zero deadline".to_string(), - })); - } - // The deadline bounds the untrusted code, not the caller's patience. Read out of the // preview SDK rather than assumed: `executeShellCommand` sends `command` and an optional // `workingDirectory` and nothing else, so there is no server-side timeout to ask for. The @@ -381,8 +385,9 @@ impl Sandbox for AzureSandbox { async fn suspend(&self, session_id: &str) -> Result<()> { Self::checked_session_id("sandbox.suspend", session_id)?; - // Accepted, not completed — the same contract the AWS backend follows. A caller that - // needs the session to have stopped polls `get` for `Suspended`. + // Accepted, not completed — the same contract the AWS backend follows. `get` reports + // `Suspended` from the moment the stop is under way, so it answers "cannot take work", + // not "has stopped"; only `terminate` confirms a session is actually gone. self.client .stop_sandbox(&self.sandbox_group, session_id) .await @@ -391,10 +396,17 @@ impl Sandbox for AzureSandbox { async fn resume(&self, session_id: &str) -> Result<()> { Self::checked_session_id("sandbox.resume", session_id)?; - self.client - .resume_sandbox(&self.sandbox_group, session_id) - .await - .map_err(|error| Self::failed("sandbox.resume", error)) + // Waking a session puts whatever it was running back on the network, so it is gated like + // `run_command` and unlike the file operations. `await_running` resumes without this, + // because a sandbox mid-boot has no policy to judge yet. + self.get(session_id).await?.ok_or_else(|| { + AlienError::new(ErrorData::SandboxCommandFailed { + failure: "sessionGone".to_string(), + reason: format!("session '{session_id}' does not exist"), + }) + })?; + + self.resume_unchecked(session_id).await } async fn snapshot(&self, _session_id: &str) -> Result { @@ -424,6 +436,7 @@ impl Sandbox for AzureSandbox { if is_not_found(&error) { return Ok(()); } + warn!(session = %session_id, %error, "could not confirm a sandbox is gone"); } tokio::time::sleep(TERMINATE_POLL_INTERVAL).await; } @@ -443,6 +456,41 @@ impl Sandbox for AzureSandbox { } impl AzureSandbox { + /// Wakes a session without judging it, for the wait that has nothing to judge yet. + async fn resume_unchecked(&self, session_id: &str) -> Result<()> { + self.client + .resume_sandbox(&self.sandbox_group, session_id) + .await + .map_err(|error| Self::failed("sandbox.resume", error)) + } + + /// Refuses a sandbox that is not running the policy the declaration asked for. + /// + /// The effective policy can change under a live session — a group-scoped policy is set + /// somewhere this binding never writes — so every path that hands one back checks, not just + /// the one that created it. + fn policy_must_hold( + &self, + sandbox: &alien_azure_clients::azure::sandbox_data_plane::Sandbox, + ) -> Result<()> { + let Some(asked) = egress_policy(&self.egress) else { + return Ok(()); + }; + if policy_holds(&asked, sandbox.egress_policy.as_ref()) { + return Ok(()); + } + + Err(AlienError::new(ErrorData::SandboxNotAsDeclared { + session_id: sandbox.id.clone(), + restriction: "egress policy".to_string(), + reason: format!( + "it is running {} where the declaration asks for {}", + describe(sandbox.egress_policy.as_ref()), + describe(Some(&asked)) + ), + })) + } + /// Turns a freshly created sandbox into a session, or says why it is not one. /// /// Every check that can fail after the sandbox exists lives here, so `create` has one place @@ -450,7 +498,6 @@ impl AzureSandbox { async fn settle( &self, sandbox: &alien_azure_clients::azure::sandbox_data_plane::Sandbox, - asked: Option<&EgressPolicy>, ) -> Result { // The running sandbox is what gets judged, not the accept: a create response sent while // the sandbox is still coming up need not carry the policy yet, and reading its absence @@ -459,19 +506,7 @@ impl AzureSandbox { // A restriction that did not take effect is worse than one that was never asked for: the // caller believes the sandbox is contained. - if let Some(asked) = asked { - if !policy_holds(asked, running.egress_policy.as_ref()) { - return Err(AlienError::new(ErrorData::SandboxNotAsDeclared { - session_id: running.id, - restriction: "egress policy".to_string(), - reason: format!( - "it came up with {} where the declaration asks for {}", - describe(running.egress_policy.as_ref()), - describe(Some(asked)) - ), - })); - } - } + self.policy_must_hold(&running)?; Ok(SandboxSession { session_id: running.id, @@ -512,8 +547,12 @@ impl AzureSandbox { // Going down, or already down. Either way nothing is bringing it up. Some("Stopping" | "Stopped" | "Suspended" | "Idle") => { if !resumed { - self.resume(session_id).await?; resumed = true; + // A resume racing a sandbox that is still stopping answers 409, which is + // the wait's business rather than the caller's: the budget decides. + if let Err(error) = self.resume_unchecked(session_id).await { + warn!(session = %session_id, %error, "resume was refused; still waiting"); + } } } // A terminated session never becomes runnable, and folding it into the timeout @@ -555,12 +594,16 @@ impl AzureSandbox { %error, "could not delete a sandbox that was never handed to its caller" ); - // A fixed clause rather than the delete's own error: that text is the cloud client's, and - // this variant is externally visible. - reason.context(ErrorData::SandboxNotAsDeclared { - session_id: session_id.to_string(), - restriction: "egress policy".to_string(), - reason: "it could not be deleted either, so it is still running".to_string(), + // Names the leak rather than the reason for it: a timeout and a policy mismatch both + // reach here, and reporting either as the other sends the reader somewhere false. The + // original reason stays on the chain. The clause is fixed text, because the delete's own + // error is the cloud client's and this variant is externally visible. + reason.context(ErrorData::SandboxCommandFailed { + failure: "sandboxLeftBehind".to_string(), + reason: format!( + "session '{session_id}' was not handed to its caller and could not be deleted, \ + so it is still running" + ), }) } @@ -1827,10 +1870,24 @@ mod tests { client .expect_create_sandbox() .times(1) - .returning(|_, _| Ok(running("fresh", None))); - settles_running(&mut client, None); + .returning(|_, request| Ok(running("fresh", request.egress))); + settles_running( + &mut client, + Some(EgressPolicy { + default_action: "Deny".to_string(), + host_rules: vec![EgressHostRule { + pattern: "*".to_string(), + action: "Deny".to_string(), + }], + rules: Vec::new(), + unmodelled: Default::default(), + traffic_inspection: Some("Full".to_string()), + }), + ); - let session = sandbox_with(client) + // Declared `deny`, because a terminated session carries no policy — judging it before + // reading the state reported a disappearing sandbox as an uncontained one. + let session = sandbox_denying(client, SandboxEgress::Deny) .get_or_create(CreateSessionRequest { session_id: Some("going-away".to_string()), tenant_key: None, @@ -1969,6 +2026,7 @@ mod tests { .expect("suspend should be accepted"); let mut client = MockSandboxDataPlaneApi::new(); + settles_running(&mut client, None); client .expect_resume_sandbox() .withf(|group, id| group == "grp" && id == "s1") @@ -2256,4 +2314,125 @@ mod tests { assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); } + + /// A policy that changed while a session was suspended is caught on the way back. + /// + /// The effective policy can be set on the group, somewhere this binding never writes, so the + /// read that finds a stopped sandbox is not the read that decides whether it is contained — + /// the one taken after it comes up is. + #[tokio::test] + async fn a_policy_that_changed_during_suspension_is_caught_on_reconnect() { + let declared = EgressPolicy { + default_action: "Deny".to_string(), + host_rules: vec![EgressHostRule { + pattern: "*".to_string(), + action: "Deny".to_string(), + }], + rules: Vec::new(), + unmodelled: Default::default(), + traffic_inspection: Some("Full".to_string()), + }; + + let mut client = MockSandboxDataPlaneApi::new(); + let mut reads = 0; + let stopped = declared.clone(); + client.expect_get_sandbox().returning(move |_, id| { + reads += 1; + Ok(match reads { + // Suspended and compliant, so the reconnect proceeds. + 1 => { + let mut sandbox = running(id, Some(stopped.clone())); + sandbox.state = Some("Stopped".to_string()); + sandbox + } + // Awake, and the group gained a host nobody here asked for. + _ => running( + id, + Some(EgressPolicy { + host_rules: vec![ + EgressHostRule { + pattern: "*".to_string(), + action: "Deny".to_string(), + }, + EgressHostRule { + pattern: "exfil.example.com".to_string(), + action: "Allow".to_string(), + }, + ], + ..stopped.clone() + }), + ), + }) + }); + // However it wakes — resumed here or already coming up — the read after it is the one + // that decides. + client.expect_resume_sandbox().returning(|_, _| Ok(())); + client.expect_create_sandbox().never(); + + let error = sandbox_denying(client, SandboxEgress::Deny) + .get_or_create(CreateSessionRequest { + session_id: Some("was-suspended".to_string()), + tenant_key: None, + env: BTreeMap::new(), + }) + .await + .expect_err("a session that woke up with more reach must not be handed back"); + + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + } + + /// A sandbox left behind must not publish the cloud's own response text. + /// + /// `discard` wraps the reason so the leak is named, and the wrapper inherits visibility: the + /// error it wraps is the cloud client's, which carries the request and response of the call + /// that failed, and the flag `into_external` reads is the outermost one. + #[tokio::test] + async fn a_sandbox_left_behind_does_not_publish_the_response_body() { + const SECRET: &str = "tenant-only-detail"; + + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_create_sandbox() + .times(1) + .returning(|_, _| Ok(running("s1", None))); + // The readiness read and the delete both fail, which is one failure in practice: a + // missing data-plane role refuses every verb. + client + .expect_get_sandbox() + .returning(|_, _| Err(http_error(403, SECRET))); + client + .expect_delete_sandbox() + .returning(|_, _| Err(http_error(403, SECRET))); + + let error = sandbox_with(client) + .create(CreateSessionRequest::default()) + .await + .expect_err("a create that cannot be confirmed must fail"); + + assert_eq!(error.code, "SANDBOX_COMMAND_FAILED", "{error}"); + assert!( + error.internal, + "the wrapper must inherit the cloud error's visibility: {error}" + ); + } + + /// Waking a session puts what it was running back on the network, so it is gated like + /// `run_command`: a caller holding an id from an older declaration must not be able to + /// resume its way around the check. + #[tokio::test] + async fn a_stale_policy_session_cannot_be_resumed() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_get_sandbox() + .times(1) + .returning(|_, id| Ok(running(id, None))); + client.expect_resume_sandbox().never(); + + let error = sandbox_denying(client, SandboxEgress::Deny) + .resume("built-under-allow") + .await + .expect_err("a session without the declared policy must not be woken"); + + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + } } From 76b8d0b873e8307e94cadff882fdd76444a45fc5 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:26:18 +0300 Subject: [PATCH 16/32] fix(sandbox): refuse a session that is on its way out, and reap one this code woke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two blockers, both found and reproduced by the review rather than read out of the diff. `get` skips the policy check for a session being deleted, because a sandbox on its way out carries no policy to judge. The two gates that stand between a kept session id and its egress then asked only whether the session exists — so a `Deleting` sandbox passed both. Azure accepts a delete rather than completing it, which is why `terminate` polls to a 404 instead of trusting the accept: the workload is still running through that window. A session built under a looser declaration, tightened since, with a delete in flight, would have run new code under the egress it was built with. Both gates now refuse a session that cannot take work, which is also the right answer for `resume`. The reconnect path judged the policy after `await_running` had already resumed the sandbox — putting whatever it was running back on the network — and then returned the error with no cleanup. Every retry repeated it, and the argument that retries converge does not hold: a stopped sandbox reporting the stale policy sends each attempt back down the same branch. It is discarded now, like every other sandbox this code creates or wakes and then refuses. And a minted id this client cannot address is no longer sent to the delete — the id it refuses to send is the id that delete would travel on. --- .../src/providers/sandbox/azure.rs | 102 +++++++++++++++--- 1 file changed, 86 insertions(+), 16 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index f0fce57a6..5a68b7669 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -164,8 +164,20 @@ impl Sandbox for AzureSandbox { // every later verb addresses the sandbox by it, and one this client cannot send is one // nothing can reach or reap. let _ = request.session_id; - if let Err(error) = Self::checked_session_id(CREATE, &sandbox.id) { - return Err(self.discard(&sandbox.id, error).await); + // Not reaped on failure: an id this client will not send is one it cannot send to the + // delete either, and a traversing id would make that delete reach another group. + if Self::checked_session_id(CREATE, &sandbox.id).is_err() { + warn!( + session = %sandbox.id, + "the data plane minted an id this client cannot address; the sandbox is running \ + and cannot be deleted through this binding" + ); + return Err(AlienError::new(ErrorData::UnexpectedResponseFormat { + provider: "azure".to_string(), + binding_name: CREATE.to_string(), + field: "id".to_string(), + response_json: format!("\"{}\"", sandbox.id), + })); } // Everything past this point owns a sandbox the caller has no id for, so every failure @@ -223,8 +235,12 @@ impl Sandbox for AzureSandbox { Ok(Some(existing)) if existing.state != SandboxSessionState::Terminated => { // Judged again on the sandbox that came up: a policy set on the group can // change while a session is suspended, and the read above saw a stopped one. + // Waking it is what makes the cleanup necessary — the wait put whatever it + // was running back on the network before anything could judge it. let running = self.await_running(GET_OR_CREATE, id).await?; - self.policy_must_hold(&running)?; + if let Err(error) = self.policy_must_hold(&running) { + return Err(self.discard(id, error).await); + } return Ok(SandboxSession { session_id: running.id, state: SandboxSessionState::Running, @@ -268,12 +284,7 @@ impl Sandbox for AzureSandbox { // session id outlives a declaration change, and nothing else stands between an id a // caller kept and the egress it was built with. One extra read against a data plane the // command itself is about to cross. - self.get(session_id).await?.ok_or_else(|| { - AlienError::new(ErrorData::SandboxCommandFailed { - failure: "sessionGone".to_string(), - reason: format!("session '{session_id}' does not exist"), - }) - })?; + self.usable_session(RUN_COMMAND, session_id).await?; // The deadline bounds the untrusted code, not the caller's patience. Read out of the // preview SDK rather than assumed: `executeShellCommand` sends `command` and an optional @@ -399,12 +410,7 @@ impl Sandbox for AzureSandbox { // Waking a session puts whatever it was running back on the network, so it is gated like // `run_command` and unlike the file operations. `await_running` resumes without this, // because a sandbox mid-boot has no policy to judge yet. - self.get(session_id).await?.ok_or_else(|| { - AlienError::new(ErrorData::SandboxCommandFailed { - failure: "sessionGone".to_string(), - reason: format!("session '{session_id}' does not exist"), - }) - })?; + self.usable_session("sandbox.resume", session_id).await?; self.resume_unchecked(session_id).await } @@ -456,6 +462,27 @@ impl Sandbox for AzureSandbox { } impl AzureSandbox { + /// Reads a session that is fit to be used, refusing one that is not. + /// + /// `get` skips the policy check for a session being deleted, because a sandbox on its way out + /// carries no policy to judge — so the callers that gate on the policy have to reject that + /// state themselves. A delete is accepted rather than completed, so a `Deleting` sandbox is + /// still running: passing one through would run new code on it under whatever egress it was + /// built with. + async fn usable_session(&self, operation: &str, session_id: &str) -> Result<()> { + let gone = || { + Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: "sessionGone".to_string(), + reason: format!("session '{session_id}' cannot take work"), + })) + }; + + match self.get(session_id).await? { + Some(session) if session.state != SandboxSessionState::Terminated => Ok(()), + _ => gone(), + } + } + /// Wakes a session without judging it, for the wait that has nothing to judge yet. async fn resume_unchecked(&self, session_id: &str) -> Result<()> { self.client @@ -2365,8 +2392,13 @@ mod tests { }) }); // However it wakes — resumed here or already coming up — the read after it is the one - // that decides. + // that decides, and a sandbox this code woke and then refused must not be left running. client.expect_resume_sandbox().returning(|_, _| Ok(())); + client + .expect_delete_sandbox() + .withf(|_, id| id == "was-suspended") + .times(1) + .returning(|_, _| Ok(())); client.expect_create_sandbox().never(); let error = sandbox_denying(client, SandboxEgress::Deny) @@ -2435,4 +2467,42 @@ mod tests { assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); } + + /// A session being deleted is still running, so it must not take new work. + /// + /// `get` skips the policy check for one — a sandbox on its way out carries no policy to + /// judge — so a gate that only asks "does it exist" would run untrusted code on a live + /// sandbox under whatever egress it was built with. Azure accepts a delete rather than + /// completing it, which is why `terminate` polls to a 404 instead of trusting the accept. + #[tokio::test] + async fn a_session_being_deleted_takes_no_new_work() { + for outcome in ["Deleting", "gone"] { + let mut client = MockSandboxDataPlaneApi::new(); + let deleting = outcome == "Deleting"; + client.expect_get_sandbox().returning(move |_, id| { + if deleting { + let mut sandbox = running(id, None); + sandbox.state = Some("Deleting".to_string()); + Ok(sandbox) + } else { + Err(http_error(404, "SandboxNotFound")) + } + }); + client.expect_execute_shell_command().never(); + client.expect_resume_sandbox().never(); + let sandbox = sandbox_denying(client, SandboxEgress::Deny); + + let ran = match sandbox.run_command("on-its-way-out", command(5)).await { + Ok(_) => panic!("{outcome}: a session that cannot take work must not run code"), + Err(error) => error, + }; + assert_eq!(ran.code, "SANDBOX_COMMAND_FAILED", "{outcome}: {ran}"); + + let woken = sandbox + .resume("on-its-way-out") + .await + .expect_err("a session that cannot take work must not be resumed"); + assert_eq!(woken.code, "SANDBOX_COMMAND_FAILED", "{outcome}: {woken}"); + } + } } From 3377b568ae43abd8836f1745413208b8c93be86b Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:42:03 +0300 Subject: [PATCH 17/32] fix(sandbox): judge a session in the state the work will run in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate asked whether a session existed and whether it was not being deleted. Neither is the question. A sandbox still coming up carries no policy yet, so judging one reported a booting session as uncontained — and `get_or_create` acts on that by deleting it and creating another. A suspended one carries the record it stopped with, which is not what the work would run under. So the gate now brings a session up before it judges it, and every verb that starts code, wakes it, or moves the caller's own content into it goes through the same path: `run_command`, `resume`, `write_files` and `get_or_create`. `readFile` and `mkdir` stay ungated — a read returns to the caller who already holds the session, and a directory plants nothing. That also settles a question the two reconnect arms answered oppositely. A session that cannot serve is replaced, whether the reason is that it is gone, being deleted, or running a policy the declaration no longer matches. The narrow part is deliberate: a readiness timeout is not one of those reasons, because answering a slow data plane with a second sandbox makes it slower. Two smaller ones from the same review. A minted id this client will not send is still reaped unless the id is itself why the delete would be unsafe — an over-long id is one path segment and nothing else can find that sandbox, while a traversing one would send the delete into another group. And `write_files` validates its paths before it spends a round trip, not after. --- .../src/providers/sandbox/azure.rs | 263 +++++++++++++----- 1 file changed, 196 insertions(+), 67 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 5a68b7669..cc18d3ab2 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -164,20 +164,28 @@ impl Sandbox for AzureSandbox { // every later verb addresses the sandbox by it, and one this client cannot send is one // nothing can reach or reap. let _ = request.session_id; - // Not reaped on failure: an id this client will not send is one it cannot send to the - // delete either, and a traversing id would make that delete reach another group. if Self::checked_session_id(CREATE, &sandbox.id).is_err() { - warn!( - session = %sandbox.id, - "the data plane minted an id this client cannot address; the sandbox is running \ - and cannot be deleted through this binding" - ); - return Err(AlienError::new(ErrorData::UnexpectedResponseFormat { + let unreadable = AlienError::new(ErrorData::UnexpectedResponseFormat { provider: "azure".to_string(), binding_name: CREATE.to_string(), field: "id".to_string(), - response_json: format!("\"{}\"", sandbox.id), - })); + response_json: format!("{:?}", sandbox.id), + }); + + // Reaped unless the id is itself what makes the delete unsafe: a path separator or an + // escape would send that delete into another group. Everything else this check + // refuses — an over-long id, an unusual character — is still safe to address once, + // and refusing to reap it leaves a running sandbox no id-holder can find. + return Err(if sandbox.id.contains(['/', '.', '%']) || sandbox.id.is_empty() { + warn!( + session = %sandbox.id, + "the data plane minted an id this client will not send; the sandbox is \ + running and cannot be deleted through this binding" + ); + unreadable + } else { + self.discard(&sandbox.id, unreadable).await + }); } // Everything past this point owns a sandbox the caller has no id for, so every failure @@ -225,37 +233,25 @@ impl Sandbox for AzureSandbox { async fn get_or_create(&self, request: CreateSessionRequest) -> Result { if let Some(id) = request.session_id.as_deref() { - match self.get(id).await { - // `create` returns a session that can take work, and reaching one someone else - // started has to mean the same thing. A suspended sandbox is the ordinary resting - // state once an idle policy is set, so the wait resumes it. - Ok(Some(existing)) if existing.state == SandboxSessionState::Running => { - return Ok(existing) - } - Ok(Some(existing)) if existing.state != SandboxSessionState::Terminated => { - // Judged again on the sandbox that came up: a policy set on the group can - // change while a session is suspended, and the read above saw a stopped one. - // Waking it is what makes the cleanup necessary — the wait put whatever it - // was running back on the network before anything could judge it. - let running = self.await_running(GET_OR_CREATE, id).await?; - if let Err(error) = self.policy_must_hold(&running) { - return Err(self.discard(id, error).await); - } - return Ok(SandboxSession { - session_id: running.id, - state: SandboxSessionState::Running, - generation: 1, - }); - } - // Terminated, or gone: both mean this id cannot serve, so a fresh session is what - // "get or create" owes the caller. - Ok(_) => {} - // A session the declaration no longer matches is as unusable as a terminated one, - // and leaving it running bills for a sandbox nothing can reach through this - // binding. Replaced rather than returned as a permanent error. - Err(error) if error.code == "SANDBOX_NOT_AS_DECLARED" => { - self.terminate(id).await?; - } + // `create` returns a session that can take work, and reaching one someone else + // started has to mean the same thing — so the same gate every other verb uses: bring + // it up, judge it there, and refuse it if it does not match. + match self.usable_session(GET_OR_CREATE, id).await { + Ok(session) => return Ok(session), + // The two ways an id can fail to serve — gone, or running a policy the + // declaration no longer matches — mean the same thing to a caller asking for a + // session, and are answered the same way: a fresh one. The gate has already + // discarded whatever it refused, so nothing is left running. + // + // Narrow on purpose: a readiness timeout says the data plane is slow, and + // answering that by creating a second sandbox makes it slower. + Err(error) + if error.code == "SANDBOX_NOT_AS_DECLARED" + || matches!( + &error.error, + Some(ErrorData::SandboxCommandFailed { failure, .. }) + if failure == "sessionGone" + ) => {} Err(error) => return Err(error), } } @@ -360,13 +356,19 @@ impl Sandbox for AzureSandbox { async fn write_files(&self, session_id: &str, files: BTreeMap>) -> Result<()> { Self::checked_session_id("sandbox.writeFiles", session_id)?; - // Checked before anything is written: partial application is the contract for a data - // plane that refuses midway, not for a path this process could have rejected first. + // Checked before anything is written, and before anything is read: partial application is + // the contract for a data plane that refuses midway, not for a path this process could + // have rejected without a round trip. let files = files .into_iter() .map(|(path, contents)| Ok((checked_path("sandbox.writeFiles", &path)?, contents))) .collect::>>()?; + // The one file operation that moves the caller's own content in. A write-then-run against + // an id kept across a tightened declaration would land the payload in a sandbox with the + // egress the declaration just removed, and the refusal would arrive a beat later. + self.usable_session("sandbox.writeFiles", session_id).await?; + // One request per path, stopping at the first failure: the same partial application every // other backend performs, so a caller sees one contract rather than five. for (path, contents) in files { @@ -407,12 +409,12 @@ impl Sandbox for AzureSandbox { async fn resume(&self, session_id: &str) -> Result<()> { Self::checked_session_id("sandbox.resume", session_id)?; - // Waking a session puts whatever it was running back on the network, so it is gated like - // `run_command` and unlike the file operations. `await_running` resumes without this, - // because a sandbox mid-boot has no policy to judge yet. + // Waking a session puts whatever it was running back on the network, so the policy is + // judged after the wake rather than before it: the stopped record is not the one the work + // runs under. That makes `resume` complete rather than accepted, which the sub-second + // resume Microsoft documents makes affordable. self.usable_session("sandbox.resume", session_id).await?; - - self.resume_unchecked(session_id).await + Ok(()) } async fn snapshot(&self, _session_id: &str) -> Result { @@ -469,18 +471,40 @@ impl AzureSandbox { /// state themselves. A delete is accepted rather than completed, so a `Deleting` sandbox is /// still running: passing one through would run new code on it under whatever egress it was /// built with. - async fn usable_session(&self, operation: &str, session_id: &str) -> Result<()> { - let gone = || { - Err(AlienError::new(ErrorData::SandboxCommandFailed { - failure: "sessionGone".to_string(), - reason: format!("session '{session_id}' cannot take work"), - })) + async fn usable_session(&self, operation: &str, session_id: &str) -> Result { + let found = match self.get(session_id).await { + Ok(found) => found, + // The read itself judges a running session, and a refusal there leaves the same + // sandbox running that a refusal below would: one discard, wherever it is found. + Err(error) if error.code == "SANDBOX_NOT_AS_DECLARED" => { + return Err(self.discard(session_id, error).await) + } + Err(error) => return Err(error), + }; + + match found { + Some(session) if session.state != SandboxSessionState::Terminated => session, + _ => { + return Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: "sessionGone".to_string(), + reason: format!("{operation}: session '{session_id}' cannot take work"), + })) + } }; - match self.get(session_id).await? { - Some(session) if session.state != SandboxSessionState::Terminated => Ok(()), - _ => gone(), + // Brought up before it is judged, not after: a suspended or booting sandbox has no + // effective policy to read, so a gate that accepted one would be judging nothing. The + // wait resumes a stopped session, which is what makes this the state the work runs in. + let running = self.await_running(operation, session_id).await?; + if let Err(error) = self.policy_must_hold(&running) { + return Err(self.discard(session_id, error).await); } + + Ok(SandboxSession { + session_id: running.id, + state: SandboxSessionState::Running, + generation: 1, + }) } /// Wakes a session without judging it, for the wait that has nothing to judge yet. @@ -500,6 +524,12 @@ impl AzureSandbox { &self, sandbox: &alien_azure_clients::azure::sandbox_data_plane::Sandbox, ) -> Result<()> { + // Only a running sandbox carries a policy worth reading. One still coming up need not + // have it yet, and judging that absence reports a booting sandbox as an uncontained one — + // which `get_or_create` acts on by deleting it. + if sandbox.state.as_deref() != Some("Running") { + return Ok(()); + } let Some(asked) = egress_policy(&self.egress) else { return Ok(()); }; @@ -1494,6 +1524,7 @@ mod tests { #[tokio::test] async fn a_failed_write_stops_the_ones_behind_it() { let mut client = MockSandboxDataPlaneApi::new(); + settles_running(&mut client, None); client .expect_write_file() .times(1) @@ -2052,18 +2083,16 @@ mod tests { .await .expect("suspend should be accepted"); + // `resume` completes rather than accepts: it judges the woken sandbox, which means the + // data plane may already have it running by the time the wait looks. let mut client = MockSandboxDataPlaneApi::new(); settles_running(&mut client, None); - client - .expect_resume_sandbox() - .withf(|group, id| group == "grp" && id == "s1") - .times(1) - .returning(|_, _| Ok(())); + client.expect_resume_sandbox().returning(|_, _| Ok(())); client.expect_stop_sandbox().never(); sandbox_with(client) .resume("s1") .await - .expect("resume should be accepted"); + .expect("resume should reach a running session"); } /// A declared idle-suspend policy has to reach the create body. @@ -2329,6 +2358,9 @@ mod tests { .expect_get_sandbox() .times(1) .returning(|_, id| Ok(running(id, None))); + // Refusing it also reaps it: a session nothing can reach through this binding + // should not keep billing. + client.expect_delete_sandbox().times(1).returning(|_, _| Ok(())); client.expect_execute_shell_command().never(); let error = match sandbox_denying(client, SandboxEgress::Deny) @@ -2364,6 +2396,10 @@ mod tests { let mut reads = 0; let stopped = declared.clone(); client.expect_get_sandbox().returning(move |_, id| { + // The replacement is compliant; only the session that was asleep woke up wider. + if id != "was-suspended" { + return Ok(running(id, Some(stopped.clone()))); + } reads += 1; Ok(match reads { // Suspended and compliant, so the reconnect proceeds. @@ -2399,18 +2435,23 @@ mod tests { .withf(|_, id| id == "was-suspended") .times(1) .returning(|_, _| Ok(())); - client.expect_create_sandbox().never(); + client + .expect_create_sandbox() + .times(1) + .returning(|_, request| Ok(running("fresh", request.egress))); - let error = sandbox_denying(client, SandboxEgress::Deny) + let session = sandbox_denying(client, SandboxEgress::Deny) .get_or_create(CreateSessionRequest { session_id: Some("was-suspended".to_string()), tenant_key: None, env: BTreeMap::new(), }) .await - .expect_err("a session that woke up with more reach must not be handed back"); + .expect("a caller asking for a session gets a usable one"); - assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + // Answered the same way as a terminated id: the one that woke up wider is discarded and + // replaced, rather than returned as an error the caller cannot act on. + assert_eq!(session.session_id, "fresh"); } /// A sandbox left behind must not publish the cloud's own response text. @@ -2458,6 +2499,9 @@ mod tests { .expect_get_sandbox() .times(1) .returning(|_, id| Ok(running(id, None))); + // Refusing it also reaps it: a session nothing can reach through this binding + // should not keep billing. + client.expect_delete_sandbox().times(1).returning(|_, _| Ok(())); client.expect_resume_sandbox().never(); let error = sandbox_denying(client, SandboxEgress::Deny) @@ -2505,4 +2549,89 @@ mod tests { assert_eq!(woken.code, "SANDBOX_COMMAND_FAILED", "{outcome}: {woken}"); } } + + /// A create whose id this client will not send is reaped unless the id is why. + /// + /// An over-long or oddly-spelled id is still one path segment, so the sandbox can be deleted + /// once and must be — nothing else can find it. An id carrying a separator or an escape is + /// the one case where the delete itself would travel somewhere else. + #[tokio::test] + async fn an_unaddressable_minted_id_is_reaped_unless_the_id_is_the_hazard() { + let minted = |id: &'static str| { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_create_sandbox() + .times(1) + .returning(move |_, _| Ok(running(id, None))); + client + }; + + // Safe to address once: reaped. + let mut client = minted("x".repeat(80).leak()); + client.expect_delete_sandbox().times(1).returning(|_, _| Ok(())); + let error = sandbox_with(client) + .create(CreateSessionRequest::default()) + .await + .expect_err("an id this client will not send must fail the create"); + assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}"); + + // The id is the hazard: the delete would travel into another group, so it is not sent. + let mut client = minted("../../other-group/sandboxes/theirs"); + client.expect_delete_sandbox().never(); + let error = sandbox_with(client) + .create(CreateSessionRequest::default()) + .await + .expect_err("a traversing id must fail the create"); + assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}"); + } + + /// A sandbox that is still coming up has no policy yet, and that is not a mismatch. + /// + /// `policy_holds` reads an absent policy as a failure, so judging a `Creating` session would + /// report a booting sandbox as an uncontained one — and `get_or_create` acts on that by + /// deleting it and creating another. + #[tokio::test] + async fn a_session_that_is_still_coming_up_is_not_a_policy_mismatch() { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_get_sandbox().times(1).returning(|_, id| { + let mut sandbox = running(id, None); + sandbox.state = Some("Creating".to_string()); + Ok(sandbox) + }); + client.expect_delete_sandbox().never(); + + let session = sandbox_denying(client, SandboxEgress::Deny) + .get("still-booting") + .await + .expect("a booting session is not a contained-ness failure") + .expect("the session exists"); + + assert_eq!(session.state, SandboxSessionState::Starting); + } + + /// Writing into a stale session is refused before the bytes land. + /// + /// `write_files` is the one file operation that moves the caller's own content in, so a + /// write-then-run against an id kept across a tightened declaration would put the payload + /// inside a sandbox with the egress the declaration just removed. + #[tokio::test] + async fn a_stale_policy_session_takes_no_written_files() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_get_sandbox() + .times(1) + .returning(|_, id| Ok(running(id, None))); + client.expect_delete_sandbox().times(1).returning(|_, _| Ok(())); + client.expect_write_file().never(); + + let error = sandbox_denying(client, SandboxEgress::Deny) + .write_files( + "built-under-allow", + BTreeMap::from([("app.py".to_string(), vec![1u8])]), + ) + .await + .expect_err("a session without the declared policy must take no content"); + + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + } } From 7e3ab8304c34cdc12973504acb7d572b08547053 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:59:44 +0300 Subject: [PATCH 18/32] fix(sandbox): judge a sleeping session before waking it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stopped sandbox carries the policy it stopped under, so it can be judged where it lies — and the gate was exempting exactly that state. The cost was concrete: a session created under `allow`, a declaration since tightened to `deny`, and a reconnect would wake it, put its workload back on the network for the length of a boot, and only then refuse it. It is judged asleep now, and again once it is up, because a group-scoped policy can change while it sleeps. Only a session still coming up is exempt, which is the one state with nothing to judge. The resume in the readiness wait was latched on the attempt rather than the outcome, so a single refusal meant nothing ever woke the sandbox again: the wait spent its whole budget watching, returned `sessionNotReady`, and that does not heal — wedging the id permanently. It retries every poll now, and never fires at all while the sandbox is still `Stopping`, which is the state the data plane refuses a resume in and the state a suspend leaves behind. The timeout carries the last refusal, because "still not running after 120s" sends a reader looking for a slow data plane when every resume was rejected. `Failed` is a state the data plane reports and this client did not know, so it became an unreadable-response error that nothing heals. It is terminal, and a session found terminal mid-wait is now replaced like one found terminal at the start — the same condition answered the same way whichever read observes it. Two verbs stopped destroying what they refuse. `run_command`, `write_files` and `resume` did not create the session and were not asked to replace it, and two revisions of a stack share a sandbox group — so reaping there turns one revision's tightened declaration into the other's outage. They refuse; `resume` puts back a session it woke and then rejected. Only `create`, which owns what it made, and `get_or_create`, which was asked for a usable session, replace. And `suspend_and_resume_reach_their_own_verbs` proves the verb is sent again: its mock answered `Running` on the first read, so the wait returned before any resume, and the assertion that named the test passed with the call never made. --- .../src/providers/sandbox/azure.rs | 509 ++++++++++++++---- 1 file changed, 402 insertions(+), 107 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index cc18d3ab2..fd7e0c2d1 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -176,7 +176,15 @@ impl Sandbox for AzureSandbox { // escape would send that delete into another group. Everything else this check // refuses — an over-long id, an unusual character — is still safe to address once, // and refusing to reap it leaves a running sandbox no id-holder can find. - return Err(if sandbox.id.contains(['/', '.', '%']) || sandbox.id.is_empty() { + // An allowlist, because the hazard is anything the URL parser reads differently: + // `abc?x` starts a query string, so the delete would land on the sandbox named `abc`. + let addressable = !sandbox.id.is_empty() + && sandbox + .id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); + + return Err(if !addressable { warn!( session = %sandbox.id, "the data plane minted an id this client will not send; the sandbox is \ @@ -198,16 +206,10 @@ impl Sandbox for AzureSandbox { async fn get(&self, session_id: &str) -> Result> { Self::checked_session_id("sandbox.get", session_id)?; - let sandbox = match self - .client - .get_sandbox(&self.sandbox_group, session_id) - .await - { - Ok(sandbox) => sandbox, - // A 404 is "gone", which is a valid answer. Anything else is a real failure and must - // not be flattened into None, or a throttle would read as an expired session. - Err(error) if is_not_found(&error) => return Ok(None), - Err(error) => return Err(Self::failed("sandbox.get", error)), + // A 404 is "gone", which is a valid answer. Anything else is a real failure and must not + // be flattened into None, or a throttle would read as an expired session. + let Some(sandbox) = self.read_session("sandbox.get", session_id).await? else { + return Ok(None); }; let state = session_state("sandbox.get", sandbox.state.as_deref())?; @@ -217,10 +219,10 @@ impl Sandbox for AzureSandbox { // ceiling, and an idle sandbox only suspends — so a caller holding its id would otherwise // be handed a sandbox whose containment is whatever it was built with. // - // Not a session on its way out: a sandbox being deleted carries no policy to judge, and - // reading that absence as a mismatch would report a disappearing session as an - // uncontained one. - if state != SandboxSessionState::Terminated { + // A stopped sandbox still carries the policy it stopped under, so it is judged like a + // running one. Only a session still coming up has nothing to judge yet — reading that + // absence as a mismatch would report a healthy session as an uncontained one. + if !matches!(sandbox.state.as_deref(), Some("Creating" | "Resuming")) { self.policy_must_hold(&sandbox)?; } @@ -236,7 +238,7 @@ impl Sandbox for AzureSandbox { // `create` returns a session that can take work, and reaching one someone else // started has to mean the same thing — so the same gate every other verb uses: bring // it up, judge it there, and refuse it if it does not match. - match self.usable_session(GET_OR_CREATE, id).await { + match self.reconnect(id).await { Ok(session) => return Ok(session), // The two ways an id can fail to serve — gone, or running a policy the // declaration no longer matches — mean the same thing to a caller asking for a @@ -250,7 +252,7 @@ impl Sandbox for AzureSandbox { || matches!( &error.error, Some(ErrorData::SandboxCommandFailed { failure, .. }) - if failure == "sessionGone" + if failure == "sessionGone" || failure == "sessionTerminated" ) => {} Err(error) => return Err(error), } @@ -280,7 +282,7 @@ impl Sandbox for AzureSandbox { // session id outlives a declaration change, and nothing else stands between an id a // caller kept and the egress it was built with. One extra read against a data plane the // command itself is about to cross. - self.usable_session(RUN_COMMAND, session_id).await?; + self.judged_session(RUN_COMMAND, session_id).await?; // The deadline bounds the untrusted code, not the caller's patience. Read out of the // preview SDK rather than assumed: `executeShellCommand` sends `command` and an optional @@ -367,7 +369,7 @@ impl Sandbox for AzureSandbox { // The one file operation that moves the caller's own content in. A write-then-run against // an id kept across a tightened declaration would land the payload in a sandbox with the // egress the declaration just removed, and the refusal would arrive a beat later. - self.usable_session("sandbox.writeFiles", session_id).await?; + self.judged_session("sandbox.writeFiles", session_id).await?; // One request per path, stopping at the first failure: the same partial application every // other backend performs, so a caller sees one contract rather than five. @@ -409,11 +411,31 @@ impl Sandbox for AzureSandbox { async fn resume(&self, session_id: &str) -> Result<()> { Self::checked_session_id("sandbox.resume", session_id)?; + const OPERATION: &str = "sandbox.resume"; + + if self.read_session(OPERATION, session_id).await?.is_none() { + return Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: "sessionGone".to_string(), + reason: format!("{OPERATION}: session '{session_id}' does not exist"), + })); + } + // Waking a session puts whatever it was running back on the network, so the policy is // judged after the wake rather than before it: the stopped record is not the one the work // runs under. That makes `resume` complete rather than accepted, which the sub-second // resume Microsoft documents makes affordable. - self.usable_session("sandbox.resume", session_id).await?; + let running = self.await_running(OPERATION, session_id).await?; + + // Put back rather than destroyed: this call woke it, so undoing that returns the session + // to the state the caller found it in. Deleting a session the caller asked to resume + // takes a decision that is not this call's to take. + if let Err(error) = self.policy_must_hold(&running) { + if let Err(failed) = self.client.stop_sandbox(&self.sandbox_group, session_id).await { + warn!(session = %session_id, error = %failed, "could not re-suspend a session that woke up uncontained"); + } + return Err(error); + } + Ok(()) } @@ -464,38 +486,38 @@ impl Sandbox for AzureSandbox { } impl AzureSandbox { - /// Reads a session that is fit to be used, refusing one that is not. + /// Brings a session the caller named back into service, or says why it cannot be. /// - /// `get` skips the policy check for a session being deleted, because a sandbox on its way out - /// carries no policy to judge — so the callers that gate on the policy have to reject that - /// state themselves. A delete is accepted rather than completed, so a `Deleting` sandbox is - /// still running: passing one through would run new code on it under whatever egress it was - /// built with. - async fn usable_session(&self, operation: &str, session_id: &str) -> Result { - let found = match self.get(session_id).await { - Ok(found) => found, - // The read itself judges a running session, and a refusal there leaves the same - // sandbox running that a refusal below would: one discard, wherever it is found. - Err(error) if error.code == "SANDBOX_NOT_AS_DECLARED" => { - return Err(self.discard(session_id, error).await) - } - Err(error) => return Err(error), + /// The one path that repairs rather than refusing: `get_or_create` asked for a usable + /// session, so a session that cannot serve is discarded and replaced rather than returned as + /// an error the caller has no way to act on. + async fn reconnect(&self, session_id: &str) -> Result { + let gone = || { + AlienError::new(ErrorData::SandboxCommandFailed { + failure: "sessionGone".to_string(), + reason: format!("{GET_OR_CREATE}: session '{session_id}' cannot take work"), + }) }; - match found { - Some(session) if session.state != SandboxSessionState::Terminated => session, - _ => { - return Err(AlienError::new(ErrorData::SandboxCommandFailed { - failure: "sessionGone".to_string(), - reason: format!("{operation}: session '{session_id}' cannot take work"), - })) + let found = match self.read_session(GET_OR_CREATE, session_id).await? { + Some(sandbox) if !matches!(sandbox.state.as_deref(), Some("Deleting" | "Failed")) => { + sandbox } + _ => return Err(gone()), }; - // Brought up before it is judged, not after: a suspended or booting sandbox has no - // effective policy to read, so a gate that accepted one would be judging nothing. The - // wait resumes a stopped session, which is what makes this the state the work runs in. - let running = self.await_running(operation, session_id).await?; + // Judged asleep before anything wakes it: a stopped sandbox carries the policy it stopped + // under, and waking one that already fails would put its workload back on the network for + // the length of a boot before this call could refuse it. + if !matches!(found.state.as_deref(), Some("Creating" | "Resuming")) { + if let Err(error) = self.policy_must_hold(&found) { + return Err(self.discard(session_id, error).await); + } + } + + // Then judged again where the work will run: a policy set on the group can change while a + // session sleeps, and only the woken record shows that. + let running = self.await_running(GET_OR_CREATE, session_id).await?; if let Err(error) = self.policy_must_hold(&running) { return Err(self.discard(session_id, error).await); } @@ -507,6 +529,57 @@ impl AzureSandbox { }) } + /// Reads a session that is fit to be used, refusing one that is not. + /// + /// Refuses rather than repairs: a session this binding did not create and the caller did not + /// ask to replace is not this call's to destroy. Two revisions of a stack share a sandbox + /// group, so a tightened one reaping a session the other is mid-command on would be an + /// outage caused by a read. + /// + /// Requires the session to be running, because that is the only state carrying a policy + /// worth judging — and waking one to write into it would undo the idle suspend the + /// declaration asked for. + async fn judged_session(&self, operation: &str, session_id: &str) -> Result<()> { + let refuse = |failure: &str, why: &str| { + Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: failure.to_string(), + reason: format!("{operation}: session '{session_id}' {why}"), + })) + }; + + let Some(sandbox) = self.read_session(operation, session_id).await? else { + return refuse("sessionGone", "does not exist"); + }; + + match sandbox.state.as_deref() { + Some("Running") => {} + Some("Creating" | "Resuming") => { + return refuse("sessionNotReady", "is still starting; wait for it to run") + } + Some("Deleting") => return refuse("sessionGone", "is being deleted"), + _ => return refuse("sessionSuspended", "is suspended; resume it first"), + } + + self.policy_must_hold(&sandbox) + } + + /// Reads a session, or `None` when it is gone, without judging its policy. + async fn read_session( + &self, + operation: &str, + session_id: &str, + ) -> Result> { + match self + .client + .get_sandbox(&self.sandbox_group, session_id) + .await + { + Ok(sandbox) => Ok(Some(sandbox)), + Err(error) if is_not_found(&error) => Ok(None), + Err(error) => Err(Self::failed(operation, error)), + } + } + /// Wakes a session without judging it, for the wait that has nothing to judge yet. async fn resume_unchecked(&self, session_id: &str) -> Result<()> { self.client @@ -524,12 +597,6 @@ impl AzureSandbox { &self, sandbox: &alien_azure_clients::azure::sandbox_data_plane::Sandbox, ) -> Result<()> { - // Only a running sandbox carries a policy worth reading. One still coming up need not - // have it yet, and judging that absence reports a booting sandbox as an uncontained one — - // which `get_or_create` acts on by deleting it. - if sandbox.state.as_deref() != Some("Running") { - return Ok(()); - } let Some(asked) = egress_policy(&self.egress) else { return Ok(()); }; @@ -587,14 +654,15 @@ impl AzureSandbox { session_id: &str, ) -> Result { let deadline = std::time::Instant::now() + SESSION_READY_TIMEOUT; - let mut resumed = false; + let mut refusal: Option = None; loop { - let sandbox = self - .client - .get_sandbox(&self.sandbox_group, session_id) - .await - .map_err(|error| Self::failed(operation, error))?; + let Some(sandbox) = self.read_session(operation, session_id).await? else { + return Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: "sessionGone".to_string(), + reason: format!("{operation}: session '{session_id}' disappeared while it was being waited for"), + })); + }; // The raw state, because the four the trait publishes cannot separate a sandbox on // its way up from one on its way down, and this loop needs that difference. @@ -602,14 +670,16 @@ impl AzureSandbox { Some("Running") => return Ok(sandbox), Some("Creating" | "Resuming") => {} // Going down, or already down. Either way nothing is bringing it up. - Some("Stopping" | "Stopped" | "Suspended" | "Idle") => { - if !resumed { - resumed = true; - // A resume racing a sandbox that is still stopping answers 409, which is - // the wait's business rather than the caller's: the budget decides. - if let Err(error) = self.resume_unchecked(session_id).await { - warn!(session = %session_id, %error, "resume was refused; still waiting"); - } + // Still going down. Resume is refused in this state — the SDK's own resumable + // set excludes it — so the wait is for `Stopped`, not for the call to work. + Some("Stopping") => {} + // Re-issued on every poll, because the attempt most likely to be refused is the + // first one: remembering only that an attempt was made would spend the whole + // budget watching a sandbox nothing is bringing up. + Some("Stopped" | "Suspended" | "Idle") => { + if let Err(error) = self.resume_unchecked(session_id).await { + warn!(session = %session_id, %error, "resume was refused; still waiting"); + refusal = Some(error.code.clone()); } } // A terminated session never becomes runnable, and folding it into the timeout @@ -624,12 +694,21 @@ impl AzureSandbox { } if std::time::Instant::now() >= deadline { + // The last refusal, because "not running after 120s" sends a reader looking for a + // slow data plane when the answer is that every resume was rejected. return Err(AlienError::new(ErrorData::SandboxCommandFailed { failure: "sessionNotReady".to_string(), - reason: format!( - "session '{session_id}' was still not running after {}s", - SESSION_READY_TIMEOUT.as_secs() - ), + reason: match refusal { + Some(code) => format!( + "session '{session_id}' was still not running after {}s; the last \ + resume was refused with {code}", + SESSION_READY_TIMEOUT.as_secs() + ), + None => format!( + "session '{session_id}' was still not running after {}s", + SESSION_READY_TIMEOUT.as_secs() + ), + }, })); } tokio::time::sleep(SESSION_READY_INTERVAL).await; @@ -915,7 +994,7 @@ fn session_state(operation: &str, state: Option<&str>) -> Result Ok(SandboxSessionState::Suspended), - Some("Deleting") => Ok(SandboxSessionState::Terminated), + Some("Deleting" | "Failed") => Ok(SandboxSessionState::Terminated), other => Err(AlienError::new(ErrorData::UnexpectedResponseFormat { provider: "azure".to_string(), binding_name: operation.to_string(), @@ -2083,11 +2162,23 @@ mod tests { .await .expect("suspend should be accepted"); - // `resume` completes rather than accepts: it judges the woken sandbox, which means the - // data plane may already have it running by the time the wait looks. + // Found asleep, so the verb is actually sent — a mock that answers `Running` on the + // first read would let this pass with `resume_sandbox` never called at all. let mut client = MockSandboxDataPlaneApi::new(); - settles_running(&mut client, None); - client.expect_resume_sandbox().returning(|_, _| Ok(())); + let mut reads = 0; + client.expect_get_sandbox().returning(move |_, id| { + reads += 1; + let mut sandbox = running(id, None); + if reads < 3 { + sandbox.state = Some("Stopped".to_string()); + } + Ok(sandbox) + }); + client + .expect_resume_sandbox() + .withf(|group, id| group == "grp" && id == "s1") + .times(1) + .returning(|_, _| Ok(())); client.expect_stop_sandbox().never(); sandbox_with(client) .resume("s1") @@ -2273,31 +2364,25 @@ mod tests { #[tokio::test] async fn a_stale_policy_session_is_replaced_rather_than_refused_forever() { let mut client = MockSandboxDataPlaneApi::new(); - // The stale session answers once, is deleted, and is gone from then on; the fresh one - // answers its own readiness read. - let mut stale_reads = 0; + // The stale session is running under no policy at all; the replacement carries the one + // the declaration asks for. client.expect_get_sandbox().returning(move |_, id| { - if id != "built-under-allow" { - return Ok(running( - id, - Some(EgressPolicy { - default_action: "Deny".to_string(), - host_rules: vec![EgressHostRule { - pattern: "*".to_string(), - action: "Deny".to_string(), - }], - rules: Vec::new(), - unmodelled: Default::default(), - traffic_inspection: Some("Full".to_string()), - }), - )); - } - stale_reads += 1; - if stale_reads == 1 { - Ok(running(id, None)) - } else { - Err(http_error(404, "SandboxNotFound")) + if id == "built-under-allow" { + return Ok(running(id, None)); } + Ok(running( + id, + Some(EgressPolicy { + default_action: "Deny".to_string(), + host_rules: vec![EgressHostRule { + pattern: "*".to_string(), + action: "Deny".to_string(), + }], + rules: Vec::new(), + unmodelled: Default::default(), + traffic_inspection: Some("Full".to_string()), + }), + )) }); client .expect_delete_sandbox() @@ -2358,9 +2443,9 @@ mod tests { .expect_get_sandbox() .times(1) .returning(|_, id| Ok(running(id, None))); - // Refusing it also reaps it: a session nothing can reach through this binding - // should not keep billing. - client.expect_delete_sandbox().times(1).returning(|_, _| Ok(())); + // Refused, not reaped: this call did not create the session and was not asked to replace + // it, and two revisions of a stack share a sandbox group. + client.expect_delete_sandbox().never(); client.expect_execute_shell_command().never(); let error = match sandbox_denying(client, SandboxEgress::Deny) @@ -2497,12 +2582,10 @@ mod tests { let mut client = MockSandboxDataPlaneApi::new(); client .expect_get_sandbox() - .times(1) .returning(|_, id| Ok(running(id, None))); - // Refusing it also reaps it: a session nothing can reach through this binding - // should not keep billing. - client.expect_delete_sandbox().times(1).returning(|_, _| Ok(())); - client.expect_resume_sandbox().never(); + // Refused, not reaped: the caller asked to wake a session, not to lose it. + client.expect_delete_sandbox().never(); + client.expect_stop_sandbox().returning(|_, _| Ok(())); let error = sandbox_denying(client, SandboxEgress::Deny) .resume("built-under-allow") @@ -2621,7 +2704,7 @@ mod tests { .expect_get_sandbox() .times(1) .returning(|_, id| Ok(running(id, None))); - client.expect_delete_sandbox().times(1).returning(|_, _| Ok(())); + client.expect_delete_sandbox().never(); client.expect_write_file().never(); let error = sandbox_denying(client, SandboxEgress::Deny) @@ -2634,4 +2717,216 @@ mod tests { assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); } + + /// A resume the data plane refuses once is retried, not abandoned for the whole wait. + /// + /// The first attempt is the one most likely to be refused — a resume racing a sandbox that is + /// still stopping answers 409 — so remembering only that an attempt was made would spend the + /// budget watching a session nothing is bringing up. + #[tokio::test] + async fn a_refused_resume_is_tried_again() { + let mut client = MockSandboxDataPlaneApi::new(); + let mut reads = 0; + client.expect_get_sandbox().returning(move |_, id| { + reads += 1; + let mut sandbox = running(id, None); + // Stopping, then stopped, then up — the shape a suspend-then-resume race produces. + sandbox.state = Some(match reads { + 1 => "Stopping", + 2 | 3 => "Stopped", + _ => "Running", + } + .to_string()); + Ok(sandbox) + }); + + let mut attempts = 0; + client.expect_resume_sandbox().times(2).returning(move |_, _| { + attempts += 1; + if attempts == 1 { + // The 409 a sandbox still stopping answers. + Err(http_error(409, "SandboxNotStopped")) + } else { + Ok(()) + } + }); + + sandbox_with(client) + .resume("racing-the-idle-policy") + .await + .expect("a refused first resume must not doom the wait"); + } + + /// A session that is not running takes no work and no content, and is not woken to take it. + /// + /// Waking one to write into it would undo the idle suspend the declaration asked for, and a + /// stopped sandbox's policy record is not the one the work would run under. + #[tokio::test] + async fn a_suspended_session_is_refused_rather_than_woken() { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_get_sandbox().returning(|_, id| { + let mut sandbox = running(id, None); + sandbox.state = Some("Stopped".to_string()); + Ok(sandbox) + }); + client.expect_resume_sandbox().never(); + client.expect_write_file().never(); + client.expect_execute_shell_command().never(); + let sandbox = sandbox_denying(client, SandboxEgress::Deny); + + let wrote = sandbox + .write_files( + "asleep", + BTreeMap::from([("app.py".to_string(), vec![1u8])]), + ) + .await + .expect_err("a suspended session takes no content"); + assert_eq!(wrote.code, "SANDBOX_COMMAND_FAILED", "{wrote}"); + + let ran = match sandbox.run_command("asleep", command(5)).await { + Ok(_) => panic!("a suspended session runs no code"), + Err(error) => error, + }; + assert_eq!(ran.code, "SANDBOX_COMMAND_FAILED", "{ran}"); + } + + /// A stopped session that no longer matches is refused before anything wakes it. + /// + /// The stopped record carries the policy it stopped under, so it is judgeable — and waking a + /// sandbox to find out would put its workload back on the network for the length of a boot + /// before this call could refuse it. + #[tokio::test] + async fn a_stopped_session_is_judged_before_it_is_woken() { + let mut client = MockSandboxDataPlaneApi::new(); + let declared = EgressPolicy { + default_action: "Deny".to_string(), + host_rules: vec![EgressHostRule { + pattern: "*".to_string(), + action: "Deny".to_string(), + }], + rules: Vec::new(), + unmodelled: Default::default(), + traffic_inspection: Some("Full".to_string()), + }; + client.expect_get_sandbox().returning(move |_, id| { + if id == "fresh" { + return Ok(running(id, Some(declared.clone()))); + } + // Built under `allow`, so it carries no policy at all. + let mut sandbox = running(id, None); + sandbox.state = Some("Stopped".to_string()); + Ok(sandbox) + }); + client.expect_resume_sandbox().never(); + client + .expect_delete_sandbox() + .times(1) + .returning(|_, _| Ok(())); + client + .expect_create_sandbox() + .times(1) + .returning(|_, request| Ok(running("fresh", request.egress))); + + let session = sandbox_denying(client, SandboxEgress::Deny) + .get_or_create(CreateSessionRequest { + session_id: Some("asleep-under-allow".to_string()), + tenant_key: None, + env: BTreeMap::new(), + }) + .await + .expect("a caller asking for a session gets a usable one"); + + assert_eq!(session.session_id, "fresh"); + } + + /// A session the data plane reports as `Failed` is replaced, not carried forever. + /// + /// It is a documented terminal state, and one this client did not know: an unmapped state + /// becomes an unexpected-response error, which nothing heals, so the id would be permanently + /// unusable through `get_or_create`. + #[tokio::test] + async fn a_failed_session_is_replaced() { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_get_sandbox().returning(|_, id| { + if id == "fresh" { + return Ok(running(id, None)); + } + let mut sandbox = running(id, None); + sandbox.state = Some("Failed".to_string()); + Ok(sandbox) + }); + client + .expect_create_sandbox() + .times(1) + .returning(|_, _| Ok(running("fresh", None))); + + let session = sandbox_with(client) + .get_or_create(CreateSessionRequest { + session_id: Some("broken".to_string()), + tenant_key: None, + env: BTreeMap::new(), + }) + .await + .expect("a failed session is replaced rather than returned"); + + assert_eq!(session.session_id, "fresh"); + } + + /// `Failed` is a state the data plane reports and this client has to know. + /// + /// An unmapped state becomes an unexpected-response error, and nothing heals that — so the id + /// of a failed sandbox would be permanently unusable rather than replaced. + #[tokio::test] + async fn a_failed_session_reads_as_terminated() { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_get_sandbox().times(1).returning(|_, id| { + let mut sandbox = running(id, None); + sandbox.state = Some("Failed".to_string()); + Ok(sandbox) + }); + + let session = sandbox_with(client) + .get("broken") + .await + .expect("a failed session is a state, not an unreadable response") + .expect("the session exists"); + + assert_eq!(session.state, SandboxSessionState::Terminated); + } + + /// A session that dies while it is being waited for is replaced, like one already dead. + /// + /// The same condition one read earlier heals as `sessionGone`; answering it differently + /// depending on which read observed it is the inconsistency this path exists to avoid. + #[tokio::test] + async fn a_session_that_dies_during_the_wait_is_replaced() { + let mut client = MockSandboxDataPlaneApi::new(); + let mut reads = 0; + client.expect_get_sandbox().returning(move |_, id| { + if id == "fresh" { + return Ok(running(id, None)); + } + reads += 1; + let mut sandbox = running(id, None); + // Asleep when it is found, being deleted by the time the wait looks. + sandbox.state = Some(if reads == 1 { "Stopped" } else { "Deleting" }.to_string()); + Ok(sandbox) + }); + client.expect_resume_sandbox().returning(|_, _| Ok(())); + client + .expect_create_sandbox() + .times(1) + .returning(|_, _| Ok(running("fresh", None))); + + let session = sandbox_with(client) + .get_or_create(CreateSessionRequest { + session_id: Some("dying".to_string()), + tenant_key: None, + env: BTreeMap::new(), + }) + .await + .expect("a session that died mid-wait is replaced"); + + assert_eq!(session.session_id, "fresh"); + } } From 1555252bdbc71ecce3c7bcee1ac5e6a2ed00bde9 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:45:45 +0300 Subject: [PATCH 19/32] fix(sandbox): suspend only the session this call woke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A resume that found the session already running, or watched it come up on its own, stopped it on a policy mismatch — ending a command another revision of the same stack was running, since both share the sandbox group. Only the wait knows whether it issued the resume, so it reports that instead of a pre-read inferring it from the state. A failed session found on reconnect is now reaped rather than left beside its replacement, and a sleeping record with no policy at all is left for the post-wake judgement: whether the data plane reports egressPolicy for a stopped sandbox is unverified, and refusing would churn every idle-suspended session if it does not. --- .../src/providers/sandbox/azure.rs | 444 +++++++++++++++--- 1 file changed, 378 insertions(+), 66 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index fd7e0c2d1..84dad37b2 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -39,6 +39,15 @@ pub struct AzureSandbox { memory: String, } +/// A session that reached `Running`, and whether this wait is what resumed it. +/// +/// Only the wait knows: a read taken before it cannot tell a session that came up on its own from +/// one this call woke, and suspending the wrong one ends another revision's command. +struct Ready { + sandbox: alien_azure_clients::azure::sandbox_data_plane::Sandbox, + resumed_here: bool, +} + impl AzureSandbox { /// Builds a provider bound to one sandbox group. pub fn new( @@ -214,15 +223,14 @@ impl Sandbox for AzureSandbox { let state = session_state("sandbox.get", sandbox.state.as_deref())?; - // Checked here as well as at create, because this is the path a reconnect takes: a - // session created under an older declaration outlives the change — Azure has no session - // ceiling, and an idle sandbox only suspends — so a caller holding its id would otherwise - // be handed a sandbox whose containment is whatever it was built with. - // - // A stopped sandbox still carries the policy it stopped under, so it is judged like a - // running one. Only a session still coming up has nothing to judge yet — reading that - // absence as a mismatch would report a healthy session as an uncontained one. - if !matches!(sandbox.state.as_deref(), Some("Creating" | "Resuming")) { + // This is the path a reconnect takes: a session outlives the declaration it was created + // under, so a caller holding its id would otherwise be handed whatever containment it was + // built with. Only the two ends of the lifecycle carry no policy, and that is not a + // mismatch. + if !matches!( + sandbox.state.as_deref(), + Some("Creating" | "Resuming" | "Deleting" | "Failed") + ) { self.policy_must_hold(&sandbox)?; } @@ -424,16 +432,31 @@ impl Sandbox for AzureSandbox { // judged after the wake rather than before it: the stopped record is not the one the work // runs under. That makes `resume` complete rather than accepted, which the sub-second // resume Microsoft documents makes affordable. - let running = self.await_running(OPERATION, session_id).await?; - - // Put back rather than destroyed: this call woke it, so undoing that returns the session - // to the state the caller found it in. Deleting a session the caller asked to resume - // takes a decision that is not this call's to take. - if let Err(error) = self.policy_must_hold(&running) { - if let Err(failed) = self.client.stop_sandbox(&self.sandbox_group, session_id).await { - warn!(session = %session_id, error = %failed, "could not re-suspend a session that woke up uncontained"); + let ready = self.await_running(OPERATION, session_id).await?; + + // Put back only if this call is what woke it. A session that was already up, or that came + // up on its own, is someone else's — another revision of the same stack shares this + // sandbox group — and stopping it would end a command that revision is mid-way through. + if let Err(error) = self.policy_must_hold(&ready.sandbox) { + if !ready.resumed_here { + return Err(error); } - return Err(error); + let Err(failed) = self + .client + .stop_sandbox(&self.sandbox_group, session_id) + .await + else { + return Err(error); + }; + + warn!(session = %session_id, error = %failed, "could not re-suspend a session that woke up uncontained"); + return Err(error.context(ErrorData::SandboxCommandFailed { + failure: "sandboxLeftAwake".to_string(), + reason: format!( + "session '{session_id}' was woken to be judged, does not carry the declared \ + policy, and could not be put back" + ), + })); } Ok(()) @@ -500,24 +523,28 @@ impl AzureSandbox { }; let found = match self.read_session(GET_OR_CREATE, session_id).await? { - Some(sandbox) if !matches!(sandbox.state.as_deref(), Some("Deleting" | "Failed")) => { - sandbox + // A failed sandbox is not going away on its own, and the caller asked for a session + // rather than for this one, so it is reaped rather than left beside its replacement. + Some(sandbox) if sandbox.state.as_deref() == Some("Failed") => { + return Err(self.discard(session_id, gone()).await) } + Some(sandbox) if sandbox.state.as_deref() != Some("Deleting") => sandbox, _ => return Err(gone()), }; - // Judged asleep before anything wakes it: a stopped sandbox carries the policy it stopped - // under, and waking one that already fails would put its workload back on the network for - // the length of a boot before this call could refuse it. - if !matches!(found.state.as_deref(), Some("Creating" | "Resuming")) { + // Judged asleep first: waking one that already fails puts its workload back on the network + // for a boot. An absent policy is unknown rather than wrong — whether the data plane + // reports one for a stopped sandbox is unverified, and refusing would churn every idle + // session if it does not. + if found.state.as_deref() != Some("Running") && found.egress_policy.is_some() { if let Err(error) = self.policy_must_hold(&found) { return Err(self.discard(session_id, error).await); } } - // Then judged again where the work will run: a policy set on the group can change while a - // session sleeps, and only the woken record shows that. - let running = self.await_running(GET_OR_CREATE, session_id).await?; + // Judged again once it is up: only the woken record covers a session that was still coming + // up, or a policy set on the group while it slept. + let running = self.await_running(GET_OR_CREATE, session_id).await?.sandbox; if let Err(error) = self.policy_must_hold(&running) { return Err(self.discard(session_id, error).await); } @@ -557,7 +584,18 @@ impl AzureSandbox { return refuse("sessionNotReady", "is still starting; wait for it to run") } Some("Deleting") => return refuse("sessionGone", "is being deleted"), - _ => return refuse("sessionSuspended", "is suspended; resume it first"), + Some("Failed") => return refuse("sessionGone", "has failed"), + Some("Stopping") => return refuse("sessionSuspended", "is stopping; wait for it"), + Some("Stopped" | "Suspended" | "Idle") => { + return refuse("sessionSuspended", "is suspended; resume it first") + } + // Unreadable rather than suspended, which would send a caller to `resume` for an + // answer it cannot give. The refusal below is reached only if the two state lists + // drift apart, and refusing is the safe side of that. + other => { + session_state(operation, other)?; + return refuse("sessionNotReady", "is in a state this client cannot read"); + } } self.policy_must_hold(&sandbox) @@ -626,7 +664,7 @@ impl AzureSandbox { // The running sandbox is what gets judged, not the accept: a create response sent while // the sandbox is still coming up need not carry the policy yet, and reading its absence // as "the restriction did not take" would delete every sandbox that answered early. - let running = self.await_running(CREATE, &sandbox.id).await?; + let running = self.await_running(CREATE, &sandbox.id).await?.sandbox; // A restriction that did not take effect is worse than one that was never asked for: the // caller believes the sandbox is contained. @@ -648,13 +686,10 @@ impl AzureSandbox { /// can stop a sandbox before its first command, and on the reconnect path a stopped sandbox /// is the ordinary resting state. Nothing else brings one up, so waiting alone would spend /// the whole deadline and then delete it. - async fn await_running( - &self, - operation: &str, - session_id: &str, - ) -> Result { + async fn await_running(&self, operation: &str, session_id: &str) -> Result { let deadline = std::time::Instant::now() + SESSION_READY_TIMEOUT; let mut refusal: Option = None; + let mut resumed_here = false; loop { let Some(sandbox) = self.read_session(operation, session_id).await? else { @@ -667,9 +702,13 @@ impl AzureSandbox { // The raw state, because the four the trait publishes cannot separate a sandbox on // its way up from one on its way down, and this loop needs that difference. match sandbox.state.as_deref() { - Some("Running") => return Ok(sandbox), + Some("Running") => { + return Ok(Ready { + sandbox, + resumed_here, + }) + } Some("Creating" | "Resuming") => {} - // Going down, or already down. Either way nothing is bringing it up. // Still going down. Resume is refused in this state — the SDK's own resumable // set excludes it — so the wait is for `Stopped`, not for the call to work. Some("Stopping") => {} @@ -677,9 +716,20 @@ impl AzureSandbox { // first one: remembering only that an attempt was made would spend the whole // budget watching a sandbox nothing is bringing up. Some("Stopped" | "Suspended" | "Idle") => { - if let Err(error) = self.resume_unchecked(session_id).await { - warn!(session = %session_id, %error, "resume was refused; still waiting"); - refusal = Some(error.code.clone()); + match self.resume_unchecked(session_id).await { + Ok(()) => { + refusal = None; + resumed_here = true; + } + Err(error) => { + warn!(session = %session_id, %error, "resume was refused; still waiting"); + refusal = Some(match &error.error { + Some(ErrorData::SandboxCommandFailed { failure, .. }) => { + failure.clone() + } + _ => error.code.clone(), + }); + } } } // A terminated session never becomes runnable, and folding it into the timeout @@ -720,7 +770,11 @@ impl AzureSandbox { /// The delete's own failure must not replace that reason — it is the finding that matters — /// but it must not vanish either: the session id is in the error, and a failed delete leaves /// a sandbox only that id can find. - async fn discard(&self, session_id: &str, reason: AlienError) -> AlienError { + async fn discard( + &self, + session_id: &str, + reason: AlienError, + ) -> AlienError { let Err(error) = self.accept_delete(session_id).await else { return reason; }; @@ -1073,11 +1127,11 @@ fn is_not_found(error: &AlienError) -> bool { #[cfg(test)] mod tests { use super::*; + use alien_azure_clients::azure::sandbox_data_plane::ExecResult; + use alien_azure_clients::azure::sandbox_data_plane::MockSandboxDataPlaneApi; use alien_azure_clients::azure::sandbox_data_plane::{ EgressRule, EgressRuleAction, EgressRuleMatch, }; - use alien_azure_clients::azure::sandbox_data_plane::ExecResult; - use alien_azure_clients::azure::sandbox_data_plane::MockSandboxDataPlaneApi; use futures::StreamExt; fn http_error(status: u16, body: &str) -> AlienError { @@ -1256,7 +1310,11 @@ mod tests { #[async_trait] impl SandboxDataPlaneApi for ScriptedExec { - async fn stop_sandbox(&self, _group: &str, _sandbox_id: &str) -> alien_client_core::Result<()> { + async fn stop_sandbox( + &self, + _group: &str, + _sandbox_id: &str, + ) -> alien_client_core::Result<()> { unreachable!("the command paths never suspend") } @@ -1519,7 +1577,15 @@ mod tests { client.expect_mkdir().never(); let sandbox = sandbox_with(client); - for path in ["../etc/shadow", "", "/", "work/", "a//b", "a/../../b", "/../escape"] { + for path in [ + "../etc/shadow", + "", + "/", + "work/", + "a//b", + "a/../../b", + "/../escape", + ] { let error = sandbox .read_file("s1", path) .await @@ -1781,7 +1847,10 @@ mod tests { assert_eq!(session.state, SandboxSessionState::Running); } - fn running(id: &str, egress: Option) -> alien_azure_clients::azure::sandbox_data_plane::Sandbox { + fn running( + id: &str, + egress: Option, + ) -> alien_azure_clients::azure::sandbox_data_plane::Sandbox { alien_azure_clients::azure::sandbox_data_plane::Sandbox { id: id.to_string(), egress_policy: egress, @@ -1997,13 +2066,14 @@ mod tests { #[tokio::test] async fn a_terminated_session_is_replaced_rather_than_reconnected_to() { let mut client = MockSandboxDataPlaneApi::new(); - client - .expect_get_sandbox() - .times(1) - .returning(|_, id| Ok(running(id, None)).map(|mut sandbox: alien_azure_clients::azure::sandbox_data_plane::Sandbox| { - sandbox.state = Some("Deleting".to_string()); - sandbox - })); + client.expect_get_sandbox().times(1).returning(|_, id| { + Ok(running(id, None)).map( + |mut sandbox: alien_azure_clients::azure::sandbox_data_plane::Sandbox| { + sandbox.state = Some("Deleting".to_string()); + sandbox + }, + ) + }); client .expect_create_sandbox() .times(1) @@ -2579,18 +2649,84 @@ mod tests { /// resume its way around the check. #[tokio::test] async fn a_stale_policy_session_cannot_be_resumed() { + let mut client = MockSandboxDataPlaneApi::new(); + // Found asleep, so this call is what wakes it — and therefore what must put it back. + let mut reads = 0; + client.expect_get_sandbox().returning(move |_, id| { + reads += 1; + let mut sandbox = running(id, None); + // Asleep for the resume's own read and the wait's first poll, so the wait is what + // wakes it — and therefore what owes the put-back. + if reads <= 2 { + sandbox.state = Some("Stopped".to_string()); + } + Ok(sandbox) + }); + client.expect_resume_sandbox().returning(|_, _| Ok(())); + // Refused, not reaped: the caller asked to wake a session, not to lose it. Put back, + // because this call is what woke it. + client.expect_delete_sandbox().never(); + client + .expect_stop_sandbox() + .times(1) + .returning(|_, _| Ok(())); + + let error = sandbox_denying(client, SandboxEgress::Deny) + .resume("built-under-allow") + .await + .expect_err("a session without the declared policy must not be woken"); + + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + } + + /// A resume that finds the session already awake refuses without touching it. + /// + /// Two revisions of a stack share a sandbox group, so stopping a session this call did not + /// wake ends whatever command the other revision is running. Refusing is this call's to do; + /// suspending someone else's work is not. + #[tokio::test] + async fn a_session_this_call_did_not_wake_is_left_running() { let mut client = MockSandboxDataPlaneApi::new(); client .expect_get_sandbox() .returning(|_, id| Ok(running(id, None))); - // Refused, not reaped: the caller asked to wake a session, not to lose it. + client.expect_resume_sandbox().never(); + client.expect_stop_sandbox().never(); client.expect_delete_sandbox().never(); - client.expect_stop_sandbox().returning(|_, _| Ok(())); let error = sandbox_denying(client, SandboxEgress::Deny) - .resume("built-under-allow") + .resume("someone-elses-session") .await - .expect_err("a session without the declared policy must not be woken"); + .expect_err("a session without the declared policy must not be handed back"); + + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + } + + /// A session that came up on its own is not this call's to suspend. + /// + /// A read taken before the wait sees `Creating` and calls that asleep, but nothing here woke + /// it — another revision created it a moment earlier. Stopping it on a policy mismatch ends + /// that revision's session; only refusing is this call's to do. + #[tokio::test] + async fn a_session_that_came_up_on_its_own_is_not_suspended() { + let mut client = MockSandboxDataPlaneApi::new(); + let mut reads = 0; + client.expect_get_sandbox().returning(move |_, id| { + reads += 1; + let mut sandbox = running(id, None); + if reads <= 2 { + sandbox.state = Some("Creating".to_string()); + } + Ok(sandbox) + }); + client.expect_resume_sandbox().never(); + client.expect_stop_sandbox().never(); + client.expect_delete_sandbox().never(); + + let error = sandbox_denying(client, SandboxEgress::Deny) + .resume("created-by-another-revision") + .await + .expect_err("a session without the declared policy must not be handed back"); assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); } @@ -2731,12 +2867,14 @@ mod tests { reads += 1; let mut sandbox = running(id, None); // Stopping, then stopped, then up — the shape a suspend-then-resume race produces. - sandbox.state = Some(match reads { - 1 => "Stopping", - 2 | 3 => "Stopped", - _ => "Running", - } - .to_string()); + sandbox.state = Some( + match reads { + 1 => "Stopping", + 2 | 3 => "Stopped", + _ => "Running", + } + .to_string(), + ); Ok(sandbox) }); @@ -2812,8 +2950,17 @@ mod tests { if id == "fresh" { return Ok(running(id, Some(declared.clone()))); } - // Built under `allow`, so it carries no policy at all. - let mut sandbox = running(id, None); + // Asleep, and the record it stopped under is present and open. + let mut sandbox = running( + id, + Some(EgressPolicy { + default_action: "Allow".to_string(), + host_rules: Vec::new(), + rules: Vec::new(), + unmodelled: Default::default(), + traffic_inspection: Some("Full".to_string()), + }), + ); sandbox.state = Some("Stopped".to_string()); Ok(sandbox) }); @@ -2855,6 +3002,13 @@ mod tests { sandbox.state = Some("Failed".to_string()); Ok(sandbox) }); + // A failed sandbox is not going away on its own, so it is reaped rather than left beside + // its replacement. + client + .expect_delete_sandbox() + .withf(|_, id| id == "broken") + .times(1) + .returning(|_, _| Ok(())); client .expect_create_sandbox() .times(1) @@ -2885,7 +3039,7 @@ mod tests { Ok(sandbox) }); - let session = sandbox_with(client) + let session = sandbox_denying(client, SandboxEgress::Deny) .get("broken") .await .expect("a failed session is a state, not an unreadable response") @@ -2912,7 +3066,6 @@ mod tests { sandbox.state = Some(if reads == 1 { "Stopped" } else { "Deleting" }.to_string()); Ok(sandbox) }); - client.expect_resume_sandbox().returning(|_, _| Ok(())); client .expect_create_sandbox() .times(1) @@ -2929,4 +3082,163 @@ mod tests { assert_eq!(session.session_id, "fresh"); } + + /// A sleeping session that still matches is reconnected, not replaced. + /// + /// The discriminating case for judging a stopped record: if the data plane does report the + /// policy for a suspended sandbox, a compliant one has to survive the reconnect — otherwise + /// every idle-suspended session would be silently churned on each attach. + #[tokio::test] + async fn a_sleeping_session_that_still_matches_is_kept() { + let declared = EgressPolicy { + default_action: "Deny".to_string(), + host_rules: vec![EgressHostRule { + pattern: "*".to_string(), + action: "Deny".to_string(), + }], + rules: Vec::new(), + unmodelled: Default::default(), + traffic_inspection: Some("Full".to_string()), + }; + + let mut client = MockSandboxDataPlaneApi::new(); + let mut reads = 0; + let carried = declared.clone(); + client.expect_get_sandbox().returning(move |_, id| { + reads += 1; + let mut sandbox = running(id, Some(carried.clone())); + // Asleep for the first two reads — the reconnect's own, and the wait's first poll — + // so the resume is actually issued. + if reads <= 2 { + sandbox.state = Some("Stopped".to_string()); + } + Ok(sandbox) + }); + client + .expect_resume_sandbox() + .times(1) + .returning(|_, _| Ok(())); + client.expect_delete_sandbox().never(); + client.expect_create_sandbox().never(); + + let session = sandbox_denying(client, SandboxEgress::Deny) + .get_or_create(CreateSessionRequest { + session_id: Some("asleep-and-fine".to_string()), + tenant_key: None, + env: BTreeMap::new(), + }) + .await + .expect("a compliant sleeping session is woken and returned"); + + assert_eq!(session.session_id, "asleep-and-fine"); + } + + /// A sleeping session with no policy on its record is woken before it is judged. + /// + /// Whether the data plane reports `egressPolicy` for a sandbox that is not running is + /// unverified. If it does not, judging the sleeping record would delete every compliant + /// idle-suspended session on every reconnect, so the absence is left for the post-wake read. + #[tokio::test] + async fn a_sleeping_session_with_no_policy_is_woken_before_it_is_judged() { + let declared = EgressPolicy { + default_action: "Deny".to_string(), + host_rules: vec![EgressHostRule { + pattern: "*".to_string(), + action: "Deny".to_string(), + }], + rules: Vec::new(), + unmodelled: Default::default(), + traffic_inspection: Some("Full".to_string()), + }; + + let mut client = MockSandboxDataPlaneApi::new(); + let mut reads = 0; + let carried = declared.clone(); + client.expect_get_sandbox().returning(move |_, id| { + reads += 1; + if reads <= 2 { + let mut asleep = running(id, None); + asleep.state = Some("Stopped".to_string()); + return Ok(asleep); + } + Ok(running(id, Some(carried.clone()))) + }); + client + .expect_resume_sandbox() + .times(1) + .returning(|_, _| Ok(())); + client.expect_delete_sandbox().never(); + client.expect_create_sandbox().never(); + + let session = sandbox_denying(client, SandboxEgress::Deny) + .get_or_create(CreateSessionRequest { + session_id: Some("asleep-without-a-record".to_string()), + tenant_key: None, + env: BTreeMap::new(), + }) + .await + .expect("an absent policy on a sleeping record is unknown, not a mismatch"); + + assert_eq!(session.session_id, "asleep-without-a-record"); + } + + /// A session woken to be judged, found uncontained, and left awake says so. + /// + /// The refusal alone would read as "nothing happened", when what happened is a sandbox this + /// call put back on the network under a policy the declaration does not allow. + #[tokio::test] + async fn a_session_that_cannot_be_put_back_is_reported_as_left_awake() { + let mut client = MockSandboxDataPlaneApi::new(); + let mut reads = 0; + client.expect_get_sandbox().returning(move |_, id| { + reads += 1; + let mut sandbox = running(id, None); + // Asleep for the resume's own read and the wait's first poll, so the wait is what + // wakes it — and therefore what owes the put-back. + if reads <= 2 { + sandbox.state = Some("Stopped".to_string()); + } + Ok(sandbox) + }); + client.expect_resume_sandbox().returning(|_, _| Ok(())); + client + .expect_stop_sandbox() + .times(1) + .returning(|_, _| Err(http_error(500, "SuspendFailed"))); + + let error = sandbox_denying(client, SandboxEgress::Deny) + .resume("built-under-allow") + .await + .expect_err("a session that woke up uncontained must not be reported as resumed"); + + assert!( + error.to_string().contains("sandboxLeftAwake"), + "a sandbox left awake has to be named, not folded into the refusal: {error}" + ); + } + + /// A state this client cannot read takes no work, and is not called suspended. + /// + /// Reporting it as suspended sends the caller to `resume`, which answers the same thing — + /// a loop that ends in a timeout instead of the unreadable state that caused it. + #[tokio::test] + async fn an_unreadable_state_takes_no_work_and_is_not_called_suspended() { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_get_sandbox().times(1).returning(|_, _| { + Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox { + id: "s1".to_string(), + egress_policy: None, + state: Some("Hibernated".to_string()), + }) + }); + client.expect_execute_shell_command().never(); + client.expect_resume_sandbox().never(); + + let error = match sandbox_with(client).run_command("s1", command(5)).await { + Ok(_) => panic!("an unreadable state must not take work"), + Err(error) => error, + }; + + assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}"); + } } From 6d4c2ad752033e2e4e68ebbbc8712555d2c2f2ba Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:47:53 +0300 Subject: [PATCH 20/32] fix(sandbox): send the verbs a repeat performs twice exactly once A create is a PUT to a collection with a service-minted id and no idempotency key, and the transport retried it three times: a response lost after the sandbox was minted made a second one, returned success, and left the first running with the caller's environment variables and no id-holder able to reap it. An exec answering late was re-sent the same way, so untrusted code could run four times. Both now take a single-attempt path; every other verb keeps its retry, which is what the new test asserts against. The rest of this change closes what the same review found. `get` and the reconnect path now share one predicate for "is there a policy here to judge", so they cannot disagree about a suspended session; `resume` judges the sleeping record before waking anything; and the fact that this call issued the resume now survives every exit from the wait, so a session woken by a wait that then failed is still put back or named. A command's own environment variables reached nothing: the exec endpoint takes no environment, so they travel as shell assignments in front of the command, with names checked because a name sits where quoting cannot reach it. Azure's catalog image rule moves to plan time beside the AWS one, so a sandbox no worker binds is still refused, and `code.image` says what Azure takes rather than offering two examples it rejects. --- .../alien-azure-clients/src/azure/common.rs | 114 +++-- .../src/azure/sandbox_data_plane.rs | 71 ++- .../src/providers/sandbox/azure.rs | 441 +++++++++++++++--- .../src/emitters/aws/sandbox.rs | 5 +- crates/alien-core/src/resources/sandbox.rs | 117 ++++- crates/alien-helm/src/emitters/sandbox.rs | 1 - .../src/emitters/aws/sandbox.rs | 5 +- .../src/emitters/azure/sandbox.rs | 77 ++- .../src/emitters/gcp/sandbox.rs | 43 ++ .../core/src/generated/schemas/sandbox.json | 2 +- .../src/generated/schemas/sandboxCode.json | 2 +- .../src/generated/schemas/sandboxEgress.json | 2 +- .../src/generated/zod/sandbox-code-schema.ts | 2 +- 13 files changed, 693 insertions(+), 189 deletions(-) diff --git a/crates/alien-azure-clients/src/azure/common.rs b/crates/alien-azure-clients/src/azure/common.rs index bf8e7e07c..126159e7a 100644 --- a/crates/alien-azure-clients/src/azure/common.rs +++ b/crates/alien-azure-clients/src/azure/common.rs @@ -184,6 +184,58 @@ impl AzureClientBase { // ------------- Low-level executor ------------- + /// Sends a request exactly once, with no retry. + /// + /// For the verbs a repeat performs twice: a PUT to a collection with a server-minted id makes + /// a second resource the caller has no id for, and an exec that answered late may already + /// have started the command. Neither carries an idempotency key, so the only safe number of + /// attempts is one. + pub async fn execute_request_once( + &self, + req: reqwest::Request, + op: &str, + res_name: &str, + ) -> Result { + Self::send_once(&self.client, req, op, res_name).await + } + + /// One attempt: send it, and turn a non-success status into an error carrying the context. + async fn send_once( + client: &reqwest::Client, + req: reqwest::Request, + op: &str, + res_name: &str, + ) -> Result { + // Captured before execution consumes the request. + let request_url = req.url().to_string(); + let request_body = req.body().and_then(|b| b.as_bytes()).map(|b| { + String::from_utf8_lossy(&b[..b.len().min(MAX_ECHOED_REQUEST_BODY)]).to_string() + }); + + let resp = client + .execute(req) + .await + .into_alien_error() + .context(ErrorData::HttpRequestFailed { + message: format!("Azure {}: HTTP error for {}", op, res_name), + })?; + let status = resp.status(); + if status.is_success() || status == StatusCode::CREATED || status == StatusCode::ACCEPTED { + return Ok(resp); + } + + let body = resp.text().await.unwrap_or_default(); + Err(create_azure_http_error_with_context( + status, + op, + "Resource", + res_name, + &body, + &request_url, + request_body, + )) + } + /// Executes an HTTP request with retry logic and returns the response if successful. #[cfg(target_arch = "wasm32")] pub async fn execute_request( @@ -207,36 +259,7 @@ impl AzureClientBase { }) })?; - // Capture request details before execution consumes the request - let request_url = req_clone.url().to_string(); - let request_body = req_clone - .body() - .and_then(|b| b.as_bytes()) - .map(|b| String::from_utf8_lossy(&b[..b.len().min(MAX_ECHOED_REQUEST_BODY)]).to_string()); - - let resp = client.execute(req_clone).await.into_alien_error().context( - ErrorData::HttpRequestFailed { - message: format!("Azure {}: HTTP error for {}", op, res_name), - }, - )?; - let status = resp.status(); - if status.is_success() - || status == StatusCode::CREATED - || status == StatusCode::ACCEPTED - { - Ok(resp) - } else { - let body = resp.text().await.unwrap_or_default(); - Err(create_azure_http_error_with_context( - status, - &op, - "Resource", - &res_name, - &body, - &request_url, - request_body, - )) - } + Self::send_once(&client, req_clone, &op, &res_name).await } }; self.with_retry(retryable).await @@ -265,36 +288,7 @@ impl AzureClientBase { }) })?; - // Capture request details before execution consumes the request - let request_url = req_clone.url().to_string(); - let request_body = req_clone - .body() - .and_then(|b| b.as_bytes()) - .map(|b| String::from_utf8_lossy(&b[..b.len().min(MAX_ECHOED_REQUEST_BODY)]).to_string()); - - let resp = client.execute(req_clone).await.into_alien_error().context( - ErrorData::HttpRequestFailed { - message: format!("Azure {}: HTTP error for {}", op, res_name), - }, - )?; - let status = resp.status(); - if status.is_success() - || status == StatusCode::CREATED - || status == StatusCode::ACCEPTED - { - Ok(resp) - } else { - let body = resp.text().await.unwrap_or_default(); - Err(create_azure_http_error_with_context( - status, - &op, - "Resource", - &res_name, - &body, - &request_url, - request_body, - )) - } + Self::send_once(&client, req_clone, &op, &res_name).await } }; self.with_retry(retryable).await diff --git a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs index 76b4b2f8a..0b217336f 100644 --- a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs +++ b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs @@ -410,8 +410,14 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { let signed = self.base.sign_request(request, &token).await?; // The create body carries the caller's environment variables, and a failure echoes the // request into the error chain, which is serialized into durable state. + // + // Sent once. The id is minted by the service and this is a PUT to a collection, so a + // re-send mints a second sandbox — and with no enumeration verb, the first one has no + // id-holder and nothing to reap it. let response = alien_client_core::redact_request_body( - self.base.execute_request(signed, "CreateSandbox", group).await, + self.base + .execute_request_once(signed, "CreateSandbox", group) + .await, )?; Self::parse(response, "CreateSandbox").await } @@ -477,9 +483,12 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { let signed = self.base.sign_request(request, &token).await?; // The body is the command, which is where a caller puts a token it wants the session to // have. + // + // Sent once: a response that never arrives does not mean the command did not start, and + // running untrusted code a second time is not a recovery. let response = alien_client_core::redact_request_body( self.base - .execute_request(signed, "ExecuteShellCommand", sandbox_id) + .execute_request_once(signed, "ExecuteShellCommand", sandbox_id) .await, )?; Self::parse(response, "ExecuteShellCommand").await @@ -594,7 +603,11 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { .body(body) .build()?; let signed = self.base.sign_request(request, &token).await?; - self.base.execute_request(signed, "Mkdir", sandbox_id).await?; + // The body is a caller-supplied path; wrapped like the other bodied calls so the next one + // added here inherits the redaction rather than the omission. + alien_client_core::redact_request_body( + self.base.execute_request(signed, "Mkdir", sandbox_id).await, + )?; Ok(()) } } @@ -633,6 +646,58 @@ mod tests { ) } + /// A create is delivered once, however the data plane answers. + /// + /// The id is minted by the service and the PUT names a collection, so a second delivery makes + /// a second sandbox that no id-holder can find and no enumeration verb can list — one this + /// call would never learn about even when it eventually succeeds. The read is the contrast: + /// repeating it is free, so it keeps the retry. + #[tokio::test] + async fn a_create_is_never_re_sent_where_a_read_is() { + let server = MockServer::start_async().await; + let unavailable = server.mock(|when, then| { + when.method(httpmock::Method::PUT); + then.status(503).body("{}"); + }); + let client = client_against(&server); + + client + .create_sandbox( + "grp", + CreateSandbox { + disk_image: "ubuntu".to_string(), + cpu: "1".to_string(), + memory: "2Gi".to_string(), + environment: Default::default(), + egress: None, + idle_suspend_seconds: None, + }, + ) + .await + .expect_err("an unavailable data plane fails the create"); + + assert_eq!( + unavailable.hits(), + 1, + "a create that may already have minted a sandbox must not be sent twice" + ); + + let read = server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(503).body("{}"); + }); + client + .get_sandbox("grp", "s1") + .await + .expect_err("an unavailable data plane fails the read"); + + assert!( + read.hits() > 1, + "a read is safe to repeat and must keep its retry: {} attempt(s)", + read.hits() + ); + } + /// Pinned because the contract came from a preview SDK Microsoft says may change. If these /// drift, the client must be re-read against the package rather than patched by guess. #[test] diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 84dad37b2..517e57841 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -39,15 +39,6 @@ pub struct AzureSandbox { memory: String, } -/// A session that reached `Running`, and whether this wait is what resumed it. -/// -/// Only the wait knows: a read taken before it cannot tell a session that came up on its own from -/// one this call woke, and suspending the wrong one ends another revision's command. -struct Ready { - sandbox: alien_azure_clients::azure::sandbox_data_plane::Sandbox, - resumed_here: bool, -} - impl AzureSandbox { /// Builds a provider bound to one sandbox group. pub fn new( @@ -227,12 +218,7 @@ impl Sandbox for AzureSandbox { // under, so a caller holding its id would otherwise be handed whatever containment it was // built with. Only the two ends of the lifecycle carry no policy, and that is not a // mismatch. - if !matches!( - sandbox.state.as_deref(), - Some("Creating" | "Resuming" | "Deleting" | "Failed") - ) { - self.policy_must_hold(&sandbox)?; - } + self.judge_if_judgeable(&sandbox)?; Ok(Some(SandboxSession { session_id: sandbox.id, @@ -300,7 +286,13 @@ impl Sandbox for AzureSandbox { // agent-supervised backends give. The client-side guard is the backstop for a data plane // that never answers at all; there the only lever left is ending the session, and that // call returns once the session is confirmed gone rather than claim containment early. - let shell = bounded_shell(&request.command, request.deadline); + // The data plane's exec takes a command and a working directory and nothing else, so a + // per-command variable has to travel as a shell assignment in front of it. Names are + // checked first: an unchecked one is a second command, not a variable. + for name in request.env.keys() { + checked_env_name(RUN_COMMAND, name)?; + } + let shell = bounded_shell(&request.command, &request.env, request.deadline); let result = self.execute_within(session_id, &shell, &request).await?; // The session's own report, removed from what the caller sees. @@ -354,6 +346,9 @@ impl Sandbox for AzureSandbox { Ok(Box::pin(stream::iter(frames))) } + /// Ungated on purpose, as is `mkdir`: reading existing content and creating an empty + /// directory add nothing to a sandbox, so neither can turn a stale session into a way to run + /// something under egress the declaration has since removed. async fn read_file(&self, session_id: &str, path: &str) -> Result> { Self::checked_session_id("sandbox.readFile", session_id)?; let path = &checked_path("sandbox.readFile", path)?; @@ -382,7 +377,6 @@ impl Sandbox for AzureSandbox { // One request per path, stopping at the first failure: the same partial application every // other backend performs, so a caller sees one contract rather than five. for (path, contents) in files { - self.client .write_file(&self.sandbox_group, session_id, &path, contents) .await @@ -421,45 +415,32 @@ impl Sandbox for AzureSandbox { Self::checked_session_id("sandbox.resume", session_id)?; const OPERATION: &str = "sandbox.resume"; - if self.read_session(OPERATION, session_id).await?.is_none() { + let Some(found) = self.read_session(OPERATION, session_id).await? else { return Err(AlienError::new(ErrorData::SandboxCommandFailed { failure: "sessionGone".to_string(), reason: format!("{OPERATION}: session '{session_id}' does not exist"), })); - } + }; - // Waking a session puts whatever it was running back on the network, so the policy is - // judged after the wake rather than before it: the stopped record is not the one the work - // runs under. That makes `resume` complete rather than accepted, which the sub-second - // resume Microsoft documents makes affordable. - let ready = self.await_running(OPERATION, session_id).await?; - - // Put back only if this call is what woke it. A session that was already up, or that came - // up on its own, is someone else's — another revision of the same stack shares this - // sandbox group — and stopping it would end a command that revision is mid-way through. - if let Err(error) = self.policy_must_hold(&ready.sandbox) { - if !ready.resumed_here { - return Err(error); - } - let Err(failed) = self - .client - .stop_sandbox(&self.sandbox_group, session_id) - .await - else { - return Err(error); - }; + // Refused from the record already in hand where that record answers it, so a session + // whose stored policy is plainly wrong is never put back on the network for a boot. + self.judge_if_judgeable(&found)?; - warn!(session = %session_id, error = %failed, "could not re-suspend a session that woke up uncontained"); - return Err(error.context(ErrorData::SandboxCommandFailed { - failure: "sandboxLeftAwake".to_string(), - reason: format!( - "session '{session_id}' was woken to be judged, does not carry the declared \ - policy, and could not be put back" - ), - })); - } + // Judged again after the wake: the stopped record is not the one the work runs under, and + // a policy set on the group can change while a session sleeps. + let mut resumed_here = false; + let woken = self + .await_running(OPERATION, session_id, &mut resumed_here) + .await; - Ok(()) + let refusal = match woken { + Err(error) => error, + Ok(running) => match self.policy_must_hold(&running) { + Ok(()) => return Ok(()), + Err(error) => error, + }, + }; + Err(self.put_back(session_id, resumed_here, refusal).await) } async fn snapshot(&self, _session_id: &str) -> Result { @@ -533,18 +514,24 @@ impl AzureSandbox { }; // Judged asleep first: waking one that already fails puts its workload back on the network - // for a boot. An absent policy is unknown rather than wrong — whether the data plane - // reports one for a stopped sandbox is unverified, and refusing would churn every idle - // session if it does not. - if found.state.as_deref() != Some("Running") && found.egress_policy.is_some() { - if let Err(error) = self.policy_must_hold(&found) { - return Err(self.discard(session_id, error).await); - } + // for a boot. + if let Err(error) = self.judge_if_judgeable(&found) { + return Err(self.discard(session_id, error).await); } // Judged again once it is up: only the woken record covers a session that was still coming // up, or a policy set on the group while it slept. - let running = self.await_running(GET_OR_CREATE, session_id).await?.sandbox; + let mut resumed_here = false; + let running = match self + .await_running(GET_OR_CREATE, session_id, &mut resumed_here) + .await + { + Ok(running) => running, + // A wait that woke it and then failed leaves it awake, and this call is about to hand + // back a different session — so the one it woke is its own to reap. + Err(error) if resumed_here => return Err(self.discard(session_id, error).await), + Err(error) => return Err(error), + }; if let Err(error) = self.policy_must_hold(&running) { return Err(self.discard(session_id, error).await); } @@ -664,7 +651,10 @@ impl AzureSandbox { // The running sandbox is what gets judged, not the accept: a create response sent while // the sandbox is still coming up need not carry the policy yet, and reading its absence // as "the restriction did not take" would delete every sandbox that answered early. - let running = self.await_running(CREATE, &sandbox.id).await?.sandbox; + let mut resumed_here = false; + let running = self + .await_running(CREATE, &sandbox.id, &mut resumed_here) + .await?; // A restriction that did not take effect is worse than one that was never asked for: the // caller believes the sandbox is contained. @@ -686,10 +676,14 @@ impl AzureSandbox { /// can stop a sandbox before its first command, and on the reconnect path a stopped sandbox /// is the ordinary resting state. Nothing else brings one up, so waiting alone would spend /// the whole deadline and then delete it. - async fn await_running(&self, operation: &str, session_id: &str) -> Result { + async fn await_running( + &self, + operation: &str, + session_id: &str, + resumed_here: &mut bool, + ) -> Result { let deadline = std::time::Instant::now() + SESSION_READY_TIMEOUT; let mut refusal: Option = None; - let mut resumed_here = false; loop { let Some(sandbox) = self.read_session(operation, session_id).await? else { @@ -702,12 +696,7 @@ impl AzureSandbox { // The raw state, because the four the trait publishes cannot separate a sandbox on // its way up from one on its way down, and this loop needs that difference. match sandbox.state.as_deref() { - Some("Running") => { - return Ok(Ready { - sandbox, - resumed_here, - }) - } + Some("Running") => return Ok(sandbox), Some("Creating" | "Resuming") => {} // Still going down. Resume is refused in this state — the SDK's own resumable // set excludes it — so the wait is for `Stopped`, not for the call to work. @@ -719,7 +708,7 @@ impl AzureSandbox { match self.resume_unchecked(session_id).await { Ok(()) => { refusal = None; - resumed_here = true; + *resumed_here = true; } Err(error) => { warn!(session = %session_id, %error, "resume was refused; still waiting"); @@ -735,10 +724,12 @@ impl AzureSandbox { // A terminated session never becomes runnable, and folding it into the timeout // would report it a minute late as a slow boot. other => { - session_state(operation, other)?; + let state = session_state(operation, other)?; return Err(AlienError::new(ErrorData::SandboxCommandFailed { failure: "sessionTerminated".to_string(), - reason: format!("session '{session_id}' is being deleted"), + reason: format!( + "session '{session_id}' reached {state:?} and will not run again" + ), })); } } @@ -765,6 +756,60 @@ impl AzureSandbox { } } + /// Whether a record carries a policy this client can hold it to. + /// + /// A running session always reports its effective policy, so an absent one there is a + /// mismatch. Off that state the data plane's behaviour is unverified, and reading absence as + /// a mismatch would refuse every idle-suspended session; the read taken after the wake is + /// authoritative either way. + fn judgeable(sandbox: &alien_azure_clients::azure::sandbox_data_plane::Sandbox) -> bool { + match sandbox.state.as_deref() { + Some("Running") => true, + Some("Stopping" | "Stopped" | "Suspended" | "Idle") => sandbox.egress_policy.is_some(), + _ => false, + } + } + + /// Judges a record only where there is something to judge. + fn judge_if_judgeable( + &self, + sandbox: &alien_azure_clients::azure::sandbox_data_plane::Sandbox, + ) -> Result<()> { + if Self::judgeable(sandbox) { + self.policy_must_hold(sandbox)?; + } + Ok(()) + } + + /// Re-suspends a session this call woke, keeping the reason it is being refused. + /// + /// Only a session this call woke: another revision of the same stack shares the sandbox + /// group, and stopping one that was already up ends a command that revision is mid-way + /// through. A stop that fails is named rather than logged — a sandbox this call put back on + /// the network under a policy the declaration does not allow is not "nothing happened". + async fn put_back( + &self, + session_id: &str, + resumed_here: bool, + reason: AlienError, + ) -> AlienError { + if !resumed_here { + return reason; + } + let Err(failed) = self.client.stop_sandbox(&self.sandbox_group, session_id).await else { + return reason; + }; + + warn!(session = %session_id, error = %failed, "could not re-suspend a session this call woke"); + reason.context(ErrorData::SandboxCommandFailed { + failure: "sandboxLeftAwake".to_string(), + reason: format!( + "session '{session_id}' was woken by this call, could not be handed back, and \ + could not be put to sleep again" + ), + }) + } + /// Deletes a sandbox the caller will never receive, keeping the reason it is being discarded. /// /// The delete's own failure must not replace that reason — it is the finding that matters — @@ -867,18 +912,51 @@ const TERMINATE_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_s /// The data plane takes one shell string, so the command is passed to `sh` as arguments rather /// than pasted into the program text: `"$@"` cannot re-parse what it holds, so an argument /// carrying a space or an operator stays one argument. -fn bounded_shell(command: &[String], deadline: std::time::Duration) -> String { +fn bounded_shell( + command: &[String], + env: &BTreeMap, + deadline: std::time::Duration, +) -> String { let escape = |value: &str| value.replace('\'', "'\\''"); let arguments = command .iter() .map(|argument| format!(" '{}'", escape(argument))) .collect::(); + // Assignments in front of a simple command are exported to it, so the wrapper and everything + // it runs see them. + let assignments = env + .iter() + .map(|(name, value)| format!("{name}='{}' ", escape(value))) + .collect::(); format!( - "sh -c '{}' sh{arguments}", + "{assignments}sh -c '{}' sh{arguments}", escape(&DeadlineReport::bounded_program(deadline)) ) } +/// Refuses a variable name the shell would read as anything other than a name. +/// +/// The name is not quotable — it sits left of the `=` — so a name carrying a space or a `;` is a +/// second command rather than a variable, and quoting the value alone would not stop it. +fn checked_env_name(operation: &str, name: &str) -> Result<()> { + let usable = !name.is_empty() + && !name.starts_with(|c: char| c.is_ascii_digit()) + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_'); + if usable { + return Ok(()); + } + Err(AlienError::new(ErrorData::InvalidInput { + operation_context: operation.to_string(), + details: format!( + "environment variable name '{name}' is not a shell name: letters, digits and \ + underscores only, and not starting with a digit" + ), + field_name: Some("env".to_string()), + })) +} + /// Refuses a caller's path before it reaches the data plane, and returns what to send. /// /// This refuses traversal syntax; it establishes no root. Whether the data plane bounds a path is @@ -1068,7 +1146,10 @@ const FULL_INSPECTION: &str = "Full"; /// The host pattern that matches everything, so `deny` is a rule rather than only a default. const EVERY_HOST: &str = "*"; -/// Longest session id the data plane is addressed with, matching the launcher-side bound. +/// Longest session id this client will put in a data-plane URL. +/// +/// A bound on what a caller hands back rather than on what Azure mints: the ids seen in practice +/// are far shorter, and the point is that an id reaching the URL is one this client chose to send. const MAX_SESSION_ID: usize = 63; /// The two operations a repeat could perform twice. @@ -1555,6 +1636,7 @@ mod tests { "&&".to_string(), "sleep 5".to_string(), ], + &BTreeMap::new(), std::time::Duration::from_millis(1500), ); assert!(wrapped.contains("sleep 1.500"), "{wrapped}"); @@ -1564,6 +1646,38 @@ mod tests { ); } + /// A per-command variable reaches the command, and its value stays data. + /// + /// The exec endpoint takes no environment, so the assignment travels in the shell string — + /// which is exactly where an unquoted value would stop being a value. + #[test] + fn the_bounded_shell_carries_variables_as_data() { + let wrapped = bounded_shell( + &["printenv".to_string(), "TOKEN".to_string()], + &BTreeMap::from([("TOKEN".to_string(), "a'; rm -rf /".to_string())]), + std::time::Duration::from_millis(1500), + ); + + assert!( + wrapped.starts_with("TOKEN='a'\\''; rm -rf /' sh -c '"), + "the value has to survive as one word: {wrapped}" + ); + } + + /// A name the shell would read as a second command never reaches the shell string. + #[test] + fn a_variable_name_that_is_not_a_name_is_refused() { + for name in ["", "A B", "A;rm", "1A", "A=B", "A-B"] { + let error = checked_env_name("sandbox.runCommand", name) + .expect_err("a name the shell would not read as a name must be refused"); + assert_eq!(error.code, "INVALID_INPUT", "name '{name}': {error}"); + } + for name in ["A", "_a", "TOKEN_1"] { + checked_env_name("sandbox.runCommand", name) + .unwrap_or_else(|error| panic!("name '{name}' is a shell name: {error}")); + } + } + /// A path that could leave the caller's own directory is refused before anything is sent. /// /// Asserted on the client never being called, not on the error: the data plane's own path @@ -2731,6 +2845,189 @@ mod tests { assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); } + /// A suspended session that reports no policy reads as suspended, not as a mismatch. + /// + /// `get` and the reconnect path have to answer this the same way. Whether the data plane + /// reports `egressPolicy` for a sandbox that is not running is unverified, so if it does not, + /// judging the record here would turn every idle-suspended session into a containment + /// failure — and `suspendResume` would advertise a state the caller cannot observe. + #[tokio::test] + async fn a_suspended_session_reporting_no_policy_is_not_a_mismatch() { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_get_sandbox().times(1).returning(|_, id| { + let mut sandbox = running(id, None); + sandbox.state = Some("Stopped".to_string()); + Ok(sandbox) + }); + + let session = sandbox_denying(client, SandboxEgress::Deny) + .get("asleep") + .await + .expect("a sleeping session must still be readable") + .expect("the session exists"); + + assert_eq!(session.state, SandboxSessionState::Suspended); + } + + /// A sleeping session whose own record is plainly wrong is refused before anything wakes it. + /// + /// Waking it to reach the same verdict puts its workload back on the network for the length of + /// a boot, which is the window this check exists to close. + #[tokio::test] + async fn a_sleeping_session_with_a_wrong_policy_is_never_woken() { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_get_sandbox().times(1).returning(|_, id| { + let mut sandbox = running( + id, + Some(EgressPolicy { + default_action: "Allow".to_string(), + host_rules: Vec::new(), + rules: Vec::new(), + unmodelled: Default::default(), + traffic_inspection: Some("Full".to_string()), + }), + ); + sandbox.state = Some("Stopped".to_string()); + Ok(sandbox) + }); + client.expect_resume_sandbox().never(); + client.expect_stop_sandbox().never(); + client.expect_delete_sandbox().never(); + + let error = sandbox_denying(client, SandboxEgress::Deny) + .resume("built-under-allow") + .await + .expect_err("a stored policy that already fails must not be woken"); + + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + } + + /// A wait that woke a session and then failed still puts it back. + /// + /// The refusal is not the only way out of `resume`: the wait can fail after issuing the + /// resume, and a session left awake by a call that returned an error is exactly the one + /// nothing else will come back for. + #[tokio::test] + async fn a_session_woken_by_a_wait_that_then_failed_is_put_back() { + let mut client = MockSandboxDataPlaneApi::new(); + let mut reads = 0; + client.expect_get_sandbox().returning(move |_, id| { + reads += 1; + let mut sandbox = running(id, None); + // Asleep for the resume's read and the wait's first poll, then unreadable. + sandbox.state = Some(if reads <= 2 { "Stopped" } else { "Hibernated" }.to_string()); + Ok(sandbox) + }); + client + .expect_resume_sandbox() + .times(1) + .returning(|_, _| Ok(())); + client + .expect_stop_sandbox() + .times(1) + .returning(|_, _| Ok(())); + + let error = sandbox_denying(client, SandboxEgress::Deny) + .resume("wakes-then-breaks") + .await + .expect_err("a wait that cannot finish must not report a resumed session"); + + assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}"); + } + + /// A reconnect that woke a session and then could not use it reaps the one it woke. + /// + /// The refusal travels either way; what must not survive it is a live sandbox this call put + /// back on the network and then walked away from. + #[tokio::test] + async fn a_session_woken_by_a_failed_reconnect_is_reaped() { + let mut client = MockSandboxDataPlaneApi::new(); + let mut reads = 0; + client.expect_get_sandbox().returning(move |_, id| { + reads += 1; + let mut sandbox = running(id, None); + sandbox.state = Some(if reads <= 2 { "Stopped" } else { "Hibernated" }.to_string()); + Ok(sandbox) + }); + client + .expect_resume_sandbox() + .times(1) + .returning(|_, _| Ok(())); + client + .expect_delete_sandbox() + .withf(|_, id| id == "woken-then-unreadable") + .times(1) + .returning(|_, _| Ok(())); + + let error = sandbox_with(client) + .get_or_create(CreateSessionRequest { + session_id: Some("woken-then-unreadable".to_string()), + tenant_key: None, + env: BTreeMap::new(), + }) + .await + .expect_err("a state this client cannot read is not a session"); + + assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}"); + } + + /// The variables a command declares reach the command. + /// + /// Every other backend honours `RunCommandRequest.env`; the exec endpoint here takes no + /// environment at all, so dropping it silently would make one backend answer a documented + /// field with nothing, and the failure would surface inside the sandbox rather than at the + /// call. + #[tokio::test] + async fn a_declared_variable_reaches_the_command() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_get_sandbox() + .returning(|_, id| Ok(running(id, None))); + client + .expect_execute_shell_command() + .times(1) + .withf(|_, _, shell, _| shell.starts_with("TOKEN='t' sh -c '")) + .returning(|_, _, _, _| { + Ok(alien_azure_clients::azure::sandbox_data_plane::ExecResult { + exit_code: Some(0), + stdout: String::new(), + // The wrapper announces its nonce before starting the command. + stderr: "beef\n".to_string(), + }) + }); + + let mut request = command(5); + request.env = BTreeMap::from([("TOKEN".to_string(), "t".to_string())]); + + sandbox_with(client) + .run_command("s1", request) + .await + .expect("a command declaring a variable must run"); + } + + /// A variable name that is not a name never reaches the shell string. + /// + /// The name sits left of the `=`, where quoting cannot reach it, so an unchecked one is a + /// second command running inside the sandbox rather than a variable in it. + #[tokio::test] + async fn a_command_carrying_an_unusable_variable_name_runs_nothing() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_get_sandbox() + .returning(|_, id| Ok(running(id, None))); + client.expect_execute_shell_command().never(); + + let mut request = command(5); + request.env = BTreeMap::from([("X; curl evil".to_string(), "1".to_string())]); + + let error = match sandbox_with(client).run_command("s1", request).await { + Ok(_) => panic!("a name the shell would run must not reach the shell"), + Err(error) => error, + }; + + assert_eq!(error.code, "INVALID_INPUT", "{error}"); + } + /// A session being deleted is still running, so it must not take new work. /// /// `get` skips the policy check for one — a sandbox on its way out carries no policy to diff --git a/crates/alien-cloudformation/src/emitters/aws/sandbox.rs b/crates/alien-cloudformation/src/emitters/aws/sandbox.rs index 004d5c72e..c7b92e781 100644 --- a/crates/alien-cloudformation/src/emitters/aws/sandbox.rs +++ b/crates/alien-cloudformation/src/emitters/aws/sandbox.rs @@ -572,8 +572,9 @@ fn egress_connector_arns(sandbox: &Sandbox, image_id: &str) -> CfExpression { /// Refuses an egress mode the emitted template cannot deliver. /// /// `deny` is built from a connector whose security group permits nothing outbound. Outbound -/// allowances are not: `allow` would depend on the network's NAT topology, and AWS has no -/// domain-filtering primitive at the connector, so `allowDomains` has nothing to render into. +/// allowances are not: AWS has no domain-filtering primitive at the connector, so `allowDomains` +/// has nothing to render into. `allow` is accepted and emits no connector at all — a MicroVM +/// without one reaches the internet. /// A template that silently ignores a declared egress policy is worse than one that refuses it. fn refuse_unsupported_egress(sandbox: &Sandbox) -> Result<()> { let refuse = |mode: &str| { diff --git a/crates/alien-core/src/resources/sandbox.rs b/crates/alien-core/src/resources/sandbox.rs index b517b77a2..23a9bd5b8 100644 --- a/crates/alien-core/src/resources/sandbox.rs +++ b/crates/alien-core/src/resources/sandbox.rs @@ -25,7 +25,9 @@ pub enum SandboxCode { /// A prebuilt container image used as the sandbox root filesystem. #[serde(rename_all = "camelCase")] Image { - /// Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`) + /// Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`). Azure takes a + /// bare catalog name such as `ubuntu` — it creates a session from a public catalog disk + /// image, so a registry path, tag or digest has nowhere to go. image: String, }, /// Source built into a sandbox image at deploy time. @@ -130,7 +132,9 @@ pub enum SandboxEgress { /// Unrestricted outbound access to the public internet, and none to private ranges or the /// deployment's own network. /// - /// Link-local carries the same exception as `Deny`. + /// Link-local carries the same exception as `Deny`. Azure delivers the first half only: its + /// egress rules match host patterns, so a private range has nothing to render into and the + /// data plane's own default applies. Allow, /// Outbound access only to the listed hostnames. /// @@ -482,6 +486,11 @@ impl Sandbox { })); } + // Read before the limits, because the image is declared whether or not any are. + if platform == Platform::Azure { + self.azure_catalog_image()?; + } + let Some(limits) = self.limits.as_ref() else { // Nothing declared, so nothing to enforce and nothing to reject. return self.validate_capabilities(&capabilities, platform); @@ -540,6 +549,45 @@ impl Sandbox { /// only tier that honours a ceiling is one whose peak fits inside it. A declaration no tier /// satisfies is refused: shipping the nearest size would give the customer a sandbox that /// exceeds the bound they wrote down. + /// The catalog disk image Azure creates a session from. + /// + /// Azure names a public catalog entry rather than pulling a reference, so a registry path, + /// tag or digest has nowhere to go. An allowlist, because the answer to "what else could be + /// in there" is a name the data plane rejects at the first session, long after the apply. + pub fn azure_catalog_image(&self) -> Result<&str> { + let refused = |value: &str, reason: &str| { + AlienError::new(ErrorData::SandboxLimitInvalid { + resource_id: self.id.clone(), + field: "code.image".to_string(), + value: value.to_string(), + reason: reason.to_string(), + }) + }; + + let SandboxCode::Image { image } = &self.code else { + return Err(refused( + "source", + "no sandbox backend builds an image from source yet", + )); + }; + + let image = image.trim(); + if image.is_empty() { + return Err(refused(image, "a sandbox has to name an image")); + } + if !image + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + { + return Err(refused( + image, + "Azure creates a session from a public catalog disk image, so code.image must be \ + a bare catalog name such as 'ubuntu'", + )); + } + Ok(image) + } + pub fn microvm_tier(&self) -> Result { let Some(limits) = self.limits.as_ref() else { // Nothing declared: AWS's own default baseline, which is also `default_limits`. @@ -853,7 +901,7 @@ mod tests { fn sandbox_with(egress: SandboxEgress, preview_ports: Vec) -> Sandbox { Sandbox::new("agent-sbx".to_string()) .code(SandboxCode::Image { - image: "ubuntu:24.04".to_string(), + image: "ubuntu".to_string(), }) .limits(SandboxLimits { cpu: "1".to_string(), @@ -995,7 +1043,7 @@ mod tests { // Declares no ceilings, which Azure refuses for its own reason, so this isolates egress. let egress_only = Sandbox::new("sbx".to_string()) .code(SandboxCode::Image { - image: "alpine:3.20".to_string(), + image: "alpine".to_string(), }) .egress(SandboxEgress::Deny) .session(SandboxSessionPolicy { @@ -1021,7 +1069,7 @@ mod tests { let undeclared = Sandbox::new("sbx".to_string()) .code(SandboxCode::Image { - image: "alpine:3.20".to_string(), + image: "alpine".to_string(), }) .egress(SandboxEgress::Deny) .session(SandboxSessionPolicy { @@ -1158,6 +1206,63 @@ mod tests { .expect("the ceiling itself is allowed"); } + /// An image reference Azure cannot honour is refused while planning, not at the first session. + /// + /// The examples in `code.image`'s own documentation — a tag, a registry path — are exactly + /// what Azure cannot take, so this is the shape a customer is most likely to declare. Caught + /// at plan time it names the sandbox; caught nowhere, it renders into the module, plans, + /// applies, and fails when the first session is created. + #[test] + fn an_image_azure_cannot_pull_is_refused_while_planning() { + let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]); + // Azure enforces no declared ceiling, so a sandbox carrying limits is refused before the + // image is ever read. + sandbox.limits = None; + + for image in [ + "ubuntu:24.04", + "ghcr.io/myorg/sandbox:latest", + "ubuntu@sha256:abc", + "", + " ", + "ubuntu latest", + "ubuntu?x", + ] { + sandbox.code = SandboxCode::Image { + image: image.to_string(), + }; + let error = sandbox + .validate_for_platform(Platform::Azure) + .expect_err("an image Azure has nowhere to put is refused"); + assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "image '{image}'"); + + // The same declaration is ordinary everywhere that pulls a reference. + sandbox + .validate_for_platform(Platform::Kubernetes) + .expect("a registry reference is what every other backend takes"); + } + + for image in ["ubuntu", "ubuntu-22.04", "debian_slim"] { + sandbox.code = SandboxCode::Image { + image: image.to_string(), + }; + sandbox + .validate_for_platform(Platform::Azure) + .unwrap_or_else(|error| panic!("'{image}' is a catalog name: {error}")); + } + + // Surrounding space is trimmed rather than carried into the create body. + sandbox.code = SandboxCode::Image { + image: " ubuntu ".to_string(), + }; + assert_eq!( + sandbox + .azure_catalog_image() + .expect("a padded name is still a name"), + "ubuntu" + ); + } + /// A deadline is accepted only where the platform itself terminates on it — the kubelet's /// `activeDeadlineSeconds` and Lambda's `maximumDurationInSeconds`. Everywhere else it would /// need a reaper that does not exist, so it is refused rather than accepted and dropped. @@ -1339,7 +1444,7 @@ mod tests { let original = sandbox_with(SandboxEgress::Deny, vec![]); let renamed = Sandbox::new("other".to_string()) .code(SandboxCode::Image { - image: "ubuntu:24.04".to_string(), + image: "ubuntu".to_string(), }) .limits( original diff --git a/crates/alien-helm/src/emitters/sandbox.rs b/crates/alien-helm/src/emitters/sandbox.rs index 76f3a3375..fc7b82f76 100644 --- a/crates/alien-helm/src/emitters/sandbox.rs +++ b/crates/alien-helm/src/emitters/sandbox.rs @@ -58,7 +58,6 @@ impl HelmEmitter for SandboxEmitter { }) })?; - let mut fragment = HelmFragment::empty(); fragment.extra_templates.insert( format!("sandbox-{}-networkpolicy.yaml", sandbox.id()), diff --git a/crates/alien-terraform/src/emitters/aws/sandbox.rs b/crates/alien-terraform/src/emitters/aws/sandbox.rs index c0edbb1f4..50dd17385 100644 --- a/crates/alien-terraform/src/emitters/aws/sandbox.rs +++ b/crates/alien-terraform/src/emitters/aws/sandbox.rs @@ -620,8 +620,9 @@ fn egress_connector_arns(sandbox: &Sandbox, label: &str) -> Expression { /// Refuses an egress mode the emitted artifact cannot deliver. /// /// `deny` is built from a connector whose security group carries no egress rule. Outbound -/// allowances are not: `allow` would depend on the network's NAT topology, and AWS has no -/// domain-filtering primitive at the connector, so `allowDomains` has nothing to render into. +/// allowances are not: AWS has no domain-filtering primitive at the connector, so `allowDomains` +/// has nothing to render into. `allow` is accepted and emits no connector at all — a MicroVM +/// without one reaches the internet. /// Emitting a template that silently ignores a declared egress policy is worse than refusing it — /// the customer would believe outbound access was configured. fn refuse_unsupported_egress(sandbox: &Sandbox) -> Result<()> { diff --git a/crates/alien-terraform/src/emitters/azure/sandbox.rs b/crates/alien-terraform/src/emitters/azure/sandbox.rs index 46546e919..34954e437 100644 --- a/crates/alien-terraform/src/emitters/azure/sandbox.rs +++ b/crates/alien-terraform/src/emitters/azure/sandbox.rs @@ -14,8 +14,7 @@ use crate::{ emitters::azure::helpers::{downcast, required_label, resource_prefix_template}, expr, }; -use alien_core::{import::EmitContext, ErrorData, Result, Sandbox, SandboxCode, SandboxEgress}; -use alien_error::AlienError; +use alien_core::{import::EmitContext, Result, Sandbox, SandboxEgress}; use hcl::expr::Expression; /// Emits the Azure sandbox group's identity for the runtime to address. @@ -56,42 +55,6 @@ fn egress(sandbox: &Sandbox) -> Expression { } } -/// The catalog image name a declaration asks for, or a refusal. -/// -/// The create body names a public catalog image, so a registry reference has nowhere to go. -/// Refusing at plan time follows the AWS emitter: a reference the backend cannot honour is -/// rejected rather than quietly replaced — silently ignoring it would run a stock image -/// whatever the declaration said, with no error anywhere. -fn catalog_disk_image(sandbox: &Sandbox) -> Result { - let unsupported = |reason: String| { - AlienError::new(ErrorData::OperationNotSupported { - operation: format!("terraform emit sandbox '{}'", sandbox.id()), - reason, - }) - }; - - match &sandbox.code { - // A tag is the shape that gets through unnoticed: `ubuntu:24.04` has no slash, renders - // into the customer's module, plans and applies, and fails at the first session. - SandboxCode::Image { image } - if image.trim().is_empty() - || image.contains('/') - || image.contains(':') - || image.contains('@') => - { - Err(unsupported(format!( - "Azure creates a sandbox from a public catalog disk image, so code.image must be \ - a bare catalog name such as 'ubuntu'; '{image}' is empty or carries a registry \ - path, tag or digest, which the data plane has nowhere to put" - ))) - } - SandboxCode::Image { image } => Ok(image.clone()), - SandboxCode::Source { .. } => Err(unsupported( - "no sandbox backend builds an image from source yet".to_string(), - )), - } -} - impl TfEmitter for AzureSandboxEmitter { fn emit(&self, _ctx: &EmitContext<'_>) -> Result { // Deliberately empty: see the module note. A group emitted here would sit idle until a @@ -112,7 +75,7 @@ impl TfEmitter for AzureSandboxEmitter { fn emit_binding_ref(&self, ctx: &EmitContext<'_>) -> Result> { let sandbox = downcast::(ctx, Sandbox::RESOURCE_TYPE)?; let _ = required_label(ctx)?; - let disk_image = catalog_disk_image(sandbox)?; + let disk_image = sandbox.azure_catalog_image()?.to_string(); let mut fields = vec![ ("service", Expression::String("sandbox-azure".to_string())), ("sandboxGroup", sandbox_group(ctx)), @@ -142,6 +105,8 @@ impl TfEmitter for AzureSandboxEmitter { #[cfg(test)] mod tests { use super::*; + use alien_core::bindings::{AzureSandboxBinding, BindingValue}; + use alien_core::SandboxCode; use alien_core::{ResourceLifecycle, SandboxSessionPolicy, Stack, StackSettings}; use indexmap::IndexMap; @@ -211,6 +176,40 @@ mod tests { assert!(open.contains(r#"mode = "allow""#), "{open}"); } + /// Every key the binding deserializes is a key the emitter writes. + /// + /// The emitter types the names by hand while the provider reads them through serde, so a + /// rename on either side lands on a customer's cluster as a deserialization failure at the + /// first session rather than as a failure at plan time. The names come from the type here, + /// not from a second hand-typed list. + #[test] + fn the_emitted_keys_are_the_ones_the_binding_deserializes() { + let rendered = binding_with( + SandboxEgress::AllowDomains { + domains: vec!["api.example.com".to_string()], + }, + Some(900), + ); + + let binding = AzureSandboxBinding { + sandbox_group: BindingValue::Value("sbg".to_string()), + data_plane_endpoint: BindingValue::Value("https://example.invalid".to_string()), + region: BindingValue::Value("eastus".to_string()), + resource_group: BindingValue::Value("rg".to_string()), + egress: SandboxEgress::Allow, + idle_suspend_seconds: Some(900), + disk_image: BindingValue::Value("ubuntu".to_string()), + }; + let keys = serde_json::to_value(&binding).expect("the binding serializes"); + + for key in keys.as_object().expect("an object").keys() { + assert!( + rendered.contains(&format!("{key} = ")), + "the emitter never writes '{key}': {rendered}" + ); + } + } + /// The idle-suspend policy travels the same way, and only when it was declared. /// /// Azure takes it at create, so a number that stops at the emitter leaves the session on the diff --git a/crates/alien-terraform/src/emitters/gcp/sandbox.rs b/crates/alien-terraform/src/emitters/gcp/sandbox.rs index c3a4d8998..f7f23ed38 100644 --- a/crates/alien-terraform/src/emitters/gcp/sandbox.rs +++ b/crates/alien-terraform/src/emitters/gcp/sandbox.rs @@ -81,3 +81,46 @@ impl TfEmitter for GcpSandboxEmitter { ]))) } } + +#[cfg(test)] +mod tests { + use super::*; + use alien_core::{SandboxCode, SandboxSessionPolicy}; + + fn sandbox_with(egress: SandboxEgress) -> Sandbox { + Sandbox::new("agents".to_string()) + .code(SandboxCode::Image { + image: "ubuntu".to_string(), + }) + .egress(egress) + .session(SandboxSessionPolicy { + max_lifetime_seconds: None, + idle_suspend_seconds: None, + }) + .build() + } + + /// A hostname list is refused rather than carried as its nearest boolean. + /// + /// `--allow-egress` is a switch: rendering the list as `true` opens every address it was + /// written to exclude, and rendering it as `false` denies every one it was written to permit. + /// Neither is the declaration, so neither is emitted. + #[test] + fn a_hostname_allowlist_is_refused_rather_than_approximated() { + let error = refuse_unsupported_egress(&sandbox_with(SandboxEgress::AllowDomains { + domains: vec!["api.example.com".to_string()], + })) + .expect_err("a hostname list has nothing to render into on Cloud Run"); + + assert_eq!(error.code, "OPERATION_NOT_SUPPORTED", "{error}"); + assert!( + error.to_string().contains("agents"), + "the refusal has to name the sandbox: {error}" + ); + + for accepted in [SandboxEgress::Deny, SandboxEgress::Allow] { + refuse_unsupported_egress(&sandbox_with(accepted.clone())) + .unwrap_or_else(|error| panic!("{accepted:?} is a switch position: {error}")); + } + } +} diff --git a/packages/core/src/generated/schemas/sandbox.json b/packages/core/src/generated/schemas/sandbox.json index 152b8b16b..d483c1c71 100644 --- a/packages/core/src/generated/schemas/sandbox.json +++ b/packages/core/src/generated/schemas/sandbox.json @@ -1 +1 @@ -{"type":"object","description":"An isolated environment for running untrusted code, created per session at runtime.","required":["id","code","egress","session"],"properties":{"code":{"description":"Where the sandbox's root filesystem comes from","oneOf":[{"type":"object","description":"A prebuilt container image used as the sandbox root filesystem.","required":["image","type"],"properties":{"image":{"type":"string","description":"Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`)"},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source built into a sandbox image at deploy time.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"x-readme-ref-name":"SandboxCode"},"egress":{"description":"Outbound network policy","oneOf":[{"type":"object","description":"No outbound network access.\n\nRouted traffic only. Link-local is not outbound and no backend's egress control reaches\nit, so this is not a boundary against instance metadata.","required":["mode"],"properties":{"mode":{"type":"string","enum":["deny"]}}},{"type":"object","description":"Unrestricted outbound access to the public internet, and none to private ranges or the\ndeployment's own network.\n\nLink-local carries the same exception as `Deny`.","required":["mode"],"properties":{"mode":{"type":"string","enum":["allow"]}}},{"type":"object","description":"Outbound access only to the listed hostnames.\n\nAzure alone expresses it: its egress proxy matches on host pattern. The others filter by\nCIDR or carry a single switch, and both would approximate the list rather than keep it.","required":["domains","mode"],"properties":{"domains":{"type":"array","items":{"type":"string"},"description":"Hostnames the sandbox may reach"},"mode":{"type":"string","enum":["allowDomains"]}}}],"x-readme-ref-name":"SandboxEgress"},"id":{"type":"string","description":"Identifier for the sandbox. Must contain only alphanumeric characters, hyphens, and\nunderscores ([A-Za-z0-9-_]). Maximum 64 characters."},"limits":{"oneOf":[{"type":"null"},{"description":"Enforced resource ceilings.\n\nOptional because not every platform can enforce them, and a declaration that names none\ntakes the platform's own defaults. Naming them on a platform that cannot enforce them is\nrejected at plan time rather than silently ignored.","type":"object","required":["cpu","memory","disk"],"properties":{"cpu":{"type":"string","description":"CPU ceiling in cores or millicores (e.g. `\"1\"`, `\"500m\"`)"},"disk":{"type":"string","description":"Disk ceiling (e.g. `\"20Gi\"`)"},"maxProcesses":{"type":["integer","null"],"format":"int32","description":"Maximum number of processes, which bounds fork bombs.\n\nOptional because only a container runtime has the primitive: Kubernetes sets a pid ceiling\nper node, not per pod, and neither AWS MicroVMs nor Azure sandboxes expose one. Declaring\nit on a platform that cannot apply it is refused at plan time.","minimum":0},"memory":{"type":"string","description":"Memory ceiling (e.g. `\"2Gi\"`, `\"512Mi\"`)"}},"additionalProperties":false,"x-readme-ref-name":"SandboxLimits"}]},"previewPorts":{"type":"array","items":{"type":"integer","format":"int32","minimum":0},"description":"Ports eligible for a preview capability. A port not listed here can never be exposed,\nso an application cannot widen its own ingress at runtime."},"session":{"description":"Session lifetime and idle behaviour","type":"object","properties":{"idleSuspendSeconds":{"type":["integer","null"],"format":"int32","description":"Idle period after which the session is suspended, where the platform supports it","minimum":0},"maxLifetimeSeconds":{"type":["integer","null"],"format":"int32","description":"Wall-clock ceiling on a single session, after which the platform terminates it.\n\nOptional because not every backend has the primitive: Kubernetes has\n`activeDeadlineSeconds` and AWS `maximumDurationInSeconds`, while neither Azure nor Local\nexpose one, so declaring a ceiling there is refused at plan time rather than accepted and\nnever applied. AWS caps it at 8 hours.","minimum":0}},"additionalProperties":false,"x-readme-ref-name":"SandboxSessionPolicy"}},"additionalProperties":false,"x-readme-ref-name":"Sandbox"} \ No newline at end of file +{"type":"object","description":"An isolated environment for running untrusted code, created per session at runtime.","required":["id","code","egress","session"],"properties":{"code":{"description":"Where the sandbox's root filesystem comes from","oneOf":[{"type":"object","description":"A prebuilt container image used as the sandbox root filesystem.","required":["image","type"],"properties":{"image":{"type":"string","description":"Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`). Azure takes a\nbare catalog name such as `ubuntu` — it creates a session from a public catalog disk\nimage, so a registry path, tag or digest has nowhere to go."},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source built into a sandbox image at deploy time.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"x-readme-ref-name":"SandboxCode"},"egress":{"description":"Outbound network policy","oneOf":[{"type":"object","description":"No outbound network access.\n\nRouted traffic only. Link-local is not outbound and no backend's egress control reaches\nit, so this is not a boundary against instance metadata.","required":["mode"],"properties":{"mode":{"type":"string","enum":["deny"]}}},{"type":"object","description":"Unrestricted outbound access to the public internet, and none to private ranges or the\ndeployment's own network.\n\nLink-local carries the same exception as `Deny`. Azure delivers the first half only: its\negress rules match host patterns, so a private range has nothing to render into and the\ndata plane's own default applies.","required":["mode"],"properties":{"mode":{"type":"string","enum":["allow"]}}},{"type":"object","description":"Outbound access only to the listed hostnames.\n\nAzure alone expresses it: its egress proxy matches on host pattern. The others filter by\nCIDR or carry a single switch, and both would approximate the list rather than keep it.","required":["domains","mode"],"properties":{"domains":{"type":"array","items":{"type":"string"},"description":"Hostnames the sandbox may reach"},"mode":{"type":"string","enum":["allowDomains"]}}}],"x-readme-ref-name":"SandboxEgress"},"id":{"type":"string","description":"Identifier for the sandbox. Must contain only alphanumeric characters, hyphens, and\nunderscores ([A-Za-z0-9-_]). Maximum 64 characters."},"limits":{"oneOf":[{"type":"null"},{"description":"Enforced resource ceilings.\n\nOptional because not every platform can enforce them, and a declaration that names none\ntakes the platform's own defaults. Naming them on a platform that cannot enforce them is\nrejected at plan time rather than silently ignored.","type":"object","required":["cpu","memory","disk"],"properties":{"cpu":{"type":"string","description":"CPU ceiling in cores or millicores (e.g. `\"1\"`, `\"500m\"`)"},"disk":{"type":"string","description":"Disk ceiling (e.g. `\"20Gi\"`)"},"maxProcesses":{"type":["integer","null"],"format":"int32","description":"Maximum number of processes, which bounds fork bombs.\n\nOptional because only a container runtime has the primitive: Kubernetes sets a pid ceiling\nper node, not per pod, and neither AWS MicroVMs nor Azure sandboxes expose one. Declaring\nit on a platform that cannot apply it is refused at plan time.","minimum":0},"memory":{"type":"string","description":"Memory ceiling (e.g. `\"2Gi\"`, `\"512Mi\"`)"}},"additionalProperties":false,"x-readme-ref-name":"SandboxLimits"}]},"previewPorts":{"type":"array","items":{"type":"integer","format":"int32","minimum":0},"description":"Ports eligible for a preview capability. A port not listed here can never be exposed,\nso an application cannot widen its own ingress at runtime."},"session":{"description":"Session lifetime and idle behaviour","type":"object","properties":{"idleSuspendSeconds":{"type":["integer","null"],"format":"int32","description":"Idle period after which the session is suspended, where the platform supports it","minimum":0},"maxLifetimeSeconds":{"type":["integer","null"],"format":"int32","description":"Wall-clock ceiling on a single session, after which the platform terminates it.\n\nOptional because not every backend has the primitive: Kubernetes has\n`activeDeadlineSeconds` and AWS `maximumDurationInSeconds`, while neither Azure nor Local\nexpose one, so declaring a ceiling there is refused at plan time rather than accepted and\nnever applied. AWS caps it at 8 hours.","minimum":0}},"additionalProperties":false,"x-readme-ref-name":"SandboxSessionPolicy"}},"additionalProperties":false,"x-readme-ref-name":"Sandbox"} \ No newline at end of file diff --git a/packages/core/src/generated/schemas/sandboxCode.json b/packages/core/src/generated/schemas/sandboxCode.json index a91988b5c..d83575f97 100644 --- a/packages/core/src/generated/schemas/sandboxCode.json +++ b/packages/core/src/generated/schemas/sandboxCode.json @@ -1 +1 @@ -{"oneOf":[{"type":"object","description":"A prebuilt container image used as the sandbox root filesystem.","required":["image","type"],"properties":{"image":{"type":"string","description":"Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`)"},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source built into a sandbox image at deploy time.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"description":"Specifies where the sandbox's root filesystem comes from.","x-readme-ref-name":"SandboxCode"} \ No newline at end of file +{"oneOf":[{"type":"object","description":"A prebuilt container image used as the sandbox root filesystem.","required":["image","type"],"properties":{"image":{"type":"string","description":"Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`). Azure takes a\nbare catalog name such as `ubuntu` — it creates a session from a public catalog disk\nimage, so a registry path, tag or digest has nowhere to go."},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source built into a sandbox image at deploy time.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"description":"Specifies where the sandbox's root filesystem comes from.","x-readme-ref-name":"SandboxCode"} \ No newline at end of file diff --git a/packages/core/src/generated/schemas/sandboxEgress.json b/packages/core/src/generated/schemas/sandboxEgress.json index b88385e76..25269f56a 100644 --- a/packages/core/src/generated/schemas/sandboxEgress.json +++ b/packages/core/src/generated/schemas/sandboxEgress.json @@ -1 +1 @@ -{"oneOf":[{"type":"object","description":"No outbound network access.\n\nRouted traffic only. Link-local is not outbound and no backend's egress control reaches\nit, so this is not a boundary against instance metadata.","required":["mode"],"properties":{"mode":{"type":"string","enum":["deny"]}}},{"type":"object","description":"Unrestricted outbound access to the public internet, and none to private ranges or the\ndeployment's own network.\n\nLink-local carries the same exception as `Deny`.","required":["mode"],"properties":{"mode":{"type":"string","enum":["allow"]}}},{"type":"object","description":"Outbound access only to the listed hostnames.\n\nAzure alone expresses it: its egress proxy matches on host pattern. The others filter by\nCIDR or carry a single switch, and both would approximate the list rather than keep it.","required":["domains","mode"],"properties":{"domains":{"type":"array","items":{"type":"string"},"description":"Hostnames the sandbox may reach"},"mode":{"type":"string","enum":["allowDomains"]}}}],"description":"Outbound network policy for a sandbox.","x-readme-ref-name":"SandboxEgress"} \ No newline at end of file +{"oneOf":[{"type":"object","description":"No outbound network access.\n\nRouted traffic only. Link-local is not outbound and no backend's egress control reaches\nit, so this is not a boundary against instance metadata.","required":["mode"],"properties":{"mode":{"type":"string","enum":["deny"]}}},{"type":"object","description":"Unrestricted outbound access to the public internet, and none to private ranges or the\ndeployment's own network.\n\nLink-local carries the same exception as `Deny`. Azure delivers the first half only: its\negress rules match host patterns, so a private range has nothing to render into and the\ndata plane's own default applies.","required":["mode"],"properties":{"mode":{"type":"string","enum":["allow"]}}},{"type":"object","description":"Outbound access only to the listed hostnames.\n\nAzure alone expresses it: its egress proxy matches on host pattern. The others filter by\nCIDR or carry a single switch, and both would approximate the list rather than keep it.","required":["domains","mode"],"properties":{"domains":{"type":"array","items":{"type":"string"},"description":"Hostnames the sandbox may reach"},"mode":{"type":"string","enum":["allowDomains"]}}}],"description":"Outbound network policy for a sandbox.","x-readme-ref-name":"SandboxEgress"} \ No newline at end of file diff --git a/packages/core/src/generated/zod/sandbox-code-schema.ts b/packages/core/src/generated/zod/sandbox-code-schema.ts index 5a9743b23..d81f72a7c 100644 --- a/packages/core/src/generated/zod/sandbox-code-schema.ts +++ b/packages/core/src/generated/zod/sandbox-code-schema.ts @@ -10,7 +10,7 @@ import { ToolchainConfigSchema } from "./toolchain-config-schema.js"; * @description Specifies where the sandbox\'s root filesystem comes from. */ export const SandboxCodeSchema = z.union([z.object({ - "image": z.string().describe("Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`)"), + "image": z.string().describe("Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`). Azure takes a\nbare catalog name such as `ubuntu` — it creates a session from a public catalog disk\nimage, so a registry path, tag or digest has nowhere to go."), "type": z.enum(["image"]) }), z.object({ "src": z.string().describe("The source directory to build from"), From 981b7686e5a522fb90cbc7f134cda8a281c3de73 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:05:19 +0300 Subject: [PATCH 21/32] fix(sandbox): own a resume whose outcome the data plane never reported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refusal is the one answer that proves a session stayed asleep. A 5xx, a timeout or a dropped connection does not: the data plane can take the resume and answer nothing, and the session wakes. Counting that as "did not wake" left the put-back inert, so a session this call returned to the network under a policy the declaration forbids was abandoned there — the hole the previous commit closed, reached through the other door. A stop that answers 404 now ends the put-back quietly. The session has reached the state the stop was for, and naming it as left awake sends an operator looking for a sandbox that does not exist. --- .../src/providers/sandbox/azure.rs | 118 +++++++++++++++++- 1 file changed, 115 insertions(+), 3 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 517e57841..20dc6cbc1 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -711,13 +711,20 @@ impl AzureSandbox { *resumed_here = true; } Err(error) => { - warn!(session = %session_id, %error, "resume was refused; still waiting"); - refusal = Some(match &error.error { + let failure = match &error.error { Some(ErrorData::SandboxCommandFailed { failure, .. }) => { failure.clone() } _ => error.code.clone(), - }); + }; + // A refusal is the one answer that proves the session did not wake. + // Anything else — a 5xx, a timeout, a dropped connection — leaves the + // outcome unknown, and an unknown wake is one this call owns. + if failure != "dataPlaneRefused" { + *resumed_here = true; + } + warn!(session = %session_id, %error, "resume was refused; still waiting"); + refusal = Some(failure); } } } @@ -799,6 +806,11 @@ impl AzureSandbox { let Err(failed) = self.client.stop_sandbox(&self.sandbox_group, session_id).await else { return reason; }; + // A session that is already gone is the state this was trying to reach, and reporting it + // as left awake sends an operator looking for a sandbox that does not exist. + if is_not_found(&failed) { + return reason; + } warn!(session = %session_id, error = %failed, "could not re-suspend a session this call woke"); reason.context(ErrorData::SandboxCommandFailed { @@ -3028,6 +3040,106 @@ mod tests { assert_eq!(error.code, "INVALID_INPUT", "{error}"); } + /// A resume whose outcome is unknown is one this call owns. + /// + /// A 5xx or a dropped connection does not mean the POST failed to land: the session can wake + /// anyway. Treating that as "did not wake" leaves a sandbox this call put back on the network + /// under a policy the declaration forbids, with nothing coming back for it. + #[tokio::test] + async fn a_resume_that_may_have_landed_is_owned() { + let mut client = MockSandboxDataPlaneApi::new(); + let mut reads = 0; + client.expect_get_sandbox().returning(move |_, id| { + reads += 1; + let mut sandbox = running(id, None); + if reads <= 2 { + sandbox.state = Some("Stopped".to_string()); + } + Ok(sandbox) + }); + // The answer never arrived; the data plane may still have taken it. + client + .expect_resume_sandbox() + .returning(|_, _| Err(http_error(503, "GatewayTimeout"))); + client + .expect_stop_sandbox() + .times(1) + .returning(|_, _| Ok(())); + + let error = sandbox_denying(client, SandboxEgress::Deny) + .resume("woke-or-did-not") + .await + .expect_err("a session that came up uncontained is not a resumed session"); + + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + } + + /// A resume the data plane refused is not one this call woke. + /// + /// The other side of the same rule: a 4xx is an answer, so the session stayed asleep and + /// whatever woke it afterwards was someone else. Stopping it would end their work. + #[tokio::test] + async fn a_refused_resume_leaves_someone_elses_session_alone() { + let mut client = MockSandboxDataPlaneApi::new(); + let mut reads = 0; + client.expect_get_sandbox().returning(move |_, id| { + reads += 1; + let mut sandbox = running(id, None); + if reads <= 2 { + sandbox.state = Some("Stopped".to_string()); + } + Ok(sandbox) + }); + // Refused, so this call did not wake it — another revision did, between the polls. + client + .expect_resume_sandbox() + .returning(|_, _| Err(http_error(409, "SandboxNotStopped"))); + client.expect_stop_sandbox().never(); + client.expect_delete_sandbox().never(); + + let error = sandbox_denying(client, SandboxEgress::Deny) + .resume("someone-elses-session") + .await + .expect_err("a session without the declared policy must not be handed back"); + + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + } + + /// A session that vanished while it was being put back is not "left awake". + /// + /// The put-back exists to name a sandbox this call left running. One the data plane says is + /// gone has reached that state by another route, and reporting it sends an operator looking + /// for something that does not exist. + #[tokio::test] + async fn a_session_that_vanished_is_not_reported_as_left_awake() { + let mut client = MockSandboxDataPlaneApi::new(); + let mut reads = 0; + client.expect_get_sandbox().returning(move |_, id| { + reads += 1; + let mut sandbox = running(id, None); + if reads <= 2 { + sandbox.state = Some("Stopped".to_string()); + } + Ok(sandbox) + }); + client.expect_resume_sandbox().returning(|_, _| Ok(())); + client + .expect_stop_sandbox() + .times(1) + .returning(|_, _| Err(http_error(404, "SandboxNotFound"))); + + let error = sandbox_denying(client, SandboxEgress::Deny) + .resume("gone-by-then") + .await + .expect_err("the refusal still travels"); + + assert!( + !error.to_string().contains("sandboxLeftAwake"), + "a sandbox the data plane says is gone was not left awake: {error}" + ); + assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}"); + } + /// A session being deleted is still running, so it must not take new work. /// /// `get` skips the policy check for one — a sandbox on its way out carries no policy to From 51f27d43db019a616cb1dffa762e5b87fa807d29 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:38:00 +0300 Subject: [PATCH 22/32] fix(sandbox): keep the caller's variables off the shell that bounds them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exec endpoint takes no environment, so a command's variables travel in the shell string. In front of the wrapper they applied to it: a declared PATH could hand `setsid`, `sleep` and `kill` no-ops, and the deadline that bounds untrusted code would never fire. They go through `env` now, so they reach the command and nothing else. The wrapper also unsets the names it uses before assigning them. An inherited exported variable keeps its export attribute across re-assignment, so a session created with `nonce` set was handing the command the deadline token it uses to tell a kill from an exit. Alongside: a reconnect refuses a session it did not wake rather than deleting it, matching what resume already did — two revisions of a stack share a sandbox group, and the replacement `get_or_create` owes its caller does not require ending the other one's work. Azure's catalog image is read while planning, where a sandbox no worker binds is still seen, and `code.image` says that AWS and Azure narrow it in opposite directions. `Allow` no longer claims a private-range denial that a host-pattern matcher and a boolean switch cannot express. --- .../src/providers/sandbox/azure.rs | 109 ++++++++++++------ .../src/providers/sandbox/mod.rs | 59 +++++++++- crates/alien-core/src/resources/sandbox.rs | 37 +++--- .../tests/generator/resource_layer_tests.rs | 3 + .../compile_time/sandbox_platform_support.rs | 4 +- .../src/emitters/gcp/sandbox.rs | 3 + .../core/src/generated/schemas/sandbox.json | 2 +- .../src/generated/schemas/sandboxCode.json | 2 +- .../src/generated/schemas/sandboxEgress.json | 2 +- .../src/generated/zod/sandbox-code-schema.ts | 2 +- 10 files changed, 162 insertions(+), 61 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 20dc6cbc1..cc54f9ae4 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -515,9 +515,12 @@ impl AzureSandbox { // Judged asleep first: waking one that already fails puts its workload back on the network // for a boot. - if let Err(error) = self.judge_if_judgeable(&found) { - return Err(self.discard(session_id, error).await); - } + // + // Refused rather than deleted, here and after the wake. A session under a policy this + // declaration does not allow may be another revision's, mid-command, in the group both + // share — and `get_or_create` gets what it owes the caller from the replacement its own + // refusal triggers, without ending work it does not own. + self.judge_if_judgeable(&found)?; // Judged again once it is up: only the woken record covers a session that was still coming // up, or a policy set on the group while it slept. @@ -527,13 +530,10 @@ impl AzureSandbox { .await { Ok(running) => running, - // A wait that woke it and then failed leaves it awake, and this call is about to hand - // back a different session — so the one it woke is its own to reap. - Err(error) if resumed_here => return Err(self.discard(session_id, error).await), - Err(error) => return Err(error), + Err(error) => return Err(self.put_back(session_id, resumed_here, error).await), }; if let Err(error) = self.policy_must_hold(&running) { - return Err(self.discard(session_id, error).await); + return Err(self.put_back(session_id, resumed_here, error).await); } Ok(SandboxSession { @@ -930,18 +930,24 @@ fn bounded_shell( deadline: std::time::Duration, ) -> String { let escape = |value: &str| value.replace('\'', "'\\''"); - let arguments = command + + // Through `env`, so the variables reach the caller's command and not the wrapper that bounds + // it: an assignment in front of the wrapper would put a caller-chosen `PATH` on the shell + // that resolves `setsid`, `sleep` and `kill`, and the deadline is only as real as those. + let mut argv = Vec::with_capacity(command.len() + env.len() + 2); + if !env.is_empty() { + argv.push("env".to_string()); + argv.extend(env.iter().map(|(name, value)| format!("{name}={value}"))); + argv.push("--".to_string()); + } + argv.extend(command.iter().cloned()); + + let arguments = argv .iter() .map(|argument| format!(" '{}'", escape(argument))) .collect::(); - // Assignments in front of a simple command are exported to it, so the wrapper and everything - // it runs see them. - let assignments = env - .iter() - .map(|(name, value)| format!("{name}='{}' ", escape(value))) - .collect::(); format!( - "{assignments}sh -c '{}' sh{arguments}", + "sh -c '{}' sh{arguments}", escape(&DeadlineReport::bounded_program(deadline)) ) } @@ -1671,8 +1677,34 @@ mod tests { ); assert!( - wrapped.starts_with("TOKEN='a'\\''; rm -rf /' sh -c '"), - "the value has to survive as one word: {wrapped}" + wrapped.ends_with("' sh 'env' 'TOKEN=a'\\''; rm -rf /' '--' 'printenv' 'TOKEN'"), + "the value has to survive as one argument to env: {wrapped}" + ); + } + + /// A caller's `PATH` reaches the command and not the wrapper that bounds it. + /// + /// The wrapper resolves `setsid`, `od`, `sleep` and `kill` through `PATH`. A caller able to + /// set it on the wrapper's own shell could hand it no-ops, and the deadline that keeps + /// untrusted code bounded would never fire. + #[test] + fn a_caller_cannot_repoint_the_wrappers_own_path() { + let wrapped = bounded_shell( + &["sleep".to_string(), "forever".to_string()], + &BTreeMap::from([("PATH".to_string(), "/tmp/attacker".to_string())]), + std::time::Duration::from_millis(1500), + ); + + let (wrapper, argv) = wrapped + .split_once("' sh ") + .expect("the wrapper's program ends where its arguments begin"); + assert!( + !wrapper.contains("PATH"), + "the wrapper has to resolve its own tools: {wrapper}" + ); + assert_eq!( + argv, "'env' 'PATH=/tmp/attacker' '--' 'sleep' 'forever'", + "the variable belongs to the command, not to the shell that bounds it" ); } @@ -2556,7 +2588,9 @@ mod tests { /// /// `get_or_create` owes the caller a usable session, and a stale-policy sandbox is as /// unusable as a terminated one — returning the refusal forever would leave the caller with - /// no way forward and the old sandbox still running. + /// no way forward. The old sandbox is left where it is: another revision of the same stack + /// shares this group and may be running in it, and the replacement is what this caller asked + /// for. #[tokio::test] async fn a_stale_policy_session_is_replaced_rather_than_refused_forever() { let mut client = MockSandboxDataPlaneApi::new(); @@ -2580,11 +2614,7 @@ mod tests { }), )) }); - client - .expect_delete_sandbox() - .withf(|_, id| id == "built-under-allow") - .times(1) - .returning(|_, _| Ok(())); + client.expect_delete_sandbox().never(); client .expect_create_sandbox() .times(1) @@ -2683,8 +2713,9 @@ mod tests { } reads += 1; Ok(match reads { - // Suspended and compliant, so the reconnect proceeds. - 1 => { + // Suspended and compliant for the reconnect's read and the wait's first poll, so + // the reconnect proceeds and the wait is what wakes it. + 1 | 2 => { let mut sandbox = running(id, Some(stopped.clone())); sandbox.state = Some("Stopped".to_string()); sandbox @@ -2708,14 +2739,15 @@ mod tests { ), }) }); - // However it wakes — resumed here or already coming up — the read after it is the one - // that decides, and a sandbox this code woke and then refused must not be left running. + // Woken here, so this call owes the put-back: it is returned to the state it was found + // in rather than destroyed, because another revision may hold the same id. client.expect_resume_sandbox().returning(|_, _| Ok(())); client - .expect_delete_sandbox() + .expect_stop_sandbox() .withf(|_, id| id == "was-suspended") .times(1) .returning(|_, _| Ok(())); + client.expect_delete_sandbox().never(); client .expect_create_sandbox() .times(1) @@ -2947,12 +2979,13 @@ mod tests { assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}"); } - /// A reconnect that woke a session and then could not use it reaps the one it woke. + /// A reconnect that woke a session and then could not use it puts back what it woke. /// /// The refusal travels either way; what must not survive it is a live sandbox this call put - /// back on the network and then walked away from. + /// on the network and then walked away from. Returned to sleep rather than deleted, because + /// the id may be another revision's. #[tokio::test] - async fn a_session_woken_by_a_failed_reconnect_is_reaped() { + async fn a_session_woken_by_a_failed_reconnect_is_put_back() { let mut client = MockSandboxDataPlaneApi::new(); let mut reads = 0; client.expect_get_sandbox().returning(move |_, id| { @@ -2966,10 +2999,11 @@ mod tests { .times(1) .returning(|_, _| Ok(())); client - .expect_delete_sandbox() + .expect_stop_sandbox() .withf(|_, id| id == "woken-then-unreadable") .times(1) .returning(|_, _| Ok(())); + client.expect_delete_sandbox().never(); let error = sandbox_with(client) .get_or_create(CreateSessionRequest { @@ -2998,7 +3032,7 @@ mod tests { client .expect_execute_shell_command() .times(1) - .withf(|_, _, shell, _| shell.starts_with("TOKEN='t' sh -c '")) + .withf(|_, _, shell, _| shell.ends_with("' sh 'env' 'TOKEN=t' '--' 'sleep' 'forever'")) .returning(|_, _, _, _| { Ok(alien_azure_clients::azure::sandbox_data_plane::ExecResult { exit_code: Some(0), @@ -3374,10 +3408,9 @@ mod tests { Ok(sandbox) }); client.expect_resume_sandbox().never(); - client - .expect_delete_sandbox() - .times(1) - .returning(|_, _| Ok(())); + // Nothing woke it and nothing owns it here, so it is left exactly as found. + client.expect_delete_sandbox().never(); + client.expect_stop_sandbox().never(); client .expect_create_sandbox() .times(1) diff --git a/crates/alien-bindings/src/providers/sandbox/mod.rs b/crates/alien-bindings/src/providers/sandbox/mod.rs index 272b2c7d7..8a298ba10 100644 --- a/crates/alien-bindings/src/providers/sandbox/mod.rs +++ b/crates/alien-bindings/src/providers/sandbox/mod.rs @@ -51,7 +51,9 @@ pub(crate) const DEADLINE_GRACE: std::time::Duration = std::time::Duration::from /// command can read its parent's `/proc//cmdline` and `environ`: a nonce that travelled in /// either could be echoed back, and untrusted code would be able to claim its own deadline. A /// shell variable is in neither, and the command cannot read what has already been written to -/// the stream it inherits. +/// the stream it inherits. `unset` first, because an inherited *exported* variable of the same +/// name keeps its export attribute across re-assignment and would carry the nonce straight back +/// into the command's own environment. /// /// Nothing but `sh` and `/dev/urandom` is required, which every session image has. #[cfg(any(feature = "azure", feature = "local"))] @@ -77,7 +79,8 @@ impl DeadlineReport { /// argv, which the command could read. pub(crate) fn bounded_program(deadline: std::time::Duration) -> String { format!( - "command -v setsid >/dev/null 2>&1 || exit {unboundable}; \ + "unset nonce command_pid killer_pid sleeper status; \ + command -v setsid >/dev/null 2>&1 || exit {unboundable}; \ nonce=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \\n') || exit {unboundable}; \ printf '%s\\n' \"$nonce\" >&2; \ setsid \"$@\" & command_pid=$!; \ @@ -307,6 +310,58 @@ mod tests { ); } + /// An inherited variable of the wrapper's own name never reaches the command. + /// + /// Run against a real `sh`, because the hazard is a shell rule rather than a string: an + /// exported variable keeps its export attribute across re-assignment, so `nonce=$(…)` would + /// write the session's own nonce into the slot the command inherits, and untrusted code could + /// then claim a deadline it was never given. A stand-in `setsid` is supplied because macOS + /// ships none, and without it the wrapper exits before reaching any of this. + #[test] + #[cfg(unix)] + fn the_wrapper_never_hands_its_nonce_to_the_command() { + use std::os::unix::fs::PermissionsExt; + + let bin = std::env::temp_dir().join(format!("alien-sandbox-{}", std::process::id())); + std::fs::create_dir_all(&bin).expect("a directory for the stand-in"); + let setsid = bin.join("setsid"); + std::fs::write(&setsid, "#!/bin/sh\nexec \"$@\"\n").expect("the stand-in is written"); + std::fs::set_permissions(&setsid, std::fs::Permissions::from_mode(0o755)) + .expect("the stand-in is executable"); + + let path = format!( + "{}:{}", + bin.display(), + std::env::var("PATH").unwrap_or_default() + ); + let run = std::process::Command::new("/bin/sh") + .arg("-c") + .arg(DeadlineReport::bounded_program(std::time::Duration::from_secs(5))) + .arg("sh") + .arg("printenv") + .arg("nonce") + .env("PATH", path) + .env("nonce", "inherited-from-the-session") + .output() + .expect("a shell runs"); + std::fs::remove_dir_all(&bin).ok(); + + let announced = String::from_utf8_lossy(&run.stderr); + let announced = announced.lines().next().unwrap_or_default().to_string(); + assert!( + announced.len() == 32 && announced.chars().all(|c| c.is_ascii_hexdigit()), + "the session has to reach the point of drawing a nonce, or this proves nothing: \ + stderr {:?}", + String::from_utf8_lossy(&run.stderr) + ); + + let seen = String::from_utf8_lossy(&run.stdout); + assert!( + seen.trim().is_empty(), + "the command must inherit no `nonce` at all, and it saw {seen:?}" + ); + } + /// A deadline neither end can honour is refused, not stretched or waited on. #[tokio::test] async fn a_deadline_outside_what_the_backends_can_honour_is_refused() { diff --git a/crates/alien-core/src/resources/sandbox.rs b/crates/alien-core/src/resources/sandbox.rs index 23a9bd5b8..2fe190890 100644 --- a/crates/alien-core/src/resources/sandbox.rs +++ b/crates/alien-core/src/resources/sandbox.rs @@ -25,9 +25,12 @@ pub enum SandboxCode { /// A prebuilt container image used as the sandbox root filesystem. #[serde(rename_all = "camelCase")] Image { - /// Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`). Azure takes a - /// bare catalog name such as `ubuntu` — it creates a session from a public catalog disk - /// image, so a registry path, tag or digest has nowhere to go. + /// Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`). + /// + /// Two backends narrow it, in opposite directions: AWS builds a MicroVM from an `s3://` + /// bundle, and Azure names an entry in a public catalog, so a bare `ubuntu`. One + /// declaration therefore cannot target both, and each refuses the other's shape while + /// planning. image: String, }, /// Source built into a sandbox image at deploy time. @@ -132,9 +135,9 @@ pub enum SandboxEgress { /// Unrestricted outbound access to the public internet, and none to private ranges or the /// deployment's own network. /// - /// Link-local carries the same exception as `Deny`. Azure delivers the first half only: its - /// egress rules match host patterns, so a private range has nothing to render into and the - /// data plane's own default applies. + /// Link-local carries the same exception as `Deny`. AWS and Kubernetes deliver both halves. + /// Azure and GCP deliver the first only: one matches host patterns and the other is a single + /// switch, so neither can name an address range to exclude. Allow, /// Outbound access only to the listed hostnames. /// @@ -543,12 +546,6 @@ impl Sandbox { self.validate_capabilities(&capabilities, platform) } - /// The MicroVM size that keeps every declared ceiling, or why none does. - /// - /// AWS sizes are discrete and a running MicroVM bursts to four times its baseline, so the - /// only tier that honours a ceiling is one whose peak fits inside it. A declaration no tier - /// satisfies is refused: shipping the nearest size would give the customer a sandbox that - /// exceeds the bound they wrote down. /// The catalog disk image Azure creates a session from. /// /// Azure names a public catalog entry rather than pulling a reference, so a registry path, @@ -565,10 +562,12 @@ impl Sandbox { }; let SandboxCode::Image { image } = &self.code else { - return Err(refused( - "source", - "no sandbox backend builds an image from source yet", - )); + return Err(AlienError::new(ErrorData::SandboxLimitInvalid { + resource_id: self.id.clone(), + field: "code".to_string(), + value: "source".to_string(), + reason: "no sandbox backend builds an image from source yet".to_string(), + })); }; let image = image.trim(); @@ -588,6 +587,12 @@ impl Sandbox { Ok(image) } + /// The MicroVM size that keeps every declared ceiling, or why none does. + /// + /// AWS sizes are discrete and a running MicroVM bursts to four times its baseline, so the + /// only tier that honours a ceiling is one whose peak fits inside it. A declaration no tier + /// satisfies is refused: shipping the nearest size would give the customer a sandbox that + /// exceeds the bound they wrote down. pub fn microvm_tier(&self) -> Result { let Some(limits) = self.limits.as_ref() else { // Nothing declared: AWS's own default baseline, which is also `default_limits`. diff --git a/crates/alien-helm/tests/generator/resource_layer_tests.rs b/crates/alien-helm/tests/generator/resource_layer_tests.rs index 03872b559..b860524b0 100644 --- a/crates/alien-helm/tests/generator/resource_layer_tests.rs +++ b/crates/alien-helm/tests/generator/resource_layer_tests.rs @@ -167,6 +167,9 @@ fn a_sandbox_allowing_egress_still_denies_the_metadata_endpoint() { /// NetworkPolicy matches addresses, not names, so a hostname allowlist has nothing to render /// into. It is refused: rendering it as `allow` would open every address the list excluded, and /// the chart would look like the policy applied. +/// +/// The second gate, not the first — a customer meets `domainEgressRules` at plan time. This one +/// covers the paths that render without planning. #[test] fn a_hostname_allowlist_is_refused_rather_than_widened() { let stack = Stack::new("sandbox-domains-chart".to_string()) diff --git a/crates/alien-preflights/src/compile_time/sandbox_platform_support.rs b/crates/alien-preflights/src/compile_time/sandbox_platform_support.rs index 4dda41d44..7597ffa36 100644 --- a/crates/alien-preflights/src/compile_time/sandbox_platform_support.rs +++ b/crates/alien-preflights/src/compile_time/sandbox_platform_support.rs @@ -80,7 +80,9 @@ mod tests { fn sandbox(id: &str, limits: Option, egress: SandboxEgress) -> Sandbox { let builder = Sandbox::new(id.to_string()) .code(SandboxCode::Image { - image: "ubuntu:24.04".to_string(), + // A bare name, because Azure takes a catalog entry rather than a reference and + // these cases are about egress and limits rather than about the image. + image: "ubuntu".to_string(), }) .egress(egress) .session(SandboxSessionPolicy { diff --git a/crates/alien-terraform/src/emitters/gcp/sandbox.rs b/crates/alien-terraform/src/emitters/gcp/sandbox.rs index f7f23ed38..bd0e9da22 100644 --- a/crates/alien-terraform/src/emitters/gcp/sandbox.rs +++ b/crates/alien-terraform/src/emitters/gcp/sandbox.rs @@ -105,6 +105,9 @@ mod tests { /// `--allow-egress` is a switch: rendering the list as `true` opens every address it was /// written to exclude, and rendering it as `false` denies every one it was written to permit. /// Neither is the declaration, so neither is emitted. + /// + /// The second gate, not the first — a customer meets `domainEgressRules` at plan time. This + /// one covers the paths that render without planning. #[test] fn a_hostname_allowlist_is_refused_rather_than_approximated() { let error = refuse_unsupported_egress(&sandbox_with(SandboxEgress::AllowDomains { diff --git a/packages/core/src/generated/schemas/sandbox.json b/packages/core/src/generated/schemas/sandbox.json index d483c1c71..6f3ebf134 100644 --- a/packages/core/src/generated/schemas/sandbox.json +++ b/packages/core/src/generated/schemas/sandbox.json @@ -1 +1 @@ -{"type":"object","description":"An isolated environment for running untrusted code, created per session at runtime.","required":["id","code","egress","session"],"properties":{"code":{"description":"Where the sandbox's root filesystem comes from","oneOf":[{"type":"object","description":"A prebuilt container image used as the sandbox root filesystem.","required":["image","type"],"properties":{"image":{"type":"string","description":"Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`). Azure takes a\nbare catalog name such as `ubuntu` — it creates a session from a public catalog disk\nimage, so a registry path, tag or digest has nowhere to go."},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source built into a sandbox image at deploy time.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"x-readme-ref-name":"SandboxCode"},"egress":{"description":"Outbound network policy","oneOf":[{"type":"object","description":"No outbound network access.\n\nRouted traffic only. Link-local is not outbound and no backend's egress control reaches\nit, so this is not a boundary against instance metadata.","required":["mode"],"properties":{"mode":{"type":"string","enum":["deny"]}}},{"type":"object","description":"Unrestricted outbound access to the public internet, and none to private ranges or the\ndeployment's own network.\n\nLink-local carries the same exception as `Deny`. Azure delivers the first half only: its\negress rules match host patterns, so a private range has nothing to render into and the\ndata plane's own default applies.","required":["mode"],"properties":{"mode":{"type":"string","enum":["allow"]}}},{"type":"object","description":"Outbound access only to the listed hostnames.\n\nAzure alone expresses it: its egress proxy matches on host pattern. The others filter by\nCIDR or carry a single switch, and both would approximate the list rather than keep it.","required":["domains","mode"],"properties":{"domains":{"type":"array","items":{"type":"string"},"description":"Hostnames the sandbox may reach"},"mode":{"type":"string","enum":["allowDomains"]}}}],"x-readme-ref-name":"SandboxEgress"},"id":{"type":"string","description":"Identifier for the sandbox. Must contain only alphanumeric characters, hyphens, and\nunderscores ([A-Za-z0-9-_]). Maximum 64 characters."},"limits":{"oneOf":[{"type":"null"},{"description":"Enforced resource ceilings.\n\nOptional because not every platform can enforce them, and a declaration that names none\ntakes the platform's own defaults. Naming them on a platform that cannot enforce them is\nrejected at plan time rather than silently ignored.","type":"object","required":["cpu","memory","disk"],"properties":{"cpu":{"type":"string","description":"CPU ceiling in cores or millicores (e.g. `\"1\"`, `\"500m\"`)"},"disk":{"type":"string","description":"Disk ceiling (e.g. `\"20Gi\"`)"},"maxProcesses":{"type":["integer","null"],"format":"int32","description":"Maximum number of processes, which bounds fork bombs.\n\nOptional because only a container runtime has the primitive: Kubernetes sets a pid ceiling\nper node, not per pod, and neither AWS MicroVMs nor Azure sandboxes expose one. Declaring\nit on a platform that cannot apply it is refused at plan time.","minimum":0},"memory":{"type":"string","description":"Memory ceiling (e.g. `\"2Gi\"`, `\"512Mi\"`)"}},"additionalProperties":false,"x-readme-ref-name":"SandboxLimits"}]},"previewPorts":{"type":"array","items":{"type":"integer","format":"int32","minimum":0},"description":"Ports eligible for a preview capability. A port not listed here can never be exposed,\nso an application cannot widen its own ingress at runtime."},"session":{"description":"Session lifetime and idle behaviour","type":"object","properties":{"idleSuspendSeconds":{"type":["integer","null"],"format":"int32","description":"Idle period after which the session is suspended, where the platform supports it","minimum":0},"maxLifetimeSeconds":{"type":["integer","null"],"format":"int32","description":"Wall-clock ceiling on a single session, after which the platform terminates it.\n\nOptional because not every backend has the primitive: Kubernetes has\n`activeDeadlineSeconds` and AWS `maximumDurationInSeconds`, while neither Azure nor Local\nexpose one, so declaring a ceiling there is refused at plan time rather than accepted and\nnever applied. AWS caps it at 8 hours.","minimum":0}},"additionalProperties":false,"x-readme-ref-name":"SandboxSessionPolicy"}},"additionalProperties":false,"x-readme-ref-name":"Sandbox"} \ No newline at end of file +{"type":"object","description":"An isolated environment for running untrusted code, created per session at runtime.","required":["id","code","egress","session"],"properties":{"code":{"description":"Where the sandbox's root filesystem comes from","oneOf":[{"type":"object","description":"A prebuilt container image used as the sandbox root filesystem.","required":["image","type"],"properties":{"image":{"type":"string","description":"Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`).\n\nTwo backends narrow it, in opposite directions: AWS builds a MicroVM from an `s3://`\nbundle, and Azure names an entry in a public catalog, so a bare `ubuntu`. One\ndeclaration therefore cannot target both, and each refuses the other's shape while\nplanning."},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source built into a sandbox image at deploy time.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"x-readme-ref-name":"SandboxCode"},"egress":{"description":"Outbound network policy","oneOf":[{"type":"object","description":"No outbound network access.\n\nRouted traffic only. Link-local is not outbound and no backend's egress control reaches\nit, so this is not a boundary against instance metadata.","required":["mode"],"properties":{"mode":{"type":"string","enum":["deny"]}}},{"type":"object","description":"Unrestricted outbound access to the public internet, and none to private ranges or the\ndeployment's own network.\n\nLink-local carries the same exception as `Deny`. AWS and Kubernetes deliver both halves.\nAzure and GCP deliver the first only: one matches host patterns and the other is a single\nswitch, so neither can name an address range to exclude.","required":["mode"],"properties":{"mode":{"type":"string","enum":["allow"]}}},{"type":"object","description":"Outbound access only to the listed hostnames.\n\nAzure alone expresses it: its egress proxy matches on host pattern. The others filter by\nCIDR or carry a single switch, and both would approximate the list rather than keep it.","required":["domains","mode"],"properties":{"domains":{"type":"array","items":{"type":"string"},"description":"Hostnames the sandbox may reach"},"mode":{"type":"string","enum":["allowDomains"]}}}],"x-readme-ref-name":"SandboxEgress"},"id":{"type":"string","description":"Identifier for the sandbox. Must contain only alphanumeric characters, hyphens, and\nunderscores ([A-Za-z0-9-_]). Maximum 64 characters."},"limits":{"oneOf":[{"type":"null"},{"description":"Enforced resource ceilings.\n\nOptional because not every platform can enforce them, and a declaration that names none\ntakes the platform's own defaults. Naming them on a platform that cannot enforce them is\nrejected at plan time rather than silently ignored.","type":"object","required":["cpu","memory","disk"],"properties":{"cpu":{"type":"string","description":"CPU ceiling in cores or millicores (e.g. `\"1\"`, `\"500m\"`)"},"disk":{"type":"string","description":"Disk ceiling (e.g. `\"20Gi\"`)"},"maxProcesses":{"type":["integer","null"],"format":"int32","description":"Maximum number of processes, which bounds fork bombs.\n\nOptional because only a container runtime has the primitive: Kubernetes sets a pid ceiling\nper node, not per pod, and neither AWS MicroVMs nor Azure sandboxes expose one. Declaring\nit on a platform that cannot apply it is refused at plan time.","minimum":0},"memory":{"type":"string","description":"Memory ceiling (e.g. `\"2Gi\"`, `\"512Mi\"`)"}},"additionalProperties":false,"x-readme-ref-name":"SandboxLimits"}]},"previewPorts":{"type":"array","items":{"type":"integer","format":"int32","minimum":0},"description":"Ports eligible for a preview capability. A port not listed here can never be exposed,\nso an application cannot widen its own ingress at runtime."},"session":{"description":"Session lifetime and idle behaviour","type":"object","properties":{"idleSuspendSeconds":{"type":["integer","null"],"format":"int32","description":"Idle period after which the session is suspended, where the platform supports it","minimum":0},"maxLifetimeSeconds":{"type":["integer","null"],"format":"int32","description":"Wall-clock ceiling on a single session, after which the platform terminates it.\n\nOptional because not every backend has the primitive: Kubernetes has\n`activeDeadlineSeconds` and AWS `maximumDurationInSeconds`, while neither Azure nor Local\nexpose one, so declaring a ceiling there is refused at plan time rather than accepted and\nnever applied. AWS caps it at 8 hours.","minimum":0}},"additionalProperties":false,"x-readme-ref-name":"SandboxSessionPolicy"}},"additionalProperties":false,"x-readme-ref-name":"Sandbox"} \ No newline at end of file diff --git a/packages/core/src/generated/schemas/sandboxCode.json b/packages/core/src/generated/schemas/sandboxCode.json index d83575f97..f4ef713d4 100644 --- a/packages/core/src/generated/schemas/sandboxCode.json +++ b/packages/core/src/generated/schemas/sandboxCode.json @@ -1 +1 @@ -{"oneOf":[{"type":"object","description":"A prebuilt container image used as the sandbox root filesystem.","required":["image","type"],"properties":{"image":{"type":"string","description":"Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`). Azure takes a\nbare catalog name such as `ubuntu` — it creates a session from a public catalog disk\nimage, so a registry path, tag or digest has nowhere to go."},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source built into a sandbox image at deploy time.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"description":"Specifies where the sandbox's root filesystem comes from.","x-readme-ref-name":"SandboxCode"} \ No newline at end of file +{"oneOf":[{"type":"object","description":"A prebuilt container image used as the sandbox root filesystem.","required":["image","type"],"properties":{"image":{"type":"string","description":"Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`).\n\nTwo backends narrow it, in opposite directions: AWS builds a MicroVM from an `s3://`\nbundle, and Azure names an entry in a public catalog, so a bare `ubuntu`. One\ndeclaration therefore cannot target both, and each refuses the other's shape while\nplanning."},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source built into a sandbox image at deploy time.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"description":"Specifies where the sandbox's root filesystem comes from.","x-readme-ref-name":"SandboxCode"} \ No newline at end of file diff --git a/packages/core/src/generated/schemas/sandboxEgress.json b/packages/core/src/generated/schemas/sandboxEgress.json index 25269f56a..00361f928 100644 --- a/packages/core/src/generated/schemas/sandboxEgress.json +++ b/packages/core/src/generated/schemas/sandboxEgress.json @@ -1 +1 @@ -{"oneOf":[{"type":"object","description":"No outbound network access.\n\nRouted traffic only. Link-local is not outbound and no backend's egress control reaches\nit, so this is not a boundary against instance metadata.","required":["mode"],"properties":{"mode":{"type":"string","enum":["deny"]}}},{"type":"object","description":"Unrestricted outbound access to the public internet, and none to private ranges or the\ndeployment's own network.\n\nLink-local carries the same exception as `Deny`. Azure delivers the first half only: its\negress rules match host patterns, so a private range has nothing to render into and the\ndata plane's own default applies.","required":["mode"],"properties":{"mode":{"type":"string","enum":["allow"]}}},{"type":"object","description":"Outbound access only to the listed hostnames.\n\nAzure alone expresses it: its egress proxy matches on host pattern. The others filter by\nCIDR or carry a single switch, and both would approximate the list rather than keep it.","required":["domains","mode"],"properties":{"domains":{"type":"array","items":{"type":"string"},"description":"Hostnames the sandbox may reach"},"mode":{"type":"string","enum":["allowDomains"]}}}],"description":"Outbound network policy for a sandbox.","x-readme-ref-name":"SandboxEgress"} \ No newline at end of file +{"oneOf":[{"type":"object","description":"No outbound network access.\n\nRouted traffic only. Link-local is not outbound and no backend's egress control reaches\nit, so this is not a boundary against instance metadata.","required":["mode"],"properties":{"mode":{"type":"string","enum":["deny"]}}},{"type":"object","description":"Unrestricted outbound access to the public internet, and none to private ranges or the\ndeployment's own network.\n\nLink-local carries the same exception as `Deny`. AWS and Kubernetes deliver both halves.\nAzure and GCP deliver the first only: one matches host patterns and the other is a single\nswitch, so neither can name an address range to exclude.","required":["mode"],"properties":{"mode":{"type":"string","enum":["allow"]}}},{"type":"object","description":"Outbound access only to the listed hostnames.\n\nAzure alone expresses it: its egress proxy matches on host pattern. The others filter by\nCIDR or carry a single switch, and both would approximate the list rather than keep it.","required":["domains","mode"],"properties":{"domains":{"type":"array","items":{"type":"string"},"description":"Hostnames the sandbox may reach"},"mode":{"type":"string","enum":["allowDomains"]}}}],"description":"Outbound network policy for a sandbox.","x-readme-ref-name":"SandboxEgress"} \ No newline at end of file diff --git a/packages/core/src/generated/zod/sandbox-code-schema.ts b/packages/core/src/generated/zod/sandbox-code-schema.ts index d81f72a7c..097faa453 100644 --- a/packages/core/src/generated/zod/sandbox-code-schema.ts +++ b/packages/core/src/generated/zod/sandbox-code-schema.ts @@ -10,7 +10,7 @@ import { ToolchainConfigSchema } from "./toolchain-config-schema.js"; * @description Specifies where the sandbox\'s root filesystem comes from. */ export const SandboxCodeSchema = z.union([z.object({ - "image": z.string().describe("Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`). Azure takes a\nbare catalog name such as `ubuntu` — it creates a session from a public catalog disk\nimage, so a registry path, tag or digest has nowhere to go."), + "image": z.string().describe("Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`).\n\nTwo backends narrow it, in opposite directions: AWS builds a MicroVM from an `s3://`\nbundle, and Azure names an entry in a public catalog, so a bare `ubuntu`. One\ndeclaration therefore cannot target both, and each refuses the other's shape while\nplanning."), "type": z.enum(["image"]) }), z.object({ "src": z.string().describe("The source directory to build from"), From ff27c0153bccaa918123a4b6402539eb5cc8d95b Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:15:52 +0300 Subject: [PATCH 23/32] docs(sandbox): hold the new comments to the standard Fifteen comments and doc-strings cut to the bar: the ones that had grown to five or six lines saying what three could, one that restated the name of the function under it, and one field doc whose middle sentence had no verb. Two that were not about wording. `judgeable`'s catch-all arm covers the transitional and terminal states and said nothing about why they carry nothing to judge. And the test for a command's declared variables never polled the stream it was handed, so it proved the call returned rather than that the command ran; it drains the stream and reads the exit now. --- .../alien-azure-clients/src/azure/common.rs | 7 +-- .../src/azure/sandbox_data_plane.rs | 22 +++------ .../src/providers/sandbox/azure.rs | 45 +++++++++---------- .../src/providers/sandbox/mod.rs | 8 ++-- crates/alien-core/src/resources/sandbox.rs | 12 ++--- .../src/emitters/azure/sandbox.rs | 4 +- .../src/emitters/gcp/sandbox.rs | 5 +-- 7 files changed, 40 insertions(+), 63 deletions(-) diff --git a/crates/alien-azure-clients/src/azure/common.rs b/crates/alien-azure-clients/src/azure/common.rs index 126159e7a..a0347a784 100644 --- a/crates/alien-azure-clients/src/azure/common.rs +++ b/crates/alien-azure-clients/src/azure/common.rs @@ -186,10 +186,8 @@ impl AzureClientBase { /// Sends a request exactly once, with no retry. /// - /// For the verbs a repeat performs twice: a PUT to a collection with a server-minted id makes - /// a second resource the caller has no id for, and an exec that answered late may already - /// have started the command. Neither carries an idempotency key, so the only safe number of - /// attempts is one. + /// A repeat PUT to a collection with a server-minted id mints a second resource, and a + /// repeat exec may re-run a command that already started. Neither carries an idempotency key. pub async fn execute_request_once( &self, req: reqwest::Request, @@ -199,7 +197,6 @@ impl AzureClientBase { Self::send_once(&self.client, req, op, res_name).await } - /// One attempt: send it, and turn a non-success status into an error carrying the context. async fn send_once( client: &reqwest::Client, req: reqwest::Request, diff --git a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs index 0b217336f..3c826b8a8 100644 --- a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs +++ b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs @@ -409,11 +409,8 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { let signed = self.base.sign_request(request, &token).await?; // The create body carries the caller's environment variables, and a failure echoes the - // request into the error chain, which is serialized into durable state. - // - // Sent once. The id is minted by the service and this is a PUT to a collection, so a - // re-send mints a second sandbox — and with no enumeration verb, the first one has no - // id-holder and nothing to reap it. + // request into the error chain, which is serialized into durable state. Sent once: the + // id is server-minted, so a re-send mints an orphan sandbox nothing can find or reap. let response = alien_client_core::redact_request_body( self.base .execute_request_once(signed, "CreateSandbox", group) @@ -482,10 +479,8 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { let signed = self.base.sign_request(request, &token).await?; // The body is the command, which is where a caller puts a token it wants the session to - // have. - // - // Sent once: a response that never arrives does not mean the command did not start, and - // running untrusted code a second time is not a recovery. + // have. Sent once: a response that never arrives does not mean the command did not + // start, so a re-send would risk running untrusted code twice. let response = alien_client_core::redact_request_body( self.base .execute_request_once(signed, "ExecuteShellCommand", sandbox_id) @@ -603,8 +598,7 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { .body(body) .build()?; let signed = self.base.sign_request(request, &token).await?; - // The body is a caller-supplied path; wrapped like the other bodied calls so the next one - // added here inherits the redaction rather than the omission. + // The body is a caller-supplied path, redacted like the other bodied calls. alien_client_core::redact_request_body( self.base.execute_request(signed, "Mkdir", sandbox_id).await, )?; @@ -648,10 +642,8 @@ mod tests { /// A create is delivered once, however the data plane answers. /// - /// The id is minted by the service and the PUT names a collection, so a second delivery makes - /// a second sandbox that no id-holder can find and no enumeration verb can list — one this - /// call would never learn about even when it eventually succeeds. The read is the contrast: - /// repeating it is free, so it keeps the retry. + /// A second delivery mints an orphan sandbox no enumeration verb can find. Reads keep their + /// retry — repeating one is free. #[tokio::test] async fn a_create_is_never_re_sent_where_a_read_is() { let server = MockServer::start_async().await; diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index cc54f9ae4..4dc5ea893 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -514,12 +514,8 @@ impl AzureSandbox { }; // Judged asleep first: waking one that already fails puts its workload back on the network - // for a boot. - // - // Refused rather than deleted, here and after the wake. A session under a policy this - // declaration does not allow may be another revision's, mid-command, in the group both - // share — and `get_or_create` gets what it owes the caller from the replacement its own - // refusal triggers, without ending work it does not own. + // for a boot. Refused rather than deleted, here and after the wake: the policy mismatch + // may belong to another revision, mid-command in the shared group. self.judge_if_judgeable(&found)?; // Judged again once it is up: only the woken record covers a session that was still coming @@ -773,11 +769,12 @@ impl AzureSandbox { match sandbox.state.as_deref() { Some("Running") => true, Some("Stopping" | "Stopped" | "Suspended" | "Idle") => sandbox.egress_policy.is_some(), + // The two ends of the lifecycle and anything unread: one has no policy yet, the other + // has dropped it, and a state this client cannot name is refused before it gets here. _ => false, } } - /// Judges a record only where there is something to judge. fn judge_if_judgeable( &self, sandbox: &alien_azure_clients::azure::sandbox_data_plane::Sandbox, @@ -2587,10 +2584,8 @@ mod tests { /// A session the declaration no longer matches is replaced, not a permanent error. /// /// `get_or_create` owes the caller a usable session, and a stale-policy sandbox is as - /// unusable as a terminated one — returning the refusal forever would leave the caller with - /// no way forward. The old sandbox is left where it is: another revision of the same stack - /// shares this group and may be running in it, and the replacement is what this caller asked - /// for. + /// unusable as a terminated one. The old sandbox is left running: another revision of the + /// same stack may share this group, and the replacement is what this caller asked for. #[tokio::test] async fn a_stale_policy_session_is_replaced_rather_than_refused_forever() { let mut client = MockSandboxDataPlaneApi::new(); @@ -2891,10 +2886,8 @@ mod tests { /// A suspended session that reports no policy reads as suspended, not as a mismatch. /// - /// `get` and the reconnect path have to answer this the same way. Whether the data plane - /// reports `egressPolicy` for a sandbox that is not running is unverified, so if it does not, - /// judging the record here would turn every idle-suspended session into a containment - /// failure — and `suspendResume` would advertise a state the caller cannot observe. + /// Whether the data plane reports `egressPolicy` off `Running` is unverified; judging it + /// here would turn every idle-suspended session into a containment failure. #[tokio::test] async fn a_suspended_session_reporting_no_policy_is_not_a_mismatch() { let mut client = MockSandboxDataPlaneApi::new(); @@ -2948,9 +2941,8 @@ mod tests { /// A wait that woke a session and then failed still puts it back. /// - /// The refusal is not the only way out of `resume`: the wait can fail after issuing the - /// resume, and a session left awake by a call that returned an error is exactly the one - /// nothing else will come back for. + /// The wait can fail after issuing the resume, and a session left awake by a call that + /// returned an error is exactly the one nothing else will come back for. #[tokio::test] async fn a_session_woken_by_a_wait_that_then_failed_is_put_back() { let mut client = MockSandboxDataPlaneApi::new(); @@ -3019,10 +3011,8 @@ mod tests { /// The variables a command declares reach the command. /// - /// Every other backend honours `RunCommandRequest.env`; the exec endpoint here takes no - /// environment at all, so dropping it silently would make one backend answer a documented - /// field with nothing, and the failure would surface inside the sandbox rather than at the - /// call. + /// Every other backend honours `RunCommandRequest.env`; dropping it here would answer a + /// documented field with nothing, and the failure would surface inside the sandbox. #[tokio::test] async fn a_declared_variable_reaches_the_command() { let mut client = MockSandboxDataPlaneApi::new(); @@ -3045,10 +3035,17 @@ mod tests { let mut request = command(5); request.env = BTreeMap::from([("TOKEN".to_string(), "t".to_string())]); - sandbox_with(client) + let frames: Vec> = sandbox_with(client) .run_command("s1", request) .await - .expect("a command declaring a variable must run"); + .expect("a command declaring a variable must run") + .collect() + .await; + + assert!( + matches!(frames.last(), Some(Ok(CommandOutput::Exit { code, .. })) if *code == 0), + "the command has to reach its exit: {frames:?}" + ); } /// A variable name that is not a name never reaches the shell string. diff --git a/crates/alien-bindings/src/providers/sandbox/mod.rs b/crates/alien-bindings/src/providers/sandbox/mod.rs index 8a298ba10..dd308ad5e 100644 --- a/crates/alien-bindings/src/providers/sandbox/mod.rs +++ b/crates/alien-bindings/src/providers/sandbox/mod.rs @@ -312,11 +312,9 @@ mod tests { /// An inherited variable of the wrapper's own name never reaches the command. /// - /// Run against a real `sh`, because the hazard is a shell rule rather than a string: an - /// exported variable keeps its export attribute across re-assignment, so `nonce=$(…)` would - /// write the session's own nonce into the slot the command inherits, and untrusted code could - /// then claim a deadline it was never given. A stand-in `setsid` is supplied because macOS - /// ships none, and without it the wrapper exits before reaching any of this. + /// Run against a real `sh`: an exported variable keeps its export attribute across + /// re-assignment, so `nonce=$(…)` would hand the command the session's own nonce. A + /// stand-in `setsid` is supplied because macOS ships none. #[test] #[cfg(unix)] fn the_wrapper_never_hands_its_nonce_to_the_command() { diff --git a/crates/alien-core/src/resources/sandbox.rs b/crates/alien-core/src/resources/sandbox.rs index 2fe190890..fbd4ce004 100644 --- a/crates/alien-core/src/resources/sandbox.rs +++ b/crates/alien-core/src/resources/sandbox.rs @@ -27,10 +27,8 @@ pub enum SandboxCode { Image { /// Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`). /// - /// Two backends narrow it, in opposite directions: AWS builds a MicroVM from an `s3://` - /// bundle, and Azure names an entry in a public catalog, so a bare `ubuntu`. One - /// declaration therefore cannot target both, and each refuses the other's shape while - /// planning. + /// Two backends narrow it in opposite directions: AWS wants an `s3://` bundle, Azure a + /// bare catalog name such as `ubuntu`. Each refuses the other's shape while planning. image: String, }, /// Source built into a sandbox image at deploy time. @@ -1213,10 +1211,8 @@ mod tests { /// An image reference Azure cannot honour is refused while planning, not at the first session. /// - /// The examples in `code.image`'s own documentation — a tag, a registry path — are exactly - /// what Azure cannot take, so this is the shape a customer is most likely to declare. Caught - /// at plan time it names the sandbox; caught nowhere, it renders into the module, plans, - /// applies, and fails when the first session is created. + /// `code.image`'s own documentation gives a tag and a registry path as examples — exactly + /// what Azure cannot take, so this is the shape a customer is most likely to declare. #[test] fn an_image_azure_cannot_pull_is_refused_while_planning() { let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]); diff --git a/crates/alien-terraform/src/emitters/azure/sandbox.rs b/crates/alien-terraform/src/emitters/azure/sandbox.rs index 34954e437..a5e516bf7 100644 --- a/crates/alien-terraform/src/emitters/azure/sandbox.rs +++ b/crates/alien-terraform/src/emitters/azure/sandbox.rs @@ -179,9 +179,7 @@ mod tests { /// Every key the binding deserializes is a key the emitter writes. /// /// The emitter types the names by hand while the provider reads them through serde, so a - /// rename on either side lands on a customer's cluster as a deserialization failure at the - /// first session rather than as a failure at plan time. The names come from the type here, - /// not from a second hand-typed list. + /// rename on either side would otherwise surface as a deserialization failure at runtime. #[test] fn the_emitted_keys_are_the_ones_the_binding_deserializes() { let rendered = binding_with( diff --git a/crates/alien-terraform/src/emitters/gcp/sandbox.rs b/crates/alien-terraform/src/emitters/gcp/sandbox.rs index bd0e9da22..4c31601bb 100644 --- a/crates/alien-terraform/src/emitters/gcp/sandbox.rs +++ b/crates/alien-terraform/src/emitters/gcp/sandbox.rs @@ -102,9 +102,8 @@ mod tests { /// A hostname list is refused rather than carried as its nearest boolean. /// - /// `--allow-egress` is a switch: rendering the list as `true` opens every address it was - /// written to exclude, and rendering it as `false` denies every one it was written to permit. - /// Neither is the declaration, so neither is emitted. + /// `--allow-egress` is a switch: rendering the list as `true` or `false` opens or denies + /// addresses the declaration did not say to. Neither is the declaration, so neither is emitted. /// /// The second gate, not the first — a customer meets `domainEgressRules` at plan time. This /// one covers the paths that render without planning. From 024b408d2a9eec9a88d7c82e8bede47bb1d45799 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:46:12 +0300 Subject: [PATCH 24/32] fix(sandbox): make the wrapper this builds actually run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `env` reads operands as assignments until one is not, then execs that one. The separator sat after the assignments, so it became the program name and every Azure command carrying variables died with 127 before it started. Dropped, and a test now runs the generated string through a real shell instead of asserting its shape — the shape was exactly what was intended, and what was intended was wrong. A program whose own name carries `=` is refused when the call also declares variables, because `env` would take it for a variable and run the next argument in its place. A session can no longer set `PATH`, `IFS`, `LD_PRELOAD` or `LD_LIBRARY_PATH`. The wrapper that holds a command to its deadline runs inside the session and inherits them: a session `PATH` chooses which `od` draws the nonce, which is the whole basis for telling a kill from an exit. The same names per command are fine — those reach the command and nothing else. The wrapper also quotes the last two expansions it had left bare, so an inherited `IFS` cannot split a pid into words that are not children. Comments that still described assignments in front of the wrapper, and three that said a refused session is deleted, now say what the code does. Schemas regenerated for a doc reworded after the last run. --- .../src/providers/sandbox/azure.rs | 212 ++++++++++++++++-- .../src/providers/sandbox/mod.rs | 11 +- .../core/src/generated/schemas/sandbox.json | 2 +- .../src/generated/schemas/sandboxCode.json | 2 +- .../src/generated/zod/sandbox-code-schema.ts | 2 +- 5 files changed, 205 insertions(+), 24 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 4dc5ea893..28d16ff46 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -142,6 +142,8 @@ impl Sandbox for AzureSandbox { } async fn create(&self, request: CreateSessionRequest) -> Result { + checked_session_env(CREATE, &request.env)?; + let asked = egress_policy(&self.egress); let sandbox = self .client @@ -236,8 +238,9 @@ impl Sandbox for AzureSandbox { Ok(session) => return Ok(session), // The two ways an id can fail to serve — gone, or running a policy the // declaration no longer matches — mean the same thing to a caller asking for a - // session, and are answered the same way: a fresh one. The gate has already - // discarded whatever it refused, so nothing is left running. + // session, and are answered the same way: a fresh one. A session refused for its + // policy is left running: it may be another revision's, and this caller is served + // by the replacement rather than by taking theirs. // // Narrow on purpose: a readiness timeout says the data plane is slow, and // answering that by creating a second sandbox makes it slower. @@ -287,11 +290,25 @@ impl Sandbox for AzureSandbox { // that never answers at all; there the only lever left is ending the session, and that // call returns once the session is confirmed gone rather than claim containment early. // The data plane's exec takes a command and a working directory and nothing else, so a - // per-command variable has to travel as a shell assignment in front of it. Names are - // checked first: an unchecked one is a second command, not a variable. + // per-command variable travels through `env` in the argv — which keeps it off the shell + // that bounds the command. Names are checked so `env` will take them as variables. for name in request.env.keys() { checked_env_name(RUN_COMMAND, name)?; } + // `env` takes operands as assignments until one is not, so a program whose own name + // carries `=` would be read as a variable and the next argument run in its place. + if !request.env.is_empty() { + if let Some(program) = request.command.first().filter(|first| first.contains('=')) { + return Err(AlienError::new(ErrorData::InvalidInput { + operation_context: RUN_COMMAND.to_string(), + details: format!( + "command '{program}' cannot carry '=' in its name while the call also \ + declares environment variables" + ), + field_name: Some("command".to_string()), + })); + } + } let shell = bounded_shell(&request.command, &request.env, request.deadline); let result = self.execute_within(session_id, &shell, &request).await?; @@ -492,9 +509,10 @@ impl Sandbox for AzureSandbox { impl AzureSandbox { /// Brings a session the caller named back into service, or says why it cannot be. /// - /// The one path that repairs rather than refusing: `get_or_create` asked for a usable - /// session, so a session that cannot serve is discarded and replaced rather than returned as - /// an error the caller has no way to act on. + /// The one path that replaces rather than only refusing: `get_or_create` asked for a usable + /// session, so an id that cannot serve becomes a fresh session rather than an error the + /// caller has no way to act on. Only a `Failed` sandbox is deleted here — one refused for its + /// policy is left alone, because the group is shared and it may be in use. async fn reconnect(&self, session_id: &str) -> Result { let gone = || { AlienError::new(ErrorData::SandboxCommandFailed { @@ -931,11 +949,10 @@ fn bounded_shell( // Through `env`, so the variables reach the caller's command and not the wrapper that bounds // it: an assignment in front of the wrapper would put a caller-chosen `PATH` on the shell // that resolves `setsid`, `sleep` and `kill`, and the deadline is only as real as those. - let mut argv = Vec::with_capacity(command.len() + env.len() + 2); + let mut argv = Vec::with_capacity(command.len() + env.len() + 1); if !env.is_empty() { argv.push("env".to_string()); argv.extend(env.iter().map(|(name, value)| format!("{name}={value}"))); - argv.push("--".to_string()); } argv.extend(command.iter().cloned()); @@ -949,10 +966,36 @@ fn bounded_shell( ) } -/// Refuses a variable name the shell would read as anything other than a name. +/// Names a session may not set, because the shell that bounds a command inherits them. /// -/// The name is not quotable — it sits left of the `=` — so a name carrying a space or a `;` is a -/// second command rather than a variable, and quoting the value alone would not stop it. +/// A session-level `PATH` chooses which `od` draws the deadline nonce, and an `IFS` changes how +/// the wrapper reads its own pids back — either hands the command a deadline it can forge. The +/// same names are safe per command, where they travel through `env` and reach only the command. +const SESSION_ENV_REFUSED: [&str; 4] = ["PATH", "IFS", "LD_PRELOAD", "LD_LIBRARY_PATH"]; + +/// Refuses an environment a session must not carry. +fn checked_session_env(operation: &str, env: &BTreeMap) -> Result<()> { + for name in env.keys() { + checked_env_name(operation, name)?; + if SESSION_ENV_REFUSED.contains(&name.as_str()) { + return Err(AlienError::new(ErrorData::InvalidInput { + operation_context: operation.to_string(), + details: format!( + "'{name}' cannot be set for the whole session, because the wrapper that holds \ + a command to its deadline inherits it; declare it on the command instead" + ), + field_name: Some("env".to_string()), + })); + } + } + Ok(()) +} + +/// Refuses a variable name `env` would not take as one. +/// +/// Kept even though the whole `NAME=value` pair is one quoted argument: a name outside this set +/// either fails the exec or silently becomes something else, and the other backends bound it the +/// same way. fn checked_env_name(operation: &str, name: &str) -> Result<()> { let usable = !name.is_empty() && !name.starts_with(|c: char| c.is_ascii_digit()) @@ -1674,7 +1717,7 @@ mod tests { ); assert!( - wrapped.ends_with("' sh 'env' 'TOKEN=a'\\''; rm -rf /' '--' 'printenv' 'TOKEN'"), + wrapped.ends_with("' sh 'env' 'TOKEN=a'\\''; rm -rf /' 'printenv' 'TOKEN'"), "the value has to survive as one argument to env: {wrapped}" ); } @@ -1700,11 +1743,145 @@ mod tests { "the wrapper has to resolve its own tools: {wrapper}" ); assert_eq!( - argv, "'env' 'PATH=/tmp/attacker' '--' 'sleep' 'forever'", + argv, "'env' 'PATH=/tmp/attacker' 'sleep' 'forever'", "the variable belongs to the command, not to the shell that bounds it" ); } + /// The wrapper the provider builds actually runs, with the variable set. + /// + /// The other tests here assert the shape of the string. This one runs it, because the shape + /// can be exactly what was intended and still not execute: `env` reads operands as + /// assignments until one is not, so a separator in the wrong place becomes the program name. + /// A stand-in `setsid` is supplied because macOS ships none. + #[test] + #[cfg(unix)] + fn the_wrapper_this_builds_runs_with_the_variable_set() { + use std::os::unix::fs::PermissionsExt; + + let bin = std::env::temp_dir().join(format!("alien-azure-shell-{}", std::process::id())); + std::fs::create_dir_all(&bin).expect("a directory for the stand-in"); + let setsid = bin.join("setsid"); + std::fs::write(&setsid, "#!/bin/sh\nexec \"$@\"\n").expect("the stand-in is written"); + std::fs::set_permissions(&setsid, std::fs::Permissions::from_mode(0o755)) + .expect("the stand-in is executable"); + let path = format!( + "{}:{}", + bin.display(), + std::env::var("PATH").unwrap_or_default() + ); + + // Addressed absolutely, so the command itself does not depend on the `PATH` under test. + let command = [ + "/bin/sh".to_string(), + "-c".to_string(), + "printf %s \"$TOKEN\"".to_string(), + ]; + + let run = |env: BTreeMap| { + let shell = bounded_shell(&command, &env, std::time::Duration::from_secs(5)); + std::process::Command::new("/bin/sh") + .arg("-c") + .arg(shell) + .env("PATH", &path) + .output() + .expect("a shell runs") + }; + + let plain = run(BTreeMap::from([("TOKEN".to_string(), "reached".to_string())])); + assert_eq!( + String::from_utf8_lossy(&plain.stdout), + "reached", + "the variable has to reach the command; stderr {:?}", + String::from_utf8_lossy(&plain.stderr) + ); + + // The wrapper resolves its own tools before the caller's environment applies, so a `PATH` + // that points nowhere reaches the command and leaves the deadline intact. + let repointed = run(BTreeMap::from([ + ("TOKEN".to_string(), "reached".to_string()), + ("PATH".to_string(), "/nonexistent".to_string()), + ])); + assert_eq!( + String::from_utf8_lossy(&repointed.stdout), + "reached", + "a caller's PATH must not break the wrapper; stderr {:?}", + String::from_utf8_lossy(&repointed.stderr) + ); + + std::fs::remove_dir_all(&bin).ok(); + } + + /// A session cannot set the variables the deadline wrapper reads. + /// + /// The wrapper runs inside the session and inherits its environment, so a session-level + /// `PATH` picks which `od` draws the deadline nonce and an `IFS` changes how the wrapper + /// reads its own pids — either lets the command claim a deadline nothing enforced. The same + /// names on a command are fine, because those reach only the command. + #[tokio::test] + async fn a_session_cannot_set_what_the_deadline_wrapper_reads() { + for name in ["PATH", "IFS", "LD_PRELOAD", "LD_LIBRARY_PATH"] { + let mut client = MockSandboxDataPlaneApi::new(); + client.expect_create_sandbox().never(); + + let error = sandbox_with(client) + .create(CreateSessionRequest { + session_id: None, + tenant_key: None, + env: BTreeMap::from([(name.to_string(), "/tmp/attacker".to_string())]), + }) + .await + .expect_err("a session that could forge its own deadline must not be created"); + + assert_eq!(error.code, "INVALID_INPUT", "{name}: {error}"); + } + + // The ordinary case still reaches the create body. + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_create_sandbox() + .times(1) + .withf(|_, request| request.environment.get("TOKEN").map(String::as_str) == Some("t")) + .returning(|_, _| Ok(running("s1", None))); + client + .expect_get_sandbox() + .returning(|_, id| Ok(running(id, None))); + + sandbox_with(client) + .create(CreateSessionRequest { + session_id: None, + tenant_key: None, + env: BTreeMap::from([("TOKEN".to_string(), "t".to_string())]), + }) + .await + .expect("an ordinary variable is still carried"); + } + + /// A program whose own name carries `=` is refused when the call also declares variables. + /// + /// `env` reads operands as assignments until one is not, so such a name would be taken as a + /// variable and the next argument run in its place — the command silently replaced rather + /// than refused. + #[tokio::test] + async fn a_program_name_env_would_swallow_is_refused() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_get_sandbox() + .returning(|_, id| Ok(running(id, None))); + client.expect_execute_shell_command().never(); + + let mut request = command(5); + request.command = vec!["FOO=bar".to_string(), "printenv".to_string()]; + request.env = BTreeMap::from([("TOKEN".to_string(), "t".to_string())]); + + let error = match sandbox_with(client).run_command("s1", request).await { + Ok(_) => panic!("a command env would swallow must not be sent"), + Err(error) => error, + }; + + assert_eq!(error.code, "INVALID_INPUT", "{error}"); + } + /// A name the shell would read as a second command never reaches the shell string. #[test] fn a_variable_name_that_is_not_a_name_is_refused() { @@ -2757,8 +2934,9 @@ mod tests { .await .expect("a caller asking for a session gets a usable one"); - // Answered the same way as a terminated id: the one that woke up wider is discarded and - // replaced, rather than returned as an error the caller cannot act on. + // Answered the same way as a terminated id: the caller gets a fresh session. The one + // that woke up wider is put back to sleep, not deleted — the id may be another + // revision's. assert_eq!(session.session_id, "fresh"); } @@ -3022,7 +3200,7 @@ mod tests { client .expect_execute_shell_command() .times(1) - .withf(|_, _, shell, _| shell.ends_with("' sh 'env' 'TOKEN=t' '--' 'sleep' 'forever'")) + .withf(|_, _, shell, _| shell.ends_with("' sh 'env' 'TOKEN=t' 'sleep' 'forever'")) .returning(|_, _, _, _| { Ok(alien_azure_clients::azure::sandbox_data_plane::ExecResult { exit_code: Some(0), diff --git a/crates/alien-bindings/src/providers/sandbox/mod.rs b/crates/alien-bindings/src/providers/sandbox/mod.rs index dd308ad5e..415c977e0 100644 --- a/crates/alien-bindings/src/providers/sandbox/mod.rs +++ b/crates/alien-bindings/src/providers/sandbox/mod.rs @@ -84,7 +84,8 @@ impl DeadlineReport { nonce=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \\n') || exit {unboundable}; \ printf '%s\\n' \"$nonce\" >&2; \ setsid \"$@\" & command_pid=$!; \ - ( sleep {deadline} & sleeper=$!; trap 'kill $sleeper 2>/dev/null; exit' TERM; wait $sleeper; \ + ( sleep {deadline} & sleeper=$!; trap 'kill \"$sleeper\" 2>/dev/null; exit' TERM; \ + wait \"$sleeper\"; \ trap '' TERM; kill -KILL -\"$command_pid\" 2>/dev/null && printf %s \"$nonce\" >&2 ) & killer_pid=$!; \ wait \"$command_pid\"; status=$?; \ kill \"$killer_pid\" 2>/dev/null; wait \"$killer_pid\"; \ @@ -296,11 +297,13 @@ mod tests { "the killer is stopped and then awaited, whatever the command's exit: {program}" ); assert!( - program.contains("wait $sleeper; trap '' TERM; kill -KILL"), - "past its sleep the killer ignores the stop, so its report is never cut off: {program}" + program.contains(r#"wait "$sleeper"; trap '' TERM; kill -KILL"#), + "past its sleep the killer ignores the stop, so its report is never cut off, and the \ + pid is quoted so an inherited IFS cannot split it into words that are not children: \ + {program}" ); assert!( - program.contains("trap 'kill $sleeper 2>/dev/null; exit' TERM"), + program.contains(r#"trap 'kill "$sleeper" 2>/dev/null; exit' TERM"#), "a stopped killer reaps its own sleeper, so none outlives the command: {program}" ); assert!( diff --git a/packages/core/src/generated/schemas/sandbox.json b/packages/core/src/generated/schemas/sandbox.json index 6f3ebf134..daa730a5c 100644 --- a/packages/core/src/generated/schemas/sandbox.json +++ b/packages/core/src/generated/schemas/sandbox.json @@ -1 +1 @@ -{"type":"object","description":"An isolated environment for running untrusted code, created per session at runtime.","required":["id","code","egress","session"],"properties":{"code":{"description":"Where the sandbox's root filesystem comes from","oneOf":[{"type":"object","description":"A prebuilt container image used as the sandbox root filesystem.","required":["image","type"],"properties":{"image":{"type":"string","description":"Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`).\n\nTwo backends narrow it, in opposite directions: AWS builds a MicroVM from an `s3://`\nbundle, and Azure names an entry in a public catalog, so a bare `ubuntu`. One\ndeclaration therefore cannot target both, and each refuses the other's shape while\nplanning."},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source built into a sandbox image at deploy time.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"x-readme-ref-name":"SandboxCode"},"egress":{"description":"Outbound network policy","oneOf":[{"type":"object","description":"No outbound network access.\n\nRouted traffic only. Link-local is not outbound and no backend's egress control reaches\nit, so this is not a boundary against instance metadata.","required":["mode"],"properties":{"mode":{"type":"string","enum":["deny"]}}},{"type":"object","description":"Unrestricted outbound access to the public internet, and none to private ranges or the\ndeployment's own network.\n\nLink-local carries the same exception as `Deny`. AWS and Kubernetes deliver both halves.\nAzure and GCP deliver the first only: one matches host patterns and the other is a single\nswitch, so neither can name an address range to exclude.","required":["mode"],"properties":{"mode":{"type":"string","enum":["allow"]}}},{"type":"object","description":"Outbound access only to the listed hostnames.\n\nAzure alone expresses it: its egress proxy matches on host pattern. The others filter by\nCIDR or carry a single switch, and both would approximate the list rather than keep it.","required":["domains","mode"],"properties":{"domains":{"type":"array","items":{"type":"string"},"description":"Hostnames the sandbox may reach"},"mode":{"type":"string","enum":["allowDomains"]}}}],"x-readme-ref-name":"SandboxEgress"},"id":{"type":"string","description":"Identifier for the sandbox. Must contain only alphanumeric characters, hyphens, and\nunderscores ([A-Za-z0-9-_]). Maximum 64 characters."},"limits":{"oneOf":[{"type":"null"},{"description":"Enforced resource ceilings.\n\nOptional because not every platform can enforce them, and a declaration that names none\ntakes the platform's own defaults. Naming them on a platform that cannot enforce them is\nrejected at plan time rather than silently ignored.","type":"object","required":["cpu","memory","disk"],"properties":{"cpu":{"type":"string","description":"CPU ceiling in cores or millicores (e.g. `\"1\"`, `\"500m\"`)"},"disk":{"type":"string","description":"Disk ceiling (e.g. `\"20Gi\"`)"},"maxProcesses":{"type":["integer","null"],"format":"int32","description":"Maximum number of processes, which bounds fork bombs.\n\nOptional because only a container runtime has the primitive: Kubernetes sets a pid ceiling\nper node, not per pod, and neither AWS MicroVMs nor Azure sandboxes expose one. Declaring\nit on a platform that cannot apply it is refused at plan time.","minimum":0},"memory":{"type":"string","description":"Memory ceiling (e.g. `\"2Gi\"`, `\"512Mi\"`)"}},"additionalProperties":false,"x-readme-ref-name":"SandboxLimits"}]},"previewPorts":{"type":"array","items":{"type":"integer","format":"int32","minimum":0},"description":"Ports eligible for a preview capability. A port not listed here can never be exposed,\nso an application cannot widen its own ingress at runtime."},"session":{"description":"Session lifetime and idle behaviour","type":"object","properties":{"idleSuspendSeconds":{"type":["integer","null"],"format":"int32","description":"Idle period after which the session is suspended, where the platform supports it","minimum":0},"maxLifetimeSeconds":{"type":["integer","null"],"format":"int32","description":"Wall-clock ceiling on a single session, after which the platform terminates it.\n\nOptional because not every backend has the primitive: Kubernetes has\n`activeDeadlineSeconds` and AWS `maximumDurationInSeconds`, while neither Azure nor Local\nexpose one, so declaring a ceiling there is refused at plan time rather than accepted and\nnever applied. AWS caps it at 8 hours.","minimum":0}},"additionalProperties":false,"x-readme-ref-name":"SandboxSessionPolicy"}},"additionalProperties":false,"x-readme-ref-name":"Sandbox"} \ No newline at end of file +{"type":"object","description":"An isolated environment for running untrusted code, created per session at runtime.","required":["id","code","egress","session"],"properties":{"code":{"description":"Where the sandbox's root filesystem comes from","oneOf":[{"type":"object","description":"A prebuilt container image used as the sandbox root filesystem.","required":["image","type"],"properties":{"image":{"type":"string","description":"Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`).\n\nTwo backends narrow it in opposite directions: AWS wants an `s3://` bundle, Azure a\nbare catalog name such as `ubuntu`. Each refuses the other's shape while planning."},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source built into a sandbox image at deploy time.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"x-readme-ref-name":"SandboxCode"},"egress":{"description":"Outbound network policy","oneOf":[{"type":"object","description":"No outbound network access.\n\nRouted traffic only. Link-local is not outbound and no backend's egress control reaches\nit, so this is not a boundary against instance metadata.","required":["mode"],"properties":{"mode":{"type":"string","enum":["deny"]}}},{"type":"object","description":"Unrestricted outbound access to the public internet, and none to private ranges or the\ndeployment's own network.\n\nLink-local carries the same exception as `Deny`. AWS and Kubernetes deliver both halves.\nAzure and GCP deliver the first only: one matches host patterns and the other is a single\nswitch, so neither can name an address range to exclude.","required":["mode"],"properties":{"mode":{"type":"string","enum":["allow"]}}},{"type":"object","description":"Outbound access only to the listed hostnames.\n\nAzure alone expresses it: its egress proxy matches on host pattern. The others filter by\nCIDR or carry a single switch, and both would approximate the list rather than keep it.","required":["domains","mode"],"properties":{"domains":{"type":"array","items":{"type":"string"},"description":"Hostnames the sandbox may reach"},"mode":{"type":"string","enum":["allowDomains"]}}}],"x-readme-ref-name":"SandboxEgress"},"id":{"type":"string","description":"Identifier for the sandbox. Must contain only alphanumeric characters, hyphens, and\nunderscores ([A-Za-z0-9-_]). Maximum 64 characters."},"limits":{"oneOf":[{"type":"null"},{"description":"Enforced resource ceilings.\n\nOptional because not every platform can enforce them, and a declaration that names none\ntakes the platform's own defaults. Naming them on a platform that cannot enforce them is\nrejected at plan time rather than silently ignored.","type":"object","required":["cpu","memory","disk"],"properties":{"cpu":{"type":"string","description":"CPU ceiling in cores or millicores (e.g. `\"1\"`, `\"500m\"`)"},"disk":{"type":"string","description":"Disk ceiling (e.g. `\"20Gi\"`)"},"maxProcesses":{"type":["integer","null"],"format":"int32","description":"Maximum number of processes, which bounds fork bombs.\n\nOptional because only a container runtime has the primitive: Kubernetes sets a pid ceiling\nper node, not per pod, and neither AWS MicroVMs nor Azure sandboxes expose one. Declaring\nit on a platform that cannot apply it is refused at plan time.","minimum":0},"memory":{"type":"string","description":"Memory ceiling (e.g. `\"2Gi\"`, `\"512Mi\"`)"}},"additionalProperties":false,"x-readme-ref-name":"SandboxLimits"}]},"previewPorts":{"type":"array","items":{"type":"integer","format":"int32","minimum":0},"description":"Ports eligible for a preview capability. A port not listed here can never be exposed,\nso an application cannot widen its own ingress at runtime."},"session":{"description":"Session lifetime and idle behaviour","type":"object","properties":{"idleSuspendSeconds":{"type":["integer","null"],"format":"int32","description":"Idle period after which the session is suspended, where the platform supports it","minimum":0},"maxLifetimeSeconds":{"type":["integer","null"],"format":"int32","description":"Wall-clock ceiling on a single session, after which the platform terminates it.\n\nOptional because not every backend has the primitive: Kubernetes has\n`activeDeadlineSeconds` and AWS `maximumDurationInSeconds`, while neither Azure nor Local\nexpose one, so declaring a ceiling there is refused at plan time rather than accepted and\nnever applied. AWS caps it at 8 hours.","minimum":0}},"additionalProperties":false,"x-readme-ref-name":"SandboxSessionPolicy"}},"additionalProperties":false,"x-readme-ref-name":"Sandbox"} \ No newline at end of file diff --git a/packages/core/src/generated/schemas/sandboxCode.json b/packages/core/src/generated/schemas/sandboxCode.json index f4ef713d4..28f575af4 100644 --- a/packages/core/src/generated/schemas/sandboxCode.json +++ b/packages/core/src/generated/schemas/sandboxCode.json @@ -1 +1 @@ -{"oneOf":[{"type":"object","description":"A prebuilt container image used as the sandbox root filesystem.","required":["image","type"],"properties":{"image":{"type":"string","description":"Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`).\n\nTwo backends narrow it, in opposite directions: AWS builds a MicroVM from an `s3://`\nbundle, and Azure names an entry in a public catalog, so a bare `ubuntu`. One\ndeclaration therefore cannot target both, and each refuses the other's shape while\nplanning."},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source built into a sandbox image at deploy time.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"description":"Specifies where the sandbox's root filesystem comes from.","x-readme-ref-name":"SandboxCode"} \ No newline at end of file +{"oneOf":[{"type":"object","description":"A prebuilt container image used as the sandbox root filesystem.","required":["image","type"],"properties":{"image":{"type":"string","description":"Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`).\n\nTwo backends narrow it in opposite directions: AWS wants an `s3://` bundle, Azure a\nbare catalog name such as `ubuntu`. Each refuses the other's shape while planning."},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source built into a sandbox image at deploy time.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"description":"Specifies where the sandbox's root filesystem comes from.","x-readme-ref-name":"SandboxCode"} \ No newline at end of file diff --git a/packages/core/src/generated/zod/sandbox-code-schema.ts b/packages/core/src/generated/zod/sandbox-code-schema.ts index 097faa453..e24fef1be 100644 --- a/packages/core/src/generated/zod/sandbox-code-schema.ts +++ b/packages/core/src/generated/zod/sandbox-code-schema.ts @@ -10,7 +10,7 @@ import { ToolchainConfigSchema } from "./toolchain-config-schema.js"; * @description Specifies where the sandbox\'s root filesystem comes from. */ export const SandboxCodeSchema = z.union([z.object({ - "image": z.string().describe("Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`).\n\nTwo backends narrow it, in opposite directions: AWS builds a MicroVM from an `s3://`\nbundle, and Azure names an entry in a public catalog, so a bare `ubuntu`. One\ndeclaration therefore cannot target both, and each refuses the other's shape while\nplanning."), + "image": z.string().describe("Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`).\n\nTwo backends narrow it in opposite directions: AWS wants an `s3://` bundle, Azure a\nbare catalog name such as `ubuntu`. Each refuses the other's shape while planning."), "type": z.enum(["image"]) }), z.object({ "src": z.string().describe("The source directory to build from"), From e4a3de285cb7ab41d2e52c9a6fd8412d4f7cf023 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:22:07 +0300 Subject: [PATCH 25/32] fix(sandbox): refuse the loader family, not the two names guessed first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `LD_AUDIT` runs attacker code inside every process the wrapper starts — including `od`, which draws the nonce the deadline report rests on. Code in the session can then write a nonce it chose to stderr before the wrapper announces one, exit 137 itself, and be reported as killed at a deadline nothing enforced. Demonstrated end to end against a real glibc loader: a command that ran for no time at all claimed a 30-second kill. Refused as `LD_*` rather than by name. The list was two entries long because two were suggested, and the loader reads more than anyone maintaining that list would remember. A command naming no program is refused too. `env` with assignments and no operand prints the environment it was handed and exits 0, so an empty command returned the session's own variables to the caller as a command that succeeded. --- .../src/providers/sandbox/azure.rs | 66 ++++++++++++++++--- 1 file changed, 56 insertions(+), 10 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 28d16ff46..05f3ca60f 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -292,6 +292,14 @@ impl Sandbox for AzureSandbox { // The data plane's exec takes a command and a working directory and nothing else, so a // per-command variable travels through `env` in the argv — which keeps it off the shell // that bounds the command. Names are checked so `env` will take them as variables. + if request.command.is_empty() { + return Err(AlienError::new(ErrorData::InvalidInput { + operation_context: RUN_COMMAND.to_string(), + details: "a command must name a program to run".to_string(), + field_name: Some("command".to_string()), + })); + } + for name in request.env.keys() { checked_env_name(RUN_COMMAND, name)?; } @@ -966,18 +974,18 @@ fn bounded_shell( ) } -/// Names a session may not set, because the shell that bounds a command inherits them. -/// -/// A session-level `PATH` chooses which `od` draws the deadline nonce, and an `IFS` changes how -/// the wrapper reads its own pids back — either hands the command a deadline it can forge. The -/// same names are safe per command, where they travel through `env` and reach only the command. -const SESSION_ENV_REFUSED: [&str; 4] = ["PATH", "IFS", "LD_PRELOAD", "LD_LIBRARY_PATH"]; - /// Refuses an environment a session must not carry. +/// +/// The wrapper that holds a command to its deadline runs inside the session and inherits its +/// environment, so a name that changes how a shell resolves, splits, or loads hands the command a +/// deadline it can forge. `PATH` chooses which `od` draws the nonce; `IFS` changes how the wrapper +/// reads its own pids; every `LD_*` runs attacker code inside `od` itself. Refused as a family +/// rather than a list, because the loader's set is longer than anything kept here would be. The +/// same names per command are safe — those travel through `env` and reach only the command. fn checked_session_env(operation: &str, env: &BTreeMap) -> Result<()> { for name in env.keys() { checked_env_name(operation, name)?; - if SESSION_ENV_REFUSED.contains(&name.as_str()) { + if matches!(name.as_str(), "PATH" | "IFS") || name.starts_with("LD_") { return Err(AlienError::new(ErrorData::InvalidInput { operation_context: operation.to_string(), details: format!( @@ -1753,7 +1761,10 @@ mod tests { /// The other tests here assert the shape of the string. This one runs it, because the shape /// can be exactly what was intended and still not execute: `env` reads operands as /// assignments until one is not, so a separator in the wrong place becomes the program name. - /// A stand-in `setsid` is supplied because macOS ships none. + /// + /// A stand-in `setsid` is supplied because macOS ships none, and it only `exec`s — it starts + /// no session. So this pins that the command runs and the variable arrives; it says nothing + /// about the kill, which needs a real `setsid` and a real process group. #[test] #[cfg(unix)] fn the_wrapper_this_builds_runs_with_the_variable_set() { @@ -1820,7 +1831,17 @@ mod tests { /// names on a command are fine, because those reach only the command. #[tokio::test] async fn a_session_cannot_set_what_the_deadline_wrapper_reads() { - for name in ["PATH", "IFS", "LD_PRELOAD", "LD_LIBRARY_PATH"] { + // `LD_AUDIT` is the one that proves the family has to go as a family: it runs attacker + // code inside `od`, which is what draws the nonce the deadline report rests on. + for name in [ + "PATH", + "IFS", + "LD_PRELOAD", + "LD_LIBRARY_PATH", + "LD_AUDIT", + "LD_DEBUG", + "LD_BIND_NOW", + ] { let mut client = MockSandboxDataPlaneApi::new(); client.expect_create_sandbox().never(); @@ -1857,6 +1878,31 @@ mod tests { .expect("an ordinary variable is still carried"); } + /// A command with no program is refused rather than run. + /// + /// `env` with assignments and no operand prints the environment it was given and exits 0, so + /// an empty command would hand the caller the session's own variables and read as a command + /// that succeeded. + #[tokio::test] + async fn a_command_naming_no_program_is_refused() { + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_get_sandbox() + .returning(|_, id| Ok(running(id, None))); + client.expect_execute_shell_command().never(); + + let mut request = command(5); + request.command = Vec::new(); + request.env = BTreeMap::from([("SECRET".to_string(), "hunter2".to_string())]); + + let error = match sandbox_with(client).run_command("s1", request).await { + Ok(_) => panic!("a command with no program must not run"), + Err(error) => error, + }; + + assert_eq!(error.code, "INVALID_INPUT", "{error}"); + } + /// A program whose own name carries `=` is refused when the call also declares variables. /// /// `env` reads operands as assignments until one is not, so such a name would be taken as a From 1d0a1c6f97ea8a4197d2786787b5f11017fbbd18 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:55:58 +0300 Subject: [PATCH 26/32] fix(sandbox): find the deadline report by its shape, not its position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bash imports `SHELLOPTS` from the environment and applies what it lists, including tracing, even when it is called `sh`. The wrapper's own trace then occupies the first line of stderr, the announcement is not where the reader looked for it, and every command in that session comes back as one the session could not bound — the ones that succeeded included. The reader now takes the first line that is a nonce and nothing else. The trace cannot be mistaken for it: a traced line carries the shell's prefix, and bash does not take that prefix from the environment. What precedes the announcement is dropped rather than returned, because it was written before the command started and one of those lines is the trace of the announcement itself. The width is checked exactly. A single hex character on a line of its own was an announcement, which made most of the stream its own repeat. `SHELLOPTS` and `BASHOPTS` join the names a session may not set. The two answers are deliberate: one keeps a name nobody listed from breaking the report, the other keeps the ones we know about out of the session. --- .../src/providers/sandbox/azure.rs | 21 ++++-- .../src/providers/sandbox/local.rs | 5 +- .../src/providers/sandbox/mod.rs | 67 ++++++++++++++++--- 3 files changed, 78 insertions(+), 15 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 05f3ca60f..a4aac18e0 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -979,13 +979,17 @@ fn bounded_shell( /// The wrapper that holds a command to its deadline runs inside the session and inherits its /// environment, so a name that changes how a shell resolves, splits, or loads hands the command a /// deadline it can forge. `PATH` chooses which `od` draws the nonce; `IFS` changes how the wrapper -/// reads its own pids; every `LD_*` runs attacker code inside `od` itself. Refused as a family -/// rather than a list, because the loader's set is longer than anything kept here would be. The -/// same names per command are safe — those travel through `env` and reach only the command. +/// reads its own pids; every `LD_*` runs attacker code inside `od` itself; `SHELLOPTS` turns on +/// tracing in a `sh` that is really bash. Refused as families where they are one, because a list +/// of names is a list of the ones somebody remembered — and `DeadlineReport::read` finds its +/// announcement by shape for the same reason, so a name missed here is noise rather than failure. +/// The same names per command are safe — those travel through `env` and reach only the command. fn checked_session_env(operation: &str, env: &BTreeMap) -> Result<()> { for name in env.keys() { checked_env_name(operation, name)?; - if matches!(name.as_str(), "PATH" | "IFS") || name.starts_with("LD_") { + if matches!(name.as_str(), "PATH" | "IFS" | "SHELLOPTS" | "BASHOPTS") + || name.starts_with("LD_") + { return Err(AlienError::new(ErrorData::InvalidInput { operation_context: operation.to_string(), details: format!( @@ -1566,7 +1570,10 @@ mod tests { const DEADLINE_PLACEHOLDER: &str = ""; /// The nonce a session would draw. Announced on the first line of stderr, and repeated by /// the killer, exactly as the wrapper does. - const SESSION_NONCE: &str = "a1b2c3d4"; + /// The width the wrapper draws — `od -N16` is 16 bytes, so 32 hex digits. Short of that is + /// not an announcement, and a fixture that used a short one pinned a weaker rule than the + /// session's. + const SESSION_NONCE: &str = "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4"; /// Wraps a scripted stderr the way a bounded session would return it. fn as_session_stderr(stderr: &str) -> String { @@ -1841,6 +1848,8 @@ mod tests { "LD_AUDIT", "LD_DEBUG", "LD_BIND_NOW", + "SHELLOPTS", + "BASHOPTS", ] { let mut client = MockSandboxDataPlaneApi::new(); client.expect_create_sandbox().never(); @@ -3252,7 +3261,7 @@ mod tests { exit_code: Some(0), stdout: String::new(), // The wrapper announces its nonce before starting the command. - stderr: "beef\n".to_string(), + stderr: "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\n".to_string(), }) }); diff --git a/crates/alien-bindings/src/providers/sandbox/local.rs b/crates/alien-bindings/src/providers/sandbox/local.rs index 9620e11ed..b0e8ffbb4 100644 --- a/crates/alien-bindings/src/providers/sandbox/local.rs +++ b/crates/alien-bindings/src/providers/sandbox/local.rs @@ -510,7 +510,10 @@ mod tests { const DEADLINE_PLACEHOLDER: &str = ""; /// The nonce a session would draw. Announced on the first line of stderr, and repeated by /// the killer, exactly as the wrapper does. - const SESSION_NONCE: &str = "a1b2c3d4"; + /// The width the wrapper draws — `od -N16` is 16 bytes, so 32 hex digits. Short of that is + /// not an announcement, and a fixture that used a short one pinned a weaker rule than the + /// session's. + const SESSION_NONCE: &str = "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4"; /// Wraps a scripted stderr the way a bounded session would return it. fn as_session_stderr(stderr: &str) -> String { diff --git a/crates/alien-bindings/src/providers/sandbox/mod.rs b/crates/alien-bindings/src/providers/sandbox/mod.rs index 415c977e0..8783fe2c9 100644 --- a/crates/alien-bindings/src/providers/sandbox/mod.rs +++ b/crates/alien-bindings/src/providers/sandbox/mod.rs @@ -59,6 +59,13 @@ pub(crate) const DEADLINE_GRACE: std::time::Duration = std::time::Duration::from #[cfg(any(feature = "azure", feature = "local"))] pub(crate) struct DeadlineReport; +/// Hex digits in the nonce the wrapper draws: `od -N16` reads 16 bytes. +/// +/// Checked exactly, so a single stray hex character on a line of its own cannot be read as an +/// announcement and turn the rest of the stream into its own repeat. +#[cfg(any(feature = "azure", feature = "local"))] +const NONCE_HEXITS: usize = 32; + #[cfg(any(feature = "azure", feature = "local"))] impl DeadlineReport { /// The shell program that runs a command under this deadline. @@ -110,11 +117,24 @@ impl DeadlineReport { /// because only the session knows the value. Whether that signal ended the command is the /// status's to say. pub(crate) fn read(exit_code: Option, stderr: &str) -> Bounded { - let announced = stderr - .split_once('\n') - .filter(|(nonce, _)| !nonce.is_empty() && nonce.chars().all(|c| c.is_ascii_hexdigit())); - - let Some((nonce, rest)) = announced else { + // The first line that is a nonce and nothing else, rather than the first line: a shell + // asked to trace itself writes its own lines before this one, and they displace an + // announcement that has to be found for the report to mean anything. Unforgeable either + // way — the session writes it before the command starts, and a traced line carries the + // shell's prefix, so nothing the command chose can be read as the announcement. + let announced = stderr.split('\n').enumerate().find_map(|(index, line)| { + let is_nonce = line.len() == NONCE_HEXITS && line.chars().all(|c| c.is_ascii_hexdigit()); + is_nonce.then(|| { + let after = stderr + .split('\n') + .skip(index + 1) + .collect::>() + .join("\n"); + (line, after) + }) + }); + + let Some((nonce, rest)) = announced.as_ref().map(|(n, r)| (*n, r.as_str())) else { // No announcement means the wrapper exited before starting anything, so nothing of // the caller's ran and nothing about a deadline can be claimed. return Bounded::NotRun { @@ -224,7 +244,7 @@ mod tests { #[test] fn only_the_session_can_report_a_deadline() { // The shell writes its own notice after the signal, so the repeat is not always last. - let killed = match DeadlineReport::read(Some(137), "abc123\nboom\nabc123Killed\n") { + let killed = match DeadlineReport::read(Some(137), "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\nboom\na1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4Killed\n") { Bounded::Ran { killed, stderr } => { assert_eq!(stderr, "boom\nKilled\n"); killed @@ -235,7 +255,7 @@ mod tests { // A command echoing something nonce-shaped repeats nothing the session announced. assert!(matches!( - DeadlineReport::read(Some(0), "abc123\nboom\ndeadbeef\n"), + DeadlineReport::read(Some(0), "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\nboom\ndeadbeef\n"), Bounded::Ran { killed: false, .. } )); } @@ -256,12 +276,43 @@ mod tests { assert!(reason.contains("could not start"), "{reason}"); } + /// The announcement survives a shell that writes before it, and a short hex line is not one. + /// + /// A `sh` that is really bash turns on tracing from `SHELLOPTS` in its environment and writes + /// its own lines first. Reading only line 1 lost the announcement there and reported every + /// command — including the ones that succeeded — as never bounded. The width is checked + /// exactly, so a stray hex fragment on a line of its own cannot stand in for it. + #[test] + fn the_announcement_is_found_by_shape_rather_than_by_position() { + let traced = format!("+ unset nonce command_pid\n+ printf\na1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\nboom\na1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4Killed\n"); + let Bounded::Ran { killed, stderr } = DeadlineReport::read(Some(137), &traced) else { + panic!("the trace must not hide the announcement"); + }; + assert!(killed, "the killer's repeat still reports the kill"); + assert_eq!( + stderr, "boom\nKilled\n", + "what precedes the announcement was written before the command started, so it is the \ + session's own noise rather than the command's — and one of those lines is the trace \ + of the announcement itself" + ); + + // Short of the width the session draws, so not an announcement — and the rest of the + // stream is not its repeat. + assert!( + matches!( + DeadlineReport::read(Some(137), "ab\nboom\nabc\n"), + Bounded::NotRun { .. } + ), + "a hex fragment is not a nonce" + ); + } + /// A command that finished as the killer fired keeps its own result. `kill` succeeds on a /// process that has exited and is not yet reaped, so the repeat alone would turn a command /// that beat its deadline into a deadline failure and throw away what it returned. #[test] fn a_command_that_finished_as_the_killer_fired_keeps_its_result() { - let Bounded::Ran { killed, stderr } = DeadlineReport::read(Some(0), "abc123\nboom\nabc123") + let Bounded::Ran { killed, stderr } = DeadlineReport::read(Some(0), "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\nboom\na1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4") else { panic!("the command ran"); }; From da08622c121bc09e728de28a2575d3a657880ca5 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:55:16 +0300 Subject: [PATCH 27/32] docs(sandbox): say what the deadline wrapper does not survive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session runs its command as root on a writable filesystem, so code in it can replace the `od` that draws the nonce and hand every later command a value it chose, or kill the killer — a sibling with the same uid — and outlast its deadline. Both measured against real shells. The doc claimed the nonce was unreachable and left the rest implied. So the wrapper bounds a command that is merely slow and reports honestly on one that is. It is not a boundary against a command working to escape one; the caller-side guard, which ends the session, is that. The process-group note is narrowed to match what it delivers: a child that starts a session of its own leaves the group and outlives the kill. The reader also trims a trailing carriage return. None of the transports here produce one, but a 33-byte announcement is invisible and the first line the command chose gets adopted in its place — an inversion resting on a property of today's transports rather than on anything checked. --- .../src/providers/sandbox/mod.rs | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/mod.rs b/crates/alien-bindings/src/providers/sandbox/mod.rs index 8783fe2c9..45aad507f 100644 --- a/crates/alien-bindings/src/providers/sandbox/mod.rs +++ b/crates/alien-bindings/src/providers/sandbox/mod.rs @@ -73,8 +73,10 @@ impl DeadlineReport { /// The command arrives as `"$@"`, so nothing re-parses its text. It is started in a session /// of its own so the kill reaches its process group rather than one pid: a command that /// spawned children would otherwise leave them running while the caller is told the deadline - /// contained it, which is the claim this path exists to make good on. An image that cannot do - /// that runs nothing — a deadline that cannot be enforced is refused, not approximated. + /// contained it. A child that starts a session of its own leaves that group and outlives the + /// kill — measured — so this covers what the command left behind, not what it moved away. An + /// image that cannot start a session runs nothing: a deadline that cannot be enforced at all + /// is refused rather than approximated. /// /// The killer repeats the nonce when its signal was delivered, which the status has to confirm: /// a command already exited and awaiting reaping takes the signal too. Once the command is @@ -123,6 +125,10 @@ impl DeadlineReport { // way — the session writes it before the command starts, and a traced line carries the // shell's prefix, so nothing the command chose can be read as the announcement. let announced = stderr.split('\n').enumerate().find_map(|(index, line)| { + // A carriage return would make the announcement 33 bytes and invisible, and the first + // line the command chose would be adopted in its place. No transport here delivers + // one today; the cost of not depending on that is one trim. + let line = line.strip_suffix('\r').unwrap_or(line); let is_nonce = line.len() == NONCE_HEXITS && line.chars().all(|c| c.is_ascii_hexdigit()); is_nonce.then(|| { let after = stderr @@ -296,6 +302,17 @@ mod tests { of the announcement itself" ); + // A carriage return does not hide the announcement, which would otherwise let the first + // line the command chose stand in for it. + let crlf = format!("a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\r\nboom\r\na1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4Killed\r\n"); + assert!( + matches!( + DeadlineReport::read(Some(137), &crlf), + Bounded::Ran { killed: true, .. } + ), + "a carriage return is not part of the nonce" + ); + // Short of the width the session draws, so not an announcement — and the rest of the // stream is not its repeat. assert!( From a1e08abfe8be38ab1bcda223cfa312f31a14cfcd Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:14:49 +0300 Subject: [PATCH 28/32] docs(sandbox): a refused session is left as it was found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The note in `get_or_create` said such a session is left running. It is left asleep when this call is what woke it, because `put_back` suspends what it woke — which is what `reconnect`'s own doc and the test beside it already said. Three statements of one rule, and this was the one that disagreed. --- crates/alien-bindings/src/providers/sandbox/azure.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index a4aac18e0..12705845f 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -239,8 +239,9 @@ impl Sandbox for AzureSandbox { // The two ways an id can fail to serve — gone, or running a policy the // declaration no longer matches — mean the same thing to a caller asking for a // session, and are answered the same way: a fresh one. A session refused for its - // policy is left running: it may be another revision's, and this caller is served - // by the replacement rather than by taking theirs. + // policy is left as it was found — asleep again if this call woke it — because it + // may be another revision's, and this caller is served by the replacement rather + // than by taking theirs. // // Narrow on purpose: a readiness timeout says the data plane is slow, and // answering that by creating a second sandbox makes it slower. From b6179a7e8f2689a557da0fc7f01197753646bce7 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:57:38 +0300 Subject: [PATCH 29/32] fix(sandbox): send a state transition once, like the verbs beside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop and resume move a sandbox between states, so a repeat is refused for the state the first attempt produced. A transition that took effect and lost its response was re-sent, the re-send answered 409, and the caller was told a session it had suspended was still awake — the false `sandboxLeftAwake` report the put-back exists to make trustworthy. They join create and exec on the single-attempt path. The wait above them already re-issues a resume itself, with the state in front of it, so the transport had no business guessing. The test that pins create now pins these too; it did not before, which is why they were classified as safe to repeat. --- .../src/azure/sandbox_data_plane.rs | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs index 3c826b8a8..6932191af 100644 --- a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs +++ b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs @@ -334,6 +334,10 @@ impl AzureSandboxDataPlaneClient { } /// A bodyless POST that moves a sandbox between states. + /// + /// Sent once. A transition that took effect and lost its response would be repeated, and the + /// repeat refused for the state the first one produced — reporting a failure for work that + /// succeeded. The wait above this re-issues a resume itself, with the state in front of it. async fn lifecycle_action( &self, group: &str, @@ -350,7 +354,7 @@ impl AzureSandboxDataPlaneClient { let request = AzureRequestBuilder::new(Method::POST, url).build()?; let signed = self.base.sign_request(request, &token).await?; self.base - .execute_request(signed, operation, sandbox_id) + .execute_request_once(signed, operation, sandbox_id) .await?; Ok(()) } @@ -688,6 +692,24 @@ mod tests { "a read is safe to repeat and must keep its retry: {} attempt(s)", read.hits() ); + + // A state transition is not safe to repeat either. If the stop takes effect and its + // response is lost, the repeat is refused for the state the first one produced — and the + // caller is told a session it did suspend is still awake. + let stop = server.mock(|when, then| { + when.method(httpmock::Method::POST).path_contains("/stop"); + then.status(503).body("{}"); + }); + client + .stop_sandbox("grp", "s1") + .await + .expect_err("an unavailable data plane fails the stop"); + + assert_eq!( + stop.hits(), + 1, + "a transition that may already have happened must not be sent twice" + ); } /// Pinned because the contract came from a preview SDK Microsoft says may change. If these From 1ca4f5a9d79935461fb3f1bfbf522e7087d035f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=AAItamar=20Zand=E2=80=AC=E2=80=8F?= <133867530+ItamarZand88@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:50:53 +0300 Subject: [PATCH 30/32] feat(sandbox): GCP Agent Platform sandbox backend, replacing Cloud Run (#478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds the GCP sandbox backend on Gemini Agent Platform — sessions are `sandboxEnvironments` created under a durable reasoning engine and reached through the `:execute` proxy — and cuts GCP over to it, removing the Cloud Run launcher implementation entirely. The backend carries files, egress-deny, suspend/resume, snapshot, and enforced-limits; the Cloud Run subprocess model (and its binding, launcher preflight, and `Worker.sandboxLauncher`) is gone. When a GCP sandbox runs, this is what happens: 1. At deploy, a preflight mutation synthesizes one reasoning engine per sandbox; its controller creates the engine through the Vertex API and records the server-assigned id. 2. The template controller creates the immutable `SandboxEnvironmentTemplate` under that engine (image, ceilings, egress switch) and waits for it to become `ACTIVE`. 3. The binding (engine, template, region) reaches the runtime provider, which builds an Agent Platform client against the binding's own region. 4. **A session is created as a `sandboxEnvironment` under the engine, and every command, file operation, and health check flows as one envelope over the `:execute` proxy.** ← the heart 5. suspend / resume / snapshot map to the platform's own verbs; teardown deletes the session, then the template, then the engine, in dependency order. This moves the GCP sandbox backend from a Cloud Run subprocess launcher to a durable Agent Platform resource. ## What I added - The agent protocol surface: an explicit envelope on `POST /`, detached pollable jobs for long commands, and a declared isolation model. - The `gcp-clients` Agent Platform client (engines, templates, sessions, `:execute`, lifecycle). - The binding, capability row, provider, and template controller for the backend. - A `GcpAgentPlatformEngine` resource: a preflight mutation + a state-machine controller that creates the reasoning engine, persists its id, and deletes it at teardown; the template controller reads that id as a dependency. - The GCP `aiplatform` permission grants (provision vs management split). ## What I removed (the cutover) The Cloud Run sandbox provider, binding (`sandbox-gcp` → `sandbox-gcp-agent-platform`), Terraform emitter, launcher preflight mutation, host-required check, `Worker.sandboxLauncher`, and the `GcpSandboxImportData` type. The shared Cloud Run *worker* client stays. No compatibility path — the sandbox feature has no users yet. ## How I tested - Unit + integration suites across alien-core, alien-infra, alien-preflights, alien-terraform, alien-bindings, alien-gcp-clients — green (the live-GCP Firestore/KV `case_4_gcp` tests fail only on the shared-project database quota, which is environmental). - The engine controller's create → poll → record-id → delete state machine, and a serialize↔deserialize round-trip guard test for both new controllers (the executor reloads controller state between reconciles; a missing by-tag arm fails above the handler layer). - The Agent Platform backend validated live on GCP earlier (health, exec, suspend/resume, snapshot, egress-deny), and the shared agent changes validated on a real AWS Firecracker MicroVM. Security review for this diff: - **Egress** — a declared `deny` sets the template's internet-access switch off; `AllowDomains` is refused at plan time and again in the controller, never coerced to a boolean. - **Permissions** — provision grants `reasoningEngines` create/delete + `sandboxEnvironmentTemplates` CRUD with no `sandboxEnvironments` verb, so provisioning cannot reach a live session; the per-session verbs live in the management set. Validated against the GCP IAM permission dataset. - **Regional addressing** — the client is built against the binding's own region, not the deployment's, so a session is signed against the correct regional endpoint. - **Secrets** — the template carries no per-session environment; env travels per command, because the command shares the agent's uid and could read anything placed in the container env. - **Retry-safety** — `create_engine` / `create_template` are single-attempt; a lost response fails to `CreateFailed` rather than creating a duplicate. - Nothing turned up. ## Breaking changes - The GCP sandbox binding shape changes (`sandbox-gcp` → `sandbox-gcp-agent-platform`); an already-deployed GCP sandbox stack must re-provision. No users yet, so no migration. - `Worker.sandboxLauncher` is removed from the public `@alienplatform/core` `Worker` type. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Cargo.lock | 1 + crates/alien-bindings-node/src/sandbox.rs | 2 + crates/alien-bindings/src/provider.rs | 49 +- .../sandbox/fixtures/gcp-sandbox-cli-help.txt | 207 --- .../src/providers/sandbox/gcp.rs | 822 --------- .../providers/sandbox/gcp_agent_platform.rs | 1298 ++++++++++++++ .../sandbox/gcp_agent_platform_tests.rs | 1059 ++++++++++++ .../src/providers/sandbox/mod.rs | 2 +- crates/alien-build/src/sandbox_bundle.rs | 29 +- crates/alien-core/src/bin/schema_exporter.rs | 1 - crates/alien-core/src/bindings/mod.rs | 4 +- crates/alien-core/src/bindings/sandbox.rs | 105 +- crates/alien-core/src/import/data/gcp/mod.rs | 2 - .../alien-core/src/import/data/gcp/sandbox.rs | 19 - crates/alien-core/src/import/data/mod.rs | 3 +- ...schema_snapshots__import_data_schemas.snap | 20 - crates/alien-core/src/resource.rs | 5 + crates/alien-core/src/resource_links.rs | 1 + .../resources/gcp_agent_platform_engine.rs | 95 ++ crates/alien-core/src/resources/mod.rs | 3 + crates/alien-core/src/resources/sandbox.rs | 218 ++- crates/alien-core/src/resources/worker.rs | 9 - .../src/gcp/agent_platform.rs | 1504 +++++++++++++++++ .../alien-gcp-clients/src/gcp/api_client.rs | 36 + crates/alien-gcp-clients/src/gcp/cloudrun.rs | 41 - .../src/gcp/gcp_request_utils.rs | 18 + crates/alien-gcp-clients/src/gcp/mod.rs | 1 + crates/alien-gcp-clients/src/lib.rs | 4 + crates/alien-infra/src/core/controller.rs | 8 + crates/alien-infra/src/core/registry.rs | 20 + .../alien-infra/src/core/service_provider.rs | 28 +- .../src/sandbox/gcp_agent_platform_engine.rs | 398 +++++ .../sandbox/gcp_agent_platform_template.rs | 1156 +++++++++++++ crates/alien-infra/src/sandbox/mod.rs | 14 +- crates/alien-infra/src/worker/gcp.rs | 75 +- .../permission-sets/sandbox/execute.jsonc | 18 + .../permission-sets/sandbox/heartbeat.jsonc | 17 + .../permission-sets/sandbox/management.jsonc | 28 +- .../permission-sets/sandbox/provision.jsonc | 27 + .../tests/gcp_sensitive_invariant.rs | 38 + .../tests/operation_coverage.rs | 17 +- .../tests/permission_set_validation.rs | 23 +- .../alien-preflights/src/compile_time/mod.rs | 1 - .../src/compile_time/sandbox_host_required.rs | 197 --- .../compile_time/sandbox_platform_support.rs | 6 +- crates/alien-preflights/src/lib.rs | 5 +- .../mutations/gcp_agent_platform_engine.rs | 206 +++ .../src/mutations/gcp_sandbox_launcher.rs | 157 -- .../src/mutations/gcp_service_activation.rs | 9 + crates/alien-preflights/src/mutations/mod.rs | 4 +- .../tests/sandbox_platform_gate.rs | 15 +- crates/alien-sandbox-agent/Cargo.toml | 1 + crates/alien-sandbox-agent/src/confine.rs | 312 ++-- crates/alien-sandbox-agent/src/error.rs | 26 + crates/alien-sandbox-agent/src/jobs.rs | 763 +++++++++ crates/alien-sandbox-agent/src/lib.rs | 1 + crates/alien-sandbox-agent/src/main.rs | 222 ++- crates/alien-sandbox-agent/src/server.rs | 290 +++- crates/alien-sandbox-agent/tests/protocol.rs | 400 ++++- crates/alien-terraform/src/built_ins.rs | 2 +- .../alien-terraform/src/emitters/gcp/mod.rs | 2 +- .../src/emitters/gcp/sandbox.rs | 289 +++- .../tests/gcp_agent_platform_sandbox_live.rs | 863 ++++++++++ packages/core/src/generated/index.ts | 2 - .../schemas/gcpSandboxImportData.json | 1 - .../schemas/sandboxCapabilities.json | 2 +- .../generated/schemas/sandboxCapability.json | 2 +- .../core/src/generated/schemas/worker.json | 2 +- .../zod/gcp-sandbox-import-data-schema.ts | 16 - packages/core/src/generated/zod/index.ts | 2 - .../zod/sandbox-capabilities-schema.ts | 1 + .../zod/sandbox-capability-schema.ts | 2 +- .../core/src/generated/zod/worker-schema.ts | 1 - packages/core/src/sandbox.ts | 5 +- 74 files changed, 9296 insertions(+), 1936 deletions(-) delete mode 100644 crates/alien-bindings/src/providers/sandbox/fixtures/gcp-sandbox-cli-help.txt delete mode 100644 crates/alien-bindings/src/providers/sandbox/gcp.rs create mode 100644 crates/alien-bindings/src/providers/sandbox/gcp_agent_platform.rs create mode 100644 crates/alien-bindings/src/providers/sandbox/gcp_agent_platform_tests.rs delete mode 100644 crates/alien-core/src/import/data/gcp/sandbox.rs create mode 100644 crates/alien-core/src/resources/gcp_agent_platform_engine.rs create mode 100644 crates/alien-gcp-clients/src/gcp/agent_platform.rs create mode 100644 crates/alien-infra/src/sandbox/gcp_agent_platform_engine.rs create mode 100644 crates/alien-infra/src/sandbox/gcp_agent_platform_template.rs delete mode 100644 crates/alien-preflights/src/compile_time/sandbox_host_required.rs create mode 100644 crates/alien-preflights/src/mutations/gcp_agent_platform_engine.rs delete mode 100644 crates/alien-preflights/src/mutations/gcp_sandbox_launcher.rs create mode 100644 crates/alien-sandbox-agent/src/jobs.rs create mode 100644 crates/alien-test/tests/gcp_agent_platform_sandbox_live.rs delete mode 100644 packages/core/src/generated/schemas/gcpSandboxImportData.json delete mode 100644 packages/core/src/generated/zod/gcp-sandbox-import-data-schema.ts diff --git a/Cargo.lock b/Cargo.lock index 06d34a614..f53c9154c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1060,6 +1060,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "uuid", ] [[package]] diff --git a/crates/alien-bindings-node/src/sandbox.rs b/crates/alien-bindings-node/src/sandbox.rs index d2e25222c..067206881 100644 --- a/crates/alien-bindings-node/src/sandbox.rs +++ b/crates/alien-bindings-node/src/sandbox.rs @@ -222,6 +222,7 @@ impl SandboxHandle { process_limit, session_lifetime, supervisor_pid_namespace, + supervisor_isolation, } = self.inner.capabilities(); [ @@ -234,6 +235,7 @@ impl SandboxHandle { (process_limit, "processLimit"), (session_lifetime, "sessionLifetime"), (supervisor_pid_namespace, "supervisorPidNamespace"), + (supervisor_isolation, "supervisorIsolation"), ] .into_iter() .filter(|(supported, _)| *supported) diff --git a/crates/alien-bindings/src/provider.rs b/crates/alien-bindings/src/provider.rs index 1099a3ee3..bc644b19c 100644 --- a/crates/alien-bindings/src/provider.rs +++ b/crates/alien-bindings/src/provider.rs @@ -1848,11 +1848,52 @@ impl BindingsProviderApi for BindingsProvider { Ok(sandbox) } #[cfg(feature = "gcp")] - SandboxBinding::Gcp(gcp_binding) => { - use crate::providers::sandbox::gcp::GcpSandbox; + SandboxBinding::GcpAgentPlatform(gcp_binding) => { + use crate::providers::sandbox::gcp_agent_platform::GcpAgentPlatformSandbox; + use alien_gcp_clients::agent_platform::AgentPlatformClient; + let gcp_config = self.client_config.gcp_config().ok_or_else(|| { + AlienError::new(ErrorData::ClientConfigInvalid { + platform: Platform::Gcp, + message: "GCP config not available".to_string(), + }) + })?; + + let engine = gcp_binding + .engine + .into_value(binding_name, "engine") + .context(ErrorData::config_invalid( + binding_name, + "Failed to resolve engine from the Agent Platform sandbox binding", + ))?; + let template = gcp_binding + .template + .into_value(binding_name, "template") + .context(ErrorData::config_invalid( + binding_name, + "Failed to resolve template from the Agent Platform sandbox binding", + ))?; + let region = gcp_binding + .region + .into_value(binding_name, "region") + .context(ErrorData::config_invalid( + binding_name, + "Failed to resolve region from the Agent Platform sandbox binding", + ))?; + + // The engine is regional with no global alias, so the endpoint is built from the + // binding's region, not the deployment's — signing against the wrong one 404s. + let mut config = gcp_config.clone(); + config.region = region; + + let client = AgentPlatformClient::new(reqwest::Client::new(), config); let sandbox: Arc = - Arc::new(GcpSandbox::new(binding_name, &gcp_binding)?); + Arc::new(GcpAgentPlatformSandbox::new( + Arc::new(client), + engine, + template, + gcp_binding.session_ttl_seconds, + )); Ok(sandbox) } #[cfg(feature = "azure")] @@ -2021,7 +2062,7 @@ impl BindingsProviderApi for BindingsProvider { #[cfg(not(feature = "azure"))] SandboxBinding::Azure(_) => Err(not_built("azure")), #[cfg(not(feature = "gcp"))] - SandboxBinding::Gcp(_) => Err(not_built("gcp")), + SandboxBinding::GcpAgentPlatform(_) => Err(not_built("gcp")), #[cfg(not(feature = "kubernetes"))] SandboxBinding::Kubernetes(_) => Err(not_built("kubernetes")), #[cfg(not(feature = "local"))] diff --git a/crates/alien-bindings/src/providers/sandbox/fixtures/gcp-sandbox-cli-help.txt b/crates/alien-bindings/src/providers/sandbox/fixtures/gcp-sandbox-cli-help.txt deleted file mode 100644 index 66838fd0e..000000000 --- a/crates/alien-bindings/src/providers/sandbox/fixtures/gcp-sandbox-cli-help.txt +++ /dev/null @@ -1,207 +0,0 @@ -# Captured from a live Cloud Run service with sandboxLauncher enabled, 2026-08-21. -# -# The reference at docs.cloud.google.com/run/docs/reference/sandbox-cli lists six verbs; this -# build has eight (completion, help are undocumented). That page says to run `sandbox -h` for -# the complete list, and it is right to. -# -# Kept so that "the launcher has no X verb" in gcp.rs is a citation rather than an assertion. -# Re-capture by running `sandbox -h`, then `sandbox -h` for each verb, inside a Cloud -# Run container deployed with --sandbox-launcher. -# --------------------------------------------------------------------------------------------- - - -===== ENVIRONMENT ===== -launcher path: /usr/local/gcp/bin/sandbox -RESULT launcher_present=yes -nproc=5 mem=4010112kB - -===== sandbox -h ===== -Serverless sandboxing CLI, providing compartmentalized execution for commands. - -Usage: - sandbox [command] - -Available Commands: - completion Generate the autocompletion script for the specified shell - delete Delete a sandbox - do Execute the specified command in a sandbox - exec Execute a command in an existing sandbox session - fork Fork a running sandbox to a new one. - help Help about any command - run Start a new sandbox. - tar Export a tarfile of the writable overlay (rootfs-upper) of a running sandbox - -Flags: - -h, --help help for sandbox - -Use "sandbox [command] --help" for more information about a command. - -===== sandbox do -h ===== -The do command provides support for executing a command in a sandbox without having to think about sandbox lifecycle management. A new sandbox will be created and destroyed for each execution, optionally persisting the state of the filesystem to a persistence directory between executions. This command blocks until the command and sandbox lifecycle completes. - -Usage: - sandbox do [flags] [command-to-execute] - -Flags: - --allow-egress Allow egress for this sandbox - -e, --env string Environment variables to set in the sandbox - --export-tar string The tarball to export rootfs-upper to on exit - -h, --help help for do - --import-tar string The tarball to import rootfs-upper from - --mount string Mounts for the sandbox - -p, --publish string Ports to expose from the sandbox - --rootfs string Run the command using the root of the executing container as the root directory of the sandbox. By default, this mount is read-only (default "/") - --sandbox-name string The ID to use for the sandbox; if not specified, a random ID will be generated - --stderr Wire the stderr pipe of the sandbox command to the stderr of the process executing in the sandbox (default true) - --stdin Wire the stdin pipe of the sandbox command to the stdin of the process executing in the sandbox (default true) - --stdout Wire the stdout pipe of the sandbox command to the stdout of the process executing in the sandbox (default true) - --sync-tar string The tarball to use for keeping the filesystem in sync (import if exists, export on exit) - --template-var string Template variables to set in the sandbox (format: KEY=VALUE) - -w, --workdir string The working directory to execute the command in - --write Allow filesystems that have been mounted to be writable by this sandbox - -===== sandbox run -h ===== -The run command creates and starts a sandbox. If no command is specified, an empty sandbox will be started. The command blocks until the container has started. - -Usage: - sandbox run [command-to-execute] [flags] - -Flags: - --allow-egress Allow egress for this sandbox. - --detach Detach the sandbox from the console - -e, --env string Environment variables to set in the sandbox - -h, --help help for run - --import-tar string The tarball to import rootfs-upper from - --mount string Mounts for the sandbox - -p, --publish string Ports to expose from the sandbox - --rootfs string Run the command using the root of the executing container as the root directory of the sandbox. (default "/") - --stderr Wire the stderr pipe of the sandbox command to the stderr of the process executing in the sandbox (default true) - --stdin Wire the stdin pipe of the sandbox command to the stdin of the process executing in the sandbox (default true) - --stdout Wire the stdout pipe of the sandbox command to the stdout of the process executing in the sandbox (default true) - --template-var string Template variables to set in the sandbox (format: KEY=VALUE) - -w, --workdir string The working directory to execute the command in. - --write Allow filesystems that have been mounted to be writable by this sandbox - -===== sandbox exec -h ===== -The exec command allows you to execute a command in a running sandbox. The sandbox must be running already, or the command will fail. - -Usage: - sandbox exec [args...] [flags] - -Flags: - -e, --env string Environment variables to set in the sandbox - -h, --help help for exec - --stderr Wire the stderr pipe of the sandbox command to the stderr of the process executing in the sandbox (default true) - --stdin Wire the stdin pipe of the sandbox command to the stdin of the process executing in the sandbox (default true) - --stdout Wire the stdout pipe of the sandbox command to the stdout of the process executing in the sandbox (default true) - -w, --workdir string The working directory to execute the command in - -===== sandbox fork -h ===== -Fork creates a new sandbox using the state and command line of a running source sandbox. - -Usage: - sandbox fork [flags] - -Flags: - --allow-egress Allow egress for this sandbox - --detach Detach the new sandbox from the console - -h, --help help for fork - -p, --publish string Ports to expose from the sandbox - --stderr Wire the stderr pipe of the sandbox command to the stderr of the process executing in the sandbox (default true) - --stdin Wire the stdin pipe of the sandbox command to the stdin of the process executing in the sandbox (default true) - --stdout Wire the stdout pipe of the sandbox command to the stdout of the process executing in the sandbox (default true) - --tar string The tarball from the source sandbox state with which the target sandbox was started - -===== sandbox tar -h ===== -The tar command creates a tarball of the writable overlay (rootfs-upper) of a sandbox container, containing all changes made in the sandbox. The tarball will capture all files and directories that differ from the rootfs. - -Usage: - sandbox tar [flags] - -Flags: - --file string The file to write the tarball to - -h, --help help for tar - --stderr Wire the stderr pipe of the sandbox command to the stderr of the process executing in the sandbox (default true) - --stdin Wire the stdin pipe of the sandbox command to the stdin of the process executing in the sandbox (default true) - --stdout Wire the stdout pipe of the sandbox command to the stdout of the process executing in the sandbox (default true) - -===== sandbox delete -h ===== -The delete command removes a sandbox and cleans up its resources. In the case of a running sandbox, the sandbox can be deleted by adding --force. - -Usage: - sandbox delete [flags] - -Flags: - --force Force delete the sandbox, even if it is running - -h, --help help for delete - --stderr Wire the stderr pipe of the sandbox command to the stderr of the process executing in the sandbox (default true) - --stdin Wire the stdin pipe of the sandbox command to the stdin of the process executing in the sandbox (default true) - --stdout Wire the stdout pipe of the sandbox command to the stdout of the process executing in the sandbox (default true) - -===== verbs this backend reports as absent ===== - suspend: absent -RESULT verb_suspend=absent - resume: absent -RESULT verb_resume=absent - list: absent -RESULT verb_list=absent - ps: absent -RESULT verb_ps=absent - snapshot: absent -RESULT verb_snapshot=absent - checkpoint: absent -RESULT verb_checkpoint=absent - restore: absent -RESULT verb_restore=absent - -===== create argv: --id versus the documented positional id ===== ---- ours: run --id poc-ours-14 --detach --- -Error: unknown flag: --id - -RESULT ours_argv_rc=0 ---- documented: run poc-doc-14 --detach --- -Running in detached mode: stdin, stdout and stderr arguments are ignored. -RESULT doc_argv_rc=0 ---- can each id be reached by exec? --- - 'poc-ours-14': not reachable -RESULT reachable_poc-ours-=no - 'poc-doc-14': REACHABLE -RESULT reachable_poc-doc-=yes - '--id': not reachable -RESULT reachable_--id=no - -===== does run without --detach block? ===== - rc=124 after 20s (rc=124 means it blocked until the timeout) -RESULT detach_needed=yes -RESULT nodetach_elapsed=20 - -===== does --env work? ===== - run --env then exec: [hello] -RESULT env_on_run=works - exec --env: [world] -RESULT env_on_exec=works - does a sandbox inherit the container's env? (Google says no) - [] -RESULT env_inherited=no - -===== tar export / import round trip ===== -Serializing rootfs upper layer into a tar archive for container: poc-tar-14, sandbox: poc-tar-14 - tar produced 2560 bytes -RESULT tar_export=yes - restored marker: Error: sandbox poc-restore-14 is not running -RESULT tar_import=no - -===== does a sandbox see the instance's CPU and memory? ===== - host: cpu=5 mem=4010112kB - sandbox: 5 4010112 -RESULT host_cpu=5 -RESULT sandbox_cpu_mem=5 4010112 - -===== CLEANUP ===== - deleted poc-ours-14 - deleted poc-doc-14 - deleted poc-nodet-14 - deleted poc-env-14 - deleted poc-tar-14 -PROBE-COMPLETE -PROBE-DONE diff --git a/crates/alien-bindings/src/providers/sandbox/gcp.rs b/crates/alien-bindings/src/providers/sandbox/gcp.rs deleted file mode 100644 index 4959feeb2..000000000 --- a/crates/alien-bindings/src/providers/sandbox/gcp.rs +++ /dev/null @@ -1,822 +0,0 @@ -//! GCP sandbox provider. -//! -//! A Cloud Run sandbox is a subprocess of the workload's own instance, created through a CLI on -//! the container's filesystem. There is no control plane to call, no credential to hold and no -//! capability to mint: the boundary is the launcher, and the launcher is already there. -//! -//! Every command is passed as argv rather than a shell string, including file paths and file -//! contents, so nothing a caller supplies is ever parsed by a shell. - -use std::collections::BTreeMap; -use std::time::Duration; - -use async_trait::async_trait; -use base64::engine::general_purpose::STANDARD as BASE64; -use base64::Engine as _; -use futures::stream::BoxStream; -use futures::StreamExt; -use tokio::sync::mpsc; - -use crate::error::{ErrorData, Result}; -use crate::traits::{ - Binding, CommandOutput, CreateSessionRequest, PreviewCapability, RunCommandRequest, Sandbox, - SandboxSession, SandboxSessionState, -}; -use alien_core::bindings::GcpSandboxBinding; -use alien_core::sandbox_process::{self, ProcessFrame, ProcessStream, FRAME_CHANNEL_DEPTH}; -use alien_core::{Platform, SandboxCapabilities}; -use alien_error::AlienError; - -/// Longest session id the launcher is asked to take, which is also a container name. -const MAX_SESSION_ID: usize = 63; - -/// How much of one command's output is kept before the terminal frame reports truncation. -const OUTPUT_CAP: usize = 8 * 1024 * 1024; - -/// Ceiling on a launcher call that is not the caller's command, such as a create or a delete. -const CONTROL_DEADLINE: Duration = Duration::from_secs(60); - -/// A Sandbox backed by the Cloud Run sandbox launcher. -#[derive(Debug)] -pub struct GcpSandbox { - launcher_path: String, - allow_egress: bool, - binding_name: String, -} - -impl GcpSandbox { - /// Builds a provider from its binding. - pub fn new(binding_name: &str, binding: &GcpSandboxBinding) -> Result { - let launcher_path = binding - .launcher_path - .clone() - .into_value(binding_name, "launcherPath") - .map_err(|error| { - AlienError::new(ErrorData::BindingConfigInvalid { - binding_name: binding_name.to_string(), - env_var: alien_core::bindings::binding_env_var_name(binding_name), - reason: error.to_string(), - }) - })?; - - let allow_egress = binding - .allow_egress - .clone() - .into_value(binding_name, "allowEgress") - .map_err(|error| { - AlienError::new(ErrorData::BindingConfigInvalid { - binding_name: binding_name.to_string(), - env_var: alien_core::bindings::binding_env_var_name(binding_name), - reason: error.to_string(), - }) - })?; - - Ok(Self { - launcher_path, - allow_egress, - binding_name: binding_name.to_string(), - }) - } - - /// Runs the launcher and returns its stdout, failing on a non-zero exit. - /// - /// Used for the control verbs. A caller's own command goes through [`Self::frames`] instead, - /// which streams rather than collecting. - async fn control(&self, operation: &str, arguments: &[String]) -> Result> { - let child = sandbox_process::spawn(&self.launcher_path, arguments) - .and_then(|mut command| command.spawn()) - .map_err(|error| { - self.failed(operation, &format!("launcher would not start: {error}")) - })?; - - let frames = sandbox_process::run(child, CONTROL_DEADLINE, OUTPUT_CAP).await; - - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - for frame in &frames { - match frame { - ProcessFrame::Output { - stream: ProcessStream::Stdout, - data, - .. - } => stdout.extend_from_slice(data), - ProcessFrame::Output { - stream: ProcessStream::Stderr, - data, - .. - } => stderr.extend_from_slice(data), - _ => {} - } - } - - match frames.last() { - Some(ProcessFrame::Exit { code: 0, .. }) => Ok(stdout), - // stderr, not the exit code alone: the launcher puts the actual cause there, and a - // bare status turns a specific failure into a guess. - Some(ProcessFrame::Exit { code, .. }) => Err(self.failed( - operation, - &format!( - "launcher exited with {code}: {}", - String::from_utf8_lossy(&stderr).trim() - ), - )), - Some(ProcessFrame::Failed { code, message }) => { - Err(self.failed(operation, &format!("{code}: {message}"))) - } - _ => Err(self.failed(operation, "launcher produced no terminal frame")), - } - } - - fn failed(&self, operation: &str, reason: &str) -> AlienError { - AlienError::new(ErrorData::OperationNotSupported { - operation: operation.to_string(), - reason: format!("{reason} (binding '{}')", self.binding_name), - }) - } - - fn unsupported(&self, capability: &str, reason: &str) -> AlienError { - AlienError::new(ErrorData::OperationNotSupported { - operation: capability.to_string(), - reason: reason.to_string(), - }) - } - - /// Refuses a path that traverses upward, the same lexical rule the in-sandbox agent applies. - fn checked_path(&self, path: &str, operation: &str) -> Result { - if path.is_empty() || path.split('/').any(|part| part == "..") { - return Err(self.failed(operation, &format!("path '{path}' traverses upward"))); - } - Ok(path.to_string()) - } - - /// Builds `sandbox exec -- `. - /// A session id the launcher cannot read as one of its own options. - /// - /// The id is positional and `--allow-egress` is a flag on the same verb, so an id shaped like - /// a flag is an application asking to widen the egress its binding decided — and the argv is - /// built here, where a shell is not involved and quoting would not help. - fn checked_session_id(operation: &str, session_id: &str) -> Result<()> { - let usable = !session_id.is_empty() - && session_id.len() <= MAX_SESSION_ID - && session_id - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') - && session_id.starts_with(|c: char| c.is_ascii_alphanumeric()); - - if usable { - return Ok(()); - } - - Err(AlienError::new(ErrorData::InvalidInput { - operation_context: operation.to_string(), - details: format!( - "session id '{session_id}' must start with a letter or digit and hold only \ - letters, digits, '-' and '_', at most {MAX_SESSION_ID} characters" - ), - field_name: Some("sessionId".to_string()), - })) - } - - fn exec_arguments(&self, session_id: &str, command: &[String]) -> Vec { - let mut arguments = vec!["exec".to_string(), session_id.to_string(), "--".to_string()]; - arguments.extend(command.iter().cloned()); - arguments - } -} - -impl Binding for GcpSandbox {} - -#[async_trait] -impl Sandbox for GcpSandbox { - fn as_any(&self) -> &dyn std::any::Any { - self - } - - fn capabilities(&self) -> SandboxCapabilities { - SandboxCapabilities::for_platform(Platform::Gcp).expect("GCP has a sandbox backend") - } - - /// Starts a sandbox with a caller-chosen id. - /// - /// The launcher's real verb and flag list is captured in - /// `fixtures/gcp-sandbox-cli-help.txt`, so the "no X verb" refusals below cite it. - /// - /// Egress comes from the binding rather than the request: the launcher decides it at create - /// time and an application must not be able to widen its own. - async fn create(&self, request: CreateSessionRequest) -> Result { - let session_id = request - .session_id - .unwrap_or_else(|| uuid::Uuid::new_v4().simple().to_string()); - Self::checked_session_id("sandbox.create", &session_id)?; - - // The id is positional and `--detach` is what makes this return: without it the launcher - // stays attached and `control` waits out its deadline instead of handing back a session. - let mut arguments = vec![ - "run".to_string(), - session_id.clone(), - "--detach".to_string(), - ]; - // A sandbox inherits nothing from the container, so a variable the caller asked for only - // exists if it is passed here. - for (key, value) in &request.env { - arguments.push("--env".to_string()); - arguments.push(format!("{key}={value}")); - } - if self.allow_egress { - arguments.push("--allow-egress".to_string()); - } - - self.control("sandbox.create", &arguments).await?; - - Ok(SandboxSession { - session_id, - state: SandboxSessionState::Running, - // A sandbox is destroyed rather than fenced, so a session never outlives its own - // generation and there is nothing for a second one to mean. - generation: 1, - }) - } - - /// Reconnecting is not offered, and the reason is measured rather than assumed. - async fn get(&self, _session_id: &str) -> Result> { - Err(self.unsupported( - "reconnect", - "a Cloud Run sandbox id is scoped to one instance, and session affinity held 2 of \ - 100 five-turn conversations", - )) - } - - async fn get_or_create(&self, request: CreateSessionRequest) -> Result { - self.create(request).await - } - - async fn list(&self) -> Result> { - Err(self.unsupported( - "reconnect", - "the launcher has no enumeration verb, and an id reaches only the instance that \ - created it", - )) - } - - async fn run_command( - &self, - session_id: &str, - request: RunCommandRequest, - ) -> Result>> { - Self::checked_session_id("sandbox.runCommand", session_id)?; - if request.command.is_empty() { - return Err(self.failed("sandbox.runCommand", "command is empty")); - } - - if request.deadline.is_zero() { - return Err(self.failed( - "sandbox.runCommand", - "a command must carry a non-zero deadline", - )); - } - - let mut arguments = self.exec_arguments(session_id, &request.command); - // Prepended rather than appended: everything after `--` is the caller's command, so - // anything meant for the launcher has to land before it. - if let Some(directory) = &request.working_directory { - arguments.insert(2, directory.clone()); - arguments.insert(2, "--workdir".to_string()); - } - for (key, value) in &request.env { - arguments.insert(2, format!("{key}={value}")); - arguments.insert(2, "--env".to_string()); - } - - let child = sandbox_process::spawn(&self.launcher_path, &arguments) - .and_then(|mut command| command.spawn()) - .map_err(|error| { - self.failed( - "sandbox.runCommand", - &format!("launcher would not start: {error}"), - ) - })?; - - let (sender, receiver) = mpsc::channel(FRAME_CHANNEL_DEPTH); - tokio::spawn(sandbox_process::stream( - child, - request.deadline, - OUTPUT_CAP, - sender, - )); - - // A failed frame becomes a stream error rather than a fabricated exit code: a deadline - // that killed the command is not the command reporting -1. - Ok( - futures::stream::unfold(receiver, |mut receiver| async move { - let frame = receiver.recv().await?; - let item = match frame { - ProcessFrame::Failed { code, message } => { - Err(AlienError::new(ErrorData::OperationNotSupported { - operation: "sandbox.runCommand".to_string(), - reason: format!("{code}: {message}"), - })) - } - other => Ok(CommandOutput::from(other)), - }; - Some((item, receiver)) - }) - .boxed(), - ) - } - - async fn read_file(&self, session_id: &str, path: &str) -> Result> { - Self::checked_session_id("sandbox.readFile", session_id)?; - let path = self.checked_path(path, "sandbox.readFile")?; - let command = vec!["/bin/cat".to_string(), path]; - self.control( - "sandbox.readFile", - &self.exec_arguments(session_id, &command), - ) - .await - } - - /// Writes files by handing the contents to the sandbox base64-encoded **as an argument**. - /// - /// Not interpolated into a shell string, so a file's contents can never be parsed as code. - /// The cost is `ARG_MAX`: a file larger than roughly a megabyte needs a different transport, - /// and fails loudly here rather than being silently truncated. - async fn write_files(&self, session_id: &str, files: BTreeMap>) -> Result<()> { - Self::checked_session_id("sandbox.writeFiles", session_id)?; - for (path, contents) in files { - let path = self.checked_path(&path, "sandbox.writeFiles")?; - let encoded = BASE64.encode(&contents); - - let command = vec![ - "/bin/sh".to_string(), - "-c".to_string(), - // Parent directories are created, matching the in-sandbox agent, so one path - // means the same thing on every backend. - "mkdir -p \"$(dirname \"$2\")\" && printf %s \"$1\" | base64 -d > \"$2\"" - .to_string(), - "sh".to_string(), - encoded, - path, - ]; - - self.control( - "sandbox.writeFiles", - &self.exec_arguments(session_id, &command), - ) - .await?; - } - - Ok(()) - } - - async fn mkdir(&self, session_id: &str, path: &str) -> Result<()> { - Self::checked_session_id("sandbox.mkdir", session_id)?; - let path = self.checked_path(path, "sandbox.mkdir")?; - let command = vec!["/bin/mkdir".to_string(), "-p".to_string(), path]; - self.control("sandbox.mkdir", &self.exec_arguments(session_id, &command)) - .await?; - Ok(()) - } - - async fn preview(&self, _session_id: &str, _port: u16) -> Result { - Err(self.unsupported( - "preview", - "a Cloud Run sandbox has no ingress of its own and no addressable endpoint", - )) - } - - async fn suspend(&self, _session_id: &str) -> Result<()> { - Err(self.unsupported("suspendResume", "the launcher has no suspend verb")) - } - - async fn resume(&self, _session_id: &str) -> Result<()> { - Err(self.unsupported("suspendResume", "the launcher has no resume verb")) - } - - async fn snapshot(&self, _session_id: &str) -> Result { - Err(self.unsupported( - "snapshot", - "`sandbox fork` produces another live sandbox rather than a durable artifact", - )) - } - - async fn terminate(&self, session_id: &str) -> Result<()> { - Self::checked_session_id("sandbox.terminate", session_id)?; - self.control( - "sandbox.terminate", - &["delete".to_string(), session_id.to_string()], - ) - .await?; - Ok(()) - } -} - -impl From for CommandOutput { - fn from(frame: ProcessFrame) -> Self { - match frame { - ProcessFrame::Output { - seq, - stream: ProcessStream::Stdout, - data, - } => CommandOutput::Stdout { seq, data }, - ProcessFrame::Output { - seq, - stream: ProcessStream::Stderr, - data, - } => CommandOutput::Stderr { seq, data }, - ProcessFrame::Exit { code, truncated } => CommandOutput::Exit { code, truncated }, - // Handled as a stream error before it reaches here, because an exit code would - // claim the command reported something it never did. - ProcessFrame::Failed { code, message } => { - unreachable!("a failed frame is mapped to an error: {code} {message}") - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use futures::StreamExt; - use alien_core::bindings::BindingValue; - - /// A fake launcher that rejects argv the real one rejects. - /// - /// Testing against a script rather than a mock is deliberate. What this provider gets wrong - /// is argument construction, and a mock of the launcher would be built from the same - /// misunderstanding as the code. - /// - /// `body` runs only after the argv passes `STRICT_PRELUDE`'s checks. A fake that accepts - /// anything is worse than none: it produced green tests for a `create` that sent - /// `run --id `, which the real launcher answers with `unknown flag: --id`. - fn launcher(body: &str) -> (tempfile::TempDir, GcpSandbox) { - launcher_with_prelude(STRICT_PRELUDE, body) - } - - /// Verbs and flags taken from a live `sandbox -h`, not from the reference page — the page - /// lists six verbs where the launcher has eight. - const STRICT_PRELUDE: &str = r#" -case "$1" in - run|exec|do|fork|tar|delete|completion|help) ;; - *) echo "Error: unknown command: $1" >&2; exit 1 ;; -esac -# The real launcher exits 0 on an unknown flag, which is how a broken create looked healthy. -# This one exits 2, so the same mistake fails a test instead of passing one. "$@" is left -# intact so the body sees exactly what the provider sent, verb included. -for a in "$@"; do - case "$a" in - --) break ;; - --detach|--allow-egress|--write|--env|--workdir|--import-tar|--mount|--rootfs|--file|--force|--tar|--sandbox-name|-e|-w) ;; - --*) echo "Error: unknown flag: $a" >&2; exit 2 ;; - esac -done -"#; - - fn launcher_with_prelude(prelude: &str, body: &str) -> (tempfile::TempDir, GcpSandbox) { - let directory = tempfile::tempdir().expect("temp dir"); - let path = directory.path().join("sandbox"); - std::fs::write(&path, format!("#!/bin/sh\n{prelude}\n{body}\n")).expect("write launcher"); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)) - .expect("make executable"); - } - - let sandbox = GcpSandbox::new( - "sbx", - &GcpSandboxBinding { - launcher_path: BindingValue::value(path.display().to_string()), - allow_egress: BindingValue::value(false), - }, - ) - .expect("binding is valid"); - - (directory, sandbox) - } - - #[tokio::test] - async fn create_names_the_session_and_withholds_egress() { - let (_dir, sandbox) = launcher(r#"echo "$@""#); - - let session = sandbox - .create(CreateSessionRequest { - session_id: Some("s1".to_string()), - tenant_key: None, - env: BTreeMap::new(), - }) - .await - .expect("create succeeds"); - - assert_eq!(session.session_id, "s1"); - assert_eq!(session.state, SandboxSessionState::Running); - } - - /// The launcher takes `--allow-egress` per sandbox, so an application that could pass its - /// own would choose its own confinement. The binding decides it. - #[tokio::test] - async fn egress_comes_from_the_binding_and_not_from_the_request() { - let (dir, _) = launcher(r#"echo "$@" > "$(dirname "$0")/argv""#); - let path = dir.path().join("sandbox"); - - for (allow, expected) in [(false, false), (true, true)] { - let sandbox = GcpSandbox::new( - "sbx", - &GcpSandboxBinding { - launcher_path: BindingValue::value(path.display().to_string()), - allow_egress: BindingValue::value(allow), - }, - ) - .expect("binding is valid"); - - sandbox - .create(CreateSessionRequest { - session_id: Some("s1".to_string()), - tenant_key: None, - env: BTreeMap::new(), - }) - .await - .expect("create succeeds"); - - let argv = std::fs::read_to_string(dir.path().join("argv")).expect("argv recorded"); - assert_eq!( - argv.contains("--allow-egress"), - expected, - "binding said allow_egress={allow}, argv was: {argv}" - ); - } - } - - /// A launcher that fails must not report a session. The cause is on stderr, and losing it - /// turns a specific failure into a guess. - #[tokio::test] - async fn a_failing_launcher_surfaces_its_stderr() { - let (_dir, sandbox) = launcher(r#"echo "quota exhausted" 1>&2; exit 7"#); - - let error = sandbox - .create(CreateSessionRequest { - session_id: Some("s1".to_string()), - tenant_key: None, - env: BTreeMap::new(), - }) - .await - .expect_err("a non-zero launcher exit is a failure"); - - let rendered = format!("{error:?}"); - assert!(rendered.contains("quota exhausted"), "got: {rendered}"); - assert!( - rendered.contains('7'), - "the exit code belongs in the error: {rendered}" - ); - } - - #[tokio::test] - async fn a_command_streams_output_and_a_real_exit_code() { - let (_dir, sandbox) = launcher(r#"echo hello; echo problem 1>&2; exit 3"#); - - let frames: Vec<_> = sandbox - .run_command( - "s1", - RunCommandRequest { - command: vec!["/bin/true".to_string()], - working_directory: None, - env: BTreeMap::new(), - deadline: Duration::from_secs(10), - }, - ) - .await - .expect("the command runs") - .collect() - .await; - - let decoded: String = frames - .iter() - .filter_map(|frame| match frame { - Ok(CommandOutput::Stdout { data, .. }) => { - Some(String::from_utf8_lossy(data).to_string()) - } - _ => None, - }) - .collect(); - assert!(decoded.contains("hello"), "stdout was: {decoded}"); - - assert!( - frames - .iter() - .any(|frame| matches!(frame, Ok(CommandOutput::Stderr { .. }))), - "stderr must be framed, not dropped" - ); - - assert!( - matches!(frames.last(), Some(Ok(CommandOutput::Exit { code: 3, .. }))), - "the terminal frame must carry the real exit code: {:?}", - frames.last() - ); - } - - /// A sandbox inherits nothing from the container, so a variable a caller asks for reaches the - /// command only if it is passed on the argv. Asserted on the recorded argv rather than on a - /// success code: the launcher exits 0 even when it rejects a flag, so a green call proves - /// nothing about what it was actually given. - #[tokio::test] - async fn an_environment_reaches_the_launcher_on_create_and_on_exec() { - let directory = tempfile::tempdir().expect("temp dir"); - let record = directory.path().join("argv"); - let (_dir, sandbox) = launcher(&format!(r#"echo "$@" >> {}"#, record.display())); - - let env = BTreeMap::from([("TOKEN".to_string(), "secret".to_string())]); - sandbox - .create(CreateSessionRequest { - session_id: Some("s1".to_string()), - tenant_key: None, - env: env.clone(), - }) - .await - .expect("a session environment is carried, not refused"); - - // The stream has to be drained: dropping it undrained kills the child before it runs. - let mut frames = sandbox - .run_command( - "s1", - RunCommandRequest { - command: vec!["true".to_string()], - working_directory: None, - env, - deadline: Duration::from_secs(5), - }, - ) - .await - .unwrap_or_else(|error| panic!("a command with variables is accepted: {error}")); - while frames.next().await.is_some() {} - - let argv = std::fs::read_to_string(&record).expect("launcher ran"); - let lines: Vec<&str> = argv.lines().collect(); - assert!( - lines[0].contains("--env TOKEN=secret"), - "create must pass the variable: {}", - lines[0] - ); - assert!( - lines[1].contains("--env TOKEN=secret"), - "exec must pass the variable: {}", - lines[1] - ); - // Before the command, or the launcher reads it as an argument to the command itself. - let exec = lines[1]; - assert!( - exec.find("--env").unwrap() < exec.find(" -- ").unwrap(), - "--env must precede the `--` separator: {exec}" - ); - } - - /// The create argv, pinned. `--id` does not exist on `run`; the id is positional, and without - /// `--detach` the launcher stays attached until the control deadline kills it. - #[tokio::test] - async fn create_passes_the_id_positionally_and_detaches() { - let directory = tempfile::tempdir().expect("temp dir"); - let record = directory.path().join("argv"); - let (_dir, sandbox) = launcher(&format!(r#"echo "$@" > {}"#, record.display())); - - sandbox - .create(CreateSessionRequest { - session_id: Some("s1".to_string()), - tenant_key: None, - env: BTreeMap::new(), - }) - .await - .expect("create succeeds"); - - let argv = std::fs::read_to_string(&record).expect("launcher ran"); - let argv = argv.trim(); - assert!(argv.starts_with("run s1"), "id is positional: {argv}"); - assert!(argv.contains("--detach"), "must detach: {argv}"); - assert!(!argv.contains("--id"), "--id is not a flag on run: {argv}"); - } - - /// A command with no deadline is a hang waiting for a slow day, in a sandbox running code the - /// caller does not control, so it is refused here as on every other backend. - #[tokio::test] - async fn a_command_without_a_deadline_is_refused() { - let (_dir, sandbox) = launcher("exit 0"); - - let Err(error) = sandbox - .run_command( - "s1", - RunCommandRequest { - command: vec!["true".to_string()], - working_directory: None, - env: BTreeMap::new(), - deadline: Duration::ZERO, - }, - ) - .await - else { - panic!("a zero deadline is not a deadline"); - }; - assert_eq!(error.code, "OPERATION_NOT_SUPPORTED"); - assert!( - error.to_string().contains("non-zero deadline"), - "the message should say what was wrong, got: {error}" - ); - } - - /// Declared capabilities and actual behaviour have to agree, or a caller branches on a lie. - #[tokio::test] - async fn unsupported_capabilities_error_rather_than_pretend() { - let (_dir, sandbox) = launcher("exit 0"); - let capabilities = sandbox.capabilities(); - - assert!(!capabilities.reconnect); - assert!(!capabilities.preview); - assert!(!capabilities.suspend_resume); - assert!(!capabilities.snapshot); - - sandbox - .get("s1") - .await - .expect_err("reconnect is not offered"); - sandbox - .list() - .await - .expect_err("enumeration is not offered"); - sandbox - .preview("s1", 8080) - .await - .expect_err("preview is not offered"); - sandbox - .suspend("s1") - .await - .expect_err("suspend is not offered"); - sandbox - .resume("s1") - .await - .expect_err("resume is not offered"); - sandbox - .snapshot("s1") - .await - .expect_err("snapshot is not offered"); - } - - /// The lexical rule the agent applies, applied here too, so one path means one thing. - #[tokio::test] - async fn a_traversing_path_is_refused_before_the_launcher_sees_it() { - let (_dir, sandbox) = launcher("exit 0"); - - sandbox - .read_file("s1", "../etc/passwd") - .await - .expect_err("a traversing path must be refused"); - sandbox - .write_files( - "s1", - BTreeMap::from([("../etc/passwd".to_string(), b"x".to_vec())]), - ) - .await - .expect_err("a traversing path must be refused on write too"); - } - - /// A session id shaped like a launcher option never reaches the launcher. - /// - /// The id is positional and `--allow-egress` is a flag on the same verb, so an application - /// passing one as its session id would be asking for the egress its binding refused it — the - /// one setting the binding decides rather than the caller. - #[tokio::test] - async fn an_option_shaped_session_id_is_refused_before_the_launcher_runs() { - let (_dir, sandbox) = launcher("exit 0"); - - for id in [ - "--allow-egress", - "-e", - "--env", - "", - "has space", - "semi;colon", - "-leading-dash", - ] { - let error = sandbox - .create(CreateSessionRequest { - session_id: Some(id.to_string()), - tenant_key: None, - env: BTreeMap::new(), - }) - .await - .expect_err(&format!("'{id}' must never reach the argv")); - assert_eq!(error.code, "INVALID_INPUT", "'{id}': {error}"); - - sandbox - .terminate(id) - .await - .expect_err(&format!("'{id}' must be refused on every verb that takes it")); - } - - // The shape the launcher is actually given, and the one this binding generates. - sandbox - .create(CreateSessionRequest { - session_id: Some("sbx-7f3a_01".to_string()), - tenant_key: None, - env: BTreeMap::new(), - }) - .await - .expect("an ordinary id is not refused"); - } -} diff --git a/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform.rs b/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform.rs new file mode 100644 index 000000000..e2a0605fe --- /dev/null +++ b/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform.rs @@ -0,0 +1,1298 @@ +//! GCP Agent Platform sandbox provider. +//! +//! Sessions are `sandboxEnvironments` created under a durable reasoning engine and reached from +//! outside the guest through the `:execute` proxy, which forwards one request to the agent's +//! `POST /` envelope and returns its body verbatim. So every command, file operation and health +//! check is one envelope over that proxy, and the lifecycle verbs are long-running operations +//! polled to completion. + +use std::collections::{BTreeMap, VecDeque}; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine as _; +use futures::stream::{self, BoxStream}; +use serde::Deserialize; +use serde_json::json; +use tracing::warn; + +use crate::error::{ErrorData, Result}; +use crate::traits::{ + Binding, CommandOutput, CreateSessionRequest, PreviewCapability, RunCommandRequest, Sandbox, + SandboxSession, SandboxSessionState, +}; +use alien_core::{SandboxCapabilities, SandboxEgress}; +use alien_error::{AlienError, Context, ContextError}; +use alien_gcp_clients::gcp::agent_platform::{ + AgentPlatformApi, AgentPlatformErrorData, EgressControlConfig, SandboxCreateRequest, + SandboxEnvironment, SandboxSnapshot, +}; +use alien_gcp_clients::gcp::longrunning::{Operation, OperationResult}; + +/// The envelope protocol version this provider speaks. It matches the agent's `PROTOCOL_VERSION`; +/// a peer that answers a different one is refused rather than guessed at. +const AGENT_PROTOCOL_VERSION: u32 = 1; + +/// The proxy holds one `:execute` request open for roughly this long, so a command whose deadline +/// is within it runs synchronously and anything longer is detached as a job and polled. Set below +/// the measured ceiling, because a command that overruns a synchronous execute is lost, where an +/// overrun job is still reachable by a later poll. +const MAX_SYNCHRONOUS_DEADLINE: Duration = Duration::from_secs(30); + +/// Longest session id this provider will place in a proxy URL. A bound on what is handed back to a +/// caller, not on what the API mints — the names seen are far shorter. +const MAX_SESSION_ID: usize = 63; + +/// How long a created sandbox has to reach `STATE_RUNNING`, and how often that is checked. +const SESSION_READY_ATTEMPTS: u32 = 150; +const SESSION_READY_INTERVAL: Duration = Duration::from_secs(2); + +/// How long a lifecycle operation (`create`, `:pause`, `:resume`, `:snapshot`) is polled before it +/// is reported incomplete rather than waited on forever. +const OPERATION_POLL_ATTEMPTS: u32 = 150; +const OPERATION_POLL_INTERVAL: Duration = Duration::from_secs(2); + +/// How long `terminate` polls the sandbox to `not-found`, turning an accepted delete into a +/// confirmed one. +const TERMINATE_POLL_ATTEMPTS: u32 = 30; +const TERMINATE_POLL_INTERVAL: Duration = Duration::from_secs(2); + +/// How often a detached job is polled for new output. +const JOB_POLL_INTERVAL: Duration = Duration::from_secs(1); + +/// The grace a job's poll loop allows past the command's own deadline before it cancels the job: +/// the agent kills the command at the deadline and the next poll reports it, and this covers the +/// round trips to observe that. +const JOB_POLL_GRACE: Duration = Duration::from_secs(15); + +const CREATE: &str = "sandbox.create"; +const GET: &str = "sandbox.get"; +const GET_OR_CREATE: &str = "sandbox.getOrCreate"; +const RUN_COMMAND: &str = "sandbox.runCommand"; +const TERMINATE: &str = "sandbox.terminate"; + +/// The generation of a session whose live container identity was not established: a state with no +/// reachable agent, or a bulk `list` that does not probe each session. Never a value +/// `generation_from_boot_id` returns, so a real identity is always distinguishable from an +/// unprobed one. +const NO_GENERATION: u64 = 0; + +/// A single health probe is bounded to this, because the client sets no per-request timeout and an +/// agent that accepts the connection but never answers would otherwise hang `get()` and `create()` +/// forever. Set above the proxy's ~30s synchronous window (see `MAX_SYNCHRONOUS_DEADLINE`) rather +/// than tight to the round trip: too tight reports a healthy session unreachable, and +/// `get_or_create` then provisions a fresh sandbox and loses the caller's filesystem — the failure +/// this task exists to prevent — where too loose only delays an already-broken session. +const AGENT_PROBE_BUDGET: Duration = Duration::from_secs(60); + +/// Maps a declared egress mode onto the template's `egressControlConfig`, or refuses one the API +/// cannot express. +/// +/// `internetAccess` is a single boolean, so `AllowDomains` has no representation and is refused +/// rather than approximated into `allow` (which would open more than was asked) or `deny` (which +/// would close a caller out of hosts it named). Not called by the runtime verbs — the template is +/// pre-created — but this is the mapping the template controller uses, kept beside the provider so +/// the two agree on what a mode means. `sandbox_label` names the offending sandbox in the refusal. +pub fn egress_control_config( + sandbox_label: &str, + egress: &SandboxEgress, +) -> Result { + let Some(internet_access) = egress.internet_access_switch() else { + return Err(AlienError::new(ErrorData::InvalidInput { + operation_context: "sandbox.template".to_string(), + details: format!( + "sandbox '{sandbox_label}' asked for domain-scoped egress, which Agent \ + Platform cannot express; it offers only 'allow' (open) and 'deny' (closed)" + ), + field_name: Some("egress".to_string()), + })); + }; + + Ok(EgressControlConfig { + internet_access: Some(internet_access), + extra: Default::default(), + }) +} + +/// A Sandbox backed by the Vertex AI Agent Platform. +#[derive(Debug)] +pub struct GcpAgentPlatformSandbox { + client: Arc, + /// Bare reasoning-engine id the client interpolates into its paths. The binding may carry a + /// full resource name, so it is reduced to its last segment once, here. + engine: String, + /// Template every session is cut from, as a resource name the create body carries unchanged. + template: String, + /// Session lifetime in seconds, from the declaration; absent takes the service default. + session_ttl_seconds: Option, +} + +impl GcpAgentPlatformSandbox { + /// Builds a provider bound to one engine and template. + /// + /// The engine is normalised to its last path segment because the client builds the full + /// resource path itself; passing the whole name would double it and address nothing. + pub fn new( + client: Arc, + engine: String, + template: String, + session_ttl_seconds: Option, + ) -> Self { + let engine = engine.rsplit('/').next().unwrap_or(&engine).to_string(); + Self { + client, + engine, + template, + session_ttl_seconds, + } + } + + /// The engine id sent to the client. Exists so a test can prove the binding's full resource + /// name was reduced to a bare segment — a doubled path is invisible against the mock otherwise. + #[cfg(test)] + pub(crate) fn engine(&self) -> &str { + &self.engine + } + + fn unsupported(&self, capability: &str, reason: &str) -> AlienError { + AlienError::new(ErrorData::OperationNotSupported { + operation: capability.to_string(), + reason: reason.to_string(), + }) + } + + /// A session id that stays a single path segment. + /// + /// The id is interpolated into the proxy URL, so one carrying `/`, `..`, `?` or `#` would + /// address a different sandbox — a resource the same engine grant can reach. The API mints + /// these; this bounds the ones a caller hands back. + fn checked_session_id(operation: &str, session_id: &str) -> Result<()> { + if is_addressable_id(session_id) { + return Ok(()); + } + Err(AlienError::new(ErrorData::InvalidInput { + operation_context: operation.to_string(), + details: format!( + "session id '{session_id}' must be a single segment of letters, digits, '-' and \ + '_', at most {MAX_SESSION_ID} characters" + ), + field_name: Some("sessionId".to_string()), + })) + } + + /// Reads a sandbox, or `None` when it is gone, without judging it. + async fn read_sandbox( + &self, + operation: &str, + session_id: &str, + ) -> Result> { + match self.client.get_sandbox(&self.engine, session_id).await { + Ok(sandbox) => Ok(Some(sandbox)), + Err(error) if is_not_found(&error) => Ok(None), + Err(error) => Err(error.context(ErrorData::SandboxUnreachable { + operation: operation.to_string(), + reason: "the Agent Platform API did not answer a sandbox read".to_string(), + })), + } + } + + /// Polls a lifecycle operation to completion, returning its response payload. + /// + /// Bounded rather than open-ended: a caller waiting forever is its own outage, and the + /// operation name is carried so an incomplete one can be resumed rather than lost. + async fn await_operation( + &self, + operation: &str, + started: Operation, + ) -> Result { + let Some(name) = started.name.clone() else { + return Err(AlienError::new(ErrorData::UnexpectedResponseFormat { + provider: "gcp-agent-platform".to_string(), + binding_name: operation.to_string(), + field: "name".to_string(), + response_json: "the operation carried no resource name to poll".to_string(), + })); + }; + + let mut current = started; + for _ in 0..OPERATION_POLL_ATTEMPTS { + if current.done == Some(true) { + return finish_operation(operation, &name, current); + } + tokio::time::sleep(OPERATION_POLL_INTERVAL).await; + current = + self.client + .get_operation(&name) + .await + .context(ErrorData::SandboxUnreachable { + operation: operation.to_string(), + reason: format!("could not read operation '{name}'"), + })?; + } + + if current.done == Some(true) { + return finish_operation(operation, &name, current); + } + Err(AlienError::new(ErrorData::SandboxUnreachable { + operation: operation.to_string(), + reason: format!("operation '{name}' did not complete within its polling budget"), + })) + } + + /// Sends one envelope through the `:execute` proxy and returns the agent's body verbatim. + /// + /// A client error is a transport failure — the proxy could not deliver or the API refused. A + /// body the op's parser cannot read is the agent's own reason, handled by each verb. A + /// not-found is reported as a gone session so a caller does not read it as a live one. + async fn execute_op( + &self, + session_id: &str, + operation: &str, + envelope: serde_json::Value, + ) -> Result> { + let body = serde_json::to_vec(&envelope).map_err(|error| { + AlienError::new(ErrorData::SerializationFailed { + message: format!("could not encode the {operation} envelope: {error}"), + }) + })?; + + self.client + .execute(&self.engine, session_id, &body) + .await + .map_err(|error| Self::execute_failed(operation, error)) + } + + fn execute_failed( + operation: &str, + error: AlienError, + ) -> AlienError { + if is_not_found(&error) { + return error.context(ErrorData::SandboxCommandFailed { + failure: "sessionGone".to_string(), + reason: format!("{operation}: the session does not exist"), + }); + } + // Non-retryable across the board: a `:execute` is single-attempt because it may already + // have started the command, and the client does not tell a delivered-but-failed call apart + // from an undelivered one. The cause stays on the chain rather than in `reason`, keeping a + // redacted request body out of an externally visible message. + error.context(ErrorData::SandboxCommandFailed { + failure: "executeFailed".to_string(), + reason: format!("{operation} could not be completed against the session"), + }) + } + + /// Confirms the agent answers and speaks the protocol, and returns the session's generation. + /// + /// A sandbox can report `STATE_RUNNING` while every `:execute` fails, so a state read is not a + /// health check; the agent has to answer for the session to be usable. The reply carries the + /// container boot id, from which the generation is derived so a caller can detect a container + /// that was replaced under a stable session name. + async fn probe_agent(&self, operation: &str, session_id: &str) -> Result { + // Mapped to unreachable whatever the failure — a refused delivery, a probe that outran its + // budget, an unparseable body, a protocol mismatch — because a health probe is idempotent + // and the caller acts on the same thing each way: the agent cannot be reached, so + // `get_or_create` provisions a fresh one rather than destroying a session it did not create. + let unreachable = |reason: String| { + AlienError::new(ErrorData::SandboxUnreachable { + operation: operation.to_string(), + reason, + }) + }; + + let body = tokio::time::timeout( + AGENT_PROBE_BUDGET, + self.client.execute( + &self.engine, + session_id, + &serde_json::to_vec(&json!({ "v": AGENT_PROTOCOL_VERSION, "op": "health" })) + .unwrap_or_default(), + ), + ) + .await + .map_err(|_| { + unreachable(format!( + "the session's agent did not answer a health probe within {}s", + AGENT_PROBE_BUDGET.as_secs() + )) + })? + .map_err(|error| { + error.context(ErrorData::SandboxUnreachable { + operation: operation.to_string(), + reason: "the session's agent did not answer a health probe".to_string(), + }) + })?; + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Health { + protocol_version: u32, + boot_id: String, + } + + let health: Health = serde_json::from_slice(&body).map_err(|_| { + unreachable(format!( + "the session's agent answered a health probe with a body this provider cannot \ + read: {}", + truncated(&body) + )) + })?; + + if health.protocol_version != AGENT_PROTOCOL_VERSION { + return Err(unreachable(format!( + "the session's agent speaks protocol {} where this provider speaks {}", + health.protocol_version, AGENT_PROTOCOL_VERSION + ))); + } + // An agent that answers without a boot id cannot be told apart from a replaced container, + // so the session is refused rather than reconnected to a possibly-blank one. + if health.boot_id.is_empty() { + return Err(unreachable( + "the session's agent reported no container boot id, so its identity cannot be \ + established" + .to_string(), + )); + } + Ok(generation_from_boot_id(&health.boot_id)) + } + + /// Deletes a sandbox the caller will never receive, keeping the reason it is discarded. + /// + /// Every failure after the sandbox exists reaches here, so `create` has one delete rather than + /// one beside each `?`. The delete's own failure names the leak without replacing the finding + /// that caused it. A not-found delete is already success in the client. + async fn discard( + &self, + session_id: &str, + reason: AlienError, + ) -> AlienError { + let Err(error) = self.client.delete_sandbox(&self.engine, session_id).await else { + return reason; + }; + warn!( + session = %session_id, + %error, + "could not delete a sandbox that was never handed to its caller" + ); + reason.context(ErrorData::SandboxCommandFailed { + failure: "sandboxLeftBehind".to_string(), + reason: format!( + "session '{session_id}' was not handed to its caller and could not be deleted, so \ + it is still running" + ), + }) + } + + /// Waits for a created sandbox to reach `STATE_RUNNING`, confirms its agent answers, and returns + /// the session's generation. + /// + /// The running record is judged, not the create accept: a sandbox still coming up need not be + /// addressable yet, and reading that as a failure would delete every one that answered early. + async fn settle(&self, session_id: &str) -> Result { + for _ in 0..SESSION_READY_ATTEMPTS { + let Some(sandbox) = self.read_sandbox(CREATE, session_id).await? else { + return Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: "sessionGone".to_string(), + reason: format!("session '{session_id}' disappeared while it was coming up"), + })); + }; + match session_state(CREATE, sandbox.state.as_deref())? { + SandboxSessionState::Running => { + return self.probe_agent(CREATE, session_id).await; + } + SandboxSessionState::Terminated => { + return Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: "sessionTerminated".to_string(), + reason: format!( + "session '{session_id}' reached a terminal state while starting" + ), + })); + } + // Waited on rather than woken: a fresh sandbox has no idle-suspend policy to pause + // it before its first command — the binding carries no such field — so a suspended + // reading here is a transient step on the way up, not a resting state to resume. + SandboxSessionState::Starting | SandboxSessionState::Suspended => {} + } + tokio::time::sleep(SESSION_READY_INTERVAL).await; + } + Err(AlienError::new(ErrorData::SandboxUnreachable { + operation: CREATE.to_string(), + reason: format!( + "session '{session_id}' was not running after {}s", + SESSION_READY_ATTEMPTS as u64 * SESSION_READY_INTERVAL.as_secs() + ), + })) + } + + /// Runs a command inside the proxy's synchronous window, streaming the buffered NDJSON body. + async fn run_synchronous( + &self, + session_id: &str, + request: &RunCommandRequest, + ) -> Result>> { + let envelope = exec_envelope("exec", session_id, request); + let body = self.execute_op(session_id, RUN_COMMAND, envelope).await?; + let frames = parse_exec_frames(&body)?; + Ok(Box::pin(stream::iter(frames))) + } + + /// Runs a command as a detached job whose output is polled for until it ends. + async fn run_detached( + &self, + session_id: &str, + request: &RunCommandRequest, + ) -> Result>> { + let envelope = exec_envelope("jobStart", session_id, request); + let body = self.execute_op(session_id, RUN_COMMAND, envelope).await?; + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct JobStart { + job_id: String, + } + let started: JobStart = serde_json::from_slice(&body).map_err(|_| { + AlienError::new(ErrorData::UnexpectedResponseFormat { + provider: "gcp-agent-platform".to_string(), + binding_name: RUN_COMMAND.to_string(), + field: "jobId".to_string(), + response_json: truncated(&body), + }) + })?; + + let state = JobPollState { + client: self.client.clone(), + engine: self.engine.clone(), + session_id: session_id.to_string(), + job_id: started.job_id, + since_seq: None, + pending: VecDeque::new(), + finished: false, + deadline_at: tokio::time::Instant::now() + request.deadline + JOB_POLL_GRACE, + }; + + Ok(Box::pin(stream::unfold(state, job_poll_step))) + } +} + +impl Binding for GcpAgentPlatformSandbox {} + +#[async_trait] +impl Sandbox for GcpAgentPlatformSandbox { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn capabilities(&self) -> SandboxCapabilities { + SandboxCapabilities::gcp_agent_platform() + } + + async fn create(&self, request: CreateSessionRequest) -> Result { + // A session inherits no per-session environment: `SandboxCreateRequest` has no env field, + // so silently dropping one would run the caller's code without the variables it asked for. + // They travel per command through `run_command` instead. + if !request.env.is_empty() { + return Err(AlienError::new(ErrorData::InvalidInput { + operation_context: CREATE.to_string(), + details: "Agent Platform carries no per-session environment; pass variables on \ + each command instead" + .to_string(), + field_name: Some("env".to_string()), + })); + } + + let started = self + .client + .create_sandbox( + &self.engine, + SandboxCreateRequest { + display_name: request.session_id.clone(), + sandbox_environment_template: Some(self.template.clone()), + sandbox_environment_snapshot: None, + ttl: self + .session_ttl_seconds + .map(|seconds| format!("{seconds}s")), + }, + ) + .await + .context(ErrorData::SandboxUnreachable { + operation: CREATE.to_string(), + reason: "the Agent Platform API refused a sandbox create".to_string(), + })?; + + let created: SandboxEnvironment = serde_json::from_value( + self.await_operation(CREATE, started).await?, + ) + .map_err(|error| { + AlienError::new(ErrorData::UnexpectedResponseFormat { + provider: "gcp-agent-platform".to_string(), + binding_name: CREATE.to_string(), + field: "response".to_string(), + response_json: format!("the create operation resolved to a non-sandbox: {error}"), + }) + })?; + + // The caller's requested id is not authoritative — the API allocates the name, and the + // last segment is the id every later verb addresses it by. One this client cannot send is + // one nothing can reach or reap, so an unreadable name is reported without a delete it + // cannot target. + let Some(session_id) = created.name.as_deref().and_then(session_segment) else { + return Err(AlienError::new(ErrorData::UnexpectedResponseFormat { + provider: "gcp-agent-platform".to_string(), + binding_name: CREATE.to_string(), + field: "name".to_string(), + response_json: format!("{:?}", created.name), + })); + }; + let session_id = session_id.to_string(); + + // Past here a sandbox exists the caller has no id for, so every failure deletes it. + match self.settle(&session_id).await { + Ok(generation) => Ok(SandboxSession { + session_id, + state: SandboxSessionState::Running, + generation, + }), + Err(error) => Err(self.discard(&session_id, error).await), + } + } + + async fn get(&self, session_id: &str) -> Result> { + Self::checked_session_id(GET, session_id)?; + let Some(sandbox) = self.read_sandbox(GET, session_id).await? else { + return Ok(None); + }; + + let state = session_state(GET, sandbox.state.as_deref())?; + // Only a running session carries a reachable agent, and a state read is not health: a + // running record whose agent does not answer is not reported as usable. A non-running + // session has no live container to identify, so it carries no generation. + let generation = if state == SandboxSessionState::Running { + self.probe_agent(GET, session_id).await? + } else { + NO_GENERATION + }; + + Ok(Some(SandboxSession { + session_id: session_id.to_string(), + state, + generation, + })) + } + + async fn get_or_create(&self, request: CreateSessionRequest) -> Result { + if let Some(id) = request.session_id.as_deref() { + // A running, reachable session is handed back; anything else is served by a fresh + // session rather than by destroying one this call did not create, which may be + // another revision's. + match self.get(id).await { + Ok(Some(session)) if session.state == SandboxSessionState::Running => { + return Ok(session) + } + // The ordinary resting state for a reconnect: a suspended session is woken and + // confirmed, and handed back if it comes up healthy. A wake this call made that + // cannot be confirmed is put back to sleep before a fresh session is provisioned — + // the paused one may be another revision's, and a second live sandbox beside it is + // a leak the caller never receives an id for. + Ok(Some(session)) if session.state == SandboxSessionState::Suspended => { + if self.resume(id).await.is_ok() { + match self.get(id).await { + Ok(Some(woken)) if woken.state == SandboxSessionState::Running => { + return Ok(woken) + } + _ => { + // The wake could not be undone: leaving it live beside a fresh + // session is a leak the caller gets no id for. Fail so the woken + // session stays identifiable rather than provisioning a second one. + if let Err(error) = self.suspend(id).await { + return Err(error.context(ErrorData::SandboxCommandFailed { + failure: "resumeRollbackFailed".to_string(), + reason: format!( + "{GET_OR_CREATE}: woke session '{id}' but could not \ + confirm it healthy or put it back to sleep" + ), + })); + } + } + } + } + } + Ok(_) => {} + Err(error) if error.code == "SANDBOX_UNREACHABLE" => {} + Err(error) => { + return Err(error.context(ErrorData::SandboxCommandFailed { + failure: "getOrCreateFailed".to_string(), + reason: format!("{GET_OR_CREATE}: reaching session '{id}' failed"), + })) + } + } + } + + self.create(request).await + } + + async fn list(&self) -> Result> { + let sandboxes = self.client.list_sandboxes(&self.engine).await.context( + ErrorData::SandboxUnreachable { + operation: "sandbox.list".to_string(), + reason: "the Agent Platform API did not answer a sandbox list".to_string(), + }, + )?; + + // A sandbox this provider cannot fully read — an unaddressable name or an unrecognised + // state — is left out rather than surfaced as a handle to nothing or failing the whole + // enumeration; one odd sandbox must not hide every other from an orphan sweep. Both halves + // are skipped for the same reason, so leniency is consistent across the record. + Ok(sandboxes + .into_iter() + .filter_map(|sandbox| { + let session_id = sandbox.name.as_deref().and_then(session_segment)?; + let state = session_state("sandbox.list", sandbox.state.as_deref()).ok()?; + // A bulk list does not probe each agent, so it reports no generation; a caller that + // needs one reads the single session through `get`. + Some(SandboxSession { + session_id: session_id.to_string(), + state, + generation: NO_GENERATION, + }) + }) + .collect()) + } + + async fn run_command( + &self, + session_id: &str, + request: RunCommandRequest, + ) -> Result>> { + Self::checked_session_id(RUN_COMMAND, session_id)?; + if request.command.is_empty() { + return Err(AlienError::new(ErrorData::InvalidInput { + operation_context: RUN_COMMAND.to_string(), + details: "a command must name a program to run".to_string(), + field_name: Some("command".to_string()), + })); + } + // Refused rather than defaulted, and refused where it floors to zero milliseconds too: the + // agent rejects a `deadlineMs` of 0, and a defaulted deadline is a hang waiting for a slow + // day in a session running code the caller does not control. + if deadline_millis(request.deadline) == 0 { + return Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: "invalidRequest".to_string(), + reason: "a command must carry a deadline of at least one millisecond".to_string(), + })); + } + + // The synchronous window is the proxy's, not the command's: a command that outlives one + // `:execute` is detached as a job so a later poll can still reach its output. + if request.deadline <= MAX_SYNCHRONOUS_DEADLINE { + self.run_synchronous(session_id, &request).await + } else { + self.run_detached(session_id, &request).await + } + } + + async fn read_file(&self, session_id: &str, path: &str) -> Result> { + Self::checked_session_id("sandbox.readFile", session_id)?; + let body = self + .execute_op( + session_id, + "sandbox.readFile", + json!({ "v": AGENT_PROTOCOL_VERSION, "op": "readFile", "path": path }), + ) + .await?; + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct ReadFile { + contents_base64: String, + } + let read: ReadFile = serde_json::from_slice(&body).map_err(|_| { + AlienError::new(ErrorData::SandboxCommandFailed { + failure: "agentRefused".to_string(), + reason: format!("sandbox.readFile was refused: {}", truncated(&body)), + }) + })?; + + BASE64 + .decode(read.contents_base64.as_bytes()) + .map_err(|error| { + AlienError::new(ErrorData::UnexpectedResponseFormat { + provider: "gcp-agent-platform".to_string(), + binding_name: "sandbox.readFile".to_string(), + field: "contentsBase64".to_string(), + response_json: format!("the agent returned data that is not base64: {error}"), + }) + }) + } + + async fn write_files(&self, session_id: &str, files: BTreeMap>) -> Result<()> { + Self::checked_session_id("sandbox.writeFiles", session_id)?; + // One request per path, stopping at the first failure — the partial application every + // backend performs, so a caller sees one contract rather than several. The agent's field + // is `contentsBase64`; `contents` is dropped silently. + for (path, contents) in files { + let body = self + .execute_op( + session_id, + "sandbox.writeFiles", + json!({ + "v": AGENT_PROTOCOL_VERSION, + "op": "writeFile", + "path": path, + "contentsBase64": BASE64.encode(&contents), + }), + ) + .await?; + confirm_empty_ok("sandbox.writeFiles", &body)?; + } + Ok(()) + } + + async fn mkdir(&self, session_id: &str, path: &str) -> Result<()> { + Self::checked_session_id("sandbox.mkdir", session_id)?; + let body = self + .execute_op( + session_id, + "sandbox.mkdir", + json!({ "v": AGENT_PROTOCOL_VERSION, "op": "mkdir", "path": path }), + ) + .await?; + confirm_empty_ok("sandbox.mkdir", &body) + } + + async fn preview(&self, _session_id: &str, _port: u16) -> Result { + Err(self.unsupported( + "preview", + "Agent Platform mints no port-scoped ingress capability; the only ingress is :execute", + )) + } + + async fn suspend(&self, session_id: &str) -> Result<()> { + Self::checked_session_id("sandbox.suspend", session_id)?; + let started = self.client.pause(&self.engine, session_id).await.context( + ErrorData::SandboxCommandFailed { + failure: "suspendFailed".to_string(), + reason: format!("sandbox.suspend: session '{session_id}' could not be paused"), + }, + )?; + self.await_operation("sandbox.suspend", started).await?; + Ok(()) + } + + async fn resume(&self, session_id: &str) -> Result<()> { + Self::checked_session_id("sandbox.resume", session_id)?; + let started = self.client.resume(&self.engine, session_id).await.context( + ErrorData::SandboxCommandFailed { + failure: "resumeFailed".to_string(), + reason: format!("sandbox.resume: session '{session_id}' could not be resumed"), + }, + )?; + self.await_operation("sandbox.resume", started).await?; + Ok(()) + } + + async fn snapshot(&self, session_id: &str) -> Result { + Self::checked_session_id("sandbox.snapshot", session_id)?; + // A generated display name, because the API takes one and the caller does not supply it. + // The trait has no restore verb, so the returned name is not yet consumable through it — + // restore is `create` from a snapshot, which this backend can do but the trait cannot ask. + let display_name = format!("snap-{}", uuid::Uuid::new_v4().simple()); + let started = self + .client + .snapshot(&self.engine, session_id, &display_name) + .await + .context(ErrorData::SandboxCommandFailed { + failure: "snapshotFailed".to_string(), + reason: format!("sandbox.snapshot: session '{session_id}' could not be captured"), + })?; + + let snapshot: SandboxSnapshot = + serde_json::from_value(self.await_operation("sandbox.snapshot", started).await?) + .map_err(|error| { + AlienError::new(ErrorData::UnexpectedResponseFormat { + provider: "gcp-agent-platform".to_string(), + binding_name: "sandbox.snapshot".to_string(), + field: "response".to_string(), + response_json: format!( + "the snapshot operation resolved to a non-snapshot: {error}" + ), + }) + })?; + + snapshot.name.ok_or_else(|| { + AlienError::new(ErrorData::UnexpectedResponseFormat { + provider: "gcp-agent-platform".to_string(), + binding_name: "sandbox.snapshot".to_string(), + field: "name".to_string(), + response_json: "the snapshot completed without a resource name".to_string(), + }) + }) + } + + async fn terminate(&self, session_id: &str) -> Result<()> { + Self::checked_session_id(TERMINATE, session_id)?; + // Accepted, not completed: the client returns before the sandbox is gone. Returning here + // would report containment while the code may still run, which is the whole point of + // terminate — so the delete is confirmed by polling to not-found. + self.client + .delete_sandbox(&self.engine, session_id) + .await + .context(ErrorData::SandboxUnreachable { + operation: TERMINATE.to_string(), + reason: format!("the delete of session '{session_id}' was not accepted"), + })?; + + for _ in 0..TERMINATE_POLL_ATTEMPTS { + match self.client.get_sandbox(&self.engine, session_id).await { + Err(error) if is_not_found(&error) => return Ok(()), + // A read that fails is not a session that is gone, and one throttled response must + // not end the poll: the attempt budget decides. + Err(error) => { + warn!(session = %session_id, %error, "could not confirm a sandbox is gone") + } + Ok(_) => {} + } + tokio::time::sleep(TERMINATE_POLL_INTERVAL).await; + } + + Err(AlienError::new(ErrorData::SandboxUnreachable { + operation: TERMINATE.to_string(), + reason: format!( + "deletion of '{session_id}' was accepted but the session was still present after \ + {}s; it may still be running", + TERMINATE_POLL_ATTEMPTS as u64 * TERMINATE_POLL_INTERVAL.as_secs() + ), + })) + } +} + +/// One step of a detached job's poll loop, yielding output frames as they arrive and a terminal +/// item once the job ends. +async fn job_poll_step(mut state: JobPollState) -> Option<(Result, JobPollState)> { + loop { + if let Some(item) = state.pending.pop_front() { + return Some((item, state)); + } + if state.finished { + return None; + } + + if tokio::time::Instant::now() >= state.deadline_at { + // Best-effort: the job is cancelled so its process group is killed, and the caller is + // told the deadline was exceeded rather than left reading a stream that never ends. + let _ = state + .client + .execute( + &state.engine, + &state.session_id, + &cancel_body(&state.job_id), + ) + .await; + state + .pending + .push_back(Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: "deadlineExceeded".to_string(), + reason: "the command's deadline elapsed before its job reported an outcome" + .to_string(), + }))); + state.finished = true; + continue; + } + + let body = match state + .client + .execute( + &state.engine, + &state.session_id, + &poll_body(&state.job_id, state.since_seq), + ) + .await + { + Ok(body) => body, + Err(error) => { + state + .pending + .push_back(Err(GcpAgentPlatformSandbox::execute_failed( + RUN_COMMAND, + error, + ))); + state.finished = true; + continue; + } + }; + + let poll: JobPoll = match serde_json::from_slice(&body) { + Ok(poll) => poll, + Err(_) => { + state.pending.push_back(Err(AlienError::new( + ErrorData::UnexpectedResponseFormat { + provider: "gcp-agent-platform".to_string(), + binding_name: RUN_COMMAND.to_string(), + field: "jobPoll".to_string(), + response_json: truncated(&body), + }, + ))); + state.finished = true; + continue; + } + }; + + for frame in poll.frames { + // A seq gap is truncated output, not a frame still to come, so the cursor takes the + // highest seq seen and the loop never waits for a "missing" one; `max` rather than the + // last frame's seq so an out-of-order frame cannot walk the cursor backwards. + state.since_seq = state.since_seq.max(frame.seq()); + state.pending.push_back(frame.into_output()); + } + + if !poll.running { + // The terminal outcome is the envelope's, not a frame's: a clean exit carries a code, + // and a deadline, spawn failure or cancel carries an error object with no code. + let terminal = match poll.error { + Some(error) => Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: error.code, + reason: error.message, + })), + None => Ok(CommandOutput::Exit { + code: poll.exit_code.unwrap_or(-1), + truncated: poll.truncated.unwrap_or(false), + }), + }; + state.pending.push_back(terminal); + state.finished = true; + continue; + } + + if state.pending.is_empty() { + tokio::time::sleep(JOB_POLL_INTERVAL).await; + } + } +} + +/// The bookkeeping a detached job's poll loop carries between steps. +struct JobPollState { + client: Arc, + engine: String, + session_id: String, + job_id: String, + since_seq: Option, + pending: VecDeque>, + finished: bool, + deadline_at: tokio::time::Instant, +} + +/// A job's output so far, and how it ended once it has. Mirrors the agent's `jobPoll` reply. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct JobPoll { + running: bool, + #[serde(default)] + frames: Vec, + #[serde(default)] + exit_code: Option, + #[serde(default)] + truncated: Option, + #[serde(default)] + error: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct JobError { + code: String, + message: String, +} + +/// A frame as the agent writes it, shared by the synchronous NDJSON body and the job frames. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", tag = "t")] +enum WireFrame { + Stdout { + seq: u64, + data: String, + }, + Stderr { + seq: u64, + data: String, + }, + Exit { + code: i32, + #[serde(default)] + truncated: bool, + }, + Error { + code: String, + message: String, + }, +} + +impl WireFrame { + fn is_terminal(&self) -> bool { + matches!(self, Self::Exit { .. } | Self::Error { .. }) + } + + fn seq(&self) -> Option { + match self { + Self::Stdout { seq, .. } | Self::Stderr { seq, .. } => Some(*seq), + _ => None, + } + } + + fn into_output(self) -> Result { + match self { + Self::Stdout { seq, data } => Ok(CommandOutput::Stdout { + seq, + data: decode_frame_data(&data)?, + }), + Self::Stderr { seq, data } => Ok(CommandOutput::Stderr { + seq, + data: decode_frame_data(&data)?, + }), + Self::Exit { code, truncated } => Ok(CommandOutput::Exit { code, truncated }), + // An error frame is the command's outcome, so it surfaces as an error rather than a + // stream that simply stopped. + Self::Error { code, message } => { + Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: code, + reason: message, + })) + } + } + } +} + +fn decode_frame_data(data: &str) -> Result> { + BASE64.decode(data).map_err(|error| { + AlienError::new(ErrorData::UnexpectedResponseFormat { + provider: "gcp-agent-platform".to_string(), + binding_name: RUN_COMMAND.to_string(), + field: "data".to_string(), + response_json: format!("an output frame's data is not base64: {error}"), + }) + }) +} + +/// Turns the agent's buffered NDJSON body into output frames. +/// +/// A body that is not frames at all is the agent's error, reported as a refusal. A body that ends +/// without a terminal frame is a transport failure, not a command that finished: the command had +/// started, so the trailing item says the outcome is unknown rather than letting a truncated +/// stream read as success. +fn parse_exec_frames(body: &[u8]) -> Result>> { + let mut frames = Vec::new(); + let mut saw_any = false; + let mut saw_terminal = false; + + for line in body.split(|byte| *byte == b'\n') { + if line.is_empty() { + continue; + } + match serde_json::from_slice::(line) { + Ok(frame) => { + saw_any = true; + saw_terminal |= frame.is_terminal(); + frames.push(frame.into_output()); + } + Err(error) => { + if !saw_any { + return Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: "agentRefused".to_string(), + reason: format!("run_command was refused: {}", truncated(body)), + })); + } + frames.push(Err(AlienError::new(ErrorData::UnexpectedResponseFormat { + provider: "gcp-agent-platform".to_string(), + binding_name: RUN_COMMAND.to_string(), + field: "frame".to_string(), + response_json: format!("an output frame did not parse: {error}"), + }))); + saw_terminal = true; + break; + } + } + } + + if !saw_any { + return Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: "agentRefused".to_string(), + reason: "run_command returned an empty body".to_string(), + })); + } + if !saw_terminal { + frames.push(Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: "outcomeUnknown".to_string(), + reason: "the command's output ended without a terminal frame, so whether it finished \ + is unknown" + .to_string(), + }))); + } + Ok(frames) +} + +/// The envelope for `exec` or `jobStart`. `deadlineMs` is the field the agent reads; both ops take +/// the identical body. +fn exec_envelope(op: &str, _session_id: &str, request: &RunCommandRequest) -> serde_json::Value { + json!({ + "v": AGENT_PROTOCOL_VERSION, + "op": op, + "command": request.command, + "deadlineMs": deadline_millis(request.deadline), + "workingDirectory": request.working_directory, + "env": request.env, + }) +} + +fn poll_body(job_id: &str, since_seq: Option) -> Vec { + serde_json::to_vec(&json!({ + "v": AGENT_PROTOCOL_VERSION, + "op": "jobPoll", + "jobId": job_id, + "sinceSeq": since_seq, + })) + .unwrap_or_default() +} + +fn cancel_body(job_id: &str) -> Vec { + serde_json::to_vec(&json!({ + "v": AGENT_PROTOCOL_VERSION, + "op": "jobCancel", + "jobId": job_id, + })) + .unwrap_or_default() +} + +/// Milliseconds, saturated: a deadline long enough to overflow `u64` ms is not one anyone meant, +/// and wrapping it would turn "effectively forever" into "immediately". +fn deadline_millis(deadline: Duration) -> u64 { + u64::try_from(deadline.as_millis()).unwrap_or(u64::MAX) +} + +/// Reads a `writeFile`/`mkdir` reply, which succeeds with an empty body. +/// +/// A non-empty body from these ops is the agent's error text, not a success shape, so it is +/// surfaced as a refusal rather than ignored. +fn confirm_empty_ok(operation: &str, body: &[u8]) -> Result<()> { + if body.iter().all(|byte| byte.is_ascii_whitespace()) { + return Ok(()); + } + Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: "agentRefused".to_string(), + reason: format!("{operation} was refused: {}", truncated(body)), + })) +} + +/// The last path segment, if it is a usable id. Used for both minted names and listed ones. +fn session_segment(name: &str) -> Option<&str> { + let segment = name.rsplit('/').next()?; + is_addressable_id(segment).then_some(segment) +} + +fn is_addressable_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= MAX_SESSION_ID + && id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') +} + +/// Maps a container boot id to a numeric generation deterministically. +/// +/// A caller may compare generations across processes, so this is an explicit FNV-1a rather than a +/// `Hash` impl — the same boot id must yield the same number in any build, and std's hashers +/// promise no cross-release stability. `| 1` keeps the result clear of `NO_GENERATION`. +fn generation_from_boot_id(boot_id: &str) -> u64 { + const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; + const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; + let mut hash = FNV_OFFSET_BASIS; + for byte in boot_id.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + hash | 1 +} + +/// The API's runtime states, in ours. An unrecognised one is an error rather than a default, +/// because every default here is a lie a caller acts on. +fn session_state(operation: &str, state: Option<&str>) -> Result { + match state { + Some("STATE_RUNNING") => Ok(SandboxSessionState::Running), + Some("STATE_CREATING" | "STATE_PENDING" | "STATE_RESUMING") => { + Ok(SandboxSessionState::Starting) + } + Some("STATE_PAUSED" | "STATE_PAUSING" | "STATE_SUSPENDED") => { + Ok(SandboxSessionState::Suspended) + } + Some("STATE_STOPPED" | "STATE_FAILED" | "STATE_DELETING" | "STATE_DELETED") => { + Ok(SandboxSessionState::Terminated) + } + other => Err(AlienError::new(ErrorData::UnexpectedResponseFormat { + provider: "gcp-agent-platform".to_string(), + binding_name: operation.to_string(), + field: "state".to_string(), + response_json: other + .map_or_else(|| "absent".to_string(), |state| format!("\"{state}\"")), + })), + } +} + +/// Turns a completed operation into its response payload, or the error it reported. +fn finish_operation(operation: &str, name: &str, op: Operation) -> Result { + match op.result { + Some(OperationResult::Response { response }) => Ok(response), + Some(OperationResult::Error { error }) => { + Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: "operationFailed".to_string(), + reason: format!( + "{operation}: operation '{name}' failed (grpc {}): {}", + error.code, error.message + ), + })) + } + None => Err(AlienError::new(ErrorData::UnexpectedResponseFormat { + provider: "gcp-agent-platform".to_string(), + binding_name: operation.to_string(), + field: "response".to_string(), + response_json: format!("operation '{name}' reported done without a result"), + })), + } +} + +/// Whether a client error means the sandbox is already gone. +/// +/// The client wraps a 404 as `RequestFailed` and leaves the `RemoteResourceNotFound` on the +/// source chain, so the classification is read by walking that chain rather than off the outer +/// variant — a path or trace id mentioning 404 in a message never reaches this. +fn is_not_found(error: &AlienError) -> bool { + const NOT_FOUND: &str = "REMOTE_RESOURCE_NOT_FOUND"; + if error.code == NOT_FOUND { + return true; + } + let mut node = error.source.as_deref(); + while let Some(current) = node { + if current.code == NOT_FOUND { + return true; + } + node = current.source.as_deref(); + } + false +} + +/// A body short enough to sit in an error message without carrying a whole response into it. +fn truncated(body: &[u8]) -> String { + const LIMIT: usize = 200; + let text = String::from_utf8_lossy(body); + let text = text.trim(); + if text.len() <= LIMIT { + return text.to_string(); + } + let end = (0..=LIMIT) + .rev() + .find(|at| text.is_char_boundary(*at)) + .unwrap_or(0); + format!("{}…", &text[..end]) +} + +#[cfg(test)] +#[path = "gcp_agent_platform_tests.rs"] +mod tests; diff --git a/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform_tests.rs b/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform_tests.rs new file mode 100644 index 000000000..a169f457c --- /dev/null +++ b/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform_tests.rs @@ -0,0 +1,1059 @@ +use super::*; +use alien_gcp_clients::gcp::agent_platform::{ + MockAgentPlatformApi, ReasoningEngine, SandboxEnvironmentTemplate, +}; +use futures::StreamExt; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// The client's own `Result`, distinct from the binding's `Result` that `super::*` brings in. +type ClientResult = alien_error::Result; + +// ---- Fixtures --------------------------------------------------------------------------------- + +const ENGINE_FULL: &str = "projects/p/locations/us-central1/reasoningEngines/eng1"; +const TEMPLATE: &str = "projects/p/locations/us-central1/sandboxTemplates/agent"; + +fn provider(client: MockAgentPlatformApi) -> GcpAgentPlatformSandbox { + provider_from(Arc::new(client)) +} + +fn provider_from(client: Arc) -> GcpAgentPlatformSandbox { + GcpAgentPlatformSandbox::new( + client, + ENGINE_FULL.to_string(), + TEMPLATE.to_string(), + Some(3600), + ) +} + +fn sandbox_name(id: &str) -> String { + format!("{ENGINE_FULL}/sandboxEnvironments/{id}") +} + +fn sandbox_in_state(id: &str, state: &str) -> SandboxEnvironment { + SandboxEnvironment { + name: Some(sandbox_name(id)), + display_name: None, + state: Some(state.to_string()), + sandbox_environment_template: None, + expire_time: None, + connection_info: None, + extra: Default::default(), + } +} + +/// A completed operation whose response is `value`. +fn done_op(value: serde_json::Value) -> Operation { + Operation { + name: Some("projects/p/locations/us-central1/operations/op1".to_string()), + metadata: None, + done: Some(true), + result: Some(OperationResult::Response { response: value }), + } +} + +fn op_of(input: &[u8]) -> String { + serde_json::from_slice::(input) + .ok() + .and_then(|value| { + value + .get("op") + .and_then(|op| op.as_str()) + .map(str::to_string) + }) + .unwrap_or_default() +} + +fn ndjson(lines: &[serde_json::Value]) -> Vec { + let mut body = Vec::new(); + for line in lines { + body.extend_from_slice( + serde_json::to_string(line) + .expect("frame serializes") + .as_bytes(), + ); + body.push(b'\n'); + } + body +} + +fn health_reply() -> Vec { + health_reply_with_boot("11111111-1111-1111-1111-111111111111") +} + +fn health_reply_with_boot(boot_id: &str) -> Vec { + serde_json::to_vec(&serde_json::json!({ "protocolVersion": 1, "bootId": boot_id })) + .expect("health serializes") +} + +fn stdout_frame(seq: u64, data: &[u8]) -> serde_json::Value { + serde_json::json!({ "t": "stdout", "seq": seq, "data": BASE64.encode(data) }) +} + +fn exit_frame(code: i32) -> serde_json::Value { + serde_json::json!({ "t": "exit", "code": code, "truncated": false }) +} + +/// The client-shaped not-found: a `RemoteResourceNotFound` wrapped as `RequestFailed`, matching how +/// the real client reports an absent sandbox. +fn not_found() -> AlienError { + AlienError::new(alien_client_core::ErrorData::RemoteResourceNotFound { + resource_type: "SandboxEnvironment".to_string(), + resource_name: "s1".to_string(), + }) + .context(AgentPlatformErrorData::RequestFailed { + operation: "get sandbox".to_string(), + message: "s1".to_string(), + }) +} + +fn execute_refused() -> AlienError { + AlienError::new(AgentPlatformErrorData::ExecuteFailed { + sandbox: "s1".to_string(), + message: "the API rejected the request".to_string(), + }) +} + +// ---- create ----------------------------------------------------------------------------------- + +/// create awaits RUNNING, probes the agent, and pins the three arguments that reach the client: +/// the engine reduced to a bare segment, the template unchanged, and the ttl as a duration. +#[tokio::test] +async fn create_awaits_running_probes_the_agent_and_pins_its_arguments() { + let mut client = MockAgentPlatformApi::new(); + client + .expect_create_sandbox() + .withf(|engine, request| { + engine == "eng1" + && request.sandbox_environment_template.as_deref() == Some(TEMPLATE) + && request.ttl.as_deref() == Some("3600s") + }) + .times(1) + .returning(|_, _| Ok(done_op(serde_json::json!({ "name": sandbox_name("s1") })))); + client + .expect_get_sandbox() + .withf(|engine, sandbox| engine == "eng1" && sandbox == "s1") + .returning(|_, id| Ok(sandbox_in_state(id, "STATE_RUNNING"))); + client + .expect_execute() + .withf(|_, sandbox, input| sandbox == "s1" && op_of(input) == "health") + .returning(|_, _, _| Ok(health_reply())); + + let session = provider(client) + .create(CreateSessionRequest::default()) + .await + .expect("create succeeds"); + + assert_eq!(session.session_id, "s1"); + assert_eq!(session.state, SandboxSessionState::Running); +} + +/// Delete-on-create-failure: a probe the agent never answers deletes the sandbox the caller never +/// received, through the one discard path. Mutation check: drop the `discard` call in `create` and +/// this test's `expect_delete_sandbox().times(1)` goes unmet. +#[tokio::test] +async fn create_deletes_the_sandbox_when_its_agent_never_answers() { + let mut client = MockAgentPlatformApi::new(); + client + .expect_create_sandbox() + .returning(|_, _| Ok(done_op(serde_json::json!({ "name": sandbox_name("s1") })))); + client + .expect_get_sandbox() + .returning(|_, id| Ok(sandbox_in_state(id, "STATE_RUNNING"))); + client + .expect_execute() + .returning(|_, _, _| Err(execute_refused())); + client + .expect_delete_sandbox() + .withf(|engine, sandbox| engine == "eng1" && sandbox == "s1") + .times(1) + .returning(|_, _| Ok(())); + + provider(client) + .create(CreateSessionRequest::default()) + .await + .expect_err("a sandbox whose agent is silent is not a usable session"); +} + +/// A per-session environment has no representation, so it is refused rather than dropped — and the +/// create is never sent, so the refusal is before any side effect. +#[tokio::test] +async fn create_refuses_a_per_session_environment() { + let mut client = MockAgentPlatformApi::new(); + client.expect_create_sandbox().never(); + + let error = provider(client) + .create(CreateSessionRequest { + env: BTreeMap::from([("TOKEN".to_string(), "secret".to_string())]), + ..Default::default() + }) + .await + .expect_err("a session environment must be refused"); + assert_eq!(error.code, "INVALID_INPUT", "{error}"); + assert!(error.to_string().contains("each command"), "{error}"); +} + +// ---- get / get_or_create ---------------------------------------------------------------------- + +#[tokio::test] +async fn get_returns_none_when_the_sandbox_is_gone() { + let mut client = MockAgentPlatformApi::new(); + client + .expect_get_sandbox() + .returning(|_, _| Err(not_found())); + + let found = provider(client) + .get("s1") + .await + .expect("a gone sandbox is a valid answer"); + assert!(found.is_none(), "a not-found sandbox is None, not an error"); +} + +/// A sandbox reports RUNNING while its agent does not answer, and `get` must not report that as a +/// usable session. Mutation check: drop the `probe_agent` call in `get` and this returns +/// `Some(Running)` instead of the unreachable error. +#[tokio::test] +async fn get_does_not_report_a_running_session_whose_agent_is_silent() { + let mut client = MockAgentPlatformApi::new(); + client + .expect_get_sandbox() + .returning(|_, id| Ok(sandbox_in_state(id, "STATE_RUNNING"))); + client + .expect_execute() + .returning(|_, _, _| Err(execute_refused())); + + let error = provider(client) + .get("s1") + .await + .expect_err("a running record with a silent agent is not a healthy session"); + assert_eq!(error.code, "SANDBOX_UNREACHABLE", "{error}"); +} + +/// Refuse-don't-destroy: `get_or_create` handed a stale id provisions a fresh session and never +/// deletes the stale one, which may be another revision's. Mutation check: add a `delete_sandbox` +/// on the reconnect-failure path and `expect_delete_sandbox().never()` fails. +#[tokio::test] +async fn get_or_create_replaces_a_stale_session_without_deleting_it() { + let mut client = MockAgentPlatformApi::new(); + // The stale session reads RUNNING but its agent is silent; the fresh one is healthy. + client + .expect_get_sandbox() + .withf(|_, sandbox| sandbox == "stale") + .returning(|_, id| Ok(sandbox_in_state(id, "STATE_RUNNING"))); + client + .expect_execute() + .withf(|_, sandbox, _| sandbox == "stale") + .returning(|_, _, _| Err(execute_refused())); + + client.expect_create_sandbox().times(1).returning(|_, _| { + Ok(done_op( + serde_json::json!({ "name": sandbox_name("fresh") }), + )) + }); + client + .expect_get_sandbox() + .withf(|_, sandbox| sandbox == "fresh") + .returning(|_, id| Ok(sandbox_in_state(id, "STATE_RUNNING"))); + client + .expect_execute() + .withf(|_, sandbox, input| sandbox == "fresh" && op_of(input) == "health") + .returning(|_, _, _| Ok(health_reply())); + + client.expect_delete_sandbox().never(); + + let session = provider(client) + .get_or_create(CreateSessionRequest { + session_id: Some("stale".to_string()), + ..Default::default() + }) + .await + .expect("a stale session is replaced"); + assert_eq!( + session.session_id, "fresh", + "the fresh session is returned, not the stale id" + ); +} + +/// A reconnect to a suspended session wakes it and hands it back, rather than creating a second +/// sandbox and orphaning the paused one. Mutation check: fold the `Suspended` arm into `Ok(_) => +/// {}` and `create_sandbox().never()` fails while a second sandbox is minted. +#[tokio::test] +async fn get_or_create_resumes_a_suspended_session_rather_than_creating_a_second() { + let reads = Arc::new(AtomicUsize::new(0)); + let mut client = MockAgentPlatformApi::new(); + client.expect_get_sandbox().returning(move |_, id| { + // Paused on the first read, running once resumed. + if reads.fetch_add(1, Ordering::SeqCst) == 0 { + Ok(sandbox_in_state(id, "STATE_PAUSED")) + } else { + Ok(sandbox_in_state(id, "STATE_RUNNING")) + } + }); + client + .expect_resume() + .times(1) + .returning(|_, _| Ok(done_op(serde_json::json!({})))); + client + .expect_execute() + .withf(|_, _, input| op_of(input) == "health") + .returning(|_, _, _| Ok(health_reply())); + client.expect_create_sandbox().never(); + client.expect_delete_sandbox().never(); + + let session = provider(client) + .get_or_create(CreateSessionRequest { + session_id: Some("paused".to_string()), + ..Default::default() + }) + .await + .expect("a suspended session is resumed and returned"); + assert_eq!(session.session_id, "paused"); + assert_eq!(session.state, SandboxSessionState::Running); + // The reconnect path the capability flip promises: a woken session carries a real generation + // read from the container it came back on, not the unprobed sentinel. + assert_ne!( + session.generation, NO_GENERATION, + "a woken session carries its container generation" + ); +} + +#[tokio::test] +async fn get_or_create_fails_rather_than_leaking_a_resume_it_cannot_roll_back() { + let mut client = MockAgentPlatformApi::new(); + // Paused before the wake and paused after it: the wake never brought the session up. + client + .expect_get_sandbox() + .returning(|_, id| Ok(sandbox_in_state(id, "STATE_PAUSED"))); + client + .expect_resume() + .times(1) + .returning(|_, _| Ok(done_op(serde_json::json!({})))); + // The compensating suspend fails, so the woken session cannot be put back to sleep. + client + .expect_pause() + .times(1) + .returning(|_, _| Err(not_found())); + client + .expect_execute() + .returning(|_, _, _| Ok(health_reply())); + // A second live sandbox must never be provisioned beside the one this call woke. + client.expect_create_sandbox().never(); + client.expect_delete_sandbox().never(); + + let error = provider(client) + .get_or_create(CreateSessionRequest { + session_id: Some("paused".to_string()), + ..Default::default() + }) + .await + .expect_err("a resume that cannot be rolled back must fail, not leak a live session"); + assert!( + error.to_string().contains("paused"), + "the failure names the woken session so it stays identifiable: {error}" + ); +} + +// ---- generation and health ------------------------------------------------------------------- + +/// The generation a `get` reports for a running session answering with `boot_id`. +async fn generation_for_boot(boot_id: &'static str) -> u64 { + let mut client = MockAgentPlatformApi::new(); + client + .expect_get_sandbox() + .returning(|_, id| Ok(sandbox_in_state(id, "STATE_RUNNING"))); + client + .expect_execute() + .returning(move |_, _, _| Ok(health_reply_with_boot(boot_id))); + + provider(client) + .get("s1") + .await + .expect("a running session") + .expect("a present session") + .generation +} + +/// The generation follows the container boot id: it changes when the container is replaced and is +/// stable without a replacement, across separate reads. Mutation check: make +/// `generation_from_boot_id` return a constant and the `assert_ne` below goes red — a reconnect +/// test that could not see a replaced container is the exact failure this backend has. +#[tokio::test] +async fn generation_tracks_the_container_boot_id() { + let first = generation_for_boot("boot-id-aaaa").await; + let replaced = generation_for_boot("boot-id-bbbb").await; + let same = generation_for_boot("boot-id-aaaa").await; + + assert_ne!( + first, replaced, + "a replaced container changes the generation" + ); + assert_eq!( + first, same, + "the same container keeps its generation across separate reads" + ); + assert_ne!( + first, NO_GENERATION, + "a probed running session carries a real generation" + ); +} + +/// A running record whose agent reports an empty boot id has no identity to reconnect to, so `get` +/// refuses it. Mutation check: drop the emptiness guard in `probe_agent` and this returns +/// `Some(Running)` instead of the unreachable error. +#[tokio::test] +async fn get_refuses_an_agent_that_reports_an_empty_boot_id() { + let mut client = MockAgentPlatformApi::new(); + client + .expect_get_sandbox() + .returning(|_, id| Ok(sandbox_in_state(id, "STATE_RUNNING"))); + client + .expect_execute() + .returning(|_, _, _| Ok(health_reply_with_boot(""))); + + let error = provider(client) + .get("s1") + .await + .expect_err("an empty boot id is no container identity"); + assert_eq!(error.code, "SANDBOX_UNREACHABLE", "{error}"); +} + +/// A health reply that omits the boot id entirely is unreadable, so the session is not reported as +/// usable. Mutation check: make `Health.boot_id` an `Option` without a guard and this returns +/// `Some(Running)`. +#[tokio::test] +async fn get_refuses_an_agent_whose_health_omits_the_boot_id() { + let mut client = MockAgentPlatformApi::new(); + client + .expect_get_sandbox() + .returning(|_, id| Ok(sandbox_in_state(id, "STATE_RUNNING"))); + client.expect_execute().returning(|_, _, _| { + Ok(serde_json::to_vec(&serde_json::json!({ "protocolVersion": 1 })).expect("serializes")) + }); + + let error = provider(client) + .get("s1") + .await + .expect_err("a health reply without a boot id is not usable"); + assert_eq!(error.code, "SANDBOX_UNREACHABLE", "{error}"); +} + +/// A wedged agent that accepts the probe and never answers must not hang `get`; the probe budget +/// cuts it off and `get` returns unreachable. `start_paused` advances the clock to the budget +/// rather than sleeping in real time. Mutation check: drop the `tokio::time::timeout` in +/// `probe_agent` and the clock instead advances to the stub's long sleep, whose `unreachable!` +/// then panics the test — red either way. +#[tokio::test(start_paused = true)] +async fn get_does_not_hang_on_a_wedged_agent() { + let error = provider_from(Arc::new(WedgedAgent)) + .get("s1") + .await + .expect_err("a wedged agent is unreachable, not a hang"); + assert_eq!(error.code, "SANDBOX_UNREACHABLE", "{error}"); +} + +/// A client whose sandbox reads RUNNING but whose `execute` never answers, standing in for an agent +/// that accepts the health probe and then wedges. Only the two methods `get` reaches are real; the +/// rest are unreachable in this test. +#[derive(Debug)] +struct WedgedAgent; + +#[async_trait] +impl AgentPlatformApi for WedgedAgent { + async fn get_sandbox(&self, _engine: &str, sandbox: &str) -> ClientResult { + Ok(sandbox_in_state(sandbox, "STATE_RUNNING")) + } + + async fn execute(&self, _engine: &str, _sandbox: &str, _input: &[u8]) -> ClientResult> { + // Far past any probe budget; the budget must return before this does. + tokio::time::sleep(Duration::from_secs(86_400)).await; + unreachable!("the probe budget should fire before a wedged execute returns") + } + + async fn create_engine(&self, _display_name: &str) -> ClientResult { + unimplemented!() + } + async fn delete_engine(&self, _engine: &str) -> ClientResult<()> { + unimplemented!() + } + async fn list_engines(&self) -> ClientResult> { + unimplemented!() + } + async fn create_template( + &self, + _engine: &str, + _template: SandboxEnvironmentTemplate, + ) -> ClientResult { + unimplemented!() + } + async fn get_template( + &self, + _engine: &str, + _template: &str, + ) -> ClientResult { + unimplemented!() + } + async fn delete_template(&self, _engine: &str, _template: &str) -> ClientResult<()> { + unimplemented!() + } + async fn list_templates(&self, _engine: &str) -> ClientResult> { + unimplemented!() + } + async fn create_sandbox( + &self, + _engine: &str, + _request: SandboxCreateRequest, + ) -> ClientResult { + unimplemented!() + } + async fn list_sandboxes(&self, _engine: &str) -> ClientResult> { + unimplemented!() + } + async fn delete_sandbox(&self, _engine: &str, _sandbox: &str) -> ClientResult<()> { + unimplemented!() + } + async fn pause(&self, _engine: &str, _sandbox: &str) -> ClientResult { + unimplemented!() + } + async fn resume(&self, _engine: &str, _sandbox: &str) -> ClientResult { + unimplemented!() + } + async fn snapshot( + &self, + _engine: &str, + _sandbox: &str, + _display_name: &str, + ) -> ClientResult { + unimplemented!() + } + async fn get_operation(&self, _name: &str) -> ClientResult { + unimplemented!() + } +} + +// ---- list ------------------------------------------------------------------------------------- + +#[tokio::test] +async fn list_maps_sandboxes_to_sessions() { + let mut client = MockAgentPlatformApi::new(); + client.expect_list_sandboxes().returning(|_| { + Ok(vec![ + sandbox_in_state("a", "STATE_RUNNING"), + sandbox_in_state("b", "STATE_PAUSED"), + ]) + }); + + let sessions = provider(client) + .list() + .await + .expect("list is supported here"); + assert_eq!(sessions.len(), 2); + assert_eq!(sessions[0].session_id, "a"); + assert_eq!(sessions[0].state, SandboxSessionState::Running); + assert_eq!(sessions[1].session_id, "b"); + assert_eq!(sessions[1].state, SandboxSessionState::Suspended); +} + +// ---- run_command: cap threshold --------------------------------------------------------------- + +/// A command inside the synchronous window runs through `exec` and starts no job. Mutation check: +/// invert the `deadline <= MAX_SYNCHRONOUS_DEADLINE` test and the `jobStart` panic below fires. +#[tokio::test] +async fn a_short_command_runs_synchronously_without_a_job() { + let mut client = MockAgentPlatformApi::new(); + client + .expect_execute() + .returning(|_, _, input| match op_of(input).as_str() { + "exec" => Ok(ndjson(&[stdout_frame(0, b"hi"), exit_frame(0)])), + "jobStart" => panic!("a short command must not start a job"), + other => panic!("unexpected op {other}"), + }); + + let frames: Vec<_> = provider(client) + .run_command( + "s1", + RunCommandRequest { + command: vec!["/bin/echo".to_string(), "hi".to_string()], + working_directory: None, + env: BTreeMap::new(), + deadline: Duration::from_secs(5), + }, + ) + .await + .expect("the command runs") + .collect() + .await; + + assert!( + matches!(frames.first(), Some(Ok(CommandOutput::Stdout { data, .. })) if data == b"hi") + ); + assert!(matches!( + frames.last(), + Some(Ok(CommandOutput::Exit { code: 0, .. })) + )); +} + +/// A command longer than the synchronous window is detached as a job and polled to its exit; no +/// `exec` is sent. The poll cursor advances so a second poll asks for frames after the first. +#[tokio::test(start_paused = true)] +async fn a_long_command_uses_the_job_path() { + let polls = Arc::new(AtomicUsize::new(0)); + let mut client = MockAgentPlatformApi::new(); + client + .expect_execute() + .returning(move |_, _, input| match op_of(input).as_str() { + "jobStart" => Ok(serde_json::to_vec(&serde_json::json!({ "jobId": "j1" })).unwrap()), + "jobPoll" => { + let poll = polls.fetch_add(1, Ordering::SeqCst); + if poll == 0 { + Ok(serde_json::to_vec(&serde_json::json!({ + "running": true, + "frames": [stdout_frame(0, b"work")], + })) + .unwrap()) + } else { + Ok(serde_json::to_vec(&serde_json::json!({ + "running": false, + "frames": [], + "exitCode": 0, + "truncated": false, + })) + .unwrap()) + } + } + "exec" => panic!("a long command must not run synchronously"), + other => panic!("unexpected op {other}"), + }); + + let frames: Vec<_> = provider(client) + .run_command( + "s1", + RunCommandRequest { + command: vec!["/bin/sleep".to_string(), "40".to_string()], + working_directory: None, + env: BTreeMap::new(), + deadline: Duration::from_secs(60), + }, + ) + .await + .expect("the job starts") + .collect() + .await; + + assert!( + matches!(frames.first(), Some(Ok(CommandOutput::Stdout { data, .. })) if data == b"work") + ); + assert!(matches!( + frames.last(), + Some(Ok(CommandOutput::Exit { code: 0, .. })) + )); +} + +/// A job the agent reports as failing (a deadline, a spawn failure) carries an error object with no +/// exit code, and the provider surfaces it rather than fabricating a clean exit. +#[tokio::test(start_paused = true)] +async fn a_job_error_object_becomes_a_stream_error() { + let mut client = MockAgentPlatformApi::new(); + client + .expect_execute() + .returning(|_, _, input| match op_of(input).as_str() { + "jobStart" => Ok(serde_json::to_vec(&serde_json::json!({ "jobId": "j1" })).unwrap()), + "jobPoll" => Ok(serde_json::to_vec(&serde_json::json!({ + "running": false, + "frames": [], + "error": { "code": "deadlineExceeded", "message": "exceeded its 60000ms deadline" }, + })) + .unwrap()), + other => panic!("unexpected op {other}"), + }); + + let frames: Vec<_> = provider(client) + .run_command( + "s1", + RunCommandRequest { + command: vec!["/bin/sleep".to_string(), "99".to_string()], + working_directory: None, + env: BTreeMap::new(), + deadline: Duration::from_secs(60), + }, + ) + .await + .expect("the job starts") + .collect() + .await; + + let error = frames + .last() + .expect("a terminal item") + .as_ref() + .expect_err("an error object is a failure"); + assert!(error.to_string().contains("deadlineExceeded"), "{error}"); +} + +/// The write-once / read-retries split, pinned in one test so neither half can pass on the +/// absence of the other. A mutating `:execute` that fails is delivered exactly once — it may have +/// already run, and re-sending it could double a side effect — while a read is polled until the +/// session settles. Mutation check: give `execute_op` a retry loop and `execute`'s `.times(1)` +/// fails; remove `terminate`'s poll and the read count collapses to one. +#[tokio::test(start_paused = true)] +async fn a_failed_command_is_delivered_once_where_a_read_still_retries() { + let reads = Arc::new(AtomicUsize::new(0)); + let reads_seen = reads.clone(); + let mut client = MockAgentPlatformApi::new(); + + client + .expect_execute() + .times(1) + .returning(|_, _, _| Err(execute_refused())); + client + .expect_delete_sandbox() + .times(1) + .returning(|_, _| Ok(())); + client.expect_get_sandbox().returning(move |_, id| { + // Present on the first two reads, gone on the third: the poll, not one read, decides. + if reads.fetch_add(1, Ordering::SeqCst) < 2 { + Ok(sandbox_in_state(id, "STATE_RUNNING")) + } else { + Err(not_found()) + } + }); + + let sut = provider(client); + + let Err(command) = sut + .run_command( + "s1", + RunCommandRequest { + command: vec!["/bin/true".to_string()], + working_directory: None, + env: BTreeMap::new(), + deadline: Duration::from_secs(5), + }, + ) + .await + else { + panic!("a mutating command whose execute fails is refused, not retried into success"); + }; + assert_eq!(command.code, "SANDBOX_COMMAND_FAILED", "{command}"); + + sut.terminate("s1") + .await + .expect("the poll confirms the session is gone"); + + assert!( + reads_seen.load(Ordering::SeqCst) > 1, + "confirming the session gone took more than one read, so the read path retries" + ); +} + +/// Refuse-don't-destroy: a command against a gone session is refused and nothing is deleted. +/// Mutation check: add a `delete_sandbox` to `run_command`'s failure path and `.never()` fails. +#[tokio::test] +async fn a_command_on_a_gone_session_is_refused_and_deletes_nothing() { + let mut client = MockAgentPlatformApi::new(); + client + .expect_execute() + .returning(|_, _, _| Err(not_found())); + client.expect_delete_sandbox().never(); + + // The synchronous exec fails before a stream exists, so the refusal is the call's own error. + let Err(error) = provider(client) + .run_command( + "s1", + RunCommandRequest { + command: vec!["/bin/true".to_string()], + working_directory: None, + env: BTreeMap::new(), + deadline: Duration::from_secs(5), + }, + ) + .await + else { + panic!("a command against a gone session is refused"); + }; + assert_eq!(error.code, "SANDBOX_COMMAND_FAILED", "{error}"); + assert!(error.to_string().contains("sessionGone"), "{error}"); +} + +#[tokio::test] +async fn a_command_without_a_deadline_or_program_is_refused() { + // `run_command`'s Ok is a stream, which is not `Debug`, so the error is matched out by hand. + let Err(empty) = provider(MockAgentPlatformApi::new()) + .run_command( + "s1", + RunCommandRequest { + command: vec![], + working_directory: None, + env: BTreeMap::new(), + deadline: Duration::from_secs(5), + }, + ) + .await + else { + panic!("an empty command is refused"); + }; + assert_eq!(empty.code, "INVALID_INPUT", "{empty}"); + + let Err(zero) = provider(MockAgentPlatformApi::new()) + .run_command( + "s1", + RunCommandRequest { + command: vec!["/bin/true".to_string()], + working_directory: None, + env: BTreeMap::new(), + deadline: Duration::ZERO, + }, + ) + .await + else { + panic!("a zero deadline is refused"); + }; + assert!(zero.to_string().contains("deadline"), "{zero}"); +} + +// ---- files ------------------------------------------------------------------------------------ + +/// writeFile sends the agent's `contentsBase64` field (never `contents`) and treats an empty body +/// as success. Mutation check: rename the field to `contents` and the `withf` assertion fails. +#[tokio::test] +async fn write_files_sends_contents_base64_and_accepts_an_empty_body() { + let mut client = MockAgentPlatformApi::new(); + client + .expect_execute() + .withf(|_, _, input| { + let value: serde_json::Value = serde_json::from_slice(input).unwrap(); + op_of(input) == "writeFile" + && value.get("contentsBase64").and_then(|v| v.as_str()) + == Some(&BASE64.encode(b"data")) + && value.get("contents").is_none() + }) + .times(1) + .returning(|_, _, _| Ok(Vec::new())); + + provider(client) + .write_files( + "s1", + BTreeMap::from([("a.txt".to_string(), b"data".to_vec())]), + ) + .await + .expect("an empty body is a successful write"); +} + +#[tokio::test] +async fn mkdir_accepts_an_empty_body() { + let mut client = MockAgentPlatformApi::new(); + client + .expect_execute() + .withf(|_, _, input| op_of(input) == "mkdir") + .returning(|_, _, _| Ok(Vec::new())); + + provider(client) + .mkdir("s1", "out") + .await + .expect("mkdir succeeds on an empty body"); +} + +#[tokio::test] +async fn read_file_decodes_the_agent_reply() { + let mut client = MockAgentPlatformApi::new(); + client + .expect_execute() + .withf(|_, _, input| op_of(input) == "readFile") + .returning(|_, _, _| { + Ok(serde_json::to_vec( + &serde_json::json!({ "contentsBase64": BASE64.encode(b"file body") }), + ) + .unwrap()) + }); + + let contents = provider(client) + .read_file("s1", "a.txt") + .await + .expect("read succeeds"); + assert_eq!(contents, b"file body"); +} + +// ---- suspend / resume / snapshot -------------------------------------------------------------- + +#[tokio::test] +async fn suspend_and_resume_await_their_operations() { + let mut client = MockAgentPlatformApi::new(); + client + .expect_pause() + .times(1) + .returning(|_, _| Ok(done_op(serde_json::json!({})))); + client + .expect_resume() + .times(1) + .returning(|_, _| Ok(done_op(serde_json::json!({})))); + + let provider = provider(client); + provider.suspend("s1").await.expect("suspend completes"); + provider.resume("s1").await.expect("resume completes"); +} + +#[tokio::test] +async fn snapshot_returns_the_snapshot_name() { + let mut client = MockAgentPlatformApi::new(); + let name = + "projects/p/locations/us-central1/reasoningEngines/eng1/sandboxEnvironmentSnapshots/snap1"; + client + .expect_snapshot() + .withf(|engine, sandbox, display| { + engine == "eng1" && sandbox == "s1" && !display.is_empty() + }) + .returning(move |_, _, _| Ok(done_op(serde_json::json!({ "name": name })))); + + let returned = provider(client) + .snapshot("s1") + .await + .expect("snapshot completes"); + assert_eq!(returned, name); +} + +// ---- terminate -------------------------------------------------------------------------------- + +/// terminate polls the accepted delete to not-found before it reports containment. Mutation check: +/// return `Ok(())` right after `delete_sandbox` and the "still present" test below passes wrongly. +#[tokio::test(start_paused = true)] +async fn terminate_confirms_by_polling_to_not_found() { + let reads = Arc::new(AtomicUsize::new(0)); + let mut client = MockAgentPlatformApi::new(); + client + .expect_delete_sandbox() + .times(1) + .returning(|_, _| Ok(())); + client.expect_get_sandbox().returning(move |_, id| { + // Present on the first read, gone on the second: an accepted delete is not a completed one. + if reads.fetch_add(1, Ordering::SeqCst) == 0 { + Ok(sandbox_in_state(id, "STATE_RUNNING")) + } else { + Err(not_found()) + } + }); + + provider(client) + .terminate("s1") + .await + .expect("a session that goes absent is confirmed gone"); +} + +#[tokio::test(start_paused = true)] +async fn terminate_reports_unconfirmed_when_the_session_stays_present() { + let mut client = MockAgentPlatformApi::new(); + client.expect_delete_sandbox().returning(|_, _| Ok(())); + client + .expect_get_sandbox() + .returning(|_, id| Ok(sandbox_in_state(id, "STATE_RUNNING"))); + + let error = provider(client) + .terminate("s1") + .await + .expect_err("a session still present after the poll is not contained"); + assert!( + error.to_string().contains("may still be running"), + "{error}" + ); +} + +// ---- unit guards ------------------------------------------------------------------------------ + +/// AllowDomains is refused naming the sandbox and both accepted modes; the two expressible modes +/// map to the boolean. Mutation check: return `Ok` for AllowDomains and this fails. +#[test] +fn egress_refuses_domain_scoping_and_names_the_modes() { + let error = egress_control_config( + "sbx-7", + &SandboxEgress::AllowDomains { + domains: vec!["x.io".into()], + }, + ) + .expect_err("domain-scoped egress has no representation"); + assert_eq!(error.code, "INVALID_INPUT", "{error}"); + let rendered = error.to_string(); + assert!(rendered.contains("sbx-7"), "names the sandbox: {rendered}"); + assert!( + rendered.contains("allow") && rendered.contains("deny"), + "names both modes: {rendered}" + ); + + assert_eq!( + egress_control_config("s", &SandboxEgress::Deny) + .expect("deny maps") + .internet_access, + Some(false) + ); + assert_eq!( + egress_control_config("s", &SandboxEgress::Allow) + .expect("allow maps") + .internet_access, + Some(true) + ); +} + +/// A session id that could address another sandbox never reaches a URL. Mutation check: weaken +/// `is_addressable_id` to accept '/' and the traversal ids below stop being refused. +#[tokio::test] +async fn a_session_id_that_could_escape_its_sandbox_is_refused() { + for id in [ + "../other", + "a/b", + "has space", + "", + "with?query", + "with#frag", + ] { + let error = provider(MockAgentPlatformApi::new()) + .get(id) + .await + .expect_err(&format!("'{id}' must be refused before it reaches a URL")); + assert_eq!(error.code, "INVALID_INPUT", "'{id}': {error}"); + } +} + +/// An output stream that ends without a terminal frame is a transport failure, not a command that +/// finished. Mutation check: drop the `saw_terminal` trailing item and this reads as success. +#[test] +fn an_output_without_a_terminal_frame_is_an_unknown_outcome() { + let frames = parse_exec_frames(&ndjson(&[stdout_frame(0, b"partial")])).expect("frames parse"); + assert_eq!(frames.len(), 2); + frames[0].as_ref().expect("the stdout frame still arrives"); + let error = frames[1] + .as_ref() + .expect_err("a truncated stream is not success"); + assert!( + error.to_string().contains("without a terminal frame"), + "{error}" + ); +} + +/// A body that is not frames at all is the agent's refusal, not a command's output. +#[test] +fn a_non_frame_body_is_reported_as_a_refusal() { + let error = parse_exec_frames(b"forbidden: a capability is required") + .expect_err("an error body is not a stream"); + assert_eq!(error.code, "SANDBOX_COMMAND_FAILED", "{error}"); +} + +/// The not-found classification is read off the source chain, where the client leaves it, not off +/// the outer `RequestFailed` variant. +#[test] +fn not_found_is_read_from_the_source_chain() { + assert!( + is_not_found(¬_found()), + "a wrapped 404 is a gone session" + ); + assert!( + !is_not_found(&execute_refused()), + "an ordinary execute failure is not a gone session" + ); +} + +#[test] +fn the_engine_is_reduced_to_a_bare_segment() { + let provider = provider(MockAgentPlatformApi::new()); + assert_eq!( + provider.engine(), + "eng1", + "the full resource name is reduced to the engine id" + ); +} diff --git a/crates/alien-bindings/src/providers/sandbox/mod.rs b/crates/alien-bindings/src/providers/sandbox/mod.rs index 45aad507f..e2fdb7ddd 100644 --- a/crates/alien-bindings/src/providers/sandbox/mod.rs +++ b/crates/alien-bindings/src/providers/sandbox/mod.rs @@ -14,7 +14,7 @@ pub mod aws; pub mod azure; #[cfg(feature = "gcp")] -pub mod gcp; +pub mod gcp_agent_platform; #[cfg(feature = "kubernetes")] pub mod kubernetes; diff --git a/crates/alien-build/src/sandbox_bundle.rs b/crates/alien-build/src/sandbox_bundle.rs index 72e0a800b..13f5f1873 100644 --- a/crates/alien-build/src/sandbox_bundle.rs +++ b/crates/alien-build/src/sandbox_bundle.rs @@ -42,7 +42,11 @@ pub const AGENT_FILENAME: &str = "alien-sandbox-agent"; pub fn dockerfile(base_image: &str) -> Result { // Checked here rather than by the callers: this is the one place the value crosses into // generated content, and a reference carrying a newline writes its own Dockerfile directives. - if base_image.is_empty() || base_image.chars().any(|c| c.is_whitespace() || c.is_control()) { + if base_image.is_empty() + || base_image + .chars() + .any(|c| c.is_whitespace() || c.is_control()) + { return Err(AlienError::new(ErrorData::BuildConfigInvalid { message: format!("base image reference '{base_image}' is not a valid image reference"), })); @@ -71,7 +75,8 @@ ENV ALIEN_SANDBOX_ROOT={SESSION_ROOT} \ ALIEN_SANDBOX_PORT={AGENT_PORT} \ ALIEN_SANDBOX_AUTHORIZATION=transport \ ALIEN_SANDBOX_EXEC_UID={EXEC_UID} \ - ALIEN_SANDBOX_EXEC_GID={EXEC_UID} + ALIEN_SANDBOX_EXEC_GID={EXEC_UID} \ + ALIEN_SANDBOX_ISOLATION=uid-split EXPOSE {AGENT_PORT} ENTRYPOINT ["{AGENT_PATH}"] @@ -109,9 +114,12 @@ pub fn write_bundle(destination: &Path, base_image: &str, agent_binary: &Path) - .into_alien_error() .context(failed("write", destination))?; - zip.start_file("Dockerfile", SimpleFileOptions::default().unix_permissions(0o644)) - .into_alien_error() - .context(failed("write", destination))?; + zip.start_file( + "Dockerfile", + SimpleFileOptions::default().unix_permissions(0o644), + ) + .into_alien_error() + .context(failed("write", destination))?; zip.write_all(dockerfile(base_image)?.as_bytes()) .into_alien_error() .context(failed("write", destination))?; @@ -147,7 +155,6 @@ mod tests { use super::*; - /// The properties below are the image's half of the supervisor boundary. A base image is /// caller-supplied, so these assertions are about what Alien adds on top of it. fn rendered() -> String { @@ -165,7 +172,9 @@ mod tests { fn the_agent_binary_is_root_owned_and_not_writable_by_the_exec_uid() { let dockerfile = rendered(); assert!( - dockerfile.contains(&format!("COPY --chown=0:0 --chmod=0755 {AGENT_FILENAME} {AGENT_PATH}")), + dockerfile.contains(&format!( + "COPY --chown=0:0 --chmod=0755 {AGENT_FILENAME} {AGENT_PATH}" + )), "the agent must be root-owned and mode 0755:\n{dockerfile}" ); } @@ -189,6 +198,7 @@ mod tests { &format!("ALIEN_SANDBOX_EXEC_UID={EXEC_UID}"), &format!("ALIEN_SANDBOX_EXEC_GID={EXEC_UID}"), &"ALIEN_SANDBOX_AUTHORIZATION=transport".to_string(), + &"ALIEN_SANDBOX_ISOLATION=uid-split".to_string(), ] { assert!(dockerfile.contains(expected.as_str()), "missing {expected}"); } @@ -232,7 +242,10 @@ mod tests { assert_eq!(names, vec!["Dockerfile", AGENT_FILENAME]); for name in &names { - assert!(!name.contains('/'), "the archive must be flat, found '{name}'"); + assert!( + !name.contains('/'), + "the archive must be flat, found '{name}'" + ); } let mut dockerfile_entry = archive.by_name("Dockerfile").expect("Dockerfile entry"); diff --git a/crates/alien-core/src/bin/schema_exporter.rs b/crates/alien-core/src/bin/schema_exporter.rs index 2e25f7dca..f7d3c2c2f 100644 --- a/crates/alien-core/src/bin/schema_exporter.rs +++ b/crates/alien-core/src/bin/schema_exporter.rs @@ -213,7 +213,6 @@ use utoipa::OpenApi; GcpArtifactRegistryImportData, GcpComputeClusterImportData, GcpPostgresImportData, - GcpSandboxImportData, AzureStorageImportData, AzureWorkerImportData, AzureQueueImportData, diff --git a/crates/alien-core/src/bindings/mod.rs b/crates/alien-core/src/bindings/mod.rs index b97386e1c..894fa5e33 100644 --- a/crates/alien-core/src/bindings/mod.rs +++ b/crates/alien-core/src/bindings/mod.rs @@ -54,8 +54,8 @@ pub use queue::{ LocalQueueBinding, PubSubQueueBinding, QueueBinding, ServiceBusQueueBinding, SqsQueueBinding, }; pub use sandbox::{ - AwsSandboxBinding, AzureSandboxBinding, GcpSandboxBinding, KubernetesSandboxBinding, - LocalSandboxBinding, SandboxBinding, + AwsSandboxBinding, AzureSandboxBinding, GcpAgentPlatformSandboxBinding, + KubernetesSandboxBinding, LocalSandboxBinding, SandboxBinding, }; pub use service_account::{ AwsServiceAccountBinding, AzureServiceAccountBinding, GcpServiceAccountBinding, diff --git a/crates/alien-core/src/bindings/sandbox.rs b/crates/alien-core/src/bindings/sandbox.rs index 209cfd69b..c8dee0977 100644 --- a/crates/alien-core/src/bindings/sandbox.rs +++ b/crates/alien-core/src/bindings/sandbox.rs @@ -22,9 +22,9 @@ pub enum SandboxBinding { /// Azure Container Apps Sandboxes #[serde(rename = "sandbox-azure")] Azure(AzureSandboxBinding), - /// Cloud Run sandboxes, launched inside the workload's own instance - #[serde(rename = "sandbox-gcp")] - Gcp(GcpSandboxBinding), + /// GCP Agent Platform sandboxes, created as sessions under a durable Agent Engine + #[serde(rename = "sandbox-gcp-agent-platform")] + GcpAgentPlatform(GcpAgentPlatformSandboxBinding), /// Sandbox pods under a sandboxed runtime class #[serde(rename = "sandbox-kubernetes")] Kubernetes(KubernetesSandboxBinding), @@ -112,19 +112,26 @@ pub struct AzureSandboxBinding { pub disk_image: BindingValue, } -/// GCP sandbox binding configuration. +/// GCP Agent Platform sandbox binding configuration. /// -/// There is no durable parent to address: a Cloud Run sandbox is a subprocess of the workload's -/// own instance, created through a CLI on the container's filesystem. +/// Sessions have a durable parent to address: an Agent Engine provisioned at deploy and reached +/// through a regional endpoint. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct GcpSandboxBinding { - /// Path to the sandbox CLI inside the Cloud Run container - pub launcher_path: BindingValue, - /// Whether sandboxes may reach the network. Carried in the binding rather than passed per - /// create: the launcher takes `--allow-egress` per sandbox, and a limit the application - /// supplies is a limit it can decline to supply. - pub allow_egress: BindingValue, +pub struct GcpAgentPlatformSandboxBinding { + /// Agent Engine that parents every session. Sessions are created and enumerated under it, + /// so a binding without it can neither reach nor reap them. + pub engine: BindingValue, + /// Template every session is created from. It carries the image digest, the ceilings and the + /// egress rules, so a session created without it runs an unpinned image with none applied. + pub template: BindingValue, + /// Region selecting the regional aiplatform endpoint. The engine is regional with no global + /// alias, so the endpoint cannot be derived without it. + pub region: BindingValue, + /// Seconds a session may live, from the declaration. Carried only when one was declared; an + /// absent value takes the service default, which is why it is not defaulted here. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_ttl_seconds: Option, } /// Kubernetes sandbox binding configuration. @@ -202,14 +209,18 @@ impl SandboxBinding { }) } - /// Creates a GCP sandbox binding. - pub fn gcp( - launcher_path: impl Into>, - allow_egress: impl Into>, + /// Creates a GCP Agent Platform sandbox binding. + pub fn gcp_agent_platform( + engine: impl Into>, + template: impl Into>, + region: impl Into>, + session_ttl_seconds: Option, ) -> Self { - Self::Gcp(GcpSandboxBinding { - launcher_path: launcher_path.into(), - allow_egress: allow_egress.into(), + Self::GcpAgentPlatform(GcpAgentPlatformSandboxBinding { + engine: engine.into(), + template: template.into(), + region: region.into(), + session_ttl_seconds, }) } @@ -268,7 +279,12 @@ mod tests { SandboxEgress::Deny, None, ), - SandboxBinding::gcp("/usr/local/gcp/bin/sandbox", false), + SandboxBinding::gcp_agent_platform( + "projects/p/locations/us-central1/reasoningEngines/1", + "projects/p/locations/us-central1/sandboxTemplates/agent", + "us-central1", + Some(3600), + ), SandboxBinding::kubernetes( "alien-sandboxes", "gvisor", @@ -291,12 +307,57 @@ mod tests { } } + /// The Agent Platform binding carries an egress-bearing template, so there is no safe default + /// for a missing required field: a binding stripped of one must fail to load rather than + /// deserialize into a session with no image, no limits and open egress. `sessionTtlSeconds` is + /// the one field that may be absent, and its absence must still parse. + #[test] + fn agent_platform_required_fields_have_no_default() { + let binding = SandboxBinding::gcp_agent_platform( + "projects/p/locations/us-central1/reasoningEngines/1", + "projects/p/locations/us-central1/sandboxTemplates/agent", + "us-central1", + Some(3600), + ); + let full = serde_json::to_value(&binding).expect("serializes"); + + for required in ["engine", "template", "region"] { + let mut stripped = full.clone(); + stripped + .as_object_mut() + .expect("binding serializes as an object") + .remove(required) + .expect("the field is present before it is stripped"); + serde_json::from_value::(stripped) + .expect_err(&format!("a binding missing '{required}' must not load")); + } + + let mut without_ttl = full; + without_ttl + .as_object_mut() + .expect("binding serializes as an object") + .remove("sessionTtlSeconds") + .expect("the fixture set a ttl"); + let restored: SandboxBinding = + serde_json::from_value(without_ttl).expect("an absent ttl still loads"); + assert_eq!( + restored, + SandboxBinding::gcp_agent_platform( + "projects/p/locations/us-central1/reasoningEngines/1", + "projects/p/locations/us-central1/sandboxTemplates/agent", + "us-central1", + None, + ), + "an absent ttl deserializes as None" + ); + } + #[test] fn service_tags_are_prefixed_and_distinct() { let tags: Vec = vec![ SandboxBinding::aws("a", "1", "r"), SandboxBinding::azure("g", "e", "r", "rg", "ubuntu", SandboxEgress::Deny, None), - SandboxBinding::gcp("p", true), + SandboxBinding::gcp_agent_platform("e", "t", "us-central1", None), SandboxBinding::kubernetes("n", "gvisor", "s", "http://op:8080", "k", "/t"), SandboxBinding::local("u", "k", "t"), ] diff --git a/crates/alien-core/src/import/data/gcp/mod.rs b/crates/alien-core/src/import/data/gcp/mod.rs index 7d6967d3b..01ba92083 100644 --- a/crates/alien-core/src/import/data/gcp/mod.rs +++ b/crates/alien-core/src/import/data/gcp/mod.rs @@ -9,7 +9,6 @@ pub mod postgres; pub mod queue; pub mod remote_bindings; pub mod remote_stack_management; -pub mod sandbox; pub mod service_account; pub mod service_activation; pub mod storage; @@ -27,7 +26,6 @@ pub use postgres::*; pub use queue::*; pub use remote_bindings::*; pub use remote_stack_management::*; -pub use sandbox::GcpSandboxImportData; pub use service_account::*; pub use service_activation::*; pub use storage::*; diff --git a/crates/alien-core/src/import/data/gcp/sandbox.rs b/crates/alien-core/src/import/data/gcp/sandbox.rs deleted file mode 100644 index 806fa2509..000000000 --- a/crates/alien-core/src/import/data/gcp/sandbox.rs +++ /dev/null @@ -1,19 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// GCP Sandbox ImportData. -/// -/// A Cloud Run sandbox has no durable parent: it is a nested gVisor sandbox started by a launcher -/// binary Cloud Run injects into the container, so there is no group, image or endpoint for setup -/// to hand over. What the runtime needs is the launcher's path, and it is carried here rather than -/// hardcoded in the provider so a change to where Cloud Run mounts it is a data change. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct GcpSandboxImportData { - /// Path to the sandbox CLI Cloud Run injects when the container sets `sandboxLauncher`. - pub launcher_path: String, - /// Whether sessions may reach the network. Taken from the declaration rather than left to the - /// application: the launcher decides egress per sandbox at create time. - pub allow_egress: bool, -} diff --git a/crates/alien-core/src/import/data/mod.rs b/crates/alien-core/src/import/data/mod.rs index 9ad9c6980..05b0c8fee 100644 --- a/crates/alien-core/src/import/data/mod.rs +++ b/crates/alien-core/src/import/data/mod.rs @@ -29,7 +29,7 @@ pub use gcp::{ GcpAiImportData, GcpArtifactRegistryImportData, GcpBuildImportData, GcpComputeClusterImportData, GcpKeyImportData, GcpKvImportData, GcpNetworkImportData, GcpPostgresImportData, GcpQueueImportData, GcpRemoteBindingsImportData, - GcpRemoteStackManagementImportData, GcpSandboxImportData, GcpServiceAccountImportData, + GcpRemoteStackManagementImportData, GcpServiceAccountImportData, GcpServiceActivationImportData, GcpStorageImportData, GcpVaultImportData, GcpWorkerImportData, }; pub use kubernetes_cluster::{ @@ -186,7 +186,6 @@ mod schema_snapshots { ("gcp_network", schema::()), ("gcp_postgres", schema::()), ("gcp_queue", schema::()), - ("gcp_sandbox", schema::()), ( "gcp_remote_stack_management", schema::(), diff --git a/crates/alien-core/src/import/data/snapshots/alien_core__import__data__schema_snapshots__import_data_schemas.snap b/crates/alien-core/src/import/data/snapshots/alien_core__import__data__schema_snapshots__import_data_schemas.snap index 8b031874a..9bf4558ef 100644 --- a/crates/alien-core/src/import/data/snapshots/alien_core__import__data__schema_snapshots__import_data_schemas.snap +++ b/crates/alien-core/src/import/data/snapshots/alien_core__import__data__schema_snapshots__import_data_schemas.snap @@ -1870,26 +1870,6 @@ expression: schemas "title": "GcpQueueImportData", "type": "object" }, - "gcp_sandbox": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "GCP Sandbox ImportData.\n\nA Cloud Run sandbox has no durable parent: it is a nested gVisor sandbox started by a launcher binary Cloud Run injects into the container, so there is no group, image or endpoint for setup to hand over. What the runtime needs is the launcher's path, and it is carried here rather than hardcoded in the provider so a change to where Cloud Run mounts it is a data change.", - "properties": { - "allowEgress": { - "description": "Whether sessions may reach the network. Taken from the declaration rather than left to the application: the launcher decides egress per sandbox at create time.", - "type": "boolean" - }, - "launcherPath": { - "description": "Path to the sandbox CLI Cloud Run injects when the container sets `sandboxLauncher`.", - "type": "string" - } - }, - "required": [ - "allowEgress", - "launcherPath" - ], - "title": "GcpSandboxImportData", - "type": "object" - }, "gcp_remote_stack_management": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "GCP RemoteStackManagement ImportData — cross-project service account the manager impersonates.", diff --git a/crates/alien-core/src/resource.rs b/crates/alien-core/src/resource.rs index 6e1875177..57f4186f2 100644 --- a/crates/alien-core/src/resource.rs +++ b/crates/alien-core/src/resource.rs @@ -258,6 +258,10 @@ impl<'de> Deserialize<'de> for Resource { serde_json::from_value::(value) .map_err(serde::de::Error::custom)?, ), + "gcp_agent_platform_engine" => Box::new( + serde_json::from_value::(value) + .map_err(serde::de::Error::custom)?, + ), "azure_storage_account" => Box::new( serde_json::from_value::(value) .map_err(serde::de::Error::custom)?, @@ -300,6 +304,7 @@ impl<'de> Deserialize<'de> for Resource { "remote-stack-management", "resource-access", "azure_resource_group", + "gcp_agent_platform_engine", "azure_storage_account", "azure_container_apps_environment", "azure_service_bus_namespace", diff --git a/crates/alien-core/src/resource_links.rs b/crates/alien-core/src/resource_links.rs index 88dbcf0bb..9c5896266 100644 --- a/crates/alien-core/src/resource_links.rs +++ b/crates/alien-core/src/resource_links.rs @@ -242,6 +242,7 @@ mod tests { ("remote-stack-management", false), ("resource-access", false), ("azure_resource_group", false), + ("gcp_agent_platform_engine", false), ("azure_storage_account", false), ("azure_container_apps_environment", false), ("azure_service_bus_namespace", false), diff --git a/crates/alien-core/src/resources/gcp_agent_platform_engine.rs b/crates/alien-core/src/resources/gcp_agent_platform_engine.rs new file mode 100644 index 000000000..eca62b092 --- /dev/null +++ b/crates/alien-core/src/resources/gcp_agent_platform_engine.rs @@ -0,0 +1,95 @@ +use crate::error::{ErrorData, Result}; +use crate::resource::{ResourceDefinition, ResourceRef, ResourceType}; +use alien_error::AlienError; +use bon::Builder; +use serde::{Deserialize, Serialize}; +use std::any::Any; + +/// A Gemini Agent Platform reasoning engine: the durable parent that sandbox +/// environment templates and sessions hang under. One per sandbox, provisioned +/// once and addressed by the server-assigned id its controller records. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[builder(start_fn = new)] +pub struct GcpAgentPlatformEngine { + /// Identifier for the engine resource within the stack. + #[builder(start_fn)] + pub id: String, +} + +impl GcpAgentPlatformEngine { + /// The resource type identifier for Agent Platform reasoning engines. + pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("gcp_agent_platform_engine"); + + pub fn id(&self) -> &str { + &self.id + } + + /// The engine id for a sandbox: one engine per sandbox. Shared by the mutation that + /// synthesizes the engine and the template controller that reads it back as a dependency, so + /// the two cannot drift on the convention. + pub fn id_for_sandbox(sandbox_id: &str) -> String { + format!("{sandbox_id}-engine") + } +} + +impl ResourceDefinition for GcpAgentPlatformEngine { + fn get_resource_type(&self) -> ResourceType { + Self::RESOURCE_TYPE + } + + fn id(&self) -> &str { + &self.id + } + + fn get_dependencies(&self) -> Vec { + Vec::new() + } + + fn validate_update(&self, _new_config: &dyn ResourceDefinition) -> Result<()> { + Err(AlienError::new(ErrorData::InvalidResourceUpdate { + resource_id: self.id.clone(), + reason: "reasoning engines cannot be updated once created".to_string(), + })) + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn box_clone(&self) -> Box { + Box::new(self.clone()) + } + + fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool { + other.as_any().downcast_ref::() == Some(self) + } + + fn to_json_value(&self) -> serde_json::Result { + serde_json::to_value(self) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_engine_carries_its_id() { + let engine = GcpAgentPlatformEngine::new("orders-engine".to_string()).build(); + assert_eq!(engine.id(), "orders-engine"); + } + + #[test] + fn an_engine_refuses_any_update() { + let engine = GcpAgentPlatformEngine::new("orders-engine".to_string()).build(); + let error = engine + .validate_update(&engine) + .expect_err("a reasoning engine is immutable once created"); + assert_eq!(error.code, "INVALID_RESOURCE_UPDATE"); + } +} diff --git a/crates/alien-core/src/resources/mod.rs b/crates/alien-core/src/resources/mod.rs index eecdc0690..4ee62edbb 100644 --- a/crates/alien-core/src/resources/mod.rs +++ b/crates/alien-core/src/resources/mod.rs @@ -48,6 +48,9 @@ pub use azure_storage_account::*; mod azure_resource_group; pub use azure_resource_group::*; +mod gcp_agent_platform_engine; +pub use gcp_agent_platform_engine::*; + mod azure_container_apps_environment; pub use azure_container_apps_environment::*; diff --git a/crates/alien-core/src/resources/sandbox.rs b/crates/alien-core/src/resources/sandbox.rs index fbd4ce004..444e9d3df 100644 --- a/crates/alien-core/src/resources/sandbox.rs +++ b/crates/alien-core/src/resources/sandbox.rs @@ -148,6 +148,22 @@ pub enum SandboxEgress { }, } +impl SandboxEgress { + /// The single outbound switch for a backend that has no host matcher, or `None` for a mode a + /// boolean cannot carry. + /// + /// `AllowDomains` needs a host list, so it maps to nothing and each caller refuses it in its + /// own error naming the sandbox. One source for what a mode means, so a template and a session + /// cannot disagree on it. + pub fn internet_access_switch(&self) -> Option { + match self { + SandboxEgress::Allow => Some(true), + SandboxEgress::Deny => Some(false), + SandboxEgress::AllowDomains { .. } => None, + } + } +} + /// How long a session may live and when it is suspended. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] @@ -201,6 +217,12 @@ pub struct SandboxCapabilities { /// Kubernetes sandbox pod drops every capability — which is also what denies `ptrace` by /// construction, so granting it there would remove a lock to add one. pub supervisor_pid_namespace: bool, + /// The process supervising a command is a different identity from the command. + /// + /// False where a command runs as the agent's own user: it can then read the supervisor's + /// environment and signal it. Separate from `supervisorPidNamespace`, which is about + /// visibility rather than identity — a backend can have one without the other. + pub supervisor_isolation: bool, } impl SandboxCapabilities { @@ -230,6 +252,9 @@ impl SandboxCapabilities { // `CAP_SYS_ADMIN`. It can drop privilege (`CAP_SETUID`/`CAP_SETGID` are held) and // it cannot create a namespace. No backend offers this today. supervisor_pid_namespace: false, + // The agent runs as uid 0 and `setuid`s the command to uid 60000, so the command + // runs under a different identity than the process supervising it. + supervisor_isolation: true, }), Platform::Azure => Ok(Self { files: true, @@ -256,24 +281,11 @@ impl SandboxCapabilities { session_lifetime: false, // No Alien process inside an Azure sandbox, so there is no supervisor to isolate. supervisor_pid_namespace: false, + // No Alien process runs the command at all — the platform's own data plane does, + // so there is no separate supervisor identity to speak of. + supervisor_isolation: false, }), - // A Cloud Run sandbox id is scoped to one instance, and session affinity does not - // hold one across turns. That is the absence of a reconnect guarantee, not a - // degraded one. - Platform::Gcp => Ok(Self { - files: true, - reconnect: false, - preview: false, - suspend_resume: false, - snapshot: false, - domain_egress_rules: false, - egress_deny: true, - enforced_limits: false, - process_limit: false, - session_lifetime: false, - // A Cloud Run sandbox is a subprocess of the workload; nothing of ours is inside. - supervisor_pid_namespace: false, - }), + Platform::Gcp => Ok(Self::gcp_agent_platform()), // Preview needs a gateway that validates a session-and-port capability, and that // gateway does not exist yet. Platform::Kubernetes => Ok(Self { @@ -293,6 +305,11 @@ impl SandboxCapabilities { // need to unshare. That is also what denies `ptrace`, so this stays false rather // than the pod being weakened to make it true. supervisor_pid_namespace: false, + // The pod pins one uid (`run_as_user: 65534` on both pod and container) with + // `capabilities.drop: [ALL]` and `allow_privilege_escalation: false`, so no + // process can setuid to split the command off from a supervisor. No uid split is + // possible, so none exists. + supervisor_isolation: false, }), Platform::Local => Ok(Self { files: true, @@ -307,8 +324,12 @@ impl SandboxCapabilities { process_limit: true, session_lifetime: false, // Local has no in-sandbox agent: the manager drives Docker from outside, so - // there is no supervisor sharing the sandbox to isolate from. + // there is no supervisor inside the sandbox to isolate from. supervisor_pid_namespace: false, + // The supervisor is the manager on the host, outside the container entirely, and + // `docker exec` runs the command as the workload uid — a different identity by + // construction. + supervisor_isolation: true, }), Platform::Machines | Platform::Test => { Err(AlienError::new(ErrorData::SandboxPlatformUnsupported { @@ -318,6 +339,41 @@ impl SandboxCapabilities { } } + /// What the GCP Agent Platform sandbox backend supports; the body of the `Platform::Gcp` arm. + pub fn gcp_agent_platform() -> Self { + Self { + // Agent file operations move over the session envelope. + files: true, + // Reaching a session across processes is safe because `generation` is derived from the + // container boot id read through the agent's health op, so a caller detects a container + // replaced under a stable session name rather than reconnecting to a blank one. + reconnect: true, + // No method mints a port-scoped ingress capability; the only ingress is `:execute`. + preview: false, + // `:pause` and `:resume` preserve the running container. + suspend_resume: true, + // A session's state can be captured and used to create another. + snapshot: true, + // Egress is shaped by VPC and DNS peering, which is not a hostname allowlist. + domain_egress_rules: false, + // A declared `deny` blocks both routed egress and DNS. + egress_deny: true, + // The declared ceilings are enforced, but by terminating the session on breach rather + // than by refusing the allocation — a caller reading `true` should expect the session + // to die, not a clean error at the point of the request. + enforced_limits: true, + // No ceiling on process count is observed. + process_limit: false, + // `ttl` maps to a session `expireTime` the platform terminates at. + session_lifetime: true, + // No PID-namespace isolation between the command and anything supervising it. + supervisor_pid_namespace: false, + // No separate supervisor identity: the command is not run under a different identity + // than the process supervising it. + supervisor_isolation: false, + } + } + /// Returns a typed error if the named capability is absent on this platform. pub fn require(&self, capability: SandboxCapability, platform: Platform) -> Result<()> { let available = match capability { @@ -332,6 +388,7 @@ impl SandboxCapabilities { SandboxCapability::ProcessLimit => self.process_limit, SandboxCapability::SessionLifetime => self.session_lifetime, SandboxCapability::SupervisorPidNamespace => self.supervisor_pid_namespace, + SandboxCapability::SupervisorIsolation => self.supervisor_isolation, }; if available { @@ -372,6 +429,8 @@ pub enum SandboxCapability { SessionLifetime, /// A command runs in its own PID namespace, isolated from the agent supervising it SupervisorPidNamespace, + /// A command runs under a different identity than the process supervising it + SupervisorIsolation, } impl SandboxCapability { @@ -389,6 +448,7 @@ impl SandboxCapability { Self::ProcessLimit => "processLimit", Self::SessionLifetime => "sessionLifetime", Self::SupervisorPidNamespace => "supervisorPidNamespace", + Self::SupervisorIsolation => "supervisorIsolation", } } } @@ -930,11 +990,11 @@ mod tests { fn capability_sets_are_per_platform() { let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported"); assert!( - !gcp.reconnect, - "a GCP session id is scoped to one instance, so reconnect is absent" + gcp.reconnect, + "generation from the container boot id makes a session reachable across processes" ); assert!(!gcp.preview); - assert!(!gcp.enforced_limits); + assert!(gcp.enforced_limits); let azure = SandboxCapabilities::for_platform(Platform::Azure).expect("azure is supported"); assert!(azure.files, "every backend moves files"); @@ -965,6 +1025,93 @@ mod tests { ); } + /// Whether the process supervising a command is a separate identity from the command. + /// + /// Values are measured, not inferred. AWS: the agent runs as uid 0 with + /// `CapEff: 00000000a80425fb` and `setuid`s the command to uid 60000, so the two differ. + /// Kubernetes: the sandbox pod pins `run_as_user: 65534` on both pod and container with + /// `capabilities.drop: [ALL]` and `allow_privilege_escalation: false`, so no uid split is + /// possible (`kubernetes_spec.rs`). Local: `docker exec` runs as the workload uid while the + /// manager supervises from the host. Azure and Agent Platform have no in-sandbox supervisor. + #[test] + fn supervisor_isolation_is_per_platform() { + let value = |platform| { + SandboxCapabilities::for_platform(platform) + .expect("supported") + .supervisor_isolation + }; + + assert!(value(Platform::Aws), "root agent setuids the command to 60000"); + assert!(value(Platform::Local), "the supervisor is on the host, outside the container"); + assert!(!value(Platform::Kubernetes), "a single pinned uid cannot be split"); + assert!(!value(Platform::Azure), "no Alien process runs the command"); + assert!( + !value(Platform::Gcp), + "no separate supervisor identity runs the command" + ); + } + + /// The point of the field: AWS and GCP report the *same* `supervisor_pid_namespace` (neither + /// has `CAP_SYS_ADMIN`), so that axis alone reads them as equivalent. They are not — AWS + /// separates the command's identity from the supervisor's and Agent Platform does not. + #[test] + fn supervisor_isolation_separates_aws_from_a_subprocess_backend() { + let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported"); + let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported"); + + assert_eq!( + aws.supervisor_pid_namespace, gcp.supervisor_pid_namespace, + "the older axis cannot tell them apart" + ); + assert!(aws.supervisor_isolation, "AWS setuids the command off the supervisor"); + assert!( + !gcp.supervisor_isolation, + "the command runs under no separate supervisor identity" + ); + } + + /// The Agent Platform row, each value against the behaviour it was measured from. `reconnect` + /// is the tripwire: it is `true` only because `generation` is derived from the container boot + /// id read through the agent's health op, so a caller detects a replaced container instead of + /// reconnecting to a blank one. It is also the body of the `Platform::Gcp` arm, asserted below. + #[test] + fn gcp_agent_platform_row_matches_measured_backend() { + let row = SandboxCapabilities::gcp_agent_platform(); + + assert!(row.files, "agent file ops move over the session envelope"); + assert!( + row.reconnect, + "generation is derived from the container boot id, so a session is reachable across \ + processes" + ); + assert!(!row.preview, "the only ingress is :execute; no port-scoped capability"); + assert!(row.suspend_resume, ":pause and :resume preserve the container"); + assert!(row.snapshot, "session state can be captured and restored into a new session"); + assert!( + !row.domain_egress_rules, + "VPC and DNS peering is not a hostname allowlist" + ); + assert!(row.egress_deny, "a declared deny blocks both egress and DNS"); + assert!( + row.enforced_limits, + "ceilings are enforced, by terminating the session on breach" + ); + assert!(!row.process_limit, "no process-count ceiling is observed"); + assert!(row.session_lifetime, "ttl maps to a session expireTime"); + assert!(!row.supervisor_pid_namespace, "no PID-namespace isolation"); + assert!( + !row.supervisor_isolation, + "the command is not run under a separate supervisor identity" + ); + + // Agent Platform is the registered GCP backend, so the arm returns exactly this row. + let live = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported"); + assert_eq!( + live, row, + "the Platform::Gcp arm is the Agent Platform capability row" + ); + } + #[test] fn platforms_without_a_backend_are_an_error_not_an_empty_set() { let error = SandboxCapabilities::for_platform(Platform::Machines) @@ -1067,8 +1214,8 @@ mod tests { fn a_platform_that_cannot_enforce_limits_still_takes_a_sandbox_without_them() { let declared = sandbox_with(SandboxEgress::Deny, Vec::new()); declared - .validate_for_platform(Platform::Gcp) - .expect_err("declaring ceilings GCP cannot enforce is rejected"); + .validate_for_platform(Platform::Azure) + .expect_err("declaring ceilings Azure cannot enforce is rejected"); let undeclared = Sandbox::new("sbx".to_string()) .code(SandboxCode::Image { @@ -1082,7 +1229,7 @@ mod tests { .build(); undeclared - .validate_for_platform(Platform::Gcp) + .validate_for_platform(Platform::Azure) .expect("a sandbox naming no ceilings takes the platform's own"); // A backend still gets a concrete set, so nothing downstream has to invent one. @@ -1104,12 +1251,11 @@ mod tests { } #[test] - fn gcp_rejects_a_sandbox_declaring_enforced_limits() { + fn gcp_accepts_a_sandbox_declaring_enforced_limits() { let sandbox = sandbox_with(SandboxEgress::Allow, vec![]); - let error = sandbox + sandbox .validate_for_platform(Platform::Gcp) - .expect_err("GCP cannot enforce ceilings on a subprocess sandbox"); - assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED"); + .expect("Agent Platform enforces declared ceilings, by terminating on breach"); } #[test] @@ -1532,4 +1678,20 @@ mod tests { declared(vec!["api.example.com".to_string()]) .expect("a named domain is what an allowlist is for"); } + + /// The two expressible modes map to the boolean; a host list maps to nothing so the caller has + /// to refuse rather than silently pick a side. + #[test] + fn internet_access_switch_maps_only_the_two_expressible_modes() { + assert_eq!(SandboxEgress::Allow.internet_access_switch(), Some(true)); + assert_eq!(SandboxEgress::Deny.internet_access_switch(), Some(false)); + assert_eq!( + SandboxEgress::AllowDomains { + domains: vec!["api.example.com".to_string()] + } + .internet_access_switch(), + None, + "a host list has no boolean and must not be approximated" + ); + } } diff --git a/crates/alien-core/src/resources/worker.rs b/crates/alien-core/src/resources/worker.rs index c49261b10..24cef47d3 100644 --- a/crates/alien-core/src/resources/worker.rs +++ b/crates/alien-core/src/resources/worker.rs @@ -204,15 +204,6 @@ pub struct Worker { /// None means platform default applies. pub concurrency_limit: Option, - /// Whether this worker hosts sandbox sessions. - /// - /// Set by preflight, not by an application: on GCP a sandbox is a subprocess of the Cloud Run - /// instance running the app, and the instance can only launch one if its container declares - /// it. Declaring it by hand would be a permission the workload does not need. - #[builder(default)] - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub sandbox_launcher: bool, - /// Optional readiness probe configuration. /// Only applicable for workers with Public ingress. /// When configured, the probe will be executed after provisioning/update to verify the worker is ready. diff --git a/crates/alien-gcp-clients/src/gcp/agent_platform.rs b/crates/alien-gcp-clients/src/gcp/agent_platform.rs new file mode 100644 index 000000000..245e259af --- /dev/null +++ b/crates/alien-gcp-clients/src/gcp/agent_platform.rs @@ -0,0 +1,1504 @@ +//! Vertex AI Agent Platform sandbox client. +//! +//! Talks to the regional host `https://{region}-aiplatform.googleapis.com/v1`, under a parent +//! reasoning engine `projects/{p}/locations/{r}/reasoningEngines/{engine}`. The provider goes +//! through here rather than speaking REST directly, so retry classification, redaction and error +//! typing live in one place. +//! +//! Retry classification is the load-bearing part. `create_*`, `execute` and the `pause`/`resume`/ +//! `snapshot` transitions are delivered **once** — a silent re-send mints an orphan the caller has +//! no id for, or repeats a transition the server already refuses for the state the first attempt +//! produced. `get_*`/`list_*` retry; `delete_*` retries and treats a not-found as done. + +use crate::gcp::api_client::{GcpClientBase, GcpServiceConfig}; +use crate::gcp::longrunning::{Operation, OperationResult}; +use crate::gcp::{GcpClientConfig, ServiceOverrides}; +use alien_client_core::redact_request_body; +use alien_error::{AlienError, AlienErrorData, Context, IntoAlienError}; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine as _; +use reqwest::{Client, Method}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fmt::Debug; +use std::time::Duration; + +use async_trait::async_trait; +#[cfg(feature = "test-utils")] +use mockall::automock; + +/// Service-override key and endpoint base for the Vertex AI host. The regional host is injected as +/// an override at construction, so the static base is a fallback that a real call never reaches. +const SERVICE_KEY: &str = "aiplatform"; +const JSON_MIME: &str = "application/json"; + +#[derive(Debug)] +struct AgentPlatformServiceConfig; + +impl GcpServiceConfig for AgentPlatformServiceConfig { + fn base_url(&self) -> &'static str { + "https://aiplatform.googleapis.com/v1" + } + fn default_audience(&self) -> &'static str { + "https://aiplatform.googleapis.com/" + } + fn service_name(&self) -> &'static str { + "Vertex AI Agent Platform" + } + fn service_key(&self) -> &'static str { + SERVICE_KEY + } +} + +// ================================================================================================= +// Errors +// ================================================================================================= + +/// Problems specific to driving the Agent Platform sandbox API. +#[derive(Debug, Clone, AlienErrorData, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AgentPlatformErrorData { + /// A create, read, list or delete call to the API failed; classification is inherited from the + /// underlying cloud error so the caller's retry decision is preserved. + #[error( + code = "AGENT_PLATFORM_REQUEST_FAILED", + message = "Agent Platform request '{operation}' failed: {message}", + retryable = "inherit", + internal = "inherit" + )] + RequestFailed { + /// The logical call that failed (e.g. "create sandbox") + operation: String, + /// Resource reference or short detail + message: String, + }, + + /// A long-running operation completed with an error status. + #[error( + code = "AGENT_PLATFORM_OPERATION_FAILED", + message = "Operation '{operation}' failed (grpc {grpc_code}): {message}", + retryable = "false", + internal = "false" + )] + OperationFailed { + /// Operation resource name + operation: String, + /// gRPC status code the operation reported + grpc_code: i32, + /// Operation error message + message: String, + }, + + /// A long-running operation never reported done within its polling budget; carries the operation + /// name so the caller can resume or clean up rather than being handed a bare timeout. + #[error( + code = "AGENT_PLATFORM_OPERATION_INCOMPLETE", + message = "Operation '{operation}' still running after {attempts} polls: {last_state}", + retryable = "false", + internal = "false" + )] + OperationIncomplete { + /// Operation resource name + operation: String, + /// Number of polls spent before giving up + attempts: u32, + /// Last observed state or error text + last_state: String, + }, + + /// A sandbox template never reached the `ACTIVE` state within its polling budget. + #[error( + code = "AGENT_PLATFORM_TEMPLATE_NOT_ACTIVE", + message = "Template '{template}' never became ACTIVE (last state '{state}') after {attempts} polls", + retryable = "false", + internal = "false" + )] + TemplateNotActive { + /// Template resource name + template: String, + /// Last observed lifecycle state + state: String, + /// Number of polls spent + attempts: u32, + }, + + /// The proxied in-sandbox execution was refused or cut short before returning a result. + #[error( + code = "AGENT_PLATFORM_EXECUTE_FAILED", + message = "Execution in sandbox '{sandbox}' was refused or cut short: {message}", + retryable = "false", + internal = "inherit" + )] + ExecuteFailed { + /// Sandbox resource name + sandbox: String, + /// Short detail; the request body is never carried here + message: String, + }, + + /// A sandbox execution returned a reply the client could not read. + #[error( + code = "AGENT_PLATFORM_EXECUTE_OUTPUT_INVALID", + message = "Execution in sandbox '{sandbox}' returned an unreadable reply: {message}", + retryable = "false", + internal = "false" + )] + ExecuteOutputInvalid { + /// Sandbox resource name + sandbox: String, + /// What was wrong with the reply + message: String, + }, +} + +/// Result type for this client. +pub type Result = alien_error::Result; + +// ================================================================================================= +// Polling +// ================================================================================================= + +/// Bound on how long the client waits for a long-running operation or a template to settle. The +/// caller owns the budget so a test can drive it to exhaustion in milliseconds and production can +/// give it minutes. +#[derive(Debug, Clone, Copy)] +pub struct PollBudget { + /// Delay between polls + pub interval: Duration, + /// Maximum number of polls before giving up + pub max_attempts: u32, +} + +impl Default for PollBudget { + fn default() -> Self { + Self { + interval: Duration::from_secs(2), + max_attempts: 150, + } + } +} + +// ================================================================================================= +// Wire types +// ================================================================================================= + +/// A reasoning engine — the parent resource sandboxes and templates hang under. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReasoningEngine { + /// Full resource name `projects/.../reasoningEngines/{id}` + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Human-readable display name + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Preview fields not modelled above, kept rather than dropped. + #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")] + pub extra: serde_json::Map, +} + +/// The immutable image + resources a sandbox is cut from. `customContainerEnvironment` is the field +/// name the API wants — `sandboxEnvironmentSpec` is the obvious guess and it is rejected. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomContainerEnvironment { + /// The container image to run + #[serde(skip_serializing_if = "Option::is_none")] + pub custom_container_spec: Option, + /// Requested and limit CPU/memory + #[serde(skip_serializing_if = "Option::is_none")] + pub resources: Option, + /// Ports the container exposes + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub ports: Vec, + /// Preview fields not modelled above, kept rather than dropped. + #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")] + pub extra: serde_json::Map, +} + +/// The container image reference for a template. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomContainerSpec { + /// Fully-qualified image URI + pub image_uri: String, + /// Preview fields not modelled above, kept rather than dropped. + #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")] + pub extra: serde_json::Map, +} + +/// CPU and memory requests/limits, each a `{cpu, memory}` map as the API returns them. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContainerResources { + /// Requested resources + #[serde(skip_serializing_if = "Option::is_none")] + pub requests: Option>, + /// Resource limits + #[serde(skip_serializing_if = "Option::is_none")] + pub limits: Option>, +} + +/// A container port declaration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContainerPort { + /// Port number + pub port: i32, + /// Protocol, e.g. `TCP` + #[serde(skip_serializing_if = "Option::is_none")] + pub protocol: Option, +} + +/// Egress policy for a template. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EgressControlConfig { + /// Whether the sandbox may reach the public internet + #[serde(skip_serializing_if = "Option::is_none")] + pub internet_access: Option, + /// Preview fields not modelled above, kept rather than dropped. + #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")] + pub extra: serde_json::Map, +} + +/// A sandbox environment template. The config is immutable once created — there is no update verb. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SandboxEnvironmentTemplate { + /// Full resource name; unset on a create request + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Human-readable display name + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// The immutable image + resources + #[serde(skip_serializing_if = "Option::is_none")] + pub custom_container_environment: Option, + /// Egress policy + #[serde(skip_serializing_if = "Option::is_none")] + pub egress_control_config: Option, + /// Lifecycle state, e.g. `ACTIVE`; unset on a create request + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, + /// Preview fields not modelled above, kept rather than dropped. + #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")] + pub extra: serde_json::Map, +} + +/// Body for creating a sandbox: from a template, or restored from a snapshot. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SandboxCreateRequest { + /// Human-readable display name + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Template to cut the sandbox from + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox_environment_template: Option, + /// Snapshot to restore the sandbox from + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox_environment_snapshot: Option, + /// Time-to-live before the sandbox expires, e.g. `3600s` + #[serde(skip_serializing_if = "Option::is_none")] + pub ttl: Option, +} + +/// How to reach a running sandbox's proxy. `routing_token` is a short-lived bearer credential and is +/// redacted in `Debug`; `connectionInfo` is `Some({})` on a sandbox that is not yet addressable, so +/// a `None` hostname must be treated as "cannot execute yet", never as ready. +#[derive(Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectionInfo { + /// Hostname of the sandbox load balancer + #[serde(skip_serializing_if = "Option::is_none")] + pub load_balancer_hostname: Option, + /// Short-lived proxy bearer token; never log it + #[serde(skip_serializing_if = "Option::is_none")] + pub routing_token: Option, +} + +impl Debug for ConnectionInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ConnectionInfo") + .field("load_balancer_hostname", &self.load_balancer_hostname) + .field("routing_token", &self.routing_token.as_ref().map(|_| "[REDACTED]")) + .finish() + } +} + +/// A sandbox environment as the API reports it. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SandboxEnvironment { + /// Full resource name `projects/.../sandboxEnvironments/{id}` + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Human-readable display name + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Runtime state, e.g. `STATE_RUNNING` + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, + /// Template the sandbox was cut from + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox_environment_template: Option, + /// When the sandbox expires + #[serde(skip_serializing_if = "Option::is_none")] + pub expire_time: Option, + /// Proxy connection details; absent until the sandbox is addressable + #[serde(skip_serializing_if = "Option::is_none")] + pub connection_info: Option, + /// Preview fields not modelled above, kept rather than dropped. + #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")] + pub extra: serde_json::Map, +} + +/// A sandbox snapshot as the API reports it. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SandboxSnapshot { + /// Full resource name `projects/.../sandboxEnvironmentSnapshots/{id}` + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Preview fields not modelled above, kept rather than dropped. + #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")] + pub extra: serde_json::Map, +} + +/// The `google.protobuf.Empty` a `pause` operation resolves to. Deserializes from any object, +/// ignoring the `@type` marker, so `await_operation::` works for value-less operations. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Empty {} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ExecuteRequest { + inputs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ExecuteBlob { + /// base64-encoded payload + data: String, + /// MIME type of the payload + mime_type: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ExecuteResponse { + #[serde(default)] + outputs: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ListSandboxesResponse { + #[serde(default)] + sandbox_environments: Vec, + next_page_token: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ListTemplatesResponse { + #[serde(default)] + sandbox_environment_templates: Vec, + next_page_token: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ListEnginesResponse { + #[serde(default)] + reasoning_engines: Vec, + next_page_token: Option, +} + +// ================================================================================================= +// API +// ================================================================================================= + +#[cfg_attr(feature = "test-utils", automock)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait AgentPlatformApi: Send + Sync + Debug { + /// Create a reasoning engine. Single-attempt; returns the operation to poll. + async fn create_engine(&self, display_name: &str) -> Result; + /// Delete a reasoning engine. Retries; a not-found is success. + async fn delete_engine(&self, engine: &str) -> Result<()>; + /// List reasoning engines under the project, following pagination. Retries. Lets the orphan + /// sweep find engines a failed run abandoned, not only those a scratch log recorded. + async fn list_engines(&self) -> Result>; + + /// Create a template. Single-attempt; returns the operation to poll. Config is immutable. + async fn create_template( + &self, + engine: &str, + template: SandboxEnvironmentTemplate, + ) -> Result; + /// Read a template. Retries. + async fn get_template(&self, engine: &str, template: &str) -> Result; + /// List templates under an engine, following pagination. Retries. Lets a replace find the old + /// template it must delete, and a resumed provision adopt what an interrupted one left. + async fn list_templates(&self, engine: &str) -> Result>; + /// Delete a template. Retries; a not-found is success. + async fn delete_template(&self, engine: &str, template: &str) -> Result<()>; + + /// Create a sandbox. Single-attempt; returns the operation to poll. + async fn create_sandbox(&self, engine: &str, request: SandboxCreateRequest) -> Result; + /// Read a sandbox. Retries. + async fn get_sandbox(&self, engine: &str, sandbox: &str) -> Result; + /// List sandboxes under an engine, following pagination. Retries. + async fn list_sandboxes(&self, engine: &str) -> Result>; + /// Delete a sandbox. Retries; a not-found is success. + async fn delete_sandbox(&self, engine: &str, sandbox: &str) -> Result<()>; + + /// Run one request inside a sandbox through the `:execute` proxy. Single-attempt; the request + /// body is redacted out of any error. `input` is opaque JSON bytes; the decoded reply is returned. + async fn execute(&self, engine: &str, sandbox: &str, input: &[u8]) -> Result>; + + /// Pause a sandbox. Single-attempt state transition; returns the operation to poll. + async fn pause(&self, engine: &str, sandbox: &str) -> Result; + /// Resume a sandbox. Single-attempt state transition; returns the operation to poll. + async fn resume(&self, engine: &str, sandbox: &str) -> Result; + /// Snapshot a sandbox. Single-attempt state transition; returns the operation to poll. + async fn snapshot(&self, engine: &str, sandbox: &str, display_name: &str) -> Result; + + /// Read a long-running operation by resource name. Retries. + async fn get_operation(&self, name: &str) -> Result; +} + +/// Client for the Agent Platform sandbox API. +#[derive(Debug)] +pub struct AgentPlatformClient { + base: GcpClientBase, +} + +impl AgentPlatformClient { + /// Build a client against the region's `aiplatform` host. The regional endpoint is injected as a + /// service override only when the config does not already carry one, so a test override wins. + pub fn new(client: Client, config: GcpClientConfig) -> Self { + let mut config = config; + let host = format!( + "https://{}-aiplatform.googleapis.com/v1", + config.region + ); + config + .service_overrides + .get_or_insert_with(|| ServiceOverrides { + endpoints: HashMap::new(), + }) + .endpoints + .entry(SERVICE_KEY.to_string()) + .or_insert(host); + + Self { + base: GcpClientBase::new(client, config, Box::new(AgentPlatformServiceConfig)), + } + } + + fn engines_path(&self) -> String { + let cfg = self.base.config(); + format!( + "projects/{}/locations/{}/reasoningEngines", + cfg.project_id, cfg.region + ) + } + + fn engine_path(&self, engine: &str) -> String { + format!("{}/{}", self.engines_path(), engine) + } + + fn templates_path(&self, engine: &str) -> String { + format!("{}/sandboxEnvironmentTemplates", self.engine_path(engine)) + } + + fn sandboxes_path(&self, engine: &str) -> String { + format!("{}/sandboxEnvironments", self.engine_path(engine)) + } + + fn sandbox_path(&self, engine: &str, sandbox: &str) -> String { + format!("{}/{}", self.sandboxes_path(engine), sandbox) + } + + /// Poll a long-running operation to completion within `budget`, returning its typed response. + /// + /// On an operation error, reports `OperationFailed`; on budget exhaustion, `OperationIncomplete` + /// carrying the operation name and last observed state — never a bare timeout. + pub async fn await_operation(&self, operation: &Operation, budget: PollBudget) -> Result + where + T: serde::de::DeserializeOwned + Send + 'static, + { + let name = match &operation.name { + Some(name) => name.clone(), + None => { + return Err(AlienError::new(AgentPlatformErrorData::OperationIncomplete { + operation: "".to_string(), + attempts: 0, + last_state: "the operation carried no resource name".to_string(), + })) + } + }; + + let mut current = operation.clone(); + for _ in 0..budget.max_attempts { + if current.done == Some(true) { + return Self::finish_operation::(current, &name); + } + tokio::time::sleep(budget.interval).await; + current = self.get_operation(&name).await?; + } + + if current.done == Some(true) { + return Self::finish_operation::(current, &name); + } + Err(AlienError::new(AgentPlatformErrorData::OperationIncomplete { + operation: name, + attempts: budget.max_attempts, + last_state: Self::describe_operation(¤t), + })) + } + + /// Poll a template until it reaches `ACTIVE`, or report `TemplateNotActive` with the last state. + pub async fn await_template_active( + &self, + engine: &str, + template: &str, + budget: PollBudget, + ) -> Result { + let mut last_state = "".to_string(); + for _ in 0..budget.max_attempts { + let current = self.get_template(engine, template).await?; + last_state = current.state.clone().unwrap_or_default(); + if last_state == "ACTIVE" { + return Ok(current); + } + tokio::time::sleep(budget.interval).await; + } + Err(AlienError::new(AgentPlatformErrorData::TemplateNotActive { + template: template.to_string(), + state: last_state, + attempts: budget.max_attempts, + })) + } + + fn finish_operation(op: Operation, name: &str) -> Result { + match op.result { + Some(OperationResult::Error { error }) => { + Err(AlienError::new(AgentPlatformErrorData::OperationFailed { + operation: name.to_string(), + grpc_code: error.code, + message: error.message, + })) + } + Some(OperationResult::Response { response }) => serde_json::from_value::(response) + .into_alien_error() + .context(AgentPlatformErrorData::RequestFailed { + operation: format!("operation '{name}' response"), + message: "response body did not match the expected type".to_string(), + }), + None => Err(AlienError::new(AgentPlatformErrorData::OperationIncomplete { + operation: name.to_string(), + attempts: 0, + last_state: "operation reported done without a result".to_string(), + })), + } + } + + fn describe_operation(op: &Operation) -> String { + match &op.result { + Some(OperationResult::Error { error }) => { + format!("last error (grpc {}): {}", error.code, error.message) + } + _ => "operation had not completed".to_string(), + } + } +} + +/// Maps a cloud error onto this client's enum, treating a not-found as success — best-effort delete. +fn tolerate_not_found(result: alien_client_core::Result, operation: &str) -> Result<()> { + match result { + Ok(_) => Ok(()), + Err(e) => { + if matches!( + &e.error, + Some(alien_client_core::ErrorData::RemoteResourceNotFound { .. }) + ) { + Ok(()) + } else { + Err::<(), _>(e).context(AgentPlatformErrorData::RequestFailed { + operation: operation.to_string(), + message: "deletion failed".to_string(), + }) + } + } + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl AgentPlatformApi for AgentPlatformClient { + async fn create_engine(&self, display_name: &str) -> Result { + let path = self.engines_path(); + self.base + .execute_request_once( + Method::POST, + &path, + None, + Some(serde_json::json!({ "displayName": display_name })), + display_name, + ) + .await + .context(AgentPlatformErrorData::RequestFailed { + operation: "create engine".to_string(), + message: display_name.to_string(), + }) + } + + async fn delete_engine(&self, engine: &str) -> Result<()> { + let path = self.engine_path(engine); + let result: alien_client_core::Result = self + .base + .execute_request(Method::DELETE, &path, None, Option::<()>::None, engine) + .await; + tolerate_not_found(result, "delete engine") + } + + async fn create_template( + &self, + engine: &str, + template: SandboxEnvironmentTemplate, + ) -> Result { + let path = self.templates_path(engine); + self.base + .execute_request_once(Method::POST, &path, None, Some(template), engine) + .await + .context(AgentPlatformErrorData::RequestFailed { + operation: "create template".to_string(), + message: format!("engine '{engine}'"), + }) + } + + async fn get_template(&self, engine: &str, template: &str) -> Result { + let path = format!("{}/{}", self.templates_path(engine), template); + self.base + .execute_request(Method::GET, &path, None, Option::<()>::None, template) + .await + .context(AgentPlatformErrorData::RequestFailed { + operation: "get template".to_string(), + message: template.to_string(), + }) + } + + async fn list_templates(&self, engine: &str) -> Result> { + let path = self.templates_path(engine); + let mut templates = Vec::new(); + let mut page_token: Option = None; + + loop { + let query = page_token + .as_ref() + .map(|token| vec![("pageToken", token.clone())]); + let page: ListTemplatesResponse = self + .base + .execute_request(Method::GET, &path, query, Option::<()>::None, engine) + .await + .context(AgentPlatformErrorData::RequestFailed { + operation: "list templates".to_string(), + message: format!("engine '{engine}'"), + })?; + + templates.extend(page.sandbox_environment_templates); + match page.next_page_token { + Some(token) if !token.is_empty() => page_token = Some(token), + _ => break, + } + } + Ok(templates) + } + + async fn list_engines(&self) -> Result> { + let path = self.engines_path(); + let mut engines = Vec::new(); + let mut page_token: Option = None; + + loop { + let query = page_token + .as_ref() + .map(|token| vec![("pageToken", token.clone())]); + let page: ListEnginesResponse = self + .base + .execute_request(Method::GET, &path, query, Option::<()>::None, "engines") + .await + .context(AgentPlatformErrorData::RequestFailed { + operation: "list engines".to_string(), + message: "project reasoning engines".to_string(), + })?; + + engines.extend(page.reasoning_engines); + match page.next_page_token { + Some(token) if !token.is_empty() => page_token = Some(token), + _ => break, + } + } + Ok(engines) + } + + async fn delete_template(&self, engine: &str, template: &str) -> Result<()> { + let path = format!("{}/{}", self.templates_path(engine), template); + let result: alien_client_core::Result = self + .base + .execute_request(Method::DELETE, &path, None, Option::<()>::None, template) + .await; + tolerate_not_found(result, "delete template") + } + + async fn create_sandbox(&self, engine: &str, request: SandboxCreateRequest) -> Result { + let path = self.sandboxes_path(engine); + self.base + .execute_request_once(Method::POST, &path, None, Some(request), engine) + .await + .context(AgentPlatformErrorData::RequestFailed { + operation: "create sandbox".to_string(), + message: format!("engine '{engine}'"), + }) + } + + async fn get_sandbox(&self, engine: &str, sandbox: &str) -> Result { + let path = self.sandbox_path(engine, sandbox); + self.base + .execute_request(Method::GET, &path, None, Option::<()>::None, sandbox) + .await + .context(AgentPlatformErrorData::RequestFailed { + operation: "get sandbox".to_string(), + message: sandbox.to_string(), + }) + } + + async fn list_sandboxes(&self, engine: &str) -> Result> { + let path = self.sandboxes_path(engine); + let mut sandboxes = Vec::new(); + let mut page_token: Option = None; + + loop { + let query = page_token + .as_ref() + .map(|token| vec![("pageToken", token.clone())]); + let page: ListSandboxesResponse = self + .base + .execute_request(Method::GET, &path, query, Option::<()>::None, engine) + .await + .context(AgentPlatformErrorData::RequestFailed { + operation: "list sandboxes".to_string(), + message: format!("engine '{engine}'"), + })?; + + sandboxes.extend(page.sandbox_environments); + match page.next_page_token { + Some(token) if !token.is_empty() => page_token = Some(token), + _ => break, + } + } + Ok(sandboxes) + } + + async fn delete_sandbox(&self, engine: &str, sandbox: &str) -> Result<()> { + let path = self.sandbox_path(engine, sandbox); + let result: alien_client_core::Result = self + .base + .execute_request(Method::DELETE, &path, None, Option::<()>::None, sandbox) + .await; + tolerate_not_found(result, "delete sandbox") + } + + async fn execute(&self, engine: &str, sandbox: &str, input: &[u8]) -> Result> { + let path = format!("{}:execute", self.sandbox_path(engine, sandbox)); + let body = ExecuteRequest { + inputs: vec![ExecuteBlob { + data: BASE64.encode(input), + mime_type: JSON_MIME.to_string(), + }], + }; + + // Single-attempt: a repeat may re-run a command the first attempt already started. The + // request body carries the caller's command and env, so redaction runs before the error is + // wrapped — the body must never reach a serialized error chain. + let raw: alien_client_core::Result = self + .base + .execute_request_once(Method::POST, &path, None, Some(body), sandbox) + .await; + let response = redact_request_body(raw).context(AgentPlatformErrorData::ExecuteFailed { + sandbox: sandbox.to_string(), + message: "the API rejected or cut short the request".to_string(), + })?; + + let blob = response.outputs.into_iter().next().ok_or_else(|| { + AlienError::new(AgentPlatformErrorData::ExecuteOutputInvalid { + sandbox: sandbox.to_string(), + message: "the reply contained no outputs".to_string(), + }) + })?; + + BASE64 + .decode(blob.data.as_bytes()) + .into_alien_error() + .context(AgentPlatformErrorData::ExecuteOutputInvalid { + sandbox: sandbox.to_string(), + message: "output data was not valid base64".to_string(), + }) + } + + async fn pause(&self, engine: &str, sandbox: &str) -> Result { + let path = format!("{}:pause", self.sandbox_path(engine, sandbox)); + self.base + .execute_request_once( + Method::POST, + &path, + None, + Some(serde_json::json!({})), + sandbox, + ) + .await + .context(AgentPlatformErrorData::RequestFailed { + operation: "pause sandbox".to_string(), + message: sandbox.to_string(), + }) + } + + async fn resume(&self, engine: &str, sandbox: &str) -> Result { + let path = format!("{}:resume", self.sandbox_path(engine, sandbox)); + self.base + .execute_request_once( + Method::POST, + &path, + None, + Some(serde_json::json!({})), + sandbox, + ) + .await + .context(AgentPlatformErrorData::RequestFailed { + operation: "resume sandbox".to_string(), + message: sandbox.to_string(), + }) + } + + async fn snapshot(&self, engine: &str, sandbox: &str, display_name: &str) -> Result { + let path = format!("{}:snapshot", self.sandbox_path(engine, sandbox)); + self.base + .execute_request_once( + Method::POST, + &path, + None, + Some(serde_json::json!({ "displayName": display_name })), + sandbox, + ) + .await + .context(AgentPlatformErrorData::RequestFailed { + operation: "snapshot sandbox".to_string(), + message: sandbox.to_string(), + }) + } + + async fn get_operation(&self, name: &str) -> Result { + self.base + .execute_request(Method::GET, name, None, Option::<()>::None, name) + .await + .context(AgentPlatformErrorData::RequestFailed { + operation: "get operation".to_string(), + message: name.to_string(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::gcp::GcpCredentials; + use httpmock::prelude::*; + + const ENGINE: &str = "eng1"; + const SANDBOX: &str = "sbx1"; + + fn client(server: &MockServer) -> AgentPlatformClient { + AgentPlatformClient::new( + reqwest::Client::new(), + GcpClientConfig { + project_id: "test-project".to_string(), + region: "us-central1".to_string(), + credentials: GcpCredentials::AccessToken { + token: "test-token".to_string(), + }, + service_overrides: Some(ServiceOverrides { + endpoints: HashMap::from([("aiplatform".to_string(), server.base_url())]), + }), + project_number: None, + }, + ) + } + + /// A tiny budget so a never-completing operation exhausts in milliseconds rather than minutes. + fn tiny_budget() -> PollBudget { + PollBudget { + interval: Duration::from_millis(1), + max_attempts: 3, + } + } + + const SANDBOXES_PATH: &str = + "/projects/test-project/locations/us-central1/reasoningEngines/eng1/sandboxEnvironments"; + const SANDBOX_PATH: &str = + "/projects/test-project/locations/us-central1/reasoningEngines/eng1/sandboxEnvironments/sbx1"; + const OP_NAME: &str = "projects/test-project/locations/us-central1/operations/op1"; + const OP_PATH: &str = "/projects/test-project/locations/us-central1/operations/op1"; + + // ---- Retry classification: a write is delivered once, a read still retries. -------------- + + /// Pins the write-once / read-retries distinction that every single-attempt verb below relies + /// on. The read is proven to retry in the same test so a bare `hits == 1` cannot pass vacuously. + #[tokio::test] + async fn create_sandbox_is_sent_once_where_a_read_retries() { + let server = MockServer::start_async().await; + let create = server + .mock_async(|when, then| { + when.method(POST).path(SANDBOXES_PATH); + then.status(503); + }) + .await; + let read = server + .mock_async(|when, then| { + when.method(GET).path(SANDBOX_PATH); + then.status(503); + }) + .await; + + client(&server) + .create_sandbox(ENGINE, SandboxCreateRequest::default()) + .await + .expect_err("create should surface the failure"); + assert_eq!(create.hits_async().await, 1, "create must not be re-sent"); + + client(&server) + .get_sandbox(ENGINE, SANDBOX) + .await + .expect_err("read should surface the failure"); + assert!( + read.hits_async().await > 1, + "a read must retry on a retryable failure" + ); + } + + #[tokio::test] + async fn create_engine_is_sent_once() { + let server = MockServer::start_async().await; + let create = server + .mock_async(|when, then| { + when.method(POST) + .path("/projects/test-project/locations/us-central1/reasoningEngines"); + then.status(503); + }) + .await; + client(&server) + .create_engine("engine-display") + .await + .expect_err("create should surface the failure"); + assert_eq!(create.hits_async().await, 1, "create engine must be sent once"); + } + + #[tokio::test] + async fn create_template_is_sent_once() { + let server = MockServer::start_async().await; + let create = server + .mock_async(|when, then| { + when.method(POST).path_contains("sandboxEnvironmentTemplates"); + then.status(503); + }) + .await; + client(&server) + .create_template(ENGINE, SandboxEnvironmentTemplate::default_for_test()) + .await + .expect_err("create should surface the failure"); + assert_eq!(create.hits_async().await, 1, "create template must be sent once"); + } + + #[tokio::test] + async fn execute_is_sent_once() { + let server = MockServer::start_async().await; + let exec = server + .mock_async(|when, then| { + when.method(POST).path_contains(":execute"); + then.status(503); + }) + .await; + client(&server) + .execute(ENGINE, SANDBOX, b"{}") + .await + .expect_err("execute should surface the failure"); + assert_eq!(exec.hits_async().await, 1, "execute must be sent once"); + } + + #[tokio::test] + async fn pause_is_sent_once() { + let server = MockServer::start_async().await; + let pause = server + .mock_async(|when, then| { + when.method(POST).path_contains(":pause"); + then.status(503); + }) + .await; + client(&server) + .pause(ENGINE, SANDBOX) + .await + .expect_err("pause should surface the failure"); + assert_eq!(pause.hits_async().await, 1, "pause must be sent once"); + } + + #[tokio::test] + async fn resume_is_sent_once() { + let server = MockServer::start_async().await; + let resume = server + .mock_async(|when, then| { + when.method(POST).path_contains(":resume"); + then.status(503); + }) + .await; + client(&server) + .resume(ENGINE, SANDBOX) + .await + .expect_err("resume should surface the failure"); + assert_eq!(resume.hits_async().await, 1, "resume must be sent once"); + } + + #[tokio::test] + async fn snapshot_is_sent_once() { + let server = MockServer::start_async().await; + let snapshot = server + .mock_async(|when, then| { + when.method(POST).path_contains(":snapshot"); + then.status(503); + }) + .await; + client(&server) + .snapshot(ENGINE, SANDBOX, "snap-display") + .await + .expect_err("snapshot should surface the failure"); + assert_eq!(snapshot.hits_async().await, 1, "snapshot must be sent once"); + } + + // ---- Redaction: an execute request body never reaches a serialized error. ---------------- + + /// The execute request body carries the caller's command and env — a place a token lands. It + /// must be absent from a serialized error, while diagnostics survive. The paired create + /// assertion proves request bodies ARE captured, so execute's absence is redaction, not a body + /// that was never recorded. + #[tokio::test] + async fn an_execute_body_is_absent_from_a_serialized_error() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(POST).path_contains(":execute"); + then.status(400).json_body_obj(&serde_json::json!({ + "error": { + "code": 400, + "message": "Execution Failed. Error: DEADLINE_EXCEEDED", + "status": "FAILED_PRECONDITION" + } + })); + }) + .await; + + let secret_payload = br#"{"command":["echo","TOKEN-abc123-secret"]}"#; + let encoded = BASE64.encode(secret_payload); + + let error = client(&server) + .execute(ENGINE, SANDBOX, secret_payload) + .await + .expect_err("execute should fail"); + let serialized = serde_json::to_string(&error).expect("serialize error"); + + assert!( + !serialized.contains(&encoded), + "the encoded request body leaked into the error: {serialized}" + ); + assert!( + !serialized.contains("TOKEN-abc123-secret"), + "the raw command leaked into the error: {serialized}" + ); + // Diagnostics survive, so the chain reached the serializer with content — the absence above + // is meaningful, not vacuous. + assert!( + serialized.contains("FAILED_PRECONDITION"), + "response diagnostics were dropped: {serialized}" + ); + + // Precondition: a non-secret create body IS captured in its error, proving the transport + // records request bodies at all. + let create_server = MockServer::start_async().await; + create_server + .mock_async(|when, then| { + when.method(POST).path(SANDBOXES_PATH); + then.status(400).json_body_obj(&serde_json::json!({ + "error": { "code": 400, "message": "bad", "status": "INVALID_ARGUMENT" } + })); + }) + .await; + let create_error = client(&create_server) + .create_sandbox( + ENGINE, + SandboxCreateRequest { + display_name: Some("MARKER-create-body-9f".to_string()), + ..Default::default() + }, + ) + .await + .expect_err("create should fail"); + let create_serialized = serde_json::to_string(&create_error).expect("serialize"); + assert!( + create_serialized.contains("MARKER-create-body-9f"), + "a create body should be captured (non-secret), proving bodies are recorded: {create_serialized}" + ); + } + + // ---- Long-running operations: bounded, last error reported. ------------------------------- + + #[tokio::test] + async fn await_operation_returns_the_resource_when_the_operation_completes() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(GET).path(OP_PATH); + then.status(200).json_body_obj(&serde_json::json!({ + "name": OP_NAME, + "done": true, + "response": { + "@type": "type.googleapis.com/google.cloud.aiplatform.v1.SandboxEnvironment", + "name": "projects/p/locations/us-central1/reasoningEngines/eng1/sandboxEnvironments/sbx1", + "state": "STATE_RUNNING" + } + })); + }) + .await; + + let pending = Operation { + name: Some(OP_NAME.to_string()), + ..Default::default() + }; + let sandbox: SandboxEnvironment = client(&server) + .await_operation(&pending, tiny_budget()) + .await + .expect("operation should resolve to a sandbox"); + assert_eq!(sandbox.state.as_deref(), Some("STATE_RUNNING")); + } + + /// A value-less `pause` operation resolves to `Empty` without choking on the `@type` marker. + #[tokio::test] + async fn await_operation_handles_a_value_less_result() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(GET).path(OP_PATH); + then.status(200).json_body_obj(&serde_json::json!({ + "name": OP_NAME, + "done": true, + "response": { "@type": "type.googleapis.com/google.protobuf.Empty" } + })); + }) + .await; + let pending = Operation { + name: Some(OP_NAME.to_string()), + ..Default::default() + }; + let _empty: Empty = client(&server) + .await_operation(&pending, tiny_budget()) + .await + .expect("a value-less operation should resolve to Empty"); + } + + /// Budget exhaustion reports the last observed state and the operation name — not a bare timeout. + #[tokio::test] + async fn await_operation_reports_the_last_error_when_the_budget_runs_out() { + let server = MockServer::start_async().await; + let poll = server + .mock_async(|when, then| { + when.method(GET).path(OP_PATH); + then.status(200).json_body_obj(&serde_json::json!({ "name": OP_NAME })); + }) + .await; + + let pending = Operation { + name: Some(OP_NAME.to_string()), + ..Default::default() + }; + let error = client(&server) + .await_operation::(&pending, tiny_budget()) + .await + .expect_err("an operation that never completes should error"); + + assert_eq!( + error.code, "AGENT_PLATFORM_OPERATION_INCOMPLETE", + "the budget-exhaustion error must be the incomplete variant" + ); + assert!( + error.message.contains(OP_NAME), + "the error must name the operation for the caller to resume: {}", + error.message + ); + assert_eq!(poll.hits_async().await, 3, "polling must stop at the budget"); + } + + /// An operation that completes with an error status reports `OperationFailed`, not success. + #[tokio::test] + async fn await_operation_surfaces_an_operation_error() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(GET).path(OP_PATH); + then.status(200).json_body_obj(&serde_json::json!({ + "name": OP_NAME, + "done": true, + "error": { "code": 9, "message": "quota exhausted" } + })); + }) + .await; + let pending = Operation { + name: Some(OP_NAME.to_string()), + ..Default::default() + }; + let error = client(&server) + .await_operation::(&pending, tiny_budget()) + .await + .expect_err("an errored operation must fail"); + assert_eq!(error.code, "AGENT_PLATFORM_OPERATION_FAILED"); + assert!(error.message.contains("quota exhausted"), "{}", error.message); + } + + // ---- Delete tolerance. -------------------------------------------------------------------- + + #[tokio::test] + async fn delete_sandbox_treats_not_found_as_success() { + let server = MockServer::start_async().await; + let delete = server + .mock_async(|when, then| { + when.method(DELETE).path(SANDBOX_PATH); + then.status(404).json_body_obj(&serde_json::json!({ + "error": { "code": 404, "message": "not found", "status": "NOT_FOUND" } + })); + }) + .await; + + client(&server) + .delete_sandbox(ENGINE, SANDBOX) + .await + .expect("a not-found delete is success"); + assert!(delete.hits_async().await >= 1, "the delete was attempted"); + } + + /// Delete rides the retrying transport, so a transient failure is retried rather than sent once. + #[tokio::test] + async fn delete_sandbox_retries_a_transient_failure() { + let server = MockServer::start_async().await; + let delete = server + .mock_async(|when, then| { + when.method(DELETE).path(SANDBOX_PATH); + then.status(503); + }) + .await; + client(&server) + .delete_sandbox(ENGINE, SANDBOX) + .await + .expect_err("a transient delete failure surfaces"); + assert!( + delete.hits_async().await > 1, + "delete must retry on a retryable failure" + ); + } + + // ---- Template listing: paginated, and a read still retries. ------------------------------- + + const TEMPLATES_PATH: &str = "/projects/test-project/locations/us-central1/reasoningEngines/eng1/sandboxEnvironmentTemplates"; + + #[tokio::test] + async fn list_templates_follows_pagination() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(GET).path(TEMPLATES_PATH).matches(|req| { + req.query_params + .as_ref() + .is_none_or(|q| q.iter().all(|(k, _)| k != "pageToken")) + }); + then.status(200).json_body_obj(&serde_json::json!({ + "sandboxEnvironmentTemplates": [{ "name": "eng1/sandboxEnvironmentTemplates/t1", "state": "ACTIVE" }], + "nextPageToken": "page2" + })); + }) + .await; + server + .mock_async(|when, then| { + when.method(GET) + .path(TEMPLATES_PATH) + .query_param("pageToken", "page2"); + then.status(200).json_body_obj(&serde_json::json!({ + "sandboxEnvironmentTemplates": [{ "name": "eng1/sandboxEnvironmentTemplates/t2", "state": "ACTIVE" }] + })); + }) + .await; + + let templates = client(&server) + .list_templates(ENGINE) + .await + .expect("both pages should list"); + assert_eq!(templates.len(), 2, "both pages were followed"); + assert_eq!( + templates[0].name.as_deref(), + Some("eng1/sandboxEnvironmentTemplates/t1") + ); + assert_eq!( + templates[1].name.as_deref(), + Some("eng1/sandboxEnvironmentTemplates/t2") + ); + } + + const ENGINES_PATH: &str = "/projects/test-project/locations/us-central1/reasoningEngines"; + + #[tokio::test] + async fn list_engines_follows_pagination() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(GET).path(ENGINES_PATH).matches(|req| { + req.query_params + .as_ref() + .is_none_or(|q| q.iter().all(|(k, _)| k != "pageToken")) + }); + then.status(200).json_body_obj(&serde_json::json!({ + "reasoningEngines": [{ "name": "projects/p/locations/us-central1/reasoningEngines/e1" }], + "nextPageToken": "page2" + })); + }) + .await; + server + .mock_async(|when, then| { + when.method(GET) + .path(ENGINES_PATH) + .query_param("pageToken", "page2"); + then.status(200).json_body_obj(&serde_json::json!({ + "reasoningEngines": [{ "name": "projects/p/locations/us-central1/reasoningEngines/e2" }] + })); + }) + .await; + + let engines = client(&server) + .list_engines() + .await + .expect("both pages should list"); + assert_eq!(engines.len(), 2, "both pages were followed"); + assert_eq!( + engines[0].name.as_deref(), + Some("projects/p/locations/us-central1/reasoningEngines/e1") + ); + assert_eq!( + engines[1].name.as_deref(), + Some("projects/p/locations/us-central1/reasoningEngines/e2") + ); + } + + #[tokio::test] + async fn list_templates_retries_a_transient_failure() { + let server = MockServer::start_async().await; + let list = server + .mock_async(|when, then| { + when.method(GET).path_contains("sandboxEnvironmentTemplates"); + then.status(503); + }) + .await; + client(&server) + .list_templates(ENGINE) + .await + .expect_err("a transient list failure surfaces"); + assert!( + list.hits_async().await > 1, + "a read must retry on a retryable failure" + ); + } + + // ---- Wire-shape pins. --------------------------------------------------------------------- + + /// `connectionInfo: {}` must parse as present-but-unaddressable, distinct from absent — a caller + /// that reads `{}` as ready would fail at first execute. + #[test] + fn connection_info_empty_is_distinct_from_absent() { + let running: SandboxEnvironment = serde_json::from_str( + r#"{"name":"n","state":"STATE_RUNNING","connectionInfo":{"loadBalancerHostname":"h","routingToken":"t"}}"#, + ) + .expect("running sandbox parses"); + assert_eq!( + running.connection_info.as_ref().and_then(|c| c.load_balancer_hostname.as_deref()), + Some("h") + ); + + let creating: SandboxEnvironment = + serde_json::from_str(r#"{"name":"n","connectionInfo":{}}"#).expect("empty parses"); + let info = creating.connection_info.expect("present but empty"); + assert!( + info.load_balancer_hostname.is_none(), + "an empty connectionInfo is present but not addressable" + ); + + let paused: SandboxEnvironment = + serde_json::from_str(r#"{"name":"n","state":"STATE_PAUSED"}"#).expect("no info parses"); + assert!(paused.connection_info.is_none(), "absent stays absent"); + } + + /// A routing token must not appear in a Debug rendering of a sandbox. + #[test] + fn a_routing_token_is_redacted_in_debug() { + let info = ConnectionInfo { + load_balancer_hostname: Some("host".to_string()), + routing_token: Some("super-secret-token".to_string()), + }; + let rendered = format!("{info:?}"); + assert!(!rendered.contains("super-secret-token"), "{rendered}"); + assert!(rendered.contains("[REDACTED]"), "{rendered}"); + } + + #[test] + fn an_execute_reply_decodes_from_base64() { + let reply: ExecuteResponse = serde_json::from_str(&format!( + r#"{{"outputs":[{{"data":"{}","mimeType":"application/json"}}]}}"#, + BASE64.encode(br#"{"op":"info"}"#) + )) + .expect("reply parses"); + let decoded = BASE64 + .decode(reply.outputs[0].data.as_bytes()) + .expect("decodes"); + assert_eq!(decoded, br#"{"op":"info"}"#); + } + + impl SandboxEnvironmentTemplate { + fn default_for_test() -> Self { + Self { + name: None, + display_name: Some("tpl-display".to_string()), + custom_container_environment: Some(CustomContainerEnvironment { + custom_container_spec: Some(CustomContainerSpec { + image_uri: "us-central1-docker.pkg.dev/p/r/agent:v1".to_string(), + extra: serde_json::Map::new(), + }), + resources: None, + ports: vec![], + extra: serde_json::Map::new(), + }), + egress_control_config: Some(EgressControlConfig { + internet_access: Some(true), + extra: serde_json::Map::new(), + }), + state: None, + extra: serde_json::Map::new(), + } + } + } + +} diff --git a/crates/alien-gcp-clients/src/gcp/api_client.rs b/crates/alien-gcp-clients/src/gcp/api_client.rs index d8681c781..a99ef48c6 100644 --- a/crates/alien-gcp-clients/src/gcp/api_client.rs +++ b/crates/alien-gcp-clients/src/gcp/api_client.rs @@ -195,6 +195,42 @@ impl GcpClientBase { .await } + /// Single-attempt sibling of [`execute_request`](Self::execute_request): builds and delivers the + /// request exactly once, never retrying. Use for non-idempotent verbs (`create`, state + /// transitions, a proxied `execute`) where a silent re-send would orphan a resource or repeat a + /// transition. Retry, when wanted, belongs to the caller. + pub async fn execute_request_once( + &self, + method: Method, + path: &str, + query_params: Option>, + body: Option, + resource_name: &str, + ) -> Result + where + T: DeserializeOwned + Send + 'static, + B: Serialize + Send + Sync + Clone + 'static, + { + let url = self.build_url(path, query_params.as_ref())?; + let mut builder = self.http.request(method.clone(), url); + + if let Some(b) = body.as_ref() { + builder = builder.json(b); + } else if method == Method::POST { + builder = builder.header(reqwest::header::CONTENT_LENGTH, "0"); + } + + let operation = format!("{} {}", method, path); + crate::gcp::gcp_request_utils::auth_send_json_once( + builder, + &self.auth().await?, + &operation, + resource_name, + self.svc_cfg.service_name(), + ) + .await + } + /// Variant for requests that do not return a body (HTTP 2xx with empty body). pub async fn execute_request_no_response( &self, diff --git a/crates/alien-gcp-clients/src/gcp/cloudrun.rs b/crates/alien-gcp-clients/src/gcp/cloudrun.rs index 08e1f59a6..cd109f01a 100644 --- a/crates/alien-gcp-clients/src/gcp/cloudrun.rs +++ b/crates/alien-gcp-clients/src/gcp/cloudrun.rs @@ -1077,14 +1077,6 @@ pub struct Container { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub args: Vec, - /// Lets this container act as a sandbox supervisor and launch sandboxes. - /// - /// The service must also declare `launch_stage: Beta` or later — Cloud Run rejects the - /// field otherwise with `FAILED_PRECONDITION: The feature 'Instant sandboxes' is not - /// supported in the declared launch stage`. - #[serde(skip_serializing_if = "Option::is_none")] - pub sandbox_launcher: Option, - /// List of environment variables to set in the container. #[builder(default)] #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -1452,39 +1444,6 @@ pub struct BuildInfo { pub source_location: Option, } -#[cfg(test)] -mod sandbox_launcher_tests { - use super::*; - - /// Cloud Run rejects `sandboxLauncher` unless the service declares BETA or later: - /// `FAILED_PRECONDITION: The feature 'Instant sandboxes' is not supported in the declared - /// launch stage`. Verified against the live API, so the two travel together. - #[test] - fn sandbox_launcher_serializes_as_the_api_spells_it() { - let container = Container { - image: "us-docker.pkg.dev/cloudrun/container/hello".to_string(), - sandbox_launcher: Some(true), - ..Default::default() - }; - - let json = serde_json::to_value(&container).expect("serializes"); - assert_eq!(json["sandboxLauncher"], serde_json::json!(true)); - } - - /// Absent rather than `false` when unset, so an ordinary container's request body is - /// unchanged and cannot trip the launch-stage precondition. - #[test] - fn an_ordinary_container_does_not_carry_the_field() { - let container = Container { - image: "img".to_string(), - ..Default::default() - }; - - let json = serde_json::to_value(&container).expect("serializes"); - assert!(json.get("sandboxLauncher").is_none(), "{json}"); - } -} - #[cfg(test)] mod tests { use super::Ingress; diff --git a/crates/alien-gcp-clients/src/gcp/gcp_request_utils.rs b/crates/alien-gcp-clients/src/gcp/gcp_request_utils.rs index 49b53db73..a6744e92c 100644 --- a/crates/alien-gcp-clients/src/gcp/gcp_request_utils.rs +++ b/crates/alien-gcp-clients/src/gcp/gcp_request_utils.rs @@ -232,6 +232,24 @@ pub async fn auth_send_json( map_gcp_result(result, operation, resource_name, resource_type) } +/// Attach the bearer token and deliver the request **exactly once**, then deserialize the JSON +/// response into `T` with GCP-specific error mapping. +/// +/// The retrying [`auth_send_json`] is wrong for a request the server cannot be told to repeat: a +/// second `create` mints an orphan the caller has no id for, and a second state transition is +/// refused for the state the first attempt already produced. Non-idempotent verbs send through +/// here so a network hiccup surfaces to the caller instead of being silently re-issued. +pub async fn auth_send_json_once( + builder: RequestBuilder, + config: &GcpAuthConfig, + operation: &str, + resource_name: &str, + resource_type: &str, +) -> Result { + let result = builder.auth_gcp_request(config)?.send_json::().await; + map_gcp_result(result, operation, resource_name, resource_type) +} + /// Attach the bearer token, apply retries and expect no response body (return `()` /// on HTTP success) with GCP-specific error mapping. pub async fn auth_send_no_response( diff --git a/crates/alien-gcp-clients/src/gcp/mod.rs b/crates/alien-gcp-clients/src/gcp/mod.rs index 6ef1fcfcb..187f2cc61 100644 --- a/crates/alien-gcp-clients/src/gcp/mod.rs +++ b/crates/alien-gcp-clients/src/gcp/mod.rs @@ -1,3 +1,4 @@ +pub mod agent_platform; pub mod api_client; pub mod artifactregistry; pub mod cloud_kms; diff --git a/crates/alien-gcp-clients/src/lib.rs b/crates/alien-gcp-clients/src/lib.rs index 54c03a718..9e98646e5 100644 --- a/crates/alien-gcp-clients/src/lib.rs +++ b/crates/alien-gcp-clients/src/lib.rs @@ -12,6 +12,10 @@ pub mod platform { } // Re-export all client APIs +pub use gcp::agent_platform::{ + AgentPlatformApi, AgentPlatformClient, AgentPlatformErrorData, ConnectionInfo, + PollBudget, SandboxCreateRequest, SandboxEnvironment, SandboxEnvironmentTemplate, +}; pub use gcp::artifactregistry::{ArtifactRegistryApi, ArtifactRegistryClient}; pub use gcp::cloud_kms::{CloudKmsApi, CloudKmsClient}; pub use gcp::cloud_sql::{CloudSqlApi, CloudSqlClient}; diff --git a/crates/alien-infra/src/core/controller.rs b/crates/alien-infra/src/core/controller.rs index 57ff20b93..15a01b434 100644 --- a/crates/alien-infra/src/core/controller.rs +++ b/crates/alien-infra/src/core/controller.rs @@ -991,6 +991,14 @@ fn deserialize_controller_by_tag( "KubernetesSandboxController" => { deser!(crate::sandbox::KubernetesSandboxController) } + #[cfg(feature = "gcp")] + "GcpAgentPlatformEngineController" => { + deser!(crate::sandbox::GcpAgentPlatformEngineController) + } + #[cfg(feature = "gcp")] + "GcpAgentPlatformTemplateController" => { + deser!(crate::sandbox::GcpAgentPlatformTemplateController) + } #[cfg(feature = "kubernetes")] "KubernetesClusterController" => { deser!(crate::kubernetes_cluster::KubernetesClusterController) diff --git a/crates/alien-infra/src/core/registry.rs b/crates/alien-infra/src/core/registry.rs index b59f941e2..8115fe3e6 100644 --- a/crates/alien-infra/src/core/registry.rs +++ b/crates/alien-infra/src/core/registry.rs @@ -768,6 +768,26 @@ impl ResourceRegistry { Box::new(DefaultControllerFactory::::new()), ); + // Register the GCP Agent Platform reasoning-engine controller. + #[cfg(feature = "gcp")] + registry.register_controller_factory( + alien_core::GcpAgentPlatformEngine::RESOURCE_TYPE, + Platform::Gcp, + Box::new( + DefaultControllerFactory::::new(), + ), + ); + + // Register the GCP Agent Platform sandbox (template) controller. + #[cfg(feature = "gcp")] + registry.register_controller_factory( + alien_core::Sandbox::RESOURCE_TYPE, + Platform::Gcp, + Box::new( + DefaultControllerFactory::::new(), + ), + ); + // Register KubernetesCluster controller. The cluster is selected or // created during setup; this runtime controller records substrate // readiness once the agent is installed and reporting. diff --git a/crates/alien-infra/src/core/service_provider.rs b/crates/alien-infra/src/core/service_provider.rs index 32b9d61db..318f9e546 100644 --- a/crates/alien-infra/src/core/service_provider.rs +++ b/crates/alien-infra/src/core/service_provider.rs @@ -28,8 +28,6 @@ use alien_aws_clients::{ use alien_azure_clients::{ application_gateways::{ApplicationGatewayApi, AzureApplicationGatewayClient}, authorization::{AuthorizationApi, AzureAuthorizationClient}, - sandbox_data_plane::{AzureSandboxDataPlaneClient, SandboxDataPlaneApi}, - sandbox_groups::{AzureSandboxGroupsClient, SandboxGroupsApi}, blob_containers::{AzureBlobContainerClient, BlobContainerApi}, cognitive_services::{AzureCognitiveServicesClient, CognitiveServicesAccountsApi}, compute::{AzureVmssClient, VirtualMachineScaleSetsApi}, @@ -51,6 +49,8 @@ use alien_azure_clients::{ private_networking::{AzurePrivateNetworkingClient, PrivateNetworkingApi}, resource_skus::{AzureResourceSkusClient, ResourceSkusApi}, resources::{AzureResourcesClient, ResourcesApi}, + sandbox_data_plane::{AzureSandboxDataPlaneClient, SandboxDataPlaneApi}, + sandbox_groups::{AzureSandboxGroupsClient, SandboxGroupsApi}, service_bus::{ AzureServiceBusDataPlaneClient, AzureServiceBusManagementClient, ServiceBusDataPlaneApi, ServiceBusManagementApi, @@ -61,6 +61,7 @@ use alien_azure_clients::{ }; use alien_error::Context; use alien_gcp_clients::{ + agent_platform::{AgentPlatformApi, AgentPlatformClient}, artifactregistry::{ArtifactRegistryApi, ArtifactRegistryClient}, cloud_kms::{CloudKmsApi, CloudKmsClient}, cloud_sql::{CloudSqlApi, CloudSqlClient}, @@ -83,9 +84,8 @@ use alien_gcp_clients::{ use alien_k8s_clients::{ deployments::DeploymentApi, events::EventApi, jobs::JobApi, kubernetes_client::KubernetesClient, metrics::MetricsApi, nodes::NodeApi, pods::PodApi, - routes::RouteApi, runtime_classes::RuntimeClassApi, secrets::SecretsApi, - services::ServiceApi, version::VersionApi, - KubernetesClientConfig, + routes::RouteApi, runtime_classes::RuntimeClassApi, secrets::SecretsApi, services::ServiceApi, + version::VersionApi, KubernetesClientConfig, }; use std::sync::Arc; @@ -192,6 +192,10 @@ pub trait PlatformServiceProvider: Send + Sync { config: &GcpClientConfig, ) -> Result>; fn get_gcp_cloud_kms_client(&self, config: &GcpClientConfig) -> Result>; + fn get_gcp_agent_platform_client( + &self, + config: &GcpClientConfig, + ) -> Result>; // Azure clients fn get_azure_application_gateway_client( @@ -899,6 +903,16 @@ impl PlatformServiceProvider for DefaultPlatformServiceProvider { ))) } + fn get_gcp_agent_platform_client( + &self, + config: &GcpClientConfig, + ) -> Result> { + Ok(Arc::new(AgentPlatformClient::new( + reqwest::Client::new(), + config.clone(), + ))) + } + fn get_gcp_firestore_client(&self, config: &GcpClientConfig) -> Result> { Ok(Arc::new(FirestoreClient::new( reqwest::Client::new(), @@ -1455,7 +1469,9 @@ impl PlatformServiceProvider for DefaultPlatformServiceProvider { #[cfg(feature = "local")] fn get_local_sandbox_manager(&self) -> Option> { - self.local_bindings.as_ref().and_then(|p| p.sandbox_manager()) + self.local_bindings + .as_ref() + .and_then(|p| p.sandbox_manager()) } #[cfg(feature = "local")] diff --git a/crates/alien-infra/src/sandbox/gcp_agent_platform_engine.rs b/crates/alien-infra/src/sandbox/gcp_agent_platform_engine.rs new file mode 100644 index 000000000..6ad56e555 --- /dev/null +++ b/crates/alien-infra/src/sandbox/gcp_agent_platform_engine.rs @@ -0,0 +1,398 @@ +//! GCP Agent Platform reasoning-engine controller. +//! +//! Creates the durable engine every sandbox template and session hangs under, one per sandbox, and +//! records its server-assigned id in state for the template controller to read as a dependency. +//! Vertex exposes no Terraform resource for the engine, so this API-call controller is its only +//! creator. +//! +//! Create-once: the id is persisted, so a later reconcile reuses it and never creates a second +//! engine. The provision permission set grants create and delete but no get/list, so readiness is +//! not re-read and reuse comes from state, never a lookup. + +use std::time::Duration; +use tracing::info; + +use crate::core::ResourceControllerContext; +use crate::error::{ErrorData, Result}; +use alien_core::{GcpAgentPlatformEngine, ResourceOutputs, ResourceStatus}; +use alien_error::{AlienError, Context, IntoAlienError}; +use alien_gcp_clients::agent_platform::ReasoningEngine; +use alien_gcp_clients::longrunning::OperationResult; +use alien_macros::controller; + +/// Last path segment of a resource name — the bare id the client interpolates back into its paths. +fn last_segment(name: &str) -> &str { + name.rsplit('/').next().unwrap_or(name) +} + +/// Requires a long-running operation to carry a name to poll — a nameless one cannot be resumed. +fn require_operation_name(name: Option, resource_id: &str) -> Result { + name.ok_or_else(|| { + AlienError::new(ErrorData::CloudPlatformError { + message: "engine operation carried no name to poll".to_string(), + resource_id: Some(resource_id.to_string()), + }) + }) +} + +#[controller] +pub struct GcpAgentPlatformEngineController { + /// Server-assigned engine id (last path segment), the contract the template controller reads. + pub(crate) engine_id: Option, + /// The create long-running operation being polled to learn the engine's id. + pub(crate) pending_operation: Option, +} + +#[controller] +impl GcpAgentPlatformEngineController { + // ─────────────── CREATE FLOW ────────────────────────────── + + #[flow_entry(Create)] + #[handler( + state = CreateStart, + on_failure = CreateFailed, + status = ResourceStatus::Provisioning, + )] + async fn create_start(&mut self, ctx: &ResourceControllerContext<'_>) -> Result { + let config = ctx.desired_resource_config::()?; + + // A persisted id means the engine already exists; create is never retried. + if self.engine_id.is_some() { + return Ok(HandlerAction::Continue { + state: Ready, + suggested_delay: None, + }); + } + + let gcp_config = ctx.get_gcp_config()?; + let client = ctx + .service_provider + .get_gcp_agent_platform_client(gcp_config)?; + + let display_name = format!("{}-{}", ctx.resource_prefix, config.id); + info!(id=%config.id, "Creating Agent Platform reasoning engine"); + let operation = + client + .create_engine(&display_name) + .await + .context(ErrorData::CloudPlatformError { + message: format!("Failed to create reasoning engine '{display_name}'"), + resource_id: Some(config.id.clone()), + })?; + + self.pending_operation = Some(require_operation_name(operation.name, &config.id)?); + Ok(HandlerAction::Continue { + state: AwaitingEngineOperation, + suggested_delay: Some(Duration::from_secs(2)), + }) + } + + #[handler( + state = AwaitingEngineOperation, + on_failure = CreateFailed, + status = ResourceStatus::Provisioning, + )] + async fn awaiting_engine_operation( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let config = ctx.desired_resource_config::()?; + let gcp_config = ctx.get_gcp_config()?; + let client = ctx + .service_provider + .get_gcp_agent_platform_client(gcp_config)?; + + let op_name = self.pending_operation.clone().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "no pending engine operation in state".to_string(), + resource_id: Some(config.id.clone()), + }) + })?; + + let operation = + client + .get_operation(&op_name) + .await + .context(ErrorData::CloudPlatformError { + message: format!("Failed to poll engine operation '{op_name}'"), + resource_id: Some(config.id.clone()), + })?; + + if operation.done != Some(true) { + return Ok(HandlerAction::Stay { + max_times: Some(150), + suggested_delay: Some(Duration::from_secs(2)), + }); + } + + let engine = match operation.result { + Some(OperationResult::Response { response }) => { + serde_json::from_value::(response) + .into_alien_error() + .context(ErrorData::CloudPlatformError { + message: "engine create operation returned an unreadable resource" + .to_string(), + resource_id: Some(config.id.clone()), + })? + } + Some(OperationResult::Error { error }) => { + return Err(AlienError::new(ErrorData::CloudPlatformError { + message: format!( + "engine create failed: {} (grpc {})", + error.message, error.code + ), + resource_id: Some(config.id.clone()), + })); + } + None => { + return Err(AlienError::new(ErrorData::CloudPlatformError { + message: "engine create operation reported done without a result".to_string(), + resource_id: Some(config.id.clone()), + })); + } + }; + + let name = engine.name.ok_or_else(|| { + AlienError::new(ErrorData::CloudPlatformError { + message: "created engine carried no resource name".to_string(), + resource_id: Some(config.id.clone()), + }) + })?; + self.engine_id = Some(last_segment(&name).to_string()); + self.pending_operation = None; + info!(id=%config.id, engine=%last_segment(&name), "reasoning engine ready"); + + Ok(HandlerAction::Continue { + state: Ready, + suggested_delay: None, + }) + } + + // ─────────────── READY STATE ──────────────────────────────── + + #[handler( + state = Ready, + on_failure = RefreshFailed, + status = ResourceStatus::Running, + )] + async fn ready(&mut self, _ctx: &ResourceControllerContext<'_>) -> Result { + // No `get_engine` on the client and no per-session health to read here; the engine is + // create-once, so Ready idles and re-reads nothing. + Ok(HandlerAction::Continue { + state: Ready, + suggested_delay: Some(Duration::from_secs(60)), + }) + } + + // ─────────────── DELETE FLOW ────────────────────────────── + + #[flow_entry(Delete)] + #[handler( + state = DeleteStart, + on_failure = DeleteFailed, + status = ResourceStatus::Deleting, + )] + async fn delete_start(&mut self, ctx: &ResourceControllerContext<'_>) -> Result { + let config = ctx.desired_resource_config::()?; + + let Some(engine) = self.engine_id.clone() else { + // Nothing was ever created — a delete with no engine is already done. + return Ok(HandlerAction::Continue { + state: Deleted, + suggested_delay: None, + }); + }; + + let gcp_config = ctx.get_gcp_config()?; + let client = ctx + .service_provider + .get_gcp_agent_platform_client(gcp_config)?; + + // An orphaned engine bills, so a genuine delete failure surfaces rather than being + // swallowed; the client already maps not-found to success. + client + .delete_engine(&engine) + .await + .context(ErrorData::CloudPlatformError { + message: format!("Failed to delete reasoning engine '{engine}'"), + resource_id: Some(config.id.clone()), + })?; + + self.engine_id = None; + info!(id=%config.id, "reasoning engine teardown complete"); + Ok(HandlerAction::Continue { + state: Deleted, + suggested_delay: None, + }) + } + + // ─────────────── TERMINALS ──────────────────────────────── + + terminal_state!( + state = CreateFailed, + status = ResourceStatus::ProvisionFailed + ); + terminal_state!(state = DeleteFailed, status = ResourceStatus::DeleteFailed); + terminal_state!( + state = RefreshFailed, + status = ResourceStatus::RefreshFailed + ); + terminal_state!(state = Deleted, status = ResourceStatus::Deleted); + + fn build_outputs(&self) -> Option { + None + } +} + +impl GcpAgentPlatformEngineController { + /// Creates a controller already holding a ready engine id, for tests that seed it as a + /// dependency of the template controller. + #[cfg(feature = "test-utils")] + pub fn mock_ready(engine_id: &str) -> Self { + Self { + state: GcpAgentPlatformEngineState::Ready, + engine_id: Some(engine_id.to_string()), + pending_operation: None, + _internal_stay_count: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::controller_test::SingleControllerExecutor; + use crate::MockPlatformServiceProvider; + use alien_core::Platform; + use alien_gcp_clients::agent_platform::MockAgentPlatformApi; + use alien_gcp_clients::longrunning::Operation; + use std::sync::Arc; + + fn provider_with(client: Arc) -> Arc { + let mut provider = MockPlatformServiceProvider::new(); + provider + .expect_get_gcp_agent_platform_client() + .returning(move |_| Ok(client.clone())); + Arc::new(provider) + } + + fn pending_op() -> Operation { + Operation { + name: Some("projects/p/locations/us-central1/operations/op1".to_string()), + metadata: None, + done: Some(false), + result: None, + } + } + + /// A completed create operation whose response carries the engine's full resource name. + fn done_engine_op(engine_id: &str) -> Operation { + Operation { + name: Some("projects/p/locations/us-central1/operations/op1".to_string()), + metadata: None, + done: Some(true), + result: Some(OperationResult::Response { + response: serde_json::json!({ + "name": format!( + "projects/p/locations/us-central1/reasoningEngines/{engine_id}" + ) + }), + }), + } + } + + async fn build_executor( + provider: Arc, + ) -> SingleControllerExecutor { + SingleControllerExecutor::builder() + .resource(GcpAgentPlatformEngine::new("orders-engine".to_string()).build()) + .controller(GcpAgentPlatformEngineController::default()) + .platform(Platform::Gcp) + .service_provider(provider) + .with_test_dependencies() + .build() + .await + .expect("executor builds") + } + + #[tokio::test] + async fn create_records_the_server_assigned_id_then_deletes_it() { + let mut m = MockAgentPlatformApi::new(); + m.expect_create_engine().returning(|_| Ok(pending_op())); + m.expect_get_operation() + .returning(|_| Ok(done_engine_op("eng-42"))); + m.expect_delete_engine().returning(|_| Ok(())); + let provider = provider_with(Arc::new(m)); + + let mut executor = build_executor(provider).await; + executor + .run_until_terminal() + .await + .expect("create runs to a steady state"); + assert_eq!(executor.status(), ResourceStatus::Running); + + let controller = executor + .internal_state::() + .expect("the controller downcasts"); + assert_eq!( + controller.engine_id.as_deref(), + Some("eng-42"), + "the server-assigned engine id is recorded, not fabricated" + ); + + executor.delete().expect("delete is accepted"); + executor + .run_until_terminal() + .await + .expect("delete runs to terminal"); + assert_eq!(executor.status(), ResourceStatus::Deleted); + } + + /// A controller must round-trip by tag: the executor persists and reloads state between + /// reconciles, and a missing by-tag arm surfaces as an above-the-handler failure with no + /// per-resource cause to read. + #[test] + fn controller_round_trips_by_tag() { + use crate::core::{deserialize_controller, serialize_controller, ResourceController}; + + let controller = GcpAgentPlatformEngineController { + engine_id: Some("eng-42".to_string()), + ..Default::default() + }; + let value = serialize_controller(&controller).expect("serializes with its tag"); + assert_eq!(value["type"], "GcpAgentPlatformEngineController"); + + let restored = deserialize_controller(value).expect("a registered tag must deserialize"); + assert_eq!(restored.controller_type(), controller.controller_type()); + } + + #[tokio::test] + async fn a_create_failure_lands_in_provision_failed() { + let mut m = MockAgentPlatformApi::new(); + m.expect_create_engine().returning(|_| { + Err(AlienError::new( + alien_gcp_clients::agent_platform::AgentPlatformErrorData::RequestFailed { + operation: "create engine".to_string(), + message: "quota exceeded".to_string(), + }, + )) + }); + let provider = provider_with(Arc::new(m)); + + let mut executor = build_executor(provider).await; + + // The failure must surface as an error the executor routes to CreateFailed, not a silent + // retry. Bounded so a poll-forever regression fails instead of hanging. + let mut surfaced = false; + for _ in 0..3 { + if executor.step().await.is_err() { + surfaced = true; + break; + } + } + assert!( + surfaced, + "a create-engine failure surfaces rather than being swallowed" + ); + } +} diff --git a/crates/alien-infra/src/sandbox/gcp_agent_platform_template.rs b/crates/alien-infra/src/sandbox/gcp_agent_platform_template.rs new file mode 100644 index 000000000..9ba568b8a --- /dev/null +++ b/crates/alien-infra/src/sandbox/gcp_agent_platform_template.rs @@ -0,0 +1,1156 @@ +//! GCP Agent Platform sandbox template controller. +//! +//! Reconciles the `SandboxEnvironmentTemplate`: the Live, release-owned object that carries +//! the image digest, ceilings and egress and warms the session pool. The reasoning engine it hangs +//! under is a separate Live resource with its own controller; this one reads the engine's id as a +//! dependency and creates templates beneath it, never creating the engine itself. +//! +//! Template config is immutable: there is no update verb, so reconciliation is replace-not-update. +//! A changed image (or any field that lands in the template body) creates a new template, waits for +//! it to become `ACTIVE`, and only then reaps the old one — so a release never leaves a session +//! pointing at a template that has already been deleted. + +use std::collections::HashMap; +use std::time::Duration; +use tracing::{info, warn}; + +use crate::core::ResourceControllerContext; +use crate::error::{ErrorData, Result}; +use crate::sandbox::GcpAgentPlatformEngineController; +use alien_core::{ + GcpAgentPlatformEngine, ResourceOutputs, ResourceRef, ResourceStatus, Sandbox, SandboxCode, + SandboxLimits, +}; +use alien_error::{AlienError, Context, IntoAlienError}; +use alien_gcp_clients::agent_platform::{ + ContainerResources, CustomContainerEnvironment, CustomContainerSpec, EgressControlConfig, + SandboxEnvironmentTemplate, +}; +use alien_gcp_clients::longrunning::OperationResult; +use alien_macros::controller; + +/// Lifecycle state the API reports for a template that is ready to cut sessions from. +const TEMPLATE_ACTIVE: &str = "ACTIVE"; + +/// Last path segment of a resource name — the id the client interpolates back into its paths. +fn last_segment(name: &str) -> &str { + name.rsplit('/').next().unwrap_or(name) +} + +/// The fields of a template that, when changed, force a replace. +/// +/// The template is immutable, so any of these differing between the desired and previous +/// declaration means the old template cannot be updated in place — it is torn down and rebuilt. +/// The image is the digest the spec names; the ceilings and egress are here because they are baked +/// into the same immutable body. +fn template_identity(sandbox: &Sandbox) -> Result<(String, SandboxLimits, bool)> { + let image = match &sandbox.code { + SandboxCode::Image { image } => image.clone(), + SandboxCode::Source { .. } => { + return Err(AlienError::new(ErrorData::ResourceConfigInvalid { + message: "no sandbox backend builds an image from source; give code.image a \ + prebuilt reference" + .to_string(), + resource_id: Some(sandbox.id().to_string()), + })); + } + }; + let internet_access = internet_access_or_refuse(sandbox)?; + Ok((image, sandbox.resolved_limits(), internet_access)) +} + +/// The egress switch, or a refusal naming the sandbox and both accepted modes. +/// +/// `AllowDomains` has no representation in the single internet-access switch, so it is refused +/// rather than approximated. The mode→switch mapping is `SandboxEgress::internet_access_switch`, so +/// this cannot disagree with the emitter or the provider on what a mode means. +fn internet_access_or_refuse(sandbox: &Sandbox) -> Result { + sandbox.egress.internet_access_switch().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: format!( + "sandbox '{}' asked for domain-scoped egress, which Agent Platform cannot \ + express; it offers only 'allow' (open) and 'deny' (closed)", + sandbox.id() + ), + resource_id: Some(sandbox.id().to_string()), + }) + }) +} + +/// Builds the immutable template body from the declaration. +fn build_template_body( + sandbox: &Sandbox, + display_name: &str, +) -> Result { + let (image, limits, internet_access) = template_identity(sandbox)?; + + // cpu and memory are the ceilings the API's resource map expresses; disk and max_processes have + // no field on this template and are enforced by the runtime tier instead. + let resources = ContainerResources { + requests: None, + limits: Some(HashMap::from([ + ("cpu".to_string(), limits.cpu), + ("memory".to_string(), limits.memory), + ])), + }; + + Ok(SandboxEnvironmentTemplate { + name: None, + display_name: Some(display_name.to_string()), + custom_container_environment: Some(CustomContainerEnvironment { + // No env: the command shares the agent's uid and can read the supervisor's + // environment, so a secret placed here would leak. Capability auth, if ever wanted, + // needs a carrier that is not the container env. + custom_container_spec: Some(CustomContainerSpec { + image_uri: image, + extra: Default::default(), + }), + resources: Some(resources), + ports: vec![], + extra: Default::default(), + }), + egress_control_config: Some(EgressControlConfig { + internet_access: Some(internet_access), + extra: Default::default(), + }), + state: None, + extra: Default::default(), + }) +} + +#[controller] +pub struct GcpAgentPlatformTemplateController { + /// Reasoning-engine id the template is created under, as the client's path interpolation wants + /// it (a bare segment). + pub(crate) engine: Option, + /// The `ACTIVE` template sessions are currently cut from (last path segment). + pub(crate) template_id: Option, + /// The create long-running operation being polled to learn a new template's id. + pub(crate) pending_operation: Option, + /// A template being brought to `ACTIVE` before it replaces `template_id`. During a replace the + /// old template keeps serving until this one is live. + pub(crate) pending_template_id: Option, + /// Project the engine lives in, kept for the binding the provider reads. + pub(crate) project_id: Option, + /// Region selecting the regional endpoint, kept for the binding. + pub(crate) region: Option, + /// Session lifetime from the declaration, carried into the binding. + pub(crate) session_ttl_seconds: Option, +} + +#[controller] +impl GcpAgentPlatformTemplateController { + // ─────────────── CREATE FLOW ────────────────────────────── + + #[flow_entry(Create)] + #[handler( + state = CreateStart, + on_failure = CreateFailed, + status = ResourceStatus::Provisioning, + )] + async fn create_start(&mut self, ctx: &ResourceControllerContext<'_>) -> Result { + let config = ctx.desired_resource_config::()?; + let gcp_config = ctx.get_gcp_config()?; + let client = ctx + .service_provider + .get_gcp_agent_platform_client(gcp_config)?; + + // The engine id is server-assigned; read it from the engine dependency's state, keyed by + // the `{id}-engine` convention the engine mutation writes. + let engine_ref = ResourceRef::new( + GcpAgentPlatformEngine::RESOURCE_TYPE, + GcpAgentPlatformEngine::id_for_sandbox(&config.id), + ); + let engine = ctx + .require_dependency::(&engine_ref)? + .engine_id + .ok_or_else(|| missing_state(&config.id, "engine id from the engine dependency"))?; + let display_name = format!("{}-{}", ctx.resource_prefix, config.id); + let body = build_template_body(config, &display_name)?; + + self.engine = Some(engine.clone()); + self.project_id = Some(gcp_config.project_id.clone()); + self.region = Some(gcp_config.region.clone()); + self.session_ttl_seconds = config.session.max_lifetime_seconds; + + info!(id=%config.id, engine=%engine, "Creating sandbox environment template"); + let operation = + client + .create_template(&engine, body) + .await + .context(ErrorData::CloudPlatformError { + message: format!("Failed to create sandbox template under engine '{engine}'"), + resource_id: Some(config.id.clone()), + })?; + + self.pending_operation = Some(require_operation_name(operation.name, &config.id)?); + Ok(HandlerAction::Continue { + state: AwaitingTemplateOperation, + suggested_delay: Some(Duration::from_secs(2)), + }) + } + + #[handler( + state = AwaitingTemplateOperation, + on_failure = CreateFailed, + status = ResourceStatus::Provisioning, + )] + async fn awaiting_template_operation( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let config = ctx.desired_resource_config::()?; + let gcp_config = ctx.get_gcp_config()?; + let client = ctx + .service_provider + .get_gcp_agent_platform_client(gcp_config)?; + + let op_name = self.pending_operation.clone().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "no pending template operation in state".to_string(), + resource_id: Some(config.id.clone()), + }) + })?; + + let operation = + client + .get_operation(&op_name) + .await + .context(ErrorData::CloudPlatformError { + message: format!("Failed to poll template operation '{op_name}'"), + resource_id: Some(config.id.clone()), + })?; + + if operation.done != Some(true) { + return Ok(HandlerAction::Stay { + max_times: Some(150), + suggested_delay: Some(Duration::from_secs(2)), + }); + } + + let template = match operation.result { + Some(OperationResult::Response { response }) => serde_json::from_value::< + SandboxEnvironmentTemplate, + >(response) + .into_alien_error() + .context(ErrorData::CloudPlatformError { + message: "template create operation returned an unreadable resource".to_string(), + resource_id: Some(config.id.clone()), + })?, + Some(OperationResult::Error { error }) => { + return Err(AlienError::new(ErrorData::CloudPlatformError { + message: format!( + "template create failed: {} (grpc {})", + error.message, error.code + ), + resource_id: Some(config.id.clone()), + })); + } + None => { + return Err(AlienError::new(ErrorData::CloudPlatformError { + message: "template create operation reported done without a result".to_string(), + resource_id: Some(config.id.clone()), + })); + } + }; + + let name = template.name.ok_or_else(|| { + AlienError::new(ErrorData::CloudPlatformError { + message: "created template carried no resource name".to_string(), + resource_id: Some(config.id.clone()), + }) + })?; + self.pending_template_id = Some(last_segment(&name).to_string()); + self.pending_operation = None; + + Ok(HandlerAction::Continue { + state: AwaitingTemplateActive, + suggested_delay: Some(Duration::from_secs(2)), + }) + } + + #[handler( + state = AwaitingTemplateActive, + on_failure = CreateFailed, + status = ResourceStatus::Provisioning, + )] + async fn awaiting_template_active( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let config = ctx.desired_resource_config::()?; + let gcp_config = ctx.get_gcp_config()?; + let client = ctx + .service_provider + .get_gcp_agent_platform_client(gcp_config)?; + let (engine, pending) = self.engine_and_pending(&config.id)?; + + let template = client.get_template(&engine, &pending).await.context( + ErrorData::CloudPlatformError { + message: format!("Failed to read template '{pending}' while waiting for ACTIVE"), + resource_id: Some(config.id.clone()), + }, + )?; + + if template.state.as_deref() != Some(TEMPLATE_ACTIVE) { + return Ok(HandlerAction::Stay { + max_times: Some(150), + suggested_delay: Some(Duration::from_secs(2)), + }); + } + + // The new template is live; only now does it become the serving one, so the reap that + // follows can delete the old without a window where sessions point at a deleted template. + self.template_id = Some(pending); + self.pending_template_id = None; + + Ok(HandlerAction::Continue { + state: ReapingOldTemplates, + suggested_delay: None, + }) + } + + #[handler( + state = ReapingOldTemplates, + on_failure = CreateFailed, + status = ResourceStatus::Provisioning, + )] + async fn reaping_old_templates( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let config = ctx.desired_resource_config::()?; + let gcp_config = ctx.get_gcp_config()?; + let client = ctx + .service_provider + .get_gcp_agent_platform_client(gcp_config)?; + let (engine, serving) = self.engine_and_template(&config.id)?; + + let templates = + client + .list_templates(&engine) + .await + .context(ErrorData::CloudPlatformError { + message: format!( + "Failed to list templates under engine '{engine}' to reap old ones" + ), + resource_id: Some(config.id.clone()), + })?; + + for template in templates { + let Some(name) = template.name.as_deref() else { + continue; + }; + let id = last_segment(name); + if id == serving { + continue; + } + // Best-effort: the new template already serves, so a straggler left by a transient + // delete failure is cost, not a correctness break — the next reconcile reaps it. A + // hard error here must not fail an update whose replacement is already live. + if let Err(e) = client.delete_template(&engine, id).await { + warn!(engine=%engine, template=%id, error=%e, "could not reap an old template, leaving it for the next reconcile"); + } else { + info!(engine=%engine, template=%id, "reaped an old sandbox template"); + } + } + + Ok(HandlerAction::Continue { + state: Ready, + suggested_delay: None, + }) + } + + // ─────────────── READY STATE ──────────────────────────────── + + #[handler( + state = Ready, + on_failure = RefreshFailed, + status = ResourceStatus::Running, + )] + async fn ready(&mut self, ctx: &ResourceControllerContext<'_>) -> Result { + let config = ctx.desired_resource_config::()?; + let gcp_config = ctx.get_gcp_config()?; + let client = ctx + .service_provider + .get_gcp_agent_platform_client(gcp_config)?; + let (engine, template_id) = self.engine_and_template(&config.id)?; + + // On this platform a resource's state is not a health signal for the sessions cut from it — + // a session can be dead while everything here reads healthy. For the template itself the + // lifecycle state is the only signal there is, so the heartbeat confirms exactly that and + // claims nothing more. + let template = client.get_template(&engine, &template_id).await.context( + ErrorData::CloudPlatformError { + message: format!("Failed to read template '{template_id}' during heartbeat"), + resource_id: Some(config.id.clone()), + }, + )?; + + if template.state.as_deref() != Some(TEMPLATE_ACTIVE) { + return Err(AlienError::new(ErrorData::ResourceDrift { + resource_id: config.id.clone(), + message: format!( + "template '{template_id}' is no longer ACTIVE (state '{}')", + template.state.as_deref().unwrap_or("") + ), + })); + } + + Ok(HandlerAction::Continue { + state: Ready, + suggested_delay: Some(Duration::from_secs(30)), + }) + } + + // ─────────────── UPDATE FLOW ────────────────────────────── + + #[flow_entry(Update, from = [Ready, RefreshFailed])] + #[handler( + state = UpdateStart, + on_failure = UpdateFailed, + status = ResourceStatus::Updating, + )] + async fn update_start(&mut self, ctx: &ResourceControllerContext<'_>) -> Result { + let config = ctx.desired_resource_config::()?; + let previous = ctx.previous_resource_config::()?; + + // The template is immutable, so an unchanged body needs no work and a changed one is a + // replace, never an in-place edit. + if template_identity(config)? == template_identity(previous)? { + info!(id=%config.id, "sandbox template unchanged; nothing to replace"); + return Ok(HandlerAction::Continue { + state: Ready, + suggested_delay: None, + }); + } + + let gcp_config = ctx.get_gcp_config()?; + let client = ctx + .service_provider + .get_gcp_agent_platform_client(gcp_config)?; + let engine = self.engine.clone().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "no engine in state to replace the template under".to_string(), + resource_id: Some(config.id.clone()), + }) + })?; + + let display_name = format!("{}-{}", ctx.resource_prefix, config.id); + let body = build_template_body(config, &display_name)?; + + info!(id=%config.id, "sandbox template body changed; creating a replacement"); + let operation = + client + .create_template(&engine, body) + .await + .context(ErrorData::CloudPlatformError { + message: format!( + "Failed to create replacement template under engine '{engine}'" + ), + resource_id: Some(config.id.clone()), + })?; + + // The old template stays in `template_id` and keeps serving; the reap after ACTIVE removes + // it. Routing through the create flow's await states keeps one mutable op per state. + self.pending_operation = Some(require_operation_name(operation.name, &config.id)?); + Ok(HandlerAction::Continue { + state: AwaitingTemplateOperation, + suggested_delay: Some(Duration::from_secs(2)), + }) + } + + // ─────────────── DELETE FLOW ────────────────────────────── + + #[flow_entry(Delete)] + #[handler( + state = DeleteStart, + on_failure = DeleteFailed, + status = ResourceStatus::Deleting, + )] + async fn delete_start(&mut self, ctx: &ResourceControllerContext<'_>) -> Result { + let config = ctx.desired_resource_config::()?; + + let Some(engine) = self.engine.clone() else { + // Nothing was ever created — a delete with no parent is already done. + return Ok(HandlerAction::Continue { + state: Deleted, + suggested_delay: None, + }); + }; + let gcp_config = ctx.get_gcp_config()?; + let client = ctx + .service_provider + .get_gcp_agent_platform_client(gcp_config)?; + + // Best-effort and idempotent: delete_template treats a not-found as success, and both the + // serving and any half-created template are torn down so a failed create leaves nothing. + for template_id in [self.template_id.clone(), self.pending_template_id.clone()] + .into_iter() + .flatten() + { + if let Err(e) = client.delete_template(&engine, &template_id).await { + warn!(engine=%engine, template=%template_id, error=%e, "could not delete a template during teardown, continuing"); + } + } + + self.clear_state(); + info!(id=%config.id, "sandbox template teardown complete"); + Ok(HandlerAction::Continue { + state: Deleted, + suggested_delay: None, + }) + } + + // ─────────────── TERMINALS ──────────────────────────────── + + terminal_state!( + state = CreateFailed, + status = ResourceStatus::ProvisionFailed + ); + terminal_state!(state = UpdateFailed, status = ResourceStatus::UpdateFailed); + terminal_state!(state = DeleteFailed, status = ResourceStatus::DeleteFailed); + terminal_state!( + state = RefreshFailed, + status = ResourceStatus::RefreshFailed + ); + terminal_state!(state = Deleted, status = ResourceStatus::Deleted); + + fn build_outputs(&self) -> Option { + None + } + + fn get_binding_params(&self) -> Result> { + use alien_core::bindings::{BindingValue, SandboxBinding}; + + let (Some(engine), Some(template_id), Some(project), Some(region)) = ( + &self.engine, + &self.template_id, + &self.project_id, + &self.region, + ) else { + return Ok(None); + }; + + let engine_name = + format!("projects/{project}/locations/{region}/reasoningEngines/{engine}"); + let template_name = format!("{engine_name}/sandboxEnvironmentTemplates/{template_id}"); + let binding = SandboxBinding::gcp_agent_platform( + BindingValue::value(engine_name), + BindingValue::value(template_name), + BindingValue::value(region.clone()), + self.session_ttl_seconds, + ); + Ok(Some( + serde_json::to_value(binding).into_alien_error().context( + ErrorData::ResourceStateSerializationFailed { + resource_id: "binding".to_string(), + message: "Failed to serialize sandbox binding parameters".to_string(), + }, + )?, + )) + } +} + +/// Requires a long-running operation to carry a name to poll — a nameless one cannot be resumed. +fn require_operation_name(name: Option, resource_id: &str) -> Result { + name.ok_or_else(|| { + AlienError::new(ErrorData::CloudPlatformError { + message: "template operation carried no name to poll".to_string(), + resource_id: Some(resource_id.to_string()), + }) + }) +} + +impl GcpAgentPlatformTemplateController { + fn clear_state(&mut self) { + self.engine = None; + self.template_id = None; + self.pending_operation = None; + self.pending_template_id = None; + self.project_id = None; + self.region = None; + self.session_ttl_seconds = None; + } + + fn engine_and_template(&self, resource_id: &str) -> Result<(String, String)> { + let engine = self + .engine + .clone() + .ok_or_else(|| missing_state(resource_id, "engine"))?; + let template_id = self + .template_id + .clone() + .ok_or_else(|| missing_state(resource_id, "template id"))?; + Ok((engine, template_id)) + } + + fn engine_and_pending(&self, resource_id: &str) -> Result<(String, String)> { + let engine = self + .engine + .clone() + .ok_or_else(|| missing_state(resource_id, "engine"))?; + let pending = self + .pending_template_id + .clone() + .ok_or_else(|| missing_state(resource_id, "pending template id"))?; + Ok((engine, pending)) + } + + /// Creates a controller already serving an ACTIVE template, for update-flow tests. + #[cfg(feature = "test-utils")] + pub fn mock_ready(engine: &str, template_id: &str) -> Self { + Self { + state: GcpAgentPlatformTemplateState::Ready, + engine: Some(engine.to_string()), + template_id: Some(template_id.to_string()), + pending_operation: None, + pending_template_id: None, + project_id: Some("test-project-123".to_string()), + region: Some("us-central1".to_string()), + session_ttl_seconds: None, + _internal_stay_count: None, + } + } +} + +fn missing_state(resource_id: &str, field: &str) -> AlienError { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: format!("controller state is missing the {field}"), + resource_id: Some(resource_id.to_string()), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::controller_test::SingleControllerExecutor; + use crate::MockPlatformServiceProvider; + use alien_core::Platform; + use alien_core::{SandboxEgress, SandboxSessionPolicy}; + use alien_gcp_clients::agent_platform::MockAgentPlatformApi; + use alien_gcp_clients::longrunning::{Operation, OperationResult}; + use std::sync::{Arc, Mutex}; + + fn sandbox_with( + egress: SandboxEgress, + image: &str, + ttl: Option, + limits: Option, + ) -> Sandbox { + let builder = Sandbox::new("agent-sbx".to_string()) + .code(SandboxCode::Image { + image: image.to_string(), + }) + .egress(egress) + .session(SandboxSessionPolicy { + max_lifetime_seconds: ttl, + idle_suspend_seconds: None, + }); + match limits { + Some(limits) => builder.limits(limits).build(), + None => builder.build(), + } + } + + /// A create long-running operation, still pending — the controller only reads its name here. + fn pending_op() -> Operation { + Operation { + name: Some("projects/p/locations/us-central1/operations/op1".to_string()), + metadata: None, + done: Some(false), + result: None, + } + } + + /// A completed create operation whose response carries the new template's resource name. + fn done_op(template_id: &str) -> Operation { + Operation { + name: Some("projects/p/locations/us-central1/operations/op1".to_string()), + metadata: None, + done: Some(true), + result: Some(OperationResult::Response { + response: serde_json::json!({ + "name": format!( + "projects/p/locations/us-central1/reasoningEngines/eng/sandboxEnvironmentTemplates/{template_id}" + ), + "state": "CREATING" + }), + }), + } + } + + fn active_template(template_id: &str) -> SandboxEnvironmentTemplate { + SandboxEnvironmentTemplate { + name: Some(format!( + "projects/p/locations/us-central1/reasoningEngines/eng/sandboxEnvironmentTemplates/{template_id}" + )), + display_name: None, + custom_container_environment: None, + egress_control_config: None, + state: Some(TEMPLATE_ACTIVE.to_string()), + extra: Default::default(), + } + } + + /// A mock that carries one sandbox from create through a heartbeat and a clean delete. + fn happy_client() -> Arc { + let mut m = MockAgentPlatformApi::new(); + m.expect_create_template() + .returning(|_, _| Ok(pending_op())); + m.expect_get_operation().returning(|_| Ok(done_op("tpl1"))); + m.expect_get_template() + .returning(|_, id| Ok(active_template(id))); + m.expect_list_templates() + .returning(|_| Ok(vec![active_template("tpl1")])); + m.expect_delete_template().returning(|_, _| Ok(())); + Arc::new(m) + } + + fn provider_with(client: Arc) -> Arc { + let mut provider = MockPlatformServiceProvider::new(); + provider + .expect_get_gcp_agent_platform_client() + .returning(move |_| Ok(client.clone())); + Arc::new(provider) + } + + async fn build_executor( + resource: Sandbox, + provider: Arc, + ) -> SingleControllerExecutor { + SingleControllerExecutor::builder() + .resource(resource) + .controller(GcpAgentPlatformTemplateController::default()) + .platform(Platform::Gcp) + .service_provider(provider) + .with_test_dependencies() + // The engine the sandbox depends on, already provisioned: create_start reads its + // server-assigned id ("eng") from here rather than fabricating one. + .with_dependency( + GcpAgentPlatformEngine::new(GcpAgentPlatformEngine::id_for_sandbox("agent-sbx")) + .build(), + GcpAgentPlatformEngineController::mock_ready("eng"), + ) + .build() + .await + .expect("executor builds") + } + + /// A controller must round-trip by tag: the executor persists and reloads state between + /// reconciles, and a missing by-tag arm fails above the handler layer with no cause to read. + #[test] + fn controller_round_trips_by_tag() { + use crate::core::{deserialize_controller, serialize_controller, ResourceController}; + + let controller = GcpAgentPlatformTemplateController { + engine: Some("eng".to_string()), + template_id: Some("tpl1".to_string()), + ..Default::default() + }; + let value = serialize_controller(&controller).expect("serializes with its tag"); + assert_eq!(value["type"], "GcpAgentPlatformTemplateController"); + + let restored = deserialize_controller(value).expect("a registered tag must deserialize"); + assert_eq!(restored.controller_type(), controller.controller_type()); + } + + // ---- 1. Create and delete flow, across config variants. ----------------------------------- + + async fn create_then_delete(resource: Sandbox) { + let provider = provider_with(happy_client()); + let mut executor = build_executor(resource, provider).await; + + executor + .run_until_terminal() + .await + .expect("create runs to a steady state"); + assert_eq!( + executor.status(), + ResourceStatus::Running, + "an ACTIVE template leaves the controller Running" + ); + + let controller = executor + .internal_state::() + .expect("the controller downcasts"); + assert_eq!( + controller.template_id.as_deref(), + Some("tpl1"), + "the ACTIVE template id is the serving one" + ); + assert_eq!( + controller.engine.as_deref(), + Some("eng"), + "the template is created under the engine's real id from the dependency" + ); + + executor.delete().expect("delete is accepted"); + executor + .run_until_terminal() + .await + .expect("delete runs to terminal"); + assert_eq!(executor.status(), ResourceStatus::Deleted); + } + + #[tokio::test] + async fn create_delete_deny_egress_default_limits() { + create_then_delete(sandbox_with( + SandboxEgress::Deny, + "ubuntu:24.04", + None, + None, + )) + .await; + } + + #[tokio::test] + async fn create_delete_allow_egress() { + create_then_delete(sandbox_with( + SandboxEgress::Allow, + "ubuntu:24.04", + None, + None, + )) + .await; + } + + #[tokio::test] + async fn create_delete_with_session_ttl() { + create_then_delete(sandbox_with( + SandboxEgress::Deny, + "ubuntu:24.04", + Some(3600), + None, + )) + .await; + } + + #[tokio::test] + async fn create_delete_with_explicit_limits() { + let limits = SandboxLimits { + cpu: "2".to_string(), + memory: "4Gi".to_string(), + disk: "20Gi".to_string(), + max_processes: None, + }; + create_then_delete(sandbox_with( + SandboxEgress::Allow, + "ghcr.io/org/sbx:v1", + Some(1800), + Some(limits), + )) + .await; + } + + // ---- 1b. The binding the provider will read. ---------------------------------------------- + + #[tokio::test] + async fn binding_params_carry_the_active_template_region_and_ttl() { + let provider = provider_with(happy_client()); + let mut executor = build_executor( + sandbox_with(SandboxEgress::Deny, "ubuntu:24.04", Some(3600), None), + provider, + ) + .await; + executor.run_until_terminal().await.expect("create runs"); + + use crate::core::ResourceController; + let params = executor + .internal_state::() + .expect("downcasts") + .get_binding_params() + .expect("binding serializes") + .expect("a running template has a binding"); + let binding: alien_core::bindings::SandboxBinding = + serde_json::from_value(params).expect("binding parses back to the Agent Platform binding type"); + + match binding { + alien_core::bindings::SandboxBinding::GcpAgentPlatform(b) => { + let region = b + .region + .into_value("gcp-agent-platform", "region") + .expect("region is a literal in a test"); + assert_eq!(region, "us-central1"); + assert_eq!(b.session_ttl_seconds, Some(3600)); + let template = b + .template + .into_value("gcp-agent-platform", "template") + .expect("template is a literal in a test"); + assert!( + template.ends_with("/sandboxEnvironmentTemplates/tpl1"), + "the binding points at the ACTIVE template: {template}" + ); + assert!( + template.contains("/reasoningEngines/eng/"), + "the binding hangs the template under the engine's real id: {template}" + ); + } + other => panic!("expected a GCP Agent Platform binding, got {other:?}"), + } + } + + // ---- 2. Update flow: no-op when the body is unchanged. ------------------------------------- + + #[tokio::test] + async fn update_with_unchanged_body_creates_no_template() { + let mut m = MockAgentPlatformApi::new(); + // The heartbeat still reads the template; a replace would create, and it must not. + m.expect_get_template() + .returning(|_, id| Ok(active_template(id))); + m.expect_create_template().never(); + let provider = provider_with(Arc::new(m)); + + let resource = sandbox_with(SandboxEgress::Deny, "ubuntu:24.04", None, None); + let mut executor = SingleControllerExecutor::builder() + .resource(resource.clone()) + .controller(GcpAgentPlatformTemplateController::mock_ready( + "eng", "tpl1", + )) + .platform(Platform::Gcp) + .service_provider(provider) + .with_test_dependencies() + .build() + .await + .expect("executor builds"); + + executor.update(resource).expect("update accepted"); + executor.run_until_terminal().await.expect("update runs"); + assert_eq!(executor.status(), ResourceStatus::Running); + assert_eq!( + executor + .internal_state::() + .expect("downcasts") + .template_id + .as_deref(), + Some("tpl1"), + "an unchanged body keeps the original template" + ); + } + + // ---- 3. Replace on image change: new template ACTIVE before the old is reaped. ------------- + + #[tokio::test] + async fn image_change_replaces_the_template_reaping_the_old_after_active() { + // Records call order so the ordering guard is checked, not just the end state. + let calls: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let mut m = MockAgentPlatformApi::new(); + m.expect_create_template() + .returning(|_, _| Ok(pending_op())); + m.expect_get_operation().returning(|_| Ok(done_op("tpl2"))); + { + let calls = calls.clone(); + m.expect_get_template().returning(move |_, id| { + if id == "tpl2" { + calls.lock().unwrap().push("active:tpl2".to_string()); + } + Ok(active_template(id)) + }); + } + m.expect_list_templates() + .returning(|_| Ok(vec![active_template("tpl1"), active_template("tpl2")])); + { + let calls = calls.clone(); + m.expect_delete_template().returning(move |_, id| { + calls.lock().unwrap().push(format!("delete:{id}")); + Ok(()) + }); + } + let provider = provider_with(Arc::new(m)); + + let mut executor = SingleControllerExecutor::builder() + .resource(sandbox_with(SandboxEgress::Deny, "old:v1", None, None)) + .controller(GcpAgentPlatformTemplateController::mock_ready( + "eng", "tpl1", + )) + .platform(Platform::Gcp) + .service_provider(provider) + .with_test_dependencies() + .build() + .await + .expect("executor builds"); + + executor + .update(sandbox_with(SandboxEgress::Deny, "new:v2", None, None)) + .expect("update accepted"); + executor.run_until_terminal().await.expect("replace runs"); + assert_eq!(executor.status(), ResourceStatus::Running); + + assert_eq!( + executor + .internal_state::() + .expect("downcasts") + .template_id + .as_deref(), + Some("tpl2"), + "the new template is now the serving one" + ); + + let calls = calls.lock().unwrap(); + let active_at = calls + .iter() + .position(|c| c == "active:tpl2") + .expect("the new template was confirmed ACTIVE"); + let delete_at = calls + .iter() + .position(|c| c == "delete:tpl1") + .expect("the old template was reaped"); + assert!( + active_at < delete_at, + "the old template must be reaped only AFTER the new one is ACTIVE; order was {calls:?}" + ); + } + + // ---- 4. Best-effort deletion: teardown succeeds even when a delete errors. ----------------- + + #[tokio::test] + async fn delete_is_best_effort_when_the_api_errors() { + let mut m = MockAgentPlatformApi::new(); + m.expect_get_template() + .returning(|_, id| Ok(active_template(id))); + m.expect_delete_template().returning(|_, _| { + Err(AlienError::new( + alien_gcp_clients::agent_platform::AgentPlatformErrorData::RequestFailed { + operation: "delete template".to_string(), + message: "persistent failure".to_string(), + }, + )) + }); + let provider = provider_with(Arc::new(m)); + + let mut executor = SingleControllerExecutor::builder() + .resource(sandbox_with( + SandboxEgress::Deny, + "ubuntu:24.04", + None, + None, + )) + .controller(GcpAgentPlatformTemplateController::mock_ready( + "eng", "tpl1", + )) + .platform(Platform::Gcp) + .service_provider(provider) + .with_test_dependencies() + .build() + .await + .expect("executor builds"); + + executor.delete().expect("delete accepted"); + executor + .run_until_terminal() + .await + .expect("a failing template delete does not fail teardown"); + assert_eq!( + executor.status(), + ResourceStatus::Deleted, + "deletion is best-effort: a straggler is left for a sweep, teardown still completes" + ); + } + + #[tokio::test] + async fn delete_before_anything_created_is_already_done() { + let m = MockAgentPlatformApi::new(); + let provider = provider_with(Arc::new(m)); + let mut executor = build_executor( + sandbox_with(SandboxEgress::Deny, "ubuntu:24.04", None, None), + provider, + ) + .await; + + executor.delete().expect("delete accepted"); + executor + .run_until_terminal() + .await + .expect("deleting a never-created template is a no-op"); + assert_eq!(executor.status(), ResourceStatus::Deleted); + } + + // ---- 5. Validation: the body carried, and the egress refusal. ------------------------------ + + #[tokio::test] + async fn create_carries_the_declared_image_limits_and_egress() { + let mut m = MockAgentPlatformApi::new(); + m.expect_create_template() + .withf(|_engine, template| { + let env = template + .custom_container_environment + .as_ref() + .expect("template carries a container environment"); + let image = env + .custom_container_spec + .as_ref() + .expect("a container spec") + .image_uri + .as_str(); + let limits = env + .resources + .as_ref() + .and_then(|r| r.limits.as_ref()) + .expect("cpu/memory limits"); + let internet = template + .egress_control_config + .as_ref() + .and_then(|e| e.internet_access); + image == "ghcr.io/org/sbx:v9" + && limits.get("cpu").map(String::as_str) == Some("2") + && limits.get("memory").map(String::as_str) == Some("4Gi") + && internet == Some(true) + }) + .returning(|_, _| Ok(pending_op())); + m.expect_get_operation().returning(|_| Ok(done_op("tpl1"))); + m.expect_get_template() + .returning(|_, id| Ok(active_template(id))); + m.expect_list_templates() + .returning(|_| Ok(vec![active_template("tpl1")])); + let provider = provider_with(Arc::new(m)); + + let limits = SandboxLimits { + cpu: "2".to_string(), + memory: "4Gi".to_string(), + disk: "20Gi".to_string(), + max_processes: None, + }; + let mut executor = build_executor( + sandbox_with( + SandboxEgress::Allow, + "ghcr.io/org/sbx:v9", + None, + Some(limits), + ), + provider, + ) + .await; + executor + .run_until_terminal() + .await + .expect("create runs with the asserted body"); + assert_eq!(executor.status(), ResourceStatus::Running); + } + + /// Domain-scoped egress has no representation in the single switch, so the template body build + /// refuses it naming the sandbox and both accepted modes — it is never approximated. + #[test] + fn build_template_body_refuses_domain_egress_naming_the_sandbox_and_modes() { + let sandbox = sandbox_with( + SandboxEgress::AllowDomains { + domains: vec!["api.example.com".to_string()], + }, + "ubuntu:24.04", + None, + None, + ); + let error = build_template_body(&sandbox, "agent-sbx") + .expect_err("a hostname list has no representation on Agent Platform"); + assert_eq!(error.code, "RESOURCE_CONFIG_INVALID", "{error}"); + let rendered = error.to_string(); + assert!( + rendered.contains("agent-sbx"), + "names the sandbox: {rendered}" + ); + assert!( + rendered.contains("allow") && rendered.contains("deny"), + "names both accepted modes: {rendered}" + ); + } +} diff --git a/crates/alien-infra/src/sandbox/mod.rs b/crates/alien-infra/src/sandbox/mod.rs index 1e0781055..a15103436 100644 --- a/crates/alien-infra/src/sandbox/mod.rs +++ b/crates/alien-infra/src/sandbox/mod.rs @@ -25,11 +25,21 @@ mod kubernetes_spec; #[cfg(feature = "kubernetes")] mod kubernetes_warm_pool; #[cfg(feature = "kubernetes")] -pub use kubernetes_warm_pool::*; -#[cfg(feature = "kubernetes")] pub use kubernetes_spec::*; +#[cfg(feature = "kubernetes")] +pub use kubernetes_warm_pool::*; #[cfg(feature = "local")] mod local; #[cfg(feature = "local")] pub use local::*; + +#[cfg(feature = "gcp")] +mod gcp_agent_platform_template; +#[cfg(feature = "gcp")] +pub use gcp_agent_platform_template::*; + +#[cfg(feature = "gcp")] +mod gcp_agent_platform_engine; +#[cfg(feature = "gcp")] +pub use gcp_agent_platform_engine::*; diff --git a/crates/alien-infra/src/worker/gcp.rs b/crates/alien-infra/src/worker/gcp.rs index 0f770cd9f..fc63eb2cc 100644 --- a/crates/alien-infra/src/worker/gcp.rs +++ b/crates/alien-infra/src/worker/gcp.rs @@ -4572,7 +4572,6 @@ impl GcpWorkerController { .env(env) .resources(resources) .ports(ports) - .maybe_sandbox_launcher(cfg.sandbox_launcher.then_some(true)) .build(); let ingress = if cfg.public_endpoints.is_empty() { @@ -4636,13 +4635,6 @@ impl GcpWorkerController { .template(template) .traffic(traffic) .invoker_iam_disabled(is_public) - // Cloud Run refuses `sandboxLauncher` outside Beta or later with - // `FAILED_PRECONDITION: The feature 'Instant sandboxes' is not supported in the - // declared launch stage`, so the two are set together or not at all. - .maybe_launch_stage( - cfg.sandbox_launcher - .then_some(alien_gcp_clients::cloudrun::LaunchStage::Beta), - ) .build(); Ok(service) @@ -5685,10 +5677,10 @@ mod tests { Arc, }; - use alien_client_core::{ErrorData as CloudClientErrorData, Result as CloudClientResult}; + use alien_client_core::ErrorData as CloudClientErrorData; use alien_core::{ - CertificateStatus, DnsRecordStatus, DomainMetadata, HttpMethod, Platform, - ResourceDomainInfo, ResourceStatus, Worker, WorkerOutputs, + CertificateStatus, DnsRecordStatus, DomainMetadata, Platform, ResourceDomainInfo, + ResourceStatus, Worker, WorkerOutputs, }; use alien_error::AlienError; use alien_gcp_clients::cloudrun::{ @@ -5699,7 +5691,7 @@ mod tests { use alien_gcp_clients::longrunning::Operation as LongRunningOperation; use alien_gcp_clients::longrunning::{OperationResult, Status}; use alien_gcp_clients::pubsub::MockPubSubApi; - use httpmock::{prelude::*, Mock}; + use httpmock::prelude::*; use rstest::rstest; use super::{ @@ -5708,10 +5700,7 @@ mod tests { GCP_RESOURCE_NAME_MAX_LEN, }; use crate::core::MockPlatformServiceProvider; - use crate::core::{ - controller_test::{SingleControllerExecutor, SingleControllerExecutorBuilder}, - PlatformServiceProvider, - }; + use crate::core::controller_test::SingleControllerExecutor; use crate::worker::readiness_probe::test_utils::create_readiness_probe_mock; use crate::worker::{fixtures::*, GcpWorkerController}; use crate::GcpWorkerState; @@ -6402,60 +6391,6 @@ mod tests { assert!(executor.outputs().is_none()); } - /// A GCP sandbox session is a subprocess of the Cloud Run instance running the app, so an - /// instance that does not declare `sandboxLauncher` cannot start one — the deploy succeeds - /// and the first `create()` fails. Cloud Run also refuses the field outside Beta with - /// `FAILED_PRECONDITION: The feature 'Instant sandboxes' is not supported in the declared - /// launch stage`, so the two have to travel together. - #[tokio::test] - async fn a_sandbox_hosting_worker_declares_the_launcher_and_its_launch_stage() { - let mut worker = basic_function(); - worker.sandbox_launcher = true; - let function_name = format!("test-{}", worker.id); - - let mut mock_cloudrun = MockCloudRunApi::new(); - mock_cloudrun - .expect_create_service() - .times(1) - .withf(|_, _, service: &Service, _| { - let container = &service - .template - .as_ref() - .expect("a revision template") - .containers[0]; - container.sandbox_launcher == Some(true) - && service.launch_stage == Some(alien_gcp_clients::cloudrun::LaunchStage::Beta) - }) - .returning(|_, _, _, _| Ok(create_successful_operation_response("create-worker"))); - mock_cloudrun - .expect_get_operation() - .returning(|_, _| Ok(create_completed_operation_response("create-worker"))); - let name_for_get = function_name.clone(); - mock_cloudrun - .expect_get_service() - .returning(move |_, _| Ok(create_successful_service_response(&name_for_get))); - mock_cloudrun - .expect_get_service_iam_policy() - .returning(|_, _| Ok(create_empty_iam_policy())); - mock_cloudrun - .expect_set_service_iam_policy() - .returning(|_, _, _| Ok(create_empty_iam_policy())); - - let mock_provider = setup_mock_service_provider(Arc::new(mock_cloudrun), None); - let mut executor = SingleControllerExecutor::builder() - .resource(worker) - .controller(GcpWorkerController::default()) - .platform(Platform::Gcp) - .service_provider(mock_provider) - .with_test_dependencies() - .build() - .await - .unwrap(); - - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Running); - } - #[tokio::test] async fn retries_cloud_run_revision_after_gar_reader_grant_propagates() { let worker = basic_function(); diff --git a/crates/alien-permissions/permission-sets/sandbox/execute.jsonc b/crates/alien-permissions/permission-sets/sandbox/execute.jsonc index ca80cd507..3bd35293c 100644 --- a/crates/alien-permissions/permission-sets/sandbox/execute.jsonc +++ b/crates/alien-permissions/permission-sets/sandbox/execute.jsonc @@ -68,6 +68,24 @@ } } } + ], + "gcp": [ + { + // The one verb that runs code and reads files inside a live session, so it lives here + // alone — heartbeat, management and provision must never reach session content. No safe + // predefined role isolates it, so it is a residual custom-role permission. + "grant": { + "permissions": ["aiplatform.sandboxEnvironments.execute"] + }, + "binding": { + "stack": { + "scope": "projects/${projectName}" + }, + "resource": { + "scope": "projects/${projectName}" + } + } + } ] } } diff --git a/crates/alien-permissions/permission-sets/sandbox/heartbeat.jsonc b/crates/alien-permissions/permission-sets/sandbox/heartbeat.jsonc index 9bf066fc4..c608a44e8 100644 --- a/crates/alien-permissions/permission-sets/sandbox/heartbeat.jsonc +++ b/crates/alien-permissions/permission-sets/sandbox/heartbeat.jsonc @@ -56,6 +56,23 @@ } } } + ], + "gcp": [ + { + // Existence and state of the session, nothing inside it. get returns no payload, and the + // content-reaching execute verb stays in sandbox/execute alone. + "grant": { + "permissions": ["aiplatform.sandboxEnvironments.get"] + }, + "binding": { + "stack": { + "scope": "projects/${projectName}" + }, + "resource": { + "scope": "projects/${projectName}" + } + } + } ] } } diff --git a/crates/alien-permissions/permission-sets/sandbox/management.jsonc b/crates/alien-permissions/permission-sets/sandbox/management.jsonc index 3ff10ce27..05203947f 100644 --- a/crates/alien-permissions/permission-sets/sandbox/management.jsonc +++ b/crates/alien-permissions/permission-sets/sandbox/management.jsonc @@ -102,8 +102,32 @@ } } } + ], + "gcp": [ + { + // Session lifecycle only. The execute verb is withheld deliberately: it is the one that + // reaches session content, so a management identity carrying it would collapse the split + // this resource relies on. + "grant": { + "permissions": [ + "aiplatform.sandboxEnvironments.create", + "aiplatform.sandboxEnvironments.delete", + "aiplatform.sandboxEnvironments.get", + "aiplatform.sandboxEnvironments.list", + "aiplatform.sandboxEnvironments.pause", + "aiplatform.sandboxEnvironments.resume", + "aiplatform.sandboxEnvironments.snapshot" + ] + }, + "binding": { + "stack": { + "scope": "projects/${projectName}" + }, + "resource": { + "scope": "projects/${projectName}" + } + } + } ] - // No GCP entry, and nothing to add: a GCP sandbox is a launcher subprocess inside the app's - // own Cloud Run instance, so it creates no GCP resource and makes no GCP API call. } } diff --git a/crates/alien-permissions/permission-sets/sandbox/provision.jsonc b/crates/alien-permissions/permission-sets/sandbox/provision.jsonc index ec966f9df..e134a7c68 100644 --- a/crates/alien-permissions/permission-sets/sandbox/provision.jsonc +++ b/crates/alien-permissions/permission-sets/sandbox/provision.jsonc @@ -175,6 +175,33 @@ } } } + ], + "gcp": [ + { + // The durable parent (reasoningEngine): create and delete only — the template controller + // is handed the engine name and never reads it back, so no get/list here. The + // release-owned template: create, delete, and the reads the controller reconciles with — + // list to find the template a prior attempt left, get to read its state. No + // sandboxEnvironments verb — provisioning must not reach a live session. + "grant": { + "permissions": [ + "aiplatform.sandboxEnvironmentTemplates.create", + "aiplatform.sandboxEnvironmentTemplates.delete", + "aiplatform.sandboxEnvironmentTemplates.get", + "aiplatform.sandboxEnvironmentTemplates.list", + "aiplatform.reasoningEngines.create", + "aiplatform.reasoningEngines.delete" + ] + }, + "binding": { + "stack": { + "scope": "projects/${projectName}" + }, + "resource": { + "scope": "projects/${projectName}" + } + } + } ] } } diff --git a/crates/alien-permissions/tests/gcp_sensitive_invariant.rs b/crates/alien-permissions/tests/gcp_sensitive_invariant.rs index 51906ca01..b805cadea 100644 --- a/crates/alien-permissions/tests/gcp_sensitive_invariant.rs +++ b/crates/alien-permissions/tests/gcp_sensitive_invariant.rs @@ -9,6 +9,8 @@ const SENSITIVE_IMPLICIT_PERMISSIONS: &[&str] = &[ "artifactregistry.repositories.downloadArtifacts", "cloudbuild.builds.get", "cloudbuild.builds.list", + // Runs code and reads files inside a live sandbox session; belongs to sandbox/execute alone. + "aiplatform.sandboxEnvironments.execute", ]; const SENSITIVE_IMPLICIT_ROLES: &[&str] = &[ @@ -74,6 +76,42 @@ fn gcp_implicit_management_sets_do_not_grant_sensitive_content() { } } +/// The execute verb reaches session content, so it must appear in `sandbox/execute` and nowhere +/// else. Positive and negative in one: the collected set is asserted to equal exactly that id. +#[test] +fn gcp_sandbox_execute_permission_is_confined_to_the_execute_set() { + const EXECUTE_PERMISSION: &str = "aiplatform.sandboxEnvironments.execute"; + + let mut sets_granting_execute: Vec<&str> = Vec::new(); + for permission_set_id in list_permission_set_ids() { + let permission_set = alien_permissions::get_permission_set(permission_set_id) + .expect("permission set exists"); + let Some(gcp_entries) = &permission_set.platforms.gcp else { + continue; + }; + + // Scan both lists unconditionally — an entry setting `permissions` and + // `residualPermissions` together could otherwise hide the grant in the unscanned one. + let grants_execute = gcp_entries.iter().any(|entry| { + let permissions = entry.grant.permissions.as_deref().unwrap_or(&[]); + let residual = entry.grant.residual_permissions.as_deref().unwrap_or(&[]); + permissions + .iter() + .chain(residual) + .any(|permission| permission == EXECUTE_PERMISSION) + }); + if grants_execute { + sets_granting_execute.push(permission_set_id); + } + } + + assert_eq!( + sets_granting_execute, + vec!["sandbox/execute"], + "{EXECUTE_PERMISSION} reaches session content and must appear in sandbox/execute alone" + ); +} + fn is_implicit_management_set(permission_set_id: &str) -> bool { permission_set_id.ends_with("/heartbeat") || permission_set_id.ends_with("/management") diff --git a/crates/alien-permissions/tests/operation_coverage.rs b/crates/alien-permissions/tests/operation_coverage.rs index 76110453d..7ac242c66 100644 --- a/crates/alien-permissions/tests/operation_coverage.rs +++ b/crates/alien-permissions/tests/operation_coverage.rs @@ -281,7 +281,12 @@ fn critical_e2e_provider_operations_are_declared() { "lambda:DeleteMicrovmImage", "lambda:ListMicrovmImageVersions", ], - gcp_permissions: &[], + gcp_permissions: &[ + "aiplatform.sandboxEnvironmentTemplates.create", + "aiplatform.sandboxEnvironmentTemplates.delete", + "aiplatform.reasoningEngines.create", + "aiplatform.reasoningEngines.delete", + ], gcp_predefined_roles: &[], azure_actions: &[ "Microsoft.App/sandboxGroups/write", @@ -295,7 +300,7 @@ fn critical_e2e_provider_operations_are_declared() { // grant that reaches inside a session and must stay in execute alone. permission_set_id: "sandbox/execute", aws_actions: &["lambda:CreateMicrovmAuthToken"], - gcp_permissions: &[], + gcp_permissions: &["aiplatform.sandboxEnvironments.execute"], gcp_predefined_roles: &[], azure_actions: &[], azure_data_actions: &[], @@ -305,7 +310,13 @@ fn critical_e2e_provider_operations_are_declared() { // Session lifecycle without content access. permission_set_id: "sandbox/management", aws_actions: &["lambda:RunMicrovm", "lambda:TerminateMicrovm"], - gcp_permissions: &[], + gcp_permissions: &[ + "aiplatform.sandboxEnvironments.create", + "aiplatform.sandboxEnvironments.delete", + "aiplatform.sandboxEnvironments.pause", + "aiplatform.sandboxEnvironments.resume", + "aiplatform.sandboxEnvironments.snapshot", + ], gcp_predefined_roles: &[], azure_actions: &[], azure_data_actions: &[], diff --git a/crates/alien-permissions/tests/permission_set_validation.rs b/crates/alien-permissions/tests/permission_set_validation.rs index e8e460c30..7053ee6c5 100644 --- a/crates/alien-permissions/tests/permission_set_validation.rs +++ b/crates/alien-permissions/tests/permission_set_validation.rs @@ -442,8 +442,29 @@ fn validate_gcp_permissions( Ok(()) } +/// Permissions the upstream dataset has not published yet. The dataset is a community mirror +/// fetched at test time and lags a preview service. Exact-match, not a prefix: a sandbox +/// permission not on this list still fails, and `aiplatform.reasoningEngines.*` is published so a +/// typo there is caught against the dataset. +const GCP_UNPUBLISHED_PERMISSIONS: &[&str] = &[ + // Agent-platform sandbox environments and their templates, in preview. + "aiplatform.sandboxEnvironmentTemplates.create", + "aiplatform.sandboxEnvironmentTemplates.delete", + "aiplatform.sandboxEnvironmentTemplates.get", + "aiplatform.sandboxEnvironmentTemplates.list", + "aiplatform.sandboxEnvironments.create", + "aiplatform.sandboxEnvironments.delete", + "aiplatform.sandboxEnvironments.get", + "aiplatform.sandboxEnvironments.list", + "aiplatform.sandboxEnvironments.pause", + "aiplatform.sandboxEnvironments.resume", + "aiplatform.sandboxEnvironments.snapshot", + "aiplatform.sandboxEnvironments.execute", +]; + fn is_known_gcp_dataset_gap(permission: &str) -> bool { - matches!(permission, "iam.serviceAccounts.getAccessToken") + permission == "iam.serviceAccounts.getAccessToken" + || GCP_UNPUBLISHED_PERMISSIONS.contains(&permission) } /// Validate Azure actions in a permission set diff --git a/crates/alien-preflights/src/compile_time/mod.rs b/crates/alien-preflights/src/compile_time/mod.rs index 305f55813..d7b1e35b7 100644 --- a/crates/alien-preflights/src/compile_time/mod.rs +++ b/crates/alien-preflights/src/compile_time/mod.rs @@ -16,7 +16,6 @@ pub mod resource_id_pattern; pub mod resource_name_length; pub mod resource_references_exist; pub mod sandbox_build_role_name; -pub mod sandbox_host_required; pub mod sandbox_platform_support; pub mod service_account_impersonate_validation; pub mod single_exposed_port_check; diff --git a/crates/alien-preflights/src/compile_time/sandbox_host_required.rs b/crates/alien-preflights/src/compile_time/sandbox_host_required.rs deleted file mode 100644 index efc863a1e..000000000 --- a/crates/alien-preflights/src/compile_time/sandbox_host_required.rs +++ /dev/null @@ -1,197 +0,0 @@ -//! A GCP Sandbox needs a Cloud Run workload to live inside. -//! -//! Unlike every other platform, GCP provisions nothing durable for a sandbox: `sandboxLauncher` -//! is a field on the Cloud Run service that hosts the app, and sandboxes are subprocesses of -//! that service's own instance. So a Sandbox declared on GCP with nothing to host it is not a -//! resource waiting to be created — it is a stack that can never work. -//! -//! Catching it here means a clear error at plan time rather than a deploy that succeeds and -//! then fails at the first `create()`. - -use crate::error::Result; -use crate::{CheckResult, CompileTimeCheck}; -use alien_core::{Platform, Sandbox, Stack}; - -/// Resource types that run on Cloud Run and can therefore host a sandbox. -/// -/// Worker alone. A GCP Container runs on a ComputeCluster rather than Cloud Run, so -/// `sandboxLauncher` has nothing to be set on and a stack hosted only by a Container would pass -/// this check and then fail at the first `create()`. -const SANDBOX_HOST_TYPES: &[&str] = &["worker"]; - -/// Ensures a GCP Sandbox has a Cloud Run workload to host it. -pub struct SandboxHostRequiredCheck; - -#[async_trait::async_trait] -impl CompileTimeCheck for SandboxHostRequiredCheck { - fn description(&self) -> &'static str { - "A Sandbox on GCP requires a Cloud Run workload to host it" - } - - fn should_run(&self, stack: &Stack, platform: Platform) -> bool { - // GCP alone: every other platform provisions a durable parent of its own. - platform == Platform::Gcp - && stack.resources().any(|(_, entry)| { - entry.config.resource_type().as_ref() == Sandbox::RESOURCE_TYPE.as_ref() - }) - } - - async fn check(&self, stack: &Stack, _platform: Platform) -> Result { - let hosts: Vec<&str> = stack - .resources() - .filter(|(_, entry)| { - SANDBOX_HOST_TYPES.contains(&entry.config.resource_type().as_ref()) - }) - .map(|(id, _)| id.as_str()) - .collect(); - - if !hosts.is_empty() { - return Ok(CheckResult::success()); - } - - let sandboxes: Vec<&str> = stack - .resources() - .filter(|(_, entry)| { - entry.config.resource_type().as_ref() == Sandbox::RESOURCE_TYPE.as_ref() - }) - .map(|(id, _)| id.as_str()) - .collect(); - - Ok(CheckResult::failed( - sandboxes - .into_iter() - .map(|id| { - format!( - "Sandbox '{id}' targets GCP, where a sandbox runs inside the Cloud Run \ - service that hosts your app. This stack declares no Worker for it to run \ - in — a Container runs on a compute cluster, not on Cloud Run. Add a \ - Worker, or target a platform that provisions sandboxes independently." - ) - }) - .collect(), - )) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use alien_core::{ - ResourceEntry, ResourceLifecycle, Sandbox, SandboxCode, SandboxEgress, SandboxLimits, - SandboxSessionPolicy, Worker, WorkerCode, - }; - use indexmap::IndexMap; - - fn sandbox_config() -> Sandbox { - Sandbox::new("agent".to_string()) - .code(SandboxCode::Image { - image: "ubuntu:24.04".to_string(), - }) - .limits(SandboxLimits { - cpu: "1".to_string(), - memory: "2Gi".to_string(), - disk: "20Gi".to_string(), - max_processes: None, - }) - .egress(SandboxEgress::Deny) - .session(SandboxSessionPolicy { - max_lifetime_seconds: None, - idle_suspend_seconds: None, - }) - .build() - } - - fn entry(config: alien_core::Resource) -> ResourceEntry { - ResourceEntry { - config, - lifecycle: ResourceLifecycle::Live, - dependencies: Vec::new(), - remote_access: false, - enabled_when: None, - } - } - - fn stack(include_worker: bool, include_sandbox: bool) -> Stack { - let mut resources = IndexMap::new(); - if include_sandbox { - resources.insert( - "agent".to_string(), - entry(alien_core::Resource::new(sandbox_config())), - ); - } - if include_worker { - let worker = Worker::new("api".to_string()) - .permissions("execution".to_string()) - .code(WorkerCode::Image { - image: "registry.example.com/api:latest".to_string(), - }) - .build(); - resources.insert("api".to_string(), entry(alien_core::Resource::new(worker))); - } - - Stack { - id: "test-stack".to_string(), - resources, - permissions: alien_core::permissions::PermissionsConfig::default(), - supported_platforms: None, - inputs: vec![], - } - } - - #[tokio::test] - async fn a_gcp_sandbox_with_no_cloud_run_host_fails_at_plan_time() { - let stack = stack(false, true); - let check = SandboxHostRequiredCheck; - - assert!(check.should_run(&stack, Platform::Gcp)); - - let result = check - .check(&stack, Platform::Gcp) - .await - .expect("check runs"); - assert!(!result.success, "a sandbox with no host must not pass"); - - let rendered = result.errors.join(" "); - assert!(rendered.contains("agent"), "names the sandbox: {rendered}"); - assert!( - rendered.contains("Add a Worker"), - "says what to add rather than only what is wrong: {rendered}" - ); - } - - #[tokio::test] - async fn a_gcp_sandbox_alongside_a_worker_passes() { - let result = SandboxHostRequiredCheck - .check(&stack(true, true), Platform::Gcp) - .await - .expect("check runs"); - - assert!(result.success); - assert!(result.errors.is_empty()); - } - - /// Every other platform provisions a durable parent, so the requirement is GCP's alone and - /// running it elsewhere would reject valid stacks. - #[tokio::test] - async fn the_check_is_scoped_to_gcp() { - let stack = stack(false, true); - let check = SandboxHostRequiredCheck; - - for platform in [ - Platform::Aws, - Platform::Azure, - Platform::Kubernetes, - Platform::Local, - ] { - assert!( - !check.should_run(&stack, platform), - "{platform} provisions its own parent and must not require a host" - ); - } - } - - #[tokio::test] - async fn a_stack_with_no_sandbox_is_not_checked() { - assert!(!SandboxHostRequiredCheck.should_run(&stack(true, false), Platform::Gcp)); - } -} diff --git a/crates/alien-preflights/src/compile_time/sandbox_platform_support.rs b/crates/alien-preflights/src/compile_time/sandbox_platform_support.rs index 7597ffa36..9af4e9142 100644 --- a/crates/alien-preflights/src/compile_time/sandbox_platform_support.rs +++ b/crates/alien-preflights/src/compile_time/sandbox_platform_support.rs @@ -104,14 +104,14 @@ mod tests { } } - /// GCP runs sandboxes as subprocesses of the app's own Cloud Run instance, which applies no - /// per-sandbox ceiling. A stack that declares one reads as bounded while the sandbox is not. + /// Azure applies no per-sandbox ceiling. A stack that declares one reads as bounded while the + /// sandbox is not, so plan time refuses it rather than letting it run unbounded. #[tokio::test] async fn ceilings_on_a_platform_that_ignores_them_fail_at_plan_time() { let stack = stack_with(sandbox("agent", Some(ceilings()), SandboxEgress::Deny)); let result = SandboxPlatformSupportCheck - .check(&stack, Platform::Gcp) + .check(&stack, Platform::Azure) .await .expect("check runs"); diff --git a/crates/alien-preflights/src/lib.rs b/crates/alien-preflights/src/lib.rs index 13f397f8e..2771e84bb 100644 --- a/crates/alien-preflights/src/lib.rs +++ b/crates/alien-preflights/src/lib.rs @@ -351,9 +351,6 @@ impl PreflightRegistry { registry.add_compile_time_check(Box::new( compile_time::sandbox_build_role_name::SandboxBuildRoleNameCheck, )); - registry.add_compile_time_check(Box::new( - compile_time::sandbox_host_required::SandboxHostRequiredCheck, - )); registry.add_compile_time_check(Box::new( compile_time::sandbox_platform_support::SandboxPlatformSupportCheck, )); @@ -453,7 +450,7 @@ impl PreflightRegistry { // These scan resource types to decide what to create, so they must see all // resources from Phase 2 (vault, etc.) registry.add_mutation(Box::new(mutations::AzureServiceActivationMutation)); - registry.add_mutation(Box::new(mutations::GcpSandboxLauncherMutation)); + registry.add_mutation(Box::new(mutations::GcpAgentPlatformEngineMutation)); registry.add_mutation(Box::new(mutations::GcpServiceActivationMutation)); registry.add_mutation(Box::new(mutations::AzureContainerAppsEnvironmentMutation)); registry.add_mutation(Box::new(mutations::AzureServiceBusNamespaceMutation)); diff --git a/crates/alien-preflights/src/mutations/gcp_agent_platform_engine.rs b/crates/alien-preflights/src/mutations/gcp_agent_platform_engine.rs new file mode 100644 index 000000000..6f7cfb253 --- /dev/null +++ b/crates/alien-preflights/src/mutations/gcp_agent_platform_engine.rs @@ -0,0 +1,206 @@ +//! Synthesizes the Agent Platform reasoning engine each GCP sandbox needs. +//! +//! Vertex exposes no Terraform resource for a reasoning engine, so it cannot be emitted; a +//! controller creates it via the API instead, and this mutation adds the resource that controller +//! reconciles — one engine per sandbox, with the sandbox depending on it so teardown orders the +//! engine after the template and sessions. + +use crate::error::Result; +use crate::StackMutation; +use alien_core::{ + DeploymentConfig, GcpAgentPlatformEngine, Platform, Resource, ResourceEntry, ResourceLifecycle, + ResourceRef, Sandbox, Stack, StackState, +}; +use async_trait::async_trait; +use tracing::info; + +pub struct GcpAgentPlatformEngineMutation; + +impl GcpAgentPlatformEngineMutation { + fn sandbox_ids(stack: &Stack) -> Vec { + stack + .resources + .iter() + .filter(|(_, entry)| { + entry.config.resource_type().as_ref() == Sandbox::RESOURCE_TYPE.as_ref() + }) + .map(|(id, _)| id.clone()) + .collect() + } +} + +#[async_trait] +impl StackMutation for GcpAgentPlatformEngineMutation { + fn description(&self) -> &'static str { + "Provision an Agent Platform reasoning engine for each GCP sandbox" + } + + fn should_run( + &self, + stack: &Stack, + stack_state: &StackState, + _config: &DeploymentConfig, + ) -> bool { + // Gcp always means the Agent Platform sandbox backend; no other GCP backend exists to key on. + stack_state.platform == Platform::Gcp && !Self::sandbox_ids(stack).is_empty() + } + + async fn mutate( + &self, + mut stack: Stack, + _stack_state: &StackState, + _config: &DeploymentConfig, + ) -> Result { + for sandbox_id in Self::sandbox_ids(&stack) { + let engine_id = GcpAgentPlatformEngine::id_for_sandbox(&sandbox_id); + + // Live: the engine is Alien-owned and created with provision permissions, not setup. + stack + .resources + .entry(engine_id.clone()) + .or_insert_with(|| ResourceEntry { + enabled_when: None, + config: Resource::new(GcpAgentPlatformEngine::new(engine_id.clone()).build()), + lifecycle: ResourceLifecycle::Live, + dependencies: Vec::new(), + remote_access: false, + }); + + // The sandbox depends on its engine, so the engine deploys first (its id is available + // when the template is built) and tears down last (after the template and sessions). + let engine_ref = + ResourceRef::new(GcpAgentPlatformEngine::RESOURCE_TYPE, engine_id.clone()); + if let Some(entry) = stack.resources.get_mut(&sandbox_id) { + if !entry.dependencies.iter().any(|r| r.id == engine_id) { + entry.dependencies.push(engine_ref); + info!(sandbox=%sandbox_id, engine=%engine_id, "sandbox depends on its reasoning engine"); + } + } + } + + Ok(stack) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alien_core::{ + PermissionsConfig, SandboxCode, SandboxEgress, SandboxSessionPolicy, StackSettings, + }; + + fn config() -> DeploymentConfig { + DeploymentConfig::builder() + .stack_settings(StackSettings::default()) + .environment_variables(alien_core::EnvironmentVariablesSnapshot { + variables: Vec::new(), + hash: String::new(), + created_at: "2024-01-01T00:00:00Z".to_string(), + }) + .allow_frozen_changes(false) + .external_bindings(alien_core::ExternalBindings::default()) + .build() + } + + fn stack_with_sandbox() -> Stack { + Stack::new("gcp-sandbox".to_string()) + .permissions(PermissionsConfig::new()) + .add( + Sandbox::new("worker-sbx".to_string()) + .code(SandboxCode::Image { + image: "python:3.12".to_string(), + }) + .egress(SandboxEgress::Deny) + .session(SandboxSessionPolicy { + max_lifetime_seconds: None, + idle_suspend_seconds: None, + }) + .build(), + ResourceLifecycle::Live, + ) + .build() + } + + fn gcp_state() -> StackState { + StackState::new(Platform::Gcp) + } + + #[tokio::test] + async fn one_engine_is_synthesized_per_sandbox_and_the_sandbox_depends_on_it() { + let stack = stack_with_sandbox(); + let state = gcp_state(); + assert!(GcpAgentPlatformEngineMutation.should_run(&stack, &state, &config())); + + let mutated = GcpAgentPlatformEngineMutation + .mutate(stack, &state, &config()) + .await + .expect("mutation should succeed"); + + let engine = mutated + .resources + .get("worker-sbx-engine") + .expect("an engine is synthesized for the sandbox"); + assert_eq!(engine.lifecycle, ResourceLifecycle::Live); + assert_eq!( + engine.config.resource_type().as_ref(), + GcpAgentPlatformEngine::RESOURCE_TYPE.as_ref() + ); + + let sandbox = mutated.resources.get("worker-sbx").expect("the sandbox"); + assert!( + sandbox + .dependencies + .iter() + .any(|r| r.id == "worker-sbx-engine"), + "the sandbox must depend on its engine for teardown ordering" + ); + } + + #[tokio::test] + async fn re_running_is_a_no_op() { + let stack = stack_with_sandbox(); + let state = gcp_state(); + let once = GcpAgentPlatformEngineMutation + .mutate(stack, &state, &config()) + .await + .expect("first pass"); + let twice = GcpAgentPlatformEngineMutation + .mutate(once, &state, &config()) + .await + .expect("second pass"); + + assert_eq!( + twice + .resources + .get("worker-sbx") + .unwrap() + .dependencies + .len(), + 1, + "the engine dependency is not appended twice" + ); + assert_eq!( + twice + .resources + .values() + .filter(|e| e.config.resource_type().as_ref() + == GcpAgentPlatformEngine::RESOURCE_TYPE.as_ref()) + .count(), + 1, + "no second engine is synthesized" + ); + } + + #[tokio::test] + async fn it_does_not_run_off_gcp_or_without_a_sandbox() { + let stack = stack_with_sandbox(); + let aws_state = StackState::new(Platform::Aws); + assert!(!GcpAgentPlatformEngineMutation.should_run(&stack, &aws_state, &config())); + + let empty = Stack::new("empty".to_string()) + .permissions(PermissionsConfig::new()) + .build(); + let empty_state = gcp_state(); + assert!(!GcpAgentPlatformEngineMutation.should_run(&empty, &empty_state, &config())); + } +} diff --git a/crates/alien-preflights/src/mutations/gcp_sandbox_launcher.rs b/crates/alien-preflights/src/mutations/gcp_sandbox_launcher.rs deleted file mode 100644 index 1bed9a07b..000000000 --- a/crates/alien-preflights/src/mutations/gcp_sandbox_launcher.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! Marks the Cloud Run workers that host a GCP stack's sandbox sessions. -//! -//! GCP provisions nothing durable for a sandbox: a session is a subprocess of the Cloud Run -//! instance already running the application, and an instance can only launch one if its container -//! declares `sandboxLauncher`. Nothing in an application's own declaration says which worker that -//! is, so preflight decides it — the same reason `SandboxHostRequiredCheck` refuses a GCP sandbox -//! with no Cloud Run host at all. - -use crate::error::Result; -use crate::StackMutation; -use alien_core::{DeploymentConfig, Platform, Sandbox, Stack, StackState, Worker}; -use async_trait::async_trait; -use tracing::info; - -pub struct GcpSandboxLauncherMutation; - -impl GcpSandboxLauncherMutation { - fn stack_has_a_sandbox(stack: &Stack) -> bool { - stack - .resources - .values() - .any(|entry| entry.config.resource_type().as_ref() == Sandbox::RESOURCE_TYPE.as_ref()) - } -} - -#[async_trait] -impl StackMutation for GcpSandboxLauncherMutation { - fn description(&self) -> &'static str { - "Let the Cloud Run workers hosting a GCP sandbox launch sessions" - } - - fn should_run( - &self, - stack: &Stack, - stack_state: &StackState, - _config: &DeploymentConfig, - ) -> bool { - stack_state.platform == Platform::Gcp && Self::stack_has_a_sandbox(stack) - } - - async fn mutate( - &self, - mut stack: Stack, - _stack_state: &StackState, - _config: &DeploymentConfig, - ) -> Result { - // Every worker, not a chosen one: a session is created through the binding, and any - // worker holding that binding can be the one that asks. Marking a subset would make - // which instance served the request decide whether the call worked. - for (id, entry) in &mut stack.resources { - let Some(worker) = entry.config.downcast_mut::() else { - continue; - }; - if worker.sandbox_launcher { - continue; - } - worker.sandbox_launcher = true; - info!(worker = %id, "Worker may launch sandbox sessions"); - } - - Ok(stack) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use alien_core::{ - PermissionsConfig, ResourceLifecycle, SandboxCode, SandboxEgress, SandboxSessionPolicy, - StackSettings, WorkerCode, - }; - - fn config() -> DeploymentConfig { - DeploymentConfig::builder() - .stack_settings(StackSettings::default()) - .environment_variables(alien_core::EnvironmentVariablesSnapshot { - variables: Vec::new(), - hash: String::new(), - created_at: "2024-01-01T00:00:00Z".to_string(), - }) - .allow_frozen_changes(false) - .external_bindings(alien_core::ExternalBindings::default()) - .build() - } - - fn stack(with_sandbox: bool) -> Stack { - let mut builder = Stack::new("gcp-sandbox".to_string()) - .permissions(PermissionsConfig::new()) - .add( - Worker::new("api".to_string()) - .permissions("execution".to_string()) - .code(WorkerCode::Image { - image: "registry.example.com/api:latest".to_string(), - }) - .build(), - ResourceLifecycle::Live, - ); - if with_sandbox { - builder = builder.add( - Sandbox::new("agent".to_string()) - .code(SandboxCode::Image { - image: "ubuntu:24.04".to_string(), - }) - .egress(SandboxEgress::Deny) - .session(SandboxSessionPolicy { - max_lifetime_seconds: None, - idle_suspend_seconds: None, - }) - .build(), - ResourceLifecycle::Frozen, - ); - } - builder.build() - } - - fn launcher(stack: &Stack, id: &str) -> bool { - stack - .resources - .get(id) - .and_then(|entry| entry.config.downcast_ref::()) - .expect("the worker") - .sandbox_launcher - } - - /// Without this the deploy succeeds and the first `create()` fails, which is the silent - /// failure the capability contract exists to prevent. - #[tokio::test] - async fn a_gcp_worker_hosting_a_sandbox_is_marked_as_a_launcher() { - let stack = stack(true); - let state = StackState::new(Platform::Gcp); - - assert!(GcpSandboxLauncherMutation.should_run(&stack, &state, &config())); - let mutated = GcpSandboxLauncherMutation - .mutate(stack, &state, &config()) - .await - .expect("mutation runs"); - - assert!(launcher(&mutated, "api")); - } - - /// The flag is a Cloud Run permission, so a stack that declares no sandbox must not acquire - /// it — and no other platform reads it at all. - #[tokio::test] - async fn nothing_is_marked_without_a_sandbox_or_off_gcp() { - let config = config(); - assert!(!GcpSandboxLauncherMutation.should_run( - &stack(false), - &StackState::new(Platform::Gcp), - &config - )); - assert!(!GcpSandboxLauncherMutation.should_run( - &stack(true), - &StackState::new(Platform::Aws), - &config - )); - } -} diff --git a/crates/alien-preflights/src/mutations/gcp_service_activation.rs b/crates/alien-preflights/src/mutations/gcp_service_activation.rs index d57e4260e..7c50b9d4b 100644 --- a/crates/alien-preflights/src/mutations/gcp_service_activation.rs +++ b/crates/alien-preflights/src/mutations/gcp_service_activation.rs @@ -169,6 +169,15 @@ impl GcpServiceActivationMutation { "aiplatform.googleapis.com".to_string(), ); } + "sandbox" => { + // Agent Platform sandboxes are Vertex reasoning engines; a stack with a + // sandbox but no ai resource would otherwise never enable aiplatform. Same + // key as "ai", so a stack with both enables it once. + services.insert( + "enable-aiplatform".to_string(), + "aiplatform.googleapis.com".to_string(), + ); + } "queue" => { services.insert( "enable-pubsub".to_string(), diff --git a/crates/alien-preflights/src/mutations/mod.rs b/crates/alien-preflights/src/mutations/mod.rs index 81aa801bf..89a51e9da 100644 --- a/crates/alien-preflights/src/mutations/mod.rs +++ b/crates/alien-preflights/src/mutations/mod.rs @@ -8,7 +8,7 @@ pub mod azure_service_activation; pub mod azure_service_bus_namespace; pub mod azure_storage_account; pub mod compute_cluster; -pub mod gcp_sandbox_launcher; +pub mod gcp_agent_platform_engine; pub mod gcp_service_activation; pub mod infrastructure_dependencies; pub mod kubernetes_cluster; @@ -39,7 +39,7 @@ pub use azure_service_activation::AzureServiceActivationMutation; pub use azure_service_bus_namespace::AzureServiceBusNamespaceMutation; pub use azure_storage_account::AzureStorageAccountMutation; pub use compute_cluster::ComputeClusterMutation; -pub use gcp_sandbox_launcher::GcpSandboxLauncherMutation; +pub use gcp_agent_platform_engine::GcpAgentPlatformEngineMutation; pub use gcp_service_activation::GcpServiceActivationMutation; pub use infrastructure_dependencies::InfrastructureDependenciesMutation; pub use kubernetes_cluster::KubernetesClusterMutation; diff --git a/crates/alien-preflights/tests/sandbox_platform_gate.rs b/crates/alien-preflights/tests/sandbox_platform_gate.rs index 921b70dd0..dbc0a8034 100644 --- a/crates/alien-preflights/tests/sandbox_platform_gate.rs +++ b/crates/alien-preflights/tests/sandbox_platform_gate.rs @@ -12,8 +12,8 @@ use alien_core::{ use alien_preflights::runner::PreflightRunner; fn stack_with(sandbox: Sandbox) -> Stack { - // GCP is the platform whose ceilings are unenforceable, and it also requires a Cloud Run host - // for any sandbox at all. The worker is here so the gate under test is the one that fires. + // Azure's sandbox ceilings are unenforceable, so a declared ceiling must fail the gate. The + // worker rounds out the stack; the gate under test is the sandbox capability one. Stack::new("sandbox-gate".to_string()) .permissions(PermissionsConfig::new().with_profile("execution", PermissionProfile::new())) .add( @@ -32,7 +32,7 @@ fn stack_with(sandbox: Sandbox) -> Stack { fn sandbox(limits: Option) -> Sandbox { let builder = Sandbox::new("agent".to_string()) .code(SandboxCode::Image { - image: "ubuntu:24.04".to_string(), + image: "ubuntu".to_string(), }) .egress(SandboxEgress::Deny) .session(SandboxSessionPolicy { @@ -45,9 +45,8 @@ fn sandbox(limits: Option) -> Sandbox { } } -/// A GCP sandbox runs as a subprocess of the app's own Cloud Run instance, which applies no -/// per-sandbox ceiling. Declaring one has to fail before anything is provisioned, or the stack -/// reads as bounded while the sandbox is not. +/// Azure applies no per-sandbox ceiling. Declaring one has to fail before anything is provisioned, +/// or the stack reads as bounded while the sandbox is not. #[tokio::test] async fn declared_ceilings_fail_preflight_on_a_platform_that_ignores_them() { let stack = stack_with(sandbox(Some(SandboxLimits { @@ -58,7 +57,7 @@ async fn declared_ceilings_fail_preflight_on_a_platform_that_ignores_them() { }))); let summary = PreflightRunner::new() - .run_compile_time_checks(&stack, Platform::Gcp) + .run_compile_time_checks(&stack, Platform::Azure) .await .expect("compile-time checks run"); @@ -81,7 +80,7 @@ async fn declared_ceilings_fail_preflight_on_a_platform_that_ignores_them() { #[tokio::test] async fn the_same_stack_without_ceilings_passes_preflight() { let summary = PreflightRunner::new() - .run_compile_time_checks(&stack_with(sandbox(None)), Platform::Gcp) + .run_compile_time_checks(&stack_with(sandbox(None)), Platform::Azure) .await .expect("compile-time checks run"); diff --git a/crates/alien-sandbox-agent/Cargo.toml b/crates/alien-sandbox-agent/Cargo.toml index 5db92b684..f2c7fe00c 100644 --- a/crates/alien-sandbox-agent/Cargo.toml +++ b/crates/alien-sandbox-agent/Cargo.toml @@ -18,6 +18,7 @@ chrono = { workspace = true } futures = { workspace = true } ed25519-compact = { workspace = true } axum = { workspace = true, features = ["tokio", "http1", "json", "query"] } +uuid = { workspace = true, features = ["v4"] } [[bin]] name = "alien-sandbox-agent" diff --git a/crates/alien-sandbox-agent/src/confine.rs b/crates/alien-sandbox-agent/src/confine.rs index 19e7ccdca..8ba52a1cd 100644 --- a/crates/alien-sandbox-agent/src/confine.rs +++ b/crates/alien-sandbox-agent/src/confine.rs @@ -1,13 +1,21 @@ //! Opening a caller's path so it cannot leave the session root. //! -//! Resolving a path and then opening it by name is check-then-use: every guard sits in the window -//! before the open, and the code being confined is running in the same guest and can drive both -//! sides of that window. `openat2` closes it by construction — the kernel resolves and opens in -//! one call, and refuses rather than following anything that would leave the root. +//! Resolving a path to a string and then opening that string is check-then-use: every guard sits in +//! the window before the open, and the code being confined runs in the same guest and can drive both +//! sides of that window. This module never does that. It walks the path one component at a time, +//! each `openat` taken relative to the *file descriptor* of the component before it, so the inode a +//! step opens is the inode the previous step checked — there is no name left to re-resolve, and +//! nothing for a caller to swap between the check and the use. //! -//! `RESOLVE_BENEATH` rejects `..` and absolute paths; `RESOLVE_NO_SYMLINKS` rejects a symlink in -//! any component, including the final one; `RESOLVE_NO_MAGICLINKS` rejects `/proc/self/fd`-style -//! links. Nothing is left for a caller to race. +//! Two rules make the walk stay beneath the root. Every step opens with `O_NOFOLLOW`, so a symlink +//! at any component — including a `/proc/self/fd`-style magic link — fails the step rather than +//! redirecting it (an intermediate component also carries `O_DIRECTORY`, so a symlink there is +//! `ENOTDIR` even when `O_PATH` would otherwise open the link itself). And `.` and `..` are refused +//! outright, so the walk only ever descends. Together these are what `openat2`'s +//! `RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS | RESOLVE_NO_MAGICLINKS` give in one syscall — done in +//! userspace instead because the sandbox runtimes this agent ships under do not all implement +//! `openat2` (gVisor returns `ENOSYS`), and a security boundary cannot depend on a syscall the +//! platform may lack. //! //! Hard links are deliberately not addressed here: a link is a second name for an inode, so no //! resolver can tell one from the file itself. The kernel's `protected_hardlinks` (1 by default, @@ -18,79 +26,102 @@ use std::io; use std::path::Path; #[cfg(target_os = "linux")] -use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; - -/// `openat2` refuses to leave the directory it starts from. -#[cfg(target_os = "linux")] -const RESOLVE_NO_MAGICLINKS: u64 = 0x02; +use std::ffi::{CStr, CString}; #[cfg(target_os = "linux")] -const RESOLVE_NO_SYMLINKS: u64 = 0x04; -#[cfg(target_os = "linux")] -const RESOLVE_BENEATH: u64 = 0x08; - -/// The kernel's `struct open_how`. Declared here because the layout is stable ABI and this is the -/// only place that needs it. +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; #[cfg(target_os = "linux")] -#[repr(C)] -#[derive(Default)] -struct OpenHow { - flags: u64, - mode: u64, - resolve: u64, -} +use std::os::unix::ffi::OsStrExt; /// Strips the leading separator so a caller's `/work/x` is read as relative to the session root. /// -/// `RESOLVE_BENEATH` refuses an absolute path outright, and a caller writing `/work/x` means the -/// session's `/work/x`, not the host's. +/// A caller writing `/work/x` means the session's `/work/x`, not the host's; the walk below only +/// ever descends from the root, so an absolute path cannot reach outside it either way. #[cfg(target_os = "linux")] fn relative(requested: &str) -> &str { requested.trim_start_matches('/') } -/// Opens a path beneath `root`, refusing anything that would resolve outside it. +/// Opens the session root itself, to begin a walk from. The root path is the agent's own and is +/// canonicalized at startup, so following symlinks within *its* prefix is fine — confinement is +/// what happens on the descent below, not here. #[cfg(target_os = "linux")] -fn open_beneath(root: &Path, requested: &str, flags: i32, mode: u32) -> io::Result { - use std::ffi::CString; - use std::os::unix::ffi::OsStrExt; - - let root_path = CString::new(root.as_os_str().as_bytes()) +fn open_root(root: &Path) -> io::Result { + let path = CString::new(root.as_os_str().as_bytes()) .map_err(|_| io::Error::from_raw_os_error(libc::EINVAL))?; - let target = CString::new(relative(requested)) - .map_err(|_| io::Error::from_raw_os_error(libc::EINVAL))?; - - // SAFETY: a valid NUL-terminated path and a flags word; the returned fd is owned below. - let root_fd = unsafe { libc::open(root_path.as_ptr(), libc::O_PATH | libc::O_DIRECTORY) }; - if root_fd < 0 { + // SAFETY: a valid NUL-terminated path; the returned fd is owned below. + let fd = unsafe { + libc::open( + path.as_ptr(), + libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC, + ) + }; + if fd < 0 { return Err(io::Error::last_os_error()); } - // SAFETY: `root_fd` is a fresh, valid descriptor this function owns. - let root_fd = unsafe { OwnedFd::from_raw_fd(root_fd) }; - - let how = OpenHow { - flags: (flags | libc::O_CLOEXEC) as u64, - mode: mode as u64, - resolve: RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS | RESOLVE_NO_MAGICLINKS, - }; + // SAFETY: a fresh, valid descriptor this function owns. + Ok(unsafe { OwnedFd::from_raw_fd(fd) }) +} - // SAFETY: `openat2` with a valid dirfd, a NUL-terminated relative path, and a correctly sized - // `open_how`. The kernel performs the whole resolution; nothing here dereferences its result. +/// A single confined step: opens `name` directly under `dir`, never following a symlink at it. +/// +/// `O_NOFOLLOW` is the whole point — it is added to every step so a symlink (or magic link) planted +/// at that name fails here rather than sending the open somewhere else. +#[cfg(target_os = "linux")] +fn open_child(dir: RawFd, name: &CStr, flags: i32, mode: u32) -> io::Result { + // SAFETY: a valid dirfd and a NUL-terminated component; `mode` is consulted only under `O_CREAT`. let fd = unsafe { - libc::syscall( - libc::SYS_openat2, - root_fd.as_raw_fd(), - target.as_ptr(), - &how as *const OpenHow, - std::mem::size_of::(), + libc::openat( + dir, + name.as_ptr(), + flags | libc::O_NOFOLLOW | libc::O_CLOEXEC, + mode as libc::c_uint, ) }; - if fd < 0 { return Err(io::Error::last_os_error()); } + // SAFETY: a fresh descriptor the kernel just returned. + Ok(unsafe { OwnedFd::from_raw_fd(fd) }) +} + +/// Rejects any component that is not a plain name. `.` and `..` are refused as `EXDEV` — the errno +/// callers already read as an attempt to leave the root. +#[cfg(target_os = "linux")] +fn confined_component(part: &str) -> io::Result { + if part == "." || part == ".." { + return Err(io::Error::from_raw_os_error(libc::EXDEV)); + } + CString::new(part).map_err(|_| io::Error::from_raw_os_error(libc::EINVAL)) +} + +/// Walks to the directory that holds `requested`'s final component, opening each parent through a +/// confined step, and returns that directory's fd together with the final component's name. A +/// symlink or `..` at any parent fails the walk rather than redirecting it. +#[cfg(target_os = "linux")] +fn descend_to_parent(root: &Path, requested: &str) -> io::Result<(OwnedFd, CString)> { + let parts: Vec<&str> = relative(requested) + .split('/') + .filter(|part| !part.is_empty()) + .collect(); + let Some((last, parents)) = parts.split_last() else { + // The session root itself is a directory, not a file a caller names to open or remove. + return Err(io::Error::from_raw_os_error(libc::EISDIR)); + }; - // SAFETY: `fd` is a fresh descriptor the kernel just returned to us. - Ok(unsafe { std::fs::File::from_raw_fd(fd as i32) }) + let mut dir = open_root(root)?; + for part in parents { + let name = confined_component(part)?; + dir = open_child(dir.as_raw_fd(), &name, libc::O_PATH | libc::O_DIRECTORY, 0)?; + } + Ok((dir, confined_component(last)?)) +} + +/// Opens a path beneath `root`, refusing anything that would resolve outside it. +#[cfg(target_os = "linux")] +fn open_beneath(root: &Path, requested: &str, flags: i32, mode: u32) -> io::Result { + let (dir, leaf) = descend_to_parent(root, requested)?; + let fd = open_child(dir.as_raw_fd(), &leaf, flags, mode)?; + Ok(std::fs::File::from(fd)) } /// Mode for a file this agent creates, and for a directory, below. @@ -118,11 +149,11 @@ pub fn open_read(root: &Path, requested: &str) -> io::Result { /// Creates or truncates a file for writing, beneath the session root. /// -/// `O_NOFOLLOW` is redundant next to `RESOLVE_NO_SYMLINKS` and harmless. `O_NONBLOCK` keeps a -/// FIFO the command planted from blocking the open; the caller checks the type on the descriptor. +/// `O_NONBLOCK` keeps a FIFO the command planted from blocking the open; the caller checks the type +/// on the descriptor. #[cfg(target_os = "linux")] pub fn open_write(root: &Path, requested: &str) -> io::Result { - let common = libc::O_WRONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK; + let common = libc::O_WRONLY | libc::O_NONBLOCK; // `O_EXCL` so "did this call create the file" is answered by the kernel rather than by a stat // that another process can invalidate. Only a file this agent created gets its mode set; one @@ -166,27 +197,13 @@ fn set_mode(file: &std::fs::File, mode: u32) -> io::Result<()> { /// /// Only reached when the mode could not be set: leaving the entry would make every later write /// take the branch that preserves an existing entry's mode, so one failure here would be -/// permanent rather than retryable. +/// permanent rather than retryable. Walks to the parent through the same confined steps, so it can +/// only unlink beneath the root. #[cfg(target_os = "linux")] fn remove_beneath(root: &Path, requested: &str, flags: i32) -> io::Result<()> { - use std::ffi::CString; - use std::os::unix::ffi::OsStrExt; - - let root_path = CString::new(root.as_os_str().as_bytes()) - .map_err(|_| io::Error::from_raw_os_error(libc::EINVAL))?; - let target = CString::new(relative(requested)) - .map_err(|_| io::Error::from_raw_os_error(libc::EINVAL))?; - - // SAFETY: a valid NUL-terminated path and a flags word; the fd is owned below. - let fd = unsafe { libc::open(root_path.as_ptr(), libc::O_PATH | libc::O_DIRECTORY) }; - if fd < 0 { - return Err(io::Error::last_os_error()); - } - // SAFETY: a fresh, valid descriptor this function owns. - let root_fd = unsafe { OwnedFd::from_raw_fd(fd) }; - - // SAFETY: a valid dirfd, a NUL-terminated relative path, and a flags word. - if unsafe { libc::unlinkat(root_fd.as_raw_fd(), target.as_ptr(), flags) } != 0 { + let (dir, leaf) = descend_to_parent(root, requested)?; + // SAFETY: a valid dirfd, a NUL-terminated leaf name, and a flags word. + if unsafe { libc::unlinkat(dir.as_raw_fd(), leaf.as_ptr(), flags) } != 0 { return Err(io::Error::last_os_error()); } Ok(()) @@ -194,32 +211,17 @@ fn remove_beneath(root: &Path, requested: &str, flags: i32) -> io::Result<()> { /// Creates a directory and its parents, one confined step at a time. /// -/// Each component is created relative to the previous one and then re-opened through the same -/// confinement, so a component swapped for a symlink mid-walk fails the next step rather than -/// redirecting it. +/// Each component is created relative to the previous one and then re-opened through a confined +/// step, so a component swapped for a symlink mid-walk fails the re-open rather than redirecting it. #[cfg(target_os = "linux")] pub fn create_dir_all(root: &Path, requested: &str) -> io::Result<()> { - use std::ffi::CString; - use std::os::unix::ffi::OsStrExt; + let mut current = open_root(root)?; - let root_path = CString::new(root.as_os_str().as_bytes()) - .map_err(|_| io::Error::from_raw_os_error(libc::EINVAL))?; - // SAFETY: a valid NUL-terminated path and a flags word. - let fd = unsafe { libc::open(root_path.as_ptr(), libc::O_PATH | libc::O_DIRECTORY) }; - if fd < 0 { - return Err(io::Error::last_os_error()); - } - // SAFETY: fresh, valid descriptor. - let mut current = unsafe { OwnedFd::from_raw_fd(fd) }; - - for component in relative(requested).split('/').filter(|part| !part.is_empty()) { - // `EXDEV` rather than `EINVAL`: this is the same refusal `RESOLVE_BENEATH` reports for a - // path that leaves the root, and callers classify the escape by errno. - if component == "." || component == ".." { - return Err(io::Error::from_raw_os_error(libc::EXDEV)); - } - - let name = CString::new(component).map_err(|_| io::Error::from_raw_os_error(libc::EINVAL))?; + for component in relative(requested) + .split('/') + .filter(|part| !part.is_empty()) + { + let name = confined_component(component)?; // SAFETY: a valid dirfd and NUL-terminated component name. let made = unsafe { libc::mkdirat(current.as_raw_fd(), name.as_ptr(), CREATED_DIR) }; @@ -248,27 +250,14 @@ pub fn create_dir_all(root: &Path, requested: &str) -> io::Result<()> { } } - let how = OpenHow { - flags: (libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC) as u64, - mode: 0, - resolve: RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS | RESOLVE_NO_MAGICLINKS, - }; - - // SAFETY: valid dirfd, NUL-terminated name, correctly sized `open_how`. - let next = unsafe { - libc::syscall( - libc::SYS_openat2, - current.as_raw_fd(), - name.as_ptr(), - &how as *const OpenHow, - std::mem::size_of::(), - ) - }; - if next < 0 { - return Err(io::Error::last_os_error()); - } - // SAFETY: fresh descriptor from the kernel; the previous one is dropped by the assignment. - current = unsafe { OwnedFd::from_raw_fd(next as i32) }; + // The re-open is where a symlink is caught: `O_NOFOLLOW` with `O_DIRECTORY` makes a symlink + // at this name `ENOTDIR`, so the walk never steps onto it even if the command raced `mkdirat`. + current = open_child( + current.as_raw_fd(), + &name, + libc::O_PATH | libc::O_DIRECTORY, + 0, + )?; } Ok(()) @@ -282,11 +271,10 @@ mod fallback { use super::*; use crate::paths::resolve_within_root; - /// Reports a refusal as `EXDEV`, the same errno `RESOLVE_BENEATH` returns, so callers + /// Reports a refusal as `EXDEV`, the same errno the Linux walk returns for `..`, so callers /// classify an escape the same way on both paths. fn resolved(root: &Path, requested: &str) -> io::Result { - resolve_within_root(root, requested) - .map_err(|_| io::Error::from_raw_os_error(libc::EXDEV)) + resolve_within_root(root, requested).map_err(|_| io::Error::from_raw_os_error(libc::EXDEV)) } pub fn open_read(root: &Path, requested: &str) -> io::Result { @@ -307,3 +295,73 @@ mod fallback { #[cfg(not(target_os = "linux"))] pub use fallback::{create_dir_all, open_read, open_write}; + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::os::unix::fs::symlink; + use std::sync::atomic::{AtomicU32, Ordering}; + + fn tmp_root() -> std::path::PathBuf { + static COUNTER: AtomicU32 = AtomicU32::new(0); + let dir = std::env::temp_dir().join(format!( + "alien-confine-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn a_file_written_beneath_the_root_reads_back() { + let root = tmp_root(); + create_dir_all(&root, "a/b").expect("nested dirs are created"); + let mut w = open_write(&root, "a/b/f").expect("a file writes beneath the root"); + w.write_all(b"hello").unwrap(); + let mut r = open_read(&root, "a/b/f").expect("the file reads back"); + let mut got = String::new(); + r.read_to_string(&mut got).unwrap(); + assert_eq!(got, "hello"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn a_dotdot_component_is_refused() { + let root = tmp_root(); + let error = open_read(&root, "../escape").expect_err("`..` must not leave the root"); + assert_eq!(error.raw_os_error(), Some(libc::EXDEV), "{error}"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn a_symlink_final_component_is_not_followed() { + let root = tmp_root(); + // A dangling target is enough: O_NOFOLLOW fails at the link itself, before the target. + symlink("/etc/passwd", root.join("link")).unwrap(); + let error = open_read(&root, "link").expect_err("a symlink target must not be followed"); + assert!( + matches!(error.raw_os_error(), Some(libc::ELOOP) | Some(libc::EMLINK)), + "{error}" + ); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn a_symlinked_parent_component_is_not_followed() { + let root = tmp_root(); + symlink("/tmp", root.join("up")).unwrap(); + let error = open_read(&root, "up/passwd") + .expect_err("a symlinked parent must not redirect the walk"); + // O_DIRECTORY on the symlink parent makes it ENOTDIR (the link itself is not a directory). + assert!( + matches!( + error.raw_os_error(), + Some(libc::ENOTDIR) | Some(libc::ELOOP) + ), + "{error}" + ); + std::fs::remove_dir_all(&root).ok(); + } +} diff --git a/crates/alien-sandbox-agent/src/error.rs b/crates/alien-sandbox-agent/src/error.rs index c2a48e54d..fb1a34e89 100644 --- a/crates/alien-sandbox-agent/src/error.rs +++ b/crates/alien-sandbox-agent/src/error.rs @@ -74,6 +74,32 @@ pub enum ErrorData { reason: String, }, + /// A poll or cancel named a job the session is not holding. + #[error( + code = "JOB_NOT_FOUND", + message = "No such job: {job_id}", + retryable = "false", + internal = "false", + http_status_code = 404 + )] + JobNotFound { + /// The job id as the caller supplied it + job_id: String, + }, + + /// Every job slot holds a still-running job, so a new one cannot be started yet. + #[error( + code = "JOB_LIMIT_REACHED", + message = "The session is running its maximum of {limit} jobs; retry once one finishes", + retryable = "true", + internal = "false", + http_status_code = 429 + )] + JobLimitReached { + /// The ceiling on concurrent jobs + limit: usize, + }, + /// The caller and the agent do not speak the same protocol version. #[error( code = "PROTOCOL_VERSION_MISMATCH", diff --git a/crates/alien-sandbox-agent/src/jobs.rs b/crates/alien-sandbox-agent/src/jobs.rs new file mode 100644 index 000000000..ed6d6cd61 --- /dev/null +++ b/crates/alien-sandbox-agent/src/jobs.rs @@ -0,0 +1,763 @@ +//! Detached command jobs: run past one request, polled for output, cancelled by killing the group. +//! +//! One `POST /` call cannot answer for a command that runs longer than the execute proxy holds a +//! single call open (~30s), whatever deadline it was given. A job runs the command detached under +//! the agent's own deadline, buffers its frames, and returns them across as many short polls as the +//! command takes. Nothing here bounds the command — [`exec::stream`] and its deadline still do; a +//! job only decouples the command's lifetime from a single request's. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; + +use tokio::sync::{mpsc, oneshot}; +use uuid::Uuid; + +use crate::error::{ErrorData, Result}; +use crate::exec::{self, ExecIdentity, ExecRequest, Frame, FRAME_CHANNEL_DEPTH}; +use alien_error::AlienError; + +/// The most jobs one session holds at once. +/// +/// A supervisor of untrusted code cannot retain job output without a ceiling: output is kept for +/// replay until a later start evicts it or the session ends, and unbounded retention is a +/// memory-exhaustion path. The worst case is `MAX_JOBS` × two streams × `output_cap` of buffered +/// frames — ≈128 MiB at the default 4 MiB cap — and a start is refused once every slot holds a +/// still-running job. This constant is the knob if that ceiling is too high for a given image. +const MAX_JOBS: usize = 16; + +/// How a job ended, lifted out of its terminal frame so a poll reports it in the response envelope +/// rather than as a frame the caller has to find and interpret. +#[derive(Debug, Clone)] +pub enum JobOutcome { + /// The command exited on its own. + Exited { + /// Process exit code + code: i32, + /// Set when output was cut short by `output_cap` rather than by the command ending + truncated: bool, + }, + /// The command did not exit normally — a deadline, a failed spawn, or a cancellation. + Failed { + /// Machine-readable cause, e.g. `deadlineExceeded` + code: String, + /// Human-readable detail + message: String, + }, +} + +/// What a poll sees of a job: the output it asked for and, once the job has ended, how it did. +pub struct JobSnapshot { + /// Output frames after the polled sequence; `Stdout`/`Stderr` only. + pub frames: Vec, + /// `None` while the job is still running. + pub outcome: Option, +} + +/// One job's buffered state, shared between its collector task and every poll. +struct Buffer { + /// Output frames in production order; the terminal frame is captured in `outcome`, not here. + frames: Vec, + /// `None` until the terminal frame arrives or the job is cancelled. + outcome: Option, + /// Set once a poll has returned the terminal outcome, so the cap never evicts a result a + /// caller has not yet read. + terminal_delivered: bool, +} + +struct Job { + buffer: Mutex, + /// Taken by the first cancel. Dropping the collector's receiver is what kills the group, so the + /// signal only has to reach the collector once. + cancel: Mutex>>, + /// Start order, so the oldest evictable job is the one reclaimed under the cap. + ordinal: u64, +} + +/// The jobs one session is running or retaining, behind interior mutability so the shared +/// [`AgentState`](crate::server::AgentState) it lives in stays immutable. +pub struct JobRegistry { + jobs: Mutex>>, + ordinal: AtomicU64, + capacity: usize, +} + +impl JobRegistry { + pub fn new() -> Self { + Self::with_capacity(MAX_JOBS) + } + + fn with_capacity(capacity: usize) -> Self { + Self { + jobs: Mutex::new(HashMap::new()), + ordinal: AtomicU64::new(0), + capacity, + } + } + + /// Starts a detached job and returns its id. + /// + /// The request is validated and a slot reserved before anything spawns, so an invalid command + /// or a full registry is refused as an error rather than as a job that instantly fails. + pub fn start( + &self, + request: ExecRequest, + working_directory: PathBuf, + identity: ExecIdentity, + output_cap: usize, + ) -> Result { + request.validate()?; + + let id = Uuid::new_v4().to_string(); + let job = Arc::new(Job { + buffer: Mutex::new(Buffer { + frames: Vec::new(), + outcome: None, + terminal_delivered: false, + }), + cancel: Mutex::new(None), + ordinal: self.ordinal.fetch_add(1, Ordering::Relaxed), + }); + + { + let mut jobs = self.lock(); + self.make_room(&mut jobs)?; + jobs.insert(id.clone(), Arc::clone(&job)); + } + + let (frames_tx, frames_rx) = mpsc::channel(FRAME_CHANNEL_DEPTH); + let (cancel_tx, cancel_rx) = oneshot::channel(); + *job.cancel.lock().expect("no panic holds a job lock") = Some(cancel_tx); + + tokio::spawn(async move { + exec::stream( + &request, + Some(&working_directory), + identity, + output_cap, + frames_tx, + ) + .await; + }); + tokio::spawn(collect(frames_rx, cancel_rx, job)); + + Ok(id) + } + + /// Returns a job's buffered output strictly after `since_seq`, or `None` when no such job. + /// + /// `since_seq` is exclusive so a poll retried after a lost response returns the frames the + /// caller is still missing rather than duplicating ones it already has. `None` returns from the + /// first frame — the value a caller passes before it has received any. + /// + /// Sequence numbers may skip: a line dropped at `output_cap` still consumes one, so a gap is + /// output that was truncated, not a frame lost in transit. The terminal `truncated` flag is + /// what reports it; a caller must not treat a gap as a frame still to come. + pub fn poll(&self, id: &str, since_seq: Option) -> Option { + let job = Arc::clone(self.lock().get(id)?); + let mut buffer = job.buffer.lock().expect("no panic holds a job lock"); + let frames = buffer + .frames + .iter() + .filter(|frame| match frame_seq(frame) { + Some(seq) => since_seq.is_none_or(|since| seq > since), + None => false, + }) + .cloned() + .collect(); + let outcome = buffer.outcome.clone(); + // The caller has now seen the terminal result, so the cap may reclaim this slot. + if outcome.is_some() { + buffer.terminal_delivered = true; + } + Some(JobSnapshot { frames, outcome }) + } + + /// Signals a job to cancel, killing its process group. Returns whether the job existed. + pub fn cancel(&self, id: &str) -> bool { + let Some(job) = self.lock().get(id).map(Arc::clone) else { + return false; + }; + if let Some(signal) = job.cancel.lock().expect("no panic holds a job lock").take() { + let _ = signal.send(()); + } + true + } + + /// How many jobs the session is holding, running and retained alike. Never above the capacity. + pub fn len(&self) -> usize { + self.lock().len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Whether a job has reached a terminal outcome, read without a poll so a test can await + /// completion without marking the result delivered. + #[cfg(test)] + fn is_finished(&self, id: &str) -> bool { + self.lock().get(id).is_some_and(|job| { + job.buffer + .lock() + .expect("no panic holds a job lock") + .outcome + .is_some() + }) + } + + fn lock(&self) -> MutexGuard<'_, HashMap>> { + self.jobs.lock().expect("no panic holds the registry lock") + } + + /// Frees a slot when the registry is full, evicting the oldest finished job. + /// + /// A running job is never evicted — its output is still being produced and its process is still + /// alive. When every slot holds one, the start is refused rather than dropping live output. + fn make_room(&self, jobs: &mut HashMap>) -> Result<()> { + if jobs.len() < self.capacity { + return Ok(()); + } + + // Only a finished job whose terminal result a caller has already read: evicting an + // unread result would turn the next poll into `JobNotFound` and lose the real exit code. + let oldest_evictable = jobs + .iter() + .filter(|(_, job)| { + let buffer = job.buffer.lock().expect("no panic holds a job lock"); + buffer.outcome.is_some() && buffer.terminal_delivered + }) + .min_by_key(|(_, job)| job.ordinal) + .map(|(id, _)| id.clone()); + + match oldest_evictable { + Some(id) => { + jobs.remove(&id); + Ok(()) + } + None => Err(AlienError::new(ErrorData::JobLimitReached { + limit: self.capacity, + })), + } + } +} + +impl Default for JobRegistry { + fn default() -> Self { + Self::new() + } +} + +/// Drains a job's frames into its buffer until the command ends or a cancel arrives. +/// +/// On cancel the receiver is dropped by returning, which closes [`exec::stream`]'s frame channel; +/// the shared process path turns that into `SIGKILL` on the command's process group, so a job +/// cancels the whole tree it spawned rather than only its direct child. +async fn collect( + mut frames: mpsc::Receiver, + mut cancel: oneshot::Receiver<()>, + job: Arc, +) { + loop { + tokio::select! { + frame = frames.recv() => match frame { + Some(Frame::Exit { code, truncated }) => { + finish(&job, JobOutcome::Exited { code, truncated }); + return; + } + Some(Frame::Error { code, message }) => { + finish(&job, JobOutcome::Failed { code, message }); + return; + } + Some(output) => { + job.buffer + .lock() + .expect("no panic holds a job lock") + .frames + .push(output); + } + // The stream always ends with a terminal frame; reaching here means the producing + // task was dropped before it sent one, which is still a job no longer running. + None => { + finish(&job, JobOutcome::Failed { + code: "streamEnded".to_string(), + message: "the command's output ended without a terminal frame".to_string(), + }); + return; + } + }, + _ = &mut cancel => { + finish(&job, JobOutcome::Failed { + code: "cancelled".to_string(), + message: "the job was cancelled".to_string(), + }); + return; + } + } + } +} + +/// Records a job's outcome, unless one is already set. +/// +/// A cancel that races the command's own terminal frame must not overwrite the real ending: the +/// first outcome to land is the one that happened. +fn finish(job: &Job, outcome: JobOutcome) { + let mut buffer = job.buffer.lock().expect("no panic holds a job lock"); + if buffer.outcome.is_none() { + buffer.outcome = Some(outcome); + } +} + +fn frame_seq(frame: &Frame) -> Option { + match frame { + Frame::Stdout { seq, .. } | Frame::Stderr { seq, .. } => Some(*seq), + Frame::Exit { .. } | Frame::Error { .. } => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::collections::{BTreeMap, BTreeSet}; + use std::time::Duration; + + use base64::engine::general_purpose::STANDARD; + use base64::Engine as _; + + /// The uid the test process already has. Setting a uid to its own is permitted unprivileged, so + /// this exercises the real spawn path without needing root. + fn same_identity() -> ExecIdentity { + #[cfg(unix)] + unsafe { + ExecIdentity { + uid: libc::getuid(), + gid: libc::getgid(), + } + } + #[cfg(not(unix))] + ExecIdentity { uid: 0, gid: 0 } + } + + fn request(command: &[&str], deadline_ms: u64) -> ExecRequest { + ExecRequest { + command: command.iter().map(|s| s.to_string()).collect(), + deadline_ms, + working_directory: None, + env: BTreeMap::new(), + } + } + + fn start(registry: &JobRegistry, command: &[&str], deadline_ms: u64) -> String { + registry + .start( + request(command, deadline_ms), + std::env::temp_dir(), + same_identity(), + 1 << 20, + ) + .expect("a valid job starts") + } + + /// Polls until the job is no longer running, returning its terminal snapshot. Bounded so a job + /// that never ends fails the test rather than hanging it. + async fn wait_for_completion(registry: &JobRegistry, id: &str) -> JobSnapshot { + // Generous enough to outlast the longest job any test starts, including the ignored one + // that sleeps past the execute proxy's cap; a job that never ends still fails rather than + // hanging the run. + for _ in 0..2400 { + let snapshot = registry.poll(id, None).expect("the job exists"); + if snapshot.outcome.is_some() { + return snapshot; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + panic!("the job never reached a terminal state"); + } + + fn seqs(frames: &[Frame]) -> Vec { + frames.iter().filter_map(frame_seq).collect() + } + + fn stdout_text(frames: &[Frame]) -> String { + let mut collected = Vec::new(); + for frame in frames { + if let Frame::Stdout { data, .. } = frame { + collected.extend_from_slice(&STANDARD.decode(data).expect("valid base64")); + } + } + String::from_utf8(collected).expect("utf8 output") + } + + /// The property the whole module exists for: a job runs past the call that started it, and its + /// output is readable incrementally while it runs and in full once it ends. + #[tokio::test] + async fn a_job_outlives_its_start_and_streams_across_polls() { + let registry = JobRegistry::new(); + + let id = start( + ®istry, + &["/bin/sh", "-c", "echo a; sleep 1; echo b; sleep 1; echo c"], + 30_000, + ); + + // The start returned while the command is still sleeping between its writes. + let early = registry.poll(&id, None).expect("the job exists"); + assert!( + early.outcome.is_none(), + "the job must still be running right after it started: {:?}", + stdout_text(&early.frames) + ); + + let done = wait_for_completion(®istry, &id).await; + assert!( + matches!(done.outcome, Some(JobOutcome::Exited { code: 0, .. })), + "the job must exit cleanly" + ); + assert_eq!( + stdout_text(&done.frames) + .split_whitespace() + .collect::>(), + vec!["a", "b", "c"], + "the full output must survive across polls" + ); + } + + /// A command longer than the execute proxy's single-call cap still completes and returns every + /// line. Ignored because it spends its wall-clock; run with `--ignored`. + #[tokio::test] + #[ignore = "spends ~35s of wall-clock proving the cap is cleared"] + async fn a_job_longer_than_the_proxy_cap_completes_in_full() { + let registry = JobRegistry::new(); + + let id = start( + ®istry, + &["/bin/sh", "-c", "echo start; sleep 35; echo end"], + 60_000, + ); + + let done = wait_for_completion(®istry, &id).await; + assert!( + matches!(done.outcome, Some(JobOutcome::Exited { code: 0, .. })), + "a 35s job must exit cleanly, not hit a cap: {:?}", + done.outcome + ); + assert_eq!( + stdout_text(&done.frames) + .split_whitespace() + .collect::>(), + vec!["start", "end"], + "both the pre- and post-sleep output must arrive" + ); + } + + /// A client that loses a response re-polls from a cursor it has already passed. Across several + /// overlapping windows — including one that rewinds behind the last — a window must begin at + /// exactly the frame after its cursor and never re-deliver one at or before it, so stitching + /// the deltas rebuilds the stream with no seq repeated and none skipped. The strictly-after + /// test polls a finished job at two fixed offsets; this walks a moving, overlapping cursor as + /// `run_detached` does. + #[tokio::test] + async fn overlapping_polls_reconstruct_the_stream_exactly_once() { + let registry = JobRegistry::new(); + let id = start( + ®istry, + &["/bin/sh", "-c", "echo a; echo b; echo c; echo d; echo e"], + 10_000, + ); + assert_eq!( + seqs(&wait_for_completion(®istry, &id).await.frames), + vec![0, 1, 2, 3, 4], + "five output lines, seq 0..=4" + ); + + let mut covered = BTreeSet::new(); + for since in [None, Some(1), Some(0), Some(3), Some(2)] { + let delta = seqs(®istry.poll(&id, since).expect("the job exists").frames); + if let Some(since) = since { + assert!( + delta.iter().all(|seq| *seq > since), + "no duplication: a window past {since} re-delivered {delta:?}" + ); + if let Some(&first) = delta.first() { + assert_eq!( + first, + since + 1, + "no gap: a window must begin at the frame right after its cursor" + ); + } + } + covered.extend(delta); + } + assert_eq!( + covered.into_iter().collect::>(), + vec![0, 1, 2, 3, 4], + "the overlapping windows together cover every frame exactly once" + ); + } + + /// A stale `sinceSeq` returns exactly the frames after it — no duplication of what the caller + /// already had, no gap before what it is missing — and the same poll repeated returns the same + /// frames, which is what makes a retried poll safe. + #[tokio::test] + async fn poll_returns_frames_strictly_after_since_seq() { + let registry = JobRegistry::new(); + let id = start( + ®istry, + &["/bin/sh", "-c", "echo a; echo b; echo c; echo d"], + 10_000, + ); + + let all = wait_for_completion(®istry, &id).await; + assert_eq!( + seqs(&all.frames), + vec![0, 1, 2, 3], + "four output lines, seq 0..=3" + ); + + let after_one = registry.poll(&id, Some(1)).expect("the job exists"); + assert_eq!( + seqs(&after_one.frames), + vec![2, 3], + "strictly after 1: no dup of 0 or 1, no gap before 2" + ); + + let retried = registry.poll(&id, Some(1)).expect("the job exists"); + assert_eq!( + seqs(&retried.frames), + vec![2, 3], + "a retried poll returns the same frames, never fewer or more" + ); + + let after_last = registry.poll(&id, Some(3)).expect("the job exists"); + assert!( + after_last.frames.is_empty(), + "nothing follows the last frame" + ); + } + + /// Cancel kills the process group, so a process the command forked does not outlive it. Proven + /// by a grandchild that keeps writing a marker file: after the cancel the file stops growing. + #[cfg(unix)] + #[tokio::test] + async fn cancel_kills_the_forked_child_too() { + let registry = JobRegistry::new(); + let marker = std::env::temp_dir().join(format!("alien-job-cancel-{}", std::process::id())); + let _ = std::fs::remove_file(&marker); + + // The grandchild is backgrounded and outlives the shell's own foreground sleep. stdout is + // closed so it cannot hold the frame pipe open — this is about the process, not the stream. + let script = format!( + "(while true; do echo x >> {} ; sleep 0.05; done) >/dev/null 2>&1 &\nsleep 30", + marker.display() + ); + let id = start(®istry, &["/bin/sh", "-c", &script], 60_000); + + tokio::time::sleep(Duration::from_millis(500)).await; + let before = std::fs::metadata(&marker).map(|m| m.len()); + + assert!( + registry.cancel(&id), + "cancelling a live job reports it existed" + ); + + // Give the kill time to land, then confirm the marker stops growing. + tokio::time::sleep(Duration::from_millis(500)).await; + let after_cancel = std::fs::metadata(&marker).map(|m| m.len()); + tokio::time::sleep(Duration::from_millis(500)).await; + let later = std::fs::metadata(&marker).map(|m| m.len()); + let _ = std::fs::remove_file(&marker); + + let before = before.expect("the grandchild must have written before the cancel"); + let after_cancel = after_cancel.expect("the marker must still exist"); + let later = later.expect("the marker must still exist"); + assert!( + before > 0, + "the grandchild wrote nothing, so this test proves nothing" + ); + assert_eq!( + after_cancel, later, + "a process the command forked outlived the cancel and is still writing" + ); + + let snapshot = registry.poll(&id, None).expect("the job exists"); + assert!( + matches!(snapshot.outcome, Some(JobOutcome::Failed { .. })), + "a cancelled job is done, not running" + ); + } + + /// A finished job whose result has been read is evicted to make room once the cap is reached, + /// so retention is bounded rather than growing with every job a session ever ran. + #[tokio::test] + async fn a_full_registry_evicts_the_oldest_finished_job() { + let registry = JobRegistry::with_capacity(2); + + let first = start(®istry, &["/bin/echo", "one"], 10_000); + let second = start(®istry, &["/bin/echo", "two"], 10_000); + wait_for_completion(®istry, &first).await; + wait_for_completion(®istry, &second).await; + + // The third start is at the cap, so the oldest finished job is evicted for it. + let third = start(®istry, &["/bin/echo", "three"], 10_000); + wait_for_completion(®istry, &third).await; + + assert_eq!(registry.len(), 2, "retention never exceeds the capacity"); + assert!( + registry.poll(&first, None).is_none(), + "the oldest finished job must have been evicted" + ); + assert!( + registry.poll(&second, None).is_some(), + "a newer finished job is retained" + ); + assert!( + registry.poll(&third, None).is_some(), + "the job that forced the eviction is retained" + ); + } + + /// A finished job no poll has read is never evicted: dropping it would turn the caller's next + /// poll into a not-found and lose the real exit code, so the cap refuses a new start instead. + #[tokio::test] + async fn an_unread_finished_job_is_not_evicted() { + let registry = JobRegistry::with_capacity(1); + + let first = start(®istry, &["/bin/echo", "one"], 10_000); + for _ in 0..2400 { + if registry.is_finished(&first) { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + assert!( + registry.is_finished(&first), + "the job reaches a terminal state" + ); + + // At the cap with the only result still unread, a new start is refused, not evicted. + let refused = registry.start( + request(&["/bin/echo", "two"], 10_000), + std::env::temp_dir(), + same_identity(), + 1 << 20, + ); + assert_eq!( + refused + .expect_err("an unread finished result must not be evicted") + .code, + "JOB_LIMIT_REACHED" + ); + + // Reading it makes the slot reclaimable, so the next start then succeeds. + assert!(registry.poll(&first, None).unwrap().outcome.is_some()); + start(®istry, &["/bin/echo", "three"], 10_000); + assert_eq!( + registry.len(), + 1, + "the read result was reclaimed for the new job" + ); + } + + /// When every slot holds a still-running job, a new start is refused rather than killing live + /// output to make room. + #[tokio::test] + async fn a_registry_full_of_running_jobs_refuses_a_new_one() { + let registry = JobRegistry::with_capacity(2); + + let first = start(®istry, &["/bin/sleep", "30"], 60_000); + let second = start(®istry, &["/bin/sleep", "30"], 60_000); + + let refused = registry.start( + request(&["/bin/echo", "blocked"], 10_000), + std::env::temp_dir(), + same_identity(), + 1 << 20, + ); + let error = refused.expect_err("a registry full of running jobs must refuse a new job"); + assert_eq!(error.code, "JOB_LIMIT_REACHED"); + + assert!(registry.cancel(&first), "the running jobs still exist"); + assert!(registry.cancel(&second)); + } + + /// An empty command is refused before a slot is reserved, so a rejected request leaves no job + /// behind to be polled or to occupy the cap. + #[tokio::test] + async fn an_invalid_request_is_refused_without_reserving_a_slot() { + let registry = JobRegistry::new(); + + let refused = registry.start( + request(&[], 10_000), + std::env::temp_dir(), + same_identity(), + 1 << 20, + ); + assert_eq!( + refused.expect_err("an empty command is invalid").code, + "REQUEST_INVALID" + ); + + // A fresh registry with a rejected start holds nothing. + let running = start(®istry, &["/bin/sleep", "30"], 60_000); + assert!(registry.cancel(&running)); + } + + /// Output past the cap is dropped and the job flagged `truncated`; the sequence gap that leaves + /// is not a frame still to come, so a poll from the last kept frame returns nothing rather than + /// waiting on the numbers truncation consumed. + #[tokio::test] + async fn a_truncated_job_reports_it_and_leaves_no_pending_frame() { + let registry = JobRegistry::new(); + let id = registry + .start( + request( + &[ + "/bin/sh", + "-c", + "for i in 1 2 3 4 5 6 7 8; do echo aaaaaaaaaa; done", + ], + 10_000, + ), + std::env::temp_dir(), + same_identity(), + 25, + ) + .expect("a valid job starts"); + + let done = wait_for_completion(®istry, &id).await; + assert!( + matches!( + done.outcome, + Some(JobOutcome::Exited { + truncated: true, + .. + }) + ), + "output past the cap must be flagged truncated: {:?}", + done.outcome + ); + + let last = *seqs(&done.frames) + .last() + .expect("some output is kept below the cap"); + assert!( + registry + .poll(&id, Some(last)) + .expect("the job exists") + .frames + .is_empty(), + "nothing follows the last kept frame, whatever numbers truncation skipped" + ); + } + + /// A poll or cancel for an id the session never held reports it is gone rather than inventing a + /// running job with no output. + #[tokio::test] + async fn a_missing_job_is_absent_to_poll_and_cancel() { + let registry = JobRegistry::new(); + assert!(registry.poll("nonexistent", None).is_none()); + assert!(!registry.cancel("nonexistent")); + } +} diff --git a/crates/alien-sandbox-agent/src/lib.rs b/crates/alien-sandbox-agent/src/lib.rs index c556c29ef..84fbcc5f0 100644 --- a/crates/alien-sandbox-agent/src/lib.rs +++ b/crates/alien-sandbox-agent/src/lib.rs @@ -1,6 +1,7 @@ pub mod confine; pub mod error; pub mod exec; +pub mod jobs; pub mod pid_namespace; pub mod files; pub mod paths; diff --git a/crates/alien-sandbox-agent/src/main.rs b/crates/alien-sandbox-agent/src/main.rs index 7769d8004..9995ae1ee 100644 --- a/crates/alien-sandbox-agent/src/main.rs +++ b/crates/alien-sandbox-agent/src/main.rs @@ -4,6 +4,7 @@ //! session. Nothing is negotiated at runtime: the process that placed this agent in the sandbox //! is the only thing that gets to decide what session it serves and what authorises a request. +use std::io; use std::net::{Ipv4Addr, SocketAddr}; use std::path::PathBuf; use std::sync::Arc; @@ -12,7 +13,9 @@ use alien_core::sandbox_capability::SandboxSessionIdentity; use alien_error::{AlienError, Context, IntoAlienError}; use alien_sandbox_agent::error::{ErrorData, Result}; use alien_sandbox_agent::exec::ExecIdentity; +use alien_sandbox_agent::jobs::JobRegistry; use alien_sandbox_agent::server::{router, AgentAuthorization, AgentState}; +use axum::serve::{Listener, ListenerExt}; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine as _; use ed25519_compact::PublicKey; @@ -35,6 +38,9 @@ const ENV_OUTPUT_CAP: &str = "ALIEN_SANDBOX_OUTPUT_CAP"; const ENV_EXEC_UID: &str = "ALIEN_SANDBOX_EXEC_UID"; /// Its primary group. const ENV_EXEC_GID: &str = "ALIEN_SANDBOX_EXEC_GID"; +/// Which isolation model is in force: `uid-split` or `platform`. Declared, never defaulted — it +/// selects a security model, so an unset value must fail to start rather than silently pick one. +const ENV_ISOLATION: &str = "ALIEN_SANDBOX_ISOLATION"; /// Bytes of each stream kept when the environment does not say. const DEFAULT_OUTPUT_CAP: usize = 4 * 1024 * 1024; @@ -49,20 +55,134 @@ async fn main() -> Result<()> { // All interfaces: on AWS the agent is reached from outside the guest, and a // loopback bind would make it unreachable. let address = SocketAddr::from((Ipv4Addr::UNSPECIFIED, port)); - let listener = tokio::net::TcpListener::bind(address) - .await + let std_listener = std::net::TcpListener::bind(address) .into_alien_error() - .context(failed("bind the agent listener", "the agent could not take its port".to_string()))?; + .context(failed( + "bind the agent listener", + "the agent could not take its port".to_string(), + ))?; tracing::info!("sandbox agent listening on {address}"); axum::serve( - listener, + // tap_io is a no-op that wraps the listener in axum's TapIo, which is what makes the + // SocketAddr connect-info available for a custom listener (the orphan rule blocks impls + // straight onto SocketAddr). + BlockingListener::new(std_listener, address).tap_io(|_| {}), router(state).into_make_service_with_connect_info::(), ) - .await - .into_alien_error() - .context(failed("serve the agent protocol", "the agent stopped serving".to_string())) + .await + .into_alien_error() + .context(failed( + "serve the agent protocol", + "the agent stopped serving".to_string(), + )) +} + +/// A listener whose accept blocks in the `accept(2)` syscall instead of waiting on an epoll edge. +/// +/// The GCP Agent Platform detaches and reattaches a sandbox's data plane on pause/resume. A tokio +/// `TcpListener` registered with epoll then stops receiving readiness for the listen socket across +/// that reattach and never accepts again — the process stays alive and the socket stays in LISTEN, +/// but no connection is served. A blocking `accept()` re-wakes on the next connection regardless, +/// which is why a plain blocking server survives the same transition. Each accepted connection is a +/// fresh tokio stream, so only the long-lived listener needs this. +struct BlockingListener { + inner: Arc, + local: SocketAddr, +} + +impl BlockingListener { + fn new(listener: std::net::TcpListener, local: SocketAddr) -> Self { + Self { + inner: Arc::new(listener), + local, + } + } +} + +impl Listener for BlockingListener { + type Io = tokio::net::TcpStream; + type Addr = SocketAddr; + + async fn accept(&mut self) -> (Self::Io, Self::Addr) { + loop { + let listener = Arc::clone(&self.inner); + match tokio::task::spawn_blocking(move || listener.accept()).await { + Ok(Ok((stream, peer))) => { + if let Err(error) = stream.set_nonblocking(true) { + tracing::warn!(%error, "dropping a connection that would not go non-blocking"); + continue; + } + match tokio::net::TcpStream::from_std(stream) { + Ok(stream) => return (stream, peer), + Err(error) => { + tracing::warn!(%error, "dropping a connection tokio would not adopt") + } + } + } + Ok(Err(error)) => { + tracing::warn!(%error, "accept failed; retrying"); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + Err(error) => tracing::warn!(%error, "the accept task failed; retrying"), + } + } + } + + fn local_addr(&self) -> io::Result { + Ok(self.local) + } +} + +/// Which boundary keeps untrusted code away from the agent that supervises it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Isolation { + /// The command runs under a uid distinct from the agent's, and the drop to it must work, so the + /// agent's binary and state stay unreachable to it. + UidSplit, + /// The container or VM is the only boundary: the command runs as the agent's own user because + /// the platform allows no other. Accepted only where a uid split is impossible. + Platform, +} + +fn load_isolation() -> Result { + match required(ENV_ISOLATION)?.as_str() { + "uid-split" => Ok(Isolation::UidSplit), + "platform" => Ok(Isolation::Platform), + other => Err(invalid( + ENV_ISOLATION, + &format!("'{other}' is not one of: uid-split, platform"), + )), + } +} + +/// The identity rules, kept pure so they can be tested without touching process state. Root is +/// refused in both models; the agent's own uid/gid is refused only under uid-split, where a +/// command sharing the agent's identity is the escalation the split exists to prevent. Platform +/// accepts it because the platform runs everything as one user — the concession is same-uid, never +/// root. +fn enforce_exec_identity( + isolation: Isolation, + exec: ExecIdentity, + agent_uid: u32, + agent_gid: u32, +) -> Result<()> { + if exec.uid == 0 { + return Err(invalid(ENV_EXEC_UID, "must not be root")); + } + if exec.gid == 0 { + return Err(invalid(ENV_EXEC_GID, "must not be the root group")); + } + if isolation == Isolation::UidSplit { + if exec.uid == agent_uid { + return Err(invalid(ENV_EXEC_UID, "must not be the agent's own user")); + } + if exec.gid == agent_gid { + return Err(invalid(ENV_EXEC_GID, "must not be the agent's own group")); + } + } + Ok(()) } fn load_state() -> Result { @@ -70,13 +190,10 @@ fn load_state() -> Result { // Canonical up front, because every path check compares against it. A root that is itself a // symlink would make each comparison a false negative. - let session_root = root - .canonicalize() - .into_alien_error() - .context(failed( - &format!("resolve {ENV_ROOT} '{}'", root.display()), - "the session root must exist before the agent starts".to_string(), - ))?; + let session_root = root.canonicalize().into_alien_error().context(failed( + &format!("resolve {ENV_ROOT} '{}'", root.display()), + "the session root must exist before the agent starts".to_string(), + ))?; let output_cap = match std::env::var(ENV_OUTPUT_CAP) { Ok(_) => parse(ENV_OUTPUT_CAP)?, @@ -91,36 +208,21 @@ fn load_state() -> Result { gid: parse(ENV_EXEC_GID)?, }; - if exec_identity.uid == 0 { - return Err(invalid(ENV_EXEC_UID, "must not be root")); - } - - // Group 0 reaches the agent's own files wherever they carry group permission, which is most - // of what refusing uid 0 is there to prevent. - if exec_identity.gid == 0 { - return Err(invalid(ENV_EXEC_GID, "must not be the root group")); - } + let isolation = load_isolation()?; - // Refusing root is not enough where the agent itself is not root: running commands as the - // agent's own identity is the same escalation with a different number, and the bundle - // documents that configuration as a supported way to run on a shared kernel. + // SAFETY: both are always-successful getters with no arguments. #[cfg(unix)] - { - // SAFETY: both are always-successful getters with no arguments. - let (agent_uid, agent_gid) = unsafe { (libc::geteuid(), libc::getegid()) }; - if exec_identity.uid == agent_uid { - return Err(invalid(ENV_EXEC_UID, "must not be the agent's own user")); - } - if exec_identity.gid == agent_gid { - return Err(invalid(ENV_EXEC_GID, "must not be the agent's own group")); - } - } + let (agent_uid, agent_gid) = unsafe { (libc::geteuid(), libc::getegid()) }; + #[cfg(not(unix))] + let (agent_uid, agent_gid) = (u32::MAX, u32::MAX); + enforce_exec_identity(isolation, exec_identity, agent_uid, agent_gid)?; Ok(AgentState { session_root, authorization: load_authorization()?, exec_identity, output_cap, + jobs: JobRegistry::new(), }) } @@ -154,11 +256,12 @@ fn load_authorization() -> Result { } "capability" => { let encoded = required(ENV_PUBLIC_KEY)?; - let bytes = BASE64.decode(&encoded).map_err(|error| { - invalid(ENV_PUBLIC_KEY, &format!("not valid base64: {error}")) + let bytes = BASE64 + .decode(&encoded) + .map_err(|error| invalid(ENV_PUBLIC_KEY, &format!("not valid base64: {error}")))?; + let public_key = PublicKey::from_slice(&bytes).map_err(|error| { + invalid(ENV_PUBLIC_KEY, &format!("not an Ed25519 key: {error}")) })?; - let public_key = PublicKey::from_slice(&bytes) - .map_err(|error| invalid(ENV_PUBLIC_KEY, &format!("not an Ed25519 key: {error}")))?; Ok(AgentAuthorization::Capability { public_key, @@ -201,3 +304,42 @@ fn failed(operation: &str, reason: String) -> ErrorData { reason, } } + +#[cfg(test)] +mod tests { + use super::*; + + fn exec(uid: u32, gid: u32) -> ExecIdentity { + ExecIdentity { uid, gid } + } + + #[test] + fn platform_accepts_the_agents_own_user() { + enforce_exec_identity(Isolation::Platform, exec(1000, 1000), 1000, 1000) + .expect("platform runs the command as the agent's own user"); + } + + #[test] + fn uid_split_refuses_the_agents_own_user() { + let error = enforce_exec_identity(Isolation::UidSplit, exec(1000, 1000), 1000, 1000) + .expect_err("uid-split refuses the agent's own user"); + assert!(error.to_string().contains("agent's own user"), "{error}"); + } + + #[test] + fn uid_split_accepts_a_distinct_user() { + // The AWS shape: the agent runs as root, the command as an unprivileged uid. + enforce_exec_identity(Isolation::UidSplit, exec(60000, 60000), 0, 0) + .expect("a distinct exec uid is exactly what uid-split is for"); + } + + #[test] + fn root_is_refused_in_both_models() { + for isolation in [Isolation::UidSplit, Isolation::Platform] { + enforce_exec_identity(isolation, exec(0, 5), 1000, 1000) + .expect_err("root uid is refused regardless of the model"); + enforce_exec_identity(isolation, exec(5, 0), 1000, 1000) + .expect_err("root gid is refused regardless of the model"); + } + } +} diff --git a/crates/alien-sandbox-agent/src/server.rs b/crates/alien-sandbox-agent/src/server.rs index 24bde745a..1d357d82a 100644 --- a/crates/alien-sandbox-agent/src/server.rs +++ b/crates/alien-sandbox-agent/src/server.rs @@ -11,7 +11,7 @@ use std::sync::Arc; use axum::body::Body; use axum::extract::{ConnectInfo, Query, State}; use axum::http::{HeaderMap, StatusCode}; -use axum::response::Response; +use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; use base64::engine::general_purpose::STANDARD as BASE64; @@ -24,6 +24,7 @@ use tokio::sync::mpsc; use crate::error::ErrorData; use crate::exec::{self, ExecIdentity, ExecRequest, Frame, FRAME_CHANNEL_DEPTH}; use crate::files; +use crate::jobs::{JobOutcome, JobRegistry, JobSnapshot}; use crate::paths::resolve_within_root; use alien_core::sandbox_capability::{SandboxOperationClass, SandboxSessionIdentity}; use alien_core::sandbox_capability_token; @@ -83,14 +84,20 @@ pub struct AgentState { pub exec_identity: ExecIdentity, /// Bytes of each stream kept before output is truncated pub output_cap: usize, + /// Detached jobs this session is running or retaining for later polls + pub jobs: JobRegistry, } -/// Liveness and the version the agent speaks. +/// Liveness, the version the agent speaks, and the container it runs in. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct HealthResponse { /// The protocol version this agent implements pub protocol_version: u32, + /// The kernel boot id of the container this agent runs in. Stable across calls and processes + /// on one kernel, so a caller compares it across reads to tell its container from a blank one + /// that replaced it under the same session name. + pub boot_id: String, } /// Optional version assertion from the caller. @@ -135,6 +142,100 @@ pub struct MkdirBody { pub path: String, } +/// The id a started job answers to. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JobStartResponse { + /// Identifier for later polls and cancellation + pub job_id: String, +} + +/// Which job to poll, and from where. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JobPollBody { + /// The job to read + pub job_id: String, + /// Return frames strictly after this sequence; absent returns from the first frame + #[serde(default)] + pub since_seq: Option, +} + +/// A job's output so far, and how it ended once it has. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JobPollResponse { + /// Whether the command is still running + pub running: bool, + /// Output frames after the polled sequence + pub frames: Vec, + /// Exit code, present once a job has exited on its own + #[serde(skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + /// Set when output was cut short by the output cap; present once a job has exited + #[serde(skip_serializing_if = "Option::is_none")] + pub truncated: Option, + /// How a job failed, present when it ended without exiting normally + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Why a job did not exit normally. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JobErrorBody { + /// Machine-readable cause, e.g. `deadlineExceeded` + pub code: String, + /// Human-readable detail + pub message: String, +} + +impl From for JobPollResponse { + fn from(snapshot: JobSnapshot) -> Self { + match snapshot.outcome { + None => Self { + running: true, + frames: snapshot.frames, + exit_code: None, + truncated: None, + error: None, + }, + Some(JobOutcome::Exited { code, truncated }) => Self { + running: false, + frames: snapshot.frames, + exit_code: Some(code), + truncated: Some(truncated), + error: None, + }, + Some(JobOutcome::Failed { code, message }) => Self { + running: false, + frames: snapshot.frames, + exit_code: None, + truncated: None, + error: Some(JobErrorBody { code, message }), + }, + } + } +} + +/// Which job to cancel. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JobCancelBody { + /// The job to cancel + pub job_id: String, +} + +/// The discriminating fields of an [`agent_platform`] envelope, read before its operation body. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct EnvelopeHead { + /// Protocol version the caller intends to speak. Refused when unsupported, never guessed. + v: Option, + /// Which operation the body carries. Absent or unknown is refused, never defaulted. + op: Option, +} + /// Builds the agent's router. pub fn router(state: Arc) -> Router { // Base64 inflates by 4/3; the rest is the JSON envelope. Without this axum's 2MB default @@ -152,14 +253,22 @@ pub fn router(state: Arc) -> Router { .route("/v1/exec", post(run_command)) .route("/v1/files", get(read_file).put(write_file)) .route("/v1/mkdir", post(mkdir)) + .route("/v1/jobs/start", post(job_start)) + .route("/v1/jobs/poll", post(job_poll)) + .route("/v1/jobs/cancel", post(job_cancel)) + // The GCP Agent Platform proxies `:execute` to `POST /` with the body verbatim and can set + // neither path nor method, so the one route it can reach carries every operation, chosen + // by `op`. Placed before the body-limit layer so an envelope `writeFile` shares the same + // ceiling as `/v1/files` rather than falling back to axum's default. + .route("/", post(agent_platform)) .layer(axum::extract::DefaultBodyLimit::max(body_limit)) .with_state(state) } /// Liveness, and the one place protocol versions are reconciled. /// -/// Unauthenticated: it reports the version and nothing about the session, so requiring a -/// capability would only stop a liveness probe from working. +/// Unauthenticated: it reports the version and the container boot id — kernel identity, not session +/// contents — so requiring a capability would only stop a liveness probe from working. async fn health( Query(query): Query, ) -> std::result::Result, ApiError> { @@ -176,11 +285,38 @@ async fn health( } } + // Failing closed: a caller that cannot read the container identity must not reconnect to a + // possibly-replaced container, so an unreadable boot id is an error, not a blank field. + let boot_id = container_boot_id().map_err(|error| { + ApiError::from(AlienError::new(ErrorData::OperationFailed { + operation: "read container boot id".to_string(), + reason: error.to_string(), + })) + })?; + Ok(Json(HealthResponse { protocol_version: PROTOCOL_VERSION, + boot_id, })) } +/// The kernel boot id of the container this agent runs in. +/// +/// `/proc/sys/kernel/random/boot_id` is stable across calls and processes on one kernel and +/// changes only when the container is replaced, which is the identity a caller's `generation` is +/// derived from. +#[cfg(target_os = "linux")] +fn container_boot_id() -> std::io::Result { + std::fs::read_to_string("/proc/sys/kernel/random/boot_id").map(|id| id.trim().to_string()) +} + +/// A non-Linux dev build has no `/proc` boot id and never runs a real sandbox reconnect, so a +/// fixed sentinel stands in rather than a per-run value that would read as a fresh container. +#[cfg(not(target_os = "linux"))] +fn container_boot_id() -> std::io::Result { + Ok("dev-build-no-boot-id".to_string()) +} + /// The image's readiness and validation hooks. /// /// AWS snapshots the MicroVM once this answers 200, and every later MicroVM boots from that @@ -299,6 +435,152 @@ async fn mkdir( Ok(StatusCode::NO_CONTENT) } +/// Starts a command as a detached job whose output is polled for rather than streamed. +/// +/// The provider chooses this over `/v1/exec` when a command's deadline is longer than one proxied +/// call can stay open. The command runs under the same deadline; only its lifetime is detached. +async fn job_start( + State(state): State>, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, + Json(request): Json, +) -> std::result::Result, ApiError> { + authorize(&state, peer, &headers, SandboxOperationClass::Execute)?; + + // Resolved here, as in `run_command`, so a refused directory answers with an error rather than a + // job whose first frame is a failure. + let working_directory = match &request.working_directory { + Some(path) => resolve_within_root(&state.session_root, path)?, + None => state.session_root.clone(), + }; + + let job_id = state + .jobs + .start(request, working_directory, state.exec_identity, state.output_cap)?; + + Ok(Json(JobStartResponse { job_id })) +} + +/// Returns a job's output after a sequence, and its ending once it has one. +async fn job_poll( + State(state): State>, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, + Json(body): Json, +) -> std::result::Result, ApiError> { + authorize(&state, peer, &headers, SandboxOperationClass::Execute)?; + + let snapshot = state.jobs.poll(&body.job_id, body.since_seq).ok_or_else(|| { + ApiError::from(AlienError::new(ErrorData::JobNotFound { + job_id: body.job_id.clone(), + })) + })?; + + Ok(Json(JobPollResponse::from(snapshot))) +} + +/// Cancels a job, killing its process group. +async fn job_cancel( + State(state): State>, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, + Json(body): Json, +) -> std::result::Result, ApiError> { + authorize(&state, peer, &headers, SandboxOperationClass::Execute)?; + + if !state.jobs.cancel(&body.job_id) { + return Err(ApiError::from(AlienError::new(ErrorData::JobNotFound { + job_id: body.job_id, + }))); + } + + Ok(Json(serde_json::json!({}))) +} + +/// The single endpoint the GCP Agent Platform can reach, dispatching by the envelope's `op`. +/// +/// The version is reconciled and the `op` resolved before any handler runs; each arm then hands +/// off to the matching `/v1/*` handler, so the response — the exec NDJSON stream included — is the +/// same bytes that route produces, and authorization stays that handler's job. +async fn agent_platform( + State(state): State>, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, + body: Bytes, +) -> std::result::Result { + let head: EnvelopeHead = serde_json::from_slice(&body).map_err(|error| { + ApiError::from(AlienError::new(ErrorData::RequestInvalid { + reason: format!("envelope is not valid JSON: {error}"), + })) + })?; + + let requested = head.v.ok_or_else(|| { + ApiError::from(AlienError::new(ErrorData::RequestInvalid { + reason: "an envelope must carry a protocol version 'v'".to_string(), + })) + })?; + if requested != PROTOCOL_VERSION { + return Err(ApiError::from(AlienError::new( + ErrorData::ProtocolVersionMismatch { + requested, + supported: PROTOCOL_VERSION, + }, + ))); + } + + let op = head.op.ok_or_else(|| { + ApiError::from(AlienError::new(ErrorData::RequestInvalid { + reason: "an envelope must name an 'op'".to_string(), + })) + })?; + + // The operation's fields are re-read from the same bytes into the body the matching `/v1/*` + // handler takes; that works only because those types ignore the envelope's `v`/`op`. Adding + // `deny_unknown_fields` to one would break this dispatch at runtime, with nothing to catch it. + match op.as_str() { + "exec" => run_command(State(state), ConnectInfo(peer), headers, Json(reparse(&body)?)).await, + "readFile" => Ok(read_file(State(state), ConnectInfo(peer), headers, Query(reparse(&body)?)) + .await? + .into_response()), + "writeFile" => Ok( + write_file(State(state), ConnectInfo(peer), headers, Json(reparse(&body)?)) + .await? + .into_response(), + ), + "mkdir" => Ok(mkdir(State(state), ConnectInfo(peer), headers, Json(reparse(&body)?)) + .await? + .into_response()), + "jobStart" => Ok( + job_start(State(state), ConnectInfo(peer), headers, Json(reparse(&body)?)) + .await? + .into_response(), + ), + "jobPoll" => Ok( + job_poll(State(state), ConnectInfo(peer), headers, Json(reparse(&body)?)) + .await? + .into_response(), + ), + "jobCancel" => Ok( + job_cancel(State(state), ConnectInfo(peer), headers, Json(reparse(&body)?)) + .await? + .into_response(), + ), + "health" => Ok(health(Query(HealthQuery { version: None })).await?.into_response()), + other => Err(ApiError::from(AlienError::new(ErrorData::RequestInvalid { + reason: format!("unknown op '{other}'"), + }))), + } +} + +/// Reads the operation's own fields out of an envelope body once its `op` has selected the type. +fn reparse(body: &Bytes) -> std::result::Result { + serde_json::from_slice(body).map_err(|error| { + ApiError::from(AlienError::new(ErrorData::RequestInvalid { + reason: format!("envelope body did not match its op: {error}"), + })) + }) +} + /// Verifies the request may reach this session, or refuses it. fn authorize( state: &AgentState, diff --git a/crates/alien-sandbox-agent/tests/protocol.rs b/crates/alien-sandbox-agent/tests/protocol.rs index 10607983e..6a98e1ce3 100644 --- a/crates/alien-sandbox-agent/tests/protocol.rs +++ b/crates/alien-sandbox-agent/tests/protocol.rs @@ -14,6 +14,7 @@ use alien_core::sandbox_capability::{ }; use alien_core::sandbox_capability_token; use alien_sandbox_agent::exec::ExecIdentity; +use alien_sandbox_agent::jobs::JobRegistry; use alien_sandbox_agent::server::{router, AgentAuthorization, AgentState, PROTOCOL_VERSION}; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine as _; @@ -40,6 +41,7 @@ struct Agent { base_url: String, keys: KeyPair, root: PathBuf, + state: Arc, _dir: TempDir, } @@ -60,8 +62,11 @@ impl Agent { }, exec_identity: test_identity(), output_cap: 1 << 20, + jobs: JobRegistry::new(), }); + let served = Arc::clone(&state); + let listener = TcpListener::bind::("127.0.0.1:0".parse().expect("literal")) .await .expect("bind loopback"); @@ -70,7 +75,7 @@ impl Agent { tokio::spawn(async move { axum::serve( listener, - router(state).into_make_service_with_connect_info::(), + router(served).into_make_service_with_connect_info::(), ) .await .expect("serve"); @@ -80,6 +85,7 @@ impl Agent { base_url: format!("http://{address}"), keys, root, + state, _dir: dir, } } @@ -140,6 +146,12 @@ async fn health_reports_the_protocol_version_without_a_capability() { assert_eq!(response.status(), 200); let body: serde_json::Value = response.json().await.expect("json"); assert_eq!(body["protocolVersion"], PROTOCOL_VERSION); + // The container boot id a caller derives its generation from: present and non-empty, so a + // reconnecting caller can tell its container from a blank one wearing the same name. + assert!( + body["bootId"].as_str().is_some_and(|id| !id.is_empty()), + "health reports a non-empty container boot id: {body}" + ); } /// The agent outlives the deployment that built its image, so a mismatch has @@ -417,6 +429,7 @@ async fn transport_authorization_needs_no_capability() { // image, and the caller here stands in for one arriving through the transport. exec_identity: ExecIdentity { uid: 60000, gid: 60000 }, output_cap: 1 << 20, + jobs: JobRegistry::new(), }); let listener = TcpListener::bind::("127.0.0.1:0".parse().expect("literal")) @@ -460,6 +473,7 @@ async fn transport_authorization_refuses_the_code_the_agent_runs() { authorization: AgentAuthorization::Transport, exec_identity: test_identity(), output_cap: 1 << 20, + jobs: JobRegistry::new(), }); let listener = TcpListener::bind::("127.0.0.1:0".parse().expect("literal")) @@ -508,3 +522,387 @@ async fn the_lifecycle_hooks_answer_without_a_capability() { ); } } + +// --- The GCP Agent Platform envelope on `POST /` --- +// +// One route carries every operation, selected by `op`, because the platform's `:execute` proxies +// `POST /` with the body verbatim and can set neither path nor method. These prove the envelope's +// output is the same bytes the versioned route produces, and that a bad envelope is refused with a +// typed error rather than defaulted onto some operation. + +/// Captures what a response is on the wire: the three things the envelope must reproduce exactly. +async fn wire(response: reqwest::Response) -> (u16, Option, String) { + let status = response.status().as_u16(); + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let body = response.text().await.expect("body"); + (status, content_type, body) +} + +/// A single write to one stream forces a deterministic frame sequence — one `stdout` at `seq: 0` +/// then `exit` — so the two NDJSON bodies are comparable byte for byte. A command writing to both +/// streams would interleave nondeterministically and its `seq` would differ per run. +#[tokio::test] +async fn exec_through_the_envelope_is_byte_identical_to_v1() { + let agent = Agent::start().await; + let client = reqwest::Client::new(); + + let versioned = client + .post(format!("{}/v1/exec", agent.base_url)) + .bearer_auth(agent.capability()) + .json(&json!({"command": ["/bin/echo", "hello"], "deadlineMs": 10_000})) + .send() + .await + .expect("responds"); + let enveloped = client + .post(format!("{}/", agent.base_url)) + .bearer_auth(agent.capability()) + .json(&json!({"v": 1, "op": "exec", "command": ["/bin/echo", "hello"], "deadlineMs": 10_000})) + .send() + .await + .expect("responds"); + + let versioned = wire(versioned).await; + let enveloped = wire(enveloped).await; + assert_eq!(versioned.1.as_deref(), Some("application/x-ndjson")); + assert_eq!( + versioned, enveloped, + "the envelope must reproduce the versioned route's stream exactly" + ); +} + +#[tokio::test] +async fn read_file_through_the_envelope_is_byte_identical_to_v1() { + let agent = Agent::start().await; + let client = reqwest::Client::new(); + + client + .put(format!("{}/v1/files", agent.base_url)) + .bearer_auth(agent.capability()) + .json(&json!({"path": "/work/main.py", "contentsBase64": BASE64.encode("print(1)")})) + .send() + .await + .expect("responds"); + + let versioned = client + .get(format!("{}/v1/files?path=/work/main.py", agent.base_url)) + .bearer_auth(agent.capability()) + .send() + .await + .expect("responds"); + let enveloped = client + .post(format!("{}/", agent.base_url)) + .bearer_auth(agent.capability()) + .json(&json!({"v": 1, "op": "readFile", "path": "/work/main.py"})) + .send() + .await + .expect("responds"); + + assert_eq!(wire(versioned).await, wire(enveloped).await); +} + +#[tokio::test] +async fn write_file_through_the_envelope_is_byte_identical_to_v1_and_lands() { + let agent = Agent::start().await; + let client = reqwest::Client::new(); + + let versioned = client + .put(format!("{}/v1/files", agent.base_url)) + .bearer_auth(agent.capability()) + .json(&json!({"path": "/work/v1.txt", "contentsBase64": BASE64.encode("x")})) + .send() + .await + .expect("responds"); + let enveloped = client + .post(format!("{}/", agent.base_url)) + .bearer_auth(agent.capability()) + .json(&json!({"v": 1, "op": "writeFile", "path": "/work/env.txt", "contentsBase64": BASE64.encode("x")})) + .send() + .await + .expect("responds"); + + assert_eq!(wire(versioned).await, wire(enveloped).await); + assert_eq!( + std::fs::read(agent.root.join("work/env.txt")).expect("the envelope write landed"), + b"x" + ); +} + +#[tokio::test] +async fn mkdir_through_the_envelope_is_byte_identical_to_v1_and_lands() { + let agent = Agent::start().await; + let client = reqwest::Client::new(); + + let versioned = client + .post(format!("{}/v1/mkdir", agent.base_url)) + .bearer_auth(agent.capability()) + .json(&json!({"path": "/work/v1"})) + .send() + .await + .expect("responds"); + let enveloped = client + .post(format!("{}/", agent.base_url)) + .bearer_auth(agent.capability()) + .json(&json!({"v": 1, "op": "mkdir", "path": "/work/env"})) + .send() + .await + .expect("responds"); + + assert_eq!(wire(versioned).await, wire(enveloped).await); + assert!(agent.root.join("work/env").is_dir(), "the envelope mkdir landed"); +} + +/// `health` carries no capability on either route, so the envelope arm must reach it without one — +/// the version it would assert is already the envelope's own `v`. +#[tokio::test] +async fn health_through_the_envelope_is_byte_identical_to_v1() { + let agent = Agent::start().await; + + let versioned = reqwest::get(format!("{}/v1/health", agent.base_url)) + .await + .expect("responds"); + let enveloped = reqwest::Client::new() + .post(format!("{}/", agent.base_url)) + .json(&json!({"v": 1, "op": "health"})) + .send() + .await + .expect("responds"); + + assert_eq!(wire(versioned).await, wire(enveloped).await); +} + +/// An absent `op` is refused, not defaulted onto an operation. Refused before authorization, so no +/// capability is needed to reach the check. +#[tokio::test] +async fn an_absent_op_is_refused() { + let agent = Agent::start().await; + + let response = reqwest::Client::new() + .post(format!("{}/", agent.base_url)) + .json(&json!({"v": 1})) + .send() + .await + .expect("responds"); + + assert_eq!(response.status(), 400); + assert!( + response.text().await.expect("body").contains("must name an 'op'"), + "the refusal must say an op is required" + ); +} + +/// An unknown `op` is refused rather than silently mapped to some operation. +#[tokio::test] +async fn an_unknown_op_is_refused() { + let agent = Agent::start().await; + + let response = reqwest::Client::new() + .post(format!("{}/", agent.base_url)) + .json(&json!({"v": 1, "op": "frobnicate"})) + .send() + .await + .expect("responds"); + + assert_eq!(response.status(), 400); + assert!( + response.text().await.expect("body").contains("frobnicate"), + "the refusal must name the unknown op" + ); +} + +/// The agent outlives the image that built it, so a version it does not implement is a named +/// refusal — not a request it half-understands. +#[tokio::test] +async fn an_unsupported_version_is_refused() { + let agent = Agent::start().await; + + let response = reqwest::Client::new() + .post(format!("{}/", agent.base_url)) + .json(&json!({"v": PROTOCOL_VERSION + 1, "op": "health"})) + .send() + .await + .expect("responds"); + + assert_eq!(response.status(), 400); + let body = response.text().await.expect("body"); + assert!( + body.contains(&format!("v{}", PROTOCOL_VERSION + 1)) && body.contains(&format!("v{PROTOCOL_VERSION}")), + "the error must name both versions: {body}" + ); +} + +/// The envelope must route through `peer.rs`, not around it: under transport authorization the code +/// the agent itself runs shares the guest's network stack and reaches this port, and it must be +/// refused there exactly as it is on `/v1/mkdir`. Running the agent with this process as its exec +/// identity is what that in-guest caller looks like from the inside. +/// +/// Linux-only because the socket's owner is read from `/proc/net/tcp`. +#[tokio::test] +#[cfg(target_os = "linux")] +async fn the_envelope_refuses_the_code_the_agent_runs_under_transport() { + let dir = TempDir::new().expect("temp dir"); + let root = dir.path().canonicalize().expect("canonical root"); + + let state = Arc::new(AgentState { + session_root: root.clone(), + authorization: AgentAuthorization::Transport, + exec_identity: test_identity(), + output_cap: 1 << 20, + jobs: JobRegistry::new(), + }); + + let listener = TcpListener::bind::("127.0.0.1:0".parse().expect("literal")) + .await + .expect("bind loopback"); + let address = listener.local_addr().expect("address"); + tokio::spawn(async move { + axum::serve( + listener, + router(state).into_make_service_with_connect_info::(), + ) + .await + .expect("serve"); + }); + + let response = reqwest::Client::new() + .post(format!("http://{address}/")) + .json(&json!({"v": 1, "op": "mkdir", "path": "/work"})) + .send() + .await + .expect("responds"); + + assert_eq!(response.status(), 403, "the envelope must not serve the agent's own supervised code"); + assert!( + !root.join("work").exists(), + "a refused envelope must not have done its work anyway" + ); +} + +// --- Detached jobs: start, poll for output across calls, cancel --- +// +// A command longer than one proxied call can stay open runs as a job. These prove the endpoints +// are wired to the same authorization and framing the streaming path uses, and that the +// synchronous path is left untouched. + +/// A job runs to completion and its output is collected across polls, exactly as a provider that +/// cannot hold one long call open would have to read it. +#[tokio::test] +async fn a_job_completes_and_its_output_is_polled_across_calls() { + let agent = Agent::start().await; + let client = reqwest::Client::new(); + + let started = client + .post(format!("{}/", agent.base_url)) + .bearer_auth(agent.capability()) + .json(&json!({ + "v": 1, + "op": "jobStart", + "command": ["/bin/sh", "-c", "echo one; echo two"], + "deadlineMs": 10_000 + })) + .send() + .await + .expect("responds"); + assert_eq!(started.status(), 200); + let job_id = started.json::().await.expect("json")["jobId"] + .as_str() + .expect("a job id") + .to_string(); + + let mut collected = Vec::new(); + let mut since: Option = None; + let mut running = true; + for _ in 0..200 { + let body = client + .post(format!("{}/", agent.base_url)) + .bearer_auth(agent.capability()) + .json(&json!({"v": 1, "op": "jobPoll", "jobId": job_id, "sinceSeq": since})) + .send() + .await + .expect("responds") + .json::() + .await + .expect("json"); + + for frame in body["frames"].as_array().expect("frames array") { + if let Some(seq) = frame["seq"].as_u64() { + since = Some(since.map_or(seq, |s| s.max(seq))); + } + if let Some(data) = frame["data"].as_str() { + collected.push( + String::from_utf8(BASE64.decode(data).expect("base64")).expect("utf8"), + ); + } + } + + if !body["running"].as_bool().expect("running is a bool") { + running = false; + assert_eq!(body["exitCode"], 0, "the job exited cleanly"); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + + assert!(!running, "the job must reach a terminal state within the poll budget"); + let text: Vec<&str> = collected.iter().flat_map(|line| line.split_whitespace()).collect(); + assert_eq!(text, vec!["one", "two"], "every line survives being polled"); +} + +/// The synchronous path is left as it was: an ordinary command over `/v1/exec` runs and returns +/// without ever creating a job. The provider decides when to reach for a job; `exec` never does. +#[tokio::test] +async fn a_command_over_exec_creates_no_job() { + let agent = Agent::start().await; + + let response = reqwest::Client::new() + .post(format!("{}/v1/exec", agent.base_url)) + .bearer_auth(agent.capability()) + .json(&json!({"command": ["/bin/echo", "hi"], "deadlineMs": 10_000})) + .send() + .await + .expect("responds"); + assert_eq!(response.status(), 200); + let _ = response.text().await.expect("body"); + + assert!( + agent.state.jobs.is_empty(), + "the synchronous exec path must not create a job" + ); +} + +/// A poll for a job the session never held is a typed 404, not an empty running job the caller +/// would wait on forever. +#[tokio::test] +async fn a_poll_for_an_unknown_job_is_refused() { + let agent = Agent::start().await; + + let response = reqwest::Client::new() + .post(format!("{}/", agent.base_url)) + .bearer_auth(agent.capability()) + .json(&json!({"v": 1, "op": "jobPoll", "jobId": "nonexistent", "sinceSeq": null})) + .send() + .await + .expect("responds"); + + assert_eq!(response.status(), 404); +} + +/// The job endpoints refuse a request that carries no capability, like every other operation that +/// can reach session contents. +#[tokio::test] +async fn starting_a_job_without_a_capability_is_refused() { + let agent = Agent::start().await; + + let response = reqwest::Client::new() + .post(format!("{}/v1/jobs/start", agent.base_url)) + .json(&json!({"command": ["/bin/echo", "hi"], "deadlineMs": 10_000})) + .send() + .await + .expect("responds"); + + assert_eq!(response.status(), 401); + assert!(agent.state.jobs.is_empty(), "a refused start creates no job"); +} diff --git a/crates/alien-terraform/src/built_ins.rs b/crates/alien-terraform/src/built_ins.rs index 0a775a817..1b25fd9ca 100644 --- a/crates/alien-terraform/src/built_ins.rs +++ b/crates/alien-terraform/src/built_ins.rs @@ -89,7 +89,7 @@ fn register_gcp(registry: &mut TfRegistry) { ); registry.register(Build::RESOURCE_TYPE, p, gcp::GcpBuildEmitter); registry.register(Worker::RESOURCE_TYPE, p, gcp::GcpWorkerEmitter); - registry.register(Sandbox::RESOURCE_TYPE, p, gcp::GcpSandboxEmitter); + registry.register(Sandbox::RESOURCE_TYPE, p, gcp::GcpAgentPlatformSandboxEmitter); registry.register( ServiceActivation::RESOURCE_TYPE, p, diff --git a/crates/alien-terraform/src/emitters/gcp/mod.rs b/crates/alien-terraform/src/emitters/gcp/mod.rs index ab2ab7ee4..6e7164230 100644 --- a/crates/alien-terraform/src/emitters/gcp/mod.rs +++ b/crates/alien-terraform/src/emitters/gcp/mod.rs @@ -31,7 +31,7 @@ pub use network::GcpNetworkEmitter; pub use queue::GcpQueueEmitter; pub use remote_bindings::GcpRemoteBindingsEmitter; pub use remote_stack_management::GcpRemoteStackManagementEmitter; -pub use sandbox::GcpSandboxEmitter; +pub use sandbox::GcpAgentPlatformSandboxEmitter; pub use service_account::GcpServiceAccountEmitter; pub use service_activation::GcpServiceActivationEmitter; pub use storage::GcpStorageEmitter; diff --git a/crates/alien-terraform/src/emitters/gcp/sandbox.rs b/crates/alien-terraform/src/emitters/gcp/sandbox.rs index 4c31601bb..5b4866f9e 100644 --- a/crates/alien-terraform/src/emitters/gcp/sandbox.rs +++ b/crates/alien-terraform/src/emitters/gcp/sandbox.rs @@ -1,10 +1,7 @@ -//! GCP Sandbox — nothing built, because Cloud Run already ships the launcher. +//! GCP Agent Platform sandbox emitter. //! -//! A Cloud Run sandbox is a nested gVisor sandbox started by a binary Cloud Run injects into the -//! container when it carries `sandboxLauncher`, which the `gcp_sandbox_launcher` preflight sets on -//! the worker hosting the sandbox. There is no control plane to provision, no group to name and no -//! endpoint to hand over: setup's whole contribution is telling the runtime where the binary is -//! and whether sandboxes may reach the network. +//! Emits the Agent Platform sandbox binding — the engine and template resource-name shapes the runtime provider reads +//! — and refuses domain-scoped egress, which the single internet-access switch cannot express. use crate::{ emitter::{TfEmitter, TfFragment}, @@ -15,114 +12,224 @@ use alien_core::{import::EmitContext, ErrorData, Result, Sandbox, SandboxEgress} use alien_error::AlienError; use hcl::expr::Expression; -/// Refuses an egress mode the launcher cannot deliver. +/// Serde `service` tag of the `GcpAgentPlatformSandboxBinding`, and the resource-name shapes +/// the engine and template are addressed by. Kept together so the binding this emits is the one +/// the provider deserializes. +const AGENT_PLATFORM_SERVICE: &str = "sandbox-gcp-agent-platform"; + +/// Refuses domain-scoped egress, which Agent Platform's single internet-access switch cannot carry. /// -/// `--allow-egress` is a switch, so a hostname list has nowhere to go and would otherwise be -/// carried as its nearest boolean — denying everything the declaration asked to permit, with -/// nothing anywhere saying so. -fn refuse_unsupported_egress(sandbox: &Sandbox) -> Result<()> { - match &sandbox.egress { - SandboxEgress::Deny | SandboxEgress::Allow => Ok(()), - SandboxEgress::AllowDomains { .. } => Err(AlienError::new(ErrorData::OperationNotSupported { - operation: format!("terraform emit sandbox '{}'", sandbox.id()), - reason: "the Cloud Run sandbox launcher takes a single egress switch, so a hostname \ - list has nothing to render into. Declare egress: deny or egress: allow" - .to_string(), - })), +/// The switch semantics live in `SandboxEgress::internet_access_switch`, so this and the provider's +/// template mapping cannot drift on which modes are expressible. Names the sandbox and both +/// accepted modes. +fn refuse_domain_egress(sandbox: &Sandbox) -> Result<()> { + if sandbox.egress.internet_access_switch().is_some() { + return Ok(()); } + Err(AlienError::new(ErrorData::OperationNotSupported { + operation: format!("terraform emit sandbox '{}'", sandbox.id()), + reason: "Agent Platform egress is a single internet-access switch, so a hostname list has \ + nothing to render into. Declare egress: deny or egress: allow" + .to_string(), + })) } -/// Where Cloud Run mounts the sandbox CLI inside a launcher-enabled container. -const LAUNCHER_PATH: &str = "/usr/local/gcp/bin/sandbox"; +/// The engine, template, region and ttl fields shared by the import ref and the binding ref. +/// +/// `engine` and `template` carry runtime-assigned ids addressed by a resource-name convention over +/// the setup label rather than a Terraform resource attribute; the Live path takes the real names +/// from the controller's binding params. `sessionTtlSeconds` is present only when the declaration +/// set a lifetime, matching the binding's `skip_serializing_if`. +fn agent_platform_fields(sandbox: &Sandbox, label: &str) -> Vec<(&'static str, Expression)> { + let mut fields = vec![ + ( + "engine", + expr::template(format!( + "projects/${{var.gcp_project}}/locations/${{var.gcp_region}}/reasoningEngines/{label}" + )), + ), + ( + "template", + expr::template(format!( + "projects/${{var.gcp_project}}/locations/${{var.gcp_region}}/reasoningEngines/{label}/sandboxEnvironmentTemplates/{label}" + )), + ), + ("region", expr::raw("var.gcp_region")), + ]; + if let Some(seconds) = sandbox.session.max_lifetime_seconds { + fields.push(( + "sessionTtlSeconds", + Expression::Number(hcl::Number::from(seconds as i64)), + )); + } + fields +} -/// Emits the launcher's location; Cloud Run provides everything else. +/// Emits the GCP Agent Platform sandbox binding: the durable Agent Engine, the release-owned +/// template, the region and the session ttl. +/// +/// The engine is a Live resource with its own controller and no Terraform analogue — Vertex +/// exposes no `google_…reasoning_engine` — so `emit` is empty as in `gcp/ai.rs` and identity +/// travels in the binding, not a resource block. #[derive(Debug, Clone, Copy, Default)] -pub struct GcpSandboxEmitter; +pub struct GcpAgentPlatformSandboxEmitter; -impl TfEmitter for GcpSandboxEmitter { +impl TfEmitter for GcpAgentPlatformSandboxEmitter { fn emit(&self, _ctx: &EmitContext<'_>) -> Result { - // Deliberately empty: see the module note. The launcher arrives with the container. + // The engine and template are created by Live controllers after apply and carry + // runtime-assigned names, so neither is a Terraform resource block. Ok(TfFragment::default()) } fn emit_import_ref(&self, ctx: &EmitContext<'_>) -> Result { - let _ = required_label(ctx)?; + let label = required_label(ctx)?; let sandbox = downcast::(ctx, Sandbox::RESOURCE_TYPE)?; - refuse_unsupported_egress(sandbox)?; - Ok(expr::object([ - ( - "launcherPath", - Expression::String(LAUNCHER_PATH.to_string()), - ), - ( - "allowEgress", - Expression::Bool(matches!(sandbox.egress, SandboxEgress::Allow)), - ), - ])) + refuse_domain_egress(sandbox)?; + Ok(expr::object(agent_platform_fields(sandbox, label))) } fn emit_binding_ref(&self, ctx: &EmitContext<'_>) -> Result> { + let label = required_label(ctx)?; let sandbox = downcast::(ctx, Sandbox::RESOURCE_TYPE)?; - let _ = required_label(ctx)?; - refuse_unsupported_egress(sandbox)?; - Ok(Some(expr::object([ - ("service", Expression::String("sandbox-gcp".to_string())), - ( - "launcherPath", - Expression::String(LAUNCHER_PATH.to_string()), - ), - // Carried in the binding rather than passed per create: the launcher takes - // `--allow-egress` per sandbox, and a limit the application supplies is one it can - // decline to supply. - ( - "allowEgress", - Expression::Bool(matches!(sandbox.egress, SandboxEgress::Allow)), - ), - ]))) + refuse_domain_egress(sandbox)?; + let mut fields = agent_platform_fields(sandbox, label); + fields.push(( + "service", + Expression::String(AGENT_PLATFORM_SERVICE.to_string()), + )); + Ok(Some(expr::object(fields))) } } #[cfg(test)] mod tests { - use super::*; - use alien_core::{SandboxCode, SandboxSessionPolicy}; - - fn sandbox_with(egress: SandboxEgress) -> Sandbox { - Sandbox::new("agents".to_string()) - .code(SandboxCode::Image { - image: "ubuntu".to_string(), - }) - .egress(egress) - .session(SandboxSessionPolicy { - max_lifetime_seconds: None, - idle_suspend_seconds: None, - }) - .build() - } + mod agent_platform { + use super::super::*; + use alien_core::bindings::SandboxBinding; + use alien_core::{ResourceLifecycle, SandboxCode, SandboxSessionPolicy, Stack, StackSettings}; + use indexmap::IndexMap; + use std::collections::BTreeSet; + + fn emit_binding(egress: SandboxEgress, ttl: Option) -> Result> { + let stack = Stack::new("acme".to_string()) + .add( + Sandbox::new("agents".to_string()) + .code(SandboxCode::Image { + image: "ubuntu".to_string(), + }) + .egress(egress) + .session(SandboxSessionPolicy { + max_lifetime_seconds: ttl, + idle_suspend_seconds: None, + }) + .build(), + ResourceLifecycle::Frozen, + ) + .build(); + let resource = stack.resources.get("agents").expect("the sandbox is in the stack"); + let names = IndexMap::from([("agents".to_string(), "agents".to_string())]); + let settings = StackSettings::default(); + let ctx = EmitContext { + stack: &stack, + resource, + resource_id: "agents", + platform: alien_core::Platform::Gcp, + targets_kubernetes: false, + stack_settings: &settings, + names: &names, + }; + GcpAgentPlatformSandboxEmitter.emit_binding_ref(&ctx) + } + + fn object_keys(expr: &Expression) -> BTreeSet { + match expr { + Expression::Object(map) => map + .keys() + .map(|key| match key { + hcl::expr::ObjectKey::Identifier(id) => id.as_str().to_string(), + hcl::expr::ObjectKey::Expression(Expression::String(s)) => s.clone(), + other => panic!("unexpected object key: {other:?}"), + }) + .collect(), + other => panic!("expected an object, got {other:?}"), + } + } + + /// The emitted keys are read against the binding type, not a second hand-typed list, so + /// a rename on either side fails here rather than reaching a customer's cluster. The ttl is + /// set on both sides so the key sets are comparable whole. + #[test] + fn emitted_binding_keys_match_the_binding_type() { + let emitted = emit_binding(SandboxEgress::Allow, Some(3600)) + .expect("the binding renders") + .expect("an Agent Platform sandbox has a binding"); + + let type_json = serde_json::to_value(SandboxBinding::gcp_agent_platform( + "e", + "t", + "us-central1", + Some(3600), + )) + .expect("the binding type serializes"); + let type_keys: BTreeSet = type_json + .as_object() + .expect("the binding serializes as an object") + .keys() + .cloned() + .collect(); + + assert_eq!( + object_keys(&emitted), + type_keys, + "emitted keys must track the binding type" + ); + } + + /// A hostname list has no representation in the single internet-access switch, so it is + /// refused naming the sandbox and both accepted modes — not approximated to a boolean. + #[test] + fn domain_egress_is_refused_naming_the_sandbox_and_modes() { + let error = emit_binding( + SandboxEgress::AllowDomains { + domains: vec!["api.example.com".to_string()], + }, + None, + ) + .expect_err("a hostname list has nothing to render into on Agent Platform"); + + assert_eq!(error.code, "OPERATION_NOT_SUPPORTED", "{error}"); + let rendered = error.to_string(); + assert!(rendered.contains("agents"), "names the sandbox: {rendered}"); + assert!( + rendered.contains("allow") && rendered.contains("deny"), + "names both accepted modes: {rendered}" + ); + + for accepted in [SandboxEgress::Deny, SandboxEgress::Allow] { + emit_binding(accepted.clone(), None) + .unwrap_or_else(|error| panic!("{accepted:?} is a switch position: {error}")); + } + } + + /// A declared lifetime reaches the binding; an absent one is omitted, matching the binding's + /// `skip_serializing_if` so the two never disagree on whether the key is present. + #[test] + fn session_ttl_is_present_only_when_declared() { + let with_ttl = emit_binding(SandboxEgress::Deny, Some(1800)) + .expect("renders") + .expect("binding"); + assert!( + object_keys(&with_ttl).contains("sessionTtlSeconds"), + "a declared lifetime reaches the binding" + ); - /// A hostname list is refused rather than carried as its nearest boolean. - /// - /// `--allow-egress` is a switch: rendering the list as `true` or `false` opens or denies - /// addresses the declaration did not say to. Neither is the declaration, so neither is emitted. - /// - /// The second gate, not the first — a customer meets `domainEgressRules` at plan time. This - /// one covers the paths that render without planning. - #[test] - fn a_hostname_allowlist_is_refused_rather_than_approximated() { - let error = refuse_unsupported_egress(&sandbox_with(SandboxEgress::AllowDomains { - domains: vec!["api.example.com".to_string()], - })) - .expect_err("a hostname list has nothing to render into on Cloud Run"); - - assert_eq!(error.code, "OPERATION_NOT_SUPPORTED", "{error}"); - assert!( - error.to_string().contains("agents"), - "the refusal has to name the sandbox: {error}" - ); - - for accepted in [SandboxEgress::Deny, SandboxEgress::Allow] { - refuse_unsupported_egress(&sandbox_with(accepted.clone())) - .unwrap_or_else(|error| panic!("{accepted:?} is a switch position: {error}")); + let without = emit_binding(SandboxEgress::Deny, None) + .expect("renders") + .expect("binding"); + assert!( + !object_keys(&without).contains("sessionTtlSeconds"), + "an undeclared lifetime is absent from the binding" + ); } } } diff --git a/crates/alien-test/tests/gcp_agent_platform_sandbox_live.rs b/crates/alien-test/tests/gcp_agent_platform_sandbox_live.rs new file mode 100644 index 000000000..836a74e94 --- /dev/null +++ b/crates/alien-test/tests/gcp_agent_platform_sandbox_live.rs @@ -0,0 +1,863 @@ +//! The GCP Agent Platform sandbox backend, driven against a real project. +//! +//! Every test here is `#[ignore]`d because it provisions real reasoning engines, templates, and +//! sandboxes and needs GCP credentials. **CI does not run `--ignored`, so none of these run in +//! CI** — they are the manual live gate that a mocked test cannot stand in for: a mock can only +//! confirm the request we chose to send, never that the real API accepts it or that a reconnect +//! actually reaches the same container. +//! +//! The Agent Platform emitter and controller are not yet wired into the provider factory, so a +//! live test cannot go through a deployed stack. It drives the client and provider directly, as +//! the proof-of-concept scripts did: create an engine, create a template from a prebuilt agent +//! image, then exercise the `Sandbox` trait against sandboxes cut from it. That means these tests +//! prove the runtime path, not the controller's template-body mapping — the inline template body +//! below mirrors the controller's `build_template_body` so it at least proves the real API accepts +//! that shape. +//! +//! Run the full suite (single-threaded, because sandbox quota is pooled per project + location): +//! +//! ```text +//! GOOGLE_TARGET_PROJECT_ID=... \ +//! GOOGLE_TARGET_REGION=us-central1 \ +//! GOOGLE_TARGET_SERVICE_ACCOUNT_KEY="$(cat key.json)" \ +//! ALIEN_TEST_GCP_AGENT_IMAGE=-docker.pkg.dev///agent: \ +//! ALIEN_TEST_GIT_TOKEN= \ +//! ALIEN_TEST_PRIVATE_REPO=/ \ +//! cargo test -p alien-test --test gcp_agent_platform_sandbox_live -- --ignored --test-threads=1 +//! ``` +//! +//! The agent image must be a prebuilt `alien-sandbox-agent` image in a registry the project can +//! pull, with `git` on its PATH for the clone tests. Teardown deletes the engine on every exit +//! path including a panic, which cascades its templates and sandboxes; `sweep_orphaned_engines` +//! reaps engines that a hard-killed run recorded but could not delete. An engine killed in the +//! window between create resolving and being recorded cannot be swept without an engine-list verb, +//! which this backend does not expose. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use futures::StreamExt; + +use alien_bindings::providers::sandbox::gcp_agent_platform::GcpAgentPlatformSandbox; +use alien_bindings::traits::{ + CommandOutput, CreateSessionRequest, RunCommandRequest, Sandbox, SandboxSessionState, +}; +use alien_core::{GcpClientConfig, GcpCredentials}; +use alien_gcp_clients::gcp::agent_platform::{ + AgentPlatformApi, AgentPlatformClient, ContainerResources, CustomContainerEnvironment, + CustomContainerSpec, EgressControlConfig, PollBudget, ReasoningEngine, SandboxCreateRequest, + SandboxEnvironment, SandboxEnvironmentTemplate, +}; + +// ---- Configuration and clients ---------------------------------------------------------------- + +const HANDOFF_ENV: &str = "ALIEN_SANDBOX_LIVE_RECONNECT"; +/// Display-name prefix on every engine and template this suite creates, so the sweep can find an +/// orphan the scratch log never recorded. +const LIVE_PREFIX: &str = "alien-sbx-live-"; +const TTL_SECONDS: u32 = 3600; + +/// The credentials a client needs, from the same `GOOGLE_TARGET_*` variables the rest of the E2E +/// harness uses. The agent image is read separately by [`agent_image`] so a process that only +/// reconnects — the reconnect child — does not have to supply an image it never provisions from. +struct LiveConfig { + project_id: String, + region: String, + credentials_json: String, +} + +fn require_env(key: &str) -> String { + std::env::var(key) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| panic!("{key} must be set to run this live test; see the module docs")) +} + +/// The prebuilt agent image a template is cut from. Separate from [`LiveConfig`] because only the +/// provisioning tests need it. +fn agent_image() -> String { + require_env("ALIEN_TEST_GCP_AGENT_IMAGE") +} + +impl LiveConfig { + /// Fails loudly on a missing variable rather than skipping: a live test that quietly passes + /// with nothing set is the false PASS this suite exists to rule out. + fn from_env() -> Self { + LiveConfig { + project_id: require_env("GOOGLE_TARGET_PROJECT_ID"), + region: require_env("GOOGLE_TARGET_REGION"), + credentials_json: require_env("GOOGLE_TARGET_SERVICE_ACCOUNT_KEY"), + } + } + + fn client(&self) -> Arc { + let config = GcpClientConfig { + project_id: self.project_id.clone(), + region: self.region.clone(), + credentials: GcpCredentials::ServiceAccountKey { + json: self.credentials_json.clone(), + }, + service_overrides: None, + project_number: None, + }; + // A per-request timeout, comfortably above the ~30s :execute proxy window: without one a + // single stalled request hangs the whole test forever, since the poll budget bounds the + // loop but not one call. + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(90)) + .build() + .expect("a client with a request timeout builds"); + Arc::new(AgentPlatformClient::new(http, config)) + } +} + +/// Generous against real provisioning: the POC measured ~113s to a template reaching `ACTIVE`. +fn budget() -> PollBudget { + PollBudget { + interval: Duration::from_secs(2), + max_attempts: 150, + } +} + +fn last_segment(name: &str) -> &str { + name.rsplit('/').next().unwrap_or(name) +} + +// ---- Provisioning and teardown ---------------------------------------------------------------- + +/// Deletes the engine on every exit path, panic included, so an assertion failure does not leak a +/// running engine. The delete runs on a throwaway thread with its own runtime because `Drop` is +/// synchronous; deleting the engine cascades its templates and sandboxes, and a not-found is +/// already success in the client. +struct EngineGuard { + client: Arc, + engine: String, +} + +impl Drop for EngineGuard { + fn drop(&mut self) { + let client = self.client.clone(); + let engine = last_segment(&self.engine).to_string(); + let _ = std::thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().expect("teardown runtime builds"); + runtime.block_on(async move { + // An engine will not delete while it still has child sandboxes, and a panicked test + // leaves its session behind. Reap the sandboxes and retry: delete_sandbox only + // starts the removal, so the engine delete has to wait for them to be gone. + for _ in 0..6 { + if let Ok(sandboxes) = client.list_sandboxes(&engine).await { + for sandbox in &sandboxes { + if let Some(name) = sandbox.name.as_deref() { + let _ = client.delete_sandbox(&engine, last_segment(name)).await; + } + } + } + tokio::time::sleep(Duration::from_secs(5)).await; + if client.delete_engine(&engine).await.is_ok() { + return; + } + } + eprintln!("teardown: could not delete engine {engine} after reaping its sandboxes"); + }); + }) + .join(); + } +} + +async fn provision_engine(client: &Arc) -> String { + let display = format!("{LIVE_PREFIX}{}", uuid::Uuid::new_v4().simple()); + let operation = client + .create_engine(&display) + .await + .expect("engine create accepted"); + let engine: ReasoningEngine = client + .await_operation(&operation, budget()) + .await + .expect("engine create operation resolves"); + engine + .name + .expect("a created engine carries a resource name") +} + +/// Builds the immutable template body, mirroring the controller's `build_template_body` shape so a +/// live run proves the real API accepts the same body the controller would send. +fn template_body(image: &str, internet_access: bool) -> SandboxEnvironmentTemplate { + SandboxEnvironmentTemplate { + name: None, + display_name: Some(format!("{LIVE_PREFIX}{}", uuid::Uuid::new_v4().simple())), + custom_container_environment: Some(CustomContainerEnvironment { + custom_container_spec: Some(CustomContainerSpec { + image_uri: image.to_string(), + extra: Default::default(), + }), + resources: Some(ContainerResources { + requests: None, + limits: Some(HashMap::from([ + ("cpu".to_string(), "2".to_string()), + ("memory".to_string(), "4Gi".to_string()), + ])), + }), + ports: vec![], + extra: Default::default(), + }), + egress_control_config: Some(EgressControlConfig { + internet_access: Some(internet_access), + extra: Default::default(), + }), + state: None, + extra: Default::default(), + } +} + +/// Creates a template under `engine` and waits for it to reach `ACTIVE`, returning its full name. +async fn provision_template( + client: &Arc, + engine: &str, + image: &str, + internet_access: bool, +) -> String { + let engine_seg = last_segment(engine); + let operation = client + .create_template(engine_seg, template_body(image, internet_access)) + .await + .expect("template create accepted"); + let created: SandboxEnvironmentTemplate = client + .await_operation(&operation, budget()) + .await + .expect("template create operation resolves"); + let name = created + .name + .expect("a created template carries a resource name"); + client + .await_template_active(engine_seg, last_segment(&name), budget()) + .await + .expect("the template reaches ACTIVE"); + name +} + +fn provider( + client: &Arc, + engine: &str, + template: &str, +) -> GcpAgentPlatformSandbox { + GcpAgentPlatformSandbox::new( + client.clone(), + engine.to_string(), + template.to_string(), + Some(TTL_SECONDS), + ) +} + +// ---- Command helpers -------------------------------------------------------------------------- + +struct CommandResult { + stdout: Vec, + stderr: Vec, + exit_code: i32, +} + +/// Drives a command to its exit, collecting the decoded streams. Asserting on this — never on the +/// transport envelope — is what keeps an empty probe from reading as a pass. +async fn run( + provider: &GcpAgentPlatformSandbox, + session: &str, + argv: &[&str], + env: BTreeMap, + deadline: Duration, +) -> CommandResult { + let mut stream = provider + .run_command( + session, + RunCommandRequest { + command: argv.iter().map(|arg| arg.to_string()).collect(), + working_directory: None, + env, + deadline, + }, + ) + .await + .expect("the command starts"); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut exit_code = None; + while let Some(frame) = stream.next().await { + match frame.expect("a command frame decodes") { + CommandOutput::Stdout { data, .. } => stdout.extend_from_slice(&data), + CommandOutput::Stderr { data, .. } => stderr.extend_from_slice(&data), + CommandOutput::Exit { code, .. } => exit_code = Some(code), + } + } + + CommandResult { + stdout, + stderr, + exit_code: exit_code.expect("exactly one terminal exit frame arrives"), + } +} + +async fn shell(provider: &GcpAgentPlatformSandbox, session: &str, script: &str) -> CommandResult { + run( + provider, + session, + &["/bin/sh", "-lc", script], + BTreeMap::new(), + Duration::from_secs(20), + ) + .await +} + +async fn wait_until_running(provider: &GcpAgentPlatformSandbox, session: &str) -> u64 { + for _ in 0..60 { + if let Some(found) = provider.get(session).await.expect("get answers") { + if found.state == SandboxSessionState::Running { + return found.generation; + } + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + panic!("session {session} never reached Running"); +} + +// ---- The mandatory flow ----------------------------------------------------------------------- + +/// create → exec → reconnect from a second process → private clone → terminate. +/// +/// The one flow the POC left half-open. Every step asserts on decoded content; the reconnect step +/// is a genuinely separate process, because an in-process reconnect only proves the client agrees +/// with itself. +#[tokio::test] +#[ignore = "requires a real GCP project; see module docs"] +async fn create_exec_reconnect_private_clone_terminate() { + let config = LiveConfig::from_env(); + // Required before any provisioning, so the run fails on setup rather than after a live engine + // exists: the private clone is part of this flow, not an optional extra. + let git_token = require_env("ALIEN_TEST_GIT_TOKEN"); + let private_repo = require_env("ALIEN_TEST_PRIVATE_REPO"); + + let client = config.client(); + let engine = provision_engine(&client).await; + record_engine(&engine); + let _guard = EngineGuard { + client: client.clone(), + engine: engine.clone(), + }; + let template = provision_template(&client, &engine, &agent_image(), true).await; + let provider = provider(&client, &engine, &template); + + let session = provider + .create(CreateSessionRequest::default()) + .await + .expect("create reaches a running, agent-answering session"); + assert_eq!(session.state, SandboxSessionState::Running); + let sid = session.session_id.clone(); + + let marker = format!("alien-sbx-live-{}", uuid::Uuid::new_v4().simple()); + let wrote = shell( + &provider, + &sid, + &format!("mkdir -p /sandbox/session && printf %s '{marker}' > /sandbox/session/marker"), + ) + .await; + assert_eq!( + wrote.exit_code, 0, + "writing the marker succeeds: {:?}", + wrote.stderr + ); + let read_back = provider + .read_file(&sid, "/session/marker") + .await + .expect("the marker file reads back"); + assert_eq!( + read_back, + marker.as_bytes(), + "the write is visible to a read" + ); + + reconnect_from_a_second_process(&engine, &template, &sid, &marker, session.generation); + + // The private clone proves the token path specifically; a public clone would only prove the + // network path, so the token/repo are required rather than substituted. + let clone = run( + &provider, + &sid, + &[ + "/bin/sh", + "-lc", + "git clone --depth 1 \"https://x-access-token:${GIT_TOKEN}@github.com/${PRIVATE_REPO}.git\" /sandbox/priv >/sandbox/clone.log 2>&1; echo rc=$?", + ], + BTreeMap::from([ + ("GIT_TOKEN".to_string(), git_token), + ("PRIVATE_REPO".to_string(), private_repo), + ]), + Duration::from_secs(25), + ) + .await; + assert_eq!(clone.exit_code, 0, "the clone command runs"); + assert!( + String::from_utf8_lossy(&clone.stdout).contains("rc=0"), + "the private clone succeeds: {}", + String::from_utf8_lossy(&clone.stdout) + ); + let head = provider + .read_file(&sid, "/priv/.git/HEAD") + .await + .expect("the cloned repo has a git dir"); + assert!( + String::from_utf8_lossy(&head).contains("ref:"), + "the clone produced a real working tree" + ); + + provider + .terminate(&sid) + .await + .expect("terminate polls the session to gone"); + assert!( + provider.get(&sid).await.expect("get answers").is_none(), + "a terminated session is gone, not merely requested gone" + ); +} + +// ---- The two-process reconnect ---------------------------------------------------------------- + +/// Re-execs this test binary at [`reconnect_reader_child`], handing it only a file of resource +/// names — no shared memory. The child, a fresh process with a fresh client, must read the marker +/// and match the container generation, then write a proof file naming a nonce only this process +/// knows. The capability verdict lives here, in the proof check, which is why the child no-ops +/// harmlessly when run on its own. +fn reconnect_from_a_second_process( + engine: &str, + template: &str, + sid: &str, + marker: &str, + generation: u64, +) { + let dir = std::env::temp_dir(); + let nonce = uuid::Uuid::new_v4().simple().to_string(); + let proof_path = dir.join(format!("alien-sbx-live-proof-{nonce}")); + let handoff_path = dir.join(format!("alien-sbx-live-handoff-{nonce}")); + + let handoff = serde_json::json!({ + "engine": engine, + "template": template, + "sandbox": sid, + "marker": marker, + "generation": generation, + "nonce": nonce, + "proofPath": proof_path.to_string_lossy(), + }); + std::fs::write(&handoff_path, handoff.to_string()).expect("the handoff file writes"); + + let exe = std::env::current_exe().expect("the test binary path"); + let status = std::process::Command::new(exe) + .args([ + "--exact", + "reconnect_reader_child", + "--ignored", + "--nocapture", + ]) + .env(HANDOFF_ENV, &handoff_path) + .status() + .expect("the reader process starts"); + assert!( + status.success(), + "the reconnect reader process failed its own assertions" + ); + + let proof = std::fs::read_to_string(&proof_path).expect("the reader wrote a proof file"); + assert!( + proof.contains(&nonce) && proof.contains(marker), + "the reader proved it read the marker in this run, not a stale one: {proof}" + ); + + let _ = std::fs::remove_file(&handoff_path); + let _ = std::fs::remove_file(&proof_path); +} + +/// Process two. When [`HANDOFF_ENV`] is unset it is not the child — it no-ops, because the +/// reconnect verdict is owned by the parent's proof-file check, not by this test running alone. +#[tokio::test] +#[ignore = "the second process of the reconnect test; the parent launches it"] +async fn reconnect_reader_child() { + let Some(handoff_path) = std::env::var_os(HANDOFF_ENV) else { + eprintln!("{HANDOFF_ENV} unset; not the reconnect child, nothing to do"); + return; + }; + + let raw = std::fs::read_to_string(&handoff_path).expect("the handoff file reads"); + let handoff: serde_json::Value = serde_json::from_str(&raw).expect("the handoff is JSON"); + let engine = handoff["engine"].as_str().expect("engine name"); + let template = handoff["template"].as_str().expect("template name"); + let sid = handoff["sandbox"].as_str().expect("sandbox id"); + let marker = handoff["marker"].as_str().expect("marker"); + let generation = handoff["generation"].as_u64().expect("generation"); + let nonce = handoff["nonce"].as_str().expect("nonce"); + let proof_path = handoff["proofPath"].as_str().expect("proof path"); + + // A fresh client built from the environment, not handed across from process one. + let client = LiveConfig::from_env().client(); + let provider = provider(&client, engine, template); + + let session = provider + .get(sid) + .await + .expect("get answers") + .expect("the sandbox is still present for the second process"); + assert_eq!( + session.state, + SandboxSessionState::Running, + "the reconnected session is running" + ); + assert_eq!( + session.generation, generation, + "the same container answers process two — its generation matches process one's" + ); + + let seen = provider + .read_file(sid, "/session/marker") + .await + .expect("process two reads process one's file"); + assert_eq!( + seen, + marker.as_bytes(), + "process two sees the exact bytes process one wrote" + ); + + // Not just a read: a second process can still mutate the same filesystem. + let appended = shell( + &provider, + sid, + "printf ' second' >> /sandbox/session/marker && cat /sandbox/session/marker", + ) + .await; + assert_eq!( + appended.exit_code, 0, + "process two mutates the shared filesystem" + ); + assert!( + String::from_utf8_lossy(&appended.stdout).contains("second"), + "the mutation is visible" + ); + + // The proof the parent verifies: only a process that actually read the marker in this run can + // write both the nonce and the marker it read. + std::fs::write( + proof_path, + format!("{nonce}:{}", String::from_utf8_lossy(&seen)), + ) + .expect("the proof file writes"); +} + +// ---- The proxy cap ---------------------------------------------------------------------------- + +/// A command longer than the ~30s `:execute` proxy cap completes via the detached job path. +/// +/// This is the single biggest difference from AWS: one synchronous execute cannot carry the work, +/// so the provider must detach and poll. A mocked test cannot see the real cap. +#[tokio::test] +#[ignore = "requires a real GCP project; spends ~40s of wall-clock against the proxy cap"] +async fn a_command_past_the_proxy_cap_completes_detached() { + let config = LiveConfig::from_env(); + let client = config.client(); + let engine = provision_engine(&client).await; + record_engine(&engine); + let _guard = EngineGuard { + client: client.clone(), + engine: engine.clone(), + }; + let template = provision_template(&client, &engine, &agent_image(), true).await; + let provider = provider(&client, &engine, &template); + + let session = provider + .create(CreateSessionRequest::default()) + .await + .expect("create succeeds"); + let sid = session.session_id.clone(); + + // A deadline past the synchronous window forces the provider onto the detached path; the + // command sleeps well past the ~30s cap and must still report its output and exit. + let result = run( + &provider, + &sid, + &["/bin/sh", "-lc", "echo start; sleep 40; echo end"], + BTreeMap::new(), + Duration::from_secs(90), + ) + .await; + assert_eq!( + result.exit_code, 0, + "a 40s command exits cleanly, not at a cap" + ); + let out = String::from_utf8_lossy(&result.stdout); + assert!( + out.contains("start") && out.contains("end"), + "both the pre- and post-sleep output survive the detached poll: {out}" + ); + + provider + .terminate(&sid) + .await + .expect("terminate confirms gone"); +} + +// ---- Capability rows measured live ------------------------------------------------------------ + +/// `suspendResume`: a suspended session resumes onto the same container with its filesystem intact. +#[tokio::test] +#[ignore = "requires a real GCP project; see module docs"] +async fn suspend_resume_preserves_the_container_and_filesystem() { + let config = LiveConfig::from_env(); + let client = config.client(); + let engine = provision_engine(&client).await; + record_engine(&engine); + let _guard = EngineGuard { + client: client.clone(), + engine: engine.clone(), + }; + let template = provision_template(&client, &engine, &agent_image(), true).await; + let provider = provider(&client, &engine, &template); + + let session = provider + .create(CreateSessionRequest::default()) + .await + .expect("create succeeds"); + let sid = session.session_id.clone(); + let before = session.generation; + + let marker = format!("mark-{}", uuid::Uuid::new_v4().simple()); + let wrote = shell( + &provider, + &sid, + &format!("printf %s '{marker}' > /sandbox/keep"), + ) + .await; + assert_eq!(wrote.exit_code, 0, "the pre-suspend marker writes"); + + provider.suspend(&sid).await.expect("the session suspends"); + provider.resume(&sid).await.expect("the session resumes"); + + let after = wait_until_running(&provider, &sid).await; + // The load-bearing guarantee is that the filesystem survives. Resume may return onto a + // reissued container with a fresh boot id — the generation is derived from it precisely so a + // caller detects that — so the generation is observed, not asserted to be unchanged. + eprintln!("suspend/resume generation: before={before} after={after}"); + let kept = provider + .read_file(&sid, "/keep") + .await + .expect("the marker survives the suspend/resume"); + assert_eq!( + kept, + marker.as_bytes(), + "the filesystem is intact across suspend/resume" + ); + + provider + .terminate(&sid) + .await + .expect("terminate confirms gone"); +} + +/// `egressDeny`: a `deny` template closes the network — the connection fails and DNS with it. +#[tokio::test] +#[ignore = "requires a real GCP project; see module docs"] +async fn egress_deny_blocks_the_network_including_dns() { + let config = LiveConfig::from_env(); + let client = config.client(); + let engine = provision_engine(&client).await; + record_engine(&engine); + let _guard = EngineGuard { + client: client.clone(), + engine: engine.clone(), + }; + // The template is immutable and carries the egress switch, so a closed network needs its own + // template rather than a flag on a command. + let template = provision_template(&client, &engine, &agent_image(), false).await; + let provider = provider(&client, &engine, &template); + + let session = provider + .create(CreateSessionRequest::default()) + .await + .expect("create succeeds"); + let sid = session.session_id.clone(); + + // DNS alone, and the resolver's own exit code is captured so a missing binary (127) cannot be + // mistaken for a blocked network — that mistake is exactly the false PASS this row must avoid. + let resolve = shell(&provider, &sid, "getent hosts github.com >/dev/null 2>&1; echo rc=$?").await; + assert_eq!(resolve.exit_code, 0, "the probe wrapper itself runs"); + let stdout = String::from_utf8_lossy(&resolve.stdout); + let rc: i32 = stdout + .trim() + .strip_prefix("rc=") + .and_then(|code| code.parse().ok()) + .unwrap_or_else(|| panic!("the probe reported no resolver exit code: {stdout}")); + assert_ne!(rc, 127, "the resolver must exist, so a nonzero code is a blocked network, not a missing binary"); + assert_ne!(rc, 0, "a closed sandbox cannot resolve github.com"); + + provider + .terminate(&sid) + .await + .expect("terminate confirms gone"); +} + +/// Snapshot **restore**: a sandbox restored from a snapshot carries the pre-snapshot filesystem and +/// not a mutation made after the snapshot. Both halves are asserted — one alone proves nothing. +/// +/// Restore has no trait verb (`create` hardcodes no snapshot), so it goes through the client +/// directly, which is the only path that can restore today. +#[tokio::test] +#[ignore = "requires a real GCP project; see module docs"] +async fn snapshot_restore_carries_pre_snapshot_state_only() { + let config = LiveConfig::from_env(); + let client = config.client(); + let engine = provision_engine(&client).await; + record_engine(&engine); + let _guard = EngineGuard { + client: client.clone(), + engine: engine.clone(), + }; + let template = provision_template(&client, &engine, &agent_image(), true).await; + let provider = provider(&client, &engine, &template); + + let session = provider + .create(CreateSessionRequest::default()) + .await + .expect("create succeeds"); + let sid = session.session_id.clone(); + + let before = format!("before-{}", uuid::Uuid::new_v4().simple()); + assert_eq!( + shell( + &provider, + &sid, + &format!("printf %s '{before}' > /sandbox/before") + ) + .await + .exit_code, + 0, + "the pre-snapshot marker writes" + ); + + let snapshot = provider + .snapshot(&sid) + .await + .expect("a snapshot is captured"); + + // A mutation the restore must not carry. + assert_eq!( + shell(&provider, &sid, "printf %s after > /sandbox/after") + .await + .exit_code, + 0, + "the post-snapshot marker writes" + ); + + let engine_seg = last_segment(&engine); + let operation = client + .create_sandbox( + engine_seg, + SandboxCreateRequest { + display_name: Some(format!("restore-{}", uuid::Uuid::new_v4().simple())), + sandbox_environment_template: None, + sandbox_environment_snapshot: Some(snapshot), + ttl: Some(format!("{TTL_SECONDS}s")), + }, + ) + .await + .expect("restore create accepted"); + let restored: SandboxEnvironment = client + .await_operation(&operation, budget()) + .await + .expect("restore create resolves"); + let restored_id = last_segment(&restored.name.expect("the restore carries a name")).to_string(); + wait_until_running(&provider, &restored_id).await; + + let carried = provider + .read_file(&restored_id, "/before") + .await + .expect("the restore carries the pre-snapshot marker"); + assert_eq!( + carried, + before.as_bytes(), + "the pre-snapshot state is present" + ); + assert!( + provider + .read_file(&restored_id, "/after") + .await + .is_err(), + "the post-snapshot mutation is absent from the restore" + ); + + provider + .terminate(&restored_id) + .await + .expect("the restore tears down"); + provider + .terminate(&sid) + .await + .expect("the source tears down"); +} + +// ---- Orphan sweep ----------------------------------------------------------------------------- + +fn sweep_log() -> PathBuf { + std::env::temp_dir().join("alien-sbx-live-engines.log") +} + +/// Records an engine name the instant it exists, so a run killed before its guard runs still leaves +/// a trail the sweep can reap. +fn record_engine(engine: &str) { + use std::io::Write as _; + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(sweep_log()) + { + let _ = writeln!(file, "{engine}"); + } +} + +/// Deletes every engine a prior live run recorded, tolerating not-found. This is the sweep for +/// orphans a failed run left behind; a completed run's engine is already gone and its line is a +/// harmless not-found here. +#[tokio::test] +#[ignore = "requires a real GCP project; reaps engines recorded by failed live runs"] +async fn sweep_orphaned_engines() { + let client = LiveConfig::from_env().client(); + let recorded = std::fs::read_to_string(sweep_log()).unwrap_or_default(); + + // Two sources, deduped: engines a failed run recorded, and engines the API still lists under + // this suite's display-name prefix. The second catches one killed before it was ever recorded — + // the gap a log-only sweep leaves. Only this suite's prefix is reaped, never a stray engine. + let mut targets: BTreeSet = recorded + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(|line| last_segment(line).to_string()) + .collect(); + for engine in client.list_engines().await.expect("listing engines to sweep") { + let matches_suite = engine + .display_name + .as_deref() + .is_some_and(|name| name.starts_with(LIVE_PREFIX)); + if let (true, Some(name)) = (matches_suite, engine.name.as_deref()) { + targets.insert(last_segment(name).to_string()); + } + } + + let mut failures = Vec::new(); + for engine in &targets { + if let Err(error) = client.delete_engine(engine).await { + failures.push(format!("{engine}: {error}")); + } + } + let _ = std::fs::remove_file(sweep_log()); + + assert!( + failures.is_empty(), + "every orphan engine must be gone after a sweep; still present: {failures:?}" + ); +} diff --git a/packages/core/src/generated/index.ts b/packages/core/src/generated/index.ts index 74c4209f2..2814e273c 100644 --- a/packages/core/src/generated/index.ts +++ b/packages/core/src/generated/index.ts @@ -207,7 +207,6 @@ export type { GcpQueueImportData } from "./zod/gcp-queue-import-data-schema.js"; export type { GcpRemoteBindingsImportData } from "./zod/gcp-remote-bindings-import-data-schema.js"; export type { GcpRemoteStackManagementHeartbeatData } from "./zod/gcp-remote-stack-management-heartbeat-data-schema.js"; export type { GcpRemoteStackManagementImportData } from "./zod/gcp-remote-stack-management-import-data-schema.js"; -export type { GcpSandboxImportData } from "./zod/gcp-sandbox-import-data-schema.js"; export type { GcpSecretManagerVaultHeartbeatData } from "./zod/gcp-secret-manager-vault-heartbeat-data-schema.js"; export type { GcpServiceAccountHeartbeatData } from "./zod/gcp-service-account-heartbeat-data-schema.js"; export type { GcpServiceAccountImportData } from "./zod/gcp-service-account-import-data-schema.js"; @@ -631,7 +630,6 @@ export { GcpQueueImportDataSchema } from "./zod/gcp-queue-import-data-schema.js" export { GcpRemoteBindingsImportDataSchema } from "./zod/gcp-remote-bindings-import-data-schema.js"; export { GcpRemoteStackManagementHeartbeatDataSchema } from "./zod/gcp-remote-stack-management-heartbeat-data-schema.js"; export { GcpRemoteStackManagementImportDataSchema } from "./zod/gcp-remote-stack-management-import-data-schema.js"; -export { GcpSandboxImportDataSchema } from "./zod/gcp-sandbox-import-data-schema.js"; export { GcpSecretManagerVaultHeartbeatDataSchema } from "./zod/gcp-secret-manager-vault-heartbeat-data-schema.js"; export { GcpServiceAccountHeartbeatDataSchema } from "./zod/gcp-service-account-heartbeat-data-schema.js"; export { GcpServiceAccountImportDataSchema } from "./zod/gcp-service-account-import-data-schema.js"; diff --git a/packages/core/src/generated/schemas/gcpSandboxImportData.json b/packages/core/src/generated/schemas/gcpSandboxImportData.json deleted file mode 100644 index 64368e057..000000000 --- a/packages/core/src/generated/schemas/gcpSandboxImportData.json +++ /dev/null @@ -1 +0,0 @@ -{"type":"object","description":"GCP Sandbox ImportData.\n\nA Cloud Run sandbox has no durable parent: it is a nested gVisor sandbox started by a launcher\nbinary Cloud Run injects into the container, so there is no group, image or endpoint for setup\nto hand over. What the runtime needs is the launcher's path, and it is carried here rather than\nhardcoded in the provider so a change to where Cloud Run mounts it is a data change.","required":["launcherPath","allowEgress"],"properties":{"allowEgress":{"type":"boolean","description":"Whether sessions may reach the network. Taken from the declaration rather than left to the\napplication: the launcher decides egress per sandbox at create time."},"launcherPath":{"type":"string","description":"Path to the sandbox CLI Cloud Run injects when the container sets `sandboxLauncher`."}},"x-readme-ref-name":"GcpSandboxImportData"} \ No newline at end of file diff --git a/packages/core/src/generated/schemas/sandboxCapabilities.json b/packages/core/src/generated/schemas/sandboxCapabilities.json index 9411a97c4..a11ae0457 100644 --- a/packages/core/src/generated/schemas/sandboxCapabilities.json +++ b/packages/core/src/generated/schemas/sandboxCapabilities.json @@ -1 +1 @@ -{"type":"object","description":"What a platform's sandbox backend can actually do.\n\nPublished so portable code can branch before calling rather than discovering a gap through\nan error. Every field here corresponds to a capability that at least one platform lacks;\ncreate, exec and terminate are the guaranteed floor and are therefore not listed.","required":["files","reconnect","preview","suspendResume","snapshot","domainEgressRules","egressDeny","enforcedLimits","processLimit","sessionLifetime","supervisorPidNamespace"],"properties":{"domainEgressRules":{"type":"boolean","description":"Egress can be restricted to a hostname allowlist"},"egressDeny":{"type":"boolean","description":"Whether a declared `deny` is actually enforced, rather than accepted and dropped"},"enforcedLimits":{"type":"boolean","description":"The platform enforces the declared cpu, memory and disk ceilings"},"files":{"type":"boolean","description":"Files can be moved in and out of a session"},"preview":{"type":"boolean","description":"An authenticated, port-scoped capability to reach a service inside the sandbox"},"processLimit":{"type":"boolean","description":"The platform can cap how many processes a session runs"},"reconnect":{"type":"boolean","description":"A later call can reach a session created by an earlier one"},"sessionLifetime":{"type":"boolean","description":"The platform terminates a session at a declared wall-clock deadline"},"snapshot":{"type":"boolean","description":"A session's full state can be captured and used to create another"},"supervisorPidNamespace":{"type":"boolean","description":"A command runs in its own PID namespace and cannot see or signal the agent's processes.\n\nOnly where an agent runs as root. Creating the namespace needs `CAP_SYS_ADMIN`, and the\nKubernetes sandbox pod drops every capability — which is also what denies `ptrace` by\nconstruction, so granting it there would remove a lock to add one."},"suspendResume":{"type":"boolean","description":"Session state can be suspended and resumed"}},"additionalProperties":false,"x-readme-ref-name":"SandboxCapabilities"} \ No newline at end of file +{"type":"object","description":"What a platform's sandbox backend can actually do.\n\nPublished so portable code can branch before calling rather than discovering a gap through\nan error. Every field here corresponds to a capability that at least one platform lacks;\ncreate, exec and terminate are the guaranteed floor and are therefore not listed.","required":["files","reconnect","preview","suspendResume","snapshot","domainEgressRules","egressDeny","enforcedLimits","processLimit","sessionLifetime","supervisorPidNamespace","supervisorIsolation"],"properties":{"domainEgressRules":{"type":"boolean","description":"Egress can be restricted to a hostname allowlist"},"egressDeny":{"type":"boolean","description":"Whether a declared `deny` is actually enforced, rather than accepted and dropped"},"enforcedLimits":{"type":"boolean","description":"The platform enforces the declared cpu, memory and disk ceilings"},"files":{"type":"boolean","description":"Files can be moved in and out of a session"},"preview":{"type":"boolean","description":"An authenticated, port-scoped capability to reach a service inside the sandbox"},"processLimit":{"type":"boolean","description":"The platform can cap how many processes a session runs"},"reconnect":{"type":"boolean","description":"A later call can reach a session created by an earlier one"},"sessionLifetime":{"type":"boolean","description":"The platform terminates a session at a declared wall-clock deadline"},"snapshot":{"type":"boolean","description":"A session's full state can be captured and used to create another"},"supervisorIsolation":{"type":"boolean","description":"The process supervising a command is a different identity from the command.\n\nFalse where a command runs as the agent's own user: it can then read the supervisor's\nenvironment and signal it. Separate from `supervisorPidNamespace`, which is about\nvisibility rather than identity — a backend can have one without the other."},"supervisorPidNamespace":{"type":"boolean","description":"A command runs in its own PID namespace and cannot see or signal the agent's processes.\n\nOnly where an agent runs as root. Creating the namespace needs `CAP_SYS_ADMIN`, and the\nKubernetes sandbox pod drops every capability — which is also what denies `ptrace` by\nconstruction, so granting it there would remove a lock to add one."},"suspendResume":{"type":"boolean","description":"Session state can be suspended and resumed"}},"additionalProperties":false,"x-readme-ref-name":"SandboxCapabilities"} \ No newline at end of file diff --git a/packages/core/src/generated/schemas/sandboxCapability.json b/packages/core/src/generated/schemas/sandboxCapability.json index 014f2c8ac..b4d39eec2 100644 --- a/packages/core/src/generated/schemas/sandboxCapability.json +++ b/packages/core/src/generated/schemas/sandboxCapability.json @@ -1 +1 @@ -{"type":"string","description":"Names a single sandbox capability, so an unsupported call can report which one it needed.","enum":["files","reconnect","preview","suspendResume","snapshot","domainEgressRules","egressDeny","enforcedLimits","processLimit","sessionLifetime","supervisorPidNamespace"],"x-readme-ref-name":"SandboxCapability"} \ No newline at end of file +{"type":"string","description":"Names a single sandbox capability, so an unsupported call can report which one it needed.","enum":["files","reconnect","preview","suspendResume","snapshot","domainEgressRules","egressDeny","enforcedLimits","processLimit","sessionLifetime","supervisorPidNamespace","supervisorIsolation"],"x-readme-ref-name":"SandboxCapability"} \ No newline at end of file diff --git a/packages/core/src/generated/schemas/worker.json b/packages/core/src/generated/schemas/worker.json index fb7622f9f..1009c8e71 100644 --- a/packages/core/src/generated/schemas/worker.json +++ b/packages/core/src/generated/schemas/worker.json @@ -1 +1 @@ -{"type":"object","description":"Represents a serverless worker that executes code in response to triggers or direct invocations.\nWorkers are the primary compute resource in serverless applications, designed to be stateless and ephemeral.","required":["id","links","triggers","permissions","code"],"properties":{"code":{"description":"Code for the worker, either a pre-built image or source code to be built.","oneOf":[{"type":"object","description":"Container image.","required":["image","type"],"properties":{"image":{"type":"string","description":"Container image (e.g., `ghcr.io/myorg/myimage:latest`)."},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source code to be built.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"x-readme-ref-name":"WorkerCode"},"commandsEnabled":{"type":"boolean","description":"Whether the worker can receive remote commands via the Commands protocol.\nWhen enabled, the platform pushes commands into the Worker runtime,\nwhich executes registered handlers.","default":false},"concurrencyLimit":{"type":["integer","null"],"format":"int32","description":"Maximum number of concurrent executions allowed for the worker.\nNone means platform default applies.","minimum":0},"environment":{"type":"object","description":"Key-value pairs to set as environment variables for the worker.","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"id":{"type":"string","description":"Identifier for the worker. Must contain only alphanumeric characters, hyphens, and underscores ([A-Za-z0-9-_]).\nMaximum 64 characters."},"links":{"type":"array","items":{"type":"object","description":"Reference to a resource by its stable id and resource type.","required":["type","id"],"properties":{"id":{"type":"string"},"type":{"type":"string","description":"Resource type identifier that determines the specific kind of resource. This field is used for polymorphic deserialization and resource-specific behavior.","examples":["worker","storage","queue","redis","postgres"],"x-readme-ref-name":"ResourceType"}},"x-readme-ref-name":"ResourceRef"},"description":"List of resource references this worker depends on."},"memoryMb":{"type":"integer","format":"int32","description":"Memory allocated to the worker in megabytes (MB).\nDefault: 512\n\nPlatform-specific constraints:\n- **AWS Lambda**: 128–10240 MB in 1 MB increments\n- **GCP Cloud Run**: 128–32768 MB\n- **Azure Container Apps**: fixed CPU/memory pairs — 512, 1024, 1536, 2048, 2560,\n 3072, 3584, 4096 MB. Values below 512 are automatically rounded up at deploy time.","default":512,"minimum":0},"permissions":{"type":"string","description":"Permission profile name that defines the permissions granted to this worker.\nThis references a profile defined in the stack's permission definitions."},"publicEndpoints":{"type":"array","items":{"type":"object","description":"Public endpoint configuration for Worker resources.","required":["name"],"properties":{"hostLabel":{"type":["string","null"],"description":"Optional DNS label override for generated endpoint hostnames."},"name":{"type":"string","description":"Endpoint name within the resource."},"wildcardSubdomains":{"type":"boolean","description":"Whether to route wildcard subdomains to this endpoint."}},"x-readme-ref-name":"WorkerPublicEndpoint"},"description":"Public endpoints exposed by this worker."},"readinessProbe":{"oneOf":[{"type":"null"},{"description":"Optional readiness probe configuration.\nOnly applicable for workers with Public ingress.\nWhen configured, the probe will be executed after provisioning/update to verify the worker is ready.","type":"object","properties":{"method":{"description":"HTTP method to use for the probe request.\nDefault: GET","type":"string","enum":["GET","POST","PUT","DELETE","HEAD","OPTIONS","PATCH"],"x-readme-ref-name":"HttpMethod"},"path":{"type":"string","description":"Path to request for the probe (e.g., \"/health\", \"/ready\").\nDefault: \"/\""}},"x-readme-ref-name":"ReadinessProbe"}]},"sandboxLauncher":{"type":"boolean","description":"Whether this worker hosts sandbox sessions.\n\nSet by preflight, not by an application: on GCP a sandbox is a subprocess of the Cloud Run\ninstance running the app, and the instance can only launch one if its container declares\nit. Declaring it by hand would be a permission the workload does not need."},"timeoutSeconds":{"type":"integer","format":"int32","description":"Maximum execution time for the worker in seconds.\nConstraints: 1‑3600 seconds (platform-specific limits may apply)\nDefault: 180","default":180,"maximum":3600,"minimum":1},"triggers":{"type":"array","items":{"oneOf":[{"type":"object","description":"Worker triggered by queue messages (always 1 message per invocation)","required":["queue","type"],"properties":{"queue":{"description":"Reference to the queue resource","type":"object","required":["type","id"],"properties":{"id":{"type":"string"},"type":{"type":"string","description":"Resource type identifier that determines the specific kind of resource. This field is used for polymorphic deserialization and resource-specific behavior.","examples":["worker","storage","queue","redis","postgres"],"x-readme-ref-name":"ResourceType"}},"x-readme-ref-name":"ResourceRef"},"type":{"type":"string","enum":["queue"]}}},{"type":"object","description":"Worker triggered by storage events (object created, deleted, etc.)","required":["storage","events","type"],"properties":{"events":{"type":"array","items":{"type":"string"},"description":"Events to trigger on (e.g., [\"created\", \"deleted\"])"},"storage":{"description":"Reference to the storage resource","type":"object","required":["type","id"],"properties":{"id":{"type":"string"},"type":{"type":"string","description":"Resource type identifier that determines the specific kind of resource. This field is used for polymorphic deserialization and resource-specific behavior.","examples":["worker","storage","queue","redis","postgres"],"x-readme-ref-name":"ResourceType"}},"x-readme-ref-name":"ResourceRef"},"type":{"type":"string","enum":["storage"]}}},{"type":"object","description":"Worker triggered on a schedule (cron expression)","required":["cron","type"],"properties":{"cron":{"type":"string","description":"Cron expression for scheduling (standard 5-field unix cron)"},"type":{"type":"string","enum":["schedule"]}}}],"description":"Defines what triggers a worker execution.","x-readme-ref-name":"WorkerTrigger"},"description":"List of triggers that define what events automatically invoke this worker.\nIf empty, the worker is only invokable directly via HTTP calls or platform-specific invocation APIs.\nWhen configured, the worker will be automatically invoked when any of the specified trigger conditions are met."}},"additionalProperties":false,"x-readme-ref-name":"Worker"} \ No newline at end of file +{"type":"object","description":"Represents a serverless worker that executes code in response to triggers or direct invocations.\nWorkers are the primary compute resource in serverless applications, designed to be stateless and ephemeral.","required":["id","links","triggers","permissions","code"],"properties":{"code":{"description":"Code for the worker, either a pre-built image or source code to be built.","oneOf":[{"type":"object","description":"Container image.","required":["image","type"],"properties":{"image":{"type":"string","description":"Container image (e.g., `ghcr.io/myorg/myimage:latest`)."},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source code to be built.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"x-readme-ref-name":"WorkerCode"},"commandsEnabled":{"type":"boolean","description":"Whether the worker can receive remote commands via the Commands protocol.\nWhen enabled, the platform pushes commands into the Worker runtime,\nwhich executes registered handlers.","default":false},"concurrencyLimit":{"type":["integer","null"],"format":"int32","description":"Maximum number of concurrent executions allowed for the worker.\nNone means platform default applies.","minimum":0},"environment":{"type":"object","description":"Key-value pairs to set as environment variables for the worker.","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"id":{"type":"string","description":"Identifier for the worker. Must contain only alphanumeric characters, hyphens, and underscores ([A-Za-z0-9-_]).\nMaximum 64 characters."},"links":{"type":"array","items":{"type":"object","description":"Reference to a resource by its stable id and resource type.","required":["type","id"],"properties":{"id":{"type":"string"},"type":{"type":"string","description":"Resource type identifier that determines the specific kind of resource. This field is used for polymorphic deserialization and resource-specific behavior.","examples":["worker","storage","queue","redis","postgres"],"x-readme-ref-name":"ResourceType"}},"x-readme-ref-name":"ResourceRef"},"description":"List of resource references this worker depends on."},"memoryMb":{"type":"integer","format":"int32","description":"Memory allocated to the worker in megabytes (MB).\nDefault: 512\n\nPlatform-specific constraints:\n- **AWS Lambda**: 128–10240 MB in 1 MB increments\n- **GCP Cloud Run**: 128–32768 MB\n- **Azure Container Apps**: fixed CPU/memory pairs — 512, 1024, 1536, 2048, 2560,\n 3072, 3584, 4096 MB. Values below 512 are automatically rounded up at deploy time.","default":512,"minimum":0},"permissions":{"type":"string","description":"Permission profile name that defines the permissions granted to this worker.\nThis references a profile defined in the stack's permission definitions."},"publicEndpoints":{"type":"array","items":{"type":"object","description":"Public endpoint configuration for Worker resources.","required":["name"],"properties":{"hostLabel":{"type":["string","null"],"description":"Optional DNS label override for generated endpoint hostnames."},"name":{"type":"string","description":"Endpoint name within the resource."},"wildcardSubdomains":{"type":"boolean","description":"Whether to route wildcard subdomains to this endpoint."}},"x-readme-ref-name":"WorkerPublicEndpoint"},"description":"Public endpoints exposed by this worker."},"readinessProbe":{"oneOf":[{"type":"null"},{"description":"Optional readiness probe configuration.\nOnly applicable for workers with Public ingress.\nWhen configured, the probe will be executed after provisioning/update to verify the worker is ready.","type":"object","properties":{"method":{"description":"HTTP method to use for the probe request.\nDefault: GET","type":"string","enum":["GET","POST","PUT","DELETE","HEAD","OPTIONS","PATCH"],"x-readme-ref-name":"HttpMethod"},"path":{"type":"string","description":"Path to request for the probe (e.g., \"/health\", \"/ready\").\nDefault: \"/\""}},"x-readme-ref-name":"ReadinessProbe"}]},"timeoutSeconds":{"type":"integer","format":"int32","description":"Maximum execution time for the worker in seconds.\nConstraints: 1‑3600 seconds (platform-specific limits may apply)\nDefault: 180","default":180,"maximum":3600,"minimum":1},"triggers":{"type":"array","items":{"oneOf":[{"type":"object","description":"Worker triggered by queue messages (always 1 message per invocation)","required":["queue","type"],"properties":{"queue":{"description":"Reference to the queue resource","type":"object","required":["type","id"],"properties":{"id":{"type":"string"},"type":{"type":"string","description":"Resource type identifier that determines the specific kind of resource. This field is used for polymorphic deserialization and resource-specific behavior.","examples":["worker","storage","queue","redis","postgres"],"x-readme-ref-name":"ResourceType"}},"x-readme-ref-name":"ResourceRef"},"type":{"type":"string","enum":["queue"]}}},{"type":"object","description":"Worker triggered by storage events (object created, deleted, etc.)","required":["storage","events","type"],"properties":{"events":{"type":"array","items":{"type":"string"},"description":"Events to trigger on (e.g., [\"created\", \"deleted\"])"},"storage":{"description":"Reference to the storage resource","type":"object","required":["type","id"],"properties":{"id":{"type":"string"},"type":{"type":"string","description":"Resource type identifier that determines the specific kind of resource. This field is used for polymorphic deserialization and resource-specific behavior.","examples":["worker","storage","queue","redis","postgres"],"x-readme-ref-name":"ResourceType"}},"x-readme-ref-name":"ResourceRef"},"type":{"type":"string","enum":["storage"]}}},{"type":"object","description":"Worker triggered on a schedule (cron expression)","required":["cron","type"],"properties":{"cron":{"type":"string","description":"Cron expression for scheduling (standard 5-field unix cron)"},"type":{"type":"string","enum":["schedule"]}}}],"description":"Defines what triggers a worker execution.","x-readme-ref-name":"WorkerTrigger"},"description":"List of triggers that define what events automatically invoke this worker.\nIf empty, the worker is only invokable directly via HTTP calls or platform-specific invocation APIs.\nWhen configured, the worker will be automatically invoked when any of the specified trigger conditions are met."}},"additionalProperties":false,"x-readme-ref-name":"Worker"} \ No newline at end of file diff --git a/packages/core/src/generated/zod/gcp-sandbox-import-data-schema.ts b/packages/core/src/generated/zod/gcp-sandbox-import-data-schema.ts deleted file mode 100644 index 8ac19e0c9..000000000 --- a/packages/core/src/generated/zod/gcp-sandbox-import-data-schema.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** -* Generated by Kubb (https://kubb.dev/). -* Do not edit manually. -*/ - -import * as z from "zod"; - -/** - * @description GCP Sandbox ImportData.\n\nA Cloud Run sandbox has no durable parent: it is a nested gVisor sandbox started by a launcher\nbinary Cloud Run injects into the container, so there is no group, image or endpoint for setup\nto hand over. What the runtime needs is the launcher\'s path, and it is carried here rather than\nhardcoded in the provider so a change to where Cloud Run mounts it is a data change. - */ -export const GcpSandboxImportDataSchema = z.object({ - "allowEgress": z.boolean().describe("Whether sessions may reach the network. Taken from the declaration rather than left to the\napplication: the launcher decides egress per sandbox at create time."), -"launcherPath": z.string().describe("Path to the sandbox CLI Cloud Run injects when the container sets `sandboxLauncher`.") - }).describe("GCP Sandbox ImportData.\n\nA Cloud Run sandbox has no durable parent: it is a nested gVisor sandbox started by a launcher\nbinary Cloud Run injects into the container, so there is no group, image or endpoint for setup\nto hand over. What the runtime needs is the launcher's path, and it is carried here rather than\nhardcoded in the provider so a change to where Cloud Run mounts it is a data change.") - -export type GcpSandboxImportData = z.infer \ No newline at end of file diff --git a/packages/core/src/generated/zod/index.ts b/packages/core/src/generated/zod/index.ts index 20706726b..2f827d42b 100644 --- a/packages/core/src/generated/zod/index.ts +++ b/packages/core/src/generated/zod/index.ts @@ -207,7 +207,6 @@ export type { GcpQueueImportData } from "./gcp-queue-import-data-schema.js"; export type { GcpRemoteBindingsImportData } from "./gcp-remote-bindings-import-data-schema.js"; export type { GcpRemoteStackManagementHeartbeatData } from "./gcp-remote-stack-management-heartbeat-data-schema.js"; export type { GcpRemoteStackManagementImportData } from "./gcp-remote-stack-management-import-data-schema.js"; -export type { GcpSandboxImportData } from "./gcp-sandbox-import-data-schema.js"; export type { GcpSecretManagerVaultHeartbeatData } from "./gcp-secret-manager-vault-heartbeat-data-schema.js"; export type { GcpServiceAccountHeartbeatData } from "./gcp-service-account-heartbeat-data-schema.js"; export type { GcpServiceAccountImportData } from "./gcp-service-account-import-data-schema.js"; @@ -631,7 +630,6 @@ export { GcpQueueImportDataSchema } from "./gcp-queue-import-data-schema.js"; export { GcpRemoteBindingsImportDataSchema } from "./gcp-remote-bindings-import-data-schema.js"; export { GcpRemoteStackManagementHeartbeatDataSchema } from "./gcp-remote-stack-management-heartbeat-data-schema.js"; export { GcpRemoteStackManagementImportDataSchema } from "./gcp-remote-stack-management-import-data-schema.js"; -export { GcpSandboxImportDataSchema } from "./gcp-sandbox-import-data-schema.js"; export { GcpSecretManagerVaultHeartbeatDataSchema } from "./gcp-secret-manager-vault-heartbeat-data-schema.js"; export { GcpServiceAccountHeartbeatDataSchema } from "./gcp-service-account-heartbeat-data-schema.js"; export { GcpServiceAccountImportDataSchema } from "./gcp-service-account-import-data-schema.js"; diff --git a/packages/core/src/generated/zod/sandbox-capabilities-schema.ts b/packages/core/src/generated/zod/sandbox-capabilities-schema.ts index 35778965d..cae9ec174 100644 --- a/packages/core/src/generated/zod/sandbox-capabilities-schema.ts +++ b/packages/core/src/generated/zod/sandbox-capabilities-schema.ts @@ -18,6 +18,7 @@ export const SandboxCapabilitiesSchema = z.object({ "reconnect": z.boolean().describe("A later call can reach a session created by an earlier one"), "sessionLifetime": z.boolean().describe("The platform terminates a session at a declared wall-clock deadline"), "snapshot": z.boolean().describe("A session's full state can be captured and used to create another"), +"supervisorIsolation": z.boolean().describe("The process supervising a command is a different identity from the command.\n\nFalse where a command runs as the agent's own user: it can then read the supervisor's\nenvironment and signal it. Separate from `supervisorPidNamespace`, which is about\nvisibility rather than identity — a backend can have one without the other."), "supervisorPidNamespace": z.boolean().describe("A command runs in its own PID namespace and cannot see or signal the agent's processes.\n\nOnly where an agent runs as root. Creating the namespace needs `CAP_SYS_ADMIN`, and the\nKubernetes sandbox pod drops every capability — which is also what denies `ptrace` by\nconstruction, so granting it there would remove a lock to add one."), "suspendResume": z.boolean().describe("Session state can be suspended and resumed") }).describe("What a platform's sandbox backend can actually do.\n\nPublished so portable code can branch before calling rather than discovering a gap through\nan error. Every field here corresponds to a capability that at least one platform lacks;\ncreate, exec and terminate are the guaranteed floor and are therefore not listed.") diff --git a/packages/core/src/generated/zod/sandbox-capability-schema.ts b/packages/core/src/generated/zod/sandbox-capability-schema.ts index 106608a51..85bce38e0 100644 --- a/packages/core/src/generated/zod/sandbox-capability-schema.ts +++ b/packages/core/src/generated/zod/sandbox-capability-schema.ts @@ -8,6 +8,6 @@ import * as z from "zod"; /** * @description Names a single sandbox capability, so an unsupported call can report which one it needed. */ -export const SandboxCapabilitySchema = z.enum(["files", "reconnect", "preview", "suspendResume", "snapshot", "domainEgressRules", "egressDeny", "enforcedLimits", "processLimit", "sessionLifetime", "supervisorPidNamespace"]).describe("Names a single sandbox capability, so an unsupported call can report which one it needed.") +export const SandboxCapabilitySchema = z.enum(["files", "reconnect", "preview", "suspendResume", "snapshot", "domainEgressRules", "egressDeny", "enforcedLimits", "processLimit", "sessionLifetime", "supervisorPidNamespace", "supervisorIsolation"]).describe("Names a single sandbox capability, so an unsupported call can report which one it needed.") export type SandboxCapability = z.infer \ No newline at end of file diff --git a/packages/core/src/generated/zod/worker-schema.ts b/packages/core/src/generated/zod/worker-schema.ts index c094044b0..b0682fc0e 100644 --- a/packages/core/src/generated/zod/worker-schema.ts +++ b/packages/core/src/generated/zod/worker-schema.ts @@ -34,7 +34,6 @@ get "publicEndpoints"(){ get "readinessProbe"(){ return z.union([ReadinessProbeSchema, z.null()]).optional() }, -"sandboxLauncher": z.optional(z.boolean().describe("Whether this worker hosts sandbox sessions.\n\nSet by preflight, not by an application: on GCP a sandbox is a subprocess of the Cloud Run\ninstance running the app, and the instance can only launch one if its container declares\nit. Declaring it by hand would be a permission the workload does not need.")), "timeoutSeconds": z.optional(z.int().min(1).max(3600).default(180).describe("Maximum execution time for the worker in seconds.\nConstraints: 1‑3600 seconds (platform-specific limits may apply)\nDefault: 180")), get "triggers"(){ return z.array(WorkerTriggerSchema.describe("Defines what triggers a worker execution.")).describe("List of triggers that define what events automatically invoke this worker.\nIf empty, the worker is only invokable directly via HTTP calls or platform-specific invocation APIs.\nWhen configured, the worker will be automatically invoked when any of the specified trigger conditions are met.") diff --git a/packages/core/src/sandbox.ts b/packages/core/src/sandbox.ts index 848045031..32915a78a 100644 --- a/packages/core/src/sandbox.ts +++ b/packages/core/src/sandbox.ts @@ -34,7 +34,10 @@ export { SandboxSchema as SandboxConfigSchema } from "./generated/index.js" * Capabilities are not uniform. Call `capabilities()` on the binding and branch, or handle the * typed error — an unsupported capability never silently succeeds. Notably GCP cannot * reconnect to a session (its session id is scoped to one Cloud Run instance), only Azure - * restricts egress to a hostname allowlist, and no platform can snapshot a session. + * restricts egress to a hostname allowlist, no platform can snapshot a session, and only AWS + * and Local run a command under a different identity than the process supervising it. Elsewhere the + * command shares the supervisor's user, so it can read the supervisor's environment and + * signal it, and the container is the isolation boundary. * * Limits are enforced ceilings, not scheduling hints, and are validated when the stack is * planned. A platform that cannot enforce them rejects the sandbox rather than ignoring them. From 1d690a37073aa88b5f13e805e7e59296b63a2854 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:17:34 +0300 Subject: [PATCH 31/32] fix(sandbox): own a lost Azure suspend by reading the resulting state A stop whose response is lost or replaced by a transient error left `suspend` reporting failure even when Azure had applied it, and a retry could then be refused because the transition already occurred. Reconcile against the record: a session that is gone or reads Suspended means the stop landed, so report success; only a still-running one surfaces the error. --- .../src/providers/sandbox/azure.rs | 54 +++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index 12705845f..d8c7b143c 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -428,13 +428,24 @@ impl Sandbox for AzureSandbox { async fn suspend(&self, session_id: &str) -> Result<()> { Self::checked_session_id("sandbox.suspend", session_id)?; + const OPERATION: &str = "sandbox.suspend"; // Accepted, not completed — the same contract the AWS backend follows. `get` reports // `Suspended` from the moment the stop is under way, so it answers "cannot take work", // not "has stopped"; only `terminate` confirms a session is actually gone. - self.client - .stop_sandbox(&self.sandbox_group, session_id) - .await - .map_err(|error| Self::failed("sandbox.suspend", error)) + let Err(error) = self.client.stop_sandbox(&self.sandbox_group, session_id).await else { + return Ok(()); + }; + + // A lost or transient response leaves the outcome unknown. Read the record: a session + // that is gone or already suspended means the stop took effect, so report success rather + // than a failure a retry would only see refused. A still-running one means it did not land. + match self.read_session(OPERATION, session_id).await? { + None => Ok(()), + Some(found) => match session_state(OPERATION, found.state.as_deref())? { + SandboxSessionState::Suspended => Ok(()), + _ => Err(Self::failed(OPERATION, error)), + }, + } } async fn resume(&self, session_id: &str) -> Result<()> { @@ -2644,6 +2655,41 @@ mod tests { .expect("resume should reach a running session"); } + /// A lost or transient stop response is reconciled against the record, not reported as a + /// failure the caller cannot act on: a session that came back suspended means the stop landed. + #[tokio::test] + async fn suspend_owns_a_lost_stop_when_the_session_comes_back_suspended() { + // Stop errors, but the session reads Suspended — the stop took effect, so report success. + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_stop_sandbox() + .times(1) + .returning(|_, _| Err(http_error(503, "gateway timeout"))); + client.expect_get_sandbox().returning(|_, id| { + let mut sandbox = running(id, None); + sandbox.state = Some("Stopped".to_string()); + Ok(sandbox) + }); + sandbox_with(client) + .suspend("s1") + .await + .expect("a stop that landed is success even when its response was lost"); + + // Stop errors and the session is still Running — the stop did not land, so surface it. + let mut client = MockSandboxDataPlaneApi::new(); + client + .expect_stop_sandbox() + .times(1) + .returning(|_, _| Err(http_error(503, "gateway timeout"))); + client + .expect_get_sandbox() + .returning(|_, id| Ok(running(id, None))); + sandbox_with(client) + .suspend("s1") + .await + .expect_err("a stop that did not land must surface the failure"); + } + /// A declared idle-suspend policy has to reach the create body. /// /// The data plane takes it at create and nowhere else, and accepts a body without it — so a From c6d9dc6602d30856c293edcb72c3c6240b99812c Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:26:31 +0300 Subject: [PATCH 32/32] style(sandbox): rustfmt the Agent Platform sandbox tree --- .../alien-azure-clients/src/azure/common.rs | 29 ++-- .../src/azure/sandbox_data_plane.rs | 92 +++++++++---- .../src/providers/sandbox/azure.rs | 127 ++++++++++++------ .../src/providers/sandbox/mod.rs | 23 +++- crates/alien-core/src/resources/sandbox.rs | 40 ++++-- .../src/gcp/agent_platform.rs | 109 ++++++++++----- crates/alien-gcp-clients/src/lib.rs | 4 +- crates/alien-infra/src/core/registry.rs | 12 +- .../sandbox/gcp_agent_platform_template.rs | 4 +- crates/alien-infra/src/sandbox/local.rs | 47 ++++--- crates/alien-infra/src/worker/gcp.rs | 2 +- crates/alien-sandbox-agent/src/lib.rs | 4 +- crates/alien-sandbox-agent/src/server.rs | 106 ++++++++++----- crates/alien-sandbox-agent/tests/protocol.rs | 75 ++++++++--- crates/alien-terraform/src/built_ins.rs | 6 +- .../src/emitters/azure/sandbox.rs | 10 +- .../src/emitters/gcp/sandbox.rs | 9 +- .../tests/gcp_agent_platform_sandbox_live.rs | 23 +++- 18 files changed, 494 insertions(+), 228 deletions(-) diff --git a/crates/alien-azure-clients/src/azure/common.rs b/crates/alien-azure-clients/src/azure/common.rs index a0347a784..e63af5d91 100644 --- a/crates/alien-azure-clients/src/azure/common.rs +++ b/crates/alien-azure-clients/src/azure/common.rs @@ -209,13 +209,14 @@ impl AzureClientBase { String::from_utf8_lossy(&b[..b.len().min(MAX_ECHOED_REQUEST_BODY)]).to_string() }); - let resp = client - .execute(req) - .await - .into_alien_error() - .context(ErrorData::HttpRequestFailed { - message: format!("Azure {}: HTTP error for {}", op, res_name), - })?; + let resp = + client + .execute(req) + .await + .into_alien_error() + .context(ErrorData::HttpRequestFailed { + message: format!("Azure {}: HTTP error for {}", op, res_name), + })?; let status = resp.status(); if status.is_success() || status == StatusCode::CREATED || status == StatusCode::ACCEPTED { return Ok(resp); @@ -326,10 +327,9 @@ impl AzureClientBase { // Capture request details before execution consumes the request let request_url = req_clone.url().to_string(); - let request_body = req_clone - .body() - .and_then(|b| b.as_bytes()) - .map(|b| String::from_utf8_lossy(&b[..b.len().min(MAX_ECHOED_REQUEST_BODY)]).to_string()); + let request_body = req_clone.body().and_then(|b| b.as_bytes()).map(|b| { + String::from_utf8_lossy(&b[..b.len().min(MAX_ECHOED_REQUEST_BODY)]).to_string() + }); let resp = client.execute(req_clone).await.into_alien_error().context( ErrorData::HttpRequestFailed { @@ -477,10 +477,9 @@ impl AzureClientBase { // Capture request details before execution consumes the request let request_url = req_clone.url().to_string(); - let request_body = req_clone - .body() - .and_then(|b| b.as_bytes()) - .map(|b| String::from_utf8_lossy(&b[..b.len().min(MAX_ECHOED_REQUEST_BODY)]).to_string()); + let request_body = req_clone.body().and_then(|b| b.as_bytes()).map(|b| { + String::from_utf8_lossy(&b[..b.len().min(MAX_ECHOED_REQUEST_BODY)]).to_string() + }); let resp = client.execute(req_clone).await.into_alien_error().context( ErrorData::HttpRequestFailed { diff --git a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs index 6932191af..d3c82a66e 100644 --- a/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs +++ b/crates/alien-azure-clients/src/azure/sandbox_data_plane.rs @@ -13,10 +13,10 @@ use crate::azure::common::{AzureClientBase, AzureRequestBuilder}; use crate::azure::token_cache::AzureTokenCache; use alien_client_core::{ErrorData, Result}; use alien_error::{Context, IntoAlienError}; -use std::collections::BTreeMap; use async_trait::async_trait; use reqwest::Method; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; #[cfg(feature = "test-utils")] use mockall::automock; @@ -345,7 +345,10 @@ impl AzureSandboxDataPlaneClient { verb: &str, operation: &str, ) -> Result<()> { - let token = self.token_cache.get_bearer_token_with_scope(ADC_SCOPE).await?; + let token = self + .token_cache + .get_bearer_token_with_scope(ADC_SCOPE) + .await?; let url = self.base.build_url( &format!("{}/{verb}", self.sandbox_path(group, sandbox_id)), Some(vec![("api-version", API_VERSION.into())]), @@ -398,7 +401,10 @@ impl AzureSandboxDataPlaneClient { #[async_trait] impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { async fn create_sandbox(&self, group: &str, request: CreateSandbox) -> Result { - let token = self.token_cache.get_bearer_token_with_scope(ADC_SCOPE).await?; + let token = self + .token_cache + .get_bearer_token_with_scope(ADC_SCOPE) + .await?; let url = self.base.build_url( &format!("{}/sandboxes", self.group_path(group)), Some(vec![("api-version", API_VERSION.into())]), @@ -424,7 +430,10 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { } async fn get_sandbox(&self, group: &str, sandbox_id: &str) -> Result { - let token = self.token_cache.get_bearer_token_with_scope(ADC_SCOPE).await?; + let token = self + .token_cache + .get_bearer_token_with_scope(ADC_SCOPE) + .await?; let url = self.base.build_url( &self.sandbox_path(group, sandbox_id), Some(vec![("api-version", API_VERSION.into())]), @@ -440,7 +449,10 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { } async fn delete_sandbox(&self, group: &str, sandbox_id: &str) -> Result<()> { - let token = self.token_cache.get_bearer_token_with_scope(ADC_SCOPE).await?; + let token = self + .token_cache + .get_bearer_token_with_scope(ADC_SCOPE) + .await?; let url = self.base.build_url( &self.sandbox_path(group, sandbox_id), Some(vec![("api-version", API_VERSION.into())]), @@ -465,7 +477,10 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { command: &str, working_directory: Option, ) -> Result { - let token = self.token_cache.get_bearer_token_with_scope(ADC_SCOPE).await?; + let token = self + .token_cache + .get_bearer_token_with_scope(ADC_SCOPE) + .await?; let url = self.base.build_url( &format!( "{}/executeShellCommand", @@ -494,7 +509,10 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { } async fn read_file(&self, group: &str, sandbox_id: &str, path: &str) -> Result> { - let token = self.token_cache.get_bearer_token_with_scope(ADC_SCOPE).await?; + let token = self + .token_cache + .get_bearer_token_with_scope(ADC_SCOPE) + .await?; let url = self.base.build_url( &format!("{}/files", self.sandbox_path(group, sandbox_id)), Some(vec![ @@ -505,20 +523,24 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { let request = AzureRequestBuilder::new(Method::GET, url).build()?; let signed = self.base.sign_request(request, &token).await?; - let response = self.base.execute_request(signed, "ReadFile", sandbox_id).await?; + let response = self + .base + .execute_request(signed, "ReadFile", sandbox_id) + .await?; // Bytes, not JSON: the body is the file, and `parse` would try to read an image or a // tarball as a document. Collected chunk by chunk so the ceiling is enforced against // what has arrived rather than after the whole file is already in memory. let mut response = response; let mut contents: Vec = Vec::new(); - while let Some(chunk) = response - .chunk() - .await - .into_alien_error() - .context(ErrorData::GenericError { - message: "Azure ADC ReadFile: the response body ended early".to_string(), - })? + while let Some(chunk) = + response + .chunk() + .await + .into_alien_error() + .context(ErrorData::GenericError { + message: "Azure ADC ReadFile: the response body ended early".to_string(), + })? { contents.extend_from_slice(&chunk); if contents.len() > MAX_FILE_BYTES { @@ -551,7 +573,10 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { })); } - let token = self.token_cache.get_bearer_token_with_scope(ADC_SCOPE).await?; + let token = self + .token_cache + .get_bearer_token_with_scope(ADC_SCOPE) + .await?; // `createDirs` is what makes a write create its parents, which is the cross-backend // contract. The SDK also takes a `mode`, deliberately not sent: its accepted format is // undocumented, and a wrong one would fail every write. @@ -589,7 +614,10 @@ impl SandboxDataPlaneApi for AzureSandboxDataPlaneClient { } async fn mkdir(&self, group: &str, sandbox_id: &str, path: &str) -> Result<()> { - let token = self.token_cache.get_bearer_token_with_scope(ADC_SCOPE).await?; + let token = self + .token_cache + .get_bearer_token_with_scope(ADC_SCOPE) + .await?; let url = self.base.build_url( &format!("{}/files/mkdir", self.sandbox_path(group, sandbox_id)), Some(vec![("api-version", API_VERSION.into())]), @@ -822,7 +850,10 @@ mod tests { then.status(200); }) .await; - client.mkdir("grp", "s1", "src").await.expect("the mkdir should succeed"); + client + .mkdir("grp", "s1", "src") + .await + .expect("the mkdir should succeed"); mkdir.assert_async().await; } @@ -855,7 +886,10 @@ mod tests { }) .await; assert_eq!( - client.read_file("grp", "s1", "image.png").await.expect("reads"), + client + .read_file("grp", "s1", "image.png") + .await + .expect("reads"), bytes ); } @@ -932,7 +966,10 @@ mod tests { // sandbox instead of refusing the body. assert_eq!(body["egressPolicy"]["defaultAction"], "Deny"); assert_eq!(body["egressPolicy"]["trafficInspection"], "Full"); - assert_eq!(body["egressPolicy"]["hostRules"][0]["pattern"], "api.example.com"); + assert_eq!( + body["egressPolicy"]["hostRules"][0]["pattern"], + "api.example.com" + ); let bare = create_body(&CreateSandbox::default()); assert!( @@ -981,7 +1018,10 @@ mod tests { assert_eq!(policy.rules.len(), 1); assert_eq!( - policy.rules[0].action.as_ref().map(|action| action.action_type.as_str()), + policy.rules[0] + .action + .as_ref() + .map(|action| action.action_type.as_str()), Some("Allow") ); } @@ -1007,7 +1047,10 @@ mod tests { then.status(202); }) .await; - client.stop_sandbox("grp", "s1").await.expect("stop is accepted"); + client + .stop_sandbox("grp", "s1") + .await + .expect("stop is accepted"); stop.assert_async().await; let resume = server @@ -1017,7 +1060,10 @@ mod tests { then.status(202); }) .await; - client.resume_sandbox("grp", "s1").await.expect("resume is accepted"); + client + .resume_sandbox("grp", "s1") + .await + .expect("resume is accepted"); resume.assert_async().await; } diff --git a/crates/alien-bindings/src/providers/sandbox/azure.rs b/crates/alien-bindings/src/providers/sandbox/azure.rs index d8c7b143c..06bb054b3 100644 --- a/crates/alien-bindings/src/providers/sandbox/azure.rs +++ b/crates/alien-bindings/src/providers/sandbox/azure.rs @@ -398,7 +398,8 @@ impl Sandbox for AzureSandbox { // The one file operation that moves the caller's own content in. A write-then-run against // an id kept across a tightened declaration would land the payload in a sandbox with the // egress the declaration just removed, and the refusal would arrive a beat later. - self.judged_session("sandbox.writeFiles", session_id).await?; + self.judged_session("sandbox.writeFiles", session_id) + .await?; // One request per path, stopping at the first failure: the same partial application every // other backend performs, so a caller sees one contract rather than five. @@ -432,7 +433,11 @@ impl Sandbox for AzureSandbox { // Accepted, not completed — the same contract the AWS backend follows. `get` reports // `Suspended` from the moment the stop is under way, so it answers "cannot take work", // not "has stopped"; only `terminate` confirms a session is actually gone. - let Err(error) = self.client.stop_sandbox(&self.sandbox_group, session_id).await else { + let Err(error) = self + .client + .stop_sandbox(&self.sandbox_group, session_id) + .await + else { return Ok(()); }; @@ -838,7 +843,11 @@ impl AzureSandbox { if !resumed_here { return reason; } - let Err(failed) = self.client.stop_sandbox(&self.sandbox_group, session_id).await else { + let Err(failed) = self + .client + .stop_sandbox(&self.sandbox_group, session_id) + .await + else { return reason; }; // A session that is already gone is the state this was trying to reach, and reporting it @@ -1023,9 +1032,7 @@ fn checked_session_env(operation: &str, env: &BTreeMap) -> Resul fn checked_env_name(operation: &str, name: &str) -> Result<()> { let usable = !name.is_empty() && !name.starts_with(|c: char| c.is_ascii_digit()) - && name - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_'); + && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'); if usable { return Ok(()); } @@ -1070,7 +1077,10 @@ fn checked_path(operation: &str, path: &str) -> Result { if relative.contains('\0') { return refused("contains a null byte"); } - if relative.split('/').any(|part| part == ".." || part.is_empty()) { + if relative + .split('/') + .any(|part| part == ".." || part.is_empty()) + { return refused("must not traverse"); } @@ -1818,7 +1828,10 @@ mod tests { .expect("a shell runs") }; - let plain = run(BTreeMap::from([("TOKEN".to_string(), "reached".to_string())])); + let plain = run(BTreeMap::from([( + "TOKEN".to_string(), + "reached".to_string(), + )])); assert_eq!( String::from_utf8_lossy(&plain.stdout), "reached", @@ -2073,7 +2086,10 @@ mod tests { .expect_write_file() .times(1) .returning(|_, _, path, _| { - assert_eq!(path, "a.txt", "the first path in order is the one attempted"); + assert_eq!( + path, "a.txt", + "the first path in order is the one attempted" + ); Err(AlienError::new(ClientErrorData::RemoteAccessDenied { resource_type: "sandbox".to_string(), resource_name: "s1".to_string(), @@ -2112,7 +2128,10 @@ mod tests { .await .expect_err("a missing file is an error"); assert_eq!(refused.code, "SANDBOX_COMMAND_FAILED", "{refused}"); - assert!(!refused.retryable, "repeating a refusal repeats it: {refused}"); + assert!( + !refused.retryable, + "repeating a refusal repeats it: {refused}" + ); let mut client = MockSandboxDataPlaneApi::new(); client.expect_read_file().times(1).returning(|_, _, _| { @@ -2125,7 +2144,10 @@ mod tests { .await .expect_err("an unavailable data plane is an error"); assert_eq!(unreachable.code, "SANDBOX_UNREACHABLE", "{unreachable}"); - assert!(unreachable.retryable, "a read is safe to repeat: {unreachable}"); + assert!( + unreachable.retryable, + "a read is safe to repeat: {unreachable}" + ); let mut client = MockSandboxDataPlaneApi::new(); settles_running(&mut client, None); @@ -2443,7 +2465,10 @@ mod tests { .times(1) .returning(move |_, _| Ok(running("s1", Some(echoed.clone())))); settles_running(&mut client, Some(elsewhere)); - client.expect_delete_sandbox().times(1).returning(|_, _| Ok(())); + client + .expect_delete_sandbox() + .times(1) + .returning(|_, _| Ok(())); let error = sandbox_denying( client, @@ -2565,7 +2590,10 @@ mod tests { .times(1) .returning(move |_, _| Ok(running("s1", Some(effective.clone())))); settles_running(&mut client, Some(came_up_with.clone())); - client.expect_delete_sandbox().times(1).returning(|_, _| Ok(())); + client + .expect_delete_sandbox() + .times(1) + .returning(|_, _| Ok(())); let error = sandbox_denying(client, asked_for()) .create(CreateSessionRequest::default()) @@ -2578,21 +2606,24 @@ mod tests { // The same policy without the extra permission creates normally, so the rule above is // refusing the addition rather than refusing everything. let mut client = MockSandboxDataPlaneApi::new(); - client.expect_create_sandbox().times(1).returning(move |_, _| { - Ok(running( - "s1", - Some(EgressPolicy { - default_action: "Deny".to_string(), - unmodelled: Default::default(), - host_rules: vec![EgressHostRule { - pattern: "api.example.com".to_string(), - action: "Allow".to_string(), - }], - rules: Vec::new(), - traffic_inspection: Some("Full".to_string()), - }), - )) - }); + client + .expect_create_sandbox() + .times(1) + .returning(move |_, _| { + Ok(running( + "s1", + Some(EgressPolicy { + default_action: "Deny".to_string(), + unmodelled: Default::default(), + host_rules: vec![EgressHostRule { + pattern: "api.example.com".to_string(), + action: "Allow".to_string(), + }], + rules: Vec::new(), + traffic_inspection: Some("Full".to_string()), + }), + )) + }); settles_running( &mut client, Some(EgressPolicy { @@ -2754,8 +2785,13 @@ mod tests { state: Some("Hibernated".to_string()), }) }; - client.expect_create_sandbox().times(1).returning(move |_, _| unreadable()); - client.expect_get_sandbox().returning(move |_, _| unreadable()); + client + .expect_create_sandbox() + .times(1) + .returning(move |_, _| unreadable()); + client + .expect_get_sandbox() + .returning(move |_, _| unreadable()); client .expect_delete_sandbox() .withf(|_, id| id == "orphan") @@ -2814,7 +2850,10 @@ mod tests { .times(1) .returning(move |_, _| Ok(running("s1", Some(effective.clone())))); settles_running(&mut client, Some(came_up_with.clone())); - client.expect_delete_sandbox().times(1).returning(|_, _| Ok(())); + client + .expect_delete_sandbox() + .times(1) + .returning(|_, _| Ok(())); let error = sandbox_denying(client, declared()) .create(CreateSessionRequest::default()) @@ -3507,7 +3546,10 @@ mod tests { // Safe to address once: reaped. let mut client = minted("x".repeat(80).leak()); - client.expect_delete_sandbox().times(1).returning(|_, _| Ok(())); + client + .expect_delete_sandbox() + .times(1) + .returning(|_, _| Ok(())); let error = sandbox_with(client) .create(CreateSessionRequest::default()) .await @@ -3599,15 +3641,18 @@ mod tests { }); let mut attempts = 0; - client.expect_resume_sandbox().times(2).returning(move |_, _| { - attempts += 1; - if attempts == 1 { - // The 409 a sandbox still stopping answers. - Err(http_error(409, "SandboxNotStopped")) - } else { - Ok(()) - } - }); + client + .expect_resume_sandbox() + .times(2) + .returning(move |_, _| { + attempts += 1; + if attempts == 1 { + // The 409 a sandbox still stopping answers. + Err(http_error(409, "SandboxNotStopped")) + } else { + Ok(()) + } + }); sandbox_with(client) .resume("racing-the-idle-policy") diff --git a/crates/alien-bindings/src/providers/sandbox/mod.rs b/crates/alien-bindings/src/providers/sandbox/mod.rs index e2fdb7ddd..ec861a27e 100644 --- a/crates/alien-bindings/src/providers/sandbox/mod.rs +++ b/crates/alien-bindings/src/providers/sandbox/mod.rs @@ -129,7 +129,8 @@ impl DeadlineReport { // line the command chose would be adopted in its place. No transport here delivers // one today; the cost of not depending on that is one trim. let line = line.strip_suffix('\r').unwrap_or(line); - let is_nonce = line.len() == NONCE_HEXITS && line.chars().all(|c| c.is_ascii_hexdigit()); + let is_nonce = + line.len() == NONCE_HEXITS && line.chars().all(|c| c.is_ascii_hexdigit()); is_nonce.then(|| { let after = stderr .split('\n') @@ -250,7 +251,10 @@ mod tests { #[test] fn only_the_session_can_report_a_deadline() { // The shell writes its own notice after the signal, so the repeat is not always last. - let killed = match DeadlineReport::read(Some(137), "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\nboom\na1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4Killed\n") { + let killed = match DeadlineReport::read( + Some(137), + "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\nboom\na1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4Killed\n", + ) { Bounded::Ran { killed, stderr } => { assert_eq!(stderr, "boom\nKilled\n"); killed @@ -261,7 +265,10 @@ mod tests { // A command echoing something nonce-shaped repeats nothing the session announced. assert!(matches!( - DeadlineReport::read(Some(0), "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\nboom\ndeadbeef\n"), + DeadlineReport::read( + Some(0), + "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\nboom\ndeadbeef\n" + ), Bounded::Ran { killed: false, .. } )); } @@ -329,8 +336,10 @@ mod tests { /// that beat its deadline into a deadline failure and throw away what it returned. #[test] fn a_command_that_finished_as_the_killer_fired_keeps_its_result() { - let Bounded::Ran { killed, stderr } = DeadlineReport::read(Some(0), "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\nboom\na1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4") - else { + let Bounded::Ran { killed, stderr } = DeadlineReport::read( + Some(0), + "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\nboom\na1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4", + ) else { panic!("the command ran"); }; assert!( @@ -405,7 +414,9 @@ mod tests { ); let run = std::process::Command::new("/bin/sh") .arg("-c") - .arg(DeadlineReport::bounded_program(std::time::Duration::from_secs(5))) + .arg(DeadlineReport::bounded_program( + std::time::Duration::from_secs(5), + )) .arg("sh") .arg("printenv") .arg("nonce") diff --git a/crates/alien-core/src/resources/sandbox.rs b/crates/alien-core/src/resources/sandbox.rs index 444e9d3df..00667916f 100644 --- a/crates/alien-core/src/resources/sandbox.rs +++ b/crates/alien-core/src/resources/sandbox.rs @@ -1041,9 +1041,18 @@ mod tests { .supervisor_isolation }; - assert!(value(Platform::Aws), "root agent setuids the command to 60000"); - assert!(value(Platform::Local), "the supervisor is on the host, outside the container"); - assert!(!value(Platform::Kubernetes), "a single pinned uid cannot be split"); + assert!( + value(Platform::Aws), + "root agent setuids the command to 60000" + ); + assert!( + value(Platform::Local), + "the supervisor is on the host, outside the container" + ); + assert!( + !value(Platform::Kubernetes), + "a single pinned uid cannot be split" + ); assert!(!value(Platform::Azure), "no Alien process runs the command"); assert!( !value(Platform::Gcp), @@ -1063,7 +1072,10 @@ mod tests { aws.supervisor_pid_namespace, gcp.supervisor_pid_namespace, "the older axis cannot tell them apart" ); - assert!(aws.supervisor_isolation, "AWS setuids the command off the supervisor"); + assert!( + aws.supervisor_isolation, + "AWS setuids the command off the supervisor" + ); assert!( !gcp.supervisor_isolation, "the command runs under no separate supervisor identity" @@ -1084,14 +1096,26 @@ mod tests { "generation is derived from the container boot id, so a session is reachable across \ processes" ); - assert!(!row.preview, "the only ingress is :execute; no port-scoped capability"); - assert!(row.suspend_resume, ":pause and :resume preserve the container"); - assert!(row.snapshot, "session state can be captured and restored into a new session"); + assert!( + !row.preview, + "the only ingress is :execute; no port-scoped capability" + ); + assert!( + row.suspend_resume, + ":pause and :resume preserve the container" + ); + assert!( + row.snapshot, + "session state can be captured and restored into a new session" + ); assert!( !row.domain_egress_rules, "VPC and DNS peering is not a hostname allowlist" ); - assert!(row.egress_deny, "a declared deny blocks both egress and DNS"); + assert!( + row.egress_deny, + "a declared deny blocks both egress and DNS" + ); assert!( row.enforced_limits, "ceilings are enforced, by terminating the session on breach" diff --git a/crates/alien-gcp-clients/src/gcp/agent_platform.rs b/crates/alien-gcp-clients/src/gcp/agent_platform.rs index 245e259af..c33e2201e 100644 --- a/crates/alien-gcp-clients/src/gcp/agent_platform.rs +++ b/crates/alien-gcp-clients/src/gcp/agent_platform.rs @@ -322,7 +322,10 @@ impl Debug for ConnectionInfo { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ConnectionInfo") .field("load_balancer_hostname", &self.load_balancer_hostname) - .field("routing_token", &self.routing_token.as_ref().map(|_| "[REDACTED]")) + .field( + "routing_token", + &self.routing_token.as_ref().map(|_| "[REDACTED]"), + ) .finish() } } @@ -440,7 +443,11 @@ pub trait AgentPlatformApi: Send + Sync + Debug { template: SandboxEnvironmentTemplate, ) -> Result; /// Read a template. Retries. - async fn get_template(&self, engine: &str, template: &str) -> Result; + async fn get_template( + &self, + engine: &str, + template: &str, + ) -> Result; /// List templates under an engine, following pagination. Retries. Lets a replace find the old /// template it must delete, and a resumed provision adopt what an interrupted one left. async fn list_templates(&self, engine: &str) -> Result>; @@ -448,7 +455,11 @@ pub trait AgentPlatformApi: Send + Sync + Debug { async fn delete_template(&self, engine: &str, template: &str) -> Result<()>; /// Create a sandbox. Single-attempt; returns the operation to poll. - async fn create_sandbox(&self, engine: &str, request: SandboxCreateRequest) -> Result; + async fn create_sandbox( + &self, + engine: &str, + request: SandboxCreateRequest, + ) -> Result; /// Read a sandbox. Retries. async fn get_sandbox(&self, engine: &str, sandbox: &str) -> Result; /// List sandboxes under an engine, following pagination. Retries. @@ -482,10 +493,7 @@ impl AgentPlatformClient { /// service override only when the config does not already carry one, so a test override wins. pub fn new(client: Client, config: GcpClientConfig) -> Self { let mut config = config; - let host = format!( - "https://{}-aiplatform.googleapis.com/v1", - config.region - ); + let host = format!("https://{}-aiplatform.googleapis.com/v1", config.region); config .service_overrides .get_or_insert_with(|| ServiceOverrides { @@ -535,11 +543,13 @@ impl AgentPlatformClient { let name = match &operation.name { Some(name) => name.clone(), None => { - return Err(AlienError::new(AgentPlatformErrorData::OperationIncomplete { - operation: "".to_string(), - attempts: 0, - last_state: "the operation carried no resource name".to_string(), - })) + return Err(AlienError::new( + AgentPlatformErrorData::OperationIncomplete { + operation: "".to_string(), + attempts: 0, + last_state: "the operation carried no resource name".to_string(), + }, + )) } }; @@ -555,11 +565,13 @@ impl AgentPlatformClient { if current.done == Some(true) { return Self::finish_operation::(current, &name); } - Err(AlienError::new(AgentPlatformErrorData::OperationIncomplete { - operation: name, - attempts: budget.max_attempts, - last_state: Self::describe_operation(¤t), - })) + Err(AlienError::new( + AgentPlatformErrorData::OperationIncomplete { + operation: name, + attempts: budget.max_attempts, + last_state: Self::describe_operation(¤t), + }, + )) } /// Poll a template until it reaches `ACTIVE`, or report `TemplateNotActive` with the last state. @@ -600,11 +612,13 @@ impl AgentPlatformClient { operation: format!("operation '{name}' response"), message: "response body did not match the expected type".to_string(), }), - None => Err(AlienError::new(AgentPlatformErrorData::OperationIncomplete { - operation: name.to_string(), - attempts: 0, - last_state: "operation reported done without a result".to_string(), - })), + None => Err(AlienError::new( + AgentPlatformErrorData::OperationIncomplete { + operation: name.to_string(), + attempts: 0, + last_state: "operation reported done without a result".to_string(), + }, + )), } } @@ -682,7 +696,11 @@ impl AgentPlatformApi for AgentPlatformClient { }) } - async fn get_template(&self, engine: &str, template: &str) -> Result { + async fn get_template( + &self, + engine: &str, + template: &str, + ) -> Result { let path = format!("{}/{}", self.templates_path(engine), template); self.base .execute_request(Method::GET, &path, None, Option::<()>::None, template) @@ -756,7 +774,11 @@ impl AgentPlatformApi for AgentPlatformClient { tolerate_not_found(result, "delete template") } - async fn create_sandbox(&self, engine: &str, request: SandboxCreateRequest) -> Result { + async fn create_sandbox( + &self, + engine: &str, + request: SandboxCreateRequest, + ) -> Result { let path = self.sandboxes_path(engine); self.base .execute_request_once(Method::POST, &path, None, Some(request), engine) @@ -1004,7 +1026,11 @@ mod tests { .create_engine("engine-display") .await .expect_err("create should surface the failure"); - assert_eq!(create.hits_async().await, 1, "create engine must be sent once"); + assert_eq!( + create.hits_async().await, + 1, + "create engine must be sent once" + ); } #[tokio::test] @@ -1012,7 +1038,8 @@ mod tests { let server = MockServer::start_async().await; let create = server .mock_async(|when, then| { - when.method(POST).path_contains("sandboxEnvironmentTemplates"); + when.method(POST) + .path_contains("sandboxEnvironmentTemplates"); then.status(503); }) .await; @@ -1020,7 +1047,11 @@ mod tests { .create_template(ENGINE, SandboxEnvironmentTemplate::default_for_test()) .await .expect_err("create should surface the failure"); - assert_eq!(create.hits_async().await, 1, "create template must be sent once"); + assert_eq!( + create.hits_async().await, + 1, + "create template must be sent once" + ); } #[tokio::test] @@ -1223,7 +1254,8 @@ mod tests { let poll = server .mock_async(|when, then| { when.method(GET).path(OP_PATH); - then.status(200).json_body_obj(&serde_json::json!({ "name": OP_NAME })); + then.status(200) + .json_body_obj(&serde_json::json!({ "name": OP_NAME })); }) .await; @@ -1245,7 +1277,11 @@ mod tests { "the error must name the operation for the caller to resume: {}", error.message ); - assert_eq!(poll.hits_async().await, 3, "polling must stop at the budget"); + assert_eq!( + poll.hits_async().await, + 3, + "polling must stop at the budget" + ); } /// An operation that completes with an error status reports `OperationFailed`, not success. @@ -1271,7 +1307,11 @@ mod tests { .await .expect_err("an errored operation must fail"); assert_eq!(error.code, "AGENT_PLATFORM_OPERATION_FAILED"); - assert!(error.message.contains("quota exhausted"), "{}", error.message); + assert!( + error.message.contains("quota exhausted"), + "{}", + error.message + ); } // ---- Delete tolerance. -------------------------------------------------------------------- @@ -1410,7 +1450,8 @@ mod tests { let server = MockServer::start_async().await; let list = server .mock_async(|when, then| { - when.method(GET).path_contains("sandboxEnvironmentTemplates"); + when.method(GET) + .path_contains("sandboxEnvironmentTemplates"); then.status(503); }) .await; @@ -1435,7 +1476,10 @@ mod tests { ) .expect("running sandbox parses"); assert_eq!( - running.connection_info.as_ref().and_then(|c| c.load_balancer_hostname.as_deref()), + running + .connection_info + .as_ref() + .and_then(|c| c.load_balancer_hostname.as_deref()), Some("h") ); @@ -1500,5 +1544,4 @@ mod tests { } } } - } diff --git a/crates/alien-gcp-clients/src/lib.rs b/crates/alien-gcp-clients/src/lib.rs index 9e98646e5..0d2f3fcb2 100644 --- a/crates/alien-gcp-clients/src/lib.rs +++ b/crates/alien-gcp-clients/src/lib.rs @@ -13,8 +13,8 @@ pub mod platform { // Re-export all client APIs pub use gcp::agent_platform::{ - AgentPlatformApi, AgentPlatformClient, AgentPlatformErrorData, ConnectionInfo, - PollBudget, SandboxCreateRequest, SandboxEnvironment, SandboxEnvironmentTemplate, + AgentPlatformApi, AgentPlatformClient, AgentPlatformErrorData, ConnectionInfo, PollBudget, + SandboxCreateRequest, SandboxEnvironment, SandboxEnvironmentTemplate, }; pub use gcp::artifactregistry::{ArtifactRegistryApi, ArtifactRegistryClient}; pub use gcp::cloud_kms::{CloudKmsApi, CloudKmsClient}; diff --git a/crates/alien-infra/src/core/registry.rs b/crates/alien-infra/src/core/registry.rs index 6449609ea..19605048c 100644 --- a/crates/alien-infra/src/core/registry.rs +++ b/crates/alien-infra/src/core/registry.rs @@ -777,9 +777,9 @@ impl ResourceRegistry { registry.register_controller_factory( alien_core::GcpAgentPlatformEngine::RESOURCE_TYPE, Platform::Gcp, - Box::new( - DefaultControllerFactory::::new(), - ), + Box::new(DefaultControllerFactory::< + crate::sandbox::GcpAgentPlatformEngineController, + >::new()), ); // Register the GCP Agent Platform sandbox (template) controller. @@ -787,9 +787,9 @@ impl ResourceRegistry { registry.register_controller_factory( alien_core::Sandbox::RESOURCE_TYPE, Platform::Gcp, - Box::new( - DefaultControllerFactory::::new(), - ), + Box::new(DefaultControllerFactory::< + crate::sandbox::GcpAgentPlatformTemplateController, + >::new()), ); // Register KubernetesCluster controller. The cluster is selected or diff --git a/crates/alien-infra/src/sandbox/gcp_agent_platform_template.rs b/crates/alien-infra/src/sandbox/gcp_agent_platform_template.rs index 9ba568b8a..f022e0445 100644 --- a/crates/alien-infra/src/sandbox/gcp_agent_platform_template.rs +++ b/crates/alien-infra/src/sandbox/gcp_agent_platform_template.rs @@ -862,8 +862,8 @@ mod tests { .get_binding_params() .expect("binding serializes") .expect("a running template has a binding"); - let binding: alien_core::bindings::SandboxBinding = - serde_json::from_value(params).expect("binding parses back to the Agent Platform binding type"); + let binding: alien_core::bindings::SandboxBinding = serde_json::from_value(params) + .expect("binding parses back to the Agent Platform binding type"); match binding { alien_core::bindings::SandboxBinding::GcpAgentPlatform(b) => { diff --git a/crates/alien-infra/src/sandbox/local.rs b/crates/alien-infra/src/sandbox/local.rs index 3d7cd72a4..9046e89ab 100644 --- a/crates/alien-infra/src/sandbox/local.rs +++ b/crates/alien-infra/src/sandbox/local.rs @@ -65,12 +65,13 @@ impl LocalSandboxController { // The session template is fixed here rather than accepted per create: a client-supplied // limit is a limit the client can decline to send, and this sandbox runs its code. - let route = alien_local::SandboxRoute::ensure(manager, &config.id, session_template(&config)?) - .await - .context(ErrorData::CloudPlatformError { - message: "Failed to serve the local sandbox route".to_string(), - resource_id: Some(config.id.clone()), - })?; + let route = + alien_local::SandboxRoute::ensure(manager, &config.id, session_template(&config)?) + .await + .context(ErrorData::CloudPlatformError { + message: "Failed to serve the local sandbox route".to_string(), + resource_id: Some(config.id.clone()), + })?; self.route_url = Some(route.base_url.clone()); self.token_path = Some(route.token_path.display().to_string()); @@ -110,13 +111,14 @@ impl LocalSandboxController { // "Healthy" on a platform with nothing durable means the runtime is still there to // create sessions in; the session count itself is not a health signal. - let sessions = manager - .list_sessions(&config.id) - .await - .context(ErrorData::CloudPlatformError { - message: "Docker sandbox health check failed".to_string(), - resource_id: Some(config.id.clone()), - })?; + let sessions = + manager + .list_sessions(&config.id) + .await + .context(ErrorData::CloudPlatformError { + message: "Docker sandbox health check failed".to_string(), + resource_id: Some(config.id.clone()), + })?; debug!(sandbox_id = %config.id, sessions = sessions.len(), "Sandbox health check passed"); @@ -261,12 +263,14 @@ impl LocalSandboxController { BindingValue::value(token_path.clone()), ); - Ok(Some(serde_json::to_value(binding).into_alien_error().context( - ErrorData::ResourceStateSerializationFailed { - resource_id: "binding".to_string(), - message: "Failed to serialize sandbox binding parameters".to_string(), - }, - )?)) + Ok(Some( + serde_json::to_value(binding).into_alien_error().context( + ErrorData::ResourceStateSerializationFailed { + resource_id: "binding".to_string(), + message: "Failed to serialize sandbox binding parameters".to_string(), + }, + )?, + )) } } @@ -450,7 +454,10 @@ mod tests { let registry = crate::core::ResourceRegistry::with_built_ins(); let controller = registry - .get_controller(alien_core::Sandbox::RESOURCE_TYPE, alien_core::Platform::Local) + .get_controller( + alien_core::Sandbox::RESOURCE_TYPE, + alien_core::Platform::Local, + ) .expect("Local must have a registered Sandbox controller"); assert_eq!(controller.controller_type(), "LocalSandboxController"); } diff --git a/crates/alien-infra/src/worker/gcp.rs b/crates/alien-infra/src/worker/gcp.rs index fc63eb2cc..0271b9456 100644 --- a/crates/alien-infra/src/worker/gcp.rs +++ b/crates/alien-infra/src/worker/gcp.rs @@ -5699,8 +5699,8 @@ mod tests { is_cross_project_image_pull_permission_error, CLOUD_RUN_SERVICE_NAME_MAX_LEN, GCP_RESOURCE_NAME_MAX_LEN, }; - use crate::core::MockPlatformServiceProvider; use crate::core::controller_test::SingleControllerExecutor; + use crate::core::MockPlatformServiceProvider; use crate::worker::readiness_probe::test_utils::create_readiness_probe_mock; use crate::worker::{fixtures::*, GcpWorkerController}; use crate::GcpWorkerState; diff --git a/crates/alien-sandbox-agent/src/lib.rs b/crates/alien-sandbox-agent/src/lib.rs index 84fbcc5f0..030446ba9 100644 --- a/crates/alien-sandbox-agent/src/lib.rs +++ b/crates/alien-sandbox-agent/src/lib.rs @@ -1,11 +1,11 @@ pub mod confine; pub mod error; pub mod exec; -pub mod jobs; -pub mod pid_namespace; pub mod files; +pub mod jobs; pub mod paths; pub mod peer; +pub mod pid_namespace; #[cfg(unix)] pub mod privilege; pub mod server; diff --git a/crates/alien-sandbox-agent/src/server.rs b/crates/alien-sandbox-agent/src/server.rs index 085f7b484..fa5f002c5 100644 --- a/crates/alien-sandbox-agent/src/server.rs +++ b/crates/alien-sandbox-agent/src/server.rs @@ -470,9 +470,12 @@ async fn job_start( None => state.session_root.clone(), }; - let job_id = state - .jobs - .start(request, working_directory, state.exec_identity, state.output_cap)?; + let job_id = state.jobs.start( + request, + working_directory, + state.exec_identity, + state.output_cap, + )?; Ok(Json(JobStartResponse { job_id })) } @@ -486,11 +489,14 @@ async fn job_poll( ) -> std::result::Result, ApiError> { authorize(&state, peer, &headers, SandboxOperationClass::Execute)?; - let snapshot = state.jobs.poll(&body.job_id, body.since_seq).ok_or_else(|| { - ApiError::from(AlienError::new(ErrorData::JobNotFound { - job_id: body.job_id.clone(), - })) - })?; + let snapshot = state + .jobs + .poll(&body.job_id, body.since_seq) + .ok_or_else(|| { + ApiError::from(AlienError::new(ErrorData::JobNotFound { + job_id: body.job_id.clone(), + })) + })?; Ok(Json(JobPollResponse::from(snapshot))) } @@ -554,34 +560,66 @@ async fn agent_platform( // handler takes; that works only because those types ignore the envelope's `v`/`op`. Adding // `deny_unknown_fields` to one would break this dispatch at runtime, with nothing to catch it. match op.as_str() { - "exec" => run_command(State(state), ConnectInfo(peer), headers, Json(reparse(&body)?)).await, - "readFile" => Ok(read_file(State(state), ConnectInfo(peer), headers, Query(reparse(&body)?)) - .await? - .into_response()), - "writeFile" => Ok( - write_file(State(state), ConnectInfo(peer), headers, Json(reparse(&body)?)) - .await? - .into_response(), - ), - "mkdir" => Ok(mkdir(State(state), ConnectInfo(peer), headers, Json(reparse(&body)?)) + "exec" => { + run_command( + State(state), + ConnectInfo(peer), + headers, + Json(reparse(&body)?), + ) + .await + } + "readFile" => Ok(read_file( + State(state), + ConnectInfo(peer), + headers, + Query(reparse(&body)?), + ) + .await? + .into_response()), + "writeFile" => Ok(write_file( + State(state), + ConnectInfo(peer), + headers, + Json(reparse(&body)?), + ) + .await? + .into_response()), + "mkdir" => Ok(mkdir( + State(state), + ConnectInfo(peer), + headers, + Json(reparse(&body)?), + ) + .await? + .into_response()), + "jobStart" => Ok(job_start( + State(state), + ConnectInfo(peer), + headers, + Json(reparse(&body)?), + ) + .await? + .into_response()), + "jobPoll" => Ok(job_poll( + State(state), + ConnectInfo(peer), + headers, + Json(reparse(&body)?), + ) + .await? + .into_response()), + "jobCancel" => Ok(job_cancel( + State(state), + ConnectInfo(peer), + headers, + Json(reparse(&body)?), + ) + .await? + .into_response()), + "health" => Ok(health(Query(HealthQuery { version: None })) .await? .into_response()), - "jobStart" => Ok( - job_start(State(state), ConnectInfo(peer), headers, Json(reparse(&body)?)) - .await? - .into_response(), - ), - "jobPoll" => Ok( - job_poll(State(state), ConnectInfo(peer), headers, Json(reparse(&body)?)) - .await? - .into_response(), - ), - "jobCancel" => Ok( - job_cancel(State(state), ConnectInfo(peer), headers, Json(reparse(&body)?)) - .await? - .into_response(), - ), - "health" => Ok(health(Query(HealthQuery { version: None })).await?.into_response()), other => Err(ApiError::from(AlienError::new(ErrorData::RequestInvalid { reason: format!("unknown op '{other}'"), }))), diff --git a/crates/alien-sandbox-agent/tests/protocol.rs b/crates/alien-sandbox-agent/tests/protocol.rs index 6a98e1ce3..133b1d3a3 100644 --- a/crates/alien-sandbox-agent/tests/protocol.rs +++ b/crates/alien-sandbox-agent/tests/protocol.rs @@ -74,11 +74,11 @@ impl Agent { tokio::spawn(async move { axum::serve( - listener, - router(served).into_make_service_with_connect_info::(), - ) - .await - .expect("serve"); + listener, + router(served).into_make_service_with_connect_info::(), + ) + .await + .expect("serve"); }); Self { @@ -288,7 +288,10 @@ async fn a_command_streams_its_output_and_a_real_exit_code() { let frames = frames(&response.text().await.expect("body")); let terminal = frames.last().expect("terminal"); assert_eq!(terminal["t"], "exit"); - assert_eq!(terminal["code"], 7, "the real exit code, not a normalised one"); + assert_eq!( + terminal["code"], 7, + "the real exit code, not a normalised one" + ); let decoded: Vec = frames .iter() @@ -363,7 +366,10 @@ async fn path_traversal_is_refused_over_the_protocol() { let client = reqwest::Client::new(); let read = client - .get(format!("{}/v1/files?path=/../../etc/passwd", agent.base_url)) + .get(format!( + "{}/v1/files?path=/../../etc/passwd", + agent.base_url + )) .bearer_auth(agent.capability()) .send() .await @@ -427,7 +433,10 @@ async fn transport_authorization_needs_no_capability() { authorization: AgentAuthorization::Transport, // Not the test's own uid: the agent and the code it runs are different users in a real // image, and the caller here stands in for one arriving through the transport. - exec_identity: ExecIdentity { uid: 60000, gid: 60000 }, + exec_identity: ExecIdentity { + uid: 60000, + gid: 60000, + }, output_cap: 1 << 20, jobs: JobRegistry::new(), }); @@ -496,7 +505,11 @@ async fn transport_authorization_refuses_the_code_the_agent_runs() { .await .expect("responds"); - assert_eq!(response.status(), 403, "the agent must not serve its own supervised code"); + assert_eq!( + response.status(), + 403, + "the agent must not serve its own supervised code" + ); assert!( !root.join("work").exists(), "a refused request must not have done its work anyway" @@ -560,7 +573,9 @@ async fn exec_through_the_envelope_is_byte_identical_to_v1() { let enveloped = client .post(format!("{}/", agent.base_url)) .bearer_auth(agent.capability()) - .json(&json!({"v": 1, "op": "exec", "command": ["/bin/echo", "hello"], "deadlineMs": 10_000})) + .json( + &json!({"v": 1, "op": "exec", "command": ["/bin/echo", "hello"], "deadlineMs": 10_000}), + ) .send() .await .expect("responds"); @@ -652,7 +667,10 @@ async fn mkdir_through_the_envelope_is_byte_identical_to_v1_and_lands() { .expect("responds"); assert_eq!(wire(versioned).await, wire(enveloped).await); - assert!(agent.root.join("work/env").is_dir(), "the envelope mkdir landed"); + assert!( + agent.root.join("work/env").is_dir(), + "the envelope mkdir landed" + ); } /// `health` carries no capability on either route, so the envelope arm must reach it without one — @@ -689,7 +707,11 @@ async fn an_absent_op_is_refused() { assert_eq!(response.status(), 400); assert!( - response.text().await.expect("body").contains("must name an 'op'"), + response + .text() + .await + .expect("body") + .contains("must name an 'op'"), "the refusal must say an op is required" ); } @@ -729,7 +751,8 @@ async fn an_unsupported_version_is_refused() { assert_eq!(response.status(), 400); let body = response.text().await.expect("body"); assert!( - body.contains(&format!("v{}", PROTOCOL_VERSION + 1)) && body.contains(&format!("v{PROTOCOL_VERSION}")), + body.contains(&format!("v{}", PROTOCOL_VERSION + 1)) + && body.contains(&format!("v{PROTOCOL_VERSION}")), "the error must name both versions: {body}" ); } @@ -774,7 +797,11 @@ async fn the_envelope_refuses_the_code_the_agent_runs_under_transport() { .await .expect("responds"); - assert_eq!(response.status(), 403, "the envelope must not serve the agent's own supervised code"); + assert_eq!( + response.status(), + 403, + "the envelope must not serve the agent's own supervised code" + ); assert!( !root.join("work").exists(), "a refused envelope must not have done its work anyway" @@ -832,9 +859,8 @@ async fn a_job_completes_and_its_output_is_polled_across_calls() { since = Some(since.map_or(seq, |s| s.max(seq))); } if let Some(data) = frame["data"].as_str() { - collected.push( - String::from_utf8(BASE64.decode(data).expect("base64")).expect("utf8"), - ); + collected + .push(String::from_utf8(BASE64.decode(data).expect("base64")).expect("utf8")); } } @@ -846,8 +872,14 @@ async fn a_job_completes_and_its_output_is_polled_across_calls() { tokio::time::sleep(std::time::Duration::from_millis(25)).await; } - assert!(!running, "the job must reach a terminal state within the poll budget"); - let text: Vec<&str> = collected.iter().flat_map(|line| line.split_whitespace()).collect(); + assert!( + !running, + "the job must reach a terminal state within the poll budget" + ); + let text: Vec<&str> = collected + .iter() + .flat_map(|line| line.split_whitespace()) + .collect(); assert_eq!(text, vec!["one", "two"], "every line survives being polled"); } @@ -904,5 +936,8 @@ async fn starting_a_job_without_a_capability_is_refused() { .expect("responds"); assert_eq!(response.status(), 401); - assert!(agent.state.jobs.is_empty(), "a refused start creates no job"); + assert!( + agent.state.jobs.is_empty(), + "a refused start creates no job" + ); } diff --git a/crates/alien-terraform/src/built_ins.rs b/crates/alien-terraform/src/built_ins.rs index 1b25fd9ca..b7a07f5d6 100644 --- a/crates/alien-terraform/src/built_ins.rs +++ b/crates/alien-terraform/src/built_ins.rs @@ -89,7 +89,11 @@ fn register_gcp(registry: &mut TfRegistry) { ); registry.register(Build::RESOURCE_TYPE, p, gcp::GcpBuildEmitter); registry.register(Worker::RESOURCE_TYPE, p, gcp::GcpWorkerEmitter); - registry.register(Sandbox::RESOURCE_TYPE, p, gcp::GcpAgentPlatformSandboxEmitter); + registry.register( + Sandbox::RESOURCE_TYPE, + p, + gcp::GcpAgentPlatformSandboxEmitter, + ); registry.register( ServiceActivation::RESOURCE_TYPE, p, diff --git a/crates/alien-terraform/src/emitters/azure/sandbox.rs b/crates/alien-terraform/src/emitters/azure/sandbox.rs index a5e516bf7..46d9092d1 100644 --- a/crates/alien-terraform/src/emitters/azure/sandbox.rs +++ b/crates/alien-terraform/src/emitters/azure/sandbox.rs @@ -130,7 +130,10 @@ mod tests { ResourceLifecycle::Frozen, ) .build(); - let resource = stack.resources.get("agents").expect("the sandbox is in the stack"); + let resource = stack + .resources + .get("agents") + .expect("the sandbox is in the stack"); let names = IndexMap::from([("agents".to_string(), "agents".to_string())]); let settings = StackSettings::default(); let ctx = EmitContext { @@ -218,9 +221,6 @@ mod tests { assert!(declared.contains("idleSuspendSeconds = 900"), "{declared}"); let undeclared = binding_with(SandboxEgress::Allow, None); - assert!( - !undeclared.contains("idleSuspendSeconds"), - "{undeclared}" - ); + assert!(!undeclared.contains("idleSuspendSeconds"), "{undeclared}"); } } diff --git a/crates/alien-terraform/src/emitters/gcp/sandbox.rs b/crates/alien-terraform/src/emitters/gcp/sandbox.rs index 5b4866f9e..9c4212034 100644 --- a/crates/alien-terraform/src/emitters/gcp/sandbox.rs +++ b/crates/alien-terraform/src/emitters/gcp/sandbox.rs @@ -106,7 +106,9 @@ mod tests { mod agent_platform { use super::super::*; use alien_core::bindings::SandboxBinding; - use alien_core::{ResourceLifecycle, SandboxCode, SandboxSessionPolicy, Stack, StackSettings}; + use alien_core::{ + ResourceLifecycle, SandboxCode, SandboxSessionPolicy, Stack, StackSettings, + }; use indexmap::IndexMap; use std::collections::BTreeSet; @@ -126,7 +128,10 @@ mod tests { ResourceLifecycle::Frozen, ) .build(); - let resource = stack.resources.get("agents").expect("the sandbox is in the stack"); + let resource = stack + .resources + .get("agents") + .expect("the sandbox is in the stack"); let names = IndexMap::from([("agents".to_string(), "agents".to_string())]); let settings = StackSettings::default(); let ctx = EmitContext { diff --git a/crates/alien-test/tests/gcp_agent_platform_sandbox_live.rs b/crates/alien-test/tests/gcp_agent_platform_sandbox_live.rs index 836a74e94..8867c759d 100644 --- a/crates/alien-test/tests/gcp_agent_platform_sandbox_live.rs +++ b/crates/alien-test/tests/gcp_agent_platform_sandbox_live.rs @@ -685,7 +685,12 @@ async fn egress_deny_blocks_the_network_including_dns() { // DNS alone, and the resolver's own exit code is captured so a missing binary (127) cannot be // mistaken for a blocked network — that mistake is exactly the false PASS this row must avoid. - let resolve = shell(&provider, &sid, "getent hosts github.com >/dev/null 2>&1; echo rc=$?").await; + let resolve = shell( + &provider, + &sid, + "getent hosts github.com >/dev/null 2>&1; echo rc=$?", + ) + .await; assert_eq!(resolve.exit_code, 0, "the probe wrapper itself runs"); let stdout = String::from_utf8_lossy(&resolve.stdout); let rc: i32 = stdout @@ -693,7 +698,10 @@ async fn egress_deny_blocks_the_network_including_dns() { .strip_prefix("rc=") .and_then(|code| code.parse().ok()) .unwrap_or_else(|| panic!("the probe reported no resolver exit code: {stdout}")); - assert_ne!(rc, 127, "the resolver must exist, so a nonzero code is a blocked network, not a missing binary"); + assert_ne!( + rc, 127, + "the resolver must exist, so a nonzero code is a blocked network, not a missing binary" + ); assert_ne!(rc, 0, "a closed sandbox cannot resolve github.com"); provider @@ -784,10 +792,7 @@ async fn snapshot_restore_carries_pre_snapshot_state_only() { "the pre-snapshot state is present" ); assert!( - provider - .read_file(&restored_id, "/after") - .await - .is_err(), + provider.read_file(&restored_id, "/after").await.is_err(), "the post-snapshot mutation is absent from the restore" ); @@ -838,7 +843,11 @@ async fn sweep_orphaned_engines() { .filter(|line| !line.is_empty()) .map(|line| last_segment(line).to_string()) .collect(); - for engine in client.list_engines().await.expect("listing engines to sweep") { + for engine in client + .list_engines() + .await + .expect("listing engines to sweep") + { let matches_suite = engine .display_name .as_deref()