@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
@@ -17,9 +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 { getWorktreeMcpRegistrationTargets, pathApiForProvisioningPlatform, provisionMcpRegistrationForWorktree, } from "../mcp-provisioning.js";
24
+ import { evaluateClaudeMcpShadowingPolicy, inspectClaudeUserConfigForMcpShadowing, resolveClaudeUserConfigPath, } from "../claude-user-config-doctor.js";
23
25
  import { provisionExecutorDenyLayer } from "./permissions.js";
24
26
  import { runProcessWithTimeout } from "./process.js";
25
27
  import { prepareResumeSpawn } from "./resume-pre-spawn.js";
@@ -35,8 +37,10 @@ import { resolveExecutorJobBaseBranch } from "./base-branch.js";
35
37
  import { validateWorkerFinalization } from "./worker-finalization.js";
36
38
  import { ensureExecutorWorktree } from "./worktree.js";
37
39
  import { buildPrBaseContractLaunchInstruction } from "../pr-base-contract.js";
38
- import { buildClaudeExecutorArgv, CLAUDE_EXECUTABLE, resolveExecutorModelAlias, resolveExecutorPrompt, } from "./worker-command.js";
39
- import { collectGitTelemetry } from "./observation.js";
40
+ import { buildClaudeExecutorArgv, CLAUDE_EXECUTABLE, resolveExecutorModelAlias, resolveExecutorPrompt, resolveWorkerPermissionPosture, } from "./worker-command.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";
40
44
  /** Default runtime for the no-op smoke process (ms). */
41
45
  const DEFAULT_SMOKE_DURATION_MS = 100;
42
46
  /** ProcessClassification maps 1:1 onto the wire ExecutorClassification. */
@@ -91,6 +95,233 @@ function buildJobLogRegistryDeps(deps) {
91
95
  platform: deps.platform,
92
96
  };
93
97
  }
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) {
109
+ return {
110
+ readFile: deps.readFile,
111
+ writeFile: deps.writeFile,
112
+ mkdir: deps.mkdir,
113
+ platform: deps.platform,
114
+ cwd: deps.cwd,
115
+ mcpServerInvocation: deps.mcpServerInvocation,
116
+ bridgeApiBaseUrl,
117
+ };
118
+ }
119
+ /**
120
+ * Provision secret-free Bridge MCP registration + Claude trust pre-approval
121
+ * (BAPI-724) into a prepared spawn worktree, for every job type that reaches
122
+ * the shared spawn path. Reuses the exact `start-tickets` provisioning
123
+ * machinery by constructing a synthetic `created` {@link SummaryRow} for this
124
+ * job/worktree — no registration serialization or second trust write happens
125
+ * here.
126
+ *
127
+ * Fail-open by construction: a provisioner warning or a `spawn-failed` result
128
+ * is logged via `deps.errorLog` and this function still returns normally, and
129
+ * an unexpected thrown exception is caught and reduced to a single fixed,
130
+ * secret-free diagnostic. Either way `runSpawnJob` proceeds to spawn the
131
+ * worker — a job that later needs an MCP tool it cannot reach fails loudly
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.
138
+ */
139
+ async function provisionMcpForPreparedSpawn(job, branch, worktreePath, deps, seams, bridgeApiBaseUrl) {
140
+ const provision = seams.provisionMcp ?? provisionMcpRegistrationForWorktree;
141
+ const row = {
142
+ key: job.ticket_key ?? String(job.id),
143
+ branch,
144
+ status: "created",
145
+ path: worktreePath,
146
+ };
147
+ try {
148
+ const result = await provision(row, buildExecutorMcpProvisioningDeps(deps, bridgeApiBaseUrl));
149
+ for (const warning of result.warnings ?? []) {
150
+ deps.errorLog(`MCP provisioning warning: ${warning}`);
151
+ }
152
+ if (result.status === "spawn-failed") {
153
+ deps.errorLog(`MCP provisioning did not complete (${result.error ?? "unknown reason"}); continuing fail-open`);
154
+ }
155
+ }
156
+ catch {
157
+ // Fail-open, secret-free: a fixed diagnostic only. Never interpolate the
158
+ // thrown exception, the worktree path, a command/argument, or credential
159
+ // material — this branch exists precisely so an unexpected provisioning
160
+ // failure can never leak anything into the executor's own logs.
161
+ deps.errorLog("MCP provisioning did not complete; continuing fail-open");
162
+ }
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
+ }
94
325
  /**
95
326
  * Default merge dispatch: resolve Bridge API access, then run the deterministic
96
327
  * merge. Exported for unit tests (access-resolution failure → retryable). The
@@ -156,7 +387,15 @@ function createNoopProcess(deps, durationMs, message) {
156
387
  },
157
388
  };
158
389
  }
159
- /** 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
+ */
160
399
  async function superviseProcess(params) {
161
400
  const isDoneRef = { value: false };
162
401
  const hb = runHeartbeatLoop({
@@ -168,6 +407,7 @@ async function superviseProcess(params) {
168
407
  proc: params.proc,
169
408
  observation: params.observation,
170
409
  collectTelemetry: params.collectTelemetry,
410
+ collectRemoteMarker: params.collectRemoteMarker,
171
411
  isDone: () => isDoneRef.value,
172
412
  });
173
413
  const result = await runProcessWithTimeout(params.proc, params.timeoutSeconds, params.deps, {
@@ -175,9 +415,11 @@ async function superviseProcess(params) {
175
415
  onStdout: (chunk) => params.observation.recordStdout(chunk),
176
416
  });
177
417
  isDoneRef.value = true;
178
- await hb;
418
+ const loopOutcome = await hb;
179
419
  params.observation.setExitCode(result.exitCode);
180
- return result;
420
+ return loopOutcome.kind === "server_stop"
421
+ ? { ...result, serverStopRequested: true }
422
+ : result;
181
423
  }
182
424
  function terminalToRunResult(terminal, successStatus) {
183
425
  switch (terminal.outcome) {
@@ -214,7 +456,27 @@ export async function runClaimedJob(job, httpClient, options, deps, _report, sea
214
456
  // via the shared `isSpawnJobType` policy (`job-types.ts`) AFTER the dedicated
215
457
  // `merge` and `smoke` branches so those keep their bespoke handling.
216
458
  if (isSpawnJobType(job.job_type)) {
217
- 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
+ }
218
480
  }
219
481
  // Unsupported job type — fail server-side; never spawn.
220
482
  await httpClient.fail(job, {
@@ -401,6 +663,19 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
401
663
  });
402
664
  return terminalToRunResult(terminal, "failed");
403
665
  }
666
+ /**
667
+ * BAPI-724 design note (deliberate, not an accidental AC-5 gap): `smoke` stays
668
+ * on this no-worktree, no-real-worker-LLM `createNoopProcess` path and does
669
+ * NOT receive MCP registration. The ratified requirement anchors MCP
670
+ * provisioning to "the executor's worktree-preparation path
671
+ * (`job-runner.ts`), alongside the existing `provisionExecutorDenyLayer` call"
672
+ * — that call, like the new MCP provisioning call, exists ONLY inside
673
+ * `runSpawnJob`, which `smoke` never reaches (see `isSpawnJobType` /
674
+ * `job-types.ts`, and this module's own "no real worker LLM" framing of the
675
+ * acceptance spine). Converting smoke into a worktree-having, worker-spawning
676
+ * job to give it something to register MCP into would be a materially larger,
677
+ * unratified change to that architectural boundary, not a wiring task.
678
+ */
404
679
  async function runSmokeJob(job, httpClient, options, deps, ownership, observation) {
405
680
  const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
406
681
  if (!timeout.ok) {
@@ -488,10 +763,18 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
488
763
  }
489
764
  }
490
765
  // Resolve an explicit `payload.prompt` or the `/implement-ticket <KEY> --auto`
491
- // synthesis (implement + recovery). `spec_review` matches neither — it is
492
- // resolved AFTER worktree preparation from its structured `payload.prompt_spec`
493
- // (rendering `{{GIT_LOG}}` needs the prepared worktree), so a non-ok result
494
- // here is not yet a failure when a `prompt_spec` is present to render.
766
+ // synthesis (implement + recovery).
767
+ //
768
+ // BAPI-725: `spec_review` now takes the explicit-prompt path like any other job
769
+ // the reconciler builds its prompt server-side and ships it in
770
+ // `payload.prompt`, so it resolves here and returns below without ever reaching
771
+ // the prompt-spec renderer. A stale `payload.prompt_spec` on an in-flight job
772
+ // enqueued before this change cannot override that: the explicit prompt wins
773
+ // unconditionally at the `explicit.ok` return.
774
+ //
775
+ // The renderer below is NOT dead. `smoke` and any future structured-prompt job
776
+ // still reach it, and rendering `{{GIT_LOG}}` is why it must run after worktree
777
+ // preparation rather than in `resolveExecutorPrompt`.
495
778
  const explicit = resolveExecutorPrompt(job);
496
779
  const payload = job.payload && typeof job.payload === "object"
497
780
  ? job.payload
@@ -526,12 +809,19 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
526
809
  }
527
810
  // Explicit/synthesized prompt wins verbatim, preserving existing behavior.
528
811
  if (explicit.ok) {
529
- 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
+ };
530
819
  }
531
820
  // No explicit/synthesized prompt: render the structured `payload.prompt_spec`
532
- // against the prepared worktree (the `spec_review` path). A malformed spec
533
- // fails loud with `ContractError.Prompt` so we `/fail` rather than spawn a
534
- // worker with an empty prompt. Error messages are secret-free by construction.
821
+ // against the prepared worktree (`smoke`, and any future structured-prompt job;
822
+ // `spec_review` left this path in BAPI-725). A malformed spec fails loud with
823
+ // `ContractError.Prompt` so we `/fail` rather than spawn a worker with an empty
824
+ // prompt. Error messages are secret-free by construction.
535
825
  const renderPrompt = seams.renderPromptSpecPrompt ?? renderPromptSpecPrompt;
536
826
  try {
537
827
  const rendered = await renderPrompt(job, {
@@ -540,7 +830,13 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
540
830
  baseBranch: options.baseBranch,
541
831
  runCommand: deps.runCommand,
542
832
  });
543
- 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
+ };
544
840
  }
545
841
  catch (err) {
546
842
  const failure = isExecutorNamedError(err)
@@ -554,7 +850,17 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
554
850
  return { ok: false, result: { status: "failed", reason: "prompt_contract" } };
555
851
  }
556
852
  }
557
- async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams) {
853
+ /**
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 +
858
+ * trust-approval attempt, worker-log/viewer setup, then `deps.spawnProcess`.
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.
862
+ */
863
+ async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, isolationHolder) {
558
864
  // --- Per-job base branch (BAPI-586) ----------------------------------
559
865
  // Resolve the effective logical base BEFORE any side effect: the persisted
560
866
  // run base (`payload.base_branch`) is authoritative, falling back to the
@@ -574,6 +880,26 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
574
880
  }
575
881
  const effectiveBaseBranch = baseResolution.baseBranch;
576
882
  const jobOptions = { ...options, baseBranch: effectiveBaseBranch };
883
+ // --- Permission posture (BAPI-725) -----------------------------------
884
+ // Resolved alongside the base branch, BEFORE any side effect, for the same
885
+ // reason: an unsupported posture is a contract failure, and failing it here
886
+ // means no worktree is cut, no deny layer is written, and no process is
887
+ // spawned for a job that can never run correctly. The error text names only
888
+ // the field — the supplied value never reaches `/fail`.
889
+ let permissionPosture;
890
+ try {
891
+ permissionPosture = resolveWorkerPermissionPosture(job.payload);
892
+ }
893
+ catch (err) {
894
+ await httpClient.fail(job, isExecutorNamedError(err)
895
+ ? toExecutorFailure(err)
896
+ : {
897
+ error_kind: "ContractError.PermissionPosture",
898
+ error_message: secretFreeErrorMessage(err),
899
+ classification: "crashed",
900
+ });
901
+ return { status: "failed", reason: "permission_posture_contract" };
902
+ }
577
903
  // --- Worktree + prompt (resume pre-spawn protocol or standard) -------
578
904
  const prep = await prepareSpawn(job, httpClient, jobOptions, deps, seams);
579
905
  if (!prep.ok)
@@ -596,6 +922,115 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
596
922
  });
597
923
  return { status: "failed", reason: "timeout_contract" };
598
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;
599
1034
  // --- Deny layer (fail-open) ------------------------------------------
600
1035
  const deny = await provisionExecutorDenyLayer(worktreePath, { baseBranch: effectiveBaseBranch }, {
601
1036
  readFile: deps.readFile,
@@ -605,6 +1040,35 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
605
1040
  });
606
1041
  if (!deny.ok)
607
1042
  deps.errorLog(deny.warning);
1043
+ // --- MCP registration + trust pre-approval (fail-open, BAPI-724) -----
1044
+ // Unconditional for every job type that reaches this shared spawn path
1045
+ // (implement/resume/spec_review/remediate/ci_fix/rebase) — no job-type
1046
+ // branching, so a future spawn job type inherits the same default access.
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
+ }
608
1072
  // --- Worker log tee + watch registry + viewer tab (BAPI-535 §8) ------
609
1073
  const registryDeps = buildJobLogRegistryDeps(deps);
610
1074
  const markFinished = seams.markExecutorJobLogFinished ?? markExecutorJobLogFinished;
@@ -670,10 +1134,24 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
670
1134
  };
671
1135
  // --- Spawn -----------------------------------------------------------
672
1136
  const alias = resolveExecutorModelAlias(job.payload);
673
- const argv = buildClaudeExecutorArgv(prompt, alias);
1137
+ // BAPI-725: the posture resolved at the top of this function — after the deny
1138
+ // layer has already been provisioned above, which is what keeps `acceptEdits`
1139
+ // from being a relaxation of what the worker may do.
1140
+ const argv = buildClaudeExecutorArgv(prompt, alias, permissionPosture);
674
1141
  // BAPI-586: inject the validated run base as BAPI_BASE_BRANCH so the worker
675
1142
  // can target it via `gh pr create --base "$BAPI_BASE_BRANCH"`.
676
- 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
+ });
677
1155
  let proc;
678
1156
  try {
679
1157
  proc = deps.spawnProcess(CLAUDE_EXECUTABLE, argv, { cwd: worktreePath, env });
@@ -697,6 +1175,10 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
697
1175
  }
698
1176
  : proc;
699
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);
700
1182
  const procResult = await superviseProcess({
701
1183
  job,
702
1184
  httpClient,
@@ -707,9 +1189,37 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
707
1189
  proc: supervisedProc,
708
1190
  timeoutSeconds: timeout.timeoutSeconds,
709
1191
  collectTelemetry,
1192
+ collectRemoteMarker,
710
1193
  });
711
1194
  // Final telemetry snapshot from the worktree.
712
- 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
+ }
713
1223
  // Finalize the log tee WITHOUT signaling the worker (it has already exited).
714
1224
  await closeWorkerLogTee(tee);
715
1225
  if (ownership.abandoned) {