refactor(core): drop Azure CLI subscription pinning

This commit is contained in:
Filip Hejmowski
2026-09-28 17:33:02 +02:00
parent de060b5b85
commit 20b0075b70
3 changed files with 33 additions and 130 deletions
+20 -46
View File
@@ -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",
@@ -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
+2 -4
View File
@@ -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.