@bridge_gpt/mcp-server 0.2.62 → 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 (80) hide show
  1. package/README.md +47 -8
  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 +6 -5
  7. package/build/conductor/bridge-api-client.js +335 -22
  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/readiness-sections.js +5 -4
  15. package/build/conductor/readiness.js +22 -11
  16. package/build/conductor/recovery-cli.js +354 -13
  17. package/build/conductor/recovery-operations.js +251 -5
  18. package/build/conductor/repair-contract.js +8 -0
  19. package/build/conductor-bundle-artifacts.js +1 -0
  20. package/build/credential-store.js +50 -12
  21. package/build/docs.generated.js +2 -1
  22. package/build/doctor.js +77 -11
  23. package/build/drive-epic-key.js +12 -0
  24. package/build/drive-epic.js +1211 -116
  25. package/build/epic-coherence-input-cli.js +137 -0
  26. package/build/epic-coherence-input.js +920 -0
  27. package/build/epic-coherence-input.test-fixtures.js +175 -0
  28. package/build/epic-implementer-bin.js +35 -29
  29. package/build/epic-implementer-bundle-cli.js +14 -1
  30. package/build/epic-launch-profile.js +14 -5
  31. package/build/epic-run-status.js +5 -0
  32. package/build/epic-shared/bridge-client.js +46 -4
  33. package/build/executor/cli.js +39 -0
  34. package/build/executor/completion-contract.js +423 -0
  35. package/build/executor/completion-supervision.js +286 -0
  36. package/build/executor/credentials.js +3 -1
  37. package/build/executor/deps.js +65 -1
  38. package/build/executor/env.js +6 -0
  39. package/build/executor/heartbeat.js +13 -3
  40. package/build/executor/http-client.js +76 -4
  41. package/build/executor/job-errors.js +48 -0
  42. package/build/executor/job-runner.js +1294 -26
  43. package/build/executor/job-types.js +10 -2
  44. package/build/executor/preflight.js +99 -0
  45. package/build/executor/process-containment.js +756 -0
  46. package/build/executor/process.js +235 -5
  47. package/build/executor/prompt-spec.js +262 -1
  48. package/build/executor/provider-checks.js +69 -0
  49. package/build/executor/rebase-finalization.js +351 -0
  50. package/build/executor/reconcile-overlap-job.js +77 -84
  51. package/build/executor/required-verification.js +313 -0
  52. package/build/executor/required-work-ledger.js +1121 -0
  53. package/build/executor/results.js +142 -0
  54. package/build/executor/resume-pre-spawn.js +87 -4
  55. package/build/executor/runner.js +145 -0
  56. package/build/executor/test-declaration.js +20 -0
  57. package/build/executor/types.js +12 -0
  58. package/build/executor/worker-command.js +49 -13
  59. package/build/executor/worker-finalization.js +88 -11
  60. package/build/executor/worker-guard-hook.js +29 -4
  61. package/build/executor/worktree.js +13 -0
  62. package/build/executor-launch.js +658 -0
  63. package/build/index.js +537 -943
  64. package/build/install-bridge-conductor.js +231 -41
  65. package/build/merge-pull-request.js +1 -1
  66. package/build/pipelines.generated.js +8 -7
  67. package/build/plan-discovery.js +137 -0
  68. package/build/plan-epic-implementer-eligibility.js +2 -2
  69. package/build/plane/cli.js +37 -3
  70. package/build/plane/member-logs.js +3 -0
  71. package/build/plane/member-roster.js +7 -1
  72. package/build/plane/shutdown.js +59 -1
  73. package/build/plane/supervisor.js +142 -0
  74. package/build/readme.generated.js +1 -1
  75. package/build/scripts/executor-protocol-contract-driver.js +13 -3
  76. package/build/setup-epic.js +28 -17
  77. package/build/version.generated.js +3 -3
  78. package/build/worker-guard-hook-bin.js +2 -2
  79. package/docs/CONDUCTOR.md +116 -2
  80. 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,
@@ -1443,16 +1443,21 @@ function normalizeBoundBaseUrl(raw) {
1443
1443
  * - An ambient `BAPI_API_KEY` is used only when the same environment also sets
1444
1444
  * `BAPI_BASE_URL` to exactly the target — the pair is its provenance.
1445
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`.
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.
1450
1455
  *
1451
1456
  * Read-only and migration-free: it never writes the store. Errors name the
1452
1457
  * logical target and the store path only — never a secret or the entry's
1453
1458
  * contents.
1454
1459
  */
1455
- export async function resolveEpicLaunchCredential(repoName, targetBaseUrl, deps) {
1460
+ export async function resolveEpicLaunchCredential(repoName, targetBaseUrl, deps, options = {}) {
1456
1461
  const target = normalizeBoundBaseUrl(targetBaseUrl);
1457
1462
  const bundleKey = `bapi:${repoName}`;
1458
1463
  if (target === null || repoName.trim().length === 0) {
@@ -1482,27 +1487,60 @@ export async function resolveEpicLaunchCredential(repoName, targetBaseUrl, deps)
1482
1487
  });
1483
1488
  if (!stored.ok)
1484
1489
  return { ok: false, kind: stored.kind, error: stored.error };
1485
- const bound = await readStoredBinding(bundleKey, deps);
1490
+ const binding = await readStoredBindingState(bundleKey, deps);
1486
1491
  const primaryPath = getPrimaryCredentialStorePath(deps);
1487
- if (bound === null) {
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
+ }
1488
1497
  return {
1489
1498
  ok: false,
1490
1499
  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}.`,
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}.`,
1494
1503
  };
1495
1504
  }
1496
- if (bound !== target) {
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) {
1497
1508
  return {
1498
1509
  ok: false,
1499
1510
  kind: "binding-mismatch",
1500
1511
  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.",
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>.",
1502
1515
  };
1503
1516
  }
1504
1517
  return { ok: true, apiKey: stored.values.BAPI_API_KEY, source: "file", binding: "store-bound" };
1505
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
+ }
1506
1544
  /**
1507
1545
  * Read the non-secret bound base URL for one bundle, or `null` when the store,
1508
1546
  * the entry, or the field is absent or unreadable. Never throws.