@bridge_gpt/mcp-server 0.2.21 → 0.2.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONDUCTOR.md +86 -27
- package/README.md +80 -6
- package/build/base-ref.js +151 -0
- package/build/commands.generated.js +5 -3
- package/build/conductor/bridge-api-client.js +44 -3
- package/build/conductor/doctor.js +33 -22
- package/build/conductor/epic-runtime.js +101 -5
- package/build/conductor/pr-ci-producer.js +21 -2
- package/build/conductor/pr-discovery.js +12 -2
- package/build/conductor-bin.js +50 -20
- package/build/credential-store.js +564 -64
- package/build/executor/base-branch.js +50 -0
- package/build/executor/env.js +12 -1
- package/build/executor/job-errors.js +1 -0
- package/build/executor/job-runner.js +38 -7
- package/build/executor/test-clock.js +6 -1
- package/build/executor/worker-finalization.js +88 -1
- package/build/executor/worktree.js +21 -1
- package/build/index.js +1979 -423
- package/build/install-bridge.js +627 -69
- package/build/pipelines.generated.js +2 -2
- package/build/pr-base-contract.js +36 -0
- package/build/readme.generated.js +1 -1
- package/build/setup-epic.js +483 -0
- package/build/start-tickets.js +164 -75
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +62 -10
- package/package.json +3 -3
- package/public/js/main.min.js +9 -9
- package/public/js/main.min.js.map +1 -1
package/build/install-bridge.js
CHANGED
|
@@ -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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
|
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
|
|
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
|
|
120
|
-
* and `-h`/`--help`. Unknown flags and positional
|
|
121
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
193
|
-
|
|
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
|
|
197
|
-
output
|
|
286
|
+
input,
|
|
287
|
+
output,
|
|
198
288
|
terminal: true,
|
|
199
289
|
});
|
|
200
|
-
//
|
|
201
|
-
//
|
|
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
|
-
|
|
303
|
+
if (!muted) {
|
|
304
|
+
output.write(s);
|
|
305
|
+
}
|
|
306
|
+
else if (s.includes(promptText)) {
|
|
307
|
+
output.write(promptText);
|
|
308
|
+
}
|
|
207
309
|
};
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
523
|
-
*
|
|
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
|
|
626
|
-
// with the (more security-relevant) missing-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
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
|
|
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 —
|
|
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
|
-
|
|
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
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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);
|