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