diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 722d13682fa5..47e6869da9f9 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -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[] diff --git a/packages/core/src/database/migration/20261007190000_azure_cli_external_credential.ts b/packages/core/src/database/migration/20261007190000_azure_cli_external_credential.ts new file mode 100644 index 000000000000..ccc3d00e4a19 --- /dev/null +++ b/packages/core/src/database/migration/20261007190000_azure_cli_external_credential.ts @@ -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 diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index ea8665a49bbf..2df9c40356ab 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -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 { +function prepareProviderURL( + model: RuntimeInfo, + baseURL: string, +): Effect.Effect { 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) => diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index 9934f7e5d759..eb562af09c23 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -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", diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 33e350175856..b5c088bbd1df 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -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 = ( @@ -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* () { diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index 9fe69ef791fd..686dc4dc3344 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -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. diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index 82b80250e980..067d6dec0a44 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -64,13 +64,13 @@ type AzureRequest = { type Route = (request: AzureRequest) => Response | Promise // 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(), }) diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index f8feefdb95cb..9b0be2a6953d 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -1011,9 +1011,9 @@ function StringChoiceField(props: { {(description) => ( - - {description()} - + + {description()} + )}