diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 92efe7d3f48..489cfe84af0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -6,6 +6,8 @@ package cmd import ( "bytes" "context" + "crypto/rand" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -1472,12 +1474,8 @@ func ensureProject( fmt.Println("Let's get your project initialized.") // Environment creation is handled separately in ensureEnvironment - initArgs := []string{ - "init", "-t", "Azure-Samples/azd-ai-starter-basic", targetDir, - } - if flags.env != "" { - initArgs = append(initArgs, "--environment", flags.env) - } else { + envName := flags.env + if envName == "" { // Derive environment name from target folder envBase := targetDir if targetDir == "." { @@ -1490,8 +1488,12 @@ func ensureProject( if len(base) > 59 { base = strings.TrimRight(base[:59], "-") } - envName := base + "-dev" - initArgs = append(initArgs, "--environment", envName) + envName = base + "-dev" + } + + initArgs := []string{ + "init", "-t", "Azure-Samples/azd-ai-starter-basic", targetDir, + "--environment", envName, } // We don't have a project yet @@ -1518,6 +1520,11 @@ func ensureProject( ) } + // Best-effort: generate a salt so uniqueString()-based resource names + // differ across project recreations. If anything fails the Bicep + // templates fall back to the original deterministic hash. + ensureResourceTokenSalt(ctx, azdClient, envName) + // Sync the extension process into the new project directory so that // subsequent local file operations see the scaffolded project. if targetDir != "." { @@ -2673,6 +2680,47 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa return nil } +//nolint:gosec // env var key name, not a credential +const resourceTokenSaltKey = "AZD_RESOURCE_TOKEN_SALT" + +// ensureResourceTokenSalt checks whether the current azd environment already +// has a resource token salt. If not, it generates and stores one. +// Failures are silently ignored so the Bicep templates fall back to the +// original deterministic uniqueString() hash. +func ensureResourceTokenSalt(ctx context.Context, azdClient *azdext.AzdClient, envName string) { + // Already have a salt from a previous init — keep it so resource names stay stable. + existing, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: envName, + Key: resourceTokenSaltKey, + }) + if err == nil && existing.Value != "" { + return + } + + // Generate a random salt; if entropy fails, fall back to deterministic naming. + salt, err := generateResourceTokenSalt() + if err != nil { + return + } + + // Persist the salt into the azd environment; if storage fails, provision + // will still work with the original deterministic resource names. + _, _ = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: envName, + Key: resourceTokenSaltKey, + Value: salt, + }) +} + +// generateResourceTokenSalt returns a random 8-character hex string. +func generateResourceTokenSalt() (string, error) { + b := make([]byte, 4) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + // resolveCollisions checks whether the auto-computed target directory or // service name already exist. When a collision is detected, the user is // prompted for a new name (or a numeric suffix is appended in no-prompt diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go index 1b1530821d9..8ce93ddb7a4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go @@ -5,6 +5,7 @@ package cmd import ( "context" + "encoding/hex" "errors" "io/fs" "net/http" @@ -20,6 +21,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/spf13/cobra" + "github.com/stretchr/testify/require" "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -2939,3 +2941,25 @@ func TestAbsolutizeRelativeManifestPaths_SrcEscapeRegression(t *testing.T) { t.Errorf("src must remain relative %q to stay inside project, got %q", "src", flags.src) } } + +func TestGenerateResourceTokenSalt(t *testing.T) { + salt, err := generateResourceTokenSalt() + require.NoError(t, err) + + // Should be 8-character hex string (4 random bytes = 8 hex chars) + require.Len(t, salt, 8) + + // Should be valid hex + _, err = hex.DecodeString(salt) + require.NoError(t, err) +} + +func TestGenerateResourceTokenSalt_Unique(t *testing.T) { + seen := make(map[string]bool) + for range 100 { + salt, err := generateResourceTokenSalt() + require.NoError(t, err) + require.False(t, seen[salt], "duplicate salt generated: %s", salt) + seen[salt] = true + } +}