@bridge_gpt/mcp-server 0.2.34 → 0.2.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +490 -366
  2. package/build/agent-capabilities/probe-context.js +8 -1
  3. package/build/agent-capabilities/probes.js +7 -1
  4. package/build/agents.generated.js +1 -1
  5. package/build/base-url.js +79 -0
  6. package/build/bridge-api-urls.js +9 -0
  7. package/build/chain-orchestrator.js +93 -15
  8. package/build/claude-review-workflow.js +264 -0
  9. package/build/claude-user-config-doctor.js +317 -0
  10. package/build/cli-release.js +53 -0
  11. package/build/commands.generated.js +6 -5
  12. package/build/conductor/bridge-api-client.js +393 -4
  13. package/build/conductor/deny-enforcement-preflight.js +1 -0
  14. package/build/conductor/done-gate.js +44 -5
  15. package/build/conductor/epic-reconcile.js +6 -0
  16. package/build/conductor/install-doctor.js +462 -0
  17. package/build/conductor-bin.js +3 -3
  18. package/build/conductor-bundle-artifacts.js +37 -15
  19. package/build/credential-store.js +205 -4
  20. package/build/direct-ticket-tools.js +70 -0
  21. package/build/doctor.js +473 -81
  22. package/build/executor/cli.js +83 -6
  23. package/build/executor/credentials.js +39 -11
  24. package/build/executor/deps.js +32 -1
  25. package/build/executor/env.js +71 -28
  26. package/build/executor/heartbeat.js +138 -17
  27. package/build/executor/http-client.js +49 -8
  28. package/build/executor/index.js +4 -0
  29. package/build/executor/job-errors.js +4 -0
  30. package/build/executor/job-runner.js +530 -20
  31. package/build/executor/observation.js +130 -0
  32. package/build/executor/permissions.js +114 -8
  33. package/build/executor/preflight.js +127 -8
  34. package/build/executor/prompt-spec.js +51 -0
  35. package/build/executor/runner.js +23 -2
  36. package/build/executor/service-unit.js +876 -0
  37. package/build/executor/test-clock.js +75 -3
  38. package/build/executor/types.js +4 -18
  39. package/build/executor/worker-command.js +73 -12
  40. package/build/executor/worker-config-isolation.js +287 -0
  41. package/build/executor/worker-finalization.js +68 -14
  42. package/build/executor/worktree.js +46 -4
  43. package/build/index.js +1039 -237
  44. package/build/init.js +503 -111
  45. package/build/install-bridge-conductor.js +491 -0
  46. package/build/install-bridge.js +1188 -247
  47. package/build/install-reexec.js +233 -0
  48. package/build/launcher-config-inspection.js +351 -0
  49. package/build/mcp-host-config.js +11 -1
  50. package/build/mcp-install-state.js +32 -0
  51. package/build/mcp-invoke.js +49 -6
  52. package/build/mcp-provisioning.js +52 -13
  53. package/build/mcp-registration-doctor.js +14 -5
  54. package/build/notifications.js +553 -0
  55. package/build/pipeline-orchestrator.js +146 -4
  56. package/build/pipeline-utils.js +3 -0
  57. package/build/pipelines.generated.js +32 -13
  58. package/build/plan-execution-ledger.js +550 -0
  59. package/build/plan-phase-routing.js +272 -0
  60. package/build/plane/alembic-head.js +110 -0
  61. package/build/plane/build-freshness.js +167 -0
  62. package/build/plane/cli.js +480 -0
  63. package/build/plane/defaults.js +266 -0
  64. package/build/plane/manifest.js +377 -0
  65. package/build/plane/member-logs.js +147 -0
  66. package/build/plane/member-roster.js +147 -0
  67. package/build/plane/preflight.js +289 -0
  68. package/build/plane/shutdown.js +195 -0
  69. package/build/plane/status.js +125 -0
  70. package/build/plane/supervisor.js +569 -0
  71. package/build/plane/test-fakes.js +156 -0
  72. package/build/plane/types.js +75 -0
  73. package/build/readme.generated.js +1 -1
  74. package/build/run-unit-tests-launcher.js +259 -0
  75. package/build/setup-epic.js +772 -28
  76. package/build/sfcc/log-gate.js +38 -11
  77. package/build/sfcc/log-query.js +55 -15
  78. package/build/sfcc/ocapi-shape.js +51 -14
  79. package/build/sfcc/output.js +41 -11
  80. package/build/sfcc/permissions.js +24 -2
  81. package/build/sfcc/read-projection.js +181 -0
  82. package/build/sfcc/read-result.js +158 -0
  83. package/build/sfcc/reads-custom-object-def.js +29 -18
  84. package/build/sfcc/reads-site-preference.js +75 -29
  85. package/build/sfcc/reads-system-object.js +40 -34
  86. package/build/sfcc/sfcc-result.js +106 -0
  87. package/build/sfcc/tool-wrapper.js +56 -13
  88. package/build/sfcc/write-grants.js +45 -22
  89. package/build/sfcc/write-guard.js +21 -13
  90. package/build/sfcc/write-result.js +61 -14
  91. package/build/sfcc/write-tool-common.js +126 -32
  92. package/build/sfcc/writes-system-object.js +11 -50
  93. package/build/start-tickets-prereqs.js +129 -0
  94. package/build/start-tickets.js +17 -13
  95. package/build/ticket-backend-metadata.js +59 -0
  96. package/build/ticket-key-utils.js +92 -0
  97. package/build/tool-error-envelope.js +71 -0
  98. package/build/tool-surface-gating.js +72 -0
  99. package/build/update-status.js +102 -0
  100. package/build/upgrade-advice.js +47 -0
  101. package/build/upgrade-cli.js +412 -110
  102. package/build/version.generated.js +1 -1
  103. package/build/worktree-core.js +73 -0
  104. package/docs/CONDUCTOR.md +132 -6
  105. package/docs/install/mcp-tool-integrations.md +29 -21
  106. package/package.json +9 -6
  107. package/pipelines/implement-ticket.json +19 -4
  108. package/build/conductor/supervisor-judgment-python.js +0 -141
  109. package/build/conductor/supervisor-judgment.js +0 -215
@@ -11,10 +11,12 @@ 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";
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;
@@ -22,7 +24,12 @@ const DEFAULT_DEADMAN_MS = 120_000;
22
24
  const DEFAULT_TERM_GRACE_MS = 10_000;
23
25
  const DEFAULT_BASE_BRANCH = "main";
24
26
  const DEFAULT_JOB_TIMEOUT_SECONDS = 20 * 60;
25
- const DEFAULT_MAX_CONCURRENT = 1;
27
+ /**
28
+ * ONE job per executor process. Exported so the invariant can be pinned against
29
+ * the real constant rather than a copy: BAPI-688 adds more executor *processes*
30
+ * (persistent service units), and must never turn one process into multi-slot.
31
+ */
32
+ export const DEFAULT_MAX_CONCURRENT = 1;
26
33
  export function getExecutorUsage() {
27
34
  return [
28
35
  "Usage: mcp-server executor --repo <name> [--repo <name> ...] [options]",
@@ -32,6 +39,7 @@ export function getExecutorUsage() {
32
39
  "Options:",
33
40
  " --repo <name> Repo to serve (repeatable).",
34
41
  " --repos=<a,b> Comma-separated repos.",
42
+ " --base-url <url> Bridge API endpoint (overrides BAPI_BASE_URL).",
35
43
  " --executor-id <id> Stable executor id (default: <hostname>-<pid>).",
36
44
  " --max-concurrent <n> Max concurrent jobs (>= 1, default 1).",
37
45
  " --once Run a single preflight/claim cycle and exit.",
@@ -41,6 +49,12 @@ export function getExecutorUsage() {
41
49
  " --base-branch <name> Base branch (default main).",
42
50
  " --no-advisory-parser Disable the advisory stream-json parser.",
43
51
  " -h, --help Show this help.",
52
+ "",
53
+ "Subcommands:",
54
+ " executor watch <job> Attach (read-only) to a running job's worker log.",
55
+ " executor install-service Generate a launchd/systemd user service unit for a",
56
+ " stable --executor-id so the executor survives reboot.",
57
+ " See `executor install-service --help`.",
44
58
  ].join("\n");
45
59
  }
46
60
  function parseIntArg(value, flag) {
@@ -63,6 +77,7 @@ export function parseExecutorArgs(argv, context) {
63
77
  let deadmanMs = DEFAULT_DEADMAN_MS;
64
78
  let baseBranch = DEFAULT_BASE_BRANCH;
65
79
  let advisoryParserEnabled = true;
80
+ let baseUrl;
66
81
  for (let i = 0; i < argv.length; i++) {
67
82
  const arg = argv[i];
68
83
  if (arg === "--help" || arg === "-h")
@@ -120,6 +135,12 @@ export function parseExecutorArgs(argv, context) {
120
135
  return { kind: "error", message: "--base-branch requires a value" };
121
136
  baseBranch = v;
122
137
  }
138
+ else if (arg === "--base-url") {
139
+ const v = argv[++i];
140
+ if (!v)
141
+ return { kind: "error", message: "--base-url requires a value" };
142
+ baseUrl = v;
143
+ }
123
144
  else if (arg === "--no-advisory-parser") {
124
145
  advisoryParserEnabled = false;
125
146
  }
@@ -150,9 +171,29 @@ export function parseExecutorArgs(argv, context) {
150
171
  baseBranch,
151
172
  advisoryParserEnabled,
152
173
  defaultJobTimeoutSeconds: DEFAULT_JOB_TIMEOUT_SECONDS,
174
+ baseUrl,
153
175
  };
154
176
  return { kind: "ok", options };
155
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
+ }
156
197
  function hasRepoFlag(argv) {
157
198
  return argv.some((a) => a === "--repo" || a === "--repos" || a.startsWith("--repos="));
158
199
  }
@@ -194,20 +235,35 @@ export async function runExecutorCli(argv, overrides = {}) {
194
235
  return 1;
195
236
  }
196
237
  const options = parsed.options;
238
+ // The mutating executor requires an EXPLICIT base URL (BAPI-676): `--base-url`
239
+ // then `BAPI_BASE_URL`, never an implicit production default. Fail here —
240
+ // before any credential resolution, HTTP client, or claim side effect.
241
+ const baseUrlResult = resolveBaseUrl(deps.env, options.baseUrl);
242
+ if (!baseUrlResult.ok) {
243
+ errorLog(`Error: ${EXECUTOR_BASE_URL_REQUIRED_MESSAGE}`);
244
+ return 1;
245
+ }
246
+ const baseUrl = baseUrlResult.baseUrl;
197
247
  // Resolve credentials for EVERY configured repo so multi-repo mode sends the
198
248
  // correct repo-bound `X-API-Key` per claim/heartbeat/complete/fail — not just
199
249
  // the first repo's key. Uses the (injectable) single-repo resolver per repo.
200
250
  const resolveApi = overrides.resolveApiAccess ?? resolveExecutorApiAccess;
201
251
  const apiKeyByRepo = {};
202
- let baseUrl = "";
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;
203
259
  for (const repo of options.repos) {
204
- const access = await resolveApi(repo, deps);
260
+ const access = await resolveApi(repo, deps, baseUrl);
205
261
  if (!access.ok) {
206
262
  errorLog(`Error: ${access.error}`);
207
263
  return 1;
208
264
  }
209
265
  apiKeyByRepo[repo] = access.apiKey;
210
- baseUrl = access.baseUrl;
266
+ resolvedBaseUrl = access.baseUrl;
211
267
  }
212
268
  const createHttpClient = overrides.createHttpClient ?? createExecutorHttpClient;
213
269
  const httpClient = createHttpClient({
@@ -217,9 +273,30 @@ export async function runExecutorCli(argv, overrides = {}) {
217
273
  mcpVersion: VERSION,
218
274
  fetch: deps.fetch,
219
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
+ }
220
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 };
221
298
  try {
222
- return await run(options, deps, httpClient);
299
+ return await run(runtimeOptions, deps, httpClient);
223
300
  }
224
301
  catch (err) {
225
302
  const message = err instanceof Error ? err.message : String(err);
@@ -7,25 +7,53 @@
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
+ import { normalizeBaseUrlCandidate } from "../base-url.js";
11
+ /**
12
+ * The documented production Bridge API base URL — the value an operator pastes
13
+ * into `BAPI_BASE_URL` or `--base-url` when they *do* mean production.
14
+ *
15
+ * It is NOT an implicit executor fallback (BAPI-676): the executor requires an
16
+ * explicit base URL and fails fast when none is supplied, so pointing at
17
+ * production is always a deliberate act. Other flows (MCP server,
18
+ * install-bridge) legitimately keep their own production defaults.
19
+ */
11
20
  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;
21
+ /**
22
+ * Shared, secret-free prerequisite message for a missing executor base URL.
23
+ * Names both supported sources and states the no-production-default contract so
24
+ * the CLI and preflight diagnostics stay identically worded. It interpolates no
25
+ * configuration or credential value.
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 " +
28
+ `(production is ${DEFAULT_BAPI_BASE_URL}). The executor never defaults to production.`;
29
+ /**
30
+ * Resolve the executor's base URL: explicit override (`--base-url`) first, then
31
+ * `BAPI_BASE_URL`, then failure. A blank override is treated as absent so a
32
+ * nonblank environment value can still win; there is NO production fallback.
33
+ */
34
+ export function resolveBaseUrl(env, explicitBaseUrl) {
35
+ const fromFlag = normalizeBaseUrlCandidate(explicitBaseUrl);
36
+ if (fromFlag !== null)
37
+ return { ok: true, baseUrl: fromFlag };
38
+ const fromEnv = normalizeBaseUrlCandidate(env?.BAPI_BASE_URL);
39
+ if (fromEnv !== null)
40
+ return { ok: true, baseUrl: fromEnv };
41
+ return { ok: false };
18
42
  }
19
43
  /**
20
44
  * Resolve API access for a single repo. Returns a structured, secret-free
21
45
  * failure for credential-not-found, read/parse failure, missing key, or an
22
46
  * invalid repo name — never throwing, never echoing credential-file contents.
23
47
  */
24
- export async function resolveExecutorApiAccess(repoName, deps) {
48
+ export async function resolveExecutorApiAccess(repoName, deps, explicitBaseUrl) {
25
49
  const trimmed = typeof repoName === "string" ? repoName.trim() : "";
26
50
  if (trimmed.length === 0) {
27
51
  return { ok: false, error: "invalid repo name: repo name is required" };
28
52
  }
53
+ const url = resolveBaseUrl(deps.env, explicitBaseUrl);
54
+ if (!url.ok) {
55
+ return { ok: false, error: EXECUTOR_BASE_URL_REQUIRED_MESSAGE };
56
+ }
29
57
  const storeDeps = {
30
58
  env: deps.env,
31
59
  homedir: deps.homedir,
@@ -43,17 +71,17 @@ export async function resolveExecutorApiAccess(repoName, deps) {
43
71
  return {
44
72
  ok: true,
45
73
  apiKey: result.credentials.apiKey,
46
- baseUrl: resolveBaseUrl(deps.env),
74
+ baseUrl: url.baseUrl,
47
75
  };
48
76
  }
49
77
  /**
50
78
  * Resolve credentials for every configured repo before the executor starts
51
79
  * claiming. Each repo resolves independently; failures are reported per-repo.
52
80
  */
53
- export async function resolveAllExecutorApiAccess(repos, deps) {
81
+ export async function resolveAllExecutorApiAccess(repos, deps, explicitBaseUrl) {
54
82
  const out = [];
55
83
  for (const repo of repos) {
56
- const access = await resolveExecutorApiAccess(repo, deps);
84
+ const access = await resolveExecutorApiAccess(repo, deps, explicitBaseUrl);
57
85
  if (access.ok) {
58
86
  out.push({ ok: true, repoName: repo, apiKey: access.apiKey, baseUrl: access.baseUrl });
59
87
  }
@@ -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 { readFile, writeFile, appendFile, mkdir, stat, statfs } from "node:fs/promises";
11
+ import { existsSync } from "node:fs";
12
+ import { readFile, writeFile, appendFile, mkdir, mkdtemp, chmod, rm, readdir, lstat, 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;
@@ -83,6 +85,23 @@ export function createDefaultExecutorDeps() {
83
85
  const s = await statfs(path);
84
86
  return { bavail: Number(s.bavail), bsize: Number(s.bsize) };
85
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(),
86
105
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
87
106
  now: () => Date.now(),
88
107
  setTimer: (cb, ms) => setTimeout(cb, ms),
@@ -113,5 +132,17 @@ export function createDefaultExecutorDeps() {
113
132
  },
114
133
  log: (message) => console.error(message),
115
134
  errorLog: (message) => console.error(message),
135
+ // Resolve how the worker MCP shim launches at dispatch time from THIS
136
+ // running executor's own on-disk build (absolute-build-path primary,
137
+ // npm-channel fallback) — never from process.cwd(). Mirrors
138
+ // `buildMcpProvisioningDeps` in `start-tickets.ts`. `nodeExecutable` stays
139
+ // "node" (not `process.execPath`) to preserve today's worker runtime
140
+ // selection; see `mcp-server-invocation.ts`'s scope-boundary note.
141
+ mcpServerInvocation: resolveMcpShimInvocationForRuntime({
142
+ moduleUrl: import.meta.url,
143
+ nodeExecutable: "node",
144
+ argv1: process.argv[1],
145
+ fileExists: existsSync,
146
+ }),
116
147
  };
117
148
  }
@@ -1,12 +1,22 @@
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 the credential
17
+ * store under the worker's redirected `XDG_CONFIG_HOME` — the executor seeds a
18
+ * repo-scoped store into the per-job isolation directory (see
19
+ * `worker-config-isolation.ts`) — so this worker environment never carries it.
10
20
  */
11
21
  import { PR_BASE_BRANCH_ENV_VAR } from "../pr-base-contract.js";
12
22
  /** Non-secret operational keys forwarded to the worker when present. */
@@ -22,12 +32,19 @@ const ALLOWED_ENV_KEYS = [
22
32
  "LANG",
23
33
  "LC_ALL",
24
34
  "TERM",
25
- "XDG_CONFIG_HOME",
26
35
  "NO_COLOR",
27
36
  ];
28
37
  /** Substrings that mark a key as secret-bearing (case-insensitive). */
29
38
  const SECRET_NAME_FRAGMENTS = ["TOKEN", "SECRET", "PASSWORD", "API_KEY"];
30
- /** Explicit deny-list of known secret / MCP / conductor keys. */
39
+ /**
40
+ * Explicit deny-list of known secret / MCP / conductor keys.
41
+ *
42
+ * BAPI-731 adds the two Claude configuration paths. They are denied on the
43
+ * INHERITED-COPY path and then set explicitly from executor-computed values
44
+ * below, following the `BAPI_BASE_BRANCH` precedent — an operator's
45
+ * `CLAUDE_CONFIG_DIR` or `XDG_CONFIG_HOME` must never be able to redirect a
46
+ * worker back at the operator's own configuration and undo isolation.
47
+ */
31
48
  const EXPLICIT_DENY_KEYS = [
32
49
  "BRIDGE_MCP_PROFILE",
33
50
  "CONDUCTOR_NODE_PATH",
@@ -36,12 +53,22 @@ const EXPLICIT_DENY_KEYS = [
36
53
  "OPENAI_API_KEY",
37
54
  "GITHUB_TOKEN",
38
55
  "GH_TOKEN",
56
+ "CLAUDE_CONFIG_DIR",
57
+ "XDG_CONFIG_HOME",
39
58
  ];
40
59
  /**
41
60
  * True only when `key` is a safe, allowlisted operational key. Exported so the
42
61
  * allowlist is locally auditable/testable. A key is allowed iff it is in the
43
62
  * allowlist AND is not explicitly denied, does not begin with `BAPI_CONDUCTOR_`,
44
63
  * and does not contain a secret-name fragment.
64
+ *
65
+ * The `BAPI_CONDUCTOR_` rejection is a WHOLE-PREFIX rule, not an enumerated list,
66
+ * which is what makes it hold for keys added later. BAPI-722's
67
+ * `BAPI_CONDUCTOR_DENY_PROBE_TIMEOUT_MS` is rejected twice over — it is absent from
68
+ * `ALLOWED_ENV_KEYS` and it carries the prefix — so it stays executor-process
69
+ * configuration and can never reach a spawned worker. That is deliberate: a worker
70
+ * runs no deny probe, and letting job-side state influence the executor's own
71
+ * safety-probe budget would invert the trust boundary.
45
72
  */
46
73
  export function isExecutorEnvKeyAllowed(key) {
47
74
  if (!ALLOWED_ENV_KEYS.includes(key))
@@ -55,24 +82,7 @@ export function isExecutorEnvKeyAllowed(key) {
55
82
  return false;
56
83
  return true;
57
84
  }
58
- /**
59
- * Build the secret-free worker environment: a fresh object containing only the
60
- * allowlisted keys that are present (and non-undefined) in `parentEnv`, plus the
61
- * literal `BRIDGE_SKIP_PREPUSH=\"1\"` (BAPI-551). This is a constant, never read
62
- * from `parentEnv` — the worker's advisory local pre-push suite (~70s) is
63
- * redundant for a conductor worker, since its PR is CI-gated by the merge gate
64
- * anyway, and the delay was observed causing a worker to background the push and
65
- * exit before it landed. `BAPI_CONDUCTOR_*` identity keys remain intentionally
66
- * absent from the worker env (TDD §7) — this literal does not change that.
67
- *
68
- * BAPI-586: when `effectiveBaseBranch` is provided (the validated run base for
69
- * this job), it is injected as `BAPI_BASE_BRANCH` so the worker can run
70
- * `gh pr create --base "$BAPI_BASE_BRANCH"` deterministically. It is set from the
71
- * EXPLICIT job value, NOT copied from `parentEnv` (`BAPI_BASE_BRANCH` is not in
72
- * the allowlist), so any inherited value cannot override the run base and no
73
- * unrelated `BAPI_*` / secret value can leak in via this key.
74
- */
75
- export function buildExecutorWorkerEnv(parentEnv, effectiveBaseBranch) {
85
+ export function buildExecutorWorkerEnv(parentEnv, options = {}) {
76
86
  const env = {};
77
87
  for (const key of ALLOWED_ENV_KEYS) {
78
88
  if (!isExecutorEnvKeyAllowed(key))
@@ -83,8 +93,41 @@ export function buildExecutorWorkerEnv(parentEnv, effectiveBaseBranch) {
83
93
  }
84
94
  }
85
95
  env.BRIDGE_SKIP_PREPUSH = "1";
86
- if (typeof effectiveBaseBranch === "string" && effectiveBaseBranch.length > 0) {
87
- env[PR_BASE_BRANCH_ENV_VAR] = effectiveBaseBranch;
96
+ if (typeof options.effectiveBaseBranch === "string" &&
97
+ options.effectiveBaseBranch.length > 0) {
98
+ env[PR_BASE_BRANCH_ENV_VAR] = options.effectiveBaseBranch;
99
+ }
100
+ // --- Worker config isolation (BAPI-731) --------------------------------
101
+ // Both paths are assigned from EXPLICIT executor-computed values and are on
102
+ // `EXPLICIT_DENY_KEYS`, so the inherited-copy loop above can never supply them.
103
+ // That ordering is the isolation guarantee: an operator `CLAUDE_CONFIG_DIR` or
104
+ // `XDG_CONFIG_HOME` cannot redirect the worker back at the operator's own
105
+ // configuration (and its `projects[...].mcpServers` map).
106
+ if (typeof options.claudeConfigDir === "string" && options.claudeConfigDir.length > 0) {
107
+ env.CLAUDE_CONFIG_DIR = options.claudeConfigDir;
108
+ }
109
+ if (typeof options.xdgConfigHome === "string" && options.xdgConfigHome.length > 0) {
110
+ env.XDG_CONFIG_HOME = options.xdgConfigHome;
111
+ }
112
+ // The ONE deliberately-injected credential, and a genuine exception to this
113
+ // module's otherwise absolute secret-free rule. It is injected because
114
+ // isolation REMOVES the worker's previous authentication path: a worker used to
115
+ // authenticate as a side effect of inheriting the operator's `HOME` and
116
+ // `~/.claude.json`, and that inheritance is exactly the defect being closed.
117
+ // Per the measured inventory (`docs/claude/claude-cli-config-isolation-inventory.md`,
118
+ // finding 4) an operator OAuth/subscription login does NOT follow an isolated
119
+ // `CLAUDE_CONFIG_DIR`, while `ANTHROPIC_API_KEY` does — so this is the only
120
+ // supported way an isolated worker can authenticate at all.
121
+ //
122
+ // It is passed from an EXPLICIT executor-resolved value rather than copied
123
+ // (`ANTHROPIC_API_KEY` remains on `EXPLICIT_DENY_KEYS` and still matches the
124
+ // `API_KEY` secret fragment), so it travels only when the isolation strategy
125
+ // deliberately supplies it, never as ambient leakage. Nothing writes it to
126
+ // disk: the only file seeded into the isolated directory is the repo-scoped
127
+ // Bridge credential store (see `worker-config-isolation.ts`), which never
128
+ // carries the Anthropic key.
129
+ if (typeof options.anthropicApiKey === "string" && options.anthropicApiKey.length > 0) {
130
+ env.ANTHROPIC_API_KEY = options.anthropicApiKey;
88
131
  }
89
132
  return env;
90
133
  }
@@ -12,40 +12,83 @@
12
12
  *
13
13
  * After abandonment the loop stops all further heartbeat / telemetry / mutation
14
14
  * work so `runClaimedJob` never reports success after losing ownership.
15
+ *
16
+ * BAPI-731 adds two behaviors, neither of which may weaken the above:
17
+ *
18
+ * - PUSH-TRIGGERED PROMPTNESS (WS-H). The loop samples the LOCAL
19
+ * remote-tracking marker for the job's branch on a short sub-interval tick.
20
+ * When the marker first appears or changes, the worker has pushed, so the
21
+ * loop flushes one immediate heartbeat instead of waiting up to a full
22
+ * interval. This is TELEMETRY ONLY (R14): the marker is never persisted, never
23
+ * reported as `last_commit_sha`, and never drives an executor decision — the
24
+ * reconciler remains the sole consumer and the sole transition authority
25
+ * (`docs/claude/conductor-standing-rules.md`).
26
+ *
27
+ * - SERVER STOP (WS-B executor half). A heartbeat response carrying the
28
+ * server's stop request kills the owned worker like `stale_claim` does, but
29
+ * reports a DISTINCT outcome so the runner can send a server-cancelled
30
+ * terminal report rather than classifying a deliberate cancellation as a
31
+ * worker crash. Inert until the reconciler ticket ships the field.
32
+ *
33
+ * Every heartbeat — initial, periodic, and push-triggered — goes through the ONE
34
+ * `sendHeartbeat` path below, so requests can never overlap or arrive out of
35
+ * order.
15
36
  */
16
37
  import { killOwnedProcess } from "./process.js";
38
+ import { normalizeHeartbeatResult } from "./types.js";
17
39
  /**
18
- * Run the heartbeat loop until the worker is done or ownership is abandoned. The
19
- * FIRST heartbeat fires promptly (no initial interval wait).
40
+ * How often the push detector samples the local remote-tracking ref. Bounded
41
+ * well under the heartbeat interval so a push is noticed promptly, and it is a
42
+ * purely local `git rev-parse` (no network), so the cost is negligible.
43
+ */
44
+ export const PUSH_DETECT_INTERVAL_MS = 5_000;
45
+ /**
46
+ * Run the heartbeat loop until the worker is done, ownership is abandoned, or
47
+ * the server requests a stop. The FIRST heartbeat fires promptly (no initial
48
+ * interval wait).
20
49
  */
21
50
  export async function runHeartbeatLoop(params) {
22
51
  const { job, httpClient, options, deps, ownership, proc, observation } = params;
23
- let first = true;
24
- while (!ownership.abandoned && !params.isDone()) {
25
- if (!first) {
26
- await deps.sleep(options.heartbeatIntervalMs);
27
- if (ownership.abandoned || params.isDone())
28
- break;
29
- }
30
- first = false;
52
+ const pushDetectIntervalMs = params.pushDetectIntervalMs ?? PUSH_DETECT_INTERVAL_MS;
53
+ // Baseline for push detection. `undefined` means "not yet sampled": the FIRST
54
+ // successful sample only establishes the baseline and never itself triggers a
55
+ // flush, so a branch that was already pushed before this loop started does not
56
+ // manufacture a spurious immediate beat.
57
+ let remoteMarker;
58
+ let remoteBaselineEstablished = false;
59
+ /**
60
+ * The single heartbeat send path. Returns the loop outcome when the beat is
61
+ * terminal, or `null` to continue. Never called concurrently with itself:
62
+ * every caller awaits it before doing anything else.
63
+ */
64
+ const sendHeartbeat = async () => {
31
65
  const git = await params.collectTelemetry();
32
66
  observation.setGitTelemetry(git);
33
67
  const residue = observation.snapshot();
34
- const outcome = await httpClient.heartbeat(job, {
68
+ const result = normalizeHeartbeatResult(await httpClient.heartbeat(job, {
69
+ // Unconditionally the LOCAL head — never the remote marker. This is what
70
+ // preserves the BAPI-704 semantic that a zero-commit job reports the base
71
+ // SHA paired with `local_commit_count: 0`.
35
72
  local_commit_count: git.local_commit_count,
36
73
  last_commit_sha: git.last_commit_sha,
37
74
  telemetry: residue,
38
- });
39
- if (outcome === "updated") {
75
+ }));
76
+ // Checked BEFORE the outcome switch: a stop request is authoritative even
77
+ // when it rides along with a non-`updated` outcome.
78
+ if (result.stop_requested === true) {
79
+ killOwnedProcess(proc, "server_stop");
80
+ return { kind: "server_stop" };
81
+ }
82
+ if (result.outcome === "updated") {
40
83
  ownership.lastSuccessfulHeartbeatAt = deps.now();
41
- continue;
84
+ return null;
42
85
  }
43
- if (outcome === "stale_claim") {
86
+ if (result.outcome === "stale_claim") {
44
87
  // Definitive dead claim: kill the worker and stop touching the worktree.
45
88
  killOwnedProcess(proc, "stale_claim");
46
89
  ownership.abandoned = true;
47
90
  ownership.abandonReason = "stale_claim";
48
- break;
91
+ return { kind: "abandoned" };
49
92
  }
50
93
  // retry_later / fatal_http_error: keep the worker alive within the dead-man
51
94
  // window; SIGKILL + abandon once 2 minutes pass without a successful beat.
@@ -53,7 +96,85 @@ export async function runHeartbeatLoop(params) {
53
96
  killOwnedProcess(proc, "deadman");
54
97
  ownership.abandoned = true;
55
98
  ownership.abandonReason = "deadman";
56
- break;
99
+ return { kind: "abandoned" };
100
+ }
101
+ return null;
102
+ };
103
+ /**
104
+ * Wait out one heartbeat interval in short push-detector ticks, flushing an
105
+ * immediate beat if the worker pushes meanwhile. Returns an outcome when a
106
+ * flushed beat was terminal.
107
+ */
108
+ const waitIntervalWatchingForPush = async () => {
109
+ if (!params.collectRemoteMarker || pushDetectIntervalMs <= 0) {
110
+ await deps.sleep(options.heartbeatIntervalMs);
111
+ return null;
112
+ }
113
+ let waited = 0;
114
+ while (waited < options.heartbeatIntervalMs) {
115
+ const slice = Math.min(pushDetectIntervalMs, options.heartbeatIntervalMs - waited);
116
+ await deps.sleep(slice);
117
+ waited += slice;
118
+ if (ownership.abandoned || params.isDone())
119
+ return null;
120
+ let sample;
121
+ try {
122
+ sample = await params.collectRemoteMarker();
123
+ }
124
+ catch {
125
+ // Degrade-don't-throw: a failed local ref read is an observability gap,
126
+ // never a job failure.
127
+ sample = undefined;
128
+ }
129
+ if (sample === undefined)
130
+ continue;
131
+ if (!remoteBaselineEstablished) {
132
+ remoteBaselineEstablished = true;
133
+ remoteMarker = sample;
134
+ continue;
135
+ }
136
+ if (sample === remoteMarker)
137
+ continue;
138
+ // The marker moved: the worker pushed. A rewind/force-push moves it too and
139
+ // is treated identically — a flush trigger, never a gate or a decision.
140
+ remoteMarker = sample;
141
+ const outcome = await sendHeartbeat();
142
+ if (outcome)
143
+ return outcome;
144
+ if (ownership.abandoned || params.isDone())
145
+ return null;
146
+ }
147
+ return null;
148
+ };
149
+ let first = true;
150
+ while (!ownership.abandoned && !params.isDone()) {
151
+ if (!first) {
152
+ const waitOutcome = await waitIntervalWatchingForPush();
153
+ if (waitOutcome)
154
+ return waitOutcome;
155
+ if (ownership.abandoned || params.isDone())
156
+ break;
157
+ }
158
+ else {
159
+ // Establish the push baseline alongside the prompt first beat, so an
160
+ // already-pushed branch does not look like a brand-new push one tick later.
161
+ if (params.collectRemoteMarker) {
162
+ try {
163
+ const sample = await params.collectRemoteMarker();
164
+ if (sample !== undefined) {
165
+ remoteMarker = sample;
166
+ }
167
+ remoteBaselineEstablished = true;
168
+ }
169
+ catch {
170
+ remoteBaselineEstablished = true;
171
+ }
172
+ }
57
173
  }
174
+ first = false;
175
+ const outcome = await sendHeartbeat();
176
+ if (outcome)
177
+ return outcome;
58
178
  }
179
+ return ownership.abandoned ? { kind: "abandoned" } : { kind: "done" };
59
180
  }