@bridge_gpt/mcp-server 0.2.33 → 0.2.36

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 (49) hide show
  1. package/README.md +456 -340
  2. package/build/agent-capabilities/probe-context.js +8 -1
  3. package/build/agent-capabilities/probes.js +7 -1
  4. package/build/agents.generated.js +1 -1
  5. package/build/claude-review-workflow.js +264 -0
  6. package/build/cli-release.js +53 -0
  7. package/build/commands.generated.js +4 -4
  8. package/build/conductor/bridge-api-client.js +252 -3
  9. package/build/conductor/deny-enforcement-preflight.js +1 -0
  10. package/build/conductor/done-gate.js +44 -5
  11. package/build/conductor/epic-reconcile.js +6 -0
  12. package/build/conductor/install-doctor.js +462 -0
  13. package/build/conductor-bin.js +3 -3
  14. package/build/conductor-bundle-artifacts.js +30 -9
  15. package/build/doctor.js +234 -1
  16. package/build/executor/cli.js +32 -5
  17. package/build/executor/credentials.js +45 -11
  18. package/build/executor/deps.js +14 -0
  19. package/build/executor/env.js +23 -6
  20. package/build/executor/index.js +4 -0
  21. package/build/executor/job-runner.js +119 -9
  22. package/build/executor/permissions.js +12 -2
  23. package/build/executor/preflight.js +95 -8
  24. package/build/executor/prompt-spec.js +51 -0
  25. package/build/executor/runner.js +15 -2
  26. package/build/executor/service-unit.js +876 -0
  27. package/build/executor/test-clock.js +8 -0
  28. package/build/executor/types.js +0 -17
  29. package/build/executor/worker-command.js +62 -9
  30. package/build/index.js +575 -143
  31. package/build/init.js +153 -51
  32. package/build/install-bridge-conductor.js +491 -0
  33. package/build/install-bridge.js +884 -176
  34. package/build/install-reexec.js +233 -0
  35. package/build/mcp-host-config.js +11 -1
  36. package/build/mcp-install-state.js +32 -0
  37. package/build/mcp-provisioning.js +22 -6
  38. package/build/pipelines.generated.js +14 -8
  39. package/build/readme.generated.js +1 -1
  40. package/build/run-unit-tests-launcher.js +257 -0
  41. package/build/setup-epic.js +117 -8
  42. package/build/upgrade-cli.js +1 -15
  43. package/build/version.generated.js +1 -1
  44. package/docs/CONDUCTOR.md +115 -4
  45. package/docs/install/mcp-tool-integrations.md +29 -21
  46. package/package.json +9 -6
  47. package/pipelines/implement-ticket.json +6 -1
  48. package/build/conductor/supervisor-judgment-python.js +0 -141
  49. package/build/conductor/supervisor-judgment.js +0 -215
package/build/doctor.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * spawning, no MCP server startup. It only ever runs read-only PATH probes
15
15
  * (`which`/`where`, `bash --version`, `git rev-parse`) through the injected deps.
16
16
  */
17
- import { readFile, stat } from "fs/promises";
17
+ import { readdir, readFile, stat } from "fs/promises";
18
18
  import { spawn } from "child_process";
19
19
  import os from "os";
20
20
  import path from "path";
@@ -28,6 +28,8 @@ import { resolveBapiCredentials } from "./credential-store.js";
28
28
  import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, formatValidAgentNames, } from "./agent-registry.js";
29
29
  import { getDoctorPrereqDescriptors, probePrerequisite, } from "./start-tickets-prereqs.js";
30
30
  import { resolveProfiles } from "./mcp-profile.js";
31
+ import { executorLaunchdDirForHome, executorSystemdDirForHome, executorIdFromLaunchdFilename, executorIdFromSystemdFilename, inspectExecutorServiceArtifact, EXECUTOR_SERVICE_BASE_URL_ENV, } from "./executor/index.js";
32
+ import { DEFAULT_BAPI_BASE_URL } from "./executor/credentials.js";
31
33
  /**
32
34
  * The report/usage title (BAPI-669, U9b). `doctor` diagnoses the whole Bridge
33
35
  * install — install status, prerequisites, launcher cache, and tool surface — so it
@@ -74,6 +76,16 @@ export function getDoctorUsage() {
74
76
  "ignore notifications/tools/list_changed must reconnect or start a new MCP",
75
77
  "session to observe surface changes; no project MCP config change is required.",
76
78
  "",
79
+ "It also includes an advisory 'Executor provisioning' section (BAPI-688): the",
80
+ "generated executor service units on this host. It enumerates them read-only by",
81
+ "fixed convention — ~/Library/LaunchAgents/com.bridge-gpt.executor.*.plist on",
82
+ "macOS, ~/.config/systemd/user/bridge-gpt-executor-*.service on Linux (Windows",
83
+ "Task Scheduler setup is manual, so it reports SKIP) — and reports each unit's",
84
+ "embedded BAPI_BASE_URL and repositories plus whether a Bridge API credential",
85
+ "resolves for each repo (target bapi:<repo>, source only, never the key value).",
86
+ "It reads no install-state file, writes nothing, runs no launchctl/systemctl, and",
87
+ "is advisory: missing, malformed, or credential-less units never change the exit code.",
88
+ "",
77
89
  "Conductor ledger / native-module diagnostics (the SQLite ledger's native",
78
90
  "binding load status and Node-version skew) live under a separate command:",
79
91
  " conductor doctor",
@@ -513,6 +525,196 @@ export function formatToolSurfaceDiagnosticReport(diag) {
513
525
  lines.push("to observe surface changes; no project MCP configuration change is required.");
514
526
  return lines.join("\n");
515
527
  }
528
+ /** Where generated units live, and how their filenames encode the executor id. */
529
+ function resolveExecutorServiceConvention(platform, homeDir) {
530
+ if (platform === "darwin") {
531
+ return {
532
+ dir: executorLaunchdDirForHome(homeDir),
533
+ kind: "launchd-plist",
534
+ idFromFilename: executorIdFromLaunchdFilename,
535
+ };
536
+ }
537
+ if (platform === "linux") {
538
+ return {
539
+ dir: executorSystemdDirForHome(homeDir),
540
+ kind: "systemd-service",
541
+ idFromFilename: executorIdFromSystemdFilename,
542
+ };
543
+ }
544
+ return null;
545
+ }
546
+ /**
547
+ * Resolve one repository's credential and IMMEDIATELY discard the key, keeping
548
+ * only a source/status label. The resolved value never enters a diagnostic
549
+ * object, a warning, or the rendered report.
550
+ */
551
+ async function probeRepoCredential(repo, deps, credDeps) {
552
+ const target = `bapi:${repo}`;
553
+ const resolve = deps.resolveCredentials ?? resolveBapiCredentials;
554
+ try {
555
+ const result = await resolve(repo, credDeps);
556
+ if (result.ok) {
557
+ return { repo, target, resolved: true, source: String(result.credentials.source) };
558
+ }
559
+ return { repo, target, resolved: false, kind: String(result.kind) };
560
+ }
561
+ catch {
562
+ // Sanitized: a resolver throw is reported as a kind, never as exception text.
563
+ return { repo, target, resolved: false, kind: "unavailable" };
564
+ }
565
+ }
566
+ /**
567
+ * Collect read-only diagnostics for every convention-named executor service unit
568
+ * on this host. Never throws: a vanished directory entry, an unreadable unit, or
569
+ * a malformed body becomes a sanitized advisory warning and collection continues.
570
+ */
571
+ export async function collectExecutorServiceDiagnostics(deps) {
572
+ if (deps.platform === "win32") {
573
+ return {
574
+ status: "skipped",
575
+ reason: "Windows executor services are set up manually through Task Scheduler, so there is no " +
576
+ "generated unit to enumerate (`executor install-service` prints the command line instead).",
577
+ };
578
+ }
579
+ const convention = resolveExecutorServiceConvention(deps.platform, deps.homedir());
580
+ if (!convention) {
581
+ return {
582
+ status: "skipped",
583
+ reason: `platform '${deps.platform}' has no generated executor service format.`,
584
+ };
585
+ }
586
+ let entries;
587
+ try {
588
+ entries = await deps.readdir(convention.dir);
589
+ }
590
+ catch {
591
+ return {
592
+ status: "skipped",
593
+ reason: `no executor service units found (${convention.dir} is absent or unreadable).`,
594
+ };
595
+ }
596
+ // Sort BEFORE inspection so report ordering is deterministic regardless of the
597
+ // order the filesystem happened to return entries in.
598
+ const matching = entries
599
+ .filter((name) => convention.idFromFilename(name) !== null)
600
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
601
+ if (matching.length === 0) {
602
+ return {
603
+ status: "skipped",
604
+ reason: `no executor service units found in ${convention.dir}.`,
605
+ };
606
+ }
607
+ const credDeps = {
608
+ env: deps.env,
609
+ homedir: deps.homedir,
610
+ platform: deps.platform,
611
+ readFile: deps.readFile,
612
+ stat: deps.stat,
613
+ stderr: () => { },
614
+ };
615
+ // Only darwin/linux reach here (win32 and unsupported platforms returned
616
+ // above), so unit paths always use POSIX semantics — matching the generators.
617
+ const pathApi = path.posix;
618
+ const units = [];
619
+ const warnings = [];
620
+ for (const name of matching) {
621
+ const unitPath = pathApi.join(convention.dir, name);
622
+ const executorId = convention.idFromFilename(name) ?? name;
623
+ try {
624
+ await deps.stat(unitPath);
625
+ }
626
+ catch {
627
+ // The entry vanished or became unreadable between enumeration and stat.
628
+ warnings.push(`${unitPath}: disappeared or became unreadable during collection.`);
629
+ continue;
630
+ }
631
+ let content;
632
+ try {
633
+ content = await deps.readFile(unitPath);
634
+ }
635
+ catch {
636
+ units.push({
637
+ path: unitPath,
638
+ executorId,
639
+ baseUrl: null,
640
+ baseUrlClass: "missing",
641
+ repos: [],
642
+ credentials: [],
643
+ warnings: ["unit could not be read; its configuration cannot be verified."],
644
+ });
645
+ continue;
646
+ }
647
+ const inspection = inspectExecutorServiceArtifact(convention.kind, content);
648
+ const unitWarnings = [...inspection.problems];
649
+ let baseUrlClass;
650
+ if (!inspection.baseUrl) {
651
+ baseUrlClass = "missing";
652
+ unitWarnings.push(`no ${EXECUTOR_SERVICE_BASE_URL_ENV} in the unit — the executor requires an explicit base ` +
653
+ "URL and will fail to start.");
654
+ }
655
+ else if (inspection.baseUrl.replace(/\/+$/, "") === DEFAULT_BAPI_BASE_URL) {
656
+ baseUrlClass = "production-default";
657
+ unitWarnings.push(`${EXECUTOR_SERVICE_BASE_URL_ENV} points at production (${DEFAULT_BAPI_BASE_URL}) — legal, ` +
658
+ "but worth confirming this host is meant to serve production jobs.");
659
+ }
660
+ else {
661
+ baseUrlClass = "configured";
662
+ }
663
+ // Probe each DISTINCT repository once per unit, even when the unit repeats it.
664
+ const distinctRepos = Array.from(new Set(inspection.repos));
665
+ const credentials = [];
666
+ for (const repo of distinctRepos) {
667
+ credentials.push(await probeRepoCredential(repo, deps, credDeps));
668
+ }
669
+ units.push({
670
+ path: unitPath,
671
+ executorId,
672
+ baseUrl: inspection.baseUrl,
673
+ baseUrlClass,
674
+ repos: inspection.repos,
675
+ credentials,
676
+ warnings: unitWarnings,
677
+ });
678
+ }
679
+ return { status: "collected", units, warnings };
680
+ }
681
+ /**
682
+ * Render the advisory "Executor provisioning" section (pure formatting — no I/O).
683
+ * Every line is found / WARN / SKIP; nothing here changes the exit code.
684
+ */
685
+ export function formatExecutorServiceDiagnosticsReport(diagnostics) {
686
+ const lines = ["", "Executor provisioning (generated service units — advisory)", ""];
687
+ if (diagnostics.status === "skipped") {
688
+ lines.push(`SKIP ${diagnostics.reason}`);
689
+ lines.push("");
690
+ lines.push("This section is read-only and advisory: it enumerates units by fixed directory and filename");
691
+ lines.push("prefix, and never changes the doctor exit code.");
692
+ return lines.join("\n");
693
+ }
694
+ for (const warning of diagnostics.warnings) {
695
+ lines.push(`WARN ${warning}`);
696
+ }
697
+ for (const unit of diagnostics.units) {
698
+ lines.push(`found ${unit.path}`);
699
+ lines.push(` executor id: ${unit.executorId}`);
700
+ lines.push(` ${EXECUTOR_SERVICE_BASE_URL_ENV}: ${unit.baseUrl ?? "(absent)"} [${unit.baseUrlClass}]`);
701
+ lines.push(` repos: ${unit.repos.length > 0 ? unit.repos.join(", ") : "(none parsed)"}`);
702
+ for (const cred of unit.credentials) {
703
+ lines.push(cred.resolved
704
+ ? ` credential ${cred.target}: resolved (source: ${cred.source})`
705
+ : ` credential ${cred.target}: NOT resolved (${cred.kind})`);
706
+ }
707
+ for (const warning of unit.warnings) {
708
+ lines.push(`WARN ${unit.path}: ${warning}`);
709
+ }
710
+ }
711
+ lines.push("");
712
+ lines.push("Generated units carry only the base URL, the generating shell's PATH, repositories, and", "executor id — credentials resolve at");
713
+ lines.push("launch time from BAPI_API_KEY or the user-scoped bapi:<repo> store, so no key value is stored on");
714
+ lines.push("disk or printed here. Starting, stopping, and enabling a unit stay operator-managed. This section");
715
+ lines.push("is advisory and never changes the doctor exit code.");
716
+ return lines.join("\n");
717
+ }
516
718
  /**
517
719
  * CLI entry for the read-only `doctor` subcommand. Returns a process exit code.
518
720
  * Help returns 0; parser errors return 1; otherwise it prints the report and
@@ -621,6 +823,37 @@ export async function runDoctorCli(argv, overrides = {}) {
621
823
  }));
622
824
  }
623
825
  }
826
+ // Advisory executor-provisioning section (BAPI-688). Strictly read-only: it
827
+ // enumerates convention-named units under a fixed OS directory, parses their
828
+ // non-secret configuration, and probes credential resolution WITHOUT retaining
829
+ // any key. It is collected independently of `DoctorCollectionResult` and, like
830
+ // the sections above, never affects the exit code — missing units, malformed
831
+ // units, and unresolved credentials are all advisory.
832
+ if (overrides.executorService !== false) {
833
+ try {
834
+ const injectedFs = deps;
835
+ const executorDeps = {
836
+ platform: overrides.executorService?.platform ?? deps.platform,
837
+ env: overrides.executorService?.env ?? deps.env,
838
+ homedir: overrides.executorService?.homedir ?? injectedFs.homedir ?? os.homedir,
839
+ readdir: overrides.executorService?.readdir ?? ((p) => readdir(p)),
840
+ readFile: overrides.executorService?.readFile ??
841
+ injectedFs.readFile ??
842
+ ((p) => readFile(p, "utf-8")),
843
+ stat: overrides.executorService?.stat ?? injectedFs.stat ?? ((p) => stat(p)),
844
+ resolveCredentials: overrides.executorService?.resolveCredentials,
845
+ };
846
+ const executorDiagnostics = await collectExecutorServiceDiagnostics(executorDeps);
847
+ log(formatExecutorServiceDiagnosticsReport(executorDiagnostics));
848
+ }
849
+ catch {
850
+ // Any unexpected failure still renders a sanitized advisory SKIP line.
851
+ log(formatExecutorServiceDiagnosticsReport({
852
+ status: "skipped",
853
+ reason: "executor service units could not be enumerated on this host.",
854
+ }));
855
+ }
856
+ }
624
857
  if (!collection.ok)
625
858
  return 1;
626
859
  return collection.results.some((r) => !r.found) ? 1 : 0;
@@ -11,7 +11,7 @@ import { VERSION } from "../version.generated.js";
11
11
  import { resolveWorktrunkBinary } from "../start-tickets-prereqs.js";
12
12
  import { resolveStartTicketsRepoName } from "../start-tickets-repo.js";
13
13
  import { createDefaultExecutorDeps } from "./deps.js";
14
- import { resolveExecutorApiAccess, } from "./credentials.js";
14
+ import { resolveBaseUrl, resolveExecutorApiAccess, EXECUTOR_BASE_URL_REQUIRED_MESSAGE, } from "./credentials.js";
15
15
  import { createExecutorHttpClient } from "./http-client.js";
16
16
  import { runExecutor } from "./runner.js";
17
17
  import { runExecutorWatchCli } from "./watch-cli.js";
@@ -22,7 +22,12 @@ const DEFAULT_DEADMAN_MS = 120_000;
22
22
  const DEFAULT_TERM_GRACE_MS = 10_000;
23
23
  const DEFAULT_BASE_BRANCH = "main";
24
24
  const DEFAULT_JOB_TIMEOUT_SECONDS = 20 * 60;
25
- const DEFAULT_MAX_CONCURRENT = 1;
25
+ /**
26
+ * ONE job per executor process. Exported so the invariant can be pinned against
27
+ * the real constant rather than a copy: BAPI-688 adds more executor *processes*
28
+ * (persistent service units), and must never turn one process into multi-slot.
29
+ */
30
+ export const DEFAULT_MAX_CONCURRENT = 1;
26
31
  export function getExecutorUsage() {
27
32
  return [
28
33
  "Usage: mcp-server executor --repo <name> [--repo <name> ...] [options]",
@@ -32,6 +37,7 @@ export function getExecutorUsage() {
32
37
  "Options:",
33
38
  " --repo <name> Repo to serve (repeatable).",
34
39
  " --repos=<a,b> Comma-separated repos.",
40
+ " --base-url <url> Bridge API endpoint (overrides BAPI_BASE_URL).",
35
41
  " --executor-id <id> Stable executor id (default: <hostname>-<pid>).",
36
42
  " --max-concurrent <n> Max concurrent jobs (>= 1, default 1).",
37
43
  " --once Run a single preflight/claim cycle and exit.",
@@ -41,6 +47,12 @@ export function getExecutorUsage() {
41
47
  " --base-branch <name> Base branch (default main).",
42
48
  " --no-advisory-parser Disable the advisory stream-json parser.",
43
49
  " -h, --help Show this help.",
50
+ "",
51
+ "Subcommands:",
52
+ " executor watch <job> Attach (read-only) to a running job's worker log.",
53
+ " executor install-service Generate a launchd/systemd user service unit for a",
54
+ " stable --executor-id so the executor survives reboot.",
55
+ " See `executor install-service --help`.",
44
56
  ].join("\n");
45
57
  }
46
58
  function parseIntArg(value, flag) {
@@ -63,6 +75,7 @@ export function parseExecutorArgs(argv, context) {
63
75
  let deadmanMs = DEFAULT_DEADMAN_MS;
64
76
  let baseBranch = DEFAULT_BASE_BRANCH;
65
77
  let advisoryParserEnabled = true;
78
+ let baseUrl;
66
79
  for (let i = 0; i < argv.length; i++) {
67
80
  const arg = argv[i];
68
81
  if (arg === "--help" || arg === "-h")
@@ -120,6 +133,12 @@ export function parseExecutorArgs(argv, context) {
120
133
  return { kind: "error", message: "--base-branch requires a value" };
121
134
  baseBranch = v;
122
135
  }
136
+ else if (arg === "--base-url") {
137
+ const v = argv[++i];
138
+ if (!v)
139
+ return { kind: "error", message: "--base-url requires a value" };
140
+ baseUrl = v;
141
+ }
123
142
  else if (arg === "--no-advisory-parser") {
124
143
  advisoryParserEnabled = false;
125
144
  }
@@ -150,6 +169,7 @@ export function parseExecutorArgs(argv, context) {
150
169
  baseBranch,
151
170
  advisoryParserEnabled,
152
171
  defaultJobTimeoutSeconds: DEFAULT_JOB_TIMEOUT_SECONDS,
172
+ baseUrl,
153
173
  };
154
174
  return { kind: "ok", options };
155
175
  }
@@ -194,20 +214,27 @@ export async function runExecutorCli(argv, overrides = {}) {
194
214
  return 1;
195
215
  }
196
216
  const options = parsed.options;
217
+ // The mutating executor requires an EXPLICIT base URL (BAPI-676): `--base-url`
218
+ // then `BAPI_BASE_URL`, never an implicit production default. Fail here —
219
+ // before any credential resolution, HTTP client, or claim side effect.
220
+ const baseUrlResult = resolveBaseUrl(deps.env, options.baseUrl);
221
+ if (!baseUrlResult.ok) {
222
+ errorLog(`Error: ${EXECUTOR_BASE_URL_REQUIRED_MESSAGE}`);
223
+ return 1;
224
+ }
225
+ const baseUrl = baseUrlResult.baseUrl;
197
226
  // Resolve credentials for EVERY configured repo so multi-repo mode sends the
198
227
  // correct repo-bound `X-API-Key` per claim/heartbeat/complete/fail — not just
199
228
  // the first repo's key. Uses the (injectable) single-repo resolver per repo.
200
229
  const resolveApi = overrides.resolveApiAccess ?? resolveExecutorApiAccess;
201
230
  const apiKeyByRepo = {};
202
- let baseUrl = "";
203
231
  for (const repo of options.repos) {
204
- const access = await resolveApi(repo, deps);
232
+ const access = await resolveApi(repo, deps, baseUrl);
205
233
  if (!access.ok) {
206
234
  errorLog(`Error: ${access.error}`);
207
235
  return 1;
208
236
  }
209
237
  apiKeyByRepo[repo] = access.apiKey;
210
- baseUrl = access.baseUrl;
211
238
  }
212
239
  const createHttpClient = overrides.createHttpClient ?? createExecutorHttpClient;
213
240
  const httpClient = createHttpClient({
@@ -7,25 +7,59 @@
7
7
  * diagnostics/results.
8
8
  */
9
9
  import { resolveBapiCredentials } from "../credential-store.js";
10
- /** Default Bridge API base URL when `BAPI_BASE_URL` is unset/blank. */
10
+ /**
11
+ * The documented production Bridge API base URL — the value an operator pastes
12
+ * into `BAPI_BASE_URL` or `--base-url` when they *do* mean production.
13
+ *
14
+ * It is NOT an implicit executor fallback (BAPI-676): the executor requires an
15
+ * explicit base URL and fails fast when none is supplied, so pointing at
16
+ * production is always a deliberate act. Other flows (MCP server,
17
+ * install-bridge) legitimately keep their own production defaults.
18
+ */
11
19
  export const DEFAULT_BAPI_BASE_URL = "https://bridgegpt-api.com";
12
- function resolveBaseUrl(env) {
13
- const raw = env.BAPI_BASE_URL;
14
- if (typeof raw === "string" && raw.trim().length > 0) {
15
- return raw.trim().replace(/\/+$/, "");
16
- }
17
- return DEFAULT_BAPI_BASE_URL;
20
+ /**
21
+ * Shared, secret-free prerequisite message for a missing executor base URL.
22
+ * Names both supported sources and states the no-production-default contract so
23
+ * the CLI and preflight diagnostics stay identically worded. It interpolates no
24
+ * configuration or credential value.
25
+ */
26
+ 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
+ `(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
+ /**
36
+ * Resolve the executor's base URL: explicit override (`--base-url`) first, then
37
+ * `BAPI_BASE_URL`, then failure. A blank override is treated as absent so a
38
+ * nonblank environment value can still win; there is NO production fallback.
39
+ */
40
+ export function resolveBaseUrl(env, explicitBaseUrl) {
41
+ const fromFlag = normalizeBaseUrlCandidate(explicitBaseUrl);
42
+ if (fromFlag !== null)
43
+ return { ok: true, baseUrl: fromFlag };
44
+ const fromEnv = normalizeBaseUrlCandidate(env?.BAPI_BASE_URL);
45
+ if (fromEnv !== null)
46
+ return { ok: true, baseUrl: fromEnv };
47
+ return { ok: false };
18
48
  }
19
49
  /**
20
50
  * Resolve API access for a single repo. Returns a structured, secret-free
21
51
  * failure for credential-not-found, read/parse failure, missing key, or an
22
52
  * invalid repo name — never throwing, never echoing credential-file contents.
23
53
  */
24
- export async function resolveExecutorApiAccess(repoName, deps) {
54
+ export async function resolveExecutorApiAccess(repoName, deps, explicitBaseUrl) {
25
55
  const trimmed = typeof repoName === "string" ? repoName.trim() : "";
26
56
  if (trimmed.length === 0) {
27
57
  return { ok: false, error: "invalid repo name: repo name is required" };
28
58
  }
59
+ const url = resolveBaseUrl(deps.env, explicitBaseUrl);
60
+ if (!url.ok) {
61
+ return { ok: false, error: EXECUTOR_BASE_URL_REQUIRED_MESSAGE };
62
+ }
29
63
  const storeDeps = {
30
64
  env: deps.env,
31
65
  homedir: deps.homedir,
@@ -43,17 +77,17 @@ export async function resolveExecutorApiAccess(repoName, deps) {
43
77
  return {
44
78
  ok: true,
45
79
  apiKey: result.credentials.apiKey,
46
- baseUrl: resolveBaseUrl(deps.env),
80
+ baseUrl: url.baseUrl,
47
81
  };
48
82
  }
49
83
  /**
50
84
  * Resolve credentials for every configured repo before the executor starts
51
85
  * claiming. Each repo resolves independently; failures are reported per-repo.
52
86
  */
53
- export async function resolveAllExecutorApiAccess(repos, deps) {
87
+ export async function resolveAllExecutorApiAccess(repos, deps, explicitBaseUrl) {
54
88
  const out = [];
55
89
  for (const repo of repos) {
56
- const access = await resolveExecutorApiAccess(repo, deps);
90
+ const access = await resolveExecutorApiAccess(repo, deps, explicitBaseUrl);
57
91
  if (access.ok) {
58
92
  out.push({ ok: true, repoName: repo, apiKey: access.apiKey, baseUrl: access.baseUrl });
59
93
  }
@@ -8,9 +8,11 @@
8
8
  * server's JSON-RPC channel in adjacent tooling).
9
9
  */
10
10
  import { execFile, spawn } from "node:child_process";
11
+ import { existsSync } from "node:fs";
11
12
  import { readFile, writeFile, appendFile, mkdir, stat, statfs } from "node:fs/promises";
12
13
  import os from "node:os";
13
14
  import { promisify } from "node:util";
15
+ import { resolveMcpShimInvocationForRuntime } from "../mcp-server-invocation.js";
14
16
  const execFileAsync = promisify(execFile);
15
17
  /** Bounded subprocess output buffer (bytes). */
16
18
  const MAX_COMMAND_BUFFER = 10 * 1024 * 1024;
@@ -113,5 +115,17 @@ export function createDefaultExecutorDeps() {
113
115
  },
114
116
  log: (message) => console.error(message),
115
117
  errorLog: (message) => console.error(message),
118
+ // Resolve how the worker MCP shim launches at dispatch time from THIS
119
+ // running executor's own on-disk build (absolute-build-path primary,
120
+ // npm-channel fallback) — never from process.cwd(). Mirrors
121
+ // `buildMcpProvisioningDeps` in `start-tickets.ts`. `nodeExecutable` stays
122
+ // "node" (not `process.execPath`) to preserve today's worker runtime
123
+ // selection; see `mcp-server-invocation.ts`'s scope-boundary note.
124
+ mcpServerInvocation: resolveMcpShimInvocationForRuntime({
125
+ moduleUrl: import.meta.url,
126
+ nodeExecutable: "node",
127
+ argv1: process.argv[1],
128
+ fileExists: existsSync,
129
+ }),
116
130
  };
117
131
  }
@@ -1,12 +1,21 @@
1
1
  /**
2
2
  * Secret-free executor worker environment (BAPI-534, TDD §7).
3
3
  *
4
- * v2 executor workers get NO Bridge MCP and NO conductor identity: unlike the
5
- * conductor worker env builder, this one omits `BRIDGE_MCP_PROFILE`, every
6
- * `BAPI_CONDUCTOR_*` key, `CONDUCTOR_NODE_PATH`, and all secret-bearing keys.
7
- * It constructs a FRESH object from a strict allowlist of non-secret operational
8
- * keys — it never copies arbitrary `process.env`, so credentials/tokens/headers
9
- * cannot leak into the spawned `claude` process.
4
+ * v2 executor workers get Bridge MCP access by default (BAPI-724), but NOT
5
+ * through this environment. Access is provisioned into the worktree's
6
+ * `.mcp.json`/`.cursor/mcp.json` (secret-free shim registration, resolved and
7
+ * written BEFORE spawn see `../mcp-provisioning.js` wiring in
8
+ * `job-runner.ts`), never via env vars. This builder still omits
9
+ * `BRIDGE_MCP_PROFILE` (an absent profile selects the intended `core` tool
10
+ * group default), every `BAPI_CONDUCTOR_*` key, `CONDUCTOR_NODE_PATH` (the
11
+ * shim's node executable is resolved into the registration's command/args at
12
+ * provisioning time instead), and all secret-bearing keys. It constructs a
13
+ * FRESH object from a strict allowlist of non-secret operational keys — it
14
+ * never copies arbitrary `process.env`, so credentials/tokens/headers cannot
15
+ * leak into the spawned `claude` process. The `mcp-invoke` shim (a separate
16
+ * process) resolves `BAPI_API_KEY` itself at call time from
17
+ * `~/.config/bridge/credentials.json` — this worker environment never carries
18
+ * it.
10
19
  */
11
20
  import { PR_BASE_BRANCH_ENV_VAR } from "../pr-base-contract.js";
12
21
  /** Non-secret operational keys forwarded to the worker when present. */
@@ -42,6 +51,14 @@ const EXPLICIT_DENY_KEYS = [
42
51
  * allowlist is locally auditable/testable. A key is allowed iff it is in the
43
52
  * allowlist AND is not explicitly denied, does not begin with `BAPI_CONDUCTOR_`,
44
53
  * and does not contain a secret-name fragment.
54
+ *
55
+ * The `BAPI_CONDUCTOR_` rejection is a WHOLE-PREFIX rule, not an enumerated list,
56
+ * which is what makes it hold for keys added later. BAPI-722's
57
+ * `BAPI_CONDUCTOR_DENY_PROBE_TIMEOUT_MS` is rejected twice over — it is absent from
58
+ * `ALLOWED_ENV_KEYS` and it carries the prefix — so it stays executor-process
59
+ * configuration and can never reach a spawned worker. That is deliberate: a worker
60
+ * runs no deny probe, and letting job-side state influence the executor's own
61
+ * safety-probe budget would invert the trust boundary.
45
62
  */
46
63
  export function isExecutorEnvKeyAllowed(key) {
47
64
  if (!ALLOWED_ENV_KEYS.includes(key))
@@ -7,4 +7,8 @@
7
7
  */
8
8
  export { runExecutorCli, getExecutorUsage, parseExecutorArgs } from "./cli.js";
9
9
  export { runExecutor } from "./runner.js";
10
+ // Persistent-service packaging (BAPI-688). The CLI entry plus the PURE helpers
11
+ // doctor needs to enumerate and inspect generated units — doctor must share this
12
+ // module's naming/format contract instead of maintaining a second parser.
13
+ export { runExecutorInstallServiceCli, getExecutorInstallServiceUsage, buildExecutorServiceArtifact, writeExecutorServiceArtifact, inspectExecutorServiceArtifact, executorLaunchdDirForHome, executorSystemdDirForHome, executorIdFromLaunchdFilename, executorIdFromSystemdFilename, EXECUTOR_SERVICE_BASE_URL_ENV, EXECUTOR_SERVICE_PATH_ENV, } from "./service-unit.js";
10
14
  export { runExecutorWatchCli, getExecutorWatchUsage, parseExecutorWatchArgs, } from "./watch-cli.js";