@bridge_gpt/mcp-server 0.2.36 → 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 (90) hide show
  1. package/README.md +44 -6
  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 +51 -14
  63. package/build/sfcc/output.js +41 -11
  64. package/build/sfcc/permissions.js +24 -2
  65. package/build/sfcc/read-projection.js +181 -0
  66. package/build/sfcc/read-result.js +158 -0
  67. package/build/sfcc/reads-custom-object-def.js +29 -18
  68. package/build/sfcc/reads-site-preference.js +75 -29
  69. package/build/sfcc/reads-system-object.js +40 -34
  70. package/build/sfcc/sfcc-result.js +106 -0
  71. package/build/sfcc/tool-wrapper.js +56 -13
  72. package/build/sfcc/write-grants.js +45 -22
  73. package/build/sfcc/write-guard.js +21 -13
  74. package/build/sfcc/write-result.js +61 -14
  75. package/build/sfcc/write-tool-common.js +126 -32
  76. package/build/sfcc/writes-system-object.js +11 -50
  77. package/build/start-tickets-prereqs.js +129 -0
  78. package/build/start-tickets.js +17 -13
  79. package/build/ticket-backend-metadata.js +59 -0
  80. package/build/ticket-key-utils.js +92 -0
  81. package/build/tool-error-envelope.js +71 -0
  82. package/build/tool-surface-gating.js +72 -0
  83. package/build/update-status.js +102 -0
  84. package/build/upgrade-advice.js +47 -0
  85. package/build/upgrade-cli.js +417 -101
  86. package/build/version.generated.js +1 -1
  87. package/build/worktree-core.js +73 -0
  88. package/docs/CONDUCTOR.md +23 -8
  89. package/package.json +3 -3
  90. package/pipelines/implement-ticket.json +15 -5
package/README.md CHANGED
@@ -397,7 +397,7 @@ An entry with no trailing `serve` still starts the server — bare invocation me
397
397
  <summary><strong>Upgrading Bridge</strong></summary>
398
398
 
399
399
  ```bash
400
- npx -y @bridge_gpt/mcp-server upgrade
400
+ npx -y @bridge_gpt/mcp-server@latest --upgrade
401
401
  ```
402
402
 
403
403
  `upgrade` fetches the latest published version, refreshes your scaffolded slash
@@ -405,8 +405,34 @@ commands, agents, and pipelines, updates the version pin in your MCP config, and
405
405
  opens a session so you can reconnect. It is also available as the
406
406
  `/upgrade-bridge` slash command.
407
407
 
408
- The server checks for updates on startup and notes a newer version in your editor's
409
- MCP output log. That check is cached for a day and never blocks startup.
408
+ Use the `@latest` form. It applies to the short-lived *upgrader* process: without
409
+ it, npx may reuse a cached older copy of the package and "upgrade" you with the
410
+ build you are trying to replace. The exact `MAJOR.MINOR.PATCH` pin the upgrader
411
+ writes into your MCP config is deliberately different — host configs stay pinned
412
+ to an exact release so a project's server is reproducible.
413
+
414
+ `upgrade` reports **per config file**, because a project can have several
415
+ (`.mcp.json`, `.cursor/mcp.json`, `.vscode/mcp.json`) and they can disagree:
416
+
417
+ ```
418
+ Launcher pins:
419
+ .mcp.json: 0.2.16 -> 0.2.36
420
+ .cursor/mcp.json: already 0.2.36
421
+ ```
422
+
423
+ When every applicable launcher pin was already at the target, it prints
424
+ `Already up-to-date.` — that status comes from comparing your configs, not from
425
+ the version of the CLI process. A non-zero exit means the upgrade did **not**
426
+ converge (an unreadable config, a launcher carrying a version range the upgrader
427
+ must not rewrite, a competing local install it could not remove, or a pin that
428
+ failed post-write verification); nothing is reported as complete in that case.
429
+
430
+ The server checks for updates on startup. The check is cached for a day and never
431
+ blocks startup. When a newer version is known, it surfaces in two places you do
432
+ not have to go looking for: a one-line warning on the server's **stderr**, and a
433
+ short advisory attached to the ordinary `tools/list` response so the agent in the
434
+ session can see that some tools may be missing or renamed in the older build.
435
+ Neither requires calling `ping` or `doctor`.
410
436
 
411
437
  Re-running `install` on an already-configured project is safe: it refreshes the
412
438
  scaffolded files without overwriting your stored credential unless you pass
@@ -559,6 +585,12 @@ Good to know, but not needed every day.
559
585
  - **How to use it:** Ask your agent to resume the full-automation chain, naming the run to continue.
560
586
  - **Flags:** None.
561
587
 
588
+ **15. Update Ticket Description**
589
+ - **What it does:** Rewrites a ticket's description with AI, using the ticket's own content and its reference material. A rewrite that changes more than 60% of the description is held for review instead of applied.
590
+ - **When it's useful:** (Refinement) When a ticket has accumulated comments, attachments, or links and its description no longer reflects them.
591
+ - **How to use it:** Ask your agent — "Update the description for BAPI-123."
592
+ - **Flags:** None. Poll the ticket's state for the outcome; if the update was held for review, read the proposal instead of applying it blind.
593
+
562
594
  ### Now and then
563
595
 
564
596
  Useful once in a while.
@@ -611,7 +643,13 @@ Useful once in a while.
611
643
  - **How to use it:** Ask your agent — "Download the design mockups attached to BAPI-123 into my docs folder," or "Attach build-log.txt to BAPI-123."
612
644
  - **Flags:** Choose the direction (download from the ticket, or upload to it) and, for a download, where the files should land.
613
645
 
614
- **9. Estimate Epic**
646
+ **9. Estimate Ticket**
647
+ - **What it does:** Estimates the development effort for one ticket. Use Estimate Epic instead for a whole epic or a named group of tickets.
648
+ - **When it's useful:** (Refinement) When you need a size for a single ticket before committing to it.
649
+ - **How to use it:** Ask your agent — "Estimate BAPI-123."
650
+ - **Flags:** Ask for a fresh estimate to regenerate rather than reuse a stored one.
651
+
652
+ **10. Estimate Epic**
615
653
  - **What it does:** Estimates an epic, or an explicit group of tickets you name.
616
654
  - **When it's useful:** (Architecture | Refinement) When you need a sizing pass across an epic, or across a set of tickets you name explicitly.
617
655
  - **How to use it:** `/estimate-epic BAPI-123`
@@ -756,7 +794,7 @@ Without this, only the diagnostic tools are registered.
756
794
 
757
795
  ### Tools
758
796
 
759
- All SFCC tools are read-only and target a developer sandbox. Oversized responses are automatically saved to `BAPI_DOCS_DIR/sfcc/` and previewed inline.
797
+ All SFCC tools are read-only and target a developer sandbox. An oversized response is saved in full to `BAPI_DOCS_DIR/sfcc/` and replaced by a parseable JSON descriptor — `truncated: true`, the `saved_path` it was written to, and the `page` metadata (`returned`, `total` when OCAPI supplied one, `has_more`) — so the collection metadata survives even though the data itself is on disk. If that save fails, the complete payload is returned inline instead, still as parseable JSON.
760
798
 
761
799
  **Diagnostics** (always available, no profile needed)
762
800
  - `sfcc_setup_status` — report on every prerequisite: Bridge API key, repo name, `version` config, `dw.json` presence/uniqueness, AM (OCAPI) token acquisition, and the independent **SFCC Log Query (WebDAV)** capability that gates `sfcc_log_query`.
@@ -764,7 +802,7 @@ All SFCC tools are read-only and target a developer sandbox. Oversized responses
764
802
 
765
803
  **System object model** (needs the `sfcc` profile)
766
804
  - `system_object_list` — list system object types (Product, Order, Customer, …).
767
- - `system_object_get` — fetch one type's definition, optionally with its full attribute definitions (`expand_attribute_definitions`).
805
+ - `system_object_get` — fetch one system object type's definition.
768
806
  - `system_object_attribute_search` — search a type's attribute definitions; prefer this over a full dump when hunting a specific `c_` custom attribute.
769
807
 
770
808
  **Custom object definitions** (needs the `sfcc` profile)
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Pure Bridge API base-URL normalization and validation (BAPI-727).
3
+ *
4
+ * Extracted so the executor's credential resolution and the `mcp-invoke` shim can
5
+ * share ONE definition of "normalized base URL" without `mcp-invoke` importing
6
+ * executor runtime behavior (`executor/credentials.ts` pulls in the executor deps
7
+ * types and the credential store, neither of which the shim should depend on).
8
+ *
9
+ * Everything here is pure: no filesystem, no process, no network, no environment.
10
+ * Validation messages name the flag and the reason ONLY — never the supplied URL,
11
+ * which can carry embedded credentials in its userinfo component.
12
+ */
13
+ /**
14
+ * Trim, strip every trailing slash, and treat an empty result as absent.
15
+ *
16
+ * This is the executor's long-standing normalization, moved verbatim so the value
17
+ * a worker registration carries is byte-identical to the one the executor itself
18
+ * uses for its HTTP calls.
19
+ */
20
+ export function normalizeBaseUrlCandidate(raw) {
21
+ if (typeof raw !== "string")
22
+ return null;
23
+ const normalized = raw.trim().replace(/\/+$/, "");
24
+ return normalized.length > 0 ? normalized : null;
25
+ }
26
+ /** Fixed, secret-free explanation for each rejection reason. */
27
+ const REJECTION_TEXT = {
28
+ empty: "value is empty",
29
+ "not-absolute-http-url": "value is not an absolute URL",
30
+ "unsupported-scheme": "only http: and https: URLs are supported",
31
+ "missing-hostname": "URL has no hostname",
32
+ "embedded-credentials": "URL must not embed a username or password",
33
+ };
34
+ /**
35
+ * Human-readable rejection message for a flag. Interpolates only the flag name and
36
+ * a fixed reason — deliberately NOT the rejected value, so a URL containing
37
+ * `https://user:secret@host` can never reach an error string, usage text, or log.
38
+ */
39
+ export function describeBaseUrlRejection(flag, reason) {
40
+ return `Invalid ${flag}: ${REJECTION_TEXT[reason]}`;
41
+ }
42
+ /**
43
+ * Strictly validate a base URL supplied on a command line: it must be a non-empty,
44
+ * absolute `http:`/`https:` URL with a hostname and no embedded user information.
45
+ * The accepted value is returned normalized by
46
+ * {@link normalizeBaseUrlCandidate} so it matches the executor's own form.
47
+ */
48
+ export function validateHttpBaseUrl(raw) {
49
+ if (typeof raw !== "string" || raw.trim().length === 0) {
50
+ return { ok: false, reason: "empty" };
51
+ }
52
+ let parsed;
53
+ try {
54
+ parsed = new URL(raw.trim());
55
+ }
56
+ catch {
57
+ // A relative value ("/api", "api.example.test/x") lands here: the WHATWG URL
58
+ // constructor requires an absolute URL when no base is supplied.
59
+ return { ok: false, reason: "not-absolute-http-url" };
60
+ }
61
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
62
+ return { ok: false, reason: "unsupported-scheme" };
63
+ }
64
+ // Defensive: for http/https the WHATWG parser already rejects an empty
65
+ // authority above (`http://` throws), so this branch is not reachable through
66
+ // those schemes today. It is kept because "must have a hostname" is the real
67
+ // invariant, and relying on a parser implementation detail to enforce it would
68
+ // be fragile.
69
+ if (parsed.hostname.length === 0) {
70
+ return { ok: false, reason: "missing-hostname" };
71
+ }
72
+ if (parsed.username.length > 0 || parsed.password.length > 0) {
73
+ return { ok: false, reason: "embedded-credentials" };
74
+ }
75
+ const normalized = normalizeBaseUrlCandidate(raw);
76
+ if (normalized === null)
77
+ return { ok: false, reason: "empty" };
78
+ return { ok: true, baseUrl: normalized };
79
+ }
@@ -9,6 +9,15 @@
9
9
  *
10
10
  * The base URL is supplied by the caller (never hardcoded here) and its
11
11
  * trailing slashes are trimmed so no doubled slash is produced.
12
+ *
13
+ * The `/jira` segment is the Bridge API's TRANSPORT namespace — the router mount
14
+ * that serves these routes in BOTH ticket backend modes. It is not, and must not
15
+ * become, an agent-visible ticket link: a ticket's URL is whatever the server
16
+ * returns on the create/read response (`https://…` in Jira mode,
17
+ * `local://tickets/…` in local mode) and is passed through verbatim. Renaming
18
+ * this mount is explicitly out of scope (no renaming of Jira-shaped surfaces),
19
+ * and synthesizing a ticket URL from it would produce a link to an API route
20
+ * rather than to a ticket.
12
21
  */
13
22
  /**
14
23
  * Create the URL builders bound to `baseUrl`. Trailing slashes on the base are
@@ -25,6 +25,8 @@
25
25
  */
26
26
  import { runPipeline, resumePipeline, peekPipelineRun } from "./pipeline-orchestrator.js";
27
27
  import { toStringVariable, substituteChainValue } from "./chain-utils.js";
28
+ import { isValidTicketKey, validateTicketKeys } from "./ticket-key-utils.js";
29
+ import { isUnsupportedInLocalModeEnvelope, parseToolErrorEnvelope, } from "./tool-error-envelope.js";
28
30
  const CHAIN_NAME = "full-automation";
29
31
  const START_TICKETS_PIPELINE = "start-tickets";
30
32
  // Display titles for preambles, keyed by child pipeline name.
@@ -166,13 +168,22 @@ function normalizeAutoApprove(value) {
166
168
  return true;
167
169
  return false;
168
170
  }
169
- const JIRA_KEY_EXACT = /^[A-Z][A-Z0-9]+-\d+$/;
170
171
  /**
171
- * Extract created Jira ticket keys from a STRUCTURED payload (an object/array
172
+ * Extract created ticket keys from a STRUCTURED payload (an object/array
172
173
  * holding `created_ticket_keys` / `ticket_key` / etc.). Collects values from the
173
- * preferred key fields in a fixed preference order, validates each against
174
- * `JIRA_KEY_EXACT`, and de-dupes in encounter order. There is deliberately NO
175
- * free-text regex fallback: pass the parsed upload-step payload (via
174
+ * preferred key fields in a fixed preference order, validates each against the
175
+ * canonical `TICKET_KEY_PATTERN`, and de-dupes in encounter order.
176
+ *
177
+ * BAPI-710: the validation used to run against a private `/^[A-Z][A-Z0-9]+-\d+$/`
178
+ * that rejected keys the server mints (`MY_PROJ-248`, `A-1`), and this helper
179
+ * DROPPED every non-match silently. Silent dropping is now impossible for the
180
+ * chain — {@link extractCreatedTicketPayload} rejects the whole payload and names
181
+ * every offending key. This flat helper still filters (its `string[]` contract
182
+ * has no failure channel), but it is a non-chain compatibility path only and the
183
+ * grammar it filters on is now the correct one.
184
+ *
185
+ * There is deliberately NO free-text regex fallback: pass the parsed
186
+ * upload-step payload (via
176
187
  * `extractCreatedKeysFromCompletedChild`), never a whole pipeline transcript, so
177
188
  * keys merely *mentioned* by an earlier step (e.g. duplicate-detection search
178
189
  * hits) can never be mistaken for this run's creations. A string / no-field
@@ -226,7 +237,7 @@ export function extractCreatedTicketKeys(source) {
226
237
  const ordered = [];
227
238
  for (const key of preferredKeys) {
228
239
  for (const candidate of buckets[key]) {
229
- if (JIRA_KEY_EXACT.test(candidate))
240
+ if (isValidTicketKey(candidate))
230
241
  ordered.push(candidate);
231
242
  }
232
243
  }
@@ -278,7 +289,7 @@ function tryParseJson(raw) {
278
289
  }
279
290
  }
280
291
  function isValidJiraKey(value) {
281
- return typeof value === "string" && JIRA_KEY_EXACT.test(value);
292
+ return isValidTicketKey(value);
282
293
  }
283
294
  /**
284
295
  * Normalize a PARSED, STRUCTURED upload payload into a {@link CreatedTicketPayload}.
@@ -316,7 +327,7 @@ export function extractCreatedTicketPayload(source) {
316
327
  if (!isValidJiraKey(parent)) {
317
328
  return {
318
329
  ok: false,
319
- error: `Epic payload "epic_parent_key" must be a valid Jira key; got ${JSON.stringify(parent)}.`,
330
+ error: `Epic payload "epic_parent_key" must be a valid ticket key; got ${JSON.stringify(parent)}.`,
320
331
  };
321
332
  }
322
333
  const rawChildren = obj.child_ticket_keys;
@@ -327,14 +338,20 @@ export function extractCreatedTicketPayload(source) {
327
338
  "implementable child ticket key.",
328
339
  };
329
340
  }
341
+ // Validate the WHOLE child list first and report every rejected key in one
342
+ // error (BAPI-710). Failing on the first bad key made a caller fix its
343
+ // payload one key per round trip, and the previous grammar rejected keys the
344
+ // server legitimately mints — so a mixed-validity list must fail atomically,
345
+ // never advance with the valid subset.
346
+ const childKeyCheck = validateTicketKeys(rawChildren, "child_ticket_keys entry");
347
+ if (!childKeyCheck.ok) {
348
+ return {
349
+ ok: false,
350
+ error: `Epic payload "child_ticket_keys" must contain only valid implementable ticket keys. ${childKeyCheck.message}`,
351
+ };
352
+ }
330
353
  const children = [];
331
354
  for (const c of rawChildren) {
332
- if (!isValidJiraKey(c)) {
333
- return {
334
- ok: false,
335
- error: `Epic payload "child_ticket_keys" must contain only valid implementable Jira keys; got ${JSON.stringify(c)}.`,
336
- };
337
- }
338
355
  if (c === parent) {
339
356
  return {
340
357
  ok: false,
@@ -361,7 +378,7 @@ export function extractCreatedTicketPayload(source) {
361
378
  return {
362
379
  ok: false,
363
380
  error: 'Single-ticket payload "created_ticket_keys" must contain exactly ' +
364
- "one valid implementable Jira key. A multi-key list is malformed " +
381
+ "one valid implementable ticket key. A multi-key list is malformed " +
365
382
  '(the legacy "first key is the Epic parent" convention is not accepted).',
366
383
  };
367
384
  }
@@ -431,6 +448,36 @@ function failedEnvelopeMentions(env, token) {
431
448
  }
432
449
  return false;
433
450
  }
451
+ /**
452
+ * Find a recognized ``UNSUPPORTED_IN_LOCAL_MODE`` refusal anywhere in a child
453
+ * pipeline envelope (BAPI-710).
454
+ *
455
+ * Scans the top-level halt error and each step's ``error`` / string ``result``,
456
+ * matching only the exact ``(error, status)`` pair via the strict parser — never
457
+ * a substring of prose, so an echoed input or a message that merely mentions the
458
+ * marker cannot terminate a chain.
459
+ *
460
+ * A ``completed`` envelope is scanned too, not just a ``failed`` one: a
461
+ * ``warn_and_continue`` step in an older persisted run can have swallowed the
462
+ * refusal into a completed envelope, and treating that as success is exactly the
463
+ * partial-success outcome this check exists to prevent.
464
+ */
465
+ function findUnsupportedLocalModeRefusal(env) {
466
+ const topLevel = parseToolErrorEnvelope(env.error);
467
+ if (isUnsupportedInLocalModeEnvelope(topLevel))
468
+ return topLevel;
469
+ const results = env.results;
470
+ if (!Array.isArray(results))
471
+ return null;
472
+ for (const step of results) {
473
+ for (const candidate of [step?.error, step?.result]) {
474
+ const parsed = parseToolErrorEnvelope(candidate);
475
+ if (isUnsupportedInLocalModeEnvelope(parsed))
476
+ return parsed;
477
+ }
478
+ }
479
+ return null;
480
+ }
434
481
  /**
435
482
  * Heuristic, no-LLM one-line stage summary. idea-to-ticket passes a structured
436
483
  * {@link CreatedTicketPayload} so the summary distinguishes the Epic parent from
@@ -782,6 +829,37 @@ async function handleChildPipelineEnvelope(persistence, recipe, row, childEnv, o
782
829
  }),
783
830
  };
784
831
  }
832
+ // BAPI-710 terminal gate. Runs BEFORE the failed-mapping branch, before the
833
+ // fan-out advance, and before the single-child finalize — i.e. before review
834
+ // fan-out, worktree preparation, or any start-tickets command can be emitted.
835
+ // A backend that cannot perform the operation will not start being able to on
836
+ // the next stage, so continuing would only produce a partial success that
837
+ // looks like a real one.
838
+ const refusal = findUnsupportedLocalModeRefusal(childEnv);
839
+ if (refusal) {
840
+ const stages = cloneStages(row.stages);
841
+ if (stages[idx])
842
+ stages[idx].status = "failed";
843
+ try {
844
+ await persistence.patchRun(row.chain_run_id, { stages, status: "failed" });
845
+ }
846
+ catch {
847
+ // best-effort — the terminal failure is what matters
848
+ }
849
+ return {
850
+ kind: "fail",
851
+ envelope: {
852
+ ...failedEnvelope("TOOL_ERROR", refusal.message, {
853
+ chain_run_id: row.chain_run_id,
854
+ chain_stage: stageRecipe.pipeline_name,
855
+ chain_step: idx + 1,
856
+ chain_total: total,
857
+ }),
858
+ backend_error: refusal.error,
859
+ ...(refusal.supported_in ? { supported_in: refusal.supported_in } : {}),
860
+ },
861
+ };
862
+ }
785
863
  if (childEnv.status === "failed") {
786
864
  // Stage 0 vagueness or no-keys produced => VALIDATION. Anchor the sentinel
787
865
  // check to the failure-text fields (the halt error + per-step error/result
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Read-only inspection of the machine's Claude user configuration
3
+ * (`~/.claude.json`) for `bridge-api` MCP registrations that can SHADOW the
4
+ * secret-free registration the conductor provisions into a worker worktree
5
+ * (BAPI-727).
6
+ *
7
+ * EMPIRICALLY ESTABLISHED BEHAVIOR — the only precedence claim this module makes:
8
+ * a `projects["<main repository path>"].mcpServers["bridge-api"]` entry was
9
+ * observed to win over a linked worktree's own `.mcp.json`. That is the failure
10
+ * this inspector exists to detect: the worker silently talks to whatever server
11
+ * the user-level entry names (in practice, production) instead of the endpoint the
12
+ * executor provisioned.
13
+ *
14
+ * NOT ESTABLISHED, AND MUST NOT BE INFERRED FROM THIS MODULE:
15
+ * - user-scope (`mcpServers`) versus project-scope precedence,
16
+ * - precedence of a `projects["<worktree path>"]` entry against anything else,
17
+ * - symlink equivalence between a configured path and the real path,
18
+ * - filesystem case-equivalence on case-insensitive hosts.
19
+ * The inspector therefore looks in all three places and reports what it finds; it
20
+ * does not rank them.
21
+ *
22
+ * SEPARATE PRIOR ART: the BAPI-451 cold-start "failed to connect" condition is a
23
+ * launcher/npx-cache TIMING problem, not configuration shadowing. Nothing here
24
+ * diagnoses or remediates that.
25
+ *
26
+ * SECRET SAFETY: this module never retains the parsed document, a registration
27
+ * entry, a command/argument array, an environment VALUE, a read exception, or
28
+ * malformed source text. Findings carry only scope labels, the config path, a
29
+ * fixed shape classification, and sorted environment KEY NAMES.
30
+ */
31
+ import { isBridgeApiShimEntry, readJsonIfPresent } from "./mcp-registration-doctor.js";
32
+ import { normalizeWorktreePathForRegistration, pathApiForProvisioningPlatform, } from "./mcp-provisioning.js";
33
+ /** The registration key both the provisioner and a user config would use. */
34
+ const BRIDGE_API_SERVER_NAME = "bridge-api";
35
+ /** The Claude user configuration file name, at the home directory root. */
36
+ const CLAUDE_USER_CONFIG_FILENAME = ".claude.json";
37
+ /**
38
+ * Build the platform-appropriate path to the Claude user configuration from an
39
+ * explicitly supplied home directory. Shared by executor preflight, per-worktree
40
+ * enforcement, and doctor so none of them hardcodes its own join, and consults no
41
+ * global process state.
42
+ */
43
+ export function resolveClaudeUserConfigPath(homeDirectory, platform) {
44
+ return pathApiForProvisioningPlatform(platform).join(homeDirectory, CLAUDE_USER_CONFIG_FILENAME);
45
+ }
46
+ /**
47
+ * Environment override that downgrades a confirmed shadowing collision from a
48
+ * refusal to a loud warning. Deliberately long and conductor-scoped so it cannot
49
+ * be set by accident.
50
+ */
51
+ export const CONDUCTOR_ALLOW_CLAUDE_MCP_SHADOWING_ENV = "BAPI_CONDUCTOR_ALLOW_CLAUDE_MCP_SHADOWING";
52
+ /** A plain, non-array object. */
53
+ function asRecord(value) {
54
+ if (!value || typeof value !== "object" || Array.isArray(value))
55
+ return null;
56
+ return value;
57
+ }
58
+ /**
59
+ * Sorted `env` key names, or `[]` when the entry has no `env` object. Only NAMES
60
+ * are read; the values are never touched.
61
+ */
62
+ function envKeyNamesOf(entry) {
63
+ const record = asRecord(entry);
64
+ if (!record)
65
+ return [];
66
+ const env = asRecord(record.env);
67
+ if (!env)
68
+ return [];
69
+ return Object.keys(env).sort();
70
+ }
71
+ /** True when the entry declares an `env` object at all (even an empty one). */
72
+ function hasEnvBlock(entry) {
73
+ const record = asRecord(entry);
74
+ if (!record)
75
+ return false;
76
+ return asRecord(record.env) !== null;
77
+ }
78
+ /**
79
+ * Structural shape probe: does this look like the Bridge `mcp-invoke` shim,
80
+ * ignoring WHICH project root it names? Used only to tell "a shim aimed at
81
+ * another checkout" apart from "some unrelated command"; acceptance always goes
82
+ * through {@link isBridgeApiShimEntry} against an expected path.
83
+ */
84
+ function looksLikeBridgeShimCommand(entry) {
85
+ const record = asRecord(entry);
86
+ if (!record)
87
+ return false;
88
+ if (typeof record.command !== "string" || record.command.length === 0)
89
+ return false;
90
+ if (!Array.isArray(record.args))
91
+ return false;
92
+ const args = record.args.filter((a) => typeof a === "string");
93
+ if (!args.includes("mcp-invoke"))
94
+ return false;
95
+ const targetIndex = args.indexOf("--target");
96
+ return targetIndex >= 0 && args[targetIndex + 1] === "bapi";
97
+ }
98
+ /**
99
+ * Classify a registration for one scope.
100
+ *
101
+ * An entry is an acceptable provisioned shim only when `isBridgeApiShimEntry()`
102
+ * accepts it for one of the paths this scope may legitimately name AND it stays
103
+ * secret-free (no `env` block). The provisioner emits exactly `{ command, args }`
104
+ * — an `env` block therefore proves the entry was not written by it, no matter how
105
+ * closely the command and arguments otherwise resemble the shim.
106
+ *
107
+ * `allowedPaths` is empty when the caller could not resolve the path this scope
108
+ * compares against; a shim-shaped entry then classifies as inconclusive rather
109
+ * than as a confirmed collision, so an unresolved path can never manufacture a
110
+ * false refusal.
111
+ */
112
+ function classifyEntry(entry, allowedPaths) {
113
+ if (hasEnvBlock(entry)) {
114
+ return {
115
+ verdict: "foreign",
116
+ shape: looksLikeBridgeShimCommand(entry) ? "shim-with-env-block" : "non-shim-registration",
117
+ };
118
+ }
119
+ for (const allowed of allowedPaths) {
120
+ if (isBridgeApiShimEntry(entry, allowed))
121
+ return { verdict: "safe" };
122
+ }
123
+ if (looksLikeBridgeShimCommand(entry)) {
124
+ if (allowedPaths.length === 0)
125
+ return { verdict: "inconclusive" };
126
+ return { verdict: "foreign", shape: "shim-for-other-project-root" };
127
+ }
128
+ return { verdict: "foreign", shape: "non-shim-registration" };
129
+ }
130
+ /** Resolve a supplied path through the shared registration normalizer, or null. */
131
+ function normalizeOrNull(rawPath, input) {
132
+ if (typeof rawPath !== "string" || rawPath.trim().length === 0)
133
+ return null;
134
+ const normalized = normalizeWorktreePathForRegistration(rawPath, {
135
+ platform: input.platform,
136
+ cwd: input.cwd,
137
+ });
138
+ return normalized.ok ? normalized.path : null;
139
+ }
140
+ /**
141
+ * Inspect `~/.claude.json` for a `bridge-api` registration at user, main-project,
142
+ * and worktree-project scope.
143
+ *
144
+ * Healthy states (no finding, no diagnostic): the file is absent, or it contains
145
+ * no same-named registration in any inspected scope, or every same-named
146
+ * registration is an acceptable provisioned shim.
147
+ *
148
+ * Degraded states (diagnostic, never a confirmed collision): the file could not be
149
+ * read, its JSON is malformed, or a user-scope shim names a project root that
150
+ * cannot be checked because no main-repository path was supplied.
151
+ *
152
+ * Never throws, never writes, never spawns.
153
+ */
154
+ export async function inspectClaudeUserConfigForMcpShadowing(input, deps) {
155
+ const configPath = input.claudeConfigPath;
156
+ const read = await readJsonIfPresent(configPath, deps);
157
+ if (read.state === "missing")
158
+ return { findings: [], diagnostics: [] };
159
+ if (read.state === "unreadable") {
160
+ return { findings: [], diagnostics: [{ kind: "unreadable", configPath }] };
161
+ }
162
+ if (read.state === "malformed") {
163
+ return { findings: [], diagnostics: [{ kind: "malformed", configPath }] };
164
+ }
165
+ const document = asRecord(read.value);
166
+ if (!document)
167
+ return { findings: [], diagnostics: [] };
168
+ const mainPath = normalizeOrNull(input.mainRepositoryPath, input);
169
+ const worktreePath = normalizeOrNull(input.worktreePath, input);
170
+ // A user-scope shim may legitimately target either checkout the executor is
171
+ // working with; each project scope may only target its own key's path.
172
+ const userAllowed = [mainPath, worktreePath].filter((p) => p !== null);
173
+ const lookups = [{ scope: "user", projectKey: null, allowedPaths: userAllowed }];
174
+ if (mainPath !== null) {
175
+ lookups.push({ scope: "main-project", projectKey: mainPath, allowedPaths: [mainPath] });
176
+ }
177
+ if (worktreePath !== null) {
178
+ lookups.push({
179
+ scope: "worktree-project",
180
+ projectKey: worktreePath,
181
+ allowedPaths: [worktreePath],
182
+ });
183
+ }
184
+ const projects = asRecord(document.projects);
185
+ const findings = [];
186
+ const diagnostics = [];
187
+ // Identical `projects` keys are ONE registration. Dedupe by key while keeping
188
+ // every requested scope it satisfies, so a main path and worktree path that
189
+ // normalize together report once with both scopes rather than twice.
190
+ const findingByProjectKey = new Map();
191
+ for (const lookup of lookups) {
192
+ let servers;
193
+ if (lookup.projectKey === null) {
194
+ servers = asRecord(document.mcpServers);
195
+ }
196
+ else {
197
+ const project = projects ? asRecord(projects[lookup.projectKey]) : null;
198
+ servers = project ? asRecord(project.mcpServers) : null;
199
+ }
200
+ if (!servers)
201
+ continue;
202
+ if (!Object.prototype.hasOwnProperty.call(servers, BRIDGE_API_SERVER_NAME))
203
+ continue;
204
+ const entry = servers[BRIDGE_API_SERVER_NAME];
205
+ const classification = classifyEntry(entry, lookup.allowedPaths);
206
+ if (classification.verdict === "safe")
207
+ continue;
208
+ if (classification.verdict === "inconclusive") {
209
+ diagnostics.push({ kind: "inconclusive-user-shim", configPath });
210
+ continue;
211
+ }
212
+ if (lookup.projectKey !== null) {
213
+ const existing = findingByProjectKey.get(lookup.projectKey);
214
+ if (existing) {
215
+ if (!existing.scopes.includes(lookup.scope))
216
+ existing.scopes.push(lookup.scope);
217
+ continue;
218
+ }
219
+ }
220
+ const finding = {
221
+ scopes: [lookup.scope],
222
+ configPath,
223
+ shape: classification.shape,
224
+ envKeyNames: envKeyNamesOf(entry),
225
+ };
226
+ if (lookup.projectKey !== null)
227
+ findingByProjectKey.set(lookup.projectKey, finding);
228
+ findings.push(finding);
229
+ }
230
+ return { findings, diagnostics };
231
+ }
232
+ // ---------------------------------------------------------------------------
233
+ // Override + shared policy
234
+ // ---------------------------------------------------------------------------
235
+ /** Documented truthy override values (trimmed, case-insensitive). */
236
+ const OVERRIDE_TRUTHY = new Set(["1", "true", "yes", "on"]);
237
+ /**
238
+ * True only for a trimmed, case-insensitive `1`, `true`, `yes`, or `on`. Absent,
239
+ * blank, `0`, `false`, `no`, `off`, and anything else are disabled — an override
240
+ * this consequential must be set deliberately, never by a stray value.
241
+ */
242
+ export function isClaudeMcpShadowingOverrideEnabled(env) {
243
+ const raw = env?.[CONDUCTOR_ALLOW_CLAUDE_MCP_SHADOWING_ENV];
244
+ if (typeof raw !== "string")
245
+ return false;
246
+ return OVERRIDE_TRUTHY.has(raw.trim().toLowerCase());
247
+ }
248
+ /** The exact wording every override path emits, pinned by tests. */
249
+ export const CLAUDE_MCP_SHADOWING_OVERRIDE_WARNING = `Proceeding anyway because ${CONDUCTOR_ALLOW_CLAUDE_MCP_SHADOWING_ENV} is set`;
250
+ /** Human-readable scope labels (fixed strings, never derived from the config). */
251
+ const SCOPE_LABELS = {
252
+ user: "user scope (mcpServers)",
253
+ "main-project": "main-repository project scope (projects[<main repo>])",
254
+ "worktree-project": "worktree project scope (projects[<worktree>])",
255
+ };
256
+ /** Fixed explanation per shape (never derived from the inspected entry). */
257
+ const SHAPE_LABELS = {
258
+ "shim-with-env-block": "a shim-shaped entry carrying an env block (the provisioner never writes env)",
259
+ "shim-for-other-project-root": "an mcp-invoke shim pointed at a different project root",
260
+ "non-shim-registration": "a registration that is not the provisioned mcp-invoke shim",
261
+ };
262
+ /**
263
+ * Render ONE finding as a secret-free sentence built exclusively from the
264
+ * inspector's safe metadata. The registration entry is never serialized.
265
+ */
266
+ export function formatClaudeMcpShadowFinding(finding) {
267
+ const scopes = finding.scopes.map((scope) => SCOPE_LABELS[scope]).join(" and ");
268
+ const envNote = finding.envKeyNames.length > 0
269
+ ? ` (env keys: ${finding.envKeyNames.join(", ")})`
270
+ : "";
271
+ return (`${finding.configPath} registers a '${BRIDGE_API_SERVER_NAME}' MCP server at ${scopes}: ` +
272
+ `${SHAPE_LABELS[finding.shape]}${envNote}`);
273
+ }
274
+ /** Render one degraded diagnostic as a fixed, path-only sentence. */
275
+ export function formatClaudeUserConfigDiagnostic(diagnostic) {
276
+ switch (diagnostic.kind) {
277
+ case "unreadable":
278
+ return `${diagnostic.configPath} could not be read; Claude MCP shadowing could not be checked`;
279
+ case "malformed":
280
+ return `${diagnostic.configPath} contains malformed JSON; Claude MCP shadowing could not be checked`;
281
+ case "inconclusive-user-shim":
282
+ return (`${diagnostic.configPath} registers a user-scope '${BRIDGE_API_SERVER_NAME}' shim for an ` +
283
+ "unverifiable project root (the main repository path could not be resolved)");
284
+ }
285
+ }
286
+ /**
287
+ * The SINGLE policy every consumer (executor preflight, per-worktree enforcement,
288
+ * doctor) applies to an inspection result, so none of them re-derives
289
+ * classification or override parsing.
290
+ *
291
+ * Confirmed findings refuse by DEFAULT. With the override enabled the result is OK
292
+ * but carries the pinned prominent warning alongside each finding, so an operator
293
+ * who deliberately opted in still sees exactly what was detected. Degraded
294
+ * diagnostics are always warnings — they are inconclusive, never a collision.
295
+ */
296
+ export function evaluateClaudeMcpShadowingPolicy(inspection, env) {
297
+ const warnings = inspection.diagnostics.map(formatClaudeUserConfigDiagnostic);
298
+ if (inspection.findings.length === 0)
299
+ return { ok: true, warnings };
300
+ const findingMessages = inspection.findings.map(formatClaudeMcpShadowFinding);
301
+ if (isClaudeMcpShadowingOverrideEnabled(env)) {
302
+ return {
303
+ ok: true,
304
+ warnings: [
305
+ ...findingMessages.map((m) => `${m}. ${CLAUDE_MCP_SHADOWING_OVERRIDE_WARNING}`),
306
+ ...warnings,
307
+ ],
308
+ };
309
+ }
310
+ return {
311
+ ok: false,
312
+ refusals: findingMessages.map((m) => `${m}. Refusing to proceed: worktree MCP provisioning may be shadowed. Delete, or ` +
313
+ `move aside, that registration, or set ` +
314
+ `${CONDUCTOR_ALLOW_CLAUDE_MCP_SHADOWING_ENV}=1 to override.`),
315
+ warnings,
316
+ };
317
+ }