@bridge_gpt/mcp-server 0.2.38 → 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 (85) hide show
  1. package/README.md +189 -14
  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/permissions.js +25 -6
  67. package/build/sfcc/reads-site-preference.js +6 -0
  68. package/build/sfcc/register.js +61 -23
  69. package/build/sfcc/registration-inventory.js +89 -0
  70. package/build/sfcc/setup-status.js +18 -34
  71. package/build/sfcc/tool-wrapper.js +294 -17
  72. package/build/sfcc/write-grants.js +33 -1
  73. package/build/sfcc/write-guard.js +41 -12
  74. package/build/sfcc/writes-custom-object-def.js +6 -2
  75. package/build/sfcc/writes-site-preference.js +6 -1
  76. package/build/sfcc/writes-system-object.js +11 -2
  77. package/build/sfcc/writes.js +13 -8
  78. package/build/start-tickets-prereqs.js +25 -15
  79. package/build/start-tickets.js +123 -21
  80. package/build/version.generated.js +1 -1
  81. package/build/worktree-core.js +9 -3
  82. package/docs/install/mcp-tool-integrations.md +54 -9
  83. package/docs/install/sfcc-integration.md +71 -24
  84. package/package.json +3 -3
  85. package/build/executor/worker-config-isolation.js +0 -287
@@ -192,6 +192,171 @@ export function parseClaudeStreamJsonLine(line) {
192
192
  function pickString(value) {
193
193
  return typeof value === "string" && value.length > 0 ? value : undefined;
194
194
  }
195
+ // ---------------------------------------------------------------------------
196
+ // MCP surface assertion (BAPI-790)
197
+ // ---------------------------------------------------------------------------
198
+ /**
199
+ * Normalize a list of MCP server names to the canonical comparison form: trimmed,
200
+ * non-empty, deduplicated, and sorted.
201
+ *
202
+ * Shared by BOTH sides of the assertion — the expected set read from the
203
+ * worktree's `.mcp.json` and the observed set read from the worker's init event —
204
+ * so ordering and duplicate registrations can never produce a false mismatch. Two
205
+ * separate normalizations would be exactly the kind of drift that turns a safety
206
+ * assertion into a flake.
207
+ */
208
+ export function normalizeMcpServerNames(names) {
209
+ const seen = new Set();
210
+ for (const value of names) {
211
+ if (typeof value !== "string")
212
+ continue;
213
+ const trimmed = value.trim();
214
+ if (trimmed.length === 0)
215
+ continue;
216
+ seen.add(trimmed);
217
+ }
218
+ return [...seen].sort();
219
+ }
220
+ /**
221
+ * Cap on the partial line the surface observer will buffer, in characters.
222
+ *
223
+ * DELIBERATELY MUCH LARGER than the 8 KB stdout excerpt in `process.ts`, and for
224
+ * the opposite reason. That excerpt is a diagnostic that gets POSTED to the
225
+ * server, so it is kept small on purpose. This buffer has to hold one complete
226
+ * `system`/`init` line intact or the assertion cannot read it at all — a
227
+ * registration with many servers and long names is legitimately several KB — so
228
+ * the bound exists only to stop unbounded growth, not to trim content. A real
229
+ * init event is orders of magnitude under this.
230
+ *
231
+ * The cap is on ONE un-terminated line, not on total throughput: every newline
232
+ * clears the buffer, so a normal newline-delimited stream never approaches it.
233
+ */
234
+ export const MAX_BUFFERED_SURFACE_LINE_CHARS = 1_048_576;
235
+ function sameNameSet(a, b) {
236
+ return a.length === b.length && a.every((name, index) => name === b[index]);
237
+ }
238
+ /**
239
+ * Create an observer that asserts the worker's loaded MCP surface against
240
+ * `expectedServerNames` (which must come from the same `.mcp.json` passed to the
241
+ * worker as `--mcp-config`).
242
+ *
243
+ * Line handling is buffered because stdout arrives in arbitrary chunks: a JSON
244
+ * event can be split across two reads, and parsing a half-line would classify a
245
+ * perfectly good init event as malformed. Malformed lines are simply skipped —
246
+ * the advisory parser's own posture — because the assertion's failure mode must
247
+ * be "no valid init event was seen" (a warning), never "unparseable text implied
248
+ * a surface" (a fabrication).
249
+ */
250
+ export function createMcpSurfaceObserver(expectedServerNames, parseInitEvent) {
251
+ const expected = normalizeMcpServerNames(expectedServerNames);
252
+ let settled = null;
253
+ let buffer = "";
254
+ /** True while skipping the remainder of a line that blew the size cap. */
255
+ let discardingOversizedLine = false;
256
+ /** Sticky: at least one line was dropped for exceeding the cap. */
257
+ let droppedOversizedLine = false;
258
+ const consumeLine = (line) => {
259
+ if (settled !== null)
260
+ return;
261
+ const trimmed = line.trim();
262
+ if (trimmed.length === 0)
263
+ return;
264
+ // The ADAPTER reads its own runtime's event shape; the executor reads none.
265
+ // A parser that throws is treated exactly like a line that said nothing —
266
+ // an adapter defect must not be able to settle the verdict, in either
267
+ // direction.
268
+ let result;
269
+ try {
270
+ result = parseInitEvent(trimmed);
271
+ }
272
+ catch {
273
+ return;
274
+ }
275
+ if (!result || result.kind === "not-init")
276
+ return;
277
+ if (result.kind === "malformed-init") {
278
+ // A valid init event we cannot read is unverifiable, and unverifiable
279
+ // containment is not containment.
280
+ settled = { kind: "mismatch", expected, observed: [] };
281
+ return;
282
+ }
283
+ // Normalize the ADAPTER-SUPPLIED names through the same function that
284
+ // normalized the expected set. Comparing two differently-normalized lists is
285
+ // exactly the drift that turns a safety assertion into a flake.
286
+ const observed = normalizeMcpServerNames(result.serverNames);
287
+ settled = sameNameSet(expected, observed)
288
+ ? { kind: "match", observed }
289
+ : { kind: "mismatch", expected, observed };
290
+ };
291
+ return {
292
+ observeStdout(chunk) {
293
+ if (settled !== null)
294
+ return;
295
+ if (typeof chunk !== "string" || chunk.length === 0)
296
+ return;
297
+ let input = chunk;
298
+ // Still skipping past a line that already blew the cap. Everything up to
299
+ // the next newline belongs to that dead line — RESYNCING on the newline
300
+ // rather than just clearing the buffer is what stops the line's tail from
301
+ // being parsed as if it were a fresh event.
302
+ if (discardingOversizedLine) {
303
+ const resyncAt = input.indexOf("\n");
304
+ if (resyncAt === -1)
305
+ return;
306
+ discardingOversizedLine = false;
307
+ input = input.slice(resyncAt + 1);
308
+ }
309
+ buffer += input;
310
+ let newlineAt = buffer.indexOf("\n");
311
+ while (newlineAt !== -1) {
312
+ consumeLine(buffer.slice(0, newlineAt));
313
+ buffer = buffer.slice(newlineAt + 1);
314
+ if (settled !== null) {
315
+ buffer = "";
316
+ return;
317
+ }
318
+ newlineAt = buffer.indexOf("\n");
319
+ }
320
+ // A residual partial line past the cap is not a line we could ever parse.
321
+ // Drop it and keep scanning the stream: a LATER valid init event still
322
+ // settles the verdict normally, so one pathological line cannot disable
323
+ // the assertion for the rest of the run.
324
+ if (buffer.length > MAX_BUFFERED_SURFACE_LINE_CHARS) {
325
+ buffer = "";
326
+ discardingOversizedLine = true;
327
+ droppedOversizedLine = true;
328
+ }
329
+ },
330
+ shouldTerminate() {
331
+ return settled !== null && settled.kind === "mismatch";
332
+ },
333
+ verdict() {
334
+ // Try the residual partial line. A stream that ends without a trailing
335
+ // newline would otherwise strand its final event in the buffer and report
336
+ // `absent` for a surface we actually observed.
337
+ //
338
+ // The buffer is deliberately NOT cleared here. `verdict()` may legitimately
339
+ // be called mid-stream, and consuming the partial line destructively would
340
+ // drop the first half of an event that was merely split across two stdout
341
+ // chunks — turning a real init event into `absent`. Re-parsing the same
342
+ // residual on a later call is harmless: `consumeLine` only settles on a
343
+ // valid init event, and a settled verdict is never revisited.
344
+ if (settled === null && buffer.length > 0) {
345
+ consumeLine(buffer);
346
+ }
347
+ if (settled !== null)
348
+ return settled;
349
+ // An oversized drop is reported as its own reason rather than folded into
350
+ // the ordinary no-init case. Both warn and neither fails the job, but they
351
+ // are different facts: one says the worker never told us, the other says we
352
+ // could not keep what it told us.
353
+ return {
354
+ kind: "absent",
355
+ reason: droppedOversizedLine ? "oversized-line" : "no-init-event",
356
+ };
357
+ },
358
+ };
359
+ }
195
360
  /**
196
361
  * Create the observation state. When `advisoryParserEnabled` is false, stdout is
197
362
  * still timestamped (`last_stdout_at`) but never parsed — proving the advisory
@@ -15,13 +15,21 @@
15
15
  * through the separately launched `mcp-invoke` shim process, which resolves the
16
16
  * credential itself — the model process never reads it directly.
17
17
  *
18
- * BAPI-740/F2: worker-config isolation seeds a repo-scoped copy of that store
19
- * INSIDE the per-job isolation directory (`bapi-conductor-claude-cfg-*` under the
20
- * executor tmp root see `worker-config-isolation.ts`), where the `~/.config`
21
- * rule cannot reach it. A prefix glob denies model reads under any isolation
22
- * directory so the seeded `credentials.json` gets the same shielding as the
23
- * operator store. The shim still reads it: the shim is a separate process, not
24
- * the model.
18
+ * BAPI-790: the two isolation-directory credential rules that BAPI-740/F2 added
19
+ * are gone, because the per-job isolation directory they guarded no longer
20
+ * exists. `~/.config/bridge/**` is once again the single Bridge-credential deny,
21
+ * and it is sufficient: the shim resolves the operator's real store in its own
22
+ * process, and the model cannot read it.
23
+ *
24
+ * Architecture Miss 23 (2026-08-14) is worth keeping in view even though its
25
+ * specific rules are retired, because the lesson generalizes: a deny glob must
26
+ * target the ASSET, never a directory that also holds the worker's own working
27
+ * state. The isolation-directory deny was originally written as a
28
+ * whole-directory glob, and because `CLAUDE_CONFIG_DIR` pointed at that same
29
+ * directory, it silently truncated every oversized tool result the CLI persisted
30
+ * there for read-back (a worker lost 96.5% of its `get_pipeline_recipe`). No
31
+ * permission posture bypasses a deny rule, so the damage was invisible. Any
32
+ * future rule added here inherits that constraint.
25
33
  *
26
34
  * The Wave-1 probe on the target machine reports enforcement layer `settings-deny`
27
35
  * (claude 2.1.201), so plain `permissions.deny` is provisioned; the PreToolUse
@@ -29,16 +37,21 @@
29
37
  */
30
38
  import path from "node:path";
31
39
  import { mergeClaudeSettingsWithCommandHook, provisionClaudeSettingsForWorktree, DEFAULT_PRE_TOOL_USE_MATCHER, } from "../claude-settings.js";
32
- import { ISOLATION_DIR_PREFIX } from "./worker-config-isolation.js";
33
40
  /**
34
41
  * The stable executor deny rules. Grammar mirrors the deny-enforcement probe's
35
42
  * `Bash(<cmd>:<args-glob>)` / `Read(<path-glob>)` form. Covers: reads under
36
43
  * ~/.ssh, ~/.aws, and keychain paths; the user-scoped Bridge credential store
37
44
  * (BAPI-724 — the model is denied direct reads, while the separately launched
38
- * `mcp-invoke` shim process still resolves it); the per-job isolation directory
39
- * (BAPI-740/F2 the seeded repo-scoped `credentials.json` lives there, outside
40
- * the `~/.config` rule's reach); force-push to the base branch; and destructive
41
- * `rm -rf` outside the worktree.
45
+ * `mcp-invoke` shim process still resolves it); force-push to the base branch;
46
+ * and destructive `rm -rf` outside the worktree.
47
+ *
48
+ * BAPI-790 removed the two per-job isolation-directory credential rules. They
49
+ * guarded a repo-scoped `credentials.json` that the executor seeded into a
50
+ * per-job Claude configuration directory; no such directory or seeded file
51
+ * exists any more, so the rules matched nothing. `Read(~/.config/bridge/**)`
52
+ * carries the whole Bridge-credential guarantee again, exactly as it did before
53
+ * BAPI-731: the shim resolves the operator's real store in its own process, and
54
+ * the model cannot read it.
42
55
  */
43
56
  export function executorDenyRules(inputs) {
44
57
  return [
@@ -47,10 +60,6 @@ export function executorDenyRules(inputs) {
47
60
  "Read(~/Library/Keychains/**)",
48
61
  "Read(/etc/shadow)",
49
62
  "Read(~/.config/bridge/**)",
50
- // Any path under any per-job isolation directory, wherever the executor's
51
- // tmp root lives (`//` anchors at the filesystem root; the prefix constant
52
- // is shared with `worker-config-isolation.ts` so they cannot drift).
53
- `Read(//**/${ISOLATION_DIR_PREFIX}*/**)`,
54
63
  `Bash(git push:*--force*${inputs.baseBranch}*)`,
55
64
  "Bash(git push:*--force*)",
56
65
  "Bash(rm:*-rf /*)",
@@ -84,8 +93,45 @@ export function mergeExecutorDenySettings(existing, inputs) {
84
93
  */
85
94
  export const EXECUTOR_SETTINGS_EXCLUDE_LINE = ".claude/settings.local.json";
86
95
  /**
87
- * Keep the executor-owned `.claude/settings.local.json` out of worker commits
88
- * (BAPI-740/F2). Worker-config isolation redirects `XDG_CONFIG_HOME`, so the
96
+ * The executor-owned conductor artifact directory (BAPI-814/D2).
97
+ *
98
+ * Excluded through the SAME per-worktree mechanism rather than through a
99
+ * `.gitignore` edit, because that mechanism is the only one that travels. This
100
+ * repository's committed `.gitignore` already carries `.conductor/`, but the
101
+ * executor also drives foreign repositories that never will, and branches cut
102
+ * before that entry landed do not have it either. A `.git/info/exclude` line is
103
+ * invisible to the diff, belongs to the checkout rather than to the tree, and
104
+ * therefore protects all three cases without the executor ever editing a
105
+ * repository's tracked ignore file.
106
+ */
107
+ export const EXECUTOR_CONDUCTOR_EXCLUDE_LINE = ".conductor/";
108
+ /**
109
+ * Every line the executor provisions into a worktree's `.git/info/exclude`, in
110
+ * the order they are appended. Named once so the writer and its tests cannot
111
+ * disagree about the set.
112
+ */
113
+ export const EXECUTOR_EXCLUDE_LINES = [
114
+ EXECUTOR_SETTINGS_EXCLUDE_LINE,
115
+ EXECUTOR_CONDUCTOR_EXCLUDE_LINE,
116
+ ];
117
+ /**
118
+ * Build the one bounded shape every exclude-provisioning warning takes: a
119
+ * failure CATEGORY plus the affected worktree path, and nothing else.
120
+ *
121
+ * `category` is always a fixed string chosen by this module — never an error
122
+ * message, a `.git` line, a filesystem path from an exception, or anything a
123
+ * worker or an operator's environment could influence. Callers log this text
124
+ * verbatim, so the bound is enforced here rather than trusted at each site.
125
+ */
126
+ function excludeWarning(worktreePath, category) {
127
+ return (`worktree git-exclude provisioning did not complete for ${worktreePath}: ${category}. ` +
128
+ `${EXECUTOR_EXCLUDE_LINES.join(" and ")} are not protected from worker commits on this ` +
129
+ "worktree; inspect it before trusting the worktree's staged changes; continuing fail-open.");
130
+ }
131
+ /**
132
+ * Keep the executor-owned `.claude/settings.local.json` and the conductor's
133
+ * `.conductor/` artifacts out of worker commits (BAPI-740/F2, BAPI-814/D2).
134
+ * Worker-config isolation redirects `XDG_CONFIG_HOME`, so the
89
135
  * operator's global git excludes (`~/.config/git/ignore`) no longer apply inside
90
136
  * a worker — empirically, a worker `git add -A` commits the deny-layer file this
91
137
  * module just wrote. A per-worktree `.git/info/exclude` entry is invisible to
@@ -95,9 +141,29 @@ export const EXECUTOR_SETTINGS_EXCLUDE_LINE = ".claude/settings.local.json";
95
141
  * A linked worktree's `.git` is a FILE containing `gitdir: <path>` pointing at
96
142
  * the main repository's per-worktree gitdir; that indirection is resolved rather
97
143
  * than assuming a directory (`EISDIR` on the read identifies a real main-checkout
98
- * `.git` directory). Idempotent: the line is appended once, never duplicated.
99
- * Fail-open, matching the rest of this module: unresolvable git metadata is a
100
- * silent skip, and an exclude-write failure returns a warning never a block.
144
+ * `.git` directory). The per-worktree gitdir is then resolved onward to the
145
+ * COMMON dir, because that — not the per-worktree dir is where git reads
146
+ * `info/exclude` from; see the comment at the resolution site. Idempotent: the
147
+ * line is appended once, never duplicated.
148
+ *
149
+ * Fail-open at every step, matching the rest of this module — but NOT silent
150
+ * (BAPI-815/R2). Two outcomes that used to be the same `{ ok: true }` are now
151
+ * told apart:
152
+ *
153
+ * - **No checkout at all** (`ENOENT` on `.git`) — a benign no-op. There is
154
+ * nothing to protect, so there is nothing to warn about.
155
+ * - **Present but unresolvable** (`.git` exists but carries no `gitdir:`
156
+ * pointer, or cannot be read) — non-blocking, but WARNED. Git metadata is
157
+ * there and the executor could not use it, which means the settings file is
158
+ * unprotected on this worktree and a human should look.
159
+ *
160
+ * That distinction is the whole fix: a total failure to ever write was invisible
161
+ * precisely because "could not resolve" and "nothing to do" returned the same
162
+ * silent success.
163
+ *
164
+ * Warnings are bounded to a failure category plus the affected worktree path.
165
+ * They never carry `.git` contents, exception text, environment values, or
166
+ * settings-file content.
101
167
  */
102
168
  async function provisionWorktreeSettingsExclude(worktreePath, deps) {
103
169
  const gitPath = path.join(worktreePath, ".git");
@@ -107,8 +173,12 @@ async function provisionWorktreeSettingsExclude(worktreePath, deps) {
107
173
  const match = /^gitdir:\s*(.+?)\s*$/m.exec(raw);
108
174
  if (!match) {
109
175
  // A readable `.git` file with no gitdir pointer is not git metadata this
110
- // function understands; do not guess at a location to write into.
111
- return { ok: true };
176
+ // function understands; do not guess at a location to write into. Present
177
+ // but unusable degrade loudly rather than reporting a clean success.
178
+ return {
179
+ ok: true,
180
+ warning: excludeWarning(worktreePath, "git metadata carries no gitdir pointer"),
181
+ };
112
182
  }
113
183
  const target = match[1];
114
184
  gitDir = path.isAbsolute(target) ? target : path.resolve(worktreePath, target);
@@ -119,13 +189,48 @@ async function provisionWorktreeSettingsExclude(worktreePath, deps) {
119
189
  // Main-checkout shape: `.git` is a real directory.
120
190
  gitDir = gitPath;
121
191
  }
122
- else {
123
- // No readable git metadata at all (e.g. ENOENT) nothing to protect.
192
+ else if (code === "ENOENT") {
193
+ // No git checkout here at all nothing to protect, nothing to report.
124
194
  return { ok: true };
125
195
  }
196
+ else {
197
+ // Git metadata is present but could not be read (EACCES, EIO, …). The
198
+ // error CODE is a bounded category; the exception itself never escapes.
199
+ return {
200
+ ok: true,
201
+ warning: excludeWarning(worktreePath, "git metadata could not be read"),
202
+ };
203
+ }
126
204
  }
205
+ // Resolve the COMMON directory before choosing where to write (BAPI-815/R2).
206
+ //
207
+ // THIS IS THE DEFECT THAT MADE THE WHOLE MECHANISM A NO-OP. `info/` is one of
208
+ // git's COMMON-dir files, not a per-worktree one: git reads exclusions from
209
+ // `<common>/info/exclude`, and for a linked worktree the common dir is the
210
+ // MAIN repository's `.git`, not `<main>/.git/worktrees/<name>`. Writing to
211
+ // the per-worktree gitdir put the rule somewhere git never looks, so
212
+ // `.claude/settings.local.json` stayed stageable in every linked worktree
213
+ // regardless of this function succeeding. Trees looked clean only because the
214
+ // operator's `~/.config/git/ignore` carried the same rule — the protection
215
+ // built to be machine-independent was supplied entirely by machine-specific
216
+ // config, and the day that global ignore is absent a worker commits the file.
217
+ //
218
+ // `<gitdir>/commondir` holds the pointer (usually the relative `../..`), and
219
+ // it is a plain file, so this needs no new command-runner dependency. Its
220
+ // absence means this IS the common dir (a main checkout), which is why the
221
+ // fallback is `gitDir` itself rather than an error.
222
+ let commonDir = gitDir;
127
223
  try {
128
- const infoDir = path.join(gitDir, "info");
224
+ const pointer = (await deps.readFile(path.join(gitDir, "commondir"))).trim();
225
+ if (pointer.length > 0) {
226
+ commonDir = path.isAbsolute(pointer) ? pointer : path.resolve(gitDir, pointer);
227
+ }
228
+ }
229
+ catch {
230
+ /* no commondir pointer: `gitDir` is already the common dir */
231
+ }
232
+ try {
233
+ const infoDir = path.join(commonDir, "info");
129
234
  await deps.mkdir(infoDir, { recursive: true });
130
235
  const excludePath = path.join(infoDir, "exclude");
131
236
  let existing = "";
@@ -135,18 +240,30 @@ async function provisionWorktreeSettingsExclude(worktreePath, deps) {
135
240
  catch {
136
241
  existing = "";
137
242
  }
138
- if (existing.split(/\r?\n/).includes(EXECUTOR_SETTINGS_EXCLUDE_LINE)) {
243
+ // Idempotent per LINE, not per file: the settings entry predates the
244
+ // `.conductor/` one, so a worktree provisioned by an earlier executor build
245
+ // already contains the first and still needs the second. A single
246
+ // "does the file mention anything of ours?" check would skip it forever.
247
+ const present = new Set(existing.split(/\r?\n/));
248
+ const missing = EXECUTOR_EXCLUDE_LINES.filter((line) => !present.has(line));
249
+ if (missing.length === 0) {
139
250
  return { ok: true };
140
251
  }
252
+ // One write. Unrelated user-authored entries are preserved verbatim, and a
253
+ // file that did not end in a newline gets one first so the appended lines
254
+ // cannot fuse onto the operator's last entry.
141
255
  const prefix = existing.length === 0 || existing.endsWith("\n") ? existing : `${existing}\n`;
142
- await deps.writeFile(excludePath, `${prefix}${EXECUTOR_SETTINGS_EXCLUDE_LINE}\n`);
256
+ await deps.writeFile(excludePath, `${prefix}${missing.map((line) => `${line}\n`).join("")}`);
143
257
  return { ok: true };
144
258
  }
145
259
  catch {
260
+ // The gitdir resolved but the exclude could not be written. Unlike the
261
+ // resolution failures above this keeps `ok: false`, because the caller's
262
+ // established contract treats a failed WRITE as the degraded case worth
263
+ // aggregating into the deny layer's own result.
146
264
  return {
147
265
  ok: false,
148
- warning: "worktree git-exclude provisioning did not complete " +
149
- `(${EXECUTOR_SETTINGS_EXCLUDE_LINE} may appear in worker commits); continuing fail-open`,
266
+ warning: excludeWarning(worktreePath, "the exclude file could not be written"),
150
267
  };
151
268
  }
152
269
  }
@@ -173,13 +290,23 @@ export async function provisionExecutorDenyLayer(worktreePath, options, deps) {
173
290
  // may already exist from an earlier provisioning, so it runs even when the
174
291
  // merge-write was refused or failed.
175
292
  const exclude = await provisionWorktreeSettingsExclude(worktreePath, deps);
176
- const warnings = [];
293
+ // `blocking` and `advisory` are kept apart on purpose. A blocking warning
294
+ // flips `ok` (the established contract, which existing callers and tests
295
+ // read); an advisory one rides along on `ok: true` so a degraded-but-
296
+ // non-failing outcome is still logged instead of vanishing (BAPI-815/R2).
297
+ const blocking = [];
298
+ const advisory = [];
177
299
  if (!result.ok) {
178
- warnings.push(`deny-layer provisioning did not complete (${result.reason}); continuing fail-open`);
300
+ blocking.push(`deny-layer provisioning did not complete (${result.reason}); continuing fail-open`);
179
301
  }
180
302
  if (!exclude.ok)
181
- warnings.push(exclude.warning);
182
- if (warnings.length === 0)
183
- return { ok: true };
184
- return { ok: false, warning: warnings.join("; ") };
303
+ blocking.push(exclude.warning);
304
+ else if (exclude.warning)
305
+ advisory.push(exclude.warning);
306
+ if (blocking.length > 0) {
307
+ return { ok: false, warning: [...blocking, ...advisory].join("; ") };
308
+ }
309
+ if (advisory.length > 0)
310
+ return { ok: true, warning: advisory.join("; ") };
311
+ return { ok: true };
185
312
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Executor platform policy (BAPI-790).
3
+ *
4
+ * WHY THIS MODULE EXISTS SEPARATELY. The darwin/linux gate used to live inside
5
+ * `worker-config-isolation.ts`, where it was one clause of an
6
+ * "is isolation establishable here?" question. BAPI-790 retires isolation, and
7
+ * the platform restriction must NOT be retired with it: Windows worker spawning
8
+ * was never measured, and enabling it is explicit follow-on scope rather than
9
+ * something a containment change unlocks as a side effect. Hoisting the gate
10
+ * into its own module is what lets the isolation module shrink without silently
11
+ * widening the supported-platform set.
12
+ *
13
+ * The gate is a PLATFORM check only. It deliberately does not consider
14
+ * credentials: a worker now authenticates through the operator's interactive
15
+ * Claude login (inventory Finding 6), so requiring an `ANTHROPIC_API_KEY` or a
16
+ * `CLAUDE_CODE_OAUTH_TOKEN` before an otherwise-runnable host may claim work
17
+ * would reintroduce exactly the onboarding barrier this epic removes.
18
+ *
19
+ * PURE. Nothing here reads the filesystem, the environment, or a credential.
20
+ * The returned reason is a fixed sentence plus the platform name — never a host
21
+ * path, an environment value, or raw exception text.
22
+ */
23
+ /**
24
+ * Platforms on which a conductor worker may be spawned.
25
+ *
26
+ * `darwin` is measured (inventory findings 1–6). `linux` is inferred from the
27
+ * same CLI and the same argv/env handling and has not been measured directly.
28
+ * Everything else — `win32` included — is unsupported and fails closed.
29
+ */
30
+ export const SUPPORTED_EXECUTOR_PLATFORMS = ["darwin", "linux"];
31
+ /**
32
+ * Render the fixed, secret-free unsupported-platform sentence.
33
+ *
34
+ * Exported so the preflight refusal and any diagnostic render byte-identical
35
+ * text; two copies of this sentence would drift.
36
+ */
37
+ export function formatUnsupportedExecutorPlatform(platform) {
38
+ return (`the conductor executor does not support platform '${platform}': worker spawning is ` +
39
+ "supported on darwin and linux only (Windows support is explicit follow-on scope, not " +
40
+ "implied by the current worker containment mechanism)");
41
+ }
42
+ /**
43
+ * Decide whether the executor may run on `platform`.
44
+ *
45
+ * Fail-closed on anything unrecognized: an unmeasured platform is never
46
+ * optimistically assumed to work.
47
+ */
48
+ export function evaluateExecutorPlatform(platform) {
49
+ const name = String(platform);
50
+ if (SUPPORTED_EXECUTOR_PLATFORMS.includes(platform)) {
51
+ return { supported: true, platform: name };
52
+ }
53
+ return { supported: false, platform: name, message: formatUnsupportedExecutorPlatform(name) };
54
+ }