Skip to content

Commit 1a58de3

Browse files
authored
feat(agents): support Foundry ${{...}} server-side expressions in env expansion (#8602)
* fix(agents): preserve Foundry ${{...}} through ${VAR} expansion Add a shared ExpandEnv helper in the azure.ai.agents extension that expands azd ${VAR} references while leaving Foundry server-side ${{...}} expressions untouched, and route the existing env-expansion call sites through it. drone/envsubst cannot parse ${{...}} and returns the whole string unexpanded, so any ${VAR} appearing alongside a ${{...}} span was silently dropped. ExpandEnv splits on ${{...}} spans, expands only the gaps, and reattaches the spans verbatim. This is the single shared expander Foundry fields will use as part of unifying Foundry config in azure.yaml (design spec section 2.5). Refactors resolveEnvironmentVariables (service_target_agent.go), resolveEnvValue (listen.go), and resolveAgentDefinitionEnvVars (run.go) to use it. * refactor(agents): address ExpandEnv review feedback - Document that a ${VAR} inside a ${{...}} span is preserved verbatim, and add a test asserting it. - Drop the now-redundant error fallback in resolveAgentDefinitionEnvVars; ExpandEnv already returns the original value on error. * fix(agents): mask ${{...}} spans so ${VAR:-default} still expands Switch ExpandEnv from splitting on ${{...}} spans to masking each span with a sentinel placeholder, running a single envsubst pass, then restoring the spans. The split approach failed when a ${{...}} expression was the default of a ${VAR:-default} construct (e.g. ${MISSING:-${{event.body}}}), leaving an unterminated "${MISSING:-" fragment that envsubst could not parse, so the whole value was returned unexpanded. The sentinel contains no $, { or }, so envsubst leaves it untouched even after a literal $. Adds tests for the default-value cases and a leading-$ case. Addresses review comment on internal/project/templating.go.
1 parent 3672e81 commit 1a58de3

5 files changed

Lines changed: 185 additions & 10 deletions

File tree

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import (
2424
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
2525
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
2626
"github.com/braydonk/yaml"
27-
"github.com/drone/envsubst"
2827
"google.golang.org/protobuf/types/known/structpb"
2928
)
3029

@@ -930,9 +929,10 @@ func resolveTemplateRef(s string) string {
930929
return s
931930
}
932931

933-
// resolveEnvValue resolves ${VAR} references in a string using envsubst.
932+
// resolveEnvValue resolves ${VAR} references in a string against the azd environment while
933+
// leaving Foundry server-side ${{...}} expressions untouched. See [project.ExpandEnv].
934934
func resolveEnvValue(value string, azdEnv map[string]string) string {
935-
resolved, err := envsubst.Eval(value, func(varName string) string {
935+
resolved, err := project.ExpandEnv(value, func(varName string) string {
936936
return azdEnv[varName]
937937
})
938938
if err != nil {

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

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@ import (
2525

2626
"azureaiagent/internal/cmd/nextstep"
2727
"azureaiagent/internal/pkg/agents/agent_yaml"
28+
"azureaiagent/internal/project"
2829

2930
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
30-
"github.com/drone/envsubst"
3131
"github.com/spf13/cobra"
3232
"go.yaml.in/yaml/v3"
3333
"google.golang.org/grpc/codes"
@@ -514,10 +514,8 @@ func resolveAgentDefinitionEnvVars(
514514
if _, isConn := connRefEnvNames[ev.Name]; isConn {
515515
continue
516516
}
517-
resolved, evalErr := envsubst.Eval(ev.Value, lookup)
518-
if evalErr != nil {
519-
resolved = ev.Value
520-
}
517+
// ExpandEnv returns the original value on error, so a failed expansion is a no-op.
518+
resolved, _ := project.ExpandEnv(ev.Value, lookup)
521519
result = append(result, fmt.Sprintf("%s=%s", ev.Name, resolved))
522520
}
523521

cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ import (
4040
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
4141
"github.com/azure/azure-dev/cli/azd/pkg/output"
4242
"github.com/braydonk/yaml"
43-
"github.com/drone/envsubst"
4443
"github.com/fatih/color"
4544
"github.com/google/uuid"
4645
"google.golang.org/protobuf/types/known/structpb"
@@ -2272,7 +2271,7 @@ func (p *AgentServiceTargetProvider) registerAgentEnvironmentVariables(
22722271
// resolveEnvironmentVariables resolves ${ENV_VAR} style references in value using azd environment variables.
22732272
// Supports default values (e.g., "${VAR:-default}") and multiple expressions (e.g., "${VAR1}-${VAR2}").
22742273
func (p *AgentServiceTargetProvider) resolveEnvironmentVariables(value string, azdEnv map[string]string) string {
2275-
resolved, err := envsubst.Eval(value, func(varName string) string {
2274+
resolved, err := ExpandEnv(value, func(varName string) string {
22762275
return azdEnv[varName]
22772276
})
22782277
if err != nil {
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package project
5+
6+
import (
7+
"fmt"
8+
"regexp"
9+
"strings"
10+
11+
"github.com/drone/envsubst"
12+
)
13+
14+
// foundryTemplatePattern matches Foundry server-side ${{...}} expressions. These are resolved
15+
// by Foundry at runtime (for example ${{connections.x.credentials.key}} or ${{event.body}})
16+
// and must survive azd's client-side ${VAR} expansion untouched. The (?s) flag lets the span
17+
// cross newlines; the lazy quantifier stops at the first closing }}.
18+
var foundryTemplatePattern = regexp.MustCompile(`(?s)\$\{\{.*?\}\}`)
19+
20+
// foundrySentinelBase is the prefix for placeholders that temporarily replace ${{...}} spans
21+
// while ${VAR} expansion runs. It contains no '$', '{', or '}', so drone/envsubst (which only
22+
// expands the braced ${...} form) copies it through untouched, even when a literal '$' precedes
23+
// it.
24+
const foundrySentinelBase = "azdFoundryTemplateSpan_"
25+
26+
// ExpandEnv expands ${VAR} references in value against the azd environment (via mapping) while
27+
// preserving Foundry server-side ${{...}} expressions verbatim. It supports default values
28+
// (${VAR:-default}) and multiple expressions, matching drone/envsubst semantics for the ${VAR}
29+
// portion. On expansion error the original value is returned unchanged alongside the error.
30+
//
31+
// This is the single shared expander every Foundry field should route through so ${VAR} and
32+
// ${{...}} are handled consistently. drone/envsubst cannot parse ${{...}}, so each span is
33+
// masked with a sentinel placeholder, a single Eval expands the ${VAR} references, then the
34+
// spans are restored. Masking rather than splitting preserves full ${VAR:-default} semantics
35+
// even when a ${{...}} expression is the default value (e.g. ${MISSING:-${{event.body}}}).
36+
// A ${VAR} inside a ${{...}} span is left as-is, since the span is reserved for Foundry.
37+
func ExpandEnv(value string, mapping func(string) string) (string, error) {
38+
spans := foundryTemplatePattern.FindAllString(value, -1)
39+
if len(spans) == 0 {
40+
expanded, err := envsubst.Eval(value, mapping)
41+
if err != nil {
42+
return value, err
43+
}
44+
return expanded, nil
45+
}
46+
47+
// Choose a sentinel that does not already occur in the input so restoration is exact.
48+
sentinel := foundrySentinelBase
49+
for strings.Contains(value, sentinel) {
50+
sentinel += "_"
51+
}
52+
53+
index := 0
54+
masked := foundryTemplatePattern.ReplaceAllStringFunc(value, func(string) string {
55+
placeholder := fmt.Sprintf("%s%d_", sentinel, index)
56+
index++
57+
return placeholder
58+
})
59+
60+
expanded, err := envsubst.Eval(masked, mapping)
61+
if err != nil {
62+
return value, err
63+
}
64+
65+
for i, span := range spans {
66+
expanded = strings.Replace(expanded, fmt.Sprintf("%s%d_", sentinel, i), span, 1)
67+
}
68+
return expanded, nil
69+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package project
5+
6+
import (
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestExpandEnv(t *testing.T) {
14+
env := map[string]string{
15+
"FOO": "bar",
16+
"ENDPOINT": "https://example.com",
17+
}
18+
mapping := func(name string) string { return env[name] }
19+
20+
tests := []struct {
21+
name string
22+
input string
23+
want string
24+
wantErr bool
25+
}{
26+
{name: "plain var", input: "${FOO}", want: "bar"},
27+
{name: "var with default present", input: "${FOO:-fallback}", want: "bar"},
28+
{name: "var with default absent", input: "${MISSING:-fallback}", want: "fallback"},
29+
{name: "missing var no default", input: "${MISSING}", want: ""},
30+
{name: "no templating", input: "plain text", want: "plain text"},
31+
{name: "empty string", input: "", want: ""},
32+
{name: "embedded var", input: "url=${ENDPOINT}/api", want: "url=https://example.com/api"},
33+
{
34+
name: "foundry expression preserved",
35+
input: "${{connections.x.credentials.key}}",
36+
want: "${{connections.x.credentials.key}}",
37+
},
38+
{
39+
name: "mixed var and foundry expression",
40+
input: "prefix ${FOO} ${{event.body}} suffix",
41+
want: "prefix bar ${{event.body}} suffix",
42+
},
43+
{
44+
name: "adjacent foundry and var spans",
45+
input: "${{a}}${FOO}${{b}}",
46+
want: "${{a}}bar${{b}}",
47+
},
48+
{
49+
name: "multiple foundry expressions only",
50+
input: "${{a}} and ${{b}}",
51+
want: "${{a}} and ${{b}}",
52+
},
53+
{
54+
name: "var inside foundry span not expanded",
55+
input: "${{ outer ${FOO} }}",
56+
want: "${{ outer ${FOO} }}",
57+
},
58+
{
59+
name: "foundry expression as default value, var unset",
60+
input: "${MISSING:-${{event.body}}}",
61+
want: "${{event.body}}",
62+
},
63+
{
64+
name: "foundry expression as default value, var set",
65+
input: "${FOO:-${{event.body}}}",
66+
want: "bar",
67+
},
68+
{
69+
name: "literal dollar before foundry span",
70+
input: "$${{event.body}}",
71+
want: "$${{event.body}}",
72+
},
73+
{
74+
name: "foundry span across newline with var",
75+
input: "${{first\nsecond}}\n${FOO}",
76+
want: "${{first\nsecond}}\nbar",
77+
},
78+
{
79+
name: "malformed unterminated expression returns original",
80+
input: "${{ unterminated",
81+
want: "${{ unterminated",
82+
wantErr: true,
83+
},
84+
}
85+
86+
for _, tt := range tests {
87+
t.Run(tt.name, func(t *testing.T) {
88+
got, err := ExpandEnv(tt.input, mapping)
89+
if tt.wantErr {
90+
require.Error(t, err)
91+
} else {
92+
require.NoError(t, err)
93+
}
94+
assert.Equal(t, tt.want, got)
95+
})
96+
}
97+
}
98+
99+
// TestExpandEnvNeverCorruptsFoundryExpressions guards the core invariant: a ${{...}} span is
100+
// always returned byte-for-byte, regardless of what surrounds it.
101+
func TestExpandEnvNeverCorruptsFoundryExpressions(t *testing.T) {
102+
mapping := func(name string) string { return map[string]string{"TOKEN": "secret"}[name] }
103+
104+
const foundry = "${{connections.github-mcp-conn.credentials.x-api-key}}"
105+
got, err := ExpandEnv("Authorization: ${TOKEN}; key: "+foundry, mapping)
106+
require.NoError(t, err)
107+
assert.Equal(t, "Authorization: secret; key: "+foundry, got)
108+
assert.Contains(t, got, foundry)
109+
}

0 commit comments

Comments
 (0)