@bridge_gpt/mcp-server 0.2.36 → 0.2.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +44 -6
  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 +51 -14
  63. package/build/sfcc/output.js +41 -11
  64. package/build/sfcc/permissions.js +24 -2
  65. package/build/sfcc/read-projection.js +181 -0
  66. package/build/sfcc/read-result.js +158 -0
  67. package/build/sfcc/reads-custom-object-def.js +29 -18
  68. package/build/sfcc/reads-site-preference.js +75 -29
  69. package/build/sfcc/reads-system-object.js +40 -34
  70. package/build/sfcc/sfcc-result.js +106 -0
  71. package/build/sfcc/tool-wrapper.js +56 -13
  72. package/build/sfcc/write-grants.js +45 -22
  73. package/build/sfcc/write-guard.js +21 -13
  74. package/build/sfcc/write-result.js +61 -14
  75. package/build/sfcc/write-tool-common.js +126 -32
  76. package/build/sfcc/writes-system-object.js +11 -50
  77. package/build/start-tickets-prereqs.js +129 -0
  78. package/build/start-tickets.js +17 -13
  79. package/build/ticket-backend-metadata.js +59 -0
  80. package/build/ticket-key-utils.js +92 -0
  81. package/build/tool-error-envelope.js +71 -0
  82. package/build/tool-surface-gating.js +72 -0
  83. package/build/update-status.js +102 -0
  84. package/build/upgrade-advice.js +47 -0
  85. package/build/upgrade-cli.js +417 -101
  86. package/build/version.generated.js +1 -1
  87. package/build/worktree-core.js +73 -0
  88. package/docs/CONDUCTOR.md +23 -8
  89. package/package.json +3 -3
  90. package/pipelines/implement-ticket.json +15 -5
@@ -17,10 +17,11 @@
17
17
  import os from "node:os";
18
18
  import { buildExecutorWorkerEnv } from "./env.js";
19
19
  import { runHeartbeatLoop } from "./heartbeat.js";
20
- import { isExecutorNamedError, toExecutorFailure, secretFreeErrorMessage, MissingVerdictArtifact, } from "./job-errors.js";
20
+ import { isExecutorNamedError, toExecutorFailure, secretFreeErrorMessage, MissingVerdictArtifact, PreSpawnVerification, WorkerConfigIsolation, BridgeCredentialUnavailable, } from "./job-errors.js";
21
21
  import { buildConductorMergeAccessForExecutorJob, buildDefaultMergeLocalDeps, runExecutorMergeJob, MERGE_RETRYABLE, MERGE_FAILED, } from "./merge-job.js";
22
22
  import { createObservationState } from "./observation.js";
23
- import { provisionMcpRegistrationForWorktree } from "../mcp-provisioning.js";
23
+ import { getWorktreeMcpRegistrationTargets, pathApiForProvisioningPlatform, provisionMcpRegistrationForWorktree, } from "../mcp-provisioning.js";
24
+ import { evaluateClaudeMcpShadowingPolicy, inspectClaudeUserConfigForMcpShadowing, resolveClaudeUserConfigPath, } from "../claude-user-config-doctor.js";
24
25
  import { provisionExecutorDenyLayer } from "./permissions.js";
25
26
  import { runProcessWithTimeout } from "./process.js";
26
27
  import { prepareResumeSpawn } from "./resume-pre-spawn.js";
@@ -37,7 +38,9 @@ import { validateWorkerFinalization } from "./worker-finalization.js";
37
38
  import { ensureExecutorWorktree } from "./worktree.js";
38
39
  import { buildPrBaseContractLaunchInstruction } from "../pr-base-contract.js";
39
40
  import { buildClaudeExecutorArgv, CLAUDE_EXECUTABLE, resolveExecutorModelAlias, resolveExecutorPrompt, resolveWorkerPermissionPosture, } from "./worker-command.js";
40
- import { collectGitTelemetry } from "./observation.js";
41
+ import { collectGitTelemetry, collectRemoteTrackingSha, evaluatePreSpawnGitVerification, } from "./observation.js";
42
+ import { establishWorkerConfigIsolation, formatWorkerConfigIsolationReason, } from "./worker-config-isolation.js";
43
+ import { resolveBapiCredentials } from "../credential-store.js";
41
44
  /** Default runtime for the no-op smoke process (ms). */
42
45
  const DEFAULT_SMOKE_DURATION_MS = 100;
43
46
  /** ProcessClassification maps 1:1 onto the wire ExecutorClassification. */
@@ -92,8 +95,17 @@ function buildJobLogRegistryDeps(deps) {
92
95
  platform: deps.platform,
93
96
  };
94
97
  }
95
- /** Map executor deps into the shared MCP-provisioning filesystem boundary. */
96
- function buildExecutorMcpProvisioningDeps(deps) {
98
+ /**
99
+ * Map executor deps into the shared MCP-provisioning filesystem boundary.
100
+ *
101
+ * `bridgeApiBaseUrl` is an EXPLICIT argument, deliberately not read from
102
+ * `deps.env` (BAPI-727). The worker env allowlist strips `BAPI_BASE_URL`, and the
103
+ * executor's own endpoint may have come from `--base-url` rather than the
104
+ * environment at all — so the environment is not a trustworthy source here. The
105
+ * only correct value is the one the caller threads down from the validated
106
+ * runtime configuration.
107
+ */
108
+ function buildExecutorMcpProvisioningDeps(deps, bridgeApiBaseUrl) {
97
109
  return {
98
110
  readFile: deps.readFile,
99
111
  writeFile: deps.writeFile,
@@ -101,6 +113,7 @@ function buildExecutorMcpProvisioningDeps(deps) {
101
113
  platform: deps.platform,
102
114
  cwd: deps.cwd,
103
115
  mcpServerInvocation: deps.mcpServerInvocation,
116
+ bridgeApiBaseUrl,
104
117
  };
105
118
  }
106
119
  /**
@@ -117,8 +130,13 @@ function buildExecutorMcpProvisioningDeps(deps) {
117
130
  * secret-free diagnostic. Either way `runSpawnJob` proceeds to spawn the
118
131
  * worker — a job that later needs an MCP tool it cannot reach fails loudly
119
132
  * through its own process/gate/timeout/budget path, never here.
133
+ *
134
+ * BAPI-727 keeps that fail-open contract UNCONDITIONAL. The blocking Claude
135
+ * shadow-integrity check deliberately lives in `runSpawnJob` ABOVE this call, not
136
+ * inside it: the fixed `catch` below would otherwise convert a confirmed
137
+ * integrity refusal into a logged warning and spawn the worker anyway.
120
138
  */
121
- async function provisionMcpForPreparedSpawn(job, branch, worktreePath, deps, seams) {
139
+ async function provisionMcpForPreparedSpawn(job, branch, worktreePath, deps, seams, bridgeApiBaseUrl) {
122
140
  const provision = seams.provisionMcp ?? provisionMcpRegistrationForWorktree;
123
141
  const row = {
124
142
  key: job.ticket_key ?? String(job.id),
@@ -127,7 +145,7 @@ async function provisionMcpForPreparedSpawn(job, branch, worktreePath, deps, sea
127
145
  path: worktreePath,
128
146
  };
129
147
  try {
130
- const result = await provision(row, buildExecutorMcpProvisioningDeps(deps));
148
+ const result = await provision(row, buildExecutorMcpProvisioningDeps(deps, bridgeApiBaseUrl));
131
149
  for (const warning of result.warnings ?? []) {
132
150
  deps.errorLog(`MCP provisioning warning: ${warning}`);
133
151
  }
@@ -143,6 +161,167 @@ async function provisionMcpForPreparedSpawn(job, branch, worktreePath, deps, sea
143
161
  deps.errorLog("MCP provisioning did not complete; continuing fail-open");
144
162
  }
145
163
  }
164
+ /**
165
+ * Adapt `ExecutorDeps` into the isolation module's explicit required interface,
166
+ * or `null` when this executor was constructed without the filesystem
167
+ * boundaries isolation needs.
168
+ *
169
+ * Returning `null` (rather than substituting a default) is the fail-closed
170
+ * posture: the optional `ExecutorDeps` members exist so existing embedders and
171
+ * test builders stay source-compatible, NOT so a worker may spawn without
172
+ * isolation. The caller turns `null` into a refusal.
173
+ */
174
+ function buildWorkerConfigIsolationDeps(deps) {
175
+ const { mkdtemp, chmod, rmRecursive, lstatPath, tmpdir } = deps;
176
+ if (!mkdtemp || !chmod || !rmRecursive || !lstatPath || !tmpdir)
177
+ return null;
178
+ return {
179
+ platform: deps.platform,
180
+ env: deps.env,
181
+ tmpdir,
182
+ mkdtemp,
183
+ mkdir: deps.mkdir,
184
+ chmod,
185
+ rmRecursive,
186
+ lstatPath,
187
+ join: (...segments) => pathApiForProvisioningPlatform(deps.platform).join(...segments),
188
+ writeFile: deps.writeFile,
189
+ };
190
+ }
191
+ /**
192
+ * Resolve the repo-scoped Bridge credential to seed into the isolated store.
193
+ *
194
+ * Resolved through the SHARED resolver (`resolveBapiCredentials`) rather than by
195
+ * reading any file directly, per the repository's shell-spawned credential rule:
196
+ * env `BAPI_API_KEY` first, then the user-scoped `bapi:<repo>` store. It runs
197
+ * against the EXECUTOR's own deps/env — deliberately, because the executor is
198
+ * still outside the isolation boundary and is the only party that can still see
199
+ * the operator's store.
200
+ *
201
+ * Returns null when no credential can be resolved. `runSpawnJob` turns null into
202
+ * a pre-spawn `/fail` with `ContractError.BridgeCredentialUnavailable` — it never
203
+ * proceeds to establish isolation with an empty store, because every job type
204
+ * that reaches `runSpawnJob` needs Bridge MCP tools, and a worker without them
205
+ * exits cleanly having done nothing (RUN-FINDINGS-2026-08-05 F2). A resolution
206
+ * exception is swallowed into the same null so no raw error (which could carry a
207
+ * path or a secret) ever reaches a diagnostic; the refusal message is a fixed
208
+ * constant.
209
+ */
210
+ async function resolveSeedBridgeCredential(deps, repoName) {
211
+ const trimmed = typeof repoName === "string" ? repoName.trim() : "";
212
+ if (trimmed.length === 0)
213
+ return null;
214
+ try {
215
+ const resolved = await resolveBapiCredentials(trimmed, {
216
+ env: deps.env,
217
+ homedir: deps.homedir,
218
+ platform: deps.platform,
219
+ readFile: deps.readFile,
220
+ stat: deps.stat,
221
+ // Route the resolver's (secret-free) insecure-mode warning through the
222
+ // executor's own log seam instead of raw process stderr.
223
+ stderr: (message) => deps.errorLog(message),
224
+ });
225
+ if (!resolved.ok)
226
+ return null;
227
+ return { repoName: trimmed, apiKey: resolved.credentials.apiKey };
228
+ }
229
+ catch {
230
+ // Never let a credential-resolution error carry a secret into a diagnostic.
231
+ return null;
232
+ }
233
+ }
234
+ /**
235
+ * Fixed, secret-free refusal text for `ContractError.BridgeCredentialUnavailable`.
236
+ * Names both supported resolution routes and no value, path fragment, or raw
237
+ * error — this exact text is what `/fail` persists as `error_message`.
238
+ */
239
+ const BRIDGE_CREDENTIAL_UNAVAILABLE_MESSAGE = "no Bridge API credential could be resolved for this job's repository, so an isolated worker " +
240
+ "would run without Bridge MCP tools and do nothing; set BAPI_API_KEY in the executor's " +
241
+ "environment or add a bapi:<repo> entry to the user-scoped credential store " +
242
+ "(~/.config/bridge/credentials.json)";
243
+ /**
244
+ * Confirm the prepared worktree's own `.mcp.json` actually carries the Bridge
245
+ * shim registration the worker needs (BAPI-731 requirement, plan step 10).
246
+ *
247
+ * Read-only. Verifies PRESENCE of the `bridge-api` server name only — it does not
248
+ * filter, rewrite, or narrow the registered surface, so every other valid
249
+ * worktree-provisioned server (including supported Tier-2 targets) is preserved
250
+ * exactly as provisioned.
251
+ */
252
+ async function verifyRequiredWorktreeMcpRegistration(worktreePath, deps) {
253
+ const [target] = getWorktreeMcpRegistrationTargets(worktreePath, deps.platform);
254
+ if (!target) {
255
+ return { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
256
+ }
257
+ try {
258
+ const raw = await deps.readFile(target.filePath);
259
+ const parsed = JSON.parse(raw);
260
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
261
+ return { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
262
+ }
263
+ const servers = parsed[target.topLevelKey];
264
+ if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
265
+ return { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
266
+ }
267
+ const present = Object.prototype.hasOwnProperty.call(servers, REQUIRED_MCP_SERVER_NAME);
268
+ return present ? { ok: true } : { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
269
+ }
270
+ catch {
271
+ // Unreadable or malformed is indistinguishable from absent for this purpose,
272
+ // and both mean the worker cannot reach Bridge. The raw error never escapes.
273
+ return { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
274
+ }
275
+ }
276
+ /** The registration name the provisioner writes and a worker requires. */
277
+ const REQUIRED_MCP_SERVER_NAME = "bridge-api";
278
+ /** Fixed, secret-free refusal text (no path, no file contents). */
279
+ const REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE = "worker config isolation active but the required 'bridge-api' MCP registration is missing or " +
280
+ "unreadable in the prepared worktree; an isolated worker has no operator-scoped registration to " +
281
+ "fall back to, so refusing to spawn a worker that would run without Bridge tools";
282
+ /**
283
+ * The stable `error_kind` for a claimed job refused because the machine's Claude
284
+ * user configuration can shadow the worker's provisioned MCP registration
285
+ * (BAPI-727). It joins the established `ContractError.*` family every other
286
+ * pre-spawn refusal in `runSpawnJob` already posts — this is the same class of
287
+ * failure: a local precondition that makes the job unrunnable-as-specified, decided
288
+ * before any side effect. The message is fixed and secret-free.
289
+ */
290
+ export const MCP_SHADOWING_ERROR_KIND = "ContractError.McpShadowing";
291
+ /**
292
+ * Run the read-only Claude user-config inspection against a PREPARED worktree,
293
+ * covering all three scopes: user, the executor's main checkout (`deps.cwd`), and
294
+ * the concrete worktree this job will run in.
295
+ *
296
+ * Degraded outcomes (unreadable/malformed config) and an enabled override are
297
+ * logged and allowed through — the first is inconclusive, the second is a
298
+ * deliberate operator decision. Only a confirmed collision without the override
299
+ * blocks. An unexpected throw fails OPEN with a fixed diagnostic: the inspector is
300
+ * a guard, and a guard that crashes must not become a new way for jobs to die.
301
+ */
302
+ async function checkClaudeMcpShadowingForPreparedWorktree(worktreePath, deps, seams) {
303
+ try {
304
+ const inspect = seams.inspectClaudeUserConfig ?? inspectClaudeUserConfigForMcpShadowing;
305
+ const inspection = await inspect({
306
+ claudeConfigPath: resolveClaudeUserConfigPath(deps.homedir(), deps.platform),
307
+ platform: deps.platform,
308
+ cwd: deps.cwd,
309
+ mainRepositoryPath: deps.cwd,
310
+ worktreePath,
311
+ }, { readFile: deps.readFile });
312
+ const policy = evaluateClaudeMcpShadowingPolicy(inspection, deps.env);
313
+ for (const warning of policy.warnings) {
314
+ deps.errorLog(`Claude MCP shadowing: ${warning}`);
315
+ }
316
+ if (policy.ok)
317
+ return { blocked: false };
318
+ return { blocked: true, message: policy.refusals.join("; ") };
319
+ }
320
+ catch {
321
+ deps.errorLog("Claude MCP shadowing check did not complete; continuing without a shadowing verdict");
322
+ return { blocked: false };
323
+ }
324
+ }
146
325
  /**
147
326
  * Default merge dispatch: resolve Bridge API access, then run the deterministic
148
327
  * merge. Exported for unit tests (access-resolution failure → retryable). The
@@ -208,7 +387,15 @@ function createNoopProcess(deps, durationMs, message) {
208
387
  },
209
388
  };
210
389
  }
211
- /** Run the process and heartbeat loop concurrently; return the process outcome. */
390
+ /**
391
+ * Run the process and heartbeat loop concurrently; return the process outcome.
392
+ *
393
+ * BAPI-731: `serverStopRequested` is carried ALONGSIDE the process
394
+ * classification rather than folded into it. When the server cancels a run the
395
+ * worker is SIGKILLed, so its raw classification would read as `killed` — which
396
+ * the caller would otherwise report as a worker fault. Keeping the flag separate
397
+ * is what lets the caller report a deliberate server cancellation distinctly.
398
+ */
212
399
  async function superviseProcess(params) {
213
400
  const isDoneRef = { value: false };
214
401
  const hb = runHeartbeatLoop({
@@ -220,6 +407,7 @@ async function superviseProcess(params) {
220
407
  proc: params.proc,
221
408
  observation: params.observation,
222
409
  collectTelemetry: params.collectTelemetry,
410
+ collectRemoteMarker: params.collectRemoteMarker,
223
411
  isDone: () => isDoneRef.value,
224
412
  });
225
413
  const result = await runProcessWithTimeout(params.proc, params.timeoutSeconds, params.deps, {
@@ -227,9 +415,11 @@ async function superviseProcess(params) {
227
415
  onStdout: (chunk) => params.observation.recordStdout(chunk),
228
416
  });
229
417
  isDoneRef.value = true;
230
- await hb;
418
+ const loopOutcome = await hb;
231
419
  params.observation.setExitCode(result.exitCode);
232
- return result;
420
+ return loopOutcome.kind === "server_stop"
421
+ ? { ...result, serverStopRequested: true }
422
+ : result;
233
423
  }
234
424
  function terminalToRunResult(terminal, successStatus) {
235
425
  switch (terminal.outcome) {
@@ -266,7 +456,27 @@ export async function runClaimedJob(job, httpClient, options, deps, _report, sea
266
456
  // via the shared `isSpawnJobType` policy (`job-types.ts`) AFTER the dedicated
267
457
  // `merge` and `smoke` branches so those keep their bespoke handling.
268
458
  if (isSpawnJobType(job.job_type)) {
269
- return runSpawnJob(job, httpClient, options, deps, ownership, observation, seams);
459
+ // BAPI-731: the per-job isolated Claude config directory is created INSIDE
460
+ // `runSpawnJob` (it must not exist before the pre-spawn contract passes) but
461
+ // is disposed HERE, in a `finally` around the whole call. That placement is
462
+ // the lifetime guarantee: disposal cannot run until the worker process has
463
+ // terminated, the log tee is finalized, and the terminal report has been
464
+ // sent — while still covering every exit path, including spawn exceptions,
465
+ // timeouts, stale claims, dead-man abandonment, server stops, artifact
466
+ // failures, finalization failures, and terminal-report failures.
467
+ const isolationHolder = { current: null };
468
+ try {
469
+ return await runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, isolationHolder);
470
+ }
471
+ finally {
472
+ if (isolationHolder.current) {
473
+ // Best-effort by contract: a cleanup failure is logged, never allowed to
474
+ // replace the job outcome already determined above.
475
+ const diagnostic = await isolationHolder.current.dispose();
476
+ if (diagnostic)
477
+ deps.errorLog(diagnostic);
478
+ }
479
+ }
270
480
  }
271
481
  // Unsupported job type — fail server-side; never spawn.
272
482
  await httpClient.fail(job, {
@@ -599,7 +809,13 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
599
809
  }
600
810
  // Explicit/synthesized prompt wins verbatim, preserving existing behavior.
601
811
  if (explicit.ok) {
602
- return { ok: true, worktreePath: wt.worktreePath, branch: wt.branch, prompt: explicit.prompt };
812
+ return {
813
+ ok: true,
814
+ worktreePath: wt.worktreePath,
815
+ branch: wt.branch,
816
+ prompt: explicit.prompt,
817
+ expectedHeadSha: wt.expectedHeadSha,
818
+ };
603
819
  }
604
820
  // No explicit/synthesized prompt: render the structured `payload.prompt_spec`
605
821
  // against the prepared worktree (`smoke`, and any future structured-prompt job;
@@ -614,7 +830,13 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
614
830
  baseBranch: options.baseBranch,
615
831
  runCommand: deps.runCommand,
616
832
  });
617
- return { ok: true, worktreePath: wt.worktreePath, branch: wt.branch, prompt: rendered };
833
+ return {
834
+ ok: true,
835
+ worktreePath: wt.worktreePath,
836
+ branch: wt.branch,
837
+ prompt: rendered,
838
+ expectedHeadSha: wt.expectedHeadSha,
839
+ };
618
840
  }
619
841
  catch (err) {
620
842
  const failure = isExecutorNamedError(err)
@@ -629,14 +851,16 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
629
851
  }
630
852
  }
631
853
  /**
632
- * Final pre-spawn preparation order (BAPI-724): worktree + prompt preparation,
633
- * timeout-contract validation, the deny-layer attempt, the MCP registration +
854
+ * Final pre-spawn preparation order (BAPI-724, BAPI-727): worktree + prompt
855
+ * preparation (which itself performs reset-based untracked cleanup and command
856
+ * provisioning), timeout-contract validation, the BLOCKING Claude MCP
857
+ * shadow-integrity check, the deny-layer attempt, the MCP registration +
634
858
  * trust-approval attempt, worker-log/viewer setup, then `deps.spawnProcess`.
635
- * Failed preparation or an invalid timeout returns BEFORE either the deny
636
- * layer or MCP provisioning runs — neither ever touches a worktree that will
637
- * not become a runnable worker.
859
+ * Failed preparation, an invalid timeout, or a confirmed MCP-shadowing collision
860
+ * returns BEFORE either the deny layer or MCP provisioning runs — neither ever
861
+ * touches a worktree that will not become a runnable worker.
638
862
  */
639
- async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams) {
863
+ async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, isolationHolder) {
640
864
  // --- Per-job base branch (BAPI-586) ----------------------------------
641
865
  // Resolve the effective logical base BEFORE any side effect: the persisted
642
866
  // run base (`payload.base_branch`) is authoritative, falling back to the
@@ -698,6 +922,115 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
698
922
  });
699
923
  return { status: "failed", reason: "timeout_contract" };
700
924
  }
925
+ // --- Pre-spawn git verification (FAIL-CLOSED, BAPI-731 WS-E) ---------
926
+ // FIRST of the pre-spawn contracts, and deliberately BEFORE every
927
+ // executor-owned write to the worktree (deny layer, MCP provisioning, worker
928
+ // log). Placement is load-bearing twice over: those writes would themselves
929
+ // dirty the tree and make a later cleanliness assertion vacuous, and a job that
930
+ // cannot run correctly should not have had anything written for it.
931
+ //
932
+ // This is insurance against the F11a class — a `ci_fix` attempt that committed
933
+ // leftover unrelated work and burned a remediation attempt without touching the
934
+ // failing check. It does NOT replace or weaken the existing hard-reset /
935
+ // untracked-clean / head-verify behavior in `worktree-core.ts`; it is an
936
+ // additional independent check on top of them.
937
+ const preSpawnTelemetry = await collectGitTelemetry({ runCommand: deps.runCommand, now: deps.now }, worktreePath, effectiveBaseBranch);
938
+ const verification = evaluatePreSpawnGitVerification(preSpawnTelemetry, prep.expectedHeadSha);
939
+ if (!verification.ok) {
940
+ // Record the attempt boundary even for a refusal: an attempt that never
941
+ // spawned still started and ended at the observed commit, so the reconciler
942
+ // sees a machine-readable "went nowhere" rather than a silent gap.
943
+ if (verification.observedSha) {
944
+ observation.setAttemptStartSha(verification.observedSha);
945
+ observation.setAttemptEndSha(verification.observedSha);
946
+ }
947
+ observation.setGitTelemetry(preSpawnTelemetry);
948
+ await httpClient.fail(job, {
949
+ error_kind: PreSpawnVerification,
950
+ error_message: verification.message,
951
+ classification: "crashed",
952
+ ...commitResidue(preSpawnTelemetry),
953
+ telemetry: observation.snapshot(),
954
+ });
955
+ return { status: "failed", reason: "pre_spawn_verification" };
956
+ }
957
+ observation.setAttemptStartSha(verification.observedSha);
958
+ // --- Claude MCP shadow integrity (BLOCKING, BAPI-727) ----------------
959
+ // Runs against the CONCRETE prepared worktree, after preparation and timeout
960
+ // validation but BEFORE the deny layer, MCP provisioning, worker-log setup, and
961
+ // spawn. Placement is the point: a `projects["<this worktree>"]` entry in
962
+ // ~/.claude.json would silently win over the registration provisioned two steps
963
+ // below, so the job must be stopped before any of those side effects — not
964
+ // after them, and not inside `provisionMcpForPreparedSpawn()`, whose fail-open
965
+ // catch exists to swallow provisioning errors and would swallow this refusal too.
966
+ const shadow = await checkClaudeMcpShadowingForPreparedWorktree(worktreePath, deps, seams);
967
+ if (shadow.blocked) {
968
+ await httpClient.fail(job, {
969
+ error_kind: MCP_SHADOWING_ERROR_KIND,
970
+ error_message: shadow.message,
971
+ classification: "crashed",
972
+ });
973
+ return { status: "failed", reason: "mcp_shadowing_contract" };
974
+ }
975
+ // --- Worker config isolation (FAIL-CLOSED, BAPI-731 WS-A) ------------
976
+ // After the retained BAPI-727 shadowing refusal (which stays defense-in-depth
977
+ // for the named `bridge-api` collision) and before the deny layer, MCP
978
+ // provisioning, log setup, and spawn.
979
+ //
980
+ // FAIL-CLOSED, unlike the fail-OPEN provisioning steps immediately below. The
981
+ // whole point is that a worker must never fall back to the operator's
982
+ // configuration: that fallback is how a worker acquired a `dbhub` handle it was
983
+ // never granted (BAPI-715 finding F4). "Could not isolate" therefore means
984
+ // "do not run", not "run with less containment".
985
+ const isolationEstablish = seams.establishWorkerConfigIsolation ?? establishWorkerConfigIsolation;
986
+ const isolationDeps = buildWorkerConfigIsolationDeps(deps);
987
+ // BAPI-731 gap: the isolated XDG root is where the Bridge credential store is
988
+ // looked up, so it must carry this repo's entry or the worker loses every
989
+ // Bridge MCP tool. Resolved here, outside the boundary, and scoped to one repo.
990
+ const seedCredential = isolationDeps
991
+ ? await resolveSeedBridgeCredential(deps, job.repo_name)
992
+ : null;
993
+ // FAIL-CLOSED on an unresolvable credential (RUN-FINDINGS-2026-08-05 F2):
994
+ // establishing isolation with an EMPTY store would succeed, and the worker
995
+ // would spawn, find no `bapi:<repo>` entry under the redirected XDG root, lose
996
+ // every Bridge MCP tool, and exit cleanly having done nothing — the original
997
+ // F2 death, reported as success. Every job type that reaches `runSpawnJob`
998
+ // needs Bridge MCP (`smoke`/`merge` never come through here), so a distinct
999
+ // pre-spawn refusal breaks nothing legitimate and makes the cause queryable.
1000
+ if (isolationDeps && !seedCredential) {
1001
+ await httpClient.fail(job, {
1002
+ error_kind: BridgeCredentialUnavailable,
1003
+ error_message: BRIDGE_CREDENTIAL_UNAVAILABLE_MESSAGE,
1004
+ classification: "crashed",
1005
+ ...commitResidue(preSpawnTelemetry),
1006
+ telemetry: observation.snapshot(),
1007
+ });
1008
+ return { status: "failed", reason: "bridge_credential_unavailable" };
1009
+ }
1010
+ const isolationResult = isolationDeps
1011
+ ? await isolationEstablish(isolationDeps, {
1012
+ ...(seedCredential ? { bridgeCredential: seedCredential } : {}),
1013
+ })
1014
+ : {
1015
+ ok: false,
1016
+ platform: String(deps.platform),
1017
+ reason: "missing-filesystem-dependency",
1018
+ message: formatWorkerConfigIsolationReason(String(deps.platform), "missing-filesystem-dependency"),
1019
+ };
1020
+ if (!isolationResult.ok) {
1021
+ await httpClient.fail(job, {
1022
+ error_kind: WorkerConfigIsolation,
1023
+ error_message: isolationResult.message,
1024
+ classification: "crashed",
1025
+ ...commitResidue(preSpawnTelemetry),
1026
+ telemetry: observation.snapshot(),
1027
+ });
1028
+ return { status: "failed", reason: "worker_config_isolation" };
1029
+ }
1030
+ const isolation = isolationResult.isolation;
1031
+ // Hand the handle to the caller's `finally` so it is disposed on EVERY exit
1032
+ // path from here on, including ones that never reach the spawn below.
1033
+ isolationHolder.current = isolation;
701
1034
  // --- Deny layer (fail-open) ------------------------------------------
702
1035
  const deny = await provisionExecutorDenyLayer(worktreePath, { baseBranch: effectiveBaseBranch }, {
703
1036
  readFile: deps.readFile,
@@ -711,7 +1044,31 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
711
1044
  // Unconditional for every job type that reaches this shared spawn path
712
1045
  // (implement/resume/spec_review/remediate/ci_fix/rebase) — no job-type
713
1046
  // branching, so a future spawn job type inherits the same default access.
714
- await provisionMcpForPreparedSpawn(job, branch, worktreePath, deps, seams);
1047
+ // BAPI-727: the validated endpoint travels from executor bootstrap through the
1048
+ // runtime options to the generated registration, so the worker's MCP server
1049
+ // targets the same Bridge API this executor claimed the job from.
1050
+ await provisionMcpForPreparedSpawn(job, branch, worktreePath, deps, seams, options.resolvedBaseUrl);
1051
+ // --- Required Bridge MCP registration (FAIL-CLOSED under isolation) ---
1052
+ // Provisioning itself stays fail-OPEN: a skipped or malformed Tier-2 target is
1053
+ // a warning, and every valid worktree-provisioned server is kept. But the
1054
+ // REQUIRED `bridge-api` shim registration becomes fail-closed once isolation is
1055
+ // active, because the two changes compose into a new failure mode: before
1056
+ // isolation, a worker missing its worktree registration could still fall back
1057
+ // to an operator-scoped `bridge-api` entry and function; under isolation there
1058
+ // is no fallback at all, so it would run silently toolless and produce
1059
+ // confidently wrong work. Refusing is the only safe reading.
1060
+ const verifyRegistration = seams.verifyRequiredWorktreeMcpRegistration ?? verifyRequiredWorktreeMcpRegistration;
1061
+ const registration = await verifyRegistration(worktreePath, deps);
1062
+ if (!registration.ok) {
1063
+ await httpClient.fail(job, {
1064
+ error_kind: WorkerConfigIsolation,
1065
+ error_message: registration.message,
1066
+ classification: "crashed",
1067
+ ...commitResidue(preSpawnTelemetry),
1068
+ telemetry: observation.snapshot(),
1069
+ });
1070
+ return { status: "failed", reason: "worker_config_isolation" };
1071
+ }
715
1072
  // --- Worker log tee + watch registry + viewer tab (BAPI-535 §8) ------
716
1073
  const registryDeps = buildJobLogRegistryDeps(deps);
717
1074
  const markFinished = seams.markExecutorJobLogFinished ?? markExecutorJobLogFinished;
@@ -783,7 +1140,18 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
783
1140
  const argv = buildClaudeExecutorArgv(prompt, alias, permissionPosture);
784
1141
  // BAPI-586: inject the validated run base as BAPI_BASE_BRANCH so the worker
785
1142
  // can target it via `gh pr create --base "$BAPI_BASE_BRANCH"`.
786
- const env = buildExecutorWorkerEnv(deps.env, effectiveBaseBranch);
1143
+ // BAPI-731: the Claude/XDG paths come from the isolation handle established
1144
+ // above — never from `deps.env`, which is exactly what makes them
1145
+ // un-overridable by the operator's environment. The API key is read from the
1146
+ // executor's own environment and passed explicitly because isolation removes
1147
+ // the worker's previous (inherited-`HOME`) authentication path entirely; see
1148
+ // the note in `env.ts` and inventory finding 4.
1149
+ const env = buildExecutorWorkerEnv(deps.env, {
1150
+ effectiveBaseBranch,
1151
+ claudeConfigDir: isolation.claudeConfigDir,
1152
+ xdgConfigHome: isolation.xdgConfigHome,
1153
+ anthropicApiKey: deps.env.ANTHROPIC_API_KEY,
1154
+ });
787
1155
  let proc;
788
1156
  try {
789
1157
  proc = deps.spawnProcess(CLAUDE_EXECUTABLE, argv, { cwd: worktreePath, env });
@@ -807,6 +1175,10 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
807
1175
  }
808
1176
  : proc;
809
1177
  const collectTelemetry = () => collectGitTelemetry({ runCommand: deps.runCommand, now: deps.now }, worktreePath, effectiveBaseBranch);
1178
+ // BAPI-731 (WS-H): sample the LOCAL remote-tracking marker so a mid-job push
1179
+ // flushes telemetry promptly instead of waiting up to a full heartbeat
1180
+ // interval. Local ref read only — never a network call on this path.
1181
+ const collectRemoteMarker = () => collectRemoteTrackingSha({ runCommand: deps.runCommand }, worktreePath, branch);
810
1182
  const procResult = await superviseProcess({
811
1183
  job,
812
1184
  httpClient,
@@ -817,9 +1189,37 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
817
1189
  proc: supervisedProc,
818
1190
  timeoutSeconds: timeout.timeoutSeconds,
819
1191
  collectTelemetry,
1192
+ collectRemoteMarker,
820
1193
  });
821
1194
  // Final telemetry snapshot from the worktree.
822
- observation.setGitTelemetry(await collectTelemetry());
1195
+ const finalTelemetry = await collectTelemetry();
1196
+ observation.setGitTelemetry(finalTelemetry);
1197
+ // Only a SUCCESSFUL final HEAD read may close the attempt boundary. A degraded
1198
+ // read leaves `attempt_end_sha` absent rather than relabeling an older sample.
1199
+ if (typeof finalTelemetry.last_commit_sha === "string") {
1200
+ observation.setAttemptEndSha(finalTelemetry.last_commit_sha);
1201
+ }
1202
+ // --- Server-requested stop (BAPI-731, WS-B executor half) -------------
1203
+ // Handled BEFORE the abandonment check below, because the server stop is the
1204
+ // one abandonment path that still owes a terminal report: the job was
1205
+ // deliberately CANCELLED while the claim was still valid, so reporting it as a
1206
+ // worker crash (the process was SIGKILLed, so its raw classification is
1207
+ // `killed`) would be actively wrong. Ownership is marked abandoned only AFTER
1208
+ // the terminal mutation is attempted, so the report goes out under a live claim.
1209
+ if (procResult.serverStopRequested) {
1210
+ await closeWorkerLogTee(tee);
1211
+ await finalizeRegistry();
1212
+ await httpClient.fail(job, {
1213
+ error_kind: "ServerStopRequested",
1214
+ error_message: "the server requested this run stop; the owned worker was terminated",
1215
+ classification: "killed",
1216
+ ...commitResidue(finalTelemetry),
1217
+ telemetry: observation.snapshot(),
1218
+ });
1219
+ ownership.abandoned = true;
1220
+ ownership.abandonReason = "server_stop";
1221
+ return { status: "abandoned", reason: "server_stop" };
1222
+ }
823
1223
  // Finalize the log tee WITHOUT signaling the worker (it has already exited).
824
1224
  await closeWorkerLogTee(tee);
825
1225
  if (ownership.abandoned) {