@bridge_gpt/mcp-server 0.2.36 → 0.2.38

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 (92) hide show
  1. package/README.md +48 -8
  2. package/build/base-url.js +79 -0
  3. package/build/bridge-api-urls.js +9 -0
  4. package/build/chain-orchestrator.js +93 -15
  5. package/build/claude-user-config-doctor.js +317 -0
  6. package/build/commands.generated.js +2 -1
  7. package/build/conductor/bridge-api-client.js +178 -4
  8. package/build/conductor-bin.js +1 -1
  9. package/build/conductor-bundle-artifacts.js +7 -6
  10. package/build/credential-store.js +205 -4
  11. package/build/direct-ticket-tools.js +70 -0
  12. package/build/doctor.js +239 -80
  13. package/build/executor/cli.js +51 -1
  14. package/build/executor/credentials.js +1 -7
  15. package/build/executor/deps.js +18 -1
  16. package/build/executor/env.js +51 -25
  17. package/build/executor/heartbeat.js +138 -17
  18. package/build/executor/http-client.js +49 -8
  19. package/build/executor/job-errors.js +4 -0
  20. package/build/executor/job-runner.js +422 -22
  21. package/build/executor/observation.js +130 -0
  22. package/build/executor/permissions.js +104 -8
  23. package/build/executor/preflight.js +32 -0
  24. package/build/executor/runner.js +8 -0
  25. package/build/executor/test-clock.js +67 -3
  26. package/build/executor/types.js +4 -1
  27. package/build/executor/worker-command.js +11 -3
  28. package/build/executor/worker-config-isolation.js +287 -0
  29. package/build/executor/worker-finalization.js +68 -14
  30. package/build/executor/worktree.js +46 -4
  31. package/build/index.js +614 -244
  32. package/build/init.js +363 -73
  33. package/build/install-bridge.js +568 -80
  34. package/build/launcher-config-inspection.js +351 -0
  35. package/build/mcp-invoke.js +49 -6
  36. package/build/mcp-provisioning.js +30 -7
  37. package/build/mcp-registration-doctor.js +14 -5
  38. package/build/notifications.js +553 -0
  39. package/build/pipeline-orchestrator.js +146 -4
  40. package/build/pipeline-utils.js +3 -0
  41. package/build/pipelines.generated.js +22 -9
  42. package/build/plan-execution-ledger.js +550 -0
  43. package/build/plan-phase-routing.js +272 -0
  44. package/build/plane/alembic-head.js +110 -0
  45. package/build/plane/build-freshness.js +167 -0
  46. package/build/plane/cli.js +480 -0
  47. package/build/plane/defaults.js +266 -0
  48. package/build/plane/manifest.js +377 -0
  49. package/build/plane/member-logs.js +147 -0
  50. package/build/plane/member-roster.js +147 -0
  51. package/build/plane/preflight.js +289 -0
  52. package/build/plane/shutdown.js +195 -0
  53. package/build/plane/status.js +125 -0
  54. package/build/plane/supervisor.js +569 -0
  55. package/build/plane/test-fakes.js +156 -0
  56. package/build/plane/types.js +75 -0
  57. package/build/readme.generated.js +1 -1
  58. package/build/run-unit-tests-launcher.js +2 -0
  59. package/build/setup-epic.js +662 -27
  60. package/build/sfcc/log-gate.js +38 -11
  61. package/build/sfcc/log-query.js +55 -15
  62. package/build/sfcc/ocapi-shape.js +70 -14
  63. package/build/sfcc/output.js +41 -11
  64. package/build/sfcc/permissions.js +24 -2
  65. package/build/sfcc/read-body.js +92 -0
  66. package/build/sfcc/read-projection.js +185 -0
  67. package/build/sfcc/read-result.js +158 -0
  68. package/build/sfcc/reads-custom-object-def.js +57 -34
  69. package/build/sfcc/reads-site-preference.js +86 -33
  70. package/build/sfcc/reads-system-object.js +50 -38
  71. package/build/sfcc/sfcc-result.js +106 -0
  72. package/build/sfcc/tool-wrapper.js +56 -13
  73. package/build/sfcc/write-grants.js +45 -22
  74. package/build/sfcc/write-guard.js +21 -13
  75. package/build/sfcc/write-result.js +71 -15
  76. package/build/sfcc/write-tool-common.js +126 -32
  77. package/build/sfcc/writes-custom-object-def.js +6 -2
  78. package/build/sfcc/writes-system-object.js +11 -50
  79. package/build/start-tickets-prereqs.js +129 -0
  80. package/build/start-tickets.js +17 -13
  81. package/build/ticket-backend-metadata.js +59 -0
  82. package/build/ticket-key-utils.js +92 -0
  83. package/build/tool-error-envelope.js +71 -0
  84. package/build/tool-surface-gating.js +72 -0
  85. package/build/update-status.js +102 -0
  86. package/build/upgrade-advice.js +47 -0
  87. package/build/upgrade-cli.js +417 -101
  88. package/build/version.generated.js +1 -1
  89. package/build/worktree-core.js +73 -0
  90. package/docs/CONDUCTOR.md +23 -8
  91. package/package.json +3 -3
  92. package/pipelines/implement-ticket.json +15 -5
@@ -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
  };
@@ -15,18 +15,30 @@
15
15
  * through the separately launched `mcp-invoke` shim process, which resolves the
16
16
  * credential itself — the model process never reads it directly.
17
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
+ *
18
26
  * The Wave-1 probe on the target machine reports enforcement layer `settings-deny`
19
27
  * (claude 2.1.201), so plain `permissions.deny` is provisioned; the PreToolUse
20
28
  * fallback is available for CLIs where settings-deny is not enforced.
21
29
  */
30
+ import path from "node:path";
22
31
  import { mergeClaudeSettingsWithCommandHook, provisionClaudeSettingsForWorktree, DEFAULT_PRE_TOOL_USE_MATCHER, } from "../claude-settings.js";
32
+ import { ISOLATION_DIR_PREFIX } from "./worker-config-isolation.js";
23
33
  /**
24
34
  * The stable executor deny rules. Grammar mirrors the deny-enforcement probe's
25
35
  * `Bash(<cmd>:<args-glob>)` / `Read(<path-glob>)` form. Covers: reads under
26
36
  * ~/.ssh, ~/.aws, and keychain paths; the user-scoped Bridge credential store
27
37
  * (BAPI-724 — the model is denied direct reads, while the separately launched
28
- * `mcp-invoke` shim process still resolves it); force-push to the base branch;
29
- * and destructive `rm -rf` outside the worktree.
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.
30
42
  */
31
43
  export function executorDenyRules(inputs) {
32
44
  return [
@@ -35,6 +47,10 @@ export function executorDenyRules(inputs) {
35
47
  "Read(~/Library/Keychains/**)",
36
48
  "Read(/etc/shadow)",
37
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}*/**)`,
38
54
  `Bash(git push:*--force*${inputs.baseBranch}*)`,
39
55
  "Bash(git push:*--force*)",
40
56
  "Bash(rm:*-rf /*)",
@@ -63,7 +79,80 @@ export function mergeExecutorDenySettings(existing, inputs) {
63
79
  return { ...existing, permissions };
64
80
  }
65
81
  /**
66
- * 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).
67
156
  * Fails open: a malformed existing file is preserved (not clobbered) and I/O
68
157
  * errors return a warning — the caller still spawns the worker.
69
158
  */
@@ -80,10 +169,17 @@ export async function provisionExecutorDenyLayer(worktreePath, options, deps) {
80
169
  }
81
170
  return merged;
82
171
  }, deps);
83
- 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)
84
183
  return { ok: true };
85
- return {
86
- ok: false,
87
- warning: `deny-layer provisioning did not complete (${result.reason}); continuing fail-open`,
88
- };
184
+ return { ok: false, warning: warnings.join("; ") };
89
185
  }
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { runDenyEnforcementPreflight } from "../conductor/deny-enforcement-preflight.js";
17
17
  import { DEFAULT_PROBE_TIMEOUT_MS } from "../agent-capabilities/types.js";
18
+ import { evaluateClaudeMcpShadowingPolicy, inspectClaudeUserConfigForMcpShadowing, resolveClaudeUserConfigPath, } from "../claude-user-config-doctor.js";
18
19
  import { resolveAllExecutorApiAccess, resolveBaseUrl, EXECUTOR_BASE_URL_REQUIRED_MESSAGE, } from "./credentials.js";
19
20
  const VERSION_DETAIL_MAX = 200;
20
21
  /**
@@ -201,6 +202,37 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
201
202
  catch {
202
203
  fatalFindings.push("deny-layer enforcement probe failed; refusing to claim");
203
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
+ }
204
236
  return {
205
237
  ok: fatalFindings.length === 0,
206
238
  fatalFindings,
@@ -74,6 +74,14 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
74
74
  }
75
75
  for (;;) {
76
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
+ }
77
85
  if (!report.ok) {
78
86
  deps.errorLog(`executor preflight refused claiming: ${report.fatalFindings.join("; ")}`);
79
87
  }
@@ -72,7 +72,15 @@ export function makeFakeExecutorDeps(clock, overrides = {}) {
72
72
  spawnProcess: () => {
73
73
  throw new Error("spawnProcess not configured for this test");
74
74
  },
75
- readFile: async () => {
75
+ readFile: async (filePath) => {
76
+ // BAPI-731: the prepared worktree's own `.mcp.json` must carry the required
77
+ // `bridge-api` registration or the runner refuses to spawn (an isolated
78
+ // worker has no operator-scoped registration to fall back to). Serve a
79
+ // minimal valid one so pre-existing spawn tests still reach the spawn they
80
+ // were written to assert on.
81
+ if (typeof filePath === "string" && filePath.endsWith(".mcp.json")) {
82
+ return JSON.stringify({ mcpServers: { "bridge-api": { command: "node", args: [] } } });
83
+ }
76
84
  // Mirror real fs/promises: a missing-file rejection carries code "ENOENT"
77
85
  // (so BAPI-664 command provisioning treats absent files as fillable).
78
86
  throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
@@ -81,11 +89,31 @@ export function makeFakeExecutorDeps(clock, overrides = {}) {
81
89
  mkdir: async () => undefined,
82
90
  stat: async () => ({ mode: 0o644 }),
83
91
  statfs: async () => ({ bavail: 1_000_000, bsize: 4096 }),
92
+ // BAPI-731 worker config isolation boundaries. In-memory fakes: no test ever
93
+ // creates, permissions, or removes a real directory. Isolation is
94
+ // fail-CLOSED, so a spawn-path test that did not supply these would refuse to
95
+ // spawn — these defaults keep the many pre-existing spawn tests exercising
96
+ // what they were written to exercise.
97
+ mkdtemp: async (prefix) => `${prefix}test`,
98
+ chmod: async () => { },
99
+ rmRecursive: async () => { },
100
+ readdir: async () => [],
101
+ lstatPath: async () => ({ isDirectory: true, isSymbolicLink: false, mtimeMs: 0 }),
102
+ tmpdir: () => "/tmp",
84
103
  sleep: clock.sleep,
85
104
  now: clock.now,
86
105
  setTimer: clock.setTimer,
87
106
  clearTimer: clock.clearTimer,
88
- env: {},
107
+ // `ANTHROPIC_API_KEY` is the isolation strategy's required credential source
108
+ // (see `worker-config-isolation.ts`); without it capability evaluation
109
+ // reports `unsupported-auth-layout` and no spawn test could run.
110
+ // `BAPI_API_KEY` is the seed credential for the isolated Bridge store
111
+ // (BAPI-740/F2); without it the runner refuses pre-spawn with
112
+ // `ContractError.BridgeCredentialUnavailable` and no spawn test could run.
113
+ env: {
114
+ ANTHROPIC_API_KEY: "test-key-not-a-real-credential",
115
+ BAPI_API_KEY: "test-bapi-key-not-a-real-credential",
116
+ },
89
117
  cwd: "/repo",
90
118
  platform: "linux",
91
119
  homedir: () => "/home/tester",
@@ -98,7 +126,43 @@ export function makeFakeExecutorDeps(clock, overrides = {}) {
98
126
  serverEntryPath: "/repo/mcp_server/build/index.js",
99
127
  },
100
128
  };
101
- return { ...base, ...overrides };
129
+ const merged = { ...base, ...overrides };
130
+ // BAPI-731: two fail-CLOSED spawn gates depend on ambient fixture state that
131
+ // predates them, so an override supplied for an unrelated reason would
132
+ // silently turn a spawn test into a refusal test. Both are re-applied here as
133
+ // FALLBACKS — an override that deliberately exercises a refusal still wins,
134
+ // because it either supplies its own key/registration or explicitly omits one.
135
+ // 1. Isolation needs an Anthropic key, and the Bridge-store seeding refusal
136
+ // (BAPI-740/F2) needs a resolvable `BAPI_API_KEY`. A test overriding `env`
137
+ // for some other purpose keeps both unless it sets the key itself — an
138
+ // override that deliberately exercises a refusal supplies its own value
139
+ // (empty string included: presence wins over the fallback).
140
+ if (overrides.env) {
141
+ const fallbacks = {};
142
+ for (const key of ["ANTHROPIC_API_KEY", "BAPI_API_KEY"]) {
143
+ if (!(key in overrides.env))
144
+ fallbacks[key] = base.env[key];
145
+ }
146
+ merged.env = { ...fallbacks, ...overrides.env };
147
+ }
148
+ // 2. The required-registration check reads the worktree's `.mcp.json`. A test
149
+ // overriding `readFile` (usually to script command provisioning) still gets
150
+ // the default registration for that one path when its own read rejects.
151
+ if (overrides.readFile) {
152
+ const override = overrides.readFile;
153
+ merged.readFile = async (filePath) => {
154
+ try {
155
+ return await override(filePath);
156
+ }
157
+ catch (err) {
158
+ if (typeof filePath === "string" && filePath.endsWith(".mcp.json")) {
159
+ return base.readFile(filePath);
160
+ }
161
+ throw err;
162
+ }
163
+ };
164
+ }
165
+ return merged;
102
166
  }
103
167
  /** Sensible default executor options for tests. */
104
168
  export function makeTestOptions(overrides = {}) {
@@ -1 +1,4 @@
1
- export {};
1
+ /** Normalize either heartbeat response form into the structured shape. */
2
+ export function normalizeHeartbeatResult(response) {
3
+ return typeof response === "string" ? { outcome: response } : response;
4
+ }
@@ -80,10 +80,18 @@ export function resolveExecutorPrompt(job) {
80
80
  // change addressing, and merge-conflict resolution (see .claude/commands/
81
81
  // implement-ticket.md "Clean session exit"). So the recovery jobs that operate
82
82
  // on the ticket's existing branch/PR — `remediate`/`ci_fix`/`rebase` — resume
83
- // through the same command, which detects the outstanding CI/review/conflict
84
- // state and continues rather than restarting. Without this, a code_review
83
+ // by RE-ENTERING THE COMPLETE RECIPE from step 1, not a distinct resume path.
84
+ // That re-entry is safe (BAPI-746), backed by re-entry handling already
85
+ // present in three of the recipe's own steps, not a new assumption: plan
86
+ // generation is idempotent because the server reuses a fresh existing plan
87
+ // instead of regenerating it (BAPI-745, `reused: true`); commit-and-push.md
88
+ // treats a clean tree with an already-pushed branch/PR as a legitimate
89
+ // re-entry rather than an empty implementation; and create-pr.md treats an
90
+ // already-open pull request on the head branch as satisfying its success
91
+ // condition. Without synthesizing this command, a code_review
85
92
  // `changes_requested` (near-certain in real runs) enqueues a `remediate` job
86
- // that fails `no usable prompt` and strands the run (BAPI-528 Milestone-A gap #3).
93
+ // that fails `no usable prompt` and strands the run (BAPI-528 Milestone-A gap
94
+ // #3).
87
95
  const ticketKey = typeof job.ticket_key === "string" && job.ticket_key.trim().length > 0
88
96
  ? job.ticket_key.trim()
89
97
  : null;