@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
@@ -53,6 +53,118 @@ export async function collectGitTelemetry(deps, worktreePath, baseBranch) {
53
53
  }
54
54
  return telemetry;
55
55
  }
56
+ /** A full 40-character hex object name. */
57
+ const FULL_SHA_PATTERN = /^[0-9a-f]{40}$/i;
58
+ /** Normalize a candidate SHA, or `null` when it is absent/malformed. */
59
+ function normalizeSha(value) {
60
+ if (typeof value !== "string")
61
+ return null;
62
+ const trimmed = value.trim();
63
+ return FULL_SHA_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null;
64
+ }
65
+ /**
66
+ * Fail-CLOSED pre-spawn contract over already-collected telemetry (BAPI-731,
67
+ * WS-E). Pure: it shells out to nothing and reuses `collectGitTelemetry()`'s
68
+ * existing reads rather than adding new git invocations.
69
+ *
70
+ * THE DEGRADED-READ RULE IS THE POINT. `collectGitTelemetry()` degrades silently
71
+ * — a failed `status --porcelain` simply omits `dirty`, and a failed
72
+ * `rev-parse HEAD` omits `last_commit_sha`. For ordinary telemetry that is
73
+ * correct (an observability gap must never fail a worker). For a fail-closed
74
+ * assertion it is the opposite: an omitted field means "could not verify", and
75
+ * "could not verify" must never be reported as "verified clean". Both omissions
76
+ * therefore REFUSE.
77
+ *
78
+ * `expectedHeadSha` is optional because `resume` legitimately has no
79
+ * remote-derived expectation: its protocol auto-commits dirty state as a WIP
80
+ * checkpoint, so it is verified for cleanliness AFTER that checkpoint but is
81
+ * exempt from SHA equality. Every other path supplies one.
82
+ *
83
+ * Messages are fixed strings plus validated hex SHAs — never porcelain output, a
84
+ * filename, a worktree path, stderr, or raw exception text.
85
+ */
86
+ export function evaluatePreSpawnGitVerification(telemetry, expectedHeadSha) {
87
+ if (typeof telemetry.dirty !== "boolean") {
88
+ return {
89
+ ok: false,
90
+ reason: "status-unverifiable",
91
+ message: "pre-spawn verification failed: the worktree's clean/dirty status could not be read, " +
92
+ "so cleanliness cannot be confirmed",
93
+ };
94
+ }
95
+ if (telemetry.dirty) {
96
+ return {
97
+ ok: false,
98
+ reason: "dirty",
99
+ message: "pre-spawn verification failed: the prepared worktree has uncommitted or untracked " +
100
+ "changes; refusing to spawn a worker that could commit unrelated leftover work",
101
+ };
102
+ }
103
+ const observedSha = normalizeSha(telemetry.last_commit_sha);
104
+ if (observedSha === null) {
105
+ return {
106
+ ok: false,
107
+ reason: "head-unverifiable",
108
+ message: "pre-spawn verification failed: the worktree's HEAD commit could not be read, " +
109
+ "so it cannot be confirmed to match the prepared commit",
110
+ };
111
+ }
112
+ // No expectation supplied (`resume`): cleanliness plus a readable HEAD is the
113
+ // whole contract.
114
+ if (expectedHeadSha === undefined) {
115
+ return { ok: true, observedSha };
116
+ }
117
+ const expected = normalizeSha(expectedHeadSha);
118
+ if (expected === null) {
119
+ // Never echo an unvalidated value into a message.
120
+ return {
121
+ ok: false,
122
+ reason: "head-unverifiable",
123
+ message: "pre-spawn verification failed: the expected commit supplied by worktree preparation " +
124
+ "is not a valid commit id",
125
+ observedSha,
126
+ };
127
+ }
128
+ if (expected !== observedSha) {
129
+ return {
130
+ ok: false,
131
+ reason: "head-mismatch",
132
+ message: `pre-spawn verification failed: the prepared worktree is at ${observedSha} but ` +
133
+ `preparation expected ${expected}`,
134
+ observedSha,
135
+ };
136
+ }
137
+ return { ok: true, observedSha };
138
+ }
139
+ /**
140
+ * Read the LOCAL remote-tracking marker for a branch (BAPI-731, WS-H).
141
+ *
142
+ * Reads `refs/remotes/origin/<branch>` from the local object store only. It
143
+ * deliberately issues NO network operation — no `fetch`, no `ls-remote`, no
144
+ * provider API — because this runs on the high-frequency telemetry path where a
145
+ * network stall would block the heartbeat loop and defeat the dead-man switch.
146
+ * A worker's own `git push` updates this ref locally, which is precisely the
147
+ * signal being sampled.
148
+ *
149
+ * Degrade-don't-throw, exactly like `collectGitTelemetry()`: any failure or
150
+ * malformed output yields `undefined` and never fails the job.
151
+ */
152
+ export async function collectRemoteTrackingSha(deps, worktreePath, branch) {
153
+ try {
154
+ const result = await deps.runCommand("git", [
155
+ "-C",
156
+ worktreePath,
157
+ "rev-parse",
158
+ `refs/remotes/origin/${branch}^{commit}`,
159
+ ]);
160
+ if (result.exitCode !== 0)
161
+ return undefined;
162
+ return normalizeSha(result.stdout) ?? undefined;
163
+ }
164
+ catch {
165
+ return undefined;
166
+ }
167
+ }
56
168
  /**
57
169
  * Tolerantly parse one Claude stream-json line for an advisory `phase_hint`.
58
170
  * Catches ALL parse/schema errors and never throws; returns `{}` when nothing
@@ -89,6 +201,8 @@ export function createObservationState(deps, options) {
89
201
  let git = {};
90
202
  const advisory = {};
91
203
  let exitCode;
204
+ let attemptStartSha;
205
+ let attemptEndSha;
92
206
  return {
93
207
  recordStdout(chunk) {
94
208
  advisory.last_stdout_at = new Date(deps.now()).toISOString();
@@ -106,6 +220,16 @@ export function createObservationState(deps, options) {
106
220
  setExitCode(code) {
107
221
  exitCode = code;
108
222
  },
223
+ setAttemptStartSha(sha) {
224
+ const normalized = normalizeSha(sha);
225
+ if (normalized)
226
+ attemptStartSha = normalized;
227
+ },
228
+ setAttemptEndSha(sha) {
229
+ const normalized = normalizeSha(sha);
230
+ if (normalized)
231
+ attemptEndSha = normalized;
232
+ },
109
233
  git() {
110
234
  return git;
111
235
  },
@@ -117,6 +241,12 @@ export function createObservationState(deps, options) {
117
241
  residue.last_stdout_at = advisory.last_stdout_at;
118
242
  if (exitCode !== undefined)
119
243
  residue.exit_code = exitCode;
244
+ // The attempt boundary pair is independent of `last_commit_sha`: it
245
+ // survives a degraded final sample rather than being overwritten by one.
246
+ if (attemptStartSha !== undefined)
247
+ residue.attempt_start_sha = attemptStartSha;
248
+ if (attemptEndSha !== undefined)
249
+ residue.attempt_end_sha = attemptEndSha;
120
250
  return residue;
121
251
  },
122
252
  };
@@ -8,16 +8,37 @@
8
8
  * liveness never does. (Refusing to claim on `enforced:false` is a SEPARATE,
9
9
  * fatal preflight concern — see `preflight.ts`.)
10
10
  *
11
+ * BAPI-724: the worker model is now provisioned with default Bridge MCP access
12
+ * (see `../mcp-provisioning.js` wiring in `job-runner.ts`), so this module also
13
+ * denies direct reads of the user-scoped Bridge credential store
14
+ * (`~/.config/bridge/**`). MCP authentication stays available to the worker
15
+ * through the separately launched `mcp-invoke` shim process, which resolves the
16
+ * credential itself — the model process never reads it directly.
17
+ *
18
+ * BAPI-740/F2: worker-config isolation seeds a repo-scoped copy of that store
19
+ * INSIDE the per-job isolation directory (`bapi-conductor-claude-cfg-*` under the
20
+ * executor tmp root — see `worker-config-isolation.ts`), where the `~/.config`
21
+ * rule cannot reach it. A prefix glob denies model reads under any isolation
22
+ * directory so the seeded `credentials.json` gets the same shielding as the
23
+ * operator store. The shim still reads it: the shim is a separate process, not
24
+ * the model.
25
+ *
11
26
  * The Wave-1 probe on the target machine reports enforcement layer `settings-deny`
12
27
  * (claude 2.1.201), so plain `permissions.deny` is provisioned; the PreToolUse
13
28
  * fallback is available for CLIs where settings-deny is not enforced.
14
29
  */
30
+ import path from "node:path";
15
31
  import { mergeClaudeSettingsWithCommandHook, provisionClaudeSettingsForWorktree, DEFAULT_PRE_TOOL_USE_MATCHER, } from "../claude-settings.js";
32
+ import { ISOLATION_DIR_PREFIX } from "./worker-config-isolation.js";
16
33
  /**
17
34
  * The stable executor deny rules. Grammar mirrors the deny-enforcement probe's
18
35
  * `Bash(<cmd>:<args-glob>)` / `Read(<path-glob>)` form. Covers: reads under
19
- * ~/.ssh, ~/.aws, and keychain paths; force-push to the base branch; and
20
- * destructive `rm -rf` outside the worktree.
36
+ * ~/.ssh, ~/.aws, and keychain paths; the user-scoped Bridge credential store
37
+ * (BAPI-724 the model is denied direct reads, while the separately launched
38
+ * `mcp-invoke` shim process still resolves it); the per-job isolation directory
39
+ * (BAPI-740/F2 — the seeded repo-scoped `credentials.json` lives there, outside
40
+ * the `~/.config` rule's reach); force-push to the base branch; and destructive
41
+ * `rm -rf` outside the worktree.
21
42
  */
22
43
  export function executorDenyRules(inputs) {
23
44
  return [
@@ -25,6 +46,11 @@ export function executorDenyRules(inputs) {
25
46
  "Read(~/.aws/**)",
26
47
  "Read(~/Library/Keychains/**)",
27
48
  "Read(/etc/shadow)",
49
+ "Read(~/.config/bridge/**)",
50
+ // Any path under any per-job isolation directory, wherever the executor's
51
+ // tmp root lives (`//` anchors at the filesystem root; the prefix constant
52
+ // is shared with `worker-config-isolation.ts` so they cannot drift).
53
+ `Read(//**/${ISOLATION_DIR_PREFIX}*/**)`,
28
54
  `Bash(git push:*--force*${inputs.baseBranch}*)`,
29
55
  "Bash(git push:*--force*)",
30
56
  "Bash(rm:*-rf /*)",
@@ -53,7 +79,80 @@ export function mergeExecutorDenySettings(existing, inputs) {
53
79
  return { ...existing, permissions };
54
80
  }
55
81
  /**
56
- * Provision the executor deny layer into `<worktree>/.claude/settings.local.json`.
82
+ * The exact line appended to the worktree's `.git/info/exclude` so a worker's
83
+ * `git add -A` cannot commit the executor-owned settings file.
84
+ */
85
+ export const EXECUTOR_SETTINGS_EXCLUDE_LINE = ".claude/settings.local.json";
86
+ /**
87
+ * Keep the executor-owned `.claude/settings.local.json` out of worker commits
88
+ * (BAPI-740/F2). Worker-config isolation redirects `XDG_CONFIG_HOME`, so the
89
+ * operator's global git excludes (`~/.config/git/ignore`) no longer apply inside
90
+ * a worker — empirically, a worker `git add -A` commits the deny-layer file this
91
+ * module just wrote. A per-worktree `.git/info/exclude` entry is invisible to
92
+ * the diff and travels with the gitdir, so it closes that without touching the
93
+ * worktree's tracked `.gitignore`.
94
+ *
95
+ * A linked worktree's `.git` is a FILE containing `gitdir: <path>` pointing at
96
+ * the main repository's per-worktree gitdir; that indirection is resolved rather
97
+ * than assuming a directory (`EISDIR` on the read identifies a real main-checkout
98
+ * `.git` directory). Idempotent: the line is appended once, never duplicated.
99
+ * Fail-open, matching the rest of this module: unresolvable git metadata is a
100
+ * silent skip, and an exclude-write failure returns a warning — never a block.
101
+ */
102
+ async function provisionWorktreeSettingsExclude(worktreePath, deps) {
103
+ const gitPath = path.join(worktreePath, ".git");
104
+ let gitDir;
105
+ try {
106
+ const raw = await deps.readFile(gitPath);
107
+ const match = /^gitdir:\s*(.+?)\s*$/m.exec(raw);
108
+ if (!match) {
109
+ // A readable `.git` file with no gitdir pointer is not git metadata this
110
+ // function understands; do not guess at a location to write into.
111
+ return { ok: true };
112
+ }
113
+ const target = match[1];
114
+ gitDir = path.isAbsolute(target) ? target : path.resolve(worktreePath, target);
115
+ }
116
+ catch (err) {
117
+ const code = err && typeof err === "object" ? err.code : undefined;
118
+ if (code === "EISDIR") {
119
+ // Main-checkout shape: `.git` is a real directory.
120
+ gitDir = gitPath;
121
+ }
122
+ else {
123
+ // No readable git metadata at all (e.g. ENOENT) — nothing to protect.
124
+ return { ok: true };
125
+ }
126
+ }
127
+ try {
128
+ const infoDir = path.join(gitDir, "info");
129
+ await deps.mkdir(infoDir, { recursive: true });
130
+ const excludePath = path.join(infoDir, "exclude");
131
+ let existing = "";
132
+ try {
133
+ existing = await deps.readFile(excludePath);
134
+ }
135
+ catch {
136
+ existing = "";
137
+ }
138
+ if (existing.split(/\r?\n/).includes(EXECUTOR_SETTINGS_EXCLUDE_LINE)) {
139
+ return { ok: true };
140
+ }
141
+ const prefix = existing.length === 0 || existing.endsWith("\n") ? existing : `${existing}\n`;
142
+ await deps.writeFile(excludePath, `${prefix}${EXECUTOR_SETTINGS_EXCLUDE_LINE}\n`);
143
+ return { ok: true };
144
+ }
145
+ catch {
146
+ return {
147
+ ok: false,
148
+ warning: "worktree git-exclude provisioning did not complete " +
149
+ `(${EXECUTOR_SETTINGS_EXCLUDE_LINE} may appear in worker commits); continuing fail-open`,
150
+ };
151
+ }
152
+ }
153
+ /**
154
+ * Provision the executor deny layer into `<worktree>/.claude/settings.local.json`,
155
+ * and shield that file from worker commits via `.git/info/exclude` (BAPI-740/F2).
57
156
  * Fails open: a malformed existing file is preserved (not clobbered) and I/O
58
157
  * errors return a warning — the caller still spawns the worker.
59
158
  */
@@ -70,10 +169,17 @@ export async function provisionExecutorDenyLayer(worktreePath, options, deps) {
70
169
  }
71
170
  return merged;
72
171
  }, deps);
73
- if (result.ok)
172
+ // Independent of the settings write above: the exclude protects a file that
173
+ // may already exist from an earlier provisioning, so it runs even when the
174
+ // merge-write was refused or failed.
175
+ const exclude = await provisionWorktreeSettingsExclude(worktreePath, deps);
176
+ const warnings = [];
177
+ if (!result.ok) {
178
+ warnings.push(`deny-layer provisioning did not complete (${result.reason}); continuing fail-open`);
179
+ }
180
+ if (!exclude.ok)
181
+ warnings.push(exclude.warning);
182
+ if (warnings.length === 0)
74
183
  return { ok: true };
75
- return {
76
- ok: false,
77
- warning: `deny-layer provisioning did not complete (${result.reason}); continuing fail-open`,
78
- };
184
+ return { ok: false, warning: warnings.join("; ") };
79
185
  }
@@ -14,8 +14,71 @@
14
14
  * allowed reuse surface — it does not pull the v1 event/ledger graph).
15
15
  */
16
16
  import { runDenyEnforcementPreflight } from "../conductor/deny-enforcement-preflight.js";
17
- import { resolveAllExecutorApiAccess } from "./credentials.js";
17
+ import { DEFAULT_PROBE_TIMEOUT_MS } from "../agent-capabilities/types.js";
18
+ import { evaluateClaudeMcpShadowingPolicy, inspectClaudeUserConfigForMcpShadowing, resolveClaudeUserConfigPath, } from "../claude-user-config-doctor.js";
19
+ import { resolveAllExecutorApiAccess, resolveBaseUrl, EXECUTOR_BASE_URL_REQUIRED_MESSAGE, } from "./credentials.js";
18
20
  const VERSION_DETAIL_MAX = 200;
21
+ /**
22
+ * Environment override for the deny probe's headless timeout (BAPI-722).
23
+ *
24
+ * EXECUTOR-PROCESS CONFIGURATION ONLY. It is deliberately excluded from spawned
25
+ * worker environments by `isExecutorEnvKeyAllowed` — a worker has no deny probe to
26
+ * time out, and letting a job influence the executor's own safety-probe budget
27
+ * would be a privilege inversion.
28
+ */
29
+ export const DENY_PROBE_TIMEOUT_ENV_KEY = "BAPI_CONDUCTOR_DENY_PROBE_TIMEOUT_MS";
30
+ /**
31
+ * Accepted bounds for the resolved probe timeout. The lower bound keeps a
32
+ * fat-fingered `1` from turning every probe into an instant HANG (which is FATAL —
33
+ * the executor would refuse to claim forever); the upper bound keeps a typo from
34
+ * wedging the claim loop for hours. Anything outside them falls back to the
35
+ * established capability-probe default rather than being clamped, so a
36
+ * misconfiguration produces the known-good value instead of a silently different
37
+ * one the operator never chose.
38
+ */
39
+ const DENY_PROBE_TIMEOUT_MIN_MS = 1_000;
40
+ const DENY_PROBE_TIMEOUT_MAX_MS = 600_000;
41
+ /**
42
+ * Resolve the deny probe's timeout from the environment, falling back to the
43
+ * established capability-probe default (`DEFAULT_PROBE_TIMEOUT_MS`).
44
+ *
45
+ * Strict: blank, non-numeric, non-integer, zero, negative, and out-of-range values
46
+ * all resolve to the default. The result is ALWAYS a finite positive number — never
47
+ * `undefined`, never `0`, never unbounded — because an unbounded deny probe is
48
+ * exactly the hang this timeout exists to prevent.
49
+ *
50
+ * The value itself is never logged alongside its surrounding environment.
51
+ */
52
+ export function resolveDenyProbeTimeoutMs(env = {}) {
53
+ const raw = env[DENY_PROBE_TIMEOUT_ENV_KEY];
54
+ if (typeof raw !== "string" || raw.trim().length === 0)
55
+ return DEFAULT_PROBE_TIMEOUT_MS;
56
+ const parsed = Number(raw.trim());
57
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed))
58
+ return DEFAULT_PROBE_TIMEOUT_MS;
59
+ if (parsed < DENY_PROBE_TIMEOUT_MIN_MS || parsed > DENY_PROBE_TIMEOUT_MAX_MS) {
60
+ return DEFAULT_PROBE_TIMEOUT_MS;
61
+ }
62
+ return parsed;
63
+ }
64
+ /** True only for a result safe to reuse: genuinely enforced by a real layer. */
65
+ function isCacheableDenyResult(result) {
66
+ return result.enforced === true && result.layer !== "none";
67
+ }
68
+ /** Create a fresh, empty executor-scoped deny-probe cache. */
69
+ export function createDenyProbeCache() {
70
+ let cached;
71
+ return {
72
+ get: () => cached,
73
+ set: (result) => {
74
+ if (isCacheableDenyResult(result))
75
+ cached = result;
76
+ },
77
+ reset: () => {
78
+ cached = undefined;
79
+ },
80
+ };
81
+ }
19
82
  function normalizeVersion(stdout, stderr) {
20
83
  return (stdout || stderr || "").trim().slice(0, VERSION_DETAIL_MAX);
21
84
  }
@@ -61,11 +124,20 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
61
124
  catch {
62
125
  fatalFindings.push("claude could not be probed");
63
126
  }
64
- // --- Credentials for every configured repo ----------------------------
65
- const access = await resolveAllExecutorApiAccess(options.repos, deps);
66
- for (const a of access) {
67
- if (!a.ok)
68
- fatalFindings.push(`credential resolution failed for repo '${a.repoName}'`);
127
+ // --- Explicit base URL (fatal; never defaults to production, BAPI-676) ---
128
+ // Resolved before per-repo credentials so a missing URL reports ONCE rather
129
+ // than once per repo. The remaining probes still run and accumulate findings.
130
+ const baseUrlResult = resolveBaseUrl(deps.env, options.baseUrl);
131
+ if (!baseUrlResult.ok) {
132
+ fatalFindings.push(EXECUTOR_BASE_URL_REQUIRED_MESSAGE);
133
+ }
134
+ else {
135
+ // --- Credentials for every configured repo --------------------------
136
+ const access = await resolveAllExecutorApiAccess(options.repos, deps, baseUrlResult.baseUrl);
137
+ for (const a of access) {
138
+ if (!a.ok)
139
+ fatalFindings.push(`credential resolution failed for repo '${a.repoName}'`);
140
+ }
69
141
  }
70
142
  // --- Disk space (fatal when it cannot be verified) --------------------
71
143
  let diskFreeGb = null;
@@ -97,10 +169,26 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
97
169
  warnings.push("local `gh` is unavailable/unauthenticated; local merges would be degraded");
98
170
  }
99
171
  // --- Deny-layer enforcement (binding fatal contract) ------------------
172
+ // BAPI-722: only THIS probe is cached across claim cycles. Every probe above —
173
+ // tooling, credentials, disk, gh — still runs on each collection, because each of
174
+ // them can genuinely change between polls (a binary removed, a credential
175
+ // rotated, a disk filled). Deny-layer enforcement is a property of the machine's
176
+ // static settings; once verified it does not spontaneously unverify.
100
177
  let denyEnforced = false;
101
178
  try {
102
- const denyProbe = seams.runDenyPreflight ?? runDenyEnforcementPreflight;
103
- const deny = await denyProbe();
179
+ const cache = seams.bypassDenyProbeCache === true ? undefined : seams.denyProbeCache;
180
+ const cachedDeny = cache?.get();
181
+ let deny;
182
+ if (cachedDeny !== undefined) {
183
+ deny = cachedDeny;
184
+ }
185
+ else {
186
+ const denyProbe = seams.runDenyPreflight ?? runDenyEnforcementPreflight;
187
+ deny = await denyProbe({ timeoutMs: resolveDenyProbeTimeoutMs(deps.env) });
188
+ // `set` itself refuses anything that is not a genuine success, so a failure
189
+ // cannot become sticky even if this call site were later moved.
190
+ seams.denyProbeCache?.set(deny);
191
+ }
104
192
  denyEnforced = deny.enforced === true;
105
193
  if (!deny.enforced) {
106
194
  fatalFindings.push(`deny-layer enforcement not verified (enforced=false, layer=${deny.layer}); refusing to claim`);
@@ -114,6 +202,37 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
114
202
  catch {
115
203
  fatalFindings.push("deny-layer enforcement probe failed; refusing to claim");
116
204
  }
205
+ // --- Claude user-config MCP shadowing (fatal by default, BAPI-727) ----
206
+ // REFUSAL BY DEFAULT is the whole point. A `bridge-api` entry in the machine's
207
+ // ~/.claude.json was observed to win over a linked worktree's provisioned
208
+ // `.mcp.json`, so the worker silently talks to whatever endpoint that entry
209
+ // names instead of the one the executor provisioned — a production-integrity
210
+ // failure that produces no error, only wrong work. A warning-only check would
211
+ // preserve exactly that silence; the explicit
212
+ // BAPI_CONDUCTOR_ALLOW_CLAUDE_MCP_SHADOWING override is the deliberate operator
213
+ // escape hatch for the cases where the collision is known and intended.
214
+ //
215
+ // Inconclusive states (unreadable/malformed config) are warnings, never fatal:
216
+ // "could not look" must not become "found a collision".
217
+ try {
218
+ const inspect = seams.inspectClaudeUserConfig ?? inspectClaudeUserConfigForMcpShadowing;
219
+ const inspection = await inspect({
220
+ claudeConfigPath: resolveClaudeUserConfigPath(deps.homedir(), deps.platform),
221
+ platform: deps.platform,
222
+ cwd: deps.cwd,
223
+ // The executor's own checkout is the main repository. Preflight runs
224
+ // before any job, so there is no prepared worktree to name here — the
225
+ // per-job check in `job-runner.ts` covers that scope.
226
+ mainRepositoryPath: deps.cwd,
227
+ }, { readFile: deps.readFile });
228
+ const policy = evaluateClaudeMcpShadowingPolicy(inspection, deps.env);
229
+ warnings.push(...policy.warnings);
230
+ if (!policy.ok)
231
+ fatalFindings.push(...policy.refusals);
232
+ }
233
+ catch {
234
+ warnings.push("Claude user-config MCP shadowing check did not complete; could not verify worker MCP integrity");
235
+ }
117
236
  return {
118
237
  ok: fatalFindings.length === 0,
119
238
  fatalFindings,
@@ -26,6 +26,20 @@ const SUPPORTED_PROMPT_SPEC_VERSION = 1;
26
26
  const GIT_LOG_MAX_CHARS = 8_000;
27
27
  /** Explicit "no prior revise reasons" value — never an empty string. */
28
28
  const REVISE_REASONS_NONE = "(none — this is the first review of the ticket spec)";
29
+ /**
30
+ * Explicit "no related-ticket context" value (BAPI-699) — never an empty string.
31
+ *
32
+ * The server normally renders this section itself (availability header + one
33
+ * record block per related ticket), so this fallback only fires when a payload
34
+ * reaches a placeholder-aware executor without a `related_context` field at all
35
+ * — e.g. a job enqueued by an older server revision. Substituting an empty
36
+ * string there would hand the reviewer a blank section that reads exactly like
37
+ * "this ticket has no neighbors", so the fallback states the blindness instead.
38
+ */
39
+ const RELATED_CONTEXT_NONE = "(no related-ticket context was supplied with this job. This review is " +
40
+ "PARTIALLY BLIND to the surrounding tickets: do NOT conclude that the ticket " +
41
+ "has no related tickets, no dependencies, and no already-merged neighbors — " +
42
+ "that information was simply not provided.)";
29
43
  /** The `git log` argv used to resolve `{{GIT_LOG}}` (list-based, never shelled). */
30
44
  const GIT_LOG_ARGS = ["log", "--oneline", "--decorate", "--max-count=50"];
31
45
  /**
@@ -39,7 +53,21 @@ const SUPPORTED_PLACEHOLDERS = new Set([
39
53
  "WORK_BRANCH",
40
54
  "GIT_LOG",
41
55
  "REVISE_REASONS",
56
+ "RELATED_CONTEXT",
57
+ "SPEC_CITATION_FRESHNESS_WARNING",
42
58
  ]);
59
+ /**
60
+ * Explicit "no stale-citation advisory" value (BAPI-722) — never an empty string.
61
+ *
62
+ * Unlike `RELATED_CONTEXT`, absence here is the COMMON and entirely healthy case:
63
+ * most tickets have no predecessor that merged after their spec was written, so the
64
+ * server omits the field. The substituted text therefore states that plainly rather
65
+ * than warning about blindness — a reviewer must not read "no advisory" as "the
66
+ * server could not tell", which would push them toward re-verifying every citation
67
+ * on every review.
68
+ */
69
+ const SPEC_CITATION_FRESHNESS_NONE = "(none — no dependency of this ticket merged after this ticket's " +
70
+ "specification snapshot was taken.)";
43
71
  function promptContractError(message) {
44
72
  return new ExecutorNamedError(PROMPT_CONTRACT_ERROR, message);
45
73
  }
@@ -190,6 +218,29 @@ export async function buildPromptSpecPlaceholderValues(job, spec, context) {
190
218
  values.REVISE_REASONS = formatReviseReasons(payload.reasons);
191
219
  break;
192
220
  }
221
+ case "RELATED_CONTEXT": {
222
+ // The server renders this section (availability header, per-entry
223
+ // record blocks, its own bounded trim marker) and records the SHA-256
224
+ // of EXACTLY the text it rendered. So the value is substituted VERBATIM
225
+ // — no `.trim()`, no re-wrapping, no re-bounding — otherwise the
226
+ // recorded display hash would stop describing what the reviewer read.
227
+ values.RELATED_CONTEXT = isNonBlankString(payload.related_context)
228
+ ? payload.related_context
229
+ : RELATED_CONTEXT_NONE;
230
+ break;
231
+ }
232
+ case "SPEC_CITATION_FRESHNESS_WARNING": {
233
+ // OPTIONAL by contract (BAPI-722): an absent field is the normal case and
234
+ // resolves to the explicit "none" text, NEVER a thrown contract error and
235
+ // never an empty substitution. This deliberately does not weaken
236
+ // {{TICKET_SPEC}}, which stays required — a review with no spec is not a
237
+ // review, whereas a review with no advisory is simply the usual review.
238
+ // Substituted verbatim: the server composed this text and bounded it.
239
+ values.SPEC_CITATION_FRESHNESS_WARNING = isNonBlankString(payload.spec_citation_freshness_warning)
240
+ ? payload.spec_citation_freshness_warning
241
+ : SPEC_CITATION_FRESHNESS_NONE;
242
+ break;
243
+ }
193
244
  }
194
245
  }
195
246
  return values;
@@ -6,7 +6,7 @@
6
6
  * preflight finding. `once` mode performs one preflight/claim cycle and returns
7
7
  * after all active jobs finish. Depends on NO v1 conductor event/ledger module.
8
8
  */
9
- import { collectExecutorPreflight, buildClaimManifest } from "./preflight.js";
9
+ import { collectExecutorPreflight, buildClaimManifest, createDenyProbeCache, } from "./preflight.js";
10
10
  import { runClaimedJob } from "./job-runner.js";
11
11
  import { sweepExecutorWorktrees } from "./worktree-gc.js";
12
12
  /**
@@ -28,6 +28,19 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
28
28
  platform: deps.platform,
29
29
  }));
30
30
  const active = new Map();
31
+ // BAPI-722: ONE deny-probe cache per `runExecutor` invocation, created OUTSIDE
32
+ // the claim loop below — that scope is the whole feature. A cache created inside
33
+ // the loop would be discarded every cycle and re-probe exactly as before; a
34
+ // module-level cache would leak across invocations and survive a deliberate
35
+ // restart, which is precisely the escape hatch an operator fixing a broken deny
36
+ // layer needs. Process lifetime is the correct boundary.
37
+ //
38
+ // An injected `preflightSeams.denyProbeCache` (or `bypassDenyProbeCache`) wins,
39
+ // so a test can force deterministic fresh probes.
40
+ const preflightSeams = {
41
+ denyProbeCache: createDenyProbeCache(),
42
+ ...(seams.preflightSeams ?? {}),
43
+ };
31
44
  /**
32
45
  * Run the conservative worktree GC sweep ONLY while the runner owns no active
33
46
  * job worktree. Failures are logged to stderr and never abort claiming
@@ -60,7 +73,15 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
60
73
  active.set(job.id, promise);
61
74
  }
62
75
  for (;;) {
63
- const report = await collectPreflight(options, deps, seams.preflightSeams ?? {});
76
+ const report = await collectPreflight(options, deps, preflightSeams);
77
+ // BAPI-727: preflight warnings were previously collected but never emitted, so
78
+ // a non-fatal finding — an overridden MCP-shadowing collision, an unreadable
79
+ // ~/.claude.json — was invisible to the operator. Emit them before the claim
80
+ // decision; this only logs and never changes the existing `!report.ok`
81
+ // claim-skipping behavior below.
82
+ for (const warning of report.warnings) {
83
+ deps.errorLog(`executor preflight warning: ${warning}`);
84
+ }
64
85
  if (!report.ok) {
65
86
  deps.errorLog(`executor preflight refused claiming: ${report.fatalFindings.join("; ")}`);
66
87
  }