@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
|
@@ -17,10 +17,11 @@
|
|
|
17
17
|
import os from "node:os";
|
|
18
18
|
import { buildExecutorWorkerEnv } from "./env.js";
|
|
19
19
|
import { runHeartbeatLoop } from "./heartbeat.js";
|
|
20
|
-
import { isExecutorNamedError, toExecutorFailure, secretFreeErrorMessage, MissingVerdictArtifact, } from "./job-errors.js";
|
|
20
|
+
import { isExecutorNamedError, toExecutorFailure, secretFreeErrorMessage, MissingVerdictArtifact, PreSpawnVerification, WorkerConfigIsolation, BridgeCredentialUnavailable, } from "./job-errors.js";
|
|
21
21
|
import { buildConductorMergeAccessForExecutorJob, buildDefaultMergeLocalDeps, runExecutorMergeJob, MERGE_RETRYABLE, MERGE_FAILED, } from "./merge-job.js";
|
|
22
22
|
import { createObservationState } from "./observation.js";
|
|
23
|
-
import { provisionMcpRegistrationForWorktree } from "../mcp-provisioning.js";
|
|
23
|
+
import { getWorktreeMcpRegistrationTargets, pathApiForProvisioningPlatform, provisionMcpRegistrationForWorktree, } from "../mcp-provisioning.js";
|
|
24
|
+
import { evaluateClaudeMcpShadowingPolicy, inspectClaudeUserConfigForMcpShadowing, resolveClaudeUserConfigPath, } from "../claude-user-config-doctor.js";
|
|
24
25
|
import { provisionExecutorDenyLayer } from "./permissions.js";
|
|
25
26
|
import { runProcessWithTimeout } from "./process.js";
|
|
26
27
|
import { prepareResumeSpawn } from "./resume-pre-spawn.js";
|
|
@@ -37,7 +38,9 @@ import { validateWorkerFinalization } from "./worker-finalization.js";
|
|
|
37
38
|
import { ensureExecutorWorktree } from "./worktree.js";
|
|
38
39
|
import { buildPrBaseContractLaunchInstruction } from "../pr-base-contract.js";
|
|
39
40
|
import { buildClaudeExecutorArgv, CLAUDE_EXECUTABLE, resolveExecutorModelAlias, resolveExecutorPrompt, resolveWorkerPermissionPosture, } from "./worker-command.js";
|
|
40
|
-
import { collectGitTelemetry } from "./observation.js";
|
|
41
|
+
import { collectGitTelemetry, collectRemoteTrackingSha, evaluatePreSpawnGitVerification, } from "./observation.js";
|
|
42
|
+
import { establishWorkerConfigIsolation, formatWorkerConfigIsolationReason, } from "./worker-config-isolation.js";
|
|
43
|
+
import { resolveBapiCredentials } from "../credential-store.js";
|
|
41
44
|
/** Default runtime for the no-op smoke process (ms). */
|
|
42
45
|
const DEFAULT_SMOKE_DURATION_MS = 100;
|
|
43
46
|
/** ProcessClassification maps 1:1 onto the wire ExecutorClassification. */
|
|
@@ -92,8 +95,17 @@ function buildJobLogRegistryDeps(deps) {
|
|
|
92
95
|
platform: deps.platform,
|
|
93
96
|
};
|
|
94
97
|
}
|
|
95
|
-
/**
|
|
96
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Map executor deps into the shared MCP-provisioning filesystem boundary.
|
|
100
|
+
*
|
|
101
|
+
* `bridgeApiBaseUrl` is an EXPLICIT argument, deliberately not read from
|
|
102
|
+
* `deps.env` (BAPI-727). The worker env allowlist strips `BAPI_BASE_URL`, and the
|
|
103
|
+
* executor's own endpoint may have come from `--base-url` rather than the
|
|
104
|
+
* environment at all — so the environment is not a trustworthy source here. The
|
|
105
|
+
* only correct value is the one the caller threads down from the validated
|
|
106
|
+
* runtime configuration.
|
|
107
|
+
*/
|
|
108
|
+
function buildExecutorMcpProvisioningDeps(deps, bridgeApiBaseUrl) {
|
|
97
109
|
return {
|
|
98
110
|
readFile: deps.readFile,
|
|
99
111
|
writeFile: deps.writeFile,
|
|
@@ -101,6 +113,7 @@ function buildExecutorMcpProvisioningDeps(deps) {
|
|
|
101
113
|
platform: deps.platform,
|
|
102
114
|
cwd: deps.cwd,
|
|
103
115
|
mcpServerInvocation: deps.mcpServerInvocation,
|
|
116
|
+
bridgeApiBaseUrl,
|
|
104
117
|
};
|
|
105
118
|
}
|
|
106
119
|
/**
|
|
@@ -117,8 +130,13 @@ function buildExecutorMcpProvisioningDeps(deps) {
|
|
|
117
130
|
* secret-free diagnostic. Either way `runSpawnJob` proceeds to spawn the
|
|
118
131
|
* worker — a job that later needs an MCP tool it cannot reach fails loudly
|
|
119
132
|
* through its own process/gate/timeout/budget path, never here.
|
|
133
|
+
*
|
|
134
|
+
* BAPI-727 keeps that fail-open contract UNCONDITIONAL. The blocking Claude
|
|
135
|
+
* shadow-integrity check deliberately lives in `runSpawnJob` ABOVE this call, not
|
|
136
|
+
* inside it: the fixed `catch` below would otherwise convert a confirmed
|
|
137
|
+
* integrity refusal into a logged warning and spawn the worker anyway.
|
|
120
138
|
*/
|
|
121
|
-
async function provisionMcpForPreparedSpawn(job, branch, worktreePath, deps, seams) {
|
|
139
|
+
async function provisionMcpForPreparedSpawn(job, branch, worktreePath, deps, seams, bridgeApiBaseUrl) {
|
|
122
140
|
const provision = seams.provisionMcp ?? provisionMcpRegistrationForWorktree;
|
|
123
141
|
const row = {
|
|
124
142
|
key: job.ticket_key ?? String(job.id),
|
|
@@ -127,7 +145,7 @@ async function provisionMcpForPreparedSpawn(job, branch, worktreePath, deps, sea
|
|
|
127
145
|
path: worktreePath,
|
|
128
146
|
};
|
|
129
147
|
try {
|
|
130
|
-
const result = await provision(row, buildExecutorMcpProvisioningDeps(deps));
|
|
148
|
+
const result = await provision(row, buildExecutorMcpProvisioningDeps(deps, bridgeApiBaseUrl));
|
|
131
149
|
for (const warning of result.warnings ?? []) {
|
|
132
150
|
deps.errorLog(`MCP provisioning warning: ${warning}`);
|
|
133
151
|
}
|
|
@@ -143,6 +161,167 @@ async function provisionMcpForPreparedSpawn(job, branch, worktreePath, deps, sea
|
|
|
143
161
|
deps.errorLog("MCP provisioning did not complete; continuing fail-open");
|
|
144
162
|
}
|
|
145
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Adapt `ExecutorDeps` into the isolation module's explicit required interface,
|
|
166
|
+
* or `null` when this executor was constructed without the filesystem
|
|
167
|
+
* boundaries isolation needs.
|
|
168
|
+
*
|
|
169
|
+
* Returning `null` (rather than substituting a default) is the fail-closed
|
|
170
|
+
* posture: the optional `ExecutorDeps` members exist so existing embedders and
|
|
171
|
+
* test builders stay source-compatible, NOT so a worker may spawn without
|
|
172
|
+
* isolation. The caller turns `null` into a refusal.
|
|
173
|
+
*/
|
|
174
|
+
function buildWorkerConfigIsolationDeps(deps) {
|
|
175
|
+
const { mkdtemp, chmod, rmRecursive, lstatPath, tmpdir } = deps;
|
|
176
|
+
if (!mkdtemp || !chmod || !rmRecursive || !lstatPath || !tmpdir)
|
|
177
|
+
return null;
|
|
178
|
+
return {
|
|
179
|
+
platform: deps.platform,
|
|
180
|
+
env: deps.env,
|
|
181
|
+
tmpdir,
|
|
182
|
+
mkdtemp,
|
|
183
|
+
mkdir: deps.mkdir,
|
|
184
|
+
chmod,
|
|
185
|
+
rmRecursive,
|
|
186
|
+
lstatPath,
|
|
187
|
+
join: (...segments) => pathApiForProvisioningPlatform(deps.platform).join(...segments),
|
|
188
|
+
writeFile: deps.writeFile,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Resolve the repo-scoped Bridge credential to seed into the isolated store.
|
|
193
|
+
*
|
|
194
|
+
* Resolved through the SHARED resolver (`resolveBapiCredentials`) rather than by
|
|
195
|
+
* reading any file directly, per the repository's shell-spawned credential rule:
|
|
196
|
+
* env `BAPI_API_KEY` first, then the user-scoped `bapi:<repo>` store. It runs
|
|
197
|
+
* against the EXECUTOR's own deps/env — deliberately, because the executor is
|
|
198
|
+
* still outside the isolation boundary and is the only party that can still see
|
|
199
|
+
* the operator's store.
|
|
200
|
+
*
|
|
201
|
+
* Returns null when no credential can be resolved. `runSpawnJob` turns null into
|
|
202
|
+
* a pre-spawn `/fail` with `ContractError.BridgeCredentialUnavailable` — it never
|
|
203
|
+
* proceeds to establish isolation with an empty store, because every job type
|
|
204
|
+
* that reaches `runSpawnJob` needs Bridge MCP tools, and a worker without them
|
|
205
|
+
* exits cleanly having done nothing (RUN-FINDINGS-2026-08-05 F2). A resolution
|
|
206
|
+
* exception is swallowed into the same null so no raw error (which could carry a
|
|
207
|
+
* path or a secret) ever reaches a diagnostic; the refusal message is a fixed
|
|
208
|
+
* constant.
|
|
209
|
+
*/
|
|
210
|
+
async function resolveSeedBridgeCredential(deps, repoName) {
|
|
211
|
+
const trimmed = typeof repoName === "string" ? repoName.trim() : "";
|
|
212
|
+
if (trimmed.length === 0)
|
|
213
|
+
return null;
|
|
214
|
+
try {
|
|
215
|
+
const resolved = await resolveBapiCredentials(trimmed, {
|
|
216
|
+
env: deps.env,
|
|
217
|
+
homedir: deps.homedir,
|
|
218
|
+
platform: deps.platform,
|
|
219
|
+
readFile: deps.readFile,
|
|
220
|
+
stat: deps.stat,
|
|
221
|
+
// Route the resolver's (secret-free) insecure-mode warning through the
|
|
222
|
+
// executor's own log seam instead of raw process stderr.
|
|
223
|
+
stderr: (message) => deps.errorLog(message),
|
|
224
|
+
});
|
|
225
|
+
if (!resolved.ok)
|
|
226
|
+
return null;
|
|
227
|
+
return { repoName: trimmed, apiKey: resolved.credentials.apiKey };
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// Never let a credential-resolution error carry a secret into a diagnostic.
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Fixed, secret-free refusal text for `ContractError.BridgeCredentialUnavailable`.
|
|
236
|
+
* Names both supported resolution routes and no value, path fragment, or raw
|
|
237
|
+
* error — this exact text is what `/fail` persists as `error_message`.
|
|
238
|
+
*/
|
|
239
|
+
const BRIDGE_CREDENTIAL_UNAVAILABLE_MESSAGE = "no Bridge API credential could be resolved for this job's repository, so an isolated worker " +
|
|
240
|
+
"would run without Bridge MCP tools and do nothing; set BAPI_API_KEY in the executor's " +
|
|
241
|
+
"environment or add a bapi:<repo> entry to the user-scoped credential store " +
|
|
242
|
+
"(~/.config/bridge/credentials.json)";
|
|
243
|
+
/**
|
|
244
|
+
* Confirm the prepared worktree's own `.mcp.json` actually carries the Bridge
|
|
245
|
+
* shim registration the worker needs (BAPI-731 requirement, plan step 10).
|
|
246
|
+
*
|
|
247
|
+
* Read-only. Verifies PRESENCE of the `bridge-api` server name only — it does not
|
|
248
|
+
* filter, rewrite, or narrow the registered surface, so every other valid
|
|
249
|
+
* worktree-provisioned server (including supported Tier-2 targets) is preserved
|
|
250
|
+
* exactly as provisioned.
|
|
251
|
+
*/
|
|
252
|
+
async function verifyRequiredWorktreeMcpRegistration(worktreePath, deps) {
|
|
253
|
+
const [target] = getWorktreeMcpRegistrationTargets(worktreePath, deps.platform);
|
|
254
|
+
if (!target) {
|
|
255
|
+
return { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
const raw = await deps.readFile(target.filePath);
|
|
259
|
+
const parsed = JSON.parse(raw);
|
|
260
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
261
|
+
return { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
|
|
262
|
+
}
|
|
263
|
+
const servers = parsed[target.topLevelKey];
|
|
264
|
+
if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
|
|
265
|
+
return { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
|
|
266
|
+
}
|
|
267
|
+
const present = Object.prototype.hasOwnProperty.call(servers, REQUIRED_MCP_SERVER_NAME);
|
|
268
|
+
return present ? { ok: true } : { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
// Unreadable or malformed is indistinguishable from absent for this purpose,
|
|
272
|
+
// and both mean the worker cannot reach Bridge. The raw error never escapes.
|
|
273
|
+
return { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/** The registration name the provisioner writes and a worker requires. */
|
|
277
|
+
const REQUIRED_MCP_SERVER_NAME = "bridge-api";
|
|
278
|
+
/** Fixed, secret-free refusal text (no path, no file contents). */
|
|
279
|
+
const REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE = "worker config isolation active but the required 'bridge-api' MCP registration is missing or " +
|
|
280
|
+
"unreadable in the prepared worktree; an isolated worker has no operator-scoped registration to " +
|
|
281
|
+
"fall back to, so refusing to spawn a worker that would run without Bridge tools";
|
|
282
|
+
/**
|
|
283
|
+
* The stable `error_kind` for a claimed job refused because the machine's Claude
|
|
284
|
+
* user configuration can shadow the worker's provisioned MCP registration
|
|
285
|
+
* (BAPI-727). It joins the established `ContractError.*` family every other
|
|
286
|
+
* pre-spawn refusal in `runSpawnJob` already posts — this is the same class of
|
|
287
|
+
* failure: a local precondition that makes the job unrunnable-as-specified, decided
|
|
288
|
+
* before any side effect. The message is fixed and secret-free.
|
|
289
|
+
*/
|
|
290
|
+
export const MCP_SHADOWING_ERROR_KIND = "ContractError.McpShadowing";
|
|
291
|
+
/**
|
|
292
|
+
* Run the read-only Claude user-config inspection against a PREPARED worktree,
|
|
293
|
+
* covering all three scopes: user, the executor's main checkout (`deps.cwd`), and
|
|
294
|
+
* the concrete worktree this job will run in.
|
|
295
|
+
*
|
|
296
|
+
* Degraded outcomes (unreadable/malformed config) and an enabled override are
|
|
297
|
+
* logged and allowed through — the first is inconclusive, the second is a
|
|
298
|
+
* deliberate operator decision. Only a confirmed collision without the override
|
|
299
|
+
* blocks. An unexpected throw fails OPEN with a fixed diagnostic: the inspector is
|
|
300
|
+
* a guard, and a guard that crashes must not become a new way for jobs to die.
|
|
301
|
+
*/
|
|
302
|
+
async function checkClaudeMcpShadowingForPreparedWorktree(worktreePath, deps, seams) {
|
|
303
|
+
try {
|
|
304
|
+
const inspect = seams.inspectClaudeUserConfig ?? inspectClaudeUserConfigForMcpShadowing;
|
|
305
|
+
const inspection = await inspect({
|
|
306
|
+
claudeConfigPath: resolveClaudeUserConfigPath(deps.homedir(), deps.platform),
|
|
307
|
+
platform: deps.platform,
|
|
308
|
+
cwd: deps.cwd,
|
|
309
|
+
mainRepositoryPath: deps.cwd,
|
|
310
|
+
worktreePath,
|
|
311
|
+
}, { readFile: deps.readFile });
|
|
312
|
+
const policy = evaluateClaudeMcpShadowingPolicy(inspection, deps.env);
|
|
313
|
+
for (const warning of policy.warnings) {
|
|
314
|
+
deps.errorLog(`Claude MCP shadowing: ${warning}`);
|
|
315
|
+
}
|
|
316
|
+
if (policy.ok)
|
|
317
|
+
return { blocked: false };
|
|
318
|
+
return { blocked: true, message: policy.refusals.join("; ") };
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
deps.errorLog("Claude MCP shadowing check did not complete; continuing without a shadowing verdict");
|
|
322
|
+
return { blocked: false };
|
|
323
|
+
}
|
|
324
|
+
}
|
|
146
325
|
/**
|
|
147
326
|
* Default merge dispatch: resolve Bridge API access, then run the deterministic
|
|
148
327
|
* merge. Exported for unit tests (access-resolution failure → retryable). The
|
|
@@ -208,7 +387,15 @@ function createNoopProcess(deps, durationMs, message) {
|
|
|
208
387
|
},
|
|
209
388
|
};
|
|
210
389
|
}
|
|
211
|
-
/**
|
|
390
|
+
/**
|
|
391
|
+
* Run the process and heartbeat loop concurrently; return the process outcome.
|
|
392
|
+
*
|
|
393
|
+
* BAPI-731: `serverStopRequested` is carried ALONGSIDE the process
|
|
394
|
+
* classification rather than folded into it. When the server cancels a run the
|
|
395
|
+
* worker is SIGKILLed, so its raw classification would read as `killed` — which
|
|
396
|
+
* the caller would otherwise report as a worker fault. Keeping the flag separate
|
|
397
|
+
* is what lets the caller report a deliberate server cancellation distinctly.
|
|
398
|
+
*/
|
|
212
399
|
async function superviseProcess(params) {
|
|
213
400
|
const isDoneRef = { value: false };
|
|
214
401
|
const hb = runHeartbeatLoop({
|
|
@@ -220,6 +407,7 @@ async function superviseProcess(params) {
|
|
|
220
407
|
proc: params.proc,
|
|
221
408
|
observation: params.observation,
|
|
222
409
|
collectTelemetry: params.collectTelemetry,
|
|
410
|
+
collectRemoteMarker: params.collectRemoteMarker,
|
|
223
411
|
isDone: () => isDoneRef.value,
|
|
224
412
|
});
|
|
225
413
|
const result = await runProcessWithTimeout(params.proc, params.timeoutSeconds, params.deps, {
|
|
@@ -227,9 +415,11 @@ async function superviseProcess(params) {
|
|
|
227
415
|
onStdout: (chunk) => params.observation.recordStdout(chunk),
|
|
228
416
|
});
|
|
229
417
|
isDoneRef.value = true;
|
|
230
|
-
await hb;
|
|
418
|
+
const loopOutcome = await hb;
|
|
231
419
|
params.observation.setExitCode(result.exitCode);
|
|
232
|
-
return
|
|
420
|
+
return loopOutcome.kind === "server_stop"
|
|
421
|
+
? { ...result, serverStopRequested: true }
|
|
422
|
+
: result;
|
|
233
423
|
}
|
|
234
424
|
function terminalToRunResult(terminal, successStatus) {
|
|
235
425
|
switch (terminal.outcome) {
|
|
@@ -266,7 +456,27 @@ export async function runClaimedJob(job, httpClient, options, deps, _report, sea
|
|
|
266
456
|
// via the shared `isSpawnJobType` policy (`job-types.ts`) AFTER the dedicated
|
|
267
457
|
// `merge` and `smoke` branches so those keep their bespoke handling.
|
|
268
458
|
if (isSpawnJobType(job.job_type)) {
|
|
269
|
-
|
|
459
|
+
// BAPI-731: the per-job isolated Claude config directory is created INSIDE
|
|
460
|
+
// `runSpawnJob` (it must not exist before the pre-spawn contract passes) but
|
|
461
|
+
// is disposed HERE, in a `finally` around the whole call. That placement is
|
|
462
|
+
// the lifetime guarantee: disposal cannot run until the worker process has
|
|
463
|
+
// terminated, the log tee is finalized, and the terminal report has been
|
|
464
|
+
// sent — while still covering every exit path, including spawn exceptions,
|
|
465
|
+
// timeouts, stale claims, dead-man abandonment, server stops, artifact
|
|
466
|
+
// failures, finalization failures, and terminal-report failures.
|
|
467
|
+
const isolationHolder = { current: null };
|
|
468
|
+
try {
|
|
469
|
+
return await runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, isolationHolder);
|
|
470
|
+
}
|
|
471
|
+
finally {
|
|
472
|
+
if (isolationHolder.current) {
|
|
473
|
+
// Best-effort by contract: a cleanup failure is logged, never allowed to
|
|
474
|
+
// replace the job outcome already determined above.
|
|
475
|
+
const diagnostic = await isolationHolder.current.dispose();
|
|
476
|
+
if (diagnostic)
|
|
477
|
+
deps.errorLog(diagnostic);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
270
480
|
}
|
|
271
481
|
// Unsupported job type — fail server-side; never spawn.
|
|
272
482
|
await httpClient.fail(job, {
|
|
@@ -599,7 +809,13 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
599
809
|
}
|
|
600
810
|
// Explicit/synthesized prompt wins verbatim, preserving existing behavior.
|
|
601
811
|
if (explicit.ok) {
|
|
602
|
-
return {
|
|
812
|
+
return {
|
|
813
|
+
ok: true,
|
|
814
|
+
worktreePath: wt.worktreePath,
|
|
815
|
+
branch: wt.branch,
|
|
816
|
+
prompt: explicit.prompt,
|
|
817
|
+
expectedHeadSha: wt.expectedHeadSha,
|
|
818
|
+
};
|
|
603
819
|
}
|
|
604
820
|
// No explicit/synthesized prompt: render the structured `payload.prompt_spec`
|
|
605
821
|
// against the prepared worktree (`smoke`, and any future structured-prompt job;
|
|
@@ -614,7 +830,13 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
614
830
|
baseBranch: options.baseBranch,
|
|
615
831
|
runCommand: deps.runCommand,
|
|
616
832
|
});
|
|
617
|
-
return {
|
|
833
|
+
return {
|
|
834
|
+
ok: true,
|
|
835
|
+
worktreePath: wt.worktreePath,
|
|
836
|
+
branch: wt.branch,
|
|
837
|
+
prompt: rendered,
|
|
838
|
+
expectedHeadSha: wt.expectedHeadSha,
|
|
839
|
+
};
|
|
618
840
|
}
|
|
619
841
|
catch (err) {
|
|
620
842
|
const failure = isExecutorNamedError(err)
|
|
@@ -629,14 +851,16 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
629
851
|
}
|
|
630
852
|
}
|
|
631
853
|
/**
|
|
632
|
-
* Final pre-spawn preparation order (BAPI-724): worktree + prompt
|
|
633
|
-
*
|
|
854
|
+
* Final pre-spawn preparation order (BAPI-724, BAPI-727): worktree + prompt
|
|
855
|
+
* preparation (which itself performs reset-based untracked cleanup and command
|
|
856
|
+
* provisioning), timeout-contract validation, the BLOCKING Claude MCP
|
|
857
|
+
* shadow-integrity check, the deny-layer attempt, the MCP registration +
|
|
634
858
|
* trust-approval attempt, worker-log/viewer setup, then `deps.spawnProcess`.
|
|
635
|
-
* Failed preparation
|
|
636
|
-
* layer or MCP provisioning runs — neither ever
|
|
637
|
-
* not become a runnable worker.
|
|
859
|
+
* Failed preparation, an invalid timeout, or a confirmed MCP-shadowing collision
|
|
860
|
+
* returns BEFORE either the deny layer or MCP provisioning runs — neither ever
|
|
861
|
+
* touches a worktree that will not become a runnable worker.
|
|
638
862
|
*/
|
|
639
|
-
async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams) {
|
|
863
|
+
async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, isolationHolder) {
|
|
640
864
|
// --- Per-job base branch (BAPI-586) ----------------------------------
|
|
641
865
|
// Resolve the effective logical base BEFORE any side effect: the persisted
|
|
642
866
|
// run base (`payload.base_branch`) is authoritative, falling back to the
|
|
@@ -698,6 +922,115 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
698
922
|
});
|
|
699
923
|
return { status: "failed", reason: "timeout_contract" };
|
|
700
924
|
}
|
|
925
|
+
// --- Pre-spawn git verification (FAIL-CLOSED, BAPI-731 WS-E) ---------
|
|
926
|
+
// FIRST of the pre-spawn contracts, and deliberately BEFORE every
|
|
927
|
+
// executor-owned write to the worktree (deny layer, MCP provisioning, worker
|
|
928
|
+
// log). Placement is load-bearing twice over: those writes would themselves
|
|
929
|
+
// dirty the tree and make a later cleanliness assertion vacuous, and a job that
|
|
930
|
+
// cannot run correctly should not have had anything written for it.
|
|
931
|
+
//
|
|
932
|
+
// This is insurance against the F11a class — a `ci_fix` attempt that committed
|
|
933
|
+
// leftover unrelated work and burned a remediation attempt without touching the
|
|
934
|
+
// failing check. It does NOT replace or weaken the existing hard-reset /
|
|
935
|
+
// untracked-clean / head-verify behavior in `worktree-core.ts`; it is an
|
|
936
|
+
// additional independent check on top of them.
|
|
937
|
+
const preSpawnTelemetry = await collectGitTelemetry({ runCommand: deps.runCommand, now: deps.now }, worktreePath, effectiveBaseBranch);
|
|
938
|
+
const verification = evaluatePreSpawnGitVerification(preSpawnTelemetry, prep.expectedHeadSha);
|
|
939
|
+
if (!verification.ok) {
|
|
940
|
+
// Record the attempt boundary even for a refusal: an attempt that never
|
|
941
|
+
// spawned still started and ended at the observed commit, so the reconciler
|
|
942
|
+
// sees a machine-readable "went nowhere" rather than a silent gap.
|
|
943
|
+
if (verification.observedSha) {
|
|
944
|
+
observation.setAttemptStartSha(verification.observedSha);
|
|
945
|
+
observation.setAttemptEndSha(verification.observedSha);
|
|
946
|
+
}
|
|
947
|
+
observation.setGitTelemetry(preSpawnTelemetry);
|
|
948
|
+
await httpClient.fail(job, {
|
|
949
|
+
error_kind: PreSpawnVerification,
|
|
950
|
+
error_message: verification.message,
|
|
951
|
+
classification: "crashed",
|
|
952
|
+
...commitResidue(preSpawnTelemetry),
|
|
953
|
+
telemetry: observation.snapshot(),
|
|
954
|
+
});
|
|
955
|
+
return { status: "failed", reason: "pre_spawn_verification" };
|
|
956
|
+
}
|
|
957
|
+
observation.setAttemptStartSha(verification.observedSha);
|
|
958
|
+
// --- Claude MCP shadow integrity (BLOCKING, BAPI-727) ----------------
|
|
959
|
+
// Runs against the CONCRETE prepared worktree, after preparation and timeout
|
|
960
|
+
// validation but BEFORE the deny layer, MCP provisioning, worker-log setup, and
|
|
961
|
+
// spawn. Placement is the point: a `projects["<this worktree>"]` entry in
|
|
962
|
+
// ~/.claude.json would silently win over the registration provisioned two steps
|
|
963
|
+
// below, so the job must be stopped before any of those side effects — not
|
|
964
|
+
// after them, and not inside `provisionMcpForPreparedSpawn()`, whose fail-open
|
|
965
|
+
// catch exists to swallow provisioning errors and would swallow this refusal too.
|
|
966
|
+
const shadow = await checkClaudeMcpShadowingForPreparedWorktree(worktreePath, deps, seams);
|
|
967
|
+
if (shadow.blocked) {
|
|
968
|
+
await httpClient.fail(job, {
|
|
969
|
+
error_kind: MCP_SHADOWING_ERROR_KIND,
|
|
970
|
+
error_message: shadow.message,
|
|
971
|
+
classification: "crashed",
|
|
972
|
+
});
|
|
973
|
+
return { status: "failed", reason: "mcp_shadowing_contract" };
|
|
974
|
+
}
|
|
975
|
+
// --- Worker config isolation (FAIL-CLOSED, BAPI-731 WS-A) ------------
|
|
976
|
+
// After the retained BAPI-727 shadowing refusal (which stays defense-in-depth
|
|
977
|
+
// for the named `bridge-api` collision) and before the deny layer, MCP
|
|
978
|
+
// provisioning, log setup, and spawn.
|
|
979
|
+
//
|
|
980
|
+
// FAIL-CLOSED, unlike the fail-OPEN provisioning steps immediately below. The
|
|
981
|
+
// whole point is that a worker must never fall back to the operator's
|
|
982
|
+
// configuration: that fallback is how a worker acquired a `dbhub` handle it was
|
|
983
|
+
// never granted (BAPI-715 finding F4). "Could not isolate" therefore means
|
|
984
|
+
// "do not run", not "run with less containment".
|
|
985
|
+
const isolationEstablish = seams.establishWorkerConfigIsolation ?? establishWorkerConfigIsolation;
|
|
986
|
+
const isolationDeps = buildWorkerConfigIsolationDeps(deps);
|
|
987
|
+
// BAPI-731 gap: the isolated XDG root is where the Bridge credential store is
|
|
988
|
+
// looked up, so it must carry this repo's entry or the worker loses every
|
|
989
|
+
// Bridge MCP tool. Resolved here, outside the boundary, and scoped to one repo.
|
|
990
|
+
const seedCredential = isolationDeps
|
|
991
|
+
? await resolveSeedBridgeCredential(deps, job.repo_name)
|
|
992
|
+
: null;
|
|
993
|
+
// FAIL-CLOSED on an unresolvable credential (RUN-FINDINGS-2026-08-05 F2):
|
|
994
|
+
// establishing isolation with an EMPTY store would succeed, and the worker
|
|
995
|
+
// would spawn, find no `bapi:<repo>` entry under the redirected XDG root, lose
|
|
996
|
+
// every Bridge MCP tool, and exit cleanly having done nothing — the original
|
|
997
|
+
// F2 death, reported as success. Every job type that reaches `runSpawnJob`
|
|
998
|
+
// needs Bridge MCP (`smoke`/`merge` never come through here), so a distinct
|
|
999
|
+
// pre-spawn refusal breaks nothing legitimate and makes the cause queryable.
|
|
1000
|
+
if (isolationDeps && !seedCredential) {
|
|
1001
|
+
await httpClient.fail(job, {
|
|
1002
|
+
error_kind: BridgeCredentialUnavailable,
|
|
1003
|
+
error_message: BRIDGE_CREDENTIAL_UNAVAILABLE_MESSAGE,
|
|
1004
|
+
classification: "crashed",
|
|
1005
|
+
...commitResidue(preSpawnTelemetry),
|
|
1006
|
+
telemetry: observation.snapshot(),
|
|
1007
|
+
});
|
|
1008
|
+
return { status: "failed", reason: "bridge_credential_unavailable" };
|
|
1009
|
+
}
|
|
1010
|
+
const isolationResult = isolationDeps
|
|
1011
|
+
? await isolationEstablish(isolationDeps, {
|
|
1012
|
+
...(seedCredential ? { bridgeCredential: seedCredential } : {}),
|
|
1013
|
+
})
|
|
1014
|
+
: {
|
|
1015
|
+
ok: false,
|
|
1016
|
+
platform: String(deps.platform),
|
|
1017
|
+
reason: "missing-filesystem-dependency",
|
|
1018
|
+
message: formatWorkerConfigIsolationReason(String(deps.platform), "missing-filesystem-dependency"),
|
|
1019
|
+
};
|
|
1020
|
+
if (!isolationResult.ok) {
|
|
1021
|
+
await httpClient.fail(job, {
|
|
1022
|
+
error_kind: WorkerConfigIsolation,
|
|
1023
|
+
error_message: isolationResult.message,
|
|
1024
|
+
classification: "crashed",
|
|
1025
|
+
...commitResidue(preSpawnTelemetry),
|
|
1026
|
+
telemetry: observation.snapshot(),
|
|
1027
|
+
});
|
|
1028
|
+
return { status: "failed", reason: "worker_config_isolation" };
|
|
1029
|
+
}
|
|
1030
|
+
const isolation = isolationResult.isolation;
|
|
1031
|
+
// Hand the handle to the caller's `finally` so it is disposed on EVERY exit
|
|
1032
|
+
// path from here on, including ones that never reach the spawn below.
|
|
1033
|
+
isolationHolder.current = isolation;
|
|
701
1034
|
// --- Deny layer (fail-open) ------------------------------------------
|
|
702
1035
|
const deny = await provisionExecutorDenyLayer(worktreePath, { baseBranch: effectiveBaseBranch }, {
|
|
703
1036
|
readFile: deps.readFile,
|
|
@@ -711,7 +1044,31 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
711
1044
|
// Unconditional for every job type that reaches this shared spawn path
|
|
712
1045
|
// (implement/resume/spec_review/remediate/ci_fix/rebase) — no job-type
|
|
713
1046
|
// branching, so a future spawn job type inherits the same default access.
|
|
714
|
-
|
|
1047
|
+
// BAPI-727: the validated endpoint travels from executor bootstrap through the
|
|
1048
|
+
// runtime options to the generated registration, so the worker's MCP server
|
|
1049
|
+
// targets the same Bridge API this executor claimed the job from.
|
|
1050
|
+
await provisionMcpForPreparedSpawn(job, branch, worktreePath, deps, seams, options.resolvedBaseUrl);
|
|
1051
|
+
// --- Required Bridge MCP registration (FAIL-CLOSED under isolation) ---
|
|
1052
|
+
// Provisioning itself stays fail-OPEN: a skipped or malformed Tier-2 target is
|
|
1053
|
+
// a warning, and every valid worktree-provisioned server is kept. But the
|
|
1054
|
+
// REQUIRED `bridge-api` shim registration becomes fail-closed once isolation is
|
|
1055
|
+
// active, because the two changes compose into a new failure mode: before
|
|
1056
|
+
// isolation, a worker missing its worktree registration could still fall back
|
|
1057
|
+
// to an operator-scoped `bridge-api` entry and function; under isolation there
|
|
1058
|
+
// is no fallback at all, so it would run silently toolless and produce
|
|
1059
|
+
// confidently wrong work. Refusing is the only safe reading.
|
|
1060
|
+
const verifyRegistration = seams.verifyRequiredWorktreeMcpRegistration ?? verifyRequiredWorktreeMcpRegistration;
|
|
1061
|
+
const registration = await verifyRegistration(worktreePath, deps);
|
|
1062
|
+
if (!registration.ok) {
|
|
1063
|
+
await httpClient.fail(job, {
|
|
1064
|
+
error_kind: WorkerConfigIsolation,
|
|
1065
|
+
error_message: registration.message,
|
|
1066
|
+
classification: "crashed",
|
|
1067
|
+
...commitResidue(preSpawnTelemetry),
|
|
1068
|
+
telemetry: observation.snapshot(),
|
|
1069
|
+
});
|
|
1070
|
+
return { status: "failed", reason: "worker_config_isolation" };
|
|
1071
|
+
}
|
|
715
1072
|
// --- Worker log tee + watch registry + viewer tab (BAPI-535 §8) ------
|
|
716
1073
|
const registryDeps = buildJobLogRegistryDeps(deps);
|
|
717
1074
|
const markFinished = seams.markExecutorJobLogFinished ?? markExecutorJobLogFinished;
|
|
@@ -783,7 +1140,18 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
783
1140
|
const argv = buildClaudeExecutorArgv(prompt, alias, permissionPosture);
|
|
784
1141
|
// BAPI-586: inject the validated run base as BAPI_BASE_BRANCH so the worker
|
|
785
1142
|
// can target it via `gh pr create --base "$BAPI_BASE_BRANCH"`.
|
|
786
|
-
|
|
1143
|
+
// BAPI-731: the Claude/XDG paths come from the isolation handle established
|
|
1144
|
+
// above — never from `deps.env`, which is exactly what makes them
|
|
1145
|
+
// un-overridable by the operator's environment. The API key is read from the
|
|
1146
|
+
// executor's own environment and passed explicitly because isolation removes
|
|
1147
|
+
// the worker's previous (inherited-`HOME`) authentication path entirely; see
|
|
1148
|
+
// the note in `env.ts` and inventory finding 4.
|
|
1149
|
+
const env = buildExecutorWorkerEnv(deps.env, {
|
|
1150
|
+
effectiveBaseBranch,
|
|
1151
|
+
claudeConfigDir: isolation.claudeConfigDir,
|
|
1152
|
+
xdgConfigHome: isolation.xdgConfigHome,
|
|
1153
|
+
anthropicApiKey: deps.env.ANTHROPIC_API_KEY,
|
|
1154
|
+
});
|
|
787
1155
|
let proc;
|
|
788
1156
|
try {
|
|
789
1157
|
proc = deps.spawnProcess(CLAUDE_EXECUTABLE, argv, { cwd: worktreePath, env });
|
|
@@ -807,6 +1175,10 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
807
1175
|
}
|
|
808
1176
|
: proc;
|
|
809
1177
|
const collectTelemetry = () => collectGitTelemetry({ runCommand: deps.runCommand, now: deps.now }, worktreePath, effectiveBaseBranch);
|
|
1178
|
+
// BAPI-731 (WS-H): sample the LOCAL remote-tracking marker so a mid-job push
|
|
1179
|
+
// flushes telemetry promptly instead of waiting up to a full heartbeat
|
|
1180
|
+
// interval. Local ref read only — never a network call on this path.
|
|
1181
|
+
const collectRemoteMarker = () => collectRemoteTrackingSha({ runCommand: deps.runCommand }, worktreePath, branch);
|
|
810
1182
|
const procResult = await superviseProcess({
|
|
811
1183
|
job,
|
|
812
1184
|
httpClient,
|
|
@@ -817,9 +1189,37 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
817
1189
|
proc: supervisedProc,
|
|
818
1190
|
timeoutSeconds: timeout.timeoutSeconds,
|
|
819
1191
|
collectTelemetry,
|
|
1192
|
+
collectRemoteMarker,
|
|
820
1193
|
});
|
|
821
1194
|
// Final telemetry snapshot from the worktree.
|
|
822
|
-
|
|
1195
|
+
const finalTelemetry = await collectTelemetry();
|
|
1196
|
+
observation.setGitTelemetry(finalTelemetry);
|
|
1197
|
+
// Only a SUCCESSFUL final HEAD read may close the attempt boundary. A degraded
|
|
1198
|
+
// read leaves `attempt_end_sha` absent rather than relabeling an older sample.
|
|
1199
|
+
if (typeof finalTelemetry.last_commit_sha === "string") {
|
|
1200
|
+
observation.setAttemptEndSha(finalTelemetry.last_commit_sha);
|
|
1201
|
+
}
|
|
1202
|
+
// --- Server-requested stop (BAPI-731, WS-B executor half) -------------
|
|
1203
|
+
// Handled BEFORE the abandonment check below, because the server stop is the
|
|
1204
|
+
// one abandonment path that still owes a terminal report: the job was
|
|
1205
|
+
// deliberately CANCELLED while the claim was still valid, so reporting it as a
|
|
1206
|
+
// worker crash (the process was SIGKILLed, so its raw classification is
|
|
1207
|
+
// `killed`) would be actively wrong. Ownership is marked abandoned only AFTER
|
|
1208
|
+
// the terminal mutation is attempted, so the report goes out under a live claim.
|
|
1209
|
+
if (procResult.serverStopRequested) {
|
|
1210
|
+
await closeWorkerLogTee(tee);
|
|
1211
|
+
await finalizeRegistry();
|
|
1212
|
+
await httpClient.fail(job, {
|
|
1213
|
+
error_kind: "ServerStopRequested",
|
|
1214
|
+
error_message: "the server requested this run stop; the owned worker was terminated",
|
|
1215
|
+
classification: "killed",
|
|
1216
|
+
...commitResidue(finalTelemetry),
|
|
1217
|
+
telemetry: observation.snapshot(),
|
|
1218
|
+
});
|
|
1219
|
+
ownership.abandoned = true;
|
|
1220
|
+
ownership.abandonReason = "server_stop";
|
|
1221
|
+
return { status: "abandoned", reason: "server_stop" };
|
|
1222
|
+
}
|
|
823
1223
|
// Finalize the log tee WITHOUT signaling the worker (it has already exited).
|
|
824
1224
|
await closeWorkerLogTee(tee);
|
|
825
1225
|
if (ownership.abandoned) {
|