@bridge_gpt/mcp-server 0.2.21 → 0.2.23
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/CONDUCTOR.md +86 -27
- package/README.md +80 -6
- package/build/base-ref.js +151 -0
- package/build/commands.generated.js +5 -3
- package/build/conductor/bridge-api-client.js +44 -3
- package/build/conductor/doctor.js +33 -22
- package/build/conductor/epic-runtime.js +101 -5
- package/build/conductor/pr-ci-producer.js +21 -2
- package/build/conductor/pr-discovery.js +12 -2
- package/build/conductor-bin.js +50 -20
- package/build/credential-store.js +564 -64
- package/build/executor/base-branch.js +50 -0
- package/build/executor/env.js +12 -1
- package/build/executor/job-errors.js +1 -0
- package/build/executor/job-runner.js +38 -7
- package/build/executor/test-clock.js +6 -1
- package/build/executor/worker-finalization.js +88 -1
- package/build/executor/worktree.js +21 -1
- package/build/index.js +1979 -423
- package/build/install-bridge.js +627 -69
- package/build/pipelines.generated.js +2 -2
- package/build/pr-base-contract.js +36 -0
- package/build/readme.generated.js +1 -1
- package/build/setup-epic.js +483 -0
- package/build/start-tickets.js +164 -75
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +62 -10
- package/package.json +3 -3
- package/public/js/main.min.js +9 -9
- package/public/js/main.min.js.map +1 -1
|
@@ -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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
391
|
-
|
|
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
|
-
|
|
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
|
}
|