@bridge_gpt/mcp-server 0.2.60 → 0.2.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -96,10 +96,11 @@ import { mapConductorDoctorReportToReadinessChecks, } from "./doctor.js";
96
96
  import { mapInstallStatusChecksToReadinessChecks, } from "../install-doctor.js";
97
97
  import { mapPlanePreflightToReadinessChecks, } from "../plane/preflight.js";
98
98
  import { runConductorInstallDoctor, } from "./install-doctor.js";
99
- import { BRING_UP_SECTION_LABELS } from "./readiness-sections.js";
99
+ import { BRING_UP_SECTION_LABELS, presentRequiredChecks, } from "./readiness-sections.js";
100
100
  import { classifyBringUpFacts } from "./bring-up-facts.js";
101
101
  import { classifyMergeExecutionEligibility, MERGE_EXECUTION_INELIGIBLE_REMEDIATION, MERGE_EXECUTION_UNVERIFIABLE_REMEDIATION, } from "./merge-execution-eligibility.js";
102
102
  import { createReadinessCheck, createReadinessCheckSafely, summarizeReadinessChecks, validateReadinessChecks, } from "../readiness-check.js";
103
+ import { ADMISSION_EVIDENCE_SCHEMA_VERSION, } from "./bridge-api-client.js";
103
104
  /** This report's own schema version. See the module docstring. */
104
105
  export const CONDUCTOR_READINESS_GATE_SCHEMA_VERSION = 1;
105
106
  /** Fixed statement carried in every report, so no consumer can mistake it for a gate. */
@@ -170,7 +171,6 @@ const SERVER_FAILURE_COPY = {
170
171
  const SERVER_REMEDIATIONS = {
171
172
  supervisorSetup: "run `install conductor` (or `setup-epic`) for this repository to write the project-default supervisor posture.",
172
173
  supervisorConfig: "run `install conductor` for this repository to write the project-default supervisor configuration.",
173
- requiredChecks: "configure a non-empty required-check list in done_gate_config; an empty list makes the done gate pass unconditionally.",
174
174
  mergeApproval: "set the merge-approval requirement explicitly in the supervisor configuration rather than leaving it unset.",
175
175
  reviewPolicyMissing: "set the repository review-policy default so the merge path and the code_review gate read the same signal.",
176
176
  reviewPolicyDivergent: "align the repository review-policy default with the done gate's review_state condition, or confirm the difference is intended.",
@@ -252,18 +252,20 @@ export function mapServerReadinessToChecks(readiness, failure = "unreachable") {
252
252
  });
253
253
  // Counts and booleans are safe facts and stay in `detail`; they never become
254
254
  // checks of their own, because there is nothing separate to remediate.
255
- outcomes.push(s.required_checks_empty
256
- ? {
257
- id: "supervisor-required-checks",
258
- status: "warn",
259
- detail: "the required-check list is EMPTY — the done gate would pass unconditionally",
260
- remediation: SERVER_REMEDIATIONS.requiredChecks,
261
- }
262
- : {
263
- id: "supervisor-required-checks",
264
- status: "pass",
265
- detail: `${s.required_checks_count} required check(s) configured`,
266
- });
255
+ //
256
+ // BAPI-1254: a nonempty count is not proof. The shared presentation — the same
257
+ // one the install-time section renders — passes ONLY a server `verified`
258
+ // verdict; a confirmed mismatch, could-not-verify, an empty list, and an older
259
+ // server's silence all fail, with distinct fixed copy.
260
+ const requiredChecks = presentRequiredChecks(s);
261
+ outcomes.push({
262
+ id: "supervisor-required-checks",
263
+ status: requiredChecks.satisfied ? "pass" : "fail",
264
+ detail: requiredChecks.detail,
265
+ ...(requiredChecks.remediation === undefined
266
+ ? {}
267
+ : { remediation: requiredChecks.remediation }),
268
+ });
267
269
  // Auto-merge OFF is a posture, not a gap: an operator may deliberately merge
268
270
  // by hand. Reported, never remediated.
269
271
  outcomes.push({
@@ -309,7 +311,7 @@ export function mapServerReadinessToChecks(readiness, failure = "unreachable") {
309
311
  outcomes.push(ciWorkflowOutcome(readiness));
310
312
  outcomes.push(...unattendedOutcomes(readiness));
311
313
  const byId = new Map(outcomes.map((outcome) => [outcome.id, outcome]));
312
- return SERVER_READINESS_DESCRIPTORS.map(({ id, label }) => {
314
+ const described = SERVER_READINESS_DESCRIPTORS.map(({ id, label }) => {
313
315
  const outcome = byId.get(id);
314
316
  if (!outcome) {
315
317
  return createReadinessCheck({
@@ -332,6 +334,97 @@ export function mapServerReadinessToChecks(readiness, failure = "unreachable") {
332
334
  : { remediation: outcome.remediation ?? SERVER_REMEDIATIONS.olderServer }),
333
335
  });
334
336
  });
337
+ // BAPI-1260 — appended ONLY when the server reported the block, so every
338
+ // pre-BAPI-1260 report keeps exactly the descriptor set above.
339
+ return [...described, ...admissionEvidenceChecks(readiness)];
340
+ }
341
+ /** Human labels for the known BAPI-1260 evidence predicates. */
342
+ const ADMISSION_EVIDENCE_LABELS = {
343
+ unattended_consent: "Launch evidence: unattended consent",
344
+ notify_webhook: "Launch evidence: notification destination",
345
+ repository_readiness: "Launch evidence: repository workflows",
346
+ required_checks: "Launch evidence: required CI checks",
347
+ index_provenance: "Launch evidence: canonical index provenance",
348
+ durable_actor: "Launch evidence: durable actor handshake",
349
+ migration_head: "Launch evidence: database migration head",
350
+ };
351
+ /** Who acts, in fixed prose. Never a value from the payload. */
352
+ const ADMISSION_OWNER_REMEDIATION = {
353
+ operator: "the operator re-runs guided setup (`install conductor --launch-profile <name>`) and re-observes.",
354
+ repository_admin: "a repository administrator resolves it through the account-settings runbook; setup never grants it.",
355
+ bridge_operations: "escalate to Bridge operations; this is not something an operator command can change.",
356
+ };
357
+ /**
358
+ * Project the server's typed launch evidence (BAPI-1260) into `server.*`
359
+ * checks, read-only.
360
+ *
361
+ * The CLIENT decides nothing here: `confirmed` renders pass, `not_applicable`
362
+ * renders skip, and `denied` and `unable_to_verify` both render fail with
363
+ * DIFFERENT detail — a known denial and an inability to verify call for
364
+ * different actions, and neither authorizes a launch. The server is the only
365
+ * admission authority; this row is what it said, not a verdict of our own.
366
+ */
367
+ export function admissionEvidenceChecks(readiness) {
368
+ const evidence = readiness.admission_evidence;
369
+ if (evidence === undefined)
370
+ return [];
371
+ if (evidence === null || evidence.schema_version !== ADMISSION_EVIDENCE_SCHEMA_VERSION) {
372
+ return [
373
+ createReadinessCheckSafely({
374
+ id: "server.admission-evidence",
375
+ source: "server",
376
+ label: "Launch evidence",
377
+ status: "fail",
378
+ detail: evidence === null
379
+ ? "the server reported no launch evidence, so no mandatory prerequisite is proven"
380
+ : "the server's launch-evidence schema is not one this CLI renders; treat every prerequisite as unverified",
381
+ remediation: "upgrade the CLI or the Bridge API deploy so both speak the same evidence schema.",
382
+ }),
383
+ ];
384
+ }
385
+ return evidence.records.map((record) => admissionEvidenceCheck(record));
386
+ }
387
+ function admissionEvidenceCheck(record) {
388
+ const slug = record.predicate.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
389
+ const label = ADMISSION_EVIDENCE_LABELS[record.predicate] ?? "Launch evidence";
390
+ const reason = record.reason ? ` (${record.reason})` : "";
391
+ const remediation = ADMISSION_OWNER_REMEDIATION[record.next_action_owner ?? "operator"] ?? ADMISSION_OWNER_REMEDIATION.operator;
392
+ switch (record.verdict) {
393
+ case "confirmed":
394
+ return createReadinessCheckSafely({
395
+ id: `server.admission-${slug}`,
396
+ source: "server",
397
+ label,
398
+ status: "pass",
399
+ detail: `confirmed by ${record.source}; fresh for ${record.max_age_seconds}s after observation`,
400
+ });
401
+ case "not_applicable":
402
+ return createReadinessCheckSafely({
403
+ id: `server.admission-${slug}`,
404
+ source: "server",
405
+ label,
406
+ status: "skip",
407
+ detail: `not applicable${reason}`,
408
+ });
409
+ case "denied":
410
+ return createReadinessCheckSafely({
411
+ id: `server.admission-${slug}`,
412
+ source: "server",
413
+ label,
414
+ status: "fail",
415
+ detail: `DENIED by ${record.source}${reason} — dependent activation, dispatch, and privileged operations are held`,
416
+ remediation,
417
+ });
418
+ default:
419
+ return createReadinessCheckSafely({
420
+ id: `server.admission-${slug}`,
421
+ source: "server",
422
+ label,
423
+ status: "fail",
424
+ detail: `could not be verified${reason} — unknown is not safe, so dependent work is held`,
425
+ remediation,
426
+ });
427
+ }
335
428
  }
336
429
  /**
337
430
  * The three BAPI-1153 bring-up safety rows.
@@ -1370,6 +1370,12 @@ export async function upsertBapiCredential(repoName, apiKey, deps) {
1370
1370
  const action = hadKey ? "updated" : "created";
1371
1371
  // Preserve sibling secret names; replace ONLY BAPI_API_KEY.
1372
1372
  const nextEntry = { ...(existingEntry ?? {}), BAPI_API_KEY: trimmedKey };
1373
+ // BAPI-1260: an epic-launch binding approves ONE key for one endpoint. A
1374
+ // different key is a different credential, so its approval does not carry
1375
+ // over — guided setup re-binds it explicitly.
1376
+ if (existingEntry && existingEntry.BAPI_API_KEY !== trimmedKey) {
1377
+ delete nextEntry[BAPI_EPIC_LAUNCH_BOUND_BASE_URL_FIELD];
1378
+ }
1373
1379
  const next = { ...base, [target]: nextEntry };
1374
1380
  const dir = path.dirname(primaryPath);
1375
1381
  const suffix = (deps.tempSuffix ?? defaultTempSuffix)();
@@ -1408,3 +1414,153 @@ export async function upsertBapiCredential(repoName, apiKey, deps) {
1408
1414
  return { ok: true, path: primaryPath, target, action, migratedFallback };
1409
1415
  }, (error) => ({ ok: false, path: primaryPath, target, kind: "lock-error", error }));
1410
1416
  }
1417
+ // ---------------------------------------------------------------------------
1418
+ // Epic-launch credential binding (BAPI-1260)
1419
+ // ---------------------------------------------------------------------------
1420
+ /**
1421
+ * NON-SECRET sibling field under `bapi:<repo>` naming the one normalized Bridge
1422
+ * API base URL the stored `BAPI_API_KEY` was approved for by guided setup.
1423
+ *
1424
+ * Every pre-existing reader asks for `BAPI_API_KEY` only, so the extra field is
1425
+ * invisible to them — existing credential clients and the public decoding of
1426
+ * the store are unchanged. Only the epic-launch contract below reads it.
1427
+ */
1428
+ export const BAPI_EPIC_LAUNCH_BOUND_BASE_URL_FIELD = "BAPI_EPIC_LAUNCH_BOUND_BASE_URL";
1429
+ /** Trim and drop trailing slashes — the executor's own base-URL normalization. */
1430
+ function normalizeBoundBaseUrl(raw) {
1431
+ if (typeof raw !== "string")
1432
+ return null;
1433
+ const normalized = raw.trim().replace(/\/+$/, "");
1434
+ return normalized.length > 0 ? normalized : null;
1435
+ }
1436
+ /**
1437
+ * Resolve the `BAPI_API_KEY` for an EPIC LAUNCH against one selected target.
1438
+ *
1439
+ * Unlike {@link resolveBapiCredentials}, the credential is coupled to the
1440
+ * normalized base URL AND the repository, so a repo-only credential is never
1441
+ * silently sent to a different endpoint:
1442
+ *
1443
+ * - An ambient `BAPI_API_KEY` is used only when the same environment also sets
1444
+ * `BAPI_BASE_URL` to exactly the target — the pair is its provenance.
1445
+ * Anything else is `ambient-key-unbound`, and the key is NOT sent.
1446
+ * - A stored key is used only when its entry carries
1447
+ * {@link BAPI_EPIC_LAUNCH_BOUND_BASE_URL_FIELD} equal to the target. An
1448
+ * entry with no binding is `binding-unbound` (a legacy credential, upgraded
1449
+ * once through guided setup); a different binding is `binding-mismatch`.
1450
+ *
1451
+ * Read-only and migration-free: it never writes the store. Errors name the
1452
+ * logical target and the store path only — never a secret or the entry's
1453
+ * contents.
1454
+ */
1455
+ export async function resolveEpicLaunchCredential(repoName, targetBaseUrl, deps) {
1456
+ const target = normalizeBoundBaseUrl(targetBaseUrl);
1457
+ const bundleKey = `bapi:${repoName}`;
1458
+ if (target === null || repoName.trim().length === 0) {
1459
+ return { ok: false, kind: "missing-key", error: "An epic launch needs a target and a repository." };
1460
+ }
1461
+ const envKey = (deps.env.BAPI_API_KEY ?? "").trim();
1462
+ if (envKey.length > 0) {
1463
+ const envBase = normalizeBoundBaseUrl(deps.env.BAPI_BASE_URL);
1464
+ if (envBase !== target) {
1465
+ return {
1466
+ ok: false,
1467
+ kind: "ambient-key-unbound",
1468
+ error: "BAPI_API_KEY is set in this environment, but BAPI_BASE_URL does not name the selected " +
1469
+ "target, so the key's endpoint cannot be shown. It was not sent. Unset BAPI_API_KEY to use " +
1470
+ "the credential approved by setup, or set BAPI_BASE_URL to the selected target.",
1471
+ };
1472
+ }
1473
+ return { ok: true, apiKey: envKey, source: "env", binding: "env-paired" };
1474
+ }
1475
+ // Read the stored entry exactly as the shared resolver would, but with the
1476
+ // environment cleared so the ambient branch above stays the only env path.
1477
+ // XDG_CONFIG_HOME is kept: it locates the store, and dropping it would read the
1478
+ // key from a different file than the binding below (and than setup wrote).
1479
+ const stored = await resolveCredentialBundle(bundleKey, ["BAPI_API_KEY"], {
1480
+ ...deps,
1481
+ env: { XDG_CONFIG_HOME: deps.env.XDG_CONFIG_HOME },
1482
+ });
1483
+ if (!stored.ok)
1484
+ return { ok: false, kind: stored.kind, error: stored.error };
1485
+ const bound = await readStoredBinding(bundleKey, deps);
1486
+ const primaryPath = getPrimaryCredentialStorePath(deps);
1487
+ if (bound === null) {
1488
+ return {
1489
+ ok: false,
1490
+ kind: "binding-unbound",
1491
+ error: `The stored credential "${bundleKey}" has not been approved for any Bridge API endpoint ` +
1492
+ `(${primaryPath}). Run guided setup once to bind it: install conductor --launch-profile <name> ` +
1493
+ `--base-url ${target}.`,
1494
+ };
1495
+ }
1496
+ if (bound !== target) {
1497
+ return {
1498
+ ok: false,
1499
+ kind: "binding-mismatch",
1500
+ error: `The stored credential "${bundleKey}" is approved for a different Bridge API endpoint than the ` +
1501
+ "selected target. It was not sent. Re-run guided setup to re-bind it explicitly.",
1502
+ };
1503
+ }
1504
+ return { ok: true, apiKey: stored.values.BAPI_API_KEY, source: "file", binding: "store-bound" };
1505
+ }
1506
+ /**
1507
+ * Read the non-secret bound base URL for one bundle, or `null` when the store,
1508
+ * the entry, or the field is absent or unreadable. Never throws.
1509
+ */
1510
+ export async function readStoredBinding(bundleKey, deps) {
1511
+ try {
1512
+ const resolution = await resolveCredentialStorePath(deps);
1513
+ if (!resolution.found)
1514
+ return null;
1515
+ const parsed = parseCredentialStoreJson(await deps.readFile(resolution.path));
1516
+ if (!parsed.ok)
1517
+ return null;
1518
+ return normalizeBoundBaseUrl(parsed.value[bundleKey]?.[BAPI_EPIC_LAUNCH_BOUND_BASE_URL_FIELD]);
1519
+ }
1520
+ catch {
1521
+ return null;
1522
+ }
1523
+ }
1524
+ /**
1525
+ * The explicit ONE-TIME upgrade path: approve the stored `bapi:<repo>` key for
1526
+ * one normalized base URL. Called only by guided setup after it has re-verified
1527
+ * access and administrative authorization at that target — never by a launch.
1528
+ *
1529
+ * Writes only the non-secret binding field, under the shared lock, through the
1530
+ * durable replacement; every other entry and sibling field is preserved. A store
1531
+ * with no key for the repository is refused rather than creating one.
1532
+ */
1533
+ export async function bindEpicLaunchCredential(repoName, targetBaseUrl, deps) {
1534
+ const target = `bapi:${repoName.trim()}`;
1535
+ const baseUrl = normalizeBoundBaseUrl(targetBaseUrl);
1536
+ if (repoName.trim().length === 0 || baseUrl === null) {
1537
+ return { ok: false, target, kind: "invalid-input", error: "A repository and a target are required." };
1538
+ }
1539
+ const primaryPath = getPrimaryCredentialStorePath(deps);
1540
+ return withCredentialStoreLock(deps, async () => {
1541
+ const primary = await readCredentialStoreJsonIfPresent(primaryPath, deps);
1542
+ if (primary.state === "error") {
1543
+ return { ok: false, target, kind: primary.kind, error: primary.error };
1544
+ }
1545
+ const entry = primary.state === "present" ? primary.value[target] : undefined;
1546
+ if (!entry || typeof entry.BAPI_API_KEY !== "string" || entry.BAPI_API_KEY.trim() === "") {
1547
+ return {
1548
+ ok: false,
1549
+ target,
1550
+ kind: "not-found",
1551
+ error: `No stored BAPI_API_KEY under "${target}" in ${primaryPath} to bind.`,
1552
+ };
1553
+ }
1554
+ const previous = normalizeBoundBaseUrl(entry[BAPI_EPIC_LAUNCH_BOUND_BASE_URL_FIELD]);
1555
+ if (previous === baseUrl)
1556
+ return { ok: true, target, action: "unchanged", path: primaryPath };
1557
+ const next = {
1558
+ ...(primary.state === "present" ? primary.value : {}),
1559
+ [target]: { ...entry, [BAPI_EPIC_LAUNCH_BOUND_BASE_URL_FIELD]: baseUrl },
1560
+ };
1561
+ const written = await durablyReplaceCredentialStoreJson(primaryPath, next, deps);
1562
+ if (!written.ok)
1563
+ return { ok: false, target, kind: written.kind, error: written.error };
1564
+ return { ok: true, target, action: previous === null ? "bound" : "rebound", path: primaryPath };
1565
+ }, (error) => ({ ok: false, target, kind: "lock-error", error }));
1566
+ }
package/build/doctor.js CHANGED
@@ -34,7 +34,8 @@ import { resolveExecutorAgentAdapter } from "./agent-launchers/executor-adapter-
34
34
  import { parseDefaultOnEnvFlag } from "./env-flags.js";
35
35
  import { createBridgeApiUrls } from "./bridge-api-urls.js";
36
36
  import { probeToolSurface } from "./tool-surface-gating.js";
37
- import { resolveBapiCredentials } from "./credential-store.js";
37
+ import { readStoredBinding, resolveBapiCredentials } from "./credential-store.js";
38
+ import { listEpicLaunchProfiles } from "./epic-launch-profile.js";
38
39
  import { resolveConductorBridgeApiAccess } from "./conductor/bridge-api-client.js";
39
40
  import { getLiveRepositoryConductors, getParseDispatcherHealth, } from "./epic-shared/bridge-client.js";
40
41
  import { resolveRequiredStartTicketsRepoName } from "./start-tickets-repo.js";
@@ -1614,12 +1615,78 @@ export function formatOperationalAdvisoryReport(diagnostic) {
1614
1615
  return lines.join("\n");
1615
1616
  }
1616
1617
  /**
1617
- * CLI entry for the read-only `doctor` subcommand. Returns a process exit code.
1618
- * Help returns 0; parser errors return 1; otherwise it prints the report and
1619
- * returns 0 when every required prerequisite is present, non-zero when any is
1620
- * missing or the platform is unsupported. Never installs, mutates, spawns tabs,
1621
- * or starts the MCP server.
1618
+ * Read-only inventory of this host's epic-launch profiles and whether each
1619
+ * one's credential binding matches its target. It reports; it never binds or
1620
+ * migrates anything.
1622
1621
  */
1622
+ export async function collectEpicLaunchProfileDiagnostic(deps) {
1623
+ const listing = await listEpicLaunchProfiles(deps);
1624
+ if (!listing.ok)
1625
+ return { store: "unreadable", message: listing.message };
1626
+ if (!listing.present)
1627
+ return { store: "absent" };
1628
+ const host = deps.hostname();
1629
+ const profiles = [];
1630
+ for (const entry of listing.entries) {
1631
+ if (!entry.validation.ok) {
1632
+ profiles.push({ name: entry.name, usable: false, problem: entry.validation.kind });
1633
+ continue;
1634
+ }
1635
+ const p = entry.validation.profile;
1636
+ const bound = await readStoredBinding(p.credentialRef.target, {
1637
+ env: deps.env,
1638
+ homedir: deps.homedir,
1639
+ platform: process.platform,
1640
+ readFile: deps.readFile,
1641
+ // Presence probe via read: the binding reader treats any failure as unbound.
1642
+ stat: async (filePath) => {
1643
+ await deps.readFile(filePath);
1644
+ return { mode: 0o600 };
1645
+ },
1646
+ });
1647
+ const hostMatches = p.host === host;
1648
+ const credentialBinding = bound === null ? "unbound" : bound === p.baseUrl ? "matches" : "different";
1649
+ profiles.push({
1650
+ name: p.name,
1651
+ usable: hostMatches && credentialBinding === "matches",
1652
+ target: p.baseUrl,
1653
+ repo: p.repoName,
1654
+ mode: p.executionMode,
1655
+ hostMatches,
1656
+ credentialBinding,
1657
+ });
1658
+ }
1659
+ return { store: "present", profiles };
1660
+ }
1661
+ /** Render the section, or `null` when this host has no profile store. */
1662
+ export function formatEpicLaunchProfileDiagnosticReport(diagnostic) {
1663
+ if (diagnostic.store === "absent")
1664
+ return null;
1665
+ const lines = ["", "Epic launch profiles (read-only)", ""];
1666
+ if (diagnostic.store === "unreadable") {
1667
+ lines.push(` [warn] ${diagnostic.message}`);
1668
+ return lines.join("\n");
1669
+ }
1670
+ if (diagnostic.profiles.length === 0)
1671
+ lines.push(" (none)");
1672
+ for (const p of diagnostic.profiles) {
1673
+ if (p.problem !== undefined) {
1674
+ lines.push(` [fail] ${p.name}: ${p.problem} — re-run \`install conductor --launch-profile ${p.name}\`.`);
1675
+ continue;
1676
+ }
1677
+ const tag = p.usable ? "[ok] " : "[fail]";
1678
+ lines.push(` ${tag} ${p.name}: ${p.target} repo=${p.repo} mode=${p.mode}`);
1679
+ if (p.hostMatches === false)
1680
+ lines.push(" approved on a different host — profiles are host-local.");
1681
+ if (p.credentialBinding === "unbound") {
1682
+ lines.push(" stored credential not yet approved for this target — re-run guided setup to bind it.");
1683
+ }
1684
+ else if (p.credentialBinding === "different") {
1685
+ lines.push(" stored credential is approved for a DIFFERENT target — a launch will refuse to send it.");
1686
+ }
1687
+ }
1688
+ return lines.join("\n");
1689
+ }
1623
1690
  export async function runDoctorCli(argv, overrides = {}) {
1624
1691
  const log = overrides.log ?? ((m) => console.log(m));
1625
1692
  const errorLog = overrides.errorLog ?? ((m) => console.error(m));
@@ -1919,6 +1986,27 @@ export async function runDoctorCli(argv, overrides = {}) {
1919
1986
  }));
1920
1987
  }
1921
1988
  }
1989
+ // Advisory epic-launch profile section (BAPI-1260). Read-only: it never binds,
1990
+ // migrates, or rewrites a profile or a credential — setup does that, with
1991
+ // consent. Never affects the exit code.
1992
+ if (overrides.launchProfiles !== false) {
1993
+ try {
1994
+ const injectedFs = deps;
1995
+ const report = formatEpicLaunchProfileDiagnosticReport(await collectEpicLaunchProfileDiagnostic({
1996
+ env: overrides.launchProfiles?.env ?? deps.env,
1997
+ homedir: overrides.launchProfiles?.homedir ?? injectedFs.homedir ?? os.homedir,
1998
+ readFile: overrides.launchProfiles?.readFile ??
1999
+ injectedFs.readFile ??
2000
+ ((p) => readFile(p, "utf-8")),
2001
+ hostname: overrides.launchProfiles?.hostname ?? os.hostname,
2002
+ }));
2003
+ if (report !== null)
2004
+ log(report);
2005
+ }
2006
+ catch {
2007
+ /* advisory only: an unexpected failure drops the section, never the doctor */
2008
+ }
2009
+ }
1922
2010
  // Advisory operational-advisories section (BAPI-963). Read by the operator
1923
2011
  // asking "what is already running?" right after the readiness sections above.
1924
2012
  //