@bridge_gpt/mcp-server 0.2.60 → 0.2.65

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.
Files changed (89) hide show
  1. package/README.md +58 -11
  2. package/build/agent-launchers/claude-executor-adapter.js +134 -3
  3. package/build/agent-launchers/executor-adapter.js +54 -2
  4. package/build/agents.generated.js +10 -1
  5. package/build/bounded-wait.js +32 -1
  6. package/build/commands.generated.js +8 -7
  7. package/build/conductor/bridge-api-client.js +678 -14
  8. package/build/conductor/bring-up-facts.js +3 -1
  9. package/build/conductor/doctor.js +23 -14
  10. package/build/conductor/epic-child-continuation.js +324 -0
  11. package/build/conductor/epic-implementer-cli.js +142 -17
  12. package/build/conductor/git-hooks.js +529 -118
  13. package/build/conductor/hook-runtime-qualification.js +340 -0
  14. package/build/conductor/merge-execution-contract.js +30 -5
  15. package/build/conductor/merge-execution-eligibility.js +10 -9
  16. package/build/conductor/readiness-sections.js +104 -20
  17. package/build/conductor/readiness.js +129 -25
  18. package/build/conductor/recovery-cli.js +354 -13
  19. package/build/conductor/recovery-operations.js +251 -5
  20. package/build/conductor/repair-contract.js +8 -0
  21. package/build/conductor-bundle-artifacts.js +1 -0
  22. package/build/credential-store.js +194 -0
  23. package/build/docs.generated.js +2 -1
  24. package/build/doctor.js +161 -7
  25. package/build/drive-epic-key.js +12 -0
  26. package/build/drive-epic.js +1318 -111
  27. package/build/epic-coherence-input-cli.js +137 -0
  28. package/build/epic-coherence-input.js +920 -0
  29. package/build/epic-coherence-input.test-fixtures.js +175 -0
  30. package/build/epic-implementer-bin.js +35 -29
  31. package/build/epic-implementer-bundle-cli.js +14 -1
  32. package/build/epic-integration-pr.js +61 -15
  33. package/build/epic-launch-profile.js +464 -0
  34. package/build/epic-run-status.js +5 -0
  35. package/build/epic-shared/bridge-client.js +46 -4
  36. package/build/executor/cli.js +48 -0
  37. package/build/executor/completion-contract.js +423 -0
  38. package/build/executor/completion-supervision.js +286 -0
  39. package/build/executor/credentials.js +19 -1
  40. package/build/executor/deps.js +65 -1
  41. package/build/executor/env.js +6 -0
  42. package/build/executor/heartbeat.js +13 -3
  43. package/build/executor/http-client.js +117 -1
  44. package/build/executor/integration-job.js +759 -0
  45. package/build/executor/job-errors.js +48 -0
  46. package/build/executor/job-runner.js +1386 -35
  47. package/build/executor/job-types.js +10 -2
  48. package/build/executor/observation.js +114 -7
  49. package/build/executor/preflight.js +99 -0
  50. package/build/executor/process-containment.js +756 -0
  51. package/build/executor/process.js +235 -5
  52. package/build/executor/prompt-spec.js +262 -1
  53. package/build/executor/provider-checks.js +69 -0
  54. package/build/executor/rebase-finalization.js +351 -0
  55. package/build/executor/reconcile-overlap-job.js +77 -84
  56. package/build/executor/required-verification.js +313 -0
  57. package/build/executor/required-work-ledger.js +1121 -0
  58. package/build/executor/results.js +142 -0
  59. package/build/executor/resume-pre-spawn.js +87 -4
  60. package/build/executor/runner.js +246 -1
  61. package/build/executor/test-declaration.js +20 -0
  62. package/build/executor/types.js +55 -0
  63. package/build/executor/worker-command.js +49 -13
  64. package/build/executor/worker-finalization.js +88 -11
  65. package/build/executor/worker-guard-hook.js +29 -4
  66. package/build/executor/worktree.js +13 -0
  67. package/build/executor-launch.js +658 -0
  68. package/build/index.js +772 -954
  69. package/build/install-bridge-conductor.js +622 -40
  70. package/build/install-bridge.js +92 -16
  71. package/build/merge-pull-request.js +1 -1
  72. package/build/merge-workflow-config.js +54 -0
  73. package/build/merge-workflow-drift.js +35 -4
  74. package/build/merge-workflow.js +386 -2
  75. package/build/pipelines.generated.js +9 -8
  76. package/build/plan-discovery.js +137 -0
  77. package/build/plan-epic-implementer-eligibility.js +2 -2
  78. package/build/plane/cli.js +37 -3
  79. package/build/plane/member-logs.js +3 -0
  80. package/build/plane/member-roster.js +7 -1
  81. package/build/plane/shutdown.js +59 -1
  82. package/build/plane/supervisor.js +148 -0
  83. package/build/readme.generated.js +1 -1
  84. package/build/scripts/executor-protocol-contract-driver.js +13 -3
  85. package/build/setup-epic.js +130 -48
  86. package/build/version.generated.js +3 -3
  87. package/build/worker-guard-hook-bin.js +2 -2
  88. package/docs/CONDUCTOR.md +116 -2
  89. package/package.json +2 -1
@@ -18,9 +18,11 @@
18
18
  * callers: `recovery-cli.ts` and `plane/shutdown.ts`.
19
19
  */
20
20
  import { randomUUID } from "crypto";
21
- import { adoptCurrentHeadAndUnparkTicket, completeMergeChild, ConductorBridgeApiError, dispatchRemediation, fetchEpicRunState, mergeChild, patchPolicy, resumeRun, safeDiagnosticMessage, scopeCatchUp, scopeReparse, stopEpicRun, unparkEpicTicket, updateEpicRunStatus, } from "./bridge-api-client.js";
21
+ import { adoptCurrentHeadAndUnparkTicket, completeMergeChild, ConductorBridgeApiError, dispatchRemediation, fetchEpicRunState, mergeChild, patchPolicy, resumeRun, safeDiagnosticMessage, scopeCatchUp, scopeReparse, stopEpicRun, unparkEpicTicket, updateEpicRunStatus, fetchChildContinuationStatus, postContinueParkedChild, REVIEW_HEAD_REFUSAL_CODES, } from "./bridge-api-client.js";
22
22
  import { DEFAULT_GATE_NAME } from "./git-ci-types.js";
23
+ import { clearContinuationReference, computeContinuationFingerprint, isFullSha, newContinuationRequestId, readContinuationReference, writeContinuationReference, } from "./epic-child-continuation.js";
23
24
  import { resolveLocalMergeCredential, resolveLocalMergeMethod, runApprovedLocalMerge, } from "./local-merge.js";
25
+ import { RESUME_RUN_UNCONFIRMED_MESSAGE } from "./repair-contract.js";
24
26
  /**
25
27
  * Bounded total attempts for a ticket-recovery CAS retry loop. Each attempt
26
28
  * begins with a fresh, authoritative state read — so this bounds full
@@ -179,6 +181,17 @@ async function ticketRecoveryWithRetry(access, options, mutate) {
179
181
  });
180
182
  }
181
183
  catch (err) {
184
+ const refusal = reviewHeadRefusal(err);
185
+ if (refusal !== null) {
186
+ return {
187
+ ok: false,
188
+ kind: "refused",
189
+ epicRunId: options.epicRunId,
190
+ ticketKey: options.ticketKey,
191
+ errorCode: refusal.errorCode,
192
+ message: refusal.message,
193
+ };
194
+ }
182
195
  return {
183
196
  ok: false,
184
197
  kind: "unavailable",
@@ -188,12 +201,14 @@ async function ticketRecoveryWithRetry(access, options, mutate) {
188
201
  };
189
202
  }
190
203
  if (result.ok) {
204
+ const recovery = result.ticket_status.review_head_recovery;
191
205
  return {
192
206
  ok: true,
193
207
  kind: "unparked",
194
208
  epicRunId: options.epicRunId,
195
209
  ticketKey: options.ticketKey,
196
210
  status: result.ticket_status.status,
211
+ ...(recovery ? { reviewHeadRecovery: recovery } : {}),
197
212
  };
198
213
  }
199
214
  // `result.kind === "cas-conflict"`: a concurrent write moved the ticket's
@@ -207,6 +222,31 @@ async function ticketRecoveryWithRetry(access, options, mutate) {
207
222
  ticketKey: options.ticketKey,
208
223
  };
209
224
  }
225
+ /**
226
+ * BAPI-1310 (E1): fixed operator sentences for the review-head refusal codes.
227
+ * Chosen by code so no server or provider text reaches the terminal.
228
+ */
229
+ const REVIEW_HEAD_REFUSAL_MESSAGES = {
230
+ REVIEW_HEAD_ADOPTION_REQUIRED: "the ticket is parked on an unexplained review-head change; accept the current " +
231
+ "head with `epic-implementer adopt-current-head-and-unpark` (keeps spent repair " +
232
+ "allowances, resumes at CI). A generic unpark or remediation is refused",
233
+ REVIEW_HEAD_EPISODE_CONFLICT: "the ticket is no longer on the stop this request was made for; re-check its " +
234
+ "status (explain-run) before trying again",
235
+ REVIEW_HEAD_STATE_INVALID: "the ticket's stored recovery state cannot support an allowance-preserving " +
236
+ "adoption",
237
+ REVIEW_HEAD_BINDING_CHANGED: "the ticket's pull request could not be confirmed open with an unchanged identity",
238
+ REVIEW_HEAD_LIVE_WORK: "an executor job is, or may be, active for this ticket; retry when it has finished",
239
+ REVIEW_HEAD_UNAVAILABLE: "the current head or the ticket specification could not be read; retry shortly",
240
+ };
241
+ /** Map a thrown client error to an E1 refusal, or `null` for anything else. */
242
+ function reviewHeadRefusal(err) {
243
+ if (!(err instanceof ConductorBridgeApiError) || err.status !== 409)
244
+ return null;
245
+ const code = err.errorCode;
246
+ if (typeof code !== "string" || !REVIEW_HEAD_REFUSAL_CODES.includes(code))
247
+ return null;
248
+ return { errorCode: code, message: REVIEW_HEAD_REFUSAL_MESSAGES[code] ?? "refused" };
249
+ }
210
250
  /**
211
251
  * Unpark a parked `needs_human` ticket, with a bounded authoritative-reread
212
252
  * retry on a concurrent `row_version` bump.
@@ -385,9 +425,17 @@ export async function patchPolicyRepair(access, options, deps = {}) {
385
425
  }
386
426
  }
387
427
  /**
388
- * Resume a paused run, or a quarantined one with `clearQuarantine`. The server
389
- * re-evaluates activation and owns the control-generation CAS retry; nothing here
390
- * reads or sends a generation.
428
+ * Resume a paused run, a quarantined one with `clearQuarantine`, or a run held by
429
+ * an eligible integration block. The server re-evaluates activation and owns the
430
+ * control-generation CAS retry; nothing here reads or sends a generation. It makes
431
+ * exactly ONE mutation call and returns a parsed success verbatim — no read, no
432
+ * retry, no poll, and no legacy `/resume` fallback.
433
+ *
434
+ * Only a structured 4xx is an authoritative refusal (the server decided and wrote
435
+ * nothing). A 5xx — even one carrying an `error_code` — a timeout, a dropped
436
+ * connection, or an unparseable/unexpected 2xx cannot prove whether the resume
437
+ * committed, so each is `unavailable` with {@link RESUME_RUN_UNCONFIRMED_MESSAGE}.
438
+ * Other repair verbs keep {@link classifyRepairError} unchanged.
391
439
  */
392
440
  export async function resumeRunRepair(access, options, deps = {}) {
393
441
  try {
@@ -395,6 +443,204 @@ export async function resumeRunRepair(access, options, deps = {}) {
395
443
  return { ok: true, result: await call(access, options) };
396
444
  }
397
445
  catch (err) {
398
- return classifyRepairError(err, "resume-run request failed");
446
+ if (err instanceof ConductorBridgeApiError &&
447
+ typeof err.status === "number" &&
448
+ err.status >= 400 &&
449
+ err.status < 500 &&
450
+ err.errorCode) {
451
+ return classifyRepairError(err, "resume-run request failed");
452
+ }
453
+ return { ok: false, kind: "unavailable", message: RESUME_RUN_UNCONFIRMED_MESSAGE };
454
+ }
455
+ }
456
+ const AUTHORITATIVE_OUTCOMES = new Set([
457
+ "committed",
458
+ "already_continued",
459
+ "stale_historical_episode",
460
+ "refused",
461
+ "unsupported",
462
+ ]);
463
+ /**
464
+ * Look the request's receipt up exactly once. Returns a decided result only when
465
+ * a durable receipt for THIS request id proves the commit; anything else is
466
+ * `null` (still unconfirmed). Never re-reads CAS tokens and never mutates.
467
+ */
468
+ async function lookupOwnReceipt(access, reference, fetchStatus) {
469
+ let status;
470
+ try {
471
+ status = await fetchStatus(access, {
472
+ epicRunId: reference.bindings.epicRunId,
473
+ ticketKey: reference.bindings.ticketKey,
474
+ requestId: reference.requestId,
475
+ });
476
+ }
477
+ catch {
478
+ return null;
479
+ }
480
+ if (!status.found || status.receipt === null)
481
+ return null;
482
+ if (status.receipt.request_id !== reference.requestId)
483
+ return null;
484
+ const outcome = status.outcome === "stale_historical_episode" ? "stale_historical_episode" : "committed";
485
+ return {
486
+ outcome,
487
+ reason_code: outcome === "committed" ? null : "stale_episode",
488
+ explanation: outcome === "committed"
489
+ ? "The receipt proves this request's continuation committed."
490
+ : "The receipt proves this request's continuation committed; the child has since parked again.",
491
+ replayed: true,
492
+ retryable: false,
493
+ receipt: status.receipt,
494
+ current: status.current,
495
+ };
496
+ }
497
+ /**
498
+ * Continue a parked child through the server's atomic continuation, at most one
499
+ * POST per invocation.
500
+ *
501
+ * A pending recovery reference for this child is REUSED as-is — same request id,
502
+ * same bindings — so a lost response is resolved by a replay the server answers
503
+ * from its receipt, and a restarted process can never approve a new head or
504
+ * episode by re-reading the child. Only when no reference exists are the
505
+ * bindings read (once), fingerprinted, and stored BEFORE the POST is sent.
506
+ *
507
+ * After a transport failure, a malformed success body, or a 5xx, exactly one
508
+ * receipt lookup follows. No second POST, no new request id, no polling for
509
+ * completion, and never a fallback to unpark.
510
+ */
511
+ export async function continueChildOnce(access, options, deps = {}) {
512
+ const fetchStatus = deps.fetchStatus ?? fetchChildContinuationStatus;
513
+ const post = deps.post ?? postContinueParkedChild;
514
+ const store = deps.store ?? {};
515
+ let reference = await readContinuationReference(options.epicRunId, options.ticketKey, store);
516
+ if (reference !== null && reference.bindings.repoName !== access.repoName) {
517
+ reference = null;
518
+ }
519
+ const reusedReference = reference !== null;
520
+ if (reference === null) {
521
+ let status;
522
+ try {
523
+ status = await fetchStatus(access, {
524
+ epicRunId: options.epicRunId,
525
+ ticketKey: options.ticketKey,
526
+ });
527
+ }
528
+ catch (err) {
529
+ return { kind: "unavailable", message: safeDiagnosticMessage(err, "could not read the child") };
530
+ }
531
+ const current = status.current;
532
+ if (!current.parked ||
533
+ !current.continuation_available ||
534
+ current.parked_gate === null ||
535
+ current.park_generation === null) {
536
+ return { kind: "not_continuable", current };
537
+ }
538
+ const head = current.expected_head_sha === null ? null : current.expected_head_sha.toLowerCase();
539
+ if (head !== null && !isFullSha(head)) {
540
+ return { kind: "unavailable", message: "the child's bound head is not a full SHA" };
541
+ }
542
+ const bindings = {
543
+ repoName: access.repoName,
544
+ epicRunId: status.epic_run_id,
545
+ ticketKey: status.ticket_key,
546
+ parkedGate: current.parked_gate,
547
+ expectedRowVersion: current.row_version,
548
+ expectedHeadSha: head,
549
+ sourceParkGeneration: current.park_generation,
550
+ };
551
+ reference = {
552
+ version: 1,
553
+ bindings,
554
+ requestId: (deps.newRequestId ?? newContinuationRequestId)(),
555
+ requestFingerprint: computeContinuationFingerprint(bindings),
556
+ createdAt: new Date().toISOString(),
557
+ };
558
+ try {
559
+ // Also keyed by the caller's own addressing so the next invocation (which
560
+ // may use the epic key rather than the resolved id) finds it.
561
+ await writeContinuationReference(reference, store);
562
+ if (options.epicRunId !== bindings.epicRunId || options.ticketKey !== bindings.ticketKey) {
563
+ await writeContinuationReference(reference, store, {
564
+ epicRunId: options.epicRunId,
565
+ ticketKey: options.ticketKey,
566
+ });
567
+ }
568
+ }
569
+ catch {
570
+ return {
571
+ kind: "unavailable",
572
+ message: "could not store the local recovery reference, so the request was not sent",
573
+ };
574
+ }
575
+ }
576
+ const ref = reference;
577
+ const clear = async () => {
578
+ await clearContinuationReference(options.epicRunId, options.ticketKey, store);
579
+ await clearContinuationReference(ref.bindings.epicRunId, ref.bindings.ticketKey, store);
580
+ };
581
+ const outcome = await post(access, {
582
+ bindings: ref.bindings,
583
+ requestId: ref.requestId,
584
+ requestFingerprint: ref.requestFingerprint,
585
+ reason: options.reason,
586
+ });
587
+ if (outcome.kind === "result" && AUTHORITATIVE_OUTCOMES.has(outcome.result.outcome)) {
588
+ await clear();
589
+ return { kind: "decided", requestId: ref.requestId, reusedReference, result: outcome.result };
590
+ }
591
+ if (outcome.kind === "refused_http") {
592
+ await clear();
593
+ return {
594
+ kind: "refused_http",
595
+ requestId: ref.requestId,
596
+ status: outcome.status,
597
+ errorCode: outcome.errorCode,
598
+ };
599
+ }
600
+ // Uncertain (transport, malformed 2xx, 5xx, or the server's own unconfirmed).
601
+ const transport = outcome.kind === "uncertain"
602
+ ? outcome.status !== undefined
603
+ ? `${outcome.reason} (status ${outcome.status})`
604
+ : outcome.reason
605
+ : "indeterminate commit";
606
+ const proven = await lookupOwnReceipt(access, ref, fetchStatus);
607
+ if (proven !== null) {
608
+ await clear();
609
+ return { kind: "decided", requestId: ref.requestId, reusedReference, result: proven };
610
+ }
611
+ return { kind: "unconfirmed", requestId: ref.requestId, reusedReference, transport };
612
+ }
613
+ /**
614
+ * Read a child's current episode and, when a local recovery reference exists (or
615
+ * `requestId` is given), that request's receipt. One bounded GET; read-only. A
616
+ * reference is cleared only when the lookup PROVES its commit; `not_found` keeps
617
+ * it, because the absence of a receipt right now does not prove the request will
618
+ * never have committed if it is still in flight.
619
+ */
620
+ export async function lookupContinuationReceipt(access, options, deps = {}) {
621
+ const fetchStatus = deps.fetchStatus ?? fetchChildContinuationStatus;
622
+ const store = deps.store ?? {};
623
+ const reference = options.requestId
624
+ ? null
625
+ : await readContinuationReference(options.epicRunId, options.ticketKey, store);
626
+ const requestId = options.requestId ?? reference?.requestId ?? null;
627
+ let status;
628
+ try {
629
+ status = await fetchStatus(access, {
630
+ epicRunId: reference?.bindings.epicRunId ?? options.epicRunId,
631
+ ticketKey: reference?.bindings.ticketKey ?? options.ticketKey,
632
+ ...(requestId ? { requestId } : {}),
633
+ });
634
+ }
635
+ catch (err) {
636
+ return { kind: "unavailable", message: safeDiagnosticMessage(err, "could not read the child") };
637
+ }
638
+ if (status.found && status.receipt !== null) {
639
+ if (reference !== null && status.receipt.request_id === reference.requestId) {
640
+ await clearContinuationReference(options.epicRunId, options.ticketKey, store);
641
+ await clearContinuationReference(reference.bindings.epicRunId, reference.bindings.ticketKey, store);
642
+ }
643
+ return { kind: "found", requestId, status };
399
644
  }
645
+ return { kind: "not_found", requestId, status };
400
646
  }
@@ -43,6 +43,14 @@ export const DRIVER_CONTEXT_MAX_BYTES = 16384;
43
43
  * never parsed, printed, or transmitted.
44
44
  */
45
45
  export const POLICY_PATCH_MAX_BYTES = 16384;
46
+ /**
47
+ * BAPI-1294 — the bounded `unavailable` message for a `resume-run` whose outcome
48
+ * the client cannot confirm (a 5xx, timeout, dropped connection, or unreadable
49
+ * 2xx). It never repeats server body text and never claims either outcome.
50
+ */
51
+ export const RESUME_RUN_UNCONFIRMED_MESSAGE = "resume-run outcome could not be confirmed: the server may or may not have " +
52
+ "committed the resume. Inspect the run read-only before choosing any separately " +
53
+ "authorized mutation";
46
54
  /**
47
55
  * The closed refusal-code vocabulary, mirroring the server table.
48
56
  *
@@ -225,6 +225,7 @@ export async function validateConductorBundleInputs(args, fs) {
225
225
  epic_plan_path: path.join(resolvedEpicDir.value, "epic-plan.md"),
226
226
  sidecar_path: path.join(resolvedEpicDir.value, "epic-plan.dag.json"),
227
227
  manifest_path: path.join(resolvedEpicDir.value, "sibling-ticket-manifest.json"),
228
+ meta_path: path.join(resolvedEpicDir.value, "epic-plan.meta.json"),
228
229
  report_path: path.join(resolvedEpicDir.value, "harmonization-report.json"),
229
230
  plan_version: planVersion.value,
230
231
  mappings: resolvedMappings,
@@ -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,191 @@ 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 when its entry carries
1447
+ * {@link BAPI_EPIC_LAUNCH_BOUND_BASE_URL_FIELD} equal to the target.
1448
+ * - BAPI-1272: a stored key with NO binding field is also used when the
1449
+ * target normalizes to `options.productionBaseUrl` (`production-default`).
1450
+ * For any other target an entry with no binding is `binding-unbound` (a
1451
+ * legacy credential, upgraded once through guided setup).
1452
+ * - A binding to a different endpoint — or a binding field that is present
1453
+ * but empty — is `binding-mismatch`, even when the target is production:
1454
+ * a key approved for a local server is never sent to production.
1455
+ *
1456
+ * Read-only and migration-free: it never writes the store. Errors name the
1457
+ * logical target and the store path only — never a secret or the entry's
1458
+ * contents.
1459
+ */
1460
+ export async function resolveEpicLaunchCredential(repoName, targetBaseUrl, deps, options = {}) {
1461
+ const target = normalizeBoundBaseUrl(targetBaseUrl);
1462
+ const bundleKey = `bapi:${repoName}`;
1463
+ if (target === null || repoName.trim().length === 0) {
1464
+ return { ok: false, kind: "missing-key", error: "An epic launch needs a target and a repository." };
1465
+ }
1466
+ const envKey = (deps.env.BAPI_API_KEY ?? "").trim();
1467
+ if (envKey.length > 0) {
1468
+ const envBase = normalizeBoundBaseUrl(deps.env.BAPI_BASE_URL);
1469
+ if (envBase !== target) {
1470
+ return {
1471
+ ok: false,
1472
+ kind: "ambient-key-unbound",
1473
+ error: "BAPI_API_KEY is set in this environment, but BAPI_BASE_URL does not name the selected " +
1474
+ "target, so the key's endpoint cannot be shown. It was not sent. Unset BAPI_API_KEY to use " +
1475
+ "the credential approved by setup, or set BAPI_BASE_URL to the selected target.",
1476
+ };
1477
+ }
1478
+ return { ok: true, apiKey: envKey, source: "env", binding: "env-paired" };
1479
+ }
1480
+ // Read the stored entry exactly as the shared resolver would, but with the
1481
+ // environment cleared so the ambient branch above stays the only env path.
1482
+ // XDG_CONFIG_HOME is kept: it locates the store, and dropping it would read the
1483
+ // key from a different file than the binding below (and than setup wrote).
1484
+ const stored = await resolveCredentialBundle(bundleKey, ["BAPI_API_KEY"], {
1485
+ ...deps,
1486
+ env: { XDG_CONFIG_HOME: deps.env.XDG_CONFIG_HOME },
1487
+ });
1488
+ if (!stored.ok)
1489
+ return { ok: false, kind: stored.kind, error: stored.error };
1490
+ const binding = await readStoredBindingState(bundleKey, deps);
1491
+ const primaryPath = getPrimaryCredentialStorePath(deps);
1492
+ if (binding.state === "absent" || binding.state === "no-entry") {
1493
+ const production = normalizeBoundBaseUrl(options.productionBaseUrl);
1494
+ if (binding.state === "absent" && production !== null && production === target) {
1495
+ return { ok: true, apiKey: stored.values.BAPI_API_KEY, source: "file", binding: "production-default" };
1496
+ }
1497
+ return {
1498
+ ok: false,
1499
+ kind: "binding-unbound",
1500
+ error: `The stored credential "${bundleKey}" has not been approved for this Bridge API endpoint ` +
1501
+ `(${primaryPath}). Production needs no approval; any other endpoint does. Run guided setup ` +
1502
+ `once to bind it: install conductor --launch-profile <name> --base-url ${target}.`,
1503
+ };
1504
+ }
1505
+ // An unreadable read-back is refused like a mismatch: the key was read a
1506
+ // moment ago, so failing closed is the only safe reading.
1507
+ if (binding.state === "invalid" || binding.state === "unreadable" || binding.baseUrl !== target) {
1508
+ return {
1509
+ ok: false,
1510
+ kind: "binding-mismatch",
1511
+ error: `The stored credential "${bundleKey}" is approved for a different Bridge API endpoint than the ` +
1512
+ "selected target. It was not sent. A key approved for a local server is never sent to " +
1513
+ "production: pass that server's --launch-profile <name> or --base-url <url>, or bind the key " +
1514
+ "once with install conductor --launch-profile <name> --base-url <url>.",
1515
+ };
1516
+ }
1517
+ return { ok: true, apiKey: stored.values.BAPI_API_KEY, source: "file", binding: "store-bound" };
1518
+ }
1519
+ /**
1520
+ * Read the binding field of one bundle in the three states of
1521
+ * {@link StoredBindingState}. Never throws.
1522
+ */
1523
+ export async function readStoredBindingState(bundleKey, deps) {
1524
+ try {
1525
+ const resolution = await resolveCredentialStorePath(deps);
1526
+ if (!resolution.found)
1527
+ return { state: "no-entry" };
1528
+ const parsed = parseCredentialStoreJson(await deps.readFile(resolution.path));
1529
+ if (!parsed.ok)
1530
+ return { state: "unreadable" };
1531
+ const entry = parsed.value[bundleKey];
1532
+ if (entry === undefined)
1533
+ return { state: "no-entry" };
1534
+ if (!Object.prototype.hasOwnProperty.call(entry, BAPI_EPIC_LAUNCH_BOUND_BASE_URL_FIELD)) {
1535
+ return { state: "absent" };
1536
+ }
1537
+ const baseUrl = normalizeBoundBaseUrl(entry[BAPI_EPIC_LAUNCH_BOUND_BASE_URL_FIELD]);
1538
+ return baseUrl === null ? { state: "invalid" } : { state: "bound", baseUrl };
1539
+ }
1540
+ catch {
1541
+ return { state: "unreadable" };
1542
+ }
1543
+ }
1544
+ /**
1545
+ * Read the non-secret bound base URL for one bundle, or `null` when the store,
1546
+ * the entry, or the field is absent or unreadable. Never throws.
1547
+ */
1548
+ export async function readStoredBinding(bundleKey, deps) {
1549
+ try {
1550
+ const resolution = await resolveCredentialStorePath(deps);
1551
+ if (!resolution.found)
1552
+ return null;
1553
+ const parsed = parseCredentialStoreJson(await deps.readFile(resolution.path));
1554
+ if (!parsed.ok)
1555
+ return null;
1556
+ return normalizeBoundBaseUrl(parsed.value[bundleKey]?.[BAPI_EPIC_LAUNCH_BOUND_BASE_URL_FIELD]);
1557
+ }
1558
+ catch {
1559
+ return null;
1560
+ }
1561
+ }
1562
+ /**
1563
+ * The explicit ONE-TIME upgrade path: approve the stored `bapi:<repo>` key for
1564
+ * one normalized base URL. Called only by guided setup after it has re-verified
1565
+ * access and administrative authorization at that target — never by a launch.
1566
+ *
1567
+ * Writes only the non-secret binding field, under the shared lock, through the
1568
+ * durable replacement; every other entry and sibling field is preserved. A store
1569
+ * with no key for the repository is refused rather than creating one.
1570
+ */
1571
+ export async function bindEpicLaunchCredential(repoName, targetBaseUrl, deps) {
1572
+ const target = `bapi:${repoName.trim()}`;
1573
+ const baseUrl = normalizeBoundBaseUrl(targetBaseUrl);
1574
+ if (repoName.trim().length === 0 || baseUrl === null) {
1575
+ return { ok: false, target, kind: "invalid-input", error: "A repository and a target are required." };
1576
+ }
1577
+ const primaryPath = getPrimaryCredentialStorePath(deps);
1578
+ return withCredentialStoreLock(deps, async () => {
1579
+ const primary = await readCredentialStoreJsonIfPresent(primaryPath, deps);
1580
+ if (primary.state === "error") {
1581
+ return { ok: false, target, kind: primary.kind, error: primary.error };
1582
+ }
1583
+ const entry = primary.state === "present" ? primary.value[target] : undefined;
1584
+ if (!entry || typeof entry.BAPI_API_KEY !== "string" || entry.BAPI_API_KEY.trim() === "") {
1585
+ return {
1586
+ ok: false,
1587
+ target,
1588
+ kind: "not-found",
1589
+ error: `No stored BAPI_API_KEY under "${target}" in ${primaryPath} to bind.`,
1590
+ };
1591
+ }
1592
+ const previous = normalizeBoundBaseUrl(entry[BAPI_EPIC_LAUNCH_BOUND_BASE_URL_FIELD]);
1593
+ if (previous === baseUrl)
1594
+ return { ok: true, target, action: "unchanged", path: primaryPath };
1595
+ const next = {
1596
+ ...(primary.state === "present" ? primary.value : {}),
1597
+ [target]: { ...entry, [BAPI_EPIC_LAUNCH_BOUND_BASE_URL_FIELD]: baseUrl },
1598
+ };
1599
+ const written = await durablyReplaceCredentialStoreJson(primaryPath, next, deps);
1600
+ if (!written.ok)
1601
+ return { ok: false, target, kind: written.kind, error: written.error };
1602
+ return { ok: true, target, action: previous === null ? "bound" : "rebound", path: primaryPath };
1603
+ }, (error) => ({ ok: false, target, kind: "lock-error", error }));
1604
+ }