@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.
- package/README.md +16 -8
- package/build/commands.generated.js +3 -3
- package/build/conductor/bridge-api-client.js +353 -2
- package/build/conductor/merge-execution-contract.js +30 -5
- package/build/conductor/merge-execution-eligibility.js +10 -9
- package/build/conductor/readiness-sections.js +99 -16
- package/build/conductor/readiness.js +108 -15
- package/build/credential-store.js +156 -0
- package/build/doctor.js +94 -6
- package/build/drive-epic.js +124 -12
- package/build/epic-implementer-bin.js +13 -13
- package/build/epic-integration-pr.js +61 -15
- package/build/epic-launch-profile.js +455 -0
- package/build/executor/cli.js +9 -0
- package/build/executor/credentials.js +17 -1
- package/build/executor/http-client.js +45 -1
- package/build/executor/integration-job.js +759 -0
- package/build/executor/job-runner.js +92 -9
- package/build/executor/observation.js +114 -7
- package/build/executor/runner.js +101 -1
- package/build/executor/types.js +43 -0
- package/build/index.js +338 -114
- package/build/install-bridge-conductor.js +398 -6
- package/build/install-bridge.js +92 -16
- package/build/merge-workflow-config.js +54 -0
- package/build/merge-workflow-drift.js +35 -4
- package/build/merge-workflow.js +386 -2
- package/build/pipelines.generated.js +1 -1
- package/build/plane/supervisor.js +6 -0
- package/build/readme.generated.js +1 -1
- package/build/setup-epic.js +103 -32
- package/build/version.generated.js +3 -3
- package/package.json +1 -1
|
@@ -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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
:
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1618
|
-
*
|
|
1619
|
-
*
|
|
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
|
//
|