@bridge_gpt/mcp-server 0.2.58 → 0.2.60

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 (127) hide show
  1. package/README.md +231 -113
  2. package/build/agent-guidance.generated.js +1 -1
  3. package/build/agent-notes.js +7 -1
  4. package/build/agent-registry.js +15 -0
  5. package/build/agent-utils.js +16 -8
  6. package/build/claude-user-config-doctor.js +1 -35
  7. package/build/codex-bridge-profile.js +1 -1
  8. package/build/commands.generated.js +7 -8
  9. package/build/conductor/bridge-api-client.js +202 -2
  10. package/build/conductor/bring-up-facts.js +1 -1
  11. package/build/conductor/claude-hook.js +16 -16
  12. package/build/conductor/doctor.js +279 -72
  13. package/build/conductor/epic-implementer-cli.js +38 -5
  14. package/build/conductor/epic-reconcile.js +1 -1
  15. package/build/conductor/epic-runtime.js +21 -12
  16. package/build/conductor/errors.js +3 -3
  17. package/build/conductor/file-scope-guard.js +4 -4
  18. package/build/conductor/git-ci-types.js +1 -1
  19. package/build/conductor/git-inspection.js +2 -2
  20. package/build/conductor/install-doctor.js +28 -6
  21. package/build/conductor/local-merge.js +319 -26
  22. package/build/conductor/merge-execution-contract.js +61 -0
  23. package/build/conductor/merge-execution-eligibility.js +129 -0
  24. package/build/conductor/merge-ledger.js +2 -2
  25. package/build/conductor/pr-ci-producer.js +6 -6
  26. package/build/conductor/pr-review-producer.js +2 -2
  27. package/build/conductor/producer-ledger.js +2 -0
  28. package/build/conductor/readiness-sections.js +166 -9
  29. package/build/conductor/readiness.js +97 -16
  30. package/build/conductor/recovery-cli.js +243 -12
  31. package/build/conductor/recovery-operations.js +64 -6
  32. package/build/conductor/repair-contract.js +38 -4
  33. package/build/conductor/spec-review-producer.js +6 -6
  34. package/build/conductor/store.js +10 -10
  35. package/build/conductor/supervisor-config.js +14 -14
  36. package/build/conductor/supervisor-ledger.js +2 -2
  37. package/build/conductor/supervisor-message-relay.js +2 -2
  38. package/build/conductor/worker-ledger-cli.js +19 -19
  39. package/build/conductor-bundle-artifacts.js +2 -2
  40. package/build/connect-bitbucket-api.js +6 -1
  41. package/build/connect-bitbucket.js +398 -88
  42. package/build/credential-store.js +16 -0
  43. package/build/docs.generated.js +2 -2
  44. package/build/doctor.js +199 -303
  45. package/build/drive-epic.js +88 -71
  46. package/build/epic-implementer-bin.js +22 -17
  47. package/build/epic-implementer-claude-hook-bin.js +4 -2
  48. package/build/epic-implementer-env.js +184 -0
  49. package/build/epic-integration-pr.js +2 -2
  50. package/build/executor/ci-rerun-job.js +269 -0
  51. package/build/executor/cli.js +31 -0
  52. package/build/executor/credentials.js +11 -0
  53. package/build/executor/env.js +79 -8
  54. package/build/executor/heartbeat.js +1 -1
  55. package/build/executor/index.js +21 -4
  56. package/build/executor/job-errors.js +3 -3
  57. package/build/executor/job-runner.js +127 -1
  58. package/build/executor/job-types.js +12 -2
  59. package/build/executor/merge-job.js +46 -5
  60. package/build/executor/observation.js +1 -1
  61. package/build/executor/preflight.js +1 -1
  62. package/build/executor/prompt-spec.js +1 -1
  63. package/build/executor/reconcile-overlap-job.js +10 -130
  64. package/build/executor/reconcile-overlap-types.js +1 -1
  65. package/build/executor/runner.js +1 -1
  66. package/build/executor/service-lifecycle.js +136 -0
  67. package/build/executor/service-unit.js +427 -9
  68. package/build/executor/types.js +5 -0
  69. package/build/index.js +320 -342
  70. package/build/init.js +20 -28
  71. package/build/install-bridge-conductor.js +208 -15
  72. package/build/install-bridge.js +555 -37
  73. package/build/mcp-host-entry-adapter.js +6 -0
  74. package/build/mcp-host-targets.js +2 -0
  75. package/build/mcp-install-state.js +42 -4
  76. package/build/mcp-invoke.js +5 -5
  77. package/build/mcp-profile.js +2 -2
  78. package/build/mcp-provisioning.js +2 -2
  79. package/build/mcp-server-invocation.js +3 -3
  80. package/build/merge-pull-request.js +118 -10
  81. package/build/merge-workflow-codeowners.js +224 -0
  82. package/build/merge-workflow-config.js +133 -0
  83. package/build/merge-workflow-drift.js +66 -0
  84. package/build/merge-workflow-install.js +638 -0
  85. package/build/merge-workflow.js +572 -0
  86. package/build/model-routing-host.js +25 -0
  87. package/build/model-routing-preferences.js +99 -0
  88. package/build/pipelines.generated.js +10 -10
  89. package/build/{plan-epic-conductor-eligibility.js → plan-epic-implementer-eligibility.js} +18 -16
  90. package/build/plan-execution-ledger.js +8 -0
  91. package/build/plane/cli.js +24 -2
  92. package/build/plane/member-roster.js +5 -5
  93. package/build/plane/preflight.js +68 -0
  94. package/build/plane/supervisor.js +1 -1
  95. package/build/plane/types.js +5 -5
  96. package/build/readiness-check.js +1 -1
  97. package/build/readme.generated.js +1 -1
  98. package/build/review-tickets.js +4 -4
  99. package/build/run-unit-tests-launcher.js +5 -0
  100. package/build/setup-epic.js +171 -17
  101. package/build/start-tickets-conductor.js +32 -32
  102. package/build/start-tickets-prereqs.js +4 -9
  103. package/build/start-tickets.js +475 -91
  104. package/build/version.generated.js +3 -3
  105. package/docs/CONDUCTOR.md +69 -18
  106. package/docs/install/mcp-tool-integrations.md +2 -1
  107. package/package.json +4 -4
  108. package/pipelines/implement-ticket.json +1 -1
  109. package/pipelines/plan-epic.json +2 -2
  110. package/build/conduct-epic/bridge-client.js +0 -773
  111. package/build/conduct-epic/checkpoint-store.js +0 -542
  112. package/build/conduct-epic/cli.js +0 -3145
  113. package/build/conduct-epic/cut-protocol.js +0 -392
  114. package/build/conduct-epic/lock.js +0 -302
  115. package/build/conduct-epic/pr-state.js +0 -286
  116. package/build/conduct-epic/spawn.js +0 -113
  117. package/build/conductor/cli.js +0 -1278
  118. package/build/conductor-bundle-cli.js +0 -256
  119. package/build/direct-ticket-tools.js +0 -70
  120. package/build/executor/reconcile-overlap-governance.js +0 -129
  121. package/build/implement-epic/checkpoint-store.js +0 -542
  122. package/build/implement-epic/cli.js +0 -3158
  123. package/build/implement-epic/lock.js +0 -302
  124. package/build/implement-epic/pr-state.js +0 -286
  125. package/build/implement-epic/spawn.js +0 -113
  126. /package/build/{implement-epic → epic-shared}/bridge-client.js +0 -0
  127. /package/build/{implement-epic → epic-shared}/cut-protocol.js +0 -0
@@ -26,6 +26,18 @@
26
26
  * command `claude`, which on a typical workstation lives in `~/.local/bin`,
27
27
  * `~/.claude/local`, or `/opt/homebrew/bin` — without the generating
28
28
  * shell's PATH a service-launched executor crash-loops at worker preflight.
29
+ *
30
+ * BAPI-1137 makes this a CHECKED property as well as a constructed one: every
31
+ * generation path runs the rendered bytes through
32
+ * {@link assertExecutorServiceCredentialPostcondition}, and doctor classifies
33
+ * an on-disk unit through the same helper. Be precise about what that claim
34
+ * covers. It is a statement about the UNIT'S OWN DECLARATIONS — the
35
+ * environment keys it declares and the arguments it passes — and nothing
36
+ * more. Host-wide SSH keys and agents, login keychains, `gh` authentication
37
+ * state, git credential helpers, and the operator's independently managed
38
+ * Claude worker authentication are all outside a unit file, are never
39
+ * inspected here, and are NOT asserted absent by any classification this
40
+ * module produces.
29
41
  * 2. **Filesystem effects only through injected dependencies.** Every `stat` /
30
42
  * `mkdir` / `writeFile` / prompt goes through {@link ExecutorServiceInstallDeps},
31
43
  * so the whole CLI is unit-testable without touching a real home directory.
@@ -56,7 +68,7 @@
56
68
  * *discriminating* literal ({@link ExecutorServiceArtifactKind}) rather than a
57
69
  * loose `string`, so a plist can never be handed to the systemd inspector.
58
70
  */
59
- import { mkdir as fsMkdir, stat as fsStat, writeFile as fsWriteFile } from "node:fs/promises";
71
+ import { chmod as fsChmod, mkdir as fsMkdir, stat as fsStat, writeFile as fsWriteFile, } from "node:fs/promises";
60
72
  import os from "node:os";
61
73
  import { existsSync } from "node:fs";
62
74
  import readline from "node:readline";
@@ -76,6 +88,23 @@ export const EXECUTOR_LAUNCHD_FILENAME_SUFFIX = ".plist";
76
88
  export const EXECUTOR_SYSTEMD_UNIT_PREFIX = "bridge-gpt-executor-";
77
89
  /** systemd user-unit filename suffix. */
78
90
  export const EXECUTOR_SYSTEMD_UNIT_SUFFIX = ".service";
91
+ /**
92
+ * The permission bits every generated unit is written with: owner read/write
93
+ * only. Applied EXPLICITLY at write time and re-applied to an existing unit,
94
+ * rather than inherited from the process umask (BAPI-1137).
95
+ */
96
+ export const EXECUTOR_SERVICE_UNIT_FILE_MODE = 0o600;
97
+ /**
98
+ * The durable-host mode flag every generated unit carries (BAPI-1137).
99
+ *
100
+ * A DECLARATION OF MODE, not a credential: it asks the executor process to
101
+ * delete any GitHub write variable it inherited, which is safe on a durable
102
+ * host because that host holds no standing GitHub write credential and its
103
+ * merges leave the host entirely. It is opt-in precisely because an ordinary
104
+ * executor's `merge` job legitimately authenticates `gh` from the environment,
105
+ * and scrubbing there would break a supported configuration.
106
+ */
107
+ export const EXECUTOR_SERVICE_SCRUB_GITHUB_CREDENTIALS_FLAG = "--scrub-inherited-github-credentials";
79
108
  /** The ONLY Bridge environment value baked into a generated unit. */
80
109
  export const EXECUTOR_SERVICE_BASE_URL_ENV = "BAPI_BASE_URL";
81
110
  /**
@@ -86,6 +115,18 @@ export const EXECUTOR_SERVICE_BASE_URL_ENV = "BAPI_BASE_URL";
86
115
  * empty PATH would be strictly worse than the service manager's default.
87
116
  */
88
117
  export const EXECUTOR_SERVICE_PATH_ENV = "PATH";
118
+ /**
119
+ * Home-relative directory segments holding the operator's standard macOS log
120
+ * area. `~/Library/Logs` exists on every macOS user account, which is why the
121
+ * two launchd log files live DIRECTLY in it under an executor-qualified name
122
+ * rather than in a Bridge subdirectory: launchd creates the log file but not a
123
+ * missing parent directory, and the generator's write contract is ONE file (the
124
+ * unit), so it has no licence to create a log directory either. A subdirectory
125
+ * would therefore produce a unit whose logging silently never worked.
126
+ */
127
+ const LAUNCHD_LOG_DIR_SEGMENTS = ["Library", "Logs"];
128
+ /** Filename prefix for the generated launchd stdout/stderr log files. */
129
+ export const EXECUTOR_LAUNCHD_LOG_FILENAME_PREFIX = "com.bridge-gpt.executor.";
89
130
  /** Home-relative directory segments holding Darwin LaunchAgents. */
90
131
  const LAUNCHD_DIR_SEGMENTS = ["Library", "LaunchAgents"];
91
132
  /** Home-relative directory segments holding Linux systemd user units. */
@@ -118,6 +159,24 @@ export function executorSystemdDirForHome(homeDir) {
118
159
  export function executorSystemdUnitPathForId(executorId, homeDir) {
119
160
  return pathApiForPlatform("linux").join(executorSystemdDirForHome(homeDir), executorSystemdUnitNameForId(executorId));
120
161
  }
162
+ /**
163
+ * The deterministic launchd stdout/stderr log paths for one executor id:
164
+ * `~/Library/Logs/com.bridge-gpt.executor.<id>.{out,err}.log`.
165
+ *
166
+ * Deterministic and per-executor on purpose. A durable host's whole value is
167
+ * that nobody is watching its terminal, so "where are this service's logs?" must
168
+ * have an answer an operator can derive from the executor id alone — and two
169
+ * executors on one host must never interleave into one file.
170
+ */
171
+ export function executorLaunchdLogPathsForId(executorId, homeDir) {
172
+ const pathApi = pathApiForPlatform("darwin");
173
+ const dir = pathApi.join(homeDir, ...LAUNCHD_LOG_DIR_SEGMENTS);
174
+ const base = `${EXECUTOR_LAUNCHD_LOG_FILENAME_PREFIX}${executorId}`;
175
+ return {
176
+ stdout: pathApi.join(dir, `${base}.out.log`),
177
+ stderr: pathApi.join(dir, `${base}.err.log`),
178
+ };
179
+ }
121
180
  /**
122
181
  * Recover the executor id from a LaunchAgents filename, or `null` when the name
123
182
  * does not follow the fixed convention. Doctor uses this so enumeration and
@@ -220,6 +279,22 @@ export function validateExecutorServiceRenderInput(input, platform) {
220
279
  throw new ExecutorServiceValidationError("base URL must not contain whitespace");
221
280
  }
222
281
  assertRenderableValue(input.invocation.executable, "service executable");
282
+ // BAPI-1137: the packaged invocation is held to the EXISTING executor
283
+ // launcher contract — an absolute node executable plus the absolute packaged
284
+ // entry point and the `executor` subcommand, exactly as
285
+ // `resolvePackagedExecutorInvocation` produces. Validating the shape here
286
+ // rather than accepting an arbitrary prefix is what stops a caller from
287
+ // smuggling a credential-bearing option into the unit's argument vector
288
+ // before the rendered-artifact postcondition ever sees it.
289
+ if (!input.invocation.executable.startsWith("/")) {
290
+ throw new ExecutorServiceValidationError("service executable must be an absolute path (a persistent unit cannot rely on PATH)");
291
+ }
292
+ if (input.invocation.prefixArgs.length !== 2 ||
293
+ !input.invocation.prefixArgs[0].startsWith("/") ||
294
+ input.invocation.prefixArgs[1] !== "executor") {
295
+ throw new ExecutorServiceValidationError("service invocation must be the packaged launcher contract: an absolute entry point " +
296
+ "followed by the 'executor' subcommand");
297
+ }
223
298
  // PATH is optional (empty means "omit the directive"), but a present value
224
299
  // is held to the same control-character barrier as every rendered value: a
225
300
  // CR/LF or NUL in PATH could open a new directive or truncate the unit.
@@ -233,6 +308,13 @@ export function validateExecutorServiceRenderInput(input, platform) {
233
308
  assertRenderableValue(repo, "repository name");
234
309
  for (const arg of input.invocation.prefixArgs)
235
310
  assertRenderableValue(arg, "service argument");
311
+ // The claim scope is rendered into the argument vector like every other
312
+ // value, so it is held to the same barrier. A CR/LF here would open an extra
313
+ // systemd directive, and `parseSystemdStructure` reads only `Environment=`
314
+ // and the FIRST `ExecStart=` — so an injected directive would sit in the
315
+ // written unit without the credential postcondition ever inspecting it.
316
+ for (const runId of input.epicRunIds ?? [])
317
+ assertRenderableValue(runId, "epic run id");
236
318
  return { ok: true };
237
319
  }
238
320
  catch (error) {
@@ -266,6 +348,10 @@ export function buildExecutorServiceArguments(input) {
266
348
  else if (input.repoWide === true) {
267
349
  args.push("--repo-wide");
268
350
  }
351
+ // BAPI-1137: durable-host mode, declared in the unit rather than inferred.
352
+ // Appended LAST so every pre-existing positional assertion over the prefix,
353
+ // repository, executor-id, and claim-scope ordering is unchanged.
354
+ args.push(EXECUTOR_SERVICE_SCRUB_GITHUB_CREDENTIALS_FLAG);
269
355
  return args;
270
356
  }
271
357
  // ---------------------------------------------------------------------------
@@ -278,6 +364,18 @@ export function buildExecutorServiceArguments(input) {
278
364
  * a non-empty `envPath`, the generating shell's `PATH` is baked alongside the
279
365
  * base URL (module docstring, invariant 1) so the executor's worker spawn can
280
366
  * resolve `claude` under launchd's minimal default environment.
367
+ *
368
+ * BAPI-1137 adds `StandardOutPath` / `StandardErrorPath`, each XML escaped like
369
+ * every other interpolated value, so a durable host's output lands somewhere an
370
+ * operator can read after the fact instead of in launchd's void. systemd needs
371
+ * no counterpart: a `--user` unit's stdout/stderr already go to journald.
372
+ *
373
+ * Two things are deliberately NOT added. The invocation stays a direct
374
+ * executable with no shell wrapper, so the process receives SIGTERM/SIGINT
375
+ * itself rather than a shell swallowing it; and no stop-timeout directive is
376
+ * rendered, because `launchd.plist(5)` documents `ExitTimeOut`'s default only as
377
+ * "system-defined" and overriding an unknown default with a guess would be a
378
+ * change of behavior dressed as a clarification.
281
379
  */
282
380
  export function renderExecutorLaunchdPlist(input) {
283
381
  assertValidRenderInput(input, "darwin");
@@ -292,7 +390,11 @@ export function renderExecutorLaunchdPlist(input) {
292
390
  if (input.envPath !== undefined && input.envPath.length > 0) {
293
391
  envEntries.push(` <key>${EXECUTOR_SERVICE_PATH_ENV}</key>`, ` <string>${xmlEscape(input.envPath)}</string>`);
294
392
  }
295
- return [
393
+ // Durable logging (BAPI-1137). Derived from the executor id and the home
394
+ // directory already validated above, so the paths are deterministic and carry
395
+ // no ambient state.
396
+ const logPaths = executorLaunchdLogPathsForId(input.executorId, input.homeDir);
397
+ const content = [
296
398
  '<?xml version="1.0" encoding="UTF-8"?>',
297
399
  '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
298
400
  '<plist version="1.0">',
@@ -307,6 +409,10 @@ export function renderExecutorLaunchdPlist(input) {
307
409
  " <array>",
308
410
  programArgs,
309
411
  " </array>",
412
+ " <key>StandardOutPath</key>",
413
+ ` <string>${xmlEscape(logPaths.stdout)}</string>`,
414
+ " <key>StandardErrorPath</key>",
415
+ ` <string>${xmlEscape(logPaths.stderr)}</string>`,
310
416
  " <key>RunAtLoad</key>",
311
417
  " <true/>",
312
418
  " <key>KeepAlive</key>",
@@ -315,6 +421,12 @@ export function renderExecutorLaunchdPlist(input) {
315
421
  "</plist>",
316
422
  "",
317
423
  ].join("\n");
424
+ // BAPI-1137: the generator refuses to hand back a unit it could not itself
425
+ // classify `confirmed-absent`. Checking the RENDERED BYTES rather than the
426
+ // input is deliberate — it is the artifact, not the intent, that ends up on
427
+ // disk and that doctor later reads back.
428
+ assertExecutorServiceCredentialPostcondition("launchd-plist", content);
429
+ return content;
318
430
  }
319
431
  /**
320
432
  * Escape a value for a systemd directive. {@link systemdQuote} handles
@@ -346,7 +458,7 @@ export function renderExecutorSystemdService(input) {
346
458
  if (input.envPath !== undefined && input.envPath.length > 0) {
347
459
  environmentLines.push(`Environment=${EXECUTOR_SERVICE_PATH_ENV}=${systemdDirectiveValue(input.envPath)}`);
348
460
  }
349
- return [
461
+ const content = [
350
462
  "[Unit]",
351
463
  `Description=${systemdDirectiveValue(`Bridge GPT epic-conductor executor (${input.executorId})`)}`,
352
464
  "After=network-online.target",
@@ -362,11 +474,19 @@ export function renderExecutorSystemdService(input) {
362
474
  "WantedBy=default.target",
363
475
  "",
364
476
  ].join("\n");
477
+ // Same postcondition as the launchd renderer, over the rendered bytes.
478
+ assertExecutorServiceCredentialPostcondition("systemd-service", content);
479
+ return content;
365
480
  }
366
481
  /**
367
482
  * Build the single artifact for a supported persistent-service platform. Pure:
368
483
  * it derives the deterministic path and renders the content, and rejects any
369
484
  * platform that has no generated-unit format rather than guessing one.
485
+ *
486
+ * The BAPI-1137 credential postcondition is inherited rather than repeated: both
487
+ * renderers assert it over their own rendered bytes before returning, so this
488
+ * builder — and {@link planExecutorServiceInstall} above it — cannot produce
489
+ * an artifact that failed the check.
370
490
  */
371
491
  export function buildExecutorServiceArtifact(input, platform) {
372
492
  if (platform === "darwin") {
@@ -501,11 +621,13 @@ function inspectSystemdService(content) {
501
621
  * never invents values.
502
622
  */
503
623
  export function inspectExecutorServiceArtifact(kind, content) {
624
+ let recovered;
504
625
  try {
505
- return kind === "launchd-plist" ? inspectLaunchdPlist(content) : inspectSystemdService(content);
626
+ recovered =
627
+ kind === "launchd-plist" ? inspectLaunchdPlist(content) : inspectSystemdService(content);
506
628
  }
507
629
  catch {
508
- return {
630
+ recovered = {
509
631
  ok: false,
510
632
  baseUrl: null,
511
633
  repos: [],
@@ -513,6 +635,251 @@ export function inspectExecutorServiceArtifact(kind, content) {
513
635
  problems: ["unit content could not be parsed"],
514
636
  };
515
637
  }
638
+ // BAPI-1137: the credential classification is computed by the SAME helper the
639
+ // renderers use as their write postcondition, so a unit doctor calls compliant
640
+ // is exactly a unit this module would agree to generate.
641
+ const declaration = inspectExecutorServiceCredentialDeclaration(kind, content);
642
+ return {
643
+ ...recovered,
644
+ noStandingGithubWriteCredential: declaration.classification,
645
+ credentialDeclarationProblems: declaration.problems,
646
+ };
647
+ }
648
+ /**
649
+ * The COMPLETE set of environment keys a generated unit may declare, compared
650
+ * case-insensitively (launchd plist keys and systemd `Environment=` names are
651
+ * both case-sensitive to the service manager, but an operator edit that changed
652
+ * only case must not slip past this allowlist).
653
+ *
654
+ * `BAPI_BASE_URL` is required; `PATH` is optional (BAPI-327). There is no third
655
+ * entry, and adding one is a deliberate widening of the asserted claim.
656
+ */
657
+ export const EXECUTOR_SERVICE_UNIT_ENV_ALLOWLIST = [
658
+ EXECUTOR_SERVICE_BASE_URL_ENV,
659
+ EXECUTOR_SERVICE_PATH_ENV,
660
+ ];
661
+ /** True when `key` is one of the two allowlisted unit environment keys. */
662
+ export function isAllowedExecutorServiceUnitEnvKey(key) {
663
+ const upper = key.toUpperCase();
664
+ return EXECUTOR_SERVICE_UNIT_ENV_ALLOWLIST.some((allowed) => allowed.toUpperCase() === upper);
665
+ }
666
+ /** A token that reads as an option: a leading `-` with something after it. */
667
+ function looksLikeOption(token) {
668
+ return token.startsWith("-") && token.length > 1;
669
+ }
670
+ /**
671
+ * Check a recovered argument vector against the CLOSED executor contract:
672
+ *
673
+ * <absolute executable> <absolute packaged entry> executor
674
+ * (--repo <name>)+ --executor-id <id>
675
+ * [ (--epic-run-id <id>)+ | --repo-wide ]
676
+ *
677
+ * Values are consumed POSITIONALLY — the token after `--repo` is that
678
+ * repository's name whatever it looks like — because repository names and paths
679
+ * are data, not option spellings, and treating a leading `-` in a value as an
680
+ * unknown option would misclassify a legal unit as credential-bearing.
681
+ *
682
+ * Anything else is a violation: an unrecognized option cannot be shown to be
683
+ * credential-free, and the contract is the whole basis of the claim.
684
+ */
685
+ export function checkExecutorServiceArgumentContract(argv) {
686
+ if (argv.length < 3) {
687
+ return {
688
+ ok: false,
689
+ kind: "ambiguous",
690
+ problem: "the recovered invocation is too short to match the executor launcher contract",
691
+ };
692
+ }
693
+ const [executable, entryPath, subcommand] = argv;
694
+ if (!executable.startsWith("/") || looksLikeOption(executable)) {
695
+ return {
696
+ ok: false,
697
+ kind: "ambiguous",
698
+ problem: "the unit's executable is not an absolute path",
699
+ };
700
+ }
701
+ if (!entryPath.startsWith("/") || looksLikeOption(entryPath)) {
702
+ return {
703
+ ok: false,
704
+ kind: "ambiguous",
705
+ problem: "the unit's packaged entry point is not an absolute path",
706
+ };
707
+ }
708
+ if (subcommand !== "executor") {
709
+ return {
710
+ ok: false,
711
+ kind: "violation",
712
+ problem: "the unit does not invoke the packaged 'executor' subcommand",
713
+ };
714
+ }
715
+ let repoCount = 0;
716
+ let executorIdCount = 0;
717
+ let epicRunIdCount = 0;
718
+ let repoWide = false;
719
+ for (let i = 3; i < argv.length; i++) {
720
+ const token = argv[i];
721
+ if (token === "--repo" || token === "--executor-id" || token === "--epic-run-id") {
722
+ if (i + 1 >= argv.length) {
723
+ return {
724
+ ok: false,
725
+ kind: "ambiguous",
726
+ problem: `'${token}' has no value in the recovered invocation`,
727
+ };
728
+ }
729
+ i++; // positional value — never re-examined as an option
730
+ if (token === "--repo")
731
+ repoCount++;
732
+ else if (token === "--executor-id")
733
+ executorIdCount++;
734
+ else
735
+ epicRunIdCount++;
736
+ continue;
737
+ }
738
+ if (token === "--repo-wide") {
739
+ repoWide = true;
740
+ continue;
741
+ }
742
+ if (token === EXECUTOR_SERVICE_SCRUB_GITHUB_CREDENTIALS_FLAG) {
743
+ // Part of the closed contract: a boolean mode declaration that carries no
744
+ // value and therefore cannot carry a secret.
745
+ continue;
746
+ }
747
+ return {
748
+ ok: false,
749
+ kind: "violation",
750
+ problem: `'${token}' is not part of the closed executor service argument contract`,
751
+ };
752
+ }
753
+ if (repoCount === 0) {
754
+ return {
755
+ ok: false,
756
+ kind: "ambiguous",
757
+ problem: "the recovered invocation names no repository",
758
+ };
759
+ }
760
+ if (executorIdCount !== 1) {
761
+ return {
762
+ ok: false,
763
+ kind: "ambiguous",
764
+ problem: "the recovered invocation does not carry exactly one --executor-id",
765
+ };
766
+ }
767
+ if (repoWide && epicRunIdCount > 0) {
768
+ return {
769
+ ok: false,
770
+ kind: "violation",
771
+ problem: "the recovered invocation carries both --repo-wide and --epic-run-id",
772
+ };
773
+ }
774
+ return { ok: true };
775
+ }
776
+ /** Recover the launchd structures: `EnvironmentVariables` keys + `ProgramArguments`. */
777
+ function parseLaunchdStructure(content) {
778
+ let envKeys = null;
779
+ const envBlock = content.match(/<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/);
780
+ if (envBlock) {
781
+ envKeys = Array.from(envBlock[1].matchAll(/<key>([^<]*)<\/key>/g)).map((m) => xmlUnescape(m[1]));
782
+ }
783
+ let argv = null;
784
+ const argsBlock = content.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
785
+ if (argsBlock) {
786
+ argv = Array.from(argsBlock[1].matchAll(/<string>([^<]*)<\/string>/g)).map((m) => xmlUnescape(m[1]));
787
+ }
788
+ return { envKeys, argv };
789
+ }
790
+ /** Recover the systemd structures: `Environment=` key names + `ExecStart=` tokens. */
791
+ function parseSystemdStructure(content) {
792
+ const lines = content.split(/\r?\n/);
793
+ const envKeys = [];
794
+ let sawEnvironment = false;
795
+ for (const line of lines) {
796
+ if (!line.startsWith("Environment="))
797
+ continue;
798
+ sawEnvironment = true;
799
+ // Only the KEY is read. The value after the first `=` is never inspected.
800
+ const assignment = line.slice("Environment=".length);
801
+ const separator = assignment.indexOf("=");
802
+ if (separator <= 0) {
803
+ // A directive with no `KEY=` shape leaves the block ambiguous rather than
804
+ // silently contributing nothing.
805
+ return { envKeys: null, argv: parseSystemdExecStart(lines) };
806
+ }
807
+ envKeys.push(assignment.slice(0, separator));
808
+ }
809
+ return { envKeys: sawEnvironment ? envKeys : null, argv: parseSystemdExecStart(lines) };
810
+ }
811
+ /** The `ExecStart=` argument vector, or null when the directive is absent. */
812
+ function parseSystemdExecStart(lines) {
813
+ const execLine = lines.find((l) => l.startsWith("ExecStart="));
814
+ if (!execLine)
815
+ return null;
816
+ return splitSystemdTokens(execLine.slice("ExecStart=".length));
817
+ }
818
+ /**
819
+ * Classify one unit's credential declaration from its parsed structures.
820
+ *
821
+ * SHARED by the renderers (which inspect their own freshly rendered output as a
822
+ * write postcondition) and by the exported inspector doctor calls over on-disk
823
+ * bytes. One implementation is the point: a generator and a diagnostic that
824
+ * each carried their own notion of "clean" would drift, and the drift would
825
+ * surface as a unit doctor calls compliant that the generator would refuse.
826
+ */
827
+ export function inspectExecutorServiceCredentialDeclaration(kind, content) {
828
+ let structure;
829
+ try {
830
+ structure =
831
+ kind === "launchd-plist" ? parseLaunchdStructure(content) : parseSystemdStructure(content);
832
+ }
833
+ catch {
834
+ return {
835
+ classification: "unverifiable",
836
+ problems: ["the unit's structure could not be parsed"],
837
+ };
838
+ }
839
+ const problems = [];
840
+ if (structure.envKeys === null) {
841
+ problems.push("the unit declares no parsable environment block");
842
+ }
843
+ if (structure.argv === null) {
844
+ problems.push("the unit declares no parsable invocation");
845
+ }
846
+ if (structure.envKeys === null || structure.argv === null) {
847
+ return { classification: "unverifiable", problems };
848
+ }
849
+ const disallowedKeys = structure.envKeys.filter((key) => !isAllowedExecutorServiceUnitEnvKey(key));
850
+ for (const key of disallowedKeys) {
851
+ // The KEY NAME only. An environment value is never read, echoed, or
852
+ // examined for token-like content.
853
+ problems.push(`the unit declares the non-allowlisted environment key '${key}'`);
854
+ }
855
+ const argumentContract = checkExecutorServiceArgumentContract(structure.argv);
856
+ if (!argumentContract.ok)
857
+ problems.push(argumentContract.problem);
858
+ if (disallowedKeys.length > 0)
859
+ return { classification: "present", problems };
860
+ if (!argumentContract.ok) {
861
+ return {
862
+ classification: argumentContract.kind === "violation" ? "present" : "unverifiable",
863
+ problems,
864
+ };
865
+ }
866
+ return { classification: "confirmed-absent", problems };
867
+ }
868
+ /**
869
+ * The write postcondition every generation path shares.
870
+ *
871
+ * Throws unless the rendered artifact classifies `confirmed-absent`, so a unit
872
+ * whose declarations cannot be shown credential-free is never written to disk in
873
+ * the first place. Called by the renderers themselves rather than by their
874
+ * callers: a postcondition a caller has to remember is one a new caller forgets.
875
+ */
876
+ export function assertExecutorServiceCredentialPostcondition(kind, content) {
877
+ const inspection = inspectExecutorServiceCredentialDeclaration(kind, content);
878
+ if (inspection.classification === "confirmed-absent")
879
+ return;
880
+ const detail = inspection.problems[0] ?? "the unit's declarations could not be verified";
881
+ throw new ExecutorServiceValidationError("the generated unit does not satisfy the no-standing-GitHub-write-credential postcondition " +
882
+ `(${inspection.classification}): ${detail}`);
516
883
  }
517
884
  /**
518
885
  * Resolve the packaged launcher a persistent unit should bake in: this process's
@@ -564,6 +931,12 @@ export function getExecutorInstallServiceUsage() {
564
931
  return [
565
932
  "Usage: mcp-server executor install-service --executor-id <id> --repo <name> [--repo <name> ...] [options]",
566
933
  "",
934
+ "The FILE-GENERATION LEG of durable-host mode. It writes one unit and stops there.",
935
+ "For the supported composed bring-up — planning, consented write, consented start,",
936
+ "read-only local inspection, and bounded server-observed readiness — run",
937
+ "`install conductor` instead. Use this command when you deliberately manage the",
938
+ "service lifecycle yourself.",
939
+ "",
567
940
  "Generates ONE long-running user-domain service unit for ONE stable executor id:",
568
941
  " macOS ~/Library/LaunchAgents/com.bridge-gpt.executor.<id>.plist",
569
942
  " Linux ~/.config/systemd/user/bridge-gpt-executor-<id>.service",
@@ -594,9 +967,15 @@ export function getExecutorInstallServiceUsage() {
594
967
  "through a CLAUDE_CODE_OAUTH_TOKEN you export into the service's own environment",
595
968
  "yourself; Bridge only forwards that value unchanged and never stores it.",
596
969
  "",
970
+ "The unit is written with permissions 0600 (owner read/write only), and on macOS it",
971
+ "declares deterministic stdout/stderr log paths under ~/Library/Logs so a durable",
972
+ "host's output is readable after the fact; a systemd --user unit logs to journald.",
973
+ "",
597
974
  "This command writes a file and nothing else. Starting, stopping, enabling, and",
598
975
  "removing the service stay operator-managed via launchctl / systemctl --user /",
599
- "Task Scheduler.",
976
+ "Task Scheduler. A written unit is NOT readiness, and neither is a started one:",
977
+ "only a server-observed executor claim, after a start you perform separately, makes",
978
+ "this host ready to do work.",
600
979
  ].join("\n");
601
980
  }
602
981
  /** Default, production dependency set for the install-service CLI. */
@@ -615,7 +994,10 @@ export function createDefaultExecutorServiceInstallDeps() {
615
994
  resolveInvocation: () => resolvePackagedExecutorInvocation(),
616
995
  stat: (filePath) => fsStat(filePath),
617
996
  mkdir: (dirPath, options) => fsMkdir(dirPath, options),
618
- writeFile: (filePath, content) => fsWriteFile(filePath, content, "utf-8"),
997
+ // Encoding AND mode passed explicitly (BAPI-1137) — never left to the
998
+ // process umask.
999
+ writeFile: (filePath, content, options) => fsWriteFile(filePath, content, options ?? { encoding: "utf-8", mode: EXECUTOR_SERVICE_UNIT_FILE_MODE }),
1000
+ chmod: (filePath, mode) => fsChmod(filePath, mode),
619
1001
  prompt: defaultConsentPrompt,
620
1002
  log: (message) => console.log(message),
621
1003
  errorLog: (message) => console.error(message),
@@ -668,7 +1050,11 @@ export async function writeExecutorServiceArtifact(artifact, deps, knownExists)
668
1050
  const replaced = knownExists ?? (await targetExists(deps.stat, artifact.path));
669
1051
  const pathApi = pathApiForPlatform(deps.platform);
670
1052
  await deps.mkdir(pathApi.dirname(artifact.path), { recursive: true });
671
- await deps.writeFile(artifact.path, artifact.content);
1053
+ await deps.writeFile(artifact.path, artifact.content, {
1054
+ encoding: "utf-8",
1055
+ mode: EXECUTOR_SERVICE_UNIT_FILE_MODE,
1056
+ });
1057
+ await secureUnitMode(artifact.path, deps.chmod);
672
1058
  return { replaced };
673
1059
  }
674
1060
  /** Reject anything that is not an absolute http(s) URL. Never echoes the value. */
@@ -736,14 +1122,40 @@ export async function writeExecutorServicePlan(plan, deps, knownExists) {
736
1122
  current = null;
737
1123
  }
738
1124
  if (current === plan.artifact.content) {
1125
+ // An `unchanged` CONTENT outcome still secures the file's mode: a unit
1126
+ // written by an older CLI (or by a run under a looser umask) is
1127
+ // byte-identical and yet group/world readable, and leaving it that way
1128
+ // because "nothing changed" would make the permission guarantee depend on
1129
+ // when the operator first installed.
1130
+ await secureUnitMode(plan.unitPath, deps.chmod);
739
1131
  return { outcome: "unchanged", unitPath: plan.unitPath, plan };
740
1132
  }
741
1133
  }
742
1134
  const pathApi = pathApiForPlatform(plan.platform);
743
1135
  await deps.mkdir(pathApi.dirname(plan.unitPath), { recursive: true });
744
- await deps.writeFile(plan.unitPath, plan.artifact.content);
1136
+ await deps.writeFile(plan.unitPath, plan.artifact.content, {
1137
+ encoding: "utf-8",
1138
+ mode: EXECUTOR_SERVICE_UNIT_FILE_MODE,
1139
+ });
1140
+ // Belt and braces for the REPLACE path: `mode` is honoured at creation only,
1141
+ // so an existing unit keeps its old bits unless they are set explicitly.
1142
+ await secureUnitMode(plan.unitPath, deps.chmod);
745
1143
  return { outcome: exists ? "replaced" : "written", unitPath: plan.unitPath, plan };
746
1144
  }
1145
+ /**
1146
+ * Apply {@link EXECUTOR_SERVICE_UNIT_FILE_MODE} through an optional seam.
1147
+ *
1148
+ * A caller without a `chmod` seam is not an error — the freshly created file
1149
+ * already carries the mode from the write — so a missing seam is a no-op rather
1150
+ * than a failure. A chmod that REJECTS does propagate: the caller asked for a
1151
+ * restricted unit, and silently leaving a readable one behind while reporting
1152
+ * success is the failure mode this whole path exists to prevent.
1153
+ */
1154
+ async function secureUnitMode(unitPath, chmod) {
1155
+ if (!chmod)
1156
+ return;
1157
+ await chmod(unitPath, EXECUTOR_SERVICE_UNIT_FILE_MODE);
1158
+ }
747
1159
  /** ENOENT means absent; any other rejection is a real failure and propagates. */
748
1160
  async function targetExists(stat, filePath) {
749
1161
  try {
@@ -982,6 +1394,9 @@ function formatPostWriteGuidance(artifact, executorId) {
982
1394
  ` launchctl bootstrap gui/$(id -u) ${posixShellQuote(artifact.path)}`,
983
1395
  ` launchctl print gui/$(id -u)/${label} # status`,
984
1396
  ` launchctl bootout gui/$(id -u)/${label} # stop + unload`,
1397
+ "",
1398
+ "Starting it is still not readiness: this host is ready only once the Bridge",
1399
+ "server observes an executor claim from it.",
985
1400
  ].join("\n");
986
1401
  }
987
1402
  const unit = posixShellQuote(executorSystemdUnitNameForId(executorId));
@@ -993,5 +1408,8 @@ function formatPostWriteGuidance(artifact, executorId) {
993
1408
  "",
994
1409
  "On a headless host, also run `loginctl enable-linger $USER` so the user service",
995
1410
  "keeps running without an active login session.",
1411
+ "",
1412
+ "Starting it is still not readiness: this host is ready only once the Bridge",
1413
+ "server observes an executor claim from it.",
996
1414
  ].join("\n");
997
1415
  }
@@ -19,6 +19,11 @@ export const EXECUTOR_JOB_TYPE_VALUES = [
19
19
  "smoke",
20
20
  // BAPI-1151 registered it server-side; BAPI-1152 lands the executor body.
21
21
  "reconcile_overlap",
22
+ // BAPI-1160 — the deterministic infrastructure re-run. Registered here AND
23
+ // implemented in `ci-rerun-job.ts`: a job type the server can dispatch but
24
+ // this union does not carry fails as `ContractError.UnsupportedJobType`,
25
+ // which is the single most likely way this ticket could ship half-landed.
26
+ "ci_rerun",
22
27
  ];
23
28
  /**
24
29
  * Server-authoritative completion classification (T2 `ExecutorClassification`,