@bridge_gpt/mcp-server 0.2.34 → 0.2.37
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 +490 -366
- package/build/agent-capabilities/probe-context.js +8 -1
- package/build/agent-capabilities/probes.js +7 -1
- package/build/agents.generated.js +1 -1
- package/build/base-url.js +79 -0
- package/build/bridge-api-urls.js +9 -0
- package/build/chain-orchestrator.js +93 -15
- package/build/claude-review-workflow.js +264 -0
- package/build/claude-user-config-doctor.js +317 -0
- package/build/cli-release.js +53 -0
- package/build/commands.generated.js +6 -5
- package/build/conductor/bridge-api-client.js +393 -4
- package/build/conductor/deny-enforcement-preflight.js +1 -0
- package/build/conductor/done-gate.js +44 -5
- package/build/conductor/epic-reconcile.js +6 -0
- package/build/conductor/install-doctor.js +462 -0
- package/build/conductor-bin.js +3 -3
- package/build/conductor-bundle-artifacts.js +37 -15
- package/build/credential-store.js +205 -4
- package/build/direct-ticket-tools.js +70 -0
- package/build/doctor.js +473 -81
- package/build/executor/cli.js +83 -6
- package/build/executor/credentials.js +39 -11
- package/build/executor/deps.js +32 -1
- package/build/executor/env.js +71 -28
- package/build/executor/heartbeat.js +138 -17
- package/build/executor/http-client.js +49 -8
- package/build/executor/index.js +4 -0
- package/build/executor/job-errors.js +4 -0
- package/build/executor/job-runner.js +530 -20
- package/build/executor/observation.js +130 -0
- package/build/executor/permissions.js +114 -8
- package/build/executor/preflight.js +127 -8
- package/build/executor/prompt-spec.js +51 -0
- package/build/executor/runner.js +23 -2
- package/build/executor/service-unit.js +876 -0
- package/build/executor/test-clock.js +75 -3
- package/build/executor/types.js +4 -18
- package/build/executor/worker-command.js +73 -12
- package/build/executor/worker-config-isolation.js +287 -0
- package/build/executor/worker-finalization.js +68 -14
- package/build/executor/worktree.js +46 -4
- package/build/index.js +1039 -237
- package/build/init.js +503 -111
- package/build/install-bridge-conductor.js +491 -0
- package/build/install-bridge.js +1188 -247
- package/build/install-reexec.js +233 -0
- package/build/launcher-config-inspection.js +351 -0
- package/build/mcp-host-config.js +11 -1
- package/build/mcp-install-state.js +32 -0
- package/build/mcp-invoke.js +49 -6
- package/build/mcp-provisioning.js +52 -13
- package/build/mcp-registration-doctor.js +14 -5
- package/build/notifications.js +553 -0
- package/build/pipeline-orchestrator.js +146 -4
- package/build/pipeline-utils.js +3 -0
- package/build/pipelines.generated.js +32 -13
- package/build/plan-execution-ledger.js +550 -0
- package/build/plan-phase-routing.js +272 -0
- package/build/plane/alembic-head.js +110 -0
- package/build/plane/build-freshness.js +167 -0
- package/build/plane/cli.js +480 -0
- package/build/plane/defaults.js +266 -0
- package/build/plane/manifest.js +377 -0
- package/build/plane/member-logs.js +147 -0
- package/build/plane/member-roster.js +147 -0
- package/build/plane/preflight.js +289 -0
- package/build/plane/shutdown.js +195 -0
- package/build/plane/status.js +125 -0
- package/build/plane/supervisor.js +569 -0
- package/build/plane/test-fakes.js +156 -0
- package/build/plane/types.js +75 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +259 -0
- package/build/setup-epic.js +772 -28
- package/build/sfcc/log-gate.js +38 -11
- package/build/sfcc/log-query.js +55 -15
- package/build/sfcc/ocapi-shape.js +51 -14
- package/build/sfcc/output.js +41 -11
- package/build/sfcc/permissions.js +24 -2
- package/build/sfcc/read-projection.js +181 -0
- package/build/sfcc/read-result.js +158 -0
- package/build/sfcc/reads-custom-object-def.js +29 -18
- package/build/sfcc/reads-site-preference.js +75 -29
- package/build/sfcc/reads-system-object.js +40 -34
- package/build/sfcc/sfcc-result.js +106 -0
- package/build/sfcc/tool-wrapper.js +56 -13
- package/build/sfcc/write-grants.js +45 -22
- package/build/sfcc/write-guard.js +21 -13
- package/build/sfcc/write-result.js +61 -14
- package/build/sfcc/write-tool-common.js +126 -32
- package/build/sfcc/writes-system-object.js +11 -50
- package/build/start-tickets-prereqs.js +129 -0
- package/build/start-tickets.js +17 -13
- package/build/ticket-backend-metadata.js +59 -0
- package/build/ticket-key-utils.js +92 -0
- package/build/tool-error-envelope.js +71 -0
- package/build/tool-surface-gating.js +72 -0
- package/build/update-status.js +102 -0
- package/build/upgrade-advice.js +47 -0
- package/build/upgrade-cli.js +412 -110
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +73 -0
- package/docs/CONDUCTOR.md +132 -6
- package/docs/install/mcp-tool-integrations.md +29 -21
- package/package.json +9 -6
- package/pipelines/implement-ticket.json +19 -4
- package/build/conductor/supervisor-judgment-python.js +0 -141
- package/build/conductor/supervisor-judgment.js +0 -215
package/build/executor/cli.js
CHANGED
|
@@ -11,10 +11,12 @@ import { VERSION } from "../version.generated.js";
|
|
|
11
11
|
import { resolveWorktrunkBinary } from "../start-tickets-prereqs.js";
|
|
12
12
|
import { resolveStartTicketsRepoName } from "../start-tickets-repo.js";
|
|
13
13
|
import { createDefaultExecutorDeps } from "./deps.js";
|
|
14
|
-
import { resolveExecutorApiAccess, } from "./credentials.js";
|
|
14
|
+
import { resolveBaseUrl, resolveExecutorApiAccess, EXECUTOR_BASE_URL_REQUIRED_MESSAGE, } from "./credentials.js";
|
|
15
15
|
import { createExecutorHttpClient } from "./http-client.js";
|
|
16
16
|
import { runExecutor } from "./runner.js";
|
|
17
17
|
import { runExecutorWatchCli } from "./watch-cli.js";
|
|
18
|
+
import { sweepOrphanedWorkerConfigDirectories } from "./worker-config-isolation.js";
|
|
19
|
+
import { pathApiForProvisioningPlatform } from "../mcp-provisioning.js";
|
|
18
20
|
/** Fixed executor timing/behavior defaults. */
|
|
19
21
|
const DEFAULT_POLL_INTERVAL_MS = 15_000;
|
|
20
22
|
const DEFAULT_HEARTBEAT_INTERVAL_MS = 60_000;
|
|
@@ -22,7 +24,12 @@ const DEFAULT_DEADMAN_MS = 120_000;
|
|
|
22
24
|
const DEFAULT_TERM_GRACE_MS = 10_000;
|
|
23
25
|
const DEFAULT_BASE_BRANCH = "main";
|
|
24
26
|
const DEFAULT_JOB_TIMEOUT_SECONDS = 20 * 60;
|
|
25
|
-
|
|
27
|
+
/**
|
|
28
|
+
* ONE job per executor process. Exported so the invariant can be pinned against
|
|
29
|
+
* the real constant rather than a copy: BAPI-688 adds more executor *processes*
|
|
30
|
+
* (persistent service units), and must never turn one process into multi-slot.
|
|
31
|
+
*/
|
|
32
|
+
export const DEFAULT_MAX_CONCURRENT = 1;
|
|
26
33
|
export function getExecutorUsage() {
|
|
27
34
|
return [
|
|
28
35
|
"Usage: mcp-server executor --repo <name> [--repo <name> ...] [options]",
|
|
@@ -32,6 +39,7 @@ export function getExecutorUsage() {
|
|
|
32
39
|
"Options:",
|
|
33
40
|
" --repo <name> Repo to serve (repeatable).",
|
|
34
41
|
" --repos=<a,b> Comma-separated repos.",
|
|
42
|
+
" --base-url <url> Bridge API endpoint (overrides BAPI_BASE_URL).",
|
|
35
43
|
" --executor-id <id> Stable executor id (default: <hostname>-<pid>).",
|
|
36
44
|
" --max-concurrent <n> Max concurrent jobs (>= 1, default 1).",
|
|
37
45
|
" --once Run a single preflight/claim cycle and exit.",
|
|
@@ -41,6 +49,12 @@ export function getExecutorUsage() {
|
|
|
41
49
|
" --base-branch <name> Base branch (default main).",
|
|
42
50
|
" --no-advisory-parser Disable the advisory stream-json parser.",
|
|
43
51
|
" -h, --help Show this help.",
|
|
52
|
+
"",
|
|
53
|
+
"Subcommands:",
|
|
54
|
+
" executor watch <job> Attach (read-only) to a running job's worker log.",
|
|
55
|
+
" executor install-service Generate a launchd/systemd user service unit for a",
|
|
56
|
+
" stable --executor-id so the executor survives reboot.",
|
|
57
|
+
" See `executor install-service --help`.",
|
|
44
58
|
].join("\n");
|
|
45
59
|
}
|
|
46
60
|
function parseIntArg(value, flag) {
|
|
@@ -63,6 +77,7 @@ export function parseExecutorArgs(argv, context) {
|
|
|
63
77
|
let deadmanMs = DEFAULT_DEADMAN_MS;
|
|
64
78
|
let baseBranch = DEFAULT_BASE_BRANCH;
|
|
65
79
|
let advisoryParserEnabled = true;
|
|
80
|
+
let baseUrl;
|
|
66
81
|
for (let i = 0; i < argv.length; i++) {
|
|
67
82
|
const arg = argv[i];
|
|
68
83
|
if (arg === "--help" || arg === "-h")
|
|
@@ -120,6 +135,12 @@ export function parseExecutorArgs(argv, context) {
|
|
|
120
135
|
return { kind: "error", message: "--base-branch requires a value" };
|
|
121
136
|
baseBranch = v;
|
|
122
137
|
}
|
|
138
|
+
else if (arg === "--base-url") {
|
|
139
|
+
const v = argv[++i];
|
|
140
|
+
if (!v)
|
|
141
|
+
return { kind: "error", message: "--base-url requires a value" };
|
|
142
|
+
baseUrl = v;
|
|
143
|
+
}
|
|
123
144
|
else if (arg === "--no-advisory-parser") {
|
|
124
145
|
advisoryParserEnabled = false;
|
|
125
146
|
}
|
|
@@ -150,9 +171,29 @@ export function parseExecutorArgs(argv, context) {
|
|
|
150
171
|
baseBranch,
|
|
151
172
|
advisoryParserEnabled,
|
|
152
173
|
defaultJobTimeoutSeconds: DEFAULT_JOB_TIMEOUT_SECONDS,
|
|
174
|
+
baseUrl,
|
|
153
175
|
};
|
|
154
176
|
return { kind: "ok", options };
|
|
155
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* Build the sweep boundary from executor deps, or `null` when this executor was
|
|
180
|
+
* constructed without the filesystem operations the sweep needs. `null` simply
|
|
181
|
+
* skips the sweep — unlike isolation itself, a missing sweep is not a safety
|
|
182
|
+
* problem, only uncollected residue.
|
|
183
|
+
*/
|
|
184
|
+
function buildWorkerConfigSweepDeps(deps) {
|
|
185
|
+
const { tmpdir, readdir, lstatPath, rmRecursive } = deps;
|
|
186
|
+
if (!tmpdir || !readdir || !lstatPath || !rmRecursive)
|
|
187
|
+
return null;
|
|
188
|
+
return {
|
|
189
|
+
tmpdir,
|
|
190
|
+
readdir,
|
|
191
|
+
lstatPath,
|
|
192
|
+
rmRecursive,
|
|
193
|
+
join: (...segments) => pathApiForProvisioningPlatform(deps.platform).join(...segments),
|
|
194
|
+
now: deps.now,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
156
197
|
function hasRepoFlag(argv) {
|
|
157
198
|
return argv.some((a) => a === "--repo" || a === "--repos" || a.startsWith("--repos="));
|
|
158
199
|
}
|
|
@@ -194,20 +235,35 @@ export async function runExecutorCli(argv, overrides = {}) {
|
|
|
194
235
|
return 1;
|
|
195
236
|
}
|
|
196
237
|
const options = parsed.options;
|
|
238
|
+
// The mutating executor requires an EXPLICIT base URL (BAPI-676): `--base-url`
|
|
239
|
+
// then `BAPI_BASE_URL`, never an implicit production default. Fail here —
|
|
240
|
+
// before any credential resolution, HTTP client, or claim side effect.
|
|
241
|
+
const baseUrlResult = resolveBaseUrl(deps.env, options.baseUrl);
|
|
242
|
+
if (!baseUrlResult.ok) {
|
|
243
|
+
errorLog(`Error: ${EXECUTOR_BASE_URL_REQUIRED_MESSAGE}`);
|
|
244
|
+
return 1;
|
|
245
|
+
}
|
|
246
|
+
const baseUrl = baseUrlResult.baseUrl;
|
|
197
247
|
// Resolve credentials for EVERY configured repo so multi-repo mode sends the
|
|
198
248
|
// correct repo-bound `X-API-Key` per claim/heartbeat/complete/fail — not just
|
|
199
249
|
// the first repo's key. Uses the (injectable) single-repo resolver per repo.
|
|
200
250
|
const resolveApi = overrides.resolveApiAccess ?? resolveExecutorApiAccess;
|
|
201
251
|
const apiKeyByRepo = {};
|
|
202
|
-
|
|
252
|
+
// BAPI-727: retain the normalized base URL from the SUCCESSFUL access result —
|
|
253
|
+
// not `options.baseUrl` (the raw, pre-validation CLI string) and not
|
|
254
|
+
// `deps.env.BAPI_BASE_URL` (which loses to an explicit `--base-url`). Worker MCP
|
|
255
|
+
// provisioning propagates this exact value, so a worker can never be pointed at a
|
|
256
|
+
// different endpoint than the executor that dispatched it. Every repo resolves
|
|
257
|
+
// against the same explicit URL, so the value is identical across the loop.
|
|
258
|
+
let resolvedBaseUrl;
|
|
203
259
|
for (const repo of options.repos) {
|
|
204
|
-
const access = await resolveApi(repo, deps);
|
|
260
|
+
const access = await resolveApi(repo, deps, baseUrl);
|
|
205
261
|
if (!access.ok) {
|
|
206
262
|
errorLog(`Error: ${access.error}`);
|
|
207
263
|
return 1;
|
|
208
264
|
}
|
|
209
265
|
apiKeyByRepo[repo] = access.apiKey;
|
|
210
|
-
|
|
266
|
+
resolvedBaseUrl = access.baseUrl;
|
|
211
267
|
}
|
|
212
268
|
const createHttpClient = overrides.createHttpClient ?? createExecutorHttpClient;
|
|
213
269
|
const httpClient = createHttpClient({
|
|
@@ -217,9 +273,30 @@ export async function runExecutorCli(argv, overrides = {}) {
|
|
|
217
273
|
mcpVersion: VERSION,
|
|
218
274
|
fetch: deps.fetch,
|
|
219
275
|
});
|
|
276
|
+
// BAPI-731: sweep isolation directories orphaned by an ungraceful earlier exit
|
|
277
|
+
// (SIGKILL, OOM, host reboot) where per-job disposal never ran. Deliberately a
|
|
278
|
+
// ONE-SHOT startup step, separate from per-job disposal: normal cleanup stays
|
|
279
|
+
// immediate, and this only reclaims residue. Never blocks claiming — a sweep
|
|
280
|
+
// failure is a bounded diagnostic, because refusing to start over leftover
|
|
281
|
+
// temp directories would be a worse failure than the leftovers.
|
|
282
|
+
const sweep = overrides.sweepOrphanedWorkerConfigDirectories ?? sweepOrphanedWorkerConfigDirectories;
|
|
283
|
+
try {
|
|
284
|
+
const isolationSweepDeps = buildWorkerConfigSweepDeps(deps);
|
|
285
|
+
if (isolationSweepDeps) {
|
|
286
|
+
const swept = await sweep(isolationSweepDeps);
|
|
287
|
+
for (const diagnostic of swept.diagnostics)
|
|
288
|
+
errorLog(diagnostic);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
errorLog("worker config isolation sweep did not complete; continuing startup");
|
|
293
|
+
}
|
|
220
294
|
const run = overrides.runExecutor ?? runExecutor;
|
|
295
|
+
// Hand the loop the VALIDATED runtime configuration: the parsed options plus the
|
|
296
|
+
// normalized base URL resolution actually settled on.
|
|
297
|
+
const runtimeOptions = { ...options, resolvedBaseUrl };
|
|
221
298
|
try {
|
|
222
|
-
return await run(
|
|
299
|
+
return await run(runtimeOptions, deps, httpClient);
|
|
223
300
|
}
|
|
224
301
|
catch (err) {
|
|
225
302
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -7,25 +7,53 @@
|
|
|
7
7
|
* diagnostics/results.
|
|
8
8
|
*/
|
|
9
9
|
import { resolveBapiCredentials } from "../credential-store.js";
|
|
10
|
-
|
|
10
|
+
import { normalizeBaseUrlCandidate } from "../base-url.js";
|
|
11
|
+
/**
|
|
12
|
+
* The documented production Bridge API base URL — the value an operator pastes
|
|
13
|
+
* into `BAPI_BASE_URL` or `--base-url` when they *do* mean production.
|
|
14
|
+
*
|
|
15
|
+
* It is NOT an implicit executor fallback (BAPI-676): the executor requires an
|
|
16
|
+
* explicit base URL and fails fast when none is supplied, so pointing at
|
|
17
|
+
* production is always a deliberate act. Other flows (MCP server,
|
|
18
|
+
* install-bridge) legitimately keep their own production defaults.
|
|
19
|
+
*/
|
|
11
20
|
export const DEFAULT_BAPI_BASE_URL = "https://bridgegpt-api.com";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Shared, secret-free prerequisite message for a missing executor base URL.
|
|
23
|
+
* Names both supported sources and states the no-production-default contract so
|
|
24
|
+
* the CLI and preflight diagnostics stay identically worded. It interpolates no
|
|
25
|
+
* configuration or credential value.
|
|
26
|
+
*/
|
|
27
|
+
export const EXECUTOR_BASE_URL_REQUIRED_MESSAGE = "no Bridge API base URL configured for the executor: pass --base-url <url> or set BAPI_BASE_URL " +
|
|
28
|
+
`(production is ${DEFAULT_BAPI_BASE_URL}). The executor never defaults to production.`;
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the executor's base URL: explicit override (`--base-url`) first, then
|
|
31
|
+
* `BAPI_BASE_URL`, then failure. A blank override is treated as absent so a
|
|
32
|
+
* nonblank environment value can still win; there is NO production fallback.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveBaseUrl(env, explicitBaseUrl) {
|
|
35
|
+
const fromFlag = normalizeBaseUrlCandidate(explicitBaseUrl);
|
|
36
|
+
if (fromFlag !== null)
|
|
37
|
+
return { ok: true, baseUrl: fromFlag };
|
|
38
|
+
const fromEnv = normalizeBaseUrlCandidate(env?.BAPI_BASE_URL);
|
|
39
|
+
if (fromEnv !== null)
|
|
40
|
+
return { ok: true, baseUrl: fromEnv };
|
|
41
|
+
return { ok: false };
|
|
18
42
|
}
|
|
19
43
|
/**
|
|
20
44
|
* Resolve API access for a single repo. Returns a structured, secret-free
|
|
21
45
|
* failure for credential-not-found, read/parse failure, missing key, or an
|
|
22
46
|
* invalid repo name — never throwing, never echoing credential-file contents.
|
|
23
47
|
*/
|
|
24
|
-
export async function resolveExecutorApiAccess(repoName, deps) {
|
|
48
|
+
export async function resolveExecutorApiAccess(repoName, deps, explicitBaseUrl) {
|
|
25
49
|
const trimmed = typeof repoName === "string" ? repoName.trim() : "";
|
|
26
50
|
if (trimmed.length === 0) {
|
|
27
51
|
return { ok: false, error: "invalid repo name: repo name is required" };
|
|
28
52
|
}
|
|
53
|
+
const url = resolveBaseUrl(deps.env, explicitBaseUrl);
|
|
54
|
+
if (!url.ok) {
|
|
55
|
+
return { ok: false, error: EXECUTOR_BASE_URL_REQUIRED_MESSAGE };
|
|
56
|
+
}
|
|
29
57
|
const storeDeps = {
|
|
30
58
|
env: deps.env,
|
|
31
59
|
homedir: deps.homedir,
|
|
@@ -43,17 +71,17 @@ export async function resolveExecutorApiAccess(repoName, deps) {
|
|
|
43
71
|
return {
|
|
44
72
|
ok: true,
|
|
45
73
|
apiKey: result.credentials.apiKey,
|
|
46
|
-
baseUrl:
|
|
74
|
+
baseUrl: url.baseUrl,
|
|
47
75
|
};
|
|
48
76
|
}
|
|
49
77
|
/**
|
|
50
78
|
* Resolve credentials for every configured repo before the executor starts
|
|
51
79
|
* claiming. Each repo resolves independently; failures are reported per-repo.
|
|
52
80
|
*/
|
|
53
|
-
export async function resolveAllExecutorApiAccess(repos, deps) {
|
|
81
|
+
export async function resolveAllExecutorApiAccess(repos, deps, explicitBaseUrl) {
|
|
54
82
|
const out = [];
|
|
55
83
|
for (const repo of repos) {
|
|
56
|
-
const access = await resolveExecutorApiAccess(repo, deps);
|
|
84
|
+
const access = await resolveExecutorApiAccess(repo, deps, explicitBaseUrl);
|
|
57
85
|
if (access.ok) {
|
|
58
86
|
out.push({ ok: true, repoName: repo, apiKey: access.apiKey, baseUrl: access.baseUrl });
|
|
59
87
|
}
|
package/build/executor/deps.js
CHANGED
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
* server's JSON-RPC channel in adjacent tooling).
|
|
9
9
|
*/
|
|
10
10
|
import { execFile, spawn } from "node:child_process";
|
|
11
|
-
import {
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { readFile, writeFile, appendFile, mkdir, mkdtemp, chmod, rm, readdir, lstat, stat, statfs, } from "node:fs/promises";
|
|
12
13
|
import os from "node:os";
|
|
13
14
|
import { promisify } from "node:util";
|
|
15
|
+
import { resolveMcpShimInvocationForRuntime } from "../mcp-server-invocation.js";
|
|
14
16
|
const execFileAsync = promisify(execFile);
|
|
15
17
|
/** Bounded subprocess output buffer (bytes). */
|
|
16
18
|
const MAX_COMMAND_BUFFER = 10 * 1024 * 1024;
|
|
@@ -83,6 +85,23 @@ export function createDefaultExecutorDeps() {
|
|
|
83
85
|
const s = await statfs(path);
|
|
84
86
|
return { bavail: Number(s.bavail), bsize: Number(s.bsize) };
|
|
85
87
|
},
|
|
88
|
+
// --- Worker config isolation filesystem boundaries (BAPI-731) ----------
|
|
89
|
+
// Bound to real `node:fs/promises` operations here so the isolation module
|
|
90
|
+
// itself never reaches for a filesystem global and stays fully unit-testable.
|
|
91
|
+
mkdtemp: (prefix) => mkdtemp(prefix),
|
|
92
|
+
chmod: (path, mode) => chmod(path, mode),
|
|
93
|
+
rmRecursive: (path) => rm(path, { recursive: true, force: true }),
|
|
94
|
+
readdir: (dirPath) => readdir(dirPath),
|
|
95
|
+
// `lstat`, never `stat`: a symlinked isolation parent or swept entry must be
|
|
96
|
+
// recognizable AS a symlink rather than reported as its target directory.
|
|
97
|
+
lstatPath: (path) => lstat(path)
|
|
98
|
+
.then((s) => ({
|
|
99
|
+
isDirectory: s.isDirectory(),
|
|
100
|
+
isSymbolicLink: s.isSymbolicLink(),
|
|
101
|
+
mtimeMs: s.mtimeMs,
|
|
102
|
+
}))
|
|
103
|
+
.catch(() => null),
|
|
104
|
+
tmpdir: () => os.tmpdir(),
|
|
86
105
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
87
106
|
now: () => Date.now(),
|
|
88
107
|
setTimer: (cb, ms) => setTimeout(cb, ms),
|
|
@@ -113,5 +132,17 @@ export function createDefaultExecutorDeps() {
|
|
|
113
132
|
},
|
|
114
133
|
log: (message) => console.error(message),
|
|
115
134
|
errorLog: (message) => console.error(message),
|
|
135
|
+
// Resolve how the worker MCP shim launches at dispatch time from THIS
|
|
136
|
+
// running executor's own on-disk build (absolute-build-path primary,
|
|
137
|
+
// npm-channel fallback) — never from process.cwd(). Mirrors
|
|
138
|
+
// `buildMcpProvisioningDeps` in `start-tickets.ts`. `nodeExecutable` stays
|
|
139
|
+
// "node" (not `process.execPath`) to preserve today's worker runtime
|
|
140
|
+
// selection; see `mcp-server-invocation.ts`'s scope-boundary note.
|
|
141
|
+
mcpServerInvocation: resolveMcpShimInvocationForRuntime({
|
|
142
|
+
moduleUrl: import.meta.url,
|
|
143
|
+
nodeExecutable: "node",
|
|
144
|
+
argv1: process.argv[1],
|
|
145
|
+
fileExists: existsSync,
|
|
146
|
+
}),
|
|
116
147
|
};
|
|
117
148
|
}
|
package/build/executor/env.js
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Secret-free executor worker environment (BAPI-534, TDD §7).
|
|
3
3
|
*
|
|
4
|
-
* v2 executor workers get
|
|
5
|
-
*
|
|
6
|
-
* `
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* v2 executor workers get Bridge MCP access by default (BAPI-724), but NOT
|
|
5
|
+
* through this environment. Access is provisioned into the worktree's
|
|
6
|
+
* `.mcp.json`/`.cursor/mcp.json` (secret-free shim registration, resolved and
|
|
7
|
+
* written BEFORE spawn — see `../mcp-provisioning.js` wiring in
|
|
8
|
+
* `job-runner.ts`), never via env vars. This builder still omits
|
|
9
|
+
* `BRIDGE_MCP_PROFILE` (an absent profile selects the intended `core` tool
|
|
10
|
+
* group default), every `BAPI_CONDUCTOR_*` key, `CONDUCTOR_NODE_PATH` (the
|
|
11
|
+
* shim's node executable is resolved into the registration's command/args at
|
|
12
|
+
* provisioning time instead), and all secret-bearing keys. It constructs a
|
|
13
|
+
* FRESH object from a strict allowlist of non-secret operational keys — it
|
|
14
|
+
* never copies arbitrary `process.env`, so credentials/tokens/headers cannot
|
|
15
|
+
* leak into the spawned `claude` process. The `mcp-invoke` shim (a separate
|
|
16
|
+
* process) resolves `BAPI_API_KEY` itself at call time from the credential
|
|
17
|
+
* store under the worker's redirected `XDG_CONFIG_HOME` — the executor seeds a
|
|
18
|
+
* repo-scoped store into the per-job isolation directory (see
|
|
19
|
+
* `worker-config-isolation.ts`) — so this worker environment never carries it.
|
|
10
20
|
*/
|
|
11
21
|
import { PR_BASE_BRANCH_ENV_VAR } from "../pr-base-contract.js";
|
|
12
22
|
/** Non-secret operational keys forwarded to the worker when present. */
|
|
@@ -22,12 +32,19 @@ const ALLOWED_ENV_KEYS = [
|
|
|
22
32
|
"LANG",
|
|
23
33
|
"LC_ALL",
|
|
24
34
|
"TERM",
|
|
25
|
-
"XDG_CONFIG_HOME",
|
|
26
35
|
"NO_COLOR",
|
|
27
36
|
];
|
|
28
37
|
/** Substrings that mark a key as secret-bearing (case-insensitive). */
|
|
29
38
|
const SECRET_NAME_FRAGMENTS = ["TOKEN", "SECRET", "PASSWORD", "API_KEY"];
|
|
30
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Explicit deny-list of known secret / MCP / conductor keys.
|
|
41
|
+
*
|
|
42
|
+
* BAPI-731 adds the two Claude configuration paths. They are denied on the
|
|
43
|
+
* INHERITED-COPY path and then set explicitly from executor-computed values
|
|
44
|
+
* below, following the `BAPI_BASE_BRANCH` precedent — an operator's
|
|
45
|
+
* `CLAUDE_CONFIG_DIR` or `XDG_CONFIG_HOME` must never be able to redirect a
|
|
46
|
+
* worker back at the operator's own configuration and undo isolation.
|
|
47
|
+
*/
|
|
31
48
|
const EXPLICIT_DENY_KEYS = [
|
|
32
49
|
"BRIDGE_MCP_PROFILE",
|
|
33
50
|
"CONDUCTOR_NODE_PATH",
|
|
@@ -36,12 +53,22 @@ const EXPLICIT_DENY_KEYS = [
|
|
|
36
53
|
"OPENAI_API_KEY",
|
|
37
54
|
"GITHUB_TOKEN",
|
|
38
55
|
"GH_TOKEN",
|
|
56
|
+
"CLAUDE_CONFIG_DIR",
|
|
57
|
+
"XDG_CONFIG_HOME",
|
|
39
58
|
];
|
|
40
59
|
/**
|
|
41
60
|
* True only when `key` is a safe, allowlisted operational key. Exported so the
|
|
42
61
|
* allowlist is locally auditable/testable. A key is allowed iff it is in the
|
|
43
62
|
* allowlist AND is not explicitly denied, does not begin with `BAPI_CONDUCTOR_`,
|
|
44
63
|
* and does not contain a secret-name fragment.
|
|
64
|
+
*
|
|
65
|
+
* The `BAPI_CONDUCTOR_` rejection is a WHOLE-PREFIX rule, not an enumerated list,
|
|
66
|
+
* which is what makes it hold for keys added later. BAPI-722's
|
|
67
|
+
* `BAPI_CONDUCTOR_DENY_PROBE_TIMEOUT_MS` is rejected twice over — it is absent from
|
|
68
|
+
* `ALLOWED_ENV_KEYS` and it carries the prefix — so it stays executor-process
|
|
69
|
+
* configuration and can never reach a spawned worker. That is deliberate: a worker
|
|
70
|
+
* runs no deny probe, and letting job-side state influence the executor's own
|
|
71
|
+
* safety-probe budget would invert the trust boundary.
|
|
45
72
|
*/
|
|
46
73
|
export function isExecutorEnvKeyAllowed(key) {
|
|
47
74
|
if (!ALLOWED_ENV_KEYS.includes(key))
|
|
@@ -55,24 +82,7 @@ export function isExecutorEnvKeyAllowed(key) {
|
|
|
55
82
|
return false;
|
|
56
83
|
return true;
|
|
57
84
|
}
|
|
58
|
-
|
|
59
|
-
* Build the secret-free worker environment: a fresh object containing only the
|
|
60
|
-
* allowlisted keys that are present (and non-undefined) in `parentEnv`, plus the
|
|
61
|
-
* literal `BRIDGE_SKIP_PREPUSH=\"1\"` (BAPI-551). This is a constant, never read
|
|
62
|
-
* from `parentEnv` — the worker's advisory local pre-push suite (~70s) is
|
|
63
|
-
* redundant for a conductor worker, since its PR is CI-gated by the merge gate
|
|
64
|
-
* anyway, and the delay was observed causing a worker to background the push and
|
|
65
|
-
* exit before it landed. `BAPI_CONDUCTOR_*` identity keys remain intentionally
|
|
66
|
-
* absent from the worker env (TDD §7) — this literal does not change that.
|
|
67
|
-
*
|
|
68
|
-
* BAPI-586: when `effectiveBaseBranch` is provided (the validated run base for
|
|
69
|
-
* this job), it is injected as `BAPI_BASE_BRANCH` so the worker can run
|
|
70
|
-
* `gh pr create --base "$BAPI_BASE_BRANCH"` deterministically. It is set from the
|
|
71
|
-
* EXPLICIT job value, NOT copied from `parentEnv` (`BAPI_BASE_BRANCH` is not in
|
|
72
|
-
* the allowlist), so any inherited value cannot override the run base and no
|
|
73
|
-
* unrelated `BAPI_*` / secret value can leak in via this key.
|
|
74
|
-
*/
|
|
75
|
-
export function buildExecutorWorkerEnv(parentEnv, effectiveBaseBranch) {
|
|
85
|
+
export function buildExecutorWorkerEnv(parentEnv, options = {}) {
|
|
76
86
|
const env = {};
|
|
77
87
|
for (const key of ALLOWED_ENV_KEYS) {
|
|
78
88
|
if (!isExecutorEnvKeyAllowed(key))
|
|
@@ -83,8 +93,41 @@ export function buildExecutorWorkerEnv(parentEnv, effectiveBaseBranch) {
|
|
|
83
93
|
}
|
|
84
94
|
}
|
|
85
95
|
env.BRIDGE_SKIP_PREPUSH = "1";
|
|
86
|
-
if (typeof effectiveBaseBranch === "string" &&
|
|
87
|
-
|
|
96
|
+
if (typeof options.effectiveBaseBranch === "string" &&
|
|
97
|
+
options.effectiveBaseBranch.length > 0) {
|
|
98
|
+
env[PR_BASE_BRANCH_ENV_VAR] = options.effectiveBaseBranch;
|
|
99
|
+
}
|
|
100
|
+
// --- Worker config isolation (BAPI-731) --------------------------------
|
|
101
|
+
// Both paths are assigned from EXPLICIT executor-computed values and are on
|
|
102
|
+
// `EXPLICIT_DENY_KEYS`, so the inherited-copy loop above can never supply them.
|
|
103
|
+
// That ordering is the isolation guarantee: an operator `CLAUDE_CONFIG_DIR` or
|
|
104
|
+
// `XDG_CONFIG_HOME` cannot redirect the worker back at the operator's own
|
|
105
|
+
// configuration (and its `projects[...].mcpServers` map).
|
|
106
|
+
if (typeof options.claudeConfigDir === "string" && options.claudeConfigDir.length > 0) {
|
|
107
|
+
env.CLAUDE_CONFIG_DIR = options.claudeConfigDir;
|
|
108
|
+
}
|
|
109
|
+
if (typeof options.xdgConfigHome === "string" && options.xdgConfigHome.length > 0) {
|
|
110
|
+
env.XDG_CONFIG_HOME = options.xdgConfigHome;
|
|
111
|
+
}
|
|
112
|
+
// The ONE deliberately-injected credential, and a genuine exception to this
|
|
113
|
+
// module's otherwise absolute secret-free rule. It is injected because
|
|
114
|
+
// isolation REMOVES the worker's previous authentication path: a worker used to
|
|
115
|
+
// authenticate as a side effect of inheriting the operator's `HOME` and
|
|
116
|
+
// `~/.claude.json`, and that inheritance is exactly the defect being closed.
|
|
117
|
+
// Per the measured inventory (`docs/claude/claude-cli-config-isolation-inventory.md`,
|
|
118
|
+
// finding 4) an operator OAuth/subscription login does NOT follow an isolated
|
|
119
|
+
// `CLAUDE_CONFIG_DIR`, while `ANTHROPIC_API_KEY` does — so this is the only
|
|
120
|
+
// supported way an isolated worker can authenticate at all.
|
|
121
|
+
//
|
|
122
|
+
// It is passed from an EXPLICIT executor-resolved value rather than copied
|
|
123
|
+
// (`ANTHROPIC_API_KEY` remains on `EXPLICIT_DENY_KEYS` and still matches the
|
|
124
|
+
// `API_KEY` secret fragment), so it travels only when the isolation strategy
|
|
125
|
+
// deliberately supplies it, never as ambient leakage. Nothing writes it to
|
|
126
|
+
// disk: the only file seeded into the isolated directory is the repo-scoped
|
|
127
|
+
// Bridge credential store (see `worker-config-isolation.ts`), which never
|
|
128
|
+
// carries the Anthropic key.
|
|
129
|
+
if (typeof options.anthropicApiKey === "string" && options.anthropicApiKey.length > 0) {
|
|
130
|
+
env.ANTHROPIC_API_KEY = options.anthropicApiKey;
|
|
88
131
|
}
|
|
89
132
|
return env;
|
|
90
133
|
}
|
|
@@ -12,40 +12,83 @@
|
|
|
12
12
|
*
|
|
13
13
|
* After abandonment the loop stops all further heartbeat / telemetry / mutation
|
|
14
14
|
* work so `runClaimedJob` never reports success after losing ownership.
|
|
15
|
+
*
|
|
16
|
+
* BAPI-731 adds two behaviors, neither of which may weaken the above:
|
|
17
|
+
*
|
|
18
|
+
* - PUSH-TRIGGERED PROMPTNESS (WS-H). The loop samples the LOCAL
|
|
19
|
+
* remote-tracking marker for the job's branch on a short sub-interval tick.
|
|
20
|
+
* When the marker first appears or changes, the worker has pushed, so the
|
|
21
|
+
* loop flushes one immediate heartbeat instead of waiting up to a full
|
|
22
|
+
* interval. This is TELEMETRY ONLY (R14): the marker is never persisted, never
|
|
23
|
+
* reported as `last_commit_sha`, and never drives an executor decision — the
|
|
24
|
+
* reconciler remains the sole consumer and the sole transition authority
|
|
25
|
+
* (`docs/claude/conductor-standing-rules.md`).
|
|
26
|
+
*
|
|
27
|
+
* - SERVER STOP (WS-B executor half). A heartbeat response carrying the
|
|
28
|
+
* server's stop request kills the owned worker like `stale_claim` does, but
|
|
29
|
+
* reports a DISTINCT outcome so the runner can send a server-cancelled
|
|
30
|
+
* terminal report rather than classifying a deliberate cancellation as a
|
|
31
|
+
* worker crash. Inert until the reconciler ticket ships the field.
|
|
32
|
+
*
|
|
33
|
+
* Every heartbeat — initial, periodic, and push-triggered — goes through the ONE
|
|
34
|
+
* `sendHeartbeat` path below, so requests can never overlap or arrive out of
|
|
35
|
+
* order.
|
|
15
36
|
*/
|
|
16
37
|
import { killOwnedProcess } from "./process.js";
|
|
38
|
+
import { normalizeHeartbeatResult } from "./types.js";
|
|
17
39
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
40
|
+
* How often the push detector samples the local remote-tracking ref. Bounded
|
|
41
|
+
* well under the heartbeat interval so a push is noticed promptly, and it is a
|
|
42
|
+
* purely local `git rev-parse` (no network), so the cost is negligible.
|
|
43
|
+
*/
|
|
44
|
+
export const PUSH_DETECT_INTERVAL_MS = 5_000;
|
|
45
|
+
/**
|
|
46
|
+
* Run the heartbeat loop until the worker is done, ownership is abandoned, or
|
|
47
|
+
* the server requests a stop. The FIRST heartbeat fires promptly (no initial
|
|
48
|
+
* interval wait).
|
|
20
49
|
*/
|
|
21
50
|
export async function runHeartbeatLoop(params) {
|
|
22
51
|
const { job, httpClient, options, deps, ownership, proc, observation } = params;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
52
|
+
const pushDetectIntervalMs = params.pushDetectIntervalMs ?? PUSH_DETECT_INTERVAL_MS;
|
|
53
|
+
// Baseline for push detection. `undefined` means "not yet sampled": the FIRST
|
|
54
|
+
// successful sample only establishes the baseline and never itself triggers a
|
|
55
|
+
// flush, so a branch that was already pushed before this loop started does not
|
|
56
|
+
// manufacture a spurious immediate beat.
|
|
57
|
+
let remoteMarker;
|
|
58
|
+
let remoteBaselineEstablished = false;
|
|
59
|
+
/**
|
|
60
|
+
* The single heartbeat send path. Returns the loop outcome when the beat is
|
|
61
|
+
* terminal, or `null` to continue. Never called concurrently with itself:
|
|
62
|
+
* every caller awaits it before doing anything else.
|
|
63
|
+
*/
|
|
64
|
+
const sendHeartbeat = async () => {
|
|
31
65
|
const git = await params.collectTelemetry();
|
|
32
66
|
observation.setGitTelemetry(git);
|
|
33
67
|
const residue = observation.snapshot();
|
|
34
|
-
const
|
|
68
|
+
const result = normalizeHeartbeatResult(await httpClient.heartbeat(job, {
|
|
69
|
+
// Unconditionally the LOCAL head — never the remote marker. This is what
|
|
70
|
+
// preserves the BAPI-704 semantic that a zero-commit job reports the base
|
|
71
|
+
// SHA paired with `local_commit_count: 0`.
|
|
35
72
|
local_commit_count: git.local_commit_count,
|
|
36
73
|
last_commit_sha: git.last_commit_sha,
|
|
37
74
|
telemetry: residue,
|
|
38
|
-
});
|
|
39
|
-
|
|
75
|
+
}));
|
|
76
|
+
// Checked BEFORE the outcome switch: a stop request is authoritative even
|
|
77
|
+
// when it rides along with a non-`updated` outcome.
|
|
78
|
+
if (result.stop_requested === true) {
|
|
79
|
+
killOwnedProcess(proc, "server_stop");
|
|
80
|
+
return { kind: "server_stop" };
|
|
81
|
+
}
|
|
82
|
+
if (result.outcome === "updated") {
|
|
40
83
|
ownership.lastSuccessfulHeartbeatAt = deps.now();
|
|
41
|
-
|
|
84
|
+
return null;
|
|
42
85
|
}
|
|
43
|
-
if (outcome === "stale_claim") {
|
|
86
|
+
if (result.outcome === "stale_claim") {
|
|
44
87
|
// Definitive dead claim: kill the worker and stop touching the worktree.
|
|
45
88
|
killOwnedProcess(proc, "stale_claim");
|
|
46
89
|
ownership.abandoned = true;
|
|
47
90
|
ownership.abandonReason = "stale_claim";
|
|
48
|
-
|
|
91
|
+
return { kind: "abandoned" };
|
|
49
92
|
}
|
|
50
93
|
// retry_later / fatal_http_error: keep the worker alive within the dead-man
|
|
51
94
|
// window; SIGKILL + abandon once 2 minutes pass without a successful beat.
|
|
@@ -53,7 +96,85 @@ export async function runHeartbeatLoop(params) {
|
|
|
53
96
|
killOwnedProcess(proc, "deadman");
|
|
54
97
|
ownership.abandoned = true;
|
|
55
98
|
ownership.abandonReason = "deadman";
|
|
56
|
-
|
|
99
|
+
return { kind: "abandoned" };
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Wait out one heartbeat interval in short push-detector ticks, flushing an
|
|
105
|
+
* immediate beat if the worker pushes meanwhile. Returns an outcome when a
|
|
106
|
+
* flushed beat was terminal.
|
|
107
|
+
*/
|
|
108
|
+
const waitIntervalWatchingForPush = async () => {
|
|
109
|
+
if (!params.collectRemoteMarker || pushDetectIntervalMs <= 0) {
|
|
110
|
+
await deps.sleep(options.heartbeatIntervalMs);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
let waited = 0;
|
|
114
|
+
while (waited < options.heartbeatIntervalMs) {
|
|
115
|
+
const slice = Math.min(pushDetectIntervalMs, options.heartbeatIntervalMs - waited);
|
|
116
|
+
await deps.sleep(slice);
|
|
117
|
+
waited += slice;
|
|
118
|
+
if (ownership.abandoned || params.isDone())
|
|
119
|
+
return null;
|
|
120
|
+
let sample;
|
|
121
|
+
try {
|
|
122
|
+
sample = await params.collectRemoteMarker();
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Degrade-don't-throw: a failed local ref read is an observability gap,
|
|
126
|
+
// never a job failure.
|
|
127
|
+
sample = undefined;
|
|
128
|
+
}
|
|
129
|
+
if (sample === undefined)
|
|
130
|
+
continue;
|
|
131
|
+
if (!remoteBaselineEstablished) {
|
|
132
|
+
remoteBaselineEstablished = true;
|
|
133
|
+
remoteMarker = sample;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (sample === remoteMarker)
|
|
137
|
+
continue;
|
|
138
|
+
// The marker moved: the worker pushed. A rewind/force-push moves it too and
|
|
139
|
+
// is treated identically — a flush trigger, never a gate or a decision.
|
|
140
|
+
remoteMarker = sample;
|
|
141
|
+
const outcome = await sendHeartbeat();
|
|
142
|
+
if (outcome)
|
|
143
|
+
return outcome;
|
|
144
|
+
if (ownership.abandoned || params.isDone())
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
};
|
|
149
|
+
let first = true;
|
|
150
|
+
while (!ownership.abandoned && !params.isDone()) {
|
|
151
|
+
if (!first) {
|
|
152
|
+
const waitOutcome = await waitIntervalWatchingForPush();
|
|
153
|
+
if (waitOutcome)
|
|
154
|
+
return waitOutcome;
|
|
155
|
+
if (ownership.abandoned || params.isDone())
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
// Establish the push baseline alongside the prompt first beat, so an
|
|
160
|
+
// already-pushed branch does not look like a brand-new push one tick later.
|
|
161
|
+
if (params.collectRemoteMarker) {
|
|
162
|
+
try {
|
|
163
|
+
const sample = await params.collectRemoteMarker();
|
|
164
|
+
if (sample !== undefined) {
|
|
165
|
+
remoteMarker = sample;
|
|
166
|
+
}
|
|
167
|
+
remoteBaselineEstablished = true;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
remoteBaselineEstablished = true;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
57
173
|
}
|
|
174
|
+
first = false;
|
|
175
|
+
const outcome = await sendHeartbeat();
|
|
176
|
+
if (outcome)
|
|
177
|
+
return outcome;
|
|
58
178
|
}
|
|
179
|
+
return ownership.abandoned ? { kind: "abandoned" } : { kind: "done" };
|
|
59
180
|
}
|