haxudev b7876c9609 Microsoft Foundry: split provider modules and harden runtime auth
Split the provider into focused auth, onboarding, CLI, runtime, and shared modules so the Entra ID flow is easier to review and maintain. Add Foundry-specific tests, preserve Azure CLI error details, move token refresh off the synchronous request path, and dedupe concurrent Entra token refreshes so onboarding and GPT-5 runtime behavior stay reliable.
2026-03-19 23:32:28 +08:00

163 lines
4.4 KiB
TypeScript

import { execFile, execFileSync, spawn } from "node:child_process";
import type { AzAccessToken, AzAccount } from "./shared.js";
import { COGNITIVE_SERVICES_RESOURCE } from "./shared.js";
export function execAz(args: string[]): string {
return execFileSync("az", args, {
encoding: "utf-8",
timeout: 30_000,
shell: process.platform === "win32",
}).trim();
}
export async function execAzAsync(args: string[]): Promise<string> {
return await new Promise<string>((resolve, reject) => {
execFile(
"az",
args,
{
encoding: "utf-8",
timeout: 30_000,
shell: process.platform === "win32",
},
(error, stdout, stderr) => {
if (error) {
const details = `${String(stderr ?? "").trim()} ${String(stdout ?? "").trim()}`.trim();
reject(
new Error(
details ? `${error.message}: ${details}` : error.message,
),
);
return;
}
resolve(String(stdout).trim());
},
);
});
}
export function isAzCliInstalled(): boolean {
try {
execAz(["version", "--output", "none"]);
return true;
} catch {
return false;
}
}
export function getLoggedInAccount(): AzAccount | null {
try {
return JSON.parse(execAz(["account", "show", "--output", "json"])) as AzAccount;
} catch {
return null;
}
}
export function listSubscriptions(): AzAccount[] {
try {
const subs = JSON.parse(execAz(["account", "list", "--output", "json", "--all"])) as AzAccount[];
return subs.filter((sub) => sub.state === "Enabled");
} catch {
return [];
}
}
export function getAccessTokenResult(params?: {
subscriptionId?: string;
tenantId?: string;
}): AzAccessToken {
const args = [
"account",
"get-access-token",
"--resource",
COGNITIVE_SERVICES_RESOURCE,
"--output",
"json",
];
if (params?.subscriptionId) {
args.push("--subscription", params.subscriptionId);
} else if (params?.tenantId) {
args.push("--tenant", params.tenantId);
}
return JSON.parse(execAz(args)) as AzAccessToken;
}
export async function getAccessTokenResultAsync(params?: {
subscriptionId?: string;
tenantId?: string;
}): Promise<AzAccessToken> {
const args = [
"account",
"get-access-token",
"--resource",
COGNITIVE_SERVICES_RESOURCE,
"--output",
"json",
];
if (params?.subscriptionId) {
args.push("--subscription", params.subscriptionId);
} else if (params?.tenantId) {
args.push("--tenant", params.tenantId);
}
return JSON.parse(await execAzAsync(args)) as AzAccessToken;
}
export async function azLoginDeviceCode(): Promise<void> {
return azLoginDeviceCodeWithOptions({});
}
export async function azLoginDeviceCodeWithOptions(params: {
tenantId?: string;
allowNoSubscriptions?: boolean;
}): Promise<void> {
return new Promise<void>((resolve, reject) => {
const maxCapturedLoginOutputChars = 8_000;
const args = [
"login",
"--use-device-code",
...(params.tenantId ? ["--tenant", params.tenantId] : []),
...(params.allowNoSubscriptions ? ["--allow-no-subscriptions"] : []),
];
const child = spawn("az", args, {
stdio: ["inherit", "pipe", "pipe"],
shell: process.platform === "win32",
});
const stdoutChunks: string[] = [];
const stderrChunks: string[] = [];
const appendBoundedChunk = (chunks: string[], text: string): void => {
if (!text) {
return;
}
chunks.push(text);
let totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
while (totalLength > maxCapturedLoginOutputChars && chunks.length > 0) {
const removed = chunks.shift();
totalLength -= removed?.length ?? 0;
}
};
child.stdout?.on("data", (chunk) => {
const text = String(chunk);
appendBoundedChunk(stdoutChunks, text);
process.stdout.write(text);
});
child.stderr?.on("data", (chunk) => {
const text = String(chunk);
appendBoundedChunk(stderrChunks, text);
process.stderr.write(text);
});
child.on("close", (code) => {
if (code === 0) {
resolve();
return;
}
const output = [...stderrChunks, ...stdoutChunks].join("").trim();
reject(
new Error(
output ? `az login exited with code ${code}: ${output}` : `az login exited with code ${code}`,
),
);
});
child.on("error", reject);
});
}