From 20b0075b7023567c90605144aec104f054479e94 Mon Sep 17 00:00:00 2001 From: Filip Hejmowski Date: Mon, 28 Sep 2026 17:33:02 +0200 Subject: [PATCH] refactor(core): drop Azure CLI subscription pinning --- packages/core/src/plugin/provider/azure.ts | 66 ++++---------- .../core/test/plugin/provider-azure.test.ts | 91 +++---------------- services/www/src/docs/content/providers.mdx | 6 +- 3 files changed, 33 insertions(+), 130 deletions(-) diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index 6a1d5edd8c6..c40e00f1ef1 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -20,8 +20,6 @@ const cognitiveScope = "https://cognitiveservices.azure.com/.default" const foundryScope = "https://ai.azure.com/.default" const managementScope = "https://management.azure.com/.default" const methodID = Integration.MethodID.make("azure-cli") -// The Azure CLI method keeps its subscription in `refresh`, because credential metadata reaches provider settings. -const refreshPrefix = "azure-cli:" // A resource name becomes a hostname label and a query literal, so anything else never leaves the process. const resourcePattern = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/ const decodeJSON = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)) @@ -32,7 +30,6 @@ const decodeToken = Schema.decodeUnknownEffect( expiresOn: Schema.optional(Schema.NonEmptyString), }), ) -const decodeSubscription = Schema.decodeUnknownEffect(Schema.Struct({ id: Schema.NonEmptyString })) const ResourceDeployments = Schema.Struct({ data: Schema.Array(Schema.Unknown) }) const decodeResourceDeployment = Schema.decodeUnknownOption( Schema.Struct({ id: Schema.NonEmptyString, model: Schema.NonEmptyString, status: Schema.String }), @@ -90,30 +87,23 @@ export function make( Effect.flatMap((result) => decodeJSON(result.stdout.toString("utf8"))), ) - const token = Effect.fn("AzurePlugin.token")(function* (scope: string, subscription: string | undefined) { + const token = Effect.fn("AzurePlugin.token")(function* (scope: string) { const now = yield* Clock.currentTimeMillis - const key = `${subscription ?? ""} ${scope}` - const cached = tokens.get(key) + const cached = tokens.get(scope) if (cached && cached.expires - now > 5 * 60_000) return cached - const result = yield* command([ - "account", - "get-access-token", - "--scope", - scope, - ...(subscription === undefined ? [] : ["--subscription", subscription]), - "--output", - "json", - ]).pipe(Effect.flatMap(decodeToken)) + const result = yield* command(["account", "get-access-token", "--scope", scope, "--output", "json"]).pipe( + Effect.flatMap(decodeToken), + ) const expires = result.expires_on !== undefined ? result.expires_on * 1000 : Date.parse(result.expiresOn ?? "") if (!Number.isFinite(expires)) return yield* Effect.fail(new Error("Azure CLI returned an invalid token expiration")) const refreshed = { access: result.accessToken, expires } - tokens.set(key, refreshed) + tokens.set(scope, refreshed) return refreshed }) - const management = (request: HttpClientRequest.HttpClientRequest, subscription: string | undefined) => - token(managementScope, subscription).pipe( + const management = (request: HttpClientRequest.HttpClientRequest) => + token(managementScope).pipe( Effect.flatMap((current) => http.execute( request.pipe( @@ -158,30 +148,25 @@ export function make( Effect.succeed({ mode: "auto" as const, url: "", - instructions: - "Sign in with `az login` before continuing. The connection keeps the Azure CLI subscription selected now.", + instructions: "Sign in with `az login` before continuing.", callback: Effect.gen(function* () { const resourceName = (typeof answer.resourceName === "string" ? answer.resourceName.trim() : "") || resolveResourceName(configured) if (!resourceName) return yield* Effect.fail(new Error("Azure resource name is required")) - // Pinning the account lets several connections coexist and survive a later `az account set`. - const subscription = yield* command(["account", "show", "--output", "json"]).pipe( - Effect.flatMap(decodeSubscription), - ) - const current = yield* token(cognitiveScope, subscription.id) + const current = yield* token(cognitiveScope) return Credential.OAuth.make({ type: "oauth", methodID, access: current.access, - refresh: `${refreshPrefix}${subscription.id}`, + refresh: "azure-cli", expires: current.expires, metadata: { resourceName }, }) }), }), refresh: (credential) => - token(cognitiveScope, credentialSubscription(credential)).pipe( + token(cognitiveScope).pipe( Effect.map((current) => Credential.OAuth.make({ ...credential, access: current.access, expires: current.expires }), ), @@ -199,15 +184,12 @@ export function make( }) // Resource Graph searches every subscription the Azure CLI account can read, not only the selected one. - const findResource = Effect.fn("AzurePlugin.findResource")(function* ( - resource: string, - subscription: string | undefined, - ) { + const findResource = Effect.fn("AzurePlugin.findResource")(function* (resource: string) { const response = yield* HttpClientRequest.post( `${endpoints.management}/providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01`, ).pipe( HttpClientRequest.schemaBodyJson(ResourceQuery)({ query: resourceQuery(resource) }), - Effect.flatMap((request) => management(request, subscription)), + Effect.flatMap(management), Effect.flatMap(HttpClientResponse.schemaBodyJson(Resources)), Effect.timeout("10 seconds"), ) @@ -216,16 +198,13 @@ export function make( return id }) - const managementDeployments = Effect.fn("AzurePlugin.managementDeployments")(function* ( - resource: string, - subscription: string | undefined, - ) { - const key = `${subscription ?? ""} ${resource.toLowerCase()}` - const id = resourceIDs.get(key) ?? (yield* findResource(resource, subscription)) + const managementDeployments = Effect.fn("AzurePlugin.managementDeployments")(function* (resource: string) { + const key = resource.toLowerCase() + const id = resourceIDs.get(key) ?? (yield* findResource(resource)) resourceIDs.set(key, id) const origin = new URL(endpoints.management).origin return yield* Stream.paginate(`${endpoints.management}${id}/deployments?api-version=2024-10-01`, (url) => - management(HttpClientRequest.get(url), subscription).pipe( + management(HttpClientRequest.get(url)).pipe( Effect.flatMap(HttpClientResponse.schemaBodyJson(ManagementDeployments)), Effect.timeout("10 seconds"), Effect.flatMap((response) => @@ -280,7 +259,7 @@ export function make( // The resource's legacy inventory serves API keys and identities without Azure Resource Manager read access. const deployments = (url: string, resource: string, credential: Credential.Value) => credential.type === "oauth" - ? managementDeployments(resource, credentialSubscription(credential)).pipe( + ? managementDeployments(resource).pipe( Effect.catch(() => resourceDeployments(url, credential)), ) : resourceDeployments(url, credential) @@ -446,7 +425,7 @@ export function make( target.hostname.endsWith(".services.ai.azure.com") && !target.pathname.startsWith("/models") ? foundryScope : cognitiveScope - const current = yield* token(scope, credentialSubscription(credential)).pipe(Effect.orDie) + const current = yield* token(scope).pipe(Effect.orDie) return `Bearer ${current.access}` }) yield* ctx.session.hook( @@ -506,11 +485,6 @@ function credentialResource(credential: Credential.Value | undefined) { return typeof resource === "string" && resource.trim() !== "" ? resource : undefined } -// Connections made before subscriptions were pinned store only the method name and follow the Azure CLI default. -function credentialSubscription(credential: { readonly refresh: string }) { - return credential.refresh.startsWith(refreshPrefix) ? credential.refresh.slice(refreshPrefix.length) : undefined -} - function resourceQuery(resource: string) { return [ "resources", diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index e243844de19..6f2ea346072 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -206,22 +206,6 @@ const azureCredential = Effect.gen(function* () { }) }) -const pinnedCredential = (subscription: string) => - Effect.gen(function* () { - const credentials = yield* Credential.Service - return yield* credentials.create({ - integrationID: Integration.ID.make("azure"), - value: Credential.OAuth.make({ - type: "oauth", - methodID: Integration.MethodID.make("azure-cli"), - access: "stored-token", - refresh: `azure-cli:${subscription}`, - expires: Date.now() + 60 * 60 * 1000, - metadata: { resourceName: "test-resource" }, - }), - }) - }) - const keyCredential = Effect.gen(function* () { const credentials = yield* Credential.Service return yield* credentials.create({ @@ -320,7 +304,6 @@ describe("AzurePlugin", () => { withAzureCommands( (args) => { commands.push([...args]) - if (args[1] === "show") return { id: "sub-a", tenantId: "tenant-a" } return { accessToken: "legacy-cli-token", expiresOn: new Date(Date.now() + 60 * 60 * 1000).toISOString(), @@ -342,28 +325,19 @@ describe("AzurePlugin", () => { }).pipe(Effect.retry({ times: 1500, schedule: Schedule.spaced("1 millis") })) const credential = (yield* (yield* Credential.Service).list(integrationID))[0]?.value - // The subscription stays out of metadata, which the model resolver merges into provider settings. - expect(credential).toEqual({ + expect(credential).toMatchObject({ type: "oauth", - methodID: Integration.MethodID.make("azure-cli"), access: "legacy-cli-token", - refresh: "azure-cli:sub-a", - expires: expect.any(Number), metadata: { resourceName: "test-resource" }, }) // Discovery for the new connection may already be minting its management token afterwards. - expect(commands.slice(0, 2)).toEqual([ - ["account", "show", "--output", "json"], - [ - "account", - "get-access-token", - "--scope", - "https://cognitiveservices.azure.com/.default", - "--subscription", - "sub-a", - "--output", - "json", - ], + expect(commands[0]).toEqual([ + "account", + "get-access-token", + "--scope", + "https://cognitiveservices.azure.com/.default", + "--output", + "json", ]) }), ), @@ -404,7 +378,6 @@ describe("AzurePlugin", () => { const deployed = yield* eventually(azureModels, (list) => list.length === 1) expect(deployed.map((model) => model.id)).toEqual([Model.ID.make("gpt-5-mini")]) - // A connection from before subscription pinning keeps following the Azure CLI default account. expect(commands).toEqual([ ["account", "get-access-token", "--scope", "https://management.azure.com/.default", "--output", "json"], ]) @@ -500,7 +473,7 @@ describe("AzurePlugin", () => { ), ) - it.live("lists Azure CLI deployments through the management API of the pinned subscription", () => { + it.live("lists Azure CLI deployments through the management API", () => { const commands: string[][] = [] const resource = account("test-resource") return withAzureCommands( @@ -529,7 +502,7 @@ describe("AzurePlugin", () => { ({ endpoints, requests }) => Effect.gen(function* () { yield* seedCatalog - yield* pinnedCredential("sub-b") + yield* azureCredential yield* addPlugin(endpoints) const deployed = yield* eventually(azureModels, (list) => list.length === 1) @@ -537,16 +510,7 @@ describe("AzurePlugin", () => { [Model.ID.make("gpt-production"), Model.ID.make("gpt-production")], ]) expect(commands).toEqual([ - [ - "account", - "get-access-token", - "--scope", - "https://management.azure.com/.default", - "--subscription", - "sub-b", - "--output", - "json", - ], + ["account", "get-access-token", "--scope", "https://management.azure.com/.default", "--output", "json"], ]) expect(requests.map((request) => [request.method, request.path, request.authorization])).toEqual([ [ @@ -970,39 +934,6 @@ describe("AzurePlugin", () => { ), ) - it.effect("mints request tokens for the subscription the connection was made with", () => { - const commands: string[][] = [] - return withAzureCommands( - (args) => { - commands.push([...args]) - return cliTokens(args) - }, - () => - Effect.gen(function* () { - yield* pinnedCredential("sub-b") - yield* addPlugin() - const hooks = yield* PluginHooks.Service - yield* hooks.trigger("session", "http.request", { - sessionID: Session.ID.make("ses_pinned"), - agent: Agent.ID.make("build"), - model: Model.Ref.make({ providerID: Provider.ID.azure, id: Model.ID.make("gpt-5-mini") }), - kind: "primary", - request: new Request("https://test-resource.openai.azure.com/openai/v1/responses"), - }) - expect(commands).toContainEqual([ - "account", - "get-access-token", - "--scope", - "https://cognitiveservices.azure.com/.default", - "--subscription", - "sub-b", - "--output", - "json", - ]) - }), - ) - }) - it.effect("prefers the connection's resource over configuration, as requests do", () => Effect.gen(function* () { const catalog = yield* Provider.Service diff --git a/services/www/src/docs/content/providers.mdx b/services/www/src/docs/content/providers.mdx index de2acd34f57..342424df3fa 100644 --- a/services/www/src/docs/content/providers.mdx +++ b/services/www/src/docs/content/providers.mdx @@ -271,16 +271,14 @@ az login Enter the resource name when connecting if it is not already supplied by configuration or the environment. A resource name saved with a connection takes precedence over configuration; connect again to change it. -Each connection keeps the Azure CLI subscription that was selected when it connected, so a later `az account set` does -not move it. To connect an account in another tenant or subscription, select it before connecting. +Azure CLI connections use the account selected in the Azure CLI. For a resource in another tenant or subscription, +select both explicitly. ```bash az login --tenant TENANT_ID az account set --subscription NAME_OR_ID ``` -Signing in to several accounts with `az login` lets you keep one connection per account and switch between them. - Instead of configuration, `AZURE_RESOURCE_NAME` supplies the resource name to the OpenCode server. The legacy `AZURE_COGNITIVE_SERVICES_RESOURCE_NAME` variable also works.