diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlow.java b/src/main/java/com/getaxonflow/sdk/AxonFlow.java index ccdef2b..cdcf0be 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlow.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlow.java @@ -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(); @@ -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 @@ -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); @@ -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()); } /** @@ -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); @@ -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); diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlowConfig.java b/src/main/java/com/getaxonflow/sdk/AxonFlowConfig.java index 2dc0fb3..90e44d8 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlowConfig.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlowConfig.java @@ -292,6 +292,20 @@ public String getUserAgent() { return userAgent; } + /** + * Returns the X-Axonflow-Client header value identifying this SDK + version. + * + *

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/"} client header value + */ + public String getClientHeader() { + return "sdk-java/" + SDK_VERSION; + } + /** * Returns the telemetry config override. * diff --git a/src/test/java/com/getaxonflow/sdk/ClientHeaderTest.java b/src/test/java/com/getaxonflow/sdk/ClientHeaderTest.java new file mode 100644 index 0000000..e5cf35f --- /dev/null +++ b/src/test/java/com/getaxonflow/sdk/ClientHeaderTest.java @@ -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. + * + *

Asserts every governed request forwards {@code X-Axonflow-Client: + * sdk-java/} so the agent can derive request scope (sdk) and validate against the + * token's aud.scope via HasScope(). + * + *

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/") + 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"); + } +}