@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
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Interactive worktree environment-file LINKING (BAPI-1019, Slice A / A.2).
3
+ *
4
+ * Architecture Miss 28 was a conductor worker that truncated the operator's real
5
+ * operational database. It could reach that database because Worktrunk's
6
+ * `copy-ignored` step had placed a COPY of the operator's `.env` inside the
7
+ * worker's worktree. BAPI-1019 removes `.env*` from that copy step at source
8
+ * (`.config/wt.toml`), which fixes the executor case outright — an executor
9
+ * worktree must hold neither a copy nor a link — but it also takes `.env` away
10
+ * from the INTERACTIVE worktrees a human actually works in, which legitimately
11
+ * need it.
12
+ *
13
+ * This module gives that file back by REFERENCE rather than by value. For each
14
+ * supported name present in the main checkout it creates
15
+ * `<worktree>/<name>` as a SYMLINK to the main checkout's file, so an operator's
16
+ * secrets exist exactly once at rest no matter how many worktrees are open, and
17
+ * editing the operator's `.env` is immediately visible everywhere instead of
18
+ * drifting per worktree.
19
+ *
20
+ * CONTAINMENT RULES, all load-bearing:
21
+ *
22
+ * - This module is for the INTERACTIVE path only. `createWorktrees()` in
23
+ * `start-tickets.ts` is its sole production caller; `ensureExecutorWorktree()`
24
+ * must never call it. An executor worktree that held a link would resolve to
25
+ * the operator's real `.env` exactly as a copy did.
26
+ * - It NEVER reads or logs file CONTENTS. Its whole dependency surface is
27
+ * metadata (`stat`/`lstat`), link creation, unlinking, and — only on a
28
+ * platform that cannot make links — a copy. There is deliberately no
29
+ * `readFile` seam to misuse.
30
+ * - Its returned warnings are FIXED text plus a closed category. No absolute
31
+ * path, username, resolved link target, file content, or exception text
32
+ * reaches the caller, because these strings surface in `start-tickets`'
33
+ * operator-facing summary.
34
+ * - A destination that already exists as a REGULAR FILE (a copy left behind by
35
+ * an earlier run, before this ticket) is replaced with a link. That is the
36
+ * migration path off the old behavior: leaving the copy in place would leave
37
+ * the second at-rest copy this ticket exists to remove.
38
+ *
39
+ * ACCEPTED WINDOWS GAP. Creating a symlink on Windows requires Developer Mode or
40
+ * an elevated process, so `symlink` there commonly fails with `EPERM`. On that
41
+ * recognized failure this module falls back to a COPY and returns a warning
42
+ * rather than throwing: an interactive Windows worktree with a copied `.env` is
43
+ * the behavior operators already had, whereas a hard failure would break worktree
44
+ * creation outright. The residual second-copy-at-rest gap on Windows is recorded
45
+ * as accepted in the ticket's exploration ("Criteria Coverage"), not as a defect
46
+ * to solve here. It does NOT weaken the executor guarantee, which is enforced by
47
+ * `executor/env-file-guard.ts` on every platform.
48
+ */
49
+ import { pathApiForPlatform } from "./worktree-core.js";
50
+ /**
51
+ * The operator environment files an interactive worktree may receive by link.
52
+ *
53
+ * A CLOSED list, not a glob. `.env` and `.env.test` are the two files this
54
+ * repository's own tooling reads; matching `.env.*` here would start linking
55
+ * whatever an operator happens to have lying around (`.env.production`,
56
+ * `.env.backup`) into every worktree, which is a wider blast radius than the
57
+ * problem needs. The executor's strip, by contrast, is deliberately a WIDE
58
+ * pattern — removing too much from a worker is safe, linking too much is not.
59
+ */
60
+ export const LINKED_OPERATOR_ENV_FILES = [".env", ".env.test"];
61
+ /**
62
+ * The FIXED warning texts. Basenames are the only variable part, and a basename
63
+ * is a constant from {@link LINKED_OPERATOR_ENV_FILES} rather than anything
64
+ * discovered on disk — so no path, username, or secret can enter these strings.
65
+ */
66
+ function symlinkUnsupportedWarning(name) {
67
+ return {
68
+ category: "symlink_unsupported",
69
+ message: `worktree ${name}: this platform could not create a symbolic link, so the file was ` +
70
+ "copied instead. The worktree now holds a second copy of that environment file at " +
71
+ "rest. On Windows, enable Developer Mode (or run elevated) to get links instead.",
72
+ };
73
+ }
74
+ function linkFailedWarning(name) {
75
+ return {
76
+ category: "link_failed",
77
+ message: `worktree ${name}: could not be provided from the main checkout, so this worktree has ` +
78
+ "no copy of it. Commands that need it will fail until it is linked by hand.",
79
+ };
80
+ }
81
+ /**
82
+ * Error codes that mean "this platform/filesystem cannot make a symlink here",
83
+ * as opposed to an ordinary failure. `EPERM` is the Windows non-Developer-Mode
84
+ * signature; the rest cover filesystems and runtimes without link support.
85
+ */
86
+ const SYMLINK_UNSUPPORTED_CODES = new Set([
87
+ "EPERM",
88
+ "ENOSYS",
89
+ "EOPNOTSUPP",
90
+ "ENOTSUP",
91
+ "UNKNOWN",
92
+ ]);
93
+ function errorCode(err) {
94
+ if (!err || typeof err !== "object")
95
+ return undefined;
96
+ const code = err.code;
97
+ return typeof code === "string" ? code : undefined;
98
+ }
99
+ /** True when two metadata records describe the same underlying file. */
100
+ function sameFile(a, b) {
101
+ if (!a || !b)
102
+ return false;
103
+ return a.dev === b.dev && a.ino === b.ino;
104
+ }
105
+ /**
106
+ * Provide the main checkout's operator environment files to an interactive
107
+ * worktree as symlinks.
108
+ *
109
+ * Never throws: every per-name failure becomes a fixed warning, because a
110
+ * worktree that was created successfully must not be reported as failed just
111
+ * because a convenience link could not be made. A name whose SOURCE is absent in
112
+ * the main checkout is an ordinary no-op with no warning at all — most checkouts
113
+ * have `.env` and no `.env.test`, and warning about that would train operators to
114
+ * ignore this channel.
115
+ */
116
+ export async function linkOperatorEnvFiles(mainCheckout, worktreePath, deps) {
117
+ const pathApi = pathApiForPlatform(deps.platform ?? process.platform);
118
+ const result = { linked: [], copied: [], unchanged: [], warnings: [] };
119
+ for (const name of LINKED_OPERATOR_ENV_FILES) {
120
+ const source = pathApi.join(mainCheckout, name);
121
+ const destination = pathApi.join(worktreePath, name);
122
+ try {
123
+ // The main checkout is the authority on whether this name exists at all.
124
+ // Checked FIRST so a missing source touches the destination in no way.
125
+ const sourceStats = await deps.stat(source);
126
+ if (!sourceStats || !sourceStats.isFile())
127
+ continue;
128
+ const existing = await deps.lstat(destination);
129
+ if (existing) {
130
+ if (existing.isSymbolicLink() && sameFile(await deps.stat(destination), sourceStats)) {
131
+ // Already pointing at this exact file. Re-creating it would be churn,
132
+ // and every unlink of a correct entry is a window in which the worktree
133
+ // has no `.env` at all.
134
+ result.unchanged.push(name);
135
+ continue;
136
+ }
137
+ // A stale link, or a regular-file copy from before this ticket. Remove
138
+ // the ENTRY — `unlink` never follows, so the main checkout's real file is
139
+ // untouched even when the entry is a link pointing straight at it.
140
+ await deps.unlink(destination);
141
+ }
142
+ try {
143
+ await deps.symlink(source, destination);
144
+ result.linked.push(name);
145
+ }
146
+ catch (err) {
147
+ if (!SYMLINK_UNSUPPORTED_CODES.has(errorCode(err) ?? ""))
148
+ throw err;
149
+ // The accepted platform gap. Fall back to the old behavior rather than
150
+ // leaving the worktree without a file it needs.
151
+ await deps.copyFile(source, destination);
152
+ result.copied.push(name);
153
+ result.warnings.push(symlinkUnsupportedWarning(name));
154
+ }
155
+ }
156
+ catch {
157
+ // Bounded on purpose: the caught error is never inspected for text, only
158
+ // discarded. A filesystem error message can carry an absolute path (which
159
+ // contains a username) and this string is printed to an operator summary.
160
+ result.warnings.push(linkFailedWarning(name));
161
+ }
162
+ }
163
+ return result;
164
+ }
@@ -21,6 +21,12 @@ export const EPIC_INTEGRATION_PR_WRITE_TIMEOUT_MS = 20_000;
21
21
  /** Bounded result count for the existence probe. */
22
22
  const LIST_LIMIT = 20;
23
23
  const PR_LIST_JSON_FIELDS = "number,headRefName,baseRefName,isDraft";
24
+ /**
25
+ * `epic/<KEY>` prefix a child PR's base branch carries under BAPI-949/BAPI-950.
26
+ * Exported (BAPI-1010) so every call site that derives an epic key from a base
27
+ * branch shares one definition instead of re-declaring a private literal.
28
+ */
29
+ export const EPIC_BRANCH_PREFIX = "epic/";
24
30
  /** The production call sites this helper is invoked from. */
25
31
  export const EPIC_INTEGRATION_PR_COMMANDS = [
26
32
  "setup-epic",
@@ -28,6 +34,10 @@ export const EPIC_INTEGRATION_PR_COMMANDS = [
28
34
  "conduct-epic catch-up",
29
35
  "conduct-epic finish",
30
36
  "executor merge",
37
+ // BAPI-1010: the executor's one-shot wind-down observer, distinct from
38
+ // "executor merge" so the generated PR body and diagnostics never mislabel
39
+ // a done-state readiness request as a ticket merge enrichment.
40
+ "executor wind-down",
31
41
  ];
32
42
  /** Only the sanctioned safe fields. Never raw command output. */
33
43
  export function formatEpicIntegrationPullRequestOutcome(outcome) {
@@ -40,7 +40,11 @@ export function getExecutorUsage() {
40
40
  " --repo <name> Repo to serve (repeatable).",
41
41
  " --repos=<a,b> Comma-separated repos.",
42
42
  " --epic-run-id <id> Dedicate this executor to an epic run (repeatable).",
43
- " Omit for the default repository-wide behavior.",
43
+ " --repo-wide Explicit compatibility opt-out: claim from every",
44
+ " authorized repo, oldest-first, unscoped by run.",
45
+ " Exactly one of --epic-run-id or --repo-wide is",
46
+ " REQUIRED; omitting both is a startup error, not",
47
+ " repository-wide claiming (BAPI-1026 / R54).",
44
48
  " --base-url <url> Bridge API endpoint (overrides BAPI_BASE_URL).",
45
49
  " --executor-id <id> Stable executor id (default: <hostname>-<pid>).",
46
50
  " --max-concurrent <n> Max concurrent jobs (>= 1, default 1).",
@@ -83,6 +87,7 @@ function isValidEpicRunId(value) {
83
87
  export function parseExecutorArgs(argv, context) {
84
88
  const repos = [];
85
89
  const epicRunIds = [];
90
+ let repoWide = false;
86
91
  let executorId;
87
92
  let maxConcurrent = DEFAULT_MAX_CONCURRENT;
88
93
  let once = false;
@@ -122,6 +127,9 @@ export function parseExecutorArgs(argv, context) {
122
127
  if (!epicRunIds.includes(trimmed))
123
128
  epicRunIds.push(trimmed);
124
129
  }
130
+ else if (arg === "--repo-wide") {
131
+ repoWide = true;
132
+ }
125
133
  else if (arg === "--executor-id") {
126
134
  executorId = argv[++i];
127
135
  if (!executorId)
@@ -182,6 +190,29 @@ export function parseExecutorArgs(argv, context) {
182
190
  const executorIdFinal = executorId && executorId.trim().length > 0
183
191
  ? executorId.trim()
184
192
  : `${context.hostname}-${context.pid}`;
193
+ // BAPI-1026 (R54) — the claim scope is now MANDATORY and explicit.
194
+ //
195
+ // Before this ticket, omitting `--epic-run-id` silently meant "claim the
196
+ // oldest queued row of ANY run in every authorized repo", which is how an
197
+ // executor could take a job belonging to an abandoned run (BAPI-993 B2). The
198
+ // server no longer treats an omitted scope as repository-wide once its
199
+ // deprecation window closes, so an executor started with no scope would poll
200
+ // forever and claim nothing. Refusing to START is the loud failure that
201
+ // replaces that silent idle: a misconfiguration is a startup error, visible
202
+ // immediately, rather than an executor that looks healthy and does no work.
203
+ if (repoWide && epicRunIds.length > 0) {
204
+ return {
205
+ kind: "error",
206
+ message: "--repo-wide cannot be combined with --epic-run-id: pass one claim scope, not both",
207
+ };
208
+ }
209
+ if (!repoWide && epicRunIds.length === 0) {
210
+ return {
211
+ kind: "error",
212
+ message: "a claim scope is required: pass --epic-run-id <id> (repeatable) to serve " +
213
+ "specific epic runs, or --repo-wide to deliberately claim repository-wide",
214
+ };
215
+ }
185
216
  const options = {
186
217
  executorId: executorIdFinal,
187
218
  repos,
@@ -208,6 +239,9 @@ export function parseExecutorArgs(argv, context) {
208
239
  // was supplied, so downstream `epicRunIds !== undefined` checks (the claim
209
240
  // manifest builder, startup diagnostics) see an unambiguous "unscoped".
210
241
  ...(epicRunIds.length > 0 ? { epicRunIds } : {}),
242
+ // BAPI-1026 (R54) — same "truly absent, never a falsy placeholder"
243
+ // discipline: `repoWide` is either `true` or the key is missing.
244
+ ...(repoWide ? { repoWide: true } : {}),
211
245
  };
212
246
  return { kind: "ok", options };
213
247
  }
@@ -267,14 +301,15 @@ export async function runExecutorCli(argv, overrides = {}) {
267
301
  return 1;
268
302
  }
269
303
  const options = parsed.options;
270
- // BAPI-794 — startup diagnostics: only non-secret run identifiers, never
271
- // credentials, job payloads, or command arguments beyond the run IDs
272
- // themselves (which are not secrets see EPIC_RUN_ID_PATTERN's docstring).
304
+ // BAPI-794/BAPI-1026 — startup diagnostics: a bounded MODE label and, when
305
+ // scoped, only the COUNT of runs. Never credentials, job payloads, command
306
+ // arguments and, since BAPI-1026, never the run IDs either, matching the
307
+ // per-claim logging policy the server side already enforces.
273
308
  if (options.epicRunIds !== undefined) {
274
- errorLog(`executor scoped to epic run(s): ${options.epicRunIds.join(", ")}`);
309
+ errorLog(`executor claim scope: scoped (epic_run_count=${options.epicRunIds.length})`);
275
310
  }
276
311
  else {
277
- errorLog("executor running repository-wide (no --epic-run-id configured)");
312
+ errorLog("executor claim scope: repo_wide (explicit --repo-wide)");
278
313
  }
279
314
  // The mutating executor requires an EXPLICIT base URL (BAPI-676): `--base-url`
280
315
  // then `BAPI_BASE_URL`, never an implicit production default. Fail here —
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { execFile, spawn } from "node:child_process";
11
11
  import { existsSync } from "node:fs";
12
- import { open, readFile, writeFile, appendFile, mkdir, mkdtemp, chmod, rm, readdir, lstat, stat, statfs, } from "node:fs/promises";
12
+ import { open, readFile, writeFile, appendFile, mkdir, mkdtemp, chmod, rm, rename, readdir, lstat, stat, statfs, } from "node:fs/promises";
13
13
  import os from "node:os";
14
14
  import { promisify } from "node:util";
15
15
  import { resolveMcpShimInvocationForRuntime } from "../mcp-server-invocation.js";
@@ -92,6 +92,10 @@ export function createDefaultExecutorDeps() {
92
92
  }
93
93
  },
94
94
  removeFile: (filePath) => rm(filePath, { force: true }),
95
+ // BAPI-1021 (AC-6): worker-log archival's rename seam. Real `fs.promises.rename`
96
+ // rejects with `code: "EXDEV"` across filesystems/devices, which worker-log.ts
97
+ // treats as an ordinary best-effort archive failure like any other.
98
+ rename: (oldPath, newPath) => rename(oldPath, newPath),
95
99
  stat: (filePath) => stat(filePath).then((s) => ({ mode: s.mode })),
96
100
  statMtimeMs: (filePath) => stat(filePath)
97
101
  .then((s) => s.mtimeMs)
@@ -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;