@bridge_gpt/mcp-server 0.2.50 → 0.2.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -8
- package/build/agent-capabilities/probe-context.js +15 -7
- package/build/agent-capabilities/probes.js +42 -6
- package/build/agent-launchers/claude-executor-adapter.js +98 -14
- package/build/commands.generated.js +1 -1
- package/build/conduct-epic/bridge-client.js +115 -1
- package/build/conduct-epic/cli.js +351 -33
- package/build/conduct-epic/cut-protocol.js +65 -0
- package/build/conductor/bridge-api-client.js +171 -5
- package/build/conductor/deny-enforcement-preflight.js +107 -10
- package/build/conductor/local-merge.js +170 -11
- package/build/conductor-bin.js +2 -2
- package/build/connect-bitbucket-api.js +370 -0
- package/build/connect-bitbucket.js +437 -0
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +230 -1
- package/build/drive-epic.js +423 -11
- package/build/env-file-link.js +164 -0
- package/build/epic-integration-pr.js +290 -0
- package/build/executor/cli.js +41 -6
- package/build/executor/deps.js +5 -1
- package/build/executor/env-file-guard.js +113 -0
- package/build/executor/env.js +78 -1
- package/build/executor/heartbeat.js +9 -0
- package/build/executor/http-client.js +90 -22
- package/build/executor/job-errors.js +43 -2
- package/build/executor/job-runner.js +137 -29
- package/build/executor/merge-job.js +102 -6
- package/build/executor/permissions.js +106 -0
- package/build/executor/preflight.js +38 -13
- package/build/executor/resume-pre-spawn.js +2 -1
- package/build/executor/runner.js +175 -4
- package/build/executor/service-unit.js +15 -0
- package/build/executor/terminal-mutation.js +22 -1
- package/build/executor/types.js +86 -0
- package/build/executor/worker-command.js +21 -5
- package/build/executor/worker-guard-hook.js +939 -0
- package/build/executor/worker-log.js +56 -0
- package/build/executor/worktree.js +11 -0
- package/build/git-reachability.js +147 -0
- package/build/index.js +535 -95
- package/build/install-bridge.js +95 -0
- package/build/pipelines.generated.js +10 -2
- package/build/plan-epic-conductor-eligibility.js +213 -0
- package/build/plane/cli.js +78 -15
- package/build/plane/defaults.js +165 -0
- package/build/plane/manifest.js +63 -8
- package/build/plane/member-logs.js +6 -0
- package/build/plane/member-roster.js +195 -11
- package/build/plane/preflight.js +43 -0
- package/build/plane/shutdown.js +25 -3
- package/build/plane/status.js +11 -0
- package/build/plane/supervisor.js +343 -14
- package/build/plane/test-fakes.js +43 -0
- package/build/plane/types.js +82 -11
- package/build/pr-base-contract.js +20 -0
- package/build/readme.generated.js +1 -1
- package/build/review-synthesis-config.js +60 -0
- package/build/scripts/executor-protocol-contract-driver.js +311 -0
- package/build/setup-epic.js +592 -139
- package/build/sfcc/log-query.js +2 -1
- package/build/sfcc/reads-custom-object-def.js +10 -13
- package/build/sfcc/reads-site-preference.js +5 -5
- package/build/sfcc/reads-system-object.js +4 -4
- package/build/sfcc/writes-custom-object-def.js +7 -7
- package/build/sfcc/writes-site-preference.js +4 -3
- package/build/sfcc/writes-system-object.js +7 -6
- package/build/start-tickets-conductor.js +11 -2
- package/build/start-tickets.js +69 -2
- package/build/version.generated.js +3 -3
- package/build/worker-containment-diagnostic.js +97 -0
- package/build/worker-guard-hook-bin.js +6 -0
- package/docs/CONDUCTOR.md +27 -0
- package/docs/install/mcp-tool-integrations.md +3 -2
- package/package.json +5 -3
- package/pipelines/plan-epic.json +5 -0
|
@@ -277,6 +277,67 @@ export const SCOPE_LIFECYCLE_LABELS = Object.freeze({
|
|
|
277
277
|
ready: "Ready",
|
|
278
278
|
failed: "Failed",
|
|
279
279
|
});
|
|
280
|
+
/**
|
|
281
|
+
* The fixed lifecycle label a heartbeat uses when the status READ itself failed
|
|
282
|
+
* (BAPI-963).
|
|
283
|
+
*
|
|
284
|
+
* A read failure is not a lifecycle state, and the raw error is deliberately not
|
|
285
|
+
* interpolated into a heartbeat: a per-poll line repeated for twenty minutes is
|
|
286
|
+
* the worst possible place to smuggle unbounded server text.
|
|
287
|
+
*/
|
|
288
|
+
export const SCOPE_BOOTSTRAP_UNREADABLE_STATE = "unreadable";
|
|
289
|
+
/**
|
|
290
|
+
* Format one bootstrap heartbeat line (BAPI-963).
|
|
291
|
+
*
|
|
292
|
+
* Shared with `setup-epic` at this seam so both conductors compute progress the
|
|
293
|
+
* same way; RENDERING stays with each caller, because the pilot writes to its own
|
|
294
|
+
* stderr advisory channel and v2 reports progress server-side.
|
|
295
|
+
*
|
|
296
|
+
* The shape is fixed and grep-friendly — elapsed first, state second — because a
|
|
297
|
+
* ~30-minute seed that printed nothing was externally indistinguishable from a
|
|
298
|
+
* hang (sleeping process, 0% CPU, a frozen `updated_at`). Elapsed seconds are
|
|
299
|
+
* clamped at zero so a clock adjustment cannot render a negative age.
|
|
300
|
+
*/
|
|
301
|
+
export function formatScopeBootstrapHeartbeat(elapsedMs, state) {
|
|
302
|
+
const seconds = Math.max(0, Math.floor(elapsedMs / 1000));
|
|
303
|
+
// `lifecycle_state` is a required non-empty string on the wire, not a closed
|
|
304
|
+
// set. Bounding it to the known labels (plus the fixed `unreadable` and a
|
|
305
|
+
// catch-all) keeps an unvalidated server string out of a line that repeats
|
|
306
|
+
// every interval for up to twenty minutes.
|
|
307
|
+
return `Seeding scope: elapsed=${seconds}s state=${boundScopeLifecycleLabel(state)}`;
|
|
308
|
+
}
|
|
309
|
+
/** The label an unrecognized lifecycle state collapses to. Never `null`, never raw. */
|
|
310
|
+
export const SCOPE_BOOTSTRAP_UNKNOWN_STATE = "unknown";
|
|
311
|
+
/**
|
|
312
|
+
* Collapse any lifecycle state to a bounded, safe label (BAPI-1054).
|
|
313
|
+
*
|
|
314
|
+
* `lifecycle_state` is a required non-empty string on the wire, not a closed set,
|
|
315
|
+
* so an unrecognized value is possible and an error-bearing one
|
|
316
|
+
* (`unreadable (<server error>)`) is routine. Extracted from
|
|
317
|
+
* {@link formatScopeBootstrapHeartbeat}, which has always applied this rule, so
|
|
318
|
+
* every surface that renders a state applies the SAME one — a second spelling of
|
|
319
|
+
* "bounded" is how raw server text eventually reaches a terminal.
|
|
320
|
+
*/
|
|
321
|
+
export function boundScopeLifecycleLabel(state) {
|
|
322
|
+
return state === SCOPE_BOOTSTRAP_UNREADABLE_STATE || state in SCOPE_LIFECYCLE_LABELS
|
|
323
|
+
? state
|
|
324
|
+
: SCOPE_BOOTSTRAP_UNKNOWN_STATE;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Describe the nominal polling window up front (BAPI-963).
|
|
328
|
+
*
|
|
329
|
+
* Computed from the interval and cap rather than hard-coded, so a change to
|
|
330
|
+
* either constant cannot leave the operator-facing duration claim stale. The
|
|
331
|
+
* window is NOMINAL: the observed pilot seed outran even this bound, which is
|
|
332
|
+
* why the wording promises a poll cadence rather than a completion time.
|
|
333
|
+
*/
|
|
334
|
+
export function describeScopeBootstrapWindow(intervalMs = SCOPE_BOOTSTRAP_POLL_INTERVAL_MS, maxPolls = SCOPE_BOOTSTRAP_MAX_POLLS) {
|
|
335
|
+
const intervalSeconds = Math.max(1, Math.round(intervalMs / 1000));
|
|
336
|
+
const windowMinutes = Math.max(1, Math.round((intervalMs * maxPolls) / 60_000));
|
|
337
|
+
return (`Seeding the epic's index scope. This copies the repository's whole parse cache and ` +
|
|
338
|
+
`verifies it, and commonly takes many minutes. Progress is reported every ` +
|
|
339
|
+
`${intervalSeconds}s; the poll gives up after about ${windowMinutes} minutes.`);
|
|
340
|
+
}
|
|
280
341
|
/**
|
|
281
342
|
* Poll a scope's lifecycle until it is `ready`, `failed`, or the bounded wait
|
|
282
343
|
* elapses, reporting each NEWLY observed lifecycle transition exactly once, in
|
|
@@ -296,6 +357,8 @@ export async function pollIndexScopeLifecycle(deps, access, scopeId, options = {
|
|
|
296
357
|
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
297
358
|
const maxPolls = options.maxPolls ?? SCOPE_BOOTSTRAP_MAX_POLLS;
|
|
298
359
|
const intervalMs = options.intervalMs ?? SCOPE_BOOTSTRAP_POLL_INTERVAL_MS;
|
|
360
|
+
const now = options.now ?? (() => new Date());
|
|
361
|
+
const startedAtMs = now().getTime();
|
|
299
362
|
let lastState = "unknown";
|
|
300
363
|
let lastStatus = null;
|
|
301
364
|
let lastReportedState = null;
|
|
@@ -304,10 +367,12 @@ export async function pollIndexScopeLifecycle(deps, access, scopeId, options = {
|
|
|
304
367
|
const status = await getIndexScopeStatus(access, scopeId, deps.fetchImpl);
|
|
305
368
|
if (!status.ok) {
|
|
306
369
|
lastState = `unreadable (${status.error})`;
|
|
370
|
+
options.onPoll?.(now().getTime() - startedAtMs, SCOPE_BOOTSTRAP_UNREADABLE_STATE);
|
|
307
371
|
continue;
|
|
308
372
|
}
|
|
309
373
|
lastStatus = status.value;
|
|
310
374
|
lastState = status.value.lifecycle_state;
|
|
375
|
+
options.onPoll?.(now().getTime() - startedAtMs, lastState);
|
|
311
376
|
if (lastState !== lastReportedState) {
|
|
312
377
|
lastReportedState = lastState;
|
|
313
378
|
options.onTransition?.(lastState, status.value);
|
|
@@ -769,6 +769,79 @@ export async function fetchEpicRunState(access, epicKey, fetchImpl = globalThis.
|
|
|
769
769
|
const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
770
770
|
return parsed;
|
|
771
771
|
}
|
|
772
|
+
// ---------------------------------------------------------------------------
|
|
773
|
+
// Executor wind-down: read-only completion-state projection (BAPI-1010)
|
|
774
|
+
// ---------------------------------------------------------------------------
|
|
775
|
+
/** Closed runtime vocabulary mirroring the Python `EpicRunStatus` Literal exactly. */
|
|
776
|
+
export const EPIC_RUN_STATUS_VALUES = [
|
|
777
|
+
"planning",
|
|
778
|
+
"pending_approval",
|
|
779
|
+
"active",
|
|
780
|
+
"blocked",
|
|
781
|
+
"abandoned",
|
|
782
|
+
"done",
|
|
783
|
+
];
|
|
784
|
+
function isEpicRunStatus(value) {
|
|
785
|
+
return typeof value === "string" && EPIC_RUN_STATUS_VALUES.includes(value);
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* GET the epic run's completion state (`status` plus the derived feature
|
|
789
|
+
* branch) through the existing {@link fetchEpicRunState} transport, then
|
|
790
|
+
* validate the payload as untrusted boundary input. Never throws — every
|
|
791
|
+
* failure, transport or validation, resolves to the closed failure member of
|
|
792
|
+
* {@link EpicRunCompletionStateResult} rather than propagating a raw error,
|
|
793
|
+
* response body, header, or credential.
|
|
794
|
+
*
|
|
795
|
+
* `epicRunId` may be an epic run id or an epic key — the underlying endpoint
|
|
796
|
+
* accepts either, exactly like {@link fetchEpicRunState}'s existing callers.
|
|
797
|
+
*/
|
|
798
|
+
export async function readEpicRunCompletionState(access, epicRunId, fetchImpl = globalThis.fetch) {
|
|
799
|
+
if (typeof epicRunId !== "string" || epicRunId.trim().length === 0) {
|
|
800
|
+
return { ok: false, reason: "invalid-input" };
|
|
801
|
+
}
|
|
802
|
+
let parsed;
|
|
803
|
+
try {
|
|
804
|
+
parsed = await fetchEpicRunState(access, epicRunId, fetchImpl);
|
|
805
|
+
}
|
|
806
|
+
catch (err) {
|
|
807
|
+
if (err instanceof ConductorBridgeApiError) {
|
|
808
|
+
if (err.kind === "http" && err.status === 404)
|
|
809
|
+
return { ok: false, reason: "not-found" };
|
|
810
|
+
if (err.kind === "unauthorized")
|
|
811
|
+
return { ok: false, reason: "unauthorized" };
|
|
812
|
+
if (err.kind === "timeout")
|
|
813
|
+
return { ok: false, reason: "timeout" };
|
|
814
|
+
if (err.kind === "server")
|
|
815
|
+
return { ok: false, reason: "server" };
|
|
816
|
+
if (err.kind === "network")
|
|
817
|
+
return { ok: false, reason: "network" };
|
|
818
|
+
if (err.kind === "invalid-input")
|
|
819
|
+
return { ok: false, reason: "invalid-input" };
|
|
820
|
+
return { ok: false, reason: "malformed" };
|
|
821
|
+
}
|
|
822
|
+
return { ok: false, reason: "network" };
|
|
823
|
+
}
|
|
824
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
825
|
+
return { ok: false, reason: "malformed" };
|
|
826
|
+
}
|
|
827
|
+
const epicRun = parsed["epic_run"];
|
|
828
|
+
if (!epicRun || typeof epicRun !== "object" || Array.isArray(epicRun)) {
|
|
829
|
+
return { ok: false, reason: "malformed" };
|
|
830
|
+
}
|
|
831
|
+
const status = epicRun["status"];
|
|
832
|
+
if (!isEpicRunStatus(status)) {
|
|
833
|
+
return { ok: false, reason: "malformed" };
|
|
834
|
+
}
|
|
835
|
+
const policyJson = epicRun["policy_json"];
|
|
836
|
+
let featureBranch;
|
|
837
|
+
if (policyJson && typeof policyJson === "object" && !Array.isArray(policyJson)) {
|
|
838
|
+
const baseBranch = policyJson["base_branch"];
|
|
839
|
+
if (typeof baseBranch === "string" && baseBranch.trim().length > 0) {
|
|
840
|
+
featureBranch = baseBranch;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return { ok: true, state: featureBranch ? { status, featureBranch } : { status } };
|
|
844
|
+
}
|
|
772
845
|
/**
|
|
773
846
|
* GET `/jira/epic-runs/runs?repo_name=<repo>&status=active` and return the
|
|
774
847
|
* list of active epic runs. Caps at 20 results — enough for typical deployments
|
|
@@ -957,8 +1030,9 @@ function parseAdvanceEpicTicketStatusResult(parsed) {
|
|
|
957
1030
|
* PATCH the per-ticket CAS status endpoint
|
|
958
1031
|
* (`PATCH /runs/{epic_run_id}/tickets/{ticket_key}`). Surfaces structured CAS
|
|
959
1032
|
* conflicts as a distinct non-throwing outcome where the backend returns one;
|
|
960
|
-
* the current backend instead raises a 400
|
|
961
|
-
*
|
|
1033
|
+
* the current backend instead raises a 400 CONFLICT on a stale row_version
|
|
1034
|
+
* (BAPI-1016 — the status stays 400; only the body error_code changed from the
|
|
1035
|
+
* former VALIDATION), which surfaces as a thrown `ConductorBridgeApiError("http", 400)`.
|
|
962
1036
|
* Transport/auth/server failures still throw a sanitized {@link ConductorBridgeApiError}.
|
|
963
1037
|
*/
|
|
964
1038
|
export async function advanceEpicTicketStatus(access, request, fetchImpl = globalThis.fetch) {
|
|
@@ -1283,6 +1357,8 @@ export async function validateEpicPlan(access, request, fetchImpl = globalThis.f
|
|
|
1283
1357
|
};
|
|
1284
1358
|
if (request.epicKey !== undefined)
|
|
1285
1359
|
body.epic_key = request.epicKey;
|
|
1360
|
+
if (request.trackedPaths !== undefined)
|
|
1361
|
+
body.tracked_paths = request.trackedPaths;
|
|
1286
1362
|
const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), JSON.stringify(body), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
1287
1363
|
return parseValidateEpicPlanResult(parsed);
|
|
1288
1364
|
}
|
|
@@ -1295,7 +1371,8 @@ function parseValidateEpicPlanResult(parsed) {
|
|
|
1295
1371
|
const planHash = p["plan_hash"];
|
|
1296
1372
|
const serializationEnabled = p["serialization_enabled"];
|
|
1297
1373
|
const insertedEdges = p["inserted_edges"];
|
|
1298
|
-
|
|
1374
|
+
const valid = p["valid"];
|
|
1375
|
+
if (typeof valid !== "boolean" ||
|
|
1299
1376
|
typeof planHash !== "string" ||
|
|
1300
1377
|
planHash.trim() === "" ||
|
|
1301
1378
|
typeof serializationEnabled !== "boolean" ||
|
|
@@ -1304,12 +1381,35 @@ function parseValidateEpicPlanResult(parsed) {
|
|
|
1304
1381
|
insertedEdges < 0) {
|
|
1305
1382
|
throw new ConductorBridgeApiError("server");
|
|
1306
1383
|
}
|
|
1384
|
+
// BAPI-1027: `valid: false` is now a FIRST-CLASS, fully-verified answer rather
|
|
1385
|
+
// than a protocol error. The historical strictness (throw on anything but
|
|
1386
|
+
// `valid === true`) existed because a body claiming a verdict without carrying
|
|
1387
|
+
// one is false assurance — but a rejection body carries its reasons, so the
|
|
1388
|
+
// caller can verify exactly what it is being told. That guard is preserved
|
|
1389
|
+
// where it still applies: a `valid: false` with no parseable rejection asserts
|
|
1390
|
+
// a failure it does not substantiate, and is still a protocol error.
|
|
1391
|
+
const rejections = parsePlanLintRejections(p["rejections"]);
|
|
1392
|
+
const notices = parsePlanLintNotices(p["notices"]);
|
|
1393
|
+
if (!valid && rejections.length === 0) {
|
|
1394
|
+
throw new ConductorBridgeApiError("server");
|
|
1395
|
+
}
|
|
1307
1396
|
// BAPI-848 — the coverage diagnostics are read TOLERANTLY, unlike the fields
|
|
1308
1397
|
// above. A server that predates them is not a protocol error, and the CLI must
|
|
1309
1398
|
// keep working against one; the defaults below are the honest reading of an
|
|
1310
1399
|
// absent field (nothing reported), and the renderer states the scope it was
|
|
1311
1400
|
// actually given rather than inventing coverage it cannot see.
|
|
1312
1401
|
return {
|
|
1402
|
+
valid,
|
|
1403
|
+
rejections,
|
|
1404
|
+
notices,
|
|
1405
|
+
// BAPI-1027: read TOLERANTLY like the coverage fields, but defaulted to the
|
|
1406
|
+
// SAFE reading. A server that predates this field reports nothing about
|
|
1407
|
+
// completeness, and "we cannot tell whether every rule ran" must not render
|
|
1408
|
+
// as a warning on every single validate against an older deployment — so an
|
|
1409
|
+
// absent field means complete, while a present `false` is honored exactly.
|
|
1410
|
+
evaluationComplete: typeof p["evaluation_complete"] === "boolean"
|
|
1411
|
+
? p["evaluation_complete"]
|
|
1412
|
+
: true,
|
|
1313
1413
|
planHash,
|
|
1314
1414
|
serializationEnabled,
|
|
1315
1415
|
insertedEdges,
|
|
@@ -1321,6 +1421,69 @@ function parseValidateEpicPlanResult(parsed) {
|
|
|
1321
1421
|
: "unreported",
|
|
1322
1422
|
};
|
|
1323
1423
|
}
|
|
1424
|
+
/** Strict-but-tolerant parse of the `rejections` array; malformed entries are dropped. */
|
|
1425
|
+
function parsePlanLintRejections(value) {
|
|
1426
|
+
if (!Array.isArray(value))
|
|
1427
|
+
return [];
|
|
1428
|
+
const out = [];
|
|
1429
|
+
for (const raw of value) {
|
|
1430
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
1431
|
+
continue;
|
|
1432
|
+
const entry = raw;
|
|
1433
|
+
const reasonCode = entry["reason_code"];
|
|
1434
|
+
const explanation = entry["explanation"];
|
|
1435
|
+
// A finding without a code or an explanation cannot be rendered usefully and
|
|
1436
|
+
// must not be counted as one: a phantom rejection would block a plan while
|
|
1437
|
+
// telling the operator nothing about why.
|
|
1438
|
+
if (typeof reasonCode !== "string" || reasonCode.trim() === "")
|
|
1439
|
+
continue;
|
|
1440
|
+
if (typeof explanation !== "string" || explanation.trim() === "")
|
|
1441
|
+
continue;
|
|
1442
|
+
const rejection = {
|
|
1443
|
+
reasonCode,
|
|
1444
|
+
explanation,
|
|
1445
|
+
ticketKeys: safeStringArray(entry["ticket_keys"]),
|
|
1446
|
+
};
|
|
1447
|
+
if (typeof entry["path"] === "string" && entry["path"] !== "") {
|
|
1448
|
+
rejection.path = entry["path"];
|
|
1449
|
+
}
|
|
1450
|
+
if (typeof entry["migration_id"] === "string" && entry["migration_id"] !== "") {
|
|
1451
|
+
rejection.migrationId = entry["migration_id"];
|
|
1452
|
+
}
|
|
1453
|
+
out.push(rejection);
|
|
1454
|
+
}
|
|
1455
|
+
return out;
|
|
1456
|
+
}
|
|
1457
|
+
/** Strict-but-tolerant parse of the `notices` array; malformed entries are dropped. */
|
|
1458
|
+
function parsePlanLintNotices(value) {
|
|
1459
|
+
if (!Array.isArray(value))
|
|
1460
|
+
return [];
|
|
1461
|
+
const out = [];
|
|
1462
|
+
for (const raw of value) {
|
|
1463
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
1464
|
+
continue;
|
|
1465
|
+
const entry = raw;
|
|
1466
|
+
const noticeCode = entry["notice_code"];
|
|
1467
|
+
const explanation = entry["explanation"];
|
|
1468
|
+
if (typeof noticeCode !== "string" || noticeCode.trim() === "")
|
|
1469
|
+
continue;
|
|
1470
|
+
if (typeof explanation !== "string" || explanation.trim() === "")
|
|
1471
|
+
continue;
|
|
1472
|
+
out.push({
|
|
1473
|
+
noticeCode,
|
|
1474
|
+
explanation,
|
|
1475
|
+
ticketKeys: safeStringArray(entry["ticket_keys"]),
|
|
1476
|
+
paths: safeStringArray(entry["paths"]),
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
return out;
|
|
1480
|
+
}
|
|
1481
|
+
/** Non-empty strings from an array, or `[]` for anything else. */
|
|
1482
|
+
function safeStringArray(value) {
|
|
1483
|
+
if (!Array.isArray(value))
|
|
1484
|
+
return [];
|
|
1485
|
+
return value.filter((v) => typeof v === "string" && v !== "");
|
|
1486
|
+
}
|
|
1324
1487
|
/** Non-negative safe integer, or 0 for anything else (absent field included). */
|
|
1325
1488
|
function safeCount(value) {
|
|
1326
1489
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
|
@@ -1341,12 +1504,15 @@ export async function storeEpicPlan(access, request, fetchImpl = globalThis.fetc
|
|
|
1341
1504
|
throw new ConductorValidationError(`planVersion mismatch: request.planVersion=${request.planVersion} but planBlob.plan_version=${blobVersion}`);
|
|
1342
1505
|
}
|
|
1343
1506
|
const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(request.epicKey)}/plan`);
|
|
1344
|
-
const
|
|
1507
|
+
const storeBody = {
|
|
1345
1508
|
repo_name: access.repoName,
|
|
1346
1509
|
plan_version: request.planVersion,
|
|
1347
1510
|
plan_blob: request.planBlob,
|
|
1348
1511
|
plan_hash: request.planHash,
|
|
1349
|
-
}
|
|
1512
|
+
};
|
|
1513
|
+
if (request.trackedPaths !== undefined)
|
|
1514
|
+
storeBody.tracked_paths = request.trackedPaths;
|
|
1515
|
+
const body = JSON.stringify(storeBody);
|
|
1350
1516
|
return fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
1351
1517
|
}
|
|
1352
1518
|
/**
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Executor deny-enforcement preflight (TDD §7 / §11, R8).
|
|
3
3
|
*
|
|
4
|
-
* v2's permission model is "
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* v2's permission model since BAPI-1020 is "the `auto` posture + a deterministic
|
|
5
|
+
* deny layer + an always-on argument guard": workers run
|
|
6
|
+
* `claude -p … --permission-mode auto` with a small stable deny set, and
|
|
7
|
+
* `--dangerously-skip-permissions` is the explicit REVERT value rather than the
|
|
8
|
+
* default. Whether `permissions.deny` is actually enforced under a given posture is
|
|
9
|
+
* version-specific and must be PROBED, never assumed — and BOTH postures are probed,
|
|
10
|
+
* for different reasons: `auto` because it is what every worker runs under, and
|
|
11
|
+
* bypass because it stays selectable and must stay guarded. A posture that is not
|
|
12
|
+
* enforced is fatal regardless of which one it is. This module exports the single
|
|
8
13
|
* reusable predicate the T3a executor's claim loop calls at startup/preflight before
|
|
9
14
|
* claiming any job: on a failed deny probe with no working fallback it returns
|
|
10
15
|
* `enforced: false`, and the executor must refuse to claim jobs (a fatal finding).
|
|
@@ -19,15 +24,28 @@ import { resolveAgentSpec } from "../agent-registry.js";
|
|
|
19
24
|
import { createProbeContext } from "../agent-capabilities/probe-context.js";
|
|
20
25
|
import { createDefaultAgentCapabilitiesDeps } from "../agent-capabilities/default-deps.js";
|
|
21
26
|
import { runDenyEnforcementCheck } from "../agent-capabilities/probes.js";
|
|
27
|
+
/**
|
|
28
|
+
* The postures probed when a caller names none (BAPI-1020).
|
|
29
|
+
*
|
|
30
|
+
* `auto` FIRST, deliberately: it is the posture every worker runs under, so when
|
|
31
|
+
* enforcement is broken the run that proves it is the one that matters, and the
|
|
32
|
+
* short-circuit below then skips the second probe entirely rather than spending
|
|
33
|
+
* minutes of real headless Claude runs re-confirming a fatal result.
|
|
34
|
+
*/
|
|
35
|
+
export const REQUIRED_DENY_ENFORCEMENT_POSTURES = [
|
|
36
|
+
"auto",
|
|
37
|
+
"skip_permissions",
|
|
38
|
+
];
|
|
22
39
|
/** Canonical refuse-to-claim directive included in every fatal/degraded-fatal result. */
|
|
23
40
|
const REFUSE_TO_CLAIM_WARNING = "The executor claim loop MUST refuse to claim jobs until settings permissions.deny " +
|
|
24
41
|
"or the PreToolUse fallback enforces the deny layer.";
|
|
25
42
|
/**
|
|
26
|
-
* Run the deny-enforcement preflight and map the shared
|
|
27
|
-
* standard inspection shape. Never throws —
|
|
28
|
-
* `enforced: false` result. Always cleans up
|
|
43
|
+
* Run the deny-enforcement preflight for ONE posture and map the shared
|
|
44
|
+
* deny-check outcome onto the standard inspection shape. Never throws —
|
|
45
|
+
* unexpected exceptions become a fatal `enforced: false` result. Always cleans up
|
|
46
|
+
* the probe context's temp dirs.
|
|
29
47
|
*/
|
|
30
|
-
|
|
48
|
+
async function runDenyEnforcementPreflightForPosture(posture, opts) {
|
|
31
49
|
try {
|
|
32
50
|
const agent = resolveAgentSpec("claude");
|
|
33
51
|
if (!agent) {
|
|
@@ -47,7 +65,7 @@ export async function runDenyEnforcementPreflight(opts = {}) {
|
|
|
47
65
|
const { result, layer } = await runDenyEnforcementCheck(ctx, {
|
|
48
66
|
model: opts.model,
|
|
49
67
|
timeoutMs: opts.timeoutMs,
|
|
50
|
-
permissionPosture:
|
|
68
|
+
permissionPosture: posture,
|
|
51
69
|
});
|
|
52
70
|
if (result.status === "pass" && layer === "settings-deny") {
|
|
53
71
|
return {
|
|
@@ -65,7 +83,8 @@ export async function runDenyEnforcementPreflight(opts = {}) {
|
|
|
65
83
|
layer: "pretooluse-hook",
|
|
66
84
|
degraded: true,
|
|
67
85
|
warnings: [
|
|
68
|
-
|
|
86
|
+
`settings permissions.deny was not enforced under the ${posture ?? "skip_permissions"} ` +
|
|
87
|
+
"posture; relying on PreToolUse fallback.",
|
|
69
88
|
],
|
|
70
89
|
status: result.status,
|
|
71
90
|
detail: result.detail,
|
|
@@ -95,3 +114,81 @@ export async function runDenyEnforcementPreflight(opts = {}) {
|
|
|
95
114
|
};
|
|
96
115
|
}
|
|
97
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Run the deny-enforcement preflight and return the executor's claim gate.
|
|
119
|
+
*
|
|
120
|
+
* With an explicit `permissionPosture`, probes exactly that posture and returns
|
|
121
|
+
* its result unchanged — the single-posture behavior every existing caller and
|
|
122
|
+
* test seam relies on.
|
|
123
|
+
*
|
|
124
|
+
* With no posture named, probes {@link REQUIRED_DENY_ENFORCEMENT_POSTURES} in
|
|
125
|
+
* order and combines (BAPI-1020). Three properties of that combination matter:
|
|
126
|
+
*
|
|
127
|
+
* - FATAL IF EITHER FAILS. `enforced` is the conjunction, so a posture that is
|
|
128
|
+
* not enforced blocks claiming even when the other one is. There is no
|
|
129
|
+
* "mostly enforced".
|
|
130
|
+
* - SHORT-CIRCUITS ON THE FIRST FAILURE. Each posture costs several real
|
|
131
|
+
* headless Claude runs and takes minutes; once the answer is fatal, the
|
|
132
|
+
* remaining postures cannot change it, and burning the time to re-confirm it
|
|
133
|
+
* would delay every claim on a machine that is already refusing to claim.
|
|
134
|
+
* - REPORTS EACH POSTURE SEPARATELY. `postures` carries every probed posture's
|
|
135
|
+
* own layer and detail. The top-level `layer` is the FIRST required posture's
|
|
136
|
+
* (`auto`, the one workers run under) rather than a merged string, so the
|
|
137
|
+
* existing consumers — which read `layer` as "what enforced for a worker" —
|
|
138
|
+
* keep reading a true answer, and the per-posture facts stay available beside
|
|
139
|
+
* it instead of being collapsed into it.
|
|
140
|
+
*/
|
|
141
|
+
export async function runDenyEnforcementPreflight(opts = {}) {
|
|
142
|
+
if (opts.permissionPosture !== undefined) {
|
|
143
|
+
return runDenyEnforcementPreflightForPosture(opts.permissionPosture, opts);
|
|
144
|
+
}
|
|
145
|
+
const postures = [];
|
|
146
|
+
const warnings = [];
|
|
147
|
+
let combined;
|
|
148
|
+
let degraded = false;
|
|
149
|
+
for (const posture of REQUIRED_DENY_ENFORCEMENT_POSTURES) {
|
|
150
|
+
const result = await runDenyEnforcementPreflightForPosture(posture, opts);
|
|
151
|
+
postures.push({
|
|
152
|
+
posture,
|
|
153
|
+
enforced: result.enforced,
|
|
154
|
+
layer: result.layer,
|
|
155
|
+
...(result.status === undefined ? {} : { status: result.status }),
|
|
156
|
+
...(result.detail === undefined ? {} : { detail: result.detail }),
|
|
157
|
+
});
|
|
158
|
+
for (const warning of result.warnings) {
|
|
159
|
+
if (!warnings.includes(warning))
|
|
160
|
+
warnings.push(warning);
|
|
161
|
+
}
|
|
162
|
+
if (result.degraded)
|
|
163
|
+
degraded = true;
|
|
164
|
+
if (combined === undefined)
|
|
165
|
+
combined = result;
|
|
166
|
+
if (!result.enforced) {
|
|
167
|
+
// Fatal already. Naming the posture is the diagnostic that matters: "deny is
|
|
168
|
+
// unenforced" is not actionable until an operator knows WHICH posture, since
|
|
169
|
+
// the fix for the worker default and the fix for the revert value differ.
|
|
170
|
+
return {
|
|
171
|
+
enforced: false,
|
|
172
|
+
layer: result.layer,
|
|
173
|
+
degraded: true,
|
|
174
|
+
warnings: [
|
|
175
|
+
`Deny-layer enforcement is not verified for the '${posture}' permission posture.`,
|
|
176
|
+
...warnings,
|
|
177
|
+
],
|
|
178
|
+
...(result.status === undefined ? {} : { status: result.status }),
|
|
179
|
+
...(result.detail === undefined ? {} : { detail: result.detail }),
|
|
180
|
+
postures,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const primary = combined;
|
|
185
|
+
return {
|
|
186
|
+
enforced: true,
|
|
187
|
+
layer: primary.layer,
|
|
188
|
+
degraded,
|
|
189
|
+
warnings,
|
|
190
|
+
...(primary.status === undefined ? {} : { status: primary.status }),
|
|
191
|
+
...(primary.detail === undefined ? {} : { detail: primary.detail }),
|
|
192
|
+
postures,
|
|
193
|
+
};
|
|
194
|
+
}
|