@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
package/build/doctor.js CHANGED
@@ -25,11 +25,14 @@ import { parseDefaultOnEnvFlag } from "./env-flags.js";
25
25
  import { createBridgeApiUrls } from "./bridge-api-urls.js";
26
26
  import { probeToolSurface } from "./tool-surface-gating.js";
27
27
  import { resolveBapiCredentials } from "./credential-store.js";
28
+ import { resolveRequiredStartTicketsRepoName } from "./start-tickets-repo.js";
29
+ import { BRIDGE_PACKAGE_NAME, describeLauncherReason, inspectLauncherConfigs, parseLauncherPin as parseSharedLauncherPin, } from "./launcher-config-inspection.js";
28
30
  import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, formatValidAgentNames, } from "./agent-registry.js";
29
31
  import { getDoctorPrereqDescriptors, probePrerequisite, } from "./start-tickets-prereqs.js";
30
32
  import { resolveProfiles } from "./mcp-profile.js";
31
33
  import { executorLaunchdDirForHome, executorSystemdDirForHome, executorIdFromLaunchdFilename, executorIdFromSystemdFilename, inspectExecutorServiceArtifact, EXECUTOR_SERVICE_BASE_URL_ENV, } from "./executor/index.js";
32
34
  import { DEFAULT_BAPI_BASE_URL } from "./executor/credentials.js";
35
+ import { evaluateWorkerConfigIsolationCapability, formatWorkerConfigIsolationReason, } from "./executor/worker-config-isolation.js";
33
36
  /**
34
37
  * The report/usage title (BAPI-669, U9b). `doctor` diagnoses the whole Bridge
35
38
  * install — install status, prerequisites, launcher cache, and tool surface — so it
@@ -62,12 +65,25 @@ export function getDoctorUsage() {
62
65
  "After that come the start-tickets prerequisite checks (for the current OS):",
63
66
  "the start-tickets preflight prerequisites plus",
64
67
  "uv, the selected agent's command, Bridge API credential resolution, and",
65
- "worktree MCP registration reachability. Credential resolution reports the",
68
+ "worktree MCP registration reachability, and a read-only check of whether the",
69
+ "Claude user config (~/.claude.json) registers a 'bridge-api' MCP server that",
70
+ "could shadow the registration provisioned into a worker worktree. That check",
71
+ "only reads the file and runs `git worktree list --porcelain`; it never edits,",
72
+ "migrates, or writes any configuration. Credential resolution reports the",
66
73
  "source it would use (env vs. store target bapi:<repo>); it never reads or",
67
74
  "prints the key value and never writes the credential store. To persist or",
68
75
  "migrate a credential, use /install-bridge or the `credentials` subcommand —",
69
76
  "doctor stays strictly read-only.",
70
77
  "",
78
+ "On a host with a configured conductor executor service it also reports",
79
+ "whether worker config isolation is healthy: whether this platform and",
80
+ "authentication layout allow a worker to run with an executor-owned",
81
+ "CLAUDE_CONFIG_DIR, so operator-scoped MCP servers are unreachable from a",
82
+ "worker. This check is a read-only capability evaluation: it never seeds or",
83
+ "removes an isolated directory, never reads the credential store, and never",
84
+ "prints a credential value. It affects the exit code only when an executor",
85
+ "service is configured; otherwise it is advisory.",
86
+ "",
71
87
  "It also includes an advisory 'MCP tool surface' section (BAPI-641): what",
72
88
  "dynamic capability gating would advertise for this repo. It performs at most",
73
89
  "one read-only GET to /jira/mcp/tool-surface (none under the kill switch) and",
@@ -209,7 +225,15 @@ export function formatDoctorPrereqSection(platform, collection) {
209
225
  }
210
226
  for (const result of collection.results) {
211
227
  const status = result.found ? "FOUND " : "MISSING";
212
- const detail = result.found && result.detail ? ` (${result.detail})` : "";
228
+ // Missing-detail stays suppressed for every prerequisite EXCEPT one that has
229
+ // been explicitly audited (BAPI-727, `renderDetailWhenMissing`). The general
230
+ // suppression exists because a missing probe's detail can be arbitrary text —
231
+ // `probePrerequisite` puts a thrown error's message there. The Claude
232
+ // MCP-shadowing descriptor is the sole opt-in: its detail is assembled only
233
+ // from the inspector's fixed scope/path/shape/env-key-name metadata, and it is
234
+ // precisely the information an operator needs in order to act.
235
+ const showDetail = result.detail && (result.found || result.renderDetailWhenMissing === true);
236
+ const detail = showDetail ? ` (${result.detail})` : "";
213
237
  lines.push(`${status} ${result.label}${detail}`);
214
238
  if (!result.found) {
215
239
  lines.push(` To install manually: ${result.installHint}`);
@@ -238,50 +262,18 @@ export function formatDoctorReport(platform, agent, collection) {
238
262
  formatDoctorPrereqSection(platform, collection),
239
263
  ].join("\n");
240
264
  }
241
- // ---------------------------------------------------------------------------
242
- // Launcher-cache diagnostics (BAPI-451 W3, E-4/E-9) — strictly read-only.
243
- //
244
- // install-bridge writes a launcher pinned to `@${VERSION}` (a specific `_npx`
245
- // bucket). The FIRST launch of a not-yet-cached pin pays a cold install inline,
246
- // which can exceed an MCP client's connect deadline. doctor inspects each
247
- // project-local config's actual `bridge-api` launcher spec and reports:
248
- // - `unpinned` — `@latest` / no version suffix (cache bucket is unstable).
249
- // - `stale-pinned` — pinned to a version other than the running `VERSION`.
250
- // - `warmed` — pinned to `VERSION` and the `_npx` bucket is present.
251
- // - `indeterminate`— pinned to `VERSION` but the bucket probe was not a clean
252
- // hit (a.k.a. `pinned-but-unwarmed`), or the config/spec was
253
- // ambiguous. Never silently reported as "warm".
254
- // The bucket probe is `npx --no-install <spec> --version`: `--no-install` means
255
- // the probe can never mutate (warm) the cache, honoring the read-only contract.
256
- // ---------------------------------------------------------------------------
257
- /** The npm package whose pinned launcher spec doctor inspects. */
258
- const BRIDGE_PACKAGE_NAME = "@bridge_gpt/mcp-server";
259
- /** Project-local MCP configs doctor inspects (mirrors install-bridge's targets). */
260
- const LAUNCHER_CONFIG_TARGETS = [
261
- { relPath: ".mcp.json", topLevelKey: "mcpServers" },
262
- { relPath: ".cursor/mcp.json", topLevelKey: "mcpServers" },
263
- { relPath: ".vscode/mcp.json", topLevelKey: "servers" },
264
- ];
265
265
  /**
266
266
  * Extract the `@bridge_gpt/mcp-server[@version]` launcher spec from an args array.
267
- * The package name is itself scoped (`@scope/name`), so the version delimiter is
268
- * the LAST `@`: `@bridge_gpt/mcp-server` is unpinned (no version), while
269
- * `@bridge_gpt/mcp-server@0.2.11` pins `0.2.11`. Returns null when no spec matches.
267
+ *
268
+ * Kept as a doctor-shaped adapter over the shared parser so doctor's existing
269
+ * `{ spec, version }` contract is unchanged: `version` is the raw specifier text
270
+ * (`0.2.11`, `latest`, `^0.2.0`) or null for the bare package.
270
271
  */
271
272
  export function parseLauncherPin(args) {
272
- if (!Array.isArray(args))
273
+ const parsed = parseSharedLauncherPin(args);
274
+ if (!parsed)
273
275
  return null;
274
- for (const arg of args) {
275
- if (typeof arg !== "string")
276
- continue;
277
- if (arg === BRIDGE_PACKAGE_NAME)
278
- return { spec: arg, version: null };
279
- if (arg.startsWith(`${BRIDGE_PACKAGE_NAME}@`)) {
280
- const version = arg.slice(BRIDGE_PACKAGE_NAME.length + 1).trim();
281
- return { spec: arg, version: version.length > 0 ? version : null };
282
- }
283
- }
284
- return null;
276
+ return { spec: parsed.spec, version: parsed.specifier };
285
277
  }
286
278
  /**
287
279
  * Default read-only cache probe. Spawns `npx --no-install <spec> --version`
@@ -320,79 +312,85 @@ function probeNpxNoInstallDefault(spec) {
320
312
  * when pinned to the running `VERSION`, probed read-only with `--no-install`.
321
313
  */
322
314
  export async function inspectLauncherCache(deps) {
315
+ // Shared, read-only facts first. `isCreatable` is deliberately omitted: doctor
316
+ // never implies a file would be created, so every absent config comes back
317
+ // `skip-inactive` and is filtered out below (an absent config is not a finding).
318
+ const shared = await inspectLauncherConfigs({
319
+ cwd: deps.cwd,
320
+ targetVersion: VERSION,
321
+ readFile: deps.readFile,
322
+ });
323
323
  const inspections = [];
324
- for (const { relPath, topLevelKey } of LAUNCHER_CONFIG_TARGETS) {
325
- const fullPath = path.join(deps.cwd, relPath);
326
- let raw;
327
- try {
328
- raw = await deps.readFile(fullPath);
329
- }
330
- catch {
331
- continue; // config absent not a finding.
332
- }
333
- let parsed;
334
- try {
335
- parsed = JSON.parse(raw);
336
- }
337
- catch {
324
+ for (const found of shared) {
325
+ // An absent config is not a doctor finding.
326
+ if (!found.filePresent)
327
+ continue;
328
+ const { relPath, spec, currentVersion } = found;
329
+ // Order matters: an unreadable or malformed config reports NO bridge entry,
330
+ // but it is still a finding — doctor cannot determine its pin, and saying
331
+ // nothing would read as "this config is fine". Classify it before the
332
+ // no-entry skip below.
333
+ if (found.action === "invalid" || found.action === "unsupported") {
338
334
  inspections.push({
339
335
  relPath,
340
- spec: null,
341
- pinnedVersion: null,
336
+ spec,
337
+ pinnedVersion: currentVersion,
342
338
  state: "indeterminate",
343
- remediation: "config is not valid JSON — cannot determine the launcher pin.",
339
+ remediation: `${describeLauncherReason(found.reason)} — cannot determine the launcher pin.`,
344
340
  });
345
341
  continue;
346
342
  }
347
- const entry = parsed && typeof parsed === "object"
348
- ? parsed[topLevelKey]?.["bridge-api"]
349
- : undefined;
350
- if (!entry)
351
- continue; // no bridge-api launcher in this config — not a finding.
352
- const pin = parseLauncherPin(entry.args);
353
- if (!pin) {
343
+ // A valid config that simply carries no bridge-api launcher is not a finding
344
+ // (matching the pre-BAPI-728 behavior exactly).
345
+ if (!found.bridgeEntryPresent)
346
+ continue;
347
+ if (found.action === "skip-worktree-shim") {
348
+ // A worktree shim runs a sibling checkout's build directly; there is no
349
+ // published-package bucket to warm and no pin to compare.
354
350
  inspections.push({
355
351
  relPath,
356
- spec: null,
352
+ spec,
357
353
  pinnedVersion: null,
358
354
  state: "indeterminate",
359
- remediation: `no ${BRIDGE_PACKAGE_NAME} spec found in the launcher args.`,
355
+ remediation: "worktree mcp-invoke shim this registration launches a local worktree build, so no published-package pin applies.",
360
356
  });
361
357
  continue;
362
358
  }
363
- if (pin.version === null || pin.version === "latest") {
359
+ if (found.action === "normalize-unpinned") {
364
360
  inspections.push({
365
361
  relPath,
366
- spec: pin.spec,
367
- pinnedVersion: pin.version,
362
+ spec,
363
+ pinnedVersion: null,
368
364
  state: "unpinned",
369
365
  remediation: `pin the launcher to ${BRIDGE_PACKAGE_NAME}@${VERSION} (run /install-bridge or the install-bridge subcommand to rewrite this config).`,
370
366
  });
371
367
  continue;
372
368
  }
373
- if (pin.version !== VERSION) {
369
+ // `repin` (behind) and `ahead` are both "pinned to something other than the
370
+ // running VERSION" — doctor's long-standing stale-pinned finding.
371
+ if (found.action === "repin" || found.action === "ahead") {
374
372
  inspections.push({
375
373
  relPath,
376
- spec: pin.spec,
377
- pinnedVersion: pin.version,
374
+ spec,
375
+ pinnedVersion: currentVersion,
378
376
  state: "stale-pinned",
379
- remediation: `config pins ${pin.version} but this package is ${VERSION}; run upgrade-bridge / install-bridge to repin and re-warm.`,
377
+ remediation: `config pins ${currentVersion} but this package is ${VERSION}; run upgrade-bridge / install-bridge to repin and re-warm.`,
380
378
  });
381
379
  continue;
382
380
  }
383
- // Pinned to the running VERSION — probe the bucket read-only.
384
- const probe = await deps.probeNpxNoInstall(pin.spec);
381
+ // Pinned exactly to the running VERSION — probe the bucket read-only.
382
+ const probe = await deps.probeNpxNoInstall(spec);
385
383
  if (probe.warmed) {
386
- inspections.push({ relPath, spec: pin.spec, pinnedVersion: pin.version, state: "warmed" });
384
+ inspections.push({ relPath, spec, pinnedVersion: currentVersion, state: "warmed" });
387
385
  }
388
386
  else {
389
387
  inspections.push({
390
388
  relPath,
391
- spec: pin.spec,
392
- pinnedVersion: pin.version,
389
+ spec,
390
+ pinnedVersion: currentVersion,
393
391
  state: "indeterminate",
394
392
  remediation: "pinned-but-unwarmed: the pinned _npx bucket is not a confirmed cache hit, so the first MCP launch may pay a one-time cold install. " +
395
- `Warm it with: npx ${pin.spec} --version, or raise MCP_TIMEOUT for the first launch.`,
393
+ `Warm it with: npx ${spec} --version, or raise MCP_TIMEOUT for the first launch.`,
396
394
  });
397
395
  }
398
396
  }
@@ -525,6 +523,57 @@ export function formatToolSurfaceDiagnosticReport(diag) {
525
523
  lines.push("to observe surface changes; no project MCP configuration change is required.");
526
524
  return lines.join("\n");
527
525
  }
526
+ /**
527
+ * Adapt the executor's read-only capability evaluator for doctor.
528
+ *
529
+ * READ-ONLY BY CONSTRUCTION: it forwards only `platform` and `env`, and
530
+ * `evaluateWorkerConfigIsolationCapability` accepts no mutating dependency at
531
+ * all — no directory-creation, permission-setting, file-writing, or removal
532
+ * operation is reachable from it even in principle. No directory is seeded, no
533
+ * credential value is read, and nothing is persisted. That is what preserves
534
+ * doctor's read-only contract while still answering AC-4's health question.
535
+ *
536
+ * NOTE: this module is pinned by `tests/pytest/mcp_server/test_doctor_static_contract.py`,
537
+ * which asserts the literal token `write` + `File` appears nowhere in this file —
538
+ * so describe write operations in prose here rather than naming that API.
539
+ */
540
+ export function collectWorkerConfigIsolationDiagnostic(input) {
541
+ const capability = evaluateWorkerConfigIsolationCapability({
542
+ platform: input.platform,
543
+ env: input.env,
544
+ });
545
+ if (capability.supported) {
546
+ return {
547
+ status: "healthy",
548
+ platform: capability.platform,
549
+ strategy: capability.strategy,
550
+ required: input.executorConfigured,
551
+ };
552
+ }
553
+ return {
554
+ status: "unhealthy",
555
+ platform: capability.platform,
556
+ remediation: formatWorkerConfigIsolationReason(capability.platform, capability.reason),
557
+ required: input.executorConfigured,
558
+ };
559
+ }
560
+ /** Render the isolation section: fixed labels plus already-bounded reason text. */
561
+ export function formatWorkerConfigIsolationDiagnosticReport(diagnostic) {
562
+ const lines = ["", "Worker config isolation (conductor executor)"];
563
+ if (diagnostic.status === "healthy") {
564
+ lines.push(` HEALTHY ${diagnostic.platform}: isolation can be established (strategy: ${diagnostic.strategy})`);
565
+ lines.push(" Workers run with an executor-owned CLAUDE_CONFIG_DIR, so operator-scoped");
566
+ lines.push(" MCP servers are unreachable from a worker.");
567
+ return lines.join("\n");
568
+ }
569
+ // An executor host cannot dispatch workers safely without isolation, so the
570
+ // label distinguishes a blocking condition from an advisory one.
571
+ lines.push(` ${diagnostic.required ? "UNHEALTHY" : "SKIP "} ${diagnostic.remediation ?? ""}`);
572
+ if (!diagnostic.required) {
573
+ lines.push(" Advisory only: no executor service is configured on this host.");
574
+ }
575
+ return lines.join("\n");
576
+ }
528
577
  /** Where generated units live, and how their filenames encode the executor id. */
529
578
  function resolveExecutorServiceConvention(platform, homeDir) {
530
579
  if (platform === "darwin") {
@@ -715,6 +764,65 @@ export function formatExecutorServiceDiagnosticsReport(diagnostics) {
715
764
  lines.push("is advisory and never changes the doctor exit code.");
716
765
  return lines.join("\n");
717
766
  }
767
+ export async function collectPlaneCredentialDiagnostic(deps) {
768
+ const anthropic = deps.env.ANTHROPIC_API_KEY;
769
+ const anthropicKeyPresent = typeof anthropic === "string" && anthropic.trim().length > 0;
770
+ const resolveRepo = deps.resolveRepoName ?? resolveRequiredStartTicketsRepoName;
771
+ let repo = null;
772
+ try {
773
+ const result = await resolveRepo({ env: deps.env, cwd: deps.cwd, readFile: deps.readFile });
774
+ if (result.ok)
775
+ repo = result.repoName;
776
+ }
777
+ catch {
778
+ /* sanitized: identity resolution failure is reported as an absent repo */
779
+ }
780
+ if (repo === null) {
781
+ return { anthropicKeyPresent, repo: null, target: null, bridgeCredentialResolved: false };
782
+ }
783
+ // Resolve, then keep ONLY a label. The key never reaches the diagnostic.
784
+ const status = await probeRepoCredential(repo, { platform: deps.platform, env: deps.env, homedir: deps.homedir, readdir: async () => [], readFile: deps.readFile, stat: deps.stat, resolveCredentials: deps.resolveCredentials }, {
785
+ env: deps.env,
786
+ homedir: deps.homedir,
787
+ platform: deps.platform,
788
+ readFile: deps.readFile,
789
+ stat: deps.stat,
790
+ stderr: () => {
791
+ /* resolver advisories are not part of the doctor report */
792
+ },
793
+ });
794
+ return {
795
+ anthropicKeyPresent,
796
+ repo,
797
+ target: status.target,
798
+ bridgeCredentialResolved: status.resolved,
799
+ source: status.source,
800
+ kind: status.kind,
801
+ };
802
+ }
803
+ /** Render the plane readiness section. Advisory: never changes the exit code. */
804
+ export function formatPlaneCredentialDiagnosticReport(diagnostic) {
805
+ const lines = ["", "Conductor plane credentials (`plane up` — advisory)"];
806
+ lines.push(diagnostic.anthropicKeyPresent
807
+ ? " OK ANTHROPIC_API_KEY is set in this shell."
808
+ : " MISSING ANTHROPIC_API_KEY is not set in this shell. Isolated workers have no OAuth\n" +
809
+ " session and refuse to spawn without it, so `plane up` will refuse.");
810
+ if (diagnostic.repo === null) {
811
+ lines.push(" MISSING the Bridge repository identity could not be resolved. Set BAPI_REPO_NAME or\n" +
812
+ " add a valid .bridge/config at the repository root.");
813
+ }
814
+ else if (diagnostic.bridgeCredentialResolved) {
815
+ lines.push(` OK credential ${diagnostic.target}: resolved (source: ${diagnostic.source}).`);
816
+ }
817
+ else {
818
+ lines.push(` MISSING credential ${diagnostic.target}: NOT resolved (${diagnostic.kind}).\n` +
819
+ " A spawned shell never sees .mcp.json env — set BAPI_API_KEY here, or store\n" +
820
+ " it with `mcp-server credentials`.");
821
+ }
822
+ lines.push(" Read-only: this section resolves an identity and probes a credential. It creates");
823
+ lines.push(" no .bridge/plane/ directory, persists nothing, and starts no plane member.");
824
+ return lines.join("\n");
825
+ }
718
826
  /**
719
827
  * CLI entry for the read-only `doctor` subcommand. Returns a process exit code.
720
828
  * Help returns 0; parser errors return 1; otherwise it prints the report and
@@ -829,6 +937,10 @@ export async function runDoctorCli(argv, overrides = {}) {
829
937
  // any key. It is collected independently of `DoctorCollectionResult` and, like
830
938
  // the sections above, never affects the exit code — missing units, malformed
831
939
  // units, and unresolved credentials are all advisory.
940
+ // BAPI-731: set only when isolation is unhealthy AND this host runs an
941
+ // executor, so an executor-only requirement never fails an ordinary MCP user's
942
+ // doctor run.
943
+ let isolationFailed = false;
832
944
  if (overrides.executorService !== false) {
833
945
  try {
834
946
  const injectedFs = deps;
@@ -845,6 +957,19 @@ export async function runDoctorCli(argv, overrides = {}) {
845
957
  };
846
958
  const executorDiagnostics = await collectExecutorServiceDiagnostics(executorDeps);
847
959
  log(formatExecutorServiceDiagnosticsReport(executorDiagnostics));
960
+ // BAPI-731: worker config isolation health, rendered right after the
961
+ // executor-service section because it is only REQUIRED when this host
962
+ // actually runs an executor. Read-only: it evaluates capability and
963
+ // creates nothing (see the assertion in `collectWorkerConfigIsolationDiagnostic`).
964
+ const isolationDiagnostic = collectWorkerConfigIsolationDiagnostic({
965
+ platform: executorDeps.platform,
966
+ env: executorDeps.env,
967
+ executorConfigured: executorDiagnostics.status === "collected" && executorDiagnostics.units.length > 0,
968
+ });
969
+ log(formatWorkerConfigIsolationDiagnosticReport(isolationDiagnostic));
970
+ if (isolationDiagnostic.status === "unhealthy" && isolationDiagnostic.required) {
971
+ isolationFailed = true;
972
+ }
848
973
  }
849
974
  catch {
850
975
  // Any unexpected failure still renders a sanitized advisory SKIP line.
@@ -854,7 +979,41 @@ export async function runDoctorCli(argv, overrides = {}) {
854
979
  }));
855
980
  }
856
981
  }
982
+ // Advisory conductor-plane credential section (BAPI-756). Read-only and, like
983
+ // every section above, never affects the exit code: `plane up` enforces these
984
+ // same invariants at the point where they actually block, and an ordinary MCP
985
+ // user who never runs a conductor plane should not see a failing doctor.
986
+ if (overrides.planeCredentials !== false) {
987
+ try {
988
+ const injectedFs = deps;
989
+ const planeDeps = {
990
+ env: overrides.planeCredentials?.env ?? deps.env,
991
+ cwd: overrides.planeCredentials?.cwd ?? deps.cwd,
992
+ platform: overrides.planeCredentials?.platform ?? deps.platform,
993
+ homedir: overrides.planeCredentials?.homedir ?? injectedFs.homedir ?? os.homedir,
994
+ readFile: overrides.planeCredentials?.readFile ??
995
+ injectedFs.readFile ??
996
+ ((p) => readFile(p, "utf-8")),
997
+ stat: overrides.planeCredentials?.stat ?? injectedFs.stat ?? ((p) => stat(p)),
998
+ resolveRepoName: overrides.planeCredentials?.resolveRepoName,
999
+ resolveCredentials: overrides.planeCredentials?.resolveCredentials,
1000
+ };
1001
+ log(formatPlaneCredentialDiagnosticReport(await collectPlaneCredentialDiagnostic(planeDeps)));
1002
+ }
1003
+ catch {
1004
+ // Any unexpected failure still renders a sanitized advisory line rather
1005
+ // than dropping the section or leaking exception text.
1006
+ log(formatPlaneCredentialDiagnosticReport({
1007
+ anthropicKeyPresent: false,
1008
+ repo: null,
1009
+ target: null,
1010
+ bridgeCredentialResolved: false,
1011
+ }));
1012
+ }
1013
+ }
857
1014
  if (!collection.ok)
858
1015
  return 1;
1016
+ if (isolationFailed)
1017
+ return 1;
859
1018
  return collection.results.some((r) => !r.found) ? 1 : 0;
860
1019
  }
@@ -15,6 +15,8 @@ import { resolveBaseUrl, resolveExecutorApiAccess, EXECUTOR_BASE_URL_REQUIRED_ME
15
15
  import { createExecutorHttpClient } from "./http-client.js";
16
16
  import { runExecutor } from "./runner.js";
17
17
  import { runExecutorWatchCli } from "./watch-cli.js";
18
+ import { sweepOrphanedWorkerConfigDirectories } from "./worker-config-isolation.js";
19
+ import { pathApiForProvisioningPlatform } from "../mcp-provisioning.js";
18
20
  /** Fixed executor timing/behavior defaults. */
19
21
  const DEFAULT_POLL_INTERVAL_MS = 15_000;
20
22
  const DEFAULT_HEARTBEAT_INTERVAL_MS = 60_000;
@@ -173,6 +175,25 @@ export function parseExecutorArgs(argv, context) {
173
175
  };
174
176
  return { kind: "ok", options };
175
177
  }
178
+ /**
179
+ * Build the sweep boundary from executor deps, or `null` when this executor was
180
+ * constructed without the filesystem operations the sweep needs. `null` simply
181
+ * skips the sweep — unlike isolation itself, a missing sweep is not a safety
182
+ * problem, only uncollected residue.
183
+ */
184
+ function buildWorkerConfigSweepDeps(deps) {
185
+ const { tmpdir, readdir, lstatPath, rmRecursive } = deps;
186
+ if (!tmpdir || !readdir || !lstatPath || !rmRecursive)
187
+ return null;
188
+ return {
189
+ tmpdir,
190
+ readdir,
191
+ lstatPath,
192
+ rmRecursive,
193
+ join: (...segments) => pathApiForProvisioningPlatform(deps.platform).join(...segments),
194
+ now: deps.now,
195
+ };
196
+ }
176
197
  function hasRepoFlag(argv) {
177
198
  return argv.some((a) => a === "--repo" || a === "--repos" || a.startsWith("--repos="));
178
199
  }
@@ -228,6 +249,13 @@ export async function runExecutorCli(argv, overrides = {}) {
228
249
  // the first repo's key. Uses the (injectable) single-repo resolver per repo.
229
250
  const resolveApi = overrides.resolveApiAccess ?? resolveExecutorApiAccess;
230
251
  const apiKeyByRepo = {};
252
+ // BAPI-727: retain the normalized base URL from the SUCCESSFUL access result —
253
+ // not `options.baseUrl` (the raw, pre-validation CLI string) and not
254
+ // `deps.env.BAPI_BASE_URL` (which loses to an explicit `--base-url`). Worker MCP
255
+ // provisioning propagates this exact value, so a worker can never be pointed at a
256
+ // different endpoint than the executor that dispatched it. Every repo resolves
257
+ // against the same explicit URL, so the value is identical across the loop.
258
+ let resolvedBaseUrl;
231
259
  for (const repo of options.repos) {
232
260
  const access = await resolveApi(repo, deps, baseUrl);
233
261
  if (!access.ok) {
@@ -235,6 +263,7 @@ export async function runExecutorCli(argv, overrides = {}) {
235
263
  return 1;
236
264
  }
237
265
  apiKeyByRepo[repo] = access.apiKey;
266
+ resolvedBaseUrl = access.baseUrl;
238
267
  }
239
268
  const createHttpClient = overrides.createHttpClient ?? createExecutorHttpClient;
240
269
  const httpClient = createHttpClient({
@@ -244,9 +273,30 @@ export async function runExecutorCli(argv, overrides = {}) {
244
273
  mcpVersion: VERSION,
245
274
  fetch: deps.fetch,
246
275
  });
276
+ // BAPI-731: sweep isolation directories orphaned by an ungraceful earlier exit
277
+ // (SIGKILL, OOM, host reboot) where per-job disposal never ran. Deliberately a
278
+ // ONE-SHOT startup step, separate from per-job disposal: normal cleanup stays
279
+ // immediate, and this only reclaims residue. Never blocks claiming — a sweep
280
+ // failure is a bounded diagnostic, because refusing to start over leftover
281
+ // temp directories would be a worse failure than the leftovers.
282
+ const sweep = overrides.sweepOrphanedWorkerConfigDirectories ?? sweepOrphanedWorkerConfigDirectories;
283
+ try {
284
+ const isolationSweepDeps = buildWorkerConfigSweepDeps(deps);
285
+ if (isolationSweepDeps) {
286
+ const swept = await sweep(isolationSweepDeps);
287
+ for (const diagnostic of swept.diagnostics)
288
+ errorLog(diagnostic);
289
+ }
290
+ }
291
+ catch {
292
+ errorLog("worker config isolation sweep did not complete; continuing startup");
293
+ }
247
294
  const run = overrides.runExecutor ?? runExecutor;
295
+ // Hand the loop the VALIDATED runtime configuration: the parsed options plus the
296
+ // normalized base URL resolution actually settled on.
297
+ const runtimeOptions = { ...options, resolvedBaseUrl };
248
298
  try {
249
- return await run(options, deps, httpClient);
299
+ return await run(runtimeOptions, deps, httpClient);
250
300
  }
251
301
  catch (err) {
252
302
  const message = err instanceof Error ? err.message : String(err);
@@ -7,6 +7,7 @@
7
7
  * diagnostics/results.
8
8
  */
9
9
  import { resolveBapiCredentials } from "../credential-store.js";
10
+ import { normalizeBaseUrlCandidate } from "../base-url.js";
10
11
  /**
11
12
  * The documented production Bridge API base URL — the value an operator pastes
12
13
  * into `BAPI_BASE_URL` or `--base-url` when they *do* mean production.
@@ -25,13 +26,6 @@ export const DEFAULT_BAPI_BASE_URL = "https://bridgegpt-api.com";
25
26
  */
26
27
  export const EXECUTOR_BASE_URL_REQUIRED_MESSAGE = "no Bridge API base URL configured for the executor: pass --base-url <url> or set BAPI_BASE_URL " +
27
28
  `(production is ${DEFAULT_BAPI_BASE_URL}). The executor never defaults to production.`;
28
- /** Trim, strip every trailing slash, and treat an empty result as absent. */
29
- function normalizeBaseUrlCandidate(raw) {
30
- if (typeof raw !== "string")
31
- return null;
32
- const normalized = raw.trim().replace(/\/+$/, "");
33
- return normalized.length > 0 ? normalized : null;
34
- }
35
29
  /**
36
30
  * Resolve the executor's base URL: explicit override (`--base-url`) first, then
37
31
  * `BAPI_BASE_URL`, then failure. A blank override is treated as absent so a
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { execFile, spawn } from "node:child_process";
11
11
  import { existsSync } from "node:fs";
12
- import { readFile, writeFile, appendFile, mkdir, stat, statfs } from "node:fs/promises";
12
+ import { readFile, writeFile, appendFile, mkdir, mkdtemp, chmod, rm, readdir, lstat, stat, statfs, } from "node:fs/promises";
13
13
  import os from "node:os";
14
14
  import { promisify } from "node:util";
15
15
  import { resolveMcpShimInvocationForRuntime } from "../mcp-server-invocation.js";
@@ -85,6 +85,23 @@ export function createDefaultExecutorDeps() {
85
85
  const s = await statfs(path);
86
86
  return { bavail: Number(s.bavail), bsize: Number(s.bsize) };
87
87
  },
88
+ // --- Worker config isolation filesystem boundaries (BAPI-731) ----------
89
+ // Bound to real `node:fs/promises` operations here so the isolation module
90
+ // itself never reaches for a filesystem global and stays fully unit-testable.
91
+ mkdtemp: (prefix) => mkdtemp(prefix),
92
+ chmod: (path, mode) => chmod(path, mode),
93
+ rmRecursive: (path) => rm(path, { recursive: true, force: true }),
94
+ readdir: (dirPath) => readdir(dirPath),
95
+ // `lstat`, never `stat`: a symlinked isolation parent or swept entry must be
96
+ // recognizable AS a symlink rather than reported as its target directory.
97
+ lstatPath: (path) => lstat(path)
98
+ .then((s) => ({
99
+ isDirectory: s.isDirectory(),
100
+ isSymbolicLink: s.isSymbolicLink(),
101
+ mtimeMs: s.mtimeMs,
102
+ }))
103
+ .catch(() => null),
104
+ tmpdir: () => os.tmpdir(),
88
105
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
89
106
  now: () => Date.now(),
90
107
  setTimer: (cb, ms) => setTimeout(cb, ms),