@bridge_gpt/mcp-server 0.2.34 → 0.2.37
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 +490 -366
- package/build/agent-capabilities/probe-context.js +8 -1
- package/build/agent-capabilities/probes.js +7 -1
- package/build/agents.generated.js +1 -1
- package/build/base-url.js +79 -0
- package/build/bridge-api-urls.js +9 -0
- package/build/chain-orchestrator.js +93 -15
- package/build/claude-review-workflow.js +264 -0
- package/build/claude-user-config-doctor.js +317 -0
- package/build/cli-release.js +53 -0
- package/build/commands.generated.js +6 -5
- package/build/conductor/bridge-api-client.js +393 -4
- package/build/conductor/deny-enforcement-preflight.js +1 -0
- package/build/conductor/done-gate.js +44 -5
- package/build/conductor/epic-reconcile.js +6 -0
- package/build/conductor/install-doctor.js +462 -0
- package/build/conductor-bin.js +3 -3
- package/build/conductor-bundle-artifacts.js +37 -15
- package/build/credential-store.js +205 -4
- package/build/direct-ticket-tools.js +70 -0
- package/build/doctor.js +473 -81
- package/build/executor/cli.js +83 -6
- package/build/executor/credentials.js +39 -11
- package/build/executor/deps.js +32 -1
- package/build/executor/env.js +71 -28
- package/build/executor/heartbeat.js +138 -17
- package/build/executor/http-client.js +49 -8
- package/build/executor/index.js +4 -0
- package/build/executor/job-errors.js +4 -0
- package/build/executor/job-runner.js +530 -20
- package/build/executor/observation.js +130 -0
- package/build/executor/permissions.js +114 -8
- package/build/executor/preflight.js +127 -8
- package/build/executor/prompt-spec.js +51 -0
- package/build/executor/runner.js +23 -2
- package/build/executor/service-unit.js +876 -0
- package/build/executor/test-clock.js +75 -3
- package/build/executor/types.js +4 -18
- package/build/executor/worker-command.js +73 -12
- package/build/executor/worker-config-isolation.js +287 -0
- package/build/executor/worker-finalization.js +68 -14
- package/build/executor/worktree.js +46 -4
- package/build/index.js +1039 -237
- package/build/init.js +503 -111
- package/build/install-bridge-conductor.js +491 -0
- package/build/install-bridge.js +1188 -247
- package/build/install-reexec.js +233 -0
- package/build/launcher-config-inspection.js +351 -0
- package/build/mcp-host-config.js +11 -1
- package/build/mcp-install-state.js +32 -0
- package/build/mcp-invoke.js +49 -6
- package/build/mcp-provisioning.js +52 -13
- package/build/mcp-registration-doctor.js +14 -5
- package/build/notifications.js +553 -0
- package/build/pipeline-orchestrator.js +146 -4
- package/build/pipeline-utils.js +3 -0
- package/build/pipelines.generated.js +32 -13
- package/build/plan-execution-ledger.js +550 -0
- package/build/plan-phase-routing.js +272 -0
- package/build/plane/alembic-head.js +110 -0
- package/build/plane/build-freshness.js +167 -0
- package/build/plane/cli.js +480 -0
- package/build/plane/defaults.js +266 -0
- package/build/plane/manifest.js +377 -0
- package/build/plane/member-logs.js +147 -0
- package/build/plane/member-roster.js +147 -0
- package/build/plane/preflight.js +289 -0
- package/build/plane/shutdown.js +195 -0
- package/build/plane/status.js +125 -0
- package/build/plane/supervisor.js +569 -0
- package/build/plane/test-fakes.js +156 -0
- package/build/plane/types.js +75 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +259 -0
- package/build/setup-epic.js +772 -28
- package/build/sfcc/log-gate.js +38 -11
- package/build/sfcc/log-query.js +55 -15
- package/build/sfcc/ocapi-shape.js +51 -14
- package/build/sfcc/output.js +41 -11
- package/build/sfcc/permissions.js +24 -2
- package/build/sfcc/read-projection.js +181 -0
- package/build/sfcc/read-result.js +158 -0
- package/build/sfcc/reads-custom-object-def.js +29 -18
- package/build/sfcc/reads-site-preference.js +75 -29
- package/build/sfcc/reads-system-object.js +40 -34
- package/build/sfcc/sfcc-result.js +106 -0
- package/build/sfcc/tool-wrapper.js +56 -13
- package/build/sfcc/write-grants.js +45 -22
- package/build/sfcc/write-guard.js +21 -13
- package/build/sfcc/write-result.js +61 -14
- package/build/sfcc/write-tool-common.js +126 -32
- package/build/sfcc/writes-system-object.js +11 -50
- package/build/start-tickets-prereqs.js +129 -0
- package/build/start-tickets.js +17 -13
- package/build/ticket-backend-metadata.js +59 -0
- package/build/ticket-key-utils.js +92 -0
- package/build/tool-error-envelope.js +71 -0
- package/build/tool-surface-gating.js +72 -0
- package/build/update-status.js +102 -0
- package/build/upgrade-advice.js +47 -0
- package/build/upgrade-cli.js +412 -110
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +73 -0
- package/docs/CONDUCTOR.md +132 -6
- package/docs/install/mcp-tool-integrations.md +29 -21
- package/package.json +9 -6
- package/pipelines/implement-ticket.json +19 -4
- package/build/conductor/supervisor-judgment-python.js +0 -141
- package/build/conductor/supervisor-judgment.js +0 -215
|
@@ -53,6 +53,118 @@ export async function collectGitTelemetry(deps, worktreePath, baseBranch) {
|
|
|
53
53
|
}
|
|
54
54
|
return telemetry;
|
|
55
55
|
}
|
|
56
|
+
/** A full 40-character hex object name. */
|
|
57
|
+
const FULL_SHA_PATTERN = /^[0-9a-f]{40}$/i;
|
|
58
|
+
/** Normalize a candidate SHA, or `null` when it is absent/malformed. */
|
|
59
|
+
function normalizeSha(value) {
|
|
60
|
+
if (typeof value !== "string")
|
|
61
|
+
return null;
|
|
62
|
+
const trimmed = value.trim();
|
|
63
|
+
return FULL_SHA_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Fail-CLOSED pre-spawn contract over already-collected telemetry (BAPI-731,
|
|
67
|
+
* WS-E). Pure: it shells out to nothing and reuses `collectGitTelemetry()`'s
|
|
68
|
+
* existing reads rather than adding new git invocations.
|
|
69
|
+
*
|
|
70
|
+
* THE DEGRADED-READ RULE IS THE POINT. `collectGitTelemetry()` degrades silently
|
|
71
|
+
* — a failed `status --porcelain` simply omits `dirty`, and a failed
|
|
72
|
+
* `rev-parse HEAD` omits `last_commit_sha`. For ordinary telemetry that is
|
|
73
|
+
* correct (an observability gap must never fail a worker). For a fail-closed
|
|
74
|
+
* assertion it is the opposite: an omitted field means "could not verify", and
|
|
75
|
+
* "could not verify" must never be reported as "verified clean". Both omissions
|
|
76
|
+
* therefore REFUSE.
|
|
77
|
+
*
|
|
78
|
+
* `expectedHeadSha` is optional because `resume` legitimately has no
|
|
79
|
+
* remote-derived expectation: its protocol auto-commits dirty state as a WIP
|
|
80
|
+
* checkpoint, so it is verified for cleanliness AFTER that checkpoint but is
|
|
81
|
+
* exempt from SHA equality. Every other path supplies one.
|
|
82
|
+
*
|
|
83
|
+
* Messages are fixed strings plus validated hex SHAs — never porcelain output, a
|
|
84
|
+
* filename, a worktree path, stderr, or raw exception text.
|
|
85
|
+
*/
|
|
86
|
+
export function evaluatePreSpawnGitVerification(telemetry, expectedHeadSha) {
|
|
87
|
+
if (typeof telemetry.dirty !== "boolean") {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
reason: "status-unverifiable",
|
|
91
|
+
message: "pre-spawn verification failed: the worktree's clean/dirty status could not be read, " +
|
|
92
|
+
"so cleanliness cannot be confirmed",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (telemetry.dirty) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
reason: "dirty",
|
|
99
|
+
message: "pre-spawn verification failed: the prepared worktree has uncommitted or untracked " +
|
|
100
|
+
"changes; refusing to spawn a worker that could commit unrelated leftover work",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
const observedSha = normalizeSha(telemetry.last_commit_sha);
|
|
104
|
+
if (observedSha === null) {
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
reason: "head-unverifiable",
|
|
108
|
+
message: "pre-spawn verification failed: the worktree's HEAD commit could not be read, " +
|
|
109
|
+
"so it cannot be confirmed to match the prepared commit",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
// No expectation supplied (`resume`): cleanliness plus a readable HEAD is the
|
|
113
|
+
// whole contract.
|
|
114
|
+
if (expectedHeadSha === undefined) {
|
|
115
|
+
return { ok: true, observedSha };
|
|
116
|
+
}
|
|
117
|
+
const expected = normalizeSha(expectedHeadSha);
|
|
118
|
+
if (expected === null) {
|
|
119
|
+
// Never echo an unvalidated value into a message.
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
reason: "head-unverifiable",
|
|
123
|
+
message: "pre-spawn verification failed: the expected commit supplied by worktree preparation " +
|
|
124
|
+
"is not a valid commit id",
|
|
125
|
+
observedSha,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (expected !== observedSha) {
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
reason: "head-mismatch",
|
|
132
|
+
message: `pre-spawn verification failed: the prepared worktree is at ${observedSha} but ` +
|
|
133
|
+
`preparation expected ${expected}`,
|
|
134
|
+
observedSha,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return { ok: true, observedSha };
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Read the LOCAL remote-tracking marker for a branch (BAPI-731, WS-H).
|
|
141
|
+
*
|
|
142
|
+
* Reads `refs/remotes/origin/<branch>` from the local object store only. It
|
|
143
|
+
* deliberately issues NO network operation — no `fetch`, no `ls-remote`, no
|
|
144
|
+
* provider API — because this runs on the high-frequency telemetry path where a
|
|
145
|
+
* network stall would block the heartbeat loop and defeat the dead-man switch.
|
|
146
|
+
* A worker's own `git push` updates this ref locally, which is precisely the
|
|
147
|
+
* signal being sampled.
|
|
148
|
+
*
|
|
149
|
+
* Degrade-don't-throw, exactly like `collectGitTelemetry()`: any failure or
|
|
150
|
+
* malformed output yields `undefined` and never fails the job.
|
|
151
|
+
*/
|
|
152
|
+
export async function collectRemoteTrackingSha(deps, worktreePath, branch) {
|
|
153
|
+
try {
|
|
154
|
+
const result = await deps.runCommand("git", [
|
|
155
|
+
"-C",
|
|
156
|
+
worktreePath,
|
|
157
|
+
"rev-parse",
|
|
158
|
+
`refs/remotes/origin/${branch}^{commit}`,
|
|
159
|
+
]);
|
|
160
|
+
if (result.exitCode !== 0)
|
|
161
|
+
return undefined;
|
|
162
|
+
return normalizeSha(result.stdout) ?? undefined;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
56
168
|
/**
|
|
57
169
|
* Tolerantly parse one Claude stream-json line for an advisory `phase_hint`.
|
|
58
170
|
* Catches ALL parse/schema errors and never throws; returns `{}` when nothing
|
|
@@ -89,6 +201,8 @@ export function createObservationState(deps, options) {
|
|
|
89
201
|
let git = {};
|
|
90
202
|
const advisory = {};
|
|
91
203
|
let exitCode;
|
|
204
|
+
let attemptStartSha;
|
|
205
|
+
let attemptEndSha;
|
|
92
206
|
return {
|
|
93
207
|
recordStdout(chunk) {
|
|
94
208
|
advisory.last_stdout_at = new Date(deps.now()).toISOString();
|
|
@@ -106,6 +220,16 @@ export function createObservationState(deps, options) {
|
|
|
106
220
|
setExitCode(code) {
|
|
107
221
|
exitCode = code;
|
|
108
222
|
},
|
|
223
|
+
setAttemptStartSha(sha) {
|
|
224
|
+
const normalized = normalizeSha(sha);
|
|
225
|
+
if (normalized)
|
|
226
|
+
attemptStartSha = normalized;
|
|
227
|
+
},
|
|
228
|
+
setAttemptEndSha(sha) {
|
|
229
|
+
const normalized = normalizeSha(sha);
|
|
230
|
+
if (normalized)
|
|
231
|
+
attemptEndSha = normalized;
|
|
232
|
+
},
|
|
109
233
|
git() {
|
|
110
234
|
return git;
|
|
111
235
|
},
|
|
@@ -117,6 +241,12 @@ export function createObservationState(deps, options) {
|
|
|
117
241
|
residue.last_stdout_at = advisory.last_stdout_at;
|
|
118
242
|
if (exitCode !== undefined)
|
|
119
243
|
residue.exit_code = exitCode;
|
|
244
|
+
// The attempt boundary pair is independent of `last_commit_sha`: it
|
|
245
|
+
// survives a degraded final sample rather than being overwritten by one.
|
|
246
|
+
if (attemptStartSha !== undefined)
|
|
247
|
+
residue.attempt_start_sha = attemptStartSha;
|
|
248
|
+
if (attemptEndSha !== undefined)
|
|
249
|
+
residue.attempt_end_sha = attemptEndSha;
|
|
120
250
|
return residue;
|
|
121
251
|
},
|
|
122
252
|
};
|
|
@@ -8,16 +8,37 @@
|
|
|
8
8
|
* liveness never does. (Refusing to claim on `enforced:false` is a SEPARATE,
|
|
9
9
|
* fatal preflight concern — see `preflight.ts`.)
|
|
10
10
|
*
|
|
11
|
+
* BAPI-724: the worker model is now provisioned with default Bridge MCP access
|
|
12
|
+
* (see `../mcp-provisioning.js` wiring in `job-runner.ts`), so this module also
|
|
13
|
+
* denies direct reads of the user-scoped Bridge credential store
|
|
14
|
+
* (`~/.config/bridge/**`). MCP authentication stays available to the worker
|
|
15
|
+
* through the separately launched `mcp-invoke` shim process, which resolves the
|
|
16
|
+
* credential itself — the model process never reads it directly.
|
|
17
|
+
*
|
|
18
|
+
* BAPI-740/F2: worker-config isolation seeds a repo-scoped copy of that store
|
|
19
|
+
* INSIDE the per-job isolation directory (`bapi-conductor-claude-cfg-*` under the
|
|
20
|
+
* executor tmp root — see `worker-config-isolation.ts`), where the `~/.config`
|
|
21
|
+
* rule cannot reach it. A prefix glob denies model reads under any isolation
|
|
22
|
+
* directory so the seeded `credentials.json` gets the same shielding as the
|
|
23
|
+
* operator store. The shim still reads it: the shim is a separate process, not
|
|
24
|
+
* the model.
|
|
25
|
+
*
|
|
11
26
|
* The Wave-1 probe on the target machine reports enforcement layer `settings-deny`
|
|
12
27
|
* (claude 2.1.201), so plain `permissions.deny` is provisioned; the PreToolUse
|
|
13
28
|
* fallback is available for CLIs where settings-deny is not enforced.
|
|
14
29
|
*/
|
|
30
|
+
import path from "node:path";
|
|
15
31
|
import { mergeClaudeSettingsWithCommandHook, provisionClaudeSettingsForWorktree, DEFAULT_PRE_TOOL_USE_MATCHER, } from "../claude-settings.js";
|
|
32
|
+
import { ISOLATION_DIR_PREFIX } from "./worker-config-isolation.js";
|
|
16
33
|
/**
|
|
17
34
|
* The stable executor deny rules. Grammar mirrors the deny-enforcement probe's
|
|
18
35
|
* `Bash(<cmd>:<args-glob>)` / `Read(<path-glob>)` form. Covers: reads under
|
|
19
|
-
* ~/.ssh, ~/.aws, and keychain paths;
|
|
20
|
-
*
|
|
36
|
+
* ~/.ssh, ~/.aws, and keychain paths; the user-scoped Bridge credential store
|
|
37
|
+
* (BAPI-724 — the model is denied direct reads, while the separately launched
|
|
38
|
+
* `mcp-invoke` shim process still resolves it); the per-job isolation directory
|
|
39
|
+
* (BAPI-740/F2 — the seeded repo-scoped `credentials.json` lives there, outside
|
|
40
|
+
* the `~/.config` rule's reach); force-push to the base branch; and destructive
|
|
41
|
+
* `rm -rf` outside the worktree.
|
|
21
42
|
*/
|
|
22
43
|
export function executorDenyRules(inputs) {
|
|
23
44
|
return [
|
|
@@ -25,6 +46,11 @@ export function executorDenyRules(inputs) {
|
|
|
25
46
|
"Read(~/.aws/**)",
|
|
26
47
|
"Read(~/Library/Keychains/**)",
|
|
27
48
|
"Read(/etc/shadow)",
|
|
49
|
+
"Read(~/.config/bridge/**)",
|
|
50
|
+
// Any path under any per-job isolation directory, wherever the executor's
|
|
51
|
+
// tmp root lives (`//` anchors at the filesystem root; the prefix constant
|
|
52
|
+
// is shared with `worker-config-isolation.ts` so they cannot drift).
|
|
53
|
+
`Read(//**/${ISOLATION_DIR_PREFIX}*/**)`,
|
|
28
54
|
`Bash(git push:*--force*${inputs.baseBranch}*)`,
|
|
29
55
|
"Bash(git push:*--force*)",
|
|
30
56
|
"Bash(rm:*-rf /*)",
|
|
@@ -53,7 +79,80 @@ export function mergeExecutorDenySettings(existing, inputs) {
|
|
|
53
79
|
return { ...existing, permissions };
|
|
54
80
|
}
|
|
55
81
|
/**
|
|
56
|
-
*
|
|
82
|
+
* The exact line appended to the worktree's `.git/info/exclude` so a worker's
|
|
83
|
+
* `git add -A` cannot commit the executor-owned settings file.
|
|
84
|
+
*/
|
|
85
|
+
export const EXECUTOR_SETTINGS_EXCLUDE_LINE = ".claude/settings.local.json";
|
|
86
|
+
/**
|
|
87
|
+
* Keep the executor-owned `.claude/settings.local.json` out of worker commits
|
|
88
|
+
* (BAPI-740/F2). Worker-config isolation redirects `XDG_CONFIG_HOME`, so the
|
|
89
|
+
* operator's global git excludes (`~/.config/git/ignore`) no longer apply inside
|
|
90
|
+
* a worker — empirically, a worker `git add -A` commits the deny-layer file this
|
|
91
|
+
* module just wrote. A per-worktree `.git/info/exclude` entry is invisible to
|
|
92
|
+
* the diff and travels with the gitdir, so it closes that without touching the
|
|
93
|
+
* worktree's tracked `.gitignore`.
|
|
94
|
+
*
|
|
95
|
+
* A linked worktree's `.git` is a FILE containing `gitdir: <path>` pointing at
|
|
96
|
+
* the main repository's per-worktree gitdir; that indirection is resolved rather
|
|
97
|
+
* than assuming a directory (`EISDIR` on the read identifies a real main-checkout
|
|
98
|
+
* `.git` directory). Idempotent: the line is appended once, never duplicated.
|
|
99
|
+
* Fail-open, matching the rest of this module: unresolvable git metadata is a
|
|
100
|
+
* silent skip, and an exclude-write failure returns a warning — never a block.
|
|
101
|
+
*/
|
|
102
|
+
async function provisionWorktreeSettingsExclude(worktreePath, deps) {
|
|
103
|
+
const gitPath = path.join(worktreePath, ".git");
|
|
104
|
+
let gitDir;
|
|
105
|
+
try {
|
|
106
|
+
const raw = await deps.readFile(gitPath);
|
|
107
|
+
const match = /^gitdir:\s*(.+?)\s*$/m.exec(raw);
|
|
108
|
+
if (!match) {
|
|
109
|
+
// A readable `.git` file with no gitdir pointer is not git metadata this
|
|
110
|
+
// function understands; do not guess at a location to write into.
|
|
111
|
+
return { ok: true };
|
|
112
|
+
}
|
|
113
|
+
const target = match[1];
|
|
114
|
+
gitDir = path.isAbsolute(target) ? target : path.resolve(worktreePath, target);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
const code = err && typeof err === "object" ? err.code : undefined;
|
|
118
|
+
if (code === "EISDIR") {
|
|
119
|
+
// Main-checkout shape: `.git` is a real directory.
|
|
120
|
+
gitDir = gitPath;
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
// No readable git metadata at all (e.g. ENOENT) — nothing to protect.
|
|
124
|
+
return { ok: true };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const infoDir = path.join(gitDir, "info");
|
|
129
|
+
await deps.mkdir(infoDir, { recursive: true });
|
|
130
|
+
const excludePath = path.join(infoDir, "exclude");
|
|
131
|
+
let existing = "";
|
|
132
|
+
try {
|
|
133
|
+
existing = await deps.readFile(excludePath);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
existing = "";
|
|
137
|
+
}
|
|
138
|
+
if (existing.split(/\r?\n/).includes(EXECUTOR_SETTINGS_EXCLUDE_LINE)) {
|
|
139
|
+
return { ok: true };
|
|
140
|
+
}
|
|
141
|
+
const prefix = existing.length === 0 || existing.endsWith("\n") ? existing : `${existing}\n`;
|
|
142
|
+
await deps.writeFile(excludePath, `${prefix}${EXECUTOR_SETTINGS_EXCLUDE_LINE}\n`);
|
|
143
|
+
return { ok: true };
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
warning: "worktree git-exclude provisioning did not complete " +
|
|
149
|
+
`(${EXECUTOR_SETTINGS_EXCLUDE_LINE} may appear in worker commits); continuing fail-open`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Provision the executor deny layer into `<worktree>/.claude/settings.local.json`,
|
|
155
|
+
* and shield that file from worker commits via `.git/info/exclude` (BAPI-740/F2).
|
|
57
156
|
* Fails open: a malformed existing file is preserved (not clobbered) and I/O
|
|
58
157
|
* errors return a warning — the caller still spawns the worker.
|
|
59
158
|
*/
|
|
@@ -70,10 +169,17 @@ export async function provisionExecutorDenyLayer(worktreePath, options, deps) {
|
|
|
70
169
|
}
|
|
71
170
|
return merged;
|
|
72
171
|
}, deps);
|
|
73
|
-
|
|
172
|
+
// Independent of the settings write above: the exclude protects a file that
|
|
173
|
+
// may already exist from an earlier provisioning, so it runs even when the
|
|
174
|
+
// merge-write was refused or failed.
|
|
175
|
+
const exclude = await provisionWorktreeSettingsExclude(worktreePath, deps);
|
|
176
|
+
const warnings = [];
|
|
177
|
+
if (!result.ok) {
|
|
178
|
+
warnings.push(`deny-layer provisioning did not complete (${result.reason}); continuing fail-open`);
|
|
179
|
+
}
|
|
180
|
+
if (!exclude.ok)
|
|
181
|
+
warnings.push(exclude.warning);
|
|
182
|
+
if (warnings.length === 0)
|
|
74
183
|
return { ok: true };
|
|
75
|
-
return {
|
|
76
|
-
ok: false,
|
|
77
|
-
warning: `deny-layer provisioning did not complete (${result.reason}); continuing fail-open`,
|
|
78
|
-
};
|
|
184
|
+
return { ok: false, warning: warnings.join("; ") };
|
|
79
185
|
}
|
|
@@ -14,8 +14,71 @@
|
|
|
14
14
|
* allowed reuse surface — it does not pull the v1 event/ledger graph).
|
|
15
15
|
*/
|
|
16
16
|
import { runDenyEnforcementPreflight } from "../conductor/deny-enforcement-preflight.js";
|
|
17
|
-
import {
|
|
17
|
+
import { DEFAULT_PROBE_TIMEOUT_MS } from "../agent-capabilities/types.js";
|
|
18
|
+
import { evaluateClaudeMcpShadowingPolicy, inspectClaudeUserConfigForMcpShadowing, resolveClaudeUserConfigPath, } from "../claude-user-config-doctor.js";
|
|
19
|
+
import { resolveAllExecutorApiAccess, resolveBaseUrl, EXECUTOR_BASE_URL_REQUIRED_MESSAGE, } from "./credentials.js";
|
|
18
20
|
const VERSION_DETAIL_MAX = 200;
|
|
21
|
+
/**
|
|
22
|
+
* Environment override for the deny probe's headless timeout (BAPI-722).
|
|
23
|
+
*
|
|
24
|
+
* EXECUTOR-PROCESS CONFIGURATION ONLY. It is deliberately excluded from spawned
|
|
25
|
+
* worker environments by `isExecutorEnvKeyAllowed` — a worker has no deny probe to
|
|
26
|
+
* time out, and letting a job influence the executor's own safety-probe budget
|
|
27
|
+
* would be a privilege inversion.
|
|
28
|
+
*/
|
|
29
|
+
export const DENY_PROBE_TIMEOUT_ENV_KEY = "BAPI_CONDUCTOR_DENY_PROBE_TIMEOUT_MS";
|
|
30
|
+
/**
|
|
31
|
+
* Accepted bounds for the resolved probe timeout. The lower bound keeps a
|
|
32
|
+
* fat-fingered `1` from turning every probe into an instant HANG (which is FATAL —
|
|
33
|
+
* the executor would refuse to claim forever); the upper bound keeps a typo from
|
|
34
|
+
* wedging the claim loop for hours. Anything outside them falls back to the
|
|
35
|
+
* established capability-probe default rather than being clamped, so a
|
|
36
|
+
* misconfiguration produces the known-good value instead of a silently different
|
|
37
|
+
* one the operator never chose.
|
|
38
|
+
*/
|
|
39
|
+
const DENY_PROBE_TIMEOUT_MIN_MS = 1_000;
|
|
40
|
+
const DENY_PROBE_TIMEOUT_MAX_MS = 600_000;
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the deny probe's timeout from the environment, falling back to the
|
|
43
|
+
* established capability-probe default (`DEFAULT_PROBE_TIMEOUT_MS`).
|
|
44
|
+
*
|
|
45
|
+
* Strict: blank, non-numeric, non-integer, zero, negative, and out-of-range values
|
|
46
|
+
* all resolve to the default. The result is ALWAYS a finite positive number — never
|
|
47
|
+
* `undefined`, never `0`, never unbounded — because an unbounded deny probe is
|
|
48
|
+
* exactly the hang this timeout exists to prevent.
|
|
49
|
+
*
|
|
50
|
+
* The value itself is never logged alongside its surrounding environment.
|
|
51
|
+
*/
|
|
52
|
+
export function resolveDenyProbeTimeoutMs(env = {}) {
|
|
53
|
+
const raw = env[DENY_PROBE_TIMEOUT_ENV_KEY];
|
|
54
|
+
if (typeof raw !== "string" || raw.trim().length === 0)
|
|
55
|
+
return DEFAULT_PROBE_TIMEOUT_MS;
|
|
56
|
+
const parsed = Number(raw.trim());
|
|
57
|
+
if (!Number.isFinite(parsed) || !Number.isInteger(parsed))
|
|
58
|
+
return DEFAULT_PROBE_TIMEOUT_MS;
|
|
59
|
+
if (parsed < DENY_PROBE_TIMEOUT_MIN_MS || parsed > DENY_PROBE_TIMEOUT_MAX_MS) {
|
|
60
|
+
return DEFAULT_PROBE_TIMEOUT_MS;
|
|
61
|
+
}
|
|
62
|
+
return parsed;
|
|
63
|
+
}
|
|
64
|
+
/** True only for a result safe to reuse: genuinely enforced by a real layer. */
|
|
65
|
+
function isCacheableDenyResult(result) {
|
|
66
|
+
return result.enforced === true && result.layer !== "none";
|
|
67
|
+
}
|
|
68
|
+
/** Create a fresh, empty executor-scoped deny-probe cache. */
|
|
69
|
+
export function createDenyProbeCache() {
|
|
70
|
+
let cached;
|
|
71
|
+
return {
|
|
72
|
+
get: () => cached,
|
|
73
|
+
set: (result) => {
|
|
74
|
+
if (isCacheableDenyResult(result))
|
|
75
|
+
cached = result;
|
|
76
|
+
},
|
|
77
|
+
reset: () => {
|
|
78
|
+
cached = undefined;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
19
82
|
function normalizeVersion(stdout, stderr) {
|
|
20
83
|
return (stdout || stderr || "").trim().slice(0, VERSION_DETAIL_MAX);
|
|
21
84
|
}
|
|
@@ -61,11 +124,20 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
|
|
|
61
124
|
catch {
|
|
62
125
|
fatalFindings.push("claude could not be probed");
|
|
63
126
|
}
|
|
64
|
-
// ---
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
127
|
+
// --- Explicit base URL (fatal; never defaults to production, BAPI-676) ---
|
|
128
|
+
// Resolved before per-repo credentials so a missing URL reports ONCE rather
|
|
129
|
+
// than once per repo. The remaining probes still run and accumulate findings.
|
|
130
|
+
const baseUrlResult = resolveBaseUrl(deps.env, options.baseUrl);
|
|
131
|
+
if (!baseUrlResult.ok) {
|
|
132
|
+
fatalFindings.push(EXECUTOR_BASE_URL_REQUIRED_MESSAGE);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
// --- Credentials for every configured repo --------------------------
|
|
136
|
+
const access = await resolveAllExecutorApiAccess(options.repos, deps, baseUrlResult.baseUrl);
|
|
137
|
+
for (const a of access) {
|
|
138
|
+
if (!a.ok)
|
|
139
|
+
fatalFindings.push(`credential resolution failed for repo '${a.repoName}'`);
|
|
140
|
+
}
|
|
69
141
|
}
|
|
70
142
|
// --- Disk space (fatal when it cannot be verified) --------------------
|
|
71
143
|
let diskFreeGb = null;
|
|
@@ -97,10 +169,26 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
|
|
|
97
169
|
warnings.push("local `gh` is unavailable/unauthenticated; local merges would be degraded");
|
|
98
170
|
}
|
|
99
171
|
// --- Deny-layer enforcement (binding fatal contract) ------------------
|
|
172
|
+
// BAPI-722: only THIS probe is cached across claim cycles. Every probe above —
|
|
173
|
+
// tooling, credentials, disk, gh — still runs on each collection, because each of
|
|
174
|
+
// them can genuinely change between polls (a binary removed, a credential
|
|
175
|
+
// rotated, a disk filled). Deny-layer enforcement is a property of the machine's
|
|
176
|
+
// static settings; once verified it does not spontaneously unverify.
|
|
100
177
|
let denyEnforced = false;
|
|
101
178
|
try {
|
|
102
|
-
const
|
|
103
|
-
const
|
|
179
|
+
const cache = seams.bypassDenyProbeCache === true ? undefined : seams.denyProbeCache;
|
|
180
|
+
const cachedDeny = cache?.get();
|
|
181
|
+
let deny;
|
|
182
|
+
if (cachedDeny !== undefined) {
|
|
183
|
+
deny = cachedDeny;
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
const denyProbe = seams.runDenyPreflight ?? runDenyEnforcementPreflight;
|
|
187
|
+
deny = await denyProbe({ timeoutMs: resolveDenyProbeTimeoutMs(deps.env) });
|
|
188
|
+
// `set` itself refuses anything that is not a genuine success, so a failure
|
|
189
|
+
// cannot become sticky even if this call site were later moved.
|
|
190
|
+
seams.denyProbeCache?.set(deny);
|
|
191
|
+
}
|
|
104
192
|
denyEnforced = deny.enforced === true;
|
|
105
193
|
if (!deny.enforced) {
|
|
106
194
|
fatalFindings.push(`deny-layer enforcement not verified (enforced=false, layer=${deny.layer}); refusing to claim`);
|
|
@@ -114,6 +202,37 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
|
|
|
114
202
|
catch {
|
|
115
203
|
fatalFindings.push("deny-layer enforcement probe failed; refusing to claim");
|
|
116
204
|
}
|
|
205
|
+
// --- Claude user-config MCP shadowing (fatal by default, BAPI-727) ----
|
|
206
|
+
// REFUSAL BY DEFAULT is the whole point. A `bridge-api` entry in the machine's
|
|
207
|
+
// ~/.claude.json was observed to win over a linked worktree's provisioned
|
|
208
|
+
// `.mcp.json`, so the worker silently talks to whatever endpoint that entry
|
|
209
|
+
// names instead of the one the executor provisioned — a production-integrity
|
|
210
|
+
// failure that produces no error, only wrong work. A warning-only check would
|
|
211
|
+
// preserve exactly that silence; the explicit
|
|
212
|
+
// BAPI_CONDUCTOR_ALLOW_CLAUDE_MCP_SHADOWING override is the deliberate operator
|
|
213
|
+
// escape hatch for the cases where the collision is known and intended.
|
|
214
|
+
//
|
|
215
|
+
// Inconclusive states (unreadable/malformed config) are warnings, never fatal:
|
|
216
|
+
// "could not look" must not become "found a collision".
|
|
217
|
+
try {
|
|
218
|
+
const inspect = seams.inspectClaudeUserConfig ?? inspectClaudeUserConfigForMcpShadowing;
|
|
219
|
+
const inspection = await inspect({
|
|
220
|
+
claudeConfigPath: resolveClaudeUserConfigPath(deps.homedir(), deps.platform),
|
|
221
|
+
platform: deps.platform,
|
|
222
|
+
cwd: deps.cwd,
|
|
223
|
+
// The executor's own checkout is the main repository. Preflight runs
|
|
224
|
+
// before any job, so there is no prepared worktree to name here — the
|
|
225
|
+
// per-job check in `job-runner.ts` covers that scope.
|
|
226
|
+
mainRepositoryPath: deps.cwd,
|
|
227
|
+
}, { readFile: deps.readFile });
|
|
228
|
+
const policy = evaluateClaudeMcpShadowingPolicy(inspection, deps.env);
|
|
229
|
+
warnings.push(...policy.warnings);
|
|
230
|
+
if (!policy.ok)
|
|
231
|
+
fatalFindings.push(...policy.refusals);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
warnings.push("Claude user-config MCP shadowing check did not complete; could not verify worker MCP integrity");
|
|
235
|
+
}
|
|
117
236
|
return {
|
|
118
237
|
ok: fatalFindings.length === 0,
|
|
119
238
|
fatalFindings,
|
|
@@ -26,6 +26,20 @@ const SUPPORTED_PROMPT_SPEC_VERSION = 1;
|
|
|
26
26
|
const GIT_LOG_MAX_CHARS = 8_000;
|
|
27
27
|
/** Explicit "no prior revise reasons" value — never an empty string. */
|
|
28
28
|
const REVISE_REASONS_NONE = "(none — this is the first review of the ticket spec)";
|
|
29
|
+
/**
|
|
30
|
+
* Explicit "no related-ticket context" value (BAPI-699) — never an empty string.
|
|
31
|
+
*
|
|
32
|
+
* The server normally renders this section itself (availability header + one
|
|
33
|
+
* record block per related ticket), so this fallback only fires when a payload
|
|
34
|
+
* reaches a placeholder-aware executor without a `related_context` field at all
|
|
35
|
+
* — e.g. a job enqueued by an older server revision. Substituting an empty
|
|
36
|
+
* string there would hand the reviewer a blank section that reads exactly like
|
|
37
|
+
* "this ticket has no neighbors", so the fallback states the blindness instead.
|
|
38
|
+
*/
|
|
39
|
+
const RELATED_CONTEXT_NONE = "(no related-ticket context was supplied with this job. This review is " +
|
|
40
|
+
"PARTIALLY BLIND to the surrounding tickets: do NOT conclude that the ticket " +
|
|
41
|
+
"has no related tickets, no dependencies, and no already-merged neighbors — " +
|
|
42
|
+
"that information was simply not provided.)";
|
|
29
43
|
/** The `git log` argv used to resolve `{{GIT_LOG}}` (list-based, never shelled). */
|
|
30
44
|
const GIT_LOG_ARGS = ["log", "--oneline", "--decorate", "--max-count=50"];
|
|
31
45
|
/**
|
|
@@ -39,7 +53,21 @@ const SUPPORTED_PLACEHOLDERS = new Set([
|
|
|
39
53
|
"WORK_BRANCH",
|
|
40
54
|
"GIT_LOG",
|
|
41
55
|
"REVISE_REASONS",
|
|
56
|
+
"RELATED_CONTEXT",
|
|
57
|
+
"SPEC_CITATION_FRESHNESS_WARNING",
|
|
42
58
|
]);
|
|
59
|
+
/**
|
|
60
|
+
* Explicit "no stale-citation advisory" value (BAPI-722) — never an empty string.
|
|
61
|
+
*
|
|
62
|
+
* Unlike `RELATED_CONTEXT`, absence here is the COMMON and entirely healthy case:
|
|
63
|
+
* most tickets have no predecessor that merged after their spec was written, so the
|
|
64
|
+
* server omits the field. The substituted text therefore states that plainly rather
|
|
65
|
+
* than warning about blindness — a reviewer must not read "no advisory" as "the
|
|
66
|
+
* server could not tell", which would push them toward re-verifying every citation
|
|
67
|
+
* on every review.
|
|
68
|
+
*/
|
|
69
|
+
const SPEC_CITATION_FRESHNESS_NONE = "(none — no dependency of this ticket merged after this ticket's " +
|
|
70
|
+
"specification snapshot was taken.)";
|
|
43
71
|
function promptContractError(message) {
|
|
44
72
|
return new ExecutorNamedError(PROMPT_CONTRACT_ERROR, message);
|
|
45
73
|
}
|
|
@@ -190,6 +218,29 @@ export async function buildPromptSpecPlaceholderValues(job, spec, context) {
|
|
|
190
218
|
values.REVISE_REASONS = formatReviseReasons(payload.reasons);
|
|
191
219
|
break;
|
|
192
220
|
}
|
|
221
|
+
case "RELATED_CONTEXT": {
|
|
222
|
+
// The server renders this section (availability header, per-entry
|
|
223
|
+
// record blocks, its own bounded trim marker) and records the SHA-256
|
|
224
|
+
// of EXACTLY the text it rendered. So the value is substituted VERBATIM
|
|
225
|
+
// — no `.trim()`, no re-wrapping, no re-bounding — otherwise the
|
|
226
|
+
// recorded display hash would stop describing what the reviewer read.
|
|
227
|
+
values.RELATED_CONTEXT = isNonBlankString(payload.related_context)
|
|
228
|
+
? payload.related_context
|
|
229
|
+
: RELATED_CONTEXT_NONE;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
case "SPEC_CITATION_FRESHNESS_WARNING": {
|
|
233
|
+
// OPTIONAL by contract (BAPI-722): an absent field is the normal case and
|
|
234
|
+
// resolves to the explicit "none" text, NEVER a thrown contract error and
|
|
235
|
+
// never an empty substitution. This deliberately does not weaken
|
|
236
|
+
// {{TICKET_SPEC}}, which stays required — a review with no spec is not a
|
|
237
|
+
// review, whereas a review with no advisory is simply the usual review.
|
|
238
|
+
// Substituted verbatim: the server composed this text and bounded it.
|
|
239
|
+
values.SPEC_CITATION_FRESHNESS_WARNING = isNonBlankString(payload.spec_citation_freshness_warning)
|
|
240
|
+
? payload.spec_citation_freshness_warning
|
|
241
|
+
: SPEC_CITATION_FRESHNESS_NONE;
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
193
244
|
}
|
|
194
245
|
}
|
|
195
246
|
return values;
|
package/build/executor/runner.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* preflight finding. `once` mode performs one preflight/claim cycle and returns
|
|
7
7
|
* after all active jobs finish. Depends on NO v1 conductor event/ledger module.
|
|
8
8
|
*/
|
|
9
|
-
import { collectExecutorPreflight, buildClaimManifest } from "./preflight.js";
|
|
9
|
+
import { collectExecutorPreflight, buildClaimManifest, createDenyProbeCache, } from "./preflight.js";
|
|
10
10
|
import { runClaimedJob } from "./job-runner.js";
|
|
11
11
|
import { sweepExecutorWorktrees } from "./worktree-gc.js";
|
|
12
12
|
/**
|
|
@@ -28,6 +28,19 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
28
28
|
platform: deps.platform,
|
|
29
29
|
}));
|
|
30
30
|
const active = new Map();
|
|
31
|
+
// BAPI-722: ONE deny-probe cache per `runExecutor` invocation, created OUTSIDE
|
|
32
|
+
// the claim loop below — that scope is the whole feature. A cache created inside
|
|
33
|
+
// the loop would be discarded every cycle and re-probe exactly as before; a
|
|
34
|
+
// module-level cache would leak across invocations and survive a deliberate
|
|
35
|
+
// restart, which is precisely the escape hatch an operator fixing a broken deny
|
|
36
|
+
// layer needs. Process lifetime is the correct boundary.
|
|
37
|
+
//
|
|
38
|
+
// An injected `preflightSeams.denyProbeCache` (or `bypassDenyProbeCache`) wins,
|
|
39
|
+
// so a test can force deterministic fresh probes.
|
|
40
|
+
const preflightSeams = {
|
|
41
|
+
denyProbeCache: createDenyProbeCache(),
|
|
42
|
+
...(seams.preflightSeams ?? {}),
|
|
43
|
+
};
|
|
31
44
|
/**
|
|
32
45
|
* Run the conservative worktree GC sweep ONLY while the runner owns no active
|
|
33
46
|
* job worktree. Failures are logged to stderr and never abort claiming
|
|
@@ -60,7 +73,15 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
60
73
|
active.set(job.id, promise);
|
|
61
74
|
}
|
|
62
75
|
for (;;) {
|
|
63
|
-
const report = await collectPreflight(options, deps,
|
|
76
|
+
const report = await collectPreflight(options, deps, preflightSeams);
|
|
77
|
+
// BAPI-727: preflight warnings were previously collected but never emitted, so
|
|
78
|
+
// a non-fatal finding — an overridden MCP-shadowing collision, an unreadable
|
|
79
|
+
// ~/.claude.json — was invisible to the operator. Emit them before the claim
|
|
80
|
+
// decision; this only logs and never changes the existing `!report.ok`
|
|
81
|
+
// claim-skipping behavior below.
|
|
82
|
+
for (const warning of report.warnings) {
|
|
83
|
+
deps.errorLog(`executor preflight warning: ${warning}`);
|
|
84
|
+
}
|
|
64
85
|
if (!report.ok) {
|
|
65
86
|
deps.errorLog(`executor preflight refused claiming: ${report.fatalFindings.join("; ")}`);
|
|
66
87
|
}
|