@bridge_gpt/mcp-server 0.2.51 → 0.2.53
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 +59 -13
- 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 +7 -5
- package/build/conduct-epic/cut-protocol.js +17 -3
- package/build/conductor/bridge-api-client.js +232 -5
- package/build/conductor/cli.js +23 -0
- package/build/conductor/deny-enforcement-preflight.js +107 -10
- package/build/conductor/doctor.js +428 -5
- package/build/conductor/install-doctor.js +65 -656
- package/build/conductor/local-merge.js +170 -11
- package/build/conductor/readiness-cli.js +152 -0
- package/build/conductor/readiness-sections.js +666 -0
- package/build/conductor/readiness.js +710 -0
- package/build/conductor/tools.js +56 -3
- package/build/conductor-bin.js +21 -17
- 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 +40 -1
- package/build/drive-epic.js +423 -11
- package/build/env-file-link.js +164 -0
- package/build/epic-integration-pr.js +10 -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 +130 -28
- package/build/executor/merge-job.js +67 -16
- 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 +4734 -4270
- package/build/install-bridge.js +95 -0
- package/build/install-doctor.js +154 -2
- package/build/pipelines.generated.js +6 -4
- package/build/plan-epic-conductor-eligibility.js +37 -7
- package/build/plane/alembic-head.js +40 -11
- package/build/plane/build-freshness.js +22 -11
- 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 +402 -44
- 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 +118 -11
- package/build/pr-base-contract.js +20 -0
- package/build/readiness-check.js +412 -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 +560 -139
- package/build/sfcc/log-query.js +2 -1
- 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 +4 -3
- package/pipelines/{full-automation.json → idea-to-pr.json} +1 -1
package/build/plane/types.js
CHANGED
|
@@ -2,23 +2,59 @@
|
|
|
2
2
|
* Typed contracts for the conductor plane supervisor (BAPI-756, slice C4).
|
|
3
3
|
*
|
|
4
4
|
* The plane is the attended set of processes a conductor run needs: the Bridge
|
|
5
|
-
* API server, the reconciler worker,
|
|
6
|
-
* module carries every boundary type so the runtime
|
|
7
|
-
* manifest, roster, supervisor, status, shutdown) stay
|
|
8
|
-
* with injected dependencies and never reach for real
|
|
5
|
+
* API server, the reconciler worker, one executor per parallel lane, and the
|
|
6
|
+
* dead-man observer. This module carries every boundary type so the runtime
|
|
7
|
+
* modules (preflight, manifest, roster, supervisor, status, shutdown) stay
|
|
8
|
+
* independently testable with injected dependencies and never reach for real
|
|
9
|
+
* I/O in a unit test.
|
|
9
10
|
*
|
|
10
11
|
* Two ratified requirements shape these types and must not be softened:
|
|
11
12
|
*
|
|
12
|
-
* - **R-
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* - **R-1, the dead-man observer is a same-host, separate-process member**
|
|
14
|
+
* (BAPI-1029, superseding R-3). The observer runs `worker.py` with
|
|
15
|
+
* `CONDUCTOR_DEAD_MAN_ONLY=true` in its OWN OS process, owned by the plane
|
|
16
|
+
* lifecycle: it has a member name, a log path, and a manifest record, and
|
|
17
|
+
* `plane status` / `plane down` treat it like every other member. Its failure
|
|
18
|
+
* domain is independent at the PROCESS level — killing the reconciler leaves
|
|
19
|
+
* the observer alive to report the death, which is the whole point. It is NOT
|
|
20
|
+
* separate-host isolation; that is A2/R36 and is deliberately out of scope.
|
|
21
|
+
* The previous R-3 arrangement (observer absent by construction, started
|
|
22
|
+
* out-of-band) could not detect its own death and is retired.
|
|
16
23
|
* - **R-4, crashes are never auto-restarted.** No type here carries a restart
|
|
17
24
|
* count, a backoff delay, or a retry budget, because no such machinery
|
|
18
25
|
* exists. A member exit is a finding to report, not a condition to repair.
|
|
26
|
+
* The observer inherits this unchanged: it is spawned once and never revived.
|
|
19
27
|
*/
|
|
20
|
-
/**
|
|
21
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Manifest schema version WRITTEN by this build. Bump only with a matching
|
|
30
|
+
* parser change.
|
|
31
|
+
*
|
|
32
|
+
* Bumped to 2 by BAPI-1029, which added the `observer` member. The member set
|
|
33
|
+
* widened rather than the record shape, so the tempting conclusion was that no
|
|
34
|
+
* bump was needed — but that reasoning only covers the direction nobody is hurt
|
|
35
|
+
* by. Old manifests stay readable by a new binary; a NEW manifest is
|
|
36
|
+
* unreadable by an OLD one, because `isPlaneMemberName` there does not know
|
|
37
|
+
* `observer` and a single unrecognized name aborted the whole parse. `plane
|
|
38
|
+
* down` on the old build then returns `unvalidated-manifest` having signalled
|
|
39
|
+
* nothing, orphaning a fully detached plane and telling the operator to delete
|
|
40
|
+
* the file by hand.
|
|
41
|
+
*
|
|
42
|
+
* The bump does not fix that for an already-shipped old binary — nothing can —
|
|
43
|
+
* but it makes the failure legible ("schema version is not supported" instead
|
|
44
|
+
* of what reads like a corrupt file), and it pairs with the member-scoped
|
|
45
|
+
* rejection in `manifest.ts` so the next member added never reproduces it.
|
|
46
|
+
*/
|
|
47
|
+
export const PLANE_MANIFEST_SCHEMA_VERSION = 2;
|
|
48
|
+
/**
|
|
49
|
+
* Every schema version this build can READ.
|
|
50
|
+
*
|
|
51
|
+
* Deliberately wider than what it writes. A plane already running under the
|
|
52
|
+
* previous build has a version-1 manifest on disk, and refusing it would
|
|
53
|
+
* orphan that plane on upgrade — the same failure this bump exists to make
|
|
54
|
+
* legible, inflicted from the other side. Accepting 1 costs nothing: the two
|
|
55
|
+
* versions differ only in whether `observer` may appear among the members.
|
|
56
|
+
*/
|
|
57
|
+
export const PLANE_MANIFEST_SUPPORTED_SCHEMA_VERSIONS = [1, 2];
|
|
22
58
|
/** Runtime artifact directory, relative to the repository root. */
|
|
23
59
|
export const PLANE_RUNTIME_DIR = ".bridge/plane";
|
|
24
60
|
/** Manifest filename inside {@link PLANE_RUNTIME_DIR}. */
|
|
@@ -87,9 +123,80 @@ export const PLANE_ENTRYPOINT_ACTION = "__entrypoint";
|
|
|
87
123
|
* together with the runtime's own pid.
|
|
88
124
|
*/
|
|
89
125
|
export const PLANE_ID_ENV_VAR = "BAPI_PLANE_ID";
|
|
90
|
-
/**
|
|
126
|
+
/**
|
|
127
|
+
* Operator-facing prose for the dead-man observer, as a copy-pasteable shell
|
|
128
|
+
* line.
|
|
129
|
+
*
|
|
130
|
+
* This is DOCUMENTATION, not an invocation. `plane up` spawns the observer with
|
|
131
|
+
* a shell-free executable plus argument vector and an observer-only child
|
|
132
|
+
* environment (see `member-roster.ts#buildPlaneObserverEnv`) — a shell string is
|
|
133
|
+
* never handed to `spawn`. The constant survives because the documented
|
|
134
|
+
* session-background FALLBACK still starts the observer by hand, and because an
|
|
135
|
+
* operator debugging the plane needs to know exactly what the observer member is.
|
|
136
|
+
*/
|
|
91
137
|
export const PLANE_OBSERVER_COMMAND = "CONDUCTOR_DEAD_MAN_ONLY=true python worker.py";
|
|
138
|
+
/** Env var that puts `worker.py` into dead-man-only mode. Observer member ONLY. */
|
|
139
|
+
export const PLANE_OBSERVER_MODE_ENV = "CONDUCTOR_DEAD_MAN_ONLY";
|
|
140
|
+
/** The exact value {@link PLANE_OBSERVER_MODE_ENV} is set to for the observer. */
|
|
141
|
+
export const PLANE_OBSERVER_MODE_VALUE = "true";
|
|
92
142
|
/** Env var naming the observer's alert channel type. */
|
|
93
143
|
export const PLANE_OBSERVER_CHANNEL_TYPE_ENV = "CONDUCTOR_DEADMAN_CHANNEL_TYPE";
|
|
94
144
|
/** Env var naming the env var that holds the observer's destination URL. */
|
|
95
145
|
export const PLANE_OBSERVER_DESTINATION_ENV = "CONDUCTOR_DEADMAN_DESTINATION_REF";
|
|
146
|
+
/** Build a {@link PlaneCheckReport} for a check that ran and passed. */
|
|
147
|
+
export function planeCheckPassed(check, label, detail) {
|
|
148
|
+
return {
|
|
149
|
+
diagnostic: null,
|
|
150
|
+
outcomes: [{ check, id: check, label, status: "pass", ...(detail ? { detail } : {}) }],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Build a {@link PlaneCheckReport} from a diagnostic plus its structured fix.
|
|
155
|
+
*
|
|
156
|
+
* The severity is the single source of the canonical status — `blocking` is
|
|
157
|
+
* `fail`, `warning` is `warn` — so the two can never disagree about the same
|
|
158
|
+
* finding.
|
|
159
|
+
*/
|
|
160
|
+
export function planeCheckFinding(diagnostic, label, detail) {
|
|
161
|
+
return {
|
|
162
|
+
diagnostic,
|
|
163
|
+
outcomes: [
|
|
164
|
+
{
|
|
165
|
+
check: diagnostic.check,
|
|
166
|
+
id: diagnostic.check,
|
|
167
|
+
label,
|
|
168
|
+
status: diagnostic.severity === "blocking" ? "fail" : "warn",
|
|
169
|
+
detail,
|
|
170
|
+
...(diagnostic.remediation ? { remediation: diagnostic.remediation } : {}),
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/** Build a {@link PlaneCheckReport} for a check that was never reached. */
|
|
176
|
+
export function planeCheckSkipped(check, label, detail) {
|
|
177
|
+
return {
|
|
178
|
+
diagnostic: null,
|
|
179
|
+
outcomes: [{ check, id: check, label, status: "skip", detail }],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Every value {@link PlaneHeartbeatComponent} may take, for runtime guards.
|
|
184
|
+
*
|
|
185
|
+
* A cross-language contract, exactly like the state vocabulary below it: this
|
|
186
|
+
* string is sent to `GET /automation/health` as `component`, and the route
|
|
187
|
+
* refuses any value outside the closed set Python declares in
|
|
188
|
+
* `src/python/llms/process_heartbeats.py`. A component added on one side alone
|
|
189
|
+
* is a 422 on every probe, so the two declarations are pinned together by
|
|
190
|
+
* `tests/pytest/mcp_server/test_plane_liveness_vocabulary_pin.py`.
|
|
191
|
+
*/
|
|
192
|
+
export const PLANE_HEARTBEAT_COMPONENTS = [
|
|
193
|
+
"reconciler",
|
|
194
|
+
"executor",
|
|
195
|
+
];
|
|
196
|
+
/** Every value {@link PlaneHeartbeatHealthState} may take, for runtime guards. */
|
|
197
|
+
export const PLANE_HEARTBEAT_HEALTH_STATES = [
|
|
198
|
+
"fresh",
|
|
199
|
+
"stale",
|
|
200
|
+
"never_seen",
|
|
201
|
+
"unknown",
|
|
202
|
+
];
|
|
@@ -17,6 +17,26 @@
|
|
|
17
17
|
* for the worker. Consumed by the worker as `gh pr create --base "$BAPI_BASE_BRANCH"`.
|
|
18
18
|
*/
|
|
19
19
|
export const PR_BASE_BRANCH_ENV_VAR = "BAPI_BASE_BRANCH";
|
|
20
|
+
/**
|
|
21
|
+
* The environment variable carrying THIS JOB's own branch (BAPI-1020).
|
|
22
|
+
*
|
|
23
|
+
* Read by the deterministic PreToolUse worker guard
|
|
24
|
+
* (`executor/worker-guard-hook.ts`), which cannot otherwise answer the two
|
|
25
|
+
* questions a deny glob is incapable of expressing: "is this `git push`
|
|
26
|
+
* destination the worker's own branch?" and "is this `git reset --hard` ref the
|
|
27
|
+
* worker's own ref?". A glob sees a string; the guard needs the identity.
|
|
28
|
+
*
|
|
29
|
+
* Declared HERE, beside {@link PR_BASE_BRANCH_ENV_VAR}, rather than as a bare
|
|
30
|
+
* string literal at the injection site, so the producing and consuming sides
|
|
31
|
+
* cannot drift apart on the spelling.
|
|
32
|
+
*
|
|
33
|
+
* It is set from the EXPLICIT job value only and is on `EXPLICIT_DENY_KEYS` in
|
|
34
|
+
* `executor/env.ts`, which matters more here than for the base branch: an ambient
|
|
35
|
+
* `BAPI_WORKER_BRANCH` exported in the operator's shell would otherwise reach a
|
|
36
|
+
* worker and REDEFINE which branch the guard considers safe to push to — turning
|
|
37
|
+
* the guard's own input into an attack surface.
|
|
38
|
+
*/
|
|
39
|
+
export const WORKER_BRANCH_ENV_VAR = "BAPI_WORKER_BRANCH";
|
|
20
40
|
/**
|
|
21
41
|
* A SINGLE-LINE, secret-free worker launch instruction telling the worker to
|
|
22
42
|
* open its PR against the injected run base. Kept free of newlines, `;`, single
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical shared readiness-check contract (BAPI-1055, AC-6).
|
|
3
|
+
*
|
|
4
|
+
* Conductor prerequisite probing is spread across five loci — `bridge doctor`
|
|
5
|
+
* ({@link file:./install-doctor.ts}), `conductor doctor`
|
|
6
|
+
* ({@link file:./conductor/doctor.ts}), `plane` preflight
|
|
7
|
+
* ({@link file:./plane/preflight.ts}), the server `conductor-readiness`
|
|
8
|
+
* collector, and the unified install doctor
|
|
9
|
+
* ({@link file:./conductor/install-doctor.ts}) that already composes four of
|
|
10
|
+
* them. Each speaks its own status vocabulary. This module is the ONE record
|
|
11
|
+
* shape they are all projected into, so a consolidated report can say
|
|
12
|
+
* pass/fail and name exactly one remediation per failure without any locus
|
|
13
|
+
* having to abandon its own verdicts.
|
|
14
|
+
*
|
|
15
|
+
* DEPENDENCY-NEUTRAL LEAF. It imports nothing: no fs, no network, no CLI, no
|
|
16
|
+
* sibling diagnostic. That is what makes it safe to import from every surface,
|
|
17
|
+
* including ones pinned to issue zero probes.
|
|
18
|
+
*
|
|
19
|
+
* ## Exhaustive source-status mapping (the accepted four-state vocabulary)
|
|
20
|
+
*
|
|
21
|
+
* The shared vocabulary has FOUR states, not three, because four is the
|
|
22
|
+
* minimum that lets every source verdict round-trip without a lossy merge.
|
|
23
|
+
* Collapsing `degraded` onto `fail` would make a fallback npm channel read
|
|
24
|
+
* identically to an unusable ledger, which would contradict the rule that each
|
|
25
|
+
* section's own advisory semantics stay unchanged.
|
|
26
|
+
*
|
|
27
|
+
* | Source | Source verdict | Canonical |
|
|
28
|
+
* | ----------------------------------- | ----------------------------- | --------- |
|
|
29
|
+
* | `install-doctor` `InstallCheckStatus` | `PASS` | `pass` |
|
|
30
|
+
* | `install-doctor` `InstallCheckStatus` | `WARN` | `warn` |
|
|
31
|
+
* | `install-doctor` `InstallCheckStatus` | `SKIP` | `skip` |
|
|
32
|
+
* | `install-doctor` `InstallCheckStatus` | `INFO` | see below |
|
|
33
|
+
* | `conductor/install-doctor` section | `ok` | `pass` |
|
|
34
|
+
* | `conductor/install-doctor` section | `degraded` | `warn` |
|
|
35
|
+
* | `conductor/install-doctor` section | `fatal` | `fail` |
|
|
36
|
+
* | `conductor/doctor` inspection | `degraded: true` | `warn` |
|
|
37
|
+
* | `plane` diagnostic | `severity: "warning"` | `warn` |
|
|
38
|
+
* | `plane` diagnostic | `severity: "blocking"` | `fail` |
|
|
39
|
+
* | server readiness | unreadable / `null` / missing | `fail` |
|
|
40
|
+
*
|
|
41
|
+
* `INFO` never becomes a canonical status. It is informational by definition,
|
|
42
|
+
* so its content is preserved in {@link ReadinessCheck.detail} while the check
|
|
43
|
+
* takes `pass`, `warn`, or `skip` according to that check's own established
|
|
44
|
+
* semantics — chosen per check id by the adapter, never guessed here.
|
|
45
|
+
*
|
|
46
|
+
* Three states are deliberately NOT interchangeable:
|
|
47
|
+
*
|
|
48
|
+
* - `fail` means a prerequisite is not satisfied, INCLUDING the case where its
|
|
49
|
+
* state could not be established. An unreadable liveness read, a failed
|
|
50
|
+
* permission probe, `executor.ready === null`, and a field a newer server
|
|
51
|
+
* would have sent are all `fail` with a remediation for restoring the probe.
|
|
52
|
+
* Uncertainty must never appear healthy.
|
|
53
|
+
* - `warn` means the prerequisite is degraded but usable, or a launch-permitting
|
|
54
|
+
* plane warning. It is not a soft failure and is never derived from silence.
|
|
55
|
+
* - `skip` means the check genuinely did not apply or a dependency prevented it
|
|
56
|
+
* from running. It is never used to hide an unknown.
|
|
57
|
+
*
|
|
58
|
+
* ## The failure/remediation invariant
|
|
59
|
+
*
|
|
60
|
+
* AC-6 requires exactly ONE named remediation on every failure, so the union
|
|
61
|
+
* below makes that a property of the type: the `fail` variant REQUIRES one
|
|
62
|
+
* non-empty scalar remediation and the `pass` variant PROHIBITS one. `warn` and
|
|
63
|
+
* `skip` may each carry at most one, because an actionable warning and the
|
|
64
|
+
* actionable deny-probe skip both have a named next step, while a
|
|
65
|
+
* dependency-driven skip has none.
|
|
66
|
+
*
|
|
67
|
+
* ## Secret discipline
|
|
68
|
+
*
|
|
69
|
+
* Every `detail` and `remediation` is fixed prose plus non-secret facts: a
|
|
70
|
+
* label, a closed enum, a count, a boolean, a timestamp, a relative identifier,
|
|
71
|
+
* or an approved base/setup URL. The helpers here cap length, strip control
|
|
72
|
+
* characters, and refuse obvious absolute paths — but they are a last line of
|
|
73
|
+
* defence, NOT permission to pass a raw exception, subprocess output, a request
|
|
74
|
+
* header, a response body, or a credential into the constructor. Projecting
|
|
75
|
+
* source data into safe prose is the adapter's job; this module only refuses
|
|
76
|
+
* the most obvious leaks.
|
|
77
|
+
*/
|
|
78
|
+
/** Every valid status, for exhaustive validation and test enumeration. */
|
|
79
|
+
export const READINESS_CHECK_STATUSES = [
|
|
80
|
+
"pass",
|
|
81
|
+
"warn",
|
|
82
|
+
"fail",
|
|
83
|
+
"skip",
|
|
84
|
+
];
|
|
85
|
+
/** Every valid source, for exhaustive validation and test enumeration. */
|
|
86
|
+
export const READINESS_CHECK_SOURCES = [
|
|
87
|
+
"install",
|
|
88
|
+
"conductor",
|
|
89
|
+
"plane",
|
|
90
|
+
"server",
|
|
91
|
+
];
|
|
92
|
+
/** Raised by {@link createReadinessCheck} for input that cannot be projected. */
|
|
93
|
+
export class ReadinessCheckValidationError extends Error {
|
|
94
|
+
constructor(reason) {
|
|
95
|
+
// The reason names the RULE that was broken, never the offending value: the
|
|
96
|
+
// value is exactly the untrusted material this module exists to keep out of
|
|
97
|
+
// output, and an error message is an output.
|
|
98
|
+
super(`invalid readiness check: ${reason}`);
|
|
99
|
+
this.name = "ReadinessCheckValidationError";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Length cap for a projected detail line. */
|
|
103
|
+
export const READINESS_DETAIL_MAX_LENGTH = 400;
|
|
104
|
+
/** Length cap for a projected remediation. */
|
|
105
|
+
export const READINESS_REMEDIATION_MAX_LENGTH = 400;
|
|
106
|
+
/**
|
|
107
|
+
* Stable id grammar: `<source>.<segment>[.<segment>...]`.
|
|
108
|
+
*
|
|
109
|
+
* Lowercase, digits, and hyphens only. Whitespace, uppercase, quotes, slashes,
|
|
110
|
+
* and colons are all rejected, which is what makes an interpolated value — a
|
|
111
|
+
* path, a repo name, an error string — structurally unable to become an id.
|
|
112
|
+
*/
|
|
113
|
+
const ID_SEGMENT = "[a-z0-9]+(?:-[a-z0-9]+)*";
|
|
114
|
+
const READINESS_ID_PATTERN = new RegExp(`^${ID_SEGMENT}(?:\\.${ID_SEGMENT})+$`);
|
|
115
|
+
/**
|
|
116
|
+
* Filesystem roots an absolute POSIX path leak actually starts with.
|
|
117
|
+
*
|
|
118
|
+
* An allowlist of DANGER rather than a general "starts with a slash" rule,
|
|
119
|
+
* because the strings this contract legitimately carries are full of leading
|
|
120
|
+
* slashes that are not paths at all: the slash commands remediations name
|
|
121
|
+
* (`/install-bridge`), and the endpoint identifiers details name (`/jira/ping`).
|
|
122
|
+
* Rejecting those would push adapters toward vaguer prose without removing any
|
|
123
|
+
* real leak — the leak is always a home, temp, or system root.
|
|
124
|
+
*/
|
|
125
|
+
const POSIX_PATH_ROOTS = [
|
|
126
|
+
"Users",
|
|
127
|
+
"home",
|
|
128
|
+
"root",
|
|
129
|
+
"var",
|
|
130
|
+
"tmp",
|
|
131
|
+
"private",
|
|
132
|
+
"opt",
|
|
133
|
+
"usr",
|
|
134
|
+
"etc",
|
|
135
|
+
"srv",
|
|
136
|
+
"mnt",
|
|
137
|
+
"media",
|
|
138
|
+
"Applications",
|
|
139
|
+
"Library",
|
|
140
|
+
"System",
|
|
141
|
+
"Volumes",
|
|
142
|
+
"node_modules",
|
|
143
|
+
];
|
|
144
|
+
/**
|
|
145
|
+
* Absolute POSIX and Windows paths, and `file://` URLs.
|
|
146
|
+
*
|
|
147
|
+
* Deliberately narrow: it catches the obvious leak (`/Users/...`, `/private/tmp/...`,
|
|
148
|
+
* `C:\\...`) without rejecting the relative, repository-conventional paths
|
|
149
|
+
* remediations legitimately name (`.github/workflows/claude-review.yml`,
|
|
150
|
+
* `docs/claude/epic-conductor-v2-operator-runbook.md`) or the approved setup
|
|
151
|
+
* URLs (`https://host/setup`) — note the Windows drive alternative requires a
|
|
152
|
+
* word boundary before the letter, so the `s:/` inside `https://` is not a hit.
|
|
153
|
+
*/
|
|
154
|
+
const ABSOLUTE_PATH_PATTERN = new RegExp("(^|[\\s\"'`(<])(?:" +
|
|
155
|
+
`\\/(?:${POSIX_PATH_ROOTS.join("|")})\\/` +
|
|
156
|
+
"|[A-Za-z]:[\\\\/]" +
|
|
157
|
+
"|file:\\/\\/)");
|
|
158
|
+
/** True when `value` looks like it embeds an absolute filesystem path. */
|
|
159
|
+
export function containsAbsolutePath(value) {
|
|
160
|
+
return ABSOLUTE_PATH_PATTERN.test(value);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Request headers and serialized response bodies.
|
|
164
|
+
*
|
|
165
|
+
* Both have recognizable shapes, which is what makes them worth refusing here:
|
|
166
|
+
* unlike a credential value — a random-looking string no validator can tell
|
|
167
|
+
* from a repository name — an `Authorization:` header or a JSON object body
|
|
168
|
+
* announces itself. Neither belongs in a report field under any circumstances,
|
|
169
|
+
* so this is a structural refusal rather than a judgement call.
|
|
170
|
+
*
|
|
171
|
+
* The credential value itself is NOT covered, and cannot be: nothing in this
|
|
172
|
+
* module ever holds one. That guarantee lives at the collectors that do, which
|
|
173
|
+
* is why they author prose instead of copying resolver output.
|
|
174
|
+
*/
|
|
175
|
+
const TRANSPORT_ARTIFACT_PATTERN = /(^|[\s"'`(<])(?:authorization\s*:|x-api-key\s*:|bearer\s+\S|\{\s*"[A-Za-z_$][\w$]*"\s*:)/i;
|
|
176
|
+
/** True when `value` looks like a request header or a serialized response body. */
|
|
177
|
+
export function containsTransportArtifact(value) {
|
|
178
|
+
return TRANSPORT_ARTIFACT_PATTERN.test(value);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Normalize already-projected prose for a bounded, secret-free field.
|
|
182
|
+
*
|
|
183
|
+
* Strips control characters (including the newlines that would let one field
|
|
184
|
+
* forge another report line), collapses runs of whitespace, trims, and caps the
|
|
185
|
+
* length. Throws when the result is empty or still looks like an absolute path.
|
|
186
|
+
*/
|
|
187
|
+
function normalizeProse(value, field, maxLength) {
|
|
188
|
+
if (typeof value !== "string") {
|
|
189
|
+
// Arrays are called out by name because "one remediation" is the invariant
|
|
190
|
+
// an array silently violates: a caller handing over two fixes is not a
|
|
191
|
+
// formatting problem, it is a contract violation.
|
|
192
|
+
throw new ReadinessCheckValidationError(Array.isArray(value) ? `${field} must be a single string, not an array` : `${field} must be a string`);
|
|
193
|
+
}
|
|
194
|
+
// eslint-disable-next-line no-control-regex -- stripping control characters is the point
|
|
195
|
+
const stripped = value.replace(/[\u0000-\u001f\u007f]+/g, " ");
|
|
196
|
+
const collapsed = stripped.replace(/\s{2,}/g, " ").trim();
|
|
197
|
+
if (collapsed.length === 0) {
|
|
198
|
+
throw new ReadinessCheckValidationError(`${field} must not be blank`);
|
|
199
|
+
}
|
|
200
|
+
if (containsAbsolutePath(collapsed)) {
|
|
201
|
+
throw new ReadinessCheckValidationError(`${field} must not contain an absolute path`);
|
|
202
|
+
}
|
|
203
|
+
if (containsTransportArtifact(collapsed)) {
|
|
204
|
+
throw new ReadinessCheckValidationError(`${field} must not contain a request header or a response body`);
|
|
205
|
+
}
|
|
206
|
+
return collapsed.length > maxLength ? `${collapsed.slice(0, maxLength - 1).trimEnd()}…` : collapsed;
|
|
207
|
+
}
|
|
208
|
+
/** Normalize a detail line. Exported so adapters project through one helper. */
|
|
209
|
+
export function normalizeReadinessDetail(value) {
|
|
210
|
+
return normalizeProse(value, "detail", READINESS_DETAIL_MAX_LENGTH);
|
|
211
|
+
}
|
|
212
|
+
/** Normalize a remediation. Exported so adapters project through one helper. */
|
|
213
|
+
export function normalizeReadinessRemediation(value) {
|
|
214
|
+
return normalizeProse(value, "remediation", READINESS_REMEDIATION_MAX_LENGTH);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Build one validated {@link ReadinessCheck}.
|
|
218
|
+
*
|
|
219
|
+
* The ONLY sanctioned way to construct a canonical check. It enforces the
|
|
220
|
+
* failure/remediation invariant, the id namespace, the closed vocabularies, and
|
|
221
|
+
* the bounded secret-free field rules — so a report cannot acquire a failure
|
|
222
|
+
* with no fix, a fix attached to a pass, or an id derived from untrusted input.
|
|
223
|
+
*
|
|
224
|
+
* Throws {@link ReadinessCheckValidationError}; callers assembling a report from
|
|
225
|
+
* external material should route through {@link validateReadinessChecks}, which
|
|
226
|
+
* converts a throw into a stable failed check rather than losing the locus.
|
|
227
|
+
*/
|
|
228
|
+
export function createReadinessCheck(input) {
|
|
229
|
+
if (input === null || typeof input !== "object") {
|
|
230
|
+
throw new ReadinessCheckValidationError("check must be an object");
|
|
231
|
+
}
|
|
232
|
+
if (!READINESS_CHECK_SOURCES.includes(input.source)) {
|
|
233
|
+
throw new ReadinessCheckValidationError("source is not a supported readiness source");
|
|
234
|
+
}
|
|
235
|
+
if (!READINESS_CHECK_STATUSES.includes(input.status)) {
|
|
236
|
+
throw new ReadinessCheckValidationError("status is not a supported readiness status");
|
|
237
|
+
}
|
|
238
|
+
if (typeof input.id !== "string" || !READINESS_ID_PATTERN.test(input.id)) {
|
|
239
|
+
throw new ReadinessCheckValidationError("id is not a stable dotted lowercase identifier");
|
|
240
|
+
}
|
|
241
|
+
if (!input.id.startsWith(`${input.source}.`)) {
|
|
242
|
+
throw new ReadinessCheckValidationError("id namespace does not match its source");
|
|
243
|
+
}
|
|
244
|
+
const label = normalizeProse(input.label, "label", READINESS_DETAIL_MAX_LENGTH);
|
|
245
|
+
const detail = input.detail === undefined ? undefined : normalizeReadinessDetail(input.detail);
|
|
246
|
+
if (input.status === "pass") {
|
|
247
|
+
if (input.remediation !== undefined) {
|
|
248
|
+
// A pass with a fix attached is how a report starts telling operators to
|
|
249
|
+
// act on things that are already fine, which is how they learn to ignore it.
|
|
250
|
+
throw new ReadinessCheckValidationError("a passing check must not carry a remediation");
|
|
251
|
+
}
|
|
252
|
+
return { id: input.id, source: input.source, label, status: "pass", ...(detail ? { detail } : {}) };
|
|
253
|
+
}
|
|
254
|
+
if (input.status === "fail") {
|
|
255
|
+
if (input.remediation === undefined) {
|
|
256
|
+
throw new ReadinessCheckValidationError("a failing check must carry exactly one remediation");
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
id: input.id,
|
|
260
|
+
source: input.source,
|
|
261
|
+
label,
|
|
262
|
+
status: "fail",
|
|
263
|
+
...(detail ? { detail } : {}),
|
|
264
|
+
remediation: normalizeReadinessRemediation(input.remediation),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const remediation = input.remediation === undefined ? undefined : normalizeReadinessRemediation(input.remediation);
|
|
268
|
+
return {
|
|
269
|
+
id: input.id,
|
|
270
|
+
source: input.source,
|
|
271
|
+
label,
|
|
272
|
+
status: input.status,
|
|
273
|
+
...(detail ? { detail } : {}),
|
|
274
|
+
...(remediation ? { remediation } : {}),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
/** Fixed prose for a check that had to be replaced because it was malformed. */
|
|
278
|
+
export const READINESS_MALFORMED_CHECK_DETAIL = "this prerequisite reported a result that failed the readiness contract, so its real state is unknown";
|
|
279
|
+
/** Fixed remediation for a replaced malformed check. */
|
|
280
|
+
export const READINESS_MALFORMED_CHECK_REMEDIATION = "re-run `conductor readiness`; if it persists, run `conductor doctor` and `install conductor` to re-collect this locus.";
|
|
281
|
+
/** Fixed label for a replaced malformed check. */
|
|
282
|
+
export const READINESS_MALFORMED_CHECK_LABEL = "Unreadable prerequisite";
|
|
283
|
+
/**
|
|
284
|
+
* Validate a whole array of checks, replacing every malformed entry with a
|
|
285
|
+
* stable, source-scoped failure rather than dropping it.
|
|
286
|
+
*
|
|
287
|
+
* Dropping is the dangerous direction: a locus that vanished reads as "nothing
|
|
288
|
+
* to report", while a failed check with fixed prose reads as "this could not be
|
|
289
|
+
* established" — which is the truth. The replacement satisfies the
|
|
290
|
+
* one-remediation invariant like any other failure.
|
|
291
|
+
*
|
|
292
|
+
* `fallbackSource` is used when the malformed entry does not even carry a usable
|
|
293
|
+
* source, so the substitute still lands in a namespace a reader can act on.
|
|
294
|
+
*/
|
|
295
|
+
export function validateReadinessChecks(checks, fallbackSource = "conductor") {
|
|
296
|
+
const validated = [];
|
|
297
|
+
let malformedIndex = 0;
|
|
298
|
+
for (const candidate of checks) {
|
|
299
|
+
try {
|
|
300
|
+
validated.push(createReadinessCheck(candidate));
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
const raw = (candidate ?? {});
|
|
304
|
+
const source = READINESS_CHECK_SOURCES.includes(raw.source)
|
|
305
|
+
? raw.source
|
|
306
|
+
: fallbackSource;
|
|
307
|
+
// The malformed entry's OWN id is never reused: it is the untrusted value
|
|
308
|
+
// that failed validation, so the substitute gets a generated stable id in
|
|
309
|
+
// the right namespace instead.
|
|
310
|
+
malformedIndex += 1;
|
|
311
|
+
validated.push(createReadinessCheck({
|
|
312
|
+
id: `${source}.unreadable-check-${malformedIndex}`,
|
|
313
|
+
source,
|
|
314
|
+
label: READINESS_MALFORMED_CHECK_LABEL,
|
|
315
|
+
status: "fail",
|
|
316
|
+
detail: READINESS_MALFORMED_CHECK_DETAIL,
|
|
317
|
+
remediation: READINESS_MALFORMED_CHECK_REMEDIATION,
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return validated;
|
|
322
|
+
}
|
|
323
|
+
/** Count checks by canonical status. Pure; used by report summaries and tests. */
|
|
324
|
+
export function summarizeReadinessChecks(checks) {
|
|
325
|
+
const counts = { pass: 0, warn: 0, fail: 0, skip: 0 };
|
|
326
|
+
for (const check of checks)
|
|
327
|
+
counts[check.status] += 1;
|
|
328
|
+
return counts;
|
|
329
|
+
}
|
|
330
|
+
/** Fixed stand-in for a source detail that failed the secret-safety contract. */
|
|
331
|
+
export const READINESS_UNSAFE_DETAIL_REPLACEMENT = "detail withheld — the source reported it in a form this report may not carry";
|
|
332
|
+
/** Fixed stand-in for a source remediation that failed the secret-safety contract. */
|
|
333
|
+
export const READINESS_UNSAFE_REMEDIATION_REPLACEMENT = "re-run `conductor readiness`; this prerequisite's own fix text could not be carried into this report safely.";
|
|
334
|
+
/**
|
|
335
|
+
* Build a check, degrading REJECTED prose rather than losing the finding — and
|
|
336
|
+
* never throwing.
|
|
337
|
+
*
|
|
338
|
+
* Legacy sections and diagnostics author their prose for a terminal, and some of
|
|
339
|
+
* them legitimately name an absolute path there — the executor service-unit
|
|
340
|
+
* collector reports the LaunchAgents directory it searched, for instance. That
|
|
341
|
+
* is fine in the report it was written for and NOT fine in this one, which is a
|
|
342
|
+
* wider surface.
|
|
343
|
+
*
|
|
344
|
+
* The two obvious responses are both wrong. Passing it through would put a home
|
|
345
|
+
* directory into the consolidated report; dropping the whole check on a
|
|
346
|
+
* validation failure would silently remove a real prerequisite because its prose
|
|
347
|
+
* was unsafe. So the offending FIELD is replaced and the id, label, and status —
|
|
348
|
+
* the parts an operator acts on — survive.
|
|
349
|
+
*
|
|
350
|
+
* The ladder degrades one field at a time, least-destructive first, so a check
|
|
351
|
+
* loses no more of itself than the failure requires. The last rung is a stable
|
|
352
|
+
* failure in the same namespace: this function is called from inside
|
|
353
|
+
* {@link file:./conductor/readiness.ts}'s checks-array literal, which is built
|
|
354
|
+
* BEFORE {@link validateReadinessChecks} runs, so a throw here would escape the
|
|
355
|
+
* aggregator's "never throws" contract rather than being absorbed by it. An
|
|
356
|
+
* earlier revision of this function replaced only `detail` and justified the gap
|
|
357
|
+
* by pointing at that later validation pass — which does not run early enough to
|
|
358
|
+
* catch it.
|
|
359
|
+
*/
|
|
360
|
+
export function createReadinessCheckSafely(input) {
|
|
361
|
+
// A pass may not carry a remediation at all, so for a pass the field is
|
|
362
|
+
// DROPPED rather than replaced — substituting prose there would invent an
|
|
363
|
+
// action for a prerequisite that is already satisfied.
|
|
364
|
+
const safeRemediation = () => input?.status === "pass"
|
|
365
|
+
? { remediation: undefined }
|
|
366
|
+
: { remediation: READINESS_UNSAFE_REMEDIATION_REPLACEMENT };
|
|
367
|
+
// 1. As authored.
|
|
368
|
+
try {
|
|
369
|
+
return createReadinessCheck(input);
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
/* fall through */
|
|
373
|
+
}
|
|
374
|
+
// 2. The detail alone — the field untrusted source prose usually arrives in.
|
|
375
|
+
try {
|
|
376
|
+
return createReadinessCheck({ ...input, detail: READINESS_UNSAFE_DETAIL_REPLACEMENT });
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
/* fall through */
|
|
380
|
+
}
|
|
381
|
+
// 3. The remediation alone, keeping a detail that was never the problem.
|
|
382
|
+
try {
|
|
383
|
+
return createReadinessCheck({ ...input, ...safeRemediation() });
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
/* fall through */
|
|
387
|
+
}
|
|
388
|
+
// 4. Both.
|
|
389
|
+
try {
|
|
390
|
+
return createReadinessCheck({
|
|
391
|
+
...input,
|
|
392
|
+
detail: READINESS_UNSAFE_DETAIL_REPLACEMENT,
|
|
393
|
+
...safeRemediation(),
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
/* fall through */
|
|
398
|
+
}
|
|
399
|
+
// 5. Structurally invalid — a bad id, source, or status, which is a caller bug
|
|
400
|
+
// rather than untrusted prose. Reported as an unreadable prerequisite in the
|
|
401
|
+
// caller's namespace when it named a usable one, exactly as
|
|
402
|
+
// `validateReadinessChecks` would have. Never a throw, and never a pass.
|
|
403
|
+
const source = READINESS_CHECK_SOURCES.includes(input?.source) ? input.source : "conductor";
|
|
404
|
+
return createReadinessCheck({
|
|
405
|
+
id: `${source}.unreadable-check`,
|
|
406
|
+
source,
|
|
407
|
+
label: READINESS_MALFORMED_CHECK_LABEL,
|
|
408
|
+
status: "fail",
|
|
409
|
+
detail: READINESS_MALFORMED_CHECK_DETAIL,
|
|
410
|
+
remediation: READINESS_MALFORMED_CHECK_REMEDIATION,
|
|
411
|
+
});
|
|
412
|
+
}
|