@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.
- package/README.md +24 -8
- package/build/agent-capabilities/probe-context.js +15 -7
- package/build/agent-capabilities/probes.js +42 -6
- package/build/agent-launchers/claude-executor-adapter.js +98 -14
- package/build/commands.generated.js +1 -1
- package/build/conduct-epic/cut-protocol.js +17 -3
- package/build/conductor/bridge-api-client.js +171 -5
- package/build/conductor/deny-enforcement-preflight.js +107 -10
- package/build/conductor/local-merge.js +170 -11
- package/build/conductor-bin.js +2 -2
- package/build/connect-bitbucket-api.js +370 -0
- package/build/connect-bitbucket.js +437 -0
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +40 -1
- package/build/drive-epic.js +423 -11
- package/build/env-file-link.js +164 -0
- package/build/epic-integration-pr.js +10 -0
- package/build/executor/cli.js +41 -6
- package/build/executor/deps.js +5 -1
- package/build/executor/env-file-guard.js +113 -0
- package/build/executor/env.js +78 -1
- package/build/executor/heartbeat.js +9 -0
- package/build/executor/http-client.js +90 -22
- package/build/executor/job-errors.js +43 -2
- package/build/executor/job-runner.js +130 -28
- package/build/executor/merge-job.js +67 -16
- package/build/executor/permissions.js +106 -0
- package/build/executor/preflight.js +38 -13
- package/build/executor/resume-pre-spawn.js +2 -1
- package/build/executor/runner.js +175 -4
- package/build/executor/service-unit.js +15 -0
- package/build/executor/terminal-mutation.js +22 -1
- package/build/executor/types.js +86 -0
- package/build/executor/worker-command.js +21 -5
- package/build/executor/worker-guard-hook.js +939 -0
- package/build/executor/worker-log.js +56 -0
- package/build/executor/worktree.js +11 -0
- package/build/git-reachability.js +147 -0
- package/build/index.js +514 -121
- package/build/install-bridge.js +95 -0
- package/build/pipelines.generated.js +5 -3
- package/build/plan-epic-conductor-eligibility.js +37 -7
- package/build/plane/cli.js +78 -15
- package/build/plane/defaults.js +165 -0
- package/build/plane/manifest.js +63 -8
- package/build/plane/member-logs.js +6 -0
- package/build/plane/member-roster.js +195 -11
- package/build/plane/preflight.js +43 -0
- package/build/plane/shutdown.js +25 -3
- package/build/plane/status.js +11 -0
- package/build/plane/supervisor.js +343 -14
- package/build/plane/test-fakes.js +43 -0
- package/build/plane/types.js +82 -11
- package/build/pr-base-contract.js +20 -0
- package/build/readme.generated.js +1 -1
- package/build/review-synthesis-config.js +60 -0
- package/build/scripts/executor-protocol-contract-driver.js +311 -0
- package/build/setup-epic.js +560 -139
- package/build/sfcc/log-query.js +2 -1
- package/build/start-tickets-conductor.js +11 -2
- package/build/start-tickets.js +69 -2
- package/build/version.generated.js +3 -3
- package/build/worker-containment-diagnostic.js +97 -0
- package/build/worker-guard-hook-bin.js +6 -0
- package/docs/CONDUCTOR.md +27 -0
- package/docs/install/mcp-tool-integrations.md +3 -2
- 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) {
|
package/build/executor/cli.js
CHANGED
|
@@ -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
|
-
"
|
|
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:
|
|
271
|
-
//
|
|
272
|
-
//
|
|
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
|
|
309
|
+
errorLog(`executor claim scope: scoped (epic_run_count=${options.epicRunIds.length})`);
|
|
275
310
|
}
|
|
276
311
|
else {
|
|
277
|
-
errorLog("executor
|
|
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 —
|
package/build/executor/deps.js
CHANGED
|
@@ -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
|
+
}
|
package/build/executor/env.js
CHANGED
|
@@ -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;
|