@bridge_gpt/mcp-server 0.2.34 → 0.2.36
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 +456 -370
- 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/claude-review-workflow.js +264 -0
- package/build/cli-release.js +53 -0
- package/build/commands.generated.js +4 -4
- package/build/conductor/bridge-api-client.js +215 -0
- 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 +30 -9
- package/build/doctor.js +234 -1
- package/build/executor/cli.js +32 -5
- package/build/executor/credentials.js +45 -11
- package/build/executor/deps.js +14 -0
- package/build/executor/env.js +23 -6
- package/build/executor/index.js +4 -0
- package/build/executor/job-runner.js +119 -9
- package/build/executor/permissions.js +12 -2
- package/build/executor/preflight.js +95 -8
- package/build/executor/prompt-spec.js +51 -0
- package/build/executor/runner.js +15 -2
- package/build/executor/service-unit.js +876 -0
- package/build/executor/test-clock.js +8 -0
- package/build/executor/types.js +0 -17
- package/build/executor/worker-command.js +62 -9
- package/build/index.js +575 -143
- package/build/init.js +153 -51
- package/build/install-bridge-conductor.js +491 -0
- package/build/install-bridge.js +628 -175
- package/build/install-reexec.js +233 -0
- package/build/mcp-host-config.js +11 -1
- package/build/mcp-install-state.js +32 -0
- package/build/mcp-provisioning.js +22 -6
- package/build/pipelines.generated.js +14 -8
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +257 -0
- package/build/setup-epic.js +117 -8
- package/build/upgrade-cli.js +1 -15
- package/build/version.generated.js +1 -1
- package/docs/CONDUCTOR.md +115 -4
- package/docs/install/mcp-tool-integrations.md +29 -21
- package/package.json +8 -5
- package/pipelines/implement-ticket.json +6 -1
- package/build/conductor/supervisor-judgment-python.js +0 -141
- package/build/conductor/supervisor-judgment.js +0 -215
|
@@ -20,6 +20,7 @@ import { runHeartbeatLoop } from "./heartbeat.js";
|
|
|
20
20
|
import { isExecutorNamedError, toExecutorFailure, secretFreeErrorMessage, MissingVerdictArtifact, } 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
24
|
import { provisionExecutorDenyLayer } from "./permissions.js";
|
|
24
25
|
import { runProcessWithTimeout } from "./process.js";
|
|
25
26
|
import { prepareResumeSpawn } from "./resume-pre-spawn.js";
|
|
@@ -35,7 +36,7 @@ import { resolveExecutorJobBaseBranch } from "./base-branch.js";
|
|
|
35
36
|
import { validateWorkerFinalization } from "./worker-finalization.js";
|
|
36
37
|
import { ensureExecutorWorktree } from "./worktree.js";
|
|
37
38
|
import { buildPrBaseContractLaunchInstruction } from "../pr-base-contract.js";
|
|
38
|
-
import { buildClaudeExecutorArgv, CLAUDE_EXECUTABLE, resolveExecutorModelAlias, resolveExecutorPrompt, } from "./worker-command.js";
|
|
39
|
+
import { buildClaudeExecutorArgv, CLAUDE_EXECUTABLE, resolveExecutorModelAlias, resolveExecutorPrompt, resolveWorkerPermissionPosture, } from "./worker-command.js";
|
|
39
40
|
import { collectGitTelemetry } from "./observation.js";
|
|
40
41
|
/** Default runtime for the no-op smoke process (ms). */
|
|
41
42
|
const DEFAULT_SMOKE_DURATION_MS = 100;
|
|
@@ -91,6 +92,57 @@ function buildJobLogRegistryDeps(deps) {
|
|
|
91
92
|
platform: deps.platform,
|
|
92
93
|
};
|
|
93
94
|
}
|
|
95
|
+
/** Map executor deps into the shared MCP-provisioning filesystem boundary. */
|
|
96
|
+
function buildExecutorMcpProvisioningDeps(deps) {
|
|
97
|
+
return {
|
|
98
|
+
readFile: deps.readFile,
|
|
99
|
+
writeFile: deps.writeFile,
|
|
100
|
+
mkdir: deps.mkdir,
|
|
101
|
+
platform: deps.platform,
|
|
102
|
+
cwd: deps.cwd,
|
|
103
|
+
mcpServerInvocation: deps.mcpServerInvocation,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Provision secret-free Bridge MCP registration + Claude trust pre-approval
|
|
108
|
+
* (BAPI-724) into a prepared spawn worktree, for every job type that reaches
|
|
109
|
+
* the shared spawn path. Reuses the exact `start-tickets` provisioning
|
|
110
|
+
* machinery by constructing a synthetic `created` {@link SummaryRow} for this
|
|
111
|
+
* job/worktree — no registration serialization or second trust write happens
|
|
112
|
+
* here.
|
|
113
|
+
*
|
|
114
|
+
* Fail-open by construction: a provisioner warning or a `spawn-failed` result
|
|
115
|
+
* is logged via `deps.errorLog` and this function still returns normally, and
|
|
116
|
+
* an unexpected thrown exception is caught and reduced to a single fixed,
|
|
117
|
+
* secret-free diagnostic. Either way `runSpawnJob` proceeds to spawn the
|
|
118
|
+
* worker — a job that later needs an MCP tool it cannot reach fails loudly
|
|
119
|
+
* through its own process/gate/timeout/budget path, never here.
|
|
120
|
+
*/
|
|
121
|
+
async function provisionMcpForPreparedSpawn(job, branch, worktreePath, deps, seams) {
|
|
122
|
+
const provision = seams.provisionMcp ?? provisionMcpRegistrationForWorktree;
|
|
123
|
+
const row = {
|
|
124
|
+
key: job.ticket_key ?? String(job.id),
|
|
125
|
+
branch,
|
|
126
|
+
status: "created",
|
|
127
|
+
path: worktreePath,
|
|
128
|
+
};
|
|
129
|
+
try {
|
|
130
|
+
const result = await provision(row, buildExecutorMcpProvisioningDeps(deps));
|
|
131
|
+
for (const warning of result.warnings ?? []) {
|
|
132
|
+
deps.errorLog(`MCP provisioning warning: ${warning}`);
|
|
133
|
+
}
|
|
134
|
+
if (result.status === "spawn-failed") {
|
|
135
|
+
deps.errorLog(`MCP provisioning did not complete (${result.error ?? "unknown reason"}); continuing fail-open`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// Fail-open, secret-free: a fixed diagnostic only. Never interpolate the
|
|
140
|
+
// thrown exception, the worktree path, a command/argument, or credential
|
|
141
|
+
// material — this branch exists precisely so an unexpected provisioning
|
|
142
|
+
// failure can never leak anything into the executor's own logs.
|
|
143
|
+
deps.errorLog("MCP provisioning did not complete; continuing fail-open");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
94
146
|
/**
|
|
95
147
|
* Default merge dispatch: resolve Bridge API access, then run the deterministic
|
|
96
148
|
* merge. Exported for unit tests (access-resolution failure → retryable). The
|
|
@@ -401,6 +453,19 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
|
|
|
401
453
|
});
|
|
402
454
|
return terminalToRunResult(terminal, "failed");
|
|
403
455
|
}
|
|
456
|
+
/**
|
|
457
|
+
* BAPI-724 design note (deliberate, not an accidental AC-5 gap): `smoke` stays
|
|
458
|
+
* on this no-worktree, no-real-worker-LLM `createNoopProcess` path and does
|
|
459
|
+
* NOT receive MCP registration. The ratified requirement anchors MCP
|
|
460
|
+
* provisioning to "the executor's worktree-preparation path
|
|
461
|
+
* (`job-runner.ts`), alongside the existing `provisionExecutorDenyLayer` call"
|
|
462
|
+
* — that call, like the new MCP provisioning call, exists ONLY inside
|
|
463
|
+
* `runSpawnJob`, which `smoke` never reaches (see `isSpawnJobType` /
|
|
464
|
+
* `job-types.ts`, and this module's own "no real worker LLM" framing of the
|
|
465
|
+
* acceptance spine). Converting smoke into a worktree-having, worker-spawning
|
|
466
|
+
* job to give it something to register MCP into would be a materially larger,
|
|
467
|
+
* unratified change to that architectural boundary, not a wiring task.
|
|
468
|
+
*/
|
|
404
469
|
async function runSmokeJob(job, httpClient, options, deps, ownership, observation) {
|
|
405
470
|
const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
|
|
406
471
|
if (!timeout.ok) {
|
|
@@ -488,10 +553,18 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
488
553
|
}
|
|
489
554
|
}
|
|
490
555
|
// Resolve an explicit `payload.prompt` or the `/implement-ticket <KEY> --auto`
|
|
491
|
-
// synthesis (implement + recovery).
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
556
|
+
// synthesis (implement + recovery).
|
|
557
|
+
//
|
|
558
|
+
// BAPI-725: `spec_review` now takes the explicit-prompt path like any other job
|
|
559
|
+
// — the reconciler builds its prompt server-side and ships it in
|
|
560
|
+
// `payload.prompt`, so it resolves here and returns below without ever reaching
|
|
561
|
+
// the prompt-spec renderer. A stale `payload.prompt_spec` on an in-flight job
|
|
562
|
+
// enqueued before this change cannot override that: the explicit prompt wins
|
|
563
|
+
// unconditionally at the `explicit.ok` return.
|
|
564
|
+
//
|
|
565
|
+
// The renderer below is NOT dead. `smoke` and any future structured-prompt job
|
|
566
|
+
// still reach it, and rendering `{{GIT_LOG}}` is why it must run after worktree
|
|
567
|
+
// preparation rather than in `resolveExecutorPrompt`.
|
|
495
568
|
const explicit = resolveExecutorPrompt(job);
|
|
496
569
|
const payload = job.payload && typeof job.payload === "object"
|
|
497
570
|
? job.payload
|
|
@@ -529,9 +602,10 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
529
602
|
return { ok: true, worktreePath: wt.worktreePath, branch: wt.branch, prompt: explicit.prompt };
|
|
530
603
|
}
|
|
531
604
|
// No explicit/synthesized prompt: render the structured `payload.prompt_spec`
|
|
532
|
-
// against the prepared worktree (
|
|
533
|
-
//
|
|
534
|
-
//
|
|
605
|
+
// against the prepared worktree (`smoke`, and any future structured-prompt job;
|
|
606
|
+
// `spec_review` left this path in BAPI-725). A malformed spec fails loud with
|
|
607
|
+
// `ContractError.Prompt` so we `/fail` rather than spawn a worker with an empty
|
|
608
|
+
// prompt. Error messages are secret-free by construction.
|
|
535
609
|
const renderPrompt = seams.renderPromptSpecPrompt ?? renderPromptSpecPrompt;
|
|
536
610
|
try {
|
|
537
611
|
const rendered = await renderPrompt(job, {
|
|
@@ -554,6 +628,14 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
554
628
|
return { ok: false, result: { status: "failed", reason: "prompt_contract" } };
|
|
555
629
|
}
|
|
556
630
|
}
|
|
631
|
+
/**
|
|
632
|
+
* Final pre-spawn preparation order (BAPI-724): worktree + prompt preparation,
|
|
633
|
+
* timeout-contract validation, the deny-layer attempt, the MCP registration +
|
|
634
|
+
* trust-approval attempt, worker-log/viewer setup, then `deps.spawnProcess`.
|
|
635
|
+
* Failed preparation or an invalid timeout returns BEFORE either the deny
|
|
636
|
+
* layer or MCP provisioning runs — neither ever touches a worktree that will
|
|
637
|
+
* not become a runnable worker.
|
|
638
|
+
*/
|
|
557
639
|
async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams) {
|
|
558
640
|
// --- Per-job base branch (BAPI-586) ----------------------------------
|
|
559
641
|
// Resolve the effective logical base BEFORE any side effect: the persisted
|
|
@@ -574,6 +656,26 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
574
656
|
}
|
|
575
657
|
const effectiveBaseBranch = baseResolution.baseBranch;
|
|
576
658
|
const jobOptions = { ...options, baseBranch: effectiveBaseBranch };
|
|
659
|
+
// --- Permission posture (BAPI-725) -----------------------------------
|
|
660
|
+
// Resolved alongside the base branch, BEFORE any side effect, for the same
|
|
661
|
+
// reason: an unsupported posture is a contract failure, and failing it here
|
|
662
|
+
// means no worktree is cut, no deny layer is written, and no process is
|
|
663
|
+
// spawned for a job that can never run correctly. The error text names only
|
|
664
|
+
// the field — the supplied value never reaches `/fail`.
|
|
665
|
+
let permissionPosture;
|
|
666
|
+
try {
|
|
667
|
+
permissionPosture = resolveWorkerPermissionPosture(job.payload);
|
|
668
|
+
}
|
|
669
|
+
catch (err) {
|
|
670
|
+
await httpClient.fail(job, isExecutorNamedError(err)
|
|
671
|
+
? toExecutorFailure(err)
|
|
672
|
+
: {
|
|
673
|
+
error_kind: "ContractError.PermissionPosture",
|
|
674
|
+
error_message: secretFreeErrorMessage(err),
|
|
675
|
+
classification: "crashed",
|
|
676
|
+
});
|
|
677
|
+
return { status: "failed", reason: "permission_posture_contract" };
|
|
678
|
+
}
|
|
577
679
|
// --- Worktree + prompt (resume pre-spawn protocol or standard) -------
|
|
578
680
|
const prep = await prepareSpawn(job, httpClient, jobOptions, deps, seams);
|
|
579
681
|
if (!prep.ok)
|
|
@@ -605,6 +707,11 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
605
707
|
});
|
|
606
708
|
if (!deny.ok)
|
|
607
709
|
deps.errorLog(deny.warning);
|
|
710
|
+
// --- MCP registration + trust pre-approval (fail-open, BAPI-724) -----
|
|
711
|
+
// Unconditional for every job type that reaches this shared spawn path
|
|
712
|
+
// (implement/resume/spec_review/remediate/ci_fix/rebase) — no job-type
|
|
713
|
+
// branching, so a future spawn job type inherits the same default access.
|
|
714
|
+
await provisionMcpForPreparedSpawn(job, branch, worktreePath, deps, seams);
|
|
608
715
|
// --- Worker log tee + watch registry + viewer tab (BAPI-535 §8) ------
|
|
609
716
|
const registryDeps = buildJobLogRegistryDeps(deps);
|
|
610
717
|
const markFinished = seams.markExecutorJobLogFinished ?? markExecutorJobLogFinished;
|
|
@@ -670,7 +777,10 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
670
777
|
};
|
|
671
778
|
// --- Spawn -----------------------------------------------------------
|
|
672
779
|
const alias = resolveExecutorModelAlias(job.payload);
|
|
673
|
-
|
|
780
|
+
// BAPI-725: the posture resolved at the top of this function — after the deny
|
|
781
|
+
// layer has already been provisioned above, which is what keeps `acceptEdits`
|
|
782
|
+
// from being a relaxation of what the worker may do.
|
|
783
|
+
const argv = buildClaudeExecutorArgv(prompt, alias, permissionPosture);
|
|
674
784
|
// BAPI-586: inject the validated run base as BAPI_BASE_BRANCH so the worker
|
|
675
785
|
// can target it via `gh pr create --base "$BAPI_BASE_BRANCH"`.
|
|
676
786
|
const env = buildExecutorWorkerEnv(deps.env, effectiveBaseBranch);
|
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
* liveness never does. (Refusing to claim on `enforced:false` is a SEPARATE,
|
|
9
9
|
* fatal preflight concern — see `preflight.ts`.)
|
|
10
10
|
*
|
|
11
|
+
* BAPI-724: the worker model is now provisioned with default Bridge MCP access
|
|
12
|
+
* (see `../mcp-provisioning.js` wiring in `job-runner.ts`), so this module also
|
|
13
|
+
* denies direct reads of the user-scoped Bridge credential store
|
|
14
|
+
* (`~/.config/bridge/**`). MCP authentication stays available to the worker
|
|
15
|
+
* through the separately launched `mcp-invoke` shim process, which resolves the
|
|
16
|
+
* credential itself — the model process never reads it directly.
|
|
17
|
+
*
|
|
11
18
|
* The Wave-1 probe on the target machine reports enforcement layer `settings-deny`
|
|
12
19
|
* (claude 2.1.201), so plain `permissions.deny` is provisioned; the PreToolUse
|
|
13
20
|
* fallback is available for CLIs where settings-deny is not enforced.
|
|
@@ -16,8 +23,10 @@ import { mergeClaudeSettingsWithCommandHook, provisionClaudeSettingsForWorktree,
|
|
|
16
23
|
/**
|
|
17
24
|
* The stable executor deny rules. Grammar mirrors the deny-enforcement probe's
|
|
18
25
|
* `Bash(<cmd>:<args-glob>)` / `Read(<path-glob>)` form. Covers: reads under
|
|
19
|
-
* ~/.ssh, ~/.aws, and keychain paths;
|
|
20
|
-
*
|
|
26
|
+
* ~/.ssh, ~/.aws, and keychain paths; the user-scoped Bridge credential store
|
|
27
|
+
* (BAPI-724 — the model is denied direct reads, while the separately launched
|
|
28
|
+
* `mcp-invoke` shim process still resolves it); force-push to the base branch;
|
|
29
|
+
* and destructive `rm -rf` outside the worktree.
|
|
21
30
|
*/
|
|
22
31
|
export function executorDenyRules(inputs) {
|
|
23
32
|
return [
|
|
@@ -25,6 +34,7 @@ export function executorDenyRules(inputs) {
|
|
|
25
34
|
"Read(~/.aws/**)",
|
|
26
35
|
"Read(~/Library/Keychains/**)",
|
|
27
36
|
"Read(/etc/shadow)",
|
|
37
|
+
"Read(~/.config/bridge/**)",
|
|
28
38
|
`Bash(git push:*--force*${inputs.baseBranch}*)`,
|
|
29
39
|
"Bash(git push:*--force*)",
|
|
30
40
|
"Bash(rm:*-rf /*)",
|
|
@@ -14,8 +14,70 @@
|
|
|
14
14
|
* allowed reuse surface — it does not pull the v1 event/ledger graph).
|
|
15
15
|
*/
|
|
16
16
|
import { runDenyEnforcementPreflight } from "../conductor/deny-enforcement-preflight.js";
|
|
17
|
-
import {
|
|
17
|
+
import { DEFAULT_PROBE_TIMEOUT_MS } from "../agent-capabilities/types.js";
|
|
18
|
+
import { resolveAllExecutorApiAccess, resolveBaseUrl, EXECUTOR_BASE_URL_REQUIRED_MESSAGE, } from "./credentials.js";
|
|
18
19
|
const VERSION_DETAIL_MAX = 200;
|
|
20
|
+
/**
|
|
21
|
+
* Environment override for the deny probe's headless timeout (BAPI-722).
|
|
22
|
+
*
|
|
23
|
+
* EXECUTOR-PROCESS CONFIGURATION ONLY. It is deliberately excluded from spawned
|
|
24
|
+
* worker environments by `isExecutorEnvKeyAllowed` — a worker has no deny probe to
|
|
25
|
+
* time out, and letting a job influence the executor's own safety-probe budget
|
|
26
|
+
* would be a privilege inversion.
|
|
27
|
+
*/
|
|
28
|
+
export const DENY_PROBE_TIMEOUT_ENV_KEY = "BAPI_CONDUCTOR_DENY_PROBE_TIMEOUT_MS";
|
|
29
|
+
/**
|
|
30
|
+
* Accepted bounds for the resolved probe timeout. The lower bound keeps a
|
|
31
|
+
* fat-fingered `1` from turning every probe into an instant HANG (which is FATAL —
|
|
32
|
+
* the executor would refuse to claim forever); the upper bound keeps a typo from
|
|
33
|
+
* wedging the claim loop for hours. Anything outside them falls back to the
|
|
34
|
+
* established capability-probe default rather than being clamped, so a
|
|
35
|
+
* misconfiguration produces the known-good value instead of a silently different
|
|
36
|
+
* one the operator never chose.
|
|
37
|
+
*/
|
|
38
|
+
const DENY_PROBE_TIMEOUT_MIN_MS = 1_000;
|
|
39
|
+
const DENY_PROBE_TIMEOUT_MAX_MS = 600_000;
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the deny probe's timeout from the environment, falling back to the
|
|
42
|
+
* established capability-probe default (`DEFAULT_PROBE_TIMEOUT_MS`).
|
|
43
|
+
*
|
|
44
|
+
* Strict: blank, non-numeric, non-integer, zero, negative, and out-of-range values
|
|
45
|
+
* all resolve to the default. The result is ALWAYS a finite positive number — never
|
|
46
|
+
* `undefined`, never `0`, never unbounded — because an unbounded deny probe is
|
|
47
|
+
* exactly the hang this timeout exists to prevent.
|
|
48
|
+
*
|
|
49
|
+
* The value itself is never logged alongside its surrounding environment.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveDenyProbeTimeoutMs(env = {}) {
|
|
52
|
+
const raw = env[DENY_PROBE_TIMEOUT_ENV_KEY];
|
|
53
|
+
if (typeof raw !== "string" || raw.trim().length === 0)
|
|
54
|
+
return DEFAULT_PROBE_TIMEOUT_MS;
|
|
55
|
+
const parsed = Number(raw.trim());
|
|
56
|
+
if (!Number.isFinite(parsed) || !Number.isInteger(parsed))
|
|
57
|
+
return DEFAULT_PROBE_TIMEOUT_MS;
|
|
58
|
+
if (parsed < DENY_PROBE_TIMEOUT_MIN_MS || parsed > DENY_PROBE_TIMEOUT_MAX_MS) {
|
|
59
|
+
return DEFAULT_PROBE_TIMEOUT_MS;
|
|
60
|
+
}
|
|
61
|
+
return parsed;
|
|
62
|
+
}
|
|
63
|
+
/** True only for a result safe to reuse: genuinely enforced by a real layer. */
|
|
64
|
+
function isCacheableDenyResult(result) {
|
|
65
|
+
return result.enforced === true && result.layer !== "none";
|
|
66
|
+
}
|
|
67
|
+
/** Create a fresh, empty executor-scoped deny-probe cache. */
|
|
68
|
+
export function createDenyProbeCache() {
|
|
69
|
+
let cached;
|
|
70
|
+
return {
|
|
71
|
+
get: () => cached,
|
|
72
|
+
set: (result) => {
|
|
73
|
+
if (isCacheableDenyResult(result))
|
|
74
|
+
cached = result;
|
|
75
|
+
},
|
|
76
|
+
reset: () => {
|
|
77
|
+
cached = undefined;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
19
81
|
function normalizeVersion(stdout, stderr) {
|
|
20
82
|
return (stdout || stderr || "").trim().slice(0, VERSION_DETAIL_MAX);
|
|
21
83
|
}
|
|
@@ -61,11 +123,20 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
|
|
|
61
123
|
catch {
|
|
62
124
|
fatalFindings.push("claude could not be probed");
|
|
63
125
|
}
|
|
64
|
-
// ---
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
126
|
+
// --- Explicit base URL (fatal; never defaults to production, BAPI-676) ---
|
|
127
|
+
// Resolved before per-repo credentials so a missing URL reports ONCE rather
|
|
128
|
+
// than once per repo. The remaining probes still run and accumulate findings.
|
|
129
|
+
const baseUrlResult = resolveBaseUrl(deps.env, options.baseUrl);
|
|
130
|
+
if (!baseUrlResult.ok) {
|
|
131
|
+
fatalFindings.push(EXECUTOR_BASE_URL_REQUIRED_MESSAGE);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
// --- Credentials for every configured repo --------------------------
|
|
135
|
+
const access = await resolveAllExecutorApiAccess(options.repos, deps, baseUrlResult.baseUrl);
|
|
136
|
+
for (const a of access) {
|
|
137
|
+
if (!a.ok)
|
|
138
|
+
fatalFindings.push(`credential resolution failed for repo '${a.repoName}'`);
|
|
139
|
+
}
|
|
69
140
|
}
|
|
70
141
|
// --- Disk space (fatal when it cannot be verified) --------------------
|
|
71
142
|
let diskFreeGb = null;
|
|
@@ -97,10 +168,26 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
|
|
|
97
168
|
warnings.push("local `gh` is unavailable/unauthenticated; local merges would be degraded");
|
|
98
169
|
}
|
|
99
170
|
// --- Deny-layer enforcement (binding fatal contract) ------------------
|
|
171
|
+
// BAPI-722: only THIS probe is cached across claim cycles. Every probe above —
|
|
172
|
+
// tooling, credentials, disk, gh — still runs on each collection, because each of
|
|
173
|
+
// them can genuinely change between polls (a binary removed, a credential
|
|
174
|
+
// rotated, a disk filled). Deny-layer enforcement is a property of the machine's
|
|
175
|
+
// static settings; once verified it does not spontaneously unverify.
|
|
100
176
|
let denyEnforced = false;
|
|
101
177
|
try {
|
|
102
|
-
const
|
|
103
|
-
const
|
|
178
|
+
const cache = seams.bypassDenyProbeCache === true ? undefined : seams.denyProbeCache;
|
|
179
|
+
const cachedDeny = cache?.get();
|
|
180
|
+
let deny;
|
|
181
|
+
if (cachedDeny !== undefined) {
|
|
182
|
+
deny = cachedDeny;
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
const denyProbe = seams.runDenyPreflight ?? runDenyEnforcementPreflight;
|
|
186
|
+
deny = await denyProbe({ timeoutMs: resolveDenyProbeTimeoutMs(deps.env) });
|
|
187
|
+
// `set` itself refuses anything that is not a genuine success, so a failure
|
|
188
|
+
// cannot become sticky even if this call site were later moved.
|
|
189
|
+
seams.denyProbeCache?.set(deny);
|
|
190
|
+
}
|
|
104
191
|
denyEnforced = deny.enforced === true;
|
|
105
192
|
if (!deny.enforced) {
|
|
106
193
|
fatalFindings.push(`deny-layer enforcement not verified (enforced=false, layer=${deny.layer}); refusing to claim`);
|
|
@@ -26,6 +26,20 @@ const SUPPORTED_PROMPT_SPEC_VERSION = 1;
|
|
|
26
26
|
const GIT_LOG_MAX_CHARS = 8_000;
|
|
27
27
|
/** Explicit "no prior revise reasons" value — never an empty string. */
|
|
28
28
|
const REVISE_REASONS_NONE = "(none — this is the first review of the ticket spec)";
|
|
29
|
+
/**
|
|
30
|
+
* Explicit "no related-ticket context" value (BAPI-699) — never an empty string.
|
|
31
|
+
*
|
|
32
|
+
* The server normally renders this section itself (availability header + one
|
|
33
|
+
* record block per related ticket), so this fallback only fires when a payload
|
|
34
|
+
* reaches a placeholder-aware executor without a `related_context` field at all
|
|
35
|
+
* — e.g. a job enqueued by an older server revision. Substituting an empty
|
|
36
|
+
* string there would hand the reviewer a blank section that reads exactly like
|
|
37
|
+
* "this ticket has no neighbors", so the fallback states the blindness instead.
|
|
38
|
+
*/
|
|
39
|
+
const RELATED_CONTEXT_NONE = "(no related-ticket context was supplied with this job. This review is " +
|
|
40
|
+
"PARTIALLY BLIND to the surrounding tickets: do NOT conclude that the ticket " +
|
|
41
|
+
"has no related tickets, no dependencies, and no already-merged neighbors — " +
|
|
42
|
+
"that information was simply not provided.)";
|
|
29
43
|
/** The `git log` argv used to resolve `{{GIT_LOG}}` (list-based, never shelled). */
|
|
30
44
|
const GIT_LOG_ARGS = ["log", "--oneline", "--decorate", "--max-count=50"];
|
|
31
45
|
/**
|
|
@@ -39,7 +53,21 @@ const SUPPORTED_PLACEHOLDERS = new Set([
|
|
|
39
53
|
"WORK_BRANCH",
|
|
40
54
|
"GIT_LOG",
|
|
41
55
|
"REVISE_REASONS",
|
|
56
|
+
"RELATED_CONTEXT",
|
|
57
|
+
"SPEC_CITATION_FRESHNESS_WARNING",
|
|
42
58
|
]);
|
|
59
|
+
/**
|
|
60
|
+
* Explicit "no stale-citation advisory" value (BAPI-722) — never an empty string.
|
|
61
|
+
*
|
|
62
|
+
* Unlike `RELATED_CONTEXT`, absence here is the COMMON and entirely healthy case:
|
|
63
|
+
* most tickets have no predecessor that merged after their spec was written, so the
|
|
64
|
+
* server omits the field. The substituted text therefore states that plainly rather
|
|
65
|
+
* than warning about blindness — a reviewer must not read "no advisory" as "the
|
|
66
|
+
* server could not tell", which would push them toward re-verifying every citation
|
|
67
|
+
* on every review.
|
|
68
|
+
*/
|
|
69
|
+
const SPEC_CITATION_FRESHNESS_NONE = "(none — no dependency of this ticket merged after this ticket's " +
|
|
70
|
+
"specification snapshot was taken.)";
|
|
43
71
|
function promptContractError(message) {
|
|
44
72
|
return new ExecutorNamedError(PROMPT_CONTRACT_ERROR, message);
|
|
45
73
|
}
|
|
@@ -190,6 +218,29 @@ export async function buildPromptSpecPlaceholderValues(job, spec, context) {
|
|
|
190
218
|
values.REVISE_REASONS = formatReviseReasons(payload.reasons);
|
|
191
219
|
break;
|
|
192
220
|
}
|
|
221
|
+
case "RELATED_CONTEXT": {
|
|
222
|
+
// The server renders this section (availability header, per-entry
|
|
223
|
+
// record blocks, its own bounded trim marker) and records the SHA-256
|
|
224
|
+
// of EXACTLY the text it rendered. So the value is substituted VERBATIM
|
|
225
|
+
// — no `.trim()`, no re-wrapping, no re-bounding — otherwise the
|
|
226
|
+
// recorded display hash would stop describing what the reviewer read.
|
|
227
|
+
values.RELATED_CONTEXT = isNonBlankString(payload.related_context)
|
|
228
|
+
? payload.related_context
|
|
229
|
+
: RELATED_CONTEXT_NONE;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
case "SPEC_CITATION_FRESHNESS_WARNING": {
|
|
233
|
+
// OPTIONAL by contract (BAPI-722): an absent field is the normal case and
|
|
234
|
+
// resolves to the explicit "none" text, NEVER a thrown contract error and
|
|
235
|
+
// never an empty substitution. This deliberately does not weaken
|
|
236
|
+
// {{TICKET_SPEC}}, which stays required — a review with no spec is not a
|
|
237
|
+
// review, whereas a review with no advisory is simply the usual review.
|
|
238
|
+
// Substituted verbatim: the server composed this text and bounded it.
|
|
239
|
+
values.SPEC_CITATION_FRESHNESS_WARNING = isNonBlankString(payload.spec_citation_freshness_warning)
|
|
240
|
+
? payload.spec_citation_freshness_warning
|
|
241
|
+
: SPEC_CITATION_FRESHNESS_NONE;
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
193
244
|
}
|
|
194
245
|
}
|
|
195
246
|
return values;
|
package/build/executor/runner.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* preflight finding. `once` mode performs one preflight/claim cycle and returns
|
|
7
7
|
* after all active jobs finish. Depends on NO v1 conductor event/ledger module.
|
|
8
8
|
*/
|
|
9
|
-
import { collectExecutorPreflight, buildClaimManifest } from "./preflight.js";
|
|
9
|
+
import { collectExecutorPreflight, buildClaimManifest, createDenyProbeCache, } from "./preflight.js";
|
|
10
10
|
import { runClaimedJob } from "./job-runner.js";
|
|
11
11
|
import { sweepExecutorWorktrees } from "./worktree-gc.js";
|
|
12
12
|
/**
|
|
@@ -28,6 +28,19 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
28
28
|
platform: deps.platform,
|
|
29
29
|
}));
|
|
30
30
|
const active = new Map();
|
|
31
|
+
// BAPI-722: ONE deny-probe cache per `runExecutor` invocation, created OUTSIDE
|
|
32
|
+
// the claim loop below — that scope is the whole feature. A cache created inside
|
|
33
|
+
// the loop would be discarded every cycle and re-probe exactly as before; a
|
|
34
|
+
// module-level cache would leak across invocations and survive a deliberate
|
|
35
|
+
// restart, which is precisely the escape hatch an operator fixing a broken deny
|
|
36
|
+
// layer needs. Process lifetime is the correct boundary.
|
|
37
|
+
//
|
|
38
|
+
// An injected `preflightSeams.denyProbeCache` (or `bypassDenyProbeCache`) wins,
|
|
39
|
+
// so a test can force deterministic fresh probes.
|
|
40
|
+
const preflightSeams = {
|
|
41
|
+
denyProbeCache: createDenyProbeCache(),
|
|
42
|
+
...(seams.preflightSeams ?? {}),
|
|
43
|
+
};
|
|
31
44
|
/**
|
|
32
45
|
* Run the conservative worktree GC sweep ONLY while the runner owns no active
|
|
33
46
|
* job worktree. Failures are logged to stderr and never abort claiming
|
|
@@ -60,7 +73,7 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
60
73
|
active.set(job.id, promise);
|
|
61
74
|
}
|
|
62
75
|
for (;;) {
|
|
63
|
-
const report = await collectPreflight(options, deps,
|
|
76
|
+
const report = await collectPreflight(options, deps, preflightSeams);
|
|
64
77
|
if (!report.ok) {
|
|
65
78
|
deps.errorLog(`executor preflight refused claiming: ${report.fatalFindings.join("; ")}`);
|
|
66
79
|
}
|