feat: add service-scoped env to Foundry extensions#9079
Conversation
Co-authored-by: huimiu <107838226+huimiu@users.noreply.github.com>
Co-authored-by: huimiu <107838226+huimiu@users.noreply.github.com>
trangevi
left a comment
There was a problem hiding this comment.
Can you please document where possible the terminology being used for the different environment locations? It's a bit difficult to keep track, and at least to me non-intuitive if I'm understanding correctly
| return fmt.Errorf("adding %s service %q: %w", host, name, err) | ||
| } | ||
|
|
||
| if err := setServiceEnvironment( |
There was a problem hiding this comment.
Can we just put the environment on the serviceConfig object on line 281, instead of needing this separate method? I'm not sure I understand what that method is doing on top of the environment object.
There was a problem hiding this comment.
ServiceConfig.Environment only carries values after azd expands them, so using it here would lose the original ${VAR} templates. The separate config call preserves the raw templates in azure.yaml
There was a problem hiding this comment.
What does "after azd expands them" mean? Does that mean if I have ${VAR}, azd core will replace that with the value of VAR in the azd environment?
There was a problem hiding this comment.
ServiceConfig.Environment contains resolved values, not the original text from azure.yaml. For example, if azure.yaml contains ${API_KEY}, core sends the extension the actual API key.
AddService therefore treats these values as plain text and cannot use this field to create a new ${API_KEY} reference. SetServiceConfigSection writes the original text directly, which is why the separate call is needed
| env: | ||
| LOG_LEVEL: info | ||
| MODEL_ENDPOINT: ${{project.endpoint}} | ||
| MODEL_ENDPOINT: $${{project.endpoint}} |
There was a problem hiding this comment.
$${{project.endpoint}} is the escaped form stored in azure.yaml. azd removes the extra $ and passes ${{project.endpoint}} through for the Foundry extension to resolve
| "credentials": { | ||
| "type": "object", | ||
| "description": "Credentials. Values may contain ${VAR} (azd env, resolved client-side) or ${{...}} (Foundry server-side resolution, passed through untouched).", | ||
| "description": "Credentials. Values may contain ${VAR} declared in the service-level env object or ${{...}} for Foundry server-side resolution.", |
There was a problem hiding this comment.
This line has ${VAR} and ${{VAR}}, I left a comment above regarding something that has $${VAR}}. Are all of these different things?
There was a problem hiding this comment.
Yes, they are different. ${VAR} is resolved from azd environment values, ${{...}} is resolved by Foundry, and $${{...}} escapes the Foundry expression while it passes through azd
There was a problem hiding this comment.
Why do we need all three? If ${VAR} is handled locally, and ${{VAR}} is handled remotely, why do we need the third? Where does the parsing happen, and can it be updated to properly filter on ${ and ${{ so that we only need two? I don't think that the string literal "{VAR}" would be considered a valid env var key anyway, so I don't thing we need to be worried about properly being able to resolve that to something concrete
There was a problem hiding this comment.
There are only two kinds of expressions:
${VAR}is resolved by azd.${{...}}is resolved by Foundry.
$${{...}} isn't a third kind — it's just the escaped form of a Foundry expression inside a service env value. Core expands env before handing it to the extension: it understands ${VAR}, but a raw ${{...}} makes core's envsubst error out, so the extra $ tells core to pass the expression through unchanged.
You're right that this is the awkward part — migration rewrites ${{...}} to $${{...}} when it moves a value into env, so the input and output azure.yaml don't match. The clean fix is to teach core's env expansion to preserve ${{...}} (the same masking we already do for Foundry config fields like target/credentials), which gets us back to two syntaxes everywhere. That changes env handling for every azd service, so I want to check the compatibility impact and add coverage first — I'd rather do that than keep the escape as a user-facing workaround. So I'll keep it out of this PR and track the core fix separately.
This comment was marked as off-topic.
This comment was marked as off-topic.
| hasServiceEnvironment := false | ||
| if resp, envErr := azdClient.Project().GetServiceConfigValue( | ||
| ctx, | ||
| &azdext.GetServiceConfigValueRequest{ServiceName: svc.Name, Path: "env"}, | ||
| ); envErr == nil { | ||
| hasServiceEnvironment = resp.GetFound() | ||
| } |
| p.serviceEnvironments, err = p.projectServiceEnvironments(ctx) | ||
| if err != nil { | ||
| return err | ||
| } |
| ctx, | ||
| &azdext.GetServiceConfigValueRequest{ServiceName: svc.Name, Path: "env"}, | ||
| ); envErr == nil { | ||
| hasServiceEnvironment = resp.GetFound() |
There was a problem hiding this comment.
On a GetServiceConfigValue error this leaves hasServiceEnvironment false, so mergeAgentRunEnvironment takes the legacy branch and injects the full azd environment, the exact leak this commit is closing. The routines and toolboxes targets in this same change fail closed by returning the error from serviceEnvDeclared. Do the same here and only trust Found after a successful call.
jongio
left a comment
There was a problem hiding this comment.
One issue from my earlier reviews is still open on this HEAD, already flagged inline at helpers.go:1003.
In azure.ai.agents/internal/cmd/helpers.go, resolveServiceRunContext swallows the GetServiceConfigValue error and leaves hasServiceEnvironment false. On a transient error that pushes mergeAgentRunEnvironment down the !hasServiceEnvironment branch and injects the full azd environment, the exact leak this change closes everywhere else. The routines and toolboxes targets fail closed by returning the error from serviceEnvDeclared. The agents path should do the same and only trust Found after a successful call.
jongio
left a comment
There was a problem hiding this comment.
Re-reviewed at ce4d70a. The empty-env isolation is handled the right way in routines, toolboxes, and both synthesizers, but two things on the agents path are still open.
The fail-open in azure.ai.agents/internal/cmd/helpers.go (line 1003) is unchanged: resolveServiceRunContext drops the GetServiceConfigValue error and leaves HasServiceEnvironment false, so mergeAgentRunEnvironment falls back to injecting the full azd environment on a transient RPC failure, which re-opens the exact leak this commit closes. routines and toolboxes fail closed by returning the error from serviceEnvDeclared; please do the same here and only trust Found after a successful call.
The new TestMergeAgentRunEnvironment case passes hasServiceEnvironment in directly, so it never exercises resolveServiceRunContext where the error is swallowed. Stubbing the client to error from GetServiceConfigValue would cover the leak path and lock the fix in.
Summary
ServiceConfig.environmentin the Foundry agent, connection, routine, and toolbox targets, with a fallback for older service definitions.envobject while preserving raw${VAR}templates.Follow-up to #8936.
Fixes: #9231
Why this change
azd core now expands a service's environment before it reaches the extension. The Foundry targets must consume those forwarded values so each service resolves its own variables correctly, while existing projects without a service-level environment continue to work.
Approach
Each target prefers the forwarded service environment and falls back to the active azd environment only for legacy service definitions. For
azd ai agent run, explicit process and command-owned values have the highest priority, followed by service-levelenv. Both local and hosted agent paths add all service values independently of legacyagent.yamlentries and do not expand core-resolved literals again. Modern services receive only their declared values and required Foundry platform values; the complete active azd environment is injected only when a service has no service-levelenv.Connection creation is owned by the
microsoft.foundryprovisioning provider rather than the connection deploy target. The provider therefore obtains core-resolved environments for every connection service and applies the matching service scope totarget,credentials, andmetadatain both greenfield and brownfield provisioning. Provider-wide azd and process values remain available only when a connection service has noenv. This keeps the migration backward compatible, avoids cross-service variable leakage, and preserves Foundry server-side expressions.Environment terminology
envalso use them as the legacy fallback.envservices.<name>.envinazure.yaml. Commands use the project config API when they must preserve these templates on disk.ServiceConfig.Environment. Extensions consume these values without expanding them again.environmentVariablesor values from a legacyagent.yaml. Serviceenvtakes precedence when both are present.Template syntax also has distinct owners:
${VAR}is resolved by azd,${{...}}is resolved by Foundry, and$${{...}}preserves a Foundry expression while it passes through azd expansion.Testing
Validated unit behavior and locally built extension bundles from the PR:
env, service-only and empty values reach disk-backed agents, unrelated azd values are excluded, and services withoutenvretain the legacy azd environment fallback.${...}are not expanded again.azd ai agent init: migrated legacyenvironmentVariablesto service-levelenv, removed the deprecated field, preserved${VAR:-default}, and escaped Foundry templates as$${{...}}.azd ai agent runwith service-levelenv: resolved service values and the Foundry project endpoint for the child process while preserving the escaped template inazure.yaml.azd ai agent runwith legacy inlineenvironmentVariables: resolved values through the active azd environment fallback.azd ai agent optimize apply: preserved raw templates and scalar values while migrating with field-level updates.azd deployfor a toolbox with service-levelenv: resolved the service-scoped toolbox endpoint.azd deployfor a legacy toolbox without service-levelenv: resolved the toolbox endpoint through the active azd environment fallback.