From 1297bedaaeaa594ef091745c042a59fc7e9c8d54 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Wed, 20 May 2026 00:19:09 +0200 Subject: [PATCH] test(runtime-e2e): add x-client-id parity runner (bash + helper crate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the runtime-e2e/x-client-id/ runners shipped in workstream B of Epic getaxonflow/axonflow-enterprise#2230 across Go/Python/TS/Java — the Rust SDK was the one outlier, verifying v9 X-Client-ID via wiremock-only unit tests with no live-agent companion. runtime-e2e/x-client-id/test.sh boots the community docker-compose stack (same shape as runtime-e2e/anthropic_interceptor/test.sh), then builds + runs runtime-e2e/x-client-id/helper/, a small tokio-based forwarding proxy that points the SDK at a 127.0.0.1 listener, captures the SDK's outbound headers off the wire, forwards the request to the real agent via reqwest, and asserts: X-Client-ID == AXONFLOW_TENANT_ID, X-Axonflow-Client starts with sdk-rust/, Authorization starts with 'Basic ', and X-Tenant-ID is absent. This is the wire-level companion to tests/x_client_id_header_test.rs (which uses wiremock) — both must pass for the v9 identity contract to be held. Bumps to v0.3.1 (patch — test infrastructure only, no SDK code change). Signed-off-by: Saurabh Jain --- CHANGELOG.md | 21 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- runtime-e2e/x-client-id/README.md | 93 +++++++++ runtime-e2e/x-client-id/helper/.gitignore | 5 + runtime-e2e/x-client-id/helper/Cargo.toml | 14 ++ runtime-e2e/x-client-id/helper/src/main.rs | 229 +++++++++++++++++++++ runtime-e2e/x-client-id/test.sh | 101 +++++++++ 8 files changed, 465 insertions(+), 2 deletions(-) create mode 100644 runtime-e2e/x-client-id/README.md create mode 100644 runtime-e2e/x-client-id/helper/.gitignore create mode 100644 runtime-e2e/x-client-id/helper/Cargo.toml create mode 100644 runtime-e2e/x-client-id/helper/src/main.rs create mode 100755 runtime-e2e/x-client-id/test.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index b6351f4..5d2c114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.1] - 2026-05-20 — `runtime-e2e/x-client-id/` parity with the other 4 SDKs + +**Patch release — test infrastructure only.** No SDK code changes; pure +parity work for the v9 identity rollout (Epic #2230, workstream B). + +### Added + +- **`runtime-e2e/x-client-id/`** runner — bash entry point + Rust + helper crate. Mirrors the Go/Python/TS/Java SDKs' `runtime-e2e/x-client-id/` + directories shipped in workstream B. Brings up the public community + docker-compose stack, then runs an in-process forwarding-proxy helper + that captures the SDK's outbound HTTP headers off the wire and asserts: + `X-Client-ID == AXONFLOW_TENANT_ID`, `X-Axonflow-Client` starts with + `sdk-rust/`, `Authorization` starts with `Basic `, and `X-Tenant-ID` + is absent. + +This is the wire-level companion to `tests/x_client_id_header_test.rs` +— which uses `wiremock` and is necessary but not sufficient (it can't +catch contract drift between the SDK and the live community-stack +agent in the same PR that causes it). + ## [0.3.0] - 2026-05-19 — `X-Axonflow-Client` + `X-Client-ID` headers on every outbound request (v9 identity) **Companion release to the v9 identity cleanup on the platform (Epic #2230).** diff --git a/Cargo.lock b/Cargo.lock index 9e79e09..1a31d00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axonflow-sdk-rust" -version = "0.3.0" +version = "0.3.1" dependencies = [ "async-trait", "base64", diff --git a/Cargo.toml b/Cargo.toml index 1ecb0ec..c3029ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "axonflow-sdk-rust" -version = "0.3.0" +version = "0.3.1" edition = "2021" rust-version = "1.78" description = "Rust SDK for the AxonFlow AI governance platform" diff --git a/runtime-e2e/x-client-id/README.md b/runtime-e2e/x-client-id/README.md new file mode 100644 index 0000000..ee7ed1d --- /dev/null +++ b/runtime-e2e/x-client-id/README.md @@ -0,0 +1,93 @@ +# Runtime proof — Rust SDK emits v9 `X-Client-ID` + ADR-050 §4 `X-Axonflow-Client` + +Brings up the community `docker-compose` stack, then runs an in-process +forwarding-proxy helper (`helper/`) that points the Rust SDK at a local +TCP listener, captures the SDK's outbound HTTP headers off the wire, +and forwards the request through to the real agent. + +This is the wire-level companion to `tests/x_client_id_header_test.rs` +(which uses `wiremock` and asserts the same four headers against a +synthetic agent). Both must pass for the v9 identity contract to be +considered held. + +## When to run + +**Pre-merge** for any change that touches: + +- `src/client.rs::new` (the headers are set on construction) +- `Cargo.toml` (a version bump changes the expected `X-Axonflow-Client`) +- The agent's `apiAuthMiddleware` upstream (`X-Client-ID` is overwritten + server-side with the auth-derived value; client-side spoofing is harmless, + but the SDK still needs to emit the header for the auth path to function + correctly in mode-mismatch scenarios) + +It is the same shape as `runtime-e2e/anthropic_interceptor/` — boots the +public community stack, runs a real SDK call against it. Matches the +parity established by the other 4 SDKs' `runtime-e2e/x-client-id/` +runners shipped in PR getaxonflow/axonflow-enterprise#2230 (workstream B). + +## Prerequisites + +- Docker + docker compose +- Network access to clone `getaxonflow/axonflow` (community) +- Cargo + a stable Rust toolchain + +## Usage + +```sh +./test.sh +``` + +Optional env vars: + +- `AXONFLOW_TENANT_ID` — Basic Auth username (default: `demo-client`) +- `AXONFLOW_TENANT_SECRET` — Basic Auth password (default: `demo-secret`) + +The script will: + +1. Clone (or refresh) the community stack into `../axonflow-community`. +2. Bring it up with `docker compose up -d --wait`. +3. Wait for `/health` to come back from the agent on port 8080. +4. Build + run `helper/` against the live stack, with the helper's + in-process forwarder bound on `127.0.0.1:0`. +5. Assert all 4 header invariants (see below). +6. Tear down the stack. + +## What it asserts + +The helper (`helper/src/main.rs`) reads the SDK's outbound HTTP headers +off the wire after the SDK has called `proxy_llm_call`, then verifies: + +| Header | Required | Expected value | +|---|---|---| +| `X-Client-ID` | present | equals `AXONFLOW_TENANT_ID` | +| `X-Axonflow-Client` | present | starts with `sdk-rust/` (ADR-050 §4) | +| `Authorization` | present | starts with `Basic ` | +| `X-Tenant-ID` | ABSENT | (the agent still accepts it as an alias for back-compat through v9, but the SDK standardizes on `X-Client-ID` post-v0.3.0) | + +Helper exits 0 on all-pass; 1 on any failed assertion. + +## What it does NOT assert + +- The agent's response correctness (the SDK call may succeed or fail + depending on whether `demo-client/demo-secret` is provisioned in the + community stack — neither outcome affects the header verdict, since + the headers are read off the request side of the wire before any + response). +- Server-side persistence (this is a pure SDK-emission test). +- Any other header beyond the four above. + +## Why this exists alongside the unit tests + +`tests/x_client_id_header_test.rs` uses `wiremock` to assert the SDK's +emission against a synthetic agent. That's necessary but not sufficient +— the agent's request-acceptance contract can drift between platform +releases without breaking the wiremock matcher (e.g., a new header the +agent's middleware requires). This runtime proof catches contract drift +between the SDK and the community-stack agent in the same PR that +causes it. + +It also matches the cross-SDK parity contract: every first-class SDK +(Go, Python, TS, Java, Rust) now ships a `runtime-e2e/x-client-id/` +runner. Drift in one SDK is caught locally; drift in the agent's +handling of the header is caught by all 5 simultaneously. diff --git a/runtime-e2e/x-client-id/helper/.gitignore b/runtime-e2e/x-client-id/helper/.gitignore new file mode 100644 index 0000000..e7ef928 --- /dev/null +++ b/runtime-e2e/x-client-id/helper/.gitignore @@ -0,0 +1,5 @@ +# Helper-local build artifacts. Lockfile excluded too — the helper's +# resolved dep graph tracks the SDK's, and committing both would churn +# on every SDK dep bump. +/target +Cargo.lock diff --git a/runtime-e2e/x-client-id/helper/Cargo.toml b/runtime-e2e/x-client-id/helper/Cargo.toml new file mode 100644 index 0000000..27a6ba7 --- /dev/null +++ b/runtime-e2e/x-client-id/helper/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "x-client-id-helper" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +axonflow-sdk-rust = { path = "../../.." } +tokio = { version = "1.52", features = ["full"] } +reqwest = { version = "0.12", features = ["rustls-tls"] } + +[[bin]] +name = "x-client-id-helper" +path = "src/main.rs" diff --git a/runtime-e2e/x-client-id/helper/src/main.rs b/runtime-e2e/x-client-id/helper/src/main.rs new file mode 100644 index 0000000..deae2de --- /dev/null +++ b/runtime-e2e/x-client-id/helper/src/main.rs @@ -0,0 +1,229 @@ +//! Real-wire test of the SDK's v9 X-Client-ID + ADR-050 §4 X-Axonflow-Client +//! header emission against a real running AxonFlow agent. +//! +//! Mirrors the in-process forwarding-proxy approach used by the other 4 +//! SDKs' runtime-e2e/x-client-id/ runners (Go: httputil.ReverseProxy, +//! Java: HttpServer + HttpClient, Python/TS: httpx/fetch monkey-patch). +//! +//! Flow: +//! 1. Bind a tokio TcpListener on 127.0.0.1:0. +//! 2. Construct the SDK with that listener's URL as `agent_url` and the +//! caller-supplied AXONFLOW_TENANT_ID / AXONFLOW_TENANT_SECRET. +//! 3. Issue one `proxy_llm_call`. +//! 4. The listener accepts the connection, parses the request headers +//! off the wire (captures the four headers we care about), forwards +//! the request to the real agent at AXONFLOW_AGENT_URL via reqwest, +//! and writes the agent's response back to the SDK. +//! 5. After the call completes, assert: +//! - `X-Client-ID` equals AXONFLOW_TENANT_ID +//! - `X-Axonflow-Client` starts with `sdk-rust/` +//! - `Authorization` starts with `Basic ` +//! - `X-Tenant-ID` absent (agent still accepts as an alias for +//! back-compat through v9, but the SDK standardizes on X-Client-ID) + +use axonflow_sdk_rust::{AxonFlowClient, AxonFlowConfig}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let tenant = + std::env::var("AXONFLOW_TENANT_ID").map_err(|_| "AXONFLOW_TENANT_ID must be set")?; + let secret = std::env::var("AXONFLOW_TENANT_SECRET") + .map_err(|_| "AXONFLOW_TENANT_SECRET must be set")?; + let upstream = + std::env::var("AXONFLOW_AGENT_URL").unwrap_or_else(|_| "http://localhost:8080".to_string()); + + let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); + let listener = TcpListener::bind("127.0.0.1:0").await?; + let proxy_url = format!("http://{}", listener.local_addr()?); + + let captured_for_server = captured.clone(); + let upstream_for_server = upstream.clone(); + tokio::spawn(async move { + let client = reqwest::Client::new(); + loop { + let (mut conn, _) = match listener.accept().await { + Ok(x) => x, + Err(_) => break, + }; + let captured = captured_for_server.clone(); + let upstream = upstream_for_server.clone(); + let client = client.clone(); + tokio::spawn(async move { + let _ = handle(&mut conn, &upstream, &client, captured).await; + }); + } + }); + + let cfg = AxonFlowConfig::new(proxy_url).with_auth(tenant.clone(), secret); + let client = AxonFlowClient::new(cfg)?; + // outcome of the call doesn't matter; only the captured headers. + let _ = client + .proxy_llm_call("", "ping", "chat", HashMap::new()) + .await; + + // small grace period so the spawned task observes the connection close + // and stores its headers into `captured` before we read. + tokio::time::sleep(Duration::from_millis(200)).await; + + let cap = captured.lock().unwrap(); + let lookup = |name: &str| -> Option { + cap.iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.clone()) + }; + + let xcid = lookup("X-Client-ID"); + let xac = lookup("X-Axonflow-Client"); + let auth = lookup("Authorization"); + let xtenant = lookup("X-Tenant-ID"); + + let mut failed = Vec::::new(); + if xcid.as_deref() != Some(tenant.as_str()) { + failed.push(format!("X-Client-ID: want {:?}, got {:?}", tenant, xcid)); + } + if !xac.as_deref().is_some_and(|v| v.starts_with("sdk-rust/")) { + failed.push(format!( + "X-Axonflow-Client: want starts-with 'sdk-rust/', got {:?}", + xac + )); + } + if !auth.as_deref().is_some_and(|v| v.starts_with("Basic ")) { + failed.push(format!( + "Authorization: want starts-with 'Basic ', got {:?}", + auth + )); + } + if let Some(v) = xtenant.as_ref() { + failed.push(format!("X-Tenant-ID: should be ABSENT, got {:?}", v)); + } + + if !failed.is_empty() { + for f in &failed { + eprintln!("FAIL: {}", f); + } + std::process::exit(1); + } + + println!("PASS: 4/4 header assertions"); + println!(" X-Client-ID: {}", xcid.unwrap()); + println!(" X-Axonflow-Client: {}", xac.unwrap()); + println!(" Authorization: Basic "); + println!(" X-Tenant-ID: "); + Ok(()) +} + +/// Read the HTTP/1.1 request from `conn`, capture its headers into +/// `captured`, forward the request to the real agent at `upstream`, and +/// stream the agent's response back to `conn`. +async fn handle( + conn: &mut TcpStream, + upstream: &str, + client: &reqwest::Client, + captured: Arc>>, +) -> std::io::Result<()> { + // Read until we see the header terminator \r\n\r\n. + let mut buf = Vec::with_capacity(8192); + let mut tmp = [0u8; 4096]; + let header_end = loop { + let n = conn.read(&mut tmp).await?; + if n == 0 { + return Ok(()); + } + buf.extend_from_slice(&tmp[..n]); + if let Some(idx) = find_double_crlf(&buf) { + break idx; + } + if buf.len() > 64 * 1024 { + // request headers too large for this minimal forwarder + return Ok(()); + } + }; + + let header_str = std::str::from_utf8(&buf[..header_end]) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + let mut lines = header_str.split("\r\n"); + let request_line = lines.next().unwrap_or(""); + let parts: Vec<&str> = request_line.split_whitespace().collect(); + let method = parts.first().copied().unwrap_or("GET"); + let path = parts.get(1).copied().unwrap_or("/"); + + let mut local_headers: Vec<(String, String)> = Vec::new(); + let mut content_length: usize = 0; + for line in lines { + if let Some((k, v)) = line.split_once(": ") { + if k.eq_ignore_ascii_case("content-length") { + content_length = v.parse().unwrap_or(0); + } + local_headers.push((k.to_string(), v.to_string())); + } + } + // Record the captured headers so the test runner can assert against them. + { + let mut cap = captured.lock().unwrap(); + cap.extend(local_headers.iter().cloned()); + } + + // Read the request body (already partially in `buf`). + let body_start = header_end + 4; + let mut body: Vec = buf[body_start..].to_vec(); + let remaining = content_length.saturating_sub(body.len()); + if remaining > 0 { + let mut rest = vec![0u8; remaining]; + conn.read_exact(&mut rest).await?; + body.extend(rest); + } + + // Forward to the real agent. + let target = format!("{}{}", upstream, path); + let method_typed: reqwest::Method = method.parse().unwrap_or(reqwest::Method::POST); + let mut req = client.request(method_typed, &target); + for (k, v) in &local_headers { + if k.eq_ignore_ascii_case("host") || k.eq_ignore_ascii_case("content-length") { + continue; + } + req = req.header(k, v); + } + req = req.body(body); + + match req.send().await { + Ok(resp) => { + let status = resp.status(); + let headers = resp.headers().clone(); + let resp_body = resp.bytes().await.unwrap_or_default(); + let mut head = format!( + "HTTP/1.1 {} {}\r\n", + status.as_u16(), + status.canonical_reason().unwrap_or("") + ); + for (k, v) in headers.iter() { + let kn = k.as_str(); + if kn.eq_ignore_ascii_case("transfer-encoding") + || kn.eq_ignore_ascii_case("content-length") + { + continue; + } + head.push_str(&format!("{}: {}\r\n", kn, v.to_str().unwrap_or(""))); + } + head.push_str(&format!("Content-Length: {}\r\n\r\n", resp_body.len())); + conn.write_all(head.as_bytes()).await?; + conn.write_all(&resp_body).await?; + } + Err(_) => { + // upstream is unreachable — the SDK call will fail but the + // headers we wanted to capture were already on the wire. + let resp = b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n"; + conn.write_all(resp).await?; + } + } + conn.flush().await?; + Ok(()) +} + +fn find_double_crlf(buf: &[u8]) -> Option { + buf.windows(4).position(|w| w == b"\r\n\r\n") +} diff --git a/runtime-e2e/x-client-id/test.sh b/runtime-e2e/x-client-id/test.sh new file mode 100755 index 0000000..5d1128b --- /dev/null +++ b/runtime-e2e/x-client-id/test.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Runtime proof — Rust SDK v9 X-Client-ID + ADR-050 §4 X-Axonflow-Client +# headers against a live community-stack agent. +# +# Mirrors the Go/Python/TS/Java SDKs' runtime-e2e/x-client-id/ runners +# (PR getaxonflow/axonflow-enterprise#2230). Brings up the community +# docker-compose stack, then runs the in-process forwarding-proxy helper +# in `helper/` which: +# +# 1. Binds a tokio TcpListener on 127.0.0.1:0. +# 2. Constructs the SDK pointing at the listener. +# 3. Issues one proxy_llm_call. +# 4. Captures the outbound headers off the wire AND forwards the +# request to the real agent. +# 5. Asserts X-Client-ID + X-Axonflow-Client + Authorization: Basic +# are present; X-Tenant-ID is absent. +# +# Usage: +# ./test.sh +# +# Exit codes: +# 0 — helper passed all 4 header assertions +# 1 — helper failed (missing/wrong header) +# 2 — community stack failed to come up + +set -uo pipefail + +red() { printf '\033[31m%s\033[0m\n' "$*"; } +green() { printf '\033[32m%s\033[0m\n' "$*"; } +blue() { printf '\033[34m%s\033[0m\n' "$*"; } + +SDK_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +HELPER_DIR="${SDK_ROOT}/runtime-e2e/x-client-id/helper" +STACK_DIR="${SDK_ROOT}/../axonflow-community" + +cleanup() { + if [ -d "$STACK_DIR" ]; then + blue ">>> Tearing down community stack" + (cd "$STACK_DIR" && docker compose down --volumes --remove-orphans 2>&1 | tail -5) || true + fi +} +trap cleanup EXIT + +if [ ! -d "$STACK_DIR" ]; then + blue ">>> Cloning community stack into $STACK_DIR" + git clone --depth 1 https://github.com/getaxonflow/axonflow.git "$STACK_DIR" || { + red "FAIL: could not clone community stack" + exit 2 + } +else + blue ">>> Refreshing community stack at $STACK_DIR" + (cd "$STACK_DIR" && git fetch --depth 1 origin main && git reset --hard origin/main) || true +fi + +blue ">>> Bringing up community stack" +(cd "$STACK_DIR" && docker compose up -d --wait --wait-timeout 180) || { + red "FAIL: docker compose up did not converge" + (cd "$STACK_DIR" && docker compose logs --tail=200) || true + exit 2 +} + +blue ">>> Waiting for agent (8080) /health" +if ! timeout 60 bash -c 'until curl -sf http://localhost:8080/health > /dev/null; do sleep 2; done'; then + red "FAIL: agent did not become healthy within 60s" + (cd "$STACK_DIR" && docker compose logs --tail=200) || true + exit 2 +fi + +AGENT_GIT_REF=$(cd "$STACK_DIR" && git rev-parse --short HEAD 2>/dev/null || echo "unknown") +blue ">>> Community stack agent git ref: ${AGENT_GIT_REF}" + +# Pull SDK version from the workspace Cargo.toml — used by the helper to +# build the expected X-Axonflow-Client value. +SDK_VERSION=$(grep -m1 '^version = ' "${SDK_ROOT}/Cargo.toml" | sed -E 's/version = "([^"]+)"/\1/') +blue ">>> SDK version: ${SDK_VERSION}" + +blue ">>> Building + running helper (this exercises the SDK code path)" +OUTPUT=$( + AXONFLOW_AGENT_URL=http://localhost:8080 \ + AXONFLOW_TENANT_ID="${AXONFLOW_TENANT_ID:-demo-client}" \ + AXONFLOW_TENANT_SECRET="${AXONFLOW_TENANT_SECRET:-demo-secret}" \ + timeout 120 cargo run --manifest-path "${HELPER_DIR}/Cargo.toml" --release --quiet 2>&1 +) +RC=$? + +echo "$OUTPUT" +echo + +if [ $RC -ne 0 ]; then + red "FAIL: helper exited with status $RC" + exit 1 +fi + +if echo "$OUTPUT" | grep -q '^PASS:'; then + green "PASS: SDK emits X-Client-ID + X-Axonflow-Client + Basic auth; X-Tenant-ID absent" + green " agent_git_ref=${AGENT_GIT_REF} sdk_version=${SDK_VERSION}" + exit 0 +else + red "FAIL: helper did not print PASS: line" + exit 1 +fi