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?
| 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
This comment was marked as off-topic.
This comment was marked as off-topic.
jongio
left a comment
There was a problem hiding this comment.
One note on the automated review comments about the len == 0 fallback in the Foundry targets (run.go, the routine and toolbox service targets, and the projects synthesizer). They're framed as cross-service secret leakage, but azdEnvironment and the service-scoped environment are both values from the same azd environment, so this is scoping within a single project, not a cross-tenant boundary. Nothing leaks between users or subscriptions.
The one real nuance: an explicit env: {} serializes the same as an omitted env, so it can't opt out of the compatibility fallback. If honoring an explicit empty scope ever matters, gate the fallback on whether env was declared in the raw service config rather than on map length. Low priority and fine to defer, since the fallback is intentional back-compat.
| 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.
The new commit closes the explicit env: {} isolation gap in the synthesizers and the routines/toolboxes targets, and the added tests cover the empty-declared case.
One issue is still open on this HEAD (already flagged inline on 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 sends mergeAgentRunEnvironment down the legacy branch and injects the full azd environment, the exact leak this commit 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.
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.
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.