@bridge_gpt/mcp-server 0.2.51 → 0.2.52

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 (67) hide show
  1. package/README.md +24 -8
  2. package/build/agent-capabilities/probe-context.js +15 -7
  3. package/build/agent-capabilities/probes.js +42 -6
  4. package/build/agent-launchers/claude-executor-adapter.js +98 -14
  5. package/build/commands.generated.js +1 -1
  6. package/build/conduct-epic/cut-protocol.js +17 -3
  7. package/build/conductor/bridge-api-client.js +171 -5
  8. package/build/conductor/deny-enforcement-preflight.js +107 -10
  9. package/build/conductor/local-merge.js +170 -11
  10. package/build/conductor-bin.js +2 -2
  11. package/build/connect-bitbucket-api.js +370 -0
  12. package/build/connect-bitbucket.js +437 -0
  13. package/build/docs.generated.js +1 -1
  14. package/build/doctor.js +40 -1
  15. package/build/drive-epic.js +423 -11
  16. package/build/env-file-link.js +164 -0
  17. package/build/epic-integration-pr.js +10 -0
  18. package/build/executor/cli.js +41 -6
  19. package/build/executor/deps.js +5 -1
  20. package/build/executor/env-file-guard.js +113 -0
  21. package/build/executor/env.js +78 -1
  22. package/build/executor/heartbeat.js +9 -0
  23. package/build/executor/http-client.js +90 -22
  24. package/build/executor/job-errors.js +43 -2
  25. package/build/executor/job-runner.js +130 -28
  26. package/build/executor/merge-job.js +67 -16
  27. package/build/executor/permissions.js +106 -0
  28. package/build/executor/preflight.js +38 -13
  29. package/build/executor/resume-pre-spawn.js +2 -1
  30. package/build/executor/runner.js +175 -4
  31. package/build/executor/service-unit.js +15 -0
  32. package/build/executor/terminal-mutation.js +22 -1
  33. package/build/executor/types.js +86 -0
  34. package/build/executor/worker-command.js +21 -5
  35. package/build/executor/worker-guard-hook.js +939 -0
  36. package/build/executor/worker-log.js +56 -0
  37. package/build/executor/worktree.js +11 -0
  38. package/build/git-reachability.js +147 -0
  39. package/build/index.js +514 -121
  40. package/build/install-bridge.js +95 -0
  41. package/build/pipelines.generated.js +5 -3
  42. package/build/plan-epic-conductor-eligibility.js +37 -7
  43. package/build/plane/cli.js +78 -15
  44. package/build/plane/defaults.js +165 -0
  45. package/build/plane/manifest.js +63 -8
  46. package/build/plane/member-logs.js +6 -0
  47. package/build/plane/member-roster.js +195 -11
  48. package/build/plane/preflight.js +43 -0
  49. package/build/plane/shutdown.js +25 -3
  50. package/build/plane/status.js +11 -0
  51. package/build/plane/supervisor.js +343 -14
  52. package/build/plane/test-fakes.js +43 -0
  53. package/build/plane/types.js +82 -11
  54. package/build/pr-base-contract.js +20 -0
  55. package/build/readme.generated.js +1 -1
  56. package/build/review-synthesis-config.js +60 -0
  57. package/build/scripts/executor-protocol-contract-driver.js +311 -0
  58. package/build/setup-epic.js +560 -139
  59. package/build/sfcc/log-query.js +2 -1
  60. package/build/start-tickets-conductor.js +11 -2
  61. package/build/start-tickets.js +69 -2
  62. package/build/version.generated.js +3 -3
  63. package/build/worker-containment-diagnostic.js +97 -0
  64. package/build/worker-guard-hook-bin.js +6 -0
  65. package/docs/CONDUCTOR.md +27 -0
  66. package/docs/install/mcp-tool-integrations.md +3 -2
  67. package/package.json +3 -2
@@ -1,3 +1,14 @@
1
+ /**
2
+ * Executor `/executor/jobs/*` protocol HTTP client (BAPI-534, TDD §7, §10).
3
+ *
4
+ * Encapsulates the T2 protocol behind an injected `fetch` boundary with correct
5
+ * fencing-token semantics. The claim response `repo_name` and `claim_token` are
6
+ * echoed on every heartbeat/complete/fail (the server mutation guards are
7
+ * repo-bound and fenced on `claim_token`). The `X-API-Key` header value is never
8
+ * logged and never included in any error string.
9
+ */
10
+ import { RECONCILER_LIVENESS_VALUES } from "./types.js";
11
+ import { readEpicRunCompletionState, } from "../conductor/bridge-api-client.js";
1
12
  /** Bound a server-provided diagnostic snippet included in error strings. */
2
13
  function boundedDetail(text) {
3
14
  const trimmed = (text ?? "").trim();
@@ -39,13 +50,6 @@ function parseStopRequested(bodyText) {
39
50
  * (BAPI-871). Exported so the client and its tests name it once.
40
51
  */
41
52
  export const RECONCILER_LIVENESS_HEADER = "X-BAPI-Reconciler-Liveness";
42
- /** The only values the server is documented to send. */
43
- const RECONCILER_LIVENESS_VALUES = [
44
- "fresh",
45
- "stale",
46
- "never_seen",
47
- "unknown",
48
- ];
49
53
  /**
50
54
  * Read the liveness header off a claim response, admitting ONLY documented
51
55
  * values.
@@ -76,17 +80,43 @@ export function parseReconcilerLivenessHeader(headers) {
76
80
  /**
77
81
  * Map a mutation (heartbeat/complete/fail) HTTP response onto the fencing
78
82
  * outcome. `allowInvalidResult` maps 422 to `invalid_job_result` (complete only).
83
+ *
84
+ * BAPI-1021 (AC-5): 401/403 map to `auth_fatal` with the exact status attached,
85
+ * BEFORE the generic `fatal_http_error` fallback — the response body is never
86
+ * retained on this path (never logged, never echoed into the returned metadata).
79
87
  */
80
88
  function mapMutationResponse(status, bodyText, allowInvalidResult) {
81
89
  if (status === 409)
82
- return "stale_claim";
90
+ return { outcome: "stale_claim" };
83
91
  if (status === 503)
84
- return "retry_later";
92
+ return { outcome: "retry_later" };
85
93
  if (status === 422 && allowInvalidResult)
86
- return "invalid_job_result";
94
+ return { outcome: "invalid_job_result" };
87
95
  if (status >= 200 && status < 300 && isOkBody(bodyText))
88
- return "updated";
89
- return "fatal_http_error";
96
+ return { outcome: "updated" };
97
+ if (status === 401 || status === 403)
98
+ return { outcome: "auth_fatal", authHttpStatus: status };
99
+ return { outcome: "fatal_http_error" };
100
+ }
101
+ /**
102
+ * Adapt the executor's injected `FetchLike` transport to the narrower
103
+ * `ConductorFetch` shape the shared read-only Bridge API GET helper expects
104
+ * (BAPI-1010). GET-only; deliberately ignores the caller's `AbortSignal` —
105
+ * `FetchLike`'s production implementation (`createDefaultExecutorDeps`)
106
+ * already wraps every request in its own `AbortController` timeout, so no
107
+ * request is ever left uncancelled by discarding this one.
108
+ */
109
+ function toConductorGetFetch(fetchLike) {
110
+ return async (url, init) => {
111
+ const requestInit = { method: "GET", headers: init.headers };
112
+ const res = await fetchLike(url, requestInit);
113
+ const text = await res.text();
114
+ return {
115
+ ok: res.status >= 200 && res.status < 300,
116
+ status: res.status,
117
+ json: async () => JSON.parse(text),
118
+ };
119
+ };
90
120
  }
91
121
  export function createExecutorHttpClient(config) {
92
122
  const base = config.baseUrl.replace(/\/+$/, "") + "/executor/jobs";
@@ -119,7 +149,7 @@ export function createExecutorHttpClient(config) {
119
149
  catch {
120
150
  // Transient network error — treat as fatal for a single mutation attempt;
121
151
  // the caller's retry/dead-man logic decides whether to keep the worker alive.
122
- return "fatal_http_error";
152
+ return { outcome: "fatal_http_error" };
123
153
  }
124
154
  }
125
155
  return {
@@ -158,7 +188,18 @@ export function createExecutorHttpClient(config) {
158
188
  return { kind: "fatal", error: "claim returned invalid JSON" };
159
189
  }
160
190
  }
161
- if (status === 401 || status === 403 || status === 422) {
191
+ if (status === 401 || status === 403) {
192
+ // BAPI-1021 (AC-5): carry the auth category + exact status alongside the
193
+ // existing error string, distinguishing this from the non-auth fatal 422
194
+ // contract response below without parsing `error`.
195
+ return {
196
+ kind: "fatal",
197
+ error: `claim rejected (HTTP ${status}): ${boundedDetail(text)}`,
198
+ authCategory: "auth_fatal",
199
+ authHttpStatus: status,
200
+ };
201
+ }
202
+ if (status === 422) {
162
203
  return { kind: "fatal", error: `claim rejected (HTTP ${status}): ${boundedDetail(text)}` };
163
204
  }
164
205
  // 5xx and any other unexpected status are retryable.
@@ -171,13 +212,14 @@ export function createExecutorHttpClient(config) {
171
212
  let status;
172
213
  let text;
173
214
  try {
174
- const res = await post(`/${job.id}/heartbeat`, {
215
+ const body = {
175
216
  repo_name: job.repo_name,
176
217
  claim_token: job.claim_token,
177
218
  local_commit_count: payload.local_commit_count,
178
219
  last_commit_sha: payload.last_commit_sha,
179
220
  telemetry: payload.telemetry,
180
- }, job.repo_name);
221
+ };
222
+ const res = await post(`/${job.id}/heartbeat`, body, job.repo_name);
181
223
  status = res.status;
182
224
  text = res.text;
183
225
  }
@@ -186,8 +228,10 @@ export function createExecutorHttpClient(config) {
186
228
  // caller's dead-man logic decides whether to keep the worker alive.
187
229
  return { outcome: "fatal_http_error" };
188
230
  }
189
- const outcome = mapMutationResponse(status, text, false);
190
- const result = { outcome };
231
+ const mapped = mapMutationResponse(status, text, false);
232
+ const result = { outcome: mapped.outcome };
233
+ if (mapped.authHttpStatus !== undefined)
234
+ result.authHttpStatus = mapped.authHttpStatus;
191
235
  if (parseStopRequested(text))
192
236
  result.stop_requested = true;
193
237
  return result;
@@ -211,7 +255,7 @@ export function createExecutorHttpClient(config) {
211
255
  }
212
256
  },
213
257
  complete(job, completion) {
214
- return mutate(`/${job.id}/complete`, {
258
+ const body = {
215
259
  repo_name: job.repo_name,
216
260
  claim_token: job.claim_token,
217
261
  job_type: completion.job_type,
@@ -222,10 +266,11 @@ export function createExecutorHttpClient(config) {
222
266
  local_commit_count: completion.local_commit_count,
223
267
  last_commit_sha: completion.last_commit_sha,
224
268
  telemetry: completion.telemetry,
225
- }, true, job.repo_name);
269
+ };
270
+ return mutate(`/${job.id}/complete`, body, true, job.repo_name);
226
271
  },
227
272
  fail(job, failure) {
228
- return mutate(`/${job.id}/fail`, {
273
+ const body = {
229
274
  repo_name: job.repo_name,
230
275
  claim_token: job.claim_token,
231
276
  error_kind: failure.error_kind,
@@ -234,7 +279,30 @@ export function createExecutorHttpClient(config) {
234
279
  local_commit_count: failure.local_commit_count,
235
280
  last_commit_sha: failure.last_commit_sha,
236
281
  telemetry: failure.telemetry,
237
- }, false, job.repo_name);
282
+ };
283
+ return mutate(`/${job.id}/fail`, body, false, job.repo_name);
284
+ },
285
+ /**
286
+ * Read-only epic-run completion-state projection (BAPI-1010). Delegates
287
+ * URL construction, timeout handling, response validation, and failure
288
+ * sanitization entirely to {@link readEpicRunCompletionState} — this
289
+ * method only resolves the repo-scoped access and adapts the transport.
290
+ * Never throws: `readEpicRunCompletionState` is itself non-throwing, and
291
+ * the try/catch below is belt-and-braces so a future change there can
292
+ * never surface an exception through this public contract.
293
+ */
294
+ async readEpicRunState(epicRunId, repoName) {
295
+ const access = {
296
+ repoName,
297
+ apiKey: keyForRepo(repoName),
298
+ baseUrl: config.baseUrl,
299
+ };
300
+ try {
301
+ return await readEpicRunCompletionState(access, epicRunId, toConductorGetFetch(config.fetch));
302
+ }
303
+ catch {
304
+ return { ok: false, reason: "network" };
305
+ }
238
306
  },
239
307
  };
240
308
  }
@@ -86,6 +86,14 @@
86
86
  * alternative to reporting it is defaulting to Claude's behavior for an agent
87
87
  * nobody implemented, which would spawn a real worker under containment the
88
88
  * operator never declared.
89
+ * - `ContractError.WorkerEnvFilePresent` — the worktree could not be CONFIRMED
90
+ * free of `.env` / `.env.*` entries at the last moment before spawn
91
+ * (BAPI-1019). An operator environment file inside a worker's worktree is how
92
+ * Architecture Miss 28 happened: the worker resolved a real database from one
93
+ * and a test harness then truncated that database. Fail-CLOSED, pre-spawn, and
94
+ * NON-RETRYABLE — the condition is a property of the HOST's worktree tooling
95
+ * rather than of the ticket, so the next job on the same host reproduces it
96
+ * identically; see `env-file-guard.ts`.
89
97
  *
90
98
  * These kinds are deliberately DISTINCT from each other so the refusals stay
91
99
  * tellable apart in `executor_jobs.error_kind`.
@@ -123,6 +131,14 @@ export const ExecutorAdapterUnavailable = "ContractError.ExecutorAdapterUnavaila
123
131
  * alternative is running an attempt that may read a stale verdict as its own.
124
132
  */
125
133
  export const StaleArtifactCleanupFailed = "ContractError.StaleArtifactCleanupFailed";
134
+ /**
135
+ * BAPI-1019 (A.4): the worktree still held — or could not be proven free of — an
136
+ * operator environment file at the spawn boundary. Sits beside
137
+ * {@link PreSpawnVerification} and {@link StaleArtifactCleanupFailed} because it
138
+ * is the same shape: a pre-spawn assertion about the worktree that refuses rather
139
+ * than proceeding on an unverified premise.
140
+ */
141
+ export const WorkerEnvFilePresent = "ContractError.WorkerEnvFilePresent";
126
142
  /** Rendered in place of an empty name list, so "none" is never ambiguous. */
127
143
  export const EMPTY_MCP_SERVER_NAME_MARKER = "none";
128
144
  /** Bound the rendered name lists so a pathological registration cannot spam a job row. */
@@ -207,6 +223,25 @@ export const WORKTREE_BUSY_MESSAGE = "another executor job already holds this ti
207
223
  "second claim is refused rather than allowed in. The refusal is expected when two jobs exist " +
208
224
  "for one ticket; the job that holds the worktree continues normally, and this one is safe to " +
209
225
  "retry once it finishes. See the executor's stderr for the conflicting job id.";
226
+ /**
227
+ * The fixed message posted with {@link WorkerEnvFilePresent} (BAPI-1019).
228
+ *
229
+ * FIXED and identity-free, following {@link WORKTREE_BUSY_MESSAGE} and
230
+ * {@link WORKER_STARTUP_FATAL_MESSAGE}: it states what was OBSERVED (an
231
+ * environment file was present in the worktree at spawn and could not be
232
+ * confirmed removed) and names the remediation, and it carries no worktree path,
233
+ * no username, no discovered filename, no link target, no file content, and no
234
+ * exception text. This string is stored on the job row and read back by anyone
235
+ * with access to the run; the attributable detail (job id, guard category) goes
236
+ * to the executor's own stderr, on the host that already has it.
237
+ *
238
+ * It does NOT say "retry" anywhere, deliberately. The condition recurs
239
+ * identically until an operator changes the host's worktree tooling.
240
+ */
241
+ export const WORKER_ENV_FILE_PRESENT_MESSAGE = "an environment file was present in this job's worktree at spawn and could not be confirmed " +
242
+ "removed, so no worker was started. A worker that reads one resolves real credentials and a " +
243
+ "real database. Check the copy-ignored exclusions in .config/wt.toml and any local tooling " +
244
+ "that writes into worktrees.";
210
245
  // The Claude-specific matcher that used to live here moved into the Claude
211
246
  // executor adapter as `classifyClaudeAuthFailure` (BAPI-781). Recognizing one
212
247
  // CLI's not-logged-in output is precisely a per-agent capability, and leaving it
@@ -217,8 +252,14 @@ export const WORKTREE_BUSY_MESSAGE = "another executor job already holds this ti
217
252
  // classification onto a stable executor failure remains executor-owned, so an
218
253
  // adapter cannot invent its own error kinds or its own operator-facing
219
254
  // remediation text.
220
- /** Bound a failure message so no unbounded/secret-bearing text is posted. */
221
- const ERROR_MESSAGE_MAX_CHARS = 300;
255
+ /**
256
+ * Bound a failure message so no unbounded/secret-bearing text is posted.
257
+ *
258
+ * EXPORTED (BAPI-1019) so a fixed-message contract test can assert against the
259
+ * same number {@link ExecutorNamedError} truncates at, rather than restating
260
+ * `300` and silently going vacuous the day the bound changes.
261
+ */
262
+ export const ERROR_MESSAGE_MAX_CHARS = 300;
222
263
  /**
223
264
  * An `Error` subclass carrying a stable `errorKind` plus a bounded
224
265
  * `errorMessage`. Named executor failures default to the `crashed` wire
@@ -14,10 +14,10 @@
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
+ import { rm, readdir, lstat, unlink } from "node:fs/promises";
18
18
  import os from "node:os";
19
19
  import { runHeartbeatLoop } from "./heartbeat.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";
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, WorkerEnvFilePresent, WORKER_ENV_FILE_PRESENT_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
23
  import { getWorktreeMcpRegistrationTargets, normalizeWorktreePathForRegistration, provisionMcpRegistrationForWorktree, } from "../mcp-provisioning.js";
@@ -27,9 +27,13 @@ import { resolveExecutorAgentId } from "./agent-identity.js";
27
27
  import { createProcessTerminationController, runProcessWithTimeout } from "./process.js";
28
28
  import { prepareResumeSpawn } from "./resume-pre-spawn.js";
29
29
  import { renderPromptSpecPrompt } from "./prompt-spec.js";
30
+ import { defaultResolveBinPath } from "../start-tickets-conductor.js";
31
+ /** The packaged bin basename of the deterministic worker guard (BAPI-1020). */
32
+ const WORKER_GUARD_BIN_FILENAME = "worker-guard-hook-bin.js";
30
33
  import { buildGenericSuccessResult, buildSmokeResult, commitResidue, readCompletionArtifacts, resolveJobTimeoutSeconds, } from "./results.js";
31
34
  import { sendTerminalMutationWithRetry } from "./terminal-mutation.js";
32
35
  import { removeStaleVerdictArtifacts } from "./stale-artifacts.js";
36
+ import { stripWorkerEnvFiles } from "./env-file-guard.js";
33
37
  import { isVerdictJobType, readVerdictArtifact } from "./verdict-artifact.js";
34
38
  import { createWorkerLogTee, closeWorkerLogTee, distinctWorkerLogSessionIds, formatWorkerLogSessionInvariantWarning, teeAsyncIterable, } from "./worker-log.js";
35
39
  import { acquireExecutorWorktreeLock, } from "./worktree-lock.js";
@@ -499,6 +503,18 @@ async function superviseProcess(params) {
499
503
  isDoneRef.value = true;
500
504
  const loopOutcome = await hb;
501
505
  params.observation.setExitCode(result.exitCode);
506
+ if (loopOutcome.kind === "auth_fatal") {
507
+ // BAPI-1021 (AC-5): notify the runner immediately with ONLY the bounded
508
+ // identifiers — never carried onto `result` itself, so the process's own
509
+ // classification/completion handling is unaffected and the job continues
510
+ // to drain normally.
511
+ params.control?.reportAuthFatal?.({
512
+ jobId: params.job.id,
513
+ repoName: params.job.repo_name,
514
+ category: "heartbeat",
515
+ httpStatus: loopOutcome.httpStatus,
516
+ });
517
+ }
502
518
  return loopOutcome.kind === "server_stop"
503
519
  ? { ...result, serverStopRequested: true }
504
520
  : result;
@@ -515,7 +531,33 @@ function terminalToRunResult(terminal, successStatus) {
515
531
  return { status: "abandoned", reason: "deadman" };
516
532
  case "skipped":
517
533
  return { status: "abandoned", reason: "already_abandoned" };
534
+ case "auth_fatal":
535
+ // BAPI-1021 (AC-5): a halted authentication/access condition, not an
536
+ // ordinary job failure or a lost/stale claim — `reportAuthFatal` (called
537
+ // by the wrapper below, before this mapping runs) is what actually
538
+ // notifies the runner.
539
+ return { status: "abandoned", reason: "auth_fatal" };
540
+ }
541
+ }
542
+ /**
543
+ * BAPI-1021 (AC-5): send a terminal mutation via
544
+ * {@link sendTerminalMutationWithRetry}, and — on an `auth_fatal` outcome —
545
+ * notify the runner exactly once via `control.reportAuthFatal`, carrying ONLY
546
+ * the job ID, repository, operation category, and HTTP status. Every other
547
+ * outcome passes through completely unchanged.
548
+ */
549
+ async function sendTerminalMutationWithAuthReport(params, job, control) {
550
+ const terminal = await sendTerminalMutationWithRetry(params);
551
+ if (terminal.outcome === "auth_fatal") {
552
+ const notice = {
553
+ jobId: job.id,
554
+ repoName: job.repo_name,
555
+ category: params.kind,
556
+ httpStatus: terminal.httpStatus,
557
+ };
558
+ control?.reportAuthFatal?.(notice);
518
559
  }
560
+ return terminal;
519
561
  }
520
562
  /** Dispatch and run a single claimed job. */
521
563
  export async function runClaimedJob(job, httpClient, options, deps, _report, seams = {},
@@ -549,10 +591,10 @@ control) {
549
591
  // ensure/recreate or worker-spawn logic — it ensures no worktree and spawns no
550
592
  // worker (TDD §5/§9).
551
593
  if (job.job_type === "merge") {
552
- return runMergeJob(job, httpClient, options, deps, ownership, observation, seams);
594
+ return runMergeJob(job, httpClient, options, deps, ownership, observation, seams, control);
553
595
  }
554
596
  if (job.job_type === "smoke") {
555
- return runSmokeJob(job, httpClient, options, deps, ownership, observation);
597
+ return runSmokeJob(job, httpClient, options, deps, ownership, observation, control);
556
598
  }
557
599
  // `implement`/`resume`/`spec_review` and the recovery jobs (`remediate`/
558
600
  // `ci_fix`/`rebase`) all run through the real headless-spawn path — dispatched
@@ -640,7 +682,7 @@ function createMergeFlowProcess(startMerge) {
640
682
  * ignored. The local merge executor's outcome otherwise drives `/complete`
641
683
  * (success) or `/fail`; stale/retry/invalid terminal outcomes reuse T3a handling.
642
684
  */
643
- async function runMergeJob(job, httpClient, options, deps, ownership, observation, seams) {
685
+ async function runMergeJob(job, httpClient, options, deps, ownership, observation, seams, control) {
644
686
  // Resolve + enforce payload.timeout_seconds BEFORE starting any merge work.
645
687
  const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
646
688
  if (!timeout.ok) {
@@ -664,6 +706,7 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
664
706
  proc: flow.proc,
665
707
  timeoutSeconds,
666
708
  collectTelemetry: async () => ({}),
709
+ control,
667
710
  });
668
711
  // Ownership abandonment (stale-claim / dead-man) wins: never send a terminal
669
712
  // mutation for a claim we no longer own, and never process a late outcome.
@@ -673,7 +716,7 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
673
716
  // Overall merge flow timed out: fail the active claim as RETRYABLE and ignore
674
717
  // any later merge promise resolution (the abort has already been signalled).
675
718
  if (procResult.classification === "timeout") {
676
- const terminal = await sendTerminalMutationWithRetry({
719
+ const terminal = await sendTerminalMutationWithAuthReport({
677
720
  kind: "fail",
678
721
  send: () => httpClient.fail(job, {
679
722
  error_kind: MERGE_RETRYABLE,
@@ -685,13 +728,13 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
685
728
  options,
686
729
  ownership,
687
730
  log: deps.log,
688
- });
731
+ }, job, control);
689
732
  return terminalToRunResult(terminal, "failed");
690
733
  }
691
734
  // A thrown error inside the merge flow maps to a bounded MergeFailed.
692
735
  const flowError = flow.getError();
693
736
  if (flowError !== undefined) {
694
- const terminal = await sendTerminalMutationWithRetry({
737
+ const terminal = await sendTerminalMutationWithAuthReport({
695
738
  kind: "fail",
696
739
  send: () => httpClient.fail(job, {
697
740
  error_kind: MERGE_FAILED,
@@ -703,14 +746,14 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
703
746
  options,
704
747
  ownership,
705
748
  log: deps.log,
706
- });
749
+ }, job, control);
707
750
  return terminalToRunResult(terminal, "failed");
708
751
  }
709
752
  const outcome = flow.getOutcome();
710
753
  if (outcome === undefined) {
711
754
  // Clean process result with no stored outcome — an internal invariant break;
712
755
  // fail loud rather than silently reporting success.
713
- const terminal = await sendTerminalMutationWithRetry({
756
+ const terminal = await sendTerminalMutationWithAuthReport({
714
757
  kind: "fail",
715
758
  send: () => httpClient.fail(job, {
716
759
  error_kind: MERGE_FAILED,
@@ -722,7 +765,7 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
722
765
  options,
723
766
  ownership,
724
767
  log: deps.log,
725
- });
768
+ }, job, control);
726
769
  return terminalToRunResult(terminal, "failed");
727
770
  }
728
771
  if (outcome.ok) {
@@ -733,24 +776,24 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
733
776
  result: outcome.result,
734
777
  telemetry: observation.snapshot(),
735
778
  };
736
- const terminal = await sendTerminalMutationWithRetry({
779
+ const terminal = await sendTerminalMutationWithAuthReport({
737
780
  kind: "complete",
738
781
  send: () => httpClient.complete(job, completion),
739
782
  deps,
740
783
  options,
741
784
  ownership,
742
785
  log: deps.log,
743
- });
786
+ }, job, control);
744
787
  return terminalToRunResult(terminal, "completed");
745
788
  }
746
- const terminal = await sendTerminalMutationWithRetry({
789
+ const terminal = await sendTerminalMutationWithAuthReport({
747
790
  kind: "fail",
748
791
  send: () => httpClient.fail(job, { ...outcome.failure, telemetry: observation.snapshot() }),
749
792
  deps,
750
793
  options,
751
794
  ownership,
752
795
  log: deps.log,
753
- });
796
+ }, job, control);
754
797
  return terminalToRunResult(terminal, "failed");
755
798
  }
756
799
  /**
@@ -767,7 +810,7 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
767
810
  * job to give it something to register MCP into would be a materially larger,
768
811
  * unratified change to that architectural boundary, not a wiring task.
769
812
  */
770
- async function runSmokeJob(job, httpClient, options, deps, ownership, observation) {
813
+ async function runSmokeJob(job, httpClient, options, deps, ownership, observation, control) {
771
814
  const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
772
815
  if (!timeout.ok) {
773
816
  // Consistent with runSpawnJob: a timeout-contract failure fails the job
@@ -796,6 +839,7 @@ async function runSmokeJob(job, httpClient, options, deps, ownership, observatio
796
839
  proc,
797
840
  timeoutSeconds,
798
841
  collectTelemetry: async () => ({}),
842
+ control,
799
843
  });
800
844
  if (ownership.abandoned) {
801
845
  return { status: "abandoned", reason: ownership.abandonReason };
@@ -813,14 +857,14 @@ async function runSmokeJob(job, httpClient, options, deps, ownership, observatio
813
857
  result,
814
858
  telemetry: observation.snapshot(),
815
859
  };
816
- const terminal = await sendTerminalMutationWithRetry({
860
+ const terminal = await sendTerminalMutationWithAuthReport({
817
861
  kind: "complete",
818
862
  send: () => httpClient.complete(job, completion),
819
863
  deps,
820
864
  options,
821
865
  ownership,
822
866
  log: deps.log,
823
- });
867
+ }, job, control);
824
868
  return terminalToRunResult(terminal, "completed");
825
869
  }
826
870
  /**
@@ -1284,6 +1328,14 @@ async function runPreparedSpawn(params) {
1284
1328
  worktreePath,
1285
1329
  baseBranch: effectiveBaseBranch,
1286
1330
  homedir: deps.homedir(),
1331
+ // BAPI-1020 — the prepared worktree's own branch and the packaged guard bin.
1332
+ // `branch` comes from `prepareSpawn`'s `wt.branch`, i.e. the worktree the
1333
+ // executor actually created, never from job payload text: the guard decides
1334
+ // "is this push destination mine?" by comparing against it, so a value the
1335
+ // payload could influence would let a job nominate its own safe branch.
1336
+ workerBranch: branch,
1337
+ workerGuardHookBinPath: (seams.resolveWorkerGuardBinPath ??
1338
+ (() => defaultResolveBinPath(WORKER_GUARD_BIN_FILENAME)))(),
1287
1339
  });
1288
1340
  // Log on WARNING PRESENCE, not on `!ok` (BAPI-815/R2). The git-exclude
1289
1341
  // hygiene step can degrade without failing the deny layer — present-but-
@@ -1354,6 +1406,13 @@ async function runPreparedSpawn(params) {
1354
1406
  writeFile: deps.writeFile,
1355
1407
  appendFile: deps.appendFile,
1356
1408
  platform: deps.platform,
1409
+ // BAPI-1021 (AC-6): the archive seams. All optional on `ExecutorDeps`
1410
+ // already, so this is source-compatible with every existing deps
1411
+ // builder — archival simply does not run when either is absent.
1412
+ rename: deps.rename,
1413
+ stat: deps.stat,
1414
+ now: deps.now,
1415
+ errorLog: deps.errorLog,
1357
1416
  });
1358
1417
  }
1359
1418
  catch {
@@ -1476,6 +1535,10 @@ async function runPreparedSpawn(params) {
1476
1535
  mcpConfigPath: mcpContract.configPath,
1477
1536
  effectiveBaseBranch,
1478
1537
  indexScope,
1538
+ // BAPI-1020 — becomes `BAPI_WORKER_BRANCH` in the worker environment, which
1539
+ // is the deterministic guard's only source of branch identity. Same
1540
+ // provenance as the deny-provisioning value above: the prepared worktree.
1541
+ workerBranch: branch,
1479
1542
  parentEnv: deps.env,
1480
1543
  });
1481
1544
  assertUsableSpawnShape(spawnShape, adapter);
@@ -1514,6 +1577,44 @@ async function runPreparedSpawn(params) {
1514
1577
  return { status: "failed", reason: "executor_adapter_unavailable" };
1515
1578
  }
1516
1579
  const mcpSurface = createMcpSurfaceObserver(mcpContract.expectedServerNames, (line) => initParsing.value.parseInitEvent(line));
1580
+ // --- Worker env-file strip (FAIL-CLOSED, BAPI-1019/A.4) ---------------
1581
+ // THE LAST thing that touches the worktree before a worker exists. Placed
1582
+ // after the deny layer, after MCP provisioning and its verification, and after
1583
+ // spawn-shape/observer construction precisely so nothing that runs later can
1584
+ // re-introduce a file the guard just confirmed gone. Everything between this
1585
+ // call and `deps.spawnProcess` below is pure in-memory work.
1586
+ //
1587
+ // Fail-CLOSED, following `StaleArtifactCleanupFailed` rather than the
1588
+ // fail-OPEN deny-layer posture two hundred lines up. The deny layer shrinks a
1589
+ // safety margin when it degrades; this gate is the margin. Architecture Miss 28
1590
+ // was a worker that reached the operator's real database through a copied
1591
+ // `.env` and truncated it — so "could not confirm the worktree is clean" must
1592
+ // refuse, never proceed and hope.
1593
+ const stripEnvFiles = seams.stripWorkerEnvFiles ?? stripWorkerEnvFiles;
1594
+ const stripped = await stripEnvFiles(worktreePath, {
1595
+ readdir: (dirPath) => readdir(dirPath),
1596
+ // `lstat`/`unlink` only — the guard must never resolve a symlink, because an
1597
+ // entry pointing at the main checkout would resolve to the operator's real
1598
+ // `.env` and unlinking THAT would delete it. The narrow bag is the
1599
+ // enforcement: no `stat`, `readlink`, or `readFile` is reachable from here.
1600
+ lstat: (target) => lstat(target),
1601
+ unlink: (target) => unlink(target),
1602
+ platform: deps.platform,
1603
+ });
1604
+ if (!stripped.ok) {
1605
+ await closeWorkerLog();
1606
+ await finalizeRegistry();
1607
+ // Exactly ONE bounded stderr line: the job id and the closed guard category.
1608
+ // No worktree path (it carries a username), no removed filename, no errno, no
1609
+ // exception text.
1610
+ deps.errorLog(`[executor] job ${job.id}: refusing to spawn, worker env files unconfirmed: ${stripped.category}`);
1611
+ await httpClient.fail(job, {
1612
+ error_kind: WorkerEnvFilePresent,
1613
+ error_message: WORKER_ENV_FILE_PRESENT_MESSAGE,
1614
+ classification: "crashed",
1615
+ });
1616
+ return { status: "failed", reason: "worker_env_file_present" };
1617
+ }
1517
1618
  let proc;
1518
1619
  try {
1519
1620
  proc = deps.spawnProcess(spawnShape.executable, spawnShape.argv, {
@@ -1583,6 +1684,7 @@ async function runPreparedSpawn(params) {
1583
1684
  // and an MCP containment refusal all converge on one `SIGTERM` and one grace
1584
1685
  // timer for this child (BAPI-828).
1585
1686
  terminationController,
1687
+ control,
1586
1688
  });
1587
1689
  // --- Adapter lifecycle (EXPLICIT, BAPI-781) ---------------------------
1588
1690
  // Consumed rather than assumed. Claude declares `{ kind: "none" }` and so
@@ -1742,14 +1844,14 @@ async function runPreparedSpawn(params) {
1742
1844
  classification: "crashed",
1743
1845
  };
1744
1846
  await finalizeRegistry();
1745
- const terminal = await sendTerminalMutationWithRetry({
1847
+ const terminal = await sendTerminalMutationWithAuthReport({
1746
1848
  kind: "fail",
1747
1849
  send: () => httpClient.fail(job, { ...failure, ...commitResidue(git), telemetry: observation.snapshot() }),
1748
1850
  deps,
1749
1851
  options,
1750
1852
  ownership,
1751
1853
  log: deps.log,
1752
- });
1854
+ }, job, control);
1753
1855
  return terminalToRunResult(terminal, "failed");
1754
1856
  }
1755
1857
  // BAPI-528 Wave 3 M2: verdict jobs (`spec_review`) also produce a
@@ -1769,14 +1871,14 @@ async function runPreparedSpawn(params) {
1769
1871
  ...commitResidue(git),
1770
1872
  telemetry: observation.snapshot(),
1771
1873
  };
1772
- const terminal = await sendTerminalMutationWithRetry({
1874
+ const terminal = await sendTerminalMutationWithAuthReport({
1773
1875
  kind: "complete",
1774
1876
  send: () => httpClient.complete(job, completion),
1775
1877
  deps,
1776
1878
  options,
1777
1879
  ownership,
1778
1880
  log: deps.log,
1779
- });
1881
+ }, job, control);
1780
1882
  await finalizeRegistry();
1781
1883
  return terminalToRunResult(terminal, "completed");
1782
1884
  }
@@ -1806,7 +1908,7 @@ async function runPreparedSpawn(params) {
1806
1908
  });
1807
1909
  if (!finalization.ok) {
1808
1910
  await finalizeRegistry();
1809
- const terminal = await sendTerminalMutationWithRetry({
1911
+ const terminal = await sendTerminalMutationWithAuthReport({
1810
1912
  kind: "fail",
1811
1913
  send: () => httpClient.fail(job, {
1812
1914
  ...finalization.failure,
@@ -1817,7 +1919,7 @@ async function runPreparedSpawn(params) {
1817
1919
  options,
1818
1920
  ownership,
1819
1921
  log: deps.log,
1820
- });
1922
+ }, job, control);
1821
1923
  return terminalToRunResult(terminal, "failed");
1822
1924
  }
1823
1925
  // BAPI-862: attach the pull request the EXECUTOR verified. `pr_url` was never
@@ -1840,14 +1942,14 @@ async function runPreparedSpawn(params) {
1840
1942
  ...commitResidue(git),
1841
1943
  telemetry: observation.snapshot(),
1842
1944
  };
1843
- const terminal = await sendTerminalMutationWithRetry({
1945
+ const terminal = await sendTerminalMutationWithAuthReport({
1844
1946
  kind: "complete",
1845
1947
  send: () => httpClient.complete(job, completion),
1846
1948
  deps,
1847
1949
  options,
1848
1950
  ownership,
1849
1951
  log: deps.log,
1850
- });
1952
+ }, job, control);
1851
1953
  await finalizeRegistry();
1852
1954
  return terminalToRunResult(terminal, "completed");
1853
1955
  }
@@ -1869,7 +1971,7 @@ async function runPreparedSpawn(params) {
1869
1971
  const authDetection = adapter.authFailureDetection;
1870
1972
  const authFailure = authDetection?.supported === true &&
1871
1973
  authDetection.value.classify(procResult.stdoutExcerpt).notAuthenticated;
1872
- const terminal = await sendTerminalMutationWithRetry({
1974
+ const terminal = await sendTerminalMutationWithAuthReport({
1873
1975
  kind: "fail",
1874
1976
  send: () => httpClient.fail(job, {
1875
1977
  error_kind: authFailure ? ClaudeNotAuthenticated : classificationToErrorKind(procResult.classification),
@@ -1884,6 +1986,6 @@ async function runPreparedSpawn(params) {
1884
1986
  options,
1885
1987
  ownership,
1886
1988
  log: deps.log,
1887
- });
1989
+ }, job, control);
1888
1990
  return terminalToRunResult(terminal, "failed");
1889
1991
  }