@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.
Files changed (91) hide show
  1. package/README.md +193 -16
  2. package/build/agent-capabilities/probe-context.js +2 -1
  3. package/build/agent-launchers/claude-executor-adapter.js +392 -0
  4. package/build/agent-launchers/executor-adapter-inspection.js +163 -0
  5. package/build/agent-launchers/executor-adapter-registry.js +90 -0
  6. package/build/agent-launchers/executor-adapter.js +136 -0
  7. package/build/agent-registry.js +28 -0
  8. package/build/agents.generated.js +1 -1
  9. package/build/claude-login.js +85 -0
  10. package/build/claude-user-config-doctor.js +59 -33
  11. package/build/commands.generated.js +12 -11
  12. package/build/conduct-epic/bridge-client.js +345 -0
  13. package/build/conduct-epic/checkpoint-store.js +423 -0
  14. package/build/conduct-epic/cli.js +1732 -0
  15. package/build/conduct-epic/lock.js +302 -0
  16. package/build/conduct-epic/pr-state.js +197 -0
  17. package/build/conduct-epic/spawn.js +101 -0
  18. package/build/conductor/bridge-api-client.js +37 -2
  19. package/build/conductor/doctor.js +11 -1
  20. package/build/conductor/install-doctor.js +184 -10
  21. package/build/conductor-bin.js +7 -7
  22. package/build/credential-store.js +10 -4
  23. package/build/credentials-cli.js +34 -19
  24. package/build/docs.generated.js +1 -1
  25. package/build/doctor.js +579 -88
  26. package/build/executor/agent-identity.js +32 -0
  27. package/build/executor/cli.js +50 -39
  28. package/build/executor/deps.js +15 -1
  29. package/build/executor/env.js +56 -45
  30. package/build/executor/index.js +9 -1
  31. package/build/executor/install-preflight.js +138 -0
  32. package/build/executor/job-errors.js +200 -0
  33. package/build/executor/job-runner.js +619 -268
  34. package/build/executor/observation.js +165 -0
  35. package/build/executor/permissions.js +163 -36
  36. package/build/executor/platform.js +54 -0
  37. package/build/executor/preflight.js +175 -67
  38. package/build/executor/process.js +39 -7
  39. package/build/executor/runner.js +19 -0
  40. package/build/executor/service-lifecycle.js +269 -0
  41. package/build/executor/service-unit.js +121 -12
  42. package/build/executor/stale-artifacts.js +70 -0
  43. package/build/executor/test-clock.js +188 -24
  44. package/build/executor/worker-command.js +22 -58
  45. package/build/executor/worker-log.js +82 -0
  46. package/build/executor/worktree-lock.js +264 -0
  47. package/build/index.js +527 -357
  48. package/build/install-bridge-conductor.js +376 -38
  49. package/build/install-bridge.js +414 -114
  50. package/build/install-doctor.js +13 -0
  51. package/build/install-reexec.js +5 -3
  52. package/build/mcp-install-state.js +130 -0
  53. package/build/mcp-profile.js +11 -2
  54. package/build/mcp-provisioning.js +15 -0
  55. package/build/merge-pull-request.js +562 -0
  56. package/build/phase-result-artifacts.js +450 -0
  57. package/build/pipeline-orchestrator.js +4 -0
  58. package/build/pipeline-utils.js +16 -0
  59. package/build/pipelines.generated.js +7 -7
  60. package/build/plane/preflight.js +18 -14
  61. package/build/plane/supervisor.js +8 -1
  62. package/build/project-root.js +34 -0
  63. package/build/readme.generated.js +1 -1
  64. package/build/run-unit-tests-launcher.js +36 -9
  65. package/build/setup-epic.js +57 -4
  66. package/build/sfcc/ocapi-shape.js +23 -4
  67. package/build/sfcc/permissions.js +25 -6
  68. package/build/sfcc/read-body.js +92 -0
  69. package/build/sfcc/read-projection.js +6 -2
  70. package/build/sfcc/reads-custom-object-def.js +33 -21
  71. package/build/sfcc/reads-site-preference.js +20 -7
  72. package/build/sfcc/reads-system-object.js +11 -5
  73. package/build/sfcc/register.js +61 -23
  74. package/build/sfcc/registration-inventory.js +89 -0
  75. package/build/sfcc/setup-status.js +18 -34
  76. package/build/sfcc/tool-wrapper.js +294 -17
  77. package/build/sfcc/write-grants.js +33 -1
  78. package/build/sfcc/write-guard.js +41 -12
  79. package/build/sfcc/write-result.js +16 -7
  80. package/build/sfcc/writes-custom-object-def.js +12 -4
  81. package/build/sfcc/writes-site-preference.js +6 -1
  82. package/build/sfcc/writes-system-object.js +11 -2
  83. package/build/sfcc/writes.js +13 -8
  84. package/build/start-tickets-prereqs.js +25 -15
  85. package/build/start-tickets.js +123 -21
  86. package/build/version.generated.js +1 -1
  87. package/build/worktree-core.js +9 -3
  88. package/docs/install/mcp-tool-integrations.md +54 -9
  89. package/docs/install/sfcc-integration.md +71 -24
  90. package/package.json +3 -3
  91. package/build/executor/worker-config-isolation.js +0 -287
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The executor's selected agent identity (BAPI-781).
3
+ *
4
+ * WHY A MODULE FOR ONE STRING. Before the adapter extraction the literal
5
+ * `"claude"` appeared throughout the spawn path — in the version probe, the
6
+ * model lookup, the deny probe, the executable name. Replacing those with a
7
+ * single shared constant is what lets `ExecutorOptions.agentId` default without
8
+ * every generic module re-hard-coding the same default, and it gives the
9
+ * eventual "make this configurable" change exactly one place to touch.
10
+ *
11
+ * Claude remains the default because it is the only implemented adapter. That is
12
+ * a statement about what exists, not a fallback: an agent explicitly selected
13
+ * but unimplemented resolves to an explicit unsupported result rather than
14
+ * quietly landing back here (see `executor-adapter-registry.ts`).
15
+ */
16
+ import { DEFAULT_AGENT_NAME } from "../agent-registry.js";
17
+ /** The agent an executor spawns workers with when none is configured. */
18
+ export const DEFAULT_EXECUTOR_AGENT_ID = DEFAULT_AGENT_NAME;
19
+ /**
20
+ * Resolve the effective agent id for a set of executor options.
21
+ *
22
+ * Normalizes blank/whitespace-only configuration to the default rather than
23
+ * passing it through: an empty string would resolve to `unknown-agent` and
24
+ * refuse every claim, which is a confusing way to report "you configured
25
+ * nothing".
26
+ */
27
+ export function resolveExecutorAgentId(agentId) {
28
+ if (typeof agentId !== "string")
29
+ return DEFAULT_EXECUTOR_AGENT_ID;
30
+ const trimmed = agentId.trim();
31
+ return trimmed.length > 0 ? trimmed : DEFAULT_EXECUTOR_AGENT_ID;
32
+ }
@@ -10,13 +10,12 @@ import os from "node:os";
10
10
  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
+ import { DEFAULT_EXECUTOR_AGENT_ID } from "./agent-identity.js";
13
14
  import { createDefaultExecutorDeps } from "./deps.js";
14
15
  import { resolveBaseUrl, resolveExecutorApiAccess, EXECUTOR_BASE_URL_REQUIRED_MESSAGE, } from "./credentials.js";
15
16
  import { createExecutorHttpClient } from "./http-client.js";
16
17
  import { runExecutor } from "./runner.js";
17
18
  import { runExecutorWatchCli } from "./watch-cli.js";
18
- import { sweepOrphanedWorkerConfigDirectories } from "./worker-config-isolation.js";
19
- import { pathApiForProvisioningPlatform } from "../mcp-provisioning.js";
20
19
  /** Fixed executor timing/behavior defaults. */
21
20
  const DEFAULT_POLL_INTERVAL_MS = 15_000;
22
21
  const DEFAULT_HEARTBEAT_INTERVAL_MS = 60_000;
@@ -39,6 +38,8 @@ export function getExecutorUsage() {
39
38
  "Options:",
40
39
  " --repo <name> Repo to serve (repeatable).",
41
40
  " --repos=<a,b> Comma-separated repos.",
41
+ " --epic-run-id <id> Dedicate this executor to an epic run (repeatable).",
42
+ " Omit for the default repository-wide behavior.",
42
43
  " --base-url <url> Bridge API endpoint (overrides BAPI_BASE_URL).",
43
44
  " --executor-id <id> Stable executor id (default: <hostname>-<pid>).",
44
45
  " --max-concurrent <n> Max concurrent jobs (>= 1, default 1).",
@@ -66,9 +67,21 @@ function parseIntArg(value, flag) {
66
67
  }
67
68
  return { ok: true, value: n };
68
69
  }
70
+ /**
71
+ * BAPI-794 — `epic_run_id` is always server-minted as `str(uuid.uuid4())`
72
+ * (`api/library/db/epic_runs.py`), so a standard UUID shape (any version) is a
73
+ * cheap, safe pre-validation: it rejects an obvious typo before any credential
74
+ * resolution or executor-loop startup, without over-constraining the CLI to a
75
+ * version the server does not itself enforce.
76
+ */
77
+ const EPIC_RUN_ID_PATTERN = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
78
+ function isValidEpicRunId(value) {
79
+ return EPIC_RUN_ID_PATTERN.test(value);
80
+ }
69
81
  /** Parse the executor CLI arguments into resolved options (pure, synchronous). */
70
82
  export function parseExecutorArgs(argv, context) {
71
83
  const repos = [];
84
+ const epicRunIds = [];
72
85
  let executorId;
73
86
  let maxConcurrent = DEFAULT_MAX_CONCURRENT;
74
87
  let once = false;
@@ -97,6 +110,17 @@ export function parseExecutorArgs(argv, context) {
97
110
  return { kind: "error", message: "--repos requires a value" };
98
111
  repos.push(...v.split(",").map((s) => s.trim()).filter(Boolean));
99
112
  }
113
+ else if (arg === "--epic-run-id") {
114
+ const v = argv[++i];
115
+ if (!v || !v.trim())
116
+ return { kind: "error", message: "--epic-run-id requires a value" };
117
+ const trimmed = v.trim();
118
+ if (!isValidEpicRunId(trimmed)) {
119
+ return { kind: "error", message: `--epic-run-id has an invalid value: ${trimmed}` };
120
+ }
121
+ if (!epicRunIds.includes(trimmed))
122
+ epicRunIds.push(trimmed);
123
+ }
100
124
  else if (arg === "--executor-id") {
101
125
  executorId = argv[++i];
102
126
  if (!executorId)
@@ -172,28 +196,20 @@ export function parseExecutorArgs(argv, context) {
172
196
  advisoryParserEnabled,
173
197
  defaultJobTimeoutSeconds: DEFAULT_JOB_TIMEOUT_SECONDS,
174
198
  baseUrl,
199
+ // BAPI-781 — the agent whose executor adapter owns argv, environment, MCP
200
+ // scoping, auth detection, deny enforcement, and lifecycle. Set explicitly
201
+ // at the bootstrap boundary so the literal `"claude"` does not reappear
202
+ // inside generic spawn code. There is no CLI flag for it yet: Claude is the
203
+ // only implemented adapter, and offering a flag that can only fail closed
204
+ // would advertise a choice that does not exist.
205
+ agentId: DEFAULT_EXECUTOR_AGENT_ID,
206
+ // BAPI-794 — `undefined` (never an empty array) when no --epic-run-id flag
207
+ // was supplied, so downstream `epicRunIds !== undefined` checks (the claim
208
+ // manifest builder, startup diagnostics) see an unambiguous "unscoped".
209
+ ...(epicRunIds.length > 0 ? { epicRunIds } : {}),
175
210
  };
176
211
  return { kind: "ok", options };
177
212
  }
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
- }
197
213
  function hasRepoFlag(argv) {
198
214
  return argv.some((a) => a === "--repo" || a === "--repos" || a.startsWith("--repos="));
199
215
  }
@@ -235,6 +251,15 @@ export async function runExecutorCli(argv, overrides = {}) {
235
251
  return 1;
236
252
  }
237
253
  const options = parsed.options;
254
+ // BAPI-794 — startup diagnostics: only non-secret run identifiers, never
255
+ // credentials, job payloads, or command arguments beyond the run IDs
256
+ // themselves (which are not secrets — see EPIC_RUN_ID_PATTERN's docstring).
257
+ if (options.epicRunIds !== undefined) {
258
+ errorLog(`executor scoped to epic run(s): ${options.epicRunIds.join(", ")}`);
259
+ }
260
+ else {
261
+ errorLog("executor running repository-wide (no --epic-run-id configured)");
262
+ }
238
263
  // The mutating executor requires an EXPLICIT base URL (BAPI-676): `--base-url`
239
264
  // then `BAPI_BASE_URL`, never an implicit production default. Fail here —
240
265
  // before any credential resolution, HTTP client, or claim side effect.
@@ -273,24 +298,10 @@ export async function runExecutorCli(argv, overrides = {}) {
273
298
  mcpVersion: VERSION,
274
299
  fetch: deps.fetch,
275
300
  });
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
- }
301
+ // BAPI-790: the startup orphan sweep is gone with the thing it swept. The
302
+ // executor no longer creates a per-job Claude configuration directory, so an
303
+ // ungraceful exit (SIGKILL, OOM, host reboot) leaves no residue for a sweep to
304
+ // reclaim and startup has one less filesystem step to get wrong.
294
305
  const run = overrides.runExecutor ?? runExecutor;
295
306
  // Hand the loop the VALIDATED runtime configuration: the parsed options plus the
296
307
  // normalized base URL resolution actually settled on.
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { execFile, spawn } from "node:child_process";
11
11
  import { existsSync } from "node:fs";
12
- import { readFile, writeFile, appendFile, mkdir, mkdtemp, chmod, rm, readdir, lstat, stat, statfs, } from "node:fs/promises";
12
+ import { open, readFile, writeFile, appendFile, mkdir, mkdtemp, chmod, rm, readdir, lstat, stat, statfs, } from "node:fs/promises";
13
13
  import os from "node:os";
14
14
  import { promisify } from "node:util";
15
15
  import { resolveMcpShimInvocationForRuntime } from "../mcp-server-invocation.js";
@@ -77,6 +77,20 @@ export function createDefaultExecutorDeps() {
77
77
  writeFile: (filePath, data) => writeFile(filePath, data, "utf-8"),
78
78
  appendFile: (filePath, data) => appendFile(filePath, data, "utf-8"),
79
79
  mkdir: (dirPath, opts) => mkdir(dirPath, opts),
80
+ // BAPI-793 worktree lock. `open(..., "wx")` is atomic at the OS level: it
81
+ // either creates the file or rejects with EEXIST, with no window between the
82
+ // two. `0o600` keeps the fencing claim token in the payload readable only by
83
+ // the operator who owns the executor.
84
+ writeFileExclusive: async (filePath, data) => {
85
+ const handle = await open(filePath, "wx", 0o600);
86
+ try {
87
+ await handle.writeFile(data, "utf-8");
88
+ }
89
+ finally {
90
+ await handle.close();
91
+ }
92
+ },
93
+ removeFile: (filePath) => rm(filePath, { force: true }),
80
94
  stat: (filePath) => stat(filePath).then((s) => ({ mode: s.mode })),
81
95
  statMtimeMs: (filePath) => stat(filePath)
82
96
  .then((s) => s.mtimeMs)
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Secret-free executor worker environment (BAPI-534, TDD §7).
2
+ * Secret-free GENERIC executor worker environment (BAPI-534, TDD §7; BAPI-790,
3
+ * BAPI-791; generalized in BAPI-781).
3
4
  *
4
5
  * v2 executor workers get Bridge MCP access by default (BAPI-724), but NOT
5
6
  * through this environment. Access is provisioned into the worktree's
@@ -12,11 +13,31 @@
12
13
  * provisioning time instead), and all secret-bearing keys. It constructs a
13
14
  * FRESH object from a strict allowlist of non-secret operational keys — it
14
15
  * 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.
16
+ * leak into a spawned worker process.
17
+ *
18
+ * THIS BUILDER IS NOW AGENT-NEUTRAL AND CARRIES NO SECRET AT ALL (BAPI-781).
19
+ * The single `CLAUDE_CODE_OAUTH_TOKEN` passthrough that used to live here moved
20
+ * into the Claude adapter (`agent-launchers/claude-executor-adapter.ts`), which
21
+ * layers it on top of this base. That relocation is the point of the extraction:
22
+ * forwarding an operator's Claude credential is an AGENT-SPECIFIC decision, and
23
+ * a generic builder that knew about it would keep every future adapter
24
+ * Claude-shaped. A different adapter forwards a different name — or none — and
25
+ * must opt in explicitly rather than inherit Claude's exception.
26
+ *
27
+ * `ANTHROPIC_API_KEY` remains absolutely denied here, forwarded or not, and no
28
+ * adapter can re-admit it through this function — see `EXPLICIT_DENY_KEYS`.
29
+ *
30
+ * `CLAUDE_CONFIG_DIR` and `XDG_CONFIG_HOME` REMAIN on the deny list even though
31
+ * the executor no longer sets them. The deny list defends against the operator's
32
+ * own ambient values: a redirect exported in the shell that launched the
33
+ * executor must not reach a worker and silently change which configuration —
34
+ * and which MCP registrations — the CLI resolves.
35
+ *
36
+ * The `mcp-invoke` shim (a separate process) resolves `BAPI_API_KEY` itself at
37
+ * call time from the operator's real user-scoped credential store, exactly as it
38
+ * did before BAPI-731, so this worker environment never carries it. The model is
39
+ * still denied direct reads of that store by the deny layer
40
+ * (`Read(~/.config/bridge/**)` in `permissions.ts`).
20
41
  */
21
42
  import { PR_BASE_BRANCH_ENV_VAR } from "../pr-base-contract.js";
22
43
  /** Non-secret operational keys forwarded to the worker when present. */
@@ -39,17 +60,33 @@ const SECRET_NAME_FRAGMENTS = ["TOKEN", "SECRET", "PASSWORD", "API_KEY"];
39
60
  /**
40
61
  * Explicit deny-list of known secret / MCP / conductor keys.
41
62
  *
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.
63
+ * The two Claude configuration paths were added by BAPI-731, when the executor
64
+ * denied the inherited values and then set its own. BAPI-790 removed the setting
65
+ * half and KEPT the denying half: a worker must inherit the operator's real
66
+ * configuration (that is how it authenticates now), but it must inherit the
67
+ * DEFAULT one. An operator who exports `CLAUDE_CONFIG_DIR` or `XDG_CONFIG_HOME`
68
+ * in the shell that launches the executor would otherwise silently redirect every
69
+ * worker at a different configuration — and a different MCP registration set —
70
+ * without anything in the executor choosing that.
47
71
  */
48
- const EXPLICIT_DENY_KEYS = [
72
+ export const EXPLICIT_DENY_KEYS = [
49
73
  "BRIDGE_MCP_PROFILE",
50
74
  "CONDUCTOR_NODE_PATH",
51
75
  "BAPI_API_KEY",
76
+ // ANTHROPIC_API_KEY has NO passthrough exception (BAPI-791): it stays denied
77
+ // absolutely, unlike CLAUDE_CODE_OAUTH_TOKEN below, which is denied here too
78
+ // but is deliberately let back in through the single explicit branch in
79
+ // the Claude adapter's own env builder, after this allowlisted-copy loop runs.
52
80
  "ANTHROPIC_API_KEY",
81
+ // CLAUDE_CODE_OAUTH_TOKEN is now denied here EXPLICITLY (BAPI-781). Under
82
+ // BAPI-791 it had to stay off this list, because the generic builder itself
83
+ // re-admitted it through a single unchanged-copy branch and an explicit deny
84
+ // would have cancelled that branch. The branch has moved into the Claude
85
+ // adapter, which layers the key on top of the object this function returns —
86
+ // so denying it here no longer breaks the passthrough, and it makes the
87
+ // generic base environment unambiguously credential-free rather than
88
+ // credential-free-by-omission.
89
+ "CLAUDE_CODE_OAUTH_TOKEN",
53
90
  "OPENAI_API_KEY",
54
91
  "GITHUB_TOKEN",
55
92
  "GH_TOKEN",
@@ -82,7 +119,7 @@ export function isExecutorEnvKeyAllowed(key) {
82
119
  return false;
83
120
  return true;
84
121
  }
85
- export function buildExecutorWorkerEnv(parentEnv, options = {}) {
122
+ export function buildExecutorBaseWorkerEnv(parentEnv, options = {}) {
86
123
  const env = {};
87
124
  for (const key of ALLOWED_ENV_KEYS) {
88
125
  if (!isExecutorEnvKeyAllowed(key))
@@ -97,37 +134,11 @@ export function buildExecutorWorkerEnv(parentEnv, options = {}) {
97
134
  options.effectiveBaseBranch.length > 0) {
98
135
  env[PR_BASE_BRANCH_ENV_VAR] = options.effectiveBaseBranch;
99
136
  }
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;
131
- }
137
+ // Nothing agent-specific is added here, and deliberately nothing secret. An
138
+ // adapter that needs to forward an operator-owned credential declares it as a
139
+ // passthrough and copies it onto the object this function returns; see
140
+ // `buildClaudeWorkerEnv`. Keeping the base free of every credential is what
141
+ // makes "which secrets can reach a worker?" answerable per adapter instead of
142
+ // per call site.
132
143
  return env;
133
144
  }
@@ -10,5 +10,13 @@ export { runExecutor } from "./runner.js";
10
10
  // Persistent-service packaging (BAPI-688). The CLI entry plus the PURE helpers
11
11
  // doctor needs to enumerate and inspect generated units — doctor must share this
12
12
  // module's naming/format contract instead of maintaining a second parser.
13
- export { runExecutorInstallServiceCli, getExecutorInstallServiceUsage, buildExecutorServiceArtifact, writeExecutorServiceArtifact, inspectExecutorServiceArtifact, executorLaunchdDirForHome, executorSystemdDirForHome, executorIdFromLaunchdFilename, executorIdFromSystemdFilename, EXECUTOR_SERVICE_BASE_URL_ENV, EXECUTOR_SERVICE_PATH_ENV, } from "./service-unit.js";
13
+ export { runExecutorInstallServiceCli, getExecutorInstallServiceUsage, buildExecutorServiceArtifact, writeExecutorServiceArtifact, inspectExecutorServiceArtifact, executorLaunchdDirForHome, executorSystemdDirForHome, executorIdFromLaunchdFilename, executorIdFromSystemdFilename,
14
+ // Programmatic, lifecycle-free plan/write seam (BAPI-779) — how
15
+ // `install conductor` reaches the generator without shelling out to the CLI.
16
+ planExecutorServiceInstall, writeExecutorServicePlan, resolvePackagedExecutorInvocation, EXECUTOR_SERVICE_BASE_URL_ENV, EXECUTOR_SERVICE_PATH_ENV, } from "./service-unit.js";
17
+ // Consented lifecycle (BAPI-779, R-1). Deliberately a SEPARATE module from the
18
+ // generator above: importing the writer never grants the ability to start a
19
+ // persistent daemon.
20
+ export { buildExecutorServiceStartPlan, buildExecutorServiceStateProbe, formatExecutorServiceStartFailure, inspectExecutorServiceState, startExecutorService, } from "./service-lifecycle.js";
21
+ export { collectExecutorInstallPreflight, formatExecutorInstallPreflight, EXECUTOR_INSTALL_REQUIRED_TOOLS, EXECUTOR_INSTALL_CLAUDE_LOGIN_REMEDIATION, } from "./install-preflight.js";
14
22
  export { runExecutorWatchCli, getExecutorWatchUsage, parseExecutorWatchArgs, } from "./watch-cli.js";
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Executor installation preflight for `install conductor` (BAPI-779).
3
+ *
4
+ * This is the gate that runs BEFORE the installer writes a service unit or
5
+ * starts anything: it answers "can this host actually run a conductor executor?"
6
+ * loudly, so an unsupported platform or a missing tool fails at the top of the
7
+ * phase instead of halfway through a persistent-daemon install.
8
+ *
9
+ * WHAT IS FATAL, AND WHAT IS NOT. Platform support, an explicit base URL, and
10
+ * the presence of `wt`, `git`, and `claude` are fatal — none of them can be
11
+ * worked around later, and provisioning a service that cannot run is worse than
12
+ * refusing. The Claude login marker is ADVISORY only, and that distinction is
13
+ * load-bearing: BAPI-790/791 retired every Bridge-managed Anthropic credential,
14
+ * so the definitive authentication check now happens at the first worker spawn,
15
+ * not here. Turning the advisory back into a gate would reintroduce exactly the
16
+ * onboarding barrier that epic removed.
17
+ *
18
+ * CREDENTIAL BOUNDARY. The only credential-adjacent read this module performs is
19
+ * the shared `detectClaudeLogin` marker probe of `<home>/.claude.json`, which
20
+ * reduces to a single boolean before returning. There is deliberately NO code
21
+ * path here that reads the OS keychain, a Claude credential file, `.mcp.json`,
22
+ * `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, a setup token, or the Bridge
23
+ * credential store — the report type has no field capable of carrying one, and
24
+ * {@link ExecutorInstallPreflightDeps} exposes no seam that could reach one.
25
+ */
26
+ import { detectClaudeLogin, formatClaudeLoginAdvisory } from "../claude-login.js";
27
+ import { evaluateExecutorPlatform, formatUnsupportedExecutorPlatform } from "./platform.js";
28
+ /** The executables a conductor executor cannot run without. */
29
+ export const EXECUTOR_INSTALL_REQUIRED_TOOLS = ["wt", "git", "claude"];
30
+ /** The exact remediation for a missing login marker — never a credential gate. */
31
+ export const EXECUTOR_INSTALL_CLAUDE_LOGIN_REMEDIATION = "claude login";
32
+ /** An absolute http(s) URL is the only shape a generated unit may bake. */
33
+ function isExplicitBaseUrl(value) {
34
+ if (typeof value !== "string" || value.trim().length === 0)
35
+ return false;
36
+ let parsed;
37
+ try {
38
+ parsed = new URL(value.trim());
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
44
+ }
45
+ /**
46
+ * Collect the preflight report. Never throws: a failing probe becomes a
47
+ * finding, because a preflight that crashes tells the operator nothing.
48
+ */
49
+ export async function collectExecutorInstallPreflight(input, deps) {
50
+ const fatalFindings = [];
51
+ const warnings = [];
52
+ const remediations = [];
53
+ // --- Platform (fatal) ---------------------------------------------------
54
+ // Reuses the single platform policy (`executor/platform.ts`) rather than
55
+ // re-listing darwin/linux, so the installer and the runner can never disagree
56
+ // about which hosts are supported.
57
+ const platformSupport = evaluateExecutorPlatform(input.platform);
58
+ if (!platformSupport.supported) {
59
+ fatalFindings.push(formatUnsupportedExecutorPlatform(String(input.platform)));
60
+ remediations.push("run the executor on a darwin or linux host; this phase writes and starts a " +
61
+ "user-domain service unit, which has no supported form here.");
62
+ }
63
+ // --- Explicit base URL (fatal) -----------------------------------------
64
+ const baseUrlExplicit = isExplicitBaseUrl(input.baseUrl);
65
+ if (!baseUrlExplicit) {
66
+ fatalFindings.push("no explicit Bridge API base URL was resolved for the service unit " +
67
+ "(an absolute http(s) URL is required).");
68
+ remediations.push("set BAPI_BASE_URL to an absolute http(s) endpoint and re-run.");
69
+ }
70
+ // --- Required tooling (fatal, one finding per tool) ---------------------
71
+ const tools = [];
72
+ for (const name of EXECUTOR_INSTALL_REQUIRED_TOOLS) {
73
+ let resolved = false;
74
+ try {
75
+ resolved = (await deps.lookupExecutable(name)) === true;
76
+ }
77
+ catch {
78
+ // A probe that throws is reported as "not resolved" rather than crashing
79
+ // the phase; the thrown value is discarded because it can echo a path.
80
+ resolved = false;
81
+ }
82
+ tools.push({ name, resolved });
83
+ if (!resolved) {
84
+ fatalFindings.push(`required executable '${name}' did not resolve on PATH.`);
85
+ remediations.push(`install '${name}' and make sure it resolves on this host's PATH.`);
86
+ }
87
+ }
88
+ // --- Claude login (ADVISORY ONLY) --------------------------------------
89
+ // Reduced to a boolean by `detectClaudeLogin` itself, which never returns the
90
+ // parsed object. A missing marker is a warning, never a refusal: BAPI-791
91
+ // moved the definitive check to the first worker spawn.
92
+ const login = await detectClaudeLogin({
93
+ homedir: deps.homedir,
94
+ readFile: deps.readFile,
95
+ });
96
+ if (!login.detected) {
97
+ warnings.push(formatClaudeLoginAdvisory(login));
98
+ remediations.push(EXECUTOR_INSTALL_CLAUDE_LOGIN_REMEDIATION);
99
+ }
100
+ return {
101
+ ok: fatalFindings.length === 0,
102
+ platform: String(input.platform),
103
+ platformSupported: platformSupport.supported,
104
+ baseUrlExplicit,
105
+ tools,
106
+ claudeLoginDetected: login.detected,
107
+ fatalFindings,
108
+ warnings,
109
+ remediations,
110
+ };
111
+ }
112
+ /**
113
+ * Render the report for an operator.
114
+ *
115
+ * Prints platform support, base-URL status, per-tool presence, and the boolean
116
+ * login advisory — and nothing else. There is no wording here about API keys,
117
+ * setup tokens, or credential capability, because this phase checks for none of
118
+ * those (see the module docstring's credential boundary).
119
+ */
120
+ export function formatExecutorInstallPreflight(report) {
121
+ const lines = [
122
+ "Executor provisioning preflight",
123
+ "───────────────────────────────",
124
+ ` platform: ${report.platform} (${report.platformSupported ? "supported" : "UNSUPPORTED"})`,
125
+ ` base URL: ${report.baseUrlExplicit ? "explicit" : "MISSING"}`,
126
+ ];
127
+ for (const tool of report.tools) {
128
+ lines.push(` ${tool.name.padEnd(15)}${tool.resolved ? "found on PATH" : "NOT FOUND on PATH"}`);
129
+ }
130
+ lines.push(` claude login: ${report.claudeLoginDetected ? "marker present (advisory)" : "no marker (advisory)"}`);
131
+ for (const finding of report.fatalFindings)
132
+ lines.push(` refused: ${finding}`);
133
+ for (const warning of report.warnings)
134
+ lines.push(` warn: ${warning}`);
135
+ for (const remediation of report.remediations)
136
+ lines.push(` → ${remediation}`);
137
+ return lines.join("\n");
138
+ }