@bridge_gpt/mcp-server 0.2.36 → 0.2.38
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 +48 -8
- 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-user-config-doctor.js +317 -0
- package/build/commands.generated.js +2 -1
- package/build/conductor/bridge-api-client.js +178 -4
- package/build/conductor-bin.js +1 -1
- package/build/conductor-bundle-artifacts.js +7 -6
- package/build/credential-store.js +205 -4
- package/build/direct-ticket-tools.js +70 -0
- package/build/doctor.js +239 -80
- package/build/executor/cli.js +51 -1
- package/build/executor/credentials.js +1 -7
- package/build/executor/deps.js +18 -1
- package/build/executor/env.js +51 -25
- package/build/executor/heartbeat.js +138 -17
- package/build/executor/http-client.js +49 -8
- package/build/executor/job-errors.js +4 -0
- package/build/executor/job-runner.js +422 -22
- package/build/executor/observation.js +130 -0
- package/build/executor/permissions.js +104 -8
- package/build/executor/preflight.js +32 -0
- package/build/executor/runner.js +8 -0
- package/build/executor/test-clock.js +67 -3
- package/build/executor/types.js +4 -1
- package/build/executor/worker-command.js +11 -3
- 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 +614 -244
- package/build/init.js +363 -73
- package/build/install-bridge.js +568 -80
- package/build/launcher-config-inspection.js +351 -0
- package/build/mcp-invoke.js +49 -6
- package/build/mcp-provisioning.js +30 -7
- 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 +22 -9
- 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 +2 -0
- package/build/setup-epic.js +662 -27
- package/build/sfcc/log-gate.js +38 -11
- package/build/sfcc/log-query.js +55 -15
- package/build/sfcc/ocapi-shape.js +70 -14
- package/build/sfcc/output.js +41 -11
- package/build/sfcc/permissions.js +24 -2
- package/build/sfcc/read-body.js +92 -0
- package/build/sfcc/read-projection.js +185 -0
- package/build/sfcc/read-result.js +158 -0
- package/build/sfcc/reads-custom-object-def.js +57 -34
- package/build/sfcc/reads-site-preference.js +86 -33
- package/build/sfcc/reads-system-object.js +50 -38
- 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 +71 -15
- package/build/sfcc/write-tool-common.js +126 -32
- package/build/sfcc/writes-custom-object-def.js +6 -2
- 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 +417 -101
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +73 -0
- package/docs/CONDUCTOR.md +23 -8
- package/package.json +3 -3
- package/pipelines/implement-ticket.json +15 -5
|
@@ -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
|
};
|
|
@@ -15,18 +15,30 @@
|
|
|
15
15
|
* through the separately launched `mcp-invoke` shim process, which resolves the
|
|
16
16
|
* credential itself — the model process never reads it directly.
|
|
17
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
|
+
*
|
|
18
26
|
* The Wave-1 probe on the target machine reports enforcement layer `settings-deny`
|
|
19
27
|
* (claude 2.1.201), so plain `permissions.deny` is provisioned; the PreToolUse
|
|
20
28
|
* fallback is available for CLIs where settings-deny is not enforced.
|
|
21
29
|
*/
|
|
30
|
+
import path from "node:path";
|
|
22
31
|
import { mergeClaudeSettingsWithCommandHook, provisionClaudeSettingsForWorktree, DEFAULT_PRE_TOOL_USE_MATCHER, } from "../claude-settings.js";
|
|
32
|
+
import { ISOLATION_DIR_PREFIX } from "./worker-config-isolation.js";
|
|
23
33
|
/**
|
|
24
34
|
* The stable executor deny rules. Grammar mirrors the deny-enforcement probe's
|
|
25
35
|
* `Bash(<cmd>:<args-glob>)` / `Read(<path-glob>)` form. Covers: reads under
|
|
26
36
|
* ~/.ssh, ~/.aws, and keychain paths; the user-scoped Bridge credential store
|
|
27
37
|
* (BAPI-724 — the model is denied direct reads, while the separately launched
|
|
28
|
-
* `mcp-invoke` shim process still resolves it);
|
|
29
|
-
*
|
|
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.
|
|
30
42
|
*/
|
|
31
43
|
export function executorDenyRules(inputs) {
|
|
32
44
|
return [
|
|
@@ -35,6 +47,10 @@ export function executorDenyRules(inputs) {
|
|
|
35
47
|
"Read(~/Library/Keychains/**)",
|
|
36
48
|
"Read(/etc/shadow)",
|
|
37
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}*/**)`,
|
|
38
54
|
`Bash(git push:*--force*${inputs.baseBranch}*)`,
|
|
39
55
|
"Bash(git push:*--force*)",
|
|
40
56
|
"Bash(rm:*-rf /*)",
|
|
@@ -63,7 +79,80 @@ export function mergeExecutorDenySettings(existing, inputs) {
|
|
|
63
79
|
return { ...existing, permissions };
|
|
64
80
|
}
|
|
65
81
|
/**
|
|
66
|
-
*
|
|
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).
|
|
67
156
|
* Fails open: a malformed existing file is preserved (not clobbered) and I/O
|
|
68
157
|
* errors return a warning — the caller still spawns the worker.
|
|
69
158
|
*/
|
|
@@ -80,10 +169,17 @@ export async function provisionExecutorDenyLayer(worktreePath, options, deps) {
|
|
|
80
169
|
}
|
|
81
170
|
return merged;
|
|
82
171
|
}, deps);
|
|
83
|
-
|
|
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)
|
|
84
183
|
return { ok: true };
|
|
85
|
-
return {
|
|
86
|
-
ok: false,
|
|
87
|
-
warning: `deny-layer provisioning did not complete (${result.reason}); continuing fail-open`,
|
|
88
|
-
};
|
|
184
|
+
return { ok: false, warning: warnings.join("; ") };
|
|
89
185
|
}
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { runDenyEnforcementPreflight } from "../conductor/deny-enforcement-preflight.js";
|
|
17
17
|
import { DEFAULT_PROBE_TIMEOUT_MS } from "../agent-capabilities/types.js";
|
|
18
|
+
import { evaluateClaudeMcpShadowingPolicy, inspectClaudeUserConfigForMcpShadowing, resolveClaudeUserConfigPath, } from "../claude-user-config-doctor.js";
|
|
18
19
|
import { resolveAllExecutorApiAccess, resolveBaseUrl, EXECUTOR_BASE_URL_REQUIRED_MESSAGE, } from "./credentials.js";
|
|
19
20
|
const VERSION_DETAIL_MAX = 200;
|
|
20
21
|
/**
|
|
@@ -201,6 +202,37 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
|
|
|
201
202
|
catch {
|
|
202
203
|
fatalFindings.push("deny-layer enforcement probe failed; refusing to claim");
|
|
203
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
|
+
}
|
|
204
236
|
return {
|
|
205
237
|
ok: fatalFindings.length === 0,
|
|
206
238
|
fatalFindings,
|
package/build/executor/runner.js
CHANGED
|
@@ -74,6 +74,14 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
74
74
|
}
|
|
75
75
|
for (;;) {
|
|
76
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
|
+
}
|
|
77
85
|
if (!report.ok) {
|
|
78
86
|
deps.errorLog(`executor preflight refused claiming: ${report.fatalFindings.join("; ")}`);
|
|
79
87
|
}
|
|
@@ -72,7 +72,15 @@ export function makeFakeExecutorDeps(clock, overrides = {}) {
|
|
|
72
72
|
spawnProcess: () => {
|
|
73
73
|
throw new Error("spawnProcess not configured for this test");
|
|
74
74
|
},
|
|
75
|
-
readFile: async () => {
|
|
75
|
+
readFile: async (filePath) => {
|
|
76
|
+
// BAPI-731: the prepared worktree's own `.mcp.json` must carry the required
|
|
77
|
+
// `bridge-api` registration or the runner refuses to spawn (an isolated
|
|
78
|
+
// worker has no operator-scoped registration to fall back to). Serve a
|
|
79
|
+
// minimal valid one so pre-existing spawn tests still reach the spawn they
|
|
80
|
+
// were written to assert on.
|
|
81
|
+
if (typeof filePath === "string" && filePath.endsWith(".mcp.json")) {
|
|
82
|
+
return JSON.stringify({ mcpServers: { "bridge-api": { command: "node", args: [] } } });
|
|
83
|
+
}
|
|
76
84
|
// Mirror real fs/promises: a missing-file rejection carries code "ENOENT"
|
|
77
85
|
// (so BAPI-664 command provisioning treats absent files as fillable).
|
|
78
86
|
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
|
|
@@ -81,11 +89,31 @@ export function makeFakeExecutorDeps(clock, overrides = {}) {
|
|
|
81
89
|
mkdir: async () => undefined,
|
|
82
90
|
stat: async () => ({ mode: 0o644 }),
|
|
83
91
|
statfs: async () => ({ bavail: 1_000_000, bsize: 4096 }),
|
|
92
|
+
// BAPI-731 worker config isolation boundaries. In-memory fakes: no test ever
|
|
93
|
+
// creates, permissions, or removes a real directory. Isolation is
|
|
94
|
+
// fail-CLOSED, so a spawn-path test that did not supply these would refuse to
|
|
95
|
+
// spawn — these defaults keep the many pre-existing spawn tests exercising
|
|
96
|
+
// what they were written to exercise.
|
|
97
|
+
mkdtemp: async (prefix) => `${prefix}test`,
|
|
98
|
+
chmod: async () => { },
|
|
99
|
+
rmRecursive: async () => { },
|
|
100
|
+
readdir: async () => [],
|
|
101
|
+
lstatPath: async () => ({ isDirectory: true, isSymbolicLink: false, mtimeMs: 0 }),
|
|
102
|
+
tmpdir: () => "/tmp",
|
|
84
103
|
sleep: clock.sleep,
|
|
85
104
|
now: clock.now,
|
|
86
105
|
setTimer: clock.setTimer,
|
|
87
106
|
clearTimer: clock.clearTimer,
|
|
88
|
-
|
|
107
|
+
// `ANTHROPIC_API_KEY` is the isolation strategy's required credential source
|
|
108
|
+
// (see `worker-config-isolation.ts`); without it capability evaluation
|
|
109
|
+
// reports `unsupported-auth-layout` and no spawn test could run.
|
|
110
|
+
// `BAPI_API_KEY` is the seed credential for the isolated Bridge store
|
|
111
|
+
// (BAPI-740/F2); without it the runner refuses pre-spawn with
|
|
112
|
+
// `ContractError.BridgeCredentialUnavailable` and no spawn test could run.
|
|
113
|
+
env: {
|
|
114
|
+
ANTHROPIC_API_KEY: "test-key-not-a-real-credential",
|
|
115
|
+
BAPI_API_KEY: "test-bapi-key-not-a-real-credential",
|
|
116
|
+
},
|
|
89
117
|
cwd: "/repo",
|
|
90
118
|
platform: "linux",
|
|
91
119
|
homedir: () => "/home/tester",
|
|
@@ -98,7 +126,43 @@ export function makeFakeExecutorDeps(clock, overrides = {}) {
|
|
|
98
126
|
serverEntryPath: "/repo/mcp_server/build/index.js",
|
|
99
127
|
},
|
|
100
128
|
};
|
|
101
|
-
|
|
129
|
+
const merged = { ...base, ...overrides };
|
|
130
|
+
// BAPI-731: two fail-CLOSED spawn gates depend on ambient fixture state that
|
|
131
|
+
// predates them, so an override supplied for an unrelated reason would
|
|
132
|
+
// silently turn a spawn test into a refusal test. Both are re-applied here as
|
|
133
|
+
// FALLBACKS — an override that deliberately exercises a refusal still wins,
|
|
134
|
+
// because it either supplies its own key/registration or explicitly omits one.
|
|
135
|
+
// 1. Isolation needs an Anthropic key, and the Bridge-store seeding refusal
|
|
136
|
+
// (BAPI-740/F2) needs a resolvable `BAPI_API_KEY`. A test overriding `env`
|
|
137
|
+
// for some other purpose keeps both unless it sets the key itself — an
|
|
138
|
+
// override that deliberately exercises a refusal supplies its own value
|
|
139
|
+
// (empty string included: presence wins over the fallback).
|
|
140
|
+
if (overrides.env) {
|
|
141
|
+
const fallbacks = {};
|
|
142
|
+
for (const key of ["ANTHROPIC_API_KEY", "BAPI_API_KEY"]) {
|
|
143
|
+
if (!(key in overrides.env))
|
|
144
|
+
fallbacks[key] = base.env[key];
|
|
145
|
+
}
|
|
146
|
+
merged.env = { ...fallbacks, ...overrides.env };
|
|
147
|
+
}
|
|
148
|
+
// 2. The required-registration check reads the worktree's `.mcp.json`. A test
|
|
149
|
+
// overriding `readFile` (usually to script command provisioning) still gets
|
|
150
|
+
// the default registration for that one path when its own read rejects.
|
|
151
|
+
if (overrides.readFile) {
|
|
152
|
+
const override = overrides.readFile;
|
|
153
|
+
merged.readFile = async (filePath) => {
|
|
154
|
+
try {
|
|
155
|
+
return await override(filePath);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
if (typeof filePath === "string" && filePath.endsWith(".mcp.json")) {
|
|
159
|
+
return base.readFile(filePath);
|
|
160
|
+
}
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return merged;
|
|
102
166
|
}
|
|
103
167
|
/** Sensible default executor options for tests. */
|
|
104
168
|
export function makeTestOptions(overrides = {}) {
|
package/build/executor/types.js
CHANGED
|
@@ -80,10 +80,18 @@ export function resolveExecutorPrompt(job) {
|
|
|
80
80
|
// change addressing, and merge-conflict resolution (see .claude/commands/
|
|
81
81
|
// implement-ticket.md "Clean session exit"). So the recovery jobs that operate
|
|
82
82
|
// on the ticket's existing branch/PR — `remediate`/`ci_fix`/`rebase` — resume
|
|
83
|
-
//
|
|
84
|
-
//
|
|
83
|
+
// by RE-ENTERING THE COMPLETE RECIPE from step 1, not a distinct resume path.
|
|
84
|
+
// That re-entry is safe (BAPI-746), backed by re-entry handling already
|
|
85
|
+
// present in three of the recipe's own steps, not a new assumption: plan
|
|
86
|
+
// generation is idempotent because the server reuses a fresh existing plan
|
|
87
|
+
// instead of regenerating it (BAPI-745, `reused: true`); commit-and-push.md
|
|
88
|
+
// treats a clean tree with an already-pushed branch/PR as a legitimate
|
|
89
|
+
// re-entry rather than an empty implementation; and create-pr.md treats an
|
|
90
|
+
// already-open pull request on the head branch as satisfying its success
|
|
91
|
+
// condition. Without synthesizing this command, a code_review
|
|
85
92
|
// `changes_requested` (near-certain in real runs) enqueues a `remediate` job
|
|
86
|
-
// that fails `no usable prompt` and strands the run (BAPI-528 Milestone-A gap
|
|
93
|
+
// that fails `no usable prompt` and strands the run (BAPI-528 Milestone-A gap
|
|
94
|
+
// #3).
|
|
87
95
|
const ticketKey = typeof job.ticket_key === "string" && job.ticket_key.trim().length > 0
|
|
88
96
|
? job.ticket_key.trim()
|
|
89
97
|
: null;
|