@bridge_gpt/mcp-server 0.2.37 → 0.2.39

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 (91) hide show
  1. package/README.md +193 -16
  2. package/build/agent-capabilities/probe-context.js +2 -1
  3. package/build/agent-launchers/claude-executor-adapter.js +392 -0
  4. package/build/agent-launchers/executor-adapter-inspection.js +163 -0
  5. package/build/agent-launchers/executor-adapter-registry.js +90 -0
  6. package/build/agent-launchers/executor-adapter.js +136 -0
  7. package/build/agent-registry.js +28 -0
  8. package/build/agents.generated.js +1 -1
  9. package/build/claude-login.js +85 -0
  10. package/build/claude-user-config-doctor.js +59 -33
  11. package/build/commands.generated.js +12 -11
  12. package/build/conduct-epic/bridge-client.js +345 -0
  13. package/build/conduct-epic/checkpoint-store.js +423 -0
  14. package/build/conduct-epic/cli.js +1732 -0
  15. package/build/conduct-epic/lock.js +302 -0
  16. package/build/conduct-epic/pr-state.js +197 -0
  17. package/build/conduct-epic/spawn.js +101 -0
  18. package/build/conductor/bridge-api-client.js +37 -2
  19. package/build/conductor/doctor.js +11 -1
  20. package/build/conductor/install-doctor.js +184 -10
  21. package/build/conductor-bin.js +7 -7
  22. package/build/credential-store.js +10 -4
  23. package/build/credentials-cli.js +34 -19
  24. package/build/docs.generated.js +1 -1
  25. package/build/doctor.js +579 -88
  26. package/build/executor/agent-identity.js +32 -0
  27. package/build/executor/cli.js +50 -39
  28. package/build/executor/deps.js +15 -1
  29. package/build/executor/env.js +56 -45
  30. package/build/executor/index.js +9 -1
  31. package/build/executor/install-preflight.js +138 -0
  32. package/build/executor/job-errors.js +200 -0
  33. package/build/executor/job-runner.js +619 -268
  34. package/build/executor/observation.js +165 -0
  35. package/build/executor/permissions.js +163 -36
  36. package/build/executor/platform.js +54 -0
  37. package/build/executor/preflight.js +175 -67
  38. package/build/executor/process.js +39 -7
  39. package/build/executor/runner.js +19 -0
  40. package/build/executor/service-lifecycle.js +269 -0
  41. package/build/executor/service-unit.js +121 -12
  42. package/build/executor/stale-artifacts.js +70 -0
  43. package/build/executor/test-clock.js +188 -24
  44. package/build/executor/worker-command.js +22 -58
  45. package/build/executor/worker-log.js +82 -0
  46. package/build/executor/worktree-lock.js +264 -0
  47. package/build/index.js +527 -357
  48. package/build/install-bridge-conductor.js +376 -38
  49. package/build/install-bridge.js +414 -114
  50. package/build/install-doctor.js +13 -0
  51. package/build/install-reexec.js +5 -3
  52. package/build/mcp-install-state.js +130 -0
  53. package/build/mcp-profile.js +11 -2
  54. package/build/mcp-provisioning.js +15 -0
  55. package/build/merge-pull-request.js +562 -0
  56. package/build/phase-result-artifacts.js +450 -0
  57. package/build/pipeline-orchestrator.js +4 -0
  58. package/build/pipeline-utils.js +16 -0
  59. package/build/pipelines.generated.js +7 -7
  60. package/build/plane/preflight.js +18 -14
  61. package/build/plane/supervisor.js +8 -1
  62. package/build/project-root.js +34 -0
  63. package/build/readme.generated.js +1 -1
  64. package/build/run-unit-tests-launcher.js +36 -9
  65. package/build/setup-epic.js +57 -4
  66. package/build/sfcc/ocapi-shape.js +23 -4
  67. package/build/sfcc/permissions.js +25 -6
  68. package/build/sfcc/read-body.js +92 -0
  69. package/build/sfcc/read-projection.js +6 -2
  70. package/build/sfcc/reads-custom-object-def.js +33 -21
  71. package/build/sfcc/reads-site-preference.js +20 -7
  72. package/build/sfcc/reads-system-object.js +11 -5
  73. package/build/sfcc/register.js +61 -23
  74. package/build/sfcc/registration-inventory.js +89 -0
  75. package/build/sfcc/setup-status.js +18 -34
  76. package/build/sfcc/tool-wrapper.js +294 -17
  77. package/build/sfcc/write-grants.js +33 -1
  78. package/build/sfcc/write-guard.js +41 -12
  79. package/build/sfcc/write-result.js +16 -7
  80. package/build/sfcc/writes-custom-object-def.js +12 -4
  81. package/build/sfcc/writes-site-preference.js +6 -1
  82. package/build/sfcc/writes-system-object.js +11 -2
  83. package/build/sfcc/writes.js +13 -8
  84. package/build/start-tickets-prereqs.js +25 -15
  85. package/build/start-tickets.js +123 -21
  86. package/build/version.generated.js +1 -1
  87. package/build/worktree-core.js +9 -3
  88. package/docs/install/mcp-tool-integrations.md +54 -9
  89. package/docs/install/sfcc-integration.md +71 -24
  90. package/package.json +3 -3
  91. package/build/executor/worker-config-isolation.js +0 -287
@@ -14,22 +14,25 @@
14
14
  * `spec_review`, plus per-job worker-log tee, watch registry, and optional
15
15
  * read-only viewer tabs wired around every spawned worker.
16
16
  */
17
+ import { rm } from "node:fs/promises";
17
18
  import os from "node:os";
18
- import { buildExecutorWorkerEnv } from "./env.js";
19
19
  import { runHeartbeatLoop } from "./heartbeat.js";
20
- import { isExecutorNamedError, toExecutorFailure, secretFreeErrorMessage, MissingVerdictArtifact, PreSpawnVerification, WorkerConfigIsolation, BridgeCredentialUnavailable, } from "./job-errors.js";
20
+ import { isExecutorNamedError, toExecutorFailure, secretFreeErrorMessage, MissingVerdictArtifact, PreSpawnVerification, RequiredMcpRegistration, McpSurfaceMismatch, WorkerStartupFatal, WORKER_STARTUP_FATAL_MESSAGE, formatMcpSurfaceMismatch, ClaudeNotAuthenticated, CLAUDE_NOT_AUTHENTICATED_MESSAGE, ExecutorAdapterUnavailable, ExecutorNamedError, WorktreeBusy, StaleArtifactCleanupFailed, WORKTREE_BUSY_MESSAGE, } 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";
25
- import { provisionExecutorDenyLayer } from "./permissions.js";
23
+ import { getWorktreeMcpRegistrationTargets, normalizeWorktreePathForRegistration, provisionMcpRegistrationForWorktree, } from "../mcp-provisioning.js";
24
+ import { resolveExecutorAgentAdapter } from "../agent-launchers/executor-adapter-registry.js";
25
+ import { redactAdapterDiagnostic } from "../agent-launchers/executor-adapter.js";
26
+ import { resolveExecutorAgentId } from "./agent-identity.js";
26
27
  import { runProcessWithTimeout } from "./process.js";
27
28
  import { prepareResumeSpawn } from "./resume-pre-spawn.js";
28
29
  import { renderPromptSpecPrompt } from "./prompt-spec.js";
29
30
  import { buildGenericSuccessResult, buildSmokeResult, commitResidue, readCompletionArtifacts, resolveJobTimeoutSeconds, } from "./results.js";
30
31
  import { sendTerminalMutationWithRetry } from "./terminal-mutation.js";
32
+ import { removeStaleVerdictArtifacts } from "./stale-artifacts.js";
31
33
  import { isVerdictJobType, readVerdictArtifact } from "./verdict-artifact.js";
32
- import { createWorkerLogTee, closeWorkerLogTee, teeAsyncIterable, } from "./worker-log.js";
34
+ import { createWorkerLogTee, closeWorkerLogTee, distinctWorkerLogSessionIds, formatWorkerLogSessionInvariantWarning, teeAsyncIterable, } from "./worker-log.js";
35
+ import { acquireExecutorWorktreeLock, } from "./worktree-lock.js";
33
36
  import { registerExecutorJobLog, markExecutorJobLogFinished, } from "./job-log-registry.js";
34
37
  import { executorViewerTabsEnabled, openExecutorViewerTab } from "./viewer-tabs.js";
35
38
  import { isImplementationStyleJobType, isRecoveryJobType, isSpawnJobType } from "./job-types.js";
@@ -37,10 +40,8 @@ import { resolveExecutorJobBaseBranch } from "./base-branch.js";
37
40
  import { validateWorkerFinalization } from "./worker-finalization.js";
38
41
  import { ensureExecutorWorktree } from "./worktree.js";
39
42
  import { buildPrBaseContractLaunchInstruction } from "../pr-base-contract.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";
43
+ import { resolveExecutorPrompt, resolveWorkerPermissionPosture, } from "./worker-command.js";
44
+ import { collectGitTelemetry, collectRemoteTrackingSha, createMcpSurfaceObserver, evaluatePreSpawnGitVerification, normalizeMcpServerNames, } from "./observation.js";
44
45
  /** Default runtime for the no-op smoke process (ms). */
45
46
  const DEFAULT_SMOKE_DURATION_MS = 100;
46
47
  /** ProcessClassification maps 1:1 onto the wire ExecutorClassification. */
@@ -136,6 +137,71 @@ function buildExecutorMcpProvisioningDeps(deps, bridgeApiBaseUrl) {
136
137
  * inside it: the fixed `catch` below would otherwise convert a confirmed
137
138
  * integrity refusal into a logged warning and spawn the worker anyway.
138
139
  */
140
+ /**
141
+ * Bind the worktree lock's filesystem boundaries to the injected executor deps
142
+ * (BAPI-793), so the lock never reaches for a filesystem global.
143
+ *
144
+ * `writeFileExclusive` and `removeFile` are OPTIONAL on `ExecutorDeps` for
145
+ * source-compatibility with the many existing test deps builders. Omitting them
146
+ * does NOT disable the lock: the seam is simply left unset and
147
+ * `acquireExecutorWorktreeLock` falls back to real `node:fs/promises`, which is
148
+ * the safe direction — a missing seam takes a real lock rather than skipping it.
149
+ * `isProcessAlive` is deliberately NOT injected here; the real
150
+ * `process.kill(pid, 0)` probe is correct in production and tests drive it
151
+ * through the `acquireWorktreeLock` seam instead.
152
+ */
153
+ function buildWorktreeLockSeams(deps) {
154
+ return {
155
+ ...(deps.writeFileExclusive ? { writeFileExclusive: deps.writeFileExclusive } : {}),
156
+ ...(deps.removeFile ? { removeFile: deps.removeFile } : {}),
157
+ readFile: deps.readFile,
158
+ mkdir: deps.mkdir,
159
+ platform: deps.platform,
160
+ };
161
+ }
162
+ /**
163
+ * The stderr diagnostic for a refused spawn (BAPI-793 proposed fix #4).
164
+ *
165
+ * Bounded identity ONLY: this job, the conflicting job, the ticket, the repo,
166
+ * and the worktree's BASENAME. Never the claim token (a fencing credential),
167
+ * never the lock payload, and never the absolute worktree path (it carries an
168
+ * operator's home directory). An unattributable holder is reported as such
169
+ * rather than guessed at.
170
+ */
171
+ function formatWorktreeBusyDiagnostic(job, worktreePath, owner) {
172
+ const segments = worktreePath.split(/[\\/]+/).filter((s) => s.length > 0);
173
+ const worktreeName = segments.length > 0 ? segments[segments.length - 1] : "unknown";
174
+ const holder = owner === null
175
+ ? "an unreadable or malformed lock (refused fail-closed)"
176
+ : `job_id=${owner.jobId} executor_id=${owner.executorId} pid=${owner.ownerPid}`;
177
+ return ("executor refused to spawn: worktree already held. " +
178
+ `job_id=${job.id} job_type=${job.job_type} repo_name=${job.repo_name} ` +
179
+ `ticket_key=${job.ticket_key ?? "none"} worktree=${worktreeName} held_by=${holder}`);
180
+ }
181
+ /**
182
+ * Read back a finished job's worker log and warn when more than one distinct
183
+ * Claude session wrote to it (BAPI-793 acceptance criterion 3).
184
+ *
185
+ * Fail-open in every direction: no tee, an unreadable log, or a log with zero
186
+ * parseable sessions all produce nothing. The only reportable state is a
187
+ * POSITIVE observation of two or more distinct sessions, which cannot happen by
188
+ * accident.
189
+ */
190
+ async function reportWorkerLogSessionInvariant(tee, deps) {
191
+ if (!tee)
192
+ return;
193
+ let logText;
194
+ try {
195
+ logText = await deps.readFile(tee.logPath);
196
+ }
197
+ catch {
198
+ return;
199
+ }
200
+ const sessions = distinctWorkerLogSessionIds(logText);
201
+ if (sessions.length > 1) {
202
+ deps.errorLog(formatWorkerLogSessionInvariantWarning(sessions.length));
203
+ }
204
+ }
139
205
  async function provisionMcpForPreparedSpawn(job, branch, worktreePath, deps, seams, bridgeApiBaseUrl) {
140
206
  const provision = seams.provisionMcp ?? provisionMcpRegistrationForWorktree;
141
207
  const row = {
@@ -162,95 +228,39 @@ async function provisionMcpForPreparedSpawn(job, branch, worktreePath, deps, sea
162
228
  }
163
229
  }
164
230
  /**
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.
231
+ * Confirm the prepared worktree's own `.mcp.json` carries the Bridge shim
232
+ * registration the worker needs, and capture the spawn contract from it
233
+ * (BAPI-731 requirement; extended by BAPI-790).
168
234
  *
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.
235
+ * Read-only. Verifies PRESENCE of the ADAPTER-DECLARED required server name only
236
+ * (BAPI-781; `bridge-api` for Claude) it does not filter, rewrite, or narrow
237
+ * the registered surface, so every other valid worktree-provisioned server
238
+ * (including supported Tier-2 targets) is preserved exactly as provisioned AND
239
+ * carried into the expected set. Narrowing the expected set to the required name
240
+ * would make the assertion fail every spawn on a repository with Tier-2 targets
241
+ * provisioned.
193
242
  *
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.
243
+ * The required name is a PARAMETER rather than a module constant because it is a
244
+ * per-agent fact: a future adapter provisions and requires whatever its own
245
+ * runtime registers. Both halves of the contract still come from ONE parse of
246
+ * ONE file, which is what keeps the `--mcp-config` path and the asserted
247
+ * expected set describing the same file.
200
248
  *
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.
249
+ * Only server NAMES are read. The command, args, and env of each registration are
250
+ * never retained, so nothing that could carry a secret leaves this function.
209
251
  */
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;
252
+ export async function verifyRequiredWorktreeMcpRegistration(worktreePath, deps, requiredServerName = REQUIRED_MCP_SERVER_NAME) {
253
+ // Normalize through the SAME path semantics the provisioner used to write the
254
+ // file, so the absolute path handed to `--mcp-config` is the one that actually
255
+ // exists on disk rather than a differently-spelled equivalent.
256
+ const normalized = normalizeWorktreePathForRegistration(worktreePath, {
257
+ platform: deps.platform,
258
+ cwd: deps.cwd,
259
+ });
260
+ if (!normalized.ok) {
261
+ return { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
232
262
  }
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);
263
+ const [target] = getWorktreeMcpRegistrationTargets(normalized.path, deps.platform);
254
264
  if (!target) {
255
265
  return { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
256
266
  }
@@ -264,8 +274,14 @@ async function verifyRequiredWorktreeMcpRegistration(worktreePath, deps) {
264
274
  if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
265
275
  return { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
266
276
  }
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 };
277
+ const names = normalizeMcpServerNames(Object.keys(servers));
278
+ if (!names.includes(requiredServerName)) {
279
+ return { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
280
+ }
281
+ return {
282
+ ok: true,
283
+ contract: { configPath: target.filePath, expectedServerNames: names },
284
+ };
269
285
  }
270
286
  catch {
271
287
  // Unreadable or malformed is indistinguishable from absent for this purpose,
@@ -273,53 +289,95 @@ async function verifyRequiredWorktreeMcpRegistration(worktreePath, deps) {
273
289
  return { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
274
290
  }
275
291
  }
276
- /** The registration name the provisioner writes and a worker requires. */
292
+ /**
293
+ * Default required registration name, retained only as the parameter default so
294
+ * existing direct callers keep working. The SPAWN PATH always passes the
295
+ * adapter's declared name explicitly — it never relies on this default.
296
+ */
277
297
  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
298
  /**
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.
299
+ * Refuse a spawn shape that is not internally consistent (BAPI-781).
300
+ *
301
+ * The checks are deliberately structural rather than agent-aware: a non-empty
302
+ * executable, a non-empty argv of strings, and a plain string-valued env. The
303
+ * runner cannot know what a correct argv looks like for an arbitrary agent —
304
+ * that is the adapter's job but it CAN refuse a shape that could not spawn
305
+ * anything sane, and refusing is the only safe response. The alternative is
306
+ * repairing it from `process.env` or hard-coded flags, which would resurrect
307
+ * exactly the Claude-shaped defaulting this ticket removes.
308
+ *
309
+ * Also verifies the executable matches the adapter's DECLARED executable, so an
310
+ * invocation capability cannot return a different binary than the one preflight
311
+ * probed and doctor reported.
289
312
  */
290
- export const MCP_SHADOWING_ERROR_KIND = "ContractError.McpShadowing";
313
+ function assertUsableSpawnShape(shape, adapter) {
314
+ const fail = (detail) => {
315
+ throw new ExecutorNamedError(ExecutorAdapterUnavailable, detail);
316
+ };
317
+ if (typeof shape?.executable !== "string" || shape.executable.trim().length === 0) {
318
+ fail("executor adapter returned an empty executable; refusing to spawn");
319
+ }
320
+ if (!Array.isArray(shape.argv) || shape.argv.length === 0) {
321
+ fail("executor adapter returned an empty argv; refusing to spawn");
322
+ }
323
+ if (shape.argv.some((entry) => typeof entry !== "string")) {
324
+ fail("executor adapter returned a non-string argv entry; refusing to spawn");
325
+ }
326
+ if (!shape.env || typeof shape.env !== "object" || Array.isArray(shape.env)) {
327
+ fail("executor adapter returned no worker environment; refusing to spawn");
328
+ }
329
+ if (Object.values(shape.env).some((value) => typeof value !== "string")) {
330
+ fail("executor adapter returned a non-string environment value; refusing to spawn");
331
+ }
332
+ const declared = adapter.executable;
333
+ if (declared?.supported === true && shape.executable !== declared.value.executable) {
334
+ fail("executor adapter returned an executable inconsistent with its declared executable; " +
335
+ "refusing to spawn a binary that preflight never probed");
336
+ }
337
+ }
338
+ /** Fixed, secret-free refusal text (no path, no file contents). */
339
+ const REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE = "the required 'bridge-api' MCP registration is missing or unreadable in the prepared " +
340
+ "worktree; a worker loads MCP servers strictly from that file, so refusing to spawn a " +
341
+ "worker that would run without Bridge tools";
291
342
  /**
292
- * Run the read-only Claude user-config inspection against a PREPARED worktree,
343
+ * Run the ADAPTER's advisory MCP inspection against a PREPARED worktree,
293
344
  * covering all three scopes: user, the executor's main checkout (`deps.cwd`), and
294
345
  * the concrete worktree this job will run in.
295
346
  *
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.
347
+ * ADVISORY ONLY (BAPI-790). This used to be able to refuse a job with
348
+ * `ContractError.McpShadowing`, because a same-named operator registration could
349
+ * genuinely win over the worktree's provisioned `.mcp.json` and point a worker at
350
+ * the wrong endpoint. It cannot now: the worker is spawned with
351
+ * `--strict-mcp-config`, so no operator-scoped registration is loaded at any
352
+ * scope, and the per-spawn init-event assertion verifies the loaded surface
353
+ * rather than trusting that. Every outcome — a confirmed collision, a degraded
354
+ * read, or an unexpected throw — is logged and the job continues.
355
+ *
356
+ * BAPI-781 routes it through the adapter, so the runner no longer imports
357
+ * Claude's user-config inspector. An adapter that declares no advisory
358
+ * capability simply produces no warnings; that is not a failure, because the
359
+ * capability is optional by design.
360
+ *
361
+ * Returns nothing on purpose: there is no verdict left for a caller to branch on,
362
+ * and a boolean nobody may act on invites someone to start acting on it again.
301
363
  */
302
- async function checkClaudeMcpShadowingForPreparedWorktree(worktreePath, deps, seams) {
364
+ async function checkAdapterMcpAdvisoryForPreparedWorktree(worktreePath, deps, adapter) {
365
+ const advisory = adapter.advisoryMcpInspection;
366
+ if (advisory?.supported !== true)
367
+ return;
303
368
  try {
304
- const inspect = seams.inspectClaudeUserConfig ?? inspectClaudeUserConfigForMcpShadowing;
305
- const inspection = await inspect({
306
- claudeConfigPath: resolveClaudeUserConfigPath(deps.homedir(), deps.platform),
369
+ const result = await advisory.value.inspect({
307
370
  platform: deps.platform,
371
+ homedir: deps.homedir(),
308
372
  cwd: deps.cwd,
309
- mainRepositoryPath: deps.cwd,
310
373
  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}`);
374
+ });
375
+ for (const warning of result.warnings) {
376
+ deps.errorLog(`worker MCP advisory: ${warning}`);
315
377
  }
316
- if (policy.ok)
317
- return { blocked: false };
318
- return { blocked: true, message: policy.refusals.join("; ") };
319
378
  }
320
379
  catch {
321
- deps.errorLog("Claude MCP shadowing check did not complete; continuing without a shadowing verdict");
322
- return { blocked: false };
380
+ deps.errorLog("worker MCP advisory inspection did not complete; continuing without an advisory verdict");
323
381
  }
324
382
  }
325
383
  /**
@@ -410,9 +468,18 @@ async function superviseProcess(params) {
410
468
  collectRemoteMarker: params.collectRemoteMarker,
411
469
  isDone: () => isDoneRef.value,
412
470
  });
471
+ const mcpSurface = params.mcpSurface;
413
472
  const result = await runProcessWithTimeout(params.proc, params.timeoutSeconds, params.deps, {
414
473
  termGraceMs: params.options.termGraceMs,
415
474
  onStdout: (chunk) => params.observation.recordStdout(chunk),
475
+ ...(mcpSurface
476
+ ? {
477
+ onStdoutTerminationCheck: (chunk) => {
478
+ mcpSurface.observeStdout(chunk);
479
+ return mcpSurface.shouldTerminate();
480
+ },
481
+ }
482
+ : {}),
416
483
  });
417
484
  isDoneRef.value = true;
418
485
  const loopOutcome = await hb;
@@ -456,27 +523,14 @@ export async function runClaimedJob(job, httpClient, options, deps, _report, sea
456
523
  // via the shared `isSpawnJobType` policy (`job-types.ts`) AFTER the dedicated
457
524
  // `merge` and `smoke` branches so those keep their bespoke handling.
458
525
  if (isSpawnJobType(job.job_type)) {
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
- }
526
+ // BAPI-790: no per-job configuration directory is created any more, so there
527
+ // is nothing to dispose and no `finally` wrapper here. Worker containment is
528
+ // established by argv (`--strict-mcp-config --mcp-config <worktree>/.mcp.json`)
529
+ // and verified from the worker's own init event inside `runSpawnJob`. Both
530
+ // are stateless with respect to the host, which is why the whole
531
+ // establish/dispose lifetime this branch used to manage simply stopped
532
+ // existing rather than moving somewhere else.
533
+ return await runSpawnJob(job, httpClient, options, deps, ownership, observation, seams);
480
534
  }
481
535
  // Unsupported job type — fail server-side; never spawn.
482
536
  await httpClient.fail(job, {
@@ -668,7 +722,8 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
668
722
  * on this no-worktree, no-real-worker-LLM `createNoopProcess` path and does
669
723
  * NOT receive MCP registration. The ratified requirement anchors MCP
670
724
  * provisioning to "the executor's worktree-preparation path
671
- * (`job-runner.ts`), alongside the existing `provisionExecutorDenyLayer` call"
725
+ * (`job-runner.ts`), alongside the existing deny-layer provisioning call"
726
+ * (which BAPI-781 moved behind the adapter's `provisionWorktreeDenyLayer` hook)
672
727
  * — that call, like the new MCP provisioning call, exists ONLY inside
673
728
  * `runSpawnJob`, which `smoke` never reaches (see `isSpawnJobType` /
674
729
  * `job-types.ts`, and this module's own "no real worker LLM" framing of the
@@ -860,7 +915,7 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
860
915
  * returns BEFORE either the deny layer or MCP provisioning runs — neither ever
861
916
  * touches a worktree that will not become a runnable worker.
862
917
  */
863
- async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, isolationHolder) {
918
+ async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams) {
864
919
  // --- Per-job base branch (BAPI-586) ----------------------------------
865
920
  // Resolve the effective logical base BEFORE any side effect: the persisted
866
921
  // run base (`payload.base_branch`) is authoritative, falling back to the
@@ -869,6 +924,42 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
869
924
  // job-scoped options carry that base through worktree seeding, prompt
870
925
  // rendering, deny layer, telemetry, env, and finalization so they cannot
871
926
  // disagree about the base.
927
+ // --- Executor adapter (FAIL-CLOSED, FIRST, BAPI-781) ------------------
928
+ // Resolved and fully validated BEFORE base-branch resolution, worktree
929
+ // creation, deny provisioning, MCP reads, and the spawn — i.e. before any side
930
+ // effect at all. Preflight already gates claiming on the same resolution, so
931
+ // reaching here with an unusable adapter means something changed underneath a
932
+ // running executor. That is exactly when a defaulting fallback would be most
933
+ // damaging: it would spawn a real worker under containment nobody declared,
934
+ // and every assertion downstream would check the default's shape and pass.
935
+ const agentId = resolveExecutorAgentId(options.agentId);
936
+ const resolveAdapter = seams.resolveAdapter ??
937
+ ((id) => resolveExecutorAgentAdapter(id, {
938
+ // The adapter owns deny provisioning and advisory inspection, but it
939
+ // owns no I/O boundary of its own — every read/write it performs is one
940
+ // the runner handed it. That keeps the runner's injected filesystem the
941
+ // single boundary a test has to fake, and keeps an adapter from quietly
942
+ // acquiring filesystem access the executor did not grant.
943
+ claude: {
944
+ readFile: deps.readFile,
945
+ denyProvisioningDeps: {
946
+ readFile: deps.readFile,
947
+ writeFile: deps.writeFile,
948
+ mkdir: deps.mkdir,
949
+ homedir: deps.homedir,
950
+ },
951
+ },
952
+ }));
953
+ const adapterResolution = resolveAdapter(agentId);
954
+ if (!adapterResolution.supported) {
955
+ await httpClient.fail(job, {
956
+ error_kind: ExecutorAdapterUnavailable,
957
+ error_message: adapterResolution.message,
958
+ classification: "crashed",
959
+ });
960
+ return { status: "failed", reason: "executor_adapter_unavailable" };
961
+ }
962
+ const adapter = adapterResolution.adapter;
872
963
  const baseResolution = resolveExecutorJobBaseBranch(job, options.baseBranch);
873
964
  if (!baseResolution.ok) {
874
965
  await httpClient.fail(job, {
@@ -905,6 +996,111 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
905
996
  if (!prep.ok)
906
997
  return prep.result;
907
998
  const { worktreePath, branch } = prep;
999
+ // --- Worktree mutual exclusion (FAIL-CLOSED, BAPI-793) ----------------
1000
+ // The FIRST thing that happens once a canonical worktree path exists, and
1001
+ // ahead of every executor-owned write to it: the deny layer, MCP
1002
+ // provisioning, the `worker.log` truncation, the viewer tab, and the spawn
1003
+ // itself all live inside the lock. `worker.log` in particular is created with
1004
+ // an empty write, so a refused contender that got as far as the tee would
1005
+ // erase the running worker's forensic log without ever spawning — which is
1006
+ // why refusal returns from HERE rather than anywhere downstream.
1007
+ //
1008
+ // Known residual window, stated rather than papered over: `prepareSpawn`
1009
+ // above already ran `ensureWorktree`, which hard-resets and cleans the tree.
1010
+ // Its path comes back FROM Worktrunk, so there is no path to lock before it
1011
+ // without duplicating Worktrunk's layout rules here — a copy that would go
1012
+ // wrong silently. Dispatch exclusion and same-ticket claim serialization
1013
+ // (the Python half of this ticket) are what shrink that window; this lock is
1014
+ // what makes everything after it safe.
1015
+ const acquireLock = seams.acquireWorktreeLock ?? acquireExecutorWorktreeLock;
1016
+ const worktreeLock = await acquireLock({
1017
+ worktreePath,
1018
+ jobId: job.id,
1019
+ claimToken: job.claim_token,
1020
+ executorId: options.executorId,
1021
+ ownerPid: process.pid,
1022
+ acquiredAt: nowIso(deps),
1023
+ }, buildWorktreeLockSeams(deps));
1024
+ if (!worktreeLock.acquired) {
1025
+ // Loud and structured, on OUR stderr, with the identity the fixed
1026
+ // server-bound message deliberately omits (BAPI-793 proposed fix #4).
1027
+ deps.errorLog(formatWorktreeBusyDiagnostic(job, worktreePath, worktreeLock.owner));
1028
+ await httpClient.fail(job, {
1029
+ error_kind: WorktreeBusy,
1030
+ error_message: WORKTREE_BUSY_MESSAGE,
1031
+ classification: "crashed",
1032
+ });
1033
+ return { status: "failed", reason: "worktree_busy" };
1034
+ }
1035
+ try {
1036
+ // --- Stale verdict artifacts (BAPI-814/D1) --------------------------
1037
+ // Inside the lock and BEFORE the worker spawns, on the SHARED attempt path,
1038
+ // so every job type routed through `runSpawnJob` is covered — `implement`,
1039
+ // `resume`, `spec_review`, and the recovery jobs. (`smoke` is deliberately
1040
+ // NOT here: it has its own branch, never acquires a worktree lock, and
1041
+ // synthesizes its verdict in process rather than reading one from disk.)
1042
+ //
1043
+ // `.conductor/` is an ignored path, so worktree preparation's `git clean -fd`
1044
+ // does not remove these files. Without this, a retry whose worker exits `0`
1045
+ // without writing a verdict would have the PREVIOUS attempt's `result.json`
1046
+ // read and posted verbatim as this attempt's answer.
1047
+ //
1048
+ // A failure here stops the attempt rather than continuing: spawning past a
1049
+ // cleanup that did not happen is the precise state being prevented. The
1050
+ // reason is bounded (a filename plus an error code) and reaches the operator
1051
+ // through the executor's normal structured failure path.
1052
+ const cleanupArtifacts = seams.removeStaleVerdictArtifacts ?? removeStaleVerdictArtifacts;
1053
+ const cleaned = await cleanupArtifacts(worktreePath, {
1054
+ // Same fallback direction as the worktree lock: a deps builder that omits
1055
+ // the optional seam gets the real removal, never a silent skip.
1056
+ removeFile: deps.removeFile ?? ((filePath) => rm(filePath, { force: true })),
1057
+ platform: deps.platform,
1058
+ });
1059
+ if (!cleaned.ok) {
1060
+ deps.errorLog(`[executor] job ${job.id}: pre-spawn artifact cleanup failed: ${cleaned.reason}`);
1061
+ await httpClient.fail(job, {
1062
+ error_kind: StaleArtifactCleanupFailed,
1063
+ error_message: cleaned.reason,
1064
+ classification: "crashed",
1065
+ });
1066
+ return { status: "failed", reason: "stale_artifact_cleanup" };
1067
+ }
1068
+ return await runPreparedSpawn({
1069
+ job,
1070
+ httpClient,
1071
+ options,
1072
+ jobOptions,
1073
+ deps,
1074
+ ownership,
1075
+ observation,
1076
+ seams,
1077
+ worktreePath,
1078
+ branch,
1079
+ prep,
1080
+ effectiveBaseBranch,
1081
+ permissionPosture,
1082
+ adapter,
1083
+ });
1084
+ }
1085
+ finally {
1086
+ // Released only here — after supervision has settled and every terminal
1087
+ // mutation above has been attempted — so the worktree is never advertised as
1088
+ // free while this job's worker could still be touching it.
1089
+ await worktreeLock.release();
1090
+ }
1091
+ }
1092
+ /**
1093
+ * Run a spawn job whose worktree is already prepared AND exclusively locked.
1094
+ *
1095
+ * Split out of {@link runSpawnJob} for exactly one reason: every terminal branch
1096
+ * below — contract refusal, spawn exception, timeout, stale claim, server stop,
1097
+ * MCP surface mismatch, startup fatal, authentication failure, verdict-artifact
1098
+ * failure, finalization failure, clean completion — must run inside the lock's
1099
+ * `try/finally`. Expressing that as a function boundary makes it structural
1100
+ * rather than a 500-line indentation nobody can verify by eye.
1101
+ */
1102
+ async function runPreparedSpawn(params) {
1103
+ const { job, httpClient, options, jobOptions, deps, ownership, observation, seams, worktreePath, branch, prep, effectiveBaseBranch, permissionPosture, adapter, } = params;
908
1104
  // BAPI-586: PR-producing spawn jobs (implement/resume/remediate/ci_fix/rebase)
909
1105
  // are told to open the PR against the injected run base. Verdict-only
910
1106
  // `spec_review` produces no PR, so it is excluded from the instruction while
@@ -955,91 +1151,39 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
955
1151
  return { status: "failed", reason: "pre_spawn_verification" };
956
1152
  }
957
1153
  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(),
1154
+ // --- Claude MCP shadow advisory (NON-BLOCKING, BAPI-727 -> BAPI-790) -
1155
+ // Still inspected, no longer blocking. Under BAPI-727 a
1156
+ // `projects["<this worktree>"]` entry in ~/.claude.json could silently win
1157
+ // over the registration provisioned two steps below, so the job had to stop
1158
+ // before any side effect. Strict flag-scoped loading removes that possibility
1159
+ // at the mechanism level, and the init-event assertion after the spawn proves
1160
+ // it per spawn rather than assuming it — so the collision is logged with its
1161
+ // remediation and the job continues.
1162
+ await checkAdapterMcpAdvisoryForPreparedWorktree(worktreePath, deps, adapter);
1163
+ // --- Deny layer (fail-open, via the adapter hook, BAPI-781) -----------
1164
+ // Provisioning moved behind the adapter because WHICH file carries the deny
1165
+ // rules is agent-specific (`.claude/settings.local.json` here). The posture is
1166
+ // unchanged and deliberately still fail-OPEN: a provisioning hiccup shrinks
1167
+ // safety margin but must not kill a job. Refusing to CLAIM on unenforced deny
1168
+ // remains a separate, fatal preflight concern.
1169
+ const denyCapability = adapter.denyEnforcement;
1170
+ if (denyCapability?.supported === true) {
1171
+ const deny = await denyCapability.value.provisionWorktreeDenyLayer({
1172
+ worktreePath,
1173
+ baseBranch: effectiveBaseBranch,
1174
+ homedir: deps.homedir(),
1007
1175
  });
1008
- return { status: "failed", reason: "bridge_credential_unavailable" };
1176
+ // Log on WARNING PRESENCE, not on `!ok` (BAPI-815/R2). The git-exclude
1177
+ // hygiene step can degrade without failing the deny layer — present-but-
1178
+ // unresolvable git metadata is exactly that case — and gating the log on
1179
+ // `!ok` is what made a total failure to ever write the exclude invisible.
1180
+ // The text is bounded at its source (category + worktree path), so it is
1181
+ // emitted verbatim through the executor's existing stderr diagnostic
1182
+ // channel. It never becomes a pre-spawn failure: the posture stays
1183
+ // fail-OPEN and the job continues to the spawn boundary.
1184
+ if (deny.warning)
1185
+ deps.errorLog(deny.warning);
1009
1186
  }
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;
1034
- // --- Deny layer (fail-open) ------------------------------------------
1035
- const deny = await provisionExecutorDenyLayer(worktreePath, { baseBranch: effectiveBaseBranch }, {
1036
- readFile: deps.readFile,
1037
- writeFile: deps.writeFile,
1038
- mkdir: deps.mkdir,
1039
- homedir: deps.homedir,
1040
- });
1041
- if (!deny.ok)
1042
- deps.errorLog(deny.warning);
1043
1187
  // --- MCP registration + trust pre-approval (fail-open, BAPI-724) -----
1044
1188
  // Unconditional for every job type that reaches this shared spawn path
1045
1189
  // (implement/resume/spec_review/remediate/ci_fix/rebase) — no job-type
@@ -1048,27 +1192,45 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
1048
1192
  // runtime options to the generated registration, so the worker's MCP server
1049
1193
  // targets the same Bridge API this executor claimed the job from.
1050
1194
  await provisionMcpForPreparedSpawn(job, branch, worktreePath, deps, seams, options.resolvedBaseUrl);
1051
- // --- Required Bridge MCP registration (FAIL-CLOSED under isolation) ---
1195
+ // --- Required Bridge MCP registration (FAIL-CLOSED) -------------------
1052
1196
  // 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.
1197
+ // a warning, and every valid worktree-provisioned server is kept. The REQUIRED
1198
+ // `bridge-api` shim registration is fail-CLOSED, and under strict loading it
1199
+ // has to be: the worker is launched with `--mcp-config <this file>`, so if the
1200
+ // file is absent or carries no Bridge shim there is no operator-scoped
1201
+ // registration to fall back to the worker would run silently toolless and
1202
+ // produce confidently wrong work.
1203
+ //
1204
+ // This is also where the spawn's MCP contract is captured (BAPI-790). The same
1205
+ // parse yields the absolute path passed to `--mcp-config` AND the expected
1206
+ // server names the init-event assertion checks, so the two can never describe
1207
+ // different files.
1208
+ //
1209
+ // BAPI-781: the required server NAME comes from the adapter's declared MCP
1210
+ // scoping capability, not from a module constant, so a future agent requires
1211
+ // whatever its own runtime registers.
1060
1212
  const verifyRegistration = seams.verifyRequiredWorktreeMcpRegistration ?? verifyRequiredWorktreeMcpRegistration;
1061
- const registration = await verifyRegistration(worktreePath, deps);
1213
+ const mcpScoping = adapter.mcpScoping;
1214
+ if (mcpScoping?.supported !== true) {
1215
+ await httpClient.fail(job, {
1216
+ error_kind: ExecutorAdapterUnavailable,
1217
+ error_message: "executor adapter declares no MCP scoping capability; refusing to spawn",
1218
+ classification: "crashed",
1219
+ });
1220
+ return { status: "failed", reason: "executor_adapter_unavailable" };
1221
+ }
1222
+ const registration = await verifyRegistration(worktreePath, deps, mcpScoping.value.requiredServerName);
1062
1223
  if (!registration.ok) {
1063
1224
  await httpClient.fail(job, {
1064
- error_kind: WorkerConfigIsolation,
1225
+ error_kind: RequiredMcpRegistration,
1065
1226
  error_message: registration.message,
1066
1227
  classification: "crashed",
1067
1228
  ...commitResidue(preSpawnTelemetry),
1068
1229
  telemetry: observation.snapshot(),
1069
1230
  });
1070
- return { status: "failed", reason: "worker_config_isolation" };
1231
+ return { status: "failed", reason: "required_mcp_registration" };
1071
1232
  }
1233
+ const mcpContract = registration.contract;
1072
1234
  // --- Worker log tee + watch registry + viewer tab (BAPI-535 §8) ------
1073
1235
  const registryDeps = buildJobLogRegistryDeps(deps);
1074
1236
  const markFinished = seams.markExecutorJobLogFinished ?? markExecutorJobLogFinished;
@@ -1133,35 +1295,101 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
1133
1295
  }
1134
1296
  };
1135
1297
  // --- Spawn -----------------------------------------------------------
1136
- const alias = resolveExecutorModelAlias(job.payload);
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);
1141
- // BAPI-586: inject the validated run base as BAPI_BASE_BRANCH so the worker
1142
- // can target it via `gh pr create --base "$BAPI_BASE_BRANCH"`.
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
- });
1298
+ // ONE complete spawn shape, requested from the adapter (BAPI-781). The runner
1299
+ // no longer knows Claude's flags, Claude's model policy, or Claude's
1300
+ // credential passthrough it knows only that an adapter returns
1301
+ // `{ executable, argv, env }`.
1302
+ //
1303
+ // BAPI-725: the posture was resolved at the top of this function after the
1304
+ // deny layer has already been provisioned above, which is what keeps
1305
+ // `acceptEdits` from being a relaxation of what the worker may do.
1306
+ //
1307
+ // BAPI-790: the argv carries `--strict-mcp-config --mcp-config <path>`, and the
1308
+ // path is the one the registration check just VERIFIED — not a path rebuilt
1309
+ // here. Shape construction is structurally downstream of that check, so a
1310
+ // worker can never be launched pointing at a file that was not confirmed to
1311
+ // exist and to register the required shim.
1312
+ //
1313
+ // BAPI-586: the validated run base travels in as BAPI_BASE_BRANCH so the
1314
+ // worker can target it via `gh pr create --base "$BAPI_BASE_BRANCH"`.
1315
+ const invocation = adapter.headlessInvocation;
1316
+ if (invocation?.supported !== true) {
1317
+ await closeWorkerLogTee(tee);
1318
+ await finalizeRegistry();
1319
+ await httpClient.fail(job, {
1320
+ error_kind: ExecutorAdapterUnavailable,
1321
+ error_message: "executor adapter declares no headless invocation capability",
1322
+ classification: "crashed",
1323
+ });
1324
+ return { status: "failed", reason: "executor_adapter_unavailable" };
1325
+ }
1326
+ let spawnShape;
1327
+ try {
1328
+ const alias = invocation.value.resolveModelAlias(job.payload);
1329
+ spawnShape = invocation.value.buildSpawnShape({
1330
+ prompt,
1331
+ modelAlias: alias,
1332
+ posture: permissionPosture,
1333
+ mcpConfigPath: mcpContract.configPath,
1334
+ effectiveBaseBranch,
1335
+ parentEnv: deps.env,
1336
+ });
1337
+ assertUsableSpawnShape(spawnShape, adapter);
1338
+ }
1339
+ catch (err) {
1340
+ // A spawn shape the adapter could not produce — or produced incompletely —
1341
+ // is NOT repaired here. Filling a missing executable from a host default or
1342
+ // a missing argv from Claude's flags is precisely the defaulting this
1343
+ // extraction removes, and it would spawn a real worker under a shape nobody
1344
+ // declared.
1345
+ await closeWorkerLogTee(tee);
1346
+ await finalizeRegistry();
1347
+ await httpClient.fail(job, {
1348
+ error_kind: ExecutorAdapterUnavailable,
1349
+ error_message: isExecutorNamedError(err)
1350
+ ? err.errorMessage
1351
+ : `executor adapter produced an unusable spawn shape: ${boundedError(err)}`,
1352
+ classification: "crashed",
1353
+ });
1354
+ return { status: "failed", reason: "executor_adapter_unavailable" };
1355
+ }
1356
+ // The per-spawn MCP surface assertion (BAPI-790). Built from the SAME verified
1357
+ // contract that produced the argv path, so the expected set and the loaded file
1358
+ // are the same file by construction. The PARSER is the adapter's; the
1359
+ // expected-versus-observed comparison stays here, so an adapter can never
1360
+ // certify its own containment.
1361
+ const initParsing = adapter.mcpInitParsing;
1362
+ if (initParsing?.supported !== true) {
1363
+ await closeWorkerLogTee(tee);
1364
+ await finalizeRegistry();
1365
+ await httpClient.fail(job, {
1366
+ error_kind: ExecutorAdapterUnavailable,
1367
+ error_message: "executor adapter declares no MCP init-event parser",
1368
+ classification: "crashed",
1369
+ });
1370
+ return { status: "failed", reason: "executor_adapter_unavailable" };
1371
+ }
1372
+ const mcpSurface = createMcpSurfaceObserver(mcpContract.expectedServerNames, (line) => initParsing.value.parseInitEvent(line));
1155
1373
  let proc;
1156
1374
  try {
1157
- proc = deps.spawnProcess(CLAUDE_EXECUTABLE, argv, { cwd: worktreePath, env });
1375
+ proc = deps.spawnProcess(spawnShape.executable, spawnShape.argv, {
1376
+ cwd: worktreePath,
1377
+ env: spawnShape.env,
1378
+ });
1158
1379
  }
1159
1380
  catch (err) {
1160
1381
  await closeWorkerLogTee(tee);
1161
1382
  await finalizeRegistry();
1383
+ // Redact through the adapter's declared secret names before bounding. A
1384
+ // spawn error can echo the environment it failed to apply, and that
1385
+ // environment may carry the operator's forwarded token.
1386
+ const redactionCapability = adapter.redaction;
1387
+ const raw = `failed to spawn worker: ${boundedError(err)}`;
1162
1388
  await httpClient.fail(job, {
1163
1389
  error_kind: "SpawnError",
1164
- error_message: `failed to spawn worker: ${boundedError(err)}`,
1390
+ error_message: redactionCapability?.supported === true
1391
+ ? redactAdapterDiagnostic(raw, deps.env, redactionCapability.value)
1392
+ : raw,
1165
1393
  classification: "crashed",
1166
1394
  });
1167
1395
  return { status: "failed", reason: "spawn_failed" };
@@ -1190,7 +1418,30 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
1190
1418
  timeoutSeconds: timeout.timeoutSeconds,
1191
1419
  collectTelemetry,
1192
1420
  collectRemoteMarker,
1421
+ mcpSurface,
1193
1422
  });
1423
+ // --- Adapter lifecycle (EXPLICIT, BAPI-781) ---------------------------
1424
+ // Consumed rather than assumed. Claude declares `{ kind: "none" }` and so
1425
+ // nothing runs here — which is correct, because BAPI-790 deleted the per-job
1426
+ // configuration directory that used to need cleanup and orphan sweeping. The
1427
+ // declaration is still READ, because "no work to do" and "nobody said" must
1428
+ // reach different code: an absent lifecycle was already refused by preflight
1429
+ // and by capability validation, and treating absence as a silent no-op here
1430
+ // would undo both.
1431
+ const lifecycleCapability = adapter.lifecycle;
1432
+ if (lifecycleCapability?.supported === true) {
1433
+ const declaration = lifecycleCapability.value.declaration;
1434
+ if (declaration.kind === "managed") {
1435
+ try {
1436
+ await declaration.cleanupAfterJob({ worktreePath });
1437
+ }
1438
+ catch (err) {
1439
+ // Cleanup is best-effort: a failure here must not turn a completed job
1440
+ // into a failed one, and the worker's real outcome is already decided.
1441
+ deps.errorLog(`adapter lifecycle cleanup failed (non-fatal): ${boundedError(err)}`);
1442
+ }
1443
+ }
1444
+ }
1194
1445
  // Final telemetry snapshot from the worktree.
1195
1446
  const finalTelemetry = await collectTelemetry();
1196
1447
  observation.setGitTelemetry(finalTelemetry);
@@ -1222,10 +1473,92 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
1222
1473
  }
1223
1474
  // Finalize the log tee WITHOUT signaling the worker (it has already exited).
1224
1475
  await closeWorkerLogTee(tee);
1476
+ // --- Worker-log session invariant (ADVISORY, BAPI-793) ----------------
1477
+ // "`.conductor/worker.log` for any completed job contains exactly one
1478
+ // `session_id`" — the cheap, durable invariant from the ticket that would have
1479
+ // caught the original incident on the first job rather than after three
1480
+ // misdiagnosed failures.
1481
+ //
1482
+ // Advisory ON PURPOSE. The lock above is the enforcement; this is the detector
1483
+ // that proves the lock held. Failing an otherwise-successful job because of an
1484
+ // observation about its log file would discard real work over a forensic
1485
+ // artifact — and a violation here means the lock was bypassed, which is a fact
1486
+ // about the host, not about the worker's output.
1487
+ await reportWorkerLogSessionInvariant(tee, deps);
1225
1488
  if (ownership.abandoned) {
1226
1489
  await finalizeRegistry();
1227
1490
  return { status: "abandoned", reason: ownership.abandonReason };
1228
1491
  }
1492
+ // --- MCP surface containment verdict (FAIL-CLOSED, BAPI-790) ----------
1493
+ // Placed AFTER the server-stop and abandonment checks and BEFORE every
1494
+ // classification branch below. That ordering is the whole precedence rule:
1495
+ //
1496
+ // - a deliberate server cancellation and a lost claim still win, because
1497
+ // reporting under a dead claim (or overriding an explicit cancel) would be
1498
+ // wrong regardless of what the worker's MCP surface looked like;
1499
+ // - but a containment failure outranks the generic `killed`/`crashed`
1500
+ // classification, because the worker was terminated BY US, on purpose, and
1501
+ // labeling that a worker fault would hide the actual cause and make the
1502
+ // condition unqueryable in `executor_jobs.error_kind`.
1503
+ //
1504
+ // The surface verdict is also consulted for a worker that finished on its own:
1505
+ // an assertion mismatch observed moments before a fast exit is still a
1506
+ // containment failure, not a successful run.
1507
+ const surfaceVerdict = mcpSurface.verdict();
1508
+ if (surfaceVerdict.kind === "mismatch") {
1509
+ await finalizeRegistry();
1510
+ await httpClient.fail(job, {
1511
+ error_kind: McpSurfaceMismatch,
1512
+ error_message: formatMcpSurfaceMismatch(surfaceVerdict.expected, surfaceVerdict.observed),
1513
+ classification: "crashed",
1514
+ ...commitResidue(finalTelemetry),
1515
+ telemetry: observation.snapshot(),
1516
+ });
1517
+ return { status: "failed", reason: "mcp_surface_mismatch" };
1518
+ }
1519
+ // --- Fatal startup, before any MCP surface was reported (BAPI-790) ----
1520
+ // A worker that exits non-zero having produced NO stream-json output at all
1521
+ // did not fail during work — it failed to start. Under strict MCP loading the
1522
+ // known cause is an enterprise `managed-mcp.json` policy rejecting the strict
1523
+ // flags, so this gets its own queryable kind instead of a generic
1524
+ // `WorkerCrashed`. The signature is deliberately structural (no output + a
1525
+ // non-zero exit) rather than a stderr text match: the enterprise fatal shape
1526
+ // has never been measured on a host available to this repository (inventory
1527
+ // Finding 6), and a guessed marker would be a fabricated diagnosis. Nothing
1528
+ // here retries without the strict flags — a fallback would silently restore
1529
+ // full inheritance on exactly the hosts with the strictest policy.
1530
+ //
1531
+ // A worker that produced output but no init event is NOT this case; it is the
1532
+ // advisory warning below.
1533
+ if (surfaceVerdict.kind === "absent" &&
1534
+ procResult.classification === "crashed" &&
1535
+ procResult.stdoutExcerpt.trim().length === 0) {
1536
+ await finalizeRegistry();
1537
+ await httpClient.fail(job, {
1538
+ error_kind: WorkerStartupFatal,
1539
+ error_message: WORKER_STARTUP_FATAL_MESSAGE,
1540
+ classification: "crashed",
1541
+ ...commitResidue(finalTelemetry),
1542
+ telemetry: observation.snapshot(),
1543
+ });
1544
+ return { status: "failed", reason: "worker_startup_fatal" };
1545
+ }
1546
+ if (surfaceVerdict.kind === "absent") {
1547
+ // Warn, never fail. The CLI's event shape is not ours to pin, and an
1548
+ // observability gap must not become a new way for jobs to die — that is the
1549
+ // opposite trade from the mismatch above, and both are deliberate.
1550
+ //
1551
+ // The two reasons are reported distinctly because they point at different
1552
+ // things to go look at: `no-init-event` at the worker/CLI, `oversized-line`
1553
+ // at this executor's own buffering bound.
1554
+ deps.errorLog(surfaceVerdict.reason === "oversized-line"
1555
+ ? "worker MCP surface could not be verified: a stream-json line exceeded the observer's " +
1556
+ "buffering bound and was discarded, so a system/init event may have been missed; " +
1557
+ "the job continues, but containment was not confirmed for this spawn"
1558
+ : "worker MCP surface could not be verified: no system/init event was observed in the " +
1559
+ "worker's stream-json output; the job continues, but containment was not confirmed " +
1560
+ "for this spawn");
1561
+ }
1229
1562
  const git = observation.git();
1230
1563
  if (procResult.classification === "clean_exit") {
1231
1564
  // Verdict jobs (`spec_review`) forward the fixed-path artifact verbatim; a
@@ -1344,12 +1677,30 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
1344
1677
  return terminalToRunResult(terminal, "completed");
1345
1678
  }
1346
1679
  // crashed / timeout / killed → fail
1680
+ //
1681
+ // BAPI-791: a recognized agent authentication failure is checked FIRST and,
1682
+ // when matched, wins over the generic classification below — it is reported as
1683
+ // `ContractError.ClaudeNotAuthenticated` instead of a generic `WorkerCrashed`,
1684
+ // even though the underlying process classification is still `crashed`. There
1685
+ // is no separate probe spawn and no retry: this is the SAME real worker spawn
1686
+ // that just ran, classified after the fact from its own captured output.
1687
+ //
1688
+ // BAPI-781: DETECTION is the adapter's (it recognizes its own runtime's output
1689
+ // shape); the ERROR KIND and the operator-facing message stay executor-owned,
1690
+ // so an adapter cannot invent its own failure vocabulary. The adapter returns
1691
+ // a verdict and a reason code only — never the matched line — so nothing from
1692
+ // the worker's stream reaches `/fail` through this path.
1347
1693
  await finalizeRegistry();
1694
+ const authDetection = adapter.authFailureDetection;
1695
+ const authFailure = authDetection?.supported === true &&
1696
+ authDetection.value.classify(procResult.stdoutExcerpt).notAuthenticated;
1348
1697
  const terminal = await sendTerminalMutationWithRetry({
1349
1698
  kind: "fail",
1350
1699
  send: () => httpClient.fail(job, {
1351
- error_kind: classificationToErrorKind(procResult.classification),
1352
- error_message: `worker exited (${procResult.classification}, exit_code=${procResult.exitCode})`,
1700
+ error_kind: authFailure ? ClaudeNotAuthenticated : classificationToErrorKind(procResult.classification),
1701
+ error_message: authFailure
1702
+ ? CLAUDE_NOT_AUTHENTICATED_MESSAGE
1703
+ : `worker exited (${procResult.classification}, exit_code=${procResult.exitCode})`,
1353
1704
  classification: toWireClassification(procResult.classification),
1354
1705
  ...commitResidue(git),
1355
1706
  telemetry: observation.snapshot(),