mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-10 07:11:25 +03:00
feat(core): connect Azure through the Azure CLI as an external credential (#53794)
This commit is contained in:
+2
@@ -47,6 +47,7 @@ import m44 from "./migration/20260819222447_session_viewed_state.js"
|
||||
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
|
||||
import m46 from "./migration/20260910120000_clear_v1_session_permission.js"
|
||||
import m47 from "./migration/20260923013825_project_time_active.js"
|
||||
import m48 from "./migration/20261007190000_azure_cli_external_credential.js"
|
||||
|
||||
export const migrations = [
|
||||
m00,
|
||||
@@ -97,4 +98,5 @@ export const migrations = [
|
||||
m45,
|
||||
m46,
|
||||
m47,
|
||||
m48,
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
|
||||
// Azure CLI connections were OAuth credentials holding a copy of the CLI's token; they now reference the CLI as an
|
||||
// external credential source. Dropping the token fields keeps the method and the resource in metadata.
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20261007190000_azure_cli_external_credential",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`
|
||||
UPDATE \`credential\`
|
||||
SET \`value\` = json_remove(json_set(\`value\`, '$.type', 'external'), '$.access', '$.refresh', '$.expires')
|
||||
WHERE \`integration_id\` = 'azure'
|
||||
AND json_extract(\`value\`, '$.type') = 'oauth'
|
||||
AND json_extract(\`value\`, '$.methodID') = 'azure-cli'
|
||||
`)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default migration
|
||||
@@ -207,7 +207,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
const settings = yield* prepareProviderSettings(
|
||||
resolved,
|
||||
Provider.mergeOverlay(resolved.settings, {
|
||||
...nativeCredentialSettings(resolved.package ?? "", credential),
|
||||
...nativeCredentialSettings(resolved, resolved.package ?? "", credential),
|
||||
...credential?.metadata,
|
||||
...configuration,
|
||||
}) ?? {},
|
||||
@@ -225,7 +225,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
const settings = {
|
||||
...(credential ? Struct.omit(mapped, ["accessToken", "apiKey", "authToken"]) : mapped),
|
||||
...(resolved.canonical === undefined ? {} : { provider: resolved.canonical }),
|
||||
...nativeCredentialSettings(specifier, credential),
|
||||
...nativeCredentialSettings(resolved, specifier, credential),
|
||||
headers: resolved.headers,
|
||||
body: resolved.body,
|
||||
}
|
||||
@@ -282,7 +282,10 @@ function prepareProviderSettings(
|
||||
)
|
||||
}
|
||||
|
||||
function prepareProviderURL(model: RuntimeInfo, baseURL: string): Effect.Effect<string, UnresolvedProviderVariablesError> {
|
||||
function prepareProviderURL(
|
||||
model: RuntimeInfo,
|
||||
baseURL: string,
|
||||
): Effect.Effect<string, UnresolvedProviderVariablesError> {
|
||||
if (!baseURL.includes("${")) return Effect.succeed(baseURL)
|
||||
const prepared = baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => process.env[name] ?? placeholder)
|
||||
const failure = unresolvedProviderVariables(model, prepared)
|
||||
@@ -299,20 +302,27 @@ function unresolvedProviderVariables(model: RuntimeInfo, baseURL: string) {
|
||||
})
|
||||
}
|
||||
|
||||
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
|
||||
const nativeCredentialSettings = (model: RuntimeInfo, specifier: string, credential: Credential.Value | undefined) => {
|
||||
if (!credential) return {}
|
||||
// The saved profile reaches the package through metadata; SigV4 keeps an ambient bearer token from taking over.
|
||||
if (credential.type === "external")
|
||||
return specifier.startsWith("@opencode/ai/providers/amazon-bedrock") ? { auth: "sigv4" } : {}
|
||||
if (credential.type === "key") return { apiKey: credential.key }
|
||||
if (credential.type === "oauth") return tokenSettings(specifier, credential.access)
|
||||
// The saved profile reaches the package through metadata; SigV4 keeps an ambient bearer token from taking over.
|
||||
if (specifier.startsWith("@opencode/ai/providers/amazon-bedrock")) return { auth: "sigv4" }
|
||||
// The Azure plugin's request hooks replace this with an Entra ID token from the Azure CLI; it only gets the
|
||||
// request past the package's own credential check.
|
||||
if (model.providerID === Provider.ID.azure) return tokenSettings(specifier, "azure-cli")
|
||||
return {}
|
||||
}
|
||||
|
||||
const tokenSettings = (specifier: string, token: string) => {
|
||||
if (specifier === "@opencode/ai/providers/anthropic" || specifier === "@opencode/ai/providers/anthropic-compatible")
|
||||
return { authToken: credential.access }
|
||||
return { authToken: token }
|
||||
if (
|
||||
specifier === "@opencode/ai/providers/google-vertex" ||
|
||||
specifier.startsWith("@opencode/ai/providers/google-vertex/")
|
||||
)
|
||||
return { accessToken: credential.access }
|
||||
return { apiKey: credential.access }
|
||||
return { accessToken: token }
|
||||
return { apiKey: token }
|
||||
}
|
||||
|
||||
const unsupported = (model: RuntimeInfo) =>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Clock, Effect, FiberHandle, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { ChildProcess } from "effect/unstable/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"
|
||||
@@ -49,8 +52,18 @@ const decodeManagementDeployment = Schema.decodeUnknownOption(
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const ResourceQuery = Schema.Struct({ query: Schema.String })
|
||||
const ResourceQuery = Schema.Struct({
|
||||
query: Schema.String,
|
||||
options: Schema.optional(Schema.Struct({ $top: Schema.Number })),
|
||||
})
|
||||
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 }
|
||||
|
||||
@@ -65,6 +78,7 @@ 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
|
||||
@@ -118,61 +132,59 @@ export function make(
|
||||
)
|
||||
|
||||
const available = Boolean(which("az"))
|
||||
const form = () =>
|
||||
iife(() => {
|
||||
if (resolveResourceName(configured) || typeof configured?.baseURL === "string") return
|
||||
return Form.Fields.make([
|
||||
{
|
||||
type: "string",
|
||||
key: "resourceName",
|
||||
title: "Enter Azure Resource Name",
|
||||
placeholder: "e.g. my-models",
|
||||
required: true,
|
||||
},
|
||||
])
|
||||
})
|
||||
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) => {
|
||||
editor.method.update({
|
||||
integrationID: Provider.ID.azure,
|
||||
method: { type: "key", label: "API key", form: form() },
|
||||
method: {
|
||||
type: "key",
|
||||
label: "API key",
|
||||
form: configuredResource
|
||||
? undefined
|
||||
: Form.Fields.make([
|
||||
{
|
||||
type: "string",
|
||||
key: "resourceName",
|
||||
title: "Enter Azure Resource Name",
|
||||
placeholder: "e.g. my-models",
|
||||
required: true,
|
||||
},
|
||||
]),
|
||||
},
|
||||
})
|
||||
if (!available) return
|
||||
editor.method.update({
|
||||
integrationID: Provider.ID.azure,
|
||||
method: {
|
||||
id: methodID,
|
||||
type: "oauth",
|
||||
type: "external",
|
||||
label: "Microsoft Entra ID (Azure CLI)",
|
||||
form: form(),
|
||||
form: configuredResource
|
||||
? undefined
|
||||
: Form.Fields.make([
|
||||
{
|
||||
type: "string",
|
||||
key: "resourceName",
|
||||
title: "Azure resource",
|
||||
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 }
|
||||
}),
|
||||
},
|
||||
]),
|
||||
},
|
||||
authorize: (answer) =>
|
||||
Effect.succeed({
|
||||
mode: "auto" as const,
|
||||
url: "",
|
||||
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"))
|
||||
const current = yield* token(cognitiveScope)
|
||||
return Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
access: current.access,
|
||||
refresh: "azure-cli",
|
||||
expires: current.expires,
|
||||
metadata: { resourceName },
|
||||
})
|
||||
}),
|
||||
}),
|
||||
refresh: (credential) =>
|
||||
token(cognitiveScope).pipe(
|
||||
Effect.map((current) =>
|
||||
Credential.OAuth.make({ ...credential, access: current.access, expires: current.expires }),
|
||||
),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -235,7 +247,7 @@ export function make(
|
||||
|
||||
const resourceDeployments = Effect.fn("AzurePlugin.resourceDeployments")(function* (
|
||||
url: string,
|
||||
credential: Credential.Key | Credential.OAuth,
|
||||
credential: Credential.Key | Credential.External,
|
||||
) {
|
||||
return yield* http
|
||||
.execute(
|
||||
@@ -244,7 +256,7 @@ export function make(
|
||||
HttpClientRequest.setHeader("User-Agent", App.useragent(ctx.app)),
|
||||
credential.type === "key"
|
||||
? HttpClientRequest.setHeader("api-key", credential.key)
|
||||
: HttpClientRequest.bearerToken(credential.access),
|
||||
: HttpClientRequest.bearerToken((yield* token(cognitiveScope)).access),
|
||||
),
|
||||
)
|
||||
.pipe(
|
||||
@@ -266,8 +278,8 @@ export function make(
|
||||
// data-plane version 2022-12-01 has it; later versions dropped `/deployments` and keep `/models`, which lists
|
||||
// models the resource can deploy rather than its deployments.
|
||||
// https://github.com/Azure/azure-rest-api-specs/blob/main/specification/cognitiveservices/data-plane/OpenAIAuthoring/stable/2022-12-01/azureopenai.json
|
||||
const deployments = (url: string, resource: string, credential: Credential.Key | Credential.OAuth) =>
|
||||
credential.type === "oauth"
|
||||
const deployments = (url: string, resource: string, credential: Credential.Key | Credential.External) =>
|
||||
credential.type === "external"
|
||||
? managementDeployments(resource).pipe(Effect.catch(() => resourceDeployments(url, credential)))
|
||||
: resourceDeployments(url, credential)
|
||||
|
||||
@@ -310,8 +322,8 @@ export function make(
|
||||
.pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (
|
||||
!credential ||
|
||||
credential.type === "external" ||
|
||||
(credential.type === "oauth" && credential.methodID !== methodID)
|
||||
credential.type === "oauth" ||
|
||||
(credential.type === "external" && credential.methodID !== methodID)
|
||||
)
|
||||
return
|
||||
const found = yield* deployments(url, name, credential).pipe(
|
||||
@@ -353,6 +365,43 @@ export function make(
|
||||
: resolveResourceName(provider.settings, loaded.resource)
|
||||
|
||||
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 (
|
||||
@@ -432,7 +481,7 @@ export function make(
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
if (credential?.type !== "oauth" || credential.methodID !== methodID) return
|
||||
if (credential?.type !== "external" || credential.methodID !== methodID) return
|
||||
const target = new URL(url)
|
||||
const scope =
|
||||
target.hostname.endsWith(".services.ai.azure.com") && !target.pathname.startsWith("/models")
|
||||
@@ -493,12 +542,23 @@ function credentialResource(credential: Credential.Value | undefined) {
|
||||
const resource =
|
||||
credential?.type === "key"
|
||||
? (credential.configuration?.resourceName ?? credential.metadata?.resourceName)
|
||||
: credential?.methodID === methodID
|
||||
: credential?.type === "external" && credential.methodID === methodID
|
||||
? credential.metadata?.resourceName
|
||||
: 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",
|
||||
|
||||
@@ -20,6 +20,7 @@ import workspaceMigration from "@opencode/core/database/migration/20260808023530
|
||||
import executionClaimsMigration from "@opencode/core/database/migration/20260811161259_execution_claim_attempts"
|
||||
import sessionInboxMigration from "@opencode/core/database/migration/20260812181746_session_inbox"
|
||||
import sessionViewedStateMigration from "@opencode/core/database/migration/20260819222447_session_viewed_state"
|
||||
import azureCliCredentialMigration from "@opencode/core/database/migration/20261007190000_azure_cli_external_credential"
|
||||
import { Global } from "@opencode/util/global"
|
||||
|
||||
const run = <A, E>(
|
||||
@@ -531,6 +532,37 @@ describe("DatabaseMigration", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("moves Azure CLI connections saved as OAuth credentials to external credentials", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* DatabaseMigration.apply(db)
|
||||
const now = Date.now()
|
||||
const oauth = { type: "oauth", methodID: "azure-cli", access: "token", refresh: "azure-cli", expires: 123 }
|
||||
yield* db.run(sql`
|
||||
INSERT INTO credential (id, integration_id, label, value, time_created, time_updated) VALUES
|
||||
('cli', 'azure', 'Azure CLI', ${JSON.stringify({ ...oauth, metadata: { resourceName: "my-models" } })}, ${now}, ${now}),
|
||||
('cli-configured', 'azure', 'Azure CLI', ${JSON.stringify(oauth)}, ${now}, ${now}),
|
||||
('key', 'azure', 'API key', ${JSON.stringify({ type: "key", key: "secret" })}, ${now}, ${now}),
|
||||
('other', 'openai', 'OAuth', ${JSON.stringify(oauth)}, ${now}, ${now})
|
||||
`)
|
||||
|
||||
yield* db.run(sql`DELETE FROM migration WHERE id = ${azureCliCredentialMigration.id}`)
|
||||
yield* DatabaseMigration.applyOnly(db, [azureCliCredentialMigration])
|
||||
|
||||
expect(yield* db.all(sql`SELECT id, value FROM credential ORDER BY id`)).toEqual([
|
||||
{
|
||||
id: "cli",
|
||||
value: JSON.stringify({ type: "external", methodID: "azure-cli", metadata: { resourceName: "my-models" } }),
|
||||
},
|
||||
{ id: "cli-configured", value: JSON.stringify({ type: "external", methodID: "azure-cli" }) },
|
||||
{ id: "key", value: JSON.stringify({ type: "key", key: "secret" }) },
|
||||
{ id: "other", value: JSON.stringify(oauth) },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("rolls back a failed migration without recording it", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -732,6 +732,43 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lets Azure CLI requests past package auth for the Azure plugin to authorize", () =>
|
||||
withConfigEnv({}, () =>
|
||||
Effect.gen(function* () {
|
||||
const credential = Credential.External.make({
|
||||
type: "external",
|
||||
methodID: Integration.MethodID.make("azure-cli"),
|
||||
metadata: { resourceName: "cli-resource" },
|
||||
})
|
||||
const azure = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/azure"), { providerID: Provider.ID.azure, headers: {}, body: {} }),
|
||||
credential,
|
||||
)
|
||||
const foundry = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/anthropic"), {
|
||||
providerID: Provider.ID.azure,
|
||||
settings: { baseURL: "https://cli-resource.services.ai.azure.com/anthropic/v1" },
|
||||
headers: {},
|
||||
body: {},
|
||||
}),
|
||||
credential,
|
||||
)
|
||||
const authorize = (resolved: LanguageModel) =>
|
||||
resolved.route.auth.apply({
|
||||
request: LLM.request({ model: resolved, prompt: "Hello" }),
|
||||
method: "POST",
|
||||
url: "https://cli-resource.openai.azure.com/openai/v1/responses",
|
||||
body: "{}",
|
||||
headers: Headers.empty,
|
||||
})
|
||||
|
||||
expect(azure.route.endpoint.baseURL).toBe("https://cli-resource.openai.azure.com/openai/v1")
|
||||
expect((yield* authorize(azure))["api-key"]).toBe("azure-cli")
|
||||
expect((yield* authorize(foundry)).authorization).toBe("Bearer azure-cli")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("does not project API key metadata into the request body", () =>
|
||||
Effect.gen(function* () {
|
||||
// V1 auth.json stored connect-form answers as API key metadata, and the legacy import preserves them there.
|
||||
|
||||
@@ -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 }) =>
|
||||
const fakeAzure = (routes: { resource?: Route; management?: Route; resources?: () => unknown[] }) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
const requests: AzureRequest[] = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: (raw) => {
|
||||
fetch: async (raw) => {
|
||||
const url = new URL(raw.url)
|
||||
const request = {
|
||||
method: raw.method,
|
||||
@@ -81,8 +81,12 @@ const fakeAzure = (routes: { resource?: Route; management?: Route }) =>
|
||||
}
|
||||
requests.push(request)
|
||||
if (request.path.startsWith("/openai/deployments")) return routes.resource?.(request) ?? notFound()
|
||||
if (request.path.startsWith("/providers/Microsoft.ResourceGraph/"))
|
||||
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()
|
||||
return Response.json({ data: [{ id: resourceID }] })
|
||||
}
|
||||
return routes.management?.(request) ?? notFound()
|
||||
},
|
||||
})
|
||||
@@ -121,7 +125,14 @@ 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))
|
||||
yield* setEnv({ PATH: `${directory}${windows ? ";" : ":"}${process.env.PATH}` })
|
||||
// The CLI writes its profile with a byte order mark.
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(`${directory}/azure/azureProfile.json`, `\uFEFF${JSON.stringify({ subscriptions: [{ id: "sub" }] })}`),
|
||||
)
|
||||
yield* setEnv({
|
||||
PATH: `${directory}${windows ? ";" : ":"}${process.env.PATH}`,
|
||||
AZURE_CONFIG_DIR: `${directory}/azure`,
|
||||
})
|
||||
const commands: string[][] = []
|
||||
const fake = AppProcess.Service.of({
|
||||
...processes,
|
||||
@@ -146,13 +157,10 @@ const fakeAzureCli = Effect.fn(function* (respond: (args: readonly string[]) =>
|
||||
}
|
||||
})
|
||||
|
||||
const cliCredential = (options: { access?: string; expires?: number } = {}) =>
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
const cliCredential = () =>
|
||||
Credential.External.make({
|
||||
type: "external",
|
||||
methodID: Integration.MethodID.make("azure-cli"),
|
||||
access: options.access ?? "stored-token",
|
||||
refresh: "azure-cli",
|
||||
expires: options.expires ?? Date.now() + hour,
|
||||
metadata: { resourceName: "test-resource" },
|
||||
})
|
||||
|
||||
@@ -231,18 +239,84 @@ describe("AzurePlugin connecting", () => {
|
||||
yield* setEnv({ PATH: "/nonexistent" })
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
expect(required(yield* integrations.get(azureID)).methods.some((method) => method.type === "oauth")).toBe(false)
|
||||
expect(required(yield* integrations.get(azureID)).methods.some((method) => method.type === "external")).toBe(
|
||||
false,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("offers the resources the Azure CLI can reach and accepts a typed resource name", () =>
|
||||
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 },
|
||||
],
|
||||
})
|
||||
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?.options?.length ?? 0) > 0))
|
||||
expect(listed).toMatchObject({
|
||||
key: "resourceName",
|
||||
custom: true,
|
||||
options: [{ value: "alpha", label: "alpha", description: "models · eastus" }],
|
||||
})
|
||||
|
||||
const external = (resourceName: string) =>
|
||||
integrations.connection.external({
|
||||
integrationID: azureID,
|
||||
methodID: Integration.MethodID.make("azure-cli"),
|
||||
answer: { resourceName },
|
||||
})
|
||||
expect(yield* external("not a hostname").pipe(Effect.flip)).toBeInstanceOf(Integration.AuthorizationError)
|
||||
yield* external("typed-resource")
|
||||
const credentials = yield* Credential.Service
|
||||
expect((yield* credentials.list(azureID)).map((item) => item.value)).toEqual([
|
||||
Credential.External.make({
|
||||
type: "external",
|
||||
methodID: Integration.MethodID.make("azure-cli"),
|
||||
metadata: { resourceName: "typed-resource" },
|
||||
}),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
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", () => {
|
||||
it.live("starts without calling Azure, even with an expired token, then lists deployments", () =>
|
||||
it.live("starts without calling Azure, then lists deployments", () =>
|
||||
Effect.gen(function* () {
|
||||
const cli = yield* fakeAzureCli()
|
||||
const azure = yield* fakeAzure({ management: () => managementPage([managed("gpt-5-mini", "gpt-5-mini")]) })
|
||||
yield* seedCatalog
|
||||
yield* connect(cliCredential({ access: "expired-token", expires: Date.now() - hour }))
|
||||
yield* connect(cliCredential())
|
||||
yield* addPlugin(azure.endpoints).pipe(cli.provide)
|
||||
|
||||
expect(cli.commands).toEqual([])
|
||||
@@ -329,7 +403,7 @@ describe("AzurePlugin discovery", () => {
|
||||
const cli = yield* fakeAzureCli()
|
||||
const azure = yield* fakeAzure({
|
||||
resource: (request) =>
|
||||
request.authorization === "Bearer stored-token"
|
||||
request.authorization === "Bearer https://cognitiveservices.azure.com/.default-token"
|
||||
? resourceList(listed("gpt-5-mini", "gpt-5-mini"))
|
||||
: unauthorized(),
|
||||
})
|
||||
|
||||
@@ -1011,9 +1011,9 @@ function StringChoiceField(props: {
|
||||
</text>
|
||||
<Show when={row.description}>
|
||||
{(description) => (
|
||||
<text paddingLeft={3} fg={theme.text.muted}>
|
||||
{description()}
|
||||
</text>
|
||||
<box paddingLeft={3}>
|
||||
<text fg={theme.text.muted}>{description()}</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
Reference in New Issue
Block a user