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
9 changes: 9 additions & 0 deletions src/main/java/com/getaxonflow/sdk/AxonFlow.java
Original file line number Diff line number Diff line change
Expand Up @@ -3365,6 +3365,7 @@ public void streamExecutionStatus(
new Request.Builder()
.url(url)
.header("User-Agent", config.getUserAgent())
.header("X-Axonflow-Client", config.getClientHeader())
.header("Accept", "text/event-stream")
.get();

Expand Down Expand Up @@ -3573,6 +3574,7 @@ private Request buildRequest(String method, String path, Object body) {
new Request.Builder()
.url(url)
.header("User-Agent", config.getUserAgent())
.header("X-Axonflow-Client", config.getClientHeader())
.header("Accept", "application/json");

// Add authentication headers
Expand Down Expand Up @@ -3624,6 +3626,7 @@ private Request buildPatchRequest(String path, Object body) {
new Request.Builder()
.url(url)
.header("User-Agent", config.getUserAgent())
.header("X-Axonflow-Client", config.getClientHeader())
.header("Accept", "application/json");

addAuthHeaders(builder);
Expand Down Expand Up @@ -3763,6 +3766,10 @@ private void addAuthHeaders(Request.Builder builder) {
String encoded =
Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
builder.header("Authorization", "Basic " + encoded);
// ADR-050 §4: every governed request to the agent carries X-Axonflow-Client
// 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());
}

/**
Expand Down Expand Up @@ -4372,6 +4379,7 @@ private Request buildOrchestratorRequest(String method, String path, Object body
new Request.Builder()
.url(url)
.header("User-Agent", config.getUserAgent())
.header("X-Axonflow-Client", config.getClientHeader())
.header("Accept", "application/json");

addAuthHeaders(builder);
Expand Down Expand Up @@ -4440,6 +4448,7 @@ private Request buildPortalRequest(String method, String path, Object body) {
new Request.Builder()
.url(url)
.header("User-Agent", config.getUserAgent())
.header("X-Axonflow-Client", config.getClientHeader())
.header("Accept", "application/json");

addPortalSessionCookie(builder);
Expand Down
14 changes: 14 additions & 0 deletions src/main/java/com/getaxonflow/sdk/AxonFlowConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,20 @@ public String getUserAgent() {
return userAgent;
}

/**
* Returns the X-Axonflow-Client header value identifying this SDK + version.
*
* <p>Per ADR-050 §4, every governed request to the agent carries this header so the agent can
* derive request scope (sdk) and validate it against the token's aud.scope via HasScope().
* Sourced from the bundled {@link #SDK_VERSION}; there is intentionally no env / config
* override (the consumer doesn't get to spoof its own client identity to the agent).
*
* @return the agent-parseable {@code "sdk-java/<semver>"} client header value
*/
public String getClientHeader() {
return "sdk-java/" + SDK_VERSION;
}

/**
* Returns the telemetry config override.
*
Expand Down
84 changes: 84 additions & 0 deletions src/test/java/com/getaxonflow/sdk/ClientHeaderTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
* 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
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.getaxonflow.sdk;

import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static org.assertj.core.api.Assertions.*;

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-Axonflow-Client header injection — ADR-050 §4.
*
* <p>Asserts every governed request forwards {@code X-Axonflow-Client:
* sdk-java/<SDK_VERSION>} so the agent can derive request scope (sdk) and validate against the
* token's aud.scope via HasScope().
*
* <p>Header value is sourced from the bundled {@link AxonFlowConfig#SDK_VERSION}; the consumer
* cannot spoof its own client identity through config (intentional — honest-99% header injection
* per ADR-050 §4).
*/
@WireMockTest
@DisplayName("X-Axonflow-Client header injection")
class ClientHeaderTest {

private static final String EXPECTED_CLIENT = "sdk-java/" + AxonFlowConfig.SDK_VERSION;

@Test
@DisplayName("should send X-Axonflow-Client on proxyLLMCall")
void shouldSendClientHeaderOnProxy(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("test-client")
.clientSecret("test-secret")
.build());

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

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

@Test
@DisplayName("getClientHeader returns sdk-java/<semver>")
void getClientHeaderShouldMatchExpectedFormat() {
AxonFlowConfig config =
AxonFlowConfig.builder().agentUrl("http://localhost:8080").build();

String header = config.getClientHeader();
assertThat(header).startsWith("sdk-java/");
// Sanity: agent's deriveScopeFromClientHeader splits on '/' and maps
// "sdk-*" prefixes to scope=sdk. Lock down the shape.
assertThat(header.split("/")).hasSize(2);
assertThat(header.split("/")[0]).isEqualTo("sdk-java");
}
}
Loading