diff --git a/packages/opencode/src/plugin/azure.ts b/packages/opencode/src/plugin/azure.ts index 8dd893a0ebd..33a164f372d 100644 --- a/packages/opencode/src/plugin/azure.ts +++ b/packages/opencode/src/plugin/azure.ts @@ -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 -} - -type AzureShell = (strings: TemplateStringsArray, ...values: string[]) => AzureCommand +type AzureCommand = (args: string[]) => Promise type AzureAccount = { readonly name: string; readonly resourceGroup: string } -export async function AzureAuthPlugin(input: { $: AzureShell }): Promise { - const available = Boolean(Bun.which("az", { PATH: process.env.PATH })) +export async function AzureAuthPlugin(): Promise { + 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 : 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 = 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 { + 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() deployments.forEach((deployment) => { diff --git a/packages/opencode/test/plugin/azure.test.ts b/packages/opencode/test/plugin/azure.test.ts index efb4c94f4d3..11e7f3a2c3e 100644 --- a/packages/opencode/test/plugin/azure.test.ts +++ b/packages/opencode/test/plugin/azure.test.ts @@ -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")