@bridge_gpt/mcp-server 0.2.21 → 0.2.24

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 (41) hide show
  1. package/README.md +144 -18
  2. package/build/base-ref.js +151 -0
  3. package/build/commands.generated.js +6 -4
  4. package/build/conductor/bridge-api-client.js +44 -3
  5. package/build/conductor/doctor.js +33 -22
  6. package/build/conductor/epic-runtime.js +101 -5
  7. package/build/conductor/pr-ci-producer.js +21 -2
  8. package/build/conductor/pr-discovery.js +12 -2
  9. package/build/conductor-bin.js +50 -20
  10. package/build/credential-store.js +564 -64
  11. package/build/decision-page-template.js +9 -4
  12. package/build/docs.generated.js +5 -0
  13. package/build/executor/base-branch.js +50 -0
  14. package/build/executor/env.js +12 -1
  15. package/build/executor/job-errors.js +1 -0
  16. package/build/executor/job-runner.js +38 -7
  17. package/build/executor/test-clock.js +6 -1
  18. package/build/executor/worker-finalization.js +88 -1
  19. package/build/executor/worktree.js +21 -1
  20. package/build/index.js +2741 -702
  21. package/build/init.js +29 -0
  22. package/build/install-bridge.js +1076 -114
  23. package/build/pipelines.generated.js +2 -2
  24. package/build/pr-base-contract.js +36 -0
  25. package/build/readme.generated.js +1 -1
  26. package/build/setup-epic.js +483 -0
  27. package/build/sfcc/log-gate.js +85 -0
  28. package/build/sfcc/log-query.js +170 -0
  29. package/build/sfcc/register.js +10 -0
  30. package/build/sfcc/setup-status.js +33 -3
  31. package/build/start-tickets.js +164 -75
  32. package/build/version.generated.js +1 -1
  33. package/build/worktree-core.js +62 -10
  34. package/{CONDUCTOR.md → docs/CONDUCTOR.md} +88 -29
  35. package/docs/install/github-app.md +189 -0
  36. package/docs/install/mcp-tool-integrations.md +305 -0
  37. package/docs/install/sfcc-integration.md +140 -0
  38. package/package.json +5 -5
  39. package/public/js/main.min.js +55 -10
  40. package/public/js/main.min.js.map +1 -1
  41. package/smoke-test/SMOKE-TEST.md +3 -2
@@ -7,12 +7,20 @@
7
7
  * 3. `~/.bridge/credentials.json` (only when the primary path is absent).
8
8
  *
9
9
  * The file is keyed by a logical target `bapi:<repoName>`. The resolver NEVER
10
- * creates or initializes credential files. The writer ({@link upsertBapiCredential})
11
- * is the ONLY mutation primitive: it upserts a single key into the user-scoped
12
- * primary store via explicit install/migration code paths, preserves every
13
- * other entry, and never touches project/worktree config files (those remain
14
- * secret-free). No code path here places secret values in thrown errors,
15
- * returned error strings, or stderr warnings.
10
+ * creates or initializes credential files. The mutation primitives — the
11
+ * best-effort {@link upsertBapiCredential} and the fail-closed bootstrap-invite
12
+ * pending-state operations ({@link prepareBootstrapPendingCredential},
13
+ * {@link repointBootstrapPendingCredential}, {@link promoteBootstrapPendingCredential})
14
+ * are the ONLY writers: they mutate the user-scoped primary store from explicit
15
+ * install/migration code paths, preserve every other entry, and never touch
16
+ * project/worktree config files (those remain secret-free). Every mutation runs
17
+ * under {@link withCredentialStoreLock}, so two concurrent writers cannot
18
+ * last-writer-win and drop a sibling credential. No code path here places secret
19
+ * values in thrown errors, returned error strings, or stderr warnings.
20
+ *
21
+ * Resolution order is unchanged by the bootstrap-invite work: a
22
+ * `bootstrap-pending:<repo>` entry is a distinct logical target and is NEVER read
23
+ * by {@link resolveBapiCredentials} — only a promoted `bapi:<repo>` credential is.
16
24
  *
17
25
  * Two-runtime credential rule: the MCP server process env and a Bash-spawned
18
26
  * CLI (e.g. `start-tickets`) env are DIFFERENT runtime surfaces. A secret in
@@ -299,6 +307,495 @@ function defaultTempSuffix() {
299
307
  tempSuffixCounter += 1;
300
308
  return `${process.pid}.${tempSuffixCounter}`;
301
309
  }
310
+ // ---------------------------------------------------------------------------
311
+ // Locking + durable (fsync'd) replacement (BAPI-606)
312
+ // ---------------------------------------------------------------------------
313
+ /** Poll interval while waiting for a contended lock. */
314
+ const LOCK_POLL_INTERVAL_MS = 50;
315
+ /** Bounded wait before a held lock is treated as abandoned. */
316
+ const LOCK_TIMEOUT_MS = 5_000;
317
+ /** Lock path, adjacent to the primary store. Never carries secret material. */
318
+ export function getCredentialStoreLockPath(deps) {
319
+ return `${getPrimaryCredentialStorePath(deps)}.lock`;
320
+ }
321
+ /**
322
+ * Acquire the exclusive credential-store lock: an atomically created (`wx`) lock
323
+ * file beside the store, with a bounded wait.
324
+ *
325
+ * When `deps.open` is absent there is no lock primitive available, so the caller
326
+ * runs unlocked — exactly the pre-BAPI-606 behavior, preserved so existing
327
+ * builders and in-memory test doubles keep working.
328
+ *
329
+ * Stale-lock recovery: a lock still held after {@link LOCK_TIMEOUT_MS} (orders of
330
+ * magnitude longer than a legitimate sub-millisecond read-modify-write) is
331
+ * treated as abandoned by a crashed process and stolen exactly once. The
332
+ * alternative — refusing forever — wedges every future install behind a lock file
333
+ * no user knows to delete.
334
+ */
335
+ async function acquireCredentialStoreLock(deps) {
336
+ const open = deps.open;
337
+ if (!open)
338
+ return { ok: true, release: async () => { } };
339
+ const lockPath = getCredentialStoreLockPath(deps);
340
+ const isPosix = deps.platform !== "win32";
341
+ const now = deps.now ?? (() => Date.now());
342
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
343
+ const release = async () => {
344
+ if (!deps.unlink)
345
+ return;
346
+ try {
347
+ await deps.unlink(lockPath);
348
+ }
349
+ catch {
350
+ /* releasing a lock that is already gone must never fail the caller */
351
+ }
352
+ };
353
+ const tryAcquire = async () => {
354
+ try {
355
+ const handle = await open(lockPath, "wx", isPosix ? 0o600 : undefined);
356
+ await handle.close();
357
+ return { ok: true };
358
+ }
359
+ catch (err) {
360
+ const code = err && typeof err === "object" ? err.code : undefined;
361
+ return { ok: false, contended: code === "EEXIST" };
362
+ }
363
+ };
364
+ try {
365
+ await deps.mkdir(path.dirname(lockPath), { recursive: true });
366
+ }
367
+ catch {
368
+ return { ok: false, error: `Unable to prepare the credentials directory for ${lockPath}.` };
369
+ }
370
+ const deadline = now() + LOCK_TIMEOUT_MS;
371
+ for (;;) {
372
+ const attempt = await tryAcquire();
373
+ if (attempt.ok)
374
+ return { ok: true, release };
375
+ if (!attempt.contended) {
376
+ return { ok: false, error: `Unable to acquire the credentials lock at ${lockPath}.` };
377
+ }
378
+ if (now() >= deadline)
379
+ break;
380
+ await sleep(LOCK_POLL_INTERVAL_MS);
381
+ }
382
+ // Bounded wait exhausted — steal the abandoned lock, once.
383
+ await release();
384
+ const stolen = await tryAcquire();
385
+ if (stolen.ok)
386
+ return { ok: true, release };
387
+ return {
388
+ ok: false,
389
+ error: `Timed out waiting for the credentials lock at ${lockPath} (another install may be running).`,
390
+ };
391
+ }
392
+ /**
393
+ * Serialize a credential-store read-modify-write against the shared lock. The
394
+ * lock is always released, and `onLockError` maps an acquisition failure into the
395
+ * caller's own (secret-free) result shape.
396
+ */
397
+ export async function withCredentialStoreLock(deps, fn, onLockError) {
398
+ const lock = await acquireCredentialStoreLock(deps);
399
+ if (!lock.ok)
400
+ return onLockError(lock.error);
401
+ try {
402
+ return await fn();
403
+ }
404
+ finally {
405
+ await lock.release();
406
+ }
407
+ }
408
+ /**
409
+ * Replace the primary store DURABLY: temp file (mode 0600) → write → **fsync** →
410
+ * close → chmod → rename, then a best-effort fsync of the containing directory.
411
+ *
412
+ * The file fsync is fail-closed: if it rejects, the write is reported as failed
413
+ * and the prior store is left intact. That is the whole point of this primitive —
414
+ * {@link upsertBapiCredential}'s temp-write + rename is durable *enough* for a
415
+ * best-effort routing credential, but not for a protocol proof that must survive a
416
+ * crash (the bootstrap-invite pending secret). The directory fsync is best-effort:
417
+ * platforms that cannot open a directory (win32) must not weaken the file fsync.
418
+ */
419
+ export async function durablyReplaceCredentialStoreJson(primaryPath, value, deps) {
420
+ const open = deps.open;
421
+ if (!open) {
422
+ return {
423
+ ok: false,
424
+ kind: "durable-unavailable",
425
+ error: `Cannot durably write ${primaryPath}: no file-handle primitive is available to fsync the write.`,
426
+ };
427
+ }
428
+ const dir = path.dirname(primaryPath);
429
+ const suffix = (deps.tempSuffix ?? defaultTempSuffix)();
430
+ const tempPath = path.join(dir, `${path.basename(primaryPath)}.${suffix}.tmp`);
431
+ const json = formatCredentialStoreJson(value);
432
+ const isPosix = deps.platform !== "win32";
433
+ let handle;
434
+ try {
435
+ await deps.mkdir(dir, { recursive: true });
436
+ handle = await open(tempPath, "w", isPosix ? 0o600 : undefined);
437
+ await handle.writeFile(json, { encoding: "utf-8" });
438
+ await handle.sync();
439
+ await handle.close();
440
+ handle = undefined;
441
+ if (isPosix)
442
+ await deps.chmod(tempPath, 0o600);
443
+ await deps.rename(tempPath, primaryPath);
444
+ }
445
+ catch {
446
+ if (handle) {
447
+ try {
448
+ await handle.close();
449
+ }
450
+ catch {
451
+ /* a close failure must not mask the real write error */
452
+ }
453
+ }
454
+ if (deps.unlink) {
455
+ try {
456
+ await deps.unlink(tempPath);
457
+ }
458
+ catch {
459
+ /* best-effort temp cleanup only; the prior store is untouched */
460
+ }
461
+ }
462
+ // Never surface the raw I/O message — it can echo the written contents.
463
+ return {
464
+ ok: false,
465
+ kind: "write-error",
466
+ error: `Failed to durably write the credentials file at ${primaryPath}.`,
467
+ };
468
+ }
469
+ try {
470
+ const dirHandle = await open(dir, "r");
471
+ try {
472
+ await dirHandle.sync();
473
+ }
474
+ finally {
475
+ await dirHandle.close();
476
+ }
477
+ }
478
+ catch {
479
+ /* directory fsync is unsupported on some platforms; the file fsync above is
480
+ the load-bearing one and already succeeded. */
481
+ }
482
+ return { ok: true };
483
+ }
484
+ // ---------------------------------------------------------------------------
485
+ // Bootstrap-invite pending state (BAPI-606)
486
+ // ---------------------------------------------------------------------------
487
+ /**
488
+ * Logical target for a bootstrap-invite secret that has been generated and
489
+ * durably saved but NOT yet redeemed. It is a separate top-level key from
490
+ * `bapi:<repo>`, so the resolver never serves an unredeemed secret as a
491
+ * credential, and a failed redemption never leaves a broken `bapi:<repo>` entry.
492
+ */
493
+ export const BOOTSTRAP_PENDING_TARGET_PREFIX = "bootstrap-pending:";
494
+ /** Secret name holding the client-generated `key_secret` inside a pending record. */
495
+ export const BOOTSTRAP_PENDING_SECRET_FIELD = "BAPI_API_KEY";
496
+ /** Secret name holding the invite FINGERPRINT (a SHA-256 hex digest, not the token). */
497
+ export const BOOTSTRAP_PENDING_FINGERPRINT_FIELD = "BOOTSTRAP_INVITE_FINGERPRINT";
498
+ /** Pending target for a repo: `bootstrap-pending:<repo>`. */
499
+ export function getBootstrapPendingTarget(repoName) {
500
+ return `${BOOTSTRAP_PENDING_TARGET_PREFIX}${(repoName ?? "").trim()}`;
501
+ }
502
+ /** `bapi:<repo>` target (no lowercasing/canonicalization — mirrors the upsert). */
503
+ function getBapiTarget(repoName) {
504
+ return `bapi:${(repoName ?? "").trim()}`;
505
+ }
506
+ /** Load the primary store for mutation, seeding from the legacy fallback on first write. */
507
+ async function loadStoreForMutation(deps) {
508
+ const primaryPath = getPrimaryCredentialStorePath(deps);
509
+ const primary = await readCredentialStoreJsonIfPresent(primaryPath, deps);
510
+ if (primary.state === "error") {
511
+ return { ok: false, kind: primary.kind, error: primary.error };
512
+ }
513
+ if (primary.state === "present") {
514
+ return { ok: true, base: { ...primary.value } };
515
+ }
516
+ const seeded = await mergeFallbackCredentialStoreOnFirstPrimaryWrite(deps);
517
+ return { ok: true, base: seeded.base };
518
+ }
519
+ /** Does a real (non-empty) `BAPI_API_KEY` already exist for `bapi:<repo>`? */
520
+ function hasExistingBapiKey(store, repoName) {
521
+ const entry = store[getBapiTarget(repoName)];
522
+ return (!!entry && typeof entry.BAPI_API_KEY === "string" && entry.BAPI_API_KEY.trim().length > 0);
523
+ }
524
+ /** Read a pending record whose fingerprint matches; `null` when absent/mismatched. */
525
+ function readMatchingPending(store, repoName, inviteFingerprint) {
526
+ const entry = store[getBootstrapPendingTarget(repoName)];
527
+ if (!entry)
528
+ return null;
529
+ const secret = entry[BOOTSTRAP_PENDING_SECRET_FIELD];
530
+ const fingerprint = entry[BOOTSTRAP_PENDING_FINGERPRINT_FIELD];
531
+ if (typeof secret !== "string" || secret.trim().length === 0)
532
+ return null;
533
+ if (fingerprint !== inviteFingerprint)
534
+ return null;
535
+ return { keySecret: secret };
536
+ }
537
+ /**
538
+ * Does `bootstrap-pending:<repo>` hold a secret belonging to a DIFFERENT invite?
539
+ *
540
+ * `readMatchingPending` returning `null` conflates two very different states —
541
+ * "no record" (safe to write) and "some OTHER invite's record" (must not be
542
+ * touched). Every writer therefore asks this question separately, because that
543
+ * other record is the ONLY proof that can replay its redemption: if its exchange
544
+ * already succeeded, overwriting it strands an admin key that no one can recover.
545
+ *
546
+ * Deliberately NOT gated on `allowOverwriteExistingCredential` — `--force` is
547
+ * consent to replace this repo's own `bapi:<repo>` credential, never consent to
548
+ * strand a key minted under someone else's invite.
549
+ *
550
+ * A record with no usable secret has nothing at stake and is not a conflict; a
551
+ * record carrying a secret but no matching fingerprint is (we cannot prove it is
552
+ * ours, so we fail closed).
553
+ */
554
+ function hasConflictingPending(store, repoName, inviteFingerprint) {
555
+ const entry = store[getBootstrapPendingTarget(repoName)];
556
+ if (!entry)
557
+ return false;
558
+ const secret = entry[BOOTSTRAP_PENDING_SECRET_FIELD];
559
+ if (typeof secret !== "string" || secret.trim().length === 0)
560
+ return false;
561
+ return entry[BOOTSTRAP_PENDING_FINGERPRINT_FIELD] !== inviteFingerprint;
562
+ }
563
+ /** One wording for the conflict, so `prepare` and `repoint` cannot drift apart. */
564
+ function pendingConflictError(target, primaryPath) {
565
+ return (`A pending bootstrap-invite credential for a DIFFERENT invite already exists at ${target} ` +
566
+ `in ${primaryPath}. It is the only proof that can replay that redemption, so it will not be ` +
567
+ "overwritten. Complete that redemption first, or — only if you are certain its invite was " +
568
+ "never exchanged — remove the entry from the store by hand.");
569
+ }
570
+ /**
571
+ * Generate (or REUSE) the bootstrap `key_secret` and durably persist it as a
572
+ * pending record — the caller must do this BEFORE calling the exchange.
573
+ *
574
+ * Reuse is what makes a retry idempotent: re-POSTing the exact same `key_secret`
575
+ * hits the server's replay branch and returns the same project and the same key.
576
+ * So a matching pending record (same invite fingerprint + repo) is reused
577
+ * verbatim and the CSPRNG is not invoked at all.
578
+ *
579
+ * A fresh record is written through {@link durablyReplaceCredentialStoreJson}, so
580
+ * a failed write or fsync produces a failure result and NO success value — the
581
+ * caller cannot proceed to the exchange with a secret that is not on disk.
582
+ */
583
+ export async function prepareBootstrapPendingCredential(params, deps) {
584
+ const primaryPath = getPrimaryCredentialStorePath(deps);
585
+ const repoName = (params.repoName ?? "").trim();
586
+ const fingerprint = (params.inviteFingerprint ?? "").trim();
587
+ const target = getBootstrapPendingTarget(repoName);
588
+ if (repoName.length === 0) {
589
+ return {
590
+ ok: false,
591
+ path: primaryPath,
592
+ target,
593
+ kind: "invalid-repo",
594
+ error: "Cannot prepare a bootstrap-invite credential: a non-empty repo name is required.",
595
+ };
596
+ }
597
+ if (fingerprint.length === 0) {
598
+ return {
599
+ ok: false,
600
+ path: primaryPath,
601
+ target,
602
+ kind: "invalid-fingerprint",
603
+ error: "Cannot prepare a bootstrap-invite credential: the invite fingerprint was empty.",
604
+ };
605
+ }
606
+ return withCredentialStoreLock(deps, async () => {
607
+ const loaded = await loadStoreForMutation(deps);
608
+ if (!loaded.ok) {
609
+ return { ok: false, path: primaryPath, target, kind: loaded.kind, error: loaded.error };
610
+ }
611
+ const base = loaded.base;
612
+ // Check the FINAL credential target before generating or writing anything:
613
+ // a redemption that would clobber an existing bapi:<repo> must stop here,
614
+ // while nothing has been consumed and nothing has been written.
615
+ if (hasExistingBapiKey(base, repoName) && !params.allowOverwriteExistingCredential) {
616
+ return {
617
+ ok: false,
618
+ path: primaryPath,
619
+ target: getBapiTarget(repoName),
620
+ kind: "credential-conflict",
621
+ error: `A credential already exists for ${getBapiTarget(repoName)} in ${primaryPath}.`,
622
+ };
623
+ }
624
+ const existing = readMatchingPending(base, repoName, fingerprint);
625
+ if (existing) {
626
+ // Reuse verbatim — this exact value is the replay proof. Do not regenerate.
627
+ return { ok: true, path: primaryPath, target, keySecret: existing.keySecret, reused: true };
628
+ }
629
+ // Not ours, but someone's: redeeming a second invite against a repo that
630
+ // still holds a pending record for a first one would overwrite that record —
631
+ // and if the first exchange already succeeded, its admin key becomes
632
+ // unrecoverable. Stop before the CSPRNG runs and before anything is written.
633
+ if (hasConflictingPending(base, repoName, fingerprint)) {
634
+ return {
635
+ ok: false,
636
+ path: primaryPath,
637
+ target,
638
+ kind: "pending-conflict",
639
+ error: pendingConflictError(target, primaryPath),
640
+ };
641
+ }
642
+ const keySecret = params.generateKeySecret();
643
+ const next = {
644
+ ...base,
645
+ [target]: {
646
+ ...(base[target] ?? {}),
647
+ [BOOTSTRAP_PENDING_SECRET_FIELD]: keySecret,
648
+ [BOOTSTRAP_PENDING_FINGERPRINT_FIELD]: fingerprint,
649
+ },
650
+ };
651
+ const written = await durablyReplaceCredentialStoreJson(primaryPath, next, deps);
652
+ if (!written.ok) {
653
+ return { ok: false, path: primaryPath, target, kind: written.kind, error: written.error };
654
+ }
655
+ return { ok: true, path: primaryPath, target, keySecret, reused: false };
656
+ }, (error) => ({ ok: false, path: primaryPath, target, kind: "lock-error", error }));
657
+ }
658
+ /**
659
+ * Atomically move a pending record to a different repo name, preserving the exact
660
+ * same `key_secret` — used when the server answers `409 repo_name_taken` and the
661
+ * user picks a new name. Regenerating the secret here would silently discard the
662
+ * replay proof for the (still unconsumed) invite.
663
+ *
664
+ * On any failure the ORIGINAL pending record is left intact: the store is
665
+ * replaced in one durable write or not at all.
666
+ */
667
+ export async function repointBootstrapPendingCredential(params, deps) {
668
+ const primaryPath = getPrimaryCredentialStorePath(deps);
669
+ const fromRepo = (params.fromRepoName ?? "").trim();
670
+ const toRepo = (params.toRepoName ?? "").trim();
671
+ const fingerprint = (params.inviteFingerprint ?? "").trim();
672
+ const target = getBootstrapPendingTarget(toRepo);
673
+ if (fromRepo.length === 0 || toRepo.length === 0) {
674
+ return {
675
+ ok: false,
676
+ path: primaryPath,
677
+ target,
678
+ kind: "invalid-repo",
679
+ error: "Cannot re-point a bootstrap-invite credential: a non-empty repo name is required.",
680
+ };
681
+ }
682
+ return withCredentialStoreLock(deps, async () => {
683
+ const loaded = await loadStoreForMutation(deps);
684
+ if (!loaded.ok) {
685
+ return { ok: false, path: primaryPath, target, kind: loaded.kind, error: loaded.error };
686
+ }
687
+ const base = loaded.base;
688
+ const pending = readMatchingPending(base, fromRepo, fingerprint);
689
+ if (!pending) {
690
+ return {
691
+ ok: false,
692
+ path: primaryPath,
693
+ target,
694
+ kind: "pending-missing",
695
+ error: `No pending bootstrap-invite credential for ${getBootstrapPendingTarget(fromRepo)} in ${primaryPath}.`,
696
+ };
697
+ }
698
+ if (toRepo === fromRepo) {
699
+ return { ok: true, path: primaryPath, target, keySecret: pending.keySecret };
700
+ }
701
+ if (hasExistingBapiKey(base, toRepo) && !params.allowOverwriteExistingCredential) {
702
+ return {
703
+ ok: false,
704
+ path: primaryPath,
705
+ target: getBapiTarget(toRepo),
706
+ kind: "credential-conflict",
707
+ error: `A credential already exists for ${getBapiTarget(toRepo)} in ${primaryPath}.`,
708
+ };
709
+ }
710
+ // Refuse to clobber a pending record at the destination that belongs to a
711
+ // DIFFERENT invite — that would destroy another redemption's replay proof.
712
+ const destination = base[getBootstrapPendingTarget(toRepo)];
713
+ if (hasConflictingPending(base, toRepo, fingerprint)) {
714
+ return {
715
+ ok: false,
716
+ path: primaryPath,
717
+ target,
718
+ kind: "pending-conflict",
719
+ error: pendingConflictError(target, primaryPath),
720
+ };
721
+ }
722
+ const next = { ...base };
723
+ delete next[getBootstrapPendingTarget(fromRepo)];
724
+ next[target] = {
725
+ ...(destination ?? {}),
726
+ [BOOTSTRAP_PENDING_SECRET_FIELD]: pending.keySecret,
727
+ [BOOTSTRAP_PENDING_FINGERPRINT_FIELD]: fingerprint,
728
+ };
729
+ const written = await durablyReplaceCredentialStoreJson(primaryPath, next, deps);
730
+ if (!written.ok) {
731
+ return { ok: false, path: primaryPath, target, kind: written.kind, error: written.error };
732
+ }
733
+ return { ok: true, path: primaryPath, target, keySecret: pending.keySecret };
734
+ }, (error) => ({ ok: false, path: primaryPath, target, kind: "lock-error", error }));
735
+ }
736
+ /**
737
+ * Promote a pending record to the real `bapi:<repo>` credential — ONLY after the
738
+ * exchange returned 200. Removing the pending entry and writing `BAPI_API_KEY`
739
+ * happen in one durable replacement, so the secret is never absent from the store.
740
+ *
741
+ * The `bapi:<repo>` overwrite check is re-run HERE, under the lock, rather than
742
+ * trusting the preflight check in {@link prepareBootstrapPendingCredential}: a
743
+ * concurrent install could have written that credential in between.
744
+ *
745
+ * A failed promotion leaves the pending record intact, so the same replay proof is
746
+ * still available to a later run.
747
+ */
748
+ export async function promoteBootstrapPendingCredential(params, deps) {
749
+ const primaryPath = getPrimaryCredentialStorePath(deps);
750
+ const repoName = (params.repoName ?? "").trim();
751
+ const fingerprint = (params.inviteFingerprint ?? "").trim();
752
+ const target = getBapiTarget(repoName);
753
+ if (repoName.length === 0) {
754
+ return {
755
+ ok: false,
756
+ path: primaryPath,
757
+ target,
758
+ kind: "invalid-repo",
759
+ error: "Cannot promote a bootstrap-invite credential: a non-empty repo name is required.",
760
+ };
761
+ }
762
+ return withCredentialStoreLock(deps, async () => {
763
+ const loaded = await loadStoreForMutation(deps);
764
+ if (!loaded.ok) {
765
+ return { ok: false, path: primaryPath, target, kind: loaded.kind, error: loaded.error };
766
+ }
767
+ const base = loaded.base;
768
+ const pending = readMatchingPending(base, repoName, fingerprint);
769
+ if (!pending) {
770
+ return {
771
+ ok: false,
772
+ path: primaryPath,
773
+ target,
774
+ kind: "pending-missing",
775
+ error: `No pending bootstrap-invite credential for ${getBootstrapPendingTarget(repoName)} in ${primaryPath}.`,
776
+ };
777
+ }
778
+ const hadKey = hasExistingBapiKey(base, repoName);
779
+ if (hadKey && !params.allowOverwriteExistingCredential) {
780
+ return {
781
+ ok: false,
782
+ path: primaryPath,
783
+ target,
784
+ kind: "credential-conflict",
785
+ error: `A credential already exists for ${target} in ${primaryPath}.`,
786
+ };
787
+ }
788
+ const next = { ...base };
789
+ delete next[getBootstrapPendingTarget(repoName)];
790
+ // Preserve sibling secret names under bapi:<repo>; replace ONLY BAPI_API_KEY.
791
+ next[target] = { ...(base[target] ?? {}), BAPI_API_KEY: pending.keySecret };
792
+ const written = await durablyReplaceCredentialStoreJson(primaryPath, next, deps);
793
+ if (!written.ok) {
794
+ return { ok: false, path: primaryPath, target, kind: written.kind, error: written.error };
795
+ }
796
+ return { ok: true, path: primaryPath, target, action: hadKey ? "updated" : "created" };
797
+ }, (error) => ({ ok: false, path: primaryPath, target, kind: "lock-error", error }));
798
+ }
302
799
  /**
303
800
  * Upsert `BAPI_API_KEY` for `bapi:<repoName>` into the user-scoped PRIMARY
304
801
  * credential store, atomically (temp file + rename) and secret-safely.
@@ -338,66 +835,69 @@ export async function upsertBapiCredential(repoName, apiKey, deps) {
338
835
  error: `Cannot store BAPI_API_KEY for ${target}: the provided key was empty.`,
339
836
  };
340
837
  }
341
- // Load the existing primary; if absent, seed from the legacy fallback store.
342
- // NOTE: this load merge → temp-write rename sequence is NOT locked. It is
343
- // safe because the writer only ever runs single-shot from the explicit
344
- // install/migration paths; two genuinely concurrent upserts could last-writer-win
345
- // and drop a sibling key. If a concurrent caller is ever added, introduce a lock.
346
- const primary = await readCredentialStoreJsonIfPresent(primaryPath, deps);
347
- let base;
348
- let migratedFallback = false;
349
- if (primary.state === "error") {
350
- return { ok: false, path: primaryPath, target, kind: primary.kind, error: primary.error };
351
- }
352
- if (primary.state === "present") {
353
- base = { ...primary.value };
354
- }
355
- else {
356
- const seeded = await mergeFallbackCredentialStoreOnFirstPrimaryWrite(deps);
357
- base = seeded.base;
358
- migratedFallback = seeded.migratedFallback;
359
- }
360
- const existingEntry = base[target];
361
- const hadKey = !!existingEntry &&
362
- typeof existingEntry.BAPI_API_KEY === "string" &&
363
- existingEntry.BAPI_API_KEY.length > 0;
364
- const action = hadKey ? "updated" : "created";
365
- // Preserve sibling secret names; replace ONLY BAPI_API_KEY.
366
- const nextEntry = { ...(existingEntry ?? {}), BAPI_API_KEY: trimmedKey };
367
- const next = { ...base, [target]: nextEntry };
368
- const dir = path.dirname(primaryPath);
369
- const suffix = (deps.tempSuffix ?? defaultTempSuffix)();
370
- const tempPath = path.join(dir, `${path.basename(primaryPath)}.${suffix}.tmp`);
371
- const json = formatCredentialStoreJson(next);
372
- const isPosix = deps.platform !== "win32";
373
- try {
374
- await deps.mkdir(dir, { recursive: true });
375
- const writeOptions = isPosix
376
- ? { encoding: "utf-8", mode: 0o600 }
377
- : { encoding: "utf-8" };
378
- await deps.writeFile(tempPath, json, writeOptions);
379
- if (isPosix) {
380
- await deps.chmod(tempPath, 0o600);
838
+ // The load merge temp-write rename sequence below is a read-modify-write:
839
+ // two concurrent upserts could last-writer-win and drop a sibling key. BAPI-606
840
+ // added concurrent callers (the bootstrap-invite pending/promote operations), so
841
+ // it now runs under the shared credential-store lock. When `deps.open` is absent
842
+ // (older builders / in-memory test doubles) there is no lock primitive available
843
+ // and the body runs exactly as it did before — unlocked and best-effort.
844
+ return withCredentialStoreLock(deps, async () => {
845
+ const primary = await readCredentialStoreJsonIfPresent(primaryPath, deps);
846
+ let base;
847
+ let migratedFallback = false;
848
+ if (primary.state === "error") {
849
+ return { ok: false, path: primaryPath, target, kind: primary.kind, error: primary.error };
381
850
  }
382
- await deps.rename(tempPath, primaryPath);
383
- }
384
- catch {
385
- // Best-effort temp cleanup; never surface the secret or the raw I/O message.
386
- if (deps.unlink) {
387
- try {
388
- await deps.unlink(tempPath);
851
+ if (primary.state === "present") {
852
+ base = { ...primary.value };
853
+ }
854
+ else {
855
+ const seeded = await mergeFallbackCredentialStoreOnFirstPrimaryWrite(deps);
856
+ base = seeded.base;
857
+ migratedFallback = seeded.migratedFallback;
858
+ }
859
+ const existingEntry = base[target];
860
+ const hadKey = !!existingEntry &&
861
+ typeof existingEntry.BAPI_API_KEY === "string" &&
862
+ existingEntry.BAPI_API_KEY.length > 0;
863
+ const action = hadKey ? "updated" : "created";
864
+ // Preserve sibling secret names; replace ONLY BAPI_API_KEY.
865
+ const nextEntry = { ...(existingEntry ?? {}), BAPI_API_KEY: trimmedKey };
866
+ const next = { ...base, [target]: nextEntry };
867
+ const dir = path.dirname(primaryPath);
868
+ const suffix = (deps.tempSuffix ?? defaultTempSuffix)();
869
+ const tempPath = path.join(dir, `${path.basename(primaryPath)}.${suffix}.tmp`);
870
+ const json = formatCredentialStoreJson(next);
871
+ const isPosix = deps.platform !== "win32";
872
+ try {
873
+ await deps.mkdir(dir, { recursive: true });
874
+ const writeOptions = isPosix
875
+ ? { encoding: "utf-8", mode: 0o600 }
876
+ : { encoding: "utf-8" };
877
+ await deps.writeFile(tempPath, json, writeOptions);
878
+ if (isPosix) {
879
+ await deps.chmod(tempPath, 0o600);
389
880
  }
390
- catch {
391
- /* cleanup failure must not mask the primary write error */
881
+ await deps.rename(tempPath, primaryPath);
882
+ }
883
+ catch {
884
+ // Best-effort temp cleanup; never surface the secret or the raw I/O message.
885
+ if (deps.unlink) {
886
+ try {
887
+ await deps.unlink(tempPath);
888
+ }
889
+ catch {
890
+ /* cleanup failure must not mask the primary write error */
891
+ }
392
892
  }
893
+ return {
894
+ ok: false,
895
+ path: primaryPath,
896
+ target,
897
+ kind: "write-error",
898
+ error: `Failed to write credentials file at ${primaryPath}.`,
899
+ };
393
900
  }
394
- return {
395
- ok: false,
396
- path: primaryPath,
397
- target,
398
- kind: "write-error",
399
- error: `Failed to write credentials file at ${primaryPath}.`,
400
- };
401
- }
402
- return { ok: true, path: primaryPath, target, action, migratedFallback };
901
+ return { ok: true, path: primaryPath, target, action, migratedFallback };
902
+ }, (error) => ({ ok: false, path: primaryPath, target, kind: "lock-error", error }));
403
903
  }
@@ -595,7 +595,8 @@ function renderSystemGoals(goals, isPlanning = false) {
595
595
  let nfrHtml = "";
596
596
  if (nfrs.length > 0) {
597
597
  let items = "";
598
- for (const nfr of nfrs) {
598
+ for (let i = 0; i < nfrs.length; i++) {
599
+ const nfr = nfrs[i];
599
600
  const statusClass = nfr.status === "confirmed"
600
601
  ? "status-confirmed"
601
602
  : nfr.status === "assumed"
@@ -603,11 +604,15 @@ function renderSystemGoals(goals, isPlanning = false) {
603
604
  : "status-open";
604
605
  // nfrId is used as data-nfr-id (becomes the JSON output key) and aria-label.
605
606
  // nfrHtmlId is a whitespace-free variant used for id/name/for attributes —
606
- // HTML5 forbids spaces in id values.
607
+ // HTML5 forbids spaces in id values. The loop index i prefixes the radio/
608
+ // textarea identifiers so each requirement is its own radio group: without
609
+ // it, two categories that collapse to the same slug (e.g. "performance
610
+ // latency" vs "performance-latency") would share a name and clobber each
611
+ // other's selection. Mirrors renderImplementationOrder's index disambiguation.
607
612
  const nfrId = escapeHtml(nfr.category);
608
613
  const nfrHtmlId = escapeHtml(nfr.category.replace(/\s+/g, "-"));
609
- const radioName = `nfr-stance-${nfrHtmlId}`;
610
- const textareaId = `nfr-comment-${nfrHtmlId}`;
614
+ const radioName = `nfr-stance-${i}-${nfrHtmlId}`;
615
+ const textareaId = `nfr-comment-${i}-${nfrHtmlId}`;
611
616
  const stanceControls = isPlanning ? `
612
617
  <div class="nfr-feedback" data-nfr-id="${nfrId}">
613
618
  <div class="radio-group" role="radiogroup" aria-label="Stance on ${nfrId}">