@bridge_gpt/mcp-server 0.2.34 → 0.2.37

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 (109) hide show
  1. package/README.md +490 -366
  2. package/build/agent-capabilities/probe-context.js +8 -1
  3. package/build/agent-capabilities/probes.js +7 -1
  4. package/build/agents.generated.js +1 -1
  5. package/build/base-url.js +79 -0
  6. package/build/bridge-api-urls.js +9 -0
  7. package/build/chain-orchestrator.js +93 -15
  8. package/build/claude-review-workflow.js +264 -0
  9. package/build/claude-user-config-doctor.js +317 -0
  10. package/build/cli-release.js +53 -0
  11. package/build/commands.generated.js +6 -5
  12. package/build/conductor/bridge-api-client.js +393 -4
  13. package/build/conductor/deny-enforcement-preflight.js +1 -0
  14. package/build/conductor/done-gate.js +44 -5
  15. package/build/conductor/epic-reconcile.js +6 -0
  16. package/build/conductor/install-doctor.js +462 -0
  17. package/build/conductor-bin.js +3 -3
  18. package/build/conductor-bundle-artifacts.js +37 -15
  19. package/build/credential-store.js +205 -4
  20. package/build/direct-ticket-tools.js +70 -0
  21. package/build/doctor.js +473 -81
  22. package/build/executor/cli.js +83 -6
  23. package/build/executor/credentials.js +39 -11
  24. package/build/executor/deps.js +32 -1
  25. package/build/executor/env.js +71 -28
  26. package/build/executor/heartbeat.js +138 -17
  27. package/build/executor/http-client.js +49 -8
  28. package/build/executor/index.js +4 -0
  29. package/build/executor/job-errors.js +4 -0
  30. package/build/executor/job-runner.js +530 -20
  31. package/build/executor/observation.js +130 -0
  32. package/build/executor/permissions.js +114 -8
  33. package/build/executor/preflight.js +127 -8
  34. package/build/executor/prompt-spec.js +51 -0
  35. package/build/executor/runner.js +23 -2
  36. package/build/executor/service-unit.js +876 -0
  37. package/build/executor/test-clock.js +75 -3
  38. package/build/executor/types.js +4 -18
  39. package/build/executor/worker-command.js +73 -12
  40. package/build/executor/worker-config-isolation.js +287 -0
  41. package/build/executor/worker-finalization.js +68 -14
  42. package/build/executor/worktree.js +46 -4
  43. package/build/index.js +1039 -237
  44. package/build/init.js +503 -111
  45. package/build/install-bridge-conductor.js +491 -0
  46. package/build/install-bridge.js +1188 -247
  47. package/build/install-reexec.js +233 -0
  48. package/build/launcher-config-inspection.js +351 -0
  49. package/build/mcp-host-config.js +11 -1
  50. package/build/mcp-install-state.js +32 -0
  51. package/build/mcp-invoke.js +49 -6
  52. package/build/mcp-provisioning.js +52 -13
  53. package/build/mcp-registration-doctor.js +14 -5
  54. package/build/notifications.js +553 -0
  55. package/build/pipeline-orchestrator.js +146 -4
  56. package/build/pipeline-utils.js +3 -0
  57. package/build/pipelines.generated.js +32 -13
  58. package/build/plan-execution-ledger.js +550 -0
  59. package/build/plan-phase-routing.js +272 -0
  60. package/build/plane/alembic-head.js +110 -0
  61. package/build/plane/build-freshness.js +167 -0
  62. package/build/plane/cli.js +480 -0
  63. package/build/plane/defaults.js +266 -0
  64. package/build/plane/manifest.js +377 -0
  65. package/build/plane/member-logs.js +147 -0
  66. package/build/plane/member-roster.js +147 -0
  67. package/build/plane/preflight.js +289 -0
  68. package/build/plane/shutdown.js +195 -0
  69. package/build/plane/status.js +125 -0
  70. package/build/plane/supervisor.js +569 -0
  71. package/build/plane/test-fakes.js +156 -0
  72. package/build/plane/types.js +75 -0
  73. package/build/readme.generated.js +1 -1
  74. package/build/run-unit-tests-launcher.js +259 -0
  75. package/build/setup-epic.js +772 -28
  76. package/build/sfcc/log-gate.js +38 -11
  77. package/build/sfcc/log-query.js +55 -15
  78. package/build/sfcc/ocapi-shape.js +51 -14
  79. package/build/sfcc/output.js +41 -11
  80. package/build/sfcc/permissions.js +24 -2
  81. package/build/sfcc/read-projection.js +181 -0
  82. package/build/sfcc/read-result.js +158 -0
  83. package/build/sfcc/reads-custom-object-def.js +29 -18
  84. package/build/sfcc/reads-site-preference.js +75 -29
  85. package/build/sfcc/reads-system-object.js +40 -34
  86. package/build/sfcc/sfcc-result.js +106 -0
  87. package/build/sfcc/tool-wrapper.js +56 -13
  88. package/build/sfcc/write-grants.js +45 -22
  89. package/build/sfcc/write-guard.js +21 -13
  90. package/build/sfcc/write-result.js +61 -14
  91. package/build/sfcc/write-tool-common.js +126 -32
  92. package/build/sfcc/writes-system-object.js +11 -50
  93. package/build/start-tickets-prereqs.js +129 -0
  94. package/build/start-tickets.js +17 -13
  95. package/build/ticket-backend-metadata.js +59 -0
  96. package/build/ticket-key-utils.js +92 -0
  97. package/build/tool-error-envelope.js +71 -0
  98. package/build/tool-surface-gating.js +72 -0
  99. package/build/update-status.js +102 -0
  100. package/build/upgrade-advice.js +47 -0
  101. package/build/upgrade-cli.js +412 -110
  102. package/build/version.generated.js +1 -1
  103. package/build/worktree-core.js +73 -0
  104. package/docs/CONDUCTOR.md +132 -6
  105. package/docs/install/mcp-tool-integrations.md +29 -21
  106. package/package.json +9 -6
  107. package/pipelines/implement-ticket.json +19 -4
  108. package/build/conductor/supervisor-judgment-python.js +0 -141
  109. package/build/conductor/supervisor-judgment.js +0 -215
package/build/doctor.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * spawning, no MCP server startup. It only ever runs read-only PATH probes
15
15
  * (`which`/`where`, `bash --version`, `git rev-parse`) through the injected deps.
16
16
  */
17
- import { readFile, stat } from "fs/promises";
17
+ import { readdir, readFile, stat } from "fs/promises";
18
18
  import { spawn } from "child_process";
19
19
  import os from "os";
20
20
  import path from "path";
@@ -25,9 +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";
33
+ import { executorLaunchdDirForHome, executorSystemdDirForHome, executorIdFromLaunchdFilename, executorIdFromSystemdFilename, inspectExecutorServiceArtifact, EXECUTOR_SERVICE_BASE_URL_ENV, } from "./executor/index.js";
34
+ import { DEFAULT_BAPI_BASE_URL } from "./executor/credentials.js";
35
+ import { evaluateWorkerConfigIsolationCapability, formatWorkerConfigIsolationReason, } from "./executor/worker-config-isolation.js";
31
36
  /**
32
37
  * The report/usage title (BAPI-669, U9b). `doctor` diagnoses the whole Bridge
33
38
  * install — install status, prerequisites, launcher cache, and tool surface — so it
@@ -60,12 +65,25 @@ export function getDoctorUsage() {
60
65
  "After that come the start-tickets prerequisite checks (for the current OS):",
61
66
  "the start-tickets preflight prerequisites plus",
62
67
  "uv, the selected agent's command, Bridge API credential resolution, and",
63
- "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",
64
73
  "source it would use (env vs. store target bapi:<repo>); it never reads or",
65
74
  "prints the key value and never writes the credential store. To persist or",
66
75
  "migrate a credential, use /install-bridge or the `credentials` subcommand —",
67
76
  "doctor stays strictly read-only.",
68
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
+ "",
69
87
  "It also includes an advisory 'MCP tool surface' section (BAPI-641): what",
70
88
  "dynamic capability gating would advertise for this repo. It performs at most",
71
89
  "one read-only GET to /jira/mcp/tool-surface (none under the kill switch) and",
@@ -74,6 +92,16 @@ export function getDoctorUsage() {
74
92
  "ignore notifications/tools/list_changed must reconnect or start a new MCP",
75
93
  "session to observe surface changes; no project MCP config change is required.",
76
94
  "",
95
+ "It also includes an advisory 'Executor provisioning' section (BAPI-688): the",
96
+ "generated executor service units on this host. It enumerates them read-only by",
97
+ "fixed convention — ~/Library/LaunchAgents/com.bridge-gpt.executor.*.plist on",
98
+ "macOS, ~/.config/systemd/user/bridge-gpt-executor-*.service on Linux (Windows",
99
+ "Task Scheduler setup is manual, so it reports SKIP) — and reports each unit's",
100
+ "embedded BAPI_BASE_URL and repositories plus whether a Bridge API credential",
101
+ "resolves for each repo (target bapi:<repo>, source only, never the key value).",
102
+ "It reads no install-state file, writes nothing, runs no launchctl/systemctl, and",
103
+ "is advisory: missing, malformed, or credential-less units never change the exit code.",
104
+ "",
77
105
  "Conductor ledger / native-module diagnostics (the SQLite ledger's native",
78
106
  "binding load status and Node-version skew) live under a separate command:",
79
107
  " conductor doctor",
@@ -197,7 +225,15 @@ export function formatDoctorPrereqSection(platform, collection) {
197
225
  }
198
226
  for (const result of collection.results) {
199
227
  const status = result.found ? "FOUND " : "MISSING";
200
- 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})` : "";
201
237
  lines.push(`${status} ${result.label}${detail}`);
202
238
  if (!result.found) {
203
239
  lines.push(` To install manually: ${result.installHint}`);
@@ -226,50 +262,18 @@ export function formatDoctorReport(platform, agent, collection) {
226
262
  formatDoctorPrereqSection(platform, collection),
227
263
  ].join("\n");
228
264
  }
229
- // ---------------------------------------------------------------------------
230
- // Launcher-cache diagnostics (BAPI-451 W3, E-4/E-9) — strictly read-only.
231
- //
232
- // install-bridge writes a launcher pinned to `@${VERSION}` (a specific `_npx`
233
- // bucket). The FIRST launch of a not-yet-cached pin pays a cold install inline,
234
- // which can exceed an MCP client's connect deadline. doctor inspects each
235
- // project-local config's actual `bridge-api` launcher spec and reports:
236
- // - `unpinned` — `@latest` / no version suffix (cache bucket is unstable).
237
- // - `stale-pinned` — pinned to a version other than the running `VERSION`.
238
- // - `warmed` — pinned to `VERSION` and the `_npx` bucket is present.
239
- // - `indeterminate`— pinned to `VERSION` but the bucket probe was not a clean
240
- // hit (a.k.a. `pinned-but-unwarmed`), or the config/spec was
241
- // ambiguous. Never silently reported as "warm".
242
- // The bucket probe is `npx --no-install <spec> --version`: `--no-install` means
243
- // the probe can never mutate (warm) the cache, honoring the read-only contract.
244
- // ---------------------------------------------------------------------------
245
- /** The npm package whose pinned launcher spec doctor inspects. */
246
- const BRIDGE_PACKAGE_NAME = "@bridge_gpt/mcp-server";
247
- /** Project-local MCP configs doctor inspects (mirrors install-bridge's targets). */
248
- const LAUNCHER_CONFIG_TARGETS = [
249
- { relPath: ".mcp.json", topLevelKey: "mcpServers" },
250
- { relPath: ".cursor/mcp.json", topLevelKey: "mcpServers" },
251
- { relPath: ".vscode/mcp.json", topLevelKey: "servers" },
252
- ];
253
265
  /**
254
266
  * Extract the `@bridge_gpt/mcp-server[@version]` launcher spec from an args array.
255
- * The package name is itself scoped (`@scope/name`), so the version delimiter is
256
- * the LAST `@`: `@bridge_gpt/mcp-server` is unpinned (no version), while
257
- * `@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.
258
271
  */
259
272
  export function parseLauncherPin(args) {
260
- if (!Array.isArray(args))
273
+ const parsed = parseSharedLauncherPin(args);
274
+ if (!parsed)
261
275
  return null;
262
- for (const arg of args) {
263
- if (typeof arg !== "string")
264
- continue;
265
- if (arg === BRIDGE_PACKAGE_NAME)
266
- return { spec: arg, version: null };
267
- if (arg.startsWith(`${BRIDGE_PACKAGE_NAME}@`)) {
268
- const version = arg.slice(BRIDGE_PACKAGE_NAME.length + 1).trim();
269
- return { spec: arg, version: version.length > 0 ? version : null };
270
- }
271
- }
272
- return null;
276
+ return { spec: parsed.spec, version: parsed.specifier };
273
277
  }
274
278
  /**
275
279
  * Default read-only cache probe. Spawns `npx --no-install <spec> --version`
@@ -308,79 +312,85 @@ function probeNpxNoInstallDefault(spec) {
308
312
  * when pinned to the running `VERSION`, probed read-only with `--no-install`.
309
313
  */
310
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
+ });
311
323
  const inspections = [];
312
- for (const { relPath, topLevelKey } of LAUNCHER_CONFIG_TARGETS) {
313
- const fullPath = path.join(deps.cwd, relPath);
314
- let raw;
315
- try {
316
- raw = await deps.readFile(fullPath);
317
- }
318
- catch {
319
- continue; // config absent not a finding.
320
- }
321
- let parsed;
322
- try {
323
- parsed = JSON.parse(raw);
324
- }
325
- 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") {
326
334
  inspections.push({
327
335
  relPath,
328
- spec: null,
329
- pinnedVersion: null,
336
+ spec,
337
+ pinnedVersion: currentVersion,
330
338
  state: "indeterminate",
331
- remediation: "config is not valid JSON — cannot determine the launcher pin.",
339
+ remediation: `${describeLauncherReason(found.reason)} — cannot determine the launcher pin.`,
332
340
  });
333
341
  continue;
334
342
  }
335
- const entry = parsed && typeof parsed === "object"
336
- ? parsed[topLevelKey]?.["bridge-api"]
337
- : undefined;
338
- if (!entry)
339
- continue; // no bridge-api launcher in this config — not a finding.
340
- const pin = parseLauncherPin(entry.args);
341
- 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.
342
350
  inspections.push({
343
351
  relPath,
344
- spec: null,
352
+ spec,
345
353
  pinnedVersion: null,
346
354
  state: "indeterminate",
347
- 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.",
348
356
  });
349
357
  continue;
350
358
  }
351
- if (pin.version === null || pin.version === "latest") {
359
+ if (found.action === "normalize-unpinned") {
352
360
  inspections.push({
353
361
  relPath,
354
- spec: pin.spec,
355
- pinnedVersion: pin.version,
362
+ spec,
363
+ pinnedVersion: null,
356
364
  state: "unpinned",
357
365
  remediation: `pin the launcher to ${BRIDGE_PACKAGE_NAME}@${VERSION} (run /install-bridge or the install-bridge subcommand to rewrite this config).`,
358
366
  });
359
367
  continue;
360
368
  }
361
- 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") {
362
372
  inspections.push({
363
373
  relPath,
364
- spec: pin.spec,
365
- pinnedVersion: pin.version,
374
+ spec,
375
+ pinnedVersion: currentVersion,
366
376
  state: "stale-pinned",
367
- 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.`,
368
378
  });
369
379
  continue;
370
380
  }
371
- // Pinned to the running VERSION — probe the bucket read-only.
372
- 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);
373
383
  if (probe.warmed) {
374
- inspections.push({ relPath, spec: pin.spec, pinnedVersion: pin.version, state: "warmed" });
384
+ inspections.push({ relPath, spec, pinnedVersion: currentVersion, state: "warmed" });
375
385
  }
376
386
  else {
377
387
  inspections.push({
378
388
  relPath,
379
- spec: pin.spec,
380
- pinnedVersion: pin.version,
389
+ spec,
390
+ pinnedVersion: currentVersion,
381
391
  state: "indeterminate",
382
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. " +
383
- `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.`,
384
394
  });
385
395
  }
386
396
  }
@@ -513,6 +523,306 @@ export function formatToolSurfaceDiagnosticReport(diag) {
513
523
  lines.push("to observe surface changes; no project MCP configuration change is required.");
514
524
  return lines.join("\n");
515
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
+ }
577
+ /** Where generated units live, and how their filenames encode the executor id. */
578
+ function resolveExecutorServiceConvention(platform, homeDir) {
579
+ if (platform === "darwin") {
580
+ return {
581
+ dir: executorLaunchdDirForHome(homeDir),
582
+ kind: "launchd-plist",
583
+ idFromFilename: executorIdFromLaunchdFilename,
584
+ };
585
+ }
586
+ if (platform === "linux") {
587
+ return {
588
+ dir: executorSystemdDirForHome(homeDir),
589
+ kind: "systemd-service",
590
+ idFromFilename: executorIdFromSystemdFilename,
591
+ };
592
+ }
593
+ return null;
594
+ }
595
+ /**
596
+ * Resolve one repository's credential and IMMEDIATELY discard the key, keeping
597
+ * only a source/status label. The resolved value never enters a diagnostic
598
+ * object, a warning, or the rendered report.
599
+ */
600
+ async function probeRepoCredential(repo, deps, credDeps) {
601
+ const target = `bapi:${repo}`;
602
+ const resolve = deps.resolveCredentials ?? resolveBapiCredentials;
603
+ try {
604
+ const result = await resolve(repo, credDeps);
605
+ if (result.ok) {
606
+ return { repo, target, resolved: true, source: String(result.credentials.source) };
607
+ }
608
+ return { repo, target, resolved: false, kind: String(result.kind) };
609
+ }
610
+ catch {
611
+ // Sanitized: a resolver throw is reported as a kind, never as exception text.
612
+ return { repo, target, resolved: false, kind: "unavailable" };
613
+ }
614
+ }
615
+ /**
616
+ * Collect read-only diagnostics for every convention-named executor service unit
617
+ * on this host. Never throws: a vanished directory entry, an unreadable unit, or
618
+ * a malformed body becomes a sanitized advisory warning and collection continues.
619
+ */
620
+ export async function collectExecutorServiceDiagnostics(deps) {
621
+ if (deps.platform === "win32") {
622
+ return {
623
+ status: "skipped",
624
+ reason: "Windows executor services are set up manually through Task Scheduler, so there is no " +
625
+ "generated unit to enumerate (`executor install-service` prints the command line instead).",
626
+ };
627
+ }
628
+ const convention = resolveExecutorServiceConvention(deps.platform, deps.homedir());
629
+ if (!convention) {
630
+ return {
631
+ status: "skipped",
632
+ reason: `platform '${deps.platform}' has no generated executor service format.`,
633
+ };
634
+ }
635
+ let entries;
636
+ try {
637
+ entries = await deps.readdir(convention.dir);
638
+ }
639
+ catch {
640
+ return {
641
+ status: "skipped",
642
+ reason: `no executor service units found (${convention.dir} is absent or unreadable).`,
643
+ };
644
+ }
645
+ // Sort BEFORE inspection so report ordering is deterministic regardless of the
646
+ // order the filesystem happened to return entries in.
647
+ const matching = entries
648
+ .filter((name) => convention.idFromFilename(name) !== null)
649
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
650
+ if (matching.length === 0) {
651
+ return {
652
+ status: "skipped",
653
+ reason: `no executor service units found in ${convention.dir}.`,
654
+ };
655
+ }
656
+ const credDeps = {
657
+ env: deps.env,
658
+ homedir: deps.homedir,
659
+ platform: deps.platform,
660
+ readFile: deps.readFile,
661
+ stat: deps.stat,
662
+ stderr: () => { },
663
+ };
664
+ // Only darwin/linux reach here (win32 and unsupported platforms returned
665
+ // above), so unit paths always use POSIX semantics — matching the generators.
666
+ const pathApi = path.posix;
667
+ const units = [];
668
+ const warnings = [];
669
+ for (const name of matching) {
670
+ const unitPath = pathApi.join(convention.dir, name);
671
+ const executorId = convention.idFromFilename(name) ?? name;
672
+ try {
673
+ await deps.stat(unitPath);
674
+ }
675
+ catch {
676
+ // The entry vanished or became unreadable between enumeration and stat.
677
+ warnings.push(`${unitPath}: disappeared or became unreadable during collection.`);
678
+ continue;
679
+ }
680
+ let content;
681
+ try {
682
+ content = await deps.readFile(unitPath);
683
+ }
684
+ catch {
685
+ units.push({
686
+ path: unitPath,
687
+ executorId,
688
+ baseUrl: null,
689
+ baseUrlClass: "missing",
690
+ repos: [],
691
+ credentials: [],
692
+ warnings: ["unit could not be read; its configuration cannot be verified."],
693
+ });
694
+ continue;
695
+ }
696
+ const inspection = inspectExecutorServiceArtifact(convention.kind, content);
697
+ const unitWarnings = [...inspection.problems];
698
+ let baseUrlClass;
699
+ if (!inspection.baseUrl) {
700
+ baseUrlClass = "missing";
701
+ unitWarnings.push(`no ${EXECUTOR_SERVICE_BASE_URL_ENV} in the unit — the executor requires an explicit base ` +
702
+ "URL and will fail to start.");
703
+ }
704
+ else if (inspection.baseUrl.replace(/\/+$/, "") === DEFAULT_BAPI_BASE_URL) {
705
+ baseUrlClass = "production-default";
706
+ unitWarnings.push(`${EXECUTOR_SERVICE_BASE_URL_ENV} points at production (${DEFAULT_BAPI_BASE_URL}) — legal, ` +
707
+ "but worth confirming this host is meant to serve production jobs.");
708
+ }
709
+ else {
710
+ baseUrlClass = "configured";
711
+ }
712
+ // Probe each DISTINCT repository once per unit, even when the unit repeats it.
713
+ const distinctRepos = Array.from(new Set(inspection.repos));
714
+ const credentials = [];
715
+ for (const repo of distinctRepos) {
716
+ credentials.push(await probeRepoCredential(repo, deps, credDeps));
717
+ }
718
+ units.push({
719
+ path: unitPath,
720
+ executorId,
721
+ baseUrl: inspection.baseUrl,
722
+ baseUrlClass,
723
+ repos: inspection.repos,
724
+ credentials,
725
+ warnings: unitWarnings,
726
+ });
727
+ }
728
+ return { status: "collected", units, warnings };
729
+ }
730
+ /**
731
+ * Render the advisory "Executor provisioning" section (pure formatting — no I/O).
732
+ * Every line is found / WARN / SKIP; nothing here changes the exit code.
733
+ */
734
+ export function formatExecutorServiceDiagnosticsReport(diagnostics) {
735
+ const lines = ["", "Executor provisioning (generated service units — advisory)", ""];
736
+ if (diagnostics.status === "skipped") {
737
+ lines.push(`SKIP ${diagnostics.reason}`);
738
+ lines.push("");
739
+ lines.push("This section is read-only and advisory: it enumerates units by fixed directory and filename");
740
+ lines.push("prefix, and never changes the doctor exit code.");
741
+ return lines.join("\n");
742
+ }
743
+ for (const warning of diagnostics.warnings) {
744
+ lines.push(`WARN ${warning}`);
745
+ }
746
+ for (const unit of diagnostics.units) {
747
+ lines.push(`found ${unit.path}`);
748
+ lines.push(` executor id: ${unit.executorId}`);
749
+ lines.push(` ${EXECUTOR_SERVICE_BASE_URL_ENV}: ${unit.baseUrl ?? "(absent)"} [${unit.baseUrlClass}]`);
750
+ lines.push(` repos: ${unit.repos.length > 0 ? unit.repos.join(", ") : "(none parsed)"}`);
751
+ for (const cred of unit.credentials) {
752
+ lines.push(cred.resolved
753
+ ? ` credential ${cred.target}: resolved (source: ${cred.source})`
754
+ : ` credential ${cred.target}: NOT resolved (${cred.kind})`);
755
+ }
756
+ for (const warning of unit.warnings) {
757
+ lines.push(`WARN ${unit.path}: ${warning}`);
758
+ }
759
+ }
760
+ lines.push("");
761
+ lines.push("Generated units carry only the base URL, the generating shell's PATH, repositories, and", "executor id — credentials resolve at");
762
+ lines.push("launch time from BAPI_API_KEY or the user-scoped bapi:<repo> store, so no key value is stored on");
763
+ lines.push("disk or printed here. Starting, stopping, and enabling a unit stay operator-managed. This section");
764
+ lines.push("is advisory and never changes the doctor exit code.");
765
+ return lines.join("\n");
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
+ }
516
826
  /**
517
827
  * CLI entry for the read-only `doctor` subcommand. Returns a process exit code.
518
828
  * Help returns 0; parser errors return 1; otherwise it prints the report and
@@ -621,7 +931,89 @@ export async function runDoctorCli(argv, overrides = {}) {
621
931
  }));
622
932
  }
623
933
  }
934
+ // Advisory executor-provisioning section (BAPI-688). Strictly read-only: it
935
+ // enumerates convention-named units under a fixed OS directory, parses their
936
+ // non-secret configuration, and probes credential resolution WITHOUT retaining
937
+ // any key. It is collected independently of `DoctorCollectionResult` and, like
938
+ // the sections above, never affects the exit code — missing units, malformed
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;
944
+ if (overrides.executorService !== false) {
945
+ try {
946
+ const injectedFs = deps;
947
+ const executorDeps = {
948
+ platform: overrides.executorService?.platform ?? deps.platform,
949
+ env: overrides.executorService?.env ?? deps.env,
950
+ homedir: overrides.executorService?.homedir ?? injectedFs.homedir ?? os.homedir,
951
+ readdir: overrides.executorService?.readdir ?? ((p) => readdir(p)),
952
+ readFile: overrides.executorService?.readFile ??
953
+ injectedFs.readFile ??
954
+ ((p) => readFile(p, "utf-8")),
955
+ stat: overrides.executorService?.stat ?? injectedFs.stat ?? ((p) => stat(p)),
956
+ resolveCredentials: overrides.executorService?.resolveCredentials,
957
+ };
958
+ const executorDiagnostics = await collectExecutorServiceDiagnostics(executorDeps);
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
+ }
973
+ }
974
+ catch {
975
+ // Any unexpected failure still renders a sanitized advisory SKIP line.
976
+ log(formatExecutorServiceDiagnosticsReport({
977
+ status: "skipped",
978
+ reason: "executor service units could not be enumerated on this host.",
979
+ }));
980
+ }
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
+ }
624
1014
  if (!collection.ok)
625
1015
  return 1;
1016
+ if (isolationFailed)
1017
+ return 1;
626
1018
  return collection.results.some((r) => !r.found) ? 1 : 0;
627
1019
  }