@bridge_gpt/mcp-server 0.2.31 → 0.2.33

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.
@@ -19,11 +19,17 @@
19
19
  * (R5). Halt on failure — so a bad key never lands in the config and
20
20
  * traps later retries behind overwrite-consent.
21
21
  * Step 3 Write the per-host MCP config (.mcp.json / .cursor/mcp.json /
22
- * .vscode/mcp.json) with REAL values (BAPI_REPO_NAME / BAPI_API_KEY /
23
- * BAPI_BASE_URL / BAPI_DOCS_DIR), read-merge-write so unrelated servers
24
- * survive, pinning the launcher to the exact running version. Detected
25
- * Windsurf / Codex (global configs we never write into automatically)
26
- * get printed manual instructions.
22
+ * .vscode/mcp.json), read-merge-write so unrelated servers survive,
23
+ * pinning the launcher to the exact running version. A VALID, UNTRACKED
24
+ * (freshly-gitignored) config receives REAL values (BAPI_REPO_NAME /
25
+ * BAPI_API_KEY / BAPI_BASE_URL / BAPI_DOCS_DIR). A git-TRACKED config
26
+ * receives the real key only after an explicit default-No TTY consent —
27
+ * otherwise (interactive decline or any non-TTY run) the SECRET-FREE
28
+ * entry (no BAPI_API_KEY; the server self-resolves it) so the key is
29
+ * never committed on the next push. An INVALID (present-but-unparseable)
30
+ * config is left byte-identical and reported with manual-merge
31
+ * instructions (BAPI-666). Detected Windsurf / Codex (global configs we
32
+ * never write into automatically) get printed manual instructions.
27
33
  * Step 4 Persist the routing credential to the user-scoped store
28
34
  * (`~/.config/bridge/credentials.json`, target `bapi:<repo>`) via
29
35
  * `upsertBapiCredential`. Non-blocking (fail-open) — mirrors Stage 6.
@@ -87,13 +93,24 @@
87
93
  * that can replay a redemption, so a successful exchange with a failed local write
88
94
  * would leave an unrecoverable admin key and a permanently spent invite.
89
95
  *
96
+ * SELF-SERVE REPLAY (BAPI-667) — the self-serve arm is the one place the CLI both
97
+ * creates AND holds the invite token, so it is the one place a retry cannot ask the
98
+ * user to re-present it. Its pending record therefore additionally stores the minted
99
+ * token, and a self-serve run LOOKS THAT RECORD UP BEFORE MINTING: a resumable
100
+ * record short-circuits the mint entirely and re-drives the same exchange. A fresh
101
+ * mint is reachable only after the replayed token draws a conclusive 401 AND the
102
+ * user explicitly consents to discard the stored record.
103
+ *
90
104
  * SECRET DISCIPLINE: THREE secrets now — the API key, the bootstrap-invite token,
91
105
  * and the client-generated `key_secret`. NONE is ever printed or logged — not in
92
106
  * stdout, stderr, error messages, or --dry-run output (they are redacted to
93
107
  * `<REDACTED>`). The ONLY places a secret is durably written are the per-host MCP
94
108
  * config (Step 3, gitignored) and the user-scoped credential store (Step 4 / the
95
- * pending record). The bootstrap-invite token itself is NEVER written to disk —
96
- * only a SHA-256 fingerprint of it, to key the pending record.
109
+ * pending record). A USER-SUPPLIED bootstrap-invite token is never written to disk —
110
+ * only a SHA-256 fingerprint of it, to key the pending record. An INTERNALLY MINTED
111
+ * self-serve token is the sole exception (BAPI-667): it is stored inside the same
112
+ * 0600, fsync'd pending record as the `key_secret`, purely so the signup can be
113
+ * replayed, and it is discarded when that record is promoted.
97
114
  */
98
115
  import { readFile, writeFile, mkdir, stat, rename, chmod, unlink, open } from "fs/promises";
99
116
  import { spawn } from "child_process";
@@ -109,7 +126,7 @@ import { provisionHostTarget, createDefaultVendorProcessDeps, } from "./mcp-host
109
126
  import { writeMcpInstallState } from "./mcp-install-state.js";
110
127
  import { ensureGitignored as ensureGitignoredShared, } from "./git-ignore-utils.js";
111
128
  import { resolveStartTicketsRepoName } from "./start-tickets-repo.js";
112
- import { upsertBapiCredential, getPrimaryCredentialStorePath, prepareBootstrapPendingCredential, repointBootstrapPendingCredential, promoteBootstrapPendingCredential, resolveBapiCredentials, } from "./credential-store.js";
129
+ import { upsertBapiCredential, getPrimaryCredentialStorePath, prepareBootstrapPendingCredential, repointBootstrapPendingCredential, promoteBootstrapPendingCredential, lookupSelfServeBootstrapPendingCredential, discardBootstrapPendingCredential, getBootstrapPendingTarget, resolveBapiCredentials, } from "./credential-store.js";
113
130
  // BAPI-631: the optional GitHub connect offer reuses the standalone command's flow and
114
131
  // API primitives verbatim — no duplicated polling, browser, or picker logic here.
115
132
  import { fetchGithubConfigurationState } from "./connect-github-api.js";
@@ -185,10 +202,29 @@ export const INSTALL_BRIDGE_AGENT_PROMPT = "Execute the /install-bridge command
185
202
  "was already configured and zero changes were made, without a fabricated applied count.";
186
203
  /** Default base URL when `BAPI_BASE_URL` is unset (mirrors index.ts). */
187
204
  export const DEFAULT_BAPI_BASE_URL = "https://bridgegpt-api.com";
205
+ /**
206
+ * The Bridge web setup page for an ALREADY-normalized base URL (BAPI-669, U8).
207
+ *
208
+ * Every message that used to name a page without an address ("Security page",
209
+ * "setup UI", "Get Started page") now interpolates this, so the guidance is
210
+ * actionable on the hosted default and on a custom deployment alike. Deliberately
211
+ * NOT a per-deployment web-path discovery mechanism — that was rejected; `<base>/setup`
212
+ * is truthful for both. It carries only a URL and never a credential.
213
+ */
214
+ export function buildInstallBridgeSetupUrl(baseUrl) {
215
+ return `${baseUrl.replace(/\/+$/, "")}/setup`;
216
+ }
188
217
  /** Default docs dir when `BAPI_DOCS_DIR` is unset (mirrors index.ts). */
189
218
  export const DEFAULT_BAPI_DOCS_DIR = "docs/tmp";
190
- /** User-facing usage text. */
191
- export function getInstallBridgeUsage() {
219
+ /**
220
+ * User-facing usage text.
221
+ *
222
+ * `baseUrl` (BAPI-669, U8) makes the API-key guidance name a real address. The CLI
223
+ * passes the run's VALIDATED, normalized base URL; direct callers (and the parser's
224
+ * `help` result) get the hosted default.
225
+ */
226
+ export function getInstallBridgeUsage(baseUrl = DEFAULT_BAPI_BASE_URL) {
227
+ const setupUrl = buildInstallBridgeSetupUrl(baseUrl);
192
228
  return [
193
229
  "Usage:",
194
230
  " npx -y @bridge_gpt/mcp-server@latest install-bridge [flags]",
@@ -204,17 +240,28 @@ export function getInstallBridgeUsage() {
204
240
  "step). So not every run applies config fields. Indexing is never asked about —",
205
241
  "it starts automatically once the repository reaches full parse readiness.",
206
242
  "",
243
+ "Your API key is written to a project MCP config only when that file is safe: a",
244
+ "valid, git-ignored config gets the real key, but a config already TRACKED by git",
245
+ "gets it only after an explicit default-No confirmation (and never at all in a",
246
+ "non-interactive run) — otherwise a secret-free entry is written and the server",
247
+ "resolves your key from the credential store at runtime. A config file that",
248
+ "cannot be parsed is left untouched, with manual-merge instructions printed.",
249
+ "",
207
250
  "Run it bare — `install-bridge` with no flags — in a terminal and it asks",
208
- `\`${INSTALL_BRIDGE_KEY_SELECTOR_PROMPT.trim()}\` first. Answer yes (or press Enter) for the`,
209
- "existing-key flow below; answer no and it asks for an email and creates a new",
210
- "Bridge workspace for you (the self-serve flow). That question is asked ONLY for a",
211
- "bare interactive run: passing ANY flag, setting BAPI_API_KEY, or running without",
212
- "an interactive terminal keeps the existing deterministic behavior and no prompt.",
251
+ `\`${INSTALL_BRIDGE_KEY_SELECTOR_PROMPT}\` first, with three numbered choices:`,
252
+ ...INSTALL_BRIDGE_ONBOARDING_CHOICES.map((choice) => ` ${choice}`),
253
+ "There is NO default pressing Enter selects nothing; you must type 1, 2, or 3",
254
+ "(one blank or invalid answer re-prompts once, then exits with guidance). Option 1",
255
+ "is the existing-key flow below, option 2 is the bootstrap-invite flow, and option 3",
256
+ "prompts for an email and creates a brand-new Bridge workspace for you (the",
257
+ "self-serve flow). That question is asked ONLY for a bare interactive run: passing",
258
+ "ANY flag, setting BAPI_API_KEY, or running without an interactive terminal keeps",
259
+ "the existing deterministic behavior and no prompt.",
213
260
  "",
214
261
  "Inputs (the only two irreducible ones):",
215
262
  " --api-key <key> Bridge API key OR bootstrap invite. Falls back to the",
216
263
  " BAPI_API_KEY env var, then an interactive (no-echo) prompt.",
217
- " Generate a key in the Bridge API web UI Security page — this",
264
+ ` Generate a key at ${setupUrl} (Security page) — this`,
218
265
  " command consumes a key, it does not create one. A value",
219
266
  " detected as a bootstrap invite (bapi_inv_…), from any of the",
220
267
  " three sources above, is instead redeemed to CREATE a new",
@@ -232,7 +279,7 @@ export function getInstallBridgeUsage() {
232
279
  " non-interactive). In the existing-key flow it MUST match the",
233
280
  " server-side repo registration (it keys the credential store",
234
281
  " as bapi:<repo>). In either new-project flow (--email,",
235
- " --invite, or a negative answer to the key question above) it",
282
+ " --invite, or chooser option 2 or 3 above) it",
236
283
  " instead NAMES the project this run creates, so you are asked",
237
284
  " to name a new project rather than match an existing one; the",
238
285
  " name must be globally unique.",
@@ -243,14 +290,22 @@ export function getInstallBridgeUsage() {
243
290
  " It requests a fresh workspace for that email, then creates",
244
291
  " the project and mints your own admin API key in one command.",
245
292
  " Falls back to the BAPI_SIGNUP_EMAIL env var, then a visible",
246
- " interactive prompt — which is also what a negative answer to",
247
- " the bare-run key question above reaches. The email is NOT a",
293
+ " interactive prompt — which is also what chooser option 3 on a",
294
+ " bare run reaches. The email is NOT a",
248
295
  " secret (it is shown as you type), but it is never printed to",
249
296
  " a log. Mutually",
250
297
  " exclusive with --api-key and --invite. No email verification",
251
298
  " is performed and no message is sent to the address — it only",
252
299
  " labels the new workspace.",
253
300
  "",
301
+ " RESUMABLE: a self-serve run that fails mid-protocol saves its",
302
+ " signup state under bootstrap-pending:<repo> in the credential",
303
+ " store. Re-running the self-serve flow RESUMES that attempt —",
304
+ " it does not sign up again — so a retry never creates a second",
305
+ " workspace. Never copy, display, or hand-remove that record; if",
306
+ " the saved invite has genuinely expired the CLI asks before",
307
+ " discarding it.",
308
+ "",
254
309
  "Bootstrap-invite onboarding (no web UI, no pre-existing key):",
255
310
  " --invite [token] Redeem a bootstrap invite you were already given: creates",
256
311
  " the project and mints your own admin API key in one command.",
@@ -289,7 +344,10 @@ export function getInstallBridgeUsage() {
289
344
  " launches nothing.",
290
345
  " --force Overwrite an existing real BAPI_API_KEY in a",
291
346
  " host config (or in the credential store) without",
292
- " prompting.",
347
+ " prompting. It does NOT bypass the git-tracked",
348
+ " config safeguard below: --force authorizes",
349
+ " replacing a credential, not disclosing your key",
350
+ " into version control.",
293
351
  " --dry-run Preview every step (scaffold targets, config",
294
352
  " files + keys with the key REDACTED, ping",
295
353
  " target, credential target, and the consent-gated",
@@ -564,6 +622,25 @@ export function promptSecretViaReadline(promptText, input = process.stdin, outpu
564
622
  muted = true;
565
623
  });
566
624
  }
625
+ /**
626
+ * The deferral command printed with the GitHub offer (BAPI-669, U6). Declining is
627
+ * cheap precisely because this exists — the connection is a standalone command.
628
+ */
629
+ export const INSTALL_BRIDGE_CONNECT_GITHUB_COMMAND = "npx -y @bridge_gpt/mcp-server connect-github";
630
+ /**
631
+ * The `Step 4b` label + purpose + deferral lines printed immediately before the
632
+ * GitHub prompt (BAPI-669, U6). Shared with {@link buildLaunchStepPreview} so the
633
+ * live run and the `--dry-run` preview can never advertise different behavior.
634
+ */
635
+ export const INSTALL_BRIDGE_GITHUB_OFFER_CONTEXT = [
636
+ "",
637
+ "Step 4b — optional: connect GitHub.",
638
+ " This installs the Bridge GitHub App so pull requests and code review work. It opens",
639
+ " github.com in your browser; no GitHub credential is shared with Bridge.",
640
+ ` You can do this later instead: ${INSTALL_BRIDGE_CONNECT_GITHUB_COMMAND}`,
641
+ ];
642
+ /** Default-No GitHub offer prompt (BAPI-669, U6 — flipped from the old `(Y/n)`). */
643
+ export const INSTALL_BRIDGE_GITHUB_CONNECT_PROMPT = "Connect GitHub? [y/N]: ";
567
644
  /**
568
645
  * Offer to connect GitHub, if it is not already connected (BAPI-631).
569
646
  *
@@ -574,7 +651,7 @@ export function promptSecretViaReadline(promptText, input = process.stdin, outpu
574
651
  * Reuses the shared connect-github flow rather than duplicating the API, polling,
575
652
  * browser, or picker logic — there is exactly one implementation of that handshake.
576
653
  */
577
- async function offerGithubConnection(repoName, deps, log) {
654
+ export async function offerGithubConnection(repoName, baseUrl, deps, log) {
578
655
  // No prompt surface → no offer. Never assume consent on a non-interactive run.
579
656
  if (!deps.isTTY || !deps.promptLine)
580
657
  return;
@@ -595,8 +672,11 @@ async function offerGithubConnection(repoName, deps, log) {
595
672
  if (!cred.ok)
596
673
  return;
597
674
  const api = {
675
+ // BAPI-668 (R9-2): the ALREADY-VALIDATED base URL is passed in. This helper
676
+ // deliberately no longer re-derives it from `deps.env` — normalization has
677
+ // exactly one point, at the CLI entry boundary.
598
678
  fetch: deps.fetch,
599
- baseUrl: deps.env.BAPI_BASE_URL?.trim() || DEFAULT_BAPI_BASE_URL,
679
+ baseUrl,
600
680
  apiKey: cred.credentials.apiKey,
601
681
  };
602
682
  const state = await fetchGithubConfigurationState(api, repoName);
@@ -608,8 +688,14 @@ async function offerGithubConnection(repoName, deps, log) {
608
688
  log(" note: could not read GitHub configuration status; skipping the GitHub offer.");
609
689
  return;
610
690
  }
611
- const answer = (await deps.promptLine("Connect GitHub? (Y/n): ")).trim().toLowerCase();
612
- if (answer === "n" || answer === "no")
691
+ // BAPI-669 (U6): explain the action BEFORE asking, and default to No. Opening a
692
+ // browser is a side effect the user should opt IN to, so a bare Enter now skips.
693
+ for (const line of INSTALL_BRIDGE_GITHUB_OFFER_CONTEXT)
694
+ log(line);
695
+ const answer = (await deps.promptLine(INSTALL_BRIDGE_GITHUB_CONNECT_PROMPT)).trim().toLowerCase();
696
+ // Only an explicit yes proceeds — blank input, EOF, "n", and anything malformed
697
+ // all skip without opening a browser.
698
+ if (answer !== "y" && answer !== "yes")
613
699
  return; // declining is a normal outcome
614
700
  const connectDeps = createDefaultConnectGithubDeps();
615
701
  const code = await runGithubConnectionFlow(connectDeps, api, repoName);
@@ -738,11 +824,20 @@ export function createDefaultInstallBridgeDeps() {
738
824
  prepareBootstrapPending: prepareBootstrapPendingCredential,
739
825
  repointBootstrapPending: repointBootstrapPendingCredential,
740
826
  promoteBootstrapPending: promoteBootstrapPendingCredential,
827
+ lookupSelfServeBootstrapPending: lookupSelfServeBootstrapPendingCredential,
828
+ discardBootstrapPending: discardBootstrapPendingCredential,
741
829
  buildShellCommand: buildGenericAgentShellCommand,
742
830
  spawnTerminalTab: getDefaultSpawnTerminalTabForPlatform(process.platform),
743
831
  startTicketsDeps: createDefaultStartTicketsDeps(),
744
832
  log: (m) => console.log(m),
745
833
  errorLog: (m) => console.error(m),
834
+ // Debug-only sink (BAPI-666): gated on BAPI_INSTALL_DEBUG so it is silent on a
835
+ // normal install and never pollutes stdout/stderr. Emits to stderr (never
836
+ // stdout) so any diagnostic can never be mistaken for machine-readable output.
837
+ debugLog: (m) => {
838
+ if (process.env.BAPI_INSTALL_DEBUG)
839
+ console.error(m);
840
+ },
746
841
  };
747
842
  }
748
843
  /**
@@ -750,6 +845,10 @@ export function createDefaultInstallBridgeDeps() {
750
845
  * interactive no-echo prompt. Fails (secret-free) when none is available and
751
846
  * stdin is non-interactive.
752
847
  *
848
+ * Returns WHICH source won alongside the value (BAPI-668, R11-1), so a rejected
849
+ * credential can name where it came from. The source is a safe label; the value
850
+ * is never printed anywhere.
851
+ *
753
852
  * Despite the name, this is CREDENTIAL-AGNOSTIC (BAPI-661): the returned value may
754
853
  * be a full Bridge API key or a bootstrap-invite token (`bapi_inv_…`) — the caller
755
854
  * classifies it with {@link classifyEnteredCredential} immediately after this
@@ -759,27 +858,29 @@ export function createDefaultInstallBridgeDeps() {
759
858
  */
760
859
  export async function resolveApiKey(options, deps) {
761
860
  if (typeof options.apiKey === "string" && options.apiKey.trim().length > 0) {
762
- return { ok: true, value: options.apiKey.trim() };
861
+ return { ok: true, value: options.apiKey.trim(), source: "flag" };
763
862
  }
764
863
  const fromEnv = deps.env.BAPI_API_KEY;
765
864
  if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
766
- return { ok: true, value: fromEnv.trim() };
865
+ return { ok: true, value: fromEnv.trim(), source: "env" };
767
866
  }
768
867
  if (deps.isTTY && deps.promptSecret) {
769
868
  const entered = (await deps.promptSecret("Bridge API key or invite (input hidden): ")).trim();
770
869
  if (entered.length > 0) {
771
- return { ok: true, value: entered };
870
+ return { ok: true, value: entered, source: "prompt" };
772
871
  }
872
+ // BAPI-667: the old copy ended with "try the hidden prompt again", which was
873
+ // simply false — the process exits here, so there is no prompt left to retry —
874
+ // and it named no route usable by someone who has no key at all.
773
875
  return {
774
876
  ok: false,
775
- error: "No Bridge API key or invite entered. Pass --api-key, set the BAPI_API_KEY environment " +
776
- "variable, or try the hidden prompt again.",
877
+ error: `No Bridge API key or invite entered. ${INSTALL_BRIDGE_ENTRY_ROUTES_ADVICE}`,
777
878
  };
778
879
  }
779
880
  return {
780
881
  ok: false,
781
- error: "A Bridge API key or invite is required. Pass --api-key or set the BAPI_API_KEY " +
782
- "environment variable (no interactive terminal is available to prompt for it).",
882
+ error: "A Bridge API key or invite is required (no interactive terminal is available to prompt " +
883
+ `for it). ${INSTALL_BRIDGE_ENTRY_ROUTES_ADVICE}`,
783
884
  };
784
885
  }
785
886
  /**
@@ -884,8 +985,40 @@ export function resolveInstallBridgeOnboardingBranch(options, env) {
884
985
  return { kind: "need-key", method: "self-serve" };
885
986
  return { kind: "have-key" };
886
987
  }
887
- /** The exact visible text of the bare-TTY onboarding selector (BAPI-626, BAPI-661). */
888
- export const INSTALL_BRIDGE_KEY_SELECTOR_PROMPT = "Do you have a Bridge API key or invite? [Y/n] ";
988
+ /**
989
+ * The bare-TTY onboarding chooser (BAPI-667), replacing the BAPI-626/661 Y/n
990
+ * question. The old prompt defaulted a bare Enter to "yes" and dropped the newest
991
+ * users — the ones with no key, no invite, and no account — into a hidden API-key
992
+ * prompt they could not answer, then exited. The self-serve path built for exactly
993
+ * that user was never named. The chooser names all three routes and has NO default,
994
+ * so Enter can no longer route anyone into a prompt they cannot satisfy.
995
+ *
996
+ * Interaction contract mirrors the BAPI-663 tool picker: render the heading and the
997
+ * numbered options once, accept ONE strict line.
998
+ */
999
+ export const INSTALL_BRIDGE_KEY_SELECTOR_PROMPT = "How would you like to connect to Bridge API?";
1000
+ /** The chooser's numbered options, in render order. Index + 1 is the accepted token. */
1001
+ export const INSTALL_BRIDGE_ONBOARDING_CHOICES = [
1002
+ "1. I have a Bridge API key",
1003
+ "2. I have an invite token",
1004
+ "3. I'm new — set me up with just my email",
1005
+ ];
1006
+ /** The single-line answer prompt drawn after the options (no bracketed default). */
1007
+ export const INSTALL_BRIDGE_ONBOARDING_CHOICE_PROMPT = "Enter 1, 2, or 3: ";
1008
+ /** The one hint line printed after a blank/invalid answer, before the single re-prompt. */
1009
+ export const INSTALL_BRIDGE_ONBOARDING_CHOICE_HINT = "Enter 1, 2, or 3.";
1010
+ /**
1011
+ * The one place the "no usable credential" copy lives (BAPI-667, U1-3).
1012
+ *
1013
+ * Centralized because the chooser failure and {@link resolveApiKey}'s two failure
1014
+ * branches previously drifted: only one of them mentioned self-serve, and one
1015
+ * advised "try the hidden prompt again" — false, because the process has already
1016
+ * exited by the time the user reads it. All three now name the SAME three routes.
1017
+ */
1018
+ export const INSTALL_BRIDGE_ENTRY_ROUTES_ADVICE = "Re-run install-bridge and choose an option: pass --api-key <key> if you have a Bridge API " +
1019
+ "key (or set BAPI_API_KEY), --invite if you were sent an invite token, or --email <addr> " +
1020
+ "to sign up with just an email — on a bare interactive run, re-run and choose option 3 to " +
1021
+ "sign up with just an email.";
889
1022
  /**
890
1023
  * Interactive wrapper around {@link resolveInstallBridgeOnboardingBranch}.
891
1024
  *
@@ -906,6 +1039,13 @@ export const INSTALL_BRIDGE_KEY_SELECTOR_PROMPT = "Do you have a Bridge API key
906
1039
  * A prompt failure becomes a typed, secret-free failure rather than an exception:
907
1040
  * the caller turns it into an exit code, and terminal/internal error text never
908
1041
  * reaches the user.
1042
+ *
1043
+ * BAPI-667 replaced the Y/n question with a three-option chooser. Two properties
1044
+ * are load-bearing: there is NO default (a bare Enter can no longer route a
1045
+ * brand-new user into the hidden key prompt), and option 3 names the self-serve
1046
+ * route explicitly so it is discoverable without reading `--help`. Validation is
1047
+ * bounded to ONE re-prompt — an unanswerable prompt that loops is the same
1048
+ * dead-end in a different costume.
909
1049
  */
910
1050
  export async function resolveInstallBridgeOnboardingBranchForRun(options, deps, argv) {
911
1051
  const branch = resolveInstallBridgeOnboardingBranch(options, deps.env);
@@ -919,30 +1059,39 @@ export async function resolveInstallBridgeOnboardingBranchForRun(options, deps,
919
1059
  }
920
1060
  const promptLine = deps.promptLine;
921
1061
  try {
922
- // Bounded so a prompt seam that returns the same invalid value forever (a
923
- // misbehaving pipe that passes the TTY check) cannot spin indefinitely.
924
- for (let attempt = 0; attempt < 5; attempt += 1) {
925
- const answer = (await promptLine(INSTALL_BRIDGE_KEY_SELECTOR_PROMPT)).trim().toLowerCase();
926
- // Blank = accept the bracketed default (Y), matching the prompt's own contract.
927
- if (answer.length === 0 || answer === "y" || answer === "yes") {
1062
+ // Render the heading + options EXACTLY once; a re-prompt after an invalid
1063
+ // answer re-asks the question without redrawing the list (BAPI-663 contract).
1064
+ deps.log(INSTALL_BRIDGE_KEY_SELECTOR_PROMPT);
1065
+ for (const choice of INSTALL_BRIDGE_ONBOARDING_CHOICES)
1066
+ deps.log(choice);
1067
+ // Bounded to two reads total: the first answer, then ONE re-prompt after a
1068
+ // single hint line. EOF resolves as an empty answer (promptLineViaReadline
1069
+ // resolves "" on close), so it costs one re-prompt and then fails cleanly —
1070
+ // it can never hang or silently select a branch.
1071
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1072
+ const answer = (await promptLine(INSTALL_BRIDGE_ONBOARDING_CHOICE_PROMPT)).trim();
1073
+ // Option 1 stays credential-agnostic: a `bapi_inv_…` value pasted into the
1074
+ // hidden key prompt is still reclassified downstream (BAPI-661).
1075
+ if (answer === "1")
928
1076
  return { ok: true, branch: { kind: "have-key" } };
1077
+ if (answer === "2") {
1078
+ return { ok: true, branch: { kind: "need-key", method: "bootstrap-invite" } };
929
1079
  }
930
- if (answer === "n" || answer === "no") {
1080
+ if (answer === "3")
931
1081
  return { ok: true, branch: { kind: "need-key", method: "self-serve" } };
932
- }
933
- deps.log("Please answer y or n (press Enter for yes).");
1082
+ if (attempt === 0)
1083
+ deps.log(INSTALL_BRIDGE_ONBOARDING_CHOICE_HINT);
934
1084
  }
935
1085
  return {
936
1086
  ok: false,
937
- error: "No valid answer to the Bridge API key question. Re-run and answer y or n.",
1087
+ error: `No option was selected. ${INSTALL_BRIDGE_ENTRY_ROUTES_ADVICE}`,
938
1088
  };
939
1089
  }
940
1090
  catch {
941
1091
  // Secret-free by construction: the caught value is never surfaced.
942
1092
  return {
943
1093
  ok: false,
944
- error: "Could not read your answer from the terminal. Re-run with --api-key <key> if you have a " +
945
- "Bridge API key, or --email <addr> to create a new Bridge workspace.",
1094
+ error: `Could not read your answer from the terminal. ${INSTALL_BRIDGE_ENTRY_ROUTES_ADVICE}`,
946
1095
  };
947
1096
  }
948
1097
  }
@@ -1194,12 +1343,17 @@ export function resolveInstallBridgeLaunchDecision(selectedPlatforms, explicitAg
1194
1343
  * The human-facing tool label for a launch agent, derived by scanning the host
1195
1344
  * registry's {@link HostTargetDefinition.launchAgent} metadata rather than keeping a
1196
1345
  * duplicate agent→label map (BAPI-657). Returns e.g. `"Claude Code"` for `"claude"`
1197
- * and `"Cursor"` for `"cursor-agent"`, falling back to the raw agent name if no host
1198
- * advertises it (never expected for a registered launch agent).
1346
+ * and `"Cursor"` for `"cursor-agent"`.
1347
+ *
1348
+ * BAPI-669 (U3): the fallback is NEUTRAL rather than the raw agent binary name. This
1349
+ * function's output is user-facing copy ("open a session in …"), so an unregistered
1350
+ * agent must degrade to generic prose instead of leaking a binary name into a
1351
+ * sentence that reads as a product name. Never expected for a registered agent.
1199
1352
  */
1353
+ export const UNKNOWN_LAUNCH_TOOL_LABEL = "your AI coding tool";
1200
1354
  export function toolLabelForLaunchAgent(agent) {
1201
1355
  const target = allHostTargets().find((t) => t.launchAgent === agent);
1202
- return target?.label ?? agent;
1356
+ return target?.label ?? UNKNOWN_LAUNCH_TOOL_LABEL;
1203
1357
  }
1204
1358
  /**
1205
1359
  * Interactive numbered chooser for when MORE THAN ONE launchable tool was selected
@@ -1424,58 +1578,244 @@ export function buildInstallBridgeServerEntry(cwd, repoName, apiKey, baseUrl, do
1424
1578
  };
1425
1579
  return { command: entry.command, args: entry.args, env };
1426
1580
  }
1427
- /** Read + parse an existing host config; `null` if absent/unparseable. */
1581
+ /**
1582
+ * Build the per-host `bridge-api` MCP entry WITHOUT an API key (BAPI-666). Same
1583
+ * version-pinned launcher and non-secret env (repo name, base URL, docs dir,
1584
+ * project root) as {@link buildInstallBridgeServerEntry}, but `BAPI_API_KEY` is
1585
+ * OMITTED ENTIRELY — never set to `YOUR_API_KEY` or `<REDACTED>`. A placeholder
1586
+ * key literal in the config would OVERRIDE credential-store resolution at runtime,
1587
+ * so the key is left absent and the server self-resolves it from the environment
1588
+ * or the user-scoped store. This is the safe entry for a git-tracked target the
1589
+ * user declined (or could not be prompted) to write a real key into.
1590
+ */
1591
+ export function buildInstallBridgeSecretFreeServerEntry(cwd, repoName, baseUrl, docsDir) {
1592
+ const entry = buildBridgeApiEntry(cwd);
1593
+ const env = {
1594
+ ...entry.env,
1595
+ BAPI_REPO_NAME: repoName,
1596
+ BAPI_BASE_URL: baseUrl,
1597
+ BAPI_DOCS_DIR: docsDir,
1598
+ };
1599
+ // buildBridgeApiEntry is already secret-free; delete defensively so a future
1600
+ // change to the scaffold entry can never smuggle a key into the secret-free path.
1601
+ delete env.BAPI_API_KEY;
1602
+ return { command: entry.command, args: entry.args, env };
1603
+ }
1604
+ /**
1605
+ * Fixed, secret-free credential-store guidance (BAPI-666). Names the resolved
1606
+ * user-scoped credentials path and the safe target id `bapi:<repoName>` so a user
1607
+ * whose config did not receive the real key knows exactly where the server WILL
1608
+ * resolve it from at runtime. Carries no API key, invite, or `key_secret` value.
1609
+ */
1610
+ function formatCredentialStoreGuidance(repoName, credentialStorePath) {
1611
+ return [
1612
+ " The bridge-api server resolves your key at runtime from the BAPI_API_KEY environment",
1613
+ ` variable or the user-scoped credential store (${credentialStorePath}, target`,
1614
+ ` bapi:${repoName}), so it stays out of this file entirely.`,
1615
+ ];
1616
+ }
1617
+ /**
1618
+ * Render the secret-free `bridge-api` entry as a MERGE-ME snippet for one host
1619
+ * target (BAPI-666). Uses the target's real root key (`mcpServers` for Claude /
1620
+ * Cursor, `servers` for VS Code) and the secret-free entry ONLY, so the API key
1621
+ * can never enter the generated snippet. The snippet is for MERGING into the
1622
+ * existing file — never for replacing it.
1623
+ */
1624
+ function formatSecretFreeManualMerge(relPath, topLevelKey, secretFreeEntry) {
1625
+ const snippet = JSON.stringify({ [topLevelKey]: { "bridge-api": secretFreeEntry } }, null, 2);
1626
+ return [
1627
+ ` To configure ${relPath} by hand, MERGE this secret-free entry into the existing`,
1628
+ " file (do not replace the file):",
1629
+ snippet,
1630
+ ];
1631
+ }
1632
+ /**
1633
+ * Fixed, secret-free wording for a present-but-unparseable host config (BAPI-666).
1634
+ * Names only the relative path and states it was left untouched — never the raw
1635
+ * content or the caught parser/filesystem exception text.
1636
+ */
1637
+ function formatInvalidConfigNotice(relPath) {
1638
+ return ` ${relPath} could not be parsed safely — it was left untouched.`;
1639
+ }
1640
+ /**
1641
+ * Read + classify an existing host config into `absent | invalid | parsed`
1642
+ * (BAPI-666). Only an `ENOENT` read failure is `absent`; every other read error,
1643
+ * JSON parse failure (comments, trailing commas, truncation), and a `null` /
1644
+ * array / non-object JSON root is `invalid` so an unreadable-but-present file is
1645
+ * never overwritten as though it were missing.
1646
+ */
1428
1647
  async function readHostConfig(deps, fullPath) {
1429
1648
  let raw;
1430
1649
  try {
1431
1650
  raw = await deps.readFile(fullPath);
1432
1651
  }
1433
- catch {
1434
- return null;
1435
- }
1652
+ catch (err) {
1653
+ // A missing file is the only benign, freshly-writable state. Any other read
1654
+ // failure (permissions, I/O) is treated as `invalid` so we never clobber a
1655
+ // present-but-unreadable file. Do NOT carry the exception text forward.
1656
+ if (err?.code === "ENOENT")
1657
+ return { state: "absent" };
1658
+ return { state: "invalid" };
1659
+ }
1660
+ let parsed;
1436
1661
  try {
1437
- const parsed = JSON.parse(raw);
1438
- return parsed && typeof parsed === "object" ? parsed : null;
1662
+ parsed = JSON.parse(raw);
1439
1663
  }
1440
1664
  catch {
1441
- return null;
1665
+ return { state: "invalid" };
1666
+ }
1667
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1668
+ return { state: "invalid" };
1442
1669
  }
1670
+ return { state: "parsed", config: parsed };
1671
+ }
1672
+ /** Narrow an unknown value to a plain (non-array) record, else `undefined`. */
1673
+ function asRecord(value) {
1674
+ return value !== null && typeof value === "object" && !Array.isArray(value)
1675
+ ? value
1676
+ : undefined;
1443
1677
  }
1444
1678
  /**
1445
1679
  * Detect whether any resolved host config already carries a real (non-placeholder)
1446
1680
  * `BAPI_API_KEY` for the `bridge-api` server — the trigger for overwrite consent.
1681
+ * An `invalid` (present-but-unsafe) config conservatively returns `true` so the
1682
+ * overwrite-consent gate assumes a real key MAY be present rather than silently
1683
+ * replacing a file we could not inspect (BAPI-666).
1447
1684
  */
1448
1685
  async function detectExistingRealKey(deps, targets) {
1449
1686
  for (const target of targets) {
1450
- const parsed = await readHostConfig(deps, path.join(deps.cwd, target.relPath));
1451
- const entry = parsed?.[target.topLevelKey]?.["bridge-api"];
1452
- if (entry?.env && !isPlaceholderApiKey(entry.env.BAPI_API_KEY)) {
1687
+ const result = await readHostConfig(deps, path.join(deps.cwd, target.relPath));
1688
+ if (result.state === "invalid")
1689
+ return true;
1690
+ if (result.state === "absent")
1691
+ continue;
1692
+ const topLevel = asRecord(result.config[target.topLevelKey]);
1693
+ const entry = asRecord(topLevel?.["bridge-api"]);
1694
+ const env = asRecord(entry?.["env"]);
1695
+ if (env && !isPlaceholderApiKey(env["BAPI_API_KEY"])) {
1453
1696
  return true;
1454
1697
  }
1455
1698
  }
1456
1699
  return false;
1457
1700
  }
1701
+ /** The fixed, secret-free hard warning shown for a git-tracked project config (BAPI-666). */
1702
+ function trackedConfigWarning(relPath) {
1703
+ return `${relPath} is tracked by git — writing your API key here would commit it on your next push`;
1704
+ }
1705
+ /**
1706
+ * Read-only probe: is `relPath` already tracked by git in the project (BAPI-666)?
1707
+ * Runs `git ls-files --error-unmatch -- <relPath>` through the injected, list-based
1708
+ * command seam (never a shell string) with `cwd = deps.cwd`. ONLY exit code 0 means
1709
+ * tracked; every other outcome — a non-zero exit (untracked / gitignored), a non-git
1710
+ * directory, or a thrown command failure — is treated as NOT tracked, preserving the
1711
+ * required fail-open behavior (a probe failure must never block the install). Emits at
1712
+ * most a fixed debug-level line (safe relative path + exit classification) and never
1713
+ * the command's stdout/stderr, exception text, env, or config content.
1714
+ */
1715
+ async function isTrackedProjectConfig(deps, relPath) {
1716
+ try {
1717
+ const result = await deps.startTicketsDeps.runCommand("git", ["ls-files", "--error-unmatch", "--", relPath], { cwd: deps.cwd });
1718
+ if (result.exitCode === 0)
1719
+ return true;
1720
+ deps.debugLog(`install-bridge: git-tracked probe for ${relPath} → not tracked (exit ${result.exitCode})`);
1721
+ return false;
1722
+ }
1723
+ catch {
1724
+ deps.debugLog(`install-bridge: git-tracked probe for ${relPath} → not tracked (probe failed)`);
1725
+ return false;
1726
+ }
1727
+ }
1728
+ /**
1729
+ * Interactively ask whether to write the REAL API key into a git-tracked config
1730
+ * (BAPI-666). Prints the required hard warning, then a default-No y/N prompt.
1731
+ * Returns `true` ONLY for an explicit `y`/`yes` (trimmed, case-insensitive). A
1732
+ * missing prompt seam, blank / negative / malformed answer, or a prompt exception
1733
+ * all DECLINE. Independent of --force: --force authorizes credential replacement,
1734
+ * not disclosure of the key into version control.
1735
+ */
1736
+ async function requestTrackedConfigConsent(deps, relPath) {
1737
+ deps.errorLog(trackedConfigWarning(relPath));
1738
+ if (!deps.promptLine)
1739
+ return false;
1740
+ let answer;
1741
+ try {
1742
+ answer = await deps.promptLine(`Write your API key into ${relPath} anyway? (y/N) `);
1743
+ }
1744
+ catch {
1745
+ return false;
1746
+ }
1747
+ const normalized = (answer ?? "").trim().toLowerCase();
1748
+ return normalized === "y" || normalized === "yes";
1749
+ }
1458
1750
  /**
1459
1751
  * Write the `bridge-api` entry into each host config via read-merge-write,
1460
- * preserving unrelated servers and top-level keys. Returns the list of written
1461
- * relative paths.
1752
+ * preserving unrelated servers and top-level keys (BAPI-666). Per target:
1753
+ *
1754
+ * - `invalid` (present but unparseable/unsafe): leave the file byte-identical (no
1755
+ * mkdir, no write), emit a loud secret-free notice + manual-merge instructions,
1756
+ * and record it under `skipped`. This is non-fatal — remaining targets continue.
1757
+ * - tracked by git: write a REAL key only after explicit TTY consent; otherwise
1758
+ * (interactive decline OR any non-TTY run) write the secret-free entry and print
1759
+ * the credential-store guidance + `git rm --cached` remediation. Tracked-file
1760
+ * consent is evaluated only AFTER the invalid check, so a to-be-skipped file
1761
+ * never receives a misleading write-consent prompt.
1762
+ * - untracked (the common freshly-gitignored case): write the real key.
1462
1763
  */
1463
- async function writeHostConfigs(deps, targets, entry) {
1764
+ async function writeHostConfigs(deps, targets, entries, trackedState, ctx) {
1464
1765
  const written = [];
1766
+ const skipped = [];
1465
1767
  for (const target of targets) {
1466
1768
  const fullPath = path.join(deps.cwd, target.relPath);
1467
- const parsed = (await readHostConfig(deps, fullPath)) ?? {};
1468
- if (!parsed[target.topLevelKey] || typeof parsed[target.topLevelKey] !== "object") {
1469
- parsed[target.topLevelKey] = {};
1769
+ const read = await readHostConfig(deps, fullPath);
1770
+ // (Step 5.16) Invalid state is evaluated BEFORE any tracked-file consent so a
1771
+ // file that will be skipped never triggers a misleading write-consent prompt.
1772
+ if (read.state === "invalid") {
1773
+ deps.errorLog(formatInvalidConfigNotice(target.relPath));
1774
+ for (const line of formatSecretFreeManualMerge(target.relPath, target.topLevelKey, entries.secretFree)) {
1775
+ deps.errorLog(line);
1776
+ }
1777
+ skipped.push({ relPath: target.relPath, reason: "invalid" });
1778
+ continue;
1470
1779
  }
1471
- parsed[target.topLevelKey]["bridge-api"] = entry;
1780
+ // Choose the entry variant. A real key only lands in an untracked target, or a
1781
+ // tracked target the user EXPLICITLY consented to on a TTY.
1782
+ const tracked = trackedState.get(target.relPath) ?? false;
1783
+ let entry = entries.real;
1784
+ let mode = "real-key";
1785
+ if (tracked) {
1786
+ const interactive = deps.isTTY && Boolean(deps.promptLine);
1787
+ const consented = interactive ? await requestTrackedConfigConsent(deps, target.relPath) : false;
1788
+ if (consented) {
1789
+ entry = entries.real;
1790
+ mode = "real-key";
1791
+ }
1792
+ else {
1793
+ entry = entries.secretFree;
1794
+ mode = "secret-free";
1795
+ // A non-TTY run never prompts; surface the same hard warning up front
1796
+ // (requestTrackedConfigConsent already printed it on the interactive path).
1797
+ if (!interactive)
1798
+ deps.errorLog(trackedConfigWarning(target.relPath));
1799
+ for (const line of formatCredentialStoreGuidance(ctx.repoName, ctx.credentialStorePath)) {
1800
+ deps.errorLog(line);
1801
+ }
1802
+ deps.errorLog(` To stop tracking it: git rm --cached -- ${target.relPath}`);
1803
+ }
1804
+ }
1805
+ // Build the document. Preserve everything on a `parsed` config; start fresh only
1806
+ // for `absent` — decided AFTER the safety branches above so an absent file is
1807
+ // never initialized before its tracked-state consent is resolved.
1808
+ const config = read.state === "parsed" ? read.config : {};
1809
+ const topLevel = asRecord(config[target.topLevelKey]) ?? {};
1810
+ topLevel["bridge-api"] = entry;
1811
+ config[target.topLevelKey] = topLevel;
1472
1812
  await deps.mkdir(path.dirname(fullPath), { recursive: true });
1473
- await deps.writeFile(fullPath, JSON.stringify(parsed, null, 2) + "\n", {
1813
+ await deps.writeFile(fullPath, JSON.stringify(config, null, 2) + "\n", {
1474
1814
  encoding: "utf-8",
1475
1815
  });
1476
- written.push(target.relPath);
1816
+ written.push({ relPath: target.relPath, mode });
1477
1817
  }
1478
- return written;
1818
+ return { written, skipped };
1479
1819
  }
1480
1820
  /**
1481
1821
  * Provision the selected GLOBAL (Codex, Copilot CLI) and MANUAL (Windsurf)
@@ -1484,9 +1824,15 @@ async function writeHostConfigs(deps, targets, entry) {
1484
1824
  * config paths are never added to the repository .gitignore. Returns secret-free
1485
1825
  * log lines and whether Codex was auto-provisioned (so the legacy Codex manual
1486
1826
  * hint can be suppressed).
1827
+ *
1828
+ * `needKey` (BAPI-669, R8) is supplied only in a need-key run. When any selected
1829
+ * target ends up `manual-required` — Windsurf's always-manual path, or a Codex
1830
+ * config that declined an automatic merge — the credential-store line is appended
1831
+ * ONCE for the whole batch, so selecting both does not print it twice.
1487
1832
  */
1488
- async function provisionSelectedGlobalTargets(deps, platforms, entry) {
1833
+ async function provisionSelectedGlobalTargets(deps, platforms, entry, needKey) {
1489
1834
  const logLines = [];
1835
+ let anyManualRequired = false;
1490
1836
  const provisionDeps = {
1491
1837
  fs: {
1492
1838
  readFile: deps.readFile,
@@ -1516,6 +1862,7 @@ async function provisionSelectedGlobalTargets(deps, platforms, entry) {
1516
1862
  logLines.push(` configured ${target.label} (${outcome.displayPath})`);
1517
1863
  break;
1518
1864
  case "manual-required":
1865
+ anyManualRequired = true;
1519
1866
  logLines.push(` ${target.label}: add the bridge-api MCP server manually to ${outcome.displayPath} ` +
1520
1867
  "(the API key is redacted in printed instructions).");
1521
1868
  break;
@@ -1527,6 +1874,9 @@ async function provisionSelectedGlobalTargets(deps, platforms, entry) {
1527
1874
  break;
1528
1875
  }
1529
1876
  }
1877
+ if (needKey && anyManualRequired) {
1878
+ logLines.push(` ${formatNeedKeyCredentialStoreLine(needKey.repoName, needKey.credentialStorePath)}`);
1879
+ }
1530
1880
  return logLines;
1531
1881
  }
1532
1882
  /** Build the `/jira/ping` URL exactly like the MCP `ping` tool / buildGetUrl. */
@@ -1544,13 +1894,30 @@ export function buildPingUrl(baseUrl, repoName) {
1544
1894
  */
1545
1895
  const CONNECTIVITY_ACCESS_DENIED_FALLBACK = "The Bridge API denied access to this repository (HTTP 403). Verify the repo_name and that this " +
1546
1896
  "credential is authorized for it.";
1897
+ /**
1898
+ * Source attribution appended to a 401 (BAPI-668, R11-2). Names WHERE the
1899
+ * rejected credential came from — the source is safe to print, the value is not —
1900
+ * so a user with a stale key exported in a shell profile is not left re-running
1901
+ * the same failing command. A prompt-sourced (or unattributed) key keeps today's
1902
+ * message byte-for-byte.
1903
+ */
1904
+ const CONNECTIVITY_KEY_SOURCE_ATTRIBUTION = {
1905
+ env: " (this key came from the BAPI_API_KEY environment variable — unset it to be prompted for a " +
1906
+ "different one)",
1907
+ flag: " (this key came from the --api-key flag — omit it to be prompted for a different one)",
1908
+ prompt: "",
1909
+ };
1547
1910
  /**
1548
1911
  * Verify connectivity. Distinguishes a rejected/expired credential (401) from a
1549
1912
  * repository-specific access denial (403, BAPI-661) from an unknown repo /
1550
1913
  * not-found (404) where the response allows. The key is sent in the `X-API-Key`
1551
1914
  * header and NEVER appears in any returned message.
1915
+ *
1916
+ * `apiKeySource` (BAPI-668) is supplied only on the ORDINARY API-key path. A
1917
+ * bootstrap-minted key is verified WITHOUT it, because attributing a
1918
+ * server-minted credential to `--api-key` or `BAPI_API_KEY` would be simply false.
1552
1919
  */
1553
- export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
1920
+ export async function verifyConnectivity(deps, baseUrl, repoName, apiKey, apiKeySource) {
1554
1921
  const url = buildPingUrl(baseUrl, repoName);
1555
1922
  let resp;
1556
1923
  try {
@@ -1574,11 +1941,16 @@ export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
1574
1941
  if (resp.ok)
1575
1942
  return { ok: true };
1576
1943
  if (resp.status === 401) {
1944
+ // BAPI-669 (U8): the remediation names an ADDRESS, derived from the same
1945
+ // already-normalized `baseUrl` this function was handed — never a bare page name,
1946
+ // and never a re-read of BAPI_BASE_URL.
1947
+ const setupUrl = buildInstallBridgeSetupUrl(baseUrl);
1577
1948
  return {
1578
1949
  ok: false,
1579
1950
  message: `The Bridge API rejected the credential (HTTP ${resp.status}). The API key may be invalid or expired ` +
1580
- "— generate a fresh one in the Bridge API web UI Security page. (An expired token can also surface as a " +
1581
- "permission error.)",
1951
+ `— generate a fresh one at ${setupUrl} (Security page). (An expired token can also surface as a ` +
1952
+ "permission error.)" +
1953
+ (apiKeySource ? CONNECTIVITY_KEY_SOURCE_ATTRIBUTION[apiKeySource] : ""),
1582
1954
  };
1583
1955
  }
1584
1956
  if (resp.status === 403) {
@@ -1680,10 +2052,15 @@ export function generateBootstrapKeySecret(randomBytes) {
1680
2052
  return randomBytes(BOOTSTRAP_KEY_SECRET_BYTES).toString("base64url");
1681
2053
  }
1682
2054
  /**
1683
- * SHA-256 hex digest of the bootstrap-invite token — the key under which the
1684
- * pending record is stored. The TOKEN ITSELF IS NEVER WRITTEN TO DISK; only this
1685
- * one-way fingerprint, so a retry can recognize "same invite, same repo" and reuse
1686
- * the exact pending `key_secret` (the replay proof).
2055
+ * SHA-256 hex digest of the bootstrap-invite token — the identity guard on every
2056
+ * pending record, so a retry can recognize "same invite, same repo" and reuse the
2057
+ * exact pending `key_secret` (the replay proof).
2058
+ *
2059
+ * For a USER-SUPPLIED invite the fingerprint is the only trace on disk. BAPI-667
2060
+ * additionally stores the token itself for an INTERNALLY MINTED self-serve invite
2061
+ * (which the user was never shown and cannot re-present) — the fingerprint remains
2062
+ * the identity guard in both shapes, and prepare / repoint / promote / discard all
2063
+ * still match on it.
1687
2064
  */
1688
2065
  export function fingerprintBootstrapInvite(token) {
1689
2066
  return createHash("sha256").update(token, "utf-8").digest("hex");
@@ -1712,12 +2089,15 @@ export async function exchangeBootstrapInvite(deps, baseUrl, token, repoName, ke
1712
2089
  // The message may contain the request (some fetch impls echo it), so it is
1713
2090
  // deliberately NOT interpolated here.
1714
2091
  void err;
2092
+ // MODE-NEUTRAL (BAPI-667). This function cannot tell a `--invite` run from a
2093
+ // self-serve one, and the two need OPPOSITE retry instructions ("re-present the
2094
+ // same invite" vs. "choose the email option"), so it states only the coarse
2095
+ // fact. The caller appends {@link buildBootstrapRetryAdvice}.
1715
2096
  return {
1716
2097
  ok: false,
1717
2098
  kind: "failed",
1718
2099
  message: `Could not reach the Bridge API at ${baseUrl} to redeem the bootstrap invite. Check ` +
1719
- "BAPI_BASE_URL and your network, then re-run — the invite has not been used, and the " +
1720
- "re-run will reuse the same locally-stored secret.",
2100
+ "BAPI_BASE_URL and your network. The invite has not been used.",
1721
2101
  };
1722
2102
  }
1723
2103
  if (resp.ok) {
@@ -1882,6 +2262,76 @@ export const BOOTSTRAP_INVITE_LOST_SECRET_MESSAGE = [
1882
2262
  export const BOOTSTRAP_INVITE_REJECTED_MESSAGE = "The Bridge API rejected the bootstrap invite (HTTP 401). The invite is invalid, expired, or " +
1883
2263
  "revoked — ask your Bridge API operator for a new one. (Your locally-stored secret was sent " +
1884
2264
  "unchanged, so this is not a lost-secret problem.)";
2265
+ /**
2266
+ * The 401 explanation for a REPLAYED SELF-SERVE token (BAPI-667). Unlike the two
2267
+ * messages above this is not a dead end: the token was minted by this CLI, so it
2268
+ * can mint another one — but only after the user explicitly consents to discard
2269
+ * the expired record, because that record is the only replay proof for a signup
2270
+ * whose exchange may in fact have succeeded.
2271
+ *
2272
+ * Sanitized by construction: it names no token, no fingerprint, and no secret.
2273
+ */
2274
+ export const BOOTSTRAP_SELF_SERVE_EXPIRED_MESSAGE = "The Bridge API rejected the stored signup invite (HTTP 401) — it is invalid, expired, or " +
2275
+ "revoked. Your saved signup attempt cannot be resumed with it.";
2276
+ /**
2277
+ * Mode-aware retry guidance (BAPI-667, R4-5), formatted in ONE place so a
2278
+ * self-serve user is never told to "re-run with the same bootstrap invite" — a
2279
+ * token they were never shown and cannot re-present.
2280
+ *
2281
+ * Secret-free by construction: it interpolates nothing.
2282
+ */
2283
+ export function buildBootstrapRetryAdvice(selfServeSignupMode) {
2284
+ return selfServeSignupMode
2285
+ ? "Re-run install-bridge and choose the email option — your previous attempt will resume."
2286
+ : "Re-run install-bridge with the same bootstrap invite — the redemption will replay and " +
2287
+ "return the same key.";
2288
+ }
2289
+ /**
2290
+ * Shared consent gate for a `credential-conflict` from `prepareBootstrapPending`
2291
+ * (BAPI-667).
2292
+ *
2293
+ * An existing `bapi:<repo>` credential is never clobbered without consent:
2294
+ * `--force`, else an interactive prompt, else a hard non-interactive failure —
2295
+ * always BEFORE the exchange, so nothing is consumed on either branch.
2296
+ *
2297
+ * This gate lives in ONE place because the self-serve and ordinary-invite arms
2298
+ * now prepare their own pending records (self-serve must additionally persist its
2299
+ * replay token in the same durable write). While the gate sat inline in whichever
2300
+ * arm happened to own the single shared call, gating that call behind
2301
+ * `if (!selfServeSignupMode)` silently dropped the prompt from the self-serve arm
2302
+ * — an `--email` / option-3 run against an already-provisioned repo hard-failed on
2303
+ * a TTY without ever offering the overwrite. Routing both arms through this helper
2304
+ * makes that drift impossible.
2305
+ *
2306
+ * `grantConsent` runs before the retry so the caller can record consent for its own
2307
+ * later repoint/promote writes. `retry` re-issues the CALLER'S prepare params with
2308
+ * `allowOverwriteExistingCredential: true`, so the self-serve arm keeps its
2309
+ * `selfServeReplayToken` on the retried write.
2310
+ *
2311
+ * Returns the result untouched when it is not a credential-conflict, the retried
2312
+ * result after consent, or `null` when the user declined or could not be asked —
2313
+ * in which case the reason is already logged and the caller returns non-zero.
2314
+ *
2315
+ * Secret-free: it names only the logical credential target.
2316
+ */
2317
+ export async function resolveCredentialConflictConsent(prepared, ctx) {
2318
+ if (prepared.ok || prepared.kind !== "credential-conflict")
2319
+ return prepared;
2320
+ if (!ctx.isTTY || !ctx.promptLine) {
2321
+ ctx.errorLog(`Error: ${prepared.error} Re-run with --force to overwrite it (refusing to overwrite a ` +
2322
+ "credential non-interactively without consent).");
2323
+ return null;
2324
+ }
2325
+ const answer = (await ctx.promptLine(`The credential store already holds a key for ${prepared.target}. Overwrite it? [y/N]: `))
2326
+ .trim()
2327
+ .toLowerCase();
2328
+ if (answer !== "y" && answer !== "yes") {
2329
+ ctx.errorLog("Aborted: existing credential left unchanged (re-run with --force to overwrite).");
2330
+ return null;
2331
+ }
2332
+ ctx.grantConsent();
2333
+ return ctx.retry();
2334
+ }
1885
2335
  /**
1886
2336
  * Render the --dry-run preview lines. Every secret is ALWAYS redacted — the
1887
2337
  * spawnCommand, config preview, and (in bootstrap-invite mode) the exchange body
@@ -1904,7 +2354,9 @@ export function buildDryRunPreview(plan) {
1904
2354
  "",
1905
2355
  "Step 1 — scaffold (runInit): commands, agents, pipelines, .bridge/config, secret-free MCP placeholders.",
1906
2356
  `Step 2 — connectivity ping (before any durable key write): GET ${plan.pingUrl} (X-API-Key: ${REDACTED_API_KEY})`,
1907
- "Step 3 — write per-host MCP config (read-merge-write, launcher version-pinned):",
2357
+ "Step 3 — write per-host MCP config (read-merge-write, launcher version-pinned;",
2358
+ " a git-tracked config needs default-No consent for the real key, else a",
2359
+ " secret-free entry; an unparseable config is skipped and left untouched):",
1908
2360
  ...plan.configTargets.map((t) => ` ${t}: BAPI_REPO_NAME=${plan.repoName}, BAPI_API_KEY=${REDACTED_API_KEY}, BAPI_BASE_URL=${plan.baseUrl}, BAPI_DOCS_DIR=${plan.docsDir}`),
1909
2361
  ...(plan.manualEditors.length > 0
1910
2362
  ? [` ${plan.manualEditors.join(" + ")}: detected (global config) — manual setup instructions would be printed.`]
@@ -1948,9 +2400,16 @@ function buildLaunchStepPreview(plan) {
1948
2400
  // BAPI-631: described, never performed in --dry-run — a preview must not open a
1949
2401
  // browser or reach the network. It is also strictly optional, so it carries no step
1950
2402
  // number of its own and never changes the 5-step count.
2403
+ //
2404
+ // BAPI-669 (U6): the quoted prompt and the purpose/deferral context below MUST
2405
+ // stay in step with the live offer — the preview advertising the old `(Y/n)`
2406
+ // default while the run asked `[y/N]` is exactly the drift this fixes.
1951
2407
  "Step 4b — optional GitHub connect (SKIPPED in --dry-run): read GitHub's configured state",
1952
2408
  " via the install manifest and, only when it is unconfigured and the terminal is",
1953
- " interactive, offer 'Connect GitHub? (Y/n)' before the agent session starts.",
2409
+ ` interactive, offer '${INSTALL_BRIDGE_GITHUB_CONNECT_PROMPT.replace(/:\s*$/, "")}' before the agent session starts.`,
2410
+ " It installs the Bridge GitHub App so pull requests and code review work, opens",
2411
+ " github.com in your browser, and shares no GitHub credential with Bridge; it",
2412
+ ` defaults to No and can be run later instead: ${INSTALL_BRIDGE_CONNECT_GITHUB_COMMAND}`,
1954
2413
  ];
1955
2414
  if (plan.launch.kind === "spawn") {
1956
2415
  return [
@@ -2001,9 +2460,12 @@ function buildBootstrapDryRunPreview(plan) {
2001
2460
  const selfServeStep = plan.selfServeSignup
2002
2461
  ? [
2003
2462
  "Step 2·pre — self-serve signup (PREVIEWED, SKIPPED in --dry-run): no Bridge workspace",
2004
- " signup is requested, no mint call is made, and no email is sent or transmitted;",
2005
- " a real run would request a fresh workspace for your email and receive an invite",
2006
- " token, which then feeds the SAME redemption protocol below.",
2463
+ " signup is requested, no mint call is made, no email is sent or transmitted, and",
2464
+ ` ${pendingTarget} is neither read nor written;`,
2465
+ " a real run would first check that record and RESUME a saved attempt if one",
2466
+ " exists, otherwise request a fresh workspace for your email and durably store the",
2467
+ " returned invite token alongside the key_secret — which then feeds the SAME",
2468
+ " redemption protocol below.",
2007
2469
  ]
2008
2470
  : [];
2009
2471
  return [
@@ -2021,7 +2483,9 @@ function buildBootstrapDryRunPreview(plan) {
2021
2483
  ` POST ${plan.exchangeUrl}`,
2022
2484
  ` body: {"token": "${REDACTED_API_KEY}", "repo_name": "${plan.repoName}", "key_secret": "${REDACTED_API_KEY}"}`,
2023
2485
  `Step 2c — connectivity ping with the newly-minted key (before any durable key write): GET ${plan.pingUrl} (X-API-Key: ${REDACTED_API_KEY})`,
2024
- "Step 3 — write per-host MCP config (read-merge-write, launcher version-pinned):",
2486
+ "Step 3 — write per-host MCP config (read-merge-write, launcher version-pinned;",
2487
+ " a git-tracked config needs default-No consent for the real key, else a",
2488
+ " secret-free entry; an unparseable config is skipped and left untouched):",
2025
2489
  ...plan.configTargets.map((t) => ` ${t}: BAPI_REPO_NAME=${plan.repoName}, BAPI_API_KEY=${REDACTED_API_KEY}, BAPI_BASE_URL=${plan.baseUrl}, BAPI_DOCS_DIR=${plan.docsDir}`),
2026
2490
  ...(plan.manualEditors.length > 0
2027
2491
  ? [` ${plan.manualEditors.join(" + ")}: detected (global config) — manual setup instructions would be printed.`]
@@ -2064,12 +2528,29 @@ export function manualEditorNames(editors) {
2064
2528
  names.push("Codex");
2065
2529
  return names;
2066
2530
  }
2531
+ /**
2532
+ * Where the key a need-key run just MINTED actually lives (BAPI-669, R8).
2533
+ *
2534
+ * A manual-setup path tells the user to paste `BAPI_API_KEY` into a global config —
2535
+ * but an invite / self-serve user has never seen their key, because the CLI minted
2536
+ * it and stored it. Without this line the instruction is unfollowable.
2537
+ *
2538
+ * SECRET DISCIPLINE (hard constraint): this formatter accepts NO key material. Its
2539
+ * parameters are a repo name and a filesystem path, both printable; it must never be
2540
+ * handed `apiKey`, `inviteToken`, `keySecret`, a signup email, or a credential record.
2541
+ */
2542
+ export function formatNeedKeyCredentialStoreLine(repoName, credentialStorePath) {
2543
+ return `Your API key is stored at ${credentialStorePath} under "bapi:${repoName}" — copy BAPI_API_KEY from there.`;
2544
+ }
2067
2545
  /**
2068
2546
  * Build the manual-instruction block for the DETECTED global-config editors only
2069
2547
  * (key always redacted). Returns `null` when neither Windsurf nor Codex is
2070
2548
  * detected, so the caller prints nothing for users who don't use them.
2549
+ *
2550
+ * `needKey` (BAPI-669, R8) is supplied only for a bootstrap-invite, reclassified-invite,
2551
+ * or self-serve run — the three flows where the key was minted rather than supplied.
2071
2552
  */
2072
- function buildManualHostInstructions(entry, editors) {
2553
+ function buildManualHostInstructions(entry, editors, needKey) {
2073
2554
  if (!editors.windsurf && !editors.codex)
2074
2555
  return null;
2075
2556
  const redactedEnv = { ...entry.env, BAPI_API_KEY: REDACTED_API_KEY };
@@ -2085,9 +2566,252 @@ function buildManualHostInstructions(entry, editors) {
2085
2566
  if (editors.codex) {
2086
2567
  lines.push("", " Codex → ~/.codex/config.toml (add an [mcp_servers.bridge-api] table with the", " same command/args/env shown above, BAPI_API_KEY set to your key).");
2087
2568
  }
2569
+ if (needKey) {
2570
+ lines.push("", ` ${formatNeedKeyCredentialStoreLine(needKey.repoName, needKey.credentialStorePath)}`);
2571
+ }
2088
2572
  return lines.join("\n");
2089
2573
  }
2090
2574
  // ---------------------------------------------------------------------------
2575
+ // Secret-safe failure classification + reporting (BAPI-668)
2576
+ // ---------------------------------------------------------------------------
2577
+ /**
2578
+ * The single diagnosis pointer every install failure block ends with. Defined
2579
+ * ONCE and reused by the Step 1–5 catch, the `index.ts` dispatch catch, the
2580
+ * tests, and the feature documentation, so the command text cannot drift.
2581
+ */
2582
+ export const INSTALL_BRIDGE_DOCTOR_COMMAND = "npx -y @bridge_gpt/mcp-server doctor";
2583
+ /**
2584
+ * The canonical pointer LINE (BAPI-669, U9a). One syntax — no quotes, no alternate
2585
+ * punctuation — shared by the Step 1–5 catch and by every operational fatal in the
2586
+ * ping / exchange / network / self-serve regions, so a user sees the same sentence
2587
+ * wherever an install dies. It must appear EXACTLY ONCE per fatal exit.
2588
+ */
2589
+ export const INSTALL_BRIDGE_DOCTOR_POINTER = `Diagnose with: ${INSTALL_BRIDGE_DOCTOR_COMMAND}`;
2590
+ /**
2591
+ * The classified cause used whenever the thrown value carries no `code` we
2592
+ * recognize. Deliberately fixed and free-form-free: it names the opt-in debug
2593
+ * switch instead of echoing anything about the caught value.
2594
+ */
2595
+ export const INSTALL_BRIDGE_UNCLASSIFIED_CAUSE = "unexpected error (run with BAPI_INSTALL_DEBUG=1 for details)";
2596
+ /** Fixed last-resort step label for the `index.ts` install-bridge dispatch catch. */
2597
+ export const INSTALL_BRIDGE_DISPATCH_STEP_LABEL = "install-bridge dispatch";
2598
+ /** Labels prefixing the OPT-IN raw diagnostics (never emitted by default). */
2599
+ export const INSTALL_BRIDGE_DEBUG_MESSAGE_LABEL = "debug (BAPI_INSTALL_DEBUG) raw message:";
2600
+ export const INSTALL_BRIDGE_DEBUG_STACK_LABEL = "debug (BAPI_INSTALL_DEBUG) raw stack:";
2601
+ /**
2602
+ * Closed filesystem-code → cause map. A `Map` (not an object literal) so a
2603
+ * hostile `code` such as `"constructor"` or `"__proto__"` can never resolve
2604
+ * through the prototype chain into a bogus "cause".
2605
+ */
2606
+ const INSTALL_BRIDGE_FILESYSTEM_CAUSES = new Map([
2607
+ ["EACCES", "permission denied"],
2608
+ ["EPERM", "permission denied"],
2609
+ ["ENOSPC", "no space left on device"],
2610
+ ["EROFS", "read-only file system"],
2611
+ ]);
2612
+ /** Closed allowlist of standard network codes that all collapse to one cause. */
2613
+ const INSTALL_BRIDGE_NETWORK_CODES = new Set([
2614
+ "ECONNREFUSED",
2615
+ "ECONNRESET",
2616
+ "ETIMEDOUT",
2617
+ "ENETUNREACH",
2618
+ "EHOSTUNREACH",
2619
+ "ENOTFOUND",
2620
+ "EAI_AGAIN",
2621
+ ]);
2622
+ /**
2623
+ * Read `error.code` without trusting the value at all: a non-object, a throwing
2624
+ * getter, and a non-string `code` all degrade to `undefined` rather than
2625
+ * escaping. Nothing else on the caught value is ever touched.
2626
+ */
2627
+ function extractInstallBridgeErrorCode(error) {
2628
+ try {
2629
+ if (typeof error !== "object" || error === null)
2630
+ return undefined;
2631
+ const code = error.code;
2632
+ return typeof code === "string" ? code : undefined;
2633
+ }
2634
+ catch {
2635
+ return undefined;
2636
+ }
2637
+ }
2638
+ /**
2639
+ * Map a caught value to a SHORT, FIXED cause label (BAPI-668, R5-1).
2640
+ *
2641
+ * SECRET DISCIPLINE — safe by SUPPRESSION, not by redaction: the normal path
2642
+ * reads ONLY a safely-guarded string `code`. It never reads `error.message`,
2643
+ * never calls `String(error)`, and never serializes the caught object, because a
2644
+ * free-form message can embed a secret this module does not hold in scope and
2645
+ * therefore could not scrub (the exact case `secret-safety.test.ts` guards).
2646
+ * Never pass an API key, invite token, or `key_secret` through here.
2647
+ */
2648
+ export function classifyInstallBridgeFailure(error) {
2649
+ const code = extractInstallBridgeErrorCode(error);
2650
+ if (code === undefined)
2651
+ return INSTALL_BRIDGE_UNCLASSIFIED_CAUSE;
2652
+ const filesystemCause = INSTALL_BRIDGE_FILESYSTEM_CAUSES.get(code);
2653
+ if (filesystemCause !== undefined)
2654
+ return filesystemCause;
2655
+ if (INSTALL_BRIDGE_NETWORK_CODES.has(code))
2656
+ return "network error";
2657
+ return INSTALL_BRIDGE_UNCLASSIFIED_CAUSE;
2658
+ }
2659
+ /** Read one debug-only field defensively; a hostile getter degrades to `undefined`. */
2660
+ function readInstallBridgeDebugValue(read) {
2661
+ try {
2662
+ const value = read();
2663
+ if (typeof value === "string")
2664
+ return value;
2665
+ if (value === undefined || value === null)
2666
+ return undefined;
2667
+ return String(value);
2668
+ }
2669
+ catch {
2670
+ return undefined;
2671
+ }
2672
+ }
2673
+ /**
2674
+ * Build the fixed, secret-free failure block for an unexpected install failure
2675
+ * (BAPI-668, R5-1/R5-3). Pure — the caller decides which sink receives the lines.
2676
+ *
2677
+ * Default output is exactly: the failed step label, the classified cause, the
2678
+ * doctor pointer, and (only when the caller supplies it) the resume advice.
2679
+ *
2680
+ * SECRET DISCIPLINE — as with {@link classifyInstallBridgeFailure}, the default
2681
+ * path is safe by SUPPRESSION: it never reads `message`/`stack`, never stringifies
2682
+ * the caught value, and never accepts a credential in its context. The raw message
2683
+ * and full stack are OPT-IN diagnostics that appear only when `debugEnabled` is
2684
+ * true (i.e. the user set `BAPI_INSTALL_DEBUG`), and even that extraction is
2685
+ * guarded so a hostile thrown value cannot make the last-resort formatter throw.
2686
+ */
2687
+ export function buildInstallBridgeFailureLines(error, context) {
2688
+ const lines = [
2689
+ `Error: install-bridge failed at: ${context.step}`,
2690
+ ` cause: ${classifyInstallBridgeFailure(error)}`,
2691
+ ` ${INSTALL_BRIDGE_DOCTOR_POINTER}`,
2692
+ ];
2693
+ if (context.resumeAdvice)
2694
+ lines.push(` ${context.resumeAdvice}`);
2695
+ if (context.debugEnabled) {
2696
+ const isError = error instanceof Error;
2697
+ const rawMessage = isError
2698
+ ? readInstallBridgeDebugValue(() => error.message)
2699
+ : readInstallBridgeDebugValue(() => String(error));
2700
+ const rawStack = isError
2701
+ ? readInstallBridgeDebugValue(() => error.stack)
2702
+ : undefined;
2703
+ lines.push(` ${INSTALL_BRIDGE_DEBUG_MESSAGE_LABEL} ${rawMessage ?? "<unavailable>"}`);
2704
+ if (rawStack)
2705
+ lines.push(` ${INSTALL_BRIDGE_DEBUG_STACK_LABEL} ${rawStack}`);
2706
+ }
2707
+ return lines;
2708
+ }
2709
+ // ---------------------------------------------------------------------------
2710
+ // Base-URL normalization + validation (BAPI-668, R9)
2711
+ // ---------------------------------------------------------------------------
2712
+ /** The ONE user-facing rejection for a malformed/non-http(s) `BAPI_BASE_URL`. */
2713
+ function buildInstallBridgeBaseUrlError(value) {
2714
+ return `BAPI_BASE_URL must be an absolute http(s) URL (got: "${value}")`;
2715
+ }
2716
+ /**
2717
+ * Normalize and validate `BAPI_BASE_URL` ONCE, at the CLI entry boundary (R9-1).
2718
+ *
2719
+ * `new URL()` alone does NOT restrict the scheme — it happily accepts
2720
+ * `ftp://x.com`, `javascript:alert(1)`, and `foo:bar` — so the protocol is checked
2721
+ * explicitly. An ABSENT variable defaults; an explicitly EMPTY or whitespace-only
2722
+ * one is REJECTED rather than silently defaulting, because "exported but blank" is
2723
+ * a misconfiguration, not an opt-out.
2724
+ *
2725
+ * The value is a URL, not a secret, so echoing it in the error is intentional.
2726
+ */
2727
+ export function resolveInstallBridgeBaseUrl(env) {
2728
+ const supplied = env.BAPI_BASE_URL;
2729
+ // A non-string (absent property, or an explicit `undefined`) is "unset" and
2730
+ // defaults; any supplied string — including "" and " " — must validate.
2731
+ const candidate = typeof supplied === "string" ? supplied.trim() : DEFAULT_BAPI_BASE_URL;
2732
+ if (candidate.length === 0)
2733
+ return { ok: false, error: buildInstallBridgeBaseUrlError("") };
2734
+ let parsed;
2735
+ try {
2736
+ parsed = new URL(candidate);
2737
+ }
2738
+ catch {
2739
+ return { ok: false, error: buildInstallBridgeBaseUrlError(candidate) };
2740
+ }
2741
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
2742
+ return { ok: false, error: buildInstallBridgeBaseUrlError(candidate) };
2743
+ }
2744
+ // The TRIMMED candidate is returned, not `parsed.toString()`: the latter would
2745
+ // append a path slash ("https://x.com" → "https://x.com/") and change every
2746
+ // downstream URL the existing tests and consumers already pin.
2747
+ return { ok: true, value: candidate };
2748
+ }
2749
+ // ---------------------------------------------------------------------------
2750
+ // Printed Step 1–5 labels (BAPI-668, R5-1)
2751
+ // ---------------------------------------------------------------------------
2752
+ /**
2753
+ * Every printed Step 1–5 label, defined once. `runInstallBridgeCli` uses each
2754
+ * constant BOTH when logging the step AND when recording the current step, so a
2755
+ * failure can never be attributed to a label the user did not just see.
2756
+ */
2757
+ export const INSTALL_BRIDGE_STEP_LABELS = {
2758
+ scaffold: "Step 1/5 — scaffolding project (commands, agents, pipelines, config placeholders)…",
2759
+ selfServeMint: "Step 2/5 — requesting Bridge self-serve setup…",
2760
+ inviteRedeem: "Step 2/5 — redeeming the bootstrap invite…",
2761
+ verifyConnectivity: "Step 2/5 — verifying connectivity…",
2762
+ writeHostConfigs: "Step 3/5 — writing per-host MCP config…",
2763
+ promoteCredential: "Step 4/5 — promoting the bootstrap credential…",
2764
+ persistCredential: "Step 4/5 — persisting routing credential…",
2765
+ };
2766
+ /**
2767
+ * The Step 5 label, which names the agent being opened. Same rule: log == record.
2768
+ * BAPI-669 (U3): it prints the host tool's DISPLAY label ("Claude Code"), never the
2769
+ * raw agent binary name ("claude") — the user picked a tool, not a binary.
2770
+ */
2771
+ export function buildInstallBridgeLaunchStepLabel(agent) {
2772
+ return `Step 5/5 — opening a ${toolLabelForLaunchAgent(agent)} session for /install-bridge configuration + concise capability report…`;
2773
+ }
2774
+ // ---------------------------------------------------------------------------
2775
+ // Working-directory banner + project-root gate (BAPI-669, U2)
2776
+ //
2777
+ // This gate is READ-ONLY by construction: it prints where the install is aimed,
2778
+ // probes a single path through the injected `deps.stat` seam, and (on a TTY only)
2779
+ // asks one question. It must stay AHEAD of onboarding selection, credential
2780
+ // resolution, repository resolution, tool selection, every network call, `runInit`,
2781
+ // launch materialization, and every write — so a decline can abort with nothing
2782
+ // minted, exchanged, or written.
2783
+ //
2784
+ // Declining is a user-initiated CANCEL, not a failure: it exits 0. A non-TTY run
2785
+ // warns and continues, preserving automation compatibility (legitimate non-git
2786
+ // installs exist, so blocking outright was rejected).
2787
+ // ---------------------------------------------------------------------------
2788
+ /** Prefix of the always-printed "where am I installing" banner. */
2789
+ export const INSTALL_BRIDGE_CWD_BANNER_PREFIX = "Installing Bridge into: ";
2790
+ /** The shared no-`.git` finding, reused by both the TTY prompt and the non-TTY warning. */
2791
+ export const INSTALL_BRIDGE_NO_GIT_WARNING = "This doesn't look like a project root (no .git found)";
2792
+ /** Default-No confirmation shown only on a TTY when `.git` is absent. */
2793
+ export const INSTALL_BRIDGE_NO_GIT_PROMPT = `${INSTALL_BRIDGE_NO_GIT_WARNING} — continue? [y/N]: `;
2794
+ /** Printed on decline. Paired with exit code 0 — a cancel is not an error. */
2795
+ export const INSTALL_BRIDGE_NO_GIT_ABORT = "Aborted — run install-bridge from your project root, or re-run and confirm to continue.";
2796
+ /** The non-TTY form: same finding, stated as a warning, then the run continues. */
2797
+ export const INSTALL_BRIDGE_NO_GIT_NONINTERACTIVE_WARNING = `Warning: ${INSTALL_BRIDGE_NO_GIT_WARNING} — continuing (non-interactive).`;
2798
+ /**
2799
+ * Existence probe for `<cwd>/.git`, through the injected `stat` boundary. A git
2800
+ * worktree's `.git` is a FILE rather than a directory, so only existence is
2801
+ * checked — never the entry type. Any probe failure is treated as "absent"; this
2802
+ * is a hint for a warning, never an authorization decision. Repository-name
2803
+ * inference is untouched by it.
2804
+ */
2805
+ async function hasProjectRootMarker(deps) {
2806
+ try {
2807
+ await deps.stat(path.join(deps.cwd, ".git"));
2808
+ return true;
2809
+ }
2810
+ catch {
2811
+ return false;
2812
+ }
2813
+ }
2814
+ // ---------------------------------------------------------------------------
2091
2815
  // CLI entry
2092
2816
  // ---------------------------------------------------------------------------
2093
2817
  /**
@@ -2104,18 +2828,90 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
2104
2828
  deps.resolveRepoViaServer = (baseUrl, apiKey) => resolveRepoViaServer(deps.fetch, baseUrl, apiKey);
2105
2829
  }
2106
2830
  const { log, errorLog } = deps;
2831
+ /**
2832
+ * Emit an OPERATIONAL fatal (BAPI-669, U9a): one or more already-composed,
2833
+ * secret-free error lines, then the canonical doctor pointer exactly once.
2834
+ *
2835
+ * Scope is deliberately narrow — the ping / exchange / network / self-serve
2836
+ * failures, i.e. the ones where the machine's state is the open question. It is
2837
+ * NOT used for user-directed cancellations (the missing-`.git` exit-0 abort, a
2838
+ * declined overwrite, a declined signup discard) or for nonfatal warnings (the
2839
+ * Step 5 terminal-spawn failure), where "run doctor" is noise rather than help.
2840
+ */
2841
+ const reportFatal = (...lines) => {
2842
+ for (const line of lines)
2843
+ errorLog(line);
2844
+ errorLog(` ${INSTALL_BRIDGE_DOCTOR_POINTER}`);
2845
+ };
2846
+ /** {@link reportFatal} plus the exit code, for the common `return fatal(…)` shape. */
2847
+ const fatal = (...lines) => {
2848
+ reportFatal(...lines);
2849
+ return 1;
2850
+ };
2851
+ // ---- BAPI-668 (R9): normalize + validate BAPI_BASE_URL ONCE, here ----
2852
+ // Resolved BEFORE the parser purely because it is pure and side-effect-free, so
2853
+ // help / parser-error usage text can name the user's REAL base URL (BAPI-669 U8)
2854
+ // instead of the hosted default. Error PRECEDENCE is deliberately unchanged: the
2855
+ // `!ok` return still happens after the argument-error return below, so a run with
2856
+ // both a bad flag and a bad BAPI_BASE_URL still reports the argument error first.
2857
+ const baseUrlResult = resolveInstallBridgeBaseUrl(deps.env);
2858
+ // A rejected value cannot be interpolated into guidance, so usage falls back to
2859
+ // the documented default — the run is about to fail on it anyway.
2860
+ const usageBaseUrl = baseUrlResult.ok ? baseUrlResult.value : DEFAULT_BAPI_BASE_URL;
2107
2861
  const parsed = parseInstallBridgeArgs(argv);
2108
2862
  if (parsed.status === "help") {
2109
- log(parsed.usage);
2863
+ log(getInstallBridgeUsage(usageBaseUrl));
2110
2864
  return 0;
2111
2865
  }
2112
2866
  if (parsed.status === "error") {
2113
2867
  errorLog(`Error: ${parsed.message}`);
2114
2868
  errorLog("");
2115
- errorLog(getInstallBridgeUsage());
2869
+ errorLog(getInstallBridgeUsage(usageBaseUrl));
2116
2870
  return 1;
2117
2871
  }
2118
2872
  const options = parsed.options;
2873
+ // Deliberately still ahead of the onboarding branch, any prompt, any network
2874
+ // call, and the --dry-run preview: a scheme-less value (the classic error) used
2875
+ // to throw ERR_INVALID_URL deep inside `buildPingUrl` — even in --dry-run — often
2876
+ // after the user had already answered a repo prompt. Failing here means no prompt
2877
+ // is shown and no side effect occurs.
2878
+ if (!baseUrlResult.ok) {
2879
+ errorLog(baseUrlResult.error);
2880
+ return 1;
2881
+ }
2882
+ const baseUrl = baseUrlResult.value;
2883
+ /** `<base>/setup` — the one web surface every guidance string points at (U8). */
2884
+ const setupUrl = buildInstallBridgeSetupUrl(baseUrl);
2885
+ // Replace the RUN-LOCAL env with a shallow copy carrying the normalized value so
2886
+ // every downstream `deps.env` consumer observes it. The caller-owned object (and
2887
+ // `process.env`) is never mutated.
2888
+ deps.env = { ...deps.env, BAPI_BASE_URL: baseUrl };
2889
+ // ---- BAPI-669 (U2): working-directory banner + project-root gate ----
2890
+ // See the section comment on `hasProjectRootMarker`: read-only, and positioned
2891
+ // ahead of ALL credential and configuration work so a decline costs nothing.
2892
+ log(`${INSTALL_BRIDGE_CWD_BANNER_PREFIX}${deps.cwd}`);
2893
+ if (!(await hasProjectRootMarker(deps))) {
2894
+ if (deps.isTTY && deps.promptLine) {
2895
+ let answer = "";
2896
+ try {
2897
+ answer = (await deps.promptLine(INSTALL_BRIDGE_NO_GIT_PROMPT)).trim().toLowerCase();
2898
+ }
2899
+ catch {
2900
+ // A prompt that cannot be read is NOT confirmation. Never infer it — the
2901
+ // default is No, so a malformed or failed read takes the abort path.
2902
+ answer = "";
2903
+ }
2904
+ if (answer !== "y" && answer !== "yes") {
2905
+ log(INSTALL_BRIDGE_NO_GIT_ABORT);
2906
+ // Exit 0: the user cancelled, and nothing was resolved, written, or spent.
2907
+ return 0;
2908
+ }
2909
+ }
2910
+ else {
2911
+ // Automation compatibility: warn, never prompt, never abort.
2912
+ errorLog(INSTALL_BRIDGE_NO_GIT_NONINTERACTIVE_WARNING);
2913
+ }
2914
+ }
2119
2915
  // Onboarding branch: have-key vs. a need-key method (`bootstrap-invite` = redeem
2120
2916
  // a pre-issued invite, `self-serve` = mint one from an email, BAPI-618). BOTH
2121
2917
  // need-key methods share the downstream redemption protocol, so
@@ -2149,6 +2945,11 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
2149
2945
  let apiKey = "";
2150
2946
  let inviteToken = "";
2151
2947
  let signupEmail = "";
2948
+ // BAPI-668 (R11-1): retained ONLY while the resolved value stays on the ordinary
2949
+ // API-key path. A value reclassified as a bootstrap invite, and either explicit
2950
+ // need-key method, leave it undefined — those paths never run the ordinary
2951
+ // key-authentication ping, so there is no source to attribute.
2952
+ let apiKeySource;
2152
2953
  if (selfServeSignupMode) {
2153
2954
  const emailResult = await resolveSignupEmail(options, deps);
2154
2955
  if (!emailResult.ok) {
@@ -2181,14 +2982,17 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
2181
2982
  apiKey = "";
2182
2983
  bootstrapInviteMode = true;
2183
2984
  selfServeSignupMode = false;
2985
+ // Deliberately NOT retained: the redemption path mints its own key, so any
2986
+ // flag/env attribution on its later ping would name the wrong credential.
2987
+ apiKeySource = undefined;
2184
2988
  }
2185
2989
  else {
2186
2990
  apiKey = keyResult.value;
2991
+ apiKeySource = keyResult.source;
2187
2992
  }
2188
2993
  }
2189
- // baseUrl is resolved BEFORE repository input because the have-key branch needs
2190
- // it for the server-resolution request.
2191
- const baseUrl = deps.env.BAPI_BASE_URL ?? DEFAULT_BAPI_BASE_URL;
2994
+ // `baseUrl` was resolved and validated at the entry boundary above (BAPI-668),
2995
+ // well before the have-key branch's server-resolution request needs it.
2192
2996
  const docsDir = deps.env.BAPI_DOCS_DIR ?? DEFAULT_BAPI_DOCS_DIR;
2193
2997
  // ---- Resolve the repository name ----
2194
2998
  // Need-key (invite or self-serve): choose-a-name for the project this run is
@@ -2235,7 +3039,7 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
2235
3039
  // (BAPI-661) WITH a visible signal that the fallback is happening, so a
2236
3040
  // guessed name is never confirmed silently as if it had been verified.
2237
3041
  log("Couldn't auto-resolve your repo from the key; falling back to a guessed name — confirm " +
2238
- "it matches the setup UI.");
3042
+ `it matches ${setupUrl} (setup UI).`);
2239
3043
  const repoResult = await resolveRepoName(options, deps, "existing-registration");
2240
3044
  if (!repoResult.ok) {
2241
3045
  errorLog(`Error: ${repoResult.error}`);
@@ -2410,386 +3214,619 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
2410
3214
  return 1;
2411
3215
  }
2412
3216
  }
2413
- // ---- Step 1 scaffold (secret-free placeholders only) ----
2414
- log("Step 1/5 scaffolding project (commands, agents, pipelines, config placeholders)…");
2415
- await deps.runInit(deps.cwd);
2416
- // ---- Step 2 mint (invite mode) or verify (normal mode) the credential ----
2417
- let inviteFingerprint = "";
2418
- if (bootstrapInviteMode) {
2419
- // ---- Self-serve signup (BAPI-618): mint the invite token from the email ----
2420
- // This is the ONE step that precedes the unchanged BAPI-606 redemption below.
2421
- // It sits AFTER the --dry-run return above, so a preview never mints, never
2422
- // sends the email, and never writes a pending secret. On success the minted
2423
- // token is assigned to `inviteToken` and the flow falls into the existing
2424
- // persist-before-exchange block VERBATIM — identical to a --invite token from
2425
- // here on. On failure we return BEFORE any CSPRNG/pending write, so nothing is
2426
- // persisted. The email and the token are never logged.
2427
- if (selfServeSignupMode) {
2428
- log("Step 2/5 — requesting Bridge self-serve setup…");
2429
- const mint = await mintSelfServeInvite(deps, baseUrl, signupEmail);
2430
- if (!mint.ok) {
2431
- if (mint.category === "rate-limited") {
2432
- errorLog("Error: Self-serve setup is temporarily rate limited. Try again later.");
2433
- }
2434
- else if (mint.category === "invalid") {
2435
- errorLog("Error: Self-serve setup could not be requested. Check the email value and try again.");
3217
+ // ---- BAPI-668 (R5-1): the guarded Step 1–5 execution region ----
3218
+ // Everything ABOVE this point argument parsing, base-URL validation, input
3219
+ // resolution, the dry-run preview, and every deliberate pre-execution validation
3220
+ // returnstays OUTSIDE the catch: those failures are already explained, and
3221
+ // folding them into a generic block would make them worse, not safer. Everything
3222
+ // from the Step 1 scaffold through the terminal Step 5 returns is inside, so no
3223
+ // fs/permission/disk failure can print a raw Node stack. Every explicitly handled
3224
+ // branch inside (fatal gitignore protection, fail-open credential persistence,
3225
+ // advisory install-state persistence, the GitHub offer, prewarm warnings, and the
3226
+ // nonfatal Step 5 spawn failure) keeps its own behavior the catch only ever sees
3227
+ // an exception that ESCAPED all of them.
3228
+ let currentStep = INSTALL_BRIDGE_STEP_LABELS.scaffold;
3229
+ let bootstrapExchangeSucceeded = false;
3230
+ try {
3231
+ // ---- Step 1 — scaffold (secret-free placeholders only) ----
3232
+ currentStep = INSTALL_BRIDGE_STEP_LABELS.scaffold;
3233
+ log(currentStep);
3234
+ await deps.runInit(deps.cwd);
3235
+ // ---- Step 2 — mint (invite mode) or verify (normal mode) the credential ----
3236
+ let inviteFingerprint = "";
3237
+ if (bootstrapInviteMode) {
3238
+ // ---- Self-serve resume (BAPI-667) — runs BEFORE any mint ----
3239
+ // Every self-serve run used to mint a FRESH invite, so a retry after any
3240
+ // mid-protocol failure presented a new fingerprint against its own pending
3241
+ // record and hard-failed with `pending-conflict` whose only advice was to
3242
+ // delete the record by hand — potentially destroying the sole trace of a live
3243
+ // admin key. It also littered a new tenant row server-side per attempt.
3244
+ //
3245
+ // So the stored record is consulted FIRST. A resumable record supplies the
3246
+ // token, the fingerprint, and the `key_secret`, and the mint endpoint is never
3247
+ // called at all. This is the whole of R4-2, and it is what makes the normal
3248
+ // retry path free of orphan tenants.
3249
+ let resumedSelfServeReplay = false;
3250
+ let keySecret = "";
3251
+ let reusedPendingSecret = false;
3252
+ if (selfServeSignupMode) {
3253
+ const lookup = await deps.lookupSelfServeBootstrapPending({ repoName }, credentialWriteDeps);
3254
+ if (!lookup.ok) {
3255
+ // Fail BEFORE the mint endpoint is called and leave the stored record
3256
+ // untouched — an ordinary-invite record, a malformed record, and an
3257
+ // unreadable store are all states where minting again would compound the
3258
+ // problem rather than recover from it.
3259
+ return fatal(`Error: ${lookup.error}`);
2436
3260
  }
2437
- else {
2438
- errorLog("Error: Unable to complete self-serve setup. Check connectivity and retry.");
3261
+ if (lookup.state === "resumable") {
3262
+ // SECRETS: assigned, never logged. The single line below names only the repo.
3263
+ inviteToken = lookup.replayToken;
3264
+ inviteFingerprint = lookup.inviteFingerprint;
3265
+ keySecret = lookup.keySecret;
3266
+ resumedSelfServeReplay = true;
3267
+ // The stored secret IS the replay proof, so a 401 here is a rejected
3268
+ // invite, not a lost secret (mirrors `prepared.reused`).
3269
+ reusedPendingSecret = true;
3270
+ log(` resuming your previous signup attempt for ${repoName}`);
2439
3271
  }
2440
- return 1;
2441
3272
  }
2442
- // Treated identically to a manually supplied invite token from here on.
2443
- inviteToken = mint.token;
2444
- }
2445
- // ===================================================================
2446
- // LOAD-BEARING ORDERING NOT DEFENSIVE POLISH. DO NOT "TIDY" THIS.
2447
- //
2448
- // The key_secret is generated HERE, fsynced to a pending record HERE, and
2449
- // only THEN sent to the exchange. That order is the protocol:
2450
- //
2451
- // * The locally-stored key_secret is the ONLY proof that can replay a
2452
- // redemption. The server's replay branch matches on (repo_name, bcrypt of
2453
- // key_secret); it never returns a recoverable credential.
2454
- // * So a successful exchange followed by a failed local write = an
2455
- // unrecoverable admin key AND a permanently spent invite. The user cannot
2456
- // re-run (a new secret gets a correct 401) and cannot re-mint (the repo
2457
- // name is now globally taken by the project they just created); only an
2458
- // operator can dig them out.
2459
- //
2460
- // Therefore the write is FAIL-CLOSED: if it does not land (including its
2461
- // fsync), we abort with a non-zero exit and NEVER call fetch. Nothing is
2462
- // consumed. Moving this persist after the exchange — the "natural" order —
2463
- // reintroduces exactly the bug above. (Contrast Step 4 in normal mode, which
2464
- // is deliberately fail-open: there, the key already exists server-side and a
2465
- // persist failure only degrades start-tickets routing.)
2466
- // ===================================================================
2467
- inviteFingerprint = fingerprintBootstrapInvite(inviteToken);
2468
- log("Step 2/5 — redeeming the bootstrap invite…");
2469
- let prepared = await deps.prepareBootstrapPending({
2470
- repoName,
2471
- inviteFingerprint,
2472
- generateKeySecret: () => generateBootstrapKeySecret(deps.randomBytes),
2473
- allowOverwriteExistingCredential: overwriteConsent,
2474
- }, credentialWriteDeps);
2475
- // An existing bapi:<repo> credential is never clobbered without consent. The
2476
- // gate mirrors the host-config gate above: --force, else an interactive prompt,
2477
- // else a hard non-interactive failure — all BEFORE the exchange.
2478
- if (!prepared.ok && prepared.kind === "credential-conflict") {
2479
- if (deps.isTTY && deps.promptLine) {
2480
- const answer = (await deps.promptLine(`The credential store already holds a key for ${prepared.target}. Overwrite it? [y/N]: `)).trim().toLowerCase();
2481
- if (answer !== "y" && answer !== "yes") {
2482
- errorLog("Aborted: existing credential left unchanged (re-run with --force to overwrite).");
2483
- return 1;
3273
+ /**
3274
+ * Mint a self-serve invite and durably store it WITH a fresh `key_secret`.
3275
+ *
3276
+ * Shared by the initial signup and the consented refresh below — the only two
3277
+ * places a self-serve token is ever created. The ordering inside is the same
3278
+ * load-bearing protocol described below: mint the token, durably store the
3279
+ * token plus the `key_secret`, and only THEN exchange. Logs its own
3280
+ * (secret-free) failure and returns false; the caller exits non-zero.
3281
+ */
3282
+ const mintAndPrepareSelfServe = async () => {
3283
+ const mint = await mintSelfServeInvite(deps, baseUrl, signupEmail);
3284
+ if (!mint.ok) {
3285
+ if (mint.category === "rate-limited") {
3286
+ // BAPI-668 (R12-1): the limiter is a single GLOBAL per-hour signup bucket,
3287
+ // not a per-IP/per-email one so "temporarily rate limited" read as "you
3288
+ // did something wrong" when nothing on the user's machine is at fault.
3289
+ // Message-only: the return value and the caller's exit code are unchanged.
3290
+ reportFatal("Error: Bridge's self-serve signup capacity for this hour is exhausted (this is a " +
3291
+ "global service limit, not a problem with your machine). Try again in about an hour.");
3292
+ }
3293
+ else if (mint.category === "invalid") {
3294
+ reportFatal("Error: Self-serve setup could not be requested. Check the email value and try again.");
3295
+ }
3296
+ else {
3297
+ reportFatal("Error: Unable to complete self-serve setup. Check connectivity and retry.");
3298
+ }
3299
+ return false;
2484
3300
  }
2485
- overwriteConsent = true;
2486
- prepared = await deps.prepareBootstrapPending({
3301
+ // Treated identically to a manually supplied invite token from here on.
3302
+ inviteToken = mint.token;
3303
+ inviteFingerprint = fingerprintBootstrapInvite(inviteToken);
3304
+ const prepareSelfServePending = (allowOverwrite) => deps.prepareBootstrapPending({
2487
3305
  repoName,
2488
3306
  inviteFingerprint,
2489
3307
  generateKeySecret: () => generateBootstrapKeySecret(deps.randomBytes),
2490
- allowOverwriteExistingCredential: true,
3308
+ allowOverwriteExistingCredential: allowOverwrite,
3309
+ // R4-1: the minted token rides the SAME durable write as the key_secret.
3310
+ // It is stored ONLY here — a user-supplied invite stays fingerprint-only.
3311
+ selfServeReplayToken: inviteToken,
2491
3312
  }, credentialWriteDeps);
3313
+ // An existing bapi:<repo> credential is never clobbered without consent —
3314
+ // the self-serve arm uses the SAME gate as the ordinary-invite arm below.
3315
+ const prep = await resolveCredentialConflictConsent(await prepareSelfServePending(overwriteConsent), {
3316
+ isTTY: deps.isTTY,
3317
+ promptLine: deps.promptLine,
3318
+ errorLog,
3319
+ grantConsent: () => {
3320
+ overwriteConsent = true;
3321
+ },
3322
+ retry: () => prepareSelfServePending(true),
3323
+ });
3324
+ if (prep === null)
3325
+ return false;
3326
+ if (!prep.ok) {
3327
+ reportFatal(`Error: could not durably store the signup credential (${prep.kind}). ${prep.error} ` +
3328
+ "No workspace has been set up — fix the problem and re-run.");
3329
+ return false;
3330
+ }
3331
+ keySecret = prep.keySecret;
3332
+ reusedPendingSecret = prep.reused;
3333
+ log(` saved the pending credential for ${prep.target} (fsynced before the exchange)`);
3334
+ return true;
3335
+ };
3336
+ // ---- Self-serve signup (BAPI-618): mint the invite token from the email ----
3337
+ // This is the ONE step that precedes the unchanged BAPI-606 redemption below.
3338
+ // It sits AFTER the --dry-run return above, so a preview never mints, never
3339
+ // sends the email, and never writes a pending secret. On failure we return
3340
+ // BEFORE the exchange, so nothing is consumed. The email and the token are
3341
+ // never logged. SKIPPED ENTIRELY when the run resumed a stored attempt.
3342
+ if (selfServeSignupMode && !resumedSelfServeReplay) {
3343
+ currentStep = INSTALL_BRIDGE_STEP_LABELS.selfServeMint;
3344
+ log(currentStep);
3345
+ if (!(await mintAndPrepareSelfServe()))
3346
+ return 1;
2492
3347
  }
2493
- else {
2494
- errorLog(`Error: ${prepared.error} Re-run with --force to overwrite it (refusing to overwrite a ` +
2495
- "credential non-interactively without consent).");
2496
- return 1;
2497
- }
2498
- }
2499
- // A pending record left by a DIFFERENT invite is that redemption's only replay
2500
- // proof. Unlike the credential conflict above there is no consent path — not
2501
- // even --force so re-running is not the fix and must not be advised.
2502
- if (!prepared.ok && prepared.kind === "pending-conflict") {
2503
- errorLog(`Error: ${prepared.error} This invite has NOT been used, and re-running will not clear ` +
2504
- "the conflict.");
2505
- return 1;
2506
- }
2507
- if (!prepared.ok) {
2508
- // Fail closed: the exchange has NOT been called, so the invite is unspent.
2509
- errorLog(`Error: could not durably store the bootstrap credential (${prepared.kind}). ${prepared.error} ` +
2510
- "The bootstrap invite has NOT been usedfix the problem and re-run.");
2511
- return 1;
2512
- }
2513
- const keySecret = prepared.keySecret;
2514
- const reusedPendingSecret = prepared.reused;
2515
- log(` saved the pending credential for ${prepared.target} (fsynced before the exchange)`);
2516
- // The exchange REPLACES the pre-flight ping: in invite mode there is no key to
2517
- // ping with this call is what mints it.
2518
- let exchange = await exchangeBootstrapInvite(deps, baseUrl, inviteToken, repoName, keySecret);
2519
- // 409 repo_name_taken: the server ROLLED BACK, so the invite is NOT consumed and
2520
- // the SAME token can be retried under a new name — with the SAME pending secret,
2521
- // re-pointed to that name.
2522
- while (!exchange.ok && exchange.kind === "repo-name-taken") {
2523
- if (!deps.isTTY || !deps.promptLine) {
2524
- errorLog(`Error: ${exchange.message} Re-run with a different --repo (the bootstrap invite has NOT ` +
2525
- "been used).");
2526
- return 1;
2527
- }
2528
- errorLog(exchange.message);
2529
- const answer = (await deps.promptLine("Choose a different repo name: ")).trim();
2530
- const validated = validateRepoName(answer);
2531
- if (!validated.ok) {
2532
- errorLog(`Error: invalid repo name ${validated.error}.`);
2533
- return 1;
3348
+ // ===================================================================
3349
+ // LOAD-BEARING ORDERING NOT DEFENSIVE POLISH. DO NOT "TIDY" THIS.
3350
+ //
3351
+ // The key_secret is generated HERE, fsynced to a pending record HERE, and
3352
+ // only THEN sent to the exchange. That order is the protocol:
3353
+ //
3354
+ // * The locally-stored key_secret is the ONLY proof that can replay a
3355
+ // redemption. The server's replay branch matches on (repo_name, bcrypt of
3356
+ // key_secret); it never returns a recoverable credential.
3357
+ // * So a successful exchange followed by a failed local write = an
3358
+ // unrecoverable admin key AND a permanently spent invite. The user cannot
3359
+ // re-run (a new secret gets a correct 401) and cannot re-mint (the repo
3360
+ // name is now globally taken by the project they just created); only an
3361
+ // operator can dig them out.
3362
+ //
3363
+ // Therefore the write is FAIL-CLOSED: if it does not land (including its
3364
+ // fsync), we abort with a non-zero exit and NEVER call fetch. Nothing is
3365
+ // consumed. Moving this persist after the exchange — the "natural" order
3366
+ // reintroduces exactly the bug above. (Contrast Step 4 in normal mode, which
3367
+ // is deliberately fail-open: there, the key already exists server-side and a
3368
+ // persist failure only degrades start-tickets routing.)
3369
+ //
3370
+ // BAPI-667: the self-serve arm satisfies this same ordering inside
3371
+ // `mintAndPrepareSelfServe` (or has already loaded a durable record via the
3372
+ // resume lookup), so the block below is the ORDINARY-INVITE preparation only.
3373
+ // ===================================================================
3374
+ if (!selfServeSignupMode) {
3375
+ inviteFingerprint = fingerprintBootstrapInvite(inviteToken);
3376
+ currentStep = INSTALL_BRIDGE_STEP_LABELS.inviteRedeem;
3377
+ log(currentStep);
3378
+ const prepareInvitePending = (allowOverwrite) => deps.prepareBootstrapPending({
3379
+ repoName,
3380
+ inviteFingerprint,
3381
+ generateKeySecret: () => generateBootstrapKeySecret(deps.randomBytes),
3382
+ allowOverwriteExistingCredential: allowOverwrite,
3383
+ // Deliberately NO `selfServeReplayToken`: a user-supplied invite is
3384
+ // re-presentable by the user, so it stays fingerprint-only on disk.
3385
+ }, credentialWriteDeps);
3386
+ // An existing bapi:<repo> credential is never clobbered without consent. The
3387
+ // gate mirrors the host-config gate above: --force, else an interactive prompt,
3388
+ // else a hard non-interactive failure — all BEFORE the exchange.
3389
+ const prepared = await resolveCredentialConflictConsent(await prepareInvitePending(overwriteConsent), {
3390
+ isTTY: deps.isTTY,
3391
+ promptLine: deps.promptLine,
3392
+ errorLog,
3393
+ grantConsent: () => {
3394
+ overwriteConsent = true;
3395
+ },
3396
+ retry: () => prepareInvitePending(true),
3397
+ });
3398
+ if (prepared === null)
3399
+ return 1;
3400
+ // A pending record left by a DIFFERENT invite is that redemption's only replay
3401
+ // proof, so it is never overwritten. BAPI-667 removed the unconditional
3402
+ // "re-running will not clear the conflict" suffix that used to be appended
3403
+ // here: it is FALSE for a stored self-serve record, which a re-run resumes
3404
+ // automatically. The credential store's own mode-aware message is now the
3405
+ // single source of truth for what the user should do.
3406
+ if (!prepared.ok && prepared.kind === "pending-conflict") {
3407
+ errorLog(`Error: ${prepared.error}`);
3408
+ return 1;
3409
+ }
3410
+ if (!prepared.ok) {
3411
+ // Fail closed: the exchange has NOT been called, so the invite is unspent.
3412
+ errorLog(`Error: could not durably store the bootstrap credential (${prepared.kind}). ${prepared.error} ` +
3413
+ "The bootstrap invite has NOT been used — fix the problem and re-run.");
3414
+ return 1;
3415
+ }
3416
+ keySecret = prepared.keySecret;
3417
+ reusedPendingSecret = prepared.reused;
3418
+ log(` saved the pending credential for ${prepared.target} (fsynced before the exchange)`);
2534
3419
  }
2535
- const nextRepo = validated.value;
2536
- const repointed = await deps.repointBootstrapPending({
2537
- fromRepoName: repoName,
2538
- toRepoName: nextRepo,
2539
- inviteFingerprint,
2540
- allowOverwriteExistingCredential: overwriteConsent,
2541
- }, credentialWriteDeps);
2542
- if (!repointed.ok) {
2543
- errorLog(`Error: could not re-point the pending bootstrap credential to '${nextRepo}' ` +
2544
- `(${repointed.kind}). ${repointed.error} The bootstrap invite has NOT been used.`);
2545
- return 1;
3420
+ // The exchange REPLACES the pre-flight ping: in invite mode there is no key to
3421
+ // ping with this call is what mints it.
3422
+ //
3423
+ // The outer loop runs at most TWICE: once for the initial (or resumed)
3424
+ // exchange, and — only after an explicit consent to discard an expired
3425
+ // self-serve record — once more with a freshly minted token (R4-3).
3426
+ let exchange;
3427
+ let selfServeRefreshed = false;
3428
+ for (;;) {
3429
+ exchange = await exchangeBootstrapInvite(deps, baseUrl, inviteToken, repoName, keySecret);
3430
+ // 409 repo_name_taken: the server ROLLED BACK, so the invite is NOT consumed and
3431
+ // the SAME token can be retried under a new name — with the SAME pending secret,
3432
+ // re-pointed to that name.
3433
+ while (!exchange.ok && exchange.kind === "repo-name-taken") {
3434
+ if (!deps.isTTY || !deps.promptLine) {
3435
+ errorLog(`Error: ${exchange.message} Re-run with a different --repo (the bootstrap invite has NOT ` +
3436
+ "been used).");
3437
+ return 1;
3438
+ }
3439
+ errorLog(exchange.message);
3440
+ const answer = (await deps.promptLine("Choose a different repo name: ")).trim();
3441
+ const validated = validateRepoName(answer);
3442
+ if (!validated.ok) {
3443
+ errorLog(`Error: invalid repo name — ${validated.error}.`);
3444
+ return 1;
3445
+ }
3446
+ const nextRepo = validated.value;
3447
+ const repointed = await deps.repointBootstrapPending({
3448
+ fromRepoName: repoName,
3449
+ toRepoName: nextRepo,
3450
+ inviteFingerprint,
3451
+ allowOverwriteExistingCredential: overwriteConsent,
3452
+ }, credentialWriteDeps);
3453
+ if (!repointed.ok) {
3454
+ errorLog(`Error: could not re-point the pending bootstrap credential to '${nextRepo}' ` +
3455
+ `(${repointed.kind}). ${repointed.error} The bootstrap invite has NOT been used.`);
3456
+ return 1;
3457
+ }
3458
+ repoName = nextRepo;
3459
+ // Same token, same key_secret — never regenerated inside the rename loop.
3460
+ exchange = await exchangeBootstrapInvite(deps, baseUrl, inviteToken, repoName, keySecret);
3461
+ }
3462
+ if (exchange.ok)
3463
+ break;
3464
+ // ---- Consent-gated self-serve recovery (BAPI-667, R4-3) ----
3465
+ // ONLY a REPLAYED self-serve token that the server CONCLUSIVELY rejected (401)
3466
+ // may be discarded and re-minted. Every other failure — network error, timeout,
3467
+ // 5xx, malformed response, a freshly minted token's first 401, and any ordinary
3468
+ // --invite 401 — leaves the record untouched, because in those cases the stored
3469
+ // record may still be the only proof of a live admin key.
3470
+ if (exchange.kind === "invalid-invite" &&
3471
+ resumedSelfServeReplay &&
3472
+ !selfServeRefreshed) {
3473
+ if (!deps.isTTY || !deps.promptLine) {
3474
+ return fatal(BOOTSTRAP_SELF_SERVE_EXPIRED_MESSAGE, `The saved attempt at ${getBootstrapPendingTarget(repoName)} in ${credentialStorePath} ` +
3475
+ "has been left untouched. Re-run install-bridge on an interactive terminal to " +
3476
+ "discard it and start a fresh signup.");
3477
+ }
3478
+ errorLog(BOOTSTRAP_SELF_SERVE_EXPIRED_MESSAGE);
3479
+ let consented = false;
3480
+ try {
3481
+ const answer = (await deps.promptLine(`Discard the saved signup at ${getBootstrapPendingTarget(repoName)} in ` +
3482
+ `${credentialStorePath} and start a fresh one? [y/N]: `)).trim().toLowerCase();
3483
+ consented = answer === "y" || answer === "yes";
3484
+ }
3485
+ catch {
3486
+ // A prompt that cannot be read is NOT consent. Never infer it.
3487
+ consented = false;
3488
+ }
3489
+ if (!consented) {
3490
+ errorLog("Aborted: the saved signup attempt was left unchanged (nothing was discarded).");
3491
+ return 1;
3492
+ }
3493
+ const discarded = await deps.discardBootstrapPending({ repoName, inviteFingerprint }, credentialWriteDeps);
3494
+ if (!discarded.ok) {
3495
+ // Stop WITHOUT minting: a fresh mint on top of a record we failed to
3496
+ // remove would strand the old record and its (possibly live) key.
3497
+ return fatal(`Error: could not discard the saved signup (${discarded.kind}). ${discarded.error}`);
3498
+ }
3499
+ log("Requesting a fresh Bridge self-serve setup…");
3500
+ if (!(await mintAndPrepareSelfServe()))
3501
+ return 1;
3502
+ selfServeRefreshed = true;
3503
+ // The refreshed token is brand new, so a 401 on it is no longer a
3504
+ // replay rejection and must not re-enter this branch.
3505
+ resumedSelfServeReplay = false;
3506
+ continue;
3507
+ }
3508
+ // The pending record is deliberately LEFT INTACT: it is the replay proof for a
3509
+ // retry (network failure / transient error), and destroying it would be the
3510
+ // unrecoverable case above. Every arm here is a terminal exchange failure —
3511
+ // invalid invite, network error, unreadable/unexpected response — so each
3512
+ // carries the doctor pointer exactly once (BAPI-669, U9a).
3513
+ return fatal(exchange.kind === "invalid-invite"
3514
+ ? reusedPendingSecret
3515
+ ? BOOTSTRAP_INVITE_REJECTED_MESSAGE
3516
+ : BOOTSTRAP_INVITE_LOST_SECRET_MESSAGE
3517
+ : // Transient/coarse failure: the exchange result is mode-neutral, so the
3518
+ // actionable retry instruction is appended here where the mode is known.
3519
+ `Error: ${exchange.message} ${buildBootstrapRetryAdvice(selfServeSignupMode)}`);
2546
3520
  }
2547
- repoName = nextRepo;
2548
- // Same token, same key_secret never regenerated inside the rename loop.
2549
- exchange = await exchangeBootstrapInvite(deps, baseUrl, inviteToken, repoName, keySecret);
2550
- }
2551
- if (!exchange.ok) {
2552
- if (exchange.kind === "invalid-invite") {
2553
- errorLog(reusedPendingSecret
2554
- ? BOOTSTRAP_INVITE_REJECTED_MESSAGE
2555
- : BOOTSTRAP_INVITE_LOST_SECRET_MESSAGE);
3521
+ // BAPI-668 (R5-2): the ONE orchestration transition that says "the invite is
3522
+ // spent and the key exists server-side". From here a failure is RESUMABLE, so
3523
+ // this same flag gates both the promotion path below and the catch block's
3524
+ // resume guidance — the two can never disagree about post-exchange state.
3525
+ bootstrapExchangeSucceeded = true;
3526
+ // The server-returned name is authoritative from here on: config, ping,
3527
+ // credential target, and the spawned session all key off it.
3528
+ if (exchange.repoName !== repoName) {
3529
+ const repointed = await deps.repointBootstrapPending({
3530
+ fromRepoName: repoName,
3531
+ toRepoName: exchange.repoName,
3532
+ inviteFingerprint,
3533
+ allowOverwriteExistingCredential: overwriteConsent,
3534
+ }, credentialWriteDeps);
3535
+ if (!repointed.ok) {
3536
+ return fatal(`Error: the project was created as '${exchange.repoName}' but the pending credential could ` +
3537
+ `not be re-pointed to it (${repointed.kind}). ${repointed.error}`);
3538
+ }
3539
+ repoName = exchange.repoName;
2556
3540
  }
2557
- else {
2558
- errorLog(`Error: ${exchange.message}`);
3541
+ log(` bootstrap invite redeemed — project '${repoName}' is ready`);
3542
+ // The minted key. From here the flow rejoins the existing path unchanged.
3543
+ apiKey = keySecret;
3544
+ }
3545
+ // ---- Step 2 (cont.) — verify connectivity BEFORE writing the key anywhere durable ----
3546
+ // R5: ping before persisting anything durably. Pinging before writeHostConfigs
3547
+ // (and the credential store) means a bad key on a first-time install halts
3548
+ // WITHOUT leaving an invalid key in the config — which would otherwise trip the
3549
+ // overwrite-consent gate on every retry (a trapped state). In invite mode this
3550
+ // runs AFTER the exchange, with the just-minted key; the pending record is the one
3551
+ // deliberate write that precedes it, for the protocol reason above.
3552
+ if (!bootstrapInviteMode) {
3553
+ currentStep = INSTALL_BRIDGE_STEP_LABELS.verifyConnectivity;
3554
+ log(currentStep);
3555
+ }
3556
+ // BAPI-668: a bootstrap-minted key is verified WITHOUT source attribution.
3557
+ const ping = await verifyConnectivity(deps, baseUrl, repoName, apiKey, bootstrapInviteMode ? undefined : apiKeySource);
3558
+ if (!ping.ok) {
3559
+ // BAPI-667: in either need-key mode the exchange already succeeded and the
3560
+ // pending record survives, so this failure IS resumable — but only with the
3561
+ // instruction that matches how the token was obtained.
3562
+ return fatal(bootstrapInviteMode
3563
+ ? `Error: ${ping.message} ${buildBootstrapRetryAdvice(selfServeSignupMode)}`
3564
+ : `Error: ${ping.message}`);
3565
+ }
3566
+ log(" connectivity OK");
3567
+ const entry = buildInstallBridgeServerEntry(deps.cwd, repoName, apiKey, baseUrl, docsDir);
3568
+ // BAPI-666: the secret-free variant (no BAPI_API_KEY) is written instead of the
3569
+ // real entry into any git-tracked project config the user did not explicitly
3570
+ // consent to, and into any target left invalid — so the real key is never
3571
+ // written to a tracked or un-ignored project config without explicit consent.
3572
+ const secretFreeEntry = buildInstallBridgeSecretFreeServerEntry(deps.cwd, repoName, baseUrl, docsDir);
3573
+ // ---- Step 3 — write per-host MCP config ----
3574
+ // Valid, untracked (freshly-gitignored) project configs receive the REAL key.
3575
+ // Git-tracked configs require explicit TTY consent (default-No) or fall back to
3576
+ // the secret-free entry; invalid (unparseable) configs are left untouched.
3577
+ currentStep = INSTALL_BRIDGE_STEP_LABELS.writeHostConfigs;
3578
+ log(currentStep);
3579
+ // BAPI-635 (Step 8): every project-local, secret-bearing config MUST be
3580
+ // gitignored BEFORE the real API key is written. A project-target gitignore
3581
+ // failure is FATAL before the secret write (fixed, secret-free message).
3582
+ const gitignoreDeps = {
3583
+ readFile: deps.readFile,
3584
+ writeFile: (p, data) => deps.writeFile(p, data, { encoding: "utf-8" }),
3585
+ mkdir: (p, o) => deps.mkdir(p, o),
3586
+ };
3587
+ for (const target of targets) {
3588
+ try {
3589
+ await ensureGitignoredShared(deps.cwd, target.relPath, gitignoreDeps);
2559
3590
  }
2560
- // The pending record is deliberately LEFT INTACT: it is the replay proof for a
2561
- // retry (network failure / transient error), and destroying it would be the
2562
- // unrecoverable case above.
2563
- return 1;
2564
- }
2565
- // The server-returned name is authoritative from here on: config, ping,
2566
- // credential target, and the spawned session all key off it.
2567
- if (exchange.repoName !== repoName) {
2568
- const repointed = await deps.repointBootstrapPending({
2569
- fromRepoName: repoName,
2570
- toRepoName: exchange.repoName,
2571
- inviteFingerprint,
2572
- allowOverwriteExistingCredential: overwriteConsent,
2573
- }, credentialWriteDeps);
2574
- if (!repointed.ok) {
2575
- errorLog(`Error: the project was created as '${exchange.repoName}' but the pending credential could ` +
2576
- `not be re-pointed to it (${repointed.kind}). ${repointed.error}`);
3591
+ catch {
3592
+ errorLog("Error: could not add a project MCP config to .gitignore before writing your key. " +
3593
+ "Aborting so the API key is never written to an un-ignored file.");
2577
3594
  return 1;
2578
3595
  }
2579
- repoName = exchange.repoName;
2580
- }
2581
- log(` bootstrap invite redeemed project '${repoName}' is ready`);
2582
- // The minted key. From here the flow rejoins the existing path unchanged.
2583
- apiKey = keySecret;
2584
- }
2585
- // ---- Step 2 (cont.) — verify connectivity BEFORE writing the key anywhere durable ----
2586
- // R5: ping before persisting anything durably. Pinging before writeHostConfigs
2587
- // (and the credential store) means a bad key on a first-time install halts
2588
- // WITHOUT leaving an invalid key in the config — which would otherwise trip the
2589
- // overwrite-consent gate on every retry (a trapped state). In invite mode this
2590
- // runs AFTER the exchange, with the just-minted key; the pending record is the one
2591
- // deliberate write that precedes it, for the protocol reason above.
2592
- if (!bootstrapInviteMode)
2593
- log("Step 2/5 — verifying connectivity…");
2594
- const ping = await verifyConnectivity(deps, baseUrl, repoName, apiKey);
2595
- if (!ping.ok) {
2596
- errorLog(`Error: ${ping.message}`);
2597
- return 1;
2598
- }
2599
- log(" connectivity OK");
2600
- const entry = buildInstallBridgeServerEntry(deps.cwd, repoName, apiKey, baseUrl, docsDir);
2601
- // ---- Step 3 write per-host MCP config with real values ----
2602
- log("Step 3/5 writing per-host MCP config…");
2603
- // BAPI-635 (Step 8): every project-local, secret-bearing config MUST be
2604
- // gitignored BEFORE the real API key is written. A project-target gitignore
2605
- // failure is FATAL before the secret write (fixed, secret-free message).
2606
- const gitignoreDeps = {
2607
- readFile: deps.readFile,
2608
- writeFile: (p, data) => deps.writeFile(p, data, { encoding: "utf-8" }),
2609
- mkdir: (p, o) => deps.mkdir(p, o),
2610
- };
2611
- for (const target of targets) {
3596
+ }
3597
+ // BAPI-666: after the (fatal) gitignore prerequisite has succeeded and before any
3598
+ // target can receive the real key, probe each project target's git-tracked state
3599
+ // (read-only, fail-open). A tracked config would carry the key into version control,
3600
+ // so it only receives the real key with explicit TTY consent — otherwise the
3601
+ // secret-free entry. Global targets are provisioned separately and are outside this
3602
+ // project-local Git guard.
3603
+ const trackedState = new Map();
3604
+ for (const target of targets) {
3605
+ trackedState.set(target.relPath, await isTrackedProjectConfig(deps, target.relPath));
3606
+ }
3607
+ const writeResult = await writeHostConfigs(deps, targets, { real: entry, secretFree: secretFreeEntry }, trackedState, { repoName, credentialStorePath });
3608
+ for (const { relPath, mode } of writeResult.written) {
3609
+ log(mode === "secret-free" ? ` wrote ${relPath} (secret-free — key resolved at runtime)` : ` wrote ${relPath}`);
3610
+ }
3611
+ for (const { relPath } of writeResult.skipped) {
3612
+ log(` skipped ${relPath} — existing config could not be parsed safely; left untouched`);
3613
+ }
3614
+ // BAPI-635: provision selected GLOBAL targets (Codex, Copilot CLI) and MANUAL
3615
+ // targets (Windsurf) via the registry-driven emitter. Global paths are never
3616
+ // added to the repository .gitignore.
3617
+ // BAPI-669 (R8): only a need-key run has a key the user has never seen, so only it
3618
+ // needs to be told where that key was stored. `bootstrapInviteMode` is true for the
3619
+ // explicit invite, the self-serve, AND the reclassified-invite flows alike, and
3620
+ // carries no credential just the repo name and the store path.
3621
+ const needKeyManual = bootstrapInviteMode
3622
+ ? { repoName, credentialStorePath }
3623
+ : undefined;
3624
+ const globalLogLines = await provisionSelectedGlobalTargets(deps, selectedPlatforms, entry, needKeyManual);
3625
+ for (const line of globalLogLines)
3626
+ log(line);
3627
+ // Legacy manual-editor instructions cover editors that are DETECTED but were
3628
+ // NOT part of the selection (so the emitter above did not handle them). The two
3629
+ // editors are suppressed INDEPENDENTLY: Codex is dropped when it was selected
3630
+ // (auto-provisioned or emitted above), Windsurf is dropped only when it was
3631
+ // selected — auto-provisioning Codex must never hide the Windsurf snippet.
3632
+ const legacyManualEditors = {
3633
+ windsurf: manualEditors.windsurf && !selectedPlatforms.includes("windsurf"),
3634
+ codex: manualEditors.codex && !selectedPlatforms.includes("codex"),
3635
+ };
3636
+ const manualInstructions = buildManualHostInstructions(entry, legacyManualEditors, needKeyManual);
3637
+ if (manualInstructions)
3638
+ log(manualInstructions);
3639
+ // BAPI-635 (Step 7): when both Claude Code and Copilot CLI are selected, warn
3640
+ // that they use different, non-shared config surfaces.
3641
+ if (selectedPlatforms.includes("claude-code") && selectedPlatforms.includes("copilot-cli")) {
3642
+ log(" Note: Claude Code uses the project .mcp.json while GitHub Copilot CLI uses only its " +
3643
+ "global ~/.copilot/mcp-config.json — the two are configured separately.");
3644
+ }
3645
+ // BAPI-635 (Step 7): Claude trust reminder — a written project MCP config is
3646
+ // not a live connection until approved in Claude Code's trust dialog.
3647
+ if (selectedPlatforms.includes("claude-code")) {
3648
+ log(" Claude Code: the project MCP server is pending approval in Claude Code's trust dialog; " +
3649
+ "restart or reload an already-running session for it to take effect.");
3650
+ }
3651
+ // BAPI-635 (Step 8 + Step 3): persist the secret-free install state, ignoring
3652
+ // it before the write. Project-local paths only; global paths are never in it.
2612
3653
  try {
2613
- await ensureGitignoredShared(deps.cwd, target.relPath, gitignoreDeps);
3654
+ await ensureGitignoredShared(deps.cwd, ".bridge/install-state.json", gitignoreDeps);
3655
+ // writeMcpInstallState catches its own I/O errors and returns { ok: false }
3656
+ // (it does NOT throw), so the failure warning must inspect the return value —
3657
+ // a try/catch alone would silently swallow a real persistence failure.
3658
+ const stateResult = await writeMcpInstallState(deps.cwd, { selectedPlatforms, projectConfigPaths: targets.map((t) => t.relPath) }, {
3659
+ readFile: deps.readFile,
3660
+ writeFile: (p, data) => deps.writeFile(p, data, { encoding: "utf-8" }),
3661
+ rename: deps.rename,
3662
+ mkdir: async (p, o) => {
3663
+ await deps.mkdir(p, o);
3664
+ },
3665
+ unlink: deps.unlink,
3666
+ });
3667
+ if (!stateResult.ok) {
3668
+ // Install-state persistence is advisory — never fail the install over it.
3669
+ errorLog("Warning: could not persist the install-state file (non-fatal).");
3670
+ }
2614
3671
  }
2615
3672
  catch {
2616
- errorLog("Error: could not add a project MCP config to .gitignore before writing your key. " +
2617
- "Aborting so the API key is never written to an un-ignored file.");
2618
- return 1;
2619
- }
2620
- }
2621
- const written = await writeHostConfigs(deps, targets, entry);
2622
- for (const relPath of written)
2623
- log(` wrote ${relPath}`);
2624
- // BAPI-635: provision selected GLOBAL targets (Codex, Copilot CLI) and MANUAL
2625
- // targets (Windsurf) via the registry-driven emitter. Global paths are never
2626
- // added to the repository .gitignore.
2627
- const globalLogLines = await provisionSelectedGlobalTargets(deps, selectedPlatforms, entry);
2628
- for (const line of globalLogLines)
2629
- log(line);
2630
- // Legacy manual-editor instructions cover editors that are DETECTED but were
2631
- // NOT part of the selection (so the emitter above did not handle them). The two
2632
- // editors are suppressed INDEPENDENTLY: Codex is dropped when it was selected
2633
- // (auto-provisioned or emitted above), Windsurf is dropped only when it was
2634
- // selected — auto-provisioning Codex must never hide the Windsurf snippet.
2635
- const legacyManualEditors = {
2636
- windsurf: manualEditors.windsurf && !selectedPlatforms.includes("windsurf"),
2637
- codex: manualEditors.codex && !selectedPlatforms.includes("codex"),
2638
- };
2639
- const manualInstructions = buildManualHostInstructions(entry, legacyManualEditors);
2640
- if (manualInstructions)
2641
- log(manualInstructions);
2642
- // BAPI-635 (Step 7): when both Claude Code and Copilot CLI are selected, warn
2643
- // that they use different, non-shared config surfaces.
2644
- if (selectedPlatforms.includes("claude-code") && selectedPlatforms.includes("copilot-cli")) {
2645
- log(" Note: Claude Code uses the project .mcp.json while GitHub Copilot CLI uses only its " +
2646
- "global ~/.copilot/mcp-config.json — the two are configured separately.");
2647
- }
2648
- // BAPI-635 (Step 7): Claude trust reminder — a written project MCP config is
2649
- // not a live connection until approved in Claude Code's trust dialog.
2650
- if (selectedPlatforms.includes("claude-code")) {
2651
- log(" Claude Code: the project MCP server is pending approval in Claude Code's trust dialog; " +
2652
- "restart or reload an already-running session for it to take effect.");
2653
- }
2654
- // BAPI-635 (Step 8 + Step 3): persist the secret-free install state, ignoring
2655
- // it before the write. Project-local paths only; global paths are never in it.
2656
- try {
2657
- await ensureGitignoredShared(deps.cwd, ".bridge/install-state.json", gitignoreDeps);
2658
- // writeMcpInstallState catches its own I/O errors and returns { ok: false }
2659
- // (it does NOT throw), so the failure warning must inspect the return value —
2660
- // a try/catch alone would silently swallow a real persistence failure.
2661
- const stateResult = await writeMcpInstallState(deps.cwd, { selectedPlatforms, projectConfigPaths: targets.map((t) => t.relPath) }, {
2662
- readFile: deps.readFile,
2663
- writeFile: (p, data) => deps.writeFile(p, data, { encoding: "utf-8" }),
2664
- rename: deps.rename,
2665
- mkdir: async (p, o) => {
2666
- await deps.mkdir(p, o);
2667
- },
2668
- unlink: deps.unlink,
2669
- });
2670
- if (!stateResult.ok) {
2671
- // Install-state persistence is advisory — never fail the install over it.
3673
+ // ensureGitignored for the state file can still throw also advisory.
2672
3674
  errorLog("Warning: could not persist the install-state file (non-fatal).");
2673
3675
  }
2674
- }
2675
- catch {
2676
- // ensureGitignored for the state file can still throw also advisory.
2677
- errorLog("Warning: could not persist the install-state file (non-fatal).");
2678
- }
2679
- // ---- Step 4 — persist the credential ----
2680
- if (bootstrapInviteMode) {
2681
- // FAIL-CLOSED (unlike normal mode below). The pending secret IS the minted admin
2682
- // key if it never lands under bapi:<repo>, the user has a live key they cannot
2683
- // resolve. Promotion removes the pending record and writes the credential in one
2684
- // durable replacement; on failure the pending record survives so a re-run replays
2685
- // the same redemption. The agent session is NOT spawned until this succeeds.
2686
- log("Step 4/5 — promoting the bootstrap credential…");
2687
- const promoted = await deps.promoteBootstrapPending({ repoName, inviteFingerprint, allowOverwriteExistingCredential: overwriteConsent }, credentialWriteDeps);
2688
- if (!promoted.ok) {
2689
- errorLog(`Error: the project and API key were created, but the credential could not be stored ` +
2690
- `(${promoted.kind}). ${promoted.error} Your key is still saved locally as a pending ` +
2691
- "record re-run install-bridge with the same bootstrap invite to finish (the redemption " +
2692
- "will replay and return the same key).");
2693
- return 1;
3676
+ // ---- Step 4 — persist the credential ----
3677
+ // Gated on the post-exchange transition rather than the mode flag (BAPI-668):
3678
+ // in bootstrap mode the exchange loop above is the ONLY way to reach here, so the
3679
+ // two are equivalent — but keying promotion to the same state that justifies the
3680
+ // catch block's resume guidance makes that invariant explicit and un-driftable.
3681
+ if (bootstrapExchangeSucceeded) {
3682
+ // FAIL-CLOSED (unlike normal mode below). The pending secret IS the minted admin
3683
+ // key if it never lands under bapi:<repo>, the user has a live key they cannot
3684
+ // resolve. Promotion removes the pending record and writes the credential in one
3685
+ // durable replacement; on failure the pending record survives so a re-run replays
3686
+ // the same redemption. The agent session is NOT spawned until this succeeds.
3687
+ currentStep = INSTALL_BRIDGE_STEP_LABELS.promoteCredential;
3688
+ log(currentStep);
3689
+ const promoted = await deps.promoteBootstrapPending({ repoName, inviteFingerprint, allowOverwriteExistingCredential: overwriteConsent }, credentialWriteDeps);
3690
+ if (!promoted.ok) {
3691
+ // Mode-aware (BAPI-667): a self-serve user has no invite to "re-present"
3692
+ // telling them to is an instruction they cannot follow.
3693
+ return fatal(`Error: the project and API key were created, but the credential could not be stored ` +
3694
+ `(${promoted.kind}). ${promoted.error} Your key is still saved locally as a pending ` +
3695
+ `record. ${buildBootstrapRetryAdvice(selfServeSignupMode)}`);
3696
+ }
3697
+ log(` stored routing credential for ${promoted.target} at ${promoted.path}`);
2694
3698
  }
2695
- log(` stored routing credential for ${promoted.target} at ${promoted.path}`);
2696
- }
2697
- else {
2698
- // ---- persist routing credential (non-blocking / fail-open) ----
2699
- log("Step 4/5 — persisting routing credential…");
2700
- try {
2701
- const result = await deps.upsertCredential(repoName, apiKey, credentialWriteDeps);
2702
- if (result.ok) {
2703
- log(` stored routing credential for ${result.target} at ${result.path}`);
3699
+ else {
3700
+ // ---- persist routing credential (non-blocking / fail-open) ----
3701
+ currentStep = INSTALL_BRIDGE_STEP_LABELS.persistCredential;
3702
+ log(currentStep);
3703
+ try {
3704
+ const result = await deps.upsertCredential(repoName, apiKey, credentialWriteDeps);
3705
+ if (result.ok) {
3706
+ log(` stored routing credential for ${result.target} at ${result.path}`);
3707
+ }
3708
+ else {
3709
+ log(` warning: could not persist the routing credential (${result.kind}). ` +
3710
+ `start-tickets model routing may not resolve the key for bapi:${repoName} ` +
3711
+ "and will fail open to the premium/Opus tier (the most expensive) — " +
3712
+ "set BAPI_API_KEY in the shell or re-run install-bridge, then verify with " +
3713
+ "'npx -y @bridge_gpt/mcp-server doctor'.");
3714
+ }
2704
3715
  }
2705
- else {
2706
- log(` warning: could not persist the routing credential (${result.kind}). ` +
2707
- `start-tickets model routing may not resolve the key for bapi:${repoName} ` +
2708
- "and will fail open to the premium/Opus tier (the most expensive) " +
2709
- "set BAPI_API_KEY in the shell or re-run install-bridge, then verify with " +
3716
+ catch {
3717
+ // Fail-open: persistence is best-effort (mirrors Stage 6). The secret is never
3718
+ // included in the warning.
3719
+ log(" warning: could not persist the routing credential (unexpected error). " +
3720
+ "start-tickets model routing may need BAPI_API_KEY in the shell and will fail open " +
3721
+ "to the premium/Opus tier (the most expensive) until fixed — verify with " +
2710
3722
  "'npx -y @bridge_gpt/mcp-server doctor'.");
2711
3723
  }
2712
3724
  }
2713
- catch {
2714
- // Fail-open: persistence is best-effort (mirrors Stage 6). The secret is never
2715
- // included in the warning.
2716
- log(" warning: could not persist the routing credential (unexpected error). " +
2717
- "start-tickets model routing may need BAPI_API_KEY in the shell and will fail open " +
2718
- "to the premium/Opus tier (the most expensive) until fixed — verify with " +
2719
- "'npx -y @bridge_gpt/mcp-server doctor'.");
2720
- }
2721
- }
2722
- // ---- optional GitHub connect offer (BAPI-631) ----
2723
- // Placed AFTER the credential is durable (the flow needs a resolvable key) and BEFORE
2724
- // the agent spawn, for two reasons: the spawned session's capability report should
2725
- // observe GitHub as configured if the user connects it here, and running it after the
2726
- // spawn would put two prompts on the same terminal at once.
2727
- await offerGithubConnection(repoName, deps, log);
2728
- // ---- Step 3b (await) settle the background pre-warm before the session opens ----
2729
- // The warm was kicked off (only on a spawn-capable path) right after the
2730
- // launch-command guards; await it HERE so the bucket is settled before the spawned
2731
- // session's first MCP launch, but AFTER all the work it overlapped with — and BEFORE
2732
- // the consent prompt, so no spawn ever races an in-flight warm. Strictly fail-open —
2733
- // the exit code is never affected. Skipped entirely when no session will be opened.
2734
- if (prewarmPromise) {
2735
- const prewarm = await prewarmPromise;
2736
- if (prewarm.ok) {
2737
- log(" launcher bucket warmed (the first MCP launch will not pay a cold install).");
2738
- log(` ${MCP_TIMEOUT_GUIDANCE}`);
3725
+ // ---- optional GitHub connect offer (BAPI-631) ----
3726
+ // Placed AFTER the credential is durable (the flow needs a resolvable key) and BEFORE
3727
+ // the agent spawn, for two reasons: the spawned session's capability report should
3728
+ // observe GitHub as configured if the user connects it here, and running it after the
3729
+ // spawn would put two prompts on the same terminal at once.
3730
+ await offerGithubConnection(repoName, baseUrl, deps, log);
3731
+ // ---- Step 3b (await) — settle the background pre-warm before the session opens ----
3732
+ // The warm was kicked off (only on a spawn-capable path) right after the
3733
+ // launch-command guards; await it HERE so the bucket is settled before the spawned
3734
+ // session's first MCP launch, but AFTER all the work it overlapped with — and BEFORE
3735
+ // the consent prompt, so no spawn ever races an in-flight warm. Strictly fail-open
3736
+ // the exit code is never affected. Skipped entirely when no session will be opened.
3737
+ if (prewarmPromise) {
3738
+ const prewarm = await prewarmPromise;
3739
+ if (prewarm.ok) {
3740
+ log(" launcher bucket warmed (the first MCP launch will not pay a cold install).");
3741
+ log(` ${MCP_TIMEOUT_GUIDANCE}`);
3742
+ }
3743
+ else {
3744
+ // Escalate to a stronger, still secret-free warning when pre-warm didn't
3745
+ // complete cleanly but the install itself still succeeds (exit unaffected).
3746
+ errorLog(`Warning: could not pre-warm the version-pinned launcher bucket${prewarm.warning ? ` (${prewarm.warning})` : ""}. ` +
3747
+ `The first MCP launch may pay a one-time cold install and could be slow. ${MCP_TIMEOUT_GUIDANCE}`);
3748
+ }
2739
3749
  }
2740
- else {
2741
- // Escalate to a stronger, still secret-free warning when pre-warm didn't
2742
- // complete cleanly but the install itself still succeeds (exit unaffected).
2743
- errorLog(`Warning: could not pre-warm the version-pinned launcher bucket${prewarm.warning ? ` (${prewarm.warning})` : ""}. ` +
2744
- `The first MCP launch may pay a one-time cold install and could be slow. ${MCP_TIMEOUT_GUIDANCE}`);
2745
- }
2746
- }
2747
- // ---- Step 5 — consent-gate, then spawn the selected tool (or print continuation) ----
2748
- // AC-7: for a spawn-capable path, ask Y/N first (`requestInstallBridgeLaunchConsent`
2749
- // resolves to no-spawn without prompting on a non-TTY run). A decline, a no-launchable
2750
- // selection, a non-TTY run, or a declined/aborted chooser all skip the spawn and fall
2751
- // through to the shared /install-bridge continuation + limited-tools notice below — so
2752
- // every no-session outcome gets identical, accurate remediation.
2753
- if (finalAgentName && launchCommand) {
2754
- const consent = await requestInstallBridgeLaunchConsent(toolLabelForLaunchAgent(finalAgentName), deps);
2755
- if (consent === "spawn") {
2756
- log(`Step 5/5 — opening a ${finalAgentName} session for /install-bridge configuration + concise capability report…`);
2757
- const terminal = detectTerminal(undefined, deps.env);
2758
- // Only the validated short runner reaches the terminal — never the inline prompt.
2759
- // The branded "Bridge Install" title labels the tab/badge instead of "install
2760
- // Implementation" (AC-6).
2761
- const spawnResult = await deps.spawnTerminalTab(deps.startTicketsDeps, terminal, launchCommand, {
2762
- key: "install",
2763
- worktreePath: deps.cwd,
2764
- title: "Bridge Install",
2765
- });
2766
- if (!spawnResult.ok) {
2767
- // Steps 1–4 (scaffold, config, connectivity-verified, credential persist) all
2768
- // succeeded and are durable; only the best-effort Step 5 tab spawn failed. The
2769
- // deterministic setup is complete, so this is a non-fatal warning (exit 0), with
2770
- // the same shared continuation so the remediation is consistent.
2771
- errorLog(`Warning: setup steps completed, but the agent session could not be opened (${spawnResult.error}).`);
2772
- log(buildManualInstallBridgeContinuation("configured", labelsForPlatforms(selectedPlatforms)));
3750
+ // ---- Step 5 — consent-gate, then spawn the selected tool (or print continuation) ----
3751
+ // AC-7: for a spawn-capable path, ask Y/N first (`requestInstallBridgeLaunchConsent`
3752
+ // resolves to no-spawn without prompting on a non-TTY run). A decline, a no-launchable
3753
+ // selection, a non-TTY run, or a declined/aborted chooser all skip the spawn and fall
3754
+ // through to the shared /install-bridge continuation + limited-tools notice below so
3755
+ // every no-session outcome gets identical, accurate remediation.
3756
+ if (finalAgentName && launchCommand) {
3757
+ const consent = await requestInstallBridgeLaunchConsent(toolLabelForLaunchAgent(finalAgentName), deps);
3758
+ if (consent === "spawn") {
3759
+ currentStep = buildInstallBridgeLaunchStepLabel(finalAgentName);
3760
+ log(currentStep);
3761
+ const terminal = detectTerminal(undefined, deps.env);
3762
+ // Only the validated short runner reaches the terminal — never the inline prompt.
3763
+ // The branded "Bridge Install" title labels the tab/badge instead of "install
3764
+ // Implementation" (AC-6).
3765
+ const spawnResult = await deps.spawnTerminalTab(deps.startTicketsDeps, terminal, launchCommand, {
3766
+ key: "install",
3767
+ worktreePath: deps.cwd,
3768
+ title: "Bridge Install",
3769
+ });
3770
+ if (!spawnResult.ok) {
3771
+ // Steps 1–4 (scaffold, config, connectivity-verified, credential persist) all
3772
+ // succeeded and are durable; only the best-effort Step 5 tab spawn failed. The
3773
+ // deterministic setup is complete, so this is a non-fatal warning (exit 0), with
3774
+ // the same shared continuation so the remediation is consistent.
3775
+ errorLog(`Warning: setup steps completed, but the agent session could not be opened (${spawnResult.error}).`);
3776
+ log(buildManualInstallBridgeContinuation("configured", labelsForPlatforms(selectedPlatforms)));
3777
+ return 0;
3778
+ }
3779
+ // BAPI-669 (U3): the handoff speaks the HOST TOOL'S display label, never the
3780
+ // raw agent binary name — computed once so all three sentences agree.
3781
+ const handoffToolLabel = toolLabelForLaunchAgent(finalAgentName);
3782
+ log("");
3783
+ log(`install-bridge setup steps complete. A fresh ${handoffToolLabel} session is now applying ` +
3784
+ "configuration, presenting the concise capability report, and recommending /learn-repository.");
3785
+ // The trust dialog is the single most common place a spawned session stalls:
3786
+ // the tab opens, nothing is approved, and the install looks hung.
3787
+ log(`In the new tab: approve the workspace and the 'bridge-api' MCP server if ${handoffToolLabel} ` +
3788
+ "asks — configuration can't proceed until you do.");
3789
+ // Recovery was previously printed ONLY on no-spawn paths, so a tab that opened
3790
+ // and then died left the user with no instruction at all.
3791
+ log(`If that tab is closed or fails, open a session in ${handoffToolLabel} and run /install-bridge.`);
3792
+ log("NOTE: the install is not finished until that session's apply reports applied fields — " +
3793
+ "it will pause to ask you to approve the project description. Indexing starts automatically " +
3794
+ "once the repository reaches full parse readiness — there is no indexing question to answer. " +
3795
+ `Verify afterwards at ${setupUrl} (Get Started page — install status panel) or via the ` +
3796
+ "session's 'Applied N of M' summary.");
2773
3797
  return 0;
2774
3798
  }
2775
- log("");
2776
- log(`install-bridge setup steps complete. A fresh ${finalAgentName} session is now applying ` +
2777
- "configuration, presenting the concise capability report, and recommending /learn-repository.");
2778
- log("NOTE: the install is not finished until that session's apply reports applied fields — " +
2779
- "it will pause to ask you to approve the project description. Indexing starts automatically " +
2780
- "once the repository reaches full parse readiness — there is no indexing question to answer. " +
2781
- "Verify afterwards on the project's Get Started page (install status panel) or via the " +
2782
- "session's 'Applied N of M' summary.");
3799
+ // Declined (or non-TTY suppression): the deterministic setup is complete and durable.
3800
+ log(buildManualInstallBridgeContinuation("configured", labelsForPlatforms(selectedPlatforms)));
2783
3801
  return 0;
2784
3802
  }
2785
- // Declined (or non-TTY suppression): the deterministic setup is complete and durable.
3803
+ // No launchable session (a no-launchable selection, or a non-TTY / declined chooser):
3804
+ // finalAgentName is null, so nothing was materialized. The configured hosts are
3805
+ // durable — print the shared continuation + limited-tools note (never the
3806
+ // empty-selection variant, which already returned above).
2786
3807
  log(buildManualInstallBridgeContinuation("configured", labelsForPlatforms(selectedPlatforms)));
2787
3808
  return 0;
2788
3809
  }
2789
- // No launchable session (a no-launchable selection, or a non-TTY / declined chooser):
2790
- // finalAgentName is null, so nothing was materialized. The configured hosts are
2791
- // durable print the shared continuation + limited-tools note (never the
2792
- // empty-selection variant, which already returned above).
2793
- log(buildManualInstallBridgeContinuation("configured", labelsForPlatforms(selectedPlatforms)));
2794
- return 0;
3810
+ catch (error) {
3811
+ // Resume guidance ONLY after the exchange actually succeeded (R5-2): before
3812
+ // that the invite is unspent and there is nothing to resume. The wording comes
3813
+ // from the mode-aware helper, never a fresh literal, so a self-serve user is
3814
+ // never told to re-present a token they were never shown.
3815
+ const resumeAdvice = bootstrapInviteMode && bootstrapExchangeSucceeded
3816
+ ? buildBootstrapRetryAdvice(selfServeSignupMode)
3817
+ : undefined;
3818
+ // The formatter receives ONLY safe context: a printed step label, the debug
3819
+ // gate, and the helper-produced advice. No apiKey, inviteToken, fingerprint,
3820
+ // keySecret, signup email, or response body is in scope for it by construction.
3821
+ // Raw diagnostics are emitted through `errorLog`, NOT `deps.debugLog`, whose
3822
+ // contract admits only secret-free text.
3823
+ const lines = buildInstallBridgeFailureLines(error, {
3824
+ step: currentStep,
3825
+ debugEnabled: Boolean(deps.env.BAPI_INSTALL_DEBUG),
3826
+ ...(resumeAdvice ? { resumeAdvice } : {}),
3827
+ });
3828
+ for (const line of lines)
3829
+ errorLog(line);
3830
+ return 1;
3831
+ }
2795
3832
  }