From ff9ec2ab0361ae71194638bdbcc75ce564c03bc2 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:50:45 +0300 Subject: [PATCH 01/21] feat(permissions): map the sandbox sets to GCP aiplatform grants Add a gcp block to each of the four sandbox permission sets. The split keeps the one content-reaching verb, sandboxEnvironments.execute, in execute alone; heartbeat, management and provision never reach a live session. A test pins that execute appears in sandbox/execute and no other set, and the sensitive-content invariant covers GCP alongside AWS and Azure. The preview sandboxEnvironment(s) permissions are absent from the upstream IAM dataset, so the validation test allowlists them exactly while leaving the published reasoningEngines permissions gated. --- .../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 ++++++++++- 7 files changed, 162 insertions(+), 6 deletions(-) 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..9241e3b35 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) and release-owned template: create, delete, and the + // reads a resumed provision needs to adopt what an interrupted one left. 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", + "aiplatform.reasoningEngines.get", + "aiplatform.reasoningEngines.list" + ] + }, + "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 From e929e34d7c59ce00cb91a365717f1742a9497ee1 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:49:34 +0300 Subject: [PATCH 02/21] feat(sandbox-agent): serve an explicit envelope on POST / MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GCP Agent Platform proxies its :execute call to POST / with the request body verbatim and can set neither path nor method, so the per-operation /v1/* routes are unreachable there. Add a single POST / endpoint whose required `op` selects exec, readFile, writeFile, mkdir or health, and whose `v` is reconciled before dispatch. Each arm hands off to the matching /v1/* handler, so the response — the exec NDJSON stream included — is byte-identical and authorization stays that handler's job. An absent or unknown op and an unsupported version are refused with a typed error rather than defaulted. Additive: /v1/* is unchanged, so AWS, Kubernetes and Local are unaffected. --- crates/alien-sandbox-agent/src/server.rs | 86 ++++++- crates/alien-sandbox-agent/tests/protocol.rs | 257 +++++++++++++++++++ 2 files changed, 342 insertions(+), 1 deletion(-) diff --git a/crates/alien-sandbox-agent/src/server.rs b/crates/alien-sandbox-agent/src/server.rs index 24bde745a..11e14c04a 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; @@ -135,6 +135,16 @@ pub struct MkdirBody { pub path: 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,6 +162,11 @@ 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)) + // 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) } @@ -299,6 +314,75 @@ async fn mkdir( Ok(StatusCode::NO_CONTENT) } +/// 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()), + "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..bedd75f57 100644 --- a/crates/alien-sandbox-agent/tests/protocol.rs +++ b/crates/alien-sandbox-agent/tests/protocol.rs @@ -508,3 +508,260 @@ 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, + }); + + 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" + ); +} From 0e65bd151a00bba1abfa76e49a35780f9f5a5b33 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:53:02 +0300 Subject: [PATCH 03/21] feat(sandbox): add a supervisor-isolation capability bit Two backends that report the same supervisorPidNamespace can still differ on whether the process supervising a command is a separate identity from it, and that difference is a security property. Publish it as its own bit so a caller comparing capability sets is not told they are equivalent. --- crates/alien-bindings-node/src/sandbox.rs | 2 + crates/alien-core/src/resources/sandbox.rs | 69 ++++++++++++++++++- .../schemas/sandboxCapabilities.json | 2 +- .../generated/schemas/sandboxCapability.json | 2 +- .../zod/sandbox-capabilities-schema.ts | 1 + .../zod/sandbox-capability-schema.ts | 2 +- packages/core/src/sandbox.ts | 3 +- 7 files changed, 76 insertions(+), 5 deletions(-) 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-core/src/resources/sandbox.rs b/crates/alien-core/src/resources/sandbox.rs index fbd4ce004..7955c3472 100644 --- a/crates/alien-core/src/resources/sandbox.rs +++ b/crates/alien-core/src/resources/sandbox.rs @@ -201,6 +201,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 +236,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,6 +265,9 @@ 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 @@ -273,6 +285,9 @@ impl SandboxCapabilities { session_lifetime: false, // A Cloud Run sandbox is a subprocess of the workload; nothing of ours is inside. supervisor_pid_namespace: false, + // The session is a subprocess of the workload, which runs it directly: no separate + // identity supervises the command. + supervisor_isolation: false, }), // Preview needs a gateway that validates a session-and-port capability, and that // gateway does not exist yet. @@ -293,6 +308,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 +327,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 { @@ -332,6 +356,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 +397,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 +416,7 @@ impl SandboxCapability { Self::ProcessLimit => "processLimit", Self::SessionLifetime => "sessionLifetime", Self::SupervisorPidNamespace => "supervisorPidNamespace", + Self::SupervisorIsolation => "supervisorIsolation", } } } @@ -965,6 +993,45 @@ 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 Cloud Run have no in-sandbox supervisor at all. + #[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), "the session is a subprocess of the workload"); + } + + /// The point of the field: AWS and Cloud Run 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 Cloud Run 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, "Cloud Run runs the command as the workload itself"); + } + #[test] fn platforms_without_a_backend_are_an_error_not_an_empty_set() { let error = SandboxCapabilities::for_platform(Platform::Machines) 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/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/sandbox.ts b/packages/core/src/sandbox.ts index 848045031..f3e2ccd08 100644 --- a/packages/core/src/sandbox.ts +++ b/packages/core/src/sandbox.ts @@ -34,7 +34,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), 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. * * 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 515242ed158809537f7f7eaa263770f1b4344114 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:07:08 +0300 Subject: [PATCH 04/21] feat(gcp-clients): add Agent Platform sandbox client Client for the Vertex AI Agent Platform sandbox REST API: engines, templates (immutable config), sandboxes, execute, and the pause/resume/ snapshot transitions, over the regional aiplatform host under a parent reasoning engine. Retry classification is the load-bearing part. create/execute/pause/ resume/snapshot deliver exactly once through a new single-attempt transport path, so a network hiccup surfaces to the caller instead of minting an orphan or repeating a state transition; get/list retry; delete retries and treats a not-found as done. Execute request bodies are redacted out of any error chain, and long-running operations are polled within a bounded budget that reports the last error rather than a bare timeout. --- .../src/gcp/agent_platform.rs | 1319 +++++++++++++++++ .../alien-gcp-clients/src/gcp/api_client.rs | 36 + .../src/gcp/gcp_request_utils.rs | 18 + crates/alien-gcp-clients/src/gcp/mod.rs | 1 + crates/alien-gcp-clients/src/lib.rs | 4 + 5 files changed, 1378 insertions(+) create mode 100644 crates/alien-gcp-clients/src/gcp/agent_platform.rs 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..43151bed1 --- /dev/null +++ b/crates/alien-gcp-clients/src/gcp/agent_platform.rs @@ -0,0 +1,1319 @@ +//! 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, +} + +// ================================================================================================= +// 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<()>; + + /// 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; + /// 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 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" + ); + } + + // ---- 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/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}; From e12fb230f609dc4d82427562d7c622a5c3319e25 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:22:47 +0300 Subject: [PATCH 05/21] feat(sandbox-agent): run long commands as detached, pollable jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single proxied call to POST / stays open only ~30s regardless of the deadline asked for, so a command that runs longer cannot answer in one call. Add jobStart/jobPoll/jobCancel — as /v1 routes and as envelope ops — so a command runs detached under the agent's own deadline while its output is polled across as many short calls as it takes. jobPoll returns frames strictly after a caller-supplied sequence so a retried poll neither duplicates nor drops output. jobCancel reuses the existing process-group kill path by dropping the collector's receiver. Retention is bounded: finished jobs are kept for replay until evicted under a per-session cap or the session ends. The synchronous exec path is untouched; choosing job vs exec is the provider's call, not the agent's. --- Cargo.lock | 1 + crates/alien-sandbox-agent/Cargo.toml | 1 + crates/alien-sandbox-agent/src/error.rs | 26 + crates/alien-sandbox-agent/src/jobs.rs | 625 +++++++++++++++++++ crates/alien-sandbox-agent/src/lib.rs | 1 + crates/alien-sandbox-agent/src/main.rs | 2 + crates/alien-sandbox-agent/src/server.rs | 167 +++++ crates/alien-sandbox-agent/tests/protocol.rs | 135 +++- 8 files changed, 957 insertions(+), 1 deletion(-) create mode 100644 crates/alien-sandbox-agent/src/jobs.rs 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-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/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..e03339549 --- /dev/null +++ b/crates/alien-sandbox-agent/src/jobs.rs @@ -0,0 +1,625 @@ +//! 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, +} + +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 finished job is the one evicted 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, + }), + 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 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(); + Some(JobSnapshot { + frames, + outcome: buffer.outcome.clone(), + }) + } + + /// 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 + } + + 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(()); + } + + let oldest_finished = jobs + .iter() + .filter(|(_, job)| { + job.buffer + .lock() + .expect("no panic holds a job lock") + .outcome + .is_some() + }) + .min_by_key(|(_, job)| job.ordinal) + .map(|(id, _)| id.clone()); + + match oldest_finished { + 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; + 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 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-but-uncollected job 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" + ); + } + + /// 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..267f540f9 100644 --- a/crates/alien-sandbox-agent/src/main.rs +++ b/crates/alien-sandbox-agent/src/main.rs @@ -12,6 +12,7 @@ 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 base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine as _; @@ -121,6 +122,7 @@ fn load_state() -> Result { authorization: load_authorization()?, exec_identity, output_cap, + jobs: JobRegistry::new(), }) } diff --git a/crates/alien-sandbox-agent/src/server.rs b/crates/alien-sandbox-agent/src/server.rs index 11e14c04a..43ca34660 100644 --- a/crates/alien-sandbox-agent/src/server.rs +++ b/crates/alien-sandbox-agent/src/server.rs @@ -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,6 +84,8 @@ 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. @@ -135,6 +138,90 @@ 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")] @@ -162,6 +249,9 @@ 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 @@ -314,6 +404,68 @@ 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 @@ -367,6 +519,21 @@ async fn agent_platform( "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}'"), diff --git a/crates/alien-sandbox-agent/tests/protocol.rs b/crates/alien-sandbox-agent/tests/protocol.rs index bedd75f57..7f544c0b3 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, } } @@ -417,6 +423,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")) @@ -765,3 +772,129 @@ async fn the_envelope_refuses_the_code_the_agent_runs_under_transport() { "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"); +} From de8017f2abbcc859d28821278775a8c26afb2a61 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:30:30 +0300 Subject: [PATCH 06/21] feat(sandbox): add the GCP Agent Platform binding and capability row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the GcpAgentPlatform sandbox binding — engine, template and region reach a durable Agent Engine and its regional endpoint, with an optional session ttl. The three required fields carry no serde default: the template is egress-bearing, so a binding missing one must fail to load rather than deserialize into a session with no image, no limits and open egress. A test pins that, and that an absent ttl still parses. Publish the Agent Platform capability row as gcp_agent_platform(), asserted value by value against measured behaviour. It is deliberately not returned by for_platform(Platform::Gcp) yet, which still reports the registered Cloud Run backend; it becomes that arm's body once the backend is swapped in. reconnect stays false until a stable session generation is wired, and the test that asserts it is the tripwire that forces both to flip together. --- crates/alien-bindings/src/provider.rs | 9 +++ crates/alien-core/src/bindings/mod.rs | 4 +- crates/alien-core/src/bindings/sandbox.rs | 92 ++++++++++++++++++++++ crates/alien-core/src/resources/sandbox.rs | 82 +++++++++++++++++++ 4 files changed, 185 insertions(+), 2 deletions(-) diff --git a/crates/alien-bindings/src/provider.rs b/crates/alien-bindings/src/provider.rs index 1099a3ee3..f163868b6 100644 --- a/crates/alien-bindings/src/provider.rs +++ b/crates/alien-bindings/src/provider.rs @@ -2026,6 +2026,15 @@ impl BindingsProviderApi for BindingsProvider { SandboxBinding::Kubernetes(_) => Err(not_built("kubernetes")), #[cfg(not(feature = "local"))] SandboxBinding::Local(_) => Err(not_built("local")), + // The binding type exists so Agent Platform declarations can be written and emitted, + // but the runtime loader for that backend is not wired yet. + SandboxBinding::GcpAgentPlatform(_) => { + Err(AlienError::new(ErrorData::OperationNotSupported { + operation: "load_sandbox(gcp-agent-platform)".to_string(), + reason: "no runtime loader for the Agent Platform sandbox backend yet" + .to_string(), + })) + } } } } diff --git a/crates/alien-core/src/bindings/mod.rs b/crates/alien-core/src/bindings/mod.rs index b97386e1c..98a11b042 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, GcpSandboxBinding, + 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..d2f9c604d 100644 --- a/crates/alien-core/src/bindings/sandbox.rs +++ b/crates/alien-core/src/bindings/sandbox.rs @@ -25,6 +25,9 @@ pub enum SandboxBinding { /// 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), @@ -127,6 +130,28 @@ pub struct GcpSandboxBinding { pub allow_egress: BindingValue, } +/// GCP Agent Platform sandbox binding configuration. +/// +/// Unlike the Cloud Run backend, sessions have a durable parent to address: an Agent Engine +/// provisioned at setup and reached through a regional endpoint. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +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. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -213,6 +238,21 @@ impl SandboxBinding { }) } + /// 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::GcpAgentPlatform(GcpAgentPlatformSandboxBinding { + engine: engine.into(), + template: template.into(), + region: region.into(), + session_ttl_seconds, + }) + } + /// Creates a Kubernetes sandbox binding. pub fn kubernetes( namespace: impl Into>, @@ -269,6 +309,12 @@ mod tests { 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 +337,58 @@ 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/resources/sandbox.rs b/crates/alien-core/src/resources/sandbox.rs index 7955c3472..772e91cd7 100644 --- a/crates/alien-core/src/resources/sandbox.rs +++ b/crates/alien-core/src/resources/sandbox.rs @@ -342,6 +342,46 @@ impl SandboxCapabilities { } } + /// What the GCP Agent Platform sandbox backend supports. + /// + /// Not what `for_platform(Platform::Gcp)` returns: that reports the Cloud Run backend, which + /// is the registered one. This row is measured against the Agent Platform backend and takes + /// effect only once it replaces Cloud Run as the registered GCP backend — at which point it + /// becomes the body of the `Platform::Gcp` arm above. + pub fn gcp_agent_platform() -> Self { + Self { + // Agent file operations move over the session envelope. + files: true, + // Reaching a session across processes needs a stable session generation, and that + // wiring is not in place; flipping this true before it lands would promise a + // guarantee the backend does not yet keep. + reconnect: false, + // 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 { @@ -1032,6 +1072,48 @@ mod tests { assert!(!gcp.supervisor_isolation, "Cloud Run runs the command as the workload itself"); } + /// The Agent Platform row, each value against the behaviour it was measured from. `reconnect` + /// is the tripwire: it stays `false` until a stable session generation is wired to reach a + /// session across processes, and whoever wires that has to flip this test and the field + /// together. This row is deliberately not what `for_platform(Platform::Gcp)` returns — that is + /// still Cloud Run — so it is asserted directly. + #[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, + "cross-process reconnect needs a session generation that is not wired yet" + ); + 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" + ); + + // The registered GCP backend is still Cloud Run, so the swap has not happened. + let live = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported"); + assert_ne!( + live, row, + "the Agent Platform row must not silently become the live GCP row before cutover" + ); + } + #[test] fn platforms_without_a_backend_are_an_error_not_an_empty_set() { let error = SandboxCapabilities::for_platform(Platform::Machines) From 28babbe8e383cfdcfe7d3488632d593c6d58b999 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:12:28 +0300 Subject: [PATCH 07/21] feat(sandbox): add the GCP Agent Platform provider Implement the Sandbox trait against Agent Platform sessions over the :execute envelope: exec under the proxy window and detached jobs above it, files and health as envelope ops, lifecycle as polled operations. Compiled and unit-tested against a mocked client but left out of the provider factory, so no declaration can select it until the cutover. --- .../providers/sandbox/gcp_agent_platform.rs | 1229 +++++++++++++++++ .../sandbox/gcp_agent_platform_tests.rs | 660 +++++++++ .../src/providers/sandbox/mod.rs | 4 + 3 files changed, 1893 insertions(+) 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 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..aece17bba --- /dev/null +++ b/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform.rs @@ -0,0 +1,1229 @@ +//! 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. +//! +//! Unregistered on purpose: it is compiled and unit-tested but no factory selects it, so no +//! declaration can reach it until the cutover wires it in. + +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 every session reports until the container-identity wiring lands. +/// +/// A sandbox reports `STATE_RUNNING` while its container has been replaced under a stable name, so +/// `generation` must be derived from the container boot id read through the agent to detect that. +/// That op does not exist yet, so a fixed value is returned and `reconnect` stays `false` in the +/// capability row until the derivation is in place — a caller must not act on this as an identity. +const PLACEHOLDER_GENERATION: u64 = 1; + +/// 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 internet_access = match egress { + SandboxEgress::Allow => true, + SandboxEgress::Deny => false, + SandboxEgress::AllowDomains { .. } => { + 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. + /// + /// 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. + async fn probe_agent(&self, operation: &str, session_id: &str) -> Result<()> { + // Mapped to unreachable whatever the failure — a refused delivery, 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 this call did not create. + let unreachable = |reason: String| { + AlienError::new(ErrorData::SandboxUnreachable { + operation: operation.to_string(), + reason, + }) + }; + + let body = self + .client + .execute( + &self.engine, + session_id, + &serde_json::to_vec(&json!({ "v": AGENT_PROTOCOL_VERSION, "op": "health" })) + .unwrap_or_default(), + ) + .await + .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, + } + + 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(AlienError::new(ErrorData::SandboxUnreachable { + operation: operation.to_string(), + reason: format!( + "the session's agent speaks protocol {} where this provider speaks {}", + health.protocol_version, AGENT_PROTOCOL_VERSION + ), + })); + } + Ok(()) + } + + /// 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`, then confirms its agent answers. + /// + /// 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 => { + self.probe_agent(CREATE, session_id).await?; + return Ok(()); + } + 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(()) => Ok(SandboxSession { + session_id, + state: SandboxSessionState::Running, + generation: PLACEHOLDER_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. + if state == SandboxSessionState::Running { + self.probe_agent(GET, session_id).await?; + } + + Ok(Some(SandboxSession { + session_id: session_id.to_string(), + state, + generation: PLACEHOLDER_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) + } + _ => { + if let Err(error) = self.suspend(id).await { + warn!(session = %id, %error, "could not re-suspend a session this call woke"); + } + } + } + } + } + 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()?; + Some(SandboxSession { + session_id: session_id.to_string(), + state, + generation: PLACEHOLDER_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 == '_') +} + +/// 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..3cddaa652 --- /dev/null +++ b/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform_tests.rs @@ -0,0 +1,660 @@ +use super::*; +use alien_gcp_clients::gcp::agent_platform::MockAgentPlatformApi; +use futures::StreamExt; +use std::sync::atomic::{AtomicUsize, Ordering}; + +// ---- 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 { + GcpAgentPlatformSandbox::new( + Arc::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 { + serde_json::to_vec(&serde_json::json!({ "protocolVersion": 1 })).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); +} + +// ---- 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}"); +} + +/// 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..aa90c4c91 100644 --- a/crates/alien-bindings/src/providers/sandbox/mod.rs +++ b/crates/alien-bindings/src/providers/sandbox/mod.rs @@ -16,6 +16,10 @@ pub mod azure; #[cfg(feature = "gcp")] pub mod gcp; +// Compiled and unit-tested, but not wired into the provider factory: the cutover selects it. +#[cfg(feature = "gcp")] +pub mod gcp_agent_platform; + #[cfg(feature = "kubernetes")] pub mod kubernetes; From 9bf57525ef027dd80eae02dd8ecd955590c3bf37 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:45:27 +0300 Subject: [PATCH 08/21] feat(sandbox): derive a GCP session generation from the container boot id A GCP sandbox can report STATE_RUNNING while its container has been silently replaced under a stable session name, so resource state is not health and a session id alone cannot tell a caller's container from a blank one wearing the same name. The agent's health op now returns the container's kernel boot id, read from /proc/sys/kernel/random/boot_id, which is stable across calls and processes on one kernel and changes when the container is replaced. The GCP provider derives a numeric generation from it with a deterministic FNV-1a hash, so a caller comparing generations across two get()s detects a replacement even across processes. A health reply that omits or empties the boot id is refused rather than reconnected to a container of unknown identity, and the probe is bounded by a named budget so a wedged agent cannot hang get() or create(). With identity wired, the Agent Platform capability row flips reconnect to true, and its tripwire test asserts the new value against the wiring. --- .../providers/sandbox/gcp_agent_platform.rs | 133 ++++++++----- .../sandbox/gcp_agent_platform_tests.rs | 188 +++++++++++++++++- crates/alien-core/src/resources/sandbox.rs | 21 +- crates/alien-sandbox-agent/src/server.rs | 37 +++- crates/alien-sandbox-agent/tests/protocol.rs | 6 + 5 files changed, 319 insertions(+), 66 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform.rs b/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform.rs index aece17bba..c75b2917f 100644 --- a/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform.rs +++ b/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform.rs @@ -76,13 +76,19 @@ const GET_OR_CREATE: &str = "sandbox.getOrCreate"; const RUN_COMMAND: &str = "sandbox.runCommand"; const TERMINATE: &str = "sandbox.terminate"; -/// The generation every session reports until the container-identity wiring lands. -/// -/// A sandbox reports `STATE_RUNNING` while its container has been replaced under a stable name, so -/// `generation` must be derived from the container boot id read through the agent to detect that. -/// That op does not exist yet, so a fixed value is returned and `reconnect` stays `false` in the -/// capability row until the derivation is in place — a caller must not act on this as an identity. -const PLACEHOLDER_GENERATION: u64 = 1; +/// 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. @@ -285,15 +291,17 @@ impl GcpAgentPlatformSandbox { }) } - /// Confirms the agent answers and speaks the protocol. + /// 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. - async fn probe_agent(&self, operation: &str, session_id: &str) -> Result<()> { - // Mapped to unreachable whatever the failure — a refused delivery, 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 this call did not create. + /// 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(), @@ -301,26 +309,34 @@ impl GcpAgentPlatformSandbox { }) }; - let body = self - .client - .execute( + 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(|error| { - error.context(ErrorData::SandboxUnreachable { - operation: operation.to_string(), - reason: "the session's agent did not answer a health probe".to_string(), - }) - })?; + ), + ) + .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(|_| { @@ -332,15 +348,21 @@ impl GcpAgentPlatformSandbox { })?; if health.protocol_version != AGENT_PROTOCOL_VERSION { - return Err(AlienError::new(ErrorData::SandboxUnreachable { - operation: operation.to_string(), - reason: format!( - "the session's agent speaks protocol {} where this provider speaks {}", - 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 + ))); } - Ok(()) + // 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. @@ -366,11 +388,12 @@ impl GcpAgentPlatformSandbox { }) } - /// Waits for a created sandbox to reach `STATE_RUNNING`, then confirms its agent answers. + /// 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<()> { + 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 { @@ -382,8 +405,7 @@ impl GcpAgentPlatformSandbox { }; match session_state(CREATE, sandbox.state.as_deref())? { SandboxSessionState::Running => { - self.probe_agent(CREATE, session_id).await?; - return Ok(()); + return self.probe_agent(CREATE, session_id).await; } SandboxSessionState::Terminated => { return Err(AlienError::new(ErrorData::SandboxCommandFailed { @@ -526,10 +548,10 @@ impl Sandbox for GcpAgentPlatformSandbox { // Past here a sandbox exists the caller has no id for, so every failure deletes it. match self.settle(&session_id).await { - Ok(()) => Ok(SandboxSession { + Ok(generation) => Ok(SandboxSession { session_id, state: SandboxSessionState::Running, - generation: PLACEHOLDER_GENERATION, + generation, }), Err(error) => Err(self.discard(&session_id, error).await), } @@ -543,15 +565,18 @@ impl Sandbox for GcpAgentPlatformSandbox { 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. - if state == SandboxSessionState::Running { - self.probe_agent(GET, session_id).await?; - } + // 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: PLACEHOLDER_GENERATION, + generation, })) } @@ -616,10 +641,12 @@ impl Sandbox for GcpAgentPlatformSandbox { .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: PLACEHOLDER_GENERATION, + generation: NO_GENERATION, }) }) .collect()) @@ -1145,6 +1172,22 @@ fn is_addressable_id(id: &str) -> bool { .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 { 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 index 3cddaa652..79a66de47 100644 --- a/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform_tests.rs +++ b/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform_tests.rs @@ -1,20 +1,22 @@ use super::*; -use alien_gcp_clients::gcp::agent_platform::MockAgentPlatformApi; +use alien_gcp_clients::gcp::agent_platform::{MockAgentPlatformApi, 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 { - GcpAgentPlatformSandbox::new( - Arc::new(client), - ENGINE_FULL.to_string(), - TEMPLATE.to_string(), - Some(3600), - ) + 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 { @@ -60,7 +62,12 @@ fn ndjson(lines: &[serde_json::Value]) -> Vec { } fn health_reply() -> Vec { - serde_json::to_vec(&serde_json::json!({ "protocolVersion": 1 })).expect("health serializes") + 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 { @@ -276,6 +283,171 @@ async fn get_or_create_resumes_a_suspended_session_rather_than_creating_a_second .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"); +} + +// ---- 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 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 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 ------------------------------------------------------------------------------------- diff --git a/crates/alien-core/src/resources/sandbox.rs b/crates/alien-core/src/resources/sandbox.rs index 772e91cd7..a335bbc9b 100644 --- a/crates/alien-core/src/resources/sandbox.rs +++ b/crates/alien-core/src/resources/sandbox.rs @@ -352,10 +352,10 @@ impl SandboxCapabilities { Self { // Agent file operations move over the session envelope. files: true, - // Reaching a session across processes needs a stable session generation, and that - // wiring is not in place; flipping this true before it lands would promise a - // guarantee the backend does not yet keep. - reconnect: false, + // 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. @@ -1073,18 +1073,19 @@ mod tests { } /// The Agent Platform row, each value against the behaviour it was measured from. `reconnect` - /// is the tripwire: it stays `false` until a stable session generation is wired to reach a - /// session across processes, and whoever wires that has to flip this test and the field - /// together. This row is deliberately not what `for_platform(Platform::Gcp)` returns — that is - /// still Cloud Run — so it is asserted directly. + /// 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. This row is deliberately not what `for_platform(Platform::Gcp)` + /// returns — that is still Cloud Run — so it is asserted directly. #[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, - "cross-process reconnect needs a session generation that is not wired yet" + 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"); diff --git a/crates/alien-sandbox-agent/src/server.rs b/crates/alien-sandbox-agent/src/server.rs index 43ca34660..1d357d82a 100644 --- a/crates/alien-sandbox-agent/src/server.rs +++ b/crates/alien-sandbox-agent/src/server.rs @@ -88,12 +88,16 @@ pub struct AgentState { 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. @@ -263,8 +267,8 @@ pub fn router(state: Arc) -> Router { /// 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> { @@ -281,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 diff --git a/crates/alien-sandbox-agent/tests/protocol.rs b/crates/alien-sandbox-agent/tests/protocol.rs index 7f544c0b3..e4e61bd4d 100644 --- a/crates/alien-sandbox-agent/tests/protocol.rs +++ b/crates/alien-sandbox-agent/tests/protocol.rs @@ -146,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 From 531255578a507e0da029acea8d133e5ddb21a6c9 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:14:05 +0300 Subject: [PATCH 09/21] feat(sandbox): reconcile the GCP Agent Platform template with a controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Agent Engine is an empty Frozen parent; the SandboxEnvironmentTemplate carries the image digest, ceilings and egress and warms the session pool, so it is Live and release-owned. Template config is immutable — there is no update verb — so reconciliation is replace-not-update: create the new template, wait for it to reach ACTIVE, and only then reap the old one, so a release never leaves a session pointing at a template that has already been deleted. Add list_templates to the client so the reaper can find superseded templates, and give SandboxEgress::internet_access_switch a single home for the mode->bool mapping so the provider, the controller and the emitter cannot disagree on what a mode means. AllowDomains has no switch position and is refused, naming the sandbox and both accepted modes, at plan time in the emitter and at build time in the controller. The emitter and controller stay unregistered; the registered GCP sandbox backend is still Cloud Run until the cutover moves the registration. --- .../providers/sandbox/gcp_agent_platform.rs | 22 +- .../sandbox/gcp_agent_platform_tests.rs | 6 + crates/alien-core/src/resources/sandbox.rs | 32 + .../src/gcp/agent_platform.rs | 103 ++ .../alien-infra/src/core/service_provider.rs | 28 +- .../sandbox/gcp_agent_platform_template.rs | 1113 +++++++++++++++++ crates/alien-infra/src/sandbox/mod.rs | 9 +- .../permission-sets/sandbox/provision.jsonc | 10 +- .../alien-terraform/src/emitters/gcp/mod.rs | 2 +- .../src/emitters/gcp/sandbox.rs | 224 ++++ 10 files changed, 1522 insertions(+), 27 deletions(-) create mode 100644 crates/alien-infra/src/sandbox/gcp_agent_platform_template.rs diff --git a/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform.rs b/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform.rs index c75b2917f..d54b8837a 100644 --- a/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform.rs +++ b/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform.rs @@ -102,19 +102,15 @@ pub fn egress_control_config( sandbox_label: &str, egress: &SandboxEgress, ) -> Result { - let internet_access = match egress { - SandboxEgress::Allow => true, - SandboxEgress::Deny => false, - SandboxEgress::AllowDomains { .. } => { - 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()), - })); - } + 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 { 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 index 79a66de47..e8b2b32db 100644 --- a/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform_tests.rs +++ b/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform_tests.rs @@ -418,6 +418,12 @@ impl AgentPlatformApi for WedgedAgent { 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, diff --git a/crates/alien-core/src/resources/sandbox.rs b/crates/alien-core/src/resources/sandbox.rs index a335bbc9b..eefc69632 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))] @@ -1682,4 +1698,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-gcp-clients/src/gcp/agent_platform.rs b/crates/alien-gcp-clients/src/gcp/agent_platform.rs index 43151bed1..78717d6b1 100644 --- a/crates/alien-gcp-clients/src/gcp/agent_platform.rs +++ b/crates/alien-gcp-clients/src/gcp/agent_platform.rs @@ -401,6 +401,14 @@ struct ListSandboxesResponse { next_page_token: Option, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ListTemplatesResponse { + #[serde(default)] + sandbox_environment_templates: Vec, + next_page_token: Option, +} + // ================================================================================================= // API // ================================================================================================= @@ -422,6 +430,9 @@ pub trait AgentPlatformApi: Send + Sync + Debug { ) -> 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<()>; @@ -671,6 +682,33 @@ impl AgentPlatformApi for AgentPlatformClient { }) } + 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 delete_template(&self, engine: &str, template: &str) -> Result<()> { let path = format!("{}/{}", self.templates_path(engine), template); let result: alien_client_core::Result = self @@ -1239,6 +1277,71 @@ mod tests { ); } + // ---- 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") + ); + } + + #[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 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_template.rs b/crates/alien-infra/src/sandbox/gcp_agent_platform_template.rs new file mode 100644 index 000000000..67e416921 --- /dev/null +++ b/crates/alien-infra/src/sandbox/gcp_agent_platform_template.rs @@ -0,0 +1,1113 @@ +//! GCP Agent Platform sandbox template controller. +//! +//! Reconciles the `SandboxEnvironmentTemplate` (T09): the Live, release-owned object that carries +//! the image digest, ceilings and egress and warms the session pool. The Agent Engine it hangs +//! under is Frozen setup and is not touched here — the controller is handed the engine and creates +//! templates beneath it. +//! +//! 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. +//! +//! Unregistered like the provider it feeds (T05): the registered GCP sandbox backend is still Cloud +//! Run, so no declaration reaches this and it is proven by the controller tests below until the +//! cutover moves the registration. + +use std::collections::HashMap; +use std::time::Duration; +use tracing::{info, warn}; + +use crate::core::ResourceControllerContext; +use crate::error::{ErrorData, Result}; +use alien_core::{ResourceOutputs, 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 { + 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 concrete engine id is server-assigned at setup; until the cutover wires the real one + // through, it is addressed by a stable per-sandbox convention. The controller is + // unregistered, so nothing depends on this reaching a live engine yet. + let engine = format!("{}-{}", ctx.resource_prefix, config.id); + 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 alien_core::Platform; + use crate::core::controller_test::SingleControllerExecutor; + use crate::MockPlatformServiceProvider; + 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() + .build() + .await + .expect("executor builds") + } + + // ---- 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" + ); + + 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 T07 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}" + ); + } + 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..a56e6b7c1 100644 --- a/crates/alien-infra/src/sandbox/mod.rs +++ b/crates/alien-infra/src/sandbox/mod.rs @@ -25,11 +25,16 @@ 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::*; diff --git a/crates/alien-permissions/permission-sets/sandbox/provision.jsonc b/crates/alien-permissions/permission-sets/sandbox/provision.jsonc index 9241e3b35..e134a7c68 100644 --- a/crates/alien-permissions/permission-sets/sandbox/provision.jsonc +++ b/crates/alien-permissions/permission-sets/sandbox/provision.jsonc @@ -178,8 +178,10 @@ ], "gcp": [ { - // The durable parent (reasoningEngine) and release-owned template: create, delete, and the - // reads a resumed provision needs to adopt what an interrupted one left. No + // 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": [ @@ -188,9 +190,7 @@ "aiplatform.sandboxEnvironmentTemplates.get", "aiplatform.sandboxEnvironmentTemplates.list", "aiplatform.reasoningEngines.create", - "aiplatform.reasoningEngines.delete", - "aiplatform.reasoningEngines.get", - "aiplatform.reasoningEngines.list" + "aiplatform.reasoningEngines.delete" ] }, "binding": { diff --git a/crates/alien-terraform/src/emitters/gcp/mod.rs b/crates/alien-terraform/src/emitters/gcp/mod.rs index ab2ab7ee4..c3442b5c4 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, GcpSandboxEmitter}; 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..70643a627 100644 --- a/crates/alien-terraform/src/emitters/gcp/sandbox.rs +++ b/crates/alien-terraform/src/emitters/gcp/sandbox.rs @@ -82,6 +82,98 @@ impl TfEmitter for GcpSandboxEmitter { } } +/// Serde `service` tag of the T07 `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. +/// +/// 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(), + })) +} + +/// The engine, template, region and ttl fields shared by the import ref and the binding ref. +/// +/// `engine` and `template` carry runtime-assigned ids, so at emit time they are addressed by a +/// resource-name convention over the setup label rather than a Terraform resource attribute — this +/// emitter is unregistered and 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 GCP Agent Platform sandbox binding: the durable Agent Engine, the release-owned +/// template, the region and the session ttl (T07). +/// +/// Unregistered on purpose, like the provider it feeds (T05): `built_ins` keeps Cloud Run as the +/// GCP sandbox backend, so the generator never dispatches here and this is exercised by direct +/// unit test until the cutover moves the registration. The engine is a Frozen setup resource with +/// 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 GcpAgentPlatformSandboxEmitter; + +impl TfEmitter for GcpAgentPlatformSandboxEmitter { + fn emit(&self, _ctx: &EmitContext<'_>) -> Result { + // The engine is setup-created and monitored only; the template is reconciled by the Live + // controller after apply. Both carry runtime-assigned names, so neither is a resource block. + Ok(TfFragment::default()) + } + + fn emit_import_ref(&self, ctx: &EmitContext<'_>) -> Result { + let label = required_label(ctx)?; + let sandbox = downcast::(ctx, Sandbox::RESOURCE_TYPE)?; + 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)?; + 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::*; @@ -125,4 +217,136 @@ mod tests { .unwrap_or_else(|error| panic!("{accepted:?} is a switch position: {error}")); } } + + // ---- Agent Platform emitter: unregistered, so exercised by direct invocation. ------------- + + 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 T07 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_t07_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 T07 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" + ); + + 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" + ); + } + } } From 59ee5c5a0c6d004f0e1404c12df7119b17838a2f Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:00:25 +0300 Subject: [PATCH 10/21] test(sandbox): add live GCP Agent Platform tests and pin two unit gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mocked test can only confirm the request we chose to send. These live tests drive the client and provider directly against a real project — the emitter and controller are unregistered, so a deployed stack is not yet a path — covering create, cross-process reconnect, the detached path past the ~30s execute cap, suspend/resume, egress-deny, and snapshot restore, each with teardown that leaves nothing behind and a sweep for orphans a failed run left. They are #[ignore]d and need credentials, so CI does not run them. Two unit gaps are closed alongside: a provider test pinning a failing mutating :execute as delivered once while a read still retries, and a job-poll test proving overlapping sinceSeq windows rebuild the stream with no duplication and no gap. --- .../sandbox/gcp_agent_platform_tests.rs | 48 + crates/alien-sandbox-agent/src/jobs.rs | 47 +- .../tests/gcp_agent_platform_sandbox_live.rs | 824 ++++++++++++++++++ 3 files changed, 918 insertions(+), 1 deletion(-) create mode 100644 crates/alien-test/tests/gcp_agent_platform_sandbox_live.rs 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 index e8b2b32db..efc9711b7 100644 --- a/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform_tests.rs +++ b/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform_tests.rs @@ -592,6 +592,54 @@ async fn a_job_error_object_becomes_a_stream_error() { 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] diff --git a/crates/alien-sandbox-agent/src/jobs.rs b/crates/alien-sandbox-agent/src/jobs.rs index e03339549..9b8d0ab99 100644 --- a/crates/alien-sandbox-agent/src/jobs.rs +++ b/crates/alien-sandbox-agent/src/jobs.rs @@ -295,7 +295,7 @@ fn frame_seq(frame: &Frame) -> Option { mod tests { use super::*; - use std::collections::BTreeMap; + use std::collections::{BTreeMap, BTreeSet}; use std::time::Duration; use base64::engine::general_purpose::STANDARD; @@ -423,6 +423,51 @@ mod tests { ); } + /// 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. 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..20281b267 --- /dev/null +++ b/crates/alien-test/tests/gcp_agent_platform_sandbox_live.rs @@ -0,0 +1,824 @@ +//! 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, 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"; +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, + }; + Arc::new(AgentPlatformClient::new(reqwest::Client::new(), 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 { + if let Err(error) = client.delete_engine(&engine).await { + eprintln!("teardown: could not delete engine {engine}: {error}"); + } + }); + }) + .join(); + } +} + +async fn provision_engine(client: &Arc) -> String { + let display = format!("alien-sbx-live-{}", 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!("alien-sbx-live-{}", 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 /tmp/session && printf %s '{marker}' > /tmp/session/marker"), + ) + .await; + assert_eq!( + wrote.exit_code, 0, + "writing the marker succeeds: {:?}", + wrote.stderr + ); + let read_back = provider + .read_file(&sid, "/tmp/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\" /tmp/priv >/tmp/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, "/tmp/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, "/tmp/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' >> /tmp/session/marker && cat /tmp/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}' > /tmp/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; + assert_eq!( + after, before, + "resume returns onto the same container, so the generation is unchanged" + ); + let kept = provider + .read_file(&sid, "/tmp/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}' > /tmp/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 > /tmp/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, "/tmp/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, "/tmp/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(); + + let mut failures = Vec::new(); + for engine in recorded + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + { + if let Err(error) = client.delete_engine(last_segment(engine)).await { + failures.push(format!("{engine}: {error}")); + } + } + let _ = std::fs::remove_file(sweep_log()); + + assert!( + failures.is_empty(), + "every recorded engine must be gone after a sweep; still present: {failures:?}" + ); +} From 7e93f02b5d0c933a10dc2f650185142838f7f984 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:14:26 +0300 Subject: [PATCH 11/21] docs(sandbox): state the container-only boundary for a shared-uid backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Where a backend runs a command under the agent's own user, the command can read the supervisor's environment and signal it, so the container — not the supervisor — is the isolation boundary. Say that in the customer-facing sandbox doc rather than leaving a caller to infer it from `supervisorIsolation: false`, and record at the template's container spec why no env is set there: a secret placed in it would be readable by the very code the agent supervises. --- .../alien-infra/src/sandbox/gcp_agent_platform_template.rs | 5 ++++- packages/core/src/sandbox.ts | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) 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 67e416921..5eae37378 100644 --- a/crates/alien-infra/src/sandbox/gcp_agent_platform_template.rs +++ b/crates/alien-infra/src/sandbox/gcp_agent_platform_template.rs @@ -98,6 +98,9 @@ fn build_template_body( 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(), @@ -615,9 +618,9 @@ fn missing_state(resource_id: &str, field: &str) -> AlienError { #[cfg(test)] mod tests { use super::*; - use alien_core::Platform; 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}; diff --git a/packages/core/src/sandbox.ts b/packages/core/src/sandbox.ts index f3e2ccd08..32915a78a 100644 --- a/packages/core/src/sandbox.ts +++ b/packages/core/src/sandbox.ts @@ -35,7 +35,9 @@ export { SandboxSchema as SandboxConfigSchema } from "./generated/index.js" * 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, no platform can snapshot a session, and only AWS - * and Local run a command under a different identity than the process supervising it. + * 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 b8a8d932235d3fe304744021108d8bc14b18e1fa Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:44:12 +0300 Subject: [PATCH 12/21] feat(sandbox-agent): declare the isolation model instead of assuming uid-split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent refused to start whenever the command's uid equalled its own, which is right where the agent can drop to a separate uid but impossible where the platform runs everything as one user — a container that allows no other uid, or a pod that has dropped every capability. Such a backend could never boot the agent at all. Take the model as a declared value, ALIEN_SANDBOX_ISOLATION = uid-split | platform, required with no default because it selects a security model. uid-split keeps today's refusal; platform accepts the shared uid because the container is then the only boundary. Root and the root group are refused in both — the concession is the shared uid, never root. The AWS image declares uid-split, the model it already runs. --- crates/alien-build/src/sandbox_bundle.rs | 29 +++-- crates/alien-sandbox-agent/src/main.rs | 154 +++++++++++++++++------ 2 files changed, 138 insertions(+), 45 deletions(-) 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-sandbox-agent/src/main.rs b/crates/alien-sandbox-agent/src/main.rs index 267f540f9..c6c9d33e2 100644 --- a/crates/alien-sandbox-agent/src/main.rs +++ b/crates/alien-sandbox-agent/src/main.rs @@ -36,6 +36,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; @@ -53,7 +56,10 @@ async fn main() -> Result<()> { let listener = tokio::net::TcpListener::bind(address) .await .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}"); @@ -61,9 +67,62 @@ async fn main() -> Result<()> { listener, 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(), + )) +} + +/// 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 { @@ -71,13 +130,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)?, @@ -92,30 +148,14 @@ 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, @@ -156,11 +196,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, @@ -203,3 +244,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"); + } + } +} From a01aa6f8832db80d2ec6e3b1e745545a3af11763 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:47:47 +0300 Subject: [PATCH 13/21] feat(gcp-clients): list engines so the orphan sweep finds what a log missed The live sweep could only delete engines a run had already recorded, so an engine killed between creation and its record was invisible to it. Add a paginating list of reasoning engines and back the sweep with it, reaping every engine that carries the suite's display-name prefix and nothing else. --- .../sandbox/gcp_agent_platform_tests.rs | 7 +- .../src/gcp/agent_platform.rs | 82 +++++++++++++++++++ .../tests/gcp_agent_platform_sandbox_live.rs | 34 ++++++-- 3 files changed, 114 insertions(+), 9 deletions(-) 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 index efc9711b7..91be81410 100644 --- a/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform_tests.rs +++ b/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform_tests.rs @@ -1,5 +1,7 @@ use super::*; -use alien_gcp_clients::gcp::agent_platform::{MockAgentPlatformApi, SandboxEnvironmentTemplate}; +use alien_gcp_clients::gcp::agent_platform::{ + MockAgentPlatformApi, ReasoningEngine, SandboxEnvironmentTemplate, +}; use futures::StreamExt; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -401,6 +403,9 @@ impl AgentPlatformApi for WedgedAgent { async fn delete_engine(&self, _engine: &str) -> ClientResult<()> { unimplemented!() } + async fn list_engines(&self) -> ClientResult> { + unimplemented!() + } async fn create_template( &self, _engine: &str, diff --git a/crates/alien-gcp-clients/src/gcp/agent_platform.rs b/crates/alien-gcp-clients/src/gcp/agent_platform.rs index 78717d6b1..245e259af 100644 --- a/crates/alien-gcp-clients/src/gcp/agent_platform.rs +++ b/crates/alien-gcp-clients/src/gcp/agent_platform.rs @@ -409,6 +409,14 @@ struct ListTemplatesResponse { next_page_token: Option, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ListEnginesResponse { + #[serde(default)] + reasoning_engines: Vec, + next_page_token: Option, +} + // ================================================================================================= // API // ================================================================================================= @@ -421,6 +429,9 @@ pub trait AgentPlatformApi: Send + Sync + Debug { 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( @@ -709,6 +720,33 @@ impl AgentPlatformApi for AgentPlatformClient { 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 @@ -1323,6 +1361,50 @@ mod tests { ); } + 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; 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 20281b267..c25764f16 100644 --- a/crates/alien-test/tests/gcp_agent_platform_sandbox_live.rs +++ b/crates/alien-test/tests/gcp_agent_platform_sandbox_live.rs @@ -33,7 +33,7 @@ //! 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, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -54,6 +54,9 @@ use alien_gcp_clients::gcp::agent_platform::{ // ---- 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 @@ -144,7 +147,7 @@ impl Drop for EngineGuard { } async fn provision_engine(client: &Arc) -> String { - let display = format!("alien-sbx-live-{}", uuid::Uuid::new_v4().simple()); + let display = format!("{LIVE_PREFIX}{}", uuid::Uuid::new_v4().simple()); let operation = client .create_engine(&display) .await @@ -163,7 +166,7 @@ async fn provision_engine(client: &Arc) -> String { fn template_body(image: &str, internet_access: bool) -> SandboxEnvironmentTemplate { SandboxEnvironmentTemplate { name: None, - display_name: Some(format!("alien-sbx-live-{}", uuid::Uuid::new_v4().simple())), + 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(), @@ -805,13 +808,28 @@ async fn sweep_orphaned_engines() { let client = LiveConfig::from_env().client(); let recorded = std::fs::read_to_string(sweep_log()).unwrap_or_default(); - let mut failures = Vec::new(); - for engine in recorded + // 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()) - { - if let Err(error) = client.delete_engine(last_segment(engine)).await { + .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}")); } } @@ -819,6 +837,6 @@ async fn sweep_orphaned_engines() { assert!( failures.is_empty(), - "every recorded engine must be gone after a sweep; still present: {failures:?}" + "every orphan engine must be gone after a sweep; still present: {failures:?}" ); } From 403733f74dd85ac66e8ad739b74046e00ec491b9 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:52:40 +0300 Subject: [PATCH 14/21] test(sandbox): give the live GCP client a request timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without one, a single stalled request hangs the whole live test indefinitely — the poll budget bounds the number of attempts, not the wait inside one call, so a create or execute that never returns is never abandoned. Set a 90s per-request timeout, above the ~30s :execute proxy window, so a stall fails the test rather than wedging it. --- .../alien-test/tests/gcp_agent_platform_sandbox_live.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 c25764f16..ad2061a80 100644 --- a/crates/alien-test/tests/gcp_agent_platform_sandbox_live.rs +++ b/crates/alien-test/tests/gcp_agent_platform_sandbox_live.rs @@ -103,7 +103,14 @@ impl LiveConfig { service_overrides: None, project_number: None, }; - Arc::new(AgentPlatformClient::new(reqwest::Client::new(), config)) + // 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)) } } From ac6e1734f2b5db143e34f4f0fb1f39763e5725bd Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:25:14 +0300 Subject: [PATCH 15/21] fix(sandbox-agent): accept connections with a blocking listen loop A tokio TcpListener stops accepting after the GCP sandbox platform detaches and reattaches the data plane on pause/resume: the process stays alive and the socket stays in LISTEN, but the reactor's epoll registration for the listen socket goes stale across the reattach and nothing is ever served again. A plain blocking accept() re-wakes on the next connection regardless, which is why a blocking server survives the same transition. Serve behind a custom axum Listener whose accept blocks in the syscall on a worker thread; per-connection IO stays a fresh tokio stream, so only the long-lived listen socket needs this. Measured against the live backend: without it a resumed sandbox is unreachable indefinitely; with it, reachable at once. --- crates/alien-sandbox-agent/src/main.rs | 66 ++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/crates/alien-sandbox-agent/src/main.rs b/crates/alien-sandbox-agent/src/main.rs index c6c9d33e2..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; @@ -14,6 +15,7 @@ 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; @@ -53,8 +55,7 @@ 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", @@ -64,7 +65,10 @@ async fn main() -> Result<()> { 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 @@ -75,6 +79,62 @@ async fn main() -> Result<()> { )) } +/// 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 { From e2214cc1da1d6d5392a6892189867f288e7bcf9e Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:11:56 +0300 Subject: [PATCH 16/21] fix(sandbox-agent): set jobs on the linux-only protocol test states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two AgentState literals behind `#[cfg(target_os = "linux")]` never got the `jobs` field, so the crate's test build did not compile on Linux — invisible on a macOS dev machine, where those tests are gated out. Add it so the Linux test build works. --- crates/alien-sandbox-agent/tests/protocol.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/alien-sandbox-agent/tests/protocol.rs b/crates/alien-sandbox-agent/tests/protocol.rs index e4e61bd4d..6a98e1ce3 100644 --- a/crates/alien-sandbox-agent/tests/protocol.rs +++ b/crates/alien-sandbox-agent/tests/protocol.rs @@ -473,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")) @@ -750,6 +751,7 @@ async fn the_envelope_refuses_the_code_the_agent_runs_under_transport() { 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")) From dc20d2b785889e9a62e384aca8a7942c316c6a70 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:11:57 +0300 Subject: [PATCH 17/21] fix(sandbox-agent): confine paths with a portable openat walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit open_beneath resolved a caller's path with openat2 (RESOLVE_BENEATH, RESOLVE_NO_SYMLINKS), which the gVisor runtime under GCP Agent Platform does not implement: every file operation returned ENOSYS, so readFile, writeFile and mkdir all failed there. Walk the path one component at a time instead, each openat taken relative to the previous component's descriptor and every step O_NOFOLLOW. The inode a step opens is the inode the previous step checked, a symlink or magic link at any component fails the step, and `.`/`..` are refused — so nothing can leave the root and there is no name left to re-resolve. That is what openat2 gave in one syscall, done in userspace so it holds on every runtime. Measured on GCP Agent Platform: mkdir, writeFile and readFile round-trip and a symlink escape is refused; the confine unit tests pass on a real kernel; the AWS and Local paths keep the same behaviour. --- crates/alien-sandbox-agent/src/confine.rs | 312 +++++++++++++--------- 1 file changed, 185 insertions(+), 127 deletions(-) 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(); + } +} From dd4d5802013fae7b63f3f14f520dd95c85457fb2 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:25:56 +0300 Subject: [PATCH 18/21] test(sandbox): keep live file markers inside the session root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live tests wrote markers with a command (`> /tmp/x`, the real /tmp) but read them back through the agent file API, which confines to the session root (/sandbox) — so the read looked at /sandbox/tmp/x and never found them. Write and read the same session-root path instead. Suspend/resume: assert the load-bearing property — the filesystem survives — and observe the generation rather than requiring it unchanged, since resume may return onto a reissued container whose new boot id the generation exists to surface. Teardown reaps a session's child sandboxes before its engine and retries, because an engine will not delete while a sandbox remains and a panicked test leaves one behind. --- .../tests/gcp_agent_platform_sandbox_live.rs | 50 ++++++++++++------- 1 file changed, 32 insertions(+), 18 deletions(-) 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 ad2061a80..836a74e94 100644 --- a/crates/alien-test/tests/gcp_agent_platform_sandbox_live.rs +++ b/crates/alien-test/tests/gcp_agent_platform_sandbox_live.rs @@ -144,9 +144,23 @@ impl Drop for EngineGuard { let _ = std::thread::spawn(move || { let runtime = tokio::runtime::Runtime::new().expect("teardown runtime builds"); runtime.block_on(async move { - if let Err(error) = client.delete_engine(&engine).await { - eprintln!("teardown: could not delete engine {engine}: {error}"); + // 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(); @@ -345,7 +359,7 @@ async fn create_exec_reconnect_private_clone_terminate() { let wrote = shell( &provider, &sid, - &format!("mkdir -p /tmp/session && printf %s '{marker}' > /tmp/session/marker"), + &format!("mkdir -p /sandbox/session && printf %s '{marker}' > /sandbox/session/marker"), ) .await; assert_eq!( @@ -354,7 +368,7 @@ async fn create_exec_reconnect_private_clone_terminate() { wrote.stderr ); let read_back = provider - .read_file(&sid, "/tmp/session/marker") + .read_file(&sid, "/session/marker") .await .expect("the marker file reads back"); assert_eq!( @@ -373,7 +387,7 @@ async fn create_exec_reconnect_private_clone_terminate() { &[ "/bin/sh", "-lc", - "git clone --depth 1 \"https://x-access-token:${GIT_TOKEN}@github.com/${PRIVATE_REPO}.git\" /tmp/priv >/tmp/clone.log 2>&1; echo rc=$?", + "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), @@ -389,7 +403,7 @@ async fn create_exec_reconnect_private_clone_terminate() { String::from_utf8_lossy(&clone.stdout) ); let head = provider - .read_file(&sid, "/tmp/priv/.git/HEAD") + .read_file(&sid, "/priv/.git/HEAD") .await .expect("the cloned repo has a git dir"); assert!( @@ -503,7 +517,7 @@ async fn reconnect_reader_child() { ); let seen = provider - .read_file(sid, "/tmp/session/marker") + .read_file(sid, "/session/marker") .await .expect("process two reads process one's file"); assert_eq!( @@ -516,7 +530,7 @@ async fn reconnect_reader_child() { let appended = shell( &provider, sid, - "printf ' second' >> /tmp/session/marker && cat /tmp/session/marker", + "printf ' second' >> /sandbox/session/marker && cat /sandbox/session/marker", ) .await; assert_eq!( @@ -617,7 +631,7 @@ async fn suspend_resume_preserves_the_container_and_filesystem() { let wrote = shell( &provider, &sid, - &format!("printf %s '{marker}' > /tmp/keep"), + &format!("printf %s '{marker}' > /sandbox/keep"), ) .await; assert_eq!(wrote.exit_code, 0, "the pre-suspend marker writes"); @@ -626,12 +640,12 @@ async fn suspend_resume_preserves_the_container_and_filesystem() { provider.resume(&sid).await.expect("the session resumes"); let after = wait_until_running(&provider, &sid).await; - assert_eq!( - after, before, - "resume returns onto the same container, so the generation is unchanged" - ); + // 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, "/tmp/keep") + .read_file(&sid, "/keep") .await .expect("the marker survives the suspend/resume"); assert_eq!( @@ -718,7 +732,7 @@ async fn snapshot_restore_carries_pre_snapshot_state_only() { shell( &provider, &sid, - &format!("printf %s '{before}' > /tmp/before") + &format!("printf %s '{before}' > /sandbox/before") ) .await .exit_code, @@ -733,7 +747,7 @@ async fn snapshot_restore_carries_pre_snapshot_state_only() { // A mutation the restore must not carry. assert_eq!( - shell(&provider, &sid, "printf %s after > /tmp/after") + shell(&provider, &sid, "printf %s after > /sandbox/after") .await .exit_code, 0, @@ -761,7 +775,7 @@ async fn snapshot_restore_carries_pre_snapshot_state_only() { wait_until_running(&provider, &restored_id).await; let carried = provider - .read_file(&restored_id, "/tmp/before") + .read_file(&restored_id, "/before") .await .expect("the restore carries the pre-snapshot marker"); assert_eq!( @@ -771,7 +785,7 @@ async fn snapshot_restore_carries_pre_snapshot_state_only() { ); assert!( provider - .read_file(&restored_id, "/tmp/after") + .read_file(&restored_id, "/after") .await .is_err(), "the post-snapshot mutation is absent from the restore" From 75be41e146c5db61f3ed251fc1c614e6e38c44a9 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:08:20 +0300 Subject: [PATCH 19/21] feat(sandbox): provision an Agent Platform reasoning engine per GCP sandbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GCP Agent Platform sandbox hangs every template and session under a reasoning engine that nothing created — Vertex exposes no Terraform resource for it, so a controller must create it through the API. Add the engine as a Live, Alien-owned resource: a preflight mutation synthesizes one engine per sandbox and makes the sandbox depend on it (so the engine deploys first and tears down last), and a controller creates it, records the server-assigned id, and deletes it at teardown. The template controller reads that id from the engine dependency instead of fabricating one. Additive: the mutation and the template controller stay unregistered until the Cloud Run backend is removed, so nothing reaches this path yet. --- crates/alien-core/src/resource.rs | 5 + crates/alien-core/src/resource_links.rs | 1 + .../resources/gcp_agent_platform_engine.rs | 96 +++++ crates/alien-core/src/resources/mod.rs | 3 + crates/alien-infra/src/core/registry.rs | 11 + .../src/sandbox/gcp_agent_platform_engine.rs | 383 ++++++++++++++++++ .../sandbox/gcp_agent_platform_template.rs | 42 +- crates/alien-infra/src/sandbox/mod.rs | 5 + .../mutations/gcp_agent_platform_engine.rs | 207 ++++++++++ crates/alien-preflights/src/mutations/mod.rs | 2 + 10 files changed, 747 insertions(+), 8 deletions(-) create mode 100644 crates/alien-core/src/resources/gcp_agent_platform_engine.rs create mode 100644 crates/alien-infra/src/sandbox/gcp_agent_platform_engine.rs create mode 100644 crates/alien-preflights/src/mutations/gcp_agent_platform_engine.rs 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..3ad588f10 --- /dev/null +++ b/crates/alien-core/src/resources/gcp_agent_platform_engine.rs @@ -0,0 +1,96 @@ +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"); + + /// Returns the engine's unique identifier within the stack. + 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-infra/src/core/registry.rs b/crates/alien-infra/src/core/registry.rs index b59f941e2..51166b150 100644 --- a/crates/alien-infra/src/core/registry.rs +++ b/crates/alien-infra/src/core/registry.rs @@ -768,6 +768,17 @@ impl ResourceRegistry { Box::new(DefaultControllerFactory::::new()), ); + // Register the GCP Agent Platform reasoning-engine controller. Inert until the cutover + // registers the mutation that synthesizes the engine resource. + #[cfg(feature = "gcp")] + registry.register_controller_factory( + alien_core::GcpAgentPlatformEngine::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/sandbox/gcp_agent_platform_engine.rs b/crates/alien-infra/src/sandbox/gcp_agent_platform_engine.rs new file mode 100644 index 000000000..995d336ff --- /dev/null +++ b/crates/alien-infra/src/sandbox/gcp_agent_platform_engine.rs @@ -0,0 +1,383 @@ +//! 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. +//! +//! Unregistered until the cutover, like the template controller (T09) it feeds: the registered GCP +//! sandbox backend is still Cloud Run, so nothing reaches this yet and it is proven by its tests. + +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); + } + + #[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 index 5eae37378..28632e0dc 100644 --- a/crates/alien-infra/src/sandbox/gcp_agent_platform_template.rs +++ b/crates/alien-infra/src/sandbox/gcp_agent_platform_template.rs @@ -1,9 +1,9 @@ //! GCP Agent Platform sandbox template controller. //! //! Reconciles the `SandboxEnvironmentTemplate` (T09): the Live, release-owned object that carries -//! the image digest, ceilings and egress and warms the session pool. The Agent Engine it hangs -//! under is Frozen setup and is not touched here — the controller is handed the engine and creates -//! templates beneath it. +//! 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 @@ -20,7 +20,11 @@ use tracing::{info, warn}; use crate::core::ResourceControllerContext; use crate::error::{ErrorData, Result}; -use alien_core::{ResourceOutputs, ResourceStatus, Sandbox, SandboxCode, SandboxLimits}; +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, @@ -155,10 +159,16 @@ impl GcpAgentPlatformTemplateController { .service_provider .get_gcp_agent_platform_client(gcp_config)?; - // The concrete engine id is server-assigned at setup; until the cutover wires the real one - // through, it is addressed by a stable per-sandbox convention. The controller is - // unregistered, so nothing depends on this reaching a live engine yet. - let engine = format!("{}-{}", ctx.resource_prefix, config.id); + // 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)?; @@ -719,6 +729,13 @@ mod tests { .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") @@ -748,6 +765,11 @@ mod tests { 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 @@ -845,6 +867,10 @@ mod tests { 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:?}"), } diff --git a/crates/alien-infra/src/sandbox/mod.rs b/crates/alien-infra/src/sandbox/mod.rs index a56e6b7c1..a15103436 100644 --- a/crates/alien-infra/src/sandbox/mod.rs +++ b/crates/alien-infra/src/sandbox/mod.rs @@ -38,3 +38,8 @@ pub use local::*; 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-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..5896b33f5 --- /dev/null +++ b/crates/alien-preflights/src/mutations/gcp_agent_platform_engine.rs @@ -0,0 +1,207 @@ +//! 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 { + // Keys on Gcp + sandbox; correct only once Cloud Run is removed as the GCP sandbox backend, + // which is why this mutation stays unregistered until the cutover. + 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/mod.rs b/crates/alien-preflights/src/mutations/mod.rs index 81aa801bf..f7f649393 100644 --- a/crates/alien-preflights/src/mutations/mod.rs +++ b/crates/alien-preflights/src/mutations/mod.rs @@ -8,6 +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_agent_platform_engine; pub mod gcp_sandbox_launcher; pub mod gcp_service_activation; pub mod infrastructure_dependencies; @@ -39,6 +40,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_agent_platform_engine::GcpAgentPlatformEngineMutation; pub use gcp_sandbox_launcher::GcpSandboxLauncherMutation; pub use gcp_service_activation::GcpServiceActivationMutation; pub use infrastructure_dependencies::InfrastructureDependenciesMutation; From b98858b737583ff31f5dbc08b8386c4d7ff65af4 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:09:44 +0300 Subject: [PATCH 20/21] feat(sandbox): make Agent Platform the GCP sandbox backend, remove Cloud Run Register the Gemini Agent Platform provider, emitter, sandbox controller and engine mutation as the GCP sandbox backend, and delete the Cloud Run implementation. The runtime loader builds an Agent Platform client against the binding's own region and creates sessions under the reasoning engine; the capability table for GCP now reports the Agent Platform row (reconnect, suspend/resume, snapshot, enforced limits); and a sandbox stack enables aiplatform at deploy. Removed: the Cloud Run sandbox provider and binding, its Terraform emitter, the launcher preflight mutation and sandbox-host-required check, the Worker.sandboxLauncher field, and the GcpSandbox import type. The shared Cloud Run worker client stays. No compatibility path: the sandbox feature has no users yet, so the binding changes from sandbox-gcp to sandbox-gcp-agent-platform with no migration. --- crates/alien-bindings/src/provider.rs | 58 +- .../sandbox/fixtures/gcp-sandbox-cli-help.txt | 207 ----- .../src/providers/sandbox/gcp.rs | 822 ------------------ .../providers/sandbox/gcp_agent_platform.rs | 3 - .../src/providers/sandbox/mod.rs | 4 - crates/alien-core/src/bin/schema_exporter.rs | 1 - crates/alien-core/src/bindings/mod.rs | 2 +- crates/alien-core/src/bindings/sandbox.rs | 35 +- 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 - .../resources/gcp_agent_platform_engine.rs | 1 - crates/alien-core/src/resources/sandbox.rs | 74 +- crates/alien-core/src/resources/worker.rs | 9 - crates/alien-gcp-clients/src/gcp/cloudrun.rs | 41 - crates/alien-infra/src/core/controller.rs | 8 + crates/alien-infra/src/core/registry.rs | 13 +- .../src/sandbox/gcp_agent_platform_engine.rs | 21 +- .../sandbox/gcp_agent_platform_template.rs | 26 +- crates/alien-infra/src/worker/gcp.rs | 75 +- .../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 | 3 +- .../src/mutations/gcp_sandbox_launcher.rs | 157 ---- .../src/mutations/gcp_service_activation.rs | 9 + crates/alien-preflights/src/mutations/mod.rs | 2 - .../tests/sandbox_platform_gate.rs | 15 +- crates/alien-terraform/src/built_ins.rs | 2 +- .../alien-terraform/src/emitters/gcp/mod.rs | 2 +- .../src/emitters/gcp/sandbox.rs | 151 +--- packages/core/src/generated/index.ts | 2 - .../schemas/gcpSandboxImportData.json | 1 - .../core/src/generated/schemas/worker.json | 2 +- .../zod/gcp-sandbox-import-data-schema.ts | 16 - packages/core/src/generated/zod/index.ts | 2 - .../core/src/generated/zod/worker-schema.ts | 1 - 39 files changed, 179 insertions(+), 1839 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 delete mode 100644 crates/alien-core/src/import/data/gcp/sandbox.rs delete mode 100644 crates/alien-preflights/src/compile_time/sandbox_host_required.rs delete mode 100644 crates/alien-preflights/src/mutations/gcp_sandbox_launcher.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/crates/alien-bindings/src/provider.rs b/crates/alien-bindings/src/provider.rs index f163868b6..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,20 +2062,11 @@ 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"))] SandboxBinding::Local(_) => Err(not_built("local")), - // The binding type exists so Agent Platform declarations can be written and emitted, - // but the runtime loader for that backend is not wired yet. - SandboxBinding::GcpAgentPlatform(_) => { - Err(AlienError::new(ErrorData::OperationNotSupported { - operation: "load_sandbox(gcp-agent-platform)".to_string(), - reason: "no runtime loader for the Agent Platform sandbox backend yet" - .to_string(), - })) - } } } } 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 index d54b8837a..c728ec6f4 100644 --- a/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform.rs +++ b/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform.rs @@ -5,9 +5,6 @@ //! `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. -//! -//! Unregistered on purpose: it is compiled and unit-tested but no factory selects it, so no -//! declaration can reach it until the cutover wires it in. use std::collections::{BTreeMap, VecDeque}; use std::sync::Arc; diff --git a/crates/alien-bindings/src/providers/sandbox/mod.rs b/crates/alien-bindings/src/providers/sandbox/mod.rs index aa90c4c91..e2fdb7ddd 100644 --- a/crates/alien-bindings/src/providers/sandbox/mod.rs +++ b/crates/alien-bindings/src/providers/sandbox/mod.rs @@ -13,10 +13,6 @@ pub mod aws; #[cfg(feature = "azure")] pub mod azure; -#[cfg(feature = "gcp")] -pub mod gcp; - -// Compiled and unit-tested, but not wired into the provider factory: the cutover selects it. #[cfg(feature = "gcp")] pub mod gcp_agent_platform; 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 98a11b042..894fa5e33 100644 --- a/crates/alien-core/src/bindings/mod.rs +++ b/crates/alien-core/src/bindings/mod.rs @@ -54,7 +54,7 @@ pub use queue::{ LocalQueueBinding, PubSubQueueBinding, QueueBinding, ServiceBusQueueBinding, SqsQueueBinding, }; pub use sandbox::{ - AwsSandboxBinding, AzureSandboxBinding, GcpAgentPlatformSandboxBinding, GcpSandboxBinding, + AwsSandboxBinding, AzureSandboxBinding, GcpAgentPlatformSandboxBinding, KubernetesSandboxBinding, LocalSandboxBinding, SandboxBinding, }; pub use service_account::{ diff --git a/crates/alien-core/src/bindings/sandbox.rs b/crates/alien-core/src/bindings/sandbox.rs index d2f9c604d..c8dee0977 100644 --- a/crates/alien-core/src/bindings/sandbox.rs +++ b/crates/alien-core/src/bindings/sandbox.rs @@ -22,9 +22,6 @@ 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), @@ -115,25 +112,10 @@ pub struct AzureSandboxBinding { pub disk_image: BindingValue, } -/// GCP 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. -#[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, -} - /// GCP Agent Platform sandbox binding configuration. /// -/// Unlike the Cloud Run backend, sessions have a durable parent to address: an Agent Engine -/// provisioned at setup and reached through a regional endpoint. +/// 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 GcpAgentPlatformSandboxBinding { @@ -227,17 +209,6 @@ impl SandboxBinding { }) } - /// Creates a GCP sandbox binding. - pub fn gcp( - launcher_path: impl Into>, - allow_egress: impl Into>, - ) -> Self { - Self::Gcp(GcpSandboxBinding { - launcher_path: launcher_path.into(), - allow_egress: allow_egress.into(), - }) - } - /// Creates a GCP Agent Platform sandbox binding. pub fn gcp_agent_platform( engine: impl Into>, @@ -308,7 +279,6 @@ 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", @@ -387,7 +357,6 @@ mod tests { 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/resources/gcp_agent_platform_engine.rs b/crates/alien-core/src/resources/gcp_agent_platform_engine.rs index 3ad588f10..eca62b092 100644 --- a/crates/alien-core/src/resources/gcp_agent_platform_engine.rs +++ b/crates/alien-core/src/resources/gcp_agent_platform_engine.rs @@ -21,7 +21,6 @@ impl GcpAgentPlatformEngine { /// The resource type identifier for Agent Platform reasoning engines. pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("gcp_agent_platform_engine"); - /// Returns the engine's unique identifier within the stack. pub fn id(&self) -> &str { &self.id } diff --git a/crates/alien-core/src/resources/sandbox.rs b/crates/alien-core/src/resources/sandbox.rs index eefc69632..444e9d3df 100644 --- a/crates/alien-core/src/resources/sandbox.rs +++ b/crates/alien-core/src/resources/sandbox.rs @@ -285,26 +285,7 @@ impl SandboxCapabilities { // 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, - // The session is a subprocess of the workload, which runs it directly: no separate - // identity supervises the command. - supervisor_isolation: 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 { @@ -358,12 +339,7 @@ impl SandboxCapabilities { } } - /// What the GCP Agent Platform sandbox backend supports. - /// - /// Not what `for_platform(Platform::Gcp)` returns: that reports the Cloud Run backend, which - /// is the registered one. This row is measured against the Agent Platform backend and takes - /// effect only once it replaces Cloud Run as the registered GCP backend — at which point it - /// becomes the body of the `Platform::Gcp` arm above. + /// 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. @@ -1014,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"); @@ -1056,7 +1032,7 @@ mod tests { /// 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 Cloud Run have no in-sandbox supervisor at all. + /// manager supervises from the host. Azure and Agent Platform have no in-sandbox supervisor. #[test] fn supervisor_isolation_is_per_platform() { let value = |platform| { @@ -1069,12 +1045,15 @@ mod tests { 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), "the session is a subprocess of the workload"); + assert!( + !value(Platform::Gcp), + "no separate supervisor identity runs the command" + ); } - /// The point of the field: AWS and Cloud Run 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 Cloud Run does not. + /// 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"); @@ -1085,14 +1064,16 @@ mod tests { "the older axis cannot tell them apart" ); assert!(aws.supervisor_isolation, "AWS setuids the command off the supervisor"); - assert!(!gcp.supervisor_isolation, "Cloud Run runs the command as the workload itself"); + 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. This row is deliberately not what `for_platform(Platform::Gcp)` - /// returns — that is still Cloud Run — so it is asserted directly. + /// 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(); @@ -1123,11 +1104,11 @@ mod tests { "the command is not run under a separate supervisor identity" ); - // The registered GCP backend is still Cloud Run, so the swap has not happened. + // 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_ne!( + assert_eq!( live, row, - "the Agent Platform row must not silently become the live GCP row before cutover" + "the Platform::Gcp arm is the Agent Platform capability row" ); } @@ -1233,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 { @@ -1248,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. @@ -1270,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] 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/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-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 51166b150..8115fe3e6 100644 --- a/crates/alien-infra/src/core/registry.rs +++ b/crates/alien-infra/src/core/registry.rs @@ -768,8 +768,7 @@ impl ResourceRegistry { Box::new(DefaultControllerFactory::::new()), ); - // Register the GCP Agent Platform reasoning-engine controller. Inert until the cutover - // registers the mutation that synthesizes the engine resource. + // Register the GCP Agent Platform reasoning-engine controller. #[cfg(feature = "gcp")] registry.register_controller_factory( alien_core::GcpAgentPlatformEngine::RESOURCE_TYPE, @@ -779,6 +778,16 @@ impl ResourceRegistry { ), ); + // 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/sandbox/gcp_agent_platform_engine.rs b/crates/alien-infra/src/sandbox/gcp_agent_platform_engine.rs index 995d336ff..6ad56e555 100644 --- a/crates/alien-infra/src/sandbox/gcp_agent_platform_engine.rs +++ b/crates/alien-infra/src/sandbox/gcp_agent_platform_engine.rs @@ -8,9 +8,6 @@ //! 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. -//! -//! Unregistered until the cutover, like the template controller (T09) it feeds: the registered GCP -//! sandbox backend is still Cloud Run, so nothing reaches this yet and it is proven by its tests. use std::time::Duration; use tracing::info; @@ -351,6 +348,24 @@ mod tests { 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(); 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 28632e0dc..9ba568b8a 100644 --- a/crates/alien-infra/src/sandbox/gcp_agent_platform_template.rs +++ b/crates/alien-infra/src/sandbox/gcp_agent_platform_template.rs @@ -1,6 +1,6 @@ //! GCP Agent Platform sandbox template controller. //! -//! Reconciles the `SandboxEnvironmentTemplate` (T09): the Live, release-owned object that carries +//! 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. @@ -9,10 +9,6 @@ //! 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. -//! -//! Unregistered like the provider it feeds (T05): the registered GCP sandbox backend is still Cloud -//! Run, so no declaration reaches this and it is proven by the controller tests below until the -//! cutover moves the registration. use std::collections::HashMap; use std::time::Duration; @@ -741,6 +737,24 @@ mod tests { .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) { @@ -849,7 +863,7 @@ mod tests { .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 T07 type"); + 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/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-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 index 5896b33f5..6f7cfb253 100644 --- a/crates/alien-preflights/src/mutations/gcp_agent_platform_engine.rs +++ b/crates/alien-preflights/src/mutations/gcp_agent_platform_engine.rs @@ -41,8 +41,7 @@ impl StackMutation for GcpAgentPlatformEngineMutation { stack_state: &StackState, _config: &DeploymentConfig, ) -> bool { - // Keys on Gcp + sandbox; correct only once Cloud Run is removed as the GCP sandbox backend, - // which is why this mutation stays unregistered until the cutover. + // 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() } 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 f7f649393..89a51e9da 100644 --- a/crates/alien-preflights/src/mutations/mod.rs +++ b/crates/alien-preflights/src/mutations/mod.rs @@ -9,7 +9,6 @@ pub mod azure_service_bus_namespace; pub mod azure_storage_account; pub mod compute_cluster; pub mod gcp_agent_platform_engine; -pub mod gcp_sandbox_launcher; pub mod gcp_service_activation; pub mod infrastructure_dependencies; pub mod kubernetes_cluster; @@ -41,7 +40,6 @@ pub use azure_service_bus_namespace::AzureServiceBusNamespaceMutation; pub use azure_storage_account::AzureStorageAccountMutation; pub use compute_cluster::ComputeClusterMutation; pub use gcp_agent_platform_engine::GcpAgentPlatformEngineMutation; -pub use gcp_sandbox_launcher::GcpSandboxLauncherMutation; 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-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 c3442b5c4..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::{GcpAgentPlatformSandboxEmitter, 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 70643a627..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,74 +12,7 @@ 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 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"; - -/// Emits the launcher's location; Cloud Run provides everything else. -#[derive(Debug, Clone, Copy, Default)] -pub struct GcpSandboxEmitter; - -impl TfEmitter for GcpSandboxEmitter { - fn emit(&self, _ctx: &EmitContext<'_>) -> Result { - // Deliberately empty: see the module note. The launcher arrives with the container. - Ok(TfFragment::default()) - } - - 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", - Expression::String(LAUNCHER_PATH.to_string()), - ), - ( - "allowEgress", - Expression::Bool(matches!(sandbox.egress, SandboxEgress::Allow)), - ), - ])) - } - - 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())), - ( - "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)), - ), - ]))) - } -} - -/// Serde `service` tag of the T07 `GcpAgentPlatformSandboxBinding`, and the resource-name shapes +/// 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"; @@ -106,11 +36,10 @@ fn refuse_domain_egress(sandbox: &Sandbox) -> Result<()> { /// The engine, template, region and ttl fields shared by the import ref and the binding ref. /// -/// `engine` and `template` carry runtime-assigned ids, so at emit time they are addressed by a -/// resource-name convention over the setup label rather than a Terraform resource attribute — this -/// emitter is unregistered and 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`. +/// `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![ ( @@ -137,20 +66,18 @@ fn agent_platform_fields(sandbox: &Sandbox, label: &str) -> Vec<(&'static str, E } /// Emits the GCP Agent Platform sandbox binding: the durable Agent Engine, the release-owned -/// template, the region and the session ttl (T07). +/// template, the region and the session ttl. /// -/// Unregistered on purpose, like the provider it feeds (T05): `built_ins` keeps Cloud Run as the -/// GCP sandbox backend, so the generator never dispatches here and this is exercised by direct -/// unit test until the cutover moves the registration. The engine is a Frozen setup resource with -/// 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. +/// 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 GcpAgentPlatformSandboxEmitter; impl TfEmitter for GcpAgentPlatformSandboxEmitter { fn emit(&self, _ctx: &EmitContext<'_>) -> Result { - // The engine is setup-created and monitored only; the template is reconciled by the Live - // controller after apply. Both carry runtime-assigned names, so neither is a resource block. + // 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()) } @@ -176,50 +103,6 @@ impl TfEmitter for GcpAgentPlatformSandboxEmitter { #[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` 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}")); - } - } - - // ---- Agent Platform emitter: unregistered, so exercised by direct invocation. ------------- - mod agent_platform { use super::super::*; use alien_core::bindings::SandboxBinding; @@ -272,11 +155,11 @@ mod tests { } } - /// The emitted keys are read against the T07 binding type, not a second hand-typed list, so + /// 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_t07_binding_type() { + 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"); @@ -298,7 +181,7 @@ mod tests { assert_eq!( object_keys(&emitted), type_keys, - "emitted keys must track the T07 binding type" + "emitted keys must track the binding type" ); } 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/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/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.") From 1847cd884cc75bbd34bfa7b9fa893b724e4fc545 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:51:42 +0300 Subject: [PATCH 21/21] fix(sandbox): retain unread job results and fail a leaked resume rollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on the GCP Agent Platform sandbox: The job cap evicted the oldest finished job whether or not its terminal result had been read, so a poll racing a new command's start could get JobNotFound and lose the real exit code. Only evict a finished job whose outcome a poll has already returned; refuse a new start otherwise. A get_or_create that woke a suspended session, found it unhealthy, and could not re-suspend it only logged and fell through to create a replacement — leaving the woken session live with no id handed back. Fail instead, naming the session so it stays identifiable. --- .../providers/sandbox/gcp_agent_platform.rs | 219 ++++++----- .../sandbox/gcp_agent_platform_tests.rs | 348 +++++++++++++----- crates/alien-sandbox-agent/src/jobs.rs | 145 ++++++-- 3 files changed, 503 insertions(+), 209 deletions(-) diff --git a/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform.rs b/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform.rs index c728ec6f4..e2a0605fe 100644 --- a/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform.rs +++ b/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform.rs @@ -222,14 +222,14 @@ impl GcpAgentPlatformSandbox { 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}'"), - })?; + 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) { @@ -363,7 +363,11 @@ impl GcpAgentPlatformSandbox { /// 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 { + 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; }; @@ -391,9 +395,7 @@ impl GcpAgentPlatformSandbox { 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" - ), + reason: format!("session '{session_id}' disappeared while it was coming up"), })); }; match session_state(CREATE, sandbox.state.as_deref())? { @@ -403,7 +405,9 @@ impl GcpAgentPlatformSandbox { SandboxSessionState::Terminated => { return Err(AlienError::new(ErrorData::SandboxCommandFailed { failure: "sessionTerminated".to_string(), - reason: format!("session '{session_id}' reached a terminal state while starting"), + 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 @@ -506,7 +510,9 @@ impl Sandbox for GcpAgentPlatformSandbox { 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")), + ttl: self + .session_ttl_seconds + .map(|seconds| format!("{seconds}s")), }, ) .await @@ -515,15 +521,17 @@ impl Sandbox for GcpAgentPlatformSandbox { 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}"), - }) - })?; + 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 @@ -594,8 +602,17 @@ impl Sandbox for GcpAgentPlatformSandbox { 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 { - warn!(session = %id, %error, "could not re-suspend a session this call woke"); + 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" + ), + })); } } } @@ -616,14 +633,12 @@ impl Sandbox for GcpAgentPlatformSandbox { } async fn list(&self) -> Result> { - let sandboxes = self - .client - .list_sandboxes(&self.engine) - .await - .context(ErrorData::SandboxUnreachable { + 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 @@ -755,28 +770,24 @@ impl Sandbox for GcpAgentPlatformSandbox { 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 { + 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 { + 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(()) } @@ -796,17 +807,18 @@ impl Sandbox for GcpAgentPlatformSandbox { 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}"), - }) - })?; + 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 { @@ -857,9 +869,7 @@ impl Sandbox for GcpAgentPlatformSandbox { /// 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)> { +async fn job_poll_step(mut state: JobPollState) -> Option<(Result, JobPollState)> { loop { if let Some(item) = state.pending.pop_front() { return Some((item, state)); @@ -879,26 +889,34 @@ async fn job_poll_step( &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 + .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)) + .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 + .pending + .push_back(Err(GcpAgentPlatformSandbox::execute_failed( + RUN_COMMAND, + error, + ))); state.finished = true; continue; } @@ -907,12 +925,14 @@ async fn job_poll_step( 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.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; } @@ -988,14 +1008,23 @@ struct JobError { #[derive(Deserialize)] #[serde(rename_all = "camelCase", tag = "t")] enum WireFrame { - Stdout { seq: u64, data: String }, - Stderr { seq: u64, data: String }, + Stdout { + seq: u64, + data: String, + }, + Stderr { + seq: u64, + data: String, + }, Exit { code: i32, #[serde(default)] truncated: bool, }, - Error { code: String, message: String }, + Error { + code: String, + message: String, + }, } impl WireFrame { @@ -1023,10 +1052,12 @@ impl WireFrame { 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, - })), + Self::Error { code, message } => { + Err(AlienError::new(ErrorData::SandboxCommandFailed { + failure: code, + reason: message, + })) + } } } } @@ -1199,26 +1230,25 @@ fn session_state(operation: &str, state: Option<&str>) -> Result Result { +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 - ), - })), + 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(), @@ -1256,7 +1286,10 @@ fn truncated(body: &[u8]) -> String { if text.len() <= LIMIT { return text.to_string(); } - let end = (0..=LIMIT).rev().find(|at| text.is_char_boundary(*at)).unwrap_or(0); + let end = (0..=LIMIT) + .rev() + .find(|at| text.is_char_boundary(*at)) + .unwrap_or(0); format!("{}…", &text[..end]) } 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 index 91be81410..a169f457c 100644 --- a/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform_tests.rs +++ b/crates/alien-bindings/src/providers/sandbox/gcp_agent_platform_tests.rs @@ -18,7 +18,12 @@ fn provider(client: MockAgentPlatformApi) -> GcpAgentPlatformSandbox { } fn provider_from(client: Arc) -> GcpAgentPlatformSandbox { - GcpAgentPlatformSandbox::new(client, ENGINE_FULL.to_string(), TEMPLATE.to_string(), Some(3600)) + GcpAgentPlatformSandbox::new( + client, + ENGINE_FULL.to_string(), + TEMPLATE.to_string(), + Some(3600), + ) } fn sandbox_name(id: &str) -> String { @@ -50,14 +55,23 @@ fn done_op(value: serde_json::Value) -> Operation { 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)) + .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.extend_from_slice( + serde_json::to_string(line) + .expect("frame serializes") + .as_bytes(), + ); body.push(b'\n'); } body @@ -188,7 +202,10 @@ async fn get_returns_none_when_the_sandbox_is_gone() { .expect_get_sandbox() .returning(|_, _| Err(not_found())); - let found = provider(client).get("s1").await.expect("a gone sandbox is a valid answer"); + 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"); } @@ -228,10 +245,11 @@ async fn get_or_create_replaces_a_stale_session_without_deleting_it() { .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_create_sandbox().times(1).returning(|_, _| { + Ok(done_op( + serde_json::json!({ "name": sandbox_name("fresh") }), + )) + }); client .expect_get_sandbox() .withf(|_, sandbox| sandbox == "fresh") @@ -250,7 +268,10 @@ async fn get_or_create_replaces_a_stale_session_without_deleting_it() { }) .await .expect("a stale session is replaced"); - assert_eq!(session.session_id, "fresh", "the fresh session is returned, not the stale id"); + 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 @@ -268,7 +289,10 @@ async fn get_or_create_resumes_a_suspended_session_rather_than_creating_a_second Ok(sandbox_in_state(id, "STATE_RUNNING")) } }); - client.expect_resume().times(1).returning(|_, _| Ok(done_op(serde_json::json!({})))); + client + .expect_resume() + .times(1) + .returning(|_, _| Ok(done_op(serde_json::json!({})))); client .expect_execute() .withf(|_, _, input| op_of(input) == "health") @@ -287,7 +311,46 @@ async fn get_or_create_resumes_a_suspended_session_rather_than_creating_a_second 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"); + 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 ------------------------------------------------------------------- @@ -320,9 +383,18 @@ async fn generation_tracks_the_container_boot_id() { 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"); + 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` @@ -423,10 +495,7 @@ impl AgentPlatformApi for WedgedAgent { async fn delete_template(&self, _engine: &str, _template: &str) -> ClientResult<()> { unimplemented!() } - async fn list_templates( - &self, - _engine: &str, - ) -> ClientResult> { + async fn list_templates(&self, _engine: &str) -> ClientResult> { unimplemented!() } async fn create_sandbox( @@ -473,7 +542,10 @@ async fn list_maps_sandboxes_to_sessions() { ]) }); - let sessions = provider(client).list().await.expect("list is supported here"); + 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); @@ -488,11 +560,13 @@ async fn list_maps_sandboxes_to_sessions() { #[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}"), - }); + 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( @@ -509,8 +583,13 @@ async fn a_short_command_runs_synchronously_without_a_job() { .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, .. })))); + 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 @@ -519,29 +598,31 @@ async fn a_short_command_runs_synchronously_without_a_job() { 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()) + 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}"), - }); + "exec" => panic!("a long command must not run synchronously"), + other => panic!("unexpected op {other}"), + }); let frames: Vec<_> = provider(client) .run_command( @@ -558,8 +639,13 @@ async fn a_long_command_uses_the_job_path() { .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, .. })))); + 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 @@ -567,16 +653,18 @@ async fn a_long_command_uses_the_job_path() { #[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}"), - }); + 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( @@ -593,7 +681,11 @@ async fn a_job_error_object_becomes_a_stream_error() { .collect() .await; - let error = frames.last().expect("a terminal item").as_ref().expect_err("an error object is a failure"); + 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}"); } @@ -608,8 +700,14 @@ async fn a_failed_command_is_delivered_once_where_a_read_still_retries() { 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_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 { @@ -637,7 +735,9 @@ async fn a_failed_command_is_delivered_once_where_a_read_still_retries() { }; assert_eq!(command.code, "SANDBOX_COMMAND_FAILED", "{command}"); - sut.terminate("s1").await.expect("the poll confirms the session is gone"); + sut.terminate("s1") + .await + .expect("the poll confirms the session is gone"); assert!( reads_seen.load(Ordering::SeqCst) > 1, @@ -650,7 +750,9 @@ async fn a_failed_command_is_delivered_once_where_a_read_still_retries() { #[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_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. @@ -720,14 +822,18 @@ async fn write_files_sends_contents_base64_and_accepts_an_empty_body() { .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("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())])) + .write_files( + "s1", + BTreeMap::from([("a.txt".to_string(), b"data".to_vec())]), + ) .await .expect("an empty body is a successful write"); } @@ -740,7 +846,10 @@ async fn mkdir_accepts_an_empty_body() { .withf(|_, _, input| op_of(input) == "mkdir") .returning(|_, _, _| Ok(Vec::new())); - provider(client).mkdir("s1", "out").await.expect("mkdir succeeds on an empty body"); + provider(client) + .mkdir("s1", "out") + .await + .expect("mkdir succeeds on an empty body"); } #[tokio::test] @@ -750,11 +859,16 @@ async fn read_file_decodes_the_agent_reply() { .expect_execute() .withf(|_, _, input| op_of(input) == "readFile") .returning(|_, _, _| { - Ok(serde_json::to_vec(&serde_json::json!({ "contentsBase64": BASE64.encode(b"file body") })) - .unwrap()) + 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"); + let contents = provider(client) + .read_file("s1", "a.txt") + .await + .expect("read succeeds"); assert_eq!(contents, b"file body"); } @@ -763,8 +877,14 @@ async fn read_file_decodes_the_agent_reply() { #[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!({})))); + 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"); @@ -774,13 +894,19 @@ async fn suspend_and_resume_await_their_operations() { #[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"; + 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()) + .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"); + let returned = provider(client) + .snapshot("s1") + .await + .expect("snapshot completes"); assert_eq!(returned, name); } @@ -792,7 +918,10 @@ async fn snapshot_returns_the_snapshot_name() { 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_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 { @@ -802,20 +931,28 @@ async fn terminate_confirms_by_polling_to_not_found() { } }); - provider(client).terminate("s1").await.expect("a session that goes absent is confirmed gone"); + 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"))); + 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}"); + assert!( + error.to_string().contains("may still be running"), + "{error}" + ); } // ---- unit guards ------------------------------------------------------------------------------ @@ -824,19 +961,31 @@ async fn terminate_reports_unconfirmed_when_the_session_stays_present() { /// 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"); + 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!( + rendered.contains("allow") && rendered.contains("deny"), + "names both modes: {rendered}" + ); assert_eq!( - egress_control_config("s", &SandboxEgress::Deny).expect("deny maps").internet_access, + 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, + egress_control_config("s", &SandboxEgress::Allow) + .expect("allow maps") + .internet_access, Some(true) ); } @@ -845,7 +994,14 @@ fn egress_refuses_domain_scoping_and_names_the_modes() { /// `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"] { + for id in [ + "../other", + "a/b", + "has space", + "", + "with?query", + "with#frag", + ] { let error = provider(MockAgentPlatformApi::new()) .get(id) .await @@ -861,8 +1017,13 @@ 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}"); + 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. @@ -877,7 +1038,10 @@ fn a_non_frame_body_is_reported_as_a_refusal() { /// 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(¬_found()), + "a wrapped 404 is a gone session" + ); assert!( !is_not_found(&execute_refused()), "an ordinary execute failure is not a gone session" @@ -887,5 +1051,9 @@ fn not_found_is_read_from_the_source_chain() { #[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"); + assert_eq!( + provider.engine(), + "eng1", + "the full resource name is reduced to the engine id" + ); } diff --git a/crates/alien-sandbox-agent/src/jobs.rs b/crates/alien-sandbox-agent/src/jobs.rs index 9b8d0ab99..ed6d6cd61 100644 --- a/crates/alien-sandbox-agent/src/jobs.rs +++ b/crates/alien-sandbox-agent/src/jobs.rs @@ -61,6 +61,9 @@ struct Buffer { 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 { @@ -68,7 +71,7 @@ struct Job { /// 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 finished job is the one evicted under the cap. + /// Start order, so the oldest evictable job is the one reclaimed under the cap. ordinal: u64, } @@ -111,6 +114,7 @@ impl JobRegistry { buffer: Mutex::new(Buffer { frames: Vec::new(), outcome: None, + terminal_delivered: false, }), cancel: Mutex::new(None), ordinal: self.ordinal.fetch_add(1, Ordering::Relaxed), @@ -127,7 +131,14 @@ impl JobRegistry { *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; + exec::stream( + &request, + Some(&working_directory), + identity, + output_cap, + frames_tx, + ) + .await; }); tokio::spawn(collect(frames_rx, cancel_rx, job)); @@ -145,7 +156,7 @@ impl JobRegistry { /// 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 buffer = job.buffer.lock().expect("no panic holds a job lock"); + let mut buffer = job.buffer.lock().expect("no panic holds a job lock"); let frames = buffer .frames .iter() @@ -155,10 +166,12 @@ impl JobRegistry { }) .cloned() .collect(); - Some(JobSnapshot { - frames, - outcome: buffer.outcome.clone(), - }) + 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. @@ -181,6 +194,19 @@ impl JobRegistry { 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") } @@ -194,19 +220,18 @@ impl JobRegistry { return Ok(()); } - let oldest_finished = jobs + // 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)| { - job.buffer - .lock() - .expect("no panic holds a job lock") - .outcome - .is_some() + 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_finished { + match oldest_evictable { Some(id) => { jobs.remove(&id); Ok(()) @@ -391,7 +416,9 @@ mod tests { "the job must exit cleanly" ); assert_eq!( - stdout_text(&done.frames).split_whitespace().collect::>(), + stdout_text(&done.frames) + .split_whitespace() + .collect::>(), vec!["a", "b", "c"], "the full output must survive across polls" ); @@ -417,7 +444,9 @@ mod tests { done.outcome ); assert_eq!( - stdout_text(&done.frames).split_whitespace().collect::>(), + stdout_text(&done.frames) + .split_whitespace() + .collect::>(), vec!["start", "end"], "both the pre- and post-sleep output must arrive" ); @@ -474,10 +503,18 @@ mod tests { #[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 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"); + 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!( @@ -506,8 +543,7 @@ mod tests { #[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 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 @@ -521,7 +557,10 @@ mod tests { 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"); + 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; @@ -549,8 +588,8 @@ mod tests { ); } - /// A finished-but-uncollected job is evicted to make room once the cap is reached, so retention - /// is bounded rather than growing with every job a session ever ran. + /// 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); @@ -579,6 +618,48 @@ mod tests { ); } + /// 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] @@ -632,7 +713,11 @@ mod tests { let id = registry .start( request( - &["/bin/sh", "-c", "for i in 1 2 3 4 5 6 7 8; do echo aaaaaaaaaa; done"], + &[ + "/bin/sh", + "-c", + "for i in 1 2 3 4 5 6 7 8; do echo aaaaaaaaaa; done", + ], 10_000, ), std::env::temp_dir(), @@ -643,12 +728,20 @@ mod tests { let done = wait_for_completion(®istry, &id).await; assert!( - matches!(done.outcome, Some(JobOutcome::Exited { truncated: true, .. })), + 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"); + let last = *seqs(&done.frames) + .last() + .expect("some output is kept below the cap"); assert!( registry .poll(&id, Some(last))