@bridge_gpt/mcp-server 0.2.37 → 0.2.39
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 +193 -16
- package/build/agent-capabilities/probe-context.js +2 -1
- package/build/agent-launchers/claude-executor-adapter.js +392 -0
- package/build/agent-launchers/executor-adapter-inspection.js +163 -0
- package/build/agent-launchers/executor-adapter-registry.js +90 -0
- package/build/agent-launchers/executor-adapter.js +136 -0
- package/build/agent-registry.js +28 -0
- package/build/agents.generated.js +1 -1
- package/build/claude-login.js +85 -0
- package/build/claude-user-config-doctor.js +59 -33
- package/build/commands.generated.js +12 -11
- package/build/conduct-epic/bridge-client.js +345 -0
- package/build/conduct-epic/checkpoint-store.js +423 -0
- package/build/conduct-epic/cli.js +1732 -0
- package/build/conduct-epic/lock.js +302 -0
- package/build/conduct-epic/pr-state.js +197 -0
- package/build/conduct-epic/spawn.js +101 -0
- package/build/conductor/bridge-api-client.js +37 -2
- package/build/conductor/doctor.js +11 -1
- package/build/conductor/install-doctor.js +184 -10
- package/build/conductor-bin.js +7 -7
- package/build/credential-store.js +10 -4
- package/build/credentials-cli.js +34 -19
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +579 -88
- package/build/executor/agent-identity.js +32 -0
- package/build/executor/cli.js +50 -39
- package/build/executor/deps.js +15 -1
- package/build/executor/env.js +56 -45
- package/build/executor/index.js +9 -1
- package/build/executor/install-preflight.js +138 -0
- package/build/executor/job-errors.js +200 -0
- package/build/executor/job-runner.js +619 -268
- package/build/executor/observation.js +165 -0
- package/build/executor/permissions.js +163 -36
- package/build/executor/platform.js +54 -0
- package/build/executor/preflight.js +175 -67
- package/build/executor/process.js +39 -7
- package/build/executor/runner.js +19 -0
- package/build/executor/service-lifecycle.js +269 -0
- package/build/executor/service-unit.js +121 -12
- package/build/executor/stale-artifacts.js +70 -0
- package/build/executor/test-clock.js +188 -24
- package/build/executor/worker-command.js +22 -58
- package/build/executor/worker-log.js +82 -0
- package/build/executor/worktree-lock.js +264 -0
- package/build/index.js +527 -357
- package/build/install-bridge-conductor.js +376 -38
- package/build/install-bridge.js +414 -114
- package/build/install-doctor.js +13 -0
- package/build/install-reexec.js +5 -3
- package/build/mcp-install-state.js +130 -0
- package/build/mcp-profile.js +11 -2
- package/build/mcp-provisioning.js +15 -0
- package/build/merge-pull-request.js +562 -0
- package/build/phase-result-artifacts.js +450 -0
- package/build/pipeline-orchestrator.js +4 -0
- package/build/pipeline-utils.js +16 -0
- package/build/pipelines.generated.js +7 -7
- package/build/plane/preflight.js +18 -14
- package/build/plane/supervisor.js +8 -1
- package/build/project-root.js +34 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +36 -9
- package/build/setup-epic.js +57 -4
- package/build/sfcc/ocapi-shape.js +23 -4
- package/build/sfcc/permissions.js +25 -6
- package/build/sfcc/read-body.js +92 -0
- package/build/sfcc/read-projection.js +6 -2
- package/build/sfcc/reads-custom-object-def.js +33 -21
- package/build/sfcc/reads-site-preference.js +20 -7
- package/build/sfcc/reads-system-object.js +11 -5
- package/build/sfcc/register.js +61 -23
- package/build/sfcc/registration-inventory.js +89 -0
- package/build/sfcc/setup-status.js +18 -34
- package/build/sfcc/tool-wrapper.js +294 -17
- package/build/sfcc/write-grants.js +33 -1
- package/build/sfcc/write-guard.js +41 -12
- package/build/sfcc/write-result.js +16 -7
- package/build/sfcc/writes-custom-object-def.js +12 -4
- package/build/sfcc/writes-site-preference.js +6 -1
- package/build/sfcc/writes-system-object.js +11 -2
- package/build/sfcc/writes.js +13 -8
- package/build/start-tickets-prereqs.js +25 -15
- package/build/start-tickets.js +123 -21
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +9 -3
- package/docs/install/mcp-tool-integrations.md +54 -9
- package/docs/install/sfcc-integration.md +71 -24
- package/package.json +3 -3
- package/build/executor/worker-config-isolation.js +0 -287
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consented executor service lifecycle (BAPI-779, ratified decision R-1).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS MODULE EXISTS SEPARATELY. `service-unit.ts` carries an invariant
|
|
5
|
+
* titled "Never manages service lifecycle": it renders and writes a unit file
|
|
6
|
+
* and runs no `launchctl` / `systemctl`. R-1 (2026-08-14) deliberately amends
|
|
7
|
+
* the *product* rule — `install conductor` may now start the service the
|
|
8
|
+
* generator just wrote — while keeping the *module* rule intact. So lifecycle
|
|
9
|
+
* lives here, behind its own entry points, and the generator stays lifecycle
|
|
10
|
+
* free. A caller that only wants a unit written physically cannot start a
|
|
11
|
+
* persistent daemon by accident, because it never imports this module.
|
|
12
|
+
*
|
|
13
|
+
* Four rules shape everything below.
|
|
14
|
+
*
|
|
15
|
+
* 1. **One attempt, never a retry loop.** Each planned command runs at most
|
|
16
|
+
* once, and the first failure stops the sequence. A service manager refusing
|
|
17
|
+
* to bootstrap a unit is a state to report, not a race to win — retrying
|
|
18
|
+
* turns a clear degradation into a hang, and the operator has the exact
|
|
19
|
+
* manual command either way.
|
|
20
|
+
* 2. **Shell-free by construction.** Every command is an
|
|
21
|
+
* {@link ExecutorServiceCommand} — an executable plus an argument ARRAY.
|
|
22
|
+
* There is no code path that concatenates a command string and hands it to a
|
|
23
|
+
* shell, so a unit path or unit name containing shell metacharacters cannot
|
|
24
|
+
* become syntax.
|
|
25
|
+
* 3. **Sanitized results only.** A failure result carries the stage label, the
|
|
26
|
+
* exit code, and the manual command. It never carries runner stdout, stderr,
|
|
27
|
+
* thrown exception text, or environment values — the command's output is
|
|
28
|
+
* untrusted for reporting purposes even though the command itself is ours.
|
|
29
|
+
* 4. **Local success is NOT readiness.** `startExecutorService` resolving `ok`
|
|
30
|
+
* means the service manager accepted the unit on THIS host. It says nothing
|
|
31
|
+
* about whether the Bridge server has observed an executor claim, which is
|
|
32
|
+
* the only thing that may produce `executor_ready`. The two are reported as
|
|
33
|
+
* separate facts everywhere, and nothing in this module may be used to
|
|
34
|
+
* derive readiness.
|
|
35
|
+
*
|
|
36
|
+
* The command forms follow the conventions the scheduler backends already use
|
|
37
|
+
* (`scheduler-backends/launchd.ts`, `scheduler-backends/systemd-user.ts`), so
|
|
38
|
+
* the executor and the scheduler address user-domain services the same way.
|
|
39
|
+
*/
|
|
40
|
+
import { posixShellQuote } from "../scheduler-backends/escaping.js";
|
|
41
|
+
/**
|
|
42
|
+
* Characters that need no shell quoting. Deliberately conservative — anything
|
|
43
|
+
* outside this set is quoted rather than reasoned about.
|
|
44
|
+
*/
|
|
45
|
+
const SHELL_SAFE_TOKEN_RE = /^[A-Za-z0-9_./:@%+=-]+$/;
|
|
46
|
+
/**
|
|
47
|
+
* Quote a token only when it needs it.
|
|
48
|
+
*
|
|
49
|
+
* The output is copy-pasted by a human, so `launchctl bootstrap gui/501 /path`
|
|
50
|
+
* beats `'launchctl' 'bootstrap' 'gui/501' '/path'` for legibility. Safety is
|
|
51
|
+
* unchanged: anything containing a shell metacharacter — which a unit name or
|
|
52
|
+
* an executor id legitimately can, since darwin allows those characters in
|
|
53
|
+
* filenames — falls through to `posixShellQuote`.
|
|
54
|
+
*/
|
|
55
|
+
function shellToken(value) {
|
|
56
|
+
return SHELL_SAFE_TOKEN_RE.test(value) ? value : posixShellQuote(value);
|
|
57
|
+
}
|
|
58
|
+
/** Render one descriptor as a shell-safe command line. */
|
|
59
|
+
function renderCommand(command) {
|
|
60
|
+
return [command.executable, ...command.args].map(shellToken).join(" ");
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Build the platform-specific start sequence.
|
|
64
|
+
*
|
|
65
|
+
* darwin: `launchctl bootstrap gui/<uid> <plist-path>`
|
|
66
|
+
* linux: `systemctl --user daemon-reload`, then
|
|
67
|
+
* `systemctl --user enable --now <unit-name>`
|
|
68
|
+
*
|
|
69
|
+
* PURE — it consults no runner, no environment, and no filesystem, so an
|
|
70
|
+
* unsupported or incompletely described target is refused before anything could
|
|
71
|
+
* be executed.
|
|
72
|
+
*/
|
|
73
|
+
export function buildExecutorServiceStartPlan(target) {
|
|
74
|
+
if (target.platform !== "darwin" && target.platform !== "linux") {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
error: `platform '${String(target.platform)}' has no supported executor service start ` +
|
|
78
|
+
"sequence (consented start is supported on darwin and linux only).",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (typeof target.serviceIdentifier !== "string" || target.serviceIdentifier.length === 0) {
|
|
82
|
+
return { ok: false, error: "the service identifier is missing, so no unit can be addressed." };
|
|
83
|
+
}
|
|
84
|
+
if (target.platform === "darwin") {
|
|
85
|
+
if (typeof target.unitPath !== "string" || target.unitPath.length === 0) {
|
|
86
|
+
return { ok: false, error: "the unit path is missing, so launchd has no unit to bootstrap." };
|
|
87
|
+
}
|
|
88
|
+
if (typeof target.uid !== "number" || !Number.isInteger(target.uid) || target.uid < 0) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
error: "a numeric uid is required for the launchd domain target `gui/<uid>`; " +
|
|
92
|
+
"none was supplied.",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const commands = [
|
|
96
|
+
{
|
|
97
|
+
stage: "launchctl bootstrap",
|
|
98
|
+
executable: "launchctl",
|
|
99
|
+
args: ["bootstrap", `gui/${target.uid}`, target.unitPath],
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
return {
|
|
103
|
+
ok: true,
|
|
104
|
+
plan: {
|
|
105
|
+
platform: "darwin",
|
|
106
|
+
serviceManager: "launchd",
|
|
107
|
+
commands,
|
|
108
|
+
manualCommand: commands.map(renderCommand).join(" && "),
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const commands = [
|
|
113
|
+
{
|
|
114
|
+
stage: "systemctl --user daemon-reload",
|
|
115
|
+
executable: "systemctl",
|
|
116
|
+
args: ["--user", "daemon-reload"],
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
stage: "systemctl --user enable --now",
|
|
120
|
+
executable: "systemctl",
|
|
121
|
+
args: ["--user", "enable", "--now", target.serviceIdentifier],
|
|
122
|
+
},
|
|
123
|
+
];
|
|
124
|
+
return {
|
|
125
|
+
ok: true,
|
|
126
|
+
plan: {
|
|
127
|
+
platform: "linux",
|
|
128
|
+
serviceManager: "systemd-user",
|
|
129
|
+
commands,
|
|
130
|
+
// The two rendered commands joined by ` && ` — the same descriptors the
|
|
131
|
+
// runner receives, so the printed and the invoked forms cannot diverge.
|
|
132
|
+
manualCommand: commands.map(renderCommand).join(" && "),
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Run the planned start sequence: each command at most once, in order, stopping
|
|
138
|
+
* at the first non-zero exit or runner refusal.
|
|
139
|
+
*
|
|
140
|
+
* A runner that throws is ONE attempted failure, not a signal to retry — a
|
|
141
|
+
* refusal (`ENOENT` on `systemctl`, a sandbox denial, a spawn error) is a
|
|
142
|
+
* durable property of the host, so re-running it would only spend time.
|
|
143
|
+
* `exitCode` is `null` in that case because no exit code exists.
|
|
144
|
+
*/
|
|
145
|
+
export async function startExecutorService(plan, run) {
|
|
146
|
+
let commandsRun = 0;
|
|
147
|
+
for (const command of plan.commands) {
|
|
148
|
+
let exitCode;
|
|
149
|
+
commandsRun++;
|
|
150
|
+
try {
|
|
151
|
+
const result = await run(command.executable, command.args);
|
|
152
|
+
exitCode = typeof result?.exitCode === "number" ? result.exitCode : 1;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// The thrown value is deliberately discarded: exception text can carry a
|
|
156
|
+
// path, an environment value, or a spawn detail that has no place in
|
|
157
|
+
// operator output.
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
manualCommand: plan.manualCommand,
|
|
161
|
+
failedStage: command.stage,
|
|
162
|
+
exitCode: null,
|
|
163
|
+
reason: "runner-refused",
|
|
164
|
+
commandsRun,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
if (exitCode !== 0) {
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
manualCommand: plan.manualCommand,
|
|
171
|
+
failedStage: command.stage,
|
|
172
|
+
exitCode,
|
|
173
|
+
reason: "nonzero-exit",
|
|
174
|
+
commandsRun,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { ok: true, manualCommand: plan.manualCommand, commandsRun };
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Render a start failure as one secret-free operator line.
|
|
182
|
+
*
|
|
183
|
+
* Exported so the installer and any future diagnostic print byte-identical
|
|
184
|
+
* text; two copies of this sentence would drift.
|
|
185
|
+
*/
|
|
186
|
+
export function formatExecutorServiceStartFailure(result) {
|
|
187
|
+
const detail = result.reason === "runner-refused"
|
|
188
|
+
? "the command could not be run on this host"
|
|
189
|
+
: `exit code ${result.exitCode}`;
|
|
190
|
+
return `service start did not complete: '${result.failedStage}' failed (${detail}).`;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Build the strictly read-only state probe for a target.
|
|
194
|
+
*
|
|
195
|
+
* darwin: `launchctl print gui/<uid>/<label>` — the same query
|
|
196
|
+
* `scheduler-backends/launchd.ts` uses for status.
|
|
197
|
+
* linux: `systemctl --user is-active <unit>` — a non-mutating query whose
|
|
198
|
+
* stdout is a single state token, which is what makes a deterministic mapping
|
|
199
|
+
* possible (`systemctl --user status` would require parsing a paragraph).
|
|
200
|
+
*/
|
|
201
|
+
export function buildExecutorServiceStateProbe(target) {
|
|
202
|
+
if (typeof target.serviceIdentifier !== "string" || target.serviceIdentifier.length === 0) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
if (target.platform === "darwin") {
|
|
206
|
+
if (typeof target.uid !== "number" || !Number.isInteger(target.uid) || target.uid < 0) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
stage: "launchctl print",
|
|
211
|
+
executable: "launchctl",
|
|
212
|
+
args: ["print", `gui/${target.uid}/${target.serviceIdentifier}`],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
if (target.platform === "linux") {
|
|
216
|
+
return {
|
|
217
|
+
stage: "systemctl --user is-active",
|
|
218
|
+
executable: "systemctl",
|
|
219
|
+
args: ["--user", "is-active", target.serviceIdentifier],
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
/** The systemd state tokens this module maps; anything else is `unknown`. */
|
|
225
|
+
const SYSTEMD_INACTIVE_TOKENS = new Set([
|
|
226
|
+
"inactive",
|
|
227
|
+
"failed",
|
|
228
|
+
"activating",
|
|
229
|
+
"deactivating",
|
|
230
|
+
"reloading",
|
|
231
|
+
]);
|
|
232
|
+
/**
|
|
233
|
+
* Inspect local service state. STRICTLY read-only: it issues exactly one probe
|
|
234
|
+
* and never starts, enables, reloads, bootstraps, or writes anything.
|
|
235
|
+
*
|
|
236
|
+
* Returns `unknown` rather than guessing whenever the probe cannot be built,
|
|
237
|
+
* the runner refuses, or the output is not a token this module recognizes — a
|
|
238
|
+
* diagnostic that invents a state is worse than one that admits ignorance.
|
|
239
|
+
*/
|
|
240
|
+
export async function inspectExecutorServiceState(target, run) {
|
|
241
|
+
const probe = buildExecutorServiceStateProbe(target);
|
|
242
|
+
if (!probe)
|
|
243
|
+
return { state: "unknown", probe: null };
|
|
244
|
+
let exitCode;
|
|
245
|
+
let stdout;
|
|
246
|
+
try {
|
|
247
|
+
const result = await run(probe.executable, probe.args);
|
|
248
|
+
exitCode = typeof result?.exitCode === "number" ? result.exitCode : 1;
|
|
249
|
+
stdout = typeof result?.stdout === "string" ? result.stdout : "";
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return { state: "unknown", probe };
|
|
253
|
+
}
|
|
254
|
+
if (target.platform === "darwin") {
|
|
255
|
+
// `launchctl print` succeeds only for a job the domain actually holds, so a
|
|
256
|
+
// zero exit is the loaded/active signal and a non-zero exit is "no such
|
|
257
|
+
// job". launchd exposes no cheap loaded-but-stopped distinction here, so
|
|
258
|
+
// this deliberately does not claim `inactive`.
|
|
259
|
+
return { state: exitCode === 0 ? "active" : "not-found", probe };
|
|
260
|
+
}
|
|
261
|
+
const token = stdout.trim().split(/\s+/)[0]?.toLowerCase() ?? "";
|
|
262
|
+
if (token === "active")
|
|
263
|
+
return { state: "active", probe };
|
|
264
|
+
if (token === "unknown")
|
|
265
|
+
return { state: "not-found", probe };
|
|
266
|
+
if (SYSTEMD_INACTIVE_TOKENS.has(token))
|
|
267
|
+
return { state: "inactive", probe };
|
|
268
|
+
return { state: "unknown", probe };
|
|
269
|
+
}
|
|
@@ -10,9 +10,16 @@
|
|
|
10
10
|
*
|
|
11
11
|
* 1. **Never embeds credentials.** A generated unit carries only the validated
|
|
12
12
|
* base URL, the generating shell's `PATH`, the repository list, the executor
|
|
13
|
-
* id, and the packaged invocation. `BAPI_API_KEY`
|
|
14
|
-
* credential-store
|
|
15
|
-
*
|
|
13
|
+
* id, and the packaged invocation. `BAPI_API_KEY` resolves at *launch* time
|
|
14
|
+
* through the executor's existing credential-store seam, so an artifact left
|
|
15
|
+
* on disk is not a secret. This holds BY CONSTRUCTION rather than by
|
|
16
|
+
* scrubbing: {@link ExecutorServiceRenderInput} has no field capable of
|
|
17
|
+
* carrying a credential, and no renderer reads ambient environment state.
|
|
18
|
+
* `CLAUDE_CODE_OAUTH_TOKEN`, when the operator has exported one into the
|
|
19
|
+
* service's own launch-time environment, forwards unchanged into the direct
|
|
20
|
+
* `claude` worker environment (BAPI-791) — Bridge never stores, resolves, or
|
|
21
|
+
* diagnoses it, and there is no `anthropic:oauth` bundle any more.
|
|
22
|
+
* `ANTHROPIC_API_KEY` is never forwarded to a worker under any circumstance.
|
|
16
23
|
* `PATH` is baked deliberately (the scheduler backends set the precedent —
|
|
17
24
|
* see `BAKED_ENV_VAR_NAMES` in `../scheduler-backends/escaping.js`,
|
|
18
25
|
* BAPI-327): under launchd / `systemd --user` the environment PATH is the
|
|
@@ -27,6 +34,24 @@
|
|
|
27
34
|
* not run `launchctl`, `systemctl`, `loginctl`, or `schtasks` — starting,
|
|
28
35
|
* stopping, and enabling the unit stay operator-owned.
|
|
29
36
|
*
|
|
37
|
+
* R-1 AMENDMENT (BAPI-779, ratified 2026-08-14). This invariant continues to
|
|
38
|
+
* hold for *this module* — the generator and its write primitives still run
|
|
39
|
+
* no lifecycle command, and `runExecutorInstallServiceCli` still only writes
|
|
40
|
+
* a file. What the amendment changes is that lifecycle management is no
|
|
41
|
+
* longer forbidden to Bridge outright: consented, one-shot start and
|
|
42
|
+
* read-only state inspection now live in a SEPARATE module,
|
|
43
|
+
* {@link ../executor/service-lifecycle.js `executor/service-lifecycle.ts`},
|
|
44
|
+
* which `install conductor` drives behind its own independent consent. The
|
|
45
|
+
* split is the point — a caller that wants a unit written cannot
|
|
46
|
+
* accidentally start a persistent daemon, because the two capabilities are
|
|
47
|
+
* in different modules with different entry points.
|
|
48
|
+
*
|
|
49
|
+
* The programmatic seam that split makes possible is
|
|
50
|
+
* {@link planExecutorServiceInstall} + {@link writeExecutorServicePlan}:
|
|
51
|
+
* pure plan construction separated from the filesystem write, with consent
|
|
52
|
+
* left entirely to the caller so the standalone CLI and the conductor can
|
|
53
|
+
* apply their own independent consent policies over the same primitives.
|
|
54
|
+
*
|
|
30
55
|
* The artifact type is deliberately executor-local rather than the scheduler's
|
|
31
56
|
* {@link ../scheduler-backends/types.js#GeneratedArtifact}: that shape carries a
|
|
32
57
|
* loose `kind: string`, whereas the executor wants a *discriminating* literal
|
|
@@ -555,9 +580,11 @@ export function getExecutorInstallServiceUsage() {
|
|
|
555
580
|
"The generated unit contains only the base URL, the generating shell's PATH, the",
|
|
556
581
|
"repositories, the executor id, and the packaged invocation. PATH is baked because",
|
|
557
582
|
"the service manager's minimal default cannot resolve the `claude` worker command.",
|
|
558
|
-
"BAPI_API_KEY
|
|
559
|
-
"
|
|
560
|
-
"
|
|
583
|
+
"BAPI_API_KEY is deliberately omitted — it resolves at launch time from the",
|
|
584
|
+
"environment or the user-scoped bapi:<repo> credential store. A worker",
|
|
585
|
+
"authenticates through this host's own interactive `claude login`, or, headlessly,",
|
|
586
|
+
"through a CLAUDE_CODE_OAUTH_TOKEN you export into the service's own environment",
|
|
587
|
+
"yourself; Bridge only forwards that value unchanged and never stores it.",
|
|
561
588
|
"",
|
|
562
589
|
"This command writes a file and nothing else. Starting, stopping, enabling, and",
|
|
563
590
|
"removing the service stay operator-managed via launchctl / systemctl --user /",
|
|
@@ -636,6 +663,79 @@ export async function writeExecutorServiceArtifact(artifact, deps, knownExists)
|
|
|
636
663
|
await deps.writeFile(artifact.path, artifact.content);
|
|
637
664
|
return { replaced };
|
|
638
665
|
}
|
|
666
|
+
/** Reject anything that is not an absolute http(s) URL. Never echoes the value. */
|
|
667
|
+
function assertAbsoluteHttpBaseUrl(baseUrl) {
|
|
668
|
+
let parsed;
|
|
669
|
+
try {
|
|
670
|
+
parsed = new URL(baseUrl);
|
|
671
|
+
}
|
|
672
|
+
catch {
|
|
673
|
+
throw new ExecutorServiceValidationError(`${EXECUTOR_SERVICE_BASE_URL_ENV} must be an absolute http(s) URL; ` +
|
|
674
|
+
"the supplied value is not a valid URL");
|
|
675
|
+
}
|
|
676
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
677
|
+
throw new ExecutorServiceValidationError(`${EXECUTOR_SERVICE_BASE_URL_ENV} must use http: or https: (got '${parsed.protocol}')`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Build the install plan for one executor id. PURE: validates the render input
|
|
682
|
+
* for the target platform and renders the artifact, touching no filesystem, no
|
|
683
|
+
* environment, and no service manager.
|
|
684
|
+
*
|
|
685
|
+
* Throws {@link ExecutorServiceValidationError} for an unsafe executor id, an
|
|
686
|
+
* invalid base URL, an empty repository list, or an unsupported platform —
|
|
687
|
+
* before any caller could reach a write.
|
|
688
|
+
*/
|
|
689
|
+
export function planExecutorServiceInstall(input, platform) {
|
|
690
|
+
// The full URL grammar check lives HERE rather than in
|
|
691
|
+
// `validateExecutorServiceRenderInput`, which deliberately only bars
|
|
692
|
+
// whitespace and control characters. `runExecutorInstallServiceCli` already
|
|
693
|
+
// performs this exact check before it reaches a plan, so its behavior is
|
|
694
|
+
// unchanged; a programmatic caller gets the same guarantee without having to
|
|
695
|
+
// remember to re-implement it, and a malformed endpoint can never be baked
|
|
696
|
+
// into a durable unit.
|
|
697
|
+
assertAbsoluteHttpBaseUrl(input.baseUrl);
|
|
698
|
+
const artifact = buildExecutorServiceArtifact(input, platform);
|
|
699
|
+
return {
|
|
700
|
+
platform,
|
|
701
|
+
executorId: input.executorId,
|
|
702
|
+
unitPath: artifact.path,
|
|
703
|
+
serviceManager: platform === "darwin" ? "launchd" : "systemd-user",
|
|
704
|
+
serviceIdentifier: platform === "darwin"
|
|
705
|
+
? executorLaunchdLabelForId(input.executorId)
|
|
706
|
+
: executorSystemdUnitNameForId(input.executorId),
|
|
707
|
+
artifact,
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Write one planned unit. Creates ONLY the unit's parent directory and ONLY the
|
|
712
|
+
* unit file — no lifecycle command, no process spawn, no install-state write.
|
|
713
|
+
*
|
|
714
|
+
* `knownExists` lets a caller that already probed the target (the standalone
|
|
715
|
+
* CLI, which stats it to word its consent prompt) avoid a second `stat`.
|
|
716
|
+
*/
|
|
717
|
+
export async function writeExecutorServicePlan(plan, deps, knownExists) {
|
|
718
|
+
const exists = knownExists ?? (await targetExists(deps.stat, plan.unitPath));
|
|
719
|
+
if (exists && deps.readFile) {
|
|
720
|
+
let current = null;
|
|
721
|
+
try {
|
|
722
|
+
current = await deps.readFile(plan.unitPath);
|
|
723
|
+
}
|
|
724
|
+
catch {
|
|
725
|
+
// An unreadable existing file is not an error here — it simply means the
|
|
726
|
+
// content comparison is unavailable, so the write proceeds and reports
|
|
727
|
+
// `replaced` rather than fabricating `unchanged`.
|
|
728
|
+
current = null;
|
|
729
|
+
}
|
|
730
|
+
if (current === plan.artifact.content) {
|
|
731
|
+
return { outcome: "unchanged", unitPath: plan.unitPath, plan };
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
const pathApi = pathApiForPlatform(plan.platform);
|
|
735
|
+
await deps.mkdir(pathApi.dirname(plan.unitPath), { recursive: true });
|
|
736
|
+
await deps.writeFile(plan.unitPath, plan.artifact.content);
|
|
737
|
+
return { outcome: exists ? "replaced" : "written", unitPath: plan.unitPath, plan };
|
|
738
|
+
}
|
|
639
739
|
/** ENOENT means absent; any other rejection is a real failure and propagates. */
|
|
640
740
|
async function targetExists(stat, filePath) {
|
|
641
741
|
try {
|
|
@@ -757,14 +857,18 @@ export async function runExecutorInstallServiceCli(argv, overrides) {
|
|
|
757
857
|
deps.log(formatWindowsGuidance(renderInput, args));
|
|
758
858
|
return 0;
|
|
759
859
|
}
|
|
760
|
-
|
|
860
|
+
// Delegates to the shared programmatic API (BAPI-779) so the CLI and the
|
|
861
|
+
// conductor render through ONE code path. Consent stays here, in the CLI,
|
|
862
|
+
// exactly as before — the plan/write primitives ask for none.
|
|
863
|
+
let plan;
|
|
761
864
|
try {
|
|
762
|
-
|
|
865
|
+
plan = planExecutorServiceInstall(renderInput, deps.platform);
|
|
763
866
|
}
|
|
764
867
|
catch (error) {
|
|
765
868
|
deps.errorLog(`Error: ${sanitizeError(error)}`);
|
|
766
869
|
return 1;
|
|
767
870
|
}
|
|
871
|
+
const artifact = plan.artifact;
|
|
768
872
|
// --- Dry run: a complete preview, before consent and before any fs call. ---
|
|
769
873
|
if (flags.dryRun) {
|
|
770
874
|
deps.log(`Target: ${artifact.path}`);
|
|
@@ -803,15 +907,18 @@ export async function runExecutorInstallServiceCli(argv, overrides) {
|
|
|
803
907
|
}
|
|
804
908
|
}
|
|
805
909
|
// --- Write phase: parent directory + the single unit, nothing else. ---
|
|
806
|
-
|
|
910
|
+
// The CLI deps carry no `readFile` seam, so the write can only report
|
|
911
|
+
// `written` or `replaced` here — the existing Created/Replaced output
|
|
912
|
+
// contract is preserved exactly.
|
|
913
|
+
let outcome;
|
|
807
914
|
try {
|
|
808
|
-
({
|
|
915
|
+
({ outcome } = await writeExecutorServicePlan(plan, deps, exists));
|
|
809
916
|
}
|
|
810
917
|
catch (error) {
|
|
811
918
|
deps.errorLog(`Error: could not write ${artifact.path}: ${sanitizeError(error)}`);
|
|
812
919
|
return 1;
|
|
813
920
|
}
|
|
814
|
-
deps.log(`${replaced ? "Replaced" : "Created"} ${artifact.path}`);
|
|
921
|
+
deps.log(`${outcome === "replaced" ? "Replaced" : "Created"} ${artifact.path}`);
|
|
815
922
|
deps.log("");
|
|
816
923
|
deps.log(formatPostWriteGuidance(artifact, options.executorId));
|
|
817
924
|
return 0;
|
|
@@ -849,7 +956,9 @@ function formatWindowsGuidance(input, args) {
|
|
|
849
956
|
"and configure the task to run at logon and restart on failure.",
|
|
850
957
|
"",
|
|
851
958
|
"The command line above carries no credential. Provide the Bridge API key at run time via",
|
|
852
|
-
"the BAPI_API_KEY environment variable or the user-scoped bapi:<repo> credential store.",
|
|
959
|
+
"the BAPI_API_KEY environment variable or the user-scoped bapi:<repo> credential store. The",
|
|
960
|
+
"worker authenticates via this host's interactive `claude login`, or, headlessly, via a",
|
|
961
|
+
"CLAUDE_CODE_OAUTH_TOKEN you export into the task's own environment yourself.",
|
|
853
962
|
].join("\n");
|
|
854
963
|
}
|
|
855
964
|
/** Post-write operator guidance — naming the manual lifecycle step, not running it. */
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-spawn removal of a previous attempt's conductor artifacts (BAPI-814/D1).
|
|
3
|
+
*
|
|
4
|
+
* `.conductor/` is an IGNORED path in this repository, and worktree preparation's
|
|
5
|
+
* `git clean -fd` deliberately omits `-x`, so ignored paths survive it. That
|
|
6
|
+
* turned a stale `result.json` from a housekeeping nuisance into a safety
|
|
7
|
+
* problem: a `spec_review` retry whose worker exits `0` without writing a
|
|
8
|
+
* verdict would have the PREVIOUS attempt's verdict read back and posted
|
|
9
|
+
* verbatim as this attempt's answer. Explicit removal by the attempt owner,
|
|
10
|
+
* holding the worktree lock, is what makes "no verdict written" observable as
|
|
11
|
+
* absence instead.
|
|
12
|
+
*
|
|
13
|
+
* WHY THIS IS ITS OWN MODULE: `verdict-artifact.ts` is pinned by
|
|
14
|
+
* `tests/pytest/mcp/test_executor_job_behaviors_static.py` to contain no `.md`
|
|
15
|
+
* or `markdown` token in its executable code — the "the executor performs NO
|
|
16
|
+
* markdown parsing, NO verdict judgment" invariant. Naming `critique.md` as a
|
|
17
|
+
* deletion target there would trip that guard. Deleting an artifact and
|
|
18
|
+
* interpreting one are genuinely different concerns, so the deletion half lives
|
|
19
|
+
* here and the guard on the reading half stays exactly as strict as it was.
|
|
20
|
+
*/
|
|
21
|
+
import { CONDUCTOR_RESULT_DIR, CONDUCTOR_RESULT_FILE } from "./verdict-artifact.js";
|
|
22
|
+
import { pathApiForExecutorPlatform } from "./worktree-inspection.js";
|
|
23
|
+
/** The critique artifact a reviewer worker writes alongside its verdict. */
|
|
24
|
+
export const CONDUCTOR_CRITIQUE_FILE = "critique.md";
|
|
25
|
+
/**
|
|
26
|
+
* The files a prior attempt may leave behind, and the ONLY ones removed here.
|
|
27
|
+
*
|
|
28
|
+
* `worker.lock` and `worker.log` are deliberately absent: the lock is live
|
|
29
|
+
* coordination state, and the log is forensic evidence truncated by its own
|
|
30
|
+
* owner (`worker-log.ts`). The `.conductor/` directory itself and any unrelated
|
|
31
|
+
* file inside it are left untouched.
|
|
32
|
+
*/
|
|
33
|
+
export const STALE_ATTEMPT_ARTIFACT_FILES = [
|
|
34
|
+
CONDUCTOR_RESULT_FILE,
|
|
35
|
+
CONDUCTOR_CRITIQUE_FILE,
|
|
36
|
+
];
|
|
37
|
+
/**
|
|
38
|
+
* Delete a previous attempt's verdict and critique artifacts.
|
|
39
|
+
*
|
|
40
|
+
* A missing file is a successful no-op — that is the normal first-attempt case.
|
|
41
|
+
* Any other filesystem failure (a permission problem, an unexpected errno) is
|
|
42
|
+
* returned as a bounded failure so the caller can stop the attempt *before*
|
|
43
|
+
* spawning: proceeding past a cleanup that did not happen is exactly the state
|
|
44
|
+
* this function exists to prevent. The returned reason names the file and the
|
|
45
|
+
* error code only — never the exception text, the absolute path, or file
|
|
46
|
+
* contents.
|
|
47
|
+
*/
|
|
48
|
+
export async function removeStaleVerdictArtifacts(worktreePath, deps) {
|
|
49
|
+
const pathApi = pathApiForExecutorPlatform(deps.platform ?? process.platform);
|
|
50
|
+
for (const file of STALE_ATTEMPT_ARTIFACT_FILES) {
|
|
51
|
+
const target = pathApi.join(worktreePath, CONDUCTOR_RESULT_DIR, file);
|
|
52
|
+
try {
|
|
53
|
+
await deps.removeFile(target);
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
const raw = err && typeof err === "object" ? err.code : undefined;
|
|
57
|
+
// Absence is the expected first-attempt state, not a failure.
|
|
58
|
+
if (raw === "ENOENT")
|
|
59
|
+
continue;
|
|
60
|
+
// A blank code is as uninformative as a missing one; both must render the
|
|
61
|
+
// same words rather than an empty pair of parentheses.
|
|
62
|
+
const code = typeof raw === "string" && raw.trim().length > 0 ? raw : "unknown error";
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
reason: `stale ${CONDUCTOR_RESULT_DIR}/${file} could not be removed (${code})`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { ok: true };
|
|
70
|
+
}
|