@bridge_gpt/mcp-server 0.2.50 → 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 (76) 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/bridge-client.js +115 -1
  7. package/build/conduct-epic/cli.js +351 -33
  8. package/build/conduct-epic/cut-protocol.js +65 -0
  9. package/build/conductor/bridge-api-client.js +171 -5
  10. package/build/conductor/deny-enforcement-preflight.js +107 -10
  11. package/build/conductor/local-merge.js +170 -11
  12. package/build/conductor-bin.js +2 -2
  13. package/build/connect-bitbucket-api.js +370 -0
  14. package/build/connect-bitbucket.js +437 -0
  15. package/build/docs.generated.js +1 -1
  16. package/build/doctor.js +230 -1
  17. package/build/drive-epic.js +423 -11
  18. package/build/env-file-link.js +164 -0
  19. package/build/epic-integration-pr.js +290 -0
  20. package/build/executor/cli.js +41 -6
  21. package/build/executor/deps.js +5 -1
  22. package/build/executor/env-file-guard.js +113 -0
  23. package/build/executor/env.js +78 -1
  24. package/build/executor/heartbeat.js +9 -0
  25. package/build/executor/http-client.js +90 -22
  26. package/build/executor/job-errors.js +43 -2
  27. package/build/executor/job-runner.js +137 -29
  28. package/build/executor/merge-job.js +102 -6
  29. package/build/executor/permissions.js +106 -0
  30. package/build/executor/preflight.js +38 -13
  31. package/build/executor/resume-pre-spawn.js +2 -1
  32. package/build/executor/runner.js +175 -4
  33. package/build/executor/service-unit.js +15 -0
  34. package/build/executor/terminal-mutation.js +22 -1
  35. package/build/executor/types.js +86 -0
  36. package/build/executor/worker-command.js +21 -5
  37. package/build/executor/worker-guard-hook.js +939 -0
  38. package/build/executor/worker-log.js +56 -0
  39. package/build/executor/worktree.js +11 -0
  40. package/build/git-reachability.js +147 -0
  41. package/build/index.js +535 -95
  42. package/build/install-bridge.js +95 -0
  43. package/build/pipelines.generated.js +10 -2
  44. package/build/plan-epic-conductor-eligibility.js +213 -0
  45. package/build/plane/cli.js +78 -15
  46. package/build/plane/defaults.js +165 -0
  47. package/build/plane/manifest.js +63 -8
  48. package/build/plane/member-logs.js +6 -0
  49. package/build/plane/member-roster.js +195 -11
  50. package/build/plane/preflight.js +43 -0
  51. package/build/plane/shutdown.js +25 -3
  52. package/build/plane/status.js +11 -0
  53. package/build/plane/supervisor.js +343 -14
  54. package/build/plane/test-fakes.js +43 -0
  55. package/build/plane/types.js +82 -11
  56. package/build/pr-base-contract.js +20 -0
  57. package/build/readme.generated.js +1 -1
  58. package/build/review-synthesis-config.js +60 -0
  59. package/build/scripts/executor-protocol-contract-driver.js +311 -0
  60. package/build/setup-epic.js +592 -139
  61. package/build/sfcc/log-query.js +2 -1
  62. package/build/sfcc/reads-custom-object-def.js +10 -13
  63. package/build/sfcc/reads-site-preference.js +5 -5
  64. package/build/sfcc/reads-system-object.js +4 -4
  65. package/build/sfcc/writes-custom-object-def.js +7 -7
  66. package/build/sfcc/writes-site-preference.js +4 -3
  67. package/build/sfcc/writes-system-object.js +7 -6
  68. package/build/start-tickets-conductor.js +11 -2
  69. package/build/start-tickets.js +69 -2
  70. package/build/version.generated.js +3 -3
  71. package/build/worker-containment-diagnostic.js +97 -0
  72. package/build/worker-guard-hook-bin.js +6 -0
  73. package/docs/CONDUCTOR.md +27 -0
  74. package/docs/install/mcp-tool-integrations.md +3 -2
  75. package/package.json +5 -3
  76. package/pipelines/plan-epic.json +5 -0
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Fail-closed pre-spawn env-file strip for an executor worktree (BAPI-1019, A.3).
3
+ *
4
+ * Architecture Miss 28: a v2 conductor worker ran an allowed `pytest` whose
5
+ * harness truncated the operator's real operational database. The worker could
6
+ * reach that database because its worktree carried a copy of the operator's
7
+ * `.env`, delivered by Worktrunk's `copy-ignored` step. `.config/wt.toml` now
8
+ * excludes `.env*` from that copy, which removes the cause — but Worktrunk runs
9
+ * `post-start` hooks in the BACKGROUND after `wt switch` returns, and any local
10
+ * tooling an operator adds can write into a worktree at any time. A source-level
11
+ * exclusion therefore cannot be the whole answer: the executor has to check, at
12
+ * the last possible moment, that the worktree it is about to hand a worker is
13
+ * actually free of environment files.
14
+ *
15
+ * That is this module. It runs as the FINAL step before `deps.spawnProcess` and
16
+ * it fails CLOSED — following `StaleArtifactCleanupFailed`, not the fail-open
17
+ * deny-layer posture. "Could not confirm clean" and "confirmed clean" must be
18
+ * distinguishable outcomes, and only the second one is allowed to spawn.
19
+ *
20
+ * THE SINGLE MOST IMPORTANT DETAIL: this module uses `lstat` and `unlink` and
21
+ * NEVER follows a symbolic link. An interactive worktree's `.env` is a symlink
22
+ * into the main checkout (see `../env-file-link.ts`), and if an entry like that
23
+ * ever appeared in an executor worktree, resolving it and unlinking the RESOLVED
24
+ * target would delete the operator's real `.env`. `unlink` removes the directory
25
+ * entry itself, so a link is removed as a link. There is deliberately no `stat`,
26
+ * `realpath`, `readlink`, or `readFile` in the dependency surface — the guarantee
27
+ * is enforced by what the module cannot reach, not by remembering not to call it.
28
+ *
29
+ * The re-list after removal is what makes the two outcomes tellable apart. An
30
+ * unreadable directory, a failed `lstat`, a failed `unlink`, or a surviving entry
31
+ * all produce `ok: false`; only a second listing that shows no matching entry
32
+ * produces `ok: true`.
33
+ *
34
+ * REDACTION. A failure returns a fixed CATEGORY and nothing else — no filename,
35
+ * no path, no resolved target, no exception text. This value reaches the job row
36
+ * and the executor's stderr, and a worktree path contains an operator's username.
37
+ * Successful `removed` names are safe by construction: they are basenames drawn
38
+ * from a fixed pattern, and knowing that `.env` was removed is the diagnostic. See
39
+ * `secretFreeErrorMessage()` / `ERROR_MESSAGE_MAX_CHARS` in `job-errors.ts` for
40
+ * the bounding posture these strings satisfy.
41
+ */
42
+ import { pathApiForExecutorPlatform } from "./worktree-inspection.js";
43
+ /**
44
+ * True for `.env` and anything under the `.env.` prefix.
45
+ *
46
+ * Deliberately WIDER than the interactive linker's closed two-name list: removing
47
+ * too much from a worker worktree is harmless, and an operator's `.env.staging`
48
+ * is exactly as dangerous as their `.env`. It is still a PREFIX rule, not a
49
+ * substring one — `.envrc` (direnv) and `.envx` do not match, because they are
50
+ * not environment files this contract is about and silently deleting an
51
+ * operator's direnv config would be a surprise well outside this ticket.
52
+ */
53
+ export function isWorkerEnvFileName(name) {
54
+ return name === ".env" || name.startsWith(".env.");
55
+ }
56
+ /**
57
+ * Strip every `.env` / `.env.*` entry from a worktree ROOT and prove it worked.
58
+ *
59
+ * Only the root is examined. A nested `.env` (a fixture under `tests/`, a
60
+ * vendored example) is not what reaches a process's environment loader, and
61
+ * walking the tree would turn a fast pre-spawn gate into an unbounded traversal
62
+ * of a whole checkout.
63
+ */
64
+ export async function stripWorkerEnvFiles(worktreePath, deps) {
65
+ const pathApi = pathApiForExecutorPlatform(deps.platform ?? process.platform);
66
+ let entries;
67
+ try {
68
+ entries = await deps.readdir(worktreePath);
69
+ }
70
+ catch {
71
+ // Cannot even see the directory ⇒ cannot confirm it is clean. Refuse.
72
+ return { ok: false, category: "listing_failed" };
73
+ }
74
+ // Sorted so a successful `removed` list is deterministic and two job rows for
75
+ // the same condition render identically.
76
+ const matches = entries.filter(isWorkerEnvFileName).sort();
77
+ for (const name of matches) {
78
+ const target = pathApi.join(worktreePath, name);
79
+ try {
80
+ // Non-following metadata. The result is deliberately unused for a decision:
81
+ // a regular file and a symlink are both removed the same way, by `unlink`.
82
+ // The call is here so an entry that cannot be inspected refuses the spawn
83
+ // instead of being unlinked blind.
84
+ await deps.lstat(target);
85
+ }
86
+ catch {
87
+ return { ok: false, category: "metadata_failed" };
88
+ }
89
+ try {
90
+ await deps.unlink(target);
91
+ }
92
+ catch {
93
+ // Includes the directory case (`EISDIR`/`EPERM`): a `.env` that is a
94
+ // DIRECTORY is not removable by `unlink`, and this guard deliberately does
95
+ // not escalate to a recursive delete inside a worktree.
96
+ return { ok: false, category: "unlink_failed" };
97
+ }
98
+ }
99
+ // The confirmation half. Without this, "we called unlink" would be reported as
100
+ // "the worktree is clean", which is exactly the claim this gate must not make
101
+ // on faith.
102
+ let remaining;
103
+ try {
104
+ remaining = await deps.readdir(worktreePath);
105
+ }
106
+ catch {
107
+ return { ok: false, category: "verification_listing_failed" };
108
+ }
109
+ if (remaining.some(isWorkerEnvFileName)) {
110
+ return { ok: false, category: "entry_survived" };
111
+ }
112
+ return { ok: true, removed: matches };
113
+ }
@@ -33,13 +33,26 @@
33
33
  * executor must not reach a worker and silently change which configuration —
34
34
  * and which MCP registrations — the CLI resolves.
35
35
  *
36
+ * BAPI-1019: this builder also injects four SENTINEL DATABASE literals
37
+ * (`DATABASE_URL`, `POSTGRES_DATABASE`, `POSTGRES_DB`, `PGDATABASE`), all naming a
38
+ * database that does not exist. Architecture Miss 28 was a worker that ran an
39
+ * allowed `pytest` whose harness truncated the operator's real operational
40
+ * database; it could name that database because a copied `.env` reached its
41
+ * worktree and nothing in the worker environment said otherwise. The env-file
42
+ * strip in `env-file-guard.ts` removes the file, and these constants are the
43
+ * SECOND layer: code inside a worker that resolves a database from the
44
+ * environment resolves a name that connects to nothing. The two layers are
45
+ * independent on purpose — the harness calls `load_dotenv(override=False)`, so a
46
+ * value already in the environment WINS over a `.env` entry, which is what makes
47
+ * the sentinel effective even if a file ever survived the strip.
48
+ *
36
49
  * The `mcp-invoke` shim (a separate process) resolves `BAPI_API_KEY` itself at
37
50
  * call time from the operator's real user-scoped credential store, exactly as it
38
51
  * did before BAPI-731, so this worker environment never carries it. The model is
39
52
  * still denied direct reads of that store by the deny layer
40
53
  * (`Read(~/.config/bridge/**)` in `permissions.ts`).
41
54
  */
42
- import { PR_BASE_BRANCH_ENV_VAR } from "../pr-base-contract.js";
55
+ import { PR_BASE_BRANCH_ENV_VAR, WORKER_BRANCH_ENV_VAR } from "../pr-base-contract.js";
43
56
  import { INDEX_SCOPE_ENV_VAR, validateOptionalIndexScope } from "../index-scope-contract.js";
44
57
  /** Non-secret operational keys forwarded to the worker when present. */
45
58
  const ALLOWED_ENV_KEYS = [
@@ -56,6 +69,36 @@ const ALLOWED_ENV_KEYS = [
56
69
  "TERM",
57
70
  "NO_COLOR",
58
71
  ];
72
+ /**
73
+ * The database name every worker resolves, and it exists nowhere (BAPI-1019).
74
+ *
75
+ * A NAME rather than an empty value, deliberately: an unset `POSTGRES_DB` makes a
76
+ * client fall back to a default (often the connecting user's name, which on a
77
+ * developer host is a real database), while a name nothing has ever created fails
78
+ * at connect and says so. The failure text carries this string, which is what the
79
+ * harness drill asserts on — proving the name reached the driver rather than
80
+ * merely that a connection failed.
81
+ */
82
+ export const WORKER_SENTINEL_DATABASE_NAME = "bridge_worker_no_database";
83
+ /**
84
+ * The sentinel connection URL. Points at port 1 on loopback — a privileged port
85
+ * nothing binds — so the failure is immediate and local rather than a DNS lookup
86
+ * or a timeout against something real.
87
+ */
88
+ export const WORKER_SENTINEL_DATABASE_URL = `postgresql://${WORKER_SENTINEL_DATABASE_NAME}@127.0.0.1:1/${WORKER_SENTINEL_DATABASE_NAME}`;
89
+ /**
90
+ * Every environment key that names a database target, in the order they are set.
91
+ *
92
+ * `DATABASE_URL` is listed first because it WINS over the `POSTGRES_*` trio in
93
+ * `db/utils.py`; the other three are set anyway so a code path that reads only
94
+ * the discrete parts is covered too.
95
+ */
96
+ export const WORKER_SENTINEL_DATABASE_ENV_KEYS = [
97
+ "DATABASE_URL",
98
+ "POSTGRES_DATABASE",
99
+ "POSTGRES_DB",
100
+ "PGDATABASE",
101
+ ];
59
102
  /** Substrings that mark a key as secret-bearing (case-insensitive). */
60
103
  const SECRET_NAME_FRAGMENTS = ["TOKEN", "SECRET", "PASSWORD", "API_KEY"];
61
104
  /**
@@ -101,6 +144,24 @@ export const EXPLICIT_DENY_KEYS = [
101
144
  // decision, and inheriting one would route an unscoped worker's plan and review
102
145
  // at another epic's shadow index while nothing in the job said so.
103
146
  INDEX_SCOPE_ENV_VAR,
147
+ // BAPI-1020: the worker's own branch follows the same rule, and for this key the
148
+ // rule is the guard's integrity rather than a research-scope mistake. The
149
+ // deterministic PreToolUse guard decides "is this push destination my own
150
+ // branch?" by comparing against this value, so an operator's ambient
151
+ // `BAPI_WORKER_BRANCH` reaching a worker would let the environment redefine what
152
+ // the guard treats as safe. Absent from ALLOWED_ENV_KEYS so it is never copied
153
+ // from `parentEnv`, and denied EXPLICITLY here so the answer is local and
154
+ // auditable rather than inferred from an omission.
155
+ WORKER_BRANCH_ENV_VAR,
156
+ // BAPI-1019: the four database keys follow the BAPI_INDEX_SCOPE precedent
157
+ // exactly — absent from ALLOWED_ENV_KEYS so they are never copied from
158
+ // `parentEnv`, and denied EXPLICITLY here so an operator's ambient value can
159
+ // never win over the sentinel constants the builder sets. Denying a key the
160
+ // builder also sets is not redundant: the deny list is what makes
161
+ // `isExecutorEnvKeyAllowed` answer "no" for these names locally and
162
+ // auditably, rather than relying on a reader noticing they are missing from
163
+ // the allowlist.
164
+ ...WORKER_SENTINEL_DATABASE_ENV_KEYS,
104
165
  ];
105
166
  /**
106
167
  * True only when `key` is a safe, allowlisted operational key. Exported so the
@@ -140,10 +201,26 @@ export function buildExecutorBaseWorkerEnv(parentEnv, options = {}) {
140
201
  }
141
202
  env.BRIDGE_SKIP_PREPUSH = "1";
142
203
  env.MAX_MCP_OUTPUT_TOKENS = "50000";
204
+ // BAPI-1019 — the sentinel database target, set for EVERY job type and every
205
+ // adapter, from constants and never from `parentEnv`. Set here rather than in
206
+ // an adapter because "a worker cannot name a real database" is a property of
207
+ // being a worker, not of being a Claude worker.
208
+ env.DATABASE_URL = WORKER_SENTINEL_DATABASE_URL;
209
+ env.POSTGRES_DATABASE = WORKER_SENTINEL_DATABASE_NAME;
210
+ env.POSTGRES_DB = WORKER_SENTINEL_DATABASE_NAME;
211
+ env.PGDATABASE = WORKER_SENTINEL_DATABASE_NAME;
143
212
  if (typeof options.effectiveBaseBranch === "string" &&
144
213
  options.effectiveBaseBranch.length > 0) {
145
214
  env[PR_BASE_BRANCH_ENV_VAR] = options.effectiveBaseBranch;
146
215
  }
216
+ // BAPI-1020 — the worker's own branch, set ONLY from the explicit job value and
217
+ // ONLY when it is a non-empty string. An empty or absent value adds no key at
218
+ // all, so an unscoped job's environment stays byte-identical to the
219
+ // pre-BAPI-1020 one rather than carrying an empty string the guard would have to
220
+ // special-case.
221
+ if (typeof options.workerBranch === "string" && options.workerBranch.length > 0) {
222
+ env[WORKER_BRANCH_ENV_VAR] = options.workerBranch;
223
+ }
147
224
  // BAPI-844: the index-scope declaration, set ONLY from the explicit option and
148
225
  // only after the shared shape check. A malformed explicit value throws here —
149
226
  // before an environment exists — rather than being dropped: silently omitting it
@@ -79,6 +79,15 @@ export async function runHeartbeatLoop(params) {
79
79
  killOwnedProcess(proc, "server_stop");
80
80
  return { kind: "server_stop" };
81
81
  }
82
+ if (result.outcome === "auth_fatal") {
83
+ // BAPI-1021 (AC-5): bypass the dead-man wait entirely — an auth failure
84
+ // will never resolve itself by waiting out `deadmanMs`. Deliberately does
85
+ // NOT reset `lastSuccessfulHeartbeatAt`, set `ownership.abandoned`/
86
+ // `abandonReason` (this is not a lost/stale claim), or invoke worktree
87
+ // reset/recreation/cleanup: the current job drains normally while the
88
+ // runner's process-level handler stops future claiming.
89
+ return { kind: "auth_fatal", httpStatus: result.authHttpStatus ?? 401 };
90
+ }
82
91
  if (result.outcome === "updated") {
83
92
  ownership.lastSuccessfulHeartbeatAt = deps.now();
84
93
  return null;
@@ -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