From c8a4769b26ccf68b065f1211c0eb51ecd414a87f Mon Sep 17 00:00:00 2001 From: Naman Tyagi Date: Wed, 13 May 2026 19:48:26 +0530 Subject: [PATCH 1/2] feat: add azd ai connection commands to agents extension Add connection CRUD commands as a sibling subcommand group under azd ai: - azd ai connection list (ARM with server-side category filter) - azd ai connection show (ARM metadata + optional data-plane credentials) - azd ai connection create (ARM PUT with --force upsert, pre-check GET) - azd ai connection delete (ARM DELETE with confirmation prompt) Architecture: - Extension namespace changed from ai.agent to ai - Connection code in internal/connections/ (self-contained, no agent imports) - Hybrid API: ARM SDK for CRUD, data-plane for credential fetch - 5-level project endpoint resolution cascade - ARM context discovery via data-plane bootstrap GET - Credential reference strings in show output for agent.yaml Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../extensions/azure.ai.agents/extension.yaml | 8 +- cli/azd/extensions/azure.ai.agents/go.mod | 2 + cli/azd/extensions/azure.ai.agents/go.sum | 2 + .../azure.ai.agents/internal/cmd/debug.go | 10 + .../azure.ai.agents/internal/cmd/root.go | 67 +-- .../internal/connections/cmd/connection.go | 457 ++++++++++++++++++ .../internal/connections/cmd/context.go | 92 ++++ .../internal/connections/cmd/endpoint.go | 145 ++++++ .../internal/connections/cmd/root.go | 35 ++ .../internal/connections/exterrors/codes.go | 36 ++ .../internal/connections/exterrors/errors.go | 52 ++ .../pkg/connections/data_client.go | 122 +++++ .../connections/pkg/connections/models.go | 32 ++ .../azure.ai.agents/internal/root.go | 51 ++ cli/azd/extensions/azure.ai.agents/main.go | 4 +- 15 files changed, 1067 insertions(+), 48 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/connections/cmd/connection.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/connections/cmd/context.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/connections/cmd/endpoint.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/connections/cmd/root.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/connections/exterrors/codes.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/connections/exterrors/errors.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/connections/pkg/connections/data_client.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/connections/pkg/connections/models.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/root.go diff --git a/cli/azd/extensions/azure.ai.agents/extension.yaml b/cli/azd/extensions/azure.ai.agents/extension.yaml index 857f27744b8..8ccb5c9f743 100644 --- a/cli/azd/extensions/azure.ai.agents/extension.yaml +++ b/cli/azd/extensions/azure.ai.agents/extension.yaml @@ -1,9 +1,9 @@ # yaml-language-server: $schema=../extension.schema.json id: azure.ai.agents -namespace: ai.agent -displayName: Foundry agents (Preview) -description: Ship agents with Microsoft Foundry from your terminal. (Preview) -usage: azd ai agent [options] +namespace: ai +displayName: Foundry AI (Preview) +description: Manage agents and connections in Microsoft Foundry. (Preview) +usage: azd ai [options] # NOTE: Make sure version.txt is in sync with this version. version: 0.1.31-preview requiredAzdVersion: ">1.23.13" diff --git a/cli/azd/extensions/azure.ai.agents/go.mod b/cli/azd/extensions/azure.ai.agents/go.mod index bfad84820b0..38f791bc3ab 100644 --- a/cli/azd/extensions/azure.ai.agents/go.mod +++ b/cli/azd/extensions/azure.ai.agents/go.mod @@ -28,6 +28,8 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) +require github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices v1.8.0 + require ( dario.cat/mergo v1.0.2 // indirect github.com/AlecAivazis/survey/v2 v2.3.7 // indirect diff --git a/cli/azd/extensions/azure.ai.agents/go.sum b/cli/azd/extensions/azure.ai.agents/go.sum index 2d1a8679e86..e7b4fc1dace 100644 --- a/cli/azd/extensions/azure.ai.agents/go.sum +++ b/cli/azd/extensions/azure.ai.agents/go.sum @@ -17,6 +17,8 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthoriza github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0/go.mod h1:/pz8dyNQe+Ey3yBp/XuYz7oqX8YDNWVpPB0hH3XWfbc= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3 v3.0.0-beta.2 h1:qiir/pptnHqp6hV8QwV+IExYIf6cPsXBfUDUXQ27t2Y= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3 v3.0.0-beta.2/go.mod h1:jVRrRDLCOuif95HDYC23ADTMlvahB7tMdl519m9Iyjc= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices v1.8.0 h1:ZMGAqCZov8+7iFUPWKVcTaLgNXUeTlz20sIuWkQWNfg= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices v1.8.0/go.mod h1:BElPQ/GZtrdQ2i5uDZw3OKLE1we75W0AEWyeBR1TWQA= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2 v2.0.0 h1:pxphC/uRZKNHNPbZ0duDDgKkefju2F03OkG5xF6byHQ= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2 v2.0.0/go.mod h1:twcwRey+l1znKBL5TEzYiZMtiVkWfM7Pq8a9vY04xYc= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry v1.3.0-beta.3 h1:4qfc7os3wRQcl+ImfeH9z0abWJzuV9IGcN1B9olmPTU= diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/debug.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/debug.go index 9d0814b76e1..6785e4a3a39 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/debug.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/debug.go @@ -18,6 +18,16 @@ import ( var connectionStringJSONRegex = regexp.MustCompile(`("[\w]*(?:CONNECTION_STRING|ConnectionString)":\s*)"[^"]*"`) +// SetupDebugLogging configures debug logging for the extension (exported for root.go). +// By default Go's standard log package writes to stderr, which causes internal +// messages (e.g. from the command runner and GitHub CLI wrapper) to appear as +// noisy user-facing output. This function silences those logs unless debug mode +// is enabled, and additionally configures the Azure SDK logger when debugging. +// Returns a cleanup function that should be deferred by the caller. +func SetupDebugLogging(flags *pflag.FlagSet) func() { + return setupDebugLogging(flags) +} + // setupDebugLogging configures debug logging for the extension. // By default Go's standard log package writes to stderr, which causes internal // messages (e.g. from the command runner and GitHub CLI wrapper) to appear as diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go index d65d1c0b8e5..595b7acb769 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go @@ -11,55 +11,38 @@ import ( "github.com/spf13/cobra" ) -func NewRootCommand() *cobra.Command { - rootCmd, extCtx := azdext.NewExtensionRootCommand(azdext.ExtensionCommandOptions{ - Name: "agent", +// NewAgentRootCommand creates the "agent" subcommand group under "azd ai". +// It registers all agent-specific commands (init, run, invoke, show, etc.). +func NewAgentRootCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + extCtx = ensureExtensionContext(extCtx) + + cmd := &cobra.Command{ Use: "agent [options]", Short: fmt.Sprintf("Ship agents with Microsoft Foundry from your terminal. %s", color.YellowString("(Preview)")), - }) - rootCmd.SilenceUsage = true - rootCmd.SilenceErrors = true - rootCmd.CompletionOptions.DisableDefaultCmd = true - - // Configure debug logging once on the root command so every subcommand - // inherits it (cobra.EnableTraverseRunHooks, set by the SDK, ensures this - // runs alongside any subcommand pre-runs). The cleanup func is intentionally - // discarded: log writes are unbuffered and the OS closes the file on exit. - sdkPreRun := rootCmd.PersistentPreRunE - rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { - if sdkPreRun != nil { - if err := sdkPreRun(cmd, args); err != nil { - return err - } - } - setupDebugLogging(cmd.Flags()) - return nil } - // Show the ASCII art banner above the default help text for the root command - defaultHelp := rootCmd.HelpFunc() - rootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { - if cmd == rootCmd { - printBanner(cmd.OutOrStdout()) + // Show the ASCII art banner above the default help text for the agent command + defaultHelp := cmd.HelpFunc() + cmd.SetHelpFunc(func(c *cobra.Command, args []string) { + if c == cmd { + printBanner(c.OutOrStdout()) } - defaultHelp(cmd, args) + defaultHelp(c, args) }) - rootCmd.SetHelpCommand(&cobra.Command{Hidden: true}) - - rootCmd.AddCommand(azdext.NewListenCommand(configureExtensionHost)) - rootCmd.AddCommand(newVersionCommand()) - rootCmd.AddCommand(newInitCommand(extCtx)) - rootCmd.AddCommand(newRunCommand(extCtx)) - rootCmd.AddCommand(newInvokeCommand(extCtx)) - rootCmd.AddCommand(newMcpCommand()) - rootCmd.AddCommand(azdext.NewMetadataCommand("1.0", "azure.ai.agents", func() *cobra.Command { - return rootCmd + cmd.AddCommand(azdext.NewListenCommand(configureExtensionHost)) + cmd.AddCommand(newVersionCommand()) + cmd.AddCommand(newInitCommand(extCtx)) + cmd.AddCommand(newRunCommand(extCtx)) + cmd.AddCommand(newInvokeCommand(extCtx)) + cmd.AddCommand(newMcpCommand()) + cmd.AddCommand(azdext.NewMetadataCommand("1.0", "azure.ai.agents", func() *cobra.Command { + return cmd })) - rootCmd.AddCommand(newShowCommand(extCtx)) - rootCmd.AddCommand(newMonitorCommand(extCtx)) - rootCmd.AddCommand(newFilesCommand(extCtx)) - rootCmd.AddCommand(newSessionCommand(extCtx)) + cmd.AddCommand(newShowCommand(extCtx)) + cmd.AddCommand(newMonitorCommand(extCtx)) + cmd.AddCommand(newFilesCommand(extCtx)) + cmd.AddCommand(newSessionCommand(extCtx)) - return rootCmd + return cmd } diff --git a/cli/azd/extensions/azure.ai.agents/internal/connections/cmd/connection.go b/cli/azd/extensions/azure.ai.agents/internal/connections/cmd/connection.go new file mode 100644 index 00000000000..15fb7912071 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/connections/cmd/connection.go @@ -0,0 +1,457 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "text/tabwriter" + + "azureaiagent/internal/connections/exterrors" + "azureaiagent/internal/connections/pkg/connections" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// --- LIST --- + +func newConnectionListCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + var kind string + + cmd := &cobra.Command{ + Use: "list", + Short: "List connections in the Foundry project.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + + connCtx, err := resolveConnectionContext(ctx, cmd) + if err != nil { + return err + } + + pager := connCtx.armClient.NewListPager( + connCtx.rg, connCtx.account, connCtx.project, nil, + ) + + var results []connectionListItem + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpListConnections) + } + for _, conn := range page.Value { + props := conn.Properties.GetConnectionPropertiesV2() + if props == nil { + continue + } + if kind != "" && props.Category != nil && string(*props.Category) != kind { + continue + } + results = append(results, connectionListItem{ + Name: deref(conn.Name), + Kind: categoryStr(props.Category), + AuthType: authTypeStr(props.AuthType), + Target: deref(props.Target), + }) + } + } + + return printList(results, extCtx.OutputFormat) + }, + } + + cmd.Flags().StringVar(&kind, "kind", "", "Filter by connection kind (e.g., RemoteTool)") + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", AllowedValues: []string{"json", "table"}, Default: "table", + }) + return cmd +} + +// --- SHOW --- + +func newConnectionShowCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + var showCredentials bool + + cmd := &cobra.Command{ + Use: "show ", + Short: "Show connection details.", + Long: "Show connection details. Use --show-credentials to fetch secret values.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + ctx := azdext.WithAccessToken(cmd.Context()) + + connCtx, err := resolveConnectionContext(ctx, cmd) + if err != nil { + return err + } + + armResp, err := connCtx.armClient.Get( + ctx, connCtx.rg, connCtx.account, connCtx.project, name, nil, + ) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpGetConnection) + } + + props := armResp.Properties.GetConnectionPropertiesV2() + result := connectionDetailResult{ + Name: deref(armResp.Name), + Kind: categoryStr(props.Category), + AuthType: authTypeStr(props.AuthType), + Target: deref(props.Target), + Metadata: props.Metadata, + } + + if showCredentials { + dpConn, dpErr := connCtx.dpClient.GetConnectionWithCredentials(ctx, name) + if dpErr != nil { + fmt.Fprintf(os.Stderr, "Warning: could not fetch credentials: %s\n", dpErr) + } else { + result.Credentials = dpConn.Credentials + result.CredentialRefs = buildCredentialReferences(name, dpConn.Credentials) + } + } + + return printDetail(result, extCtx.OutputFormat) + }, + } + + cmd.Flags().BoolVar(&showCredentials, "show-credentials", false, + "Fetch credential values from the data plane") + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", AllowedValues: []string{"json", "table"}, Default: "table", + }) + return cmd +} + +// --- CREATE --- + +func newConnectionCreateCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + var ( + kind string + target string + authType string + key string + customKeys []string + metadata []string + force bool + ) + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a new Foundry project connection.", + Example: ` azd ai connection create my-search \ + --kind CognitiveSearch --target https://my-search.search.windows.net/ \ + --auth-type ApiKey --key "abc123..." + + azd ai connection create my-tavily \ + --kind RemoteTool --target https://mcp.tavily.com/mcp \ + --auth-type CustomKeys --custom-key "x-api-key=tvly-abc123"`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + ctx := azdext.WithAccessToken(cmd.Context()) + + connCtx, err := resolveConnectionContext(ctx, cmd) + if err != nil { + return err + } + + // Pre-check: fail if connection exists and --force not set + if !force { + if _, err := connCtx.armClient.Get( + ctx, connCtx.rg, connCtx.account, connCtx.project, name, nil, + ); err == nil { + return exterrors.Validation( + exterrors.CodeConnectionAlreadyExists, + fmt.Sprintf("Connection %q already exists.", name), + "Use --force to replace the existing connection.", + ) + } + } + + body, err := buildConnectionBody(kind, target, authType, key, customKeys, metadata) + if err != nil { + return err + } + + _, err = connCtx.armClient.Create( + ctx, connCtx.rg, connCtx.account, connCtx.project, name, + &armcognitiveservices.ProjectConnectionsClientCreateOptions{ + Connection: body, + }, + ) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpCreateConnection) + } + + fmt.Printf("Connection %q created in project %q.\n", name, connCtx.project) + return nil + }, + } + + cmd.Flags().StringVar(&kind, "kind", "", "Connection kind (e.g., RemoteTool, CognitiveSearch)") + cmd.Flags().StringVar(&target, "target", "", "Target URL or ARM resource ID") + cmd.Flags().StringVar(&authType, "auth-type", "None", "Auth type: ApiKey, CustomKeys, None") + cmd.Flags().StringVar(&key, "key", "", "API key (for ApiKey auth)") + cmd.Flags().StringArrayVar(&customKeys, "custom-key", nil, "Custom key=value (repeatable)") + cmd.Flags().StringArrayVar(&metadata, "metadata", nil, "Metadata key=value (repeatable)") + cmd.Flags().BoolVar(&force, "force", false, "Replace existing connection (upsert)") + return cmd +} + +// --- DELETE --- + +func newConnectionDeleteCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + var force bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a connection.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + ctx := azdext.WithAccessToken(cmd.Context()) + + connCtx, err := resolveConnectionContext(ctx, cmd) + if err != nil { + return err + } + + resp, err := connCtx.armClient.Get( + ctx, connCtx.rg, connCtx.account, connCtx.project, name, nil, + ) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpGetConnection) + } + + props := resp.Properties.GetConnectionPropertiesV2() + fmt.Printf("Connection: %s (%s)\n", name, categoryStr(props.Category)) + fmt.Printf("Target: %s\n", deref(props.Target)) + + if !force { + if extCtx.NoPrompt { + return exterrors.Validation( + exterrors.CodeMissingForceFlag, + fmt.Sprintf("Deleting %q requires confirmation.", name), + "Use --force to skip confirmation in non-interactive mode.", + ) + } + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + confirmResp, err := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ + Options: &azdext.ConfirmOptions{ + Message: "Are you sure you want to delete this connection?", + DefaultValue: new(false), + }, + }) + if err != nil { + return err + } + if !*confirmResp.Value { + fmt.Println("Cancelled.") + return nil + } + } + + _, err = connCtx.armClient.Delete( + ctx, connCtx.rg, connCtx.account, connCtx.project, name, nil, + ) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpDeleteConnection) + } + + fmt.Printf("Connection %q deleted.\n", name) + return nil + }, + } + + cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt") + return cmd +} + +// --- Helpers --- + +type connectionListItem struct { + Name string `json:"name"` + Kind string `json:"kind"` + AuthType string `json:"authType"` + Target string `json:"target"` +} + +type connectionDetailResult struct { + Name string `json:"name"` + Kind string `json:"kind"` + AuthType string `json:"authType"` + Target string `json:"target"` + Metadata map[string]*string `json:"metadata,omitempty"` + Credentials *connections.ConnectionCredentials `json:"credentials,omitempty"` + CredentialRefs map[string]string `json:"credentialReferences,omitempty"` +} + +func buildCredentialReferences( + connName string, creds *connections.ConnectionCredentials, +) map[string]string { + if creds == nil { + return nil + } + refs := map[string]string{} + if creds.Key != "" { + refs["key"] = fmt.Sprintf("${{connections.%s.credentials.key}}", connName) + } + for k := range creds.CustomKeys { + refs[k] = fmt.Sprintf("${{connections.%s.credentials.%s}}", connName, k) + } + if len(refs) == 0 { + return nil + } + return refs +} + +func buildConnectionBody( + kind, target, authType, key string, + customKeys, metadata []string, +) (*armcognitiveservices.ConnectionPropertiesV2BasicResource, error) { + metaMap := parseKVPtrMap(metadata) + cat := armcognitiveservices.ConnectionCategory(kind) + at := armcognitiveservices.ConnectionAuthType(authType) + + switch authType { + case "ApiKey": + return &armcognitiveservices.ConnectionPropertiesV2BasicResource{ + Properties: &armcognitiveservices.APIKeyAuthConnectionProperties{ + AuthType: &at, + Category: &cat, + Target: &target, + Credentials: &armcognitiveservices.ConnectionAPIKey{Key: &key}, + Metadata: metaMap, + }, + }, nil + + case "CustomKeys": + keysMap := parseKVPtrMap(customKeys) + return &armcognitiveservices.ConnectionPropertiesV2BasicResource{ + Properties: &armcognitiveservices.CustomKeysConnectionProperties{ + AuthType: &at, + Category: &cat, + Target: &target, + Credentials: &armcognitiveservices.CustomKeys{Keys: keysMap}, + Metadata: metaMap, + }, + }, nil + + case "None", "": + noneAuth := armcognitiveservices.ConnectionAuthTypeNone + return &armcognitiveservices.ConnectionPropertiesV2BasicResource{ + Properties: &armcognitiveservices.NoneAuthTypeConnectionProperties{ + AuthType: &noneAuth, + Category: &cat, + Target: &target, + Metadata: metaMap, + }, + }, nil + + default: + return nil, exterrors.Validation( + exterrors.CodeInvalidAuthType, + fmt.Sprintf("Unsupported auth type %q.", authType), + "Supported: ApiKey, CustomKeys, None", + ) + } +} + +func printList(items []connectionListItem, format string) error { + if format == "json" { + data, err := json.MarshalIndent(items, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil + } + w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(w, "Name\tKind\tAuth Type\tTarget") + fmt.Fprintln(w, "----\t----\t---------\t------") + for _, item := range items { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", item.Name, item.Kind, item.AuthType, item.Target) + } + return w.Flush() +} + +func printDetail(result connectionDetailResult, format string) error { + if format == "json" { + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil + } + fmt.Printf("Name: %s\n", result.Name) + fmt.Printf("Kind: %s\n", result.Kind) + fmt.Printf("Auth Type: %s\n", result.AuthType) + fmt.Printf("Target: %s\n", result.Target) + if result.Credentials != nil { + fmt.Println("\nCredentials:") + if result.Credentials.Key != "" { + fmt.Printf(" key: %s\n", result.Credentials.Key) + } + for k, v := range result.Credentials.CustomKeys { + fmt.Printf(" %s: %s\n", k, v) + } + } + if len(result.CredentialRefs) > 0 { + fmt.Println("\nCredential References (for agent.yaml):") + for k, v := range result.CredentialRefs { + fmt.Printf(" %s: %s\n", k, v) + } + } + return nil +} + +func parseKVPtrMap(pairs []string) map[string]*string { + if len(pairs) == 0 { + return nil + } + result := make(map[string]*string, len(pairs)) + for _, pair := range pairs { + for i := range len(pair) { + if pair[i] == '=' { + v := pair[i+1:] + result[pair[:i]] = &v + break + } + } + } + return result +} + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} + +func categoryStr(c *armcognitiveservices.ConnectionCategory) string { + if c == nil { + return "" + } + return string(*c) +} + +func authTypeStr(a *armcognitiveservices.ConnectionAuthType) string { + if a == nil { + return "" + } + return string(*a) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/connections/cmd/context.go b/cli/azd/extensions/azure.ai.agents/internal/connections/cmd/context.go new file mode 100644 index 00000000000..d5be7fe70ec --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/connections/cmd/context.go @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + + "azureaiagent/internal/connections/exterrors" + "azureaiagent/internal/connections/pkg/connections" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices" + "github.com/spf13/cobra" +) + +// dataClient is a type alias for the data-plane client (used in endpoint.go). +type dataClient = connections.DataClient + +// connectionContext holds the resolved clients and project info for connection operations. +type connectionContext struct { + armClient *armcognitiveservices.ProjectConnectionsClient + dpClient *connections.DataClient + rg string + account string + project string +} + +// resolveConnectionContext resolves the project endpoint, discovers ARM context, +// and creates both clients needed for connection operations. +func resolveConnectionContext( + ctx context.Context, + cmd *cobra.Command, +) (*connectionContext, error) { + endpoint, err := resolveProjectEndpoint(ctx, cmd) + if err != nil { + return nil, err + } + + account, project, err := parseEndpointComponents(endpoint) + if err != nil { + return nil, err + } + + cred, err := newCredential() + if err != nil { + return nil, err + } + + // Data-plane client (for list, get-with-credentials, and ARM discovery) + dpClient := connections.NewDataClient(endpoint, cred) + + // Discover subscription + resource group from data-plane response + armCtx, err := discoverARMContext(ctx, dpClient) + if err != nil { + return nil, err + } + + // ARM SDK client for CRUD + armClient, err := armcognitiveservices.NewProjectConnectionsClient( + armCtx.SubscriptionID, cred, nil, + ) + if err != nil { + return nil, fmt.Errorf("failed to create ARM connections client: %w", err) + } + + return &connectionContext{ + armClient: armClient, + dpClient: dpClient, + rg: armCtx.ResourceGroup, + account: account, + project: project, + }, nil +} + +// newCredential creates an Azure credential for API calls. +func newCredential() (azcore.TokenCredential, error) { + cred, err := azidentity.NewAzureDeveloperCLICredential( + &azidentity.AzureDeveloperCLICredentialOptions{}, + ) + if err != nil { + return nil, exterrors.Auth( + exterrors.CodeCredentialCreationFailed, + fmt.Sprintf("Failed to create Azure credential: %s", err), + "Run 'azd auth login' to authenticate.", + ) + } + + return cred, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/connections/cmd/endpoint.go b/cli/azd/extensions/azure.ai.agents/internal/connections/cmd/endpoint.go new file mode 100644 index 00000000000..fbe3aad0267 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/connections/cmd/endpoint.go @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "net/url" + "os" + "strings" + + "azureaiagent/internal/connections/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// resolveProjectEndpoint implements the 5-level resolution cascade from the spec. +// +// 1. -p / --project-endpoint flag +// 2. Active azd env → AZURE_AI_PROJECT_ENDPOINT +// 3. Global config → extensions.ai-agents.context.endpoint +// 4. FOUNDRY_PROJECT_ENDPOINT environment variable +// 5. Structured error +func resolveProjectEndpoint(ctx context.Context, cmd *cobra.Command) (string, error) { + // 1. Flag + if ep, _ := cmd.Flags().GetString("project-endpoint"); ep != "" { + return ep, nil + } + + // 2 & 3. Try azd host (env value + global config) — best-effort + azdClient, err := azdext.NewAzdClient() + if err == nil { + defer azdClient.Close() + + // 2. Active azd env → AZURE_AI_PROJECT_ENDPOINT + if envResp, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}); err == nil { + if valResp, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: envResp.Environment.Name, + Key: "AZURE_AI_PROJECT_ENDPOINT", + }); err == nil && valResp.Value != "" { + return valResp.Value, nil + } + } + + // 3. Global config → extensions.ai-agents.context.endpoint + ch, cfgErr := azdext.NewConfigHelper(azdClient) + if cfgErr == nil { + var endpoint string + if found, err := ch.GetUserJSON(ctx, "extensions.ai-agents.context.endpoint", &endpoint); err == nil && found && endpoint != "" { + return endpoint, nil + } + } + } + + // 4. FOUNDRY_PROJECT_ENDPOINT environment variable + if ep := os.Getenv("FOUNDRY_PROJECT_ENDPOINT"); ep != "" { + return ep, nil + } + + // 5. Structured error + return "", exterrors.Dependency( + exterrors.CodeMissingProjectEndpoint, + "No Foundry project endpoint resolved.", + "Run 'azd ai project set' to set one, or pass '--project-endpoint'.", + ) +} + +// parseEndpointComponents extracts account and project names from the endpoint URL. +// Expected format: https://{account}.services.ai.azure.com/api/projects/{project} +func parseEndpointComponents(endpoint string) (account, project string, err error) { + u, err := url.Parse(endpoint) + if err != nil { + return "", "", fmt.Errorf("invalid endpoint URL: %w", err) + } + + account, _, _ = strings.Cut(u.Hostname(), ".") + + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + for i, p := range parts { + if p == "projects" && i+1 < len(parts) { + project = parts[i+1] + break + } + } + + if account == "" || project == "" { + return "", "", fmt.Errorf("could not parse account/project from endpoint %q", endpoint) + } + + return account, project, nil +} + +// armContext holds the ARM components needed for SDK calls. +type armContext struct { + SubscriptionID string + ResourceGroup string + AccountName string + ProjectName string +} + +// discoverARMContext makes a data-plane list call to discover subscription and +// resource group from the ARM resource IDs embedded in connection responses. +func discoverARMContext( + ctx context.Context, + dpClient *dataClient, +) (*armContext, error) { + conns, err := dpClient.ListConnections(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list connections for ARM discovery: %w", err) + } + + if len(conns) == 0 { + return nil, fmt.Errorf("no connections found in project; cannot discover ARM context") + } + + return parseARMResourceID(conns[0].ID) +} + +// parseARMResourceID extracts ARM components from a full resource ID string. +func parseARMResourceID(resourceID string) (*armContext, error) { + parts := strings.Split(resourceID, "/") + result := &armContext{} + + for i, part := range parts { + switch { + case part == "subscriptions" && i+1 < len(parts): + result.SubscriptionID = parts[i+1] + case part == "resourceGroups" && i+1 < len(parts): + result.ResourceGroup = parts[i+1] + case part == "accounts" && i+1 < len(parts): + result.AccountName = parts[i+1] + case part == "projects" && i+1 < len(parts): + result.ProjectName = parts[i+1] + } + } + + if result.SubscriptionID == "" || result.ResourceGroup == "" || + result.AccountName == "" || result.ProjectName == "" { + return nil, fmt.Errorf("could not extract ARM context from resource ID: %s", resourceID) + } + + return result, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/connections/cmd/root.go b/cli/azd/extensions/azure.ai.agents/internal/connections/cmd/root.go new file mode 100644 index 00000000000..806212b7ddd --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/connections/cmd/root.go @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// NewConnectionRootCommand creates the "connection" subcommand group under "azd ai". +func NewConnectionRootCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "connection [options]", + Short: "Manage Foundry project connections. (Preview)", + Long: `Manage connections (connected resources) in a Foundry project. + +Connections link a Foundry project to external services such as MCP servers, +AI Search, Bing, ACR, App Insights, AI Services, and custom APIs. + +Each connection has a kind, target URL, auth type, optional credentials, +and optional metadata.`, + } + + // Register -p / --project-endpoint as a persistent flag so all subcommands inherit it + cmd.PersistentFlags().StringP("project-endpoint", "p", "", + "Foundry project endpoint URL (overrides env var and config)") + + cmd.AddCommand(newConnectionListCommand(extCtx)) + cmd.AddCommand(newConnectionShowCommand(extCtx)) + cmd.AddCommand(newConnectionCreateCommand(extCtx)) + cmd.AddCommand(newConnectionDeleteCommand(extCtx)) + + return cmd +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/connections/exterrors/codes.go b/cli/azd/extensions/azure.ai.agents/internal/connections/exterrors/codes.go new file mode 100644 index 00000000000..ac44b3c0578 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/connections/exterrors/codes.go @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package exterrors + +// Error codes for connection validation. +const ( + CodeConflictingArguments = "conflicting_arguments" + CodeMissingConnectionField = "missing_connection_field" + CodeInvalidConnectionKind = "invalid_connection_kind" + CodeInvalidAuthType = "invalid_auth_type" + CodeInvalidFromFile = "invalid_from_file" + CodeMissingForceFlag = "missing_force_flag" + CodeConnectionAlreadyExists = "connection_already_exists" +) + +// Error codes for endpoint resolution. +const ( + CodeMissingProjectEndpoint = "missing_project_endpoint" +) + +// Error codes for auth. +const ( + //nolint:gosec // error code identifier, not a credential + CodeCredentialCreationFailed = "credential_creation_failed" +) + +// Operation names for ServiceFromAzure errors. +const ( + OpCreateConnection = "create_connection" + OpUpdateConnection = "update_connection" + OpDeleteConnection = "delete_connection" + OpGetConnection = "get_connection" + OpGetConnectionCredentials = "get_connection_credentials" + OpListConnections = "list_connections" +) diff --git a/cli/azd/extensions/azure.ai.agents/internal/connections/exterrors/errors.go b/cli/azd/extensions/azure.ai.agents/internal/connections/exterrors/errors.go new file mode 100644 index 00000000000..ff56943a3e8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/connections/exterrors/errors.go @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package exterrors + +import ( + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// Validation returns a validation error for user-input or flag errors. +func Validation(code, message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryValidation, + Suggestion: suggestion, + } +} + +// Dependency returns a dependency error for missing resources or services. +func Dependency(code, message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryDependency, + Suggestion: suggestion, + } +} + +// Auth returns an auth error for authentication or authorization failures. +func Auth(code, message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryAuth, + Suggestion: suggestion, + } +} + +// ServiceFromAzure converts an Azure SDK error into a structured service error. +func ServiceFromAzure(err error, operation string) error { + if respErr, ok := err.(*azcore.ResponseError); ok { + return &azdext.ServiceError{ + Message: respErr.Error(), + ErrorCode: respErr.ErrorCode, + StatusCode: respErr.StatusCode, + ServiceName: operation, + } + } + return err +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/connections/pkg/connections/data_client.go b/cli/azd/extensions/azure.ai.agents/internal/connections/pkg/connections/data_client.go new file mode 100644 index 00000000000..a0f797176ca --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/connections/pkg/connections/data_client.go @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package connections + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/azure/azure-dev/cli/azd/pkg/azsdk" +) + +const dataPlaneAPIVersion = "2025-11-15-preview" + +// DataClient provides read operations via the Foundry data plane. +// Used for listing connections (including ARM ID discovery) and fetching credentials. +type DataClient struct { + endpoint string + pipeline runtime.Pipeline +} + +// NewDataClient creates a new data-plane client for connection operations. +func NewDataClient(endpoint string, cred azcore.TokenCredential) *DataClient { + clientOptions := &policy.ClientOptions{ + PerCallPolicies: []policy.Policy{ + runtime.NewBearerTokenPolicy( + cred, + []string{"https://ai.azure.com/.default"}, + nil, + ), + azsdk.NewMsCorrelationPolicy(), + azsdk.NewUserAgentPolicy("azd-ext-azure-ai-connection/0.1.0"), + }, + } + + pipeline := runtime.NewPipeline( + "azure-ai-connection-data", + "v1.0.0", + runtime.PipelineOptions{}, + clientOptions, + ) + + return &DataClient{endpoint: endpoint, pipeline: pipeline} +} + +// ListConnections retrieves all connections from the project via data-plane GET. +func (c *DataClient) ListConnections(ctx context.Context) ([]Connection, error) { + targetURL := fmt.Sprintf("%s/connections?api-version=%s", c.endpoint, dataPlaneAPIVersion) + + req, err := runtime.NewRequest(ctx, http.MethodGet, targetURL) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var paged PagedConnection + if err := json.Unmarshal(body, &paged); err != nil { + return nil, fmt.Errorf("failed to unmarshal connections: %w", err) + } + + return paged.Value, nil +} + +// GetConnectionWithCredentials retrieves a specific connection with its credentials +// via the data-plane POST endpoint. +func (c *DataClient) GetConnectionWithCredentials( + ctx context.Context, + name string, +) (*Connection, error) { + targetURL := fmt.Sprintf( + "%s/connections/%s/getConnectionWithCredentials?api-version=%s", + c.endpoint, url.PathEscape(name), dataPlaneAPIVersion, + ) + + req, err := runtime.NewRequest(ctx, http.MethodPost, targetURL) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var conn Connection + if err := json.Unmarshal(body, &conn); err != nil { + return nil, fmt.Errorf("failed to unmarshal connection: %w", err) + } + + return &conn, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/connections/pkg/connections/models.go b/cli/azd/extensions/azure.ai.agents/internal/connections/pkg/connections/models.go new file mode 100644 index 00000000000..035d0ba99e6 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/connections/pkg/connections/models.go @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package connections + +// Connection represents a Foundry project connection from the data-plane API. +type Connection struct { + Name string `json:"name"` + ID string `json:"id"` + Type string `json:"type"` + Target string `json:"target"` + IsDefault bool `json:"isDefault"` + Credentials *ConnectionCredentials `json:"credentials,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// ConnectionCredentials holds credential values returned by the data-plane +// getConnectionWithCredentials endpoint. The shape varies by auth type: +// - ApiKey: Key is populated +// - CustomKeys: CustomKeys map is populated +// - AAD/None: Only Type is populated, no secret values +type ConnectionCredentials struct { + Type string `json:"type"` + Key string `json:"key,omitempty"` + CustomKeys map[string]string `json:"keys,omitempty"` +} + +// PagedConnection represents a paged collection of connections. +type PagedConnection struct { + Value []Connection `json:"value"` + NextLink *string `json:"nextLink,omitempty"` +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/root.go b/cli/azd/extensions/azure.ai.agents/internal/root.go new file mode 100644 index 00000000000..85236a7fa8d --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/root.go @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package internal + +import ( + "fmt" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/fatih/color" + "github.com/spf13/cobra" + + agentcmd "azureaiagent/internal/cmd" + connectioncmd "azureaiagent/internal/connections/cmd" +) + +// NewRootCommand creates the top-level "ai" root command for the extension. +// It wires agent and connection as sibling subcommand groups under "azd ai". +func NewRootCommand() *cobra.Command { + rootCmd, extCtx := azdext.NewExtensionRootCommand(azdext.ExtensionCommandOptions{ + Name: "ai", + Use: "ai [options]", + Short: fmt.Sprintf("Manage agents and connections in Microsoft Foundry. %s", color.YellowString("(Preview)")), + }) + rootCmd.SilenceUsage = true + rootCmd.SilenceErrors = true + rootCmd.CompletionOptions.DisableDefaultCmd = true + + // Configure debug logging once on the root command so every subcommand + // inherits it (cobra.EnableTraverseRunHooks, set by the SDK, ensures this + // runs alongside any subcommand pre-runs). The cleanup func is intentionally + // discarded: log writes are unbuffered and the OS closes the file on exit. + sdkPreRun := rootCmd.PersistentPreRunE + rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + if sdkPreRun != nil { + if err := sdkPreRun(cmd, args); err != nil { + return err + } + } + agentcmd.SetupDebugLogging(cmd.Flags()) + return nil + } + + rootCmd.SetHelpCommand(&cobra.Command{Hidden: true}) + + // Sibling command groups — each self-contained, easy to extract later + rootCmd.AddCommand(agentcmd.NewAgentRootCommand(extCtx)) + rootCmd.AddCommand(connectioncmd.NewConnectionRootCommand(extCtx)) + + return rootCmd +} diff --git a/cli/azd/extensions/azure.ai.agents/main.go b/cli/azd/extensions/azure.ai.agents/main.go index 471e3cbd0dd..ecb9bfb72e7 100644 --- a/cli/azd/extensions/azure.ai.agents/main.go +++ b/cli/azd/extensions/azure.ai.agents/main.go @@ -4,11 +4,11 @@ package main import ( - "azureaiagent/internal/cmd" + "azureaiagent/internal" "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) func main() { - azdext.Run(cmd.NewRootCommand()) + azdext.Run(internal.NewRootCommand()) } From 3f7cb4cc74f8cd7990c4bc073949ffdd967d0ce2 Mon Sep 17 00:00:00 2001 From: Naman Tyagi Date: Wed, 13 May 2026 22:49:37 +0530 Subject: [PATCH 2/2] feat: resolve connection credential references in azd ai agent run During local agent startup, scan the agent manifest environment_variables for connection reference patterns, fetch credentials from the Foundry data plane via POST getConnectionWithCredentials, and inject resolved values into the spawned agent process environment. - Reads agent.manifest.yaml / agent.yaml from the project directory - Matches pattern and resolves via data-plane API - Caches per connection name to avoid redundant API calls - Logs key names only, never credential values - Fails gracefully with a warning if resolution fails Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/connection_credentials.go | 170 ++++++++++++++++++ .../azure.ai.agents/internal/cmd/run.go | 11 ++ 2 files changed, 181 insertions(+) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/connection_credentials.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/connection_credentials.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/connection_credentials.go new file mode 100644 index 00000000000..28b9fb842bc --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/connection_credentials.go @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "regexp" + "strings" + + "azureaiagent/internal/connections/pkg/connections" + "azureaiagent/internal/pkg/agents/agent_yaml" + + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" +) + +// connectionRefPattern matches ${{connections..credentials.}} references +// in agent manifest environment variable values. +var connectionRefPattern = regexp.MustCompile(`\$\{\{connections\.([^.]+)\.credentials\.([^}]+)\}\}`) + +// resolveConnectionCredentials reads the agent manifest from projectDir, +// scans environment_variables for ${{connections..credentials.}} patterns, +// fetches credential values from the Foundry data plane, and returns them as +// KEY=VALUE strings ready to inject into the agent process environment. +// +// This is additive to existing env var handling in run.go: +// - ${VAR} references are already resolved via loadAzdEnvironment +// - ${{connections...}} references are resolved here via data-plane API +// - Literal values pass through unchanged +// +// Returns nil (no error) if no manifest is found, no env vars are declared, +// or no connection references are present — the agent still starts normally. +func resolveConnectionCredentials( + ctx context.Context, + projectDir string, + endpoint string, +) ([]string, error) { + if endpoint == "" { + return nil, nil + } + + // Find and parse the agent manifest + manifestPath := findManifestInDir(projectDir) + if manifestPath == "" { + return nil, nil + } + + manifestBytes, err := os.ReadFile(manifestPath) + if err != nil { + log.Printf("run: could not read manifest %s: %v", manifestPath, err) + return nil, nil + } + + manifest, err := agent_yaml.LoadAndValidateAgentManifest(manifestBytes) + if err != nil { + log.Printf("run: could not parse manifest %s: %v", manifestPath, err) + return nil, nil + } + + // Extract environment variables from the manifest + containerAgent, ok := manifest.Template.(agent_yaml.ContainerAgent) + if !ok || containerAgent.EnvironmentVariables == nil { + return nil, nil + } + + // Scan for connection references + type connRef struct { + envName string // the env var name (e.g., TAVILY_API_KEY) + connName string // connection name (e.g., my-test-conn) + credKey string // credential key (e.g., x-api-key) + } + + var refs []connRef + for _, ev := range *containerAgent.EnvironmentVariables { + matches := connectionRefPattern.FindStringSubmatch(ev.Value) + if matches != nil { + refs = append(refs, connRef{ + envName: ev.Name, + connName: matches[1], + credKey: matches[2], + }) + } + } + + if len(refs) == 0 { + return nil, nil + } + + // Create data-plane credential and client + cred, err := azidentity.NewAzureDeveloperCLICredential( + &azidentity.AzureDeveloperCLICredentialOptions{}, + ) + if err != nil { + return nil, fmt.Errorf("failed to create credential for connection resolution: %w", err) + } + + dpClient := connections.NewDataClient(endpoint, cred) + + // Resolve each reference, caching per connection name + connCache := map[string]*connections.Connection{} + var result []string + + for _, ref := range refs { + conn, cached := connCache[ref.connName] + if !cached { + conn, err = dpClient.GetConnectionWithCredentials(ctx, ref.connName) + if err != nil { + return nil, fmt.Errorf( + "failed to resolve credential for %s (connection %q): %w", + ref.envName, ref.connName, err, + ) + } + connCache[ref.connName] = conn + } + + // Look up the credential key + var credValue string + if ref.credKey == "key" && conn.Credentials != nil && conn.Credentials.Key != "" { + credValue = conn.Credentials.Key + } else if conn.Credentials != nil { + if v, ok := conn.Credentials.CustomKeys[ref.credKey]; ok { + credValue = v + } + } + + if credValue == "" { + return nil, fmt.Errorf( + "credential key %q not found on connection %q (for env var %s)", + ref.credKey, ref.connName, ref.envName, + ) + } + + result = append(result, fmt.Sprintf("%s=%s", ref.envName, credValue)) + // Log the key name only — NEVER log the value + log.Printf("run: resolved connection credential: %s (connection: %s, key: %s)", + ref.envName, ref.connName, ref.credKey) + } + + if len(result) > 0 { + fmt.Fprintf(os.Stderr, " %d connection credential(s) resolved\n", len(result)) + } + + return result, nil +} + +// findManifestInDir looks for an agent manifest file in the given directory. +// Checks: agent.manifest.yaml, agent.yaml, agent.manifest.yml, agent.yml +func findManifestInDir(dir string) string { + candidates := []string{ + "agent.manifest.yaml", + "agent.yaml", + "agent.manifest.yml", + "agent.yml", + } + for _, name := range candidates { + path := filepath.Join(dir, name) + if _, err := os.Stat(path); err == nil { + // Quick check: must contain "template" key to be a manifest + data, err := os.ReadFile(path) + if err == nil && strings.Contains(string(data), "template:") { + return path + } + } + } + return "" +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go index ee2ef0b530c..5941b8a98e8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go @@ -158,6 +158,17 @@ func runRun(ctx context.Context, flags *runFlags, noPrompt bool) error { env = append(env, fmt.Sprintf("%s=%s", k, v)) } env = appendFoundryEnvVars(env, azdEnvVars, runCtx.ServiceName) + + // Resolve ${{connections..credentials.}} references from the + // agent manifest's environment_variables section. These are fetched from + // the Foundry data plane at runtime and injected into the agent process. + if endpoint := azdEnvVars["AZURE_AI_PROJECT_ENDPOINT"]; endpoint != "" { + if connEnv, err := resolveConnectionCredentials(ctx, projectDir, endpoint); err != nil { + fmt.Fprintf(os.Stderr, "Warning: connection credential resolution failed: %s\n", err) + } else { + env = append(env, connEnv...) + } + } } url := fmt.Sprintf("http://localhost:%d", flags.port)