@bridge_gpt/mcp-server 0.2.60 → 0.2.62
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/README.md +16 -8
- package/build/commands.generated.js +3 -3
- package/build/conductor/bridge-api-client.js +353 -2
- package/build/conductor/merge-execution-contract.js +30 -5
- package/build/conductor/merge-execution-eligibility.js +10 -9
- package/build/conductor/readiness-sections.js +99 -16
- package/build/conductor/readiness.js +108 -15
- package/build/credential-store.js +156 -0
- package/build/doctor.js +94 -6
- package/build/drive-epic.js +124 -12
- package/build/epic-implementer-bin.js +13 -13
- package/build/epic-integration-pr.js +61 -15
- package/build/epic-launch-profile.js +455 -0
- package/build/executor/cli.js +9 -0
- package/build/executor/credentials.js +17 -1
- package/build/executor/http-client.js +45 -1
- package/build/executor/integration-job.js +759 -0
- package/build/executor/job-runner.js +92 -9
- package/build/executor/observation.js +114 -7
- package/build/executor/runner.js +101 -1
- package/build/executor/types.js +43 -0
- package/build/index.js +338 -114
- package/build/install-bridge-conductor.js +398 -6
- package/build/install-bridge.js +92 -16
- package/build/merge-workflow-config.js +54 -0
- package/build/merge-workflow-drift.js +35 -4
- package/build/merge-workflow.js +386 -2
- package/build/pipelines.generated.js +1 -1
- package/build/plane/supervisor.js +6 -0
- package/build/readme.generated.js +1 -1
- package/build/setup-epic.js +103 -32
- package/build/version.generated.js +3 -3
- package/package.json +1 -1
|
@@ -110,6 +110,12 @@ import { bootstrapConductorSupervisorDefaults, ConductorBridgeApiError, fetchCon
|
|
|
110
110
|
import { CONDUCTOR_OPERATOR_RUNBOOK_POINTER, EXECUTOR_PROVISIONING_GUIDANCE, conductorInstallDoctorExitCode, conductorInstallDoctorHasFatal, formatConductorInstallDoctorReport, } from "./conductor/install-doctor.js";
|
|
111
111
|
import { describeNativeLedgerAvailability, } from "./conductor/doctor.js";
|
|
112
112
|
import { CONDUCTOR_BIN_UNRESOLVED_ERROR, MANAGED_HOOK_NAMES, } from "./conductor/git-hooks.js";
|
|
113
|
+
import os from "node:os";
|
|
114
|
+
import path from "node:path";
|
|
115
|
+
import { readFile as readFileAsync, stat as statAsync } from "node:fs/promises";
|
|
116
|
+
import { bindEpicLaunchCredential, readStoredBinding, } from "./credential-store.js";
|
|
117
|
+
import { EPIC_LAUNCH_EXECUTION_MODES, EPIC_LAUNCH_PROFILE_NAME_PATTERN, EPIC_LAUNCH_PROFILE_VERSION, createNodeEpicLaunchStoreDeps, readEpicLaunchProfile, renderEpicLaunchCommand, resolveEpicLaunchTarget, writeEpicLaunchProfile, } from "./epic-launch-profile.js";
|
|
118
|
+
import { DEFAULT_BAPI_BASE_URL } from "./executor/credentials.js";
|
|
113
119
|
import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
|
|
114
120
|
import { CLAUDE_REVIEW_WORKFLOW_RELPATH, renderClaudeReviewWorkflow, } from "./claude-review-workflow.js";
|
|
115
121
|
import { formatExecutorInstallPreflight, } from "./executor/install-preflight.js";
|
|
@@ -161,6 +167,18 @@ export function getInstallBridgeConductorUsage() {
|
|
|
161
167
|
" --secret-name <NAME> claude-review workflow: OAuth token secret NAME",
|
|
162
168
|
" --branches a,b claude-review workflow: PR branch triggers",
|
|
163
169
|
" --skip-workflow Never offer the claude-review workflow",
|
|
170
|
+
" --launch-profile <name> (BAPI-1260) Opt in to the reusable epic launch: after",
|
|
171
|
+
" every mandatory prerequisite is verified, persist a",
|
|
172
|
+
" host-local profile and print the ONE drive-epic",
|
|
173
|
+
" command that selects it (runs in a fresh shell).",
|
|
174
|
+
" --base-url <url> Explicit Bridge API target for --launch-profile.",
|
|
175
|
+
" Required there (or BAPI_BASE_URL): nothing defaults",
|
|
176
|
+
" to production; select it deliberately.",
|
|
177
|
+
" --execution-mode <mode> local | durable (default: durable with",
|
|
178
|
+
" --executor-id, otherwise local).",
|
|
179
|
+
" --policy-file <path> Run policy the profile references; an unattended",
|
|
180
|
+
" run needs notify.webhook_url reachable from the",
|
|
181
|
+
" API server (notify.local_sink is not enough).",
|
|
164
182
|
" --dry-run Diagnose and preview only. Performs NO writes:",
|
|
165
183
|
" no POST, no file write, no host MCP config write,",
|
|
166
184
|
" no Git hook write, no install-state or capability",
|
|
@@ -255,6 +273,10 @@ export function parseInstallBridgeConductorArgs(argv) {
|
|
|
255
273
|
let secretName;
|
|
256
274
|
let branches;
|
|
257
275
|
const requiredChecks = [];
|
|
276
|
+
let launchProfile;
|
|
277
|
+
let baseUrl;
|
|
278
|
+
let executionMode;
|
|
279
|
+
let policyFile;
|
|
258
280
|
const takeValue = (arg, inline, next) => {
|
|
259
281
|
if (inline !== undefined) {
|
|
260
282
|
if (inline.trim().length === 0)
|
|
@@ -297,7 +319,11 @@ export function parseInstallBridgeConductorArgs(argv) {
|
|
|
297
319
|
case "--epic-run-id":
|
|
298
320
|
case "--allowed-bot":
|
|
299
321
|
case "--secret-name":
|
|
300
|
-
case "--branches":
|
|
322
|
+
case "--branches":
|
|
323
|
+
case "--launch-profile":
|
|
324
|
+
case "--base-url":
|
|
325
|
+
case "--execution-mode":
|
|
326
|
+
case "--policy-file": {
|
|
301
327
|
const taken = takeValue(flag, inline, argv[i + 1]);
|
|
302
328
|
if (!taken.ok)
|
|
303
329
|
return { status: "error", message: taken.error };
|
|
@@ -322,6 +348,28 @@ export function parseInstallBridgeConductorArgs(argv) {
|
|
|
322
348
|
allowedBot = value;
|
|
323
349
|
else if (flag === "--secret-name")
|
|
324
350
|
secretName = value;
|
|
351
|
+
else if (flag === "--launch-profile") {
|
|
352
|
+
if (!EPIC_LAUNCH_PROFILE_NAME_PATTERN.test(value)) {
|
|
353
|
+
return {
|
|
354
|
+
status: "error",
|
|
355
|
+
message: "--launch-profile must match [a-z0-9][a-z0-9._-]{0,62}.",
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
launchProfile = value;
|
|
359
|
+
}
|
|
360
|
+
else if (flag === "--base-url")
|
|
361
|
+
baseUrl = value;
|
|
362
|
+
else if (flag === "--policy-file")
|
|
363
|
+
policyFile = value;
|
|
364
|
+
else if (flag === "--execution-mode") {
|
|
365
|
+
if (!EPIC_LAUNCH_EXECUTION_MODES.includes(value)) {
|
|
366
|
+
return {
|
|
367
|
+
status: "error",
|
|
368
|
+
message: `Invalid --execution-mode '${value}'. Expected one of: ${EPIC_LAUNCH_EXECUTION_MODES.join(", ")}`,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
executionMode = value;
|
|
372
|
+
}
|
|
325
373
|
else if (flag === "--branches") {
|
|
326
374
|
const list = splitList(value);
|
|
327
375
|
if (list.length === 0)
|
|
@@ -404,6 +452,20 @@ export function parseInstallBridgeConductorArgs(argv) {
|
|
|
404
452
|
"--executor-id.",
|
|
405
453
|
};
|
|
406
454
|
}
|
|
455
|
+
// BAPI-1260 — the launch-profile inputs only mean something together.
|
|
456
|
+
if (launchProfile === undefined && (baseUrl !== undefined || executionMode !== undefined)) {
|
|
457
|
+
return {
|
|
458
|
+
status: "error",
|
|
459
|
+
message: "--base-url and --execution-mode configure an epic launch profile, so they require --launch-profile.",
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
if (executionMode === "durable" && executorId === undefined) {
|
|
463
|
+
return {
|
|
464
|
+
status: "error",
|
|
465
|
+
message: "--execution-mode durable requires --executor-id: a durable launch is served by a provisioned " +
|
|
466
|
+
"executor service, and this command proves its server-observed handshake before approving it.",
|
|
467
|
+
};
|
|
468
|
+
}
|
|
407
469
|
return {
|
|
408
470
|
status: "ok",
|
|
409
471
|
options: {
|
|
@@ -421,9 +483,34 @@ export function parseInstallBridgeConductorArgs(argv) {
|
|
|
421
483
|
executorId,
|
|
422
484
|
...(epicRunIds.length > 0 ? { epicRunIds } : {}),
|
|
423
485
|
...(repoWide ? { repoWide: true } : {}),
|
|
486
|
+
...(launchProfile ? { launchProfile } : {}),
|
|
487
|
+
...(baseUrl ? { baseUrl } : {}),
|
|
488
|
+
...(launchProfile
|
|
489
|
+
? { executionMode: executionMode ?? (executorId !== undefined ? "durable" : "local") }
|
|
490
|
+
: {}),
|
|
491
|
+
...(policyFile ? { policyFile } : {}),
|
|
424
492
|
},
|
|
425
493
|
};
|
|
426
494
|
}
|
|
495
|
+
/** Real host-local implementations of the launch-profile seams. */
|
|
496
|
+
export function createDefaultLaunchProfileDeps(env) {
|
|
497
|
+
const store = createNodeEpicLaunchStoreDeps(env);
|
|
498
|
+
return {
|
|
499
|
+
hostname: () => os.hostname(),
|
|
500
|
+
nowIso: () => new Date().toISOString(),
|
|
501
|
+
readProfile: (name) => readEpicLaunchProfile(name, store),
|
|
502
|
+
writeProfile: (profile) => writeEpicLaunchProfile(profile, store),
|
|
503
|
+
readCredentialBinding: (repoName) => readStoredBinding(`bapi:${repoName}`, {
|
|
504
|
+
env,
|
|
505
|
+
homedir: store.homedir,
|
|
506
|
+
platform: store.platform,
|
|
507
|
+
readFile: store.readFile,
|
|
508
|
+
stat: (p) => statAsync(p),
|
|
509
|
+
}),
|
|
510
|
+
bindCredential: (repoName, baseUrl) => bindEpicLaunchCredential(repoName, baseUrl, store),
|
|
511
|
+
readPolicyFile: (absolutePath) => readFileAsync(absolutePath, "utf-8"),
|
|
512
|
+
};
|
|
513
|
+
}
|
|
427
514
|
// ---------------------------------------------------------------------------
|
|
428
515
|
// Bounded readiness observation (BAPI-779)
|
|
429
516
|
// ---------------------------------------------------------------------------
|
|
@@ -948,6 +1035,265 @@ async function runConductorLocalObservabilityPhase(deps, local) {
|
|
|
948
1035
|
/**
|
|
949
1036
|
* Run the nested conductor installer. Returns the process exit code.
|
|
950
1037
|
*/
|
|
1038
|
+
// ---------------------------------------------------------------------------
|
|
1039
|
+
// Epic launch profile phase (BAPI-1260)
|
|
1040
|
+
// ---------------------------------------------------------------------------
|
|
1041
|
+
/** The packaged CLI's own npm spec, used when no absolute build path resolves. */
|
|
1042
|
+
const LAUNCH_NPM_CHANNEL_SPEC = `${MCP_PACKAGE_NAME}@latest`;
|
|
1043
|
+
/**
|
|
1044
|
+
* The stable invocation a profile records. A durable profile requires the
|
|
1045
|
+
* absolute packaged path (the same resolver the service unit uses, which already
|
|
1046
|
+
* refuses npx caches); a local one may fall back to the published package on
|
|
1047
|
+
* a named channel. Never a transient `_npx` cache path.
|
|
1048
|
+
*/
|
|
1049
|
+
function resolveLaunchRuntime(executor, mode) {
|
|
1050
|
+
const invocation = executor?.resolveInvocation();
|
|
1051
|
+
if (invocation?.ok) {
|
|
1052
|
+
const entryPath = invocation.invocation.prefixArgs[0];
|
|
1053
|
+
if (typeof entryPath === "string" && entryPath.length > 0) {
|
|
1054
|
+
return {
|
|
1055
|
+
ok: true,
|
|
1056
|
+
runtime: { kind: "packaged", executable: invocation.invocation.executable, entryPath },
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
if (mode === "durable") {
|
|
1061
|
+
return {
|
|
1062
|
+
ok: false,
|
|
1063
|
+
error: invocation && !invocation.ok
|
|
1064
|
+
? invocation.error
|
|
1065
|
+
: "no absolute packaged CLI path could be resolved for a durable launch.",
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
return { ok: true, runtime: { kind: "npm-channel", spec: LAUNCH_NPM_CHANNEL_SPEC } };
|
|
1069
|
+
}
|
|
1070
|
+
/** Loopback / link-local / private hostnames the API server cannot reach from elsewhere. */
|
|
1071
|
+
function isHostLocalHostname(hostname) {
|
|
1072
|
+
const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
1073
|
+
return (h === "localhost" ||
|
|
1074
|
+
h.endsWith(".localhost") ||
|
|
1075
|
+
h === "::1" ||
|
|
1076
|
+
/^127\./.test(h) ||
|
|
1077
|
+
/^0\.0\.0\.0$/.test(h) ||
|
|
1078
|
+
/^169\.254\./.test(h));
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Assess the policy's notification destination from the API SERVER's network
|
|
1082
|
+
* perspective. Presence and reachability SHAPE only — the authoritative
|
|
1083
|
+
* delivery check is the server's own `verify_notify_webhook` at approval, and a
|
|
1084
|
+
* failure there holds activation. A launcher-local listener can never pass
|
|
1085
|
+
* here for a remote target: the server cannot reach this machine's loopback.
|
|
1086
|
+
*/
|
|
1087
|
+
function assessNotification(policy, attended, targetBaseUrl) {
|
|
1088
|
+
if (attended) {
|
|
1089
|
+
return { status: "not-applicable", detail: "the referenced policy is attended; no escalation webhook is required." };
|
|
1090
|
+
}
|
|
1091
|
+
const notify = policy && typeof policy.notify === "object" && policy.notify !== null
|
|
1092
|
+
? policy.notify
|
|
1093
|
+
: null;
|
|
1094
|
+
const url = typeof notify?.webhook_url === "string" ? notify.webhook_url.trim() : "";
|
|
1095
|
+
if (url === "") {
|
|
1096
|
+
return {
|
|
1097
|
+
status: "missing",
|
|
1098
|
+
detail: "an unattended launch needs notify.webhook_url in its --policy-file" +
|
|
1099
|
+
(notify && "local_sink" in notify ? " (notify.local_sink is not an escalation channel)." : "."),
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
let webhook;
|
|
1103
|
+
let target;
|
|
1104
|
+
try {
|
|
1105
|
+
webhook = new URL(url);
|
|
1106
|
+
target = new URL(targetBaseUrl);
|
|
1107
|
+
}
|
|
1108
|
+
catch {
|
|
1109
|
+
return { status: "missing", detail: "notify.webhook_url is not an absolute URL." };
|
|
1110
|
+
}
|
|
1111
|
+
if (webhook.protocol !== "https:" && webhook.protocol !== "http:") {
|
|
1112
|
+
return { status: "missing", detail: "notify.webhook_url must be an http(s) URL." };
|
|
1113
|
+
}
|
|
1114
|
+
if (isHostLocalHostname(webhook.hostname) && !isHostLocalHostname(target.hostname)) {
|
|
1115
|
+
return {
|
|
1116
|
+
status: "missing",
|
|
1117
|
+
detail: "notify.webhook_url points at this machine's loopback, which the API server at the selected " +
|
|
1118
|
+
"target cannot reach. Use an API-reachable, durable notification service — a terminal-held " +
|
|
1119
|
+
"local listener cannot establish server reachability.",
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
return {
|
|
1123
|
+
status: "ok",
|
|
1124
|
+
detail: "notify.webhook_url is declared and addressable from the selected API server; the server " +
|
|
1125
|
+
"delivers a verification to it at approval, and a failure there holds activation.",
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Verify every mandatory launch prerequisite at the selected target, then —
|
|
1130
|
+
* and only then — persist the profile and print the one command that selects
|
|
1131
|
+
* it. Any unproved prerequisite yields `incomplete-setup` with the reason, and
|
|
1132
|
+
* no profile is written, so a profile's existence means setup was sufficient.
|
|
1133
|
+
*
|
|
1134
|
+
* Consent is READ from the server (`unattended.conductor_allowed`), never
|
|
1135
|
+
* written: ordinary configuration writes cannot self-grant unattended
|
|
1136
|
+
* authority, and nothing in this phase tries to.
|
|
1137
|
+
*/
|
|
1138
|
+
async function runLaunchProfilePhase(deps, options, inputs) {
|
|
1139
|
+
const name = options.launchProfile;
|
|
1140
|
+
const mode = options.executionMode ?? "local";
|
|
1141
|
+
const lp = deps.launchProfiles ?? createDefaultLaunchProfileDeps(deps.env);
|
|
1142
|
+
const repoName = inputs.access.repoName;
|
|
1143
|
+
const gaps = [];
|
|
1144
|
+
deps.log("");
|
|
1145
|
+
deps.log(`Epic launch profile '${name}' — verification…`);
|
|
1146
|
+
deps.log(` target: ${inputs.baseUrl}`);
|
|
1147
|
+
deps.log(` repository: ${repoName}`);
|
|
1148
|
+
deps.log(` mode: ${mode}`);
|
|
1149
|
+
// (1) Access at THIS target — only a server answer proves the key works there.
|
|
1150
|
+
if (inputs.readiness === null) {
|
|
1151
|
+
gaps.push("the selected target's conductor readiness could not be read with this repository's credential.");
|
|
1152
|
+
}
|
|
1153
|
+
// (2) The policy reference and what it implies.
|
|
1154
|
+
let policy = null;
|
|
1155
|
+
let policyPath = null;
|
|
1156
|
+
if (options.policyFile !== undefined) {
|
|
1157
|
+
policyPath = path.resolve(deps.cwd, options.policyFile);
|
|
1158
|
+
try {
|
|
1159
|
+
const parsed = JSON.parse(await lp.readPolicyFile(policyPath));
|
|
1160
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
1161
|
+
policy = parsed;
|
|
1162
|
+
}
|
|
1163
|
+
else {
|
|
1164
|
+
gaps.push("the --policy-file does not hold a JSON object.");
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
catch {
|
|
1168
|
+
gaps.push("the --policy-file could not be read as JSON.");
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
const attended = policy?.posture === "attended";
|
|
1172
|
+
// (3) Consent — the server's administrative authority, read not written.
|
|
1173
|
+
const consent = inputs.readiness?.unattended?.conductor_allowed;
|
|
1174
|
+
if (attended) {
|
|
1175
|
+
deps.log(" consent: not applicable (attended policy)");
|
|
1176
|
+
}
|
|
1177
|
+
else if (consent === true) {
|
|
1178
|
+
deps.log(" consent: confirmed by the server (unattended_epic_implementer_allowed)");
|
|
1179
|
+
}
|
|
1180
|
+
else {
|
|
1181
|
+
deps.log(` consent: ${consent === false ? "NOT granted" : "could not be verified"}`);
|
|
1182
|
+
gaps.push(consent === false
|
|
1183
|
+
? "unattended consent is not granted for this repository; an administrator sets it " +
|
|
1184
|
+
"(docs/claude/account-settings-operator-runbook.md) — this command never writes it."
|
|
1185
|
+
: "unattended consent could not be verified at the selected target (older server or unreadable report).");
|
|
1186
|
+
}
|
|
1187
|
+
// (4) Notification, from the server's network perspective.
|
|
1188
|
+
const notification = assessNotification(policy, attended, inputs.baseUrl);
|
|
1189
|
+
deps.log(` notify: ${notification.status} — ${notification.detail}`);
|
|
1190
|
+
if (notification.status === "missing")
|
|
1191
|
+
gaps.push(notification.detail);
|
|
1192
|
+
// (5) The actor that consumes the work.
|
|
1193
|
+
if (mode === "durable") {
|
|
1194
|
+
deps.log(` actor: ${inputs.actorObserved ? "server-observed executor handshake" : "NOT observed"}`);
|
|
1195
|
+
if (!inputs.actorObserved) {
|
|
1196
|
+
gaps.push("no executor was observed by the server for this repository; a written unit, a started " +
|
|
1197
|
+
"service, or a local process is not proof. Provision it with --executor-id and let the " +
|
|
1198
|
+
"bounded observation complete.");
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
else {
|
|
1202
|
+
deps.log(" actor: local — drive-epic starts this repository's plane and scoped lanes itself");
|
|
1203
|
+
}
|
|
1204
|
+
// (6) A stable runtime invocation.
|
|
1205
|
+
if (!inputs.runtime.ok) {
|
|
1206
|
+
gaps.push(`the packaged CLI invocation is not stable: ${inputs.runtime.error}`);
|
|
1207
|
+
}
|
|
1208
|
+
else {
|
|
1209
|
+
deps.log(` runtime: ${inputs.runtime.runtime.kind === "packaged" ? "packaged absolute path" : inputs.runtime.runtime.spec}`);
|
|
1210
|
+
}
|
|
1211
|
+
// (7) The existing profile: a changed binding is re-approved explicitly.
|
|
1212
|
+
const existing = await lp.readProfile(name);
|
|
1213
|
+
if (existing.ok) {
|
|
1214
|
+
const prior = existing.profile;
|
|
1215
|
+
const changed = prior.baseUrl !== inputs.baseUrl || prior.repoName !== repoName || prior.executionMode !== mode;
|
|
1216
|
+
if (changed) {
|
|
1217
|
+
deps.log("");
|
|
1218
|
+
deps.log(` profile '${name}' exists with a DIFFERENT binding:`);
|
|
1219
|
+
deps.log(` current: ${prior.baseUrl} repo=${prior.repoName} mode=${prior.executionMode}`);
|
|
1220
|
+
deps.log(` proposed: ${inputs.baseUrl} repo=${repoName} mode=${mode}`);
|
|
1221
|
+
const rebind = await confirm(deps, `Re-bind profile '${name}' to the proposed target? [y/N]: `);
|
|
1222
|
+
if (!rebind)
|
|
1223
|
+
gaps.push(`re-binding profile '${name}' was declined; it was left unchanged.`);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
else if (existing.kind !== "profile-missing") {
|
|
1227
|
+
// Unreadable or from a newer/older schema: reported, and replaced only
|
|
1228
|
+
// through the same consented write below.
|
|
1229
|
+
deps.log(` existing profile: ${existing.message}`);
|
|
1230
|
+
}
|
|
1231
|
+
// (8) The credential binding — the explicit one-time upgrade path.
|
|
1232
|
+
if (gaps.length === 0) {
|
|
1233
|
+
const bound = await lp.readCredentialBinding(repoName);
|
|
1234
|
+
if (bound === inputs.baseUrl) {
|
|
1235
|
+
deps.log(` credential: bapi:${repoName} is already approved for this target`);
|
|
1236
|
+
}
|
|
1237
|
+
else {
|
|
1238
|
+
deps.log(bound === null
|
|
1239
|
+
? ` credential: bapi:${repoName} is not yet approved for any endpoint (legacy repo-only binding)`
|
|
1240
|
+
: ` credential: bapi:${repoName} is approved for a DIFFERENT endpoint`);
|
|
1241
|
+
const approve = await confirm(deps, `Approve the stored credential bapi:${repoName} for ${inputs.baseUrl} only? [y/N]: `);
|
|
1242
|
+
if (!approve) {
|
|
1243
|
+
gaps.push("the credential binding was not approved, so a launch could not use the stored key.");
|
|
1244
|
+
}
|
|
1245
|
+
else {
|
|
1246
|
+
const result = await lp.bindCredential(repoName, inputs.baseUrl);
|
|
1247
|
+
if (!result.ok)
|
|
1248
|
+
gaps.push(`the credential binding could not be written: ${result.error}`);
|
|
1249
|
+
else
|
|
1250
|
+
deps.log(` credential: ${result.action} — approved for this target only`);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
if (gaps.length > 0 || !inputs.runtime.ok) {
|
|
1255
|
+
deps.log("");
|
|
1256
|
+
deps.log(` incomplete — profile '${name}' was NOT written:`);
|
|
1257
|
+
for (const gap of gaps)
|
|
1258
|
+
deps.log(` → ${gap}`);
|
|
1259
|
+
deps.log(` re-run: npx -y ${MCP_PACKAGE_NAME} install conductor --launch-profile ${name} ` +
|
|
1260
|
+
`--base-url ${inputs.baseUrl} --required-check <name> ...`);
|
|
1261
|
+
return { complete: false };
|
|
1262
|
+
}
|
|
1263
|
+
const write = await confirm(deps, `Write epic launch profile '${name}' (references only, no secrets)? [y/N]: `);
|
|
1264
|
+
if (!write) {
|
|
1265
|
+
deps.log(" declined — no profile was written.");
|
|
1266
|
+
return { complete: false };
|
|
1267
|
+
}
|
|
1268
|
+
const profile = {
|
|
1269
|
+
version: EPIC_LAUNCH_PROFILE_VERSION,
|
|
1270
|
+
name,
|
|
1271
|
+
baseUrl: inputs.baseUrl,
|
|
1272
|
+
repoName,
|
|
1273
|
+
executionMode: mode,
|
|
1274
|
+
host: lp.hostname(),
|
|
1275
|
+
runtime: inputs.runtime.runtime,
|
|
1276
|
+
policyRef: policyPath !== null ? { kind: "file", path: policyPath } : null,
|
|
1277
|
+
credentialRef: { store: "bridge-credentials", target: `bapi:${repoName}` },
|
|
1278
|
+
approvedAt: lp.nowIso(),
|
|
1279
|
+
};
|
|
1280
|
+
const written = await lp.writeProfile(profile);
|
|
1281
|
+
if (!written.ok) {
|
|
1282
|
+
deps.errorLog(` profile write failed: ${written.message}`);
|
|
1283
|
+
return { complete: false };
|
|
1284
|
+
}
|
|
1285
|
+
deps.log(` ${written.action} ${written.path}`);
|
|
1286
|
+
deps.log("");
|
|
1287
|
+
deps.log("Launch command (fresh shell; no exports needed):");
|
|
1288
|
+
deps.log(` ${renderEpicLaunchCommand(profile, {
|
|
1289
|
+
epicKey: options.epicKey ?? "<EPIC>",
|
|
1290
|
+
planFile: options.planFile ? path.resolve(deps.cwd, options.planFile) : "<path>",
|
|
1291
|
+
})
|
|
1292
|
+
// Placeholders are for the operator to replace, not literal arguments.
|
|
1293
|
+
.replace("'<EPIC>'", "<EPIC>")
|
|
1294
|
+
.replace("'<path>'", "<path>")}`);
|
|
1295
|
+
return { complete: true };
|
|
1296
|
+
}
|
|
951
1297
|
export async function runInstallBridgeConductorCli(argv, deps) {
|
|
952
1298
|
const parsed = parseInstallBridgeConductorArgs(argv);
|
|
953
1299
|
if (parsed.status === "help") {
|
|
@@ -962,12 +1308,31 @@ export async function runInstallBridgeConductorCli(argv, deps) {
|
|
|
962
1308
|
}
|
|
963
1309
|
const options = parsed.options;
|
|
964
1310
|
// ---- Base URL: normalized ONCE, confirmed BEFORE any network call ------
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1311
|
+
// BAPI-1260: an epic launch profile has NO production default. Its target is
|
|
1312
|
+
// --base-url or BAPI_BASE_URL (they must agree), decided here before any
|
|
1313
|
+
// request; the hosted default below applies to the legacy flow only.
|
|
1314
|
+
let baseUrl;
|
|
1315
|
+
if (options.launchProfile !== undefined) {
|
|
1316
|
+
const target = resolveEpicLaunchTarget({
|
|
1317
|
+
flagBaseUrl: options.baseUrl,
|
|
1318
|
+
profile: null,
|
|
1319
|
+
env: deps.env,
|
|
1320
|
+
});
|
|
1321
|
+
if (!target.ok) {
|
|
1322
|
+
deps.errorLog(target.message);
|
|
1323
|
+
deps.errorLog("Nothing was sent anywhere and nothing was written.");
|
|
1324
|
+
return 1;
|
|
1325
|
+
}
|
|
1326
|
+
baseUrl = target.baseUrl;
|
|
1327
|
+
}
|
|
1328
|
+
else {
|
|
1329
|
+
const baseUrlResult = deps.resolveBaseUrl(deps.env);
|
|
1330
|
+
if (!baseUrlResult.ok) {
|
|
1331
|
+
deps.errorLog(baseUrlResult.error);
|
|
1332
|
+
return 1;
|
|
1333
|
+
}
|
|
1334
|
+
baseUrl = baseUrlResult.value;
|
|
969
1335
|
}
|
|
970
|
-
const baseUrl = baseUrlResult.value;
|
|
971
1336
|
// ---- Consent #1: the resolved base URL ---------------------------------
|
|
972
1337
|
// This consent gates the FIRST network request of any kind — including the
|
|
973
1338
|
// read-only doctor readiness GET (which carries this repository's API key)
|
|
@@ -975,6 +1340,10 @@ export async function runInstallBridgeConductorCli(argv, deps) {
|
|
|
975
1340
|
// BAPI_BASE_URL is unset, so the operator must see and confirm the target
|
|
976
1341
|
// before the key is sent anywhere. No TTY means no consent means abort.
|
|
977
1342
|
deps.log(`Bridge API base URL: ${baseUrl}`);
|
|
1343
|
+
if (options.launchProfile !== undefined) {
|
|
1344
|
+
deps.log(` explicit target for epic launch profile '${options.launchProfile}'` +
|
|
1345
|
+
(baseUrl === DEFAULT_BAPI_BASE_URL ? " — this is the PRODUCTION Bridge API, selected deliberately." : "."));
|
|
1346
|
+
}
|
|
978
1347
|
const baseUrlOk = await confirm(deps, "Bootstrap against this URL? [y/N]: ");
|
|
979
1348
|
if (!baseUrlOk) {
|
|
980
1349
|
deps.log("Aborted — no network call (readiness GET or bootstrap POST) was attempted.");
|
|
@@ -1101,6 +1470,11 @@ export async function runInstallBridgeConductorCli(argv, deps) {
|
|
|
1101
1470
|
else if (executor) {
|
|
1102
1471
|
deps.log(" Executor provisioning: skipped (no --executor-id supplied)");
|
|
1103
1472
|
}
|
|
1473
|
+
if (options.launchProfile !== undefined) {
|
|
1474
|
+
deps.log(` Epic launch profile '${options.launchProfile}': would verify target access, credential ` +
|
|
1475
|
+
"binding, consent, notification, and actor, and persist the profile only if all hold " +
|
|
1476
|
+
"(each write behind its own consent) — nothing was read from or written to the profile store.");
|
|
1477
|
+
}
|
|
1104
1478
|
renderCapabilityMatrix(deps, firstReport.readiness);
|
|
1105
1479
|
deps.log("");
|
|
1106
1480
|
deps.log("complete-with-manual-steps (dry run)");
|
|
@@ -1320,6 +1694,24 @@ export async function runInstallBridgeConductorCli(argv, deps) {
|
|
|
1320
1694
|
for (const action of manualActions)
|
|
1321
1695
|
deps.log(` → ${action}`);
|
|
1322
1696
|
}
|
|
1697
|
+
// ---- BAPI-1260: the reusable epic launch profile ------------------------
|
|
1698
|
+
// Last, because it can only be approved on facts every earlier phase
|
|
1699
|
+
// produced: server-observed readiness at THIS target, the executor handshake
|
|
1700
|
+
// for a durable host, and the operator's consents.
|
|
1701
|
+
if (options.launchProfile !== undefined) {
|
|
1702
|
+
const launch = await runLaunchProfilePhase(deps, options, {
|
|
1703
|
+
access,
|
|
1704
|
+
baseUrl,
|
|
1705
|
+
readiness: postReadiness,
|
|
1706
|
+
actorObserved: observedReadiness !== null || postReadiness?.executor.ready === true,
|
|
1707
|
+
runtime: resolveLaunchRuntime(executor, options.executionMode ?? "local"),
|
|
1708
|
+
});
|
|
1709
|
+
deps.log("");
|
|
1710
|
+
if (!launch.complete) {
|
|
1711
|
+
deps.log("incomplete-setup");
|
|
1712
|
+
return 1;
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1323
1715
|
deps.log("");
|
|
1324
1716
|
// The executor leg can only be true when the server observed a recent claim,
|
|
1325
1717
|
// so a bootstrap alone can never print `complete`. The two local phases above
|
package/build/install-bridge.js
CHANGED
|
@@ -325,7 +325,11 @@ export const INSTALL_BRIDGE_AGENT_PROMPT = "Run /install-bridge now, with no arg
|
|
|
325
325
|
"do' line. The server decides eligibility (repository state AND whether this caller can run the " +
|
|
326
326
|
"command), so do NOT apply a role rule or a learned-state rule of your own. Do NOT run the " +
|
|
327
327
|
"returned command yourself — only recommend it; running it is the human's next explicit " +
|
|
328
|
-
"invocation.
|
|
328
|
+
"invocation. When you recommend a command and `next_step.command_context` is a non-empty " +
|
|
329
|
+
"string, print it verbatim on its own line directly under the command — it is the server's " +
|
|
330
|
+
"explanation of what the command gives the human and how long it takes; do not reword, shorten, " +
|
|
331
|
+
"or extend it, and render nothing in its place when it is empty or missing. " +
|
|
332
|
+
"For other supporting context around a recommended command, use the manifest's own " +
|
|
329
333
|
"next_step.post_install_indexing and next_step.post_install_scheduler text rather than inventing " +
|
|
330
334
|
"an explanation for it. " +
|
|
331
335
|
"Never request, echo, or transport any credential — only ever direct the human to that " +
|
|
@@ -1943,21 +1947,34 @@ export async function resolveSignupEmail(options, deps) {
|
|
|
1943
1947
|
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
|
|
1944
1948
|
return { ok: true, value: fromEnv.trim() };
|
|
1945
1949
|
}
|
|
1950
|
+
const entered = await promptForBoundedEmail(deps, "Email for Bridge workspace setup (required): ");
|
|
1951
|
+
if (entered === undefined)
|
|
1952
|
+
return { ok: false, error: "No email entered." };
|
|
1953
|
+
return { ok: true, value: entered };
|
|
1954
|
+
}
|
|
1955
|
+
/**
|
|
1956
|
+
* The bounded interactive email prompt shared by self-serve signup
|
|
1957
|
+
* ({@link resolveSignupEmail}) and invite redemption (BAPI-1263). Two reads at
|
|
1958
|
+
* most: the first answer, then ONE re-prompt after a single hint line. EOF
|
|
1959
|
+
* resolves as an empty value (promptLineViaReadline resolves "" on close), which
|
|
1960
|
+
* fails the shape check like any other blank answer, so it costs one re-prompt and
|
|
1961
|
+
* then fails cleanly rather than hanging. Returns the trimmed (case-preserved)
|
|
1962
|
+
* address, or `undefined` after two invalid answers. The answer is never echoed.
|
|
1963
|
+
* The caller guarantees `deps.promptLine` exists.
|
|
1964
|
+
*/
|
|
1965
|
+
async function promptForBoundedEmail(deps, promptText) {
|
|
1946
1966
|
const promptLine = deps.promptLine;
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
// resolves "" on close), which fails the shape check like any other blank
|
|
1950
|
-
// answer, so it costs one re-prompt and then fails cleanly rather than hanging.
|
|
1967
|
+
if (!promptLine)
|
|
1968
|
+
return undefined;
|
|
1951
1969
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1952
|
-
const raw = await promptLine(
|
|
1970
|
+
const raw = await promptLine(promptText);
|
|
1953
1971
|
const entered = String(raw ?? "").trim();
|
|
1954
|
-
if (INSTALL_BRIDGE_SIGNUP_EMAIL_SHAPE.test(entered))
|
|
1955
|
-
return
|
|
1956
|
-
}
|
|
1972
|
+
if (INSTALL_BRIDGE_SIGNUP_EMAIL_SHAPE.test(entered))
|
|
1973
|
+
return entered;
|
|
1957
1974
|
if (attempt === 0)
|
|
1958
1975
|
deps.log(INSTALL_BRIDGE_SIGNUP_EMAIL_HINT);
|
|
1959
1976
|
}
|
|
1960
|
-
return
|
|
1977
|
+
return undefined;
|
|
1961
1978
|
}
|
|
1962
1979
|
/**
|
|
1963
1980
|
* Select the onboarding branch as pure logic (deterministic, no I/O, no prompt):
|
|
@@ -3428,15 +3445,21 @@ export function buildBootstrapExchangeUrl(baseUrl) {
|
|
|
3428
3445
|
* POST the bootstrap-invite exchange. The token and `key_secret` travel ONLY in
|
|
3429
3446
|
* the JSON request body (never a query string, never a header, never a log line).
|
|
3430
3447
|
* Bounded by the same 10s timeout as the connectivity ping.
|
|
3448
|
+
*
|
|
3449
|
+
* BAPI-1263: an optional nonempty `email` is appended as a fourth body field, used
|
|
3450
|
+
* only on the retry after an `email-required` refusal. Omitted or empty, the body is
|
|
3451
|
+
* the legacy three-field one, byte for byte.
|
|
3431
3452
|
*/
|
|
3432
|
-
export async function exchangeBootstrapInvite(deps, baseUrl, token, repoName, keySecret) {
|
|
3453
|
+
export async function exchangeBootstrapInvite(deps, baseUrl, token, repoName, keySecret, email) {
|
|
3433
3454
|
const url = buildBootstrapExchangeUrl(baseUrl);
|
|
3434
3455
|
let resp;
|
|
3435
3456
|
try {
|
|
3436
3457
|
resp = await deps.fetch(url, {
|
|
3437
3458
|
method: "POST",
|
|
3438
3459
|
headers: { "Content-Type": "application/json" },
|
|
3439
|
-
body: JSON.stringify(
|
|
3460
|
+
body: JSON.stringify(email
|
|
3461
|
+
? { token, repo_name: repoName, key_secret: keySecret, email }
|
|
3462
|
+
: { token, repo_name: repoName, key_secret: keySecret }),
|
|
3440
3463
|
signal: AbortSignal.timeout(10_000),
|
|
3441
3464
|
});
|
|
3442
3465
|
}
|
|
@@ -3490,12 +3513,42 @@ export async function exchangeBootstrapInvite(deps, baseUrl, token, repoName, ke
|
|
|
3490
3513
|
if (resp.status === 401) {
|
|
3491
3514
|
return { ok: false, kind: "invalid-invite", message: `The Bridge API rejected the bootstrap invite (HTTP ${resp.status}).` };
|
|
3492
3515
|
}
|
|
3516
|
+
if (resp.status === 426) {
|
|
3517
|
+
// Only the exact `email_required` refusal is recognized; any other, absent, or
|
|
3518
|
+
// unreadable 426 body stays on the generic failure path below. The message is
|
|
3519
|
+
// installer-owned — nothing from the response is propagated.
|
|
3520
|
+
let recognized = false;
|
|
3521
|
+
try {
|
|
3522
|
+
const body = (await resp.json());
|
|
3523
|
+
recognized = body?.detail?.error === "email_required";
|
|
3524
|
+
}
|
|
3525
|
+
catch {
|
|
3526
|
+
recognized = false;
|
|
3527
|
+
}
|
|
3528
|
+
if (recognized) {
|
|
3529
|
+
return {
|
|
3530
|
+
ok: false,
|
|
3531
|
+
kind: "email-required",
|
|
3532
|
+
message: "The Bridge API requires an email address to redeem this invite.",
|
|
3533
|
+
};
|
|
3534
|
+
}
|
|
3535
|
+
}
|
|
3493
3536
|
return {
|
|
3494
3537
|
ok: false,
|
|
3495
3538
|
kind: "failed",
|
|
3496
3539
|
message: `The bootstrap exchange failed (HTTP ${resp.status}). Verify BAPI_BASE_URL and try again.`,
|
|
3497
3540
|
};
|
|
3498
3541
|
}
|
|
3542
|
+
/** The invite-email prompt (BAPI-1263), shown once after an `email_required` refusal. */
|
|
3543
|
+
const INVITE_EMAIL_PROMPT = "Email for this Bridge invite (required): ";
|
|
3544
|
+
/** BAPI-1263: fixed, secret-free failures for the invite email recovery. */
|
|
3545
|
+
export const INVITE_EMAIL_REQUIRED_NON_INTERACTIVE_MESSAGE = "Error: this invite needs an email address, and there is no interactive terminal to ask for it. " +
|
|
3546
|
+
`The bootstrap invite has NOT been used. Re-run \`npx -y ${MCP_PACKAGE_NAME} install\` in an ` +
|
|
3547
|
+
"interactive terminal.";
|
|
3548
|
+
export const INVITE_EMAIL_INVALID_ANSWERS_MESSAGE = "Error: no valid email address was entered. The bootstrap invite has NOT been used. Re-run " +
|
|
3549
|
+
"install and enter an address like you@example.com.";
|
|
3550
|
+
export const INVITE_EMAIL_REPEATED_REFUSAL_MESSAGE = "Error: the Bridge API still requires an email address after one was entered. The bootstrap " +
|
|
3551
|
+
"invite has NOT been used. Re-run install, or contact support if this repeats.";
|
|
3499
3552
|
// ---------------------------------------------------------------------------
|
|
3500
3553
|
// Self-serve mint (BAPI-618): email -> invite token
|
|
3501
3554
|
// ---------------------------------------------------------------------------
|
|
@@ -6014,12 +6067,35 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
6014
6067
|
// self-serve record — once more with a freshly minted token (R4-3).
|
|
6015
6068
|
let exchange;
|
|
6016
6069
|
let selfServeRefreshed = false;
|
|
6070
|
+
// BAPI-1263: the email typed after an `email_required` refusal. Run-local only —
|
|
6071
|
+
// never persisted, logged, or written to the pending record — and carried on
|
|
6072
|
+
// every later ordinary-invite exchange, including after a 409 rename.
|
|
6073
|
+
let inviteEmail;
|
|
6017
6074
|
for (;;) {
|
|
6018
|
-
exchange = await exchangeBootstrapInvite(deps, baseUrl, inviteToken, repoName, keySecret);
|
|
6075
|
+
exchange = await exchangeBootstrapInvite(deps, baseUrl, inviteToken, repoName, keySecret, inviteEmail);
|
|
6019
6076
|
// 409 repo_name_taken: the server ROLLED BACK, so the invite is NOT consumed and
|
|
6020
6077
|
// the SAME token can be retried under a new name — with the SAME pending secret,
|
|
6021
|
-
// re-pointed to that name.
|
|
6022
|
-
|
|
6078
|
+
// re-pointed to that name. The same loop handles an ordinary invite's 426
|
|
6079
|
+
// `email_required` (BAPI-1263), so a 409 on the email-bearing retry re-enters
|
|
6080
|
+
// the rename branch without an extra exchange request.
|
|
6081
|
+
while (!exchange.ok &&
|
|
6082
|
+
(exchange.kind === "repo-name-taken" ||
|
|
6083
|
+
(exchange.kind === "email-required" && !selfServeSignupMode))) {
|
|
6084
|
+
if (exchange.kind === "email-required") {
|
|
6085
|
+
if (inviteEmail !== undefined) {
|
|
6086
|
+
return fatal(INVITE_EMAIL_REPEATED_REFUSAL_MESSAGE);
|
|
6087
|
+
}
|
|
6088
|
+
if (!deps.isTTY || !deps.promptLine) {
|
|
6089
|
+
return fatal(INVITE_EMAIL_REQUIRED_NON_INTERACTIVE_MESSAGE);
|
|
6090
|
+
}
|
|
6091
|
+
const typedEmail = await promptForBoundedEmail(deps, INVITE_EMAIL_PROMPT);
|
|
6092
|
+
if (typedEmail === undefined) {
|
|
6093
|
+
return fatal(INVITE_EMAIL_INVALID_ANSWERS_MESSAGE);
|
|
6094
|
+
}
|
|
6095
|
+
inviteEmail = typedEmail;
|
|
6096
|
+
exchange = await exchangeBootstrapInvite(deps, baseUrl, inviteToken, repoName, keySecret, inviteEmail);
|
|
6097
|
+
continue;
|
|
6098
|
+
}
|
|
6023
6099
|
if (!deps.isTTY || !deps.promptLine) {
|
|
6024
6100
|
errorLog(`Error: ${exchange.message} Re-run with a different --repo (the bootstrap invite has NOT ` +
|
|
6025
6101
|
"been used).");
|
|
@@ -6046,7 +6122,7 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
6046
6122
|
}
|
|
6047
6123
|
repoName = nextRepo;
|
|
6048
6124
|
// Same token, same key_secret — never regenerated inside the rename loop.
|
|
6049
|
-
exchange = await exchangeBootstrapInvite(deps, baseUrl, inviteToken, repoName, keySecret);
|
|
6125
|
+
exchange = await exchangeBootstrapInvite(deps, baseUrl, inviteToken, repoName, keySecret, inviteEmail);
|
|
6050
6126
|
}
|
|
6051
6127
|
if (exchange.ok)
|
|
6052
6128
|
break;
|