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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).**
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
93 changes: 93 additions & 0 deletions runtime-e2e/x-client-id/README.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions runtime-e2e/x-client-id/helper/.gitignore
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions runtime-e2e/x-client-id/helper/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
229 changes: 229 additions & 0 deletions runtime-e2e/x-client-id/helper/src/main.rs
Original file line number Diff line number Diff line change
@@ -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<dyn std::error::Error>> {
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<Mutex<Vec<(String, String)>>> = 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<String> {
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::<String>::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 <redacted base64>");
println!(" X-Tenant-ID: <absent (✓)>");
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<Mutex<Vec<(String, String)>>>,
) -> 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<u8> = 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<usize> {
buf.windows(4).position(|w| w == b"\r\n\r\n")
}
Loading