@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
|
@@ -17,9 +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 { getWorktreeMcpRegistrationTargets, pathApiForProvisioningPlatform, provisionMcpRegistrationForWorktree, } from "../mcp-provisioning.js";
|
|
24
|
+
import { evaluateClaudeMcpShadowingPolicy, inspectClaudeUserConfigForMcpShadowing, resolveClaudeUserConfigPath, } from "../claude-user-config-doctor.js";
|
|
23
25
|
import { provisionExecutorDenyLayer } from "./permissions.js";
|
|
24
26
|
import { runProcessWithTimeout } from "./process.js";
|
|
25
27
|
import { prepareResumeSpawn } from "./resume-pre-spawn.js";
|
|
@@ -35,8 +37,10 @@ import { resolveExecutorJobBaseBranch } from "./base-branch.js";
|
|
|
35
37
|
import { validateWorkerFinalization } from "./worker-finalization.js";
|
|
36
38
|
import { ensureExecutorWorktree } from "./worktree.js";
|
|
37
39
|
import { buildPrBaseContractLaunchInstruction } from "../pr-base-contract.js";
|
|
38
|
-
import { buildClaudeExecutorArgv, CLAUDE_EXECUTABLE, resolveExecutorModelAlias, resolveExecutorPrompt, } from "./worker-command.js";
|
|
39
|
-
import { collectGitTelemetry } from "./observation.js";
|
|
40
|
+
import { buildClaudeExecutorArgv, CLAUDE_EXECUTABLE, resolveExecutorModelAlias, resolveExecutorPrompt, resolveWorkerPermissionPosture, } from "./worker-command.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";
|
|
40
44
|
/** Default runtime for the no-op smoke process (ms). */
|
|
41
45
|
const DEFAULT_SMOKE_DURATION_MS = 100;
|
|
42
46
|
/** ProcessClassification maps 1:1 onto the wire ExecutorClassification. */
|
|
@@ -91,6 +95,233 @@ function buildJobLogRegistryDeps(deps) {
|
|
|
91
95
|
platform: deps.platform,
|
|
92
96
|
};
|
|
93
97
|
}
|
|
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) {
|
|
109
|
+
return {
|
|
110
|
+
readFile: deps.readFile,
|
|
111
|
+
writeFile: deps.writeFile,
|
|
112
|
+
mkdir: deps.mkdir,
|
|
113
|
+
platform: deps.platform,
|
|
114
|
+
cwd: deps.cwd,
|
|
115
|
+
mcpServerInvocation: deps.mcpServerInvocation,
|
|
116
|
+
bridgeApiBaseUrl,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Provision secret-free Bridge MCP registration + Claude trust pre-approval
|
|
121
|
+
* (BAPI-724) into a prepared spawn worktree, for every job type that reaches
|
|
122
|
+
* the shared spawn path. Reuses the exact `start-tickets` provisioning
|
|
123
|
+
* machinery by constructing a synthetic `created` {@link SummaryRow} for this
|
|
124
|
+
* job/worktree — no registration serialization or second trust write happens
|
|
125
|
+
* here.
|
|
126
|
+
*
|
|
127
|
+
* Fail-open by construction: a provisioner warning or a `spawn-failed` result
|
|
128
|
+
* is logged via `deps.errorLog` and this function still returns normally, and
|
|
129
|
+
* an unexpected thrown exception is caught and reduced to a single fixed,
|
|
130
|
+
* secret-free diagnostic. Either way `runSpawnJob` proceeds to spawn the
|
|
131
|
+
* worker — a job that later needs an MCP tool it cannot reach fails loudly
|
|
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.
|
|
138
|
+
*/
|
|
139
|
+
async function provisionMcpForPreparedSpawn(job, branch, worktreePath, deps, seams, bridgeApiBaseUrl) {
|
|
140
|
+
const provision = seams.provisionMcp ?? provisionMcpRegistrationForWorktree;
|
|
141
|
+
const row = {
|
|
142
|
+
key: job.ticket_key ?? String(job.id),
|
|
143
|
+
branch,
|
|
144
|
+
status: "created",
|
|
145
|
+
path: worktreePath,
|
|
146
|
+
};
|
|
147
|
+
try {
|
|
148
|
+
const result = await provision(row, buildExecutorMcpProvisioningDeps(deps, bridgeApiBaseUrl));
|
|
149
|
+
for (const warning of result.warnings ?? []) {
|
|
150
|
+
deps.errorLog(`MCP provisioning warning: ${warning}`);
|
|
151
|
+
}
|
|
152
|
+
if (result.status === "spawn-failed") {
|
|
153
|
+
deps.errorLog(`MCP provisioning did not complete (${result.error ?? "unknown reason"}); continuing fail-open`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// Fail-open, secret-free: a fixed diagnostic only. Never interpolate the
|
|
158
|
+
// thrown exception, the worktree path, a command/argument, or credential
|
|
159
|
+
// material — this branch exists precisely so an unexpected provisioning
|
|
160
|
+
// failure can never leak anything into the executor's own logs.
|
|
161
|
+
deps.errorLog("MCP provisioning did not complete; continuing fail-open");
|
|
162
|
+
}
|
|
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
|
+
}
|
|
94
325
|
/**
|
|
95
326
|
* Default merge dispatch: resolve Bridge API access, then run the deterministic
|
|
96
327
|
* merge. Exported for unit tests (access-resolution failure → retryable). The
|
|
@@ -156,7 +387,15 @@ function createNoopProcess(deps, durationMs, message) {
|
|
|
156
387
|
},
|
|
157
388
|
};
|
|
158
389
|
}
|
|
159
|
-
/**
|
|
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
|
+
*/
|
|
160
399
|
async function superviseProcess(params) {
|
|
161
400
|
const isDoneRef = { value: false };
|
|
162
401
|
const hb = runHeartbeatLoop({
|
|
@@ -168,6 +407,7 @@ async function superviseProcess(params) {
|
|
|
168
407
|
proc: params.proc,
|
|
169
408
|
observation: params.observation,
|
|
170
409
|
collectTelemetry: params.collectTelemetry,
|
|
410
|
+
collectRemoteMarker: params.collectRemoteMarker,
|
|
171
411
|
isDone: () => isDoneRef.value,
|
|
172
412
|
});
|
|
173
413
|
const result = await runProcessWithTimeout(params.proc, params.timeoutSeconds, params.deps, {
|
|
@@ -175,9 +415,11 @@ async function superviseProcess(params) {
|
|
|
175
415
|
onStdout: (chunk) => params.observation.recordStdout(chunk),
|
|
176
416
|
});
|
|
177
417
|
isDoneRef.value = true;
|
|
178
|
-
await hb;
|
|
418
|
+
const loopOutcome = await hb;
|
|
179
419
|
params.observation.setExitCode(result.exitCode);
|
|
180
|
-
return
|
|
420
|
+
return loopOutcome.kind === "server_stop"
|
|
421
|
+
? { ...result, serverStopRequested: true }
|
|
422
|
+
: result;
|
|
181
423
|
}
|
|
182
424
|
function terminalToRunResult(terminal, successStatus) {
|
|
183
425
|
switch (terminal.outcome) {
|
|
@@ -214,7 +456,27 @@ export async function runClaimedJob(job, httpClient, options, deps, _report, sea
|
|
|
214
456
|
// via the shared `isSpawnJobType` policy (`job-types.ts`) AFTER the dedicated
|
|
215
457
|
// `merge` and `smoke` branches so those keep their bespoke handling.
|
|
216
458
|
if (isSpawnJobType(job.job_type)) {
|
|
217
|
-
|
|
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
|
+
}
|
|
218
480
|
}
|
|
219
481
|
// Unsupported job type — fail server-side; never spawn.
|
|
220
482
|
await httpClient.fail(job, {
|
|
@@ -401,6 +663,19 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
|
|
|
401
663
|
});
|
|
402
664
|
return terminalToRunResult(terminal, "failed");
|
|
403
665
|
}
|
|
666
|
+
/**
|
|
667
|
+
* BAPI-724 design note (deliberate, not an accidental AC-5 gap): `smoke` stays
|
|
668
|
+
* on this no-worktree, no-real-worker-LLM `createNoopProcess` path and does
|
|
669
|
+
* NOT receive MCP registration. The ratified requirement anchors MCP
|
|
670
|
+
* provisioning to "the executor's worktree-preparation path
|
|
671
|
+
* (`job-runner.ts`), alongside the existing `provisionExecutorDenyLayer` call"
|
|
672
|
+
* — that call, like the new MCP provisioning call, exists ONLY inside
|
|
673
|
+
* `runSpawnJob`, which `smoke` never reaches (see `isSpawnJobType` /
|
|
674
|
+
* `job-types.ts`, and this module's own "no real worker LLM" framing of the
|
|
675
|
+
* acceptance spine). Converting smoke into a worktree-having, worker-spawning
|
|
676
|
+
* job to give it something to register MCP into would be a materially larger,
|
|
677
|
+
* unratified change to that architectural boundary, not a wiring task.
|
|
678
|
+
*/
|
|
404
679
|
async function runSmokeJob(job, httpClient, options, deps, ownership, observation) {
|
|
405
680
|
const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
|
|
406
681
|
if (!timeout.ok) {
|
|
@@ -488,10 +763,18 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
488
763
|
}
|
|
489
764
|
}
|
|
490
765
|
// Resolve an explicit `payload.prompt` or the `/implement-ticket <KEY> --auto`
|
|
491
|
-
// synthesis (implement + recovery).
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
766
|
+
// synthesis (implement + recovery).
|
|
767
|
+
//
|
|
768
|
+
// BAPI-725: `spec_review` now takes the explicit-prompt path like any other job
|
|
769
|
+
// — the reconciler builds its prompt server-side and ships it in
|
|
770
|
+
// `payload.prompt`, so it resolves here and returns below without ever reaching
|
|
771
|
+
// the prompt-spec renderer. A stale `payload.prompt_spec` on an in-flight job
|
|
772
|
+
// enqueued before this change cannot override that: the explicit prompt wins
|
|
773
|
+
// unconditionally at the `explicit.ok` return.
|
|
774
|
+
//
|
|
775
|
+
// The renderer below is NOT dead. `smoke` and any future structured-prompt job
|
|
776
|
+
// still reach it, and rendering `{{GIT_LOG}}` is why it must run after worktree
|
|
777
|
+
// preparation rather than in `resolveExecutorPrompt`.
|
|
495
778
|
const explicit = resolveExecutorPrompt(job);
|
|
496
779
|
const payload = job.payload && typeof job.payload === "object"
|
|
497
780
|
? job.payload
|
|
@@ -526,12 +809,19 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
526
809
|
}
|
|
527
810
|
// Explicit/synthesized prompt wins verbatim, preserving existing behavior.
|
|
528
811
|
if (explicit.ok) {
|
|
529
|
-
return {
|
|
812
|
+
return {
|
|
813
|
+
ok: true,
|
|
814
|
+
worktreePath: wt.worktreePath,
|
|
815
|
+
branch: wt.branch,
|
|
816
|
+
prompt: explicit.prompt,
|
|
817
|
+
expectedHeadSha: wt.expectedHeadSha,
|
|
818
|
+
};
|
|
530
819
|
}
|
|
531
820
|
// No explicit/synthesized prompt: render the structured `payload.prompt_spec`
|
|
532
|
-
// against the prepared worktree (
|
|
533
|
-
//
|
|
534
|
-
//
|
|
821
|
+
// against the prepared worktree (`smoke`, and any future structured-prompt job;
|
|
822
|
+
// `spec_review` left this path in BAPI-725). A malformed spec fails loud with
|
|
823
|
+
// `ContractError.Prompt` so we `/fail` rather than spawn a worker with an empty
|
|
824
|
+
// prompt. Error messages are secret-free by construction.
|
|
535
825
|
const renderPrompt = seams.renderPromptSpecPrompt ?? renderPromptSpecPrompt;
|
|
536
826
|
try {
|
|
537
827
|
const rendered = await renderPrompt(job, {
|
|
@@ -540,7 +830,13 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
540
830
|
baseBranch: options.baseBranch,
|
|
541
831
|
runCommand: deps.runCommand,
|
|
542
832
|
});
|
|
543
|
-
return {
|
|
833
|
+
return {
|
|
834
|
+
ok: true,
|
|
835
|
+
worktreePath: wt.worktreePath,
|
|
836
|
+
branch: wt.branch,
|
|
837
|
+
prompt: rendered,
|
|
838
|
+
expectedHeadSha: wt.expectedHeadSha,
|
|
839
|
+
};
|
|
544
840
|
}
|
|
545
841
|
catch (err) {
|
|
546
842
|
const failure = isExecutorNamedError(err)
|
|
@@ -554,7 +850,17 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
554
850
|
return { ok: false, result: { status: "failed", reason: "prompt_contract" } };
|
|
555
851
|
}
|
|
556
852
|
}
|
|
557
|
-
|
|
853
|
+
/**
|
|
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 +
|
|
858
|
+
* trust-approval attempt, worker-log/viewer setup, then `deps.spawnProcess`.
|
|
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.
|
|
862
|
+
*/
|
|
863
|
+
async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, isolationHolder) {
|
|
558
864
|
// --- Per-job base branch (BAPI-586) ----------------------------------
|
|
559
865
|
// Resolve the effective logical base BEFORE any side effect: the persisted
|
|
560
866
|
// run base (`payload.base_branch`) is authoritative, falling back to the
|
|
@@ -574,6 +880,26 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
574
880
|
}
|
|
575
881
|
const effectiveBaseBranch = baseResolution.baseBranch;
|
|
576
882
|
const jobOptions = { ...options, baseBranch: effectiveBaseBranch };
|
|
883
|
+
// --- Permission posture (BAPI-725) -----------------------------------
|
|
884
|
+
// Resolved alongside the base branch, BEFORE any side effect, for the same
|
|
885
|
+
// reason: an unsupported posture is a contract failure, and failing it here
|
|
886
|
+
// means no worktree is cut, no deny layer is written, and no process is
|
|
887
|
+
// spawned for a job that can never run correctly. The error text names only
|
|
888
|
+
// the field — the supplied value never reaches `/fail`.
|
|
889
|
+
let permissionPosture;
|
|
890
|
+
try {
|
|
891
|
+
permissionPosture = resolveWorkerPermissionPosture(job.payload);
|
|
892
|
+
}
|
|
893
|
+
catch (err) {
|
|
894
|
+
await httpClient.fail(job, isExecutorNamedError(err)
|
|
895
|
+
? toExecutorFailure(err)
|
|
896
|
+
: {
|
|
897
|
+
error_kind: "ContractError.PermissionPosture",
|
|
898
|
+
error_message: secretFreeErrorMessage(err),
|
|
899
|
+
classification: "crashed",
|
|
900
|
+
});
|
|
901
|
+
return { status: "failed", reason: "permission_posture_contract" };
|
|
902
|
+
}
|
|
577
903
|
// --- Worktree + prompt (resume pre-spawn protocol or standard) -------
|
|
578
904
|
const prep = await prepareSpawn(job, httpClient, jobOptions, deps, seams);
|
|
579
905
|
if (!prep.ok)
|
|
@@ -596,6 +922,115 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
596
922
|
});
|
|
597
923
|
return { status: "failed", reason: "timeout_contract" };
|
|
598
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;
|
|
599
1034
|
// --- Deny layer (fail-open) ------------------------------------------
|
|
600
1035
|
const deny = await provisionExecutorDenyLayer(worktreePath, { baseBranch: effectiveBaseBranch }, {
|
|
601
1036
|
readFile: deps.readFile,
|
|
@@ -605,6 +1040,35 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
605
1040
|
});
|
|
606
1041
|
if (!deny.ok)
|
|
607
1042
|
deps.errorLog(deny.warning);
|
|
1043
|
+
// --- MCP registration + trust pre-approval (fail-open, BAPI-724) -----
|
|
1044
|
+
// Unconditional for every job type that reaches this shared spawn path
|
|
1045
|
+
// (implement/resume/spec_review/remediate/ci_fix/rebase) — no job-type
|
|
1046
|
+
// branching, so a future spawn job type inherits the same default access.
|
|
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
|
+
}
|
|
608
1072
|
// --- Worker log tee + watch registry + viewer tab (BAPI-535 §8) ------
|
|
609
1073
|
const registryDeps = buildJobLogRegistryDeps(deps);
|
|
610
1074
|
const markFinished = seams.markExecutorJobLogFinished ?? markExecutorJobLogFinished;
|
|
@@ -670,10 +1134,24 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
670
1134
|
};
|
|
671
1135
|
// --- Spawn -----------------------------------------------------------
|
|
672
1136
|
const alias = resolveExecutorModelAlias(job.payload);
|
|
673
|
-
|
|
1137
|
+
// BAPI-725: the posture resolved at the top of this function — after the deny
|
|
1138
|
+
// layer has already been provisioned above, which is what keeps `acceptEdits`
|
|
1139
|
+
// from being a relaxation of what the worker may do.
|
|
1140
|
+
const argv = buildClaudeExecutorArgv(prompt, alias, permissionPosture);
|
|
674
1141
|
// BAPI-586: inject the validated run base as BAPI_BASE_BRANCH so the worker
|
|
675
1142
|
// can target it via `gh pr create --base "$BAPI_BASE_BRANCH"`.
|
|
676
|
-
|
|
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
|
+
});
|
|
677
1155
|
let proc;
|
|
678
1156
|
try {
|
|
679
1157
|
proc = deps.spawnProcess(CLAUDE_EXECUTABLE, argv, { cwd: worktreePath, env });
|
|
@@ -697,6 +1175,10 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
697
1175
|
}
|
|
698
1176
|
: proc;
|
|
699
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);
|
|
700
1182
|
const procResult = await superviseProcess({
|
|
701
1183
|
job,
|
|
702
1184
|
httpClient,
|
|
@@ -707,9 +1189,37 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
707
1189
|
proc: supervisedProc,
|
|
708
1190
|
timeoutSeconds: timeout.timeoutSeconds,
|
|
709
1191
|
collectTelemetry,
|
|
1192
|
+
collectRemoteMarker,
|
|
710
1193
|
});
|
|
711
1194
|
// Final telemetry snapshot from the worktree.
|
|
712
|
-
|
|
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
|
+
}
|
|
713
1223
|
// Finalize the log tee WITHOUT signaling the worker (it has already exited).
|
|
714
1224
|
await closeWorkerLogTee(tee);
|
|
715
1225
|
if (ownership.abandoned) {
|