Skip to content
Closed
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
165 changes: 165 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package cmd

import (
"fmt"
"net/http"
"net/url"
"strings"
)

// parseAgentEndpoint parses a deployed agent endpoint URL of the form
//
// <projectEndpoint>/agents/<agentName>/versions/<agentVersion>
//
// and returns the project endpoint and agent name. The version segment is
// accepted but not used by invocation requests.
//
// Both `/agents/<name>/versions/<v>` and `/agents/<name>` are accepted; a
// trailing slash is tolerated. Anything else after the agent name is rejected
// to surface typos early.
//
// Agent names are validated to contain only characters that are safe to use
// as a path segment without further encoding, since the parsed name is
// substituted into invocation URLs as-is.
func parseAgentEndpoint(raw string) (projectEndpoint, agentName string, err error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", fmt.Errorf("agent endpoint is empty")
}

parsed, err := url.Parse(raw)
if err != nil {
return "", "", fmt.Errorf("invalid agent endpoint URL: %w", err)
}
if parsed.Scheme == "" || parsed.Host == "" {
return "", "", fmt.Errorf(
"invalid agent endpoint URL %q: must include scheme and host", raw,
)
}
if parsed.Scheme != "https" {
return "", "", fmt.Errorf(
"invalid agent endpoint URL %q: scheme must be https", raw,
)
}

// Use the un-decoded path so percent-encoded segments are preserved when
// re-substituting the agent name into invocation URLs downstream.
path := strings.TrimRight(parsed.EscapedPath(), "/")
if strings.HasSuffix(path, "/agents") {
return "", "", fmt.Errorf(
"invalid agent endpoint %q: agent name is empty", raw,
)
}
idx := strings.LastIndex(path, "/agents/")
if idx < 0 {
return "", "", fmt.Errorf(
"invalid agent endpoint %q: expected '/agents/<name>' in path", raw,
)
}
Comment on lines +33 to +61
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseAgentEndpoint currently accepts any https host and any prefix path as long as it contains "/agents/", which means azd ai agent invoke --agent-endpoint could send an Azure bearer token to an arbitrary domain/path if the user pastes a malformed/malicious URL. Consider validating that the host matches the expected Foundry domain (e.g., *.services.ai.azure.com, consistent with buildAgentEndpoint) and that the project path includes the expected /api/projects/<project> prefix before returning projectEndpoint.

Copilot uses AI. Check for mistakes.

projectPath := path[:idx]
rest := strings.TrimPrefix(path[idx:], "/agents/")
if rest == "" {
return "", "", fmt.Errorf(
"invalid agent endpoint %q: missing agent name after '/agents/'", raw,
)
}

segments := strings.Split(rest, "/")
agentName = segments[0]
if agentName == "" {
return "", "", fmt.Errorf(
"invalid agent endpoint %q: agent name is empty", raw,
)
}
if !isValidAgentNameSegment(agentName) {
return "", "", fmt.Errorf(
"invalid agent endpoint %q: agent name %q contains unsupported characters",
raw, agentName,
)
}

// Accept exactly `/agents/<name>` or `/agents/<name>/versions/<version>`;
// reject anything longer to surface pasted-but-truncated URLs.
switch len(segments) {
case 1:
// /agents/<name>
case 3:
if segments[1] != "versions" || segments[2] == "" {
return "", "", fmt.Errorf(
"invalid agent endpoint %q: expected '/agents/<name>' "+
"or '/agents/<name>/versions/<version>'", raw,
)
}
default:
return "", "", fmt.Errorf(
"invalid agent endpoint %q: expected '/agents/<name>' "+
"or '/agents/<name>/versions/<version>'", raw,
)
}

projectURL := *parsed
if err := setURLEscapedPath(&projectURL, projectPath); err != nil {
return "", "", fmt.Errorf("invalid agent endpoint %q: %w", raw, err)
}
projectURL.RawQuery = ""
projectURL.Fragment = ""
projectEndpoint = strings.TrimRight(projectURL.String(), "/")

return projectEndpoint, agentName, nil
}

// isValidAgentNameSegment reports whether s is safe to substitute into a URL
// path segment without further escaping. Agent names produced by azd are
// alphanumeric plus '-' and '_'.
func isValidAgentNameSegment(s string) bool {
if s == "" {
return false
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z',
r >= 'A' && r <= 'Z',
r >= '0' && r <= '9',
r == '-', r == '_':
// ok
default:
return false
}
}
return true
}

// setURLEscapedPath assigns an already-escaped path to u, decoding it for
// u.Path so url.URL stays internally consistent.
func setURLEscapedPath(u *url.URL, escapedPath string) error {
decoded, err := url.PathUnescape(escapedPath)
if err != nil {
return err
}
u.Path = decoded
if escapedPath != decoded {
u.RawPath = escapedPath
} else {
u.RawPath = ""
}
return nil
}

// printEphemeralSessionHint prints the server-assigned session ID (if any)
// when running in --agent-endpoint mode where azd has nowhere to persist it.
// This lets the user copy the ID into --session-id for subsequent invokes.
func printEphemeralSessionHint(existingSID string, resp *http.Response) {
if existingSID != "" || resp == nil {
return
}
newSID := resp.Header.Get("x-agent-session-id")
if newSID == "" {
return
}
fmt.Printf("Server session ID: %s\n", newSID)
fmt.Printf("(Pass --session-id %s to reuse this session on the next invoke.)\n", newSID)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package cmd

import (
"strings"
"testing"
)

func TestParseAgentEndpoint(t *testing.T) {
t.Parallel()

tests := []struct {
name string
input string
wantProject string
wantAgentName string
wantErrSubstr string
}{
{
name: "full deploy-printed endpoint with version",
input: "https://acct.services.ai.azure.com/api/projects/proj/agents/my-agent/versions/1",
wantProject: "https://acct.services.ai.azure.com/api/projects/proj",
wantAgentName: "my-agent",
},
{
name: "without version suffix",
input: "https://acct.services.ai.azure.com/api/projects/proj/agents/my-agent",
wantProject: "https://acct.services.ai.azure.com/api/projects/proj",
wantAgentName: "my-agent",
},
{
name: "trailing slash",
input: "https://acct.services.ai.azure.com/api/projects/proj/agents/my-agent/versions/1/",
wantProject: "https://acct.services.ai.azure.com/api/projects/proj",
wantAgentName: "my-agent",
},
{
name: "agent name with hyphens",
input: "https://x.services.ai.azure.com/api/projects/y/agents/agent-with-many-hyphens-1/versions/2",
wantProject: "https://x.services.ai.azure.com/api/projects/y",
wantAgentName: "agent-with-many-hyphens-1",
},
{
name: "leading/trailing whitespace tolerated",
input: " https://acct.services.ai.azure.com/api/projects/proj/agents/my-agent ",
wantProject: "https://acct.services.ai.azure.com/api/projects/proj",
wantAgentName: "my-agent",
},
{
name: "empty",
input: "",
wantErrSubstr: "empty",
},
{
name: "no scheme",
input: "acct.services.ai.azure.com/api/projects/proj/agents/my-agent",
wantErrSubstr: "scheme",
},
{
name: "wrong scheme",
input: "ftp://acct.services.ai.azure.com/api/projects/proj/agents/my-agent",
wantErrSubstr: "scheme",
},
{
name: "http scheme rejected",
input: "http://acct.services.ai.azure.com/api/projects/proj/agents/my-agent",
wantErrSubstr: "https",
},
{
name: "missing /agents/ segment",
input: "https://acct.services.ai.azure.com/api/projects/proj",
wantErrSubstr: "/agents/",
},
{
name: "agent name empty",
input: "https://acct.services.ai.azure.com/api/projects/proj/agents/",
wantErrSubstr: "agent name",
},
{
name: "unexpected suffix after agent name",
input: "https://acct.services.ai.azure.com/api/projects/proj/agents/my-agent/endpoint",
wantErrSubstr: "versions",
},
{
name: "versions without value",
input: "https://acct.services.ai.azure.com/api/projects/proj/agents/my-agent/versions/",
wantErrSubstr: "versions",
},
{
name: "trailing junk after versions rejected",
input: "https://acct.services.ai.azure.com/api/projects/proj/agents/my-agent/versions/1/extra",
wantErrSubstr: "versions",
},
{
name: "agent name with unsupported characters rejected",
input: "https://acct.services.ai.azure.com/api/projects/proj/agents/my%20agent/versions/1",
wantErrSubstr: "unsupported characters",
},
{
name: "agent name with dot rejected",
input: "https://acct.services.ai.azure.com/api/projects/proj/agents/bad.name/versions/1",
wantErrSubstr: "unsupported characters",
},
{
name: "query string and fragment stripped",
input: "https://acct.services.ai.azure.com/api/projects/proj/agents/my-agent/versions/1?x=y#frag",
wantProject: "https://acct.services.ai.azure.com/api/projects/proj",
wantAgentName: "my-agent",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

gotProject, gotAgent, err := parseAgentEndpoint(tc.input)
if tc.wantErrSubstr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tc.wantErrSubstr)
}
if !strings.Contains(err.Error(), tc.wantErrSubstr) {
t.Fatalf("error %q does not contain %q", err.Error(), tc.wantErrSubstr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotProject != tc.wantProject {
t.Errorf("projectEndpoint: got %q, want %q", gotProject, tc.wantProject)
}
if gotAgent != tc.wantAgentName {
t.Errorf("agentName: got %q, want %q", gotAgent, tc.wantAgentName)
}
})
}
}
Loading
Loading