@bridge_gpt/mcp-server 0.2.36 → 0.2.38

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.
Files changed (92) hide show
  1. package/README.md +48 -8
  2. package/build/base-url.js +79 -0
  3. package/build/bridge-api-urls.js +9 -0
  4. package/build/chain-orchestrator.js +93 -15
  5. package/build/claude-user-config-doctor.js +317 -0
  6. package/build/commands.generated.js +2 -1
  7. package/build/conductor/bridge-api-client.js +178 -4
  8. package/build/conductor-bin.js +1 -1
  9. package/build/conductor-bundle-artifacts.js +7 -6
  10. package/build/credential-store.js +205 -4
  11. package/build/direct-ticket-tools.js +70 -0
  12. package/build/doctor.js +239 -80
  13. package/build/executor/cli.js +51 -1
  14. package/build/executor/credentials.js +1 -7
  15. package/build/executor/deps.js +18 -1
  16. package/build/executor/env.js +51 -25
  17. package/build/executor/heartbeat.js +138 -17
  18. package/build/executor/http-client.js +49 -8
  19. package/build/executor/job-errors.js +4 -0
  20. package/build/executor/job-runner.js +422 -22
  21. package/build/executor/observation.js +130 -0
  22. package/build/executor/permissions.js +104 -8
  23. package/build/executor/preflight.js +32 -0
  24. package/build/executor/runner.js +8 -0
  25. package/build/executor/test-clock.js +67 -3
  26. package/build/executor/types.js +4 -1
  27. package/build/executor/worker-command.js +11 -3
  28. package/build/executor/worker-config-isolation.js +287 -0
  29. package/build/executor/worker-finalization.js +68 -14
  30. package/build/executor/worktree.js +46 -4
  31. package/build/index.js +614 -244
  32. package/build/init.js +363 -73
  33. package/build/install-bridge.js +568 -80
  34. package/build/launcher-config-inspection.js +351 -0
  35. package/build/mcp-invoke.js +49 -6
  36. package/build/mcp-provisioning.js +30 -7
  37. package/build/mcp-registration-doctor.js +14 -5
  38. package/build/notifications.js +553 -0
  39. package/build/pipeline-orchestrator.js +146 -4
  40. package/build/pipeline-utils.js +3 -0
  41. package/build/pipelines.generated.js +22 -9
  42. package/build/plan-execution-ledger.js +550 -0
  43. package/build/plan-phase-routing.js +272 -0
  44. package/build/plane/alembic-head.js +110 -0
  45. package/build/plane/build-freshness.js +167 -0
  46. package/build/plane/cli.js +480 -0
  47. package/build/plane/defaults.js +266 -0
  48. package/build/plane/manifest.js +377 -0
  49. package/build/plane/member-logs.js +147 -0
  50. package/build/plane/member-roster.js +147 -0
  51. package/build/plane/preflight.js +289 -0
  52. package/build/plane/shutdown.js +195 -0
  53. package/build/plane/status.js +125 -0
  54. package/build/plane/supervisor.js +569 -0
  55. package/build/plane/test-fakes.js +156 -0
  56. package/build/plane/types.js +75 -0
  57. package/build/readme.generated.js +1 -1
  58. package/build/run-unit-tests-launcher.js +2 -0
  59. package/build/setup-epic.js +662 -27
  60. package/build/sfcc/log-gate.js +38 -11
  61. package/build/sfcc/log-query.js +55 -15
  62. package/build/sfcc/ocapi-shape.js +70 -14
  63. package/build/sfcc/output.js +41 -11
  64. package/build/sfcc/permissions.js +24 -2
  65. package/build/sfcc/read-body.js +92 -0
  66. package/build/sfcc/read-projection.js +185 -0
  67. package/build/sfcc/read-result.js +158 -0
  68. package/build/sfcc/reads-custom-object-def.js +57 -34
  69. package/build/sfcc/reads-site-preference.js +86 -33
  70. package/build/sfcc/reads-system-object.js +50 -38
  71. package/build/sfcc/sfcc-result.js +106 -0
  72. package/build/sfcc/tool-wrapper.js +56 -13
  73. package/build/sfcc/write-grants.js +45 -22
  74. package/build/sfcc/write-guard.js +21 -13
  75. package/build/sfcc/write-result.js +71 -15
  76. package/build/sfcc/write-tool-common.js +126 -32
  77. package/build/sfcc/writes-custom-object-def.js +6 -2
  78. package/build/sfcc/writes-system-object.js +11 -50
  79. package/build/start-tickets-prereqs.js +129 -0
  80. package/build/start-tickets.js +17 -13
  81. package/build/ticket-backend-metadata.js +59 -0
  82. package/build/ticket-key-utils.js +92 -0
  83. package/build/tool-error-envelope.js +71 -0
  84. package/build/tool-surface-gating.js +72 -0
  85. package/build/update-status.js +102 -0
  86. package/build/upgrade-advice.js +47 -0
  87. package/build/upgrade-cli.js +417 -101
  88. package/build/version.generated.js +1 -1
  89. package/build/worktree-core.js +73 -0
  90. package/docs/CONDUCTOR.md +23 -8
  91. package/package.json +3 -3
  92. package/pipelines/implement-ticket.json +15 -5
@@ -0,0 +1,351 @@
1
+ /**
2
+ * Shared, read-only inspection of every project-local MCP launcher config
3
+ * (BAPI-728).
4
+ *
5
+ * Before this module, three callers each carried their own idea of what a
6
+ * launcher config looks like:
7
+ *
8
+ * - `doctor.ts` parsed the pin correctly and reported `STALE-PINNED`.
9
+ * - `upgrade-cli.ts` kept a duplicate path list and never read the pin at all,
10
+ * deciding from the running process `VERSION` instead — which is why a
11
+ * twenty-release repin reported itself as `0.2.36 -> 0.2.36`.
12
+ * - `init.ts` repaired the pin as an undocumented side effect of writing.
13
+ *
14
+ * This module is the single authority for *what a config currently says*. It is
15
+ * strictly read-only: it never writes, never spawns, never touches the network,
16
+ * and never resolves credentials. Callers layer their own concerns on top —
17
+ * `doctor` adds a read-only npx cache probe, `init` performs the mutation,
18
+ * `upgrade` renders the transition report and owns exit status.
19
+ *
20
+ * Secret safety: an inspection result carries only sanitized launcher facts —
21
+ * relative path, launcher shape, the package spec token, and versions. It never
22
+ * carries config bodies, `env` maps, credential values, or raw error text.
23
+ */
24
+ import path from "path";
25
+ /** The npm package every Bridge launcher spec names. */
26
+ export const BRIDGE_PACKAGE_NAME = "@bridge_gpt/mcp-server";
27
+ /**
28
+ * The subcommand token that marks a worktree shim registration. A shim points at
29
+ * a sibling worktree's server rather than a published package, so no pin applies
30
+ * and no rewrite is ever safe (BAPI-714 / worktree provisioning).
31
+ */
32
+ const WORKTREE_SHIM_TOKEN = "mcp-invoke";
33
+ /** The MCP server entry key every Bridge registration uses. */
34
+ export const BRIDGE_ENTRY_KEY = "bridge-api";
35
+ /**
36
+ * Every project-local MCP config Bridge registers into. Exported so `doctor`,
37
+ * `init`, and `upgrade` can no longer drift apart on which paths exist or which
38
+ * root key each one uses. Callers that need a different *order* should map over
39
+ * their own list and use {@link launcherTargetFor} for the metadata.
40
+ */
41
+ export const LAUNCHER_CONFIG_TARGETS = [
42
+ { relPath: ".mcp.json", topLevelKey: "mcpServers" },
43
+ { relPath: ".cursor/mcp.json", topLevelKey: "mcpServers" },
44
+ { relPath: ".vscode/mcp.json", topLevelKey: "servers" },
45
+ ];
46
+ /** Look up a target's metadata by relative path, or `undefined` if unknown. */
47
+ export function launcherTargetFor(relPath) {
48
+ return LAUNCHER_CONFIG_TARGETS.find((t) => t.relPath === relPath);
49
+ }
50
+ // ---------------------------------------------------------------------------
51
+ // Exact-version parsing and comparison
52
+ // ---------------------------------------------------------------------------
53
+ /**
54
+ * Strict exact-release form: `MAJOR.MINOR.PATCH`, nothing else. Deliberately
55
+ * narrower than semver at large — a prerelease, build metadata, a `v` prefix, a
56
+ * two-part `0.2`, a range (`^0.2.0`), or a dist-tag (`beta`) is NOT an exact
57
+ * version and must never be treated as one. Everything the package publishes
58
+ * takes this form, so anything else in a config is something a human wrote and
59
+ * an automated repin has no business silently rewriting.
60
+ */
61
+ const EXACT_VERSION_RE = /^(\d+)\.(\d+)\.(\d+)$/;
62
+ /** Parse an exact `MAJOR.MINOR.PATCH` string, or return null for any other form. */
63
+ export function parseExactVersion(raw) {
64
+ if (typeof raw !== "string")
65
+ return null;
66
+ const match = EXACT_VERSION_RE.exec(raw.trim());
67
+ if (!match)
68
+ return null;
69
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
70
+ }
71
+ /** True when `value` is an exact `MAJOR.MINOR.PATCH` release string. */
72
+ export function isExactVersion(raw) {
73
+ return parseExactVersion(raw) !== null;
74
+ }
75
+ /**
76
+ * Compare two exact versions: negative when `a < b`, zero when equal, positive
77
+ * when `a > b`. Returns null when either side is not an exact version, so a
78
+ * caller can never accidentally order a range against a release.
79
+ */
80
+ export function compareExactVersions(a, b) {
81
+ const left = parseExactVersion(a);
82
+ const right = parseExactVersion(b);
83
+ if (!left || !right)
84
+ return null;
85
+ for (let i = 0; i < 3; i++) {
86
+ if (left[i] !== right[i])
87
+ return left[i] - right[i];
88
+ }
89
+ return 0;
90
+ }
91
+ /**
92
+ * Extract the Bridge package token from a launcher args array.
93
+ *
94
+ * The package name is itself scoped (`@scope/name`), so the version delimiter is
95
+ * the FIRST `@` *after* the package name — not the last `@` in the string.
96
+ * `@bridge_gpt/mcp-server` is bare; `@bridge_gpt/mcp-server@0.2.16` pins
97
+ * `0.2.16`. A non-Bridge package token is never matched.
98
+ */
99
+ export function parseLauncherPin(args) {
100
+ if (!Array.isArray(args))
101
+ return null;
102
+ for (const arg of args) {
103
+ if (typeof arg !== "string")
104
+ continue;
105
+ if (arg === BRIDGE_PACKAGE_NAME) {
106
+ return { spec: arg, specifier: null, exactVersion: null };
107
+ }
108
+ if (arg.startsWith(`${BRIDGE_PACKAGE_NAME}@`)) {
109
+ const specifier = arg.slice(BRIDGE_PACKAGE_NAME.length + 1).trim();
110
+ if (specifier.length === 0) {
111
+ return { spec: arg, specifier: null, exactVersion: null };
112
+ }
113
+ return {
114
+ spec: arg,
115
+ specifier,
116
+ exactVersion: isExactVersion(specifier) ? specifier : null,
117
+ };
118
+ }
119
+ }
120
+ return null;
121
+ }
122
+ /** True when the args array carries the worktree-shim subcommand token. */
123
+ export function isWorktreeShimArgs(args) {
124
+ return Array.isArray(args) && args.some((a) => a === WORKTREE_SHIM_TOKEN);
125
+ }
126
+ /** Human-readable text for each reason code (no dynamic content). */
127
+ const REASON_TEXT = {
128
+ "file-unreadable": "the file exists but could not be read",
129
+ "invalid-json": "the file is not valid JSON",
130
+ "root-not-object": "the JSON root is not an object",
131
+ "servers-not-object": "the servers section is not an object",
132
+ "entry-not-object": "the bridge-api entry is not an object",
133
+ "no-package-token": "the bridge-api launcher has no recognizable @bridge_gpt/mcp-server token",
134
+ "version-range": "the launcher pin is a version range, which an automated repin must not rewrite",
135
+ "non-release-specifier": "the launcher pin is a dist-tag or channel, not an exact release",
136
+ "malformed-version": "the launcher pin is not a valid MAJOR.MINOR.PATCH release",
137
+ "pinned-ahead-of-target": "the launcher pin is newer than the target version and must not be downgraded",
138
+ };
139
+ /** Render a reason code as sanitized prose. */
140
+ export function describeLauncherReason(reason) {
141
+ return REASON_TEXT[reason];
142
+ }
143
+ /** True for the two actions that must abort a mutation run before any write. */
144
+ export function isPrerequisiteFailure(inspection) {
145
+ return inspection.action === "invalid" || inspection.action === "unsupported";
146
+ }
147
+ /**
148
+ * True when this target carries a standard launcher whose pin participates in
149
+ * the "is everything already current?" question. Worktree shims, inactive
150
+ * targets, and absent files deliberately do not.
151
+ */
152
+ export function isApplicableStandardLauncher(inspection) {
153
+ return (inspection.shape === "standard" &&
154
+ (inspection.action === "repin" ||
155
+ inspection.action === "already-current" ||
156
+ inspection.action === "normalize-unpinned"));
157
+ }
158
+ /**
159
+ * Inspect every project-local MCP config's `bridge-api` launcher, read-only.
160
+ *
161
+ * Every target always produces exactly one result, so a caller can render one
162
+ * line per config without re-deriving which paths it looked at. Only an `ENOENT`
163
+ * read is treated as absence — a permission error on an existing file is an
164
+ * `invalid` prerequisite failure, never a "missing file we can freshly create".
165
+ */
166
+ export async function inspectLauncherConfigs(options) {
167
+ const targets = options.targets ?? LAUNCHER_CONFIG_TARGETS;
168
+ const results = [];
169
+ for (const target of targets) {
170
+ results.push(await inspectOne(target, options));
171
+ }
172
+ return results;
173
+ }
174
+ async function inspectOne(target, options) {
175
+ const { targetVersion } = options;
176
+ const base = {
177
+ relPath: target.relPath,
178
+ topLevelKey: target.topLevelKey,
179
+ targetVersion,
180
+ spec: null,
181
+ currentVersion: null,
182
+ };
183
+ let raw;
184
+ try {
185
+ raw = await options.readFile(path.join(options.cwd, target.relPath));
186
+ }
187
+ catch (err) {
188
+ const code = err && typeof err === "object" ? err.code : undefined;
189
+ if (code === "ENOENT") {
190
+ const creatable = options.isCreatable?.(target.relPath) ?? false;
191
+ return {
192
+ ...base,
193
+ filePresent: false,
194
+ bridgeEntryPresent: false,
195
+ shape: "none",
196
+ action: creatable ? "create" : "skip-inactive",
197
+ };
198
+ }
199
+ // Present-but-unreadable is NOT absence. Creating a fresh file over a config
200
+ // we merely failed to read would silently destroy a working registration.
201
+ return {
202
+ ...base,
203
+ filePresent: true,
204
+ bridgeEntryPresent: false,
205
+ shape: "none",
206
+ action: "invalid",
207
+ reason: "file-unreadable",
208
+ };
209
+ }
210
+ let parsed;
211
+ try {
212
+ parsed = JSON.parse(raw);
213
+ }
214
+ catch {
215
+ return {
216
+ ...base,
217
+ filePresent: true,
218
+ bridgeEntryPresent: false,
219
+ shape: "none",
220
+ action: "invalid",
221
+ reason: "invalid-json",
222
+ };
223
+ }
224
+ if (!isPlainObject(parsed)) {
225
+ return {
226
+ ...base,
227
+ filePresent: true,
228
+ bridgeEntryPresent: false,
229
+ shape: "none",
230
+ action: "invalid",
231
+ reason: "root-not-object",
232
+ };
233
+ }
234
+ const servers = parsed[target.topLevelKey];
235
+ if (servers !== undefined && !isPlainObject(servers)) {
236
+ return {
237
+ ...base,
238
+ filePresent: true,
239
+ bridgeEntryPresent: false,
240
+ shape: "none",
241
+ action: "invalid",
242
+ reason: "servers-not-object",
243
+ };
244
+ }
245
+ const entry = isPlainObject(servers)
246
+ ? servers[BRIDGE_ENTRY_KEY]
247
+ : undefined;
248
+ if (entry === undefined) {
249
+ // A valid host config that simply has no Bridge registration yet. Other
250
+ // servers in the same file are deliberately not inspected or reported.
251
+ return {
252
+ ...base,
253
+ filePresent: true,
254
+ bridgeEntryPresent: false,
255
+ shape: "none",
256
+ action: "add",
257
+ };
258
+ }
259
+ if (!isPlainObject(entry)) {
260
+ return {
261
+ ...base,
262
+ filePresent: true,
263
+ bridgeEntryPresent: true,
264
+ shape: "custom",
265
+ action: "invalid",
266
+ reason: "entry-not-object",
267
+ };
268
+ }
269
+ const args = entry.args;
270
+ // Worktree shims are detected BEFORE any package-token requirement: the
271
+ // absolute-build-path form (`node /abs/path/build/index.js mcp-invoke`) has no
272
+ // package token at all, and must not be misread as an unsupported launcher.
273
+ if (isWorktreeShimArgs(args)) {
274
+ return {
275
+ ...base,
276
+ filePresent: true,
277
+ bridgeEntryPresent: true,
278
+ shape: "worktree-shim",
279
+ action: "skip-worktree-shim",
280
+ };
281
+ }
282
+ const pin = parseLauncherPin(args);
283
+ if (!pin) {
284
+ return {
285
+ ...base,
286
+ filePresent: true,
287
+ bridgeEntryPresent: true,
288
+ shape: "custom",
289
+ action: "unsupported",
290
+ reason: "no-package-token",
291
+ };
292
+ }
293
+ const common = {
294
+ ...base,
295
+ filePresent: true,
296
+ bridgeEntryPresent: true,
297
+ shape: "standard",
298
+ spec: pin.spec,
299
+ };
300
+ // Bare and explicit `@latest` are the two forms an exact-pin policy normalizes
301
+ // rather than rejects — both mean "whatever npx resolves", which is exactly the
302
+ // non-determinism BAPI-429 pinned away from.
303
+ if (pin.specifier === null || pin.specifier === "latest") {
304
+ return { ...common, action: "normalize-unpinned" };
305
+ }
306
+ if (!isExactVersion(pin.specifier)) {
307
+ return {
308
+ ...common,
309
+ action: "unsupported",
310
+ reason: classifyNonExactSpecifier(pin.specifier),
311
+ };
312
+ }
313
+ const comparison = compareExactVersions(pin.specifier, targetVersion);
314
+ if (comparison === null) {
315
+ // The TARGET is not an exact release. Refuse rather than guess.
316
+ return {
317
+ ...common,
318
+ currentVersion: pin.specifier,
319
+ action: "unsupported",
320
+ reason: "malformed-version",
321
+ };
322
+ }
323
+ if (comparison > 0) {
324
+ return {
325
+ ...common,
326
+ currentVersion: pin.specifier,
327
+ action: "ahead",
328
+ reason: "pinned-ahead-of-target",
329
+ };
330
+ }
331
+ return {
332
+ ...common,
333
+ currentVersion: pin.specifier,
334
+ action: comparison === 0 ? "already-current" : "repin",
335
+ };
336
+ }
337
+ /** Classify why a non-exact specifier cannot be treated as a release pin. */
338
+ function classifyNonExactSpecifier(specifier) {
339
+ if (/^[\^~]|^[<>=]/.test(specifier) || specifier.includes(" - ") || specifier.includes("||")) {
340
+ return "version-range";
341
+ }
342
+ // A leading digit means the author meant a version and got the shape wrong
343
+ // (`0.2`, `1.2.3.4`); anything else is a dist-tag or channel (`beta`, `next`).
344
+ if (/^v?\d/.test(specifier))
345
+ return "malformed-version";
346
+ return "non-release-specifier";
347
+ }
348
+ /** True for a non-null, non-array object. */
349
+ function isPlainObject(value) {
350
+ return typeof value === "object" && value !== null && !Array.isArray(value);
351
+ }
@@ -25,6 +25,7 @@ import path from "path";
25
25
  import os from "os";
26
26
  import { resolveRepoNameForProjectRoot, readBridgeConfig, validateMcpTarget, } from "./bridge-config.js";
27
27
  import { resolveBapiCredentials } from "./credential-store.js";
28
+ import { describeBaseUrlRejection, validateHttpBaseUrl } from "./base-url.js";
28
29
  import { getThirdPartyTargetDefinition, resolveThirdPartyTargetEnv, validateThirdPartyTargetManifestEntry, } from "./third-party-mcp-targets.js";
29
30
  // ---------------------------------------------------------------------------
30
31
  // Usage / argument parsing
@@ -47,18 +48,30 @@ export function getMcpInvokeUsage() {
47
48
  " API server; a configured third-party target from",
48
49
  " .bridge/config (e.g. sfcc) launches that server.",
49
50
  " --project-root <ABS_PATH> Absolute path to the worktree (required)",
51
+ " --base-url <HTTP(S)_URL> Optional. Bridge API endpoint for the spawned",
52
+ " server; applies ONLY to --target bapi. When",
53
+ " omitted, the child inherits BAPI_BASE_URL",
54
+ " unchanged. Must be an absolute http/https URL",
55
+ " with no embedded username or password.",
50
56
  " -h, --help Show this help",
51
57
  ].join("\n");
52
58
  }
53
- const KNOWN_FLAGS = new Set(["--target", "--project-root"]);
59
+ const KNOWN_FLAGS = new Set(["--target", "--project-root", "--base-url"]);
54
60
  /**
55
- * Parse `--target` and `--project-root` (split or `=` form). Rejects unknown
56
- * flags, positional arguments, missing values, and duplicates. Requires a safe
57
- * non-empty `--target` identifier and a host-platform-absolute `--project-root`.
61
+ * Parse `--target`, `--project-root`, and the optional `--base-url` (split or `=`
62
+ * form). Rejects unknown flags, positional arguments, missing values, and
63
+ * duplicates. Requires a safe non-empty `--target` identifier and a
64
+ * host-platform-absolute `--project-root`.
65
+ *
66
+ * `--base-url` is validated strictly and is valid ONLY with `--target bapi`;
67
+ * pairing it with any other target is an error rather than a silent no-op, so a
68
+ * misgenerated registration fails loudly instead of quietly routing a Tier-2
69
+ * server nowhere. No rejection message ever echoes the supplied URL.
58
70
  */
59
71
  export function parseMcpInvokeArgs(argv) {
60
72
  let target;
61
73
  let projectRoot;
74
+ let baseUrl;
62
75
  for (let i = 0; i < argv.length; i++) {
63
76
  const token = argv[i];
64
77
  if (token === "-h" || token === "--help") {
@@ -91,6 +104,16 @@ export function parseMcpInvokeArgs(argv) {
91
104
  }
92
105
  target = value;
93
106
  }
107
+ else if (flag === "--base-url") {
108
+ if (baseUrl !== undefined) {
109
+ return { status: "error", message: "Duplicate --base-url flag" };
110
+ }
111
+ const validation = validateHttpBaseUrl(value);
112
+ if (!validation.ok) {
113
+ return { status: "error", message: describeBaseUrlRejection("--base-url", validation.reason) };
114
+ }
115
+ baseUrl = validation.baseUrl;
116
+ }
94
117
  else {
95
118
  if (projectRoot !== undefined) {
96
119
  return { status: "error", message: "Duplicate --project-root flag" };
@@ -111,7 +134,15 @@ export function parseMcpInvokeArgs(argv) {
111
134
  if (!path.isAbsolute(projectRoot)) {
112
135
  return { status: "error", message: "--project-root must be an absolute path" };
113
136
  }
114
- return { status: "ok", target: targetValidation.value, projectRoot };
137
+ if (baseUrl !== undefined && targetValidation.value !== "bapi") {
138
+ return {
139
+ status: "error",
140
+ message: "--base-url is only valid with --target bapi",
141
+ };
142
+ }
143
+ return baseUrl === undefined
144
+ ? { status: "ok", target: targetValidation.value, projectRoot }
145
+ : { status: "ok", target: targetValidation.value, projectRoot, baseUrl };
115
146
  }
116
147
  // ---------------------------------------------------------------------------
117
148
  // Validation, env construction, signals
@@ -284,6 +315,15 @@ export async function resolveBapiInvocation(projectRoot, deps) {
284
315
  if (typeof conductorNodePath === "string" && conductorNodePath.trim().length > 0) {
285
316
  overlay.CONDUCTOR_NODE_PATH = conductorNodePath;
286
317
  }
318
+ // BAPI-727: overlay the endpoint ONLY when `--base-url` was supplied, mirroring
319
+ // the conditional `CONDUCTOR_NODE_PATH` overlay directly above. The absent case
320
+ // must add, delete, normalize, and override nothing — `buildChildEnv` already
321
+ // inherits the parent's `BAPI_BASE_URL`, and the server keeps its own production
322
+ // default for callers that were never given a URL. Like `CONDUCTOR_NODE_PATH`,
323
+ // this is non-secret routing metadata, not a credential.
324
+ if (typeof deps.baseUrl === "string" && deps.baseUrl.length > 0) {
325
+ overlay.BAPI_BASE_URL = deps.baseUrl;
326
+ }
287
327
  const env = buildChildEnv(deps.env, overlay);
288
328
  return {
289
329
  ok: true,
@@ -356,7 +396,7 @@ export async function runMcpInvokeCli(argv, overrides = {}) {
356
396
  stderr(getMcpInvokeUsage());
357
397
  return 1;
358
398
  }
359
- const { target, projectRoot } = parsed;
399
+ const { target, projectRoot, baseUrl } = parsed;
360
400
  const statFn = overrides.stat ?? ((p) => stat(p));
361
401
  const dirCheck = await validateProjectRootDirectory(projectRoot, statFn);
362
402
  if (!dirCheck.ok) {
@@ -379,12 +419,15 @@ export async function runMcpInvokeCli(argv, overrides = {}) {
379
419
  }));
380
420
  const resolveCredentials = overrides.resolveCredentials ??
381
421
  ((rn) => resolveBapiCredentials(rn, credentialDeps));
422
+ // The parsed URL reaches ONLY this branch; the parser already rejected
423
+ // `--base-url` for every other target, so the Tier-2 path below cannot see it.
382
424
  const resolved = await resolveBapiInvocation(projectRoot, {
383
425
  env,
384
426
  execPath: process.execPath,
385
427
  scriptPath: process.argv[1],
386
428
  resolveRepoName,
387
429
  resolveCredentials,
430
+ baseUrl,
388
431
  });
389
432
  if (!resolved.ok) {
390
433
  stderr(`Error: ${resolved.error}`);
@@ -78,13 +78,29 @@ export function serverNameForMcpTarget(target) {
78
78
  * form preferred) and never depends on a worker-injected `CONDUCTOR_NODE_PATH`
79
79
  * or any other worker-shell env — the executor worker environment carries no
80
80
  * such key at all (see `executor/env.ts`).
81
+ *
82
+ * BAPI-727 extends the BAPI-527 note above rather than contradicting it. The
83
+ * optional `bridgeApiBaseUrl` is appended as the ARGUMENT `--base-url <url>`, and
84
+ * only for the `bapi` target. A Bridge API endpoint is non-secret ROUTING
85
+ * metadata — the same value that appears in an operator's `--base-url` flag and in
86
+ * generated executor service units — so persisting it does not weaken the
87
+ * no-`env`, no-credential registration invariant. The entry keeps exactly
88
+ * `command` and `args`; credentials continue to be resolved by the `mcp-invoke`
89
+ * shim at launch and are never written into the worktree.
81
90
  */
82
- export function buildShimMcpServerEntry(target, absoluteWorktreePath, invocation) {
83
- return buildMcpShimCommand(invocation, target, absoluteWorktreePath);
91
+ export function buildShimMcpServerEntry(target, absoluteWorktreePath, invocation, bridgeApiBaseUrl) {
92
+ const entry = buildMcpShimCommand(invocation, target, absoluteWorktreePath);
93
+ // Tier-2 targets are structurally unchanged: `--base-url` is a `bapi`-only flag
94
+ // and `mcp-invoke` rejects it for any other target, so appending it elsewhere
95
+ // would generate a registration that refuses to launch.
96
+ if (target !== "bapi" || bridgeApiBaseUrl === undefined)
97
+ return entry;
98
+ // Clone/extend the generated args; never add a property beyond command/args.
99
+ return { command: entry.command, args: [...entry.args, "--base-url", bridgeApiBaseUrl] };
84
100
  }
85
101
  /** Back-compat wrapper around `buildShimMcpServerEntry("bapi", ...)`. */
86
- export function buildBridgeApiShimMcpServerEntry(absoluteWorktreePath, invocation) {
87
- return buildShimMcpServerEntry("bapi", absoluteWorktreePath, invocation);
102
+ export function buildBridgeApiShimMcpServerEntry(absoluteWorktreePath, invocation, bridgeApiBaseUrl) {
103
+ return buildShimMcpServerEntry("bapi", absoluteWorktreePath, invocation, bridgeApiBaseUrl);
88
104
  }
89
105
  /**
90
106
  * Convert all supported manifest `mcp` entries into a map of server name -> shim
@@ -93,12 +109,14 @@ export function buildBridgeApiShimMcpServerEntry(absoluteWorktreePath, invocatio
93
109
  * or incomplete non-`bapi` targets are skipped with a secret-free warning (they
94
110
  * never appear in the returned entries).
95
111
  */
96
- export function buildMcpServerEntriesForManifest(manifest, absoluteWorktreePath, invocation) {
112
+ export function buildMcpServerEntriesForManifest(manifest, absoluteWorktreePath, invocation, bridgeApiBaseUrl) {
97
113
  const entries = {};
98
114
  const warnings = [];
99
115
  for (const mcp of manifest.mcp) {
100
116
  if (mcp.target === "bapi") {
101
- entries[serverNameForMcpTarget("bapi")] = buildShimMcpServerEntry("bapi", absoluteWorktreePath, invocation);
117
+ // The optional base URL reaches ONLY this branch — Tier-2 entries below are
118
+ // built without it and stay byte-identical to their pre-BAPI-727 shape.
119
+ entries[serverNameForMcpTarget("bapi")] = buildShimMcpServerEntry("bapi", absoluteWorktreePath, invocation, bridgeApiBaseUrl);
102
120
  continue;
103
121
  }
104
122
  const definition = getThirdPartyTargetDefinition(mcp.target);
@@ -162,6 +180,11 @@ export function mergeEnabledMcpjsonServers(existing, serverNames) {
162
180
  * Unrelated top-level fields and unrelated MCP servers are preserved; only the
163
181
  * generated server names are replaced. Any legacy secret-embedded entry for a
164
182
  * generated server name is force-upgraded to the secret-free shim shape.
183
+ *
184
+ * Deliberately UNCHANGED by BAPI-727: because a generated name is replaced rather
185
+ * than merged field-by-field, re-provisioning an existing worktree automatically
186
+ * upgrades an older `bridge-api` entry that lacks `--base-url` to the URL-bearing
187
+ * one. No migration step is needed.
165
188
  */
166
189
  export function mergeMcpRegistrations(existing, topLevelKey, entries) {
167
190
  const result = existing && typeof existing === "object" && !Array.isArray(existing)
@@ -321,7 +344,7 @@ export async function provisionMcpRegistrationForWorktree(row, deps) {
321
344
  if (!normalized.ok) {
322
345
  return { ...row, status: "spawn-failed", error: `MCP provisioning failed: ${normalized.error}` };
323
346
  }
324
- const built = buildMcpServerEntriesForManifest(read.manifest, normalized.path, deps.mcpServerInvocation);
347
+ const built = buildMcpServerEntriesForManifest(read.manifest, normalized.path, deps.mcpServerInvocation, deps.bridgeApiBaseUrl);
325
348
  if (Object.keys(built.entries).length === 0) {
326
349
  // Nothing supported to write (e.g. a manifest with no bapi target and no
327
350
  // supported Tier-2 targets). Surface any warnings but leave status unchanged.
@@ -8,17 +8,26 @@
8
8
  */
9
9
  import path from "path";
10
10
  /**
11
- * Read and parse a JSON file. A read failure (e.g. ENOENT) is `missing`; invalid
12
- * JSON is `malformed` (carrying only the path, never the raw content); otherwise
13
- * `present` with the parsed value.
11
+ * Read and parse a JSON file.
12
+ *
13
+ * A read failure whose error carries an `ENOENT` code is `missing` — the file
14
+ * genuinely is not there. Any OTHER read failure (e.g. `EACCES`) is `unreadable`:
15
+ * BAPI-727's shadow inspector must not treat "I could not look" as "there is
16
+ * nothing there", because an unreadable `~/.claude.json` could still be shadowing
17
+ * a worker's MCP registration. Invalid JSON stays `malformed`; both non-present
18
+ * failure states carry ONLY the path — never the raw content and never the read
19
+ * exception's message, which can contain arbitrary text.
14
20
  */
15
21
  export async function readJsonIfPresent(filePath, deps) {
16
22
  let raw;
17
23
  try {
18
24
  raw = await deps.readFile(filePath);
19
25
  }
20
- catch {
21
- return { state: "missing" };
26
+ catch (err) {
27
+ const code = err && typeof err === "object" ? err.code : undefined;
28
+ if (code === "ENOENT")
29
+ return { state: "missing" };
30
+ return { state: "unreadable", path: filePath };
22
31
  }
23
32
  try {
24
33
  return { state: "present", value: JSON.parse(raw) };