mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
refactor(core): drop Azure CLI subscription pinning
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user