mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-30 11:02:17 +00:00
fix(opencode): remove Bun dependency from Azure authentication (#45845)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
This commit is contained in:
parent
8a7cc0c0ff
commit
733562e92a
2 changed files with 155 additions and 74 deletions
|
|
@ -2,10 +2,12 @@ import { readFile } from "node:fs/promises"
|
|||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { which } from "@opencode-ai/core/util/which"
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
import type { Provider } from "@opencode-ai/sdk/v2"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { OAUTH_DUMMY_KEY } from "../auth"
|
||||
import { Process } from "../util/process"
|
||||
|
||||
const AZURE_COGNITIVE_SERVICES_SCOPE = "https://cognitiveservices.azure.com/.default"
|
||||
const AZURE_FOUNDRY_SCOPE = "https://ai.azure.com/.default"
|
||||
|
|
@ -44,16 +46,11 @@ const decodeAzureDeployments = Schema.decodeUnknownPromise(
|
|||
),
|
||||
)
|
||||
|
||||
type AzureCommand = {
|
||||
quiet(): AzureCommand
|
||||
json(): Promise<unknown>
|
||||
}
|
||||
|
||||
type AzureShell = (strings: TemplateStringsArray, ...values: string[]) => AzureCommand
|
||||
type AzureCommand = (args: string[]) => Promise<unknown>
|
||||
type AzureAccount = { readonly name: string; readonly resourceGroup: string }
|
||||
|
||||
export async function AzureAuthPlugin(input: { $: AzureShell }): Promise<Hooks> {
|
||||
const available = Boolean(Bun.which("az", { PATH: process.env.PATH }))
|
||||
export async function AzureAuthPlugin(): Promise<Hooks> {
|
||||
const available = Boolean(which("az"))
|
||||
// Avoid launching Azure CLI on unrelated commands just because the executable is installed.
|
||||
const signedIn = available
|
||||
? await readFile(join(process.env.AZURE_CONFIG_DIR ?? join(homedir(), ".azure"), "azureProfile.json"), "utf8")
|
||||
|
|
@ -63,17 +60,15 @@ export async function AzureAuthPlugin(input: { $: AzureShell }): Promise<Hooks>
|
|||
: false
|
||||
const accounts =
|
||||
!process.env.AZURE_RESOURCE_NAME && !process.env.AZURE_RESOURCE_GROUP && signedIn
|
||||
? await input.$`az cognitiveservices account list --output json --only-show-errors`
|
||||
.quiet()
|
||||
.json()
|
||||
? await runAzure(["cognitiveservices", "account", "list", "--output", "json", "--only-show-errors"])
|
||||
.then(decodeAzureAccounts)
|
||||
.catch(() => [])
|
||||
: []
|
||||
return createAzureAuthHooks(input.$, fetch, accounts, available)
|
||||
return createAzureAuthHooks(runAzure, fetch, accounts, available)
|
||||
}
|
||||
|
||||
export function createAzureAuthHooks(
|
||||
shell: AzureShell,
|
||||
run: AzureCommand,
|
||||
request: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> = fetch,
|
||||
accounts: readonly AzureAccount[] = [],
|
||||
available = true,
|
||||
|
|
@ -84,7 +79,7 @@ export function createAzureAuthHooks(
|
|||
if (cached && cached.expires - Date.now() > AZURE_TOKEN_REFRESH_BUFFER) return cached.token
|
||||
|
||||
const result = await decodeAzureCliToken(
|
||||
await shell`az account get-access-token --scope ${scope} --output json`.quiet().json(),
|
||||
await run(["account", "get-access-token", "--scope", scope, "--output", "json"]),
|
||||
)
|
||||
const expires = result.expires_on !== undefined ? result.expires_on * 1000 : Date.parse(result.expiresOn ?? "")
|
||||
if (!Number.isFinite(expires)) throw new Error("Azure CLI returned an invalid token expiration")
|
||||
|
|
@ -135,7 +130,7 @@ export function createAzureAuthHooks(
|
|||
if (context.auth?.type !== "oauth") return provider.models
|
||||
const resource = context.auth.accountId
|
||||
if (!resource) return {}
|
||||
return discoverAzureModels(provider.models, resource, shell).catch((error: unknown) => {
|
||||
return discoverAzureModels(provider.models, resource, run).catch((error: unknown) => {
|
||||
Effect.runSync(
|
||||
Effect.logWarning("Azure model discovery failed", {
|
||||
resource,
|
||||
|
|
@ -205,21 +200,36 @@ export function createAzureAuthHooks(
|
|||
return hooks
|
||||
}
|
||||
|
||||
async function discoverAzureModels(models: Provider["models"], resourceName: string, shell: AzureShell) {
|
||||
async function runAzure(args: string[]): Promise<unknown> {
|
||||
const result = await Process.run([which("az") ?? "az", ...args])
|
||||
return JSON.parse(result.stdout.toString())
|
||||
}
|
||||
|
||||
async function discoverAzureModels(models: Provider["models"], resourceName: string, run: AzureCommand) {
|
||||
const resourceGroup = process.env.AZURE_RESOURCE_GROUP
|
||||
const account = resourceGroup
|
||||
? { name: resourceName, resourceGroup }
|
||||
: (
|
||||
await decodeAzureAccounts(
|
||||
await shell`az cognitiveservices account list --output json --only-show-errors`.quiet().json(),
|
||||
await run(["cognitiveservices", "account", "list", "--output", "json", "--only-show-errors"]),
|
||||
)
|
||||
).find((account) => account.name.toLowerCase() === resourceName.toLowerCase())
|
||||
if (!account) throw new Error(`Azure resource "${resourceName}" was not found in the active subscription`)
|
||||
|
||||
const deployments = await decodeAzureDeployments(
|
||||
await shell`az cognitiveservices account deployment list --name ${account.name} --resource-group ${account.resourceGroup} --output json --only-show-errors`
|
||||
.quiet()
|
||||
.json(),
|
||||
await run([
|
||||
"cognitiveservices",
|
||||
"account",
|
||||
"deployment",
|
||||
"list",
|
||||
"--name",
|
||||
account.name,
|
||||
"--resource-group",
|
||||
account.resourceGroup,
|
||||
"--output",
|
||||
"json",
|
||||
"--only-show-errors",
|
||||
]),
|
||||
)
|
||||
const found = new Map<string, Provider["models"][string]>()
|
||||
deployments.forEach((deployment) => {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { chmod } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
import type { Auth, Provider } from "@opencode-ai/sdk/v2"
|
||||
import { OAUTH_DUMMY_KEY } from "../../src/auth"
|
||||
import { AzureAuthPlugin, createAzureAuthHooks } from "../../src/plugin/azure"
|
||||
import { Process } from "../../src/util/process"
|
||||
import { which } from "@opencode-ai/core/util/which"
|
||||
|
||||
const resourceName = process.env.AZURE_RESOURCE_NAME
|
||||
const resourceGroup = process.env.AZURE_RESOURCE_GROUP
|
||||
|
|
@ -93,35 +96,127 @@ function models(...ids: string[]): Provider["models"] {
|
|||
}
|
||||
|
||||
function azureShell(scopes: string[]) {
|
||||
return (_strings: TemplateStringsArray, ...values: string[]) => {
|
||||
const output = {
|
||||
quiet: () => output,
|
||||
json: async () => {
|
||||
const scope = values[0]
|
||||
scopes.push(scope)
|
||||
return {
|
||||
accessToken: `${scope}-token`,
|
||||
expires_on: Math.floor((Date.now() + 60 * 60 * 1000) / 1000),
|
||||
}
|
||||
},
|
||||
return async (args: string[]) => {
|
||||
const scope = args[args.indexOf("--scope") + 1]
|
||||
scopes.push(scope)
|
||||
return {
|
||||
accessToken: `${scope}-token`,
|
||||
expires_on: Math.floor((Date.now() + 60 * 60 * 1000) / 1000),
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
function discoveryShell(accounts: unknown, deployments: unknown, commands: string[]) {
|
||||
return (strings: TemplateStringsArray, ...values: string[]) => {
|
||||
const command = String.raw(strings, ...values)
|
||||
return async (args: string[]) => {
|
||||
const command = ["az", ...args].join(" ")
|
||||
commands.push(command)
|
||||
const output = {
|
||||
quiet: () => output,
|
||||
json: async () => (command.includes("deployment list") ? deployments : accounts),
|
||||
}
|
||||
return output
|
||||
return command.includes("deployment list") ? deployments : accounts
|
||||
}
|
||||
}
|
||||
|
||||
async function azureCli(dir: string) {
|
||||
const bin = path.join(dir, "azure cli")
|
||||
const calls = path.join(dir, "calls.jsonl")
|
||||
const script = path.join(bin, "cli.cjs")
|
||||
await Bun.write(calls, "")
|
||||
await Bun.write(
|
||||
script,
|
||||
`
|
||||
const fs = require("node:fs")
|
||||
const args = process.argv.slice(2)
|
||||
fs.appendFileSync(${JSON.stringify(calls)}, JSON.stringify(args) + "\\n")
|
||||
console.log(JSON.stringify(args.includes("get-access-token")
|
||||
? { accessToken: "test-token", expires_on: Math.floor(Date.now() / 1000) + 3600 }
|
||||
: args.includes("deployment") ? [] : [{ name: "test-resource", resourceGroup: "test group & value" }]))
|
||||
`,
|
||||
)
|
||||
const executable = path.join(bin, process.platform === "win32" ? "az.cmd" : "az")
|
||||
await Bun.write(
|
||||
executable,
|
||||
process.platform === "win32"
|
||||
? `@"${process.execPath}" "${script}" %*\r\n`
|
||||
: `#!/bin/sh\nexec '${process.execPath}' '${script}' "$@"\n`,
|
||||
)
|
||||
await chmod(executable, 0o755)
|
||||
return {
|
||||
bin,
|
||||
calls: async () =>
|
||||
(await Bun.file(calls).text())
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line)),
|
||||
}
|
||||
}
|
||||
|
||||
describe("plugin.azure", () => {
|
||||
test("initializes and runs Azure CLI under Node without Bun or a plugin shell", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const node = which("node")
|
||||
if (!node) throw new Error("Node is required for the Azure runtime compatibility test")
|
||||
const bundle = await Bun.build({
|
||||
entrypoints: [path.join(import.meta.dir, "../../src/plugin/azure.ts")],
|
||||
target: "node",
|
||||
format: "esm",
|
||||
})
|
||||
expect(bundle.success).toBe(true)
|
||||
const entry = path.join(tmp.path, "azure.mjs")
|
||||
await Bun.write(entry, bundle.outputs[0])
|
||||
const cli = await azureCli(tmp.path)
|
||||
await Bun.write(path.join(tmp.path, "azureProfile.json"), '\uFEFF{"subscriptions":[{}]}')
|
||||
for (const installed of [false, true]) {
|
||||
const result = await Process.run(
|
||||
[
|
||||
node,
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
`
|
||||
import assert from "node:assert/strict"
|
||||
import { AzureAuthPlugin } from ${JSON.stringify(pathToFileURL(entry).href)}
|
||||
assert.equal(typeof Bun, "undefined")
|
||||
delete process.env.AZURE_RESOURCE_NAME
|
||||
delete process.env.AZURE_RESOURCE_GROUP
|
||||
const hooks = await AzureAuthPlugin({ $: undefined })
|
||||
assert.equal(hooks.auth.provider, "azure")
|
||||
assert.deepEqual(hooks.auth.methods.map((method) => method.type), ${JSON.stringify(installed ? ["api", "oauth"] : ["api"])})
|
||||
if (${installed}) {
|
||||
const method = hooks.auth.methods.find((method) => method.type === "oauth")
|
||||
assert.equal(method.prompts[0].type, "select")
|
||||
const authorization = await method.authorize({ resourceSelection: "test-resource" })
|
||||
const auth = await authorization.callback()
|
||||
assert.equal(auth.type, "success")
|
||||
assert.equal(auth.accountId, "test-resource")
|
||||
assert.deepEqual(await hooks.provider.models({ models: {} }, { auth: { ...auth, type: "oauth" } }), {})
|
||||
}
|
||||
`,
|
||||
],
|
||||
{
|
||||
env: { PATH: installed ? cli.bin : tmp.path, XDG_DATA_HOME: tmp.path, AZURE_CONFIG_DIR: tmp.path },
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
expect(result.stderr.toString()).toBe("")
|
||||
expect(result.code).toBe(0)
|
||||
}
|
||||
expect(await cli.calls()).toEqual([
|
||||
["cognitiveservices", "account", "list", "--output", "json", "--only-show-errors"],
|
||||
["account", "get-access-token", "--scope", "https://cognitiveservices.azure.com/.default", "--output", "json"],
|
||||
["cognitiveservices", "account", "list", "--output", "json", "--only-show-errors"],
|
||||
[
|
||||
"cognitiveservices",
|
||||
"account",
|
||||
"deployment",
|
||||
"list",
|
||||
"--name",
|
||||
"test-resource",
|
||||
"--resource-group",
|
||||
"test group & value",
|
||||
"--output",
|
||||
"json",
|
||||
"--only-show-errors",
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
for (const profile of [
|
||||
{ name: "missing", content: undefined, signedIn: false },
|
||||
{ name: "logged out", content: '{"subscriptions":[]}', signedIn: false },
|
||||
|
|
@ -129,22 +224,16 @@ describe("plugin.azure", () => {
|
|||
]) {
|
||||
test(`only lists resources for a cached Azure login (${profile.name})`, async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const executable = path.join(tmp.path, process.platform === "win32" ? "az.cmd" : "az")
|
||||
await Bun.write(executable, process.platform === "win32" ? "@exit /b 0\r\n" : "#!/bin/sh\nexit 0\n")
|
||||
await chmod(executable, 0o755)
|
||||
process.env.PATH = `${tmp.path}${path.delimiter}${originalPath}`
|
||||
const cli = await azureCli(tmp.path)
|
||||
process.env.PATH = cli.bin
|
||||
process.env.AZURE_CONFIG_DIR = path.join(tmp.path, "azure-cli")
|
||||
if (profile.content)
|
||||
await Bun.write(path.join(process.env.AZURE_CONFIG_DIR, "azureProfile.json"), profile.content)
|
||||
delete process.env.AZURE_RESOURCE_NAME
|
||||
delete process.env.AZURE_RESOURCE_GROUP
|
||||
const commands: string[] = []
|
||||
const hooks = await AzureAuthPlugin()
|
||||
|
||||
const hooks = await AzureAuthPlugin({
|
||||
$: discoveryShell([{ name: "test-resource", resourceGroup: "test-group" }], [], commands),
|
||||
})
|
||||
|
||||
expect(commands).toHaveLength(profile.signedIn ? 1 : 0)
|
||||
expect(await cli.calls()).toHaveLength(profile.signedIn ? 1 : 0)
|
||||
expect(hooks.auth?.methods.some((method) => method.type === "oauth")).toBe(true)
|
||||
if (profile.signedIn) expect(oauthMethod(hooks).prompts?.[0].type).toBe("select")
|
||||
})
|
||||
|
|
@ -246,16 +335,10 @@ describe("plugin.azure", () => {
|
|||
})
|
||||
|
||||
test("supports Azure CLI versions that only provide expiresOn", async () => {
|
||||
const hooks = createAzureAuthHooks(() => {
|
||||
const output = {
|
||||
quiet: () => output,
|
||||
json: async () => ({
|
||||
accessToken: "legacy-token",
|
||||
expiresOn: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
}
|
||||
return output
|
||||
})
|
||||
const hooks = createAzureAuthHooks(async () => ({
|
||||
accessToken: "legacy-token",
|
||||
expiresOn: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
}))
|
||||
const authorization = await oauthMethod(hooks).authorize({ resourceName: "test-resource" })
|
||||
if (authorization.method !== "auto") throw new Error("Unexpected Azure authorization method")
|
||||
|
||||
|
|
@ -263,13 +346,7 @@ describe("plugin.azure", () => {
|
|||
})
|
||||
|
||||
test("rejects Azure CLI tokens without a usable expiration", async () => {
|
||||
const hooks = createAzureAuthHooks(() => {
|
||||
const output = {
|
||||
quiet: () => output,
|
||||
json: async () => ({ accessToken: "invalid-token" }),
|
||||
}
|
||||
return output
|
||||
})
|
||||
const hooks = createAzureAuthHooks(async () => ({ accessToken: "invalid-token" }))
|
||||
const authorization = await oauthMethod(hooks).authorize({ resourceName: "test-resource" })
|
||||
if (authorization.method !== "auto") throw new Error("Unexpected Azure authorization method")
|
||||
|
||||
|
|
@ -369,14 +446,8 @@ describe("plugin.azure", () => {
|
|||
})
|
||||
|
||||
test("keeps configured models available when Azure discovery fails", async () => {
|
||||
const hooks = createAzureAuthHooks(() => {
|
||||
const output = {
|
||||
quiet: () => output,
|
||||
json: async () => {
|
||||
throw new Error("Azure CLI failed")
|
||||
},
|
||||
}
|
||||
return output
|
||||
const hooks = createAzureAuthHooks(async () => {
|
||||
throw new Error("Azure CLI failed")
|
||||
})
|
||||
const list = hooks.provider?.models
|
||||
if (!list) throw new Error("Azure provider model hook is missing")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue