diff --git a/crates/kernel/ironclaw_host_runtime/src/first_party_tools/http.rs b/crates/kernel/ironclaw_host_runtime/src/first_party_tools/http.rs index 8c2af6e7e3..f5b7a28946 100644 --- a/crates/kernel/ironclaw_host_runtime/src/first_party_tools/http.rs +++ b/crates/kernel/ironclaw_host_runtime/src/first_party_tools/http.rs @@ -26,7 +26,7 @@ use crate::{ use super::{ first_party_capability_manifest, - http_output::{HttpDispatchOutput, shape_response}, + http_output::{HttpDispatchOutput, classify_status, shape_response}, input_error, }; @@ -245,7 +245,9 @@ pub(super) async fn dispatch( ) .await? .map_err(|error| http_error(error, save_mode))?; - Ok(shape_response(response, response_body_limit)) + let status = response.status; + let shaped = shape_response(response, response_body_limit); + classify_status(shaped, status) } fn method(input: &Value) -> Result { diff --git a/crates/kernel/ironclaw_host_runtime/src/first_party_tools/http_output.rs b/crates/kernel/ironclaw_host_runtime/src/first_party_tools/http_output.rs index 7bc1d24299..71646acba9 100644 --- a/crates/kernel/ironclaw_host_runtime/src/first_party_tools/http_output.rs +++ b/crates/kernel/ironclaw_host_runtime/src/first_party_tools/http_output.rs @@ -1,7 +1,12 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; -use ironclaw_host_api::http::RuntimeHttpEgressResponse; +use ironclaw_host_api::{ + dispatch::RuntimeDispatchErrorKind, http::RuntimeHttpEgressResponse, resource::ResourceUsage, + result_meta::MODEL_DIAGNOSTIC_MAX_BYTES, +}; use serde_json::{Map, Value, json}; +use crate::FirstPartyCapabilityError; + use super::model_visible_output::{ max_binary_bytes_for_base64_budget, serialized_json_content_len, serialized_json_len, truncate_str_for_json_content_budget, truncate_string_for_json_content_budget, @@ -73,6 +78,125 @@ pub(super) fn shape_response( } } +/// Transport completion is not capability success: HTTP 4xx/5xx responses are +/// model-visible, recoverable `OperationFailed` outcomes carrying the bounded, +/// sanitized response as diagnostic context. Informational, successful, and +/// redirect responses stay inspectable successful results; redirects are +/// returned, never followed. Drawn deliberately at 400 so rate-limit and +/// overload responses (429/503) are never reported as success. +pub(super) fn classify_status( + shaped: HttpDispatchOutput, + status: u16, +) -> Result { + if !(400..=599).contains(&status) { + return Ok(shaped); + } + tracing::debug!( + http_status = status, + dispatch_error_kind = RuntimeDispatchErrorKind::OperationFailed.as_str(), + "first-party HTTP response status classified as capability failure" + ); + let usage = ResourceUsage::default().set_network_egress_bytes(shaped.network_egress_bytes); + Err(FirstPartyCapabilityError::dispatch_with_diagnostic( + RuntimeDispatchErrorKind::OperationFailed, + Some(format!("HTTP request returned status {status}")), + bounded_failure_diagnostic(&shaped.output, status), + ) + .with_usage(usage)) +} + +/// Serialize the shaped output as the failure diagnostic, trimming the response +/// body so the serialized diagnostic stays within the model-visible diagnostic +/// budget. The resolution boundary truncates the whole diagnostic string at +/// `MODEL_DIAGNOSTIC_MAX_BYTES`, and `serde_json::Map` serializes keys in +/// sorted order, so an untrimmed diagnostic would cut `status`, `auth_hint`, +/// and the truncation envelope out of the model-visible text. Trimming here +/// keeps the verdict fields intact and the diagnostic valid JSON. +fn bounded_failure_diagnostic(output: &Value, status: u16) -> String { + let Some(output) = output.as_object() else { + return fallback_diagnostic(status); + }; + let mut output = output.clone(); + if serialized_output_len(&output) <= MODEL_DIAGNOSTIC_MAX_BYTES { + return serialize_diagnostic(&output, status); + } + let mut body_bytes_returned = None; + let mut headers_truncated = output.contains_key("headers_truncated"); + let mut trimmed_any = false; + // Truncation markers and the truncation envelope grow the serialized size + // too, so re-measure after each trim and keep shrinking until the + // diagnostic fits; the fixed verdict fields alone are always far below + // the budget. + for _ in 0..8 { + if trimmed_any { + insert_truncation_envelope(&mut output, headers_truncated, body_bytes_returned); + } + let current_len = serialized_output_len(&output); + if current_len <= MODEL_DIAGNOSTIC_MAX_BYTES { + break; + } + let excess_bytes = current_len.saturating_sub(MODEL_DIAGNOSTIC_MAX_BYTES); + if let Some(body_text) = output.get("body_text").and_then(Value::as_str) { + let current_body_budget = serialized_json_content_len(body_text); + let target_body_budget = current_body_budget.saturating_sub(excess_bytes); + let (body_text, _) = + truncate_str_for_json_content_budget(body_text, target_body_budget); + let returned_body_bytes = body_text.len(); + output.insert( + "body_text".to_string(), + Value::String(body_text.to_string()), + ); + mark_inline_body_truncated(&mut output, returned_body_bytes); + body_bytes_returned = Some(returned_body_bytes); + trimmed_any = true; + } else if let Some(body_base64) = output.get("body_base64").and_then(Value::as_str) { + let target_len = body_base64.len().saturating_sub(excess_bytes) / 4 * 4; + // safety: base64 text is ASCII and `target_len` is a multiple of 4 + // no larger than `body_base64.len()`, so the slice lands on a + // UTF-8 boundary. + let body_base64 = body_base64[..target_len].to_string(); // safety: ASCII base64, multiple-of-4 length + let returned_body_bytes = max_binary_bytes_for_base64_budget(target_len); + output.insert("body_base64".to_string(), Value::String(body_base64)); + mark_inline_body_truncated(&mut output, returned_body_bytes); + body_bytes_returned = Some(returned_body_bytes); + trimmed_any = true; + } else if output + .get("headers") + .and_then(Value::as_array) + .is_some_and(|headers| !headers.is_empty()) + { + headers_truncated |= + trim_headers_for_final_budget(&mut output, MODEL_DIAGNOSTIC_MAX_BYTES, current_len); + trimmed_any = true; + } else { + // The fixed verdict fields alone exceed the budget; keep the + // verdict rather than a broken JSON fragment. + return fallback_diagnostic(status); + } + } + if serialized_output_len(&output) > MODEL_DIAGNOSTIC_MAX_BYTES { + return fallback_diagnostic(status); + } + serialize_diagnostic(&output, status) +} + +/// The diagnostic string is produced from a `serde_json::Value`, which can +/// only fail to serialize on lone-surrogate text. Log the cause and fall back +/// to a fixed verdict payload rather than dropping the failure context. +fn serialize_diagnostic(output: &Map, status: u16) -> String { + match serde_json::to_string(output) { + Ok(diagnostic) => diagnostic, + Err(error) => { + tracing::debug!(%error, "failed to serialize HTTP failure diagnostic"); + fallback_diagnostic(status) + } + } +} + +fn fallback_diagnostic(status: u16) -> String { + format!("{{\"status\":{status},\"error\":\"diagnostic unavailable\"}}") +} + /// When an outbound `builtin.http` request is rejected for missing or invalid /// authorization, nudge the model toward the extension that can inject /// credentials for the host, rather than concluding the resource is @@ -329,8 +453,13 @@ fn mark_inline_body_truncated(output: &mut Map, returned_body_byt #[cfg(test)] mod tests { + use ironclaw_host_api::{ + http::{RuntimeHttpEgressResponse, RuntimeHttpSavedBody}, + path::ScopedPath, + }; + use serde_json::json; + use super::*; - use ironclaw_host_api::http::RuntimeHttpEgressResponse; fn response_with_status(status: u16) -> RuntimeHttpEgressResponse { RuntimeHttpEgressResponse { @@ -369,4 +498,82 @@ mod tests { ); } } + + #[test] + fn failure_diagnostic_trims_large_bodies_while_preserving_verdict_fields() { + let shaped = shape_response( + RuntimeHttpEgressResponse { + status: 403, + headers: Vec::new(), + body: vec![b'a'; 32 * 1024], + saved_body: None, + request_bytes: 42, + response_bytes: 32 * 1024, + redaction_applied: false, + }, + 48 * 1024, + ); + + let diagnostic = bounded_failure_diagnostic(&shaped.output, 403); + + assert!( + diagnostic.len() <= MODEL_DIAGNOSTIC_MAX_BYTES, + "diagnostic must fit the model-visible budget, got {} bytes", + diagnostic.len() + ); + let parsed: Value = + serde_json::from_str(&diagnostic).expect("trimmed diagnostic must stay valid JSON"); + assert_eq!(parsed["status"], json!(403)); + assert!( + parsed["auth_hint"].as_str().is_some(), + "auth_hint must survive the budget trim" + ); + assert_eq!(parsed["truncation"]["body"], json!(true)); + assert!( + parsed["body_text"].as_str().is_some(), + "trimmed body must remain visible" + ); + } + + #[test] + fn failure_diagnostic_preserves_saved_body_metadata_without_inline_body() { + let shaped = shape_response( + RuntimeHttpEgressResponse { + status: 403, + headers: vec![("content-type".to_string(), "application/json".to_string())], + body: Vec::new(), + saved_body: Some(RuntimeHttpSavedBody { + path: ScopedPath::new("/workspace/x.json").unwrap(), + bytes_written: 10, + }), + request_bytes: 0, + response_bytes: 10, + redaction_applied: false, + }, + 1024, + ); + + let diagnostic = bounded_failure_diagnostic(&shaped.output, 403); + let parsed: Value = + serde_json::from_str(&diagnostic).expect("diagnostic must be valid JSON"); + assert_eq!(parsed["status"], json!(403)); + assert_eq!( + parsed["saved_body"], + json!({"path": "/workspace/x.json", "bytes_written": 10}) + ); + assert!( + parsed.get("body_text").is_none(), + "save-mode diagnostics carry saved_body metadata, not the inline body" + ); + } + + #[test] + fn small_failure_diagnostic_is_serialized_untrimmed() { + let shaped = shape_response(response_with_status(403), 1024); + let diagnostic = bounded_failure_diagnostic(&shaped.output, 403); + let parsed: Value = + serde_json::from_str(&diagnostic).expect("diagnostic must be valid JSON"); + assert_eq!(parsed["status"], json!(403)); + assert!(diagnostic.len() <= MODEL_DIAGNOSTIC_MAX_BYTES); + } } diff --git a/crates/kernel/ironclaw_host_runtime/tests/first_party_builtin_tools.rs b/crates/kernel/ironclaw_host_runtime/tests/first_party_builtin_tools.rs index 3d072b0495..f5671568c1 100644 --- a/crates/kernel/ironclaw_host_runtime/tests/first_party_builtin_tools.rs +++ b/crates/kernel/ironclaw_host_runtime/tests/first_party_builtin_tools.rs @@ -22,7 +22,7 @@ use ironclaw_host_api::capability_surface::CapabilitySurfacePolicy; use ironclaw_host_api::process::{ CommandExecutionOutput, CommandExecutionRequest, RuntimeProcessError, SandboxCommandTransport, }; -use ironclaw_host_api::result_meta::FailureKind; +use ironclaw_host_api::result_meta::{FailureKind, MODEL_DIAGNOSTIC_MAX_BYTES}; use ironclaw_host_api::runtime_policy::{ ApprovalPolicy, AuditMode, DeploymentMode, EffectiveRuntimePolicy, FilesystemBackendKind, NetworkMode, ProcessBackendKind, RuntimeProfile, SecretMode, @@ -4433,6 +4433,233 @@ async fn builtin_http_invokes_through_host_runtime_egress() { assert!(request.credential_injections.is_empty()); } +#[tokio::test] +async fn builtin_http_surfaces_http_error_status_as_failed_outcome() { + let egress = Arc::new(RecordingRuntimeHttpEgress::with_status_and_body( + 403, + br#"{"message":"authentication required"}"#.to_vec(), + )); + let runtime = runtime_with_http_egress(Arc::clone(&egress)); + + let failure = invoke_failure_with_context( + &runtime, + HTTP_CAPABILITY_ID, + json!({"url": "https://api.example.test/private"}), + execution_context_with_network([HTTP_CAPABILITY_ID], http_test_policy()), + ) + .await; + + assert_eq!(failure.kind, FailureKind::OperationFailed); + assert_eq!( + failure.safe_summary().as_deref(), + Some("HTTP request returned status 403") + ); + let Some(DispatchFailureDetail::Diagnostic { text }) = failure.detail else { + panic!("HTTP error response must remain available as diagnostic context"); + }; + let response: Value = serde_json::from_str(&text).expect("HTTP diagnostic must be JSON"); + assert_eq!(response["status"], json!(403)); + assert_eq!( + response["body_text"], + json!(r#"{"message":"authentication required"}"#) + ); + assert!(response["auth_hint"].as_str().is_some_and(|hint| { + hint.contains("authentication/authorization") && hint.contains("extension") + })); + assert_eq!(egress.requests().len(), 1); +} + +#[tokio::test] +async fn builtin_http_keeps_redirect_responses_model_visible() { + let egress = Arc::new( + RecordingRuntimeHttpEgress::with_status_and_body(302, Vec::new()) + .with_headers(vec![("location".to_string(), "/next".to_string())]), + ); + let runtime = runtime_with_http_egress(Arc::clone(&egress)); + + let output = invoke_with_context( + &runtime, + HTTP_CAPABILITY_ID, + json!({"url": "https://api.example.test/redirect"}), + execution_context_with_network([HTTP_CAPABILITY_ID], http_test_policy()), + ) + .await + .expect("redirect responses must remain inspectable results"); + + assert_eq!(output["status"], json!(302)); + assert_eq!(output["headers"][0]["name"], json!("location")); + assert_eq!(output["headers"][0]["value"], json!("/next")); +} + +#[tokio::test] +async fn builtin_http_surfaces_server_error_status_as_failed_outcome() { + let egress = Arc::new(RecordingRuntimeHttpEgress::with_status_and_body( + 500, + br#"{"error":"internal"}"#.to_vec(), + )); + let runtime = runtime_with_http_egress(Arc::clone(&egress)); + + let failure = invoke_failure_with_context( + &runtime, + HTTP_CAPABILITY_ID, + json!({"url": "https://api.example.test/boom"}), + execution_context_with_network([HTTP_CAPABILITY_ID], http_test_policy()), + ) + .await; + + assert_eq!(failure.kind, FailureKind::OperationFailed); + assert_eq!( + failure.safe_summary().as_deref(), + Some("HTTP request returned status 500") + ); + let Some(DispatchFailureDetail::Diagnostic { text }) = failure.detail else { + panic!("HTTP error response must remain available as diagnostic context"); + }; + let response: Value = serde_json::from_str(&text).expect("HTTP diagnostic must be JSON"); + assert_eq!(response["status"], json!(500)); + assert_eq!(response["body_text"], json!(r#"{"error":"internal"}"#)); + assert_eq!(egress.requests().len(), 1); +} + +#[tokio::test] +async fn builtin_http_save_surfaces_http_error_status_as_failed_outcome() { + let egress = Arc::new( + RecordingRuntimeHttpEgress::with_status_and_body(403, br#"{"message":"denied"}"#.to_vec()) + .with_saved_body("/workspace/denied.json", 20), + ); + let runtime = runtime_with_http_egress(Arc::clone(&egress)); + let mounts = MountView::new(vec![MountGrant::new( + MountAlias::new("/workspace").unwrap(), + VirtualPath::new("/projects/workspace").unwrap(), + MountPermissions::read_write(), + )]) + .unwrap(); + + let failure = invoke_failure_with_context( + &runtime, + HTTP_SAVE_CAPABILITY_ID, + json!({ + "url": "https://api.example.test/private", + "save_to": "/workspace/denied.json" + }), + execution_context_with_mounts_and_network( + [HTTP_SAVE_CAPABILITY_ID], + mounts, + http_test_policy(), + ), + ) + .await; + + assert_eq!(failure.kind, FailureKind::OperationFailed); + assert_eq!( + failure.safe_summary().as_deref(), + Some("HTTP request returned status 403") + ); + let Some(DispatchFailureDetail::Diagnostic { text }) = failure.detail else { + panic!("HTTP error response must remain available as diagnostic context"); + }; + let response: Value = serde_json::from_str(&text).expect("HTTP diagnostic must be JSON"); + assert_eq!(response["status"], json!(403)); + assert_eq!( + response["saved_body"], + json!({"path": "/workspace/denied.json", "bytes_written": 20}) + ); + assert!( + response.get("body_text").is_none(), + "save-mode diagnostics carry saved_body metadata, not the inline body" + ); + + let requests = egress.requests(); + assert_eq!(requests.len(), 1); + assert!( + requests[0].save_body_to.is_some(), + "save-mode error path must still use strict host egress with a save target" + ); +} + +#[tokio::test] +async fn builtin_http_classifies_status_range_boundaries() { + for status in [400u16, 599] { + let egress = Arc::new(RecordingRuntimeHttpEgress::with_status_and_body( + status, + Vec::new(), + )); + let runtime = runtime_with_http_egress(Arc::clone(&egress)); + + let failure = invoke_failure_with_context( + &runtime, + HTTP_CAPABILITY_ID, + json!({"url": "https://api.example.test/edge"}), + execution_context_with_network([HTTP_CAPABILITY_ID], http_test_policy()), + ) + .await; + assert_eq!( + failure.kind, + FailureKind::OperationFailed, + "status {status} must classify as a failure" + ); + } + for status in [100u16, 304, 600] { + let egress = Arc::new(RecordingRuntimeHttpEgress::with_status_and_body( + status, + Vec::new(), + )); + let runtime = runtime_with_http_egress(Arc::clone(&egress)); + + let output = invoke_with_context( + &runtime, + HTTP_CAPABILITY_ID, + json!({"url": "https://api.example.test/edge"}), + execution_context_with_network([HTTP_CAPABILITY_ID], http_test_policy()), + ) + .await + .unwrap_or_else(|error| { + panic!("status {status} must stay an inspectable result, got {error:?}") + }); + assert_eq!(output["status"], json!(status)); + } +} + +#[tokio::test] +async fn builtin_http_error_diagnostic_respects_model_diagnostic_budget() { + let egress = Arc::new(RecordingRuntimeHttpEgress::with_status_and_body( + 403, + vec![b'a'; 16 * 1024], + )); + let runtime = runtime_with_http_egress(Arc::clone(&egress)); + + let failure = invoke_failure_with_context( + &runtime, + HTTP_CAPABILITY_ID, + json!({"url": "https://api.example.test/private"}), + execution_context_with_network([HTTP_CAPABILITY_ID], http_test_policy()), + ) + .await; + + assert_eq!(failure.kind, FailureKind::OperationFailed); + let Some(DispatchFailureDetail::Diagnostic { text }) = failure.detail else { + panic!("HTTP error response must remain available as diagnostic context"); + }; + assert!( + text.len() <= MODEL_DIAGNOSTIC_MAX_BYTES, + "diagnostic must fit the model-visible budget, got {} bytes", + text.len() + ); + let response: Value = + serde_json::from_str(&text).expect("trimmed diagnostic must stay valid JSON"); + assert_eq!( + response["status"], + json!(403), + "status must survive the budget trim" + ); + assert_eq!(response["truncation"]["body"], json!(true)); + assert!( + response["body_text"].as_str().is_some(), + "trimmed error body must remain visible in the diagnostic" + ); + assert_eq!(egress.requests().len(), 1); +} + #[tokio::test] async fn builtin_http_requires_tool_call_http_egress_for_inline_output() { let egress = Arc::new(RecordingRuntimeHttpEgress::with_body(b"ok".to_vec())); diff --git a/docs/reborn/contracts/host-runtime.md b/docs/reborn/contracts/host-runtime.md index ab0713190a..a38617e1ab 100644 --- a/docs/reborn/contracts/host-runtime.md +++ b/docs/reborn/contracts/host-runtime.md @@ -100,6 +100,32 @@ Path-placeholder credential targets are higher risk than headers or query parame Built-in host HTTP returns redirect responses without following them. This preserves the #3088 redirect invariant for the current V1 surface by never forwarding credentials to a redirected target. The invariant is pinned by the host-runtime runtime egress contract and the `ironclaw_network` reqwest transport redirect contract. A future redirect-following transport must re-run network policy and credential target policy for every hop before reinjecting credentials. +First-party `builtin.http` and `builtin.http.save` classify HTTP 4xx and 5xx +responses as model-visible `OperationFailed` capability outcomes, while preserving +the bounded, sanitized response as diagnostic context. Transport completion alone +is not capability success. Informational, successful, and redirect responses remain +inspectable successful results; redirects are still never followed automatically. +The failure diagnostic is trimmed to the model-visible diagnostic budget +(`MODEL_DIAGNOSTIC_MAX_BYTES`) before serialization, so `status`, `auth_hint`, and +the truncation envelope survive intact as valid JSON; the full sanitized body +remains inspectable by re-issuing the original request through `builtin.http.save` +with `save_to` — that new request writes the sanitized body to the scoped mount as +`saved_body`, which `builtin.read_file` can then read. A later save call cannot +retrieve the body of the prior failed call, and re-issuing a mutating request may +repeat its external side effect. In save mode the sanitized body is written to the scoped +mount before the failure verdict is produced, and the diagnostic carries only +`saved_body` path metadata (`path`, `bytes_written`) — a retry decision must +inspect `saved_body` first, because treating the verdict as "nothing happened" +duplicates the write. The host never retries failed HTTP calls automatically; +for rate-limited or overloaded responses (429/503) the model should apply +backoff rather than immediately re-invoking. For 401/403/407 the diagnostic +retains the extension-install hint; extension install remains approval-gated and +credential injection remains manifest-scoped to the target audience. This is +pinned by the `builtin_http_*` status-classification tests (403, 500, save-mode +403, range boundaries, and diagnostic budget) in +`first_party_builtin_tools` and the `architecture-runtime` group in +`scripts/reborn-e2e-rust.sh`. + For WASM host-mediated HTTP imports, `WasmRuntimeHttpAdapter` carries the invoking capability id into `WasmRuntimeCredentialProvider`. Host composition derives the default provider from validated manifest v2 `runtime_credentials` declarations on WASM capability descriptors. The provider matches the request URL against the declared HTTPS audience through the `ironclaw_network` target parser/matcher, then emits `StagedObligation` injection plans for matching capability+audience pairs. When a declaration uses `source = { type = "product_auth_account", provider = "..." }`, authorization emits an account-backed obligation and the host-runtime resolver stages the selected account's access secret under the declared runtime credential slot handle before egress. Explicit `WasmStagedRuntimeCredentials` rules remain available for named legacy/test composition, but production manifest-backed tools should use the manifest-derived provider. The WASM guest still supplies only method/url/headers/body and never chooses credential handles, account providers, or targets. Script and shell process execution keep Docker containers ambient-network-disabled by default (`docker run --network none`). Tenant sandbox composition can now attach explicit broker affordances for commands. The preferred network shape bind-mounts a host-owned Unix socket and exposes `IRONCLAW_REBORN_NETWORK_MODE=brokered`, `IRONCLAW_REBORN_HTTP_BROKER_SOCKET`, and `IRONCLAW_REBORN_HTTP_BROKER_URL` while preserving Docker `--network none`; Unix-socket broker paths are Unix-host affordances, and Windows hosts must use the HTTP-proxy broker shape. The HTTP-proxy shape is available for compositions that accept Docker network attachment and exposes standard `http_proxy`/`https_proxy` values. Secret broker handoff is metadata-only through `IRONCLAW_REBORN_SECRET_MODE=brokered` plus either `IRONCLAW_REBORN_SECRET_BROKER_SOCKET` or `IRONCLAW_REBORN_SECRET_BROKER_URL`; raw secret material is not injected into command environments. Composition must provision broker sockets or endpoints per `ResourceScope`/`CapabilityId` handoff so tenants cannot share a reusable broker authority by accident. Caller-supplied overrides for reserved broker environment variables fail closed, and containers without a configured network broker still run with Docker networking disabled. If scripts later gain a brokered HTTP SDK, sidecar, helper process, or host API, every request must flow through `ironclaw_sandbox::ScriptRuntimeHttpAdapter`. The host supplies the `ResourceScope`, `CapabilityId`, `NetworkPolicy`, credential injection plan, response body limit, and timeout; script/runtime input must not invent secret handles, raw credential headers/query parameters, DNS checks, private-IP checks, or direct HTTP clients inside `ironclaw_sandbox`. diff --git a/scripts/reborn-e2e-rust.sh b/scripts/reborn-e2e-rust.sh index b7f0386d33..353aebd6b5 100755 --- a/scripts/reborn-e2e-rust.sh +++ b/scripts/reborn-e2e-rust.sh @@ -104,6 +104,20 @@ run_architecture_runtime() { run_test ironclaw_host_runtime reborn_e2e_gate run_test ironclaw_host_runtime reborn_invoke_vertical_slice run_test ironclaw_host_runtime runtime_http_egress_contract + # Pins docs/reborn/contracts/host-runtime.md: an HTTP 4xx/5xx response is a + # model-visible failed capability outcome, not transport-level success. + run_test_exact ironclaw_host_runtime first_party_builtin_tools \ + builtin_http_surfaces_http_error_status_as_failed_outcome + run_test_exact ironclaw_host_runtime first_party_builtin_tools \ + builtin_http_surfaces_server_error_status_as_failed_outcome + run_test_exact ironclaw_host_runtime first_party_builtin_tools \ + builtin_http_save_surfaces_http_error_status_as_failed_outcome + run_test_exact ironclaw_host_runtime first_party_builtin_tools \ + builtin_http_classifies_status_range_boundaries + # Pins docs/reborn/contracts/host-runtime.md: the failure diagnostic is + # trimmed to the model-visible diagnostic budget and stays valid JSON. + run_test_exact ironclaw_host_runtime first_party_builtin_tools \ + builtin_http_error_diagnostic_respects_model_diagnostic_budget run_test ironclaw_host_runtime builtin_obligation_handler_contract run_test ironclaw_host_runtime obligation_services_composition_contract run_test ironclaw_host_runtime production_trust_contract