@bridge_gpt/mcp-server 0.2.59 → 0.2.60

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.
Files changed (71) hide show
  1. package/README.md +173 -51
  2. package/build/agent-guidance.generated.js +1 -1
  3. package/build/agent-notes.js +7 -1
  4. package/build/commands.generated.js +4 -4
  5. package/build/conductor/bridge-api-client.js +53 -2
  6. package/build/conductor/doctor.js +255 -53
  7. package/build/conductor/epic-runtime.js +11 -2
  8. package/build/conductor/install-doctor.js +24 -2
  9. package/build/conductor/local-merge.js +319 -26
  10. package/build/conductor/merge-execution-contract.js +61 -0
  11. package/build/conductor/merge-execution-eligibility.js +129 -0
  12. package/build/conductor/readiness-sections.js +159 -2
  13. package/build/conductor/readiness.js +95 -14
  14. package/build/conductor/recovery-operations.js +8 -2
  15. package/build/connect-bitbucket-api.js +6 -1
  16. package/build/connect-bitbucket.js +398 -88
  17. package/build/credential-store.js +16 -0
  18. package/build/docs.generated.js +2 -2
  19. package/build/doctor.js +183 -9
  20. package/build/drive-epic.js +23 -0
  21. package/build/epic-implementer-bin.js +10 -10
  22. package/build/epic-implementer-claude-hook-bin.js +1 -1
  23. package/build/epic-implementer-env.js +43 -14
  24. package/build/executor/ci-rerun-job.js +269 -0
  25. package/build/executor/cli.js +31 -0
  26. package/build/executor/credentials.js +11 -0
  27. package/build/executor/env.js +60 -0
  28. package/build/executor/index.js +21 -4
  29. package/build/executor/job-runner.js +126 -0
  30. package/build/executor/job-types.js +12 -2
  31. package/build/executor/merge-job.js +11 -2
  32. package/build/executor/service-lifecycle.js +136 -0
  33. package/build/executor/service-unit.js +427 -9
  34. package/build/executor/types.js +5 -0
  35. package/build/index.js +194 -179
  36. package/build/install-bridge-conductor.js +201 -8
  37. package/build/install-bridge.js +356 -30
  38. package/build/mcp-install-state.js +42 -4
  39. package/build/merge-pull-request.js +103 -10
  40. package/build/merge-workflow-codeowners.js +224 -0
  41. package/build/merge-workflow-config.js +133 -0
  42. package/build/merge-workflow-drift.js +66 -0
  43. package/build/merge-workflow-install.js +638 -0
  44. package/build/merge-workflow.js +572 -0
  45. package/build/pipelines.generated.js +2 -2
  46. package/build/plane/cli.js +24 -2
  47. package/build/plane/preflight.js +68 -0
  48. package/build/readme.generated.js +1 -1
  49. package/build/run-unit-tests-launcher.js +5 -0
  50. package/build/setup-epic.js +163 -9
  51. package/build/version.generated.js +3 -3
  52. package/package.json +3 -3
  53. package/build/conduct-epic/bridge-client.js +0 -773
  54. package/build/conduct-epic/checkpoint-store.js +0 -542
  55. package/build/conduct-epic/cli.js +0 -3145
  56. package/build/conduct-epic/cut-protocol.js +0 -392
  57. package/build/conduct-epic/lock.js +0 -302
  58. package/build/conduct-epic/pr-state.js +0 -286
  59. package/build/conduct-epic/spawn.js +0 -113
  60. package/build/conductor/cli.js +0 -1278
  61. package/build/conductor-bundle-cli.js +0 -256
  62. package/build/direct-ticket-tools.js +0 -70
  63. package/build/executor/reconcile-overlap-governance.js +0 -129
  64. package/build/implement-epic/bridge-client.js +0 -773
  65. package/build/implement-epic/checkpoint-store.js +0 -542
  66. package/build/implement-epic/cli.js +0 -3158
  67. package/build/implement-epic/cut-protocol.js +0 -392
  68. package/build/implement-epic/lock.js +0 -302
  69. package/build/implement-epic/pr-state.js +0 -286
  70. package/build/implement-epic/spawn.js +0 -113
  71. 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: { ...process.env, ...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
- resolve(result);
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
- // Non-interactive + no-noise env for gh; the caller's env (incl. any token)
490
- // is overlaid first, then the safety pins.
491
- const ghEnv = {
492
- ...deps.env,
493
- GH_PROMPT_DISABLED: "1",
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
- * Probe whether this host can merge with `gh` at all, BEFORE any merge is
789
- * attempted.
990
+ * Resolve the `owner/name` the permission probe should ask about.
790
991
  *
791
- * Without this the two operator-fixable failures are invisible: a missing `gh`
792
- * and a logged-out `gh` both make `gh pr view` exit non-zero, which this module
793
- * reports as `gh_pr_view_failed` — a reason that names no fix. Splitting them
794
- * out is the difference between "run `gh auth login`" and "something went wrong
795
- * reading the pull request".
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: "local_gh_unauthenticated" };
1087
+ return { ok: false, reason: reasons.invalid };
820
1088
  }
821
1089
  if (auth.status !== 0)
822
- return { ok: false, reason: "local_gh_unauthenticated" };
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
- const ghEnv = {
844
- ...deps.env,
845
- GH_PROMPT_DISABLED: "1",
846
- GH_NO_UPDATE_NOTIFIER: "1",
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
- const preflight = await preflightLocalGh(run, ghEnv);
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,61 @@
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 ONLY workflow contract version this CLI understands.
51
+ *
52
+ * Exact equality. A newer version is a workflow this build has never been taught
53
+ * to reason about, so it is NOT eligible — see
54
+ * {@link classifyMergeExecutionEligibility}.
55
+ */
56
+ export const MERGE_WORKFLOW_EXPECTED_VERSION = 1;
57
+ /**
58
+ * The single command that generates the merge workflow and opens its pull
59
+ * request. Every refusal, remediation, and readiness line imports this constant.
60
+ */
61
+ export const MERGE_WORKFLOW_REMEDY_COMMAND = "install-bridge conductor --merge-workflow";
@@ -0,0 +1,129 @@
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
+ * ## Exact version, always
27
+ *
28
+ * Eligibility requires {@link MERGE_WORKFLOW_EXPECTED_VERSION} exactly. A NEWER
29
+ * version is not eligible either: it is a workflow this build has never been
30
+ * taught to reason about, and "newer is probably fine" is a guess about a merge
31
+ * actuator.
32
+ */
33
+ import { MERGE_WORKFLOW_EXPECTED_VERSION, MERGE_WORKFLOW_REMEDY_COMMAND, } from "./merge-execution-contract.js";
34
+ /**
35
+ * Remediation for a verdict that could not be established.
36
+ *
37
+ * Retry-oriented on purpose: it directs the operator at credentials,
38
+ * permissions, and connectivity, and it never claims the capability is absent.
39
+ * Telling someone to install a workflow that is already installed is the failure
40
+ * mode this wording exists to avoid.
41
+ */
42
+ export const MERGE_EXECUTION_UNVERIFIABLE_REMEDIATION = "re-run once the Bridge API is reachable and repository credentials resolve; merge " +
43
+ "eligibility is UNKNOWN here, not confirmed missing. If the server reports no " +
44
+ "merge-execution block at all, upgrade the Bridge API deploy.";
45
+ /**
46
+ * Remediation for a CONFIRMED insufficiency. Names the one command that fixes it.
47
+ *
48
+ * Built from the shared constant rather than written out, so a rename in the
49
+ * contract lands here without an edit and no surface can spell the command a
50
+ * second way.
51
+ */
52
+ export const MERGE_EXECUTION_INELIGIBLE_REMEDIATION = `run \`${MERGE_WORKFLOW_REMEDY_COMMAND}\` to generate the merge workflow and open its ` +
53
+ "pull request, then re-run once it is merged to the default branch.";
54
+ function unverifiable(reason, detail) {
55
+ return {
56
+ status: "could_not_verify",
57
+ reason,
58
+ detail,
59
+ remediation: MERGE_EXECUTION_UNVERIFIABLE_REMEDIATION,
60
+ };
61
+ }
62
+ function ineligible(reason, detail) {
63
+ return {
64
+ status: "not_eligible",
65
+ reason,
66
+ detail,
67
+ remediation: MERGE_EXECUTION_INELIGIBLE_REMEDIATION,
68
+ };
69
+ }
70
+ /**
71
+ * Classify this repository's merge-execution eligibility, FAIL-CLOSED.
72
+ *
73
+ * Reads only the parsed server report and re-derives nothing. The order below is
74
+ * "could not tell" before "confirmed bad", fact by fact, so an operator is never
75
+ * told to repair a condition that could not even be read.
76
+ *
77
+ * `eligible` requires the FULL truth table simultaneously: both probes
78
+ * succeeded, the workflow is present and enabled at the exact expected version,
79
+ * the App holds `actions: write`, the workflow token holds write authority, the
80
+ * branch policy was readable, and the workflow actor is admitted. Any one of
81
+ * them missing is decisive.
82
+ *
83
+ * The shared remedy command appears only as remediation METADATA. Nothing here
84
+ * executes it, and nothing infers repository state locally to decide whether it
85
+ * is needed.
86
+ */
87
+ export function classifyMergeExecutionEligibility(readiness) {
88
+ const m = readiness.merge_execution;
89
+ if (m === null || m === undefined) {
90
+ return unverifiable("block_unreported", "this Bridge API deploy does not report merge-execution eligibility");
91
+ }
92
+ const gh = readiness.github;
93
+ // The App permission probe comes first: without it, "actions: write denied"
94
+ // below cannot be distinguished from "never checked".
95
+ if (!gh.actions_probe_succeeded) {
96
+ return unverifiable("app_actions_probe_failed", "the GitHub App actions permission could not be checked");
97
+ }
98
+ if (!m.merge_workflow_probe_succeeded) {
99
+ return unverifiable("workflow_probe_failed", "the merge workflow's metadata could not be read");
100
+ }
101
+ if (!m.branch_protection_probe_succeeded) {
102
+ return unverifiable("branch_policy_unreadable", `the branch policy on the ${m.branch_scope.replace("_", " ")} could not be read`);
103
+ }
104
+ if (!m.merge_workflow_present) {
105
+ return ineligible("workflow_absent", "the merge workflow is not present");
106
+ }
107
+ if (!m.merge_workflow_enabled) {
108
+ return ineligible("workflow_disabled", "the merge workflow is present but disabled");
109
+ }
110
+ if (m.merge_workflow_version !== MERGE_WORKFLOW_EXPECTED_VERSION) {
111
+ // A newer version lands here too — see the module docstring.
112
+ return ineligible("workflow_version_mismatch", `merge workflow version ${m.merge_workflow_version ?? "unset"} ` +
113
+ `(expected ${MERGE_WORKFLOW_EXPECTED_VERSION})`);
114
+ }
115
+ if (!gh.actions_write) {
116
+ return ineligible("app_actions_write_denied", `the GitHub App holds actions: ${gh.actions_permission_level}`);
117
+ }
118
+ if (!m.merge_workflow_token_write_authority) {
119
+ return ineligible("workflow_token_write_denied", "the merge workflow's own token does not hold write authority");
120
+ }
121
+ if (!m.branch_protection_actions_bot_admitted) {
122
+ return ineligible("actor_not_admitted", "the branch policy does not admit the workflow actor");
123
+ }
124
+ return {
125
+ status: "eligible",
126
+ detail: `merge workflow present, enabled, version ${MERGE_WORKFLOW_EXPECTED_VERSION}; ` +
127
+ "App actions: write, workflow token write authority, workflow actor admitted",
128
+ };
129
+ }