Code

/**
 * mcp/skills/detectIdpUsername.ts — detect_idp_username
 *
 * Phase 1 — Windows × Entra + Windows × Okta.
 * Phase 2 (this commit) — macOS × Okta Verify, macOS × Entra (Company
 *                          Portal + Intune SSO + Office license), Jamf
 *                          Connect (works across Okta / Entra / Google
 *                          IDP backends). Re-adds tccCategories:
 *                          ["FullDiskAccess"] to this tool because
 *                          some macOS probes read other apps' plists
 *                          and JSON files under ~/Library/.
 *
 * Purpose
 * -------
 * Reads the device's existing IDP-agent state to discover the user's
 * IDP login username (email / UPN).  Used by cloud-IDP password-reset
 * to auto-populate the username for `request_idemeum_idp_reset` rather
 * than asking the user via chat narration (which works poorly in
 * practice — the conversationIdRef clears on run end).
 *
 * Designed for cross-skill reuse: callers pass `idp` explicitly and
 * the tool does NOT depend on `detect_identity_provider`.  Other
 * skills (identity-auth-repair) can call this directly once they
 * know the IDP.
 *
 * Sources by platform + IDP
 * -------------------------
 * win32 + entra   `dsregcmd /status` → parse "User Name" / "Executing
 *                 Account Name" under the Diagnostic Data / SSO State
 *                 sections.  Reliable only when device is AAD-joined.
 * win32 + okta    `reg query HKCU\Software\Okta\Okta Verify /s` →
 *                 parse `UserName` REG_SZ values from each account
 *                 subkey.
 * win32 + google  Not supported.  Google Credential Provider for
 *                 Windows uses different storage.
 *
 * darwin + entra  Fallback chain (try each, collect ALL hits):
 *                 1. `defaults read com.microsoft.CompanyPortalMac`
 *                    UserPrincipalName / LastSignedInUser / UserEmail
 *                 2. `defaults read
 *                    com.microsoft.CompanyPortalMac.ssoextension`
 *                    SignedInUserUPN / lastSignedInUPN
 *                 3. `~/Library/Group Containers/UBF8T346G9.Office/
 *                    UserInfo.plist` via plutil → JSON
 *                 4. Jamf Connect probes filtered to IDP === "azure" / "entra"
 *
 * darwin + okta   Fallback chain:
 *                 1. `defaults read com.okta.OktaVerify` AccountList /
 *                    LastEnrolledUserEmail
 *                 2. `~/Library/Application Support/Okta Verify/
 *                    OktaVerifyData.json` via fs.readFile + JSON.parse
 *                 3. Jamf Connect probes filtered to IDP === "okta"
 *
 * darwin + google Fallback chain:
 *                 1. Jamf Connect probes filtered to IDP === "google"
 *                    (Google Workspace on macOS has no standalone
 *                    agent that stores the user email persistently;
 *                    Chrome / Google Workspace browser sign-in state
 *                    is not a reliable signal.)
 *
 * Jamf Connect (used across all darwin IDPs):
 *                 a. `dscl . -read /Users/$USER OIDCProvider
 *                    OIDCProviderUserName` — Jamf Connect writes the
 *                    IDP user into the local account schema once
 *                    bound. Most reliable signal, no FDA needed.
 *                 b. `defaults read com.jamf.connect.state` ADUserName /
 *                    NomadIdpUserName — Jamf Connect's user prefs.
 *                 c. `/Library/Preferences/com.jamf.connect.plist`
 *                    via plutil (system-scope, FDA-gated).
 *
 * Plist domains marked RESEARCHED below are documented in public Apple /
 * Microsoft / Okta / Jamf docs but not verified end-to-end against real
 * deployments at the time of writing. Probes use graceful empty-return
 * semantics — a wrong path degrades to "next source", never throws.
 *
 * Confidence ranking
 * ------------------
 * "high"   — direct identity-store probes (dsregcmd UPN on AAD-joined
 *            device; Okta Verify account in user-hive registry;
 *            dscl OIDCProvider on Jamf-Connect-bound macs;
 *            Company Portal UPN from defaults read).
 * "medium" — derived / secondary stores (Office license registration;
 *            Jamf Connect state plist; multi-account Okta Verify
 *            where caller must pick).
 * "low"    — speculative / partial matches (not used in Phase 1 or 2).
 *
 * Return contract
 * ---------------
 *   { primaryUsername: string | null, candidates: [...],
 *     platform, idp, reason? }
 *
 * When `primaryUsername` is null, `reason` explains why.  Callers
 * should NOT throw on unsupported combinations — null + reason is
 * the documented "I don't know" answer.
 *
 * Smoke test
 *   npx tsx -r dotenv/config mcp/skills/detectIdpUsername.ts
 */
 
import { exec }      from "child_process";
import { promisify } from "util";
import * as os       from "os";
import { z }         from "zod";
import type { Idp }  from "./_shared/idp";
 
const execAsync = promisify(exec);
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "detect_idp_username",
  description:
    "Detects the user's IDP login username (email / UPN) from on-device " +
    "IDP-agent state. Used to auto-populate username for cloud-IDP reset " +
    "flows rather than asking the user via chat. Supports Windows × " +
    "{Entra, Okta} (Phase 1) and macOS × {Entra, Okta, Google via Jamf " +
    "Connect} (Phase 2). Phase 2 macOS probes read other apps' plists / " +
    "Application Support JSON files which are gated by Full Disk Access " +
    "on macOS 14+, hence tccCategories. Returns null with a reason for " +
    "unsupported combinations — never throws.",
  riskLevel:       "low",
  destructive:     false,
  requiresConsent: false,
  supportsDryRun:  false,
  affectedScope:   ["user"],
  auditRequired:   false,
  // macOS probes read other apps' Library files (Company Portal,
  // Okta Verify, Jamf Connect state, Office license registration).
  // Some sources (dscl, defaults read of own-user prefs) don't need FDA,
  // but the FDA-gated sources are the safety net for completeness — when
  // FDA is granted all sources run; when not, FDA-required sources return
  // empty and the non-FDA sources still contribute. G4's tccPreflightCheck
  // asks for FDA once upfront rather than silently degrading mid-run.
  tccCategories:   ["FullDiskAccess"],
  schema: {
    idp: z
      .enum(["okta", "entra", "google"])
      .describe(
        "IDP type to probe. Caller passes the value from " +
        "detect_identity_provider.primary (or knows it independently).",
      ),
    tenant: z
      .string()
      .optional()
      .describe(
        "Optional tenant filter — when provided and the probe finds " +
        "multiple accounts, candidates are filtered to those matching " +
        "this tenant. Useful for Okta multi-tenant setups.",
      ),
  },
} as const;
 
// -- Types --------------------------------------------------------------------
 
export interface UsernameCandidate {
  username:   string;
  source:     string;
  confidence: "high" | "medium" | "low";
  tenant?:    string;
}
 
export interface DetectIdpUsernameResult {
  primaryUsername: string | null;
  candidates:      UsernameCandidate[];
  platform:        "darwin" | "win32" | "other";
  idp:             Idp;
  /** Populated when primaryUsername is null — explains why. */
  reason?:         string;
}
 
// -- Platform helper ----------------------------------------------------------
 
function resolvePlatform(): "darwin" | "win32" | "other" {
  const p = os.platform();
  if (p === "darwin") return "darwin";
  if (p === "win32")  return "win32";
  return "other";
}
 
// -- win32 + entra: dsregcmd /status -----------------------------------------
 
/**
 * Parse `dsregcmd /status` output for the user's UPN.
 *
 * Output format varies across Windows versions but the UPN consistently
 * appears under one of these labels:
 *   "User Name"            (Diagnostic Data / SSO State sections)
 *   "Executing Account Name"  (older Windows 10 builds)
 *   "UserPrincipalName"    (some variants)
 *
 * We try each pattern in order and return the first match. Filter out
 * MACHINE\AccountName-style values (no @ sign) — those are local
 * accounts, not UPNs.
 */
function parseDsregcmdUpn(stdout: string): string | null {
  const patterns = [
    /^\s*User Name\s*:\s*(\S+@\S+\.\S+)\s*$/im,
    /^\s*Executing Account Name\s*:\s*(\S+@\S+\.\S+)\s*$/im,
    /^\s*UserPrincipalName\s*:\s*(\S+@\S+\.\S+)\s*$/im,
  ];
 
  for (const re of patterns) {
    const m = stdout.match(re);
    if (m && m[1]) {
      const upn = m[1].trim();
      // Sanity check — must contain @ and a TLD-ish suffix.
      if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(upn)) {
        return upn;
      }
    }
  }
  return null;
}
 
async function probeEntraWin32(): Promise<UsernameCandidate[]> {
  let stdout = "";
  try {
    ({ stdout } = await execAsync("dsregcmd /status", {
      maxBuffer: 2 * 1024 * 1024,
      timeout:   10_000,
    }));
  } catch {
    return [];
  }
 
  // First check the device is actually AAD-joined — a UPN from dsregcmd on
  // a non-joined device is meaningless (could be stale state).
  const aadJoined = /AzureAdJoined\s*:\s*YES/i.test(stdout);
  if (!aadJoined) {
    return [];
  }
 
  const upn = parseDsregcmdUpn(stdout);
  if (!upn) return [];
 
  return [{
    username:   upn,
    source:     "dsregcmd /status (AAD-joined)",
    confidence: "high",
  }];
}
 
// -- win32 + okta: registry probe --------------------------------------------
 
/**
 * Parse `reg query HKCU\Software\Okta\Okta Verify /s` output.
 *
 * Recursive registry dump format is roughly:
 *   HKEY_CURRENT_USER\Software\Okta\Okta Verify\Accounts\<id>
 *       UserName    REG_SZ    alice@example.com
 *       OrgUrl      REG_SZ    https://acme.okta.com
 *       ...
 *   HKEY_CURRENT_USER\Software\Okta\Okta Verify\Accounts\<id2>
 *       UserName    REG_SZ    bob@example.com
 *       ...
 *
 * We extract UserName + OrgUrl pairs by tracking the current key context.
 * Returns 0-N candidates depending on how many accounts are configured.
 *
 * NOTE: Exact registry layout differs across Okta Verify versions. This
 * parser is based on the documented format from Okta Verify 5.x+.
 * Verify on a real install if results look wrong.
 */
function parseOktaVerifyRegistry(stdout: string): UsernameCandidate[] {
  const candidates: UsernameCandidate[] = [];
  let currentTenant: string | undefined;
  let pendingUserName: string | undefined;
 
  const flush = () => {
    if (pendingUserName) {
      candidates.push({
        username:   pendingUserName,
        source:     "Okta Verify registry (HKCU)",
        confidence: "high",
        ...(currentTenant ? { tenant: currentTenant } : {}),
      });
    }
    pendingUserName = undefined;
    currentTenant   = undefined;
  };
 
  const lines = stdout.split(/\r?\n/);
  for (const line of lines) {
    // New key — flush any pending account from the previous key.
    if (/^HKEY_(CURRENT_USER|LOCAL_MACHINE)\\/i.test(line)) {
      flush();
      continue;
    }
    // Value lines look like:  "    UserName    REG_SZ    alice@example.com"
    const userMatch = line.match(/^\s+UserName\s+REG_SZ\s+(\S+@\S+\.\S+)\s*$/i);
    if (userMatch) {
      pendingUserName = userMatch[1].trim();
      continue;
    }
    const orgMatch = line.match(/^\s+OrgUrl\s+REG_SZ\s+https?:\/\/([^/\s]+)/i);
    if (orgMatch) {
      // "acme.okta.com" → "acme"
      const host = orgMatch[1];
      const slug = host.replace(/\.okta(?:preview|-emea)?\.com$/i, "");
      if (slug && slug !== host) {
        currentTenant = slug;
      }
      continue;
    }
  }
  flush(); // final account at EOF
  return candidates;
}
 
async function probeOktaWin32(): Promise<UsernameCandidate[]> {
  // reg query is shell-safe — fixed key, no user input interpolation.
  // /s = recursive (subkeys + values).
  let stdout = "";
  try {
    ({ stdout } = await execAsync(
      `reg query "HKCU\\Software\\Okta\\Okta Verify" /s`,
      { maxBuffer: 2 * 1024 * 1024, timeout: 10_000 },
    ));
  } catch {
    // Key may not exist (Okta Verify not installed / no accounts configured)
    // — return empty rather than throwing.
    return [];
  }
 
  return parseOktaVerifyRegistry(stdout);
}
 
// -- darwin shared helpers ----------------------------------------------------
 
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
 
/** Validate "email-shaped" — must contain @ and a TLD-ish suffix. */
function looksLikeEmail(value: unknown): value is string {
  return typeof value === "string" && EMAIL_RE.test(value.trim());
}
 
/**
 * Run `defaults read <domain> <key>` and return the trimmed stdout, or
 * null on any failure (missing domain, missing key, parse error, etc).
 *
 * `defaults` exits non-zero when domain or key is missing — we swallow
 * those to keep probes silent.
 */
async function runDefaultsRead(
  domain: string,
  key:    string,
): Promise<string | null> {
  // `defaults read` arg-list is shell-safe (no shell interpretation; exec
  // spawns directly). Domain + key come from this file's hardcoded
  // constants — no user input interpolation.
  let stdout = "";
  try {
    ({ stdout } = await execAsync(`defaults read "${domain}" "${key}"`, {
      maxBuffer: 1 * 1024 * 1024,
      timeout:   5_000,
    }));
  } catch {
    return null;
  }
  const trimmed = stdout.trim();
  return trimmed.length > 0 ? trimmed : null;
}
 
/**
 * Try each key in priority order against a single `defaults` domain.
 * First non-empty, email-shaped value wins. Returns null if nothing
 * matches.
 */
async function tryDefaultsKeys(
  domain: string,
  keys:   string[],
): Promise<string | null> {
  for (const key of keys) {
    const v = await runDefaultsRead(domain, key);
    if (v && looksLikeEmail(v)) return v.trim();
  }
  return null;
}
 
/**
 * Run `plutil -convert json -o - <path>` to convert a binary or XML
 * plist into JSON. Returns the parsed object, or null on failure.
 *
 * This is the FDA-gated path — when probing other apps' files under
 * ~/Library/Application Support/ or ~/Library/Group Containers/,
 * macOS 14+ requires FullDiskAccess.
 */
async function tryReadPlistAsJson(
  filePath: string,
): Promise<unknown | null> {
  let stdout = "";
  try {
    ({ stdout } = await execAsync(
      `plutil -convert json -o - "${filePath}"`,
      { maxBuffer: 4 * 1024 * 1024, timeout: 5_000 },
    ));
  } catch {
    return null;
  }
  try {
    return JSON.parse(stdout);
  } catch {
    return null;
  }
}
 
/**
 * Read a JSON file directly (no plutil intermediary). Used for Okta
 * Verify's OktaVerifyData.json which is already JSON-format on disk.
 * Returns parsed object or null on any failure.
 */
async function tryReadJsonFile(
  filePath: string,
): Promise<unknown | null> {
  try {
    const fs = await import("fs/promises");
    const text = await fs.readFile(filePath, "utf8");
    return JSON.parse(text);
  } catch {
    return null;
  }
}
 
/**
 * Recursively walk a parsed plist/JSON object collecting any string
 * value that looks like an email/UPN. Useful for probes where the key
 * name varies across versions or accounts are nested in arrays/dicts.
 */
function collectEmailsFromObject(obj: unknown, out: Set<string> = new Set()): Set<string> {
  if (obj === null || obj === undefined) return out;
  if (typeof obj === "string") {
    if (looksLikeEmail(obj)) out.add(obj.trim());
    return out;
  }
  if (Array.isArray(obj)) {
    for (const item of obj) collectEmailsFromObject(item, out);
    return out;
  }
  if (typeof obj === "object") {
    for (const v of Object.values(obj as Record<string, unknown>)) {
      collectEmailsFromObject(v, out);
    }
  }
  return out;
}
 
// -- darwin × entra probes ----------------------------------------------------
 
/**
 * Probe 1: Microsoft Company Portal user defaults.
 *
 * RESEARCHED domain `com.microsoft.CompanyPortalMac` (newer macOS builds);
 * fallback `com.microsoft.CompanyPortal` (older builds). Known keys vary
 * across Company Portal versions — try all of them.
 */
async function probeEntraCompanyPortalDarwin(): Promise<UsernameCandidate[]> {
  const keys = [
    "UserPrincipalName",
    "UPN",
    "UserEmail",
    "LastSignedInUser",
    "SignedInUser",
  ];
  const domains = [
    "com.microsoft.CompanyPortalMac",
    "com.microsoft.CompanyPortal",
  ];
  for (const domain of domains) {
    const upn = await tryDefaultsKeys(domain, keys);
    if (upn) {
      return [{
        username:   upn,
        source:     "entra-company-portal-defaults",
        confidence: "high",
      }];
    }
  }
  return [];
}
 
/**
 * Probe 2: Microsoft Intune SSO extension defaults.
 *
 * The Enterprise SSO Plug-in for Apple devices ships as part of Company
 * Portal and runs as a Network Extension. Its defaults store the signed-in
 * UPN under various keys across versions.
 */
async function probeEntraIntuneSsoExtensionDarwin(): Promise<UsernameCandidate[]> {
  const keys = [
    "SignedInUserUPN",
    "lastSignedInUPN",
    "PrimaryAccountUPN",
    "AccountUPN",
  ];
  const upn = await tryDefaultsKeys(
    "com.microsoft.CompanyPortalMac.ssoextension",
    keys,
  );
  if (!upn) return [];
  return [{
    username:   upn,
    source:     "entra-intune-sso-extension",
    confidence: "high",
  }];
}
 
/**
 * Probe 3: Microsoft Office license registration (FDA-gated).
 *
 * Office for Mac stores per-user license + identity info under
 * `~/Library/Group Containers/UBF8T346G9.Office/`. The exact file varies
 * (Licenses.plist, UserInfo.plist, LicenseRegistration.json) — we walk
 * the directory for any plist/json and collect email-shaped strings.
 *
 * Confidence is "medium" because Office stores the licensed email which
 * may differ from the user's current Entra UPN (e.g. after a tenant
 * migration the Office license can lag).
 */
async function probeEntraOfficeLicenseDarwin(): Promise<UsernameCandidate[]> {
  const home = os.homedir();
  const officeDir = `${home}/Library/Group Containers/UBF8T346G9.Office`;
  const fs = await import("fs/promises");
 
  // List directory contents — fail silently if missing or unreadable.
  let entries: string[] = [];
  try {
    entries = await fs.readdir(officeDir);
  } catch {
    return [];
  }
 
  const collected = new Set<string>();
  for (const entry of entries) {
    if (!/\.(plist|json)$/i.test(entry)) continue;
    const fullPath = `${officeDir}/${entry}`;
    const parsed = entry.endsWith(".json")
      ? await tryReadJsonFile(fullPath)
      : await tryReadPlistAsJson(fullPath);
    if (parsed !== null) collectEmailsFromObject(parsed, collected);
  }
 
  return [...collected].map((username) => ({
    username,
    source:     "entra-office-license",
    confidence: "medium" as const,
  }));
}
 
// -- darwin × okta probes -----------------------------------------------------
 
/**
 * Probe 1: Okta Verify user defaults.
 *
 * RESEARCHED domain `com.okta.OktaVerify` (also seen as `com.okta.mobile`
 * on newer versions). Known keys vary; the Accounts array is per-version
 * and complex, so we lean on tryDefaultsKeys for simple scalar keys first.
 */
async function probeOktaVerifyDefaultsDarwin(): Promise<UsernameCandidate[]> {
  const keys = [
    "RegisteredEmail",
    "Username",
    "LastEnrolledUserEmail",
    "PrimaryAccountEmail",
  ];
  const domains = [
    "com.okta.OktaVerify",
    "com.okta.mobile",
  ];
  for (const domain of domains) {
    const email = await tryDefaultsKeys(domain, keys);
    if (email) {
      return [{
        username:   email,
        source:     "okta-verify-defaults",
        confidence: "high",
      }];
    }
  }
  return [];
}
 
/**
 * Probe 2: Okta Verify on-disk Application Support data (FDA-gated).
 *
 * RESEARCHED path `~/Library/Application Support/Okta Verify/`. Files
 * vary by version (OktaVerifyData.json, accounts.json, registration.json).
 * Walk the directory + collect email-shaped strings.
 */
async function probeOktaVerifyDataDarwin(): Promise<UsernameCandidate[]> {
  const home = os.homedir();
  const oktaDir = `${home}/Library/Application Support/Okta Verify`;
  const fs = await import("fs/promises");
 
  let entries: string[] = [];
  try {
    entries = await fs.readdir(oktaDir);
  } catch {
    return [];
  }
 
  const collected = new Set<string>();
  for (const entry of entries) {
    if (!/\.(json|plist)$/i.test(entry)) continue;
    const fullPath = `${oktaDir}/${entry}`;
    const parsed = entry.endsWith(".json")
      ? await tryReadJsonFile(fullPath)
      : await tryReadPlistAsJson(fullPath);
    if (parsed !== null) collectEmailsFromObject(parsed, collected);
  }
 
  return [...collected].map((username) => ({
    username,
    source:     "okta-verify-data",
    confidence: "high" as const,
  }));
}
 
// -- darwin × Jamf Connect probes (IDP-aware) ---------------------------------
 
/**
 * Normalize an IDP identifier string from Jamf Connect / dscl output to
 * one of the canonical Idp enum values. Returns null for unrecognized.
 *
 * Jamf Connect reports IDP types as: "okta", "azure", "google",
 * "googleidp", "onelogin", "pingfederate", etc. dscl OIDCProvider
 * uses similar values. We only care about the three IDPs this tool
 * supports.
 */
function normalizeIdp(raw: string): Idp | null {
  const v = raw.toLowerCase().trim();
  if (v === "okta") return "okta";
  if (v === "azure" || v === "azuread" || v === "entra" || v === "entraid") return "entra";
  if (v === "google" || v === "googleidp" || v === "googleworkspace") return "google";
  return null;
}
 
/**
 * Probe a: `dscl . -read /Users/$USER OIDCProvider OIDCProviderUserName`.
 *
 * Jamf Connect writes the IDP user into the local account schema once
 * bound — this is the most reliable Jamf Connect signal and needs no
 * FDA. Output format:
 *   OIDCProvider: Okta
 *   OIDCProviderUserName: alice@example.com
 *
 * Filters by the requested IDP — returns empty if the Jamf-Connect-bound
 * IDP doesn't match `targetIdp`.
 */
async function probeJamfConnectDsclDarwin(
  targetIdp: Idp,
): Promise<UsernameCandidate[]> {
  const userInfo = os.userInfo();
  let stdout = "";
  try {
    ({ stdout } = await execAsync(
      `dscl . -read "/Users/${userInfo.username}" OIDCProvider OIDCProviderUserName`,
      { maxBuffer: 256 * 1024, timeout: 5_000 },
    ));
  } catch {
    return [];
  }
 
  const providerMatch = stdout.match(/^OIDCProvider:\s*(\S+)\s*$/im);
  const userMatch     = stdout.match(/^OIDCProviderUserName:\s*(\S+)\s*$/im);
 
  if (!providerMatch || !userMatch) return [];
 
  const providerIdp = normalizeIdp(providerMatch[1]);
  if (providerIdp !== targetIdp) return [];
 
  const username = userMatch[1].trim();
  if (!looksLikeEmail(username)) return [];
 
  return [{
    username,
    source:     "jamf-connect-dscl-oidc",
    confidence: "high",
  }];
}
 
/**
 * Probe b: `defaults read com.jamf.connect.state` for IDP + user.
 *
 * RESEARCHED domain — Jamf Connect's user-preferences plist. Key names
 * include ADUserName / NomadIdpUserName / IdpUser. IDP type lives in
 * keys like IdpProvider / OidcProvider / OidcDiscoveryURL.
 *
 * Filters by targetIdp — if Jamf Connect reports a different IDP backend,
 * returns empty.
 */
async function probeJamfConnectDefaultsDarwin(
  targetIdp: Idp,
): Promise<UsernameCandidate[]> {
  const domains = [
    "com.jamf.connect.state",
    "com.jamf.connect",
  ];
  const userKeys = [
    "ADUserName",
    "NomadIdpUserName",
    "IdpUser",
    "LastUser",
    "OidcUser",
  ];
  const idpKeys = [
    "IdpProvider",
    "OidcProvider",
    "OidcDiscoveryURL",
  ];
 
  for (const domain of domains) {
    const username = await tryDefaultsKeys(domain, userKeys);
    if (!username) continue;
 
    // Check IDP type — if we can't verify the IDP matches, return empty
    // rather than guess (we don't want to surface an Entra UPN when
    // caller asked for Okta).
    let idpMatches = false;
    for (const idpKey of idpKeys) {
      const idpRaw = await runDefaultsRead(domain, idpKey);
      if (!idpRaw) continue;
      const normalized = normalizeIdp(idpRaw);
      // For OidcDiscoveryURL, infer from URL host (login.microsoftonline.com
      // → entra, etc.).
      const fromUrl =
        idpKey === "OidcDiscoveryURL"
          ? idpFromDiscoveryUrl(idpRaw)
          : normalized;
      if (fromUrl === targetIdp) {
        idpMatches = true;
        break;
      }
    }
    if (!idpMatches) continue;
 
    return [{
      username,
      source:     "jamf-connect-defaults",
      confidence: "high",
    }];
  }
  return [];
}
 
/**
 * Infer IDP from an OIDC discovery URL host.
 *   https://login.microsoftonline.com/<tenant>/v2.0/... → entra
 *   https://acme.okta.com/.well-known/openid-configuration → okta
 *   https://accounts.google.com/.well-known/openid-configuration → google
 */
function idpFromDiscoveryUrl(url: string): Idp | null {
  const lower = url.toLowerCase();
  if (lower.includes("login.microsoftonline.com") || lower.includes("sts.windows.net")) {
    return "entra";
  }
  if (lower.match(/\.okta(preview|-emea)?\.com/)) return "okta";
  if (lower.includes("accounts.google.com")) return "google";
  return null;
}
 
/**
 * Probe c: System-scope Jamf Connect plist (FDA-gated).
 *
 * RESEARCHED path `/Library/Preferences/com.jamf.connect.plist`. Same
 * keys as the user-scope plist; walking via plutil → JSON. Useful when
 * Jamf Connect's user-scope prefs got cleared but the system-scope
 * binding is intact.
 */
async function probeJamfConnectStatePlistDarwin(
  targetIdp: Idp,
): Promise<UsernameCandidate[]> {
  const plistPath = "/Library/Preferences/com.jamf.connect.plist";
  const parsed = await tryReadPlistAsJson(plistPath);
  if (!parsed || typeof parsed !== "object") return [];
 
  const obj = parsed as Record<string, unknown>;
  const username =
    pickStringKey(obj, ["ADUserName", "NomadIdpUserName", "IdpUser", "LastUser", "OidcUser"]);
  if (!username || !looksLikeEmail(username)) return [];
 
  // IDP filter — same as the defaults probe.
  const idpRaw =
    pickStringKey(obj, ["IdpProvider", "OidcProvider"]) ??
    pickStringKey(obj, ["OidcDiscoveryURL"]);
  if (!idpRaw) return [];
 
  const inferredIdp =
    normalizeIdp(idpRaw) ?? idpFromDiscoveryUrl(idpRaw);
  if (inferredIdp !== targetIdp) return [];
 
  return [{
    username,
    source:     "jamf-connect-state-plist",
    confidence: "medium",
  }];
}
 
/** Return the first key from `keys` whose value in `obj` is a non-empty string. */
function pickStringKey(
  obj:  Record<string, unknown>,
  keys: string[],
): string | null {
  for (const key of keys) {
    const v = obj[key];
    if (typeof v === "string" && v.trim().length > 0) return v.trim();
  }
  return null;
}
 
// -- darwin dispatcher --------------------------------------------------------
 
/**
 * Run all macOS probes for the requested IDP, collecting candidates from
 * every source. Each probe is independent — one returning empty does not
 * abort the others. Final ranking is done by `selectPrimary`.
 */
async function probeDarwin(idp: Idp): Promise<UsernameCandidate[]> {
  const probes: Array<Promise<UsernameCandidate[]>> = [];
 
  if (idp === "entra") {
    probes.push(probeEntraCompanyPortalDarwin());
    probes.push(probeEntraIntuneSsoExtensionDarwin());
    probes.push(probeEntraOfficeLicenseDarwin());
  } else if (idp === "okta") {
    probes.push(probeOktaVerifyDefaultsDarwin());
    probes.push(probeOktaVerifyDataDarwin());
  }
  // Jamf Connect probes run for all three IDPs — they filter internally by
  // matching the bound IDP against the requested one.
  probes.push(probeJamfConnectDsclDarwin(idp));
  probes.push(probeJamfConnectDefaultsDarwin(idp));
  probes.push(probeJamfConnectStatePlistDarwin(idp));
 
  const results = await Promise.all(probes);
  return results.flat();
}
 
// -- Result builder -----------------------------------------------------------
 
function selectPrimary(
  candidates: UsernameCandidate[],
  tenantFilter?: string,
): UsernameCandidate | null {
  let pool = candidates;
 
  // Apply tenant filter when supplied — drops candidates that don't match.
  if (tenantFilter && tenantFilter.length > 0) {
    const filtered = candidates.filter(c => c.tenant === tenantFilter);
    if (filtered.length > 0) pool = filtered;
    // If filter eliminates everything, fall back to the full list — better
    // to surface SOMETHING than nothing.
  }
 
  if (pool.length === 0) return null;
 
  // Highest-confidence first; ties broken by order of discovery.
  const ranked = [...pool].sort((a, b) => {
    const rank = (c: UsernameCandidate) =>
      c.confidence === "high" ? 0 : c.confidence === "medium" ? 1 : 2;
    return rank(a) - rank(b);
  });
  return ranked[0];
}
 
// -- Exported run function ----------------------------------------------------
 
export async function run(args: {
  idp:     Idp;
  tenant?: string;
}): Promise<DetectIdpUsernameResult> {
  const platform = resolvePlatform();
  const idp      = args.idp;
 
  // Platform × IDP support matrix (Phase 1).
  // Each cell returns null + reason OR runs a probe.
 
  // darwin: Phase 2 probes (Entra / Okta / Google via Jamf Connect).
  if (platform === "darwin") {
    const candidates = await probeDarwin(idp);
    if (candidates.length === 0) {
      return {
        primaryUsername: null,
        candidates,
        platform,
        idp,
        reason:
          idp === "google"
            ? "No Jamf-Connect-bound Google identity found. Google Workspace " +
              "on macOS has no standalone agent storing the user email — " +
              "ask the user for their Google account email."
            : `No ${idp} identity found on this macOS device. The IDP-agent ` +
              `(Company Portal / Okta Verify / Jamf Connect) may not be ` +
              `installed, or Full Disk Access may be denied. Ask the user ` +
              `for their ${idp} login email.`,
      };
    }
    const primary = selectPrimary(candidates, args.tenant);
    return {
      primaryUsername: primary?.username ?? null,
      candidates,
      platform,
      idp,
      ...(primary === null
        ? { reason: "Probe found candidates but tenant filter excluded all of them." }
        : {}),
    };
  }
 
  // other: unsupported (Linux, etc.).
  if (platform === "other") {
    return {
      primaryUsername: null,
      candidates:      [],
      platform,
      idp,
      reason: `Platform "${os.platform()}" is not supported by this tool.`,
    };
  }
 
  // win32 — run the IDP-specific probe.
  let candidates: UsernameCandidate[] = [];
 
  if (idp === "entra") {
    candidates = await probeEntraWin32();
    if (candidates.length === 0) {
      return {
        primaryUsername: null,
        candidates,
        platform,
        idp,
        reason:
          "Device is not Azure AD-joined OR dsregcmd reported no UPN. " +
          "Ask the user for their Entra login UPN.",
      };
    }
  } else if (idp === "okta") {
    candidates = await probeOktaWin32();
    if (candidates.length === 0) {
      return {
        primaryUsername: null,
        candidates,
        platform,
        idp,
        reason:
          "Okta Verify is not installed OR no accounts are configured " +
          "in the user-hive registry. Ask the user for their Okta email.",
      };
    }
  } else {
    // google — no Phase 1 probe.
    return {
      primaryUsername: null,
      candidates:      [],
      platform,
      idp,
      reason:
        "Google Workspace IDP-username probes are not yet supported. " +
        "Ask the user for their Google account email.",
    };
  }
 
  // Apply tenant filter, pick primary by confidence ranking.
  const primary = selectPrimary(candidates, args.tenant);
 
  return {
    primaryUsername: primary?.username ?? null,
    candidates,
    platform,
    idp,
    ...(primary === null
      ? { reason: "Probe found candidates but tenant filter excluded all of them." }
      : {}),
  };
}
 
// Exported purely for unit tests so they can drive the parsers directly
// without spawning subprocesses.
export const __testing = {
  parseDsregcmdUpn,
  parseOktaVerifyRegistry,
  selectPrimary,
  // darwin helpers
  looksLikeEmail,
  collectEmailsFromObject,
  normalizeIdp,
  idpFromDiscoveryUrl,
  pickStringKey,
};
 
// -- CLI smoke test -----------------------------------------------------------
 
if (false) {
  run({ idp: "entra" })
    .then((r) => console.log(JSON.stringify(r, null, 2)))
    .catch((err: Error) => { console.error(err.message); process.exit(1); });
}