Skip to content

feat: add service-scoped env to Foundry extensions - #9079

Open
huimiu wants to merge 28 commits into
mainfrom
hui/update-foundry-extension-env
Open

feat: add service-scoped env to Foundry extensions#9079
huimiu wants to merge 28 commits into
mainfrom
hui/update-foundry-extension-env

Conversation

@huimiu

@huimiu huimiu commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

Foundry extensions (agent, connection, routine, toolbox) now read service-scoped environment values from ServiceConfig.Environment instead of the project-wide azd environment, and write generated variables into the standard services.<name>.env block.

  • Each target prefers the forwarded service environment; services without env keep the old project-wide behavior.
  • Fixed two timing bugs where extensions read environment values before the user was prompted for them.
  • Replaced the agents extension's two independent ${VAR} scanners with one shared scanner.
  • Updated schemas, examples, minimum azd versions, and SDK dependencies for azd 1.27.1.

Follow-up to #8936. Fixes: #9231

Why this change

Service scoping. azd core now expands each service's environment before handing it to the extension. Extensions have to consume those values so services stop leaking variables into each other, while projects with no service-level env keep working unchanged.

The empty-value bug. azd up initializes every service target while building the execution graph — before the user is prompted for a missing subscription or location. A config read at that moment holds empty strings, so the user answers the prompt and the extension still deploys a blank value, with no error anywhere. Two fixes: the agent target now uses the config passed to each deploy-time entrypoint instead of the copy cached at Initialize (re-running $ref expansion on it), and the provisioning provider resolves the azd environment before it reads service environments.

One ${VAR} scanner. Writing an env: block means deciding which variables a service references. #9212 answered a near-identical question — which variables to prompt for — with a second implementation, and the two had already drifted:

init prompting generated env: block
Implementation regex hand-written scanner
Nested default ${A:-${B}} inexpressible partly handled
Escaping odd/even $ count any preceding $

They are now one scanner, pinned by a test to foundry.ExpandEnv — the code that actually resolves these values — so it can't drift again. Escaping follows the expander too: $${VAR} is literal, $$${VAR} expands. The only remaining policy difference is one explicit line: init skips a reference that has a default, the env block records it.

Nested references stay out of scope by design: ${OUTER:-${NESTED}} reports OUTER only. foundry.ExpandEnv still resolves NESTED at deploy, but nothing discovers it, so it never reaches the generated env: block and init never prompts for it. That limitation is now stated in the scanner's doc comment and pinned by a test instead of left implicit — keep defaults literal.

Environment terminology

Term Meaning
Process environment Inherited by a locally launched agent. Highest precedence, with command-owned values.
Active azd environment Project-wide values. Modern services use these only for required platform values; services without env also use them as the legacy fallback.
Service env Raw templates under services.<name>.env. Commands use the project config API to preserve them on disk.
Forwarded service environment Service-scoped, core-expanded literals from ServiceConfig.Environment. Consumed as-is, never expanded again.
Agent definition environment Deprecated inline environmentVariables or legacy agent.yaml. Service env wins when both exist.

Template syntax has distinct owners: ${VAR} is resolved by azd, ${{...}} by Foundry, and $${{...}} preserves a Foundry expression through azd expansion.

Manual Testing

  • azd ai agent init — migrated legacy environmentVariables to env, preserved ${VAR:-default}, escaped Foundry templates as $${{...}}.
  • azd ai agent run — service env resolved for the child process while the template stayed intact on disk; legacy inline definitions resolved through the azd fallback.
  • azd ai agent optimize apply — raw templates and scalars preserved through field-level updates.
  • azd deploy — toolbox endpoint resolved from service env, and from the azd fallback for a legacy toolbox.

@github-actions github-actions Bot added ext-agents azure.ai.agents extension ext-connections azure.ai.connections extension ext-foundry azure.ai.{agents,connections,inspector,projects,routines,skills,toolboxes}, microsoft.foundry ext-routines azure.ai.routines extension ext-toolboxes azure.ai.toolboxes extension labels Jul 10, 2026
@huimiu
huimiu marked this pull request as ready for review July 10, 2026 12:16
Copilot AI review requested due to automatic review settings July 10, 2026 12:16

This comment was marked as outdated.

jongio

This comment was marked as outdated.

@huimiu huimiu changed the title fix: use core env in Foundry service targets refactor: use core env in Foundry service targets Jul 14, 2026
Co-authored-by: huimiu <107838226+huimiu@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 14, 2026 09:54
@huimiu
huimiu removed the request for review from Copilot July 14, 2026 09:54
Co-authored-by: huimiu <107838226+huimiu@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 14, 2026 09:59
@huimiu
huimiu removed the request for review from Copilot July 14, 2026 09:59
jongio

This comment was marked as outdated.

Copilot AI review requested due to automatic review settings July 14, 2026 10:33

@jongio jongio left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-ran the full pass on de05ddf9. Build, vet and tests are clean in all four modules at this commit and CI is green. The two MatchesBicepBuild failures I hit locally also fail on the base commit 7d34c7e, so those are a local bicep version difference and not from this branch.

One new thing, on the legacy definition path. ResolveAgentEnvironmentVariable falls back to the project-wide azd environment even when the service declares an isolated env: scope, which is the opposite of what connectionEnvironmentMapping does in the projects extension in this same PR. Detail inline.

My earlier threads on the agents GetServiceConfigValue error path, the empty generated env: block, the provisioning provider ordering and wording, and ${A:-${B}} collection are unchanged by this commit and still stand. My approve on de05ddf9 stays as is.

if mapping == nil {
return ""
}
return mapping(variableName)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the name isn't in serviceEnvironment this always falls through to mapping, and both callers hand it the full azd environment: run.go:552 passes azdEnvVars[varName] and service_target_agent.go:2712 passes azdEnv[varName]. Neither caller knows whether the service declared env:.

The projects extension goes the other way in this same PR. Once declared is true, connectionEnvironmentMapping returns serviceEnvironment[name] with no project fallback, so a connection service with env: {} resolves ${FOO} to empty instead of picking up a project-wide FOO.

Net effect: a hosted agent with env: {} plus a legacy environment_variables: [{name: TARGET, value: "${FOO}"}] still resolves FOO from the project environment, at run and at deploy.

Deliberate for the deprecated environment_variables block, or should it follow the connection rule? run.go already carries runCtx.HasServiceEnvironment that could be threaded in, and the deploy path would need the same probe the routines and toolboxes targets do. If it is deliberate, worth saying so on the doc comment, because ResolveAgentEnvironmentVariable reads as scope-aware right now.

Comment thread cli/azd/extensions/azure.ai.agents/internal/cmd/env_refs.go

@jongio jongio left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incremental review of the 5 commits since fea0f498.

internal/cmd/env_refs.go:96 - environmentReferenceAt doesn't verify value[start+1] == '{', so $name} parses as a reference with the first character dropped. That writes phantom entries into the generated env: block, and on $ab:-${REAL}} it swallows the real REAL reference so init never prompts for it. Repro and a fix inline; your existing tests pass with the change.

Still open from my last pass: internal/cmd/helpers.go:1000 swallows the GetServiceConfigValue error, so a transient RPC failure makes a service that declared env: {} fall back to the full azd environment. The routines and toolboxes targets fail closed on the same call.

Replied to @trangevi on the escaping constants.

Comment thread cli/azd/extensions/azure.ai.agents/internal/cmd/env_refs.go
Copilot AI review requested due to automatic review settings July 29, 2026 08:27
@huimiu
huimiu force-pushed the hui/update-foundry-extension-env branch from d3705f4 to 4f00973 Compare July 29, 2026 08:30

This comment was marked as outdated.

Copilot AI review requested due to automatic review settings July 29, 2026 08:36

This comment was marked as outdated.

@jongio jongio left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two notes on env_refs_test.go.

TestFindEnvironmentReferencesMatchesExpander is the guard for the scanner/expander contract, and every value in its list has $ immediately followed by {. That shape never reaches the environmentReferenceAt path from the open thread on line 107 of env_refs.go, so the guard passes while the contract it states is already broken.

The doc comment on TestNestedDefaultIsNotDiscovered says the outer name reaches init prompting, but the assertion below it says otherwise.

Comment thread cli/azd/extensions/azure.ai.agents/internal/cmd/env_refs_test.go
Comment thread cli/azd/extensions/azure.ai.agents/internal/cmd/env_refs_test.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 41 out of 41 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go:999

  • azd-code-reviewer: Do not treat a failed GetServiceConfigValue call as a legacy service. If this RPC fails for a service with env: {}, HasServiceEnvironment remains false and mergeAgentRunEnvironment injects the full active azd environment into the child process, defeating service isolation and potentially exposing unrelated secrets. Return the error instead.
	if resp, envErr := azdClient.Project().GetServiceConfigValue(

cli/azd/extensions/azure.ai.agents/internal/cmd/env_refs_test.go:250

  • azd-code-reviewer: The PR description says this scanner has a 588-input differential test against both former implementations, but this file contains only the table cases and this one-way check. This assertion only verifies that reported names are expanded, so an implementation that misses a live reference—or returns no references at all—still passes. Add the claimed generated differential/completeness coverage so the shared scanner cannot silently omit variables from both prompting and generated env blocks.
	values := []string{

@jongio jongio left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing the escaping comment leaves out. resolveVars diverges on :- as well as on $$, and that half breaks the scanner's own policy line for the three ignoreEnvironmentEscaping fields.

Comment thread cli/azd/extensions/azure.ai.agents/internal/cmd/env_refs.go

@trangevi trangevi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved, pending addressing Jon's remaining comments

@jongio jongio left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seven threads from my earlier passes are still open on this HEAD with no replies. Listing them so they don't get lost now that the PR is showing approved.

  • env_refs.go resolveVars divergence: I added detail on that thread. The :- half fails silently, and a value the user was prompted for ends up in the Bicep param as literal text.
  • helpers.go the service env read swallows the RPC error and leaves hasServiceEnvironment false, so the run path falls back to the full azd environment, which is the leak this PR closes. Routines and toolboxes return the error instead.
  • agent_definition.go ResolveAgentEnvironmentVariable falls through to the caller mapping for any name a declared env: doesn't carry, and both callers hand it the full azd environment.
  • resource_services.go setServiceEnvironment returns before writing when collection comes back empty, so a service with no ${VAR} references gets no env: key and reads as legacy at run time.
  • env_refs.go environmentReferenceAt never checks that the byte after $ is {. "$HOME}" reports OME while foundry.ExpandEnv leaves the value untouched, which is the exact invariant TestFindEnvironmentReferencesMatchesExpander asserts.
  • env_refs_test.go every value in that parity corpus has $ followed by {, so it can't catch the case above.
  • env_refs_test.go the TestNestedDefaultIsNotDiscovered comment says the outer name reaches init prompting, but the assertion four lines down is require.Empty.

None of these are new asks, they're the same threads. The two scanner ones and the two test ones are narrow. The three that fall back to the full azd environment are the ones I'd want settled before this merges, since they all undo the isolation the PR is adding.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 41 out of 41 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

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

  • azd-code-reviewer: This no longer resolves service-scoped Foundry connection references for local runs. A forwarded value such as ${{connections.search.credentials.key}} is only cloned here (the only replacement below is project.endpoint). Although the same entry is resolved into defEnv, mergeAgentRunEnvironment discards it because the key exists in serviceEnvironment, so the child receives the literal expression instead of the credential. Route service-environment entries through the existing connection-reference resolution before giving them precedence.
func resolveLocalServiceEnvironment(
	environment map[string]string,
	endpoint string,
) map[string]string {

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 41 out of 41 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

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

  • Service-scoped connection expressions are resolved into definitionEnvironment, but this branch discards that resolved entry whenever the same key exists in serviceEnvironment. The child therefore receives the forwarded literal ${{connections...}} instead of the credential value; the previous resolveServiceEnvironmentVars path resolved these expressions before merging. Merge the resolved definition entry for service-scoped keys (while retaining process/command precedence).
	for _, entry := range definitionEnvironment {
		key, _, _ := strings.Cut(entry, "=")
		_, serviceScoped := serviceEnvironment[key]
		if serviceScoped {
			continue

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 42 out of 42 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (4)

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

  • azd-code-reviewer: Service-scoped Foundry connection expressions are left literal here. A raw env value such as API_KEY: $${{connections.search.credentials.key}} is forwarded as ${{...}}; resolveAgentDefinitionEnvVars resolves its merged definition entry to the credential, but this branch then discards that resolved entry because the key is service-scoped, leaving the literal expression in the child process. Preserve service precedence after resolving these expressions (the previous service-env path resolved connection refs before merging), and add an end-to-end merge test for this case.
	for _, entry := range definitionEnvironment {
		key, _, _ := strings.Cut(entry, "=")
		_, serviceScoped := serviceEnvironment[key]
		if serviceScoped {
			continue
		}

cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go:859

  • azd-code-reviewer: This test references os.Stdout, which cli/azd/.golangci.yaml:18-21 explicitly forbids in test files; agent_definition_test.go is not covered by the stdout-test allowlist, so lint will fail. Pass an io.Writer into the warning helper and assert with a bytes.Buffer instead of swapping global stdout.
	original := os.Stdout
	os.Stdout = writer
	defer func() { os.Stdout = original }()

cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go:680

  • azd-code-reviewer: This declaration check runs after serviceForHost expands $ref, so an env: placed in a referenced connection file is treated as scoped even though azd core only forwards/expands core service fields from the root azure.yaml entry. serviceEnvironments[name] is therefore empty and every ${VAR} in that connection resolves to "" instead of the legacy environment. Reject env as a core field in connection root refs, or determine scope from the unexpanded root node before selecting this mapping.
		declared := len(serviceEnvironments[name]) > 0 || connectionEnvDeclared(node)
		mapping := connectionEnvironmentMapping(
			env,
			serviceEnvironments[name],
			declared,

cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go:680

  • azd-code-reviewer: This declaration check runs after serviceForHost expands $ref, so an env: placed in a referenced connection file is treated as scoped even though azd core only forwards/expands core service fields from the root azure.yaml entry. serviceEnvironments[name] is therefore empty and every ${VAR} in that connection resolves to "" instead of the legacy environment. Reject env as a core field in connection root refs (as the agent path now does), or determine scope from the unexpanded root node before selecting this mapping.

@trangevi trangevi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving, pending addressing Jon's comments, and with a possible suggestion to consider now or as a follow up, if it would indeed make things easier.

// alone. "$ab:-${REAL}}" is the costly shape, because the
// phantom would span the string and hide the live REAL.
func environmentReferenceAt(value string, start int) (environmentReference, bool) {
if start+1 >= len(value) || value[start+1] != '{' {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe regex would be easier here? and in some of the other places where we're introducing these kinds of checks. Idk for sure that it would be, just throwing it out there to consider

@jongio jongio left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Four earlier issues are still open on this HEAD:

  • foundry_provisioning_provider.go: on-disk and ejected provisioning still substitutes only the project environment, so service aliases can remain unresolved or bind project-wide values.
  • resource_services.go: init still skips writing env: {} when no references are collected, so generated services fall back to the full azd environment locally.
  • foundry_provisioning_provider.go: subscription and location prompting still happens before project service validation, obscuring configuration errors, especially under --no-prompt.
  • foundry_provisioning_provider.go: the broadened $ref failure still reports an existing-endpoint error for greenfield service configuration.

These paths are unchanged by the latest commits, so I can't approve this revision yet.

🤖 agent jongio

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/extensions Extensions (general) ext-agents azure.ai.agents extension ext-connections azure.ai.connections extension ext-foundry azure.ai.{agents,connections,inspector,projects,routines,skills,toolboxes}, microsoft.foundry ext-projects azure.ai.projects extension ext-routines azure.ai.routines extension ext-toolboxes azure.ai.toolboxes extension

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Migrate Foundry extensions to service-level env

5 participants