fix(core): stop listing Azure resources at startup (#54870)

This commit is contained in:
Filip
2026-10-12 19:03:58 +02:00
committed by GitHub
Unverified
parent 5683367e27
commit 7bac892c73
2 changed files with 16 additions and 120 deletions
+2 -75
View File
@@ -1,11 +1,8 @@
import { Clock, Effect, FiberHandle, Option, Schema, Semaphore, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"
import { ChildProcess } from "effect/process"
import path from "node:path"
import { define } from "@opencode/plugin/effect/plugin"
import { Form } from "@opencode/schema/form"
import { FSUtil } from "@opencode/util/fs-util"
import { Global } from "@opencode/util/global"
import { AppProcess } from "@opencode/util/process"
import { App } from "../../app.js"
import { Bus } from "../../bus.js"
@@ -14,7 +11,6 @@ import { Integration } from "../../integration.js"
import { IntegrationConnection } from "../../integration/connection.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import { iife } from "../../util/iife.js"
import { which } from "../../util/which.js"
import type { PluginInternal } from "../internal.js"
import { configuredSettings } from "./configured.js"
@@ -52,18 +48,8 @@ const decodeManagementDeployment = Schema.decodeUnknownOption(
}),
}),
)
const ResourceQuery = Schema.Struct({
query: Schema.String,
options: Schema.optional(Schema.Struct({ $top: Schema.Number })),
})
const ResourceQuery = Schema.Struct({ query: Schema.String })
const Resources = Schema.Struct({ data: Schema.Array(Schema.Struct({ id: Schema.NonEmptyString })) })
const ResourceListing = Schema.Struct({ data: Schema.Array(Schema.Unknown) })
const decodeProfile = Schema.decodeUnknownOption(
Schema.fromJsonString(Schema.Struct({ subscriptions: Schema.NonEmptyArray(Schema.Unknown) })),
)
const decodeResourceListing = Schema.decodeUnknownOption(
Schema.Struct({ resourceName: Schema.NonEmptyString, resourceGroup: Schema.String, location: Schema.String }),
)
type Deployment = { readonly name: string; readonly model: string }
@@ -78,7 +64,6 @@ export function make(
effect: Effect.fn(function* (ctx) {
const configured = yield* configuredSettings(Provider.ID.azure)
const processes = yield* AppProcess.Service
const fs = yield* FSUtil.Service
const bus = yield* Bus.Service
const credentials = yield* Credential.Service
const providers = yield* Provider.Service
@@ -133,8 +118,6 @@ export function make(
const available = Boolean(which("az"))
const configuredResource = Boolean(resolveResourceName(configured) || typeof configured?.baseURL === "string")
// Undefined until the Azure CLI answers; a failed lookup leaves the form to manual entry.
const listing: { resources?: readonly Form.Option[] } = {}
yield* ctx.integration.transform((editor) => {
// The retired azure-cognitive-services provider's env var still connects Azure.
@@ -177,16 +160,7 @@ export function make(
placeholder: "e.g. my-models",
required: true,
pattern: resourcePattern.source,
// Resources are listed once at startup, so one created later is typed in. Without a list the field
// is a plain text input.
...iife(() => {
if (!listing.resources) return { description: "Requests use your `az login` session." }
if (listing.resources.length === 0)
return {
description: "No Azure OpenAI or AI Services resources found for your `az login` account.",
}
return { custom: true, options: listing.resources }
}),
description: "Enter the name of your Azure OpenAI or AI Services resource.",
},
]),
},
@@ -371,42 +345,6 @@ export function make(
Object.assign(loaded, yield* load())
// Lists the resources the Azure CLI account can reach in the background, so the connect form can offer them
// without startup waiting on Azure. The CLI's profile shows a signed-in account without running the CLI.
// https://learn.microsoft.com/cli/azure/azure-cli-configuration#cli-configuration-file
const listResources = Effect.fn("AzurePlugin.listResources")(function* () {
const profile = yield* fs.readFileStringSafe(
path.join(process.env.AZURE_CONFIG_DIR ?? path.join(Global.Path.home, ".azure"), "azureProfile.json"),
)
// The Azure CLI writes the profile with a byte order mark.
if (Option.isNone(decodeProfile(profile?.replace(/^\uFEFF/, "")))) return
const response = yield* HttpClientRequest.post(
`${endpoints.management}/providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01`,
).pipe(
HttpClientRequest.schemaBodyJson(ResourceQuery)({ query: resourceListQuery, options: { $top: 1000 } }),
Effect.flatMap(management),
Effect.flatMap(HttpClientResponse.schemaBodyJson(ResourceListing)),
Effect.timeout("10 seconds"),
)
listing.resources = response.data.flatMap((raw): Form.Option[] => {
const item = Option.getOrUndefined(decodeResourceListing(raw))
if (!item || !resourcePattern.test(item.resourceName)) return []
return [
{
value: item.resourceName,
label: item.resourceName,
description: `${item.resourceGroup} · ${item.location}`,
},
]
})
yield* ctx.integration.reload()
})
if (available && !configuredResource)
yield* listResources().pipe(
Effect.catch((cause) => Effect.logDebug("failed to list Azure resources", { cause })),
Effect.forkScoped,
)
yield* ctx.provider.transform((evt) => {
for (const item of evt.list()) {
if (
@@ -553,17 +491,6 @@ function credentialResource(credential: Credential.Value | undefined) {
return typeof resource === "string" && resource.trim() !== "" ? resource : undefined
}
// Entra ID authentication requires a custom subdomain, which is the resource name every endpoint uses.
// https://learn.microsoft.com/azure/ai-services/cognitive-services-custom-subdomains
const resourceListQuery = [
"resources",
"| where type =~ 'microsoft.cognitiveservices/accounts' and kind in~ ('AIServices', 'OpenAI')",
"| extend resourceName = tostring(properties.customSubDomainName)",
"| where isnotempty(resourceName)",
"| project resourceName, resourceGroup, location",
"| order by resourceName asc",
].join(" ")
function resourceQuery(resource: string) {
return [
"resources",
@@ -64,13 +64,13 @@ type AzureRequest = {
type Route = (request: AzureRequest) => Response | Promise<Response>
// Answers like Azure: the resource's own deployment list, Resource Graph, and the management API.
const fakeAzure = (routes: { resource?: Route; management?: Route; resources?: () => unknown[] }) =>
const fakeAzure = (routes: { resource?: Route; management?: Route }) =>
Effect.acquireRelease(
Effect.sync(() => {
const requests: AzureRequest[] = []
const server = Bun.serve({
port: 0,
fetch: async (raw) => {
fetch: (raw) => {
const url = new URL(raw.url)
const request = {
method: raw.method,
@@ -81,12 +81,8 @@ const fakeAzure = (routes: { resource?: Route; management?: Route; resources?: (
}
requests.push(request)
if (request.path.startsWith("/openai/deployments")) return routes.resource?.(request) ?? notFound()
if (request.path.startsWith("/providers/Microsoft.ResourceGraph/")) {
// The connect form lists every resource; discovery looks up one resource's ID.
if ((await raw.text()).includes("project resourceName"))
return routes.resources ? Response.json({ data: routes.resources() }) : unauthorized()
if (request.path.startsWith("/providers/Microsoft.ResourceGraph/"))
return Response.json({ data: [{ id: resourceID }] })
}
return routes.management?.(request) ?? notFound()
},
})
@@ -125,7 +121,8 @@ const fakeAzureCli = Effect.fn(function* (respond: (args: readonly string[]) =>
const executable = `${directory}/${windows ? "az.cmd" : "az"}`
yield* Effect.promise(() => Bun.write(executable, windows ? "@exit /b 0\r\n" : "#!/bin/sh\nexit 0\n"))
yield* Effect.promise(() => chmod(executable, 0o755))
// The CLI writes its profile with a byte order mark.
// A signed-in profile, so tests show that startup leaves Azure alone even when the CLI could reach it. The CLI
// writes it with a byte order mark.
yield* Effect.promise(() =>
Bun.write(`${directory}/azure/azureProfile.json`, `\uFEFF${JSON.stringify({ subscriptions: [{ id: "sub" }] })}`),
)
@@ -245,31 +242,21 @@ describe("AzurePlugin connecting", () => {
}),
)
it.live("offers the resources the Azure CLI can reach and accepts a typed resource name", () =>
it.live("asks for the resource name as text without calling Azure", () =>
Effect.gen(function* () {
yield* setEnv(noResourceEnv)
const cli = yield* fakeAzureCli()
const azure = yield* fakeAzure({
resources: () => [
{ resourceName: "alpha", resourceGroup: "models", location: "eastus" },
{ resourceName: "not a hostname", resourceGroup: "models", location: "eastus" },
{ id: 42 },
],
})
const azure = yield* fakeAzure({})
yield* addPlugin(azure.endpoints).pipe(cli.provide)
const integrations = yield* Integration.Service
const field = Effect.gen(function* () {
const method = required(yield* integrations.get(azureID)).methods.find((method) => method.type === "external")
const field = method?.type === "external" ? method.form?.[0] : undefined
return field?.type === "string" ? field : undefined
})
const method = required(yield* integrations.get(azureID)).methods.find((method) => method.type === "external")
const field = method?.type === "external" ? method.form?.[0] : undefined
expect(field).toMatchObject({ type: "string", key: "resourceName" })
expect(field?.type === "string" ? field.options : undefined).toBeUndefined()
const listed = required(yield* eventually(field, (field) => (field?.options?.length ?? 0) > 0))
expect(listed).toMatchObject({
key: "resourceName",
custom: true,
options: [{ value: "alpha", label: "alpha", description: "models · eastus" }],
})
yield* settle
expect(cli.commands).toEqual([])
expect(azure.requests).toEqual([])
const external = (resourceName: string) =>
integrations.connection.external({
@@ -290,24 +277,6 @@ describe("AzurePlugin connecting", () => {
}),
)
it.live("asks for the resource name as text when the Azure CLI lists no resources", () =>
Effect.gen(function* () {
yield* setEnv(noResourceEnv)
const cli = yield* fakeAzureCli()
const azure = yield* fakeAzure({ resources: () => [] })
yield* addPlugin(azure.endpoints).pipe(cli.provide)
const integrations = yield* Integration.Service
const field = Effect.gen(function* () {
const method = required(yield* integrations.get(azureID)).methods.find((method) => method.type === "external")
const field = method?.type === "external" ? method.form?.[0] : undefined
return field?.type === "string" ? field : undefined
})
const listed = required(yield* eventually(field, (field) => field?.description?.startsWith("No ") === true))
expect(listed.options).toBeUndefined()
expect(listed.custom).toBeUndefined()
}),
)
})
describe("AzurePlugin startup", () => {