@bridge_gpt/mcp-server 0.2.25 → 0.2.27
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 +59 -9
- package/build/agents.generated.js +1 -1
- package/build/bridge-api-urls.js +31 -0
- package/build/commands.generated.js +4 -4
- package/build/conductor-bin.js +68 -9267
- package/build/conductor-bundle-artifacts.js +802 -0
- package/build/conductor-bundle-cli.js +256 -0
- package/build/conductor-claude-hook-bin.js +2 -172
- package/build/decision-page-template.js +2 -31
- package/build/docs.generated.js +2 -1
- package/build/doctor.js +148 -1
- package/build/env-flags.js +31 -0
- package/build/index.js +4659 -34836
- package/build/init.js +87 -59
- package/build/install-bridge.js +380 -22
- package/build/mcp-host-config.js +521 -0
- package/build/mcp-host-targets.js +194 -0
- package/build/mcp-install-state.js +175 -0
- package/build/pipelines.generated.js +5 -4
- package/build/readme.generated.js +1 -1
- package/build/start-tickets.js +118 -5
- package/build/tool-surface-gating.js +396 -0
- package/build/version.generated.js +1 -1
- package/docs/install/mcp-tool-integrations.md +2 -2
- package/package.json +5 -7
- package/design-assets/favicon/android-chrome-192x192.png +0 -0
- package/design-assets/favicon/android-chrome-512x512.png +0 -0
- package/design-assets/favicon/apple-touch-icon.png +0 -0
- package/design-assets/favicon/favicon-16x16.png +0 -0
- package/design-assets/favicon/favicon.ico +0 -0
- package/design-assets/favicon/site.webmanifest +0 -1
- package/public/css/main.min.css +0 -4971
- package/public/css/main.min.css.map +0 -1
- package/public/fonts/OFL.txt +0 -93
- package/public/fonts/SourceSansPro-Black.ttf +0 -0
- package/public/fonts/SourceSansPro-BlackItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-Bold.ttf +0 -0
- package/public/fonts/SourceSansPro-BoldItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-ExtraLight.ttf +0 -0
- package/public/fonts/SourceSansPro-ExtraLightItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-Italic.ttf +0 -0
- package/public/fonts/SourceSansPro-Light.ttf +0 -0
- package/public/fonts/SourceSansPro-LightItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-Regular.ttf +0 -0
- package/public/fonts/SourceSansPro-SemiBold.ttf +0 -0
- package/public/fonts/SourceSansPro-SemiBoldItalic.ttf +0 -0
- package/public/img/bridge-logo-160x51.webp +0 -0
- package/public/img/bridge-logo-300x92.webp +0 -0
- package/public/img/favicon/android-chrome-192x192.png +0 -0
- package/public/img/favicon/android-chrome-512x512.png +0 -0
- package/public/img/favicon/apple-touch-icon.png +0 -0
- package/public/img/favicon/favicon-16x16.png +0 -0
- package/public/img/favicon/favicon-32x32.png +0 -0
- package/public/img/favicon/favicon.ico +0 -0
- package/public/img/favicon/site.webmanifest +0 -1
- package/public/img/installation/bitbucket/app-password-1.png +0 -0
- package/public/img/installation/bitbucket/app-password-2.png +0 -0
- package/public/img/installation/bitbucket/create-token-1.png +0 -0
- package/public/img/installation/bitbucket/create-token-2.png +0 -0
- package/public/img/installation/bitbucket/webhook-1.png +0 -0
- package/public/img/installation/github/github-review-webhook.png +0 -0
- package/public/img/installation/jira/credentials/api-key.png +0 -0
- package/public/img/installation/jira/webhook/create-rule.png +0 -0
- package/public/img/installation/jira/webhook/project-settings.png +0 -0
- package/public/img/installation/jira/webhook/rule-create-1.png +0 -0
- package/public/img/installation/jira/webhook/rule-create-2.png +0 -0
- package/public/img/installation/jira/webhook/rule-create-3.png +0 -0
- package/public/img/installation/pinecone/pinecone-api-key.png +0 -0
- package/public/img/installation/pinecone/pinecone-index.png +0 -0
- package/public/js/main.min.js +0 -10427
- package/public/js/main.min.js.map +0 -1
- package/smoke-test/SMOKE-TEST.md +0 -560
- package/smoke-test/smoke-test-mcp.md +0 -23
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-platform MCP host-config emission + provisioning (BAPI-635).
|
|
3
|
+
*
|
|
4
|
+
* Converts the single logical Bridge entry (from
|
|
5
|
+
* {@link file:./install-bridge.ts} `buildInstallBridgeServerEntry`) into each
|
|
6
|
+
* platform's physical JSON or TOML representation, and writes it without
|
|
7
|
+
* clobbering user-managed configuration.
|
|
8
|
+
*
|
|
9
|
+
* Write strategy per target comes from the data-only registry
|
|
10
|
+
* {@link file:./mcp-host-targets.ts}:
|
|
11
|
+
* - vendor-first — prefer the official vendor CLI, fall back to a safe direct
|
|
12
|
+
* write (Claude Code, Copilot CLI, Codex).
|
|
13
|
+
* - direct — merge-write the JSON file directly (Cursor, Copilot VS Code).
|
|
14
|
+
* - manual-instructions — never auto-write; emit redacted instructions
|
|
15
|
+
* (Windsurf).
|
|
16
|
+
*
|
|
17
|
+
* SECRET DISCIPLINE: the API key lives only inside the logical entry's `env`.
|
|
18
|
+
* When a verified vendor contract delivers it via argv (all three do — none
|
|
19
|
+
* expose a stdin channel), the argv is constructed and spawned INSIDE the vendor
|
|
20
|
+
* adapter and is never stringified, logged, previewed, returned, or interpolated
|
|
21
|
+
* into a warning. Structured outcomes and inspectors carry presence + launcher
|
|
22
|
+
* args only, never env values.
|
|
23
|
+
*
|
|
24
|
+
* TOML NOTE: there is no comment-preserving TOML library available, so Codex
|
|
25
|
+
* direct writes render a canonical document for a missing file, APPEND canonical
|
|
26
|
+
* tables to an existing file that has no `bridge-api` table (preserving every
|
|
27
|
+
* existing byte), and DECLINE (`manual-required`) an existing `bridge-api` table
|
|
28
|
+
* rather than reserialize unrelated content.
|
|
29
|
+
*/
|
|
30
|
+
import { VERSION } from "./version.generated.js";
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Path helpers (POSIX-normalized; module stays free of node:path).
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
function joinPath(base, rel) {
|
|
35
|
+
const b = base.endsWith("/") ? base.slice(0, -1) : base;
|
|
36
|
+
const r = rel.startsWith("/") ? rel.slice(1) : rel;
|
|
37
|
+
return `${b}/${r}`;
|
|
38
|
+
}
|
|
39
|
+
function dirnameOf(p) {
|
|
40
|
+
const i = p.lastIndexOf("/");
|
|
41
|
+
if (i <= 0)
|
|
42
|
+
return "/";
|
|
43
|
+
return p.slice(0, i);
|
|
44
|
+
}
|
|
45
|
+
/** Resolve a target's absolute path from injected cwd/homedir. */
|
|
46
|
+
export function resolveTargetAbsPath(target, ctx) {
|
|
47
|
+
if (target.scope === "project") {
|
|
48
|
+
if (!target.relPath)
|
|
49
|
+
throw new Error(`${target.id} missing relPath`);
|
|
50
|
+
return joinPath(ctx.cwd, target.relPath);
|
|
51
|
+
}
|
|
52
|
+
if (!target.absPathResolver)
|
|
53
|
+
throw new Error(`${target.id} missing absPathResolver`);
|
|
54
|
+
return target.absPathResolver(ctx.homedir);
|
|
55
|
+
}
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Entry adaptation
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
/**
|
|
60
|
+
* Adapt the shared logical entry into a target's physical JSON entry: copy
|
|
61
|
+
* command/args/env, conditionally add the registry `transportType` as `type`,
|
|
62
|
+
* and merge `extraEntryKeys`. Claude Code omits `type`; Cursor/Copilot VS Code
|
|
63
|
+
* use `stdio`; Copilot CLI uses `local` + `tools: ["*"]`.
|
|
64
|
+
*/
|
|
65
|
+
export function adaptBridgeEntryForHostTarget(entry, target) {
|
|
66
|
+
const physical = {};
|
|
67
|
+
if (target.transportType !== undefined) {
|
|
68
|
+
physical.type = target.transportType;
|
|
69
|
+
}
|
|
70
|
+
physical.command = entry.command;
|
|
71
|
+
physical.args = [...entry.args];
|
|
72
|
+
physical.env = { ...entry.env };
|
|
73
|
+
if (target.extraEntryKeys) {
|
|
74
|
+
for (const [k, v] of Object.entries(target.extraEntryKeys)) {
|
|
75
|
+
physical[k] = v;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return physical;
|
|
79
|
+
}
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// JSON read / merge / write
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
function isEnoent(err) {
|
|
84
|
+
return (typeof err === "object" &&
|
|
85
|
+
err !== null &&
|
|
86
|
+
err.code === "ENOENT");
|
|
87
|
+
}
|
|
88
|
+
/** Read a JSON host config into distinct outcomes; malformed is never `{}`. */
|
|
89
|
+
export async function readJsonHostConfig(path, deps) {
|
|
90
|
+
let raw;
|
|
91
|
+
try {
|
|
92
|
+
raw = await deps.readFile(path);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
if (isEnoent(err))
|
|
96
|
+
return { state: "missing" };
|
|
97
|
+
return { state: "read-error", message: "config could not be read" };
|
|
98
|
+
}
|
|
99
|
+
let parsed;
|
|
100
|
+
try {
|
|
101
|
+
parsed = JSON.parse(raw);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return { state: "invalid" };
|
|
105
|
+
}
|
|
106
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
107
|
+
return { state: "invalid" };
|
|
108
|
+
}
|
|
109
|
+
return { state: "valid", value: parsed };
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Merge the adapted `bridge-api` entry into an existing JSON document under the
|
|
113
|
+
* registry-selected root key. Unrelated top-level keys and unrelated servers are
|
|
114
|
+
* preserved; only the `bridge-api` entry is replaced.
|
|
115
|
+
*/
|
|
116
|
+
export function mergeJsonHostConfig(existing, target, adaptedEntry) {
|
|
117
|
+
const merged = { ...existing };
|
|
118
|
+
const rootRaw = merged[target.topLevelKey];
|
|
119
|
+
const root = rootRaw && typeof rootRaw === "object" && !Array.isArray(rootRaw)
|
|
120
|
+
? { ...rootRaw }
|
|
121
|
+
: {};
|
|
122
|
+
root["bridge-api"] = adaptedEntry;
|
|
123
|
+
merged[target.topLevelKey] = root;
|
|
124
|
+
return merged;
|
|
125
|
+
}
|
|
126
|
+
/** Build a fresh JSON document containing only the Bridge entry. */
|
|
127
|
+
function freshJsonDocument(target, adaptedEntry) {
|
|
128
|
+
return { [target.topLevelKey]: { "bridge-api": adaptedEntry } };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Direct-write a JSON host config. Malformed existing JSON is skipped with an
|
|
132
|
+
* explicit `skipped — invalid JSON` result and is never overwritten.
|
|
133
|
+
*/
|
|
134
|
+
export async function writeJsonHostConfig(path, target, adaptedEntry, deps) {
|
|
135
|
+
const read = await readJsonHostConfig(path, deps);
|
|
136
|
+
if (read.state === "invalid" || read.state === "read-error") {
|
|
137
|
+
return { status: "skipped-invalid", message: "skipped — invalid JSON" };
|
|
138
|
+
}
|
|
139
|
+
const isCreate = read.state === "missing";
|
|
140
|
+
const doc = isCreate
|
|
141
|
+
? freshJsonDocument(target, adaptedEntry)
|
|
142
|
+
: mergeJsonHostConfig(read.value, target, adaptedEntry);
|
|
143
|
+
await deps.mkdir(dirnameOf(path), { recursive: true });
|
|
144
|
+
await deps.writeFile(path, JSON.stringify(doc, null, 2) + "\n");
|
|
145
|
+
return { status: isCreate ? "created" : "direct-written", path };
|
|
146
|
+
}
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Codex TOML rendering + safe merge
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
/** Escape a string for a TOML basic string literal. */
|
|
151
|
+
export function tomlEscapeString(value) {
|
|
152
|
+
let out = "";
|
|
153
|
+
for (const ch of value) {
|
|
154
|
+
switch (ch) {
|
|
155
|
+
case "\\":
|
|
156
|
+
out += "\\\\";
|
|
157
|
+
break;
|
|
158
|
+
case '"':
|
|
159
|
+
out += '\\"';
|
|
160
|
+
break;
|
|
161
|
+
case "\n":
|
|
162
|
+
out += "\\n";
|
|
163
|
+
break;
|
|
164
|
+
case "\r":
|
|
165
|
+
out += "\\r";
|
|
166
|
+
break;
|
|
167
|
+
case "\t":
|
|
168
|
+
out += "\\t";
|
|
169
|
+
break;
|
|
170
|
+
case "\b":
|
|
171
|
+
out += "\\b";
|
|
172
|
+
break;
|
|
173
|
+
case "\f":
|
|
174
|
+
out += "\\f";
|
|
175
|
+
break;
|
|
176
|
+
default: {
|
|
177
|
+
const code = ch.codePointAt(0);
|
|
178
|
+
if (code < 0x20) {
|
|
179
|
+
out += "\\u" + code.toString(16).padStart(4, "0");
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
out += ch;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
function tomlBasicString(value) {
|
|
190
|
+
return `"${tomlEscapeString(value)}"`;
|
|
191
|
+
}
|
|
192
|
+
function tomlStringArray(values) {
|
|
193
|
+
return `[${values.map(tomlBasicString).join(", ")}]`;
|
|
194
|
+
}
|
|
195
|
+
/** Deterministic env-key order for the Codex table (matches the web snippet). */
|
|
196
|
+
const CODEX_ENV_ORDER = [
|
|
197
|
+
"BAPI_BASE_URL",
|
|
198
|
+
"BAPI_REPO_NAME",
|
|
199
|
+
"BAPI_API_KEY",
|
|
200
|
+
"BAPI_DOCS_DIR",
|
|
201
|
+
"BAPI_PROJECT_ROOT",
|
|
202
|
+
];
|
|
203
|
+
function orderedEnvKeys(env) {
|
|
204
|
+
const known = CODEX_ENV_ORDER.filter((k) => k in env);
|
|
205
|
+
const extra = Object.keys(env)
|
|
206
|
+
.filter((k) => !CODEX_ENV_ORDER.includes(k))
|
|
207
|
+
.sort();
|
|
208
|
+
return [...known, ...extra];
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Render the canonical Codex TOML for the Bridge entry. Every string, path, URL,
|
|
212
|
+
* and launcher arg is TOML-escaped so it cannot produce malformed TOML.
|
|
213
|
+
*/
|
|
214
|
+
export function renderCodexBridgeToml(entry) {
|
|
215
|
+
const lines = [
|
|
216
|
+
"[mcp_servers.bridge-api]",
|
|
217
|
+
`command = ${tomlBasicString(entry.command)}`,
|
|
218
|
+
`args = ${tomlStringArray(entry.args)}`,
|
|
219
|
+
"",
|
|
220
|
+
"[mcp_servers.bridge-api.env]",
|
|
221
|
+
];
|
|
222
|
+
for (const key of orderedEnvKeys(entry.env)) {
|
|
223
|
+
lines.push(`${key} = ${tomlBasicString(entry.env[key])}`);
|
|
224
|
+
}
|
|
225
|
+
return lines.join("\n") + "\n";
|
|
226
|
+
}
|
|
227
|
+
/** Whether a TOML document already declares a `[mcp_servers.bridge-api]` table. */
|
|
228
|
+
export function codexHasBridgeTable(content) {
|
|
229
|
+
// Match the bare table header or any dotted subtable header, at line start
|
|
230
|
+
// (allowing leading whitespace). Never reserialize — presence only.
|
|
231
|
+
return /^[ \t]*\[mcp_servers\.bridge-api(\.[^\]]*)?\][ \t]*$/m.test(content);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Compute the safe Codex merge action. A missing document → canonical create; an
|
|
235
|
+
* existing document without a Bridge table → append (existing bytes preserved,
|
|
236
|
+
* minimal newline boundary); an existing Bridge table → decline (manual-required)
|
|
237
|
+
* rather than reserialize.
|
|
238
|
+
*/
|
|
239
|
+
export function mergeCodexHostConfig(existing, entry) {
|
|
240
|
+
if (existing.state === "missing") {
|
|
241
|
+
return { action: "create", content: renderCodexBridgeToml(entry) };
|
|
242
|
+
}
|
|
243
|
+
if (existing.state === "read-error" || existing.state === "invalid") {
|
|
244
|
+
// Cannot safely reason about the file → decline.
|
|
245
|
+
return { action: "manual-required" };
|
|
246
|
+
}
|
|
247
|
+
const content = existing.state === "text"
|
|
248
|
+
? existing.content
|
|
249
|
+
: // "valid" JsonReadResult is not used for TOML; guard anyway.
|
|
250
|
+
"";
|
|
251
|
+
if (codexHasBridgeTable(content)) {
|
|
252
|
+
return { action: "manual-required" };
|
|
253
|
+
}
|
|
254
|
+
const boundary = content.length === 0 || content.endsWith("\n") ? "\n" : "\n\n";
|
|
255
|
+
return { action: "append", content: content + boundary + renderCodexBridgeToml(entry) };
|
|
256
|
+
}
|
|
257
|
+
/** Read a TOML file into a missing / text / read-error result. */
|
|
258
|
+
async function readTomlHostConfig(path, deps) {
|
|
259
|
+
try {
|
|
260
|
+
const content = await deps.readFile(path);
|
|
261
|
+
return { state: "text", content };
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
if (isEnoent(err))
|
|
265
|
+
return { state: "missing" };
|
|
266
|
+
return { state: "read-error", message: "config could not be read" };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/** Direct-write the Codex TOML config using the safe merge strategy. */
|
|
270
|
+
export async function writeCodexHostConfig(path, entry, deps) {
|
|
271
|
+
const read = await readTomlHostConfig(path, deps);
|
|
272
|
+
const merge = mergeCodexHostConfig(read, entry);
|
|
273
|
+
if (merge.action === "manual-required") {
|
|
274
|
+
return { status: "manual-required" };
|
|
275
|
+
}
|
|
276
|
+
await deps.mkdir(dirnameOf(path), { recursive: true });
|
|
277
|
+
await deps.writeFile(path, merge.content);
|
|
278
|
+
return { status: merge.action === "create" ? "created" : "direct-written", path };
|
|
279
|
+
}
|
|
280
|
+
/** Inspect a JSON host config for a `bridge-api` entry (no env values read). */
|
|
281
|
+
export async function inspectJsonHostEntry(path, target, deps) {
|
|
282
|
+
const read = await readJsonHostConfig(path, deps);
|
|
283
|
+
if (read.state !== "valid")
|
|
284
|
+
return { present: false };
|
|
285
|
+
const root = read.value[target.topLevelKey];
|
|
286
|
+
if (!root || typeof root !== "object" || Array.isArray(root))
|
|
287
|
+
return { present: false };
|
|
288
|
+
const entry = root["bridge-api"];
|
|
289
|
+
if (!entry || typeof entry !== "object")
|
|
290
|
+
return { present: false };
|
|
291
|
+
const args = entry.args;
|
|
292
|
+
return {
|
|
293
|
+
present: true,
|
|
294
|
+
args: Array.isArray(args) ? args.filter((a) => typeof a === "string") : undefined,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/** Inspect a Codex TOML config for a `bridge-api` table (no env values read). */
|
|
298
|
+
export async function inspectTomlHostEntry(path, deps) {
|
|
299
|
+
const read = await readTomlHostConfig(path, deps);
|
|
300
|
+
if (read.state !== "text")
|
|
301
|
+
return { present: false };
|
|
302
|
+
if (!codexHasBridgeTable(read.content))
|
|
303
|
+
return { present: false };
|
|
304
|
+
// Extract the args = [...] line from the main table for pin classification.
|
|
305
|
+
const m = read.content.match(/^\s*args\s*=\s*\[(.*?)\]/m);
|
|
306
|
+
let args;
|
|
307
|
+
if (m) {
|
|
308
|
+
args = m[1]
|
|
309
|
+
.split(",")
|
|
310
|
+
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
|
|
311
|
+
.filter((s) => s.length > 0);
|
|
312
|
+
}
|
|
313
|
+
return { present: true, args };
|
|
314
|
+
}
|
|
315
|
+
/** Inspect any target (format-aware) for a `bridge-api` entry. */
|
|
316
|
+
export async function inspectHostEntry(target, ctx, deps) {
|
|
317
|
+
const path = resolveTargetAbsPath(target, ctx);
|
|
318
|
+
return target.format === "toml"
|
|
319
|
+
? inspectTomlHostEntry(path, deps)
|
|
320
|
+
: inspectJsonHostEntry(path, target, deps);
|
|
321
|
+
}
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// Vendor CLI invocation builders (argv-isolated; secret-bearing for some kinds)
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
/**
|
|
326
|
+
* Whether a vendor kind's secret-delivery contract is VERIFIED. Only verified
|
|
327
|
+
* contracts are ever invoked; an uncertain contract falls back to a direct
|
|
328
|
+
* write rather than guessing argv. Verified from empirical probes (BAPI-635):
|
|
329
|
+
* claude `mcp add-json <name> <json>` and copilot `mcp add --env` both deliver
|
|
330
|
+
* the secret through argv. Codex's contract is unverified (binary absent at
|
|
331
|
+
* authoring time) → never invoked; always direct-written.
|
|
332
|
+
*/
|
|
333
|
+
function isVendorContractVerified(kind) {
|
|
334
|
+
return kind === "claude-add-json" || kind === "copilot-add";
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Build the vendor add invocation for a target. Returns `null` when the
|
|
338
|
+
* contract is unverified (caller must fall back to a direct write). The returned
|
|
339
|
+
* `args` may be secret-bearing and MUST be handed straight to the process
|
|
340
|
+
* boundary without logging.
|
|
341
|
+
*/
|
|
342
|
+
function buildVendorInvocation(target, entry) {
|
|
343
|
+
const vendor = target.vendorCli;
|
|
344
|
+
if (!vendor || !isVendorContractVerified(vendor.kind))
|
|
345
|
+
return null;
|
|
346
|
+
if (vendor.kind === "claude-add-json") {
|
|
347
|
+
// claude mcp add-json <name> <json> --scope project
|
|
348
|
+
// The JSON payload carries env.BAPI_API_KEY → secret-bearing argv.
|
|
349
|
+
const json = JSON.stringify({
|
|
350
|
+
command: entry.command,
|
|
351
|
+
args: entry.args,
|
|
352
|
+
env: entry.env,
|
|
353
|
+
});
|
|
354
|
+
return {
|
|
355
|
+
bin: vendor.bin,
|
|
356
|
+
args: ["mcp", "add-json", "bridge-api", json, "--scope", "project"],
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
if (vendor.kind === "copilot-add") {
|
|
360
|
+
// copilot mcp add bridge-api --tools "*" --env K=V ... -- <command> <args...>
|
|
361
|
+
// Env pairs (incl BAPI_API_KEY) are secret-bearing argv.
|
|
362
|
+
const envArgs = [];
|
|
363
|
+
for (const [k, v] of Object.entries(entry.env)) {
|
|
364
|
+
envArgs.push("--env", `${k}=${v}`);
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
bin: vendor.bin,
|
|
368
|
+
args: [
|
|
369
|
+
"mcp",
|
|
370
|
+
"add",
|
|
371
|
+
"bridge-api",
|
|
372
|
+
"--tools",
|
|
373
|
+
"*",
|
|
374
|
+
...envArgs,
|
|
375
|
+
"--",
|
|
376
|
+
entry.command,
|
|
377
|
+
...entry.args,
|
|
378
|
+
],
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
// Provisioning orchestration
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
function outcome(target, status, detail) {
|
|
387
|
+
return {
|
|
388
|
+
platform: target.id,
|
|
389
|
+
status,
|
|
390
|
+
displayPath: target.displayPath,
|
|
391
|
+
detail,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
/** Direct-write a single target (JSON or Codex TOML) and map to an outcome. */
|
|
395
|
+
async function directWriteTarget(target, entry, deps) {
|
|
396
|
+
const path = resolveTargetAbsPath(target, deps);
|
|
397
|
+
if (target.format === "toml") {
|
|
398
|
+
const res = await writeCodexHostConfig(path, entry, deps.fs);
|
|
399
|
+
if (res.status === "manual-required") {
|
|
400
|
+
return outcome(target, "manual-required", "existing config could not be safely updated");
|
|
401
|
+
}
|
|
402
|
+
return outcome(target, "direct-written");
|
|
403
|
+
}
|
|
404
|
+
const adapted = adaptBridgeEntryForHostTarget(entry, target);
|
|
405
|
+
const res = await writeJsonHostConfig(path, target, adapted, deps.fs);
|
|
406
|
+
if (res.status === "skipped-invalid") {
|
|
407
|
+
return outcome(target, "skipped-invalid", res.message);
|
|
408
|
+
}
|
|
409
|
+
return outcome(target, "direct-written");
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Provision a single host target, honoring its write strategy:
|
|
413
|
+
* - manual-instructions → `manual-required`
|
|
414
|
+
* - direct → JSON merge-write (or Codex safe write)
|
|
415
|
+
* - vendor-first → probe + invoke the verified vendor CLI, verify the entry
|
|
416
|
+
* landed, else fall back to a safe direct write. Any missing binary, spawn
|
|
417
|
+
* error, timeout, non-zero exit, or missing post-write entry triggers the
|
|
418
|
+
* fallback rather than a fatal error.
|
|
419
|
+
*/
|
|
420
|
+
export async function provisionHostTarget(target, entry, deps) {
|
|
421
|
+
if (target.writeStrategy === "manual-instructions") {
|
|
422
|
+
return outcome(target, "manual-required", "global config — manual setup");
|
|
423
|
+
}
|
|
424
|
+
if (target.writeStrategy === "direct") {
|
|
425
|
+
return directWriteTarget(target, entry, deps);
|
|
426
|
+
}
|
|
427
|
+
// vendor-first
|
|
428
|
+
const invocation = buildVendorInvocation(target, entry);
|
|
429
|
+
if (invocation) {
|
|
430
|
+
const available = await deps.vendor.probeBinary(invocation.bin, deps.env);
|
|
431
|
+
if (available) {
|
|
432
|
+
const result = await deps.vendor.invokeVendorAdd(invocation, deps.env);
|
|
433
|
+
if (result.ok) {
|
|
434
|
+
// Verify the entry actually landed before trusting the vendor CLI.
|
|
435
|
+
const inspected = await inspectHostEntry(target, deps, deps.fs);
|
|
436
|
+
if (inspected.present) {
|
|
437
|
+
return outcome(target, "vendor-written");
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// Fallback: safe direct write (covers unverified contracts like Codex, a
|
|
443
|
+
// missing/failed vendor binary, or a nominally-successful write that left no
|
|
444
|
+
// entry).
|
|
445
|
+
return directWriteTarget(target, entry, deps);
|
|
446
|
+
}
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
// Default vendor process boundary (spawns real vendor CLIs, secret-safe)
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
/** Env keys stripped from a read-only probe environment. */
|
|
451
|
+
const PROBE_SECRET_KEYS = ["BAPI_API_KEY", "BAPI_INVITE", "BAPI_SIGNUP_EMAIL"];
|
|
452
|
+
/** Return a shallow env clone with credential sentinels removed. */
|
|
453
|
+
export function sanitizeProbeEnv(env) {
|
|
454
|
+
const clone = { ...env };
|
|
455
|
+
for (const key of PROBE_SECRET_KEYS)
|
|
456
|
+
delete clone[key];
|
|
457
|
+
return clone;
|
|
458
|
+
}
|
|
459
|
+
/** The launcher spec for the current version (pins are version-locked). */
|
|
460
|
+
export function currentLauncherSpec() {
|
|
461
|
+
return `@bridge_gpt/mcp-server@${VERSION}`;
|
|
462
|
+
}
|
|
463
|
+
/** Bounded probe/invoke timeout (ms). */
|
|
464
|
+
const VENDOR_PROCESS_TIMEOUT_MS = 8000;
|
|
465
|
+
/**
|
|
466
|
+
* Build the default vendor process boundary. Both operations use array-based
|
|
467
|
+
* spawning with `shell:false`, ignored stdio, a bounded timeout, and a sanitized
|
|
468
|
+
* env for the read-only probe. Every path resolves (never rejects) so a vendor
|
|
469
|
+
* failure is a fallback trigger, not a crash. Secret-bearing argv is passed
|
|
470
|
+
* straight to `spawn` and never logged.
|
|
471
|
+
*/
|
|
472
|
+
export function createDefaultVendorProcessDeps(spawnFn) {
|
|
473
|
+
return {
|
|
474
|
+
probeBinary: (bin, env) => new Promise((resolve) => {
|
|
475
|
+
let settled = false;
|
|
476
|
+
const done = (ok) => {
|
|
477
|
+
if (settled)
|
|
478
|
+
return;
|
|
479
|
+
settled = true;
|
|
480
|
+
resolve(ok);
|
|
481
|
+
};
|
|
482
|
+
try {
|
|
483
|
+
const child = spawnFn(bin, ["--version"], {
|
|
484
|
+
stdio: "ignore",
|
|
485
|
+
shell: false,
|
|
486
|
+
env: sanitizeProbeEnv(env),
|
|
487
|
+
timeout: VENDOR_PROCESS_TIMEOUT_MS,
|
|
488
|
+
});
|
|
489
|
+
child.on("error", () => done(false));
|
|
490
|
+
child.on("close", (code) => done(code === 0));
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
done(false);
|
|
494
|
+
}
|
|
495
|
+
}),
|
|
496
|
+
invokeVendorAdd: (invocation, env) => new Promise((resolve) => {
|
|
497
|
+
let settled = false;
|
|
498
|
+
const done = (ok) => {
|
|
499
|
+
if (settled)
|
|
500
|
+
return;
|
|
501
|
+
settled = true;
|
|
502
|
+
resolve({ ok });
|
|
503
|
+
};
|
|
504
|
+
try {
|
|
505
|
+
const child = spawnFn(invocation.bin, invocation.args, {
|
|
506
|
+
stdio: "ignore",
|
|
507
|
+
shell: false,
|
|
508
|
+
// The add command needs the credential sentinels in argv, not env;
|
|
509
|
+
// env stays sanitized so nothing leaks through a second channel.
|
|
510
|
+
env: sanitizeProbeEnv(env),
|
|
511
|
+
timeout: VENDOR_PROCESS_TIMEOUT_MS,
|
|
512
|
+
});
|
|
513
|
+
child.on("error", () => done(false));
|
|
514
|
+
child.on("close", (code) => done(code === 0));
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
done(false);
|
|
518
|
+
}
|
|
519
|
+
}),
|
|
520
|
+
};
|
|
521
|
+
}
|