Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions crates/kernel/ironclaw_host_runtime/src/first_party_tools/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down Expand Up @@ -131,6 +131,10 @@ fn http_resource_profile() -> ResourceProfile {
pub(super) async fn dispatch(
request: &FirstPartyCapabilityRequest,
) -> Result<HttpDispatchOutput, FirstPartyCapabilityError> {
// Failure-path usage accounting mirrors the sibling dispatches in
// `first_party_tools/mod.rs`: wall time is measured over the whole
// dispatch and attached to the capability error.
let started = std::time::Instant::now();
let egress = request
.services
.runtime_http_egress
Expand Down Expand Up @@ -245,7 +249,17 @@ 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;
// Shape at the caller's `response_body_limit` so egress-truncation
// accounting stays correct: `shape_response` derives
// `body_was_truncated_by_egress` from that limit, and a body the egress
// already cut at the caller's cap must not be reported as complete. The
// failure diagnostic applies its own display budget separately in
// `bounded_failure_diagnostic`; the success-budget trim run here is
// discarded for error statuses, which is bounded and sub-millisecond.
let shaped = shape_response(response, response_body_limit);
let wall_clock_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
classify_status(shaped, status, wall_clock_ms)
}

fn method(input: &Value) -> Result<NetworkMethod, FirstPartyCapabilityError> {
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -4433,6 +4433,285 @@ 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 governor = Arc::new(InMemoryResourceGovernor::new());
let runtime = runtime_with_http_egress_and_governor(Arc::clone(&egress), Arc::clone(&governor));

let failure = invoke_failure_with_context(
&runtime,
HTTP_CAPABILITY_ID,
json!({
"method": "post",
"url": "https://api.example.test/private",
"body": "paid"
}),
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);
// The failure carries usage like the sibling dispatch paths: egress bytes
// from the request body flow into the governor even for failed calls.
// (wall_clock_ms is pinned at the classify_status unit seam instead of
// here because integration-tier wall-clock is timing-dependent; the
// governor records the full failed-call usage, wall_clock_ms included.)
let tenant_account = ResourceAccount::tenant(TenantId::new(LOCAL_DEFAULT_TENANT_ID).unwrap());
let usage = governor.usage_for(&tenant_account);
assert_eq!(
usage.network_egress_bytes, 4,
"failed calls must still account egress bytes"
);
}

#[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_error_diagnostic_preserves_egress_truncation_flag() {
// The egress returns a partial body when it hits the caller's
// response_body_limit; the failure diagnostic must keep reporting that
// truncation instead of presenting the partial body as complete.
let egress = Arc::new(RecordingRuntimeHttpEgress::with_status_and_body(
403,
b"partial body that exceeds the one-byte cap".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",
"response_body_limit": 1
}),
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");
};
let response: Value = serde_json::from_str(&text).expect("diagnostic must stay valid JSON");
assert_eq!(response["status"], json!(403));
assert_eq!(
response["body_truncated"],
json!(true),
"egress truncation at the caller cap must stay visible"
);
assert_eq!(response["truncation"]["body"], json!(true));
}

#[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()));
Expand Down
37 changes: 37 additions & 0 deletions docs/reborn/contracts/host-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,43 @@ 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 — with headroom reserved
for the loop-host injection fence — so `status`, `auth_hint`, and the
truncation envelope survive intact as valid JSON. The original body from a
network-only failed call is not retained. To retain the response body, use
`builtin.http.save` on the initial request; saved output is subject to the
save-mode response limit. Re-issuing the request creates a new response and
may repeat external side effects. A later save call cannot retrieve the body
of the prior failed call. In save mode, body content is represented only by
`saved_body` path metadata (`path`, `bytes_written`); `status`, `auth_hint`,
and truncation metadata remain in the failure diagnostic — a retry decision
must inspect `saved_body` first, because treating the verdict as "nothing
happened" duplicates the write. When the error body trips the loop-host
injection scan, the seam wraps the diagnostic in the external-content security
fence before the observation budget is applied; the reserved headroom keeps
that fenced diagnostic within the observation bound, and the verdict itself
never depends on the diagnostic JSON surviving either way — the
`OperationFailed` classification and the `HTTP request returned status N` safe
summary always reach the model. 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. When the host holds
provider delay metadata it populates `retry_after_ms` on the recovery
observation; `builtin.http` does not parse `Retry-After`, so `retry_after_ms`
stays `None` for these responses — and `None` does not permit an immediate
retry. 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<RuntimeHttpEgress>`. 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`.
Loading
Loading