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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,29 @@ All notable changes to the AxonFlow Java SDK will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [8.1.0] - 2026-05-19 — `X-Client-ID` header on every outbound request (v9 identity)

**Companion release to the v9 identity cleanup on the platform (Epic #2230).**
Every governed request now carries an `X-Client-ID: <effective_client_id>`
header alongside the existing Basic Auth + `X-Axonflow-Client` headers.
Value matches the SDK's Basic Auth username — smart default `community`
when no `clientId` is configured.

### Added

- **`X-Client-ID` header on outbound HTTP requests.** Server-side identity
decisions no longer need to re-decode Basic Auth. The agent's
`apiAuthMiddleware` overwrites the header with its own auth-derived
value, so caller-supplied values are harmless (no spoofing surface).
Set in `addAuthHeaders` (`AxonFlow.java`), the canonical funnel for
every governed request.

### Compatibility

- Backward-compatible against v8 and v9 platforms: v8 agents ignore the
unknown header; v9 agents derive identity from Basic Auth regardless.
- No SDK config changes. No removed fields. No changed defaults.

## [8.0.0] - 2026-05-09 — Decision History API + policy_version recorded on every decision + telemetry simplification

**Major release.** The headline feature is the new decision-history client
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>com.getaxonflow</groupId>
<artifactId>axonflow-sdk</artifactId>
<version>8.0.0</version>
<version>8.1.0</version>
<packaging>jar</packaging>

<name>AxonFlow Java SDK</name>
Expand Down
99 changes: 99 additions & 0 deletions runtime-e2e/x-client-id/SdkXClientIdTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* runtime-e2e/x-client-id/SdkXClientIdTest.java
*
* Per CLAUDE.md HARD RULE #0: real-wire test of the SDK's v9 X-Client-ID
* header emission to a real running AxonFlow agent.
*
* Approach: the SDK does not expose its internal OkHttpClient, so we
* run a tiny in-process forwarding proxy (Java's HttpServer + the JDK's
* built-in HttpClient) that inspects every request, captures the
* X-Client-ID header, and forwards to the real agent. The SDK is
* pointed at the proxy. Bytes flow real → real.
*
* Run:
* AXONFLOW_AGENT_URL=http://localhost:8080 \
* AXONFLOW_TENANT_ID=cs_... AXONFLOW_TENANT_SECRET=... \
* java -cp "<sdk-jar>:<deps>" runtime-e2e/x-client-id/SdkXClientIdTest.java
*/
import com.getaxonflow.sdk.AxonFlow;
import com.getaxonflow.sdk.AxonFlowConfig;
import com.getaxonflow.sdk.types.MCPCheckInputResponse;
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.concurrent.atomic.AtomicReference;

public class SdkXClientIdTest {
public static void main(String[] args) throws Exception {
String endpoint = System.getenv().getOrDefault("AXONFLOW_AGENT_URL", "http://localhost:8080");
String tenant = System.getenv("AXONFLOW_TENANT_ID");
String secret = System.getenv("AXONFLOW_TENANT_SECRET");
if (tenant == null || secret == null) {
System.err.println(
"AXONFLOW_TENANT_ID + AXONFLOW_TENANT_SECRET must be set; see ../README.md");
System.exit(2);
}

final URI target = URI.create(endpoint);
final HttpClient forwarder = HttpClient.newHttpClient();
final AtomicReference<String> sawClientId = new AtomicReference<>("");

HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext(
"/",
ex -> {
sawClientId.set(ex.getRequestHeaders().getFirst("X-Client-ID"));
byte[] body = ex.getRequestBody().readAllBytes();
HttpRequest.Builder b =
HttpRequest.newBuilder(target.resolve(ex.getRequestURI()))
.method(ex.getRequestMethod(), HttpRequest.BodyPublishers.ofByteArray(body));
ex.getRequestHeaders()
.forEach(
(k, vs) -> {
if (!k.equalsIgnoreCase("host") && !k.equalsIgnoreCase("content-length")) {
vs.forEach(v -> b.header(k, v));
}
});
try {
HttpResponse<byte[]> resp =
forwarder.send(b.build(), HttpResponse.BodyHandlers.ofByteArray());
byte[] respBody = resp.body();
ex.sendResponseHeaders(resp.statusCode(), respBody.length);
ex.getResponseBody().write(respBody);
ex.getResponseBody().close();
} catch (Exception e) {
ex.sendResponseHeaders(502, 0);
ex.close();
}
});
server.start();
String proxyUrl = "http://127.0.0.1:" + server.getAddress().getPort();

AxonFlow client =
AxonFlow.create(
AxonFlowConfig.builder()
.agentUrl(proxyUrl)
.clientId(tenant)
.clientSecret(secret)
.build());

System.out.println("Asserting wire X-Client-ID = " + tenant);
try {
MCPCheckInputResponse r = client.mcpCheckInput("postgres", "SELECT 1");
// outcome doesn't matter; only the captured header
} catch (Exception ignored) {
// outcome doesn't matter; only the captured header
}
server.stop(0);

String got = sawClientId.get();
if (!tenant.equals(got)) {
System.err.println("FAIL: wire X-Client-ID = \"" + got + "\", want \"" + tenant + "\"");
System.exit(1);
}
System.out.println("PASS: wire X-Client-ID = \"" + got + "\"");
}
}
5 changes: 5 additions & 0 deletions src/main/java/com/getaxonflow/sdk/AxonFlow.java
Original file line number Diff line number Diff line change
Expand Up @@ -3907,6 +3907,11 @@ private void addAuthHeaders(Request.Builder builder) {
// so the agent can derive request scope (sdk) and validate it against the
// token's aud.scope via HasScope(). Sourced from SDK_VERSION; no env override.
builder.header("X-Axonflow-Client", config.getClientHeader());
// X-Client-ID (v9): server-side identity decisions don't have to
// re-decode Basic auth. The agent's apiAuthMiddleware overwrites
// the header with its auth-derived value, so caller-supplied
// values are harmless (no spoofing surface).
builder.header("X-Client-ID", effectiveClientId);
}

/**
Expand Down
102 changes: 102 additions & 0 deletions src/test/java/com/getaxonflow/sdk/XClientIdHeaderTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
* Copyright 2025 AxonFlow
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*/
package com.getaxonflow.sdk;

import static com.github.tomakehurst.wiremock.client.WireMock.*;

import com.getaxonflow.sdk.types.*;
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

/**
* X-Client-ID header verification (v9 identity).
*
* <p>Every governed request carries {@code X-Client-ID} alongside Basic Auth. The agent's
* apiAuthMiddleware overwrites the header with its own auth-derived value, so a missing or
* wrong client-side header is harmless server-side. These tests pin SDK-emitted behaviour so
* future regressions are caught early.
*/
@WireMockTest
@DisplayName("X-Client-ID header (v9)")
class XClientIdHeaderTest {

@Test
@DisplayName("emits X-Client-ID: community when no clientId configured")
void communityDefault(WireMockRuntimeInfo wmRuntimeInfo) {
stubFor(
post(urlEqualTo("/api/request"))
.willReturn(
aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"success\":true,\"data\":{\"answer\":\"ok\"}}")));

AxonFlow client =
AxonFlow.create(AxonFlowConfig.builder().agentUrl(wmRuntimeInfo.getHttpBaseUrl()).build());

client.proxyLLMCall(ClientRequest.builder().userToken("").query("ping").build());

verify(
postRequestedFor(urlEqualTo("/api/request"))
.withHeader("X-Client-ID", equalTo("community")));
}

@Test
@DisplayName("emits X-Client-ID matching configured clientId")
void configuredClient(WireMockRuntimeInfo wmRuntimeInfo) {
stubFor(
post(urlEqualTo("/api/request"))
.willReturn(
aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"success\":true,\"data\":{\"answer\":\"ok\"}}")));

AxonFlow client =
AxonFlow.create(
AxonFlowConfig.builder()
.agentUrl(wmRuntimeInfo.getHttpBaseUrl())
.clientId("acme-corp")
.clientSecret("secret")
.build());

client.proxyLLMCall(ClientRequest.builder().userToken("").query("ping").build());

verify(
postRequestedFor(urlEqualTo("/api/request"))
.withHeader("X-Client-ID", equalTo("acme-corp")));
}

@Test
@DisplayName("does NOT emit legacy X-Tenant-ID")
void noLegacyTenantHeader(WireMockRuntimeInfo wmRuntimeInfo) {
stubFor(
post(urlEqualTo("/api/request"))
.willReturn(
aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"success\":true,\"data\":{\"answer\":\"ok\"}}")));

AxonFlow client =
AxonFlow.create(
AxonFlowConfig.builder()
.agentUrl(wmRuntimeInfo.getHttpBaseUrl())
.clientId("acme-corp")
.clientSecret("secret")
.build());

client.proxyLLMCall(ClientRequest.builder().userToken("").query("ping").build());

verify(postRequestedFor(urlEqualTo("/api/request")).withoutHeader("X-Tenant-ID"));
}
}
Loading