@bridge_gpt/mcp-server 0.2.59 → 0.2.62
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 +181 -51
- package/build/agent-guidance.generated.js +1 -1
- package/build/agent-notes.js +7 -1
- package/build/commands.generated.js +6 -6
- package/build/conductor/bridge-api-client.js +406 -4
- package/build/conductor/doctor.js +255 -53
- package/build/conductor/epic-runtime.js +11 -2
- package/build/conductor/install-doctor.js +24 -2
- package/build/conductor/local-merge.js +319 -26
- package/build/conductor/merge-execution-contract.js +86 -0
- package/build/conductor/merge-execution-eligibility.js +130 -0
- package/build/conductor/readiness-sections.js +256 -16
- package/build/conductor/readiness.js +203 -29
- package/build/conductor/recovery-operations.js +8 -2
- package/build/connect-bitbucket-api.js +6 -1
- package/build/connect-bitbucket.js +398 -88
- package/build/credential-store.js +172 -0
- package/build/docs.generated.js +2 -2
- package/build/doctor.js +277 -15
- package/build/drive-epic.js +147 -12
- package/build/epic-implementer-bin.js +15 -15
- package/build/epic-implementer-claude-hook-bin.js +1 -1
- package/build/epic-implementer-env.js +43 -14
- package/build/epic-integration-pr.js +61 -15
- package/build/epic-launch-profile.js +455 -0
- package/build/executor/ci-rerun-job.js +269 -0
- package/build/executor/cli.js +40 -0
- package/build/executor/credentials.js +28 -1
- package/build/executor/env.js +60 -0
- package/build/executor/http-client.js +45 -1
- package/build/executor/index.js +21 -4
- package/build/executor/integration-job.js +759 -0
- package/build/executor/job-runner.js +218 -9
- package/build/executor/job-types.js +12 -2
- package/build/executor/merge-job.js +11 -2
- package/build/executor/observation.js +114 -7
- package/build/executor/runner.js +101 -1
- package/build/executor/service-lifecycle.js +136 -0
- package/build/executor/service-unit.js +427 -9
- package/build/executor/types.js +48 -0
- package/build/index.js +479 -240
- package/build/install-bridge-conductor.js +599 -14
- package/build/install-bridge.js +448 -46
- package/build/mcp-install-state.js +42 -4
- package/build/merge-pull-request.js +103 -10
- package/build/merge-workflow-codeowners.js +224 -0
- package/build/merge-workflow-config.js +187 -0
- package/build/merge-workflow-drift.js +97 -0
- package/build/merge-workflow-install.js +638 -0
- package/build/merge-workflow.js +956 -0
- package/build/pipelines.generated.js +3 -3
- package/build/plane/cli.js +24 -2
- package/build/plane/preflight.js +68 -0
- package/build/plane/supervisor.js +6 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +5 -0
- package/build/setup-epic.js +263 -38
- package/build/version.generated.js +3 -3
- package/package.json +3 -3
- package/build/conduct-epic/bridge-client.js +0 -773
- package/build/conduct-epic/checkpoint-store.js +0 -542
- package/build/conduct-epic/cli.js +0 -3145
- package/build/conduct-epic/cut-protocol.js +0 -392
- package/build/conduct-epic/lock.js +0 -302
- package/build/conduct-epic/pr-state.js +0 -286
- package/build/conduct-epic/spawn.js +0 -113
- package/build/conductor/cli.js +0 -1278
- package/build/conductor-bundle-cli.js +0 -256
- package/build/direct-ticket-tools.js +0 -70
- package/build/executor/reconcile-overlap-governance.js +0 -129
- package/build/implement-epic/bridge-client.js +0 -773
- package/build/implement-epic/checkpoint-store.js +0 -542
- package/build/implement-epic/cli.js +0 -3158
- package/build/implement-epic/cut-protocol.js +0 -392
- package/build/implement-epic/lock.js +0 -302
- package/build/implement-epic/pr-state.js +0 -286
- package/build/implement-epic/spawn.js +0 -113
- package/build/plan-epic-conductor-eligibility.js +0 -213
|
@@ -31,6 +31,27 @@
|
|
|
31
31
|
* Subprocess safety: argument arrays (never a shell string), a validated numeric
|
|
32
32
|
* PR number, a merge method from a fixed allowlist, `GH_PROMPT_DISABLED` to avoid
|
|
33
33
|
* interactive hangs, and no token/secret ever logged. Default OFF everywhere.
|
|
34
|
+
*
|
|
35
|
+
* BAPI-1136 de-personalization:
|
|
36
|
+
* - The merge no longer authenticates as whoever happens to be logged into `gh`
|
|
37
|
+
* on the machine running this process. It authenticates with ONE explicitly
|
|
38
|
+
* configured, customer-held scoped credential — either a configured local
|
|
39
|
+
* credential or a machine-user credential — supplied through
|
|
40
|
+
* {@link LocalMergeDeps.credential}. Bridge never stores either one.
|
|
41
|
+
* - The child environment is now built from an ALLOWLIST rather than inherited
|
|
42
|
+
* wholesale. `GH_TOKEN`, `GITHUB_TOKEN`, and both Bridge configuration keys
|
|
43
|
+
* are excluded from the inherited base, so an ambient login cannot leak in
|
|
44
|
+
* through the back door after the front door was closed.
|
|
45
|
+
* - No credential means no privileged subprocess. The guard lives inside the
|
|
46
|
+
* shared executor, so an overlooked caller fails closed with a named reason
|
|
47
|
+
* instead of running `gh` unauthenticated.
|
|
48
|
+
* - Captured stdout/stderr is sanitized before it leaves the runner, so a `gh`
|
|
49
|
+
* message that echoes an environment assignment or a token cannot reach a
|
|
50
|
+
* ledger event, an MCP envelope, or a dry-run serialization.
|
|
51
|
+
*
|
|
52
|
+
* The decision ladder itself is UNCHANGED: which credential authenticates the
|
|
53
|
+
* subprocess is a different question from what the executor decides, and this
|
|
54
|
+
* module answers only the first.
|
|
34
55
|
*/
|
|
35
56
|
import { spawn } from "child_process";
|
|
36
57
|
import { pollCiChecksForCommit, ConductorBridgeApiError, safeDiagnosticMessage, } from "./bridge-api-client.js";
|
|
@@ -91,6 +112,163 @@ export function resolveLocalMergeMethod(value) {
|
|
|
91
112
|
? value
|
|
92
113
|
: "squash";
|
|
93
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* The one process-launch configuration key each credential kind reads.
|
|
117
|
+
*
|
|
118
|
+
* There is NO fallback between them and no fallback to anything else: not to an
|
|
119
|
+
* inherited `GH_TOKEN` or `GITHUB_TOKEN`, not to a `gh auth` session, and not to
|
|
120
|
+
* the Bridge credential store. A merge mode that silently borrowed the other
|
|
121
|
+
* mode's credential would make "which identity performed this write" unanswerable
|
|
122
|
+
* from the run's own configuration, which is the property this whole change
|
|
123
|
+
* exists to establish.
|
|
124
|
+
*
|
|
125
|
+
* Neither key is ever routed through `resolveBapiCredentials`,
|
|
126
|
+
* `resolveCredentialBundle`, project configuration, or install state. A GitHub
|
|
127
|
+
* merge credential is not a Bridge credential and must not acquire Bridge's
|
|
128
|
+
* persistence.
|
|
129
|
+
*/
|
|
130
|
+
export const LOCAL_MERGE_CREDENTIAL_ENV_KEYS = {
|
|
131
|
+
local: "BAPI_LOCAL_MERGE_TOKEN",
|
|
132
|
+
machine_token: "BAPI_MACHINE_MERGE_TOKEN",
|
|
133
|
+
};
|
|
134
|
+
/** The named, mode-specific refusal when the configured credential is absent. */
|
|
135
|
+
export const LOCAL_MERGE_CREDENTIAL_MISSING_REASONS = {
|
|
136
|
+
local: "local_merge_credential_missing",
|
|
137
|
+
machine_token: "machine_merge_credential_missing",
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* Resolve the one credential a kind is allowed to read.
|
|
141
|
+
*
|
|
142
|
+
* Returns `null` for an absent or blank value — never the other kind's value and
|
|
143
|
+
* never an inherited one.
|
|
144
|
+
*/
|
|
145
|
+
export function resolveLocalMergeCredential(env, kind) {
|
|
146
|
+
const raw = env[LOCAL_MERGE_CREDENTIAL_ENV_KEYS[kind]];
|
|
147
|
+
if (typeof raw !== "string")
|
|
148
|
+
return null;
|
|
149
|
+
const token = raw.trim();
|
|
150
|
+
if (token.length === 0)
|
|
151
|
+
return null;
|
|
152
|
+
return { kind, token };
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Environment variables the `gh` child is allowed to inherit.
|
|
156
|
+
*
|
|
157
|
+
* An allowlist, not a denylist. `gh` needs a working PATH, a home directory, a
|
|
158
|
+
* temporary directory, a locale, certificate configuration, and proxy settings;
|
|
159
|
+
* it needs nothing else this process happens to be carrying. Inheriting the
|
|
160
|
+
* whole environment is how an ambient login survives every attempt to remove it,
|
|
161
|
+
* and it is also how an unrelated secret ends up in a subprocess that writes to
|
|
162
|
+
* a public repository.
|
|
163
|
+
*/
|
|
164
|
+
const CHILD_ENV_ALLOWLIST = [
|
|
165
|
+
// Resolution and platform basics.
|
|
166
|
+
"PATH",
|
|
167
|
+
"Path",
|
|
168
|
+
"PATHEXT",
|
|
169
|
+
"HOME",
|
|
170
|
+
"USERPROFILE",
|
|
171
|
+
"SystemRoot",
|
|
172
|
+
"SYSTEMROOT",
|
|
173
|
+
"windir",
|
|
174
|
+
"COMSPEC",
|
|
175
|
+
"ComSpec",
|
|
176
|
+
// Temporary directories.
|
|
177
|
+
"TMPDIR",
|
|
178
|
+
"TEMP",
|
|
179
|
+
"TMP",
|
|
180
|
+
// Locale.
|
|
181
|
+
"LANG",
|
|
182
|
+
"LC_ALL",
|
|
183
|
+
"LC_CTYPE",
|
|
184
|
+
// TLS trust.
|
|
185
|
+
"SSL_CERT_FILE",
|
|
186
|
+
"SSL_CERT_DIR",
|
|
187
|
+
"CURL_CA_BUNDLE",
|
|
188
|
+
"NODE_EXTRA_CA_CERTS",
|
|
189
|
+
// Proxies.
|
|
190
|
+
"HTTP_PROXY",
|
|
191
|
+
"HTTPS_PROXY",
|
|
192
|
+
"NO_PROXY",
|
|
193
|
+
"http_proxy",
|
|
194
|
+
"https_proxy",
|
|
195
|
+
"no_proxy",
|
|
196
|
+
];
|
|
197
|
+
/**
|
|
198
|
+
* Keys that must never reach the child from an inherited environment, even if a
|
|
199
|
+
* future edit widens the allowlist above.
|
|
200
|
+
*
|
|
201
|
+
* `GH_TOKEN` appears here AND is injected explicitly below. That is not a
|
|
202
|
+
* contradiction: the inherited one is an ambient identity and is dropped, while
|
|
203
|
+
* the injected one is the resolved credential this run was configured with.
|
|
204
|
+
*/
|
|
205
|
+
const CHILD_ENV_DENYLIST = new Set([
|
|
206
|
+
"GH_TOKEN",
|
|
207
|
+
"GITHUB_TOKEN",
|
|
208
|
+
"BAPI_LOCAL_MERGE_TOKEN",
|
|
209
|
+
"BAPI_MACHINE_MERGE_TOKEN",
|
|
210
|
+
]);
|
|
211
|
+
/**
|
|
212
|
+
* Build the complete `gh` child environment.
|
|
213
|
+
*
|
|
214
|
+
* Exactly three sources, in this order: the allowlisted operational base, the
|
|
215
|
+
* non-interactive pins, and the one resolved credential as `GH_TOKEN`. A caller
|
|
216
|
+
* cannot add a fourth — `extra` is filtered through the same denylist, so an
|
|
217
|
+
* overlooked call site passing `{ ...process.env }` or an explicit
|
|
218
|
+
* `GITHUB_TOKEN` contributes neither.
|
|
219
|
+
*/
|
|
220
|
+
export function buildLocalMergeChildEnv(base, credential, extra = {}) {
|
|
221
|
+
const child = {};
|
|
222
|
+
for (const key of CHILD_ENV_ALLOWLIST) {
|
|
223
|
+
if (CHILD_ENV_DENYLIST.has(key))
|
|
224
|
+
continue;
|
|
225
|
+
const value = base[key];
|
|
226
|
+
if (typeof value === "string")
|
|
227
|
+
child[key] = value;
|
|
228
|
+
}
|
|
229
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
230
|
+
if (CHILD_ENV_DENYLIST.has(key))
|
|
231
|
+
continue;
|
|
232
|
+
if (typeof value === "string")
|
|
233
|
+
child[key] = value;
|
|
234
|
+
}
|
|
235
|
+
// The pins come after `extra` so a caller cannot re-enable an interactive
|
|
236
|
+
// prompt that would hang a merge nobody is watching.
|
|
237
|
+
child.GH_PROMPT_DISABLED = "1";
|
|
238
|
+
child.GH_NO_UPDATE_NOTIFIER = "1";
|
|
239
|
+
if (credential)
|
|
240
|
+
child.GH_TOKEN = credential.token;
|
|
241
|
+
return child;
|
|
242
|
+
}
|
|
243
|
+
/** Fixed replacement for anything redacted out of captured output. */
|
|
244
|
+
const REDACTED = "[redacted]";
|
|
245
|
+
/**
|
|
246
|
+
* Strip credential-shaped material from captured subprocess output.
|
|
247
|
+
*
|
|
248
|
+
* Applied before a {@link LocalCommandResult} leaves the runner, so every
|
|
249
|
+
* downstream consumer — ledger events, MCP envelopes, dry-run serializations,
|
|
250
|
+
* diagnostic strings — is working from already-sanitized text rather than each
|
|
251
|
+
* remembering to sanitize for itself.
|
|
252
|
+
*
|
|
253
|
+
* Two passes. First, any `KEY=value` assignment for a known credential key,
|
|
254
|
+
* because `gh` and git both echo environment in some error paths. Second, the
|
|
255
|
+
* literal credential values this run holds, which catches a token echoed without
|
|
256
|
+
* its key.
|
|
257
|
+
*/
|
|
258
|
+
export function sanitizeLocalMergeOutput(text, credentials = []) {
|
|
259
|
+
if (typeof text !== "string" || text.length === 0)
|
|
260
|
+
return "";
|
|
261
|
+
let out = text;
|
|
262
|
+
for (const key of CHILD_ENV_DENYLIST) {
|
|
263
|
+
out = out.split(new RegExp(`${key}=\\S*`, "g")).join(`${key}=${REDACTED}`);
|
|
264
|
+
}
|
|
265
|
+
for (const value of credentials) {
|
|
266
|
+
if (typeof value === "string" && value.length >= 8) {
|
|
267
|
+
out = out.split(value).join(REDACTED);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
94
272
|
/**
|
|
95
273
|
* Async, abort-aware default command runner. Uses `spawn` (never a synchronous
|
|
96
274
|
* runner) so a slow/hung `gh` does not block the Node event loop — the heartbeat
|
|
@@ -100,12 +278,18 @@ export function resolveLocalMergeMethod(value) {
|
|
|
100
278
|
* never logs raw stdout/stderr.
|
|
101
279
|
*/
|
|
102
280
|
async function defaultRunCommand(cmd, args, env, signal) {
|
|
281
|
+
// BAPI-1136: `env` arrives ALREADY built by `buildLocalMergeChildEnv` and is
|
|
282
|
+
// the complete child environment. The former `{ ...process.env, ...env }`
|
|
283
|
+
// merge is gone — it re-inherited the ambient identity the caller had just
|
|
284
|
+
// finished excluding, which made every de-personalization upstream cosmetic.
|
|
285
|
+
const childEnv = env ?? buildLocalMergeChildEnv(process.env, null);
|
|
286
|
+
const credentialValues = [childEnv.GH_TOKEN];
|
|
103
287
|
return await new Promise((resolve) => {
|
|
104
288
|
let settled = false;
|
|
105
289
|
let timedOut = false;
|
|
106
290
|
let stdout = "";
|
|
107
291
|
let stderr = "";
|
|
108
|
-
const child = spawn(cmd, args, { env:
|
|
292
|
+
const child = spawn(cmd, args, { env: childEnv });
|
|
109
293
|
const killChild = () => {
|
|
110
294
|
try {
|
|
111
295
|
child.kill("SIGKILL");
|
|
@@ -129,7 +313,13 @@ async function defaultRunCommand(cmd, args, env, signal) {
|
|
|
129
313
|
clearTimeout(timer);
|
|
130
314
|
if (signal)
|
|
131
315
|
signal.removeEventListener("abort", onAbort);
|
|
132
|
-
|
|
316
|
+
// Sanitize HERE, at the single point every path resolves through, so no
|
|
317
|
+
// downstream consumer has to remember to do it.
|
|
318
|
+
resolve({
|
|
319
|
+
...result,
|
|
320
|
+
stdout: sanitizeLocalMergeOutput(result.stdout, credentialValues),
|
|
321
|
+
stderr: sanitizeLocalMergeOutput(result.stderr, credentialValues),
|
|
322
|
+
});
|
|
133
323
|
};
|
|
134
324
|
if (signal) {
|
|
135
325
|
if (signal.aborted)
|
|
@@ -486,13 +676,11 @@ export function makeLocalMergeExecutor(options = {}, deps = {}) {
|
|
|
486
676
|
const signal = deps.signal;
|
|
487
677
|
const ciWaitTimeoutMs = sanitizeWaitMs(options.ciWaitTimeoutMs, DEFAULT_CI_WAIT_TIMEOUT_MS);
|
|
488
678
|
const ciWaitPollIntervalMs = sanitizeWaitMs(options.ciWaitPollIntervalMs, DEFAULT_CI_WAIT_POLL_INTERVAL_MS);
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
GH_NO_UPDATE_NOTIFIER: "1",
|
|
495
|
-
};
|
|
679
|
+
// BAPI-1136: an allowlisted operational base plus exactly one resolved
|
|
680
|
+
// credential. The caller's environment contributes PATH and friends; it
|
|
681
|
+
// contributes no identity.
|
|
682
|
+
const credential = deps.credential ?? null;
|
|
683
|
+
const ghEnv = buildLocalMergeChildEnv(deps.env ?? process.env, credential);
|
|
496
684
|
return async (access, request) => {
|
|
497
685
|
const pr = request.pr_number;
|
|
498
686
|
const expectedSha = request.expected_head_sha;
|
|
@@ -523,6 +711,15 @@ export function makeLocalMergeExecutor(options = {}, deps = {}) {
|
|
|
523
711
|
{ type: "merge.failed", status: "failed", reason, details },
|
|
524
712
|
]);
|
|
525
713
|
};
|
|
714
|
+
// BAPI-1136, defense in depth. The credential is resolved by the CALLER, and
|
|
715
|
+
// every production caller now does so explicitly — but a caller that forgot
|
|
716
|
+
// must not reach a privileged `gh` subprocess unauthenticated, silently
|
|
717
|
+
// falling back to whatever identity the runner happens to carry. Refusing
|
|
718
|
+
// here, before the first read, makes that structural rather than a
|
|
719
|
+
// convention every future call site has to remember.
|
|
720
|
+
if (!credential) {
|
|
721
|
+
return fail(LOCAL_MERGE_CREDENTIAL_MISSING_REASONS.local);
|
|
722
|
+
}
|
|
526
723
|
/**
|
|
527
724
|
* Re-check the merged state after an AMBIGUOUS `gh` failure (a `gh` timeout,
|
|
528
725
|
* an unparseable/failed view, a CI poll failure, or a generic non-conflict
|
|
@@ -784,15 +981,79 @@ function buildRefMismatchResponse(request, baseDetails, mismatch) {
|
|
|
784
981
|
{ type: "merge.failed", status: "failed", reason, details },
|
|
785
982
|
]);
|
|
786
983
|
}
|
|
984
|
+
// --------------------------------------------------------------------------- //
|
|
985
|
+
// BAPI-832: local execution of a server-approved merge
|
|
986
|
+
// --------------------------------------------------------------------------- //
|
|
987
|
+
/** A provider repository identity: exactly one `/`, no whitespace or globbing. */
|
|
988
|
+
const OWNER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
787
989
|
/**
|
|
788
|
-
*
|
|
789
|
-
* attempted.
|
|
990
|
+
* Resolve the `owner/name` the permission probe should ask about.
|
|
790
991
|
*
|
|
791
|
-
*
|
|
792
|
-
*
|
|
793
|
-
*
|
|
794
|
-
*
|
|
795
|
-
*
|
|
992
|
+
* Prefers a caller-supplied identity, falls back to asking `gh` which repository
|
|
993
|
+
* it resolves here, and returns `null` when neither produces a well-formed
|
|
994
|
+
* value. `null` SKIPS the probe rather than failing it: not knowing which
|
|
995
|
+
* repository to ask about is not evidence that the credential lacks permission,
|
|
996
|
+
* and refusing on it would break every host whose working directory is not a
|
|
997
|
+
* checkout. The merge's own guards still refuse an actually-unpermitted token —
|
|
998
|
+
* only the finer diagnosis is unavailable.
|
|
999
|
+
*/
|
|
1000
|
+
async function resolveProbeRepository(run, ghEnv, supplied) {
|
|
1001
|
+
if (typeof supplied === "string" && OWNER_NAME_PATTERN.test(supplied.trim())) {
|
|
1002
|
+
return supplied.trim();
|
|
1003
|
+
}
|
|
1004
|
+
let view;
|
|
1005
|
+
try {
|
|
1006
|
+
view = await run("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], ghEnv);
|
|
1007
|
+
}
|
|
1008
|
+
catch {
|
|
1009
|
+
return null;
|
|
1010
|
+
}
|
|
1011
|
+
if (view.status !== 0)
|
|
1012
|
+
return null;
|
|
1013
|
+
const resolved = view.stdout.trim();
|
|
1014
|
+
return OWNER_NAME_PATTERN.test(resolved) ? resolved : null;
|
|
1015
|
+
}
|
|
1016
|
+
/** Mode-specific reason triples, so no call site re-derives a spelling. */
|
|
1017
|
+
const PREFLIGHT_REASONS = {
|
|
1018
|
+
local: {
|
|
1019
|
+
missing: "local_merge_credential_missing",
|
|
1020
|
+
invalid: "local_merge_credential_invalid",
|
|
1021
|
+
unpermitted: "local_merge_credential_unpermitted",
|
|
1022
|
+
},
|
|
1023
|
+
machine_token: {
|
|
1024
|
+
missing: "machine_merge_credential_missing",
|
|
1025
|
+
invalid: "machine_merge_credential_invalid",
|
|
1026
|
+
unpermitted: "machine_merge_credential_unpermitted",
|
|
1027
|
+
},
|
|
1028
|
+
};
|
|
1029
|
+
/**
|
|
1030
|
+
* Probe whether this host can merge with the CONFIGURED credential, before any
|
|
1031
|
+
* merge is attempted.
|
|
1032
|
+
*
|
|
1033
|
+
* Without this the operator-fixable failures are invisible: a missing `gh`, an
|
|
1034
|
+
* unconfigured credential, an expired one, and one that authenticates but lacks
|
|
1035
|
+
* write access to this repository all make `gh pr view` exit non-zero, which
|
|
1036
|
+
* this module would otherwise report as `gh_pr_view_failed` — a reason that
|
|
1037
|
+
* names no fix.
|
|
1038
|
+
*
|
|
1039
|
+
* BAPI-1136 changed the QUESTION this asks. It used to ask "is the ambient `gh`
|
|
1040
|
+
* logged in", which is the wrong question once the merge no longer uses an
|
|
1041
|
+
* ambient identity: a host with a logged-in operator and no configured
|
|
1042
|
+
* credential would pass a probe and then fail the merge. It now asks "is the
|
|
1043
|
+
* credential this run was configured with usable HERE, for THIS repository",
|
|
1044
|
+
* which is the question whose answer predicts the merge.
|
|
1045
|
+
*
|
|
1046
|
+
* Four outcomes, each fail-closed:
|
|
1047
|
+
*
|
|
1048
|
+
* 1. `gh` cannot be invoked -> `local_gh_unavailable`.
|
|
1049
|
+
* 2. No credential configured -> the kind's `*_credential_missing`, returned
|
|
1050
|
+
* BEFORE any privileged subprocess runs.
|
|
1051
|
+
* 3. `gh auth status` rejects the credential -> the kind's `*_credential_invalid`.
|
|
1052
|
+
* 4. The repository permission probe does not prove write access -> the kind's
|
|
1053
|
+
* `*_credential_unpermitted`.
|
|
1054
|
+
*
|
|
1055
|
+
* A network failure or an unparseable capability response is bounded and lands
|
|
1056
|
+
* on (4) rather than passing: an unprovable permission is not permission.
|
|
796
1057
|
*
|
|
797
1058
|
* Deliberately async and built on the same `run` seam as the merge itself. The
|
|
798
1059
|
* read-only equivalent in `conductor/doctor.ts` (`inspectLocalMerge`) probes
|
|
@@ -801,7 +1062,8 @@ function buildRefMismatchResponse(request, baseDetails, mismatch) {
|
|
|
801
1062
|
* (This module's static contract forbids synchronous process execution outright,
|
|
802
1063
|
* so the token itself cannot appear here — not even in a comment.)
|
|
803
1064
|
*/
|
|
804
|
-
export async function preflightLocalGh(run, ghEnv) {
|
|
1065
|
+
export async function preflightLocalGh(run, ghEnv, context = { kind: "local" }) {
|
|
1066
|
+
const reasons = PREFLIGHT_REASONS[context.kind] ?? PREFLIGHT_REASONS.local;
|
|
805
1067
|
let version;
|
|
806
1068
|
try {
|
|
807
1069
|
version = await run("gh", ["--version"], ghEnv);
|
|
@@ -811,15 +1073,41 @@ export async function preflightLocalGh(run, ghEnv) {
|
|
|
811
1073
|
}
|
|
812
1074
|
if (version.status !== 0)
|
|
813
1075
|
return { ok: false, reason: "local_gh_unavailable" };
|
|
1076
|
+
// The credential check comes AFTER the capability check and BEFORE every
|
|
1077
|
+
// privileged call: "gh is missing" and "gh is fine but nothing configured it"
|
|
1078
|
+
// are different fixes, and neither should cost an authenticated request.
|
|
1079
|
+
const credentialPresent = context.credentialPresent ?? (typeof ghEnv.GH_TOKEN === "string" && ghEnv.GH_TOKEN.length > 0);
|
|
1080
|
+
if (!credentialPresent)
|
|
1081
|
+
return { ok: false, reason: reasons.missing };
|
|
814
1082
|
let auth;
|
|
815
1083
|
try {
|
|
816
1084
|
auth = await run("gh", ["auth", "status"], ghEnv);
|
|
817
1085
|
}
|
|
818
1086
|
catch {
|
|
819
|
-
return { ok: false, reason:
|
|
1087
|
+
return { ok: false, reason: reasons.invalid };
|
|
820
1088
|
}
|
|
821
1089
|
if (auth.status !== 0)
|
|
822
|
-
return { ok: false, reason:
|
|
1090
|
+
return { ok: false, reason: reasons.invalid };
|
|
1091
|
+
// A credential can authenticate and still be unable to merge — a read-scoped
|
|
1092
|
+
// token, or a machine user who was never granted write on this repository.
|
|
1093
|
+
// That distinction is the difference between "rotate the token" and "grant the
|
|
1094
|
+
// account access", so it is worth one read-only request to establish.
|
|
1095
|
+
const repository = await resolveProbeRepository(run, ghEnv, context.repository);
|
|
1096
|
+
if (repository !== null) {
|
|
1097
|
+
let probe;
|
|
1098
|
+
try {
|
|
1099
|
+
probe = await run("gh", ["api", `repos/${repository}`, "--jq", ".permissions.push"], ghEnv);
|
|
1100
|
+
}
|
|
1101
|
+
catch {
|
|
1102
|
+
return { ok: false, reason: reasons.unpermitted };
|
|
1103
|
+
}
|
|
1104
|
+
if (probe.status !== 0)
|
|
1105
|
+
return { ok: false, reason: reasons.unpermitted };
|
|
1106
|
+
// Bounded parse. Anything that is not an explicit `true` fails closed; raw
|
|
1107
|
+
// `gh` output is never surfaced, only this verdict.
|
|
1108
|
+
if (probe.stdout.trim() !== "true")
|
|
1109
|
+
return { ok: false, reason: reasons.unpermitted };
|
|
1110
|
+
}
|
|
823
1111
|
return { ok: true };
|
|
824
1112
|
}
|
|
825
1113
|
/**
|
|
@@ -840,14 +1128,19 @@ export async function preflightLocalGh(run, ghEnv) {
|
|
|
840
1128
|
* a request built from a live approval for that exact PR and head SHA.
|
|
841
1129
|
*/
|
|
842
1130
|
export async function runApprovedLocalMerge(request, options = {}, deps = {}) {
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
};
|
|
1131
|
+
// BAPI-1136: one allowlisted child environment carrying exactly one resolved
|
|
1132
|
+
// credential — never the caller's environment spread in wholesale.
|
|
1133
|
+
const credential = deps.credential ?? null;
|
|
1134
|
+
const ghEnv = buildLocalMergeChildEnv(deps.env ?? process.env, credential);
|
|
848
1135
|
const rawRun = deps.runCommand ?? defaultRunCommand;
|
|
849
1136
|
const run = (cmd, args, env) => Promise.resolve(rawRun(cmd, args, env, deps.signal));
|
|
850
|
-
|
|
1137
|
+
// `request.repo_name` is Bridge's own repository name, NOT `owner/name`, so it
|
|
1138
|
+
// is deliberately not passed here — the probe resolves the provider identity
|
|
1139
|
+
// itself, or skips.
|
|
1140
|
+
const preflight = await preflightLocalGh(run, ghEnv, {
|
|
1141
|
+
kind: credential?.kind ?? "local",
|
|
1142
|
+
credentialPresent: credential !== null,
|
|
1143
|
+
});
|
|
851
1144
|
if (!preflight.ok) {
|
|
852
1145
|
return buildResponse(request, "failed", preflight.reason, false, [
|
|
853
1146
|
{
|
|
@@ -864,7 +1157,7 @@ export async function runApprovedLocalMerge(request, options = {}, deps = {}) {
|
|
|
864
1157
|
},
|
|
865
1158
|
]);
|
|
866
1159
|
}
|
|
867
|
-
const executor = makeLocalMergeExecutor({ method: options.method, skipCiWait: true }, deps);
|
|
1160
|
+
const executor = makeLocalMergeExecutor({ method: options.method, skipCiWait: true }, { ...deps, credential });
|
|
868
1161
|
// `access` is only ever used by the CI poll, which `skipCiWait` bypasses. The
|
|
869
1162
|
// throwing stub makes that structural, not a comment: if a future edit ever
|
|
870
1163
|
// reaches the poll from this path, it fails loudly in tests instead of
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The TypeScript MIRROR of the shared merge-execution contract (BAPI-1135).
|
|
3
|
+
*
|
|
4
|
+
* The Python original is `api/library/epic_implementer/merge_execution_contract.py`
|
|
5
|
+
* (plus `pre_create_refusal.py` for the refusal code). The two runtimes do not
|
|
6
|
+
* share code, so the contract is mirrored VALUE FOR VALUE here and every
|
|
7
|
+
* spelling below must match its Python twin byte for byte.
|
|
8
|
+
*
|
|
9
|
+
* ## Why literals, not client aliases
|
|
10
|
+
*
|
|
11
|
+
* Each constant is an exported literal rather than a re-export of a parsed
|
|
12
|
+
* server field. A refusal message built from a server-echoed string would be a
|
|
13
|
+
* second spelling the moment the server stopped echoing it; a message built from
|
|
14
|
+
* THIS constant is the same string the server's own refusal carries. The closed
|
|
15
|
+
* refusal-reason union in `setup-epic.ts` is likewise derived from
|
|
16
|
+
* {@link MERGE_EXECUTION_INELIGIBLE} rather than re-typed, so the union cannot
|
|
17
|
+
* drift through a third handwritten spelling.
|
|
18
|
+
*
|
|
19
|
+
* ## What this module does not do
|
|
20
|
+
*
|
|
21
|
+
* It performs no probe, no repository read, and no dispatch. Every fact it is
|
|
22
|
+
* compared against arrives from the SERVER's readiness report. A client that
|
|
23
|
+
* inspected a local checkout to decide merge eligibility would be answering a
|
|
24
|
+
* different question from the one the server's before-merge preflight answers.
|
|
25
|
+
*
|
|
26
|
+
* ## Deferred remedy
|
|
27
|
+
*
|
|
28
|
+
* {@link MERGE_WORKFLOW_REMEDY_COMMAND} names the generate-and-PR command epic
|
|
29
|
+
* child 4 ships. Until child 4 lands, a refusal may name a command that is not
|
|
30
|
+
* yet an executable entry point on the operator's machine. That residual risk,
|
|
31
|
+
* its three containments, and who owns escalation are recorded once, in the
|
|
32
|
+
* Python module's docstring; this mirror does not restate them.
|
|
33
|
+
*/
|
|
34
|
+
/** The one `RunPolicy.merge_execution_mode` value this contract governs. */
|
|
35
|
+
export const MERGE_EXECUTION_MODE_GITHUB_ACTIONS = "github_actions";
|
|
36
|
+
/**
|
|
37
|
+
* The server-owned pre-create refusal code.
|
|
38
|
+
*
|
|
39
|
+
* Mirrors `api/library/epic_implementer/pre_create_refusal.py`. The client adds
|
|
40
|
+
* remediation TEXT around this code and invents no alias.
|
|
41
|
+
*/
|
|
42
|
+
export const MERGE_EXECUTION_INELIGIBLE = "merge_execution_ineligible";
|
|
43
|
+
/** The canonical merge workflow's repository-relative path on the default branch. */
|
|
44
|
+
export const MERGE_WORKFLOW_PATH = ".github/workflows/epic-implementer-merge.yml";
|
|
45
|
+
/** The workflow's stable file-name identifier, as the Actions API accepts it. */
|
|
46
|
+
export const MERGE_WORKFLOW_IDENTIFIER = "epic-implementer-merge.yml";
|
|
47
|
+
/** The top-level workflow key carrying the machine-readable contract version. */
|
|
48
|
+
export const MERGE_WORKFLOW_VERSION_MARKER_KEY = "x-bridge-merge-contract-version";
|
|
49
|
+
/**
|
|
50
|
+
* The workflow contract version this CLI RENDERS and installs.
|
|
51
|
+
*
|
|
52
|
+
* BAPI-1259 moved it from `1` to `2` (the integration-action job and the
|
|
53
|
+
* `operation` dispatch input). Whether an INSTALLED version is usable is
|
|
54
|
+
* answered by the two closed sets below — never by "at least". A newer version
|
|
55
|
+
* is a workflow this build has never been taught to reason about, so it is NOT
|
|
56
|
+
* eligible — see {@link classifyMergeExecutionEligibility}.
|
|
57
|
+
*/
|
|
58
|
+
export const MERGE_WORKFLOW_EXPECTED_VERSION = 2;
|
|
59
|
+
/**
|
|
60
|
+
* Installed contract versions a CHILD merge may be dispatched to. Mirrors
|
|
61
|
+
* `MERGE_WORKFLOW_CHILD_MERGE_COMPATIBLE_VERSIONS` in the Python contract.
|
|
62
|
+
* Version 1's child-merge job is the behavior version 2 keeps unchanged.
|
|
63
|
+
*/
|
|
64
|
+
export const MERGE_WORKFLOW_CHILD_MERGE_COMPATIBLE_VERSIONS = [1, 2];
|
|
65
|
+
/**
|
|
66
|
+
* Installed contract versions an INTEGRATION action may be dispatched to.
|
|
67
|
+
* Mirrors `INTEGRATION_ACTION_WORKFLOW_VERSIONS` in the Python contract.
|
|
68
|
+
*/
|
|
69
|
+
export const INTEGRATION_ACTION_WORKFLOW_VERSIONS = [2];
|
|
70
|
+
/** True when an installed marker may receive a child-merge dispatch. */
|
|
71
|
+
export function mergeWorkflowVersionSupportsChildMerge(version) {
|
|
72
|
+
return (typeof version === "number" &&
|
|
73
|
+
Number.isInteger(version) &&
|
|
74
|
+
MERGE_WORKFLOW_CHILD_MERGE_COMPATIBLE_VERSIONS.includes(version));
|
|
75
|
+
}
|
|
76
|
+
/** True when an installed marker may receive an integration-action dispatch. */
|
|
77
|
+
export function mergeWorkflowVersionSupportsIntegrationActions(version) {
|
|
78
|
+
return (typeof version === "number" &&
|
|
79
|
+
Number.isInteger(version) &&
|
|
80
|
+
INTEGRATION_ACTION_WORKFLOW_VERSIONS.includes(version));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The single command that generates the merge workflow and opens its pull
|
|
84
|
+
* request. Every refusal, remediation, and readiness line imports this constant.
|
|
85
|
+
*/
|
|
86
|
+
export const MERGE_WORKFLOW_REMEDY_COMMAND = "install-bridge conductor --merge-workflow";
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE client-side merge-execution eligibility classifier (BAPI-1135).
|
|
3
|
+
*
|
|
4
|
+
* Three surfaces act on repository merge eligibility — the readiness sections,
|
|
5
|
+
* the `drive-epic` v2 readiness requirement, and the `setup-epic` pre-create
|
|
6
|
+
* refusal. If each read the server block itself they would drift, and an
|
|
7
|
+
* operator told "ready" by one and refused by another would have no way to tell
|
|
8
|
+
* which was right. So the classification lives here and each surface only
|
|
9
|
+
* renders it, exactly as `bring-up-facts.ts` does for the bring-up facts.
|
|
10
|
+
*
|
|
11
|
+
* PURE. No probe, network call, filesystem access, or spawn. It reads an
|
|
12
|
+
* already-parsed readiness response and returns a closed verdict. In particular
|
|
13
|
+
* it never inspects a local checkout: a client that decided merge eligibility
|
|
14
|
+
* from local files would be answering a different question from the one the
|
|
15
|
+
* server's before-merge preflight answers.
|
|
16
|
+
*
|
|
17
|
+
* ## The three decisive states, and why unknown is its own state
|
|
18
|
+
*
|
|
19
|
+
* `eligible` / `not_eligible` / `could_not_verify`. The third is not a
|
|
20
|
+
* convenience — it is the whole safety property. "The workflow is absent" and
|
|
21
|
+
* "the probe failed" produce the same *blocking* outcome but completely
|
|
22
|
+
* different operator actions: one installs a workflow, the other fixes
|
|
23
|
+
* credentials or connectivity. Collapsing them sends half of all operators to do
|
|
24
|
+
* work that changes nothing. `could_not_verify` is never treated as eligible.
|
|
25
|
+
*
|
|
26
|
+
* ## Known versions only, always
|
|
27
|
+
*
|
|
28
|
+
* Eligibility requires membership in the closed set
|
|
29
|
+
* {@link MERGE_WORKFLOW_CHILD_MERGE_COMPATIBLE_VERSIONS} (BAPI-1259: version 1
|
|
30
|
+
* and version 2 both carry the unchanged child-merge job). A NEWER version is
|
|
31
|
+
* not eligible: it is a workflow this build has never been taught to reason
|
|
32
|
+
* about, and "newer is probably fine" is a guess about a merge actuator.
|
|
33
|
+
*/
|
|
34
|
+
import { MERGE_WORKFLOW_CHILD_MERGE_COMPATIBLE_VERSIONS, MERGE_WORKFLOW_REMEDY_COMMAND, mergeWorkflowVersionSupportsChildMerge, } from "./merge-execution-contract.js";
|
|
35
|
+
/**
|
|
36
|
+
* Remediation for a verdict that could not be established.
|
|
37
|
+
*
|
|
38
|
+
* Retry-oriented on purpose: it directs the operator at credentials,
|
|
39
|
+
* permissions, and connectivity, and it never claims the capability is absent.
|
|
40
|
+
* Telling someone to install a workflow that is already installed is the failure
|
|
41
|
+
* mode this wording exists to avoid.
|
|
42
|
+
*/
|
|
43
|
+
export const MERGE_EXECUTION_UNVERIFIABLE_REMEDIATION = "re-run once the Bridge API is reachable and repository credentials resolve; merge " +
|
|
44
|
+
"eligibility is UNKNOWN here, not confirmed missing. If the server reports no " +
|
|
45
|
+
"merge-execution block at all, upgrade the Bridge API deploy.";
|
|
46
|
+
/**
|
|
47
|
+
* Remediation for a CONFIRMED insufficiency. Names the one command that fixes it.
|
|
48
|
+
*
|
|
49
|
+
* Built from the shared constant rather than written out, so a rename in the
|
|
50
|
+
* contract lands here without an edit and no surface can spell the command a
|
|
51
|
+
* second way.
|
|
52
|
+
*/
|
|
53
|
+
export const MERGE_EXECUTION_INELIGIBLE_REMEDIATION = `run \`${MERGE_WORKFLOW_REMEDY_COMMAND}\` to generate the merge workflow and open its ` +
|
|
54
|
+
"pull request, then re-run once it is merged to the default branch.";
|
|
55
|
+
function unverifiable(reason, detail) {
|
|
56
|
+
return {
|
|
57
|
+
status: "could_not_verify",
|
|
58
|
+
reason,
|
|
59
|
+
detail,
|
|
60
|
+
remediation: MERGE_EXECUTION_UNVERIFIABLE_REMEDIATION,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function ineligible(reason, detail) {
|
|
64
|
+
return {
|
|
65
|
+
status: "not_eligible",
|
|
66
|
+
reason,
|
|
67
|
+
detail,
|
|
68
|
+
remediation: MERGE_EXECUTION_INELIGIBLE_REMEDIATION,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Classify this repository's merge-execution eligibility, FAIL-CLOSED.
|
|
73
|
+
*
|
|
74
|
+
* Reads only the parsed server report and re-derives nothing. The order below is
|
|
75
|
+
* "could not tell" before "confirmed bad", fact by fact, so an operator is never
|
|
76
|
+
* told to repair a condition that could not even be read.
|
|
77
|
+
*
|
|
78
|
+
* `eligible` requires the FULL truth table simultaneously: both probes
|
|
79
|
+
* succeeded, the workflow is present and enabled at the exact expected version,
|
|
80
|
+
* the App holds `actions: write`, the workflow token holds write authority, the
|
|
81
|
+
* branch policy was readable, and the workflow actor is admitted. Any one of
|
|
82
|
+
* them missing is decisive.
|
|
83
|
+
*
|
|
84
|
+
* The shared remedy command appears only as remediation METADATA. Nothing here
|
|
85
|
+
* executes it, and nothing infers repository state locally to decide whether it
|
|
86
|
+
* is needed.
|
|
87
|
+
*/
|
|
88
|
+
export function classifyMergeExecutionEligibility(readiness) {
|
|
89
|
+
const m = readiness.merge_execution;
|
|
90
|
+
if (m === null || m === undefined) {
|
|
91
|
+
return unverifiable("block_unreported", "this Bridge API deploy does not report merge-execution eligibility");
|
|
92
|
+
}
|
|
93
|
+
const gh = readiness.github;
|
|
94
|
+
// The App permission probe comes first: without it, "actions: write denied"
|
|
95
|
+
// below cannot be distinguished from "never checked".
|
|
96
|
+
if (!gh.actions_probe_succeeded) {
|
|
97
|
+
return unverifiable("app_actions_probe_failed", "the GitHub App actions permission could not be checked");
|
|
98
|
+
}
|
|
99
|
+
if (!m.merge_workflow_probe_succeeded) {
|
|
100
|
+
return unverifiable("workflow_probe_failed", "the merge workflow's metadata could not be read");
|
|
101
|
+
}
|
|
102
|
+
if (!m.branch_protection_probe_succeeded) {
|
|
103
|
+
return unverifiable("branch_policy_unreadable", `the branch policy on the ${m.branch_scope.replace("_", " ")} could not be read`);
|
|
104
|
+
}
|
|
105
|
+
if (!m.merge_workflow_present) {
|
|
106
|
+
return ineligible("workflow_absent", "the merge workflow is not present");
|
|
107
|
+
}
|
|
108
|
+
if (!m.merge_workflow_enabled) {
|
|
109
|
+
return ineligible("workflow_disabled", "the merge workflow is present but disabled");
|
|
110
|
+
}
|
|
111
|
+
if (!mergeWorkflowVersionSupportsChildMerge(m.merge_workflow_version)) {
|
|
112
|
+
// A newer version lands here too — see the module docstring.
|
|
113
|
+
return ineligible("workflow_version_mismatch", `merge workflow version ${m.merge_workflow_version ?? "unset"} ` +
|
|
114
|
+
`(expected one of ${MERGE_WORKFLOW_CHILD_MERGE_COMPATIBLE_VERSIONS.join(", ")})`);
|
|
115
|
+
}
|
|
116
|
+
if (!gh.actions_write) {
|
|
117
|
+
return ineligible("app_actions_write_denied", `the GitHub App holds actions: ${gh.actions_permission_level}`);
|
|
118
|
+
}
|
|
119
|
+
if (!m.merge_workflow_token_write_authority) {
|
|
120
|
+
return ineligible("workflow_token_write_denied", "the merge workflow's own token does not hold write authority");
|
|
121
|
+
}
|
|
122
|
+
if (!m.branch_protection_actions_bot_admitted) {
|
|
123
|
+
return ineligible("actor_not_admitted", "the branch policy does not admit the workflow actor");
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
status: "eligible",
|
|
127
|
+
detail: `merge workflow present, enabled, version ${m.merge_workflow_version}; ` +
|
|
128
|
+
"App actions: write, workflow token write authority, workflow actor admitted",
|
|
129
|
+
};
|
|
130
|
+
}
|