@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.
- package/README.md +490 -366
- package/build/agent-capabilities/probe-context.js +8 -1
- package/build/agent-capabilities/probes.js +7 -1
- package/build/agents.generated.js +1 -1
- package/build/base-url.js +79 -0
- package/build/bridge-api-urls.js +9 -0
- package/build/chain-orchestrator.js +93 -15
- package/build/claude-review-workflow.js +264 -0
- package/build/claude-user-config-doctor.js +317 -0
- package/build/cli-release.js +53 -0
- package/build/commands.generated.js +6 -5
- package/build/conductor/bridge-api-client.js +393 -4
- package/build/conductor/deny-enforcement-preflight.js +1 -0
- package/build/conductor/done-gate.js +44 -5
- package/build/conductor/epic-reconcile.js +6 -0
- package/build/conductor/install-doctor.js +462 -0
- package/build/conductor-bin.js +3 -3
- package/build/conductor-bundle-artifacts.js +37 -15
- package/build/credential-store.js +205 -4
- package/build/direct-ticket-tools.js +70 -0
- package/build/doctor.js +473 -81
- package/build/executor/cli.js +83 -6
- package/build/executor/credentials.js +39 -11
- package/build/executor/deps.js +32 -1
- package/build/executor/env.js +71 -28
- package/build/executor/heartbeat.js +138 -17
- package/build/executor/http-client.js +49 -8
- package/build/executor/index.js +4 -0
- package/build/executor/job-errors.js +4 -0
- package/build/executor/job-runner.js +530 -20
- package/build/executor/observation.js +130 -0
- package/build/executor/permissions.js +114 -8
- package/build/executor/preflight.js +127 -8
- package/build/executor/prompt-spec.js +51 -0
- package/build/executor/runner.js +23 -2
- package/build/executor/service-unit.js +876 -0
- package/build/executor/test-clock.js +75 -3
- package/build/executor/types.js +4 -18
- package/build/executor/worker-command.js +73 -12
- package/build/executor/worker-config-isolation.js +287 -0
- package/build/executor/worker-finalization.js +68 -14
- package/build/executor/worktree.js +46 -4
- package/build/index.js +1039 -237
- package/build/init.js +503 -111
- package/build/install-bridge-conductor.js +491 -0
- package/build/install-bridge.js +1188 -247
- package/build/install-reexec.js +233 -0
- package/build/launcher-config-inspection.js +351 -0
- package/build/mcp-host-config.js +11 -1
- package/build/mcp-install-state.js +32 -0
- package/build/mcp-invoke.js +49 -6
- package/build/mcp-provisioning.js +52 -13
- package/build/mcp-registration-doctor.js +14 -5
- package/build/notifications.js +553 -0
- package/build/pipeline-orchestrator.js +146 -4
- package/build/pipeline-utils.js +3 -0
- package/build/pipelines.generated.js +32 -13
- package/build/plan-execution-ledger.js +550 -0
- package/build/plan-phase-routing.js +272 -0
- package/build/plane/alembic-head.js +110 -0
- package/build/plane/build-freshness.js +167 -0
- package/build/plane/cli.js +480 -0
- package/build/plane/defaults.js +266 -0
- package/build/plane/manifest.js +377 -0
- package/build/plane/member-logs.js +147 -0
- package/build/plane/member-roster.js +147 -0
- package/build/plane/preflight.js +289 -0
- package/build/plane/shutdown.js +195 -0
- package/build/plane/status.js +125 -0
- package/build/plane/supervisor.js +569 -0
- package/build/plane/test-fakes.js +156 -0
- package/build/plane/types.js +75 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +259 -0
- package/build/setup-epic.js +772 -28
- package/build/sfcc/log-gate.js +38 -11
- package/build/sfcc/log-query.js +55 -15
- package/build/sfcc/ocapi-shape.js +51 -14
- package/build/sfcc/output.js +41 -11
- package/build/sfcc/permissions.js +24 -2
- package/build/sfcc/read-projection.js +181 -0
- package/build/sfcc/read-result.js +158 -0
- package/build/sfcc/reads-custom-object-def.js +29 -18
- package/build/sfcc/reads-site-preference.js +75 -29
- package/build/sfcc/reads-system-object.js +40 -34
- package/build/sfcc/sfcc-result.js +106 -0
- package/build/sfcc/tool-wrapper.js +56 -13
- package/build/sfcc/write-grants.js +45 -22
- package/build/sfcc/write-guard.js +21 -13
- package/build/sfcc/write-result.js +61 -14
- package/build/sfcc/write-tool-common.js +126 -32
- package/build/sfcc/writes-system-object.js +11 -50
- package/build/start-tickets-prereqs.js +129 -0
- package/build/start-tickets.js +17 -13
- package/build/ticket-backend-metadata.js +59 -0
- package/build/ticket-key-utils.js +92 -0
- package/build/tool-error-envelope.js +71 -0
- package/build/tool-surface-gating.js +72 -0
- package/build/update-status.js +102 -0
- package/build/upgrade-advice.js +47 -0
- package/build/upgrade-cli.js +412 -110
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +73 -0
- package/docs/CONDUCTOR.md +132 -6
- package/docs/install/mcp-tool-integrations.md +29 -21
- package/package.json +9 -6
- package/pipelines/implement-ticket.json +19 -4
- package/build/conductor/supervisor-judgment-python.js +0 -141
- package/build/conductor/supervisor-judgment.js +0 -215
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parameterized `claude-review` workflow scaffold (BAPI-679).
|
|
3
|
+
*
|
|
4
|
+
* The Bridge repo's own `.github/workflows/claude-review.yml` carries three
|
|
5
|
+
* ORGANIZATION-SPECIFIC literals — the allowed bot slug, the OAuth secret name,
|
|
6
|
+
* and the branch triggers — so it cannot be copied into a consumer repo as-is.
|
|
7
|
+
* This module renders a structurally equivalent workflow from validated
|
|
8
|
+
* parameters, with no org value embedded anywhere in the template source.
|
|
9
|
+
*
|
|
10
|
+
* The template is a plain TypeScript string constant rather than a generated
|
|
11
|
+
* asset: the package has no workflow-asset generator, and adding one to ship a
|
|
12
|
+
* single template would create a `*.generated.ts` that must never be hand-edited
|
|
13
|
+
* for no benefit. `tsc` ships this module in the published package already.
|
|
14
|
+
*
|
|
15
|
+
* INJECTION SAFETY is the module's core constraint. Every interpolated value is
|
|
16
|
+
* validated against a narrow allowlist BEFORE rendering, so no input can close a
|
|
17
|
+
* YAML scalar, introduce a key, or inject a `${{ }}` expression. Validation
|
|
18
|
+
* failures throw before any filesystem collaborator is touched.
|
|
19
|
+
*
|
|
20
|
+
* All filesystem access is injected so the module is unit-testable with
|
|
21
|
+
* in-memory fakes.
|
|
22
|
+
*/
|
|
23
|
+
/** Repo-relative path this module owns. It writes NOTHING else. */
|
|
24
|
+
export const CLAUDE_REVIEW_WORKFLOW_RELPATH = ".github/workflows/claude-review.yml";
|
|
25
|
+
/** Thrown when a parameter fails validation. Carries no rendered YAML. */
|
|
26
|
+
export class ClaudeReviewWorkflowValidationError extends Error {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "ClaudeReviewWorkflowValidationError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// A GitHub App bot login: alphanumerics and hyphens, optionally suffixed `[bot]`.
|
|
33
|
+
// Deliberately rejects whitespace, quotes, newlines, `$`, `{`, and `}`.
|
|
34
|
+
const ALLOWED_BOT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9-]*(\[bot\])?$/;
|
|
35
|
+
// GitHub secret identifiers are uppercase alphanumerics and underscores, not
|
|
36
|
+
// starting with a digit and not using the reserved `GITHUB_` prefix.
|
|
37
|
+
const SECRET_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
|
|
38
|
+
// Branch trigger patterns: git ref characters plus the `*` glob GitHub accepts.
|
|
39
|
+
// Rejects whitespace, quotes, `:`, `$`, `{`, `}`, and leading/trailing slashes.
|
|
40
|
+
const BRANCH_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._\-/*]*$/;
|
|
41
|
+
const MAX_BOT_LENGTH = 100;
|
|
42
|
+
const MAX_SECRET_NAME_LENGTH = 100;
|
|
43
|
+
const MAX_BRANCH_LENGTH = 200;
|
|
44
|
+
const MAX_BRANCHES = 20;
|
|
45
|
+
function validateAllowedBot(value) {
|
|
46
|
+
if (typeof value !== "string" || value.length === 0 || value.length > MAX_BOT_LENGTH) {
|
|
47
|
+
throw new ClaudeReviewWorkflowValidationError("allowedBot must be a non-empty slug");
|
|
48
|
+
}
|
|
49
|
+
if (!ALLOWED_BOT_PATTERN.test(value)) {
|
|
50
|
+
throw new ClaudeReviewWorkflowValidationError("allowedBot must match a GitHub bot login (alphanumerics/hyphens, optional `[bot]` suffix)");
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
function validateSecretName(value) {
|
|
55
|
+
if (typeof value !== "string" ||
|
|
56
|
+
value.length === 0 ||
|
|
57
|
+
value.length > MAX_SECRET_NAME_LENGTH) {
|
|
58
|
+
throw new ClaudeReviewWorkflowValidationError("secretName must be a non-empty identifier");
|
|
59
|
+
}
|
|
60
|
+
if (!SECRET_NAME_PATTERN.test(value)) {
|
|
61
|
+
throw new ClaudeReviewWorkflowValidationError("secretName must be an uppercase GitHub secret identifier (A-Z, 0-9, underscore)");
|
|
62
|
+
}
|
|
63
|
+
if (value.startsWith("GITHUB_")) {
|
|
64
|
+
throw new ClaudeReviewWorkflowValidationError("secretName must not use the reserved GITHUB_ prefix");
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
function validateBranches(value) {
|
|
69
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
70
|
+
throw new ClaudeReviewWorkflowValidationError("branches must be a non-empty array");
|
|
71
|
+
}
|
|
72
|
+
if (value.length > MAX_BRANCHES) {
|
|
73
|
+
throw new ClaudeReviewWorkflowValidationError("branches list is too long");
|
|
74
|
+
}
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const raw of value) {
|
|
78
|
+
if (typeof raw !== "string" || raw.length === 0 || raw.length > MAX_BRANCH_LENGTH) {
|
|
79
|
+
throw new ClaudeReviewWorkflowValidationError("each branch must be a non-empty string");
|
|
80
|
+
}
|
|
81
|
+
if (!BRANCH_PATTERN.test(raw)) {
|
|
82
|
+
throw new ClaudeReviewWorkflowValidationError("each branch must be a valid git ref pattern (no whitespace, quotes, or expressions)");
|
|
83
|
+
}
|
|
84
|
+
if (seen.has(raw)) {
|
|
85
|
+
throw new ClaudeReviewWorkflowValidationError(`duplicate branch: ${raw}`);
|
|
86
|
+
}
|
|
87
|
+
seen.add(raw);
|
|
88
|
+
out.push(raw);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Serialize a validated scalar as a single-quoted YAML string.
|
|
94
|
+
*
|
|
95
|
+
* Safe because the value already passed an allowlist that excludes `'`, so the
|
|
96
|
+
* escape below can never be reached with adversarial input — it is kept as
|
|
97
|
+
* defense in depth rather than as the primary control.
|
|
98
|
+
*/
|
|
99
|
+
function yamlSingleQuoted(value) {
|
|
100
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
101
|
+
}
|
|
102
|
+
/** Serialize a validated string list as an inline YAML flow sequence. */
|
|
103
|
+
function yamlFlowList(values) {
|
|
104
|
+
return `[${values.map(yamlSingleQuoted).join(", ")}]`;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Render the workflow YAML deterministically from validated parameters.
|
|
108
|
+
*
|
|
109
|
+
* Structurally mirrors the Bridge repo's own workflow — same triggers,
|
|
110
|
+
* concurrency, permissions, action, sticky comment, and the verdict/
|
|
111
|
+
* `Reviewed-SHA`/`run-id` contract the Conductor's merge gate consumes — with
|
|
112
|
+
* every org-specific literal replaced by a parameter.
|
|
113
|
+
*
|
|
114
|
+
* @throws ClaudeReviewWorkflowValidationError before producing any output.
|
|
115
|
+
*/
|
|
116
|
+
export function renderClaudeReviewWorkflow(params) {
|
|
117
|
+
const allowedBot = validateAllowedBot(params.allowedBot);
|
|
118
|
+
const secretName = validateSecretName(params.secretName);
|
|
119
|
+
const branches = validateBranches(params.branches);
|
|
120
|
+
// `${'$'}{{ ... }}` is avoided by building the expression marker from a constant,
|
|
121
|
+
// so nothing in this template can be mistaken for a TS template substitution.
|
|
122
|
+
const gh = (expr) => "${{ " + expr + " }}";
|
|
123
|
+
return `# Generated by \`install-bridge conductor\` (Bridge API, BAPI-679).
|
|
124
|
+
# Safe to edit: this file is never overwritten without explicit consent.
|
|
125
|
+
name: Claude Code Review
|
|
126
|
+
|
|
127
|
+
on:
|
|
128
|
+
pull_request:
|
|
129
|
+
types: [opened, reopened, synchronize, ready_for_review]
|
|
130
|
+
branches: ${yamlFlowList(branches)}
|
|
131
|
+
|
|
132
|
+
# A new push to the PR cancels an in-flight review of the stale head, matching the
|
|
133
|
+
# Reviewed-SHA provenance the review emits: the Conductor rejects a verdict whose
|
|
134
|
+
# Reviewed-SHA does not match the current head, so cancelling a superseded review
|
|
135
|
+
# only avoids wasted minutes.
|
|
136
|
+
concurrency:
|
|
137
|
+
group: claude-review-${gh("github.event.pull_request.number")}
|
|
138
|
+
cancel-in-progress: true
|
|
139
|
+
|
|
140
|
+
jobs:
|
|
141
|
+
claude-review:
|
|
142
|
+
runs-on: ubuntu-latest
|
|
143
|
+
if: ${gh("github.event.pull_request.draft == false")}
|
|
144
|
+
permissions:
|
|
145
|
+
actions: read
|
|
146
|
+
contents: read
|
|
147
|
+
pull-requests: write
|
|
148
|
+
issues: write
|
|
149
|
+
id-token: write
|
|
150
|
+
steps:
|
|
151
|
+
- uses: actions/checkout@v7
|
|
152
|
+
with:
|
|
153
|
+
fetch-depth: 1
|
|
154
|
+
|
|
155
|
+
- name: Claude Code Review
|
|
156
|
+
id: review
|
|
157
|
+
uses: anthropics/claude-code-action@v1
|
|
158
|
+
with:
|
|
159
|
+
claude_code_oauth_token: ${gh(`secrets.${secretName}`)}
|
|
160
|
+
allowed_bots: ${yamlSingleQuoted(allowedBot)}
|
|
161
|
+
use_sticky_comment: true
|
|
162
|
+
track_progress: true
|
|
163
|
+
prompt: |
|
|
164
|
+
REPO: ${gh("github.repository")}
|
|
165
|
+
PR NUMBER: ${gh("github.event.pull_request.number")}
|
|
166
|
+
|
|
167
|
+
Review this pull request with a focus on:
|
|
168
|
+
- Bugs, logic errors, and edge cases
|
|
169
|
+
- Security issues (injection, auth bypasses, sensitive data exposure)
|
|
170
|
+
- Project conventions and patterns (see CLAUDE.md)
|
|
171
|
+
- Data-access correctness (parameterized queries, migration safety)
|
|
172
|
+
- Test coverage gaps for changed code
|
|
173
|
+
|
|
174
|
+
Be specific: cite file paths and line numbers.
|
|
175
|
+
|
|
176
|
+
Call out significant issues; ignore minor nits with no meaningful impact.
|
|
177
|
+
If there are no significant issues, just say "Looks good to me!"
|
|
178
|
+
|
|
179
|
+
Use \`gh pr comment\` for top-level feedback.
|
|
180
|
+
Use \`mcp__github_inline_comment__create_inline_comment\` (with \`confirmed: true\`)
|
|
181
|
+
to highlight specific code issues.
|
|
182
|
+
Only post GitHub comments - don't submit review text as messages.
|
|
183
|
+
|
|
184
|
+
After your feedback, end your top-level \`gh pr comment\` with EXACTLY ONE
|
|
185
|
+
machine-readable verdict line, on its own line, verbatim — this single line is
|
|
186
|
+
the source of truth consumed by automation (the Conductor merge gate):
|
|
187
|
+
- \`claude-review-verdict: approved\` — when the PR is safe to merge
|
|
188
|
+
- \`claude-review-verdict: changes_requested\` — when significant issues remain
|
|
189
|
+
Emit exactly one such verdict line, and do not use the words \`approved\` or
|
|
190
|
+
\`changes_requested\` as a verdict anywhere else in the comment.
|
|
191
|
+
|
|
192
|
+
Immediately BELOW the verdict line, on its own line, emit this exact
|
|
193
|
+
head-SHA provenance line verbatim — it binds the verdict to the exact commit
|
|
194
|
+
you reviewed so the Conductor can reject a stale verdict:
|
|
195
|
+
\`Reviewed-SHA: ${gh("github.event.pull_request.head.sha")}\`
|
|
196
|
+
|
|
197
|
+
Also include the GitHub Actions run id on its own line so overlapping
|
|
198
|
+
reviews resolve deterministically by run id, verbatim:
|
|
199
|
+
\`run-id: ${gh("github.run_id")}\`
|
|
200
|
+
claude_args: |
|
|
201
|
+
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Read,Glob,Grep"
|
|
202
|
+
`;
|
|
203
|
+
}
|
|
204
|
+
function joinCwd(cwd, rel) {
|
|
205
|
+
const base = cwd.endsWith("/") ? cwd.slice(0, -1) : cwd;
|
|
206
|
+
return `${base}/${rel}`;
|
|
207
|
+
}
|
|
208
|
+
/** Absolute path of the workflow file for a project. */
|
|
209
|
+
export function claudeReviewWorkflowPath(cwd) {
|
|
210
|
+
return joinCwd(cwd, CLAUDE_REVIEW_WORKFLOW_RELPATH);
|
|
211
|
+
}
|
|
212
|
+
function isEnoent(err) {
|
|
213
|
+
return (typeof err === "object" && err !== null && err.code === "ENOENT");
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Classify an existing workflow file against the rendered content.
|
|
217
|
+
*
|
|
218
|
+
* Returns a status ONLY — never the existing file's content, and never the read
|
|
219
|
+
* error's message (which can echo an absolute path or, for a misconfigured
|
|
220
|
+
* checkout, file content).
|
|
221
|
+
*/
|
|
222
|
+
export async function inspectClaudeReviewWorkflow(cwd, renderedContent, deps) {
|
|
223
|
+
let existing;
|
|
224
|
+
try {
|
|
225
|
+
existing = await deps.readFile(claudeReviewWorkflowPath(cwd));
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
return isEnoent(err) ? { status: "absent" } : { status: "unreadable" };
|
|
229
|
+
}
|
|
230
|
+
return existing === renderedContent ? { status: "identical" } : { status: "different" };
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Write the workflow, read-compare-write, with SEPARATE overwrite consent.
|
|
234
|
+
*
|
|
235
|
+
* `overwriteConsent` must be an explicit decision made for THIS file. It is
|
|
236
|
+
* never inferred from the installer's earlier base-URL or bootstrap consent:
|
|
237
|
+
* this file governs review automation on every PR, so silently replacing an
|
|
238
|
+
* operator's version could disable review repo-wide.
|
|
239
|
+
*
|
|
240
|
+
* Writes exactly one path — {@link CLAUDE_REVIEW_WORKFLOW_RELPATH} — and nothing else.
|
|
241
|
+
*/
|
|
242
|
+
export async function writeClaudeReviewWorkflow(cwd, renderedContent, deps, options = {}) {
|
|
243
|
+
const finalPath = claudeReviewWorkflowPath(cwd);
|
|
244
|
+
const inspection = await inspectClaudeReviewWorkflow(cwd, renderedContent, deps);
|
|
245
|
+
if (inspection.status === "identical") {
|
|
246
|
+
return { ok: true, outcome: "unchanged", path: finalPath };
|
|
247
|
+
}
|
|
248
|
+
if (inspection.status === "unreadable") {
|
|
249
|
+
return { ok: false, error: "existing workflow file could not be read" };
|
|
250
|
+
}
|
|
251
|
+
if (inspection.status === "different" && options.overwriteConsent !== true) {
|
|
252
|
+
return { ok: true, outcome: "skipped", path: finalPath };
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
await deps.mkdir(joinCwd(cwd, ".github/workflows"), { recursive: true });
|
|
256
|
+
await deps.writeFile(finalPath, renderedContent);
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
// Message is the fs error's own text (path + errno), never file content.
|
|
260
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
261
|
+
return { ok: false, error: `failed to write workflow: ${message}` };
|
|
262
|
+
}
|
|
263
|
+
return { ok: true, outcome: "written", path: finalPath };
|
|
264
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared npm release resolution for the Bridge CLI (BAPI-714).
|
|
3
|
+
*
|
|
4
|
+
* ONE bounded, fail-open lookup of the latest published `@bridge_gpt/mcp-server`
|
|
5
|
+
* version, shared by `upgrade` (which already re-exec'd from `@latest`) and by
|
|
6
|
+
* the `install` path's new self-re-exec. Extracted into its own leaf module so
|
|
7
|
+
* `install-reexec.ts` does not have to import `upgrade-cli.ts` — that module
|
|
8
|
+
* pulls in `runInit`, the terminal registry, and the agent registry, none of
|
|
9
|
+
* which the install hand-off needs.
|
|
10
|
+
*
|
|
11
|
+
* The contract is deliberately narrow: return a nonblank version string ONLY for
|
|
12
|
+
* a successful, well-formed registry response, and resolve to `null` for every
|
|
13
|
+
* other outcome — non-2xx, timeout/abort, network failure, unparseable body,
|
|
14
|
+
* non-object JSON, missing/blank/non-string `version`. It never throws and never
|
|
15
|
+
* logs, because both callers treat "no usable version" as "keep using the local
|
|
16
|
+
* copy" rather than as an error worth reporting to the user.
|
|
17
|
+
*/
|
|
18
|
+
/** The npm registry endpoint carrying the latest published release metadata. */
|
|
19
|
+
export const NPM_LATEST_ENDPOINT = "https://registry.npmjs.org/@bridge_gpt/mcp-server/latest";
|
|
20
|
+
/**
|
|
21
|
+
* Hard bound on the registry lookup. Owned here rather than duplicated at each
|
|
22
|
+
* call site: `install` is a first-time user's very first contact with Bridge, so
|
|
23
|
+
* a hung registry may never stall it past this budget.
|
|
24
|
+
*/
|
|
25
|
+
export const NPM_LATEST_TIMEOUT_MS = 3000;
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the latest published version of `@bridge_gpt/mcp-server`, or `null`.
|
|
28
|
+
*
|
|
29
|
+
* Fail-open by construction: every failure mode collapses to `null` so the
|
|
30
|
+
* caller proceeds with its local `VERSION`. Emits no output on any path.
|
|
31
|
+
*/
|
|
32
|
+
export async function fetchLatestVersion(deps = {}) {
|
|
33
|
+
const doFetch = deps.fetch ?? globalThis.fetch;
|
|
34
|
+
const timeoutSignal = deps.timeoutSignal ?? ((ms) => AbortSignal.timeout(ms));
|
|
35
|
+
try {
|
|
36
|
+
const res = await doFetch(NPM_LATEST_ENDPOINT, {
|
|
37
|
+
signal: timeoutSignal(NPM_LATEST_TIMEOUT_MS),
|
|
38
|
+
});
|
|
39
|
+
if (!res || !res.ok)
|
|
40
|
+
return null;
|
|
41
|
+
const data = await res.json();
|
|
42
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
43
|
+
return null;
|
|
44
|
+
const version = data.version;
|
|
45
|
+
if (typeof version !== "string")
|
|
46
|
+
return null;
|
|
47
|
+
const trimmed = version.trim();
|
|
48
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|