@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.
Files changed (78) hide show
  1. package/README.md +181 -51
  2. package/build/agent-guidance.generated.js +1 -1
  3. package/build/agent-notes.js +7 -1
  4. package/build/commands.generated.js +6 -6
  5. package/build/conductor/bridge-api-client.js +406 -4
  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 +86 -0
  11. package/build/conductor/merge-execution-eligibility.js +130 -0
  12. package/build/conductor/readiness-sections.js +256 -16
  13. package/build/conductor/readiness.js +203 -29
  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 +172 -0
  18. package/build/docs.generated.js +2 -2
  19. package/build/doctor.js +277 -15
  20. package/build/drive-epic.js +147 -12
  21. package/build/epic-implementer-bin.js +15 -15
  22. package/build/epic-implementer-claude-hook-bin.js +1 -1
  23. package/build/epic-implementer-env.js +43 -14
  24. package/build/epic-integration-pr.js +61 -15
  25. package/build/epic-launch-profile.js +455 -0
  26. package/build/executor/ci-rerun-job.js +269 -0
  27. package/build/executor/cli.js +40 -0
  28. package/build/executor/credentials.js +28 -1
  29. package/build/executor/env.js +60 -0
  30. package/build/executor/http-client.js +45 -1
  31. package/build/executor/index.js +21 -4
  32. package/build/executor/integration-job.js +759 -0
  33. package/build/executor/job-runner.js +218 -9
  34. package/build/executor/job-types.js +12 -2
  35. package/build/executor/merge-job.js +11 -2
  36. package/build/executor/observation.js +114 -7
  37. package/build/executor/runner.js +101 -1
  38. package/build/executor/service-lifecycle.js +136 -0
  39. package/build/executor/service-unit.js +427 -9
  40. package/build/executor/types.js +48 -0
  41. package/build/index.js +479 -240
  42. package/build/install-bridge-conductor.js +599 -14
  43. package/build/install-bridge.js +448 -46
  44. package/build/mcp-install-state.js +42 -4
  45. package/build/merge-pull-request.js +103 -10
  46. package/build/merge-workflow-codeowners.js +224 -0
  47. package/build/merge-workflow-config.js +187 -0
  48. package/build/merge-workflow-drift.js +97 -0
  49. package/build/merge-workflow-install.js +638 -0
  50. package/build/merge-workflow.js +956 -0
  51. package/build/pipelines.generated.js +3 -3
  52. package/build/plane/cli.js +24 -2
  53. package/build/plane/preflight.js +68 -0
  54. package/build/plane/supervisor.js +6 -0
  55. package/build/readme.generated.js +1 -1
  56. package/build/run-unit-tests-launcher.js +5 -0
  57. package/build/setup-epic.js +263 -38
  58. package/build/version.generated.js +3 -3
  59. package/package.json +3 -3
  60. package/build/conduct-epic/bridge-client.js +0 -773
  61. package/build/conduct-epic/checkpoint-store.js +0 -542
  62. package/build/conduct-epic/cli.js +0 -3145
  63. package/build/conduct-epic/cut-protocol.js +0 -392
  64. package/build/conduct-epic/lock.js +0 -302
  65. package/build/conduct-epic/pr-state.js +0 -286
  66. package/build/conduct-epic/spawn.js +0 -113
  67. package/build/conductor/cli.js +0 -1278
  68. package/build/conductor-bundle-cli.js +0 -256
  69. package/build/direct-ticket-tools.js +0 -70
  70. package/build/executor/reconcile-overlap-governance.js +0 -129
  71. package/build/implement-epic/bridge-client.js +0 -773
  72. package/build/implement-epic/checkpoint-store.js +0 -542
  73. package/build/implement-epic/cli.js +0 -3158
  74. package/build/implement-epic/cut-protocol.js +0 -392
  75. package/build/implement-epic/lock.js +0 -302
  76. package/build/implement-epic/pr-state.js +0 -286
  77. package/build/implement-epic/spawn.js +0 -113
  78. package/build/plan-epic-conductor-eligibility.js +0 -213
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Deterministic `ci_rerun` executor job (BAPI-1160).
3
+ *
4
+ * Re-runs ONE failed workflow run whose failure the SERVER already proved to be
5
+ * infrastructure rather than code. It has no worktree, no branch, no prompt, and
6
+ * no worker: it validates a server-authored payload, invokes
7
+ * `gh run rerun --failed <run_id> --repo <owner/repo>` through the executor's
8
+ * ambient `gh`, polls the run under a bounded deadline, and reports a FACT
9
+ * ENVELOPE.
10
+ *
11
+ * Three properties are load-bearing, and each exists because its absence caused
12
+ * or would cause a real failure:
13
+ *
14
+ * 1. **No transition authority (R14 rule 3).** The envelope carries a run id, a
15
+ * conductor attempt, a lane, an observed conclusion, and optionally the
16
+ * provider's own run-attempt counter. It carries NO verdict, gate name, or
17
+ * requested transition, and the backend model is `extra="forbid"` so one
18
+ * cannot be smuggled in. Successful actuation is not a CI verdict: the
19
+ * ordinary CI observer must independently re-observe the current head before
20
+ * the `ci` gate may move, exactly as it would had a human re-run the lane.
21
+ *
22
+ * 2. **The claude-review workflow is never re-run.** That workflow joins a
23
+ * per-PR concurrency group with `cancel-in-progress: true`, so re-running it
24
+ * could CANCEL a legitimately in-flight review of a NEWER head. The rendered
25
+ * workflow is pinned never to contain `gh run rerun` for this reason; this
26
+ * job enforces the other half, refusing any payload or looked-up run that
27
+ * identifies it — BEFORE any subprocess is invoked.
28
+ *
29
+ * 3. **Ambient `gh` authentication only.** The Bridge GitHub App is
30
+ * `contents: read` and gains no `actions: write` for this feature. The re-run
31
+ * is actuated by the executor machine's own `gh`, so no token, credential
32
+ * bundle, or environment credential is injected, added to argv, or returned.
33
+ *
34
+ * Diagnostics go to `console.error` only. MCP stdout is a protocol channel and
35
+ * writing child output to it corrupts the session.
36
+ */
37
+ import { createProductionEpicIntegrationGhRunner, } from "../epic-integration-pr.js";
38
+ import { CLAUDE_REVIEW_WORKFLOW_RELPATH } from "../claude-review-workflow-drift.js";
39
+ /** Machine-readable, sanitized failure kinds for a refused or failed re-run. */
40
+ export const CI_RERUN_REFUSED = "CiRerunRefused";
41
+ export const CI_RERUN_FAILED = "CiRerunFailed";
42
+ /** Bounded deadline for the post-rerun poll. */
43
+ export const CI_RERUN_POLL_DEADLINE_MS = 3 * 60 * 1000;
44
+ /** Interval between bounded poll reads. */
45
+ export const CI_RERUN_POLL_INTERVAL_MS = 10 * 1000;
46
+ /** Per-`gh`-invocation timeout. */
47
+ export const CI_RERUN_GH_TIMEOUT_MS = 60 * 1000;
48
+ const OWNER_REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
49
+ const CLAUDE_REVIEW_WORKFLOW_BASENAME = CLAUDE_REVIEW_WORKFLOW_RELPATH.split("/").pop() ?? "claude-review.yml";
50
+ /**
51
+ * True when a workflow identity names the claude-review workflow.
52
+ *
53
+ * Matches on the workflow file BASENAME and on the conventional display name, so
54
+ * a `path` (`.github/workflows/claude-review.yml`) and a `name`
55
+ * (`claude-review`) are both recognized. Deliberately permissive in the REFUSING
56
+ * direction: a false positive costs one un-actuated re-run, while a false
57
+ * negative could cancel a real review of a newer head.
58
+ */
59
+ export function identifiesClaudeReviewWorkflow(identity) {
60
+ if (typeof identity !== "string")
61
+ return false;
62
+ const normalized = identity.trim().toLowerCase();
63
+ if (!normalized)
64
+ return false;
65
+ return (normalized.endsWith(CLAUDE_REVIEW_WORKFLOW_BASENAME.toLowerCase()) ||
66
+ normalized.includes("claude-review") ||
67
+ normalized.includes("claude review"));
68
+ }
69
+ /**
70
+ * Validate a raw server payload into typed fields, SYNCHRONOUSLY and before any
71
+ * subprocess is invoked.
72
+ *
73
+ * Fails closed on every unusable shape. A job that cannot prove what it is about
74
+ * to re-run must not run `gh` and then discover the problem — that is how a
75
+ * malformed payload becomes a re-run of the wrong workflow.
76
+ */
77
+ export function parseCiRerunPayload(payload) {
78
+ const refuse = (reason) => ({ ok: false, failure: { errorKind: CI_RERUN_REFUSED, reason } });
79
+ if (typeof payload !== "object" || payload === null) {
80
+ return refuse("payload_not_an_object");
81
+ }
82
+ const raw = payload;
83
+ const repoFullName = raw.repo_full_name;
84
+ if (typeof repoFullName !== "string" || !OWNER_REPO_RE.test(repoFullName.trim())) {
85
+ return refuse("repo_full_name_invalid");
86
+ }
87
+ const workflowRunId = raw.workflow_run_id;
88
+ if (typeof workflowRunId !== "number" ||
89
+ !Number.isInteger(workflowRunId) ||
90
+ workflowRunId <= 0) {
91
+ return refuse("workflow_run_id_invalid");
92
+ }
93
+ const lane = raw.required_check_lane;
94
+ if (typeof lane !== "string" || !lane.trim()) {
95
+ return refuse("required_check_lane_missing");
96
+ }
97
+ const attempt = raw.conductor_infra_attempt;
98
+ if (typeof attempt !== "number" || !Number.isInteger(attempt) || attempt <= 0) {
99
+ return refuse("conductor_infra_attempt_invalid");
100
+ }
101
+ const identityRaw = raw.workflow_identity;
102
+ const workflowIdentity = typeof identityRaw === "string" && identityRaw.trim() ? identityRaw.trim() : null;
103
+ // The review-workflow refusal, evaluated on the SERVER-SUPPLIED identity and
104
+ // on the lane name, before any subprocess exists.
105
+ if (identifiesClaudeReviewWorkflow(workflowIdentity) ||
106
+ identifiesClaudeReviewWorkflow(lane)) {
107
+ return refuse("claude_review_workflow_refused");
108
+ }
109
+ return {
110
+ ok: true,
111
+ fields: {
112
+ repoFullName: repoFullName.trim().toLowerCase(),
113
+ workflowRunId,
114
+ requiredCheckLane: lane.trim(),
115
+ conductorInfraAttempt: attempt,
116
+ workflowIdentity,
117
+ },
118
+ };
119
+ }
120
+ function parseRunView(stdout) {
121
+ let decoded;
122
+ try {
123
+ decoded = JSON.parse(stdout);
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ if (typeof decoded !== "object" || decoded === null)
129
+ return null;
130
+ const raw = decoded;
131
+ const str = (value) => typeof value === "string" && value.trim() ? value.trim() : null;
132
+ const repo = raw.repository;
133
+ return {
134
+ name: str(raw.name),
135
+ path: str(raw.path) ?? str(raw.workflowName),
136
+ status: str(raw.status),
137
+ conclusion: str(raw.conclusion),
138
+ runAttempt: typeof raw.attempt === "number" && Number.isInteger(raw.attempt) && raw.attempt > 0
139
+ ? raw.attempt
140
+ : null,
141
+ repoFullName: typeof repo === "object" && repo !== null
142
+ ? str(repo.nameWithOwner)
143
+ : str(raw.repository),
144
+ };
145
+ }
146
+ const RUN_VIEW_FIELDS = "name,status,conclusion,attempt,workflowName";
147
+ /**
148
+ * Run one deterministic `ci_rerun` job.
149
+ *
150
+ * Returns the fact envelope on success, or a sanitized failure. Never throws for
151
+ * a provider or process condition, and never returns provider text.
152
+ */
153
+ export async function runCiRerunJob(payload, deps) {
154
+ const parsed = parseCiRerunPayload(payload);
155
+ if (!parsed.ok) {
156
+ console.error(`[ci_rerun] payload refused: ${parsed.failure.reason}`);
157
+ return parsed;
158
+ }
159
+ const fields = parsed.fields;
160
+ const gh = deps.gh;
161
+ const now = deps.now ?? (() => Date.now());
162
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
163
+ const refuse = (reason) => {
164
+ console.error(`[ci_rerun] refused: ${reason}`);
165
+ return { ok: false, failure: { errorKind: CI_RERUN_REFUSED, reason } };
166
+ };
167
+ // A CONDITIONAL lookup. It is issued only when the server supplied no workflow
168
+ // identity, because then nothing yet proves the run is not the review
169
+ // workflow. When the server DID supply one, it was already checked above and a
170
+ // second read would buy nothing.
171
+ let view = null;
172
+ if (fields.workflowIdentity === null) {
173
+ const lookup = await gh([
174
+ "run",
175
+ "view",
176
+ String(fields.workflowRunId),
177
+ "--repo",
178
+ fields.repoFullName,
179
+ "--json",
180
+ RUN_VIEW_FIELDS,
181
+ ], { timeoutMs: CI_RERUN_GH_TIMEOUT_MS });
182
+ if (lookup.exitCode !== 0) {
183
+ return refuse("run_lookup_failed");
184
+ }
185
+ view = parseRunView(lookup.stdout);
186
+ if (view === null) {
187
+ return refuse("run_lookup_unreadable");
188
+ }
189
+ if (identifiesClaudeReviewWorkflow(view.name) ||
190
+ identifiesClaudeReviewWorkflow(view.path)) {
191
+ return refuse("claude_review_workflow_refused");
192
+ }
193
+ if (view.repoFullName !== null &&
194
+ view.repoFullName.toLowerCase() !== fields.repoFullName) {
195
+ return refuse("run_repository_mismatch");
196
+ }
197
+ }
198
+ // The actuation. An ARGUMENT ARRAY with `shell: false` (the injected runner's
199
+ // contract), so no value here is ever interpolated into a command line. No
200
+ // token, credential, or environment addition: `gh` uses the executor
201
+ // machine's ambient authentication and nothing else.
202
+ const rerun = await gh([
203
+ "run",
204
+ "rerun",
205
+ "--failed",
206
+ String(fields.workflowRunId),
207
+ "--repo",
208
+ fields.repoFullName,
209
+ ], { timeoutMs: CI_RERUN_GH_TIMEOUT_MS });
210
+ if (rerun.exitCode !== 0) {
211
+ // The reserved infrastructure attempt stays CONSUMED. A re-run that the
212
+ // provider refused is a spent attempt, not a free retry: treating it as free
213
+ // would let a permanently refusing provider buy unbounded jobs.
214
+ console.error(`[ci_rerun] rerun invocation failed (exit=${rerun.exitCode ?? "null"}` +
215
+ `${rerun.timedOut ? ", timed out" : ""})`);
216
+ return {
217
+ ok: false,
218
+ failure: {
219
+ errorKind: CI_RERUN_FAILED,
220
+ reason: rerun.timedOut ? "rerun_timed_out" : "rerun_invocation_failed",
221
+ },
222
+ };
223
+ }
224
+ // Bounded poll for the conclusion. An expired deadline is a REPORTABLE
225
+ // outcome, not a failure: the re-run was actuated, the attempt was spent, and
226
+ // the server re-observes the head on its own schedule either way.
227
+ const deadline = now() + CI_RERUN_POLL_DEADLINE_MS;
228
+ let observedConclusion = null;
229
+ let providerRunAttempt = view?.runAttempt ?? null;
230
+ while (now() < deadline) {
231
+ await sleep(CI_RERUN_POLL_INTERVAL_MS);
232
+ const poll = await gh([
233
+ "run",
234
+ "view",
235
+ String(fields.workflowRunId),
236
+ "--repo",
237
+ fields.repoFullName,
238
+ "--json",
239
+ RUN_VIEW_FIELDS,
240
+ ], { timeoutMs: CI_RERUN_GH_TIMEOUT_MS });
241
+ if (poll.exitCode !== 0)
242
+ continue;
243
+ const polled = parseRunView(poll.stdout);
244
+ if (polled === null)
245
+ continue;
246
+ if (polled.runAttempt !== null)
247
+ providerRunAttempt = polled.runAttempt;
248
+ if (polled.status === "completed") {
249
+ observedConclusion = polled.conclusion;
250
+ break;
251
+ }
252
+ }
253
+ const result = {
254
+ run_id: fields.workflowRunId,
255
+ conductor_attempt: fields.conductorInfraAttempt,
256
+ lane: fields.requiredCheckLane,
257
+ observed_conclusion: observedConclusion,
258
+ };
259
+ if (providerRunAttempt !== null) {
260
+ result.provider_run_attempt = providerRunAttempt;
261
+ }
262
+ console.error(`[ci_rerun] run=${fields.workflowRunId} attempt=${fields.conductorInfraAttempt} ` +
263
+ `conclusion=${observedConclusion ?? "pending"}`);
264
+ return { ok: true, result };
265
+ }
266
+ /** Production dependency bundle: the existing bounded `execFile`-based runner. */
267
+ export function createProductionCiRerunDeps() {
268
+ return { gh: createProductionEpicIntegrationGhRunner() };
269
+ }
@@ -13,8 +13,10 @@ import { resolveStartTicketsRepoName } from "../start-tickets-repo.js";
13
13
  import { DEFAULT_EXECUTOR_AGENT_ID } from "./agent-identity.js";
14
14
  import { executorEpicRunIdRefusal, isValidExecutorEpicRunId, validateExecutorClaimScope, } from "./claim-scope.js";
15
15
  import { createDefaultExecutorDeps } from "./deps.js";
16
+ import { scrubExecutorProcessGithubCredentials } from "./env.js";
16
17
  import { resolveBaseUrl, resolveExecutorApiAccess, EXECUTOR_BASE_URL_REQUIRED_MESSAGE, } from "./credentials.js";
17
18
  import { createExecutorHttpClient } from "./http-client.js";
19
+ import { createIntegrationActionHttpClient, } from "./integration-job.js";
18
20
  import { startExecutorSleepAssertion } from "./platform.js";
19
21
  import { runExecutor } from "./runner.js";
20
22
  import { runExecutorWatchCli } from "./watch-cli.js";
@@ -78,6 +80,7 @@ export function parseExecutorArgs(argv, context) {
78
80
  const repos = [];
79
81
  const epicRunIds = [];
80
82
  let repoWide = false;
83
+ let scrubInheritedGithubCredentials = false;
81
84
  let executorId;
82
85
  let maxConcurrent = DEFAULT_MAX_CONCURRENT;
83
86
  let once = false;
@@ -123,6 +126,9 @@ export function parseExecutorArgs(argv, context) {
123
126
  else if (arg === "--repo-wide") {
124
127
  repoWide = true;
125
128
  }
129
+ else if (arg === "--scrub-inherited-github-credentials") {
130
+ scrubInheritedGithubCredentials = true;
131
+ }
126
132
  else if (arg === "--executor-id") {
127
133
  executorId = argv[++i];
128
134
  if (!executorId)
@@ -231,6 +237,9 @@ export function parseExecutorArgs(argv, context) {
231
237
  // BAPI-1026 (R54) — same "truly absent, never a falsy placeholder"
232
238
  // discipline: `repoWide` is either `true` or the key is missing.
233
239
  ...(repoWide ? { repoWide: true } : {}),
240
+ // BAPI-1137: absent unless explicitly requested, so an ordinary executor's
241
+ // options object is byte-for-byte what it was before this slice.
242
+ ...(scrubInheritedGithubCredentials ? { scrubInheritedGithubCredentials: true } : {}),
234
243
  };
235
244
  return { kind: "ok", options };
236
245
  }
@@ -290,6 +299,29 @@ export async function runExecutorCli(argv, overrides = {}) {
290
299
  return 1;
291
300
  }
292
301
  const options = parsed.options;
302
+ // BAPI-1137 (R-4) — scrub inherited GitHub write variables, when this process
303
+ // was asked to. This is the earliest point the request is KNOWN, and it is
304
+ // still ahead of everything that could observe or forward the credential:
305
+ // base-URL resolution, Bridge credential resolution, the HTTP client,
306
+ // preflight, worker spawn, and even the claim-scope status line below.
307
+ //
308
+ // OPT-IN, and that is the load-bearing part. The executor's own `merge` job
309
+ // reaches `gh` through this very object (`deps.env` is `process.env`, which
310
+ // `buildDefaultMergeLocalDeps` hands to `local-merge.ts`'s `ghEnv`), and a
311
+ // token in the environment is a supported `gh` auth mechanism. An
312
+ // unconditional scrub would therefore break `merge_execution_mode: local` on
313
+ // any host whose operator started the executor from a shell exporting
314
+ // `GH_TOKEN` — a working merge turned into `local_gh_unauthenticated`. The
315
+ // durable host sets the flag in its generated unit because it holds no
316
+ // standing GitHub write credential by design and its merges leave the host.
317
+ //
318
+ // `process.env` deliberately, not `deps.env`: the deps bundle may be a test
319
+ // fake, while the thing children inherit is the real process environment.
320
+ // Values are never read — only key names are matched — so nothing here can
321
+ // log or leak what it deleted.
322
+ if (options.scrubInheritedGithubCredentials === true) {
323
+ scrubExecutorProcessGithubCredentials(process.env);
324
+ }
293
325
  // BAPI-794/BAPI-1026 — startup diagnostics: a bounded MODE label and, when
294
326
  // scoped, only the COUNT of runs. Never credentials, job payloads, command
295
327
  // arguments — and, since BAPI-1026, never the run IDs either, matching the
@@ -366,8 +398,16 @@ export async function runExecutorCli(argv, overrides = {}) {
366
398
  clearTimer: deps.clearTimer,
367
399
  errorLog,
368
400
  });
401
+ const createIntegrationClient = overrides.createIntegrationClient ?? createIntegrationActionHttpClient;
369
402
  const runnerSeams = {
370
403
  onSignal: overrides.onSignal ?? defaultSignalRegistrar,
404
+ integrationActions: createIntegrationClient({
405
+ baseUrl,
406
+ apiKey: apiKeyByRepo[options.repoName],
407
+ apiKeyByRepo,
408
+ mcpVersion: VERSION,
409
+ fetch: deps.fetch,
410
+ }),
371
411
  };
372
412
  try {
373
413
  return await run(runtimeOptions, deps, httpClient, runnerSeams);
@@ -5,8 +5,19 @@
5
5
  * through the shared credential store (`resolveBapiCredentials`) — never by
6
6
  * reading worktree-local MCP config, and never leaking secret values into
7
7
  * diagnostics/results.
8
+ *
9
+ * BAPI-1137 (R-4) leaves that lookup exactly as it was — parent environment
10
+ * first, then the user-scoped `bapi:<repo>` store — and adds nothing beside it.
11
+ * This module resolves ONE credential for ONE purpose: authenticating the
12
+ * executor's own Bridge calls. It gains no GitHub bundle, no GitHub registration
13
+ * field, no GitHub CLI argument, and no GitHub-derived diagnostic value, which is
14
+ * what lets a durable host assert it holds no standing GitHub write credential.
15
+ * `CLAUDE_CODE_OAUTH_TOKEN` is not part of that assertion either way: it is
16
+ * host-local worker authentication the operator manages independently, Bridge
17
+ * never resolves or stores it, and `ANTHROPIC_API_KEY` remains absolutely denied
18
+ * to a worker (`executor/env.ts`).
8
19
  */
9
- import { resolveBapiCredentials } from "../credential-store.js";
20
+ import { readStoredBinding, resolveBapiCredentials } from "../credential-store.js";
10
21
  import { normalizeBaseUrlCandidate } from "../base-url.js";
11
22
  /**
12
23
  * The documented production Bridge API base URL — the value an operator pastes
@@ -68,6 +79,22 @@ export async function resolveExecutorApiAccess(repoName, deps, explicitBaseUrl)
68
79
  error: `could not resolve Bridge API credentials for repo '${trimmed}' (${result.kind})`,
69
80
  };
70
81
  }
82
+ // BAPI-1260: a stored key that guided setup approved for ONE endpoint is never
83
+ // sent to another. A restarted executor re-reads the binding here, so a unit
84
+ // whose --base-url drifted from the approved target refuses before its first
85
+ // authenticated request. An unbound (legacy) entry keeps today's behavior —
86
+ // the explicit-target requirement above still applies to it.
87
+ if (result.credentials.source === "file") {
88
+ const bound = await readStoredBinding(`bapi:${trimmed}`, storeDeps);
89
+ if (bound !== null && bound !== url.baseUrl) {
90
+ return {
91
+ ok: false,
92
+ error: `the stored credential for repo '${trimmed}' is approved for a different Bridge API ` +
93
+ "endpoint than this executor's base URL; it was not sent. Re-run guided setup " +
94
+ "(install conductor) to re-bind it, or correct --base-url.",
95
+ };
96
+ }
97
+ }
71
98
  return {
72
99
  ok: true,
73
100
  apiKey: result.credentials.apiKey,
@@ -170,6 +170,66 @@ export const EXPLICIT_DENY_KEYS = [
170
170
  // the allowlist.
171
171
  ...WORKER_SENTINEL_DATABASE_ENV_KEYS,
172
172
  ];
173
+ // ---------------------------------------------------------------------------
174
+ // Executor-PROCESS GitHub credential deny-list (BAPI-1137, R-4)
175
+ //
176
+ // Everything above this line governs what reaches a spawned WORKER. This block
177
+ // governs the executor process itself, and it exists because the worker
178
+ // allowlist cannot reach far enough: a manager-level environment injection — a
179
+ // `launchctl setenv`, a systemd drop-in, a `~/.zprofile` that ran before the
180
+ // operator started the executor by hand — puts a GitHub write token in the
181
+ // executor's own environment, where a subprocess that builds its own environment
182
+ // (or any future code path that reads one) could still find it.
183
+ //
184
+ // The durable-host claim is that the host holds NO STANDING GitHub write
185
+ // credential. A unit that declares none (see `service-unit.ts`) and a process
186
+ // that scrubs any it inherited are the two halves of that claim; without the
187
+ // second, the first only proves the token did not come from the file Bridge
188
+ // generated.
189
+ // ---------------------------------------------------------------------------
190
+ /**
191
+ * The GitHub write-credential variable names the executor process refuses to
192
+ * carry. Exactly the two names this repository's environment-safety conventions
193
+ * already recognize (`EXPLICIT_DENY_KEYS` above denies the same pair for a
194
+ * worker), so there is ONE vocabulary for "a GitHub write credential" rather
195
+ * than a second, drifting list.
196
+ */
197
+ export const EXECUTOR_PROCESS_GITHUB_CREDENTIAL_DENY_KEYS = [
198
+ "GH_TOKEN",
199
+ "GITHUB_TOKEN",
200
+ ];
201
+ /**
202
+ * Delete every prohibited GitHub credential variable from the executor's own
203
+ * environment, and report only HOW MANY were removed.
204
+ *
205
+ * Three properties are load-bearing and are what the return type is shaped
206
+ * around:
207
+ *
208
+ * - **Names are compared case-insensitively.** `GH_TOKEN`, `gh_token`, and
209
+ * `Gh_Token` are the same variable to a shell on a case-preserving platform,
210
+ * and a scrubber that matched only the canonical spelling would leave the
211
+ * other two in place.
212
+ * - **Values are never read.** Nothing here dereferences a prohibited key
213
+ * before deleting it, so no credential value can be logged, returned,
214
+ * compared, or classified — not even as "looks like a PAT". A count is the
215
+ * most a caller can learn.
216
+ * - **It mutates in place.** The executor spawns children that inherit
217
+ * `process.env`; returning a filtered COPY would leave the real environment
218
+ * untouched and the guarantee imaginary.
219
+ */
220
+ export function scrubExecutorProcessGithubCredentials(env = process.env) {
221
+ const prohibited = new Set(EXECUTOR_PROCESS_GITHUB_CREDENTIAL_DENY_KEYS.map((key) => key.toUpperCase()));
222
+ let removedCount = 0;
223
+ // A snapshot of the key names, because deleting while iterating the live
224
+ // object is exactly the kind of thing that silently skips an entry.
225
+ for (const key of Object.keys(env)) {
226
+ if (!prohibited.has(key.toUpperCase()))
227
+ continue;
228
+ delete env[key];
229
+ removedCount++;
230
+ }
231
+ return { removedCount };
232
+ }
173
233
  /**
174
234
  * True only when `key` is a safe, allowlisted operational key. Exported so the
175
235
  * allowlist is locally auditable/testable. A key is allowed iff it is in the
@@ -9,6 +9,30 @@
9
9
  */
10
10
  import { RECONCILER_LIVENESS_VALUES } from "./types.js";
11
11
  import { readEpicRunCompletionState, } from "../conductor/bridge-api-client.js";
12
+ /** BAPI-1258 — the server's stable `error_code` for refused capacity evidence. */
13
+ export const UNSUPPORTED_TERMINAL_CAPACITY_ERROR_CODE = "UNSUPPORTED_TERMINAL_CAPACITY";
14
+ /**
15
+ * Whether a `/fail` response refused the capacity evidence it carried: a 422
16
+ * (malformed envelope) or a 409 whose `error_code` is the stable unsupported
17
+ * code. A 409 `STALE_CLAIM` is NOT a refusal — the claim is gone, and resending
18
+ * cannot help. The body is inspected only for that one closed token.
19
+ */
20
+ function isCapacityEvidenceRefusal(status, bodyText) {
21
+ if (status === 422)
22
+ return true;
23
+ if (status !== 409)
24
+ return false;
25
+ try {
26
+ const parsed = JSON.parse(bodyText);
27
+ const detail = parsed && typeof parsed === "object" ? parsed.detail : undefined;
28
+ return (!!detail &&
29
+ typeof detail === "object" &&
30
+ detail.error_code === UNSUPPORTED_TERMINAL_CAPACITY_ERROR_CODE);
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
12
36
  /** Bound a server-provided diagnostic snippet included in error strings. */
13
37
  function boundedDetail(text) {
14
38
  const trimmed = (text ?? "").trim();
@@ -269,7 +293,7 @@ export function createExecutorHttpClient(config) {
269
293
  };
270
294
  return mutate(`/${job.id}/complete`, body, true, job.repo_name);
271
295
  },
272
- fail(job, failure) {
296
+ async fail(job, failure) {
273
297
  const body = {
274
298
  repo_name: job.repo_name,
275
299
  claim_token: job.claim_token,
@@ -280,6 +304,26 @@ export function createExecutorHttpClient(config) {
280
304
  last_commit_sha: failure.last_commit_sha,
281
305
  telemetry: failure.telemetry,
282
306
  };
307
+ if (failure.capacity_evidence === undefined) {
308
+ return mutate(`/${job.id}/fail`, body, false, job.repo_name);
309
+ }
310
+ // BAPI-1258 — send the evidence once. If the server refuses it (unsupported
311
+ // protocol, or a malformed envelope), re-send the SAME ordinary failure
312
+ // without it, exactly once, so the job still reaches a terminal state and
313
+ // the original error kind/classification are preserved. Refused evidence
314
+ // never becomes an automatic recovery.
315
+ let first;
316
+ try {
317
+ first = await post(`/${job.id}/fail`, { ...body, capacity_evidence: failure.capacity_evidence }, job.repo_name);
318
+ }
319
+ catch {
320
+ return { outcome: "fatal_http_error" };
321
+ }
322
+ if (!isCapacityEvidenceRefusal(first.status, first.text)) {
323
+ return mapMutationResponse(first.status, first.text, false);
324
+ }
325
+ (config.warn ?? ((message) => console.error(message)))(`executor: server refused terminal capacity evidence for job ${job.id} ` +
326
+ `(HTTP ${first.status}); reporting an ordinary failure without automatic recovery.`);
283
327
  return mutate(`/${job.id}/fail`, body, false, job.repo_name);
284
328
  },
285
329
  /**
@@ -7,16 +7,33 @@
7
7
  */
8
8
  export { runExecutorCli, getExecutorUsage, parseExecutorArgs } from "./cli.js";
9
9
  export { runExecutor } from "./runner.js";
10
- // Persistent-service packaging (BAPI-688). The CLI entry plus the PURE helpers
11
- // doctor needs to enumerate and inspect generated units — doctor must share this
10
+ // Persistent-service packaging (BAPI-688), the file-generation leg of
11
+ // durable-host mode (BAPI-1137). The CLI entry plus the PURE helpers doctor
12
+ // needs to enumerate and inspect generated units — doctor must share this
12
13
  // module's naming/format contract instead of maintaining a second parser.
14
+ //
15
+ // This group grants GENERATION only. It confers no lifecycle capability, which
16
+ // is why the lifecycle exports stay in their own group below: a caller that
17
+ // imports the generator still cannot start a persistent daemon.
13
18
  export { runExecutorInstallServiceCli, getExecutorInstallServiceUsage, buildExecutorServiceArtifact, writeExecutorServiceArtifact, inspectExecutorServiceArtifact, executorLaunchdDirForHome, executorSystemdDirForHome, executorIdFromLaunchdFilename, executorIdFromSystemdFilename,
14
19
  // Programmatic, lifecycle-free plan/write seam (BAPI-779) — how
15
20
  // `install conductor` reaches the generator without shelling out to the CLI.
16
- planExecutorServiceInstall, writeExecutorServicePlan, resolvePackagedExecutorInvocation, EXECUTOR_SERVICE_BASE_URL_ENV, EXECUTOR_SERVICE_PATH_ENV, } from "./service-unit.js";
21
+ planExecutorServiceInstall, writeExecutorServicePlan, resolvePackagedExecutorInvocation,
22
+ // Durable-host credential declaration contract (BAPI-1137, R-4). doctor needs
23
+ // the shared inspector and the postcondition helper so its diagnostic and the
24
+ // generator's write check are literally the same rule; exporting the allowlist
25
+ // and the argument-contract predicate keeps the contract locally auditable
26
+ // rather than discoverable only by reading the renderer.
27
+ inspectExecutorServiceCredentialDeclaration, assertExecutorServiceCredentialPostcondition, checkExecutorServiceArgumentContract, isAllowedExecutorServiceUnitEnvKey, executorLaunchdLogPathsForId, EXECUTOR_SERVICE_BASE_URL_ENV, EXECUTOR_SERVICE_PATH_ENV, EXECUTOR_SERVICE_UNIT_ENV_ALLOWLIST, EXECUTOR_SERVICE_UNIT_FILE_MODE, } from "./service-unit.js";
17
28
  // Consented lifecycle (BAPI-779, R-1). Deliberately a SEPARATE module from the
18
29
  // generator above: importing the writer never grants the ability to start a
19
30
  // persistent daemon.
20
- export { buildExecutorServiceStartPlan, buildExecutorServiceStateProbe, formatExecutorServiceStartFailure, inspectExecutorServiceState, startExecutorService, } from "./service-lifecycle.js";
31
+ export { buildExecutorServiceStartPlan, buildExecutorServiceStateProbe, formatExecutorServiceStartFailure, inspectExecutorServiceState, startExecutorService,
32
+ // Durability prerequisites (BAPI-1137). Exported SEPARATELY from the
33
+ // generation group above, and alongside — not inside — the start group: these
34
+ // are strictly read-only probes, so importing them grants the ability to ASK
35
+ // whether this host survives logout, never the ability to change it. Nothing
36
+ // here runs `loginctl enable-linger`.
37
+ buildExecutorServiceDurabilityProbe, inspectExecutorServiceDurability, EXECUTOR_LINGER_REMEDIATION, EXECUTOR_MACOS_SESSION_REMEDIATION, } from "./service-lifecycle.js";
21
38
  export { collectExecutorInstallPreflight, formatExecutorInstallPreflight, EXECUTOR_INSTALL_REQUIRED_TOOLS, EXECUTOR_INSTALL_CLAUDE_LOGIN_REMEDIATION, } from "./install-preflight.js";
22
39
  export { runExecutorWatchCli, getExecutorWatchUsage, parseExecutorWatchArgs, } from "./watch-cli.js";