@bridge_gpt/mcp-server 0.2.60 → 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.
@@ -20,7 +20,7 @@ export const EPIC_INTEGRATION_PR_READ_TIMEOUT_MS = 5_000;
20
20
  export const EPIC_INTEGRATION_PR_WRITE_TIMEOUT_MS = 20_000;
21
21
  /** Bounded result count for the existence probe. */
22
22
  const LIST_LIMIT = 20;
23
- const PR_LIST_JSON_FIELDS = "number,headRefName,baseRefName,isDraft";
23
+ const PR_LIST_JSON_FIELDS = "number,headRefName,baseRefName,isDraft,headRefOid";
24
24
  /**
25
25
  * `epic/<KEY>` prefix a child PR's base branch carries under BAPI-949/BAPI-950.
26
26
  * Exported (BAPI-1010) so every call site that derives an epic key from a base
@@ -40,6 +40,9 @@ export const EPIC_INTEGRATION_PR_COMMANDS = [
40
40
  // "executor merge" so the generated PR body and diagnostics never mislabel
41
41
  // a done-state readiness request as a ticket merge enrichment.
42
42
  "executor wind-down",
43
+ // BAPI-1259: a durable `mark_ready` integration action performed by a local or
44
+ // repo-wide executor under explicitly configured customer authority.
45
+ "executor integration",
43
46
  ];
44
47
  /** Only the sanctioned safe fields. Never raw command output. */
45
48
  export function formatEpicIntegrationPullRequestOutcome(outcome) {
@@ -69,6 +72,10 @@ export function buildEpicIntegrationPullRequestBody(epicKey, command) {
69
72
  "Only a human merges an epic integration branch — the conductor never merges it.",
70
73
  ].join("\n");
71
74
  }
75
+ /** `--repo owner/name` when an explicit repository is bound, else nothing. */
76
+ function repoArgs(repo) {
77
+ return repo !== undefined ? ["--repo", repo] : [];
78
+ }
72
79
  function isRecord(value) {
73
80
  return typeof value === "object" && value !== null && !Array.isArray(value);
74
81
  }
@@ -100,9 +107,16 @@ async function classifyGhUnavailable(gh, cwd) {
100
107
  * (non-zero exit, unparseable output, a non-array shape, an item with an
101
108
  * invalid number, or a record whose head/base do not match) is `unavailable`,
102
109
  * never treated as absence.
110
+ *
111
+ * BAPI-1259: the probe is also CONCLUSIVE only about exactly one PR. More than
112
+ * one matching record is `probe_ambiguous`, and a page that fills the whole
113
+ * limit is `probe_inconclusive` (there may be more records than it shows) —
114
+ * the old "first matching record wins" rule could bind readiness to an
115
+ * arbitrary pull request.
103
116
  */
104
- async function probeMatchingOpenPr(gh, epicBranch, baseBranch, cwd) {
117
+ async function probeMatchingOpenPr(gh, epicBranch, baseBranch, cwd, repo) {
105
118
  const args = [
119
+ ...repoArgs(repo),
106
120
  "pr",
107
121
  "list",
108
122
  "--state",
@@ -139,6 +153,11 @@ async function probeMatchingOpenPr(gh, epicBranch, baseBranch, cwd) {
139
153
  if (parsed.length === 0) {
140
154
  return { kind: "absent" };
141
155
  }
156
+ if (parsed.length >= LIST_LIMIT) {
157
+ // A saturated page may be hiding more records; nothing here is conclusive.
158
+ return { kind: "unavailable", reason: "probe_inconclusive" };
159
+ }
160
+ const matches = [];
142
161
  for (const item of parsed) {
143
162
  if (!isRecord(item))
144
163
  continue;
@@ -152,9 +171,17 @@ async function probeMatchingOpenPr(gh, epicBranch, baseBranch, cwd) {
152
171
  head === epicBranch &&
153
172
  base === baseBranch &&
154
173
  typeof isDraft === "boolean") {
155
- return { kind: "found", number, isDraft };
174
+ const oid = item.headRefOid;
175
+ const headSha = typeof oid === "string" && /^[0-9a-f]{40}$/i.test(oid) ? oid.toLowerCase() : null;
176
+ matches.push({ number, isDraft, headSha });
156
177
  }
157
178
  }
179
+ if (matches.length > 1) {
180
+ return { kind: "unavailable", reason: "probe_ambiguous" };
181
+ }
182
+ if (matches.length === 1) {
183
+ return { kind: "found", ...matches[0] };
184
+ }
158
185
  // Records exist but none matches both head and base exactly, or the shape is
159
186
  // unexpected — never treated as absence.
160
187
  return { kind: "unavailable", reason: "probe_malformed" };
@@ -168,10 +195,11 @@ function extractPrNumberFromCreateOutput(stdout) {
168
195
  const n = Number(match[1]);
169
196
  return Number.isInteger(n) && n > 0 ? n : null;
170
197
  }
171
- async function createDraftPr(gh, epicKey, epicBranch, baseBranch, command, cwd) {
198
+ async function createDraftPr(gh, epicKey, epicBranch, baseBranch, command, cwd, repo) {
172
199
  const title = buildEpicIntegrationPullRequestTitle(epicKey);
173
200
  const body = buildEpicIntegrationPullRequestBody(epicKey, command);
174
201
  const args = [
202
+ ...repoArgs(repo),
175
203
  "pr",
176
204
  "create",
177
205
  "--draft",
@@ -200,15 +228,23 @@ async function createDraftPr(gh, epicKey, epicBranch, baseBranch, command, cwd)
200
228
  }
201
229
  return { kind: "create_failed" };
202
230
  }
203
- async function maybeMakeReady(gh, prNumber, isDraft, requestReady, cwd) {
231
+ async function maybeMakeReady(gh, prNumber, isDraft, requestReady, cwd, headBinding = {
232
+ observedHeadSha: null,
233
+ }, repo) {
204
234
  if (!requestReady)
205
235
  return "not_requested";
206
236
  if (prNumber === null)
207
237
  return "ready_failed";
238
+ if (headBinding.expectedHeadSha !== undefined) {
239
+ // Bound readiness: an unknown observed head is not the expected head.
240
+ if (headBinding.observedHeadSha !== headBinding.expectedHeadSha.toLowerCase()) {
241
+ return "ready_refused_head_drift";
242
+ }
243
+ }
208
244
  if (!isDraft)
209
245
  return "already_ready";
210
246
  try {
211
- const result = await gh(["pr", "ready", String(prNumber)], {
247
+ const result = await gh([...repoArgs(repo), "pr", "ready", String(prNumber)], {
212
248
  cwd,
213
249
  timeoutMs: EPIC_INTEGRATION_PR_WRITE_TIMEOUT_MS,
214
250
  });
@@ -219,33 +255,34 @@ async function maybeMakeReady(gh, prNumber, isDraft, requestReady, cwd) {
219
255
  }
220
256
  }
221
257
  async function ensureEpicIntegrationPullRequestInner(options) {
222
- const { epicKey, epicBranch, baseBranch, command, gh, cwd, requestReady } = options;
223
- const probe = await probeMatchingOpenPr(gh, epicBranch, baseBranch, cwd);
258
+ const { epicKey, epicBranch, baseBranch, command, gh, cwd, requestReady, expectedHeadSha, repo } = options;
259
+ const bind = (observedHeadSha) => expectedHeadSha === undefined ? undefined : { expectedHeadSha, observedHeadSha };
260
+ const probe = await probeMatchingOpenPr(gh, epicBranch, baseBranch, cwd, repo);
224
261
  if (probe.kind === "unavailable") {
225
262
  return { kind: "unavailable", reason: probe.reason };
226
263
  }
227
264
  if (probe.kind === "found") {
228
- const readiness = await maybeMakeReady(gh, probe.number, probe.isDraft, requestReady, cwd);
265
+ const readiness = await maybeMakeReady(gh, probe.number, probe.isDraft, requestReady, cwd, bind(probe.headSha), repo);
229
266
  return { kind: "already_open", reason: "already_open", prNumber: probe.number, readiness };
230
267
  }
231
268
  // Confirmed absent: create.
232
- const created = await createDraftPr(gh, epicKey, epicBranch, baseBranch, command, cwd);
269
+ const created = await createDraftPr(gh, epicKey, epicBranch, baseBranch, command, cwd, repo);
233
270
  if (created.kind === "no_commits") {
234
271
  return { kind: "deferred", reason: "no_commits_between_branches" };
235
272
  }
236
273
  if (created.kind === "created") {
237
274
  // Resolve the number through a fresh matching probe first; fall back to the
238
275
  // strict URL-derived hint only when the probe cannot confirm it.
239
- const reprobe = await probeMatchingOpenPr(gh, epicBranch, baseBranch, cwd);
276
+ const reprobe = await probeMatchingOpenPr(gh, epicBranch, baseBranch, cwd, repo);
240
277
  const prNumber = reprobe.kind === "found" ? reprobe.number : created.numberHint;
241
278
  const isDraft = reprobe.kind === "found" ? reprobe.isDraft : true;
242
- const readiness = await maybeMakeReady(gh, prNumber, isDraft, requestReady, cwd);
279
+ const readiness = await maybeMakeReady(gh, prNumber, isDraft, requestReady, cwd, bind(reprobe.kind === "found" ? reprobe.headSha : null), repo);
243
280
  return { kind: "created", reason: "created", prNumber, readiness };
244
281
  }
245
282
  // Generic create failure: resolve a possible concurrent creator once, never more.
246
- const race = await probeMatchingOpenPr(gh, epicBranch, baseBranch, cwd);
283
+ const race = await probeMatchingOpenPr(gh, epicBranch, baseBranch, cwd, repo);
247
284
  if (race.kind === "found") {
248
- const readiness = await maybeMakeReady(gh, race.number, race.isDraft, requestReady, cwd);
285
+ const readiness = await maybeMakeReady(gh, race.number, race.isDraft, requestReady, cwd, bind(race.headSha), repo);
249
286
  return { kind: "already_open", reason: "already_open", prNumber: race.number, readiness };
250
287
  }
251
288
  return { kind: "unavailable", reason: "create_unavailable" };
@@ -270,9 +307,18 @@ export async function ensureEpicIntegrationPullRequest(options) {
270
307
  * binary, timeout, spawn error) resolves a non-zero/timed-out result rather
271
308
  * than rejecting.
272
309
  */
273
- export function createProductionEpicIntegrationGhRunner(execFileImpl = nodeExecFile) {
310
+ export function createProductionEpicIntegrationGhRunner(execFileImpl = nodeExecFile,
311
+ /**
312
+ * BAPI-1259: an EXPLICIT child environment. A durable integration action
313
+ * passes the restricted environment `buildLocalMergeChildEnv` builds from the
314
+ * configured customer credential, so an ambient `gh` login, `GH_TOKEN`, or
315
+ * `GITHUB_TOKEN` can never authorize the write. Omitted, the child inherits
316
+ * the parent environment exactly as before (the wind-down observer).
317
+ */
318
+ env) {
274
319
  return (args, options) => new Promise((resolve) => {
275
320
  execFileImpl("gh", args, {
321
+ ...(env ? { env } : {}),
276
322
  cwd: options.cwd,
277
323
  timeout: options.timeoutMs,
278
324
  maxBuffer: 4 * 1024 * 1024,
@@ -0,0 +1,455 @@
1
+ /**
2
+ * Host-local epic-launch profiles (BAPI-1260).
3
+ *
4
+ * A profile is the approved, reusable binding guided setup produces for one
5
+ * epic launch target: WHICH Bridge API endpoint, WHICH repository, WHICH
6
+ * execution host and mode, HOW the packaged CLI is invoked, and REFERENCES to the
7
+ * run policy and the credential. It is what lets the launch command printed at
8
+ * the end of setup run unchanged in a fresh shell — no `export BAPI_BASE_URL`,
9
+ * no remembered working shell, no database repair.
10
+ *
11
+ * ## What it is not
12
+ *
13
+ * - **Not a secret store.** A profile holds references only. The credential
14
+ * reference names a `bapi:<repo>` entry in the user-scoped credential store;
15
+ * the key itself stays there. Validation rejects any field this module does
16
+ * not know, so a secret cannot be smuggled in under a new name.
17
+ * - **Not project configuration.** Profiles live beside the credential store in
18
+ * the USER's config directory (`$XDG_CONFIG_HOME/bridge/` or
19
+ * `~/.config/bridge/`), never in the repository, so committed configuration
20
+ * stays secret-free and host-neutral.
21
+ * - **Not an authority.** A profile selects a target; the server still decides
22
+ * admission. Changing a profile's target is a guided-setup act (it re-verifies
23
+ * access at the new target); a command-line override alone never rebinds one.
24
+ *
25
+ * Every exported operation returns a typed result and never throws.
26
+ */
27
+ import os from "node:os";
28
+ import path from "node:path";
29
+ import { chmod, mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
30
+ import { durablyReplaceCredentialStoreJson, getPrimaryCredentialStorePath, withCredentialStoreLock, } from "./credential-store.js";
31
+ import { validateHttpBaseUrl, describeBaseUrlRejection } from "./base-url.js";
32
+ import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
33
+ import { isTransientNpxEntryPath } from "./mcp-server-invocation.js";
34
+ // ---------------------------------------------------------------------------
35
+ // Types
36
+ // ---------------------------------------------------------------------------
37
+ /** The only profile schema this build reads or writes. */
38
+ export const EPIC_LAUNCH_PROFILE_VERSION = 1;
39
+ /** File name of the host-local profile store, beside `credentials.json`. */
40
+ export const EPIC_LAUNCH_PROFILE_STORE_FILENAME = "epic-launch-profiles.json";
41
+ /** Selector shape: short, lowercase, shell-safe without quoting. */
42
+ export const EPIC_LAUNCH_PROFILE_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,62}$/;
43
+ /** Where the executor that serves this launch runs. */
44
+ export const EPIC_LAUNCH_EXECUTION_MODES = ["local", "durable"];
45
+ /** One-time migration guidance for a profile this build cannot read. */
46
+ export function epicLaunchProfileSetupGuidance(name) {
47
+ return (`Re-run guided setup to (re)write it: npx -y ${MCP_PACKAGE_NAME} install conductor ` +
48
+ `--launch-profile ${EPIC_LAUNCH_PROFILE_NAME_PATTERN.test(name) ? name : "<name>"} --base-url <url> ...`);
49
+ }
50
+ // ---------------------------------------------------------------------------
51
+ // Validation (pure)
52
+ // ---------------------------------------------------------------------------
53
+ const PROFILE_KEYS = new Set([
54
+ "version",
55
+ "name",
56
+ "baseUrl",
57
+ "repoName",
58
+ "executionMode",
59
+ "host",
60
+ "runtime",
61
+ "policyRef",
62
+ "credentialRef",
63
+ "approvedAt",
64
+ ]);
65
+ const NPM_CHANNEL_SPEC = new RegExp(`^${MCP_PACKAGE_NAME.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}@[A-Za-z0-9][A-Za-z0-9.+-]{0,63}$`);
66
+ function isPlainObject(value) {
67
+ return typeof value === "object" && value !== null && !Array.isArray(value);
68
+ }
69
+ function nonEmptyString(value, max = 512) {
70
+ return typeof value === "string" && value.trim().length > 0 && value.length <= max && !/[\0\n\r]/.test(value);
71
+ }
72
+ function malformed(name, field, why) {
73
+ return {
74
+ ok: false,
75
+ kind: "profile-malformed",
76
+ message: `Epic launch profile '${name}' is unusable: ${field} ${why}. ${epicLaunchProfileSetupGuidance(name)}`,
77
+ };
78
+ }
79
+ function validateRuntime(name, raw) {
80
+ if (!isPlainObject(raw))
81
+ return malformed(name, "runtime", "is not an object");
82
+ if (raw.kind === "packaged") {
83
+ const keys = Object.keys(raw);
84
+ if (keys.some((k) => !["kind", "executable", "entryPath"].includes(k))) {
85
+ return malformed(name, "runtime", "carries an unknown field");
86
+ }
87
+ if (!nonEmptyString(raw.executable) || !path.isAbsolute(raw.executable)) {
88
+ return malformed(name, "runtime.executable", "is not an absolute path");
89
+ }
90
+ if (!nonEmptyString(raw.entryPath) || !path.isAbsolute(raw.entryPath)) {
91
+ return malformed(name, "runtime.entryPath", "is not an absolute path");
92
+ }
93
+ if (isTransientNpxEntryPath(raw.entryPath)) {
94
+ return malformed(name, "runtime.entryPath", "lives in a transient npx cache that can be pruned");
95
+ }
96
+ return { kind: "packaged", executable: raw.executable, entryPath: raw.entryPath };
97
+ }
98
+ if (raw.kind === "npm-channel") {
99
+ if (Object.keys(raw).some((k) => !["kind", "spec"].includes(k))) {
100
+ return malformed(name, "runtime", "carries an unknown field");
101
+ }
102
+ if (typeof raw.spec !== "string" || !NPM_CHANNEL_SPEC.test(raw.spec)) {
103
+ return malformed(name, "runtime.spec", `is not ${MCP_PACKAGE_NAME}@<channel-or-version>`);
104
+ }
105
+ return { kind: "npm-channel", spec: raw.spec };
106
+ }
107
+ return malformed(name, "runtime.kind", "is not 'packaged' or 'npm-channel'");
108
+ }
109
+ /**
110
+ * Validate one decoded profile. Strict: unknown fields, a GitHub or non-`bapi:`
111
+ * credential reference, a transient runtime, a relative policy path, or a
112
+ * durable-mode profile without a packaged runtime are all refused.
113
+ */
114
+ export function validateEpicLaunchProfile(raw, selector = "<unnamed>") {
115
+ if (!isPlainObject(raw))
116
+ return malformed(selector, "the entry", "is not an object");
117
+ if (raw.version !== EPIC_LAUNCH_PROFILE_VERSION) {
118
+ return {
119
+ ok: false,
120
+ kind: "profile-unsupported-version",
121
+ message: `Epic launch profile '${selector}' uses an unsupported schema version; this build reads ` +
122
+ `version ${EPIC_LAUNCH_PROFILE_VERSION} only. ${epicLaunchProfileSetupGuidance(selector)}`,
123
+ };
124
+ }
125
+ for (const key of Object.keys(raw)) {
126
+ if (!PROFILE_KEYS.has(key))
127
+ return malformed(selector, `field '${key.slice(0, 40)}'`, "is not recognized");
128
+ }
129
+ if (typeof raw.name !== "string" || !EPIC_LAUNCH_PROFILE_NAME_PATTERN.test(raw.name) || raw.name !== selector) {
130
+ return malformed(selector, "name", "does not match its selector");
131
+ }
132
+ const url = validateHttpBaseUrl(typeof raw.baseUrl === "string" ? raw.baseUrl : undefined);
133
+ if (!url.ok)
134
+ return malformed(selector, "baseUrl", `is invalid (${describeBaseUrlRejection("baseUrl", url.reason)})`);
135
+ if (url.baseUrl !== raw.baseUrl)
136
+ return malformed(selector, "baseUrl", "is not normalized");
137
+ if (!nonEmptyString(raw.repoName, 200))
138
+ return malformed(selector, "repoName", "is missing");
139
+ if (!EPIC_LAUNCH_EXECUTION_MODES.includes(raw.executionMode)) {
140
+ return malformed(selector, "executionMode", "is not 'local' or 'durable'");
141
+ }
142
+ if (!nonEmptyString(raw.host, 255))
143
+ return malformed(selector, "host", "is missing");
144
+ const runtime = validateRuntime(selector, raw.runtime);
145
+ if ("ok" in runtime)
146
+ return runtime;
147
+ if (raw.executionMode === "durable" && runtime.kind !== "packaged") {
148
+ return malformed(selector, "runtime", "must be a packaged absolute path for durable execution");
149
+ }
150
+ let policyRef = null;
151
+ if (raw.policyRef !== null && raw.policyRef !== undefined) {
152
+ const p = raw.policyRef;
153
+ if (!isPlainObject(p) || p.kind !== "file" || Object.keys(p).some((k) => k !== "kind" && k !== "path")) {
154
+ return malformed(selector, "policyRef", "is not a file reference");
155
+ }
156
+ if (!nonEmptyString(p.path) || !path.isAbsolute(p.path)) {
157
+ return malformed(selector, "policyRef.path", "is not an absolute path");
158
+ }
159
+ policyRef = { kind: "file", path: p.path };
160
+ }
161
+ const c = raw.credentialRef;
162
+ if (!isPlainObject(c) ||
163
+ c.store !== "bridge-credentials" ||
164
+ Object.keys(c).some((k) => k !== "store" && k !== "target") ||
165
+ c.target !== `bapi:${raw.repoName}`) {
166
+ // Refused rather than generalized: a launch uses exactly one Bridge
167
+ // credential, and a `github:*` (or any other) reference here would be the
168
+ // first step toward a standing code-host credential on the launch path.
169
+ return malformed(selector, "credentialRef", "must reference exactly bapi:<repoName>");
170
+ }
171
+ if (!nonEmptyString(raw.approvedAt, 64))
172
+ return malformed(selector, "approvedAt", "is missing");
173
+ return {
174
+ ok: true,
175
+ profile: {
176
+ version: EPIC_LAUNCH_PROFILE_VERSION,
177
+ name: raw.name,
178
+ baseUrl: url.baseUrl,
179
+ repoName: raw.repoName,
180
+ executionMode: raw.executionMode,
181
+ host: raw.host,
182
+ runtime,
183
+ policyRef,
184
+ credentialRef: { store: "bridge-credentials", target: `bapi:${raw.repoName}` },
185
+ approvedAt: raw.approvedAt,
186
+ },
187
+ };
188
+ }
189
+ // ---------------------------------------------------------------------------
190
+ // Store I/O
191
+ // ---------------------------------------------------------------------------
192
+ /** The profile store path, beside the primary credential store. */
193
+ export function getEpicLaunchProfileStorePath(deps) {
194
+ return path.join(path.dirname(getPrimaryCredentialStorePath(deps)), EPIC_LAUNCH_PROFILE_STORE_FILENAME);
195
+ }
196
+ async function readStoreDocument(deps) {
197
+ const storePath = getEpicLaunchProfileStorePath(deps);
198
+ let raw;
199
+ try {
200
+ raw = await deps.readFile(storePath);
201
+ }
202
+ catch (err) {
203
+ const code = err && typeof err === "object" ? err.code : undefined;
204
+ if (code === "ENOENT") {
205
+ return { ok: true, present: false, document: { version: EPIC_LAUNCH_PROFILE_VERSION, profiles: {} } };
206
+ }
207
+ return { ok: false, kind: "store-unreadable", message: `Unable to read ${storePath}.` };
208
+ }
209
+ let parsed;
210
+ try {
211
+ parsed = JSON.parse(raw);
212
+ }
213
+ catch {
214
+ return { ok: false, kind: "store-unreadable", message: `${storePath} is not valid JSON.` };
215
+ }
216
+ if (!isPlainObject(parsed) || !isPlainObject(parsed.profiles)) {
217
+ return { ok: false, kind: "store-unreadable", message: `${storePath} has no profiles object.` };
218
+ }
219
+ if (parsed.version !== EPIC_LAUNCH_PROFILE_VERSION) {
220
+ return {
221
+ ok: false,
222
+ kind: "profile-unsupported-version",
223
+ message: `${storePath} uses an unsupported schema version; this build reads version ` +
224
+ `${EPIC_LAUNCH_PROFILE_VERSION} only. ${epicLaunchProfileSetupGuidance("<name>")}`,
225
+ };
226
+ }
227
+ return { ok: true, present: true, document: { version: EPIC_LAUNCH_PROFILE_VERSION, profiles: parsed.profiles } };
228
+ }
229
+ /**
230
+ * Read and validate one profile by selector. Read-only: a missing, malformed, or
231
+ * unsupported profile is reported with guidance and never repaired here.
232
+ *
233
+ * `host`, when supplied, must match the profile's approved host — a profile
234
+ * copied to another machine names a runtime and a credential store that do not
235
+ * exist there.
236
+ */
237
+ export async function readEpicLaunchProfile(name, deps) {
238
+ if (!EPIC_LAUNCH_PROFILE_NAME_PATTERN.test(name)) {
239
+ return {
240
+ ok: false,
241
+ kind: "invalid-name",
242
+ message: "An epic launch profile name must match [a-z0-9][a-z0-9._-]{0,62}.",
243
+ };
244
+ }
245
+ const store = await readStoreDocument(deps);
246
+ if (!store.ok)
247
+ return store;
248
+ if (!Object.prototype.hasOwnProperty.call(store.document.profiles, name)) {
249
+ return {
250
+ ok: false,
251
+ kind: "profile-missing",
252
+ message: `No epic launch profile named '${name}' exists on this host. ${epicLaunchProfileSetupGuidance(name)}`,
253
+ };
254
+ }
255
+ const validated = validateEpicLaunchProfile(store.document.profiles[name], name);
256
+ if (!validated.ok)
257
+ return validated;
258
+ if (deps.host !== undefined && validated.profile.host !== deps.host) {
259
+ return {
260
+ ok: false,
261
+ kind: "profile-host-mismatch",
262
+ message: `Epic launch profile '${name}' was approved on a different host. Profiles are host-local; ` +
263
+ epicLaunchProfileSetupGuidance(name),
264
+ };
265
+ }
266
+ return validated;
267
+ }
268
+ export async function listEpicLaunchProfiles(deps) {
269
+ const store = await readStoreDocument(deps);
270
+ if (!store.ok)
271
+ return store;
272
+ if (!store.present)
273
+ return { ok: true, present: false };
274
+ const entries = Object.keys(store.document.profiles)
275
+ .sort()
276
+ .map((name) => ({ name, validation: validateEpicLaunchProfile(store.document.profiles[name], name) }));
277
+ return { ok: true, present: true, entries };
278
+ }
279
+ /**
280
+ * Persist one validated profile: locked (the shared credential-store lock) and
281
+ * durable (temp → fsync → rename). Every other profile in the store is kept,
282
+ * including ones this build cannot validate — a newer build's profile is never
283
+ * deleted by an older one.
284
+ */
285
+ export async function writeEpicLaunchProfile(profile, deps) {
286
+ const validated = validateEpicLaunchProfile(profile, profile.name);
287
+ if (!validated.ok)
288
+ return validated;
289
+ const storePath = getEpicLaunchProfileStorePath(deps);
290
+ return withCredentialStoreLock(deps, async () => {
291
+ const store = await readStoreDocument(deps);
292
+ if (!store.ok)
293
+ return store;
294
+ const existing = store.document.profiles[profile.name];
295
+ const serialized = JSON.parse(JSON.stringify(validated.profile));
296
+ if (existing !== undefined && JSON.stringify(existing) === JSON.stringify(serialized)) {
297
+ return { ok: true, path: storePath, action: "unchanged" };
298
+ }
299
+ const next = {
300
+ version: EPIC_LAUNCH_PROFILE_VERSION,
301
+ profiles: { ...store.document.profiles, [profile.name]: serialized },
302
+ };
303
+ // The durable writer is typed for the credential store, but it serializes
304
+ // any JSON value the same way (2-space indent, 0600, fsync, rename).
305
+ const written = await durablyReplaceCredentialStoreJson(storePath, next, deps);
306
+ if (!written.ok) {
307
+ return { ok: false, kind: "store-write-failed", message: `Failed to durably write ${storePath}.` };
308
+ }
309
+ return { ok: true, path: storePath, action: existing === undefined ? "created" : "updated" };
310
+ }, (error) => ({ ok: false, kind: "store-write-failed", message: error }));
311
+ }
312
+ /**
313
+ * Normalize every applicable target source and decide ONE target, before any
314
+ * credential is resolved and before any request is made.
315
+ *
316
+ * - With a selected profile, the profile's target wins, and a `--base-url` or
317
+ * `BAPI_BASE_URL` that names a DIFFERENT endpoint is a conflict — an override
318
+ * alone never rebinds a profile; guided setup does that.
319
+ * - Without one, `--base-url` and `BAPI_BASE_URL` must agree when both are set.
320
+ * - Nothing set is `target-missing`. There is no production default here:
321
+ * production is supported, but only as a deliberate selection.
322
+ *
323
+ * Messages name the source and the rule only — never a URL, which can carry
324
+ * userinfo.
325
+ */
326
+ export function resolveEpicLaunchTarget(inputs) {
327
+ const normalize = (raw, label) => {
328
+ if (raw === undefined || raw.trim() === "")
329
+ return { ok: true, value: null };
330
+ const v = validateHttpBaseUrl(raw);
331
+ if (!v.ok)
332
+ return { ok: false, message: describeBaseUrlRejection(label, v.reason) };
333
+ return { ok: true, value: v.baseUrl };
334
+ };
335
+ const flag = normalize(inputs.flagBaseUrl, "--base-url");
336
+ if (!flag.ok)
337
+ return { ok: false, kind: "target-invalid", message: flag.message };
338
+ const env = normalize(inputs.env.BAPI_BASE_URL, "BAPI_BASE_URL");
339
+ if (!env.ok)
340
+ return { ok: false, kind: "target-invalid", message: env.message };
341
+ const profile = inputs.profile;
342
+ if (profile !== null) {
343
+ if (flag.value !== null && flag.value !== profile.baseUrl) {
344
+ return {
345
+ ok: false,
346
+ kind: "target-conflict",
347
+ message: `--base-url names a different endpoint than launch profile '${profile.name}'. An override ` +
348
+ "does not rebind a profile; re-run guided setup to change its target.",
349
+ };
350
+ }
351
+ if (env.value !== null && env.value !== profile.baseUrl) {
352
+ return {
353
+ ok: false,
354
+ kind: "target-conflict",
355
+ message: `BAPI_BASE_URL names a different endpoint than launch profile '${profile.name}'. Unset it, ` +
356
+ "or re-run guided setup to change the profile's target.",
357
+ };
358
+ }
359
+ const flagRepo = inputs.flagRepo?.trim();
360
+ if (flagRepo && flagRepo !== profile.repoName) {
361
+ return {
362
+ ok: false,
363
+ kind: "repository-conflict",
364
+ message: `--repo names a different repository than launch profile '${profile.name}'.`,
365
+ };
366
+ }
367
+ return { ok: true, baseUrl: profile.baseUrl, source: "profile", repoName: profile.repoName };
368
+ }
369
+ if (flag.value !== null && env.value !== null && flag.value !== env.value) {
370
+ return {
371
+ ok: false,
372
+ kind: "target-conflict",
373
+ message: "--base-url and BAPI_BASE_URL name different endpoints. Keep one, or make them agree.",
374
+ };
375
+ }
376
+ if (flag.value !== null)
377
+ return { ok: true, baseUrl: flag.value, source: "flag" };
378
+ if (env.value !== null)
379
+ return { ok: true, baseUrl: env.value, source: "env" };
380
+ return {
381
+ ok: false,
382
+ kind: "target-missing",
383
+ message: "No Bridge API target is selected for this epic launch. Pass --launch-profile <name> (created by " +
384
+ `\`npx -y ${MCP_PACKAGE_NAME} install conductor --launch-profile <name> --base-url <url>\`), or ` +
385
+ "--base-url <url>. Nothing defaults to production.",
386
+ };
387
+ }
388
+ // ---------------------------------------------------------------------------
389
+ // Command rendering (pure)
390
+ // ---------------------------------------------------------------------------
391
+ /** POSIX single-quote a word unless it is already shell-safe. */
392
+ export function shellQuote(word) {
393
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(word))
394
+ return word;
395
+ return `'${word.replace(/'/g, `'\\''`)}'`;
396
+ }
397
+ /** The argv prefix that invokes the packaged CLI for a runtime. */
398
+ export function epicLaunchRuntimeArgv(runtime) {
399
+ return runtime.kind === "packaged"
400
+ ? [runtime.executable, runtime.entryPath]
401
+ : ["npx", "-y", runtime.spec];
402
+ }
403
+ /**
404
+ * Render the one reusable `drive-epic` command for a profile: shell-safe,
405
+ * secret-free (a profile holds no secret and nothing here reads the
406
+ * environment), and complete — the profile selector, the plan, and the policy —
407
+ * so it runs unchanged in a fresh shell.
408
+ */
409
+ export function renderEpicLaunchCommand(profile, inputs) {
410
+ const policy = inputs.policyFile ?? profile.policyRef?.path;
411
+ const argv = [
412
+ ...epicLaunchRuntimeArgv(profile.runtime),
413
+ "drive-epic",
414
+ inputs.epicKey,
415
+ "--launch-profile",
416
+ profile.name,
417
+ "--plan-file",
418
+ inputs.planFile,
419
+ ...(policy ? ["--policy-file", policy] : []),
420
+ ...(inputs.featureBranch ? ["--feature-branch", inputs.featureBranch] : []),
421
+ ...(inputs.intoBase ? ["--into-base"] : []),
422
+ ...(inputs.attended ? ["--attended"] : []),
423
+ ];
424
+ return argv.map(shellQuote).join(" ");
425
+ }
426
+ // ---------------------------------------------------------------------------
427
+ // Production I/O
428
+ // ---------------------------------------------------------------------------
429
+ /**
430
+ * Real-filesystem write deps for the profile store and the credential binding:
431
+ * `fs/promises` plus the `open` primitive that gives the shared lock and the
432
+ * fsync'd replacement. Built here rather than imported from `index.ts`, whose
433
+ * import runs the CLI dispatcher.
434
+ */
435
+ export function createNodeEpicLaunchStoreDeps(env = process.env) {
436
+ return {
437
+ env,
438
+ homedir: os.homedir,
439
+ platform: process.platform,
440
+ readFile: (p) => readFile(p, "utf-8"),
441
+ mkdir: (p, options) => mkdir(p, options),
442
+ writeFile: (p, data, options) => writeFile(p, data, options),
443
+ rename: (oldPath, newPath) => rename(oldPath, newPath),
444
+ chmod: (p, mode) => chmod(p, mode),
445
+ unlink: (p) => unlink(p),
446
+ open: async (p, flags, mode) => {
447
+ const handle = await open(p, flags, mode);
448
+ return {
449
+ writeFile: (data) => handle.writeFile(data, { encoding: "utf-8" }),
450
+ sync: () => handle.sync(),
451
+ close: () => handle.close(),
452
+ };
453
+ },
454
+ };
455
+ }
@@ -16,6 +16,7 @@ import { createDefaultExecutorDeps } from "./deps.js";
16
16
  import { scrubExecutorProcessGithubCredentials } from "./env.js";
17
17
  import { resolveBaseUrl, resolveExecutorApiAccess, EXECUTOR_BASE_URL_REQUIRED_MESSAGE, } from "./credentials.js";
18
18
  import { createExecutorHttpClient } from "./http-client.js";
19
+ import { createIntegrationActionHttpClient, } from "./integration-job.js";
19
20
  import { startExecutorSleepAssertion } from "./platform.js";
20
21
  import { runExecutor } from "./runner.js";
21
22
  import { runExecutorWatchCli } from "./watch-cli.js";
@@ -397,8 +398,16 @@ export async function runExecutorCli(argv, overrides = {}) {
397
398
  clearTimer: deps.clearTimer,
398
399
  errorLog,
399
400
  });
401
+ const createIntegrationClient = overrides.createIntegrationClient ?? createIntegrationActionHttpClient;
400
402
  const runnerSeams = {
401
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
+ }),
402
411
  };
403
412
  try {
404
413
  return await run(runtimeOptions, deps, httpClient, runnerSeams);