-
Notifications
You must be signed in to change notification settings - Fork 305
feat(ai-agents): add --agent-endpoint flag to invoke command #7932
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
165 changes: 165 additions & 0 deletions
165
cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| ) | ||
| } | ||
|
|
||
| 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) | ||
| } | ||
139 changes: 139 additions & 0 deletions
139
cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| }) | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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-endpointcould 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 returningprojectEndpoint.