@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.
- package/README.md +16 -8
- package/build/commands.generated.js +3 -3
- package/build/conductor/bridge-api-client.js +353 -2
- package/build/conductor/merge-execution-contract.js +30 -5
- package/build/conductor/merge-execution-eligibility.js +10 -9
- package/build/conductor/readiness-sections.js +99 -16
- package/build/conductor/readiness.js +108 -15
- package/build/credential-store.js +156 -0
- package/build/doctor.js +94 -6
- package/build/drive-epic.js +124 -12
- package/build/epic-implementer-bin.js +13 -13
- package/build/epic-integration-pr.js +61 -15
- package/build/epic-launch-profile.js +455 -0
- package/build/executor/cli.js +9 -0
- package/build/executor/credentials.js +17 -1
- package/build/executor/http-client.js +45 -1
- package/build/executor/integration-job.js +759 -0
- package/build/executor/job-runner.js +92 -9
- package/build/executor/observation.js +114 -7
- package/build/executor/runner.js +101 -1
- package/build/executor/types.js +43 -0
- package/build/index.js +338 -114
- package/build/install-bridge-conductor.js +398 -6
- package/build/install-bridge.js +92 -16
- package/build/merge-workflow-config.js +54 -0
- package/build/merge-workflow-drift.js +35 -4
- package/build/merge-workflow.js +386 -2
- package/build/pipelines.generated.js +1 -1
- package/build/plane/supervisor.js +6 -0
- package/build/readme.generated.js +1 -1
- package/build/setup-epic.js +103 -32
- package/build/version.generated.js +3 -3
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
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
|
+
}
|
package/build/executor/cli.js
CHANGED
|
@@ -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);
|