@bridge_gpt/mcp-server 0.2.20 → 0.2.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,13 +28,34 @@
28
28
  * reload the just-written `.mcp.json`, and field derivation needs an agent
29
29
  * runtime the shell does not have.
30
30
  *
31
- * SECRET DISCIPLINE: the API key is NEVER printed or logged — not in stdout,
32
- * stderr, error messages, or --dry-run output (it is redacted to `<REDACTED>`).
33
- * The ONLY place the key is durably written is the per-host MCP config (Step 3,
34
- * gitignored) and the user-scoped credential store (Step 4).
31
+ * BOOTSTRAP-INVITE MODE (BAPI-606) the one exception to "this command consumes
32
+ * a key, it does not create one". With `--invite` (or `BAPI_INVITE`) there is no
33
+ * API key yet, so the pre-flight ping of Step 2 CANNOT be made: the exchange is
34
+ * what mints the key, and it REPLACES that ping. The order becomes:
35
+ *
36
+ * resolve bootstrap-invite token (no-echo prompt by DEFAULT — the delivered
37
+ * one-liner is secret-free) → resolve repo name → scaffold → generate
38
+ * `key_secret` (CSPRNG) and **fsync it to a pending credential record** →
39
+ * `POST <base>/setup/bootstrap` {token, repo_name, key_secret} → ping with the
40
+ * newly-minted key → write host configs → promote pending → `bapi:<repo>` →
41
+ * spawn. Everything from the ping onward is the existing flow, unchanged.
42
+ *
43
+ * The persist-before-exchange ordering is LOAD-BEARING PROTOCOL (see the comment
44
+ * at the pending-preparation call site): the local `key_secret` is the ONLY proof
45
+ * that can replay a redemption, so a successful exchange with a failed local write
46
+ * would leave an unrecoverable admin key and a permanently spent invite.
47
+ *
48
+ * SECRET DISCIPLINE: THREE secrets now — the API key, the bootstrap-invite token,
49
+ * and the client-generated `key_secret`. NONE is ever printed or logged — not in
50
+ * stdout, stderr, error messages, or --dry-run output (they are redacted to
51
+ * `<REDACTED>`). The ONLY places a secret is durably written are the per-host MCP
52
+ * config (Step 3, gitignored) and the user-scoped credential store (Step 4 / the
53
+ * pending record). The bootstrap-invite token itself is NEVER written to disk —
54
+ * only a SHA-256 fingerprint of it, to key the pending record.
35
55
  */
36
- import { readFile, writeFile, mkdir, stat, rename, chmod, unlink } from "fs/promises";
56
+ import { readFile, writeFile, mkdir, stat, rename, chmod, unlink, open } from "fs/promises";
37
57
  import { spawn } from "child_process";
58
+ import { randomBytes as cryptoRandomBytes, createHash } from "crypto";
38
59
  import os from "os";
39
60
  import path from "path";
40
61
  import readline from "readline";
@@ -42,7 +63,7 @@ import { runInit, buildBridgeApiEntry } from "./init.js";
42
63
  import { VERSION } from "./version.generated.js";
43
64
  import { validateRepoName } from "./bridge-config.js";
44
65
  import { resolveStartTicketsRepoName } from "./start-tickets-repo.js";
45
- import { upsertBapiCredential, getPrimaryCredentialStorePath, } from "./credential-store.js";
66
+ import { upsertBapiCredential, getPrimaryCredentialStorePath, prepareBootstrapPendingCredential, repointBootstrapPendingCredential, promoteBootstrapPendingCredential, } from "./credential-store.js";
46
67
  import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, formatValidAgentNames, } from "./agent-registry.js";
47
68
  import { buildGenericAgentShellCommand, getDefaultSpawnTerminalTabForPlatform, detectTerminal, createDefaultStartTicketsDeps, } from "./start-tickets.js";
48
69
  /** Redaction sentinel — the API-key value is NEVER printed; this stands in. */
@@ -92,33 +113,60 @@ export function getInstallBridgeUsage() {
92
113
  " --api-key <key> Bridge API key. Falls back to the BAPI_API_KEY env var,",
93
114
  " then an interactive (no-echo) prompt. Generate one in the",
94
115
  " Bridge API web UI Security page — this command consumes a",
95
- " key, it does not create one. NEVER printed or logged.",
116
+ " key, it does not create one (--invite is the one exception:",
117
+ " it CREATES the project and its first admin key). NEVER",
118
+ " printed or logged.",
96
119
  " --repo <name> Repository name. Falls back to BAPI_REPO_NAME, then an",
97
120
  " inferred default you confirm interactively. MUST match the",
98
121
  " server-side repo registration (it keys the credential",
99
122
  " store as bapi:<repo>). Required (no inference) when stdin",
100
- " is non-interactive.",
123
+ " is non-interactive. With --invite it is the name your NEW",
124
+ " project is created under (globally unique).",
125
+ "",
126
+ "Bootstrap-invite onboarding (no web UI, no pre-existing key):",
127
+ " --invite [token] Redeem a bootstrap invite: creates the project and mints",
128
+ " your own admin API key in one command. Mutually exclusive",
129
+ " with --api-key (in this mode the key is created, not",
130
+ " consumed).",
131
+ "",
132
+ " Run it WITHOUT a value — `install-bridge --invite` — and the",
133
+ " token is read from an interactive prompt with echo",
134
+ " suppressed, then sent only in the request body. This is the",
135
+ " default and the recommended path: 'a copy/paste one-liner'",
136
+ " and 'the token never touches shell history' are",
137
+ " contradictory, so the one-liner your operator sends you is",
138
+ " SECRET-FREE and the CLI asks for the token.",
139
+ "",
140
+ " Passing the token inline (--invite <token>, --invite=<token>)",
141
+ " or via BAPI_INVITE is for SCRIPTING ONLY: both forms EXPOSE",
142
+ " THE TOKEN to your shell history and to the process list.",
101
143
  "",
102
144
  "Flags:",
103
145
  " --force Overwrite an existing real BAPI_API_KEY in a",
104
- " host config without prompting.",
146
+ " host config (or in the credential store) without",
147
+ " prompting.",
105
148
  " --dry-run Preview every step (scaffold targets, config",
106
149
  " files + keys with the key REDACTED, ping",
107
150
  " target, credential target, spawn command)",
108
151
  " without writing, pinging, or spawning anything.",
152
+ " With --invite it also never calls the exchange",
153
+ " endpoint and never generates or stores a secret.",
109
154
  " --agent claude|cursor-agent Agent to launch for the agentic remainder",
110
155
  " (default: claude).",
111
156
  " -h, --help Show this help.",
112
157
  "",
113
158
  "Environment: BAPI_BASE_URL (default https://bridgegpt-api.com) and BAPI_DOCS_DIR",
114
159
  "(default docs/tmp) are read from the environment with the shown fallbacks.",
160
+ "BAPI_INVITE supplies the bootstrap-invite token non-interactively (scripting",
161
+ "only — it is exposed to shell history; prefer the prompt).",
115
162
  ].join("\n");
116
163
  }
117
164
  /**
118
165
  * Parse argv strictly. Supports `--api-key`, `--repo`, `--agent` (each with a
119
- * `--flag value` or `--flag=value` form), the boolean `--force` / `--dry-run`,
120
- * and `-h`/`--help`. Unknown flags and positional args are rejected. The API key
121
- * value is captured but never echoed (no error message includes it).
166
+ * `--flag value` or `--flag=value` form), the OPTIONAL-value `--invite`, the
167
+ * boolean `--force` / `--dry-run`, and `-h`/`--help`. Unknown flags and positional
168
+ * args are rejected. The API-key and bootstrap-invite values are captured but
169
+ * never echoed (no error message includes either).
122
170
  */
123
171
  export function parseInstallBridgeArgs(argv) {
124
172
  if (argv.includes("-h") || argv.includes("--help")) {
@@ -129,6 +177,11 @@ export function parseInstallBridgeArgs(argv) {
129
177
  let force = false;
130
178
  let dryRun = false;
131
179
  let agentName = DEFAULT_AGENT_NAME;
180
+ let invite;
181
+ // Track SUPPLIED-ness separately from the values: `--invite` is legitimately
182
+ // valueless (prompt path) and `--api-key ""` is still a contradiction with it.
183
+ let inviteSupplied = false;
184
+ let apiKeySupplied = false;
132
185
  /** Read a `--flag value` or `--flag=value` value, advancing the index. */
133
186
  const readValue = (arg, flag, i) => {
134
187
  if (arg.startsWith(`${flag}=`)) {
@@ -154,9 +207,27 @@ export function parseInstallBridgeArgs(argv) {
154
207
  if ("error" in r)
155
208
  return { status: "error", message: r.error };
156
209
  apiKey = r.value;
210
+ apiKeySupplied = true;
157
211
  i = r.nextIndex;
158
212
  continue;
159
213
  }
214
+ if (arg === "--invite" || arg.startsWith("--invite=")) {
215
+ inviteSupplied = true;
216
+ if (arg.startsWith("--invite=")) {
217
+ invite = arg.slice("--invite=".length);
218
+ }
219
+ else {
220
+ // OPTIONAL value: a bare `--invite` selects bootstrap-invite mode and the
221
+ // token is prompted for (no echo). Only a following non-flag token is
222
+ // consumed as the value — a bootstrap-invite token never starts with '-'.
223
+ const next = argv[i + 1];
224
+ if (typeof next === "string" && !next.startsWith("-")) {
225
+ invite = next;
226
+ i += 1;
227
+ }
228
+ }
229
+ continue;
230
+ }
160
231
  if (arg === "--repo" || arg.startsWith("--repo=")) {
161
232
  const r = readValue(arg, "--repo", i);
162
233
  if ("error" in r)
@@ -187,38 +258,92 @@ export function parseInstallBridgeArgs(argv) {
187
258
  message: `Unexpected positional argument: '${arg}'. install-bridge does not accept positional arguments.`,
188
259
  };
189
260
  }
190
- return { status: "ok", options: { apiKey, repo, force, dryRun, agentName } };
261
+ // Mutually exclusive by construction: bootstrap-invite mode CREATES the key, so
262
+ // consuming one is a contradiction. Names the flags only — never their values.
263
+ if (inviteSupplied && apiKeySupplied) {
264
+ return {
265
+ status: "error",
266
+ message: "--invite and --api-key are mutually exclusive: a bootstrap invite creates your API key, " +
267
+ "it does not consume an existing one.",
268
+ };
269
+ }
270
+ return {
271
+ status: "ok",
272
+ options: { apiKey, repo, force, dryRun, agentName, invite, inviteMode: inviteSupplied },
273
+ };
191
274
  }
192
- /** No-echo secret prompt on stderr (so it never lands in piped stdout). */
193
- function promptSecretViaReadline(promptText) {
275
+ /**
276
+ * No-echo secret prompt on stderr (so it never lands in piped stdout).
277
+ *
278
+ * `input`/`output` are injectable for tests ONLY — production always uses the real
279
+ * stdin/stderr. They exist because this function is the one piece of the install
280
+ * flow that talks to a live terminal, and stubbing it out at the deps seam left the
281
+ * terminal behavior itself (prompt visibility, echo suppression) fully unverified.
282
+ */
283
+ export function promptSecretViaReadline(promptText, input = process.stdin, output = process.stderr) {
194
284
  return new Promise((resolve) => {
195
285
  const rl = readline.createInterface({
196
- input: process.stdin,
197
- output: process.stderr,
286
+ input,
287
+ output,
198
288
  terminal: true,
199
289
  });
200
- // Suppress echo of typed characters: override the internal writer so only the
201
- // prompt (written explicitly below) is shown, never the keystrokes.
290
+ // The prompt MUST be owned by readline (passed to `question` below), never
291
+ // written to stderr by hand. On every redraw readline emits cursorTo(0) +
292
+ // clearScreenDown straight to `output`, bypassing the `_writeToOutput` hook
293
+ // — so a hand-written prompt is erased on the first redraw, leaving a blank
294
+ // line with muted echo that is indistinguishable from a hang.
295
+ //
296
+ // Echo suppression therefore works by reprinting the prompt and swallowing
297
+ // everything else: readline redraws the line as `prompt + typed input`, so a
298
+ // write containing the prompt is a redraw (reprint the prompt alone, drop the
299
+ // secret) and any other write is a keystroke echo (drop it).
202
300
  const mutable = rl;
203
301
  let muted = false;
204
302
  mutable._writeToOutput = (s) => {
205
- if (!muted)
206
- process.stderr.write(s);
303
+ if (!muted) {
304
+ output.write(s);
305
+ }
306
+ else if (s.includes(promptText)) {
307
+ output.write(promptText);
308
+ }
207
309
  };
208
- process.stderr.write(promptText);
209
- muted = true;
210
- rl.question("", (answer) => {
310
+ // `close` fires without an answer when stdin hits EOF (piped/closed input).
311
+ // Resolving empty lets the caller emit its real "no key entered" error; left
312
+ // unresolved this promise deadlocks the top-level await and Node exits 13
313
+ // with only an "unsettled top-level await" warning.
314
+ //
315
+ // `answered` is load-bearing: `rl.close()` emits `close` SYNCHRONOUSLY, so on
316
+ // the happy path this handler runs before `resolve(answer)` below and would
317
+ // otherwise settle the promise empty — silently discarding the typed key.
318
+ let answered = false;
319
+ rl.on("close", () => {
320
+ if (!answered)
321
+ resolve("");
322
+ });
323
+ rl.question(promptText, (answer) => {
324
+ answered = true;
211
325
  rl.close();
212
- process.stderr.write("\n");
326
+ output.write("\n");
213
327
  resolve(answer.trim());
214
328
  });
329
+ // Only mute AFTER `question` has drawn the prompt at full visibility.
330
+ muted = true;
215
331
  });
216
332
  }
217
333
  /** Echoed single-line prompt on stderr (used for repo confirmation / value). */
218
334
  function promptLineViaReadline(promptText) {
219
335
  return new Promise((resolve) => {
220
336
  const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
337
+ // See promptSecretViaReadline: EOF must resolve rather than deadlock the
338
+ // top-level await, and `answered` guards the synchronous `close` from
339
+ // discarding a real answer.
340
+ let answered = false;
341
+ rl.on("close", () => {
342
+ if (!answered)
343
+ resolve("");
344
+ });
221
345
  rl.question(promptText, (answer) => {
346
+ answered = true;
222
347
  rl.close();
223
348
  resolve(answer.trim());
224
349
  });
@@ -237,6 +362,9 @@ function spawnPrewarmDefault(command, args, env) {
237
362
  return new Promise((resolve) => {
238
363
  const sanitizedEnv = { ...env };
239
364
  delete sanitizedEnv.BAPI_API_KEY;
365
+ // The bootstrap-invite token is likewise never needed by a `--version` probe
366
+ // and must not reach an npm lifecycle script.
367
+ delete sanitizedEnv.BAPI_INVITE;
240
368
  try {
241
369
  const child = spawn(command, args, {
242
370
  shell: false,
@@ -278,12 +406,24 @@ export function createDefaultInstallBridgeDeps() {
278
406
  rename: (a, b) => rename(a, b),
279
407
  chmod: (p, m) => chmod(p, m),
280
408
  unlink: (p) => unlink(p),
409
+ open: async (p, flags, mode) => {
410
+ const handle = await open(p, flags, mode);
411
+ return {
412
+ writeFile: (data) => handle.writeFile(data, { encoding: "utf-8" }),
413
+ sync: () => handle.sync(),
414
+ close: () => handle.close(),
415
+ };
416
+ },
417
+ randomBytes: (size) => cryptoRandomBytes(size),
281
418
  promptSecret: isTTY ? promptSecretViaReadline : undefined,
282
419
  promptLine: isTTY ? promptLineViaReadline : undefined,
283
420
  fetch: (...args) => fetch(...args),
284
421
  spawnPrewarm: spawnPrewarmDefault,
285
422
  runInit,
286
423
  upsertCredential: upsertBapiCredential,
424
+ prepareBootstrapPending: prepareBootstrapPendingCredential,
425
+ repointBootstrapPending: repointBootstrapPendingCredential,
426
+ promoteBootstrapPending: promoteBootstrapPendingCredential,
287
427
  buildShellCommand: buildGenericAgentShellCommand,
288
428
  spawnTerminalTab: getDefaultSpawnTerminalTabForPlatform(process.platform),
289
429
  startTicketsDeps: createDefaultStartTicketsDeps(),
@@ -316,6 +456,45 @@ export async function resolveApiKey(options, deps) {
316
456
  "(no interactive terminal is available to prompt for it).",
317
457
  };
318
458
  }
459
+ /**
460
+ * Resolve the BOOTSTRAP-INVITE token: `--invite <token>` → `BAPI_INVITE` env →
461
+ * interactive no-echo prompt. Fails (secret-free) when none is available and
462
+ * stdin is non-interactive.
463
+ *
464
+ * The order mirrors {@link resolveApiKey}, but the EMPHASIS is inverted, and that
465
+ * inversion is the point: "a copy/paste one-liner" and "the token never touches
466
+ * shell history" are contradictory — both `--invite <token>` and
467
+ * `BAPI_INVITE=… npx …` land in shell history and in `ps`. So the one-liner an
468
+ * operator hands an invitee is SECRET-FREE (`install-bridge --invite`), and the
469
+ * no-echo prompt below is the DEFAULT path rather than a last-resort fallback. The
470
+ * token then travels only in the exchange request body. The inline/env forms remain
471
+ * for scripting and are documented as history-exposing.
472
+ *
473
+ * The token value is never echoed: not in the prompt, not in any error here.
474
+ */
475
+ export async function resolveInviteToken(options, deps) {
476
+ if (typeof options.invite === "string" && options.invite.trim().length > 0) {
477
+ return { ok: true, value: options.invite.trim() };
478
+ }
479
+ const fromEnv = deps.env.BAPI_INVITE;
480
+ if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
481
+ return { ok: true, value: fromEnv.trim() };
482
+ }
483
+ if (deps.isTTY && deps.promptSecret) {
484
+ const entered = (await deps.promptSecret("Bootstrap invite token (input hidden): ")).trim();
485
+ if (entered.length > 0) {
486
+ return { ok: true, value: entered };
487
+ }
488
+ return { ok: false, error: "No bootstrap invite token entered." };
489
+ }
490
+ return {
491
+ ok: false,
492
+ error: "A bootstrap invite token is required. Pass --invite <token> or set the BAPI_INVITE " +
493
+ "environment variable (no interactive terminal is available to prompt for it). Note that " +
494
+ "both forms expose the token to your shell history and process list — prefer running " +
495
+ "'install-bridge --invite' interactively.",
496
+ };
497
+ }
319
498
  /**
320
499
  * Resolve the repo name: `--repo` → `BAPI_REPO_NAME` env → inferred default
321
500
  * (from .bridge/config, else the cwd basename) confirmed interactively. Fails
@@ -518,11 +697,152 @@ export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
518
697
  message: `Connectivity check failed (HTTP ${resp.status}). Verify your repo, API key, and BAPI_BASE_URL.`,
519
698
  };
520
699
  }
700
+ // ---------------------------------------------------------------------------
701
+ // Bootstrap-invite exchange (BAPI-606) — replaces the pre-flight ping in invite mode
702
+ // ---------------------------------------------------------------------------
703
+ /** Bytes of CSPRNG entropy behind `key_secret` (server requires exactly 32). */
704
+ export const BOOTSTRAP_KEY_SECRET_BYTES = 32;
705
+ /**
706
+ * Generate the client-side `key_secret`: exactly 32 CSPRNG bytes, base64url,
707
+ * which yields exactly the 43 unpadded characters the server validates. The
708
+ * length is EXACT, not a floor — the server rejects 42 and 44, because ">= 43"
709
+ * had no upper bound and an over-72-byte input is a bcrypt-truncation hazard.
710
+ *
711
+ * The returned value is a SECRET. It is never logged, never previewed, and never
712
+ * placed in an error.
713
+ */
714
+ export function generateBootstrapKeySecret(randomBytes) {
715
+ return randomBytes(BOOTSTRAP_KEY_SECRET_BYTES).toString("base64url");
716
+ }
521
717
  /**
522
- * Render the --dry-run preview lines. The API key is ALWAYS redacted — the
523
- * spawnCommand and config preview never embed the secret.
718
+ * SHA-256 hex digest of the bootstrap-invite token the key under which the
719
+ * pending record is stored. The TOKEN ITSELF IS NEVER WRITTEN TO DISK; only this
720
+ * one-way fingerprint, so a retry can recognize "same invite, same repo" and reuse
721
+ * the exact pending `key_secret` (the replay proof).
722
+ */
723
+ export function fingerprintBootstrapInvite(token) {
724
+ return createHash("sha256").update(token, "utf-8").digest("hex");
725
+ }
726
+ /** The exchange endpoint: `<base>/setup/bootstrap`. No query string — ever. */
727
+ export function buildBootstrapExchangeUrl(baseUrl) {
728
+ return `${baseUrl.replace(/\/+$/, "")}/setup/bootstrap`;
729
+ }
730
+ /**
731
+ * POST the bootstrap-invite exchange. The token and `key_secret` travel ONLY in
732
+ * the JSON request body (never a query string, never a header, never a log line).
733
+ * Bounded by the same 10s timeout as the connectivity ping.
734
+ */
735
+ export async function exchangeBootstrapInvite(deps, baseUrl, token, repoName, keySecret) {
736
+ const url = buildBootstrapExchangeUrl(baseUrl);
737
+ let resp;
738
+ try {
739
+ resp = await deps.fetch(url, {
740
+ method: "POST",
741
+ headers: { "Content-Type": "application/json" },
742
+ body: JSON.stringify({ token, repo_name: repoName, key_secret: keySecret }),
743
+ signal: AbortSignal.timeout(10_000),
744
+ });
745
+ }
746
+ catch (err) {
747
+ // The message may contain the request (some fetch impls echo it), so it is
748
+ // deliberately NOT interpolated here.
749
+ void err;
750
+ return {
751
+ ok: false,
752
+ kind: "failed",
753
+ message: `Could not reach the Bridge API at ${baseUrl} to redeem the bootstrap invite. Check ` +
754
+ "BAPI_BASE_URL and your network, then re-run — the invite has not been used, and the " +
755
+ "re-run will reuse the same locally-stored secret.",
756
+ };
757
+ }
758
+ if (resp.ok) {
759
+ let repo;
760
+ try {
761
+ const body = (await resp.json());
762
+ repo = body?.repo_name;
763
+ }
764
+ catch {
765
+ return {
766
+ ok: false,
767
+ kind: "failed",
768
+ message: "The Bridge API returned an unreadable response to the bootstrap exchange.",
769
+ };
770
+ }
771
+ // Validate the authoritative name before it reaches a path, a config target, or
772
+ // the credential store.
773
+ const validated = validateRepoName(repo);
774
+ if (!validated.ok) {
775
+ return {
776
+ ok: false,
777
+ kind: "failed",
778
+ message: "The Bridge API returned an unexpected repo name for the bootstrap exchange.",
779
+ };
780
+ }
781
+ return { ok: true, repoName: validated.value };
782
+ }
783
+ if (resp.status === 409) {
784
+ return {
785
+ ok: false,
786
+ kind: "repo-name-taken",
787
+ message: `The repo name '${repoName}' is already taken (HTTP 409). Repo names are globally unique.`,
788
+ };
789
+ }
790
+ if (resp.status === 401) {
791
+ return { ok: false, kind: "invalid-invite", message: `The Bridge API rejected the bootstrap invite (HTTP ${resp.status}).` };
792
+ }
793
+ return {
794
+ ok: false,
795
+ kind: "failed",
796
+ message: `The bootstrap exchange failed (HTTP ${resp.status}). Verify BAPI_BASE_URL and try again.`,
797
+ };
798
+ }
799
+ /**
800
+ * The 401 message for a run that had to GENERATE a fresh secret — i.e. no local
801
+ * pending record existed. Besides a simply-wrong token, this is exactly what a lost
802
+ * `~/.config/bridge` looks like after a successful redemption: the retry sends a
803
+ * NEW secret, so the server's replay branch cannot match and correctly answers 401
804
+ * (letting a different secret replace the key would turn a spent token back into a
805
+ * bearer credential).
806
+ *
807
+ * The recovery is therefore NOT "re-run" and NOT "re-mint" — re-running sends
808
+ * another new secret and gets 401 again, and a fresh invite cannot reuse the repo
809
+ * name, which is globally unique and now taken by the project already created. Do
810
+ * not suggest either.
811
+ */
812
+ export const BOOTSTRAP_INVITE_LOST_SECRET_MESSAGE = [
813
+ "The Bridge API rejected the bootstrap invite (HTTP 401).",
814
+ "",
815
+ "Either the invite is invalid, expired, or revoked — or it was ALREADY redeemed from this",
816
+ "machine and the local secret has since been lost (e.g. ~/.config/bridge was deleted).",
817
+ "",
818
+ "If it was already redeemed, you cannot recover it yourself:",
819
+ " • Re-running will NOT work: each run without the original local secret sends a new one,",
820
+ " which cannot match what the server stored, so it will keep returning 401.",
821
+ " • A new bootstrap invite will NOT work either: your repo name is globally unique and is",
822
+ " now taken by the project you already created, so it cannot be redeemed again.",
823
+ "",
824
+ "Ask your Bridge API operator to recover it for you: they revoke the orphaned key",
825
+ "(DELETE /setup/keys/{id}) and issue a replacement key for the EXISTING project",
826
+ "(POST /setup/keys), then send you that key. Run install-bridge with --api-key <that key>.",
827
+ ].join("\n");
828
+ /**
829
+ * The 401 message for a run that REUSED an existing pending secret — the replay
830
+ * proof was sent and still rejected, so the invite itself is not usable.
831
+ */
832
+ export const BOOTSTRAP_INVITE_REJECTED_MESSAGE = "The Bridge API rejected the bootstrap invite (HTTP 401). The invite is invalid, expired, or " +
833
+ "revoked — ask your Bridge API operator for a new one. (Your locally-stored secret was sent " +
834
+ "unchanged, so this is not a lost-secret problem.)";
835
+ /**
836
+ * Render the --dry-run preview lines. Every secret is ALWAYS redacted — the
837
+ * spawnCommand, config preview, and (in bootstrap-invite mode) the exchange body
838
+ * never embed the API key, the invite token, or the generated `key_secret`.
839
+ *
840
+ * The plan itself holds no secret and no invite fingerprint, so this function
841
+ * CANNOT leak one: in bootstrap-invite mode it is called before any secret exists.
524
842
  */
525
843
  export function buildDryRunPreview(plan) {
844
+ if (plan.bootstrapInvite)
845
+ return buildBootstrapDryRunPreview(plan);
526
846
  return [
527
847
  "install-bridge --dry-run (no writes, no network, no spawns)",
528
848
  `Repo name: ${plan.repoName}`,
@@ -543,6 +863,40 @@ export function buildDryRunPreview(plan) {
543
863
  `Step 5 — spawn agent session: ${plan.spawnCommand}`,
544
864
  ];
545
865
  }
866
+ /**
867
+ * Bootstrap-invite dry-run preview. A --dry-run must not consume the invite OR
868
+ * LEAVE STATE BEHIND: skipping the HTTP call is not enough — a preview that wrote
869
+ * a pending `key_secret` would have left credential material on disk for a
870
+ * redemption that never happened. So the caller returns here BEFORE the CSPRNG
871
+ * runs and before anything is written.
872
+ */
873
+ function buildBootstrapDryRunPreview(plan) {
874
+ const pendingTarget = `bootstrap-pending:${plan.repoName}`;
875
+ return [
876
+ "install-bridge --invite --dry-run (no writes, no network, no spawns, no secret generated)",
877
+ `Repo name: ${plan.repoName} (created by the exchange; globally unique)`,
878
+ `Base URL: ${plan.baseUrl}`,
879
+ `Docs dir: ${plan.docsDir}`,
880
+ `Agent: ${plan.agentName}`,
881
+ "",
882
+ "Step 1 — scaffold (runInit): commands, agents, pipelines, .bridge/config, secret-free MCP placeholders.",
883
+ `Step 2a — generate key_secret (32 CSPRNG bytes) and fsync it to ${pendingTarget} at ${plan.credentialStorePath}`,
884
+ " BEFORE the exchange. If that write fails the run ABORTS and no invite is spent.",
885
+ `Step 2b — redeem the bootstrap invite (replaces the pre-flight ping — there is no key yet):`,
886
+ ` POST ${plan.exchangeUrl}`,
887
+ ` body: {"token": "${REDACTED_API_KEY}", "repo_name": "${plan.repoName}", "key_secret": "${REDACTED_API_KEY}"}`,
888
+ `Step 2c — connectivity ping with the newly-minted key (before any durable key write): GET ${plan.pingUrl} (X-API-Key: ${REDACTED_API_KEY})`,
889
+ "Step 3 — write per-host MCP config (read-merge-write, launcher version-pinned):",
890
+ ...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}`),
891
+ ...(plan.manualEditors.length > 0
892
+ ? [` ${plan.manualEditors.join(" + ")}: detected (global config) — manual setup instructions would be printed.`]
893
+ : []),
894
+ `Step 3b — pre-warm the version-pinned launcher bucket (fail-open, env sanitized — BAPI_API_KEY / BAPI_INVITE removed): ${plan.prewarmCommand}`,
895
+ MCP_TIMEOUT_GUIDANCE,
896
+ `Step 4 — promote ${pendingTarget} → ${plan.credentialTarget} at ${plan.credentialStorePath} (only after the exchange succeeds)`,
897
+ `Step 5 — spawn agent session: ${plan.spawnCommand}`,
898
+ ];
899
+ }
546
900
  /**
547
901
  * Detect global-config editors that install-bridge cannot safely write into
548
902
  * (their configs live outside the project). Windsurf is detected from the
@@ -621,21 +975,50 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
621
975
  return 1;
622
976
  }
623
977
  const options = parsed.options;
978
+ // Bootstrap-invite mode: `--invite` (with or without a value), or a non-empty
979
+ // BAPI_INVITE. Everything below branches on this ONE flag; an invocation without
980
+ // it is the unchanged consume-a-key flow.
981
+ const bootstrapInviteMode = options.inviteMode === true || (deps.env.BAPI_INVITE ?? "").trim().length > 0;
624
982
  // ---- Resolve inputs (may prompt when interactive) ----
625
- // Resolve the API key first so a fully-empty non-interactive invocation fails
626
- // with the (more security-relevant) missing-key message before the repo one.
627
- const keyResult = await resolveApiKey(options, deps);
628
- if (!keyResult.ok) {
629
- errorLog(`Error: ${keyResult.error}`);
630
- return 1;
983
+ // Resolve the credential input first so a fully-empty non-interactive invocation
984
+ // fails with the (more security-relevant) missing-secret message before the repo
985
+ // one. In bootstrap-invite mode there is no API key to resolve — the exchange
986
+ // MINTS it — so resolveApiKey is skipped entirely.
987
+ let apiKey = "";
988
+ let inviteToken = "";
989
+ if (bootstrapInviteMode) {
990
+ const inviteResult = await resolveInviteToken(options, deps);
991
+ if (!inviteResult.ok) {
992
+ errorLog(`Error: ${inviteResult.error}`);
993
+ return 1;
994
+ }
995
+ inviteToken = inviteResult.value;
996
+ }
997
+ else {
998
+ const keyResult = await resolveApiKey(options, deps);
999
+ if (!keyResult.ok) {
1000
+ errorLog(`Error: ${keyResult.error}`);
1001
+ return 1;
1002
+ }
1003
+ apiKey = keyResult.value;
631
1004
  }
632
- const apiKey = keyResult.value;
633
1005
  const repoResult = await resolveRepoName(options, deps);
634
1006
  if (!repoResult.ok) {
635
1007
  errorLog(`Error: ${repoResult.error}`);
636
1008
  return 1;
637
1009
  }
638
- const repoName = repoResult.value;
1010
+ let repoName = repoResult.value;
1011
+ // Invite mode creates the project, so the name must be valid BEFORE any pending
1012
+ // state or exchange. (Normal mode's name is already registered server-side, so
1013
+ // its behavior is deliberately left untouched.)
1014
+ if (bootstrapInviteMode) {
1015
+ const validated = validateRepoName(repoName);
1016
+ if (!validated.ok) {
1017
+ errorLog(`Error: invalid repo name — ${validated.error}.`);
1018
+ return 1;
1019
+ }
1020
+ repoName = validated.value;
1021
+ }
639
1022
  const baseUrl = deps.env.BAPI_BASE_URL ?? DEFAULT_BAPI_BASE_URL;
640
1023
  const docsDir = deps.env.BAPI_DOCS_DIR ?? DEFAULT_BAPI_DOCS_DIR;
641
1024
  const agent = resolveAgentSpec(options.agentName) ?? resolveAgentSpec(DEFAULT_AGENT_NAME);
@@ -659,16 +1042,34 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
659
1042
  pingUrl: buildPingUrl(baseUrl, repoName),
660
1043
  prewarmCommand: buildPrewarmCommandPreview(),
661
1044
  spawnCommand,
1045
+ ...(bootstrapInviteMode
1046
+ ? { bootstrapInvite: true, exchangeUrl: buildBootstrapExchangeUrl(baseUrl) }
1047
+ : {}),
662
1048
  };
663
1049
  // ---- --dry-run: preview every step, strictly no side effects ----
1050
+ // Positioned BEFORE the CSPRNG, the pending write, the exchange, the scaffold,
1051
+ // the config writes, the pre-warm, and the spawn: in bootstrap-invite mode a
1052
+ // preview must neither consume the invite NOR leave a pending secret on disk.
664
1053
  if (options.dryRun) {
665
1054
  for (const line of buildDryRunPreview(plan))
666
1055
  log(line);
667
1056
  return 0;
668
1057
  }
669
- const entry = buildInstallBridgeServerEntry(deps.cwd, repoName, apiKey, baseUrl, docsDir);
1058
+ const credentialWriteDeps = {
1059
+ env: deps.env,
1060
+ homedir: deps.homedir,
1061
+ platform: deps.platform,
1062
+ readFile: deps.readFile,
1063
+ mkdir: deps.mkdir,
1064
+ writeFile: (p, d, o) => deps.writeFile(p, d, o),
1065
+ rename: deps.rename,
1066
+ chmod: deps.chmod,
1067
+ unlink: deps.unlink,
1068
+ open: deps.open,
1069
+ };
670
1070
  // ---- Overwrite consent: a real existing key requires --force or a prompt ----
671
1071
  const hasRealKey = await detectExistingRealKey(deps, targets);
1072
+ let overwriteConsent = options.force;
672
1073
  if (hasRealKey && !options.force) {
673
1074
  if (deps.isTTY && deps.promptLine) {
674
1075
  const answer = (await deps.promptLine("A host config already contains a BAPI_API_KEY. Overwrite it? [y/N]: ")).trim().toLowerCase();
@@ -676,6 +1077,7 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
676
1077
  errorLog("Aborted: existing API key left unchanged (re-run with --force to overwrite).");
677
1078
  return 1;
678
1079
  }
1080
+ overwriteConsent = true;
679
1081
  }
680
1082
  else {
681
1083
  errorLog("Error: a host config already contains a BAPI_API_KEY. Re-run with --force to overwrite it " +
@@ -686,18 +1088,165 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
686
1088
  // ---- Step 1 — scaffold (secret-free placeholders only) ----
687
1089
  log("Step 1/5 — scaffolding project (commands, agents, pipelines, config placeholders)…");
688
1090
  await deps.runInit(deps.cwd);
689
- // ---- Step 2 — verify connectivity BEFORE writing the key anywhere durable ----
1091
+ // ---- Step 2 — mint (invite mode) or verify (normal mode) the credential ----
1092
+ let inviteFingerprint = "";
1093
+ if (bootstrapInviteMode) {
1094
+ // ===================================================================
1095
+ // LOAD-BEARING ORDERING — NOT DEFENSIVE POLISH. DO NOT "TIDY" THIS.
1096
+ //
1097
+ // The key_secret is generated HERE, fsynced to a pending record HERE, and
1098
+ // only THEN sent to the exchange. That order is the protocol:
1099
+ //
1100
+ // * The locally-stored key_secret is the ONLY proof that can replay a
1101
+ // redemption. The server's replay branch matches on (repo_name, bcrypt of
1102
+ // key_secret); it never returns a recoverable credential.
1103
+ // * So a successful exchange followed by a failed local write = an
1104
+ // unrecoverable admin key AND a permanently spent invite. The user cannot
1105
+ // re-run (a new secret gets a correct 401) and cannot re-mint (the repo
1106
+ // name is now globally taken by the project they just created); only an
1107
+ // operator can dig them out.
1108
+ //
1109
+ // Therefore the write is FAIL-CLOSED: if it does not land (including its
1110
+ // fsync), we abort with a non-zero exit and NEVER call fetch. Nothing is
1111
+ // consumed. Moving this persist after the exchange — the "natural" order —
1112
+ // reintroduces exactly the bug above. (Contrast Step 4 in normal mode, which
1113
+ // is deliberately fail-open: there, the key already exists server-side and a
1114
+ // persist failure only degrades start-tickets routing.)
1115
+ // ===================================================================
1116
+ inviteFingerprint = fingerprintBootstrapInvite(inviteToken);
1117
+ log("Step 2/5 — redeeming the bootstrap invite…");
1118
+ let prepared = await deps.prepareBootstrapPending({
1119
+ repoName,
1120
+ inviteFingerprint,
1121
+ generateKeySecret: () => generateBootstrapKeySecret(deps.randomBytes),
1122
+ allowOverwriteExistingCredential: overwriteConsent,
1123
+ }, credentialWriteDeps);
1124
+ // An existing bapi:<repo> credential is never clobbered without consent. The
1125
+ // gate mirrors the host-config gate above: --force, else an interactive prompt,
1126
+ // else a hard non-interactive failure — all BEFORE the exchange.
1127
+ if (!prepared.ok && prepared.kind === "credential-conflict") {
1128
+ if (deps.isTTY && deps.promptLine) {
1129
+ const answer = (await deps.promptLine(`The credential store already holds a key for ${prepared.target}. Overwrite it? [y/N]: `)).trim().toLowerCase();
1130
+ if (answer !== "y" && answer !== "yes") {
1131
+ errorLog("Aborted: existing credential left unchanged (re-run with --force to overwrite).");
1132
+ return 1;
1133
+ }
1134
+ overwriteConsent = true;
1135
+ prepared = await deps.prepareBootstrapPending({
1136
+ repoName,
1137
+ inviteFingerprint,
1138
+ generateKeySecret: () => generateBootstrapKeySecret(deps.randomBytes),
1139
+ allowOverwriteExistingCredential: true,
1140
+ }, credentialWriteDeps);
1141
+ }
1142
+ else {
1143
+ errorLog(`Error: ${prepared.error} Re-run with --force to overwrite it (refusing to overwrite a ` +
1144
+ "credential non-interactively without consent).");
1145
+ return 1;
1146
+ }
1147
+ }
1148
+ // A pending record left by a DIFFERENT invite is that redemption's only replay
1149
+ // proof. Unlike the credential conflict above there is no consent path — not
1150
+ // even --force — so re-running is not the fix and must not be advised.
1151
+ if (!prepared.ok && prepared.kind === "pending-conflict") {
1152
+ errorLog(`Error: ${prepared.error} This invite has NOT been used, and re-running will not clear ` +
1153
+ "the conflict.");
1154
+ return 1;
1155
+ }
1156
+ if (!prepared.ok) {
1157
+ // Fail closed: the exchange has NOT been called, so the invite is unspent.
1158
+ errorLog(`Error: could not durably store the bootstrap credential (${prepared.kind}). ${prepared.error} ` +
1159
+ "The bootstrap invite has NOT been used — fix the problem and re-run.");
1160
+ return 1;
1161
+ }
1162
+ const keySecret = prepared.keySecret;
1163
+ const reusedPendingSecret = prepared.reused;
1164
+ log(` saved the pending credential for ${prepared.target} (fsynced before the exchange)`);
1165
+ // The exchange REPLACES the pre-flight ping: in invite mode there is no key to
1166
+ // ping with — this call is what mints it.
1167
+ let exchange = await exchangeBootstrapInvite(deps, baseUrl, inviteToken, repoName, keySecret);
1168
+ // 409 repo_name_taken: the server ROLLED BACK, so the invite is NOT consumed and
1169
+ // the SAME token can be retried under a new name — with the SAME pending secret,
1170
+ // re-pointed to that name.
1171
+ while (!exchange.ok && exchange.kind === "repo-name-taken") {
1172
+ if (!deps.isTTY || !deps.promptLine) {
1173
+ errorLog(`Error: ${exchange.message} Re-run with a different --repo (the bootstrap invite has NOT ` +
1174
+ "been used).");
1175
+ return 1;
1176
+ }
1177
+ errorLog(exchange.message);
1178
+ const answer = (await deps.promptLine("Choose a different repo name: ")).trim();
1179
+ const validated = validateRepoName(answer);
1180
+ if (!validated.ok) {
1181
+ errorLog(`Error: invalid repo name — ${validated.error}.`);
1182
+ return 1;
1183
+ }
1184
+ const nextRepo = validated.value;
1185
+ const repointed = await deps.repointBootstrapPending({
1186
+ fromRepoName: repoName,
1187
+ toRepoName: nextRepo,
1188
+ inviteFingerprint,
1189
+ allowOverwriteExistingCredential: overwriteConsent,
1190
+ }, credentialWriteDeps);
1191
+ if (!repointed.ok) {
1192
+ errorLog(`Error: could not re-point the pending bootstrap credential to '${nextRepo}' ` +
1193
+ `(${repointed.kind}). ${repointed.error} The bootstrap invite has NOT been used.`);
1194
+ return 1;
1195
+ }
1196
+ repoName = nextRepo;
1197
+ // Same token, same key_secret — never regenerated inside the rename loop.
1198
+ exchange = await exchangeBootstrapInvite(deps, baseUrl, inviteToken, repoName, keySecret);
1199
+ }
1200
+ if (!exchange.ok) {
1201
+ if (exchange.kind === "invalid-invite") {
1202
+ errorLog(reusedPendingSecret
1203
+ ? BOOTSTRAP_INVITE_REJECTED_MESSAGE
1204
+ : BOOTSTRAP_INVITE_LOST_SECRET_MESSAGE);
1205
+ }
1206
+ else {
1207
+ errorLog(`Error: ${exchange.message}`);
1208
+ }
1209
+ // The pending record is deliberately LEFT INTACT: it is the replay proof for a
1210
+ // retry (network failure / transient error), and destroying it would be the
1211
+ // unrecoverable case above.
1212
+ return 1;
1213
+ }
1214
+ // The server-returned name is authoritative from here on: config, ping,
1215
+ // credential target, and the spawned session all key off it.
1216
+ if (exchange.repoName !== repoName) {
1217
+ const repointed = await deps.repointBootstrapPending({
1218
+ fromRepoName: repoName,
1219
+ toRepoName: exchange.repoName,
1220
+ inviteFingerprint,
1221
+ allowOverwriteExistingCredential: overwriteConsent,
1222
+ }, credentialWriteDeps);
1223
+ if (!repointed.ok) {
1224
+ errorLog(`Error: the project was created as '${exchange.repoName}' but the pending credential could ` +
1225
+ `not be re-pointed to it (${repointed.kind}). ${repointed.error}`);
1226
+ return 1;
1227
+ }
1228
+ repoName = exchange.repoName;
1229
+ }
1230
+ log(` bootstrap invite redeemed — project '${repoName}' is ready`);
1231
+ // The minted key. From here the flow rejoins the existing path unchanged.
1232
+ apiKey = keySecret;
1233
+ }
1234
+ // ---- Step 2 (cont.) — verify connectivity BEFORE writing the key anywhere durable ----
690
1235
  // R5: ping before persisting anything durably. Pinging before writeHostConfigs
691
1236
  // (and the credential store) means a bad key on a first-time install halts
692
1237
  // WITHOUT leaving an invalid key in the config — which would otherwise trip the
693
- // overwrite-consent gate on every retry (a trapped state).
694
- log("Step 2/5 verifying connectivity…");
1238
+ // overwrite-consent gate on every retry (a trapped state). In invite mode this
1239
+ // runs AFTER the exchange, with the just-minted key; the pending record is the one
1240
+ // deliberate write that precedes it, for the protocol reason above.
1241
+ if (!bootstrapInviteMode)
1242
+ log("Step 2/5 — verifying connectivity…");
695
1243
  const ping = await verifyConnectivity(deps, baseUrl, repoName, apiKey);
696
1244
  if (!ping.ok) {
697
1245
  errorLog(`Error: ${ping.message}`);
698
1246
  return 1;
699
1247
  }
700
1248
  log(" connectivity OK");
1249
+ const entry = buildInstallBridgeServerEntry(deps.cwd, repoName, apiKey, baseUrl, docsDir);
701
1250
  // ---- Step 3 — write per-host MCP config with real values ----
702
1251
  log("Step 3/5 — writing per-host MCP config…");
703
1252
  const written = await writeHostConfigs(deps, targets, entry);
@@ -726,40 +1275,49 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
726
1275
  errorLog(`Warning: could not pre-warm the version-pinned launcher bucket${prewarm.warning ? ` (${prewarm.warning})` : ""}. ` +
727
1276
  `The first MCP launch may pay a one-time cold install and could be slow. ${MCP_TIMEOUT_GUIDANCE}`);
728
1277
  }
729
- // ---- Step 4 — persist routing credential (non-blocking / fail-open) ----
730
- log("Step 4/5 — persisting routing credential…");
731
- try {
732
- const writeDeps = {
733
- env: deps.env,
734
- homedir: deps.homedir,
735
- platform: deps.platform,
736
- readFile: deps.readFile,
737
- mkdir: deps.mkdir,
738
- writeFile: (p, d, o) => deps.writeFile(p, d, o),
739
- rename: deps.rename,
740
- chmod: deps.chmod,
741
- unlink: deps.unlink,
742
- };
743
- const result = await deps.upsertCredential(repoName, apiKey, writeDeps);
744
- if (result.ok) {
745
- log(` stored routing credential for ${result.target} at ${result.path}`);
1278
+ // ---- Step 4 — persist the credential ----
1279
+ if (bootstrapInviteMode) {
1280
+ // FAIL-CLOSED (unlike normal mode below). The pending secret IS the minted admin
1281
+ // key if it never lands under bapi:<repo>, the user has a live key they cannot
1282
+ // resolve. Promotion removes the pending record and writes the credential in one
1283
+ // durable replacement; on failure the pending record survives so a re-run replays
1284
+ // the same redemption. The agent session is NOT spawned until this succeeds.
1285
+ log("Step 4/5 — promoting the bootstrap credential…");
1286
+ const promoted = await deps.promoteBootstrapPending({ repoName, inviteFingerprint, allowOverwriteExistingCredential: overwriteConsent }, credentialWriteDeps);
1287
+ if (!promoted.ok) {
1288
+ errorLog(`Error: the project and API key were created, but the credential could not be stored ` +
1289
+ `(${promoted.kind}). ${promoted.error} Your key is still saved locally as a pending ` +
1290
+ "record — re-run install-bridge with the same bootstrap invite to finish (the redemption " +
1291
+ "will replay and return the same key).");
1292
+ return 1;
746
1293
  }
747
- else {
748
- log(` warning: could not persist the routing credential (${result.kind}). ` +
749
- `start-tickets model routing may not resolve the key for ${plan.credentialTarget} ` +
750
- "and will fail open to the premium/Opus tier (the most expensive) — " +
751
- "set BAPI_API_KEY in the shell or re-run install-bridge, then verify with " +
1294
+ log(` stored routing credential for ${promoted.target} at ${promoted.path}`);
1295
+ }
1296
+ else {
1297
+ // ---- persist routing credential (non-blocking / fail-open) ----
1298
+ log("Step 4/5 persisting routing credential…");
1299
+ try {
1300
+ const result = await deps.upsertCredential(repoName, apiKey, credentialWriteDeps);
1301
+ if (result.ok) {
1302
+ log(` stored routing credential for ${result.target} at ${result.path}`);
1303
+ }
1304
+ else {
1305
+ log(` warning: could not persist the routing credential (${result.kind}). ` +
1306
+ `start-tickets model routing may not resolve the key for bapi:${repoName} ` +
1307
+ "and will fail open to the premium/Opus tier (the most expensive) — " +
1308
+ "set BAPI_API_KEY in the shell or re-run install-bridge, then verify with " +
1309
+ "'npx -y @bridge_gpt/mcp-server doctor'.");
1310
+ }
1311
+ }
1312
+ catch {
1313
+ // Fail-open: persistence is best-effort (mirrors Stage 6). The secret is never
1314
+ // included in the warning.
1315
+ log(" warning: could not persist the routing credential (unexpected error). " +
1316
+ "start-tickets model routing may need BAPI_API_KEY in the shell and will fail open " +
1317
+ "to the premium/Opus tier (the most expensive) until fixed — verify with " +
752
1318
  "'npx -y @bridge_gpt/mcp-server doctor'.");
753
1319
  }
754
1320
  }
755
- catch {
756
- // Fail-open: persistence is best-effort (mirrors Stage 6). The secret is never
757
- // included in the warning.
758
- log(" warning: could not persist the routing credential (unexpected error). " +
759
- "start-tickets model routing may need BAPI_API_KEY in the shell and will fail open " +
760
- "to the premium/Opus tier (the most expensive) until fixed — verify with " +
761
- "'npx -y @bridge_gpt/mcp-server doctor'.");
762
- }
763
1321
  // ---- Step 5 — spawn a fresh agent session for the agentic remainder ----
764
1322
  log(`Step 5/5 — opening a ${agent.name} session for /install-bridge + /learn-repository…`);
765
1323
  const terminal = detectTerminal(undefined, deps.env);