Skip to content

Commit 3168fca

Browse files
m5i-workCopilotCopilottrangevi
authored
Add --client-header flag to azd ai agent invoke (#8478) (#9043)
* Add --client-header flag to azd ai agent invoke (#8478) Add a --client-header flag to send custom x-client-* request headers with azd ai agent invoke. The responses and invocations protocol proxies forward the x-client-* header family to the agent container, so the flag accepts only x-client-* names (case-insensitive) and rejects any other name with guidance toward --user-identity/--call-id for identity headers. The a2a protocol uses its own fixed internal-routing header set and does not propagate x-client-*, so combining --client-header with --protocol a2a is a hard error. Custom headers are applied before managed headers (Authorization, Content-Type, user identity) so managed headers always win. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix: address PR review comments on invoke.go and invoke_headers_test.go - Fix comment 'translating head' -> 'translating proxy' in invoke.go - Add a2a + client-header re-validation in Run() after protocol auto-detection to prevent silent header drops when --protocol is omitted - Remove misleading/dead lines from TestApplyCustomHeaders that set Content-Type before calling applyCustomHeaders (reverse order from what the comment claimed), leaving only the correct builder-order test Co-authored-by: trangevi <26490000+trangevi@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: trangevi <26490000+trangevi@users.noreply.github.com> Co-authored-by: Travis Angevine <trangevi@microsoft.com>
1 parent 9242404 commit 3168fca

5 files changed

Lines changed: 309 additions & 3 deletions

File tree

cli/azd/extensions/azure.ai.agents/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Features Added
66

7+
- [[#8478]](https://github.com/Azure/azure-dev/issues/8478) Add `--client-header` to `azd ai agent invoke` for sending custom `x-client-*` request headers in `"Name: Value"` format (repeatable). The responses and invocations protocols forward the `x-client-*` header family to the agent; other header names are rejected, and the flag is not supported with the `a2a` protocol (which does not propagate `x-client-*` headers). Managed headers (`Authorization`, `Content-Type`, user identity) always take precedence.
78
- [[#8939]](https://github.com/Azure/azure-dev/pull/8939) Add native support for the Activity protocol to `azd ai agent`. `azd ai agent init` can now scaffold an Activity-protocol agent (defaulting to the service-recommended version `2.0.0`), `azd deploy` provisions a companion Azure Bot Service registration authenticated with `BotServiceRbac` and prints Microsoft Teams setup guidance, and `azd down` tears the bot down. Both init-from-code and init-from-manifest flows are supported.
89
- [[#8989]](https://github.com/Azure/azure-dev/pull/8989) Add `a2a` protocol support to `azd ai agent invoke`. A plain message is wrapped in a JSON-RPC 2.0 `message/send` request, `--input-file` sends a complete JSON-RPC request, and `--output raw` dumps the response verbatim. A2A is remote-only (not available with `--local`).
910

cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ type invokeFlags struct {
4545
version string
4646
outputFmt string
4747
callID string
48+
clientHeaders []string
4849
}
4950

5051
// outputRaw is the sentinel value of the inherited --output flag that selects
@@ -63,9 +64,10 @@ const maxInvokeVersionLength = 128
6364
var createInvokeVersionSession = createInvokeVersionSessionImpl
6465

6566
type InvokeAction struct {
66-
flags *invokeFlags
67-
noPrompt bool
68-
endpoint *parsedAgentEndpoint
67+
flags *invokeFlags
68+
noPrompt bool
69+
endpoint *parsedAgentEndpoint
70+
clientHeaders http.Header
6971
}
7072

7173
func newInvokeCommand(extCtx *azdext.ExtensionContext) *cobra.Command {
@@ -105,6 +107,12 @@ Use --call-id to send a call ID with a local invoke. It is sent as the
105107
x-agent-foundry-call-id header and applies only to local invocations; it is
106108
ignored for remote requests.
107109
110+
Use --client-header to send custom x-client-* request headers in "Name: Value"
111+
format (repeatable). The responses and invocations protocols forward the
112+
x-client-* header family to the agent; other header names are rejected, and
113+
the flag is not supported with the a2a protocol (which does not propagate
114+
x-client-* headers). For identity headers use --user-identity or --call-id.
115+
108116
Use --output raw (or -o raw) to dump the unmodified server response (status
109117
line, headers, and body verbatim) to stdout. Useful for debugging server
110118
behavior and inspecting response headers (for example, the agent version
@@ -146,6 +154,9 @@ suppressed in raw mode.`,
146154
# Dump the raw server response (status line, headers, body) for debugging
147155
azd ai agent invoke --output raw "Hello!"
148156
157+
# Send custom x-client-* headers (repeatable)
158+
azd ai agent invoke --client-header "x-client-request-id: abc123" --client-header "x-client-tenant: contoso" "Hello!"
159+
149160
# Invoke a deployed agent from any directory using the endpoint URL shown by 'azd ai agent show'
150161
azd ai agent invoke \
151162
--agent-endpoint https://<acct>.services.ai.azure.com/api/projects/<proj>/agents/<name>/endpoint/protocols/openai/responses?api-version=v1 \
@@ -230,6 +241,23 @@ suppressed in raw mode.`,
230241
}
231242
}
232243

244+
clientHeaders, err := parseCustomHeaders(flags.clientHeaders)
245+
if err != nil {
246+
return err
247+
}
248+
// A2A routes through a translating proxy with its own fixed header
249+
// set and does not propagate x-client-* to the agent, so accepting
250+
// the flag there would silently drop the headers.
251+
if len(clientHeaders) > 0 && agent_api.AgentProtocol(flags.protocol) == agent_api.AgentProtocolA2A {
252+
return exterrors.Validation(
253+
exterrors.CodeInvalidParameter,
254+
"--client-header is not supported with the a2a protocol",
255+
"the a2a protocol does not forward x-client-* headers to the agent; "+
256+
"use --protocol responses or invocations to send client headers",
257+
)
258+
}
259+
action.clientHeaders = clientHeaders
260+
233261
return action.Run(ctx)
234262
},
235263
}
@@ -258,6 +286,14 @@ suppressed in raw mode.`,
258286
"Call ID header value (sent as "+agent_api.AgentFoundryCallIDHeader+" for local invocations only; "+
259287
"ignored for remote requests)",
260288
)
289+
cmd.Flags().StringArrayVar(
290+
&flags.clientHeaders,
291+
"client-header",
292+
nil,
293+
`Custom x-client-* request header in "Name: Value" format (repeatable). `+
294+
"The responses and invocations protocols forward the x-client-* header family to the agent; "+
295+
"other header names are rejected and the flag is not supported with a2a.",
296+
)
261297
cmd.Flags().StringVar(
262298
&flags.agentEndpoint,
263299
"agent-endpoint",
@@ -386,6 +422,21 @@ func (a *InvokeAction) Run(ctx context.Context) error {
386422
return err
387423
}
388424

425+
// Re-validate after protocol resolution: when --protocol was omitted the
426+
// protocol may have been auto-detected as a2a (e.g. from agent.yaml). In
427+
// that case the flag-parse guard above was skipped and clientHeaders was
428+
// populated, but a2aRemote never calls applyCustomHeaders — the headers
429+
// would be silently dropped, which is the exact silent no-op the guard
430+
// intends to prevent.
431+
if len(a.clientHeaders) > 0 && protocol == agent_api.AgentProtocolA2A {
432+
return exterrors.Validation(
433+
exterrors.CodeInvalidParameter,
434+
"--client-header is not supported with the a2a protocol",
435+
"the a2a protocol does not forward x-client-* headers to the agent; "+
436+
"use --protocol responses or invocations to send client headers",
437+
)
438+
}
439+
389440
if a.flags.local {
390441
switch protocol {
391442
case agent_api.AgentProtocolInvocations:
@@ -619,6 +670,7 @@ func (a *InvokeAction) responsesLocal(ctx context.Context) error {
619670
if err != nil {
620671
return fmt.Errorf("failed to create request: %w", err)
621672
}
673+
applyCustomHeaders(req, a.clientHeaders)
622674
req.Header.Set("Content-Type", "application/json")
623675
applyLocalUserIdentityHeader(req, &a.flags.userIdentityFlags)
624676
applyLocalCallIDHeader(req, a.flags.callID)
@@ -1018,6 +1070,7 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error {
10181070
if err != nil {
10191071
return fmt.Errorf("failed to create request: %w", err)
10201072
}
1073+
applyCustomHeaders(req, a.clientHeaders)
10211074
req.Header.Set("Content-Type", "application/json")
10221075
req.Header.Set("Authorization", "Bearer "+rc.bearerToken)
10231076
applyRemoteUserIdentityHeader(req, &a.flags.userIdentityFlags)
@@ -1145,6 +1198,7 @@ func (a *InvokeAction) invocationsLocal(ctx context.Context) error {
11451198
if err != nil {
11461199
return fmt.Errorf("failed to create request: %w", err)
11471200
}
1201+
applyCustomHeaders(req, a.clientHeaders)
11481202
req.Header.Set("Content-Type", contentTypeForBody(body))
11491203
applyLocalUserIdentityHeader(req, &a.flags.userIdentityFlags)
11501204
applyLocalCallIDHeader(req, a.flags.callID)
@@ -1255,6 +1309,7 @@ func (a *InvokeAction) invocationsRemote(ctx context.Context) error {
12551309
if err != nil {
12561310
return fmt.Errorf("failed to create request: %w", err)
12571311
}
1312+
applyCustomHeaders(req, a.clientHeaders)
12581313
req.Header.Set("Content-Type", contentTypeForBody(body))
12591314
req.Header.Set("Authorization", "Bearer "+rc.bearerToken)
12601315
applyRemoteUserIdentityHeader(req, &a.flags.userIdentityFlags)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package cmd
5+
6+
import (
7+
"fmt"
8+
"net/http"
9+
"strings"
10+
11+
"azureaiagent/internal/exterrors"
12+
)
13+
14+
// clientHeaderPrefix is the required prefix (case-insensitive) for headers
15+
// passed via --client-header. The Responses protocol proxy forwards the x-client-*
16+
// family to the agent container; other header names are rejected so users are
17+
// not misled into thinking arbitrary headers reach the agent.
18+
const clientHeaderPrefix = "x-client-"
19+
20+
// parseCustomHeaders converts raw "Name: Value" flag entries (curl -H style)
21+
// into an http.Header with canonicalized keys. Repeating a name adds multiple
22+
// values. Only x-client-* names are accepted; any other name (or a malformed
23+
// entry) is rejected so the mistake surfaces before any request is sent.
24+
func parseCustomHeaders(entries []string) (http.Header, error) {
25+
if len(entries) == 0 {
26+
return nil, nil
27+
}
28+
29+
headers := http.Header{}
30+
for _, entry := range entries {
31+
name, value, ok := strings.Cut(entry, ":")
32+
if !ok {
33+
return nil, exterrors.Validation(
34+
exterrors.CodeInvalidParameter,
35+
fmt.Sprintf("invalid --client-header value %q", entry),
36+
`use the "Name: Value" format, for example --client-header "x-client-request-id: abc123"`,
37+
)
38+
}
39+
40+
name = strings.TrimSpace(name)
41+
if name == "" {
42+
return nil, exterrors.Validation(
43+
exterrors.CodeInvalidParameter,
44+
fmt.Sprintf("invalid --client-header value %q: header name is empty", entry),
45+
`use the "Name: Value" format, for example --client-header "x-client-request-id: abc123"`,
46+
)
47+
}
48+
if !isValidHeaderName(name) {
49+
return nil, exterrors.Validation(
50+
exterrors.CodeInvalidParameter,
51+
fmt.Sprintf("invalid --client-header name %q", name),
52+
"header names may only contain letters, digits, and the characters !#$%&'*+-.^_`|~",
53+
)
54+
}
55+
if !strings.HasPrefix(strings.ToLower(name), clientHeaderPrefix) {
56+
return nil, exterrors.Validation(
57+
exterrors.CodeInvalidParameter,
58+
fmt.Sprintf("unsupported --client-header name %q", name),
59+
fmt.Sprintf(
60+
"only %s* headers can be sent with --client-header; "+
61+
"use --user-identity or --call-id for identity headers",
62+
clientHeaderPrefix,
63+
),
64+
)
65+
}
66+
67+
headers.Add(name, strings.TrimSpace(value))
68+
}
69+
70+
return headers, nil
71+
}
72+
73+
// isValidHeaderName reports whether name is a valid HTTP field name per the
74+
// RFC 7230 token grammar. This guards against sending malformed headers (for
75+
// example, names containing spaces) that would otherwise fail deeper in the
76+
// stack with a less actionable error.
77+
func isValidHeaderName(name string) bool {
78+
for _, r := range name {
79+
switch {
80+
case r >= 'a' && r <= 'z',
81+
r >= 'A' && r <= 'Z',
82+
r >= '0' && r <= '9':
83+
continue
84+
}
85+
if !strings.ContainsRune("!#$%&'*+-.^_`|~", r) {
86+
return false
87+
}
88+
}
89+
return true
90+
}
91+
92+
// applyCustomHeaders adds the parsed custom headers to req. It is called before
93+
// the request builders set their managed headers (Content-Type, Authorization,
94+
// user identity, and so on) so those always take precedence and a custom header
95+
// can never accidentally clobber authentication.
96+
func applyCustomHeaders(req *http.Request, headers http.Header) {
97+
for name, values := range headers {
98+
for _, value := range values {
99+
req.Header.Add(name, value)
100+
}
101+
}
102+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package cmd
5+
6+
import (
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
)
11+
12+
func TestParseCustomHeaders(t *testing.T) {
13+
t.Parallel()
14+
15+
t.Run("nil for no entries", func(t *testing.T) {
16+
t.Parallel()
17+
got, err := parseCustomHeaders(nil)
18+
if err != nil {
19+
t.Fatalf("unexpected error: %v", err)
20+
}
21+
if got != nil {
22+
t.Fatalf("expected nil header, got %v", got)
23+
}
24+
})
25+
26+
t.Run("parses and canonicalizes name, trims value", func(t *testing.T) {
27+
t.Parallel()
28+
got, err := parseCustomHeaders([]string{"x-client-request-id: abc123 "})
29+
if err != nil {
30+
t.Fatalf("unexpected error: %v", err)
31+
}
32+
if v := got.Get("X-Client-Request-Id"); v != "abc123" {
33+
t.Fatalf("expected trimmed canonical value, got %q", v)
34+
}
35+
})
36+
37+
t.Run("repeated name adds multiple values", func(t *testing.T) {
38+
t.Parallel()
39+
got, err := parseCustomHeaders([]string{"X-Client-Tag: a", "X-Client-Tag: b"})
40+
if err != nil {
41+
t.Fatalf("unexpected error: %v", err)
42+
}
43+
values := got.Values("X-Client-Tag")
44+
if len(values) != 2 || values[0] != "a" || values[1] != "b" {
45+
t.Fatalf("expected [a b], got %v", values)
46+
}
47+
})
48+
49+
t.Run("allows empty value", func(t *testing.T) {
50+
t.Parallel()
51+
got, err := parseCustomHeaders([]string{"X-Client-Empty:"})
52+
if err != nil {
53+
t.Fatalf("unexpected error: %v", err)
54+
}
55+
if _, ok := got["X-Client-Empty"]; !ok {
56+
t.Fatalf("expected header to be present with empty value, got %v", got)
57+
}
58+
})
59+
60+
t.Run("accepts prefix case-insensitively", func(t *testing.T) {
61+
t.Parallel()
62+
got, err := parseCustomHeaders([]string{"X-CLIENT-Foo: bar"})
63+
if err != nil {
64+
t.Fatalf("unexpected error: %v", err)
65+
}
66+
if v := got.Get("X-Client-Foo"); v != "bar" {
67+
t.Fatalf("expected header to be accepted, got %q", v)
68+
}
69+
})
70+
71+
t.Run("rejects missing colon", func(t *testing.T) {
72+
t.Parallel()
73+
if _, err := parseCustomHeaders([]string{"NoColonHere"}); err == nil {
74+
t.Fatal("expected error for entry without a colon")
75+
}
76+
})
77+
78+
t.Run("rejects empty name", func(t *testing.T) {
79+
t.Parallel()
80+
if _, err := parseCustomHeaders([]string{":value"}); err == nil {
81+
t.Fatal("expected error for empty header name")
82+
}
83+
})
84+
85+
t.Run("rejects invalid name", func(t *testing.T) {
86+
t.Parallel()
87+
if _, err := parseCustomHeaders([]string{"x-client-bad name: value"}); err == nil {
88+
t.Fatal("expected error for header name with a space")
89+
}
90+
})
91+
92+
t.Run("rejects non x-client name", func(t *testing.T) {
93+
t.Parallel()
94+
if _, err := parseCustomHeaders([]string{"X-Custom-Header: value"}); err == nil {
95+
t.Fatal("expected error for header name outside the x-client- family")
96+
}
97+
})
98+
}
99+
100+
func TestIsValidHeaderName(t *testing.T) {
101+
t.Parallel()
102+
103+
valid := []string{"X-Client-Request-Id", "Authorization", "a", "X-Foo_Bar", "x.y", "A1!#$%&'*+-.^_`|~"}
104+
for _, name := range valid {
105+
if !isValidHeaderName(name) {
106+
t.Errorf("expected %q to be valid", name)
107+
}
108+
}
109+
110+
invalid := []string{"has space", "with:colon", "tab\ttab", "new\nline"}
111+
for _, name := range invalid {
112+
if isValidHeaderName(name) {
113+
t.Errorf("expected %q to be invalid", name)
114+
}
115+
}
116+
}
117+
118+
func TestApplyCustomHeaders(t *testing.T) {
119+
t.Parallel()
120+
121+
req := httptest.NewRequest(http.MethodPost, "http://localhost/responses", nil)
122+
headers := http.Header{
123+
"X-Client-Request-Id": {"abc"},
124+
"X-Client-Tag": {"a", "b"},
125+
}
126+
applyCustomHeaders(req, headers)
127+
128+
if v := req.Header.Get("X-Client-Request-Id"); v != "abc" {
129+
t.Fatalf("expected header to be applied, got %q", v)
130+
}
131+
if values := req.Header.Values("X-Client-Tag"); len(values) != 2 {
132+
t.Fatalf("expected 2 values for X-Client-Tag, got %v", values)
133+
}
134+
135+
// Simulate builder order: custom applied first, managed overrides.
136+
req2 := httptest.NewRequest(http.MethodPost, "http://localhost/responses", nil)
137+
applyCustomHeaders(req2, http.Header{"Content-Type": {"text/plain"}})
138+
req2.Header.Set("Content-Type", "application/json")
139+
if v := req2.Header.Get("Content-Type"); v != "application/json" {
140+
t.Fatalf("expected managed Content-Type to win, got %q", v)
141+
}
142+
}

0 commit comments

Comments
 (0)