@bragi-gmbh/codebus 1.4.0 → 1.6.1

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 (146) hide show
  1. package/dist/ci-hooks/ci-hooks.js +126 -0
  2. package/dist/ci-hooks/ci-hooks.js.map +1 -0
  3. package/dist/commands/adr.js +30 -0
  4. package/dist/commands/adr.js.map +1 -0
  5. package/dist/commands/agent.js +175 -0
  6. package/dist/commands/agent.js.map +1 -0
  7. package/dist/commands/as.js +1 -1
  8. package/dist/commands/as.js.map +1 -1
  9. package/dist/commands/ask.js +1 -1
  10. package/dist/commands/ask.js.map +1 -1
  11. package/dist/commands/checkin.js +1 -1
  12. package/dist/commands/checkin.js.map +1 -1
  13. package/dist/commands/ci-findings.js +129 -0
  14. package/dist/commands/ci-findings.js.map +1 -0
  15. package/dist/commands/ci-reply.js +117 -0
  16. package/dist/commands/ci-reply.js.map +1 -0
  17. package/dist/commands/ci-steer-escalate.js +65 -0
  18. package/dist/commands/ci-steer-escalate.js.map +1 -0
  19. package/dist/commands/cli-mcp-correlation.js +131 -0
  20. package/dist/commands/cli-mcp-correlation.js.map +1 -0
  21. package/dist/commands/connector-attach-detach.js +542 -0
  22. package/dist/commands/connector-attach-detach.js.map +1 -0
  23. package/dist/commands/connector.js +513 -0
  24. package/dist/commands/connector.js.map +1 -0
  25. package/dist/commands/contract.js +33 -0
  26. package/dist/commands/contract.js.map +1 -0
  27. package/dist/commands/correlation.js +194 -0
  28. package/dist/commands/correlation.js.map +1 -0
  29. package/dist/commands/decision.js +63 -0
  30. package/dist/commands/decision.js.map +1 -1
  31. package/dist/commands/digest.js +14 -3
  32. package/dist/commands/digest.js.map +1 -1
  33. package/dist/commands/do.js +127 -0
  34. package/dist/commands/do.js.map +1 -0
  35. package/dist/commands/factory-attention.js +106 -0
  36. package/dist/commands/factory-attention.js.map +1 -0
  37. package/dist/commands/factory-context-read.js +171 -0
  38. package/dist/commands/factory-context-read.js.map +1 -0
  39. package/dist/commands/factory-interventions.js +102 -0
  40. package/dist/commands/factory-interventions.js.map +1 -0
  41. package/dist/commands/factory.js +187 -0
  42. package/dist/commands/factory.js.map +1 -1
  43. package/dist/commands/github-login.js +109 -53
  44. package/dist/commands/github-login.js.map +1 -1
  45. package/dist/commands/graph-pull.js +130 -0
  46. package/dist/commands/graph-pull.js.map +1 -0
  47. package/dist/commands/graph.js +5 -1
  48. package/dist/commands/graph.js.map +1 -1
  49. package/dist/commands/inbox.js +7 -7
  50. package/dist/commands/inbox.js.map +1 -1
  51. package/dist/commands/ingest.js +1 -1
  52. package/dist/commands/ingest.js.map +1 -1
  53. package/dist/commands/init.js +10 -1
  54. package/dist/commands/init.js.map +1 -1
  55. package/dist/commands/install.js +1160 -55
  56. package/dist/commands/install.js.map +1 -1
  57. package/dist/commands/integration-ledger.js +81 -0
  58. package/dist/commands/integration-ledger.js.map +1 -0
  59. package/dist/commands/knowledge.js +194 -0
  60. package/dist/commands/knowledge.js.map +1 -0
  61. package/dist/commands/links.js +1 -1
  62. package/dist/commands/links.js.map +1 -1
  63. package/dist/commands/login.js +1 -1
  64. package/dist/commands/login.js.map +1 -1
  65. package/dist/commands/morning.js +50 -1
  66. package/dist/commands/morning.js.map +1 -1
  67. package/dist/commands/plan-compiler.js +260 -0
  68. package/dist/commands/plan-compiler.js.map +1 -0
  69. package/dist/commands/plan-lint.js +275 -0
  70. package/dist/commands/plan-lint.js.map +1 -0
  71. package/dist/commands/plan-score.js +203 -0
  72. package/dist/commands/plan-score.js.map +1 -0
  73. package/dist/commands/plan.js +481 -15
  74. package/dist/commands/plan.js.map +1 -1
  75. package/dist/commands/product-validity.js +168 -0
  76. package/dist/commands/product-validity.js.map +1 -0
  77. package/dist/commands/question.js +1 -1
  78. package/dist/commands/question.js.map +1 -1
  79. package/dist/commands/read-policy.js +131 -0
  80. package/dist/commands/read-policy.js.map +1 -0
  81. package/dist/commands/read.js +1 -1
  82. package/dist/commands/read.js.map +1 -1
  83. package/dist/commands/repo.js +106 -8
  84. package/dist/commands/repo.js.map +1 -1
  85. package/dist/commands/repository-protection.js +209 -0
  86. package/dist/commands/repository-protection.js.map +1 -0
  87. package/dist/commands/resolve.js +2 -2
  88. package/dist/commands/resolve.js.map +1 -1
  89. package/dist/commands/self-healing.js +26 -0
  90. package/dist/commands/self-healing.js.map +1 -0
  91. package/dist/commands/send.js +1 -1
  92. package/dist/commands/send.js.map +1 -1
  93. package/dist/commands/serial-merge.js +303 -0
  94. package/dist/commands/serial-merge.js.map +1 -0
  95. package/dist/commands/session.js +1 -1
  96. package/dist/commands/session.js.map +1 -1
  97. package/dist/commands/spend.js +31 -0
  98. package/dist/commands/spend.js.map +1 -0
  99. package/dist/commands/task.js +92 -1
  100. package/dist/commands/task.js.map +1 -1
  101. package/dist/commands/taxonomy.js +21 -0
  102. package/dist/commands/taxonomy.js.map +1 -0
  103. package/dist/commands/verify.js +77 -0
  104. package/dist/commands/verify.js.map +1 -0
  105. package/dist/config/self-healing-analysis.json +158 -0
  106. package/dist/errors.js +18 -4
  107. package/dist/errors.js.map +1 -1
  108. package/dist/execution/client.js +53 -0
  109. package/dist/execution/client.js.map +1 -0
  110. package/dist/execution/local.js +24 -0
  111. package/dist/execution/local.js.map +1 -0
  112. package/dist/execution/ssh.js +38 -0
  113. package/dist/execution/ssh.js.map +1 -0
  114. package/dist/graph.js +78 -16
  115. package/dist/graph.js.map +1 -1
  116. package/dist/index.js +40 -2
  117. package/dist/index.js.map +1 -1
  118. package/dist/json-lite.js +248 -0
  119. package/dist/json-lite.js.map +1 -0
  120. package/dist/knowledge-drift.js +40 -0
  121. package/dist/knowledge-drift.js.map +1 -0
  122. package/dist/lint/parallelism.js +414 -0
  123. package/dist/lint/parallelism.js.map +1 -0
  124. package/dist/mail-latency.js +11 -1
  125. package/dist/mail-latency.js.map +1 -1
  126. package/dist/mcp-ci-findings.js +31 -0
  127. package/dist/mcp-ci-findings.js.map +1 -0
  128. package/dist/mcp-correlation.js +290 -0
  129. package/dist/mcp-correlation.js.map +1 -0
  130. package/dist/mcp-factory-context-read.js +81 -0
  131. package/dist/mcp-factory-context-read.js.map +1 -0
  132. package/dist/mcp-registry.js +147 -1
  133. package/dist/mcp-registry.js.map +1 -1
  134. package/dist/mcp.js +94 -5
  135. package/dist/mcp.js.map +1 -1
  136. package/dist/openrouter-reservation-guard.js +97 -0
  137. package/dist/openrouter-reservation-guard.js.map +1 -0
  138. package/dist/self-healing-analysis-loader.js +150 -0
  139. package/dist/self-healing-analysis-loader.js.map +1 -0
  140. package/dist/sender.js +11 -9
  141. package/dist/sender.js.map +1 -1
  142. package/dist/toml-lite.js +546 -0
  143. package/dist/toml-lite.js.map +1 -0
  144. package/dist/version-check.js +23 -7
  145. package/dist/version-check.js.map +1 -1
  146. package/package.json +5 -3
@@ -1,6 +1,7 @@
1
- import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, copyFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, openSync, closeSync, fsyncSync, renameSync, unlinkSync, statSync, lstatSync, realpathSync, readlinkSync, accessSync, constants as fsConstants, } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join, dirname } from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ import { join, dirname, basename, isAbsolute, delimiter } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { OK, WARN } from "./login.js";
6
7
  import { readCachedDigest } from "./digest.js";
@@ -11,6 +12,9 @@ import { hookEventHasCommand } from "../mail-latency.js";
11
12
  import { loadFileConfig, saveFileConfig } from "../config.js";
12
13
  import { ONE_LINER } from "../positioning.js";
13
14
  import { createClient } from "@supabase/supabase-js";
15
+ import { isDevLinkedInstall, isCheckoutShapedPackageRoot } from "../version-check.js";
16
+ import { findTopLevelTableBlock, parseSimpleTomlTable, replaceSimpleTomlValues, mightReferenceOurTable } from "../toml-lite.js";
17
+ import { replaceJsonValue } from "../json-lite.js";
14
18
  // F5: derive the reported tool count from the actual registry instead of a
15
19
  // hardcoded number that rots every time a tool is added/removed. Imported
16
20
  // from mcp-registry.ts, NOT mcp.ts (F1): mcp-registry.ts is side-effect-free
@@ -46,13 +50,33 @@ function manualClaudeInstructions() {
46
50
  ' "hooks": { "SessionStart": [ { "hooks": [ { "type": "command", "command": "codebus session start --hook" } ] } ] }',
47
51
  ].join("\n");
48
52
  }
49
- async function installClaude(o) {
53
+ /**
54
+ * Luna finding: this used to read, mutate, and `writeFileSync` ~/.claude/
55
+ * settings.json DIRECTLY — no acquireConfigLock, no atomicWriteFileSync, no
56
+ * TOCTOU re-check — even though installMcp writes this EXACT SAME file
57
+ * through writeIfUnchanged() elsewhere in this file. Two concurrent
58
+ * `codebus install claude` runs (or an install racing a hand edit) could
59
+ * both read the same old file and the later plain write would silently
60
+ * discard the other's changes. Now routed through the same machinery —
61
+ * see installCodexHooks (hooks.json) for the identical fix applied there
62
+ * first. Existing contract preserved: this function has always treated a
63
+ * failure to safely write settings.json as fatal (process.exit(1) on a
64
+ * parse error, below) — a write-time conflict now gets the same treatment,
65
+ * rather than inventing a new boolean-return contract nothing here or in
66
+ * its one caller (installAll) currently consumes.
67
+ */
68
+ export async function installClaude(o) {
50
69
  const path = CLAUDE_SETTINGS();
51
70
  let settings = {};
71
+ let existingText = "";
52
72
  try {
53
73
  if (existsSync(path)) {
54
- settings = JSON.parse(readFileSync(path, "utf8"));
55
- copyFileSync(path, `${path}.codebus-backup`); // never destroy user settings
74
+ existingText = readFileSync(path, "utf8");
75
+ settings = JSON.parse(existingText);
76
+ // Backup is now handled by writeIfUnchanged()'s own atomic backup
77
+ // write below (against whatever the file's content actually is right
78
+ // before the write, not this initial read) — no separate copyFileSync
79
+ // needed here any more.
56
80
  }
57
81
  }
58
82
  catch (e) {
@@ -84,7 +108,15 @@ async function installClaude(o) {
84
108
  }
85
109
  }
86
110
  }
87
- const already = JSON.stringify(sessionStart).includes("codebus session start --hook");
111
+ // Luna finding: a serialized substring check over the WHOLE hook array
112
+ // (JSON.stringify(...).includes(...)) is fooled by any unrelated hook
113
+ // whose description, argument, or command merely CONTAINS this text —
114
+ // reporting "already installed" and skipping the real write while turn-
115
+ // boundary/session-start coverage is actually absent. hookEventHasCommand
116
+ // checks only the `command` field of each entry for an EXACT match,
117
+ // mirroring the same precision installCodexHooks already uses below (and
118
+ // the manual per-entry command check in the upgrade loop just above).
119
+ const already = hookEventHasCommand(sessionStart, "codebus session start --hook");
88
120
  if (upgraded) {
89
121
  settings.hooks.SessionStart = sessionStart;
90
122
  console.log(`${OK} SessionStart hook upgraded → codebus session start --hook (bound dirs auto-orient; others stay silent)`);
@@ -98,9 +130,39 @@ async function installClaude(o) {
98
130
  console.log(`${OK} SessionStart hook already installed`);
99
131
  }
100
132
  // Stop hook: turn-boundary mail surfacing (identity-explicit; silent elsewhere).
133
+ // Luna finding: an existing hook wired with the OLDER bare "codebus tick"
134
+ // (no --hook) was left untouched by the exact-match check below, AND a
135
+ // second, --hook-form entry got appended alongside it — the old hook
136
+ // stays active, still resolving the author's config.json identity in
137
+ // every unrelated machine-wide session, exactly the identity-leak class
138
+ // of bug the --hook form exists to close (same shape as the pre-fix
139
+ // SessionStart bug above). Fixed with the identical in-place-migration
140
+ // pattern the SessionStart loop just above already uses: find an entry
141
+ // whose command is EXACTLY the old bare form and rewrite that SAME hook
142
+ // object's `command` field (same position, same object, every other
143
+ // field on it untouched) rather than remove-then-add.
101
144
  const stop = settings.hooks.Stop ?? [];
102
- const stopAlready = JSON.stringify(stop).includes("codebus tick");
103
- if (!stopAlready) {
145
+ let stopUpgraded = false;
146
+ for (const entry of stop) {
147
+ for (const h of entry?.hooks ?? []) {
148
+ if (h?.command === "codebus tick") {
149
+ h.command = "codebus tick --hook";
150
+ stopUpgraded = true;
151
+ }
152
+ }
153
+ }
154
+ // Luna finding: same class of bug as the SessionStart check above — a
155
+ // serialized substring match ("codebus tick" over the WHOLE Stop array) is
156
+ // fooled by any unrelated hook whose description/command/argument happens
157
+ // to contain those words, silently suppressing the real install while the
158
+ // installer still reports success. Exact command-field match instead,
159
+ // mirroring installCodexHooks's Stop-hook check below.
160
+ const stopAlready = hookEventHasCommand(stop, "codebus tick --hook");
161
+ if (stopUpgraded) {
162
+ settings.hooks.Stop = stop;
163
+ console.log(`${OK} Stop hook upgraded → codebus tick --hook (new-mail surfacing at turn boundaries)`);
164
+ }
165
+ else if (!stopAlready) {
104
166
  stop.push(STOP_HOOK);
105
167
  settings.hooks.Stop = stop;
106
168
  console.log(`${OK} Stop hook → codebus tick --hook (new-mail surfacing at turn boundaries)`);
@@ -109,8 +171,21 @@ async function installClaude(o) {
109
171
  console.log(`${OK} Stop hook already installed`);
110
172
  }
111
173
  mkdirSync(join(homedir(), ".claude"), { recursive: true });
112
- writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
113
- console.log(`${OK} Wrote ${path}${existsSync(`${path}.codebus-backup`) ? ` (backup: ${path}.codebus-backup)` : ""}`);
174
+ const newText = JSON.stringify(settings, null, 2) + "\n";
175
+ const backupPath = `${path}.codebus-backup`;
176
+ // Same lock+atomic-write+TOCTOU-guard machinery installMcp/installCodexMcp/
177
+ // installCodexHooks already use. `existingText` is what THIS call
178
+ // originally read; if another `codebus install claude` (or a hand edit)
179
+ // changed the file in between, this aborts as `reason: "changed"` — fatal
180
+ // here (process.exit(1)), matching this function's own pre-existing
181
+ // "can't safely write settings.json → exit(1)" contract (see the parse-
182
+ // error catch above) rather than silently clobbering the concurrent edit.
183
+ const result = writeIfUnchanged(path, existingText, newText, existingText ? backupPath : undefined);
184
+ if (!result.ok) {
185
+ console.log(`${WARN} ${describeWriteFailure(result, path)} Re-run \`codebus install claude\`.`);
186
+ process.exit(1);
187
+ }
188
+ console.log(`${OK} Wrote ${path}${existingText ? ` (backup: ${backupPath})` : ""}`);
114
189
  // Verify the statusline path works (local read only — never network).
115
190
  const snap = await readCachedDigest();
116
191
  console.log(snap
@@ -147,6 +222,19 @@ const CODEX_SESSION_START_HOOK_GROUP = { hooks: [{ type: "command", command: "co
147
222
  * auto-check installed" when this silently failed to write anything, or a
148
223
  * malformed/unreadable hooks.json leaves the user believing coverage exists
149
224
  * when it doesn't (an undetected mail-delivery gap).
225
+ *
226
+ * Luna finding: this used to read, mutate, and `writeFileSync` the file
227
+ * directly — no lock, no atomic temp-file rename, no TOCTOU re-check —
228
+ * despite installMcp/installCodexMcp/installClaude's settings.json and
229
+ * config.toml writes ALL having already been brought under
230
+ * writeIfUnchanged()/acquireConfigLock()/atomicWriteFileSync() elsewhere in
231
+ * this file. Two concurrent `codebus install codex` runs could both read
232
+ * the same old file and the later plain write would silently discard the
233
+ * other run's changes; a Codex process reading hooks.json mid-write could
234
+ * also observe a partially-written file and fail to load its hooks. Now
235
+ * routed through the SAME existing machinery every other managed config
236
+ * file in this installer already gets — the read/validate/mutate logic
237
+ * below is otherwise unchanged, only the final write mechanism is.
150
238
  */
151
239
  // A plain JSON object: not null, not an array. Used to validate every level
152
240
  // of hooks.json we're about to read/mutate — ai-review finding: checking only
@@ -160,12 +248,14 @@ const CODEX_SESSION_START_HOOK_GROUP = { hooks: [{ type: "command", command: "co
160
248
  function isPlainObject(x) {
161
249
  return typeof x === "object" && x !== null && !Array.isArray(x);
162
250
  }
163
- function installCodexHooks() {
251
+ export function installCodexHooks() {
164
252
  const path = CODEX_HOOKS_FILE();
165
253
  let file = {};
254
+ let existingText = "";
166
255
  try {
167
256
  if (existsSync(path)) {
168
- const parsed = JSON.parse(readFileSync(path, "utf8"));
257
+ existingText = readFileSync(path, "utf8");
258
+ const parsed = JSON.parse(existingText);
169
259
  if (!isPlainObject(parsed)) {
170
260
  throw new Error(`expected a JSON object at the top level, got ${Array.isArray(parsed) ? "an array" : typeof parsed}`);
171
261
  }
@@ -179,7 +269,10 @@ function installCodexHooks() {
179
269
  }
180
270
  }
181
271
  file = parsed;
182
- copyFileSync(path, `${path}.codebus-backup`); // never destroy user hooks
272
+ // Backup is now handled by writeIfUnchanged()'s own atomic backup
273
+ // write below (against whatever the file's content actually is right
274
+ // before the write, not this initial read) — no separate copyFileSync
275
+ // needed here any more.
183
276
  }
184
277
  }
185
278
  catch (e) {
@@ -211,22 +304,156 @@ function installCodexHooks() {
211
304
  console.log(`${OK} Codex SessionStart hook already installed`);
212
305
  }
213
306
  mkdirSync(join(homedir(), ".codex"), { recursive: true });
214
- writeFileSync(path, JSON.stringify(file, null, 2) + "\n");
215
- console.log(`${OK} Wrote ${path}${existsSync(`${path}.codebus-backup`) ? ` (backup: ${path}.codebus-backup)` : ""}`);
307
+ const newText = JSON.stringify(file, null, 2) + "\n";
308
+ const backupPath = `${path}.codebus-backup`;
309
+ // Same lock+atomic-write+TOCTOU-guard machinery installMcp/installCodexMcp/
310
+ // installClaude's settings.json and config.toml writes already use — see
311
+ // writeIfUnchanged. `existingText` is what THIS call originally read; if
312
+ // another `codebus install codex` (or a hand edit) changed the file in
313
+ // between, this aborts as `reason: "changed"` rather than silently
314
+ // clobbering it, instead of the bare writeFileSync this replaces, which
315
+ // had no such check at all.
316
+ const result = writeIfUnchanged(path, existingText, newText, existingText ? backupPath : undefined);
317
+ if (!result.ok) {
318
+ console.log(`${WARN} ${describeWriteFailure(result, path)} Re-run \`codebus install codex\`.`);
319
+ return false;
320
+ }
321
+ console.log(`${OK} Wrote ${path}${existingText ? ` (backup: ${backupPath})` : ""}`);
216
322
  return true;
217
323
  }
218
- export function installCodex() {
324
+ /**
325
+ * Luna finding: the wrapper written below shadows `codex` on PATH (per the
326
+ * `export PATH="$HOME/.codebus/bin:$PATH"` instruction this function
327
+ * prints), and its own body used to `exec codex "$@"` — a BARE name, not an
328
+ * absolute path. Bash's `exec` re-resolves a bare command against the
329
+ * CURRENT PATH on every invocation; since the wrapper never strips its own
330
+ * directory out of that PATH first, `codex` inside the wrapper's own body
331
+ * resolves right back to the SAME wrapper. Confirmed empirically with a
332
+ * standalone reproduction of this exact script and PATH ordering: it hangs
333
+ * indefinitely (an unbounded chain of `exec` self-replacements — not a stack
334
+ * overflow, since `exec` replaces the process image each time, just a
335
+ * process that never reaches the real Codex and never returns). Codex would
336
+ * never actually start. Confirmed via `git log -S` that this shape has
337
+ * existed since the wrapper's introduction (commit 9b51231) — pre-existing,
338
+ * not introduced by this PR's earlier changes; found and fixed now.
339
+ *
340
+ * Fixed by resolving the REAL `codex` binary's absolute path ONCE, here, at
341
+ * wrapper-GENERATION time — searching PATH in the same left-to-right order a
342
+ * shell would, but excluding `excludeDir` (our own ~/.codebus/bin) so a
343
+ * re-run of `codebus install codex`, with the wrapper already active on this
344
+ * process's own PATH, can't find itself and hardcode its own path back into
345
+ * itself. The resolved absolute path is then hardcoded into the wrapper's
346
+ * `exec` line instead of a bare name — simpler and more robust than trying
347
+ * to strip/reconstruct PATH inside the wrapper script itself, and it means
348
+ * the wrapper's behavior no longer depends on whatever PATH happens to be
349
+ * live at the moment it runs.
350
+ *
351
+ * Luna follow-up finding: `join(dir, "codex")` alone does NOT make the
352
+ * result absolute — if a PATH entry is itself relative (e.g. `./bin`, a real
353
+ * if unusual PATH entry), the candidate stays relative too, and `statSync`
354
+ * still finds it because it resolves against THIS process's cwd at
355
+ * generation time. But the STRING gets hardcoded into the wrapper verbatim,
356
+ * so a later invocation from a different cwd would resolve that same
357
+ * relative string against ITS cwd instead and fail to find Codex — silently
358
+ * reintroducing a cwd-dependence the whole point of hardcoding was to
359
+ * remove. Fixed by canonicalizing the match through `realpathSync` before
360
+ * returning it: guaranteed absolute, and it also collapses any `.`/`..`
361
+ * segments or symlinks, which for an already-absolute, non-symlinked PATH
362
+ * entry is a no-op (returns the same path) but for anything else produces
363
+ * the genuinely stable target the wrapper needs. Wrapped in its own
364
+ * try/catch — realpathSync failing here (e.g. a symlink loop) just means
365
+ * this candidate isn't usable; the loop's existing "keep looking" contract
366
+ * already handles that correctly by falling through to the next PATH entry.
367
+ *
368
+ * Luna follow-up finding: `st.mode & 0o111` only asks "does SOME execute
369
+ * bit exist anywhere in this file's mode (owner, group, OR other)" — it
370
+ * says nothing about whether THIS process, running as this uid/gid, is
371
+ * actually one of the parties that bit applies to. A file mode 0700 owned
372
+ * by root has an execute bit set, so the old check accepted it — but an
373
+ * ordinary user's process can't execute it (only root can), and would hit
374
+ * a real EACCES the moment the generated wrapper actually tried to run it,
375
+ * long after `codebus install codex` had already reported success. Fixed by
376
+ * also requiring `accessSync(candidate, X_OK)` to succeed — the correct,
377
+ * kernel-backed way to ask "can the CALLING process execute this," which
378
+ * properly accounts for uid/gid/mode-bit interaction rather than eyeballing
379
+ * the mode bits ourselves. Wrapped in the same try/catch as the existing
380
+ * per-candidate checks: accessSync throwing (EACCES, or the file
381
+ * disappearing between statSync and here) just means this candidate isn't
382
+ * usable — keep looking, same as any other per-candidate failure.
383
+ */
384
+ function resolveRealCodexBinary(excludeDir) {
385
+ const pathEnv = process.env.PATH ?? "";
386
+ let excludeReal = null;
387
+ try {
388
+ excludeReal = realpathSync(excludeDir);
389
+ }
390
+ catch { /* doesn't exist yet — nothing to exclude */ }
391
+ for (const dir of pathEnv.split(delimiter)) {
392
+ if (!dir)
393
+ continue;
394
+ if (dir === excludeDir)
395
+ continue;
396
+ let dirReal = null;
397
+ try {
398
+ dirReal = realpathSync(dir);
399
+ }
400
+ catch { /* dir may not exist on disk — fall through to the raw-string check above */ }
401
+ if (dirReal !== null && excludeReal !== null && dirReal === excludeReal)
402
+ continue;
403
+ const candidate = join(dir, "codex");
404
+ try {
405
+ const st = statSync(candidate);
406
+ if (st.isFile() && (st.mode & 0o111) !== 0) {
407
+ accessSync(candidate, fsConstants.X_OK); // throws if THIS process can't actually execute it (wrong uid/gid, etc.) — caught below, keep looking
408
+ return realpathSync(candidate); // canonical absolute path — first match wins, same as shell PATH resolution
409
+ }
410
+ }
411
+ catch { /* not here, not executable by us, or not resolvable — keep looking */ }
412
+ }
413
+ return null;
414
+ }
415
+ /** Safely single-quotes a string for embedding in a POSIX sh/bash command line. */
416
+ function shQuoteSingle(s) {
417
+ return `'${s.replace(/'/g, `'\\''`)}'`;
418
+ }
419
+ export async function installCodex(o = {}) {
219
420
  const binDir = join(homedir(), ".codebus", "bin");
220
421
  const wrapper = join(binDir, "codex");
221
422
  mkdirSync(binDir, { recursive: true });
423
+ const realCodexPath = resolveRealCodexBinary(binDir);
424
+ if (!realCodexPath) {
425
+ console.log(`${WARN} Could not find a real \`codex\` binary anywhere on PATH (excluding ${binDir}) — refusing to write a wrapper that could never launch Codex.`);
426
+ console.log(` Install Codex first (so a real \`codex\` executable is reachable on PATH), then re-run \`codebus install codex\`.`);
427
+ process.exit(1);
428
+ }
222
429
  // Safer v1 (deliberate): session start only. No auto session-end posts until
223
430
  // users trust the ritual — an unwanted handoff message erodes trust fast.
431
+ //
432
+ // Luna/PR#110 finding: this wrapper shadows `codex` on PATH, so it fires on
433
+ // EVERY invocation of the `codex` binary on the machine — a human's own
434
+ // deliberate terminal session and any unattended/automated launch alike;
435
+ // the wrapper has no way to tell those apart, and `runStart` never blocks
436
+ // on input either way, so there is no legitimate case here that needs the
437
+ // plain (non-hook) path's fuller identity resolution. Plain `codebus
438
+ // session start` calls resolveConfig() directly, which — unlike --hook —
439
+ // lets identitySource "file" (the bare author-credential fallback) through;
440
+ // that's precisely how a machine-wide wrapper like this one ends up
441
+ // orienting an unrelated Codex launch as the author's own personal agent
442
+ // identity. `--hook` applies the same identity-explicit-or-silent contract
443
+ // this repo already uses for the JSON SessionStart hooks above (see
444
+ // CODEX_SESSION_START_HOOK_GROUP / hookEventHasCommand): only ever prints
445
+ // when identity resolves via this session's own checkin or a deliberate
446
+ // cwd binding, silent otherwise, and swallows every failure internally
447
+ // (see startCmd's hook branch in session.ts) rather than throwing. The
448
+ // trailing `|| true` is kept anyway as a second, redundant layer of the
449
+ // same "a bus outage must never block coding" guarantee — belt and
450
+ // suspenders, not load-bearing on its own now.
224
451
  writeFileSync(wrapper, [
225
452
  "#!/usr/bin/env bash",
226
453
  "# CodeBus wrapper: pull the bus before Codex starts. Best-effort — a bus",
227
454
  "# outage must never block coding.",
228
- "codebus session start || true",
229
- 'exec codex "$@"',
455
+ "codebus session start --hook || true",
456
+ `exec ${shQuoteSingle(realCodexPath)} "$@"`,
230
457
  "",
231
458
  ].join("\n"));
232
459
  chmodSync(wrapper, 0o755);
@@ -235,8 +462,11 @@ export function installCodex() {
235
462
  console.log(` export PATH="$HOME/.codebus/bin:$PATH"`);
236
463
  console.log(`(session end stays manual for now: codebus session end --summary "...")`);
237
464
  // Register the MCP server so a Codex session can PULL (codebus_inbox/
238
- // codebus_next) natively via tool calls.
239
- installCodexMcp();
465
+ // codebus_next) natively via tool calls. installCodexMcp returns true when
466
+ // a write was NEEDED but skipped specifically because consent couldn't be
467
+ // obtained non-interactively — that outcome must not let this command
468
+ // exit 0 (see the check after installCodexHooks below).
469
+ const mcpConsentSkippedNonInteractively = await installCodexMcp(o);
240
470
  // Durable turn-boundary auto-check (the actual RC3 fix): wire Codex's own
241
471
  // Stop hook, same mechanism/command as Claude Code's Stop hook above. Only
242
472
  // claim coverage when the write actually succeeded (ai-review finding: a
@@ -251,6 +481,18 @@ export function installCodex() {
251
481
  console.log(`${WARN} Durable auto-check NOT installed — fix ~/.codex/hooks.json (see above) and re-run \`codebus install codex\`.`);
252
482
  console.log(` Until then, \`codebus checkin\` falls back to arming one watch window at a time.`);
253
483
  }
484
+ // sol round-5 finding #2: the wrapper + hooks steps above are intentionally
485
+ // independent and still run regardless of the MCP step's outcome — but a
486
+ // non-interactive run that never got to ask for consent must not exit 0,
487
+ // or a script/CI run has no way to tell that from a real success. Mirrors
488
+ // installMcp's own non-interactive-skip handling (process.exit(1)) for
489
+ // consistency between the two installers, run only after every
490
+ // independent step above has already had its chance to complete.
491
+ if (mcpConsentSkippedNonInteractively) {
492
+ console.log(`${WARN} codebus-mcp registration in ~/.codex/config.toml needed consent but couldn't get it (no TTY, no --yes) — treat this run as incomplete.`);
493
+ console.log(` Pass --yes, or re-run \`codebus install codex\` interactively.`);
494
+ process.exit(1);
495
+ }
254
496
  }
255
497
  /**
256
498
  * installMcp — register the codebus-mcp server in Claude Code settings.
@@ -259,57 +501,156 @@ export function installCodex() {
259
501
  */
260
502
  export async function installMcp(o = {}) {
261
503
  const path = CLAUDE_SETTINGS();
504
+ let existingText = "";
262
505
  let settings = {};
263
506
  try {
264
507
  if (existsSync(path)) {
265
- settings = JSON.parse(readFileSync(path, "utf8"));
508
+ // Any failure here (a permissions error, a corrupt/unreadable file, a
509
+ // JSON syntax error) must abort loudly, never be silently treated as
510
+ // "file doesn't exist" — existsSync() already told us it does (sol
511
+ // round-2 finding #4: only ENOENT may ever mean "safe to treat as
512
+ // absent"; existsSync()+readFileSync() throwing here is by
513
+ // construction NOT that case).
514
+ existingText = readFileSync(path, "utf8");
515
+ const parsed = JSON.parse(existingText);
516
+ // Luna/PR#110 finding: a syntactically valid settings.json can still
517
+ // have the WRONG shape — e.g. "mcpServers": [] (an array, not an
518
+ // object). Without this check, `mcpServers.codebus` reads as
519
+ // undefined, so the drift branch never fires, and the fresh-
520
+ // registration branch's `settings.mcpServers.codebus = mcpConfig`
521
+ // sets a non-index property on an ARRAY — syntactically legal in JS,
522
+ // but JSON.stringify silently DROPS non-index properties on arrays.
523
+ // The write would then "succeed" (valid JSON goes out, the command
524
+ // reports success) having registered nothing at all. Same validation
525
+ // style installCodexHooks already uses for its own file: fail
526
+ // closed with a clear, specific message rather than silently
527
+ // mis-writing or claiming success.
528
+ if (!isPlainObject(parsed)) {
529
+ throw new Error(`expected a JSON object at the top level, got ${Array.isArray(parsed) ? "an array" : typeof parsed}`);
530
+ }
531
+ if (parsed.mcpServers !== undefined && !isPlainObject(parsed.mcpServers)) {
532
+ throw new Error(`expected "mcpServers" to be a JSON object, got ${Array.isArray(parsed.mcpServers) ? "an array" : typeof parsed.mcpServers}`);
533
+ }
534
+ settings = parsed;
266
535
  }
267
536
  }
268
537
  catch (e) {
269
- console.log(`${WARN} Could not parse ${path}: ${e.message}`);
538
+ console.log(`${WARN} Could not read/parse ${path}: ${e.message}`);
270
539
  console.log("Manual fallback — add to ~/.claude/settings.json:");
271
540
  printMcpServerConfig();
272
541
  process.exit(1);
273
542
  }
274
543
  // Prepare the MCP server config.
275
544
  const mcpConfig = buildMcpServerConfig();
276
- // Check if already installed.
545
+ const currentIsCheckout = isDevLinkedInstall();
546
+ printDevCheckoutWarning(currentIsCheckout);
547
+ assertMcpServerPathExists(mcpConfig);
548
+ // Check if already installed — and if so, whether the registered pointer
549
+ // has drifted from the canonical one this running install would write
550
+ // today. See decideMcpPointerAction() for the bug this guards: a one-shot
551
+ // "already registered, no-op forever" check let a stale/mutable pointer
552
+ // (e.g. written from a dev checkout) survive every future `install mcp`
553
+ // re-run with no way to self-heal. DESIGN (sol round 2): every drift heal
554
+ // — regardless of direction — requires the SAME explicit consent as a
555
+ // fresh registration. Earlier revisions tried to auto-heal the "obviously
556
+ // safe" direction unattended, gated on an ownership check; sol correctly
557
+ // pointed out that check is forgeable (a copied manifest or symlinked
558
+ // target can satisfy it) and isn't a sound basis for an unattended write.
559
+ // Ownership is now purely ADVISORY — it colors the confirmation prompt's
560
+ // wording, never whether a prompt happens at all.
277
561
  const mcpServers = settings.mcpServers ?? {};
278
562
  if (mcpServers.codebus) {
279
- console.log(`${OK} codebus-mcp already registered in mcpServers`);
563
+ const decision = decideMcpPointerAction(mcpServers.codebus, mcpConfig, currentIsCheckout);
564
+ if (decision.kind === "noop") {
565
+ console.log(`${OK} codebus-mcp already registered in mcpServers`);
566
+ return;
567
+ }
568
+ if (decision.kind === "unrecognized-shape") {
569
+ console.log(`${WARN} codebus-mcp is already registered in mcpServers but not in the shape \`codebus install mcp\` writes (looks hand-edited or customized) — leaving it untouched.`);
570
+ console.log(` If this should be reset, remove the "codebus" entry from ${path} and re-run \`codebus install mcp\`.`);
571
+ return;
572
+ }
573
+ // decision.kind === "consent-required" — ALWAYS, for every drift.
574
+ printDriftConsentExplanation(path, decision.oldPointer, mcpConfig.args[0], decision.existingOwnership, decision.currentIsCheckout);
575
+ const driftConsent = await requestConsent(o, "Update the registered codebus-mcp pointer? (y/n): ");
576
+ if (driftConsent === "non-interactive") {
577
+ console.log(`${WARN} non-interactive: re-run with --yes to accept this pointer update`);
578
+ process.exit(1);
579
+ }
580
+ if (driftConsent === "declined") {
581
+ console.log(`${WARN} Pointer left unchanged.`);
582
+ return;
583
+ }
584
+ // Surgical patch: rewrite ONLY mcpServers.codebus's value, byte-for-byte
585
+ // preserving everything else in the file (indentation, unrelated keys,
586
+ // formatting) — a full JSON.parse+stringify round trip would silently
587
+ // renormalize all of that for an edit the user approved by DIFF, not by
588
+ // re-reading the whole file.
589
+ const patched = replaceJsonValue(existingText, ["mcpServers", "codebus"], JSON.stringify(mcpConfig));
590
+ if (patched === null) {
591
+ // Either the value genuinely isn't there any more (shouldn't happen —
592
+ // we just parsed this exact document above), OR findJsonValueSpan
593
+ // detected a duplicate key along the path and refused to guess which
594
+ // occurrence is real (sol round-2 finding #2). Either way: never fall
595
+ // back to a full reserialize here; fail loudly instead of silently
596
+ // reformatting or patching the wrong occurrence.
597
+ console.log(`${WARN} Could not surgically patch ${path} (possibly a duplicate "mcpServers" or "codebus" key) — leaving it untouched. Please resolve any duplicate keys and update mcpServers.codebus by hand.`);
598
+ return;
599
+ }
600
+ mkdirSync(join(homedir(), ".claude"), { recursive: true });
601
+ const backupPath = `${path}.codebus-backup`;
602
+ const result = writeIfUnchanged(path, existingText, patched, backupPath);
603
+ if (!result.ok) {
604
+ console.log(`${WARN} ${describeWriteFailure(result, path)} Re-run \`codebus install mcp\`.`);
605
+ process.exit(1);
606
+ }
607
+ console.log(`${OK} codebus-mcp pointer updated (was pointing at ${decision.oldPointer}, drifted from the current install)`);
608
+ console.log(`${OK} Backup saved to ${backupPath}`);
280
609
  return;
281
610
  }
282
- // A12 / artifacts-hook policy: non-interactive without explicit opt-in abort.
283
- // Non-TTY (piped, CI, another tool) + no --yes user cannot consent.
284
- if (!process.stdin.isTTY && !o.yes) {
285
- console.log(`${WARN} non-interactive: re-run with --yes to opt in`);
286
- process.exit(1);
287
- }
288
- // Print what will be written (A12 explicit consent gate).
611
+ // Print what will be written (A12 explicit consent gate). Full disclosure
612
+ // (sol round-2 finding #7): this write reserializes the ENTIRE file, not
613
+ // just inserts the fragment shown below — say so explicitly so consent to
614
+ // the diff isn't mistaken for consent to an undisclosed whole-file
615
+ // transformation (formatting/duplicate-key normalization included).
289
616
  console.log("\nWill register codebus-mcp in ~/.claude/settings.json:\n");
290
617
  console.log(JSON.stringify({ mcpServers: { codebus: mcpConfig } }, null, 2));
291
618
  console.log();
292
- // Confirm before writing (interactive mode only — non-TTY would have aborted above).
293
- if (!o.yes) {
294
- const answer = await getUserConfirmation("Proceed with registration? (y/n): ");
295
- if (!answer.toLowerCase().startsWith("y")) {
296
- console.log(`${WARN} Installation cancelled.`);
297
- process.exit(0);
298
- }
619
+ if (existingText) {
620
+ console.log(`${WARN} This reserializes the ENTIRE file — formatting, key order, and any duplicate keys will be`);
621
+ console.log(` normalized to a fresh JSON.stringify, not just have this fragment inserted in place.`);
622
+ }
623
+ // A12 / artifacts-hook policy: explicit consent required (non-TTY + no
624
+ // --yes → user cannot consent → abort).
625
+ const freshConsent = await requestConsent(o, "Proceed with registration? (y/n): ");
626
+ if (freshConsent === "non-interactive") {
627
+ console.log(`${WARN} non-interactive: re-run with --yes to opt in`);
628
+ process.exit(1);
629
+ }
630
+ if (freshConsent === "declined") {
631
+ console.log(`${WARN} Installation cancelled.`);
632
+ process.exit(0);
299
633
  }
300
- // Write the config.
634
+ // Write the config. This is a first-time, explicitly-confirmed write (the
635
+ // user just saw and approved the exact JSON above, plus the disclosure
636
+ // that it reserializes the whole file), so a full reserialize is
637
+ // acceptable here — unlike the drift repair above, which patches only the
638
+ // one value it's allowed to touch.
301
639
  mkdirSync(join(homedir(), ".claude"), { recursive: true });
302
640
  if (!settings.mcpServers) {
303
641
  settings.mcpServers = {};
304
642
  }
305
643
  settings.mcpServers.codebus = mcpConfig;
306
- // Backup existing config.
307
- if (existsSync(path)) {
308
- copyFileSync(path, `${path}.codebus-backup`);
644
+ const newText = JSON.stringify(settings, null, 2) + "\n";
645
+ const backupPath = `${path}.codebus-backup`;
646
+ const freshResult = writeIfUnchanged(path, existingText, newText, existingText ? backupPath : undefined);
647
+ if (!freshResult.ok) {
648
+ console.log(`${WARN} ${describeWriteFailure(freshResult, path)} Re-run \`codebus install mcp --yes\`.`);
649
+ process.exit(1);
309
650
  }
310
- writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
311
651
  console.log(`${OK} Registered codebus-mcp in ${path}`);
312
- console.log(`${OK} Backup saved to ${path}.codebus-backup`);
652
+ if (existingText)
653
+ console.log(`${OK} Backup saved to ${backupPath}`);
313
654
  console.log("\nCodeBus MCP tools are now available in Claude Code:");
314
655
  console.log(" • ask_graph — Query CodeBus graph snapshot");
315
656
  console.log(" • codebus_state — Session digest (counts, next item)");
@@ -329,8 +670,20 @@ export async function installMcp(o = {}) {
329
670
  console.log(" • graph_search — Search a connected repo's remote graph");
330
671
  console.log(" • blast_radius — What breaks if I change this (local or remote)");
331
672
  console.log(" • graph_gaps — Knowledge gaps in a connected repo's graph");
673
+ console.log(" • ticket_suspect_code — Rank code evidence related to a ticket");
332
674
  console.log(" • plan_status — Per-plan task/ticket/PR/coverage/health rollup");
333
675
  console.log(" • delivery_status — Paginated per-plan delivery + quality rollup");
676
+ console.log(" • doc_add — Store a shared text document");
677
+ console.log(" • doc_get — Fetch a shared text document");
678
+ console.log(" • plan_list — List visible plans");
679
+ console.log(" • plan_show — Show one visible plan");
680
+ console.log(" • plan_who — List current plan assignments");
681
+ console.log(" • plan_stuck — List currently stuck plans");
682
+ console.log(" • knowledge_list — List knowledge items");
683
+ console.log(" • knowledge_show — Show a knowledge item");
684
+ console.log(" • digest — Fetch the current attention digest");
685
+ console.log(" • factory_run_status — Get factory run status");
686
+ console.log(" • factory_run_events — Get factory run events");
334
687
  }
335
688
  function buildMcpServerConfig() {
336
689
  // Absolute paths, deliberately: a bare "codebus-mcp" resolves only through
@@ -346,15 +699,77 @@ function buildMcpServerConfig() {
346
699
  args: [join(dirname(fileURLToPath(import.meta.url)), "..", "mcp.js")],
347
700
  };
348
701
  }
702
+ /**
703
+ * Luna/PR#110 finding: buildMcpServerConfig() computes args[0] relative to
704
+ * THIS module's own location (import.meta.url). When running from a BUILT
705
+ * checkout that's .../dist/commands/install.js, so args[0] lands on
706
+ * .../dist/mcp.js — which the build step actually produces. But when the
707
+ * CLI runs directly from source with no build step (e.g. `npm run dev`,
708
+ * tsx/ts-node), import.meta.url is under .../src/commands/install.ts
709
+ * instead, so the SAME computation lands on .../src/mcp.js — which does not
710
+ * exist; the source file is src/mcp.ts, never compiled to .js in place.
711
+ * isDevLinkedInstall()'s warning above is a DIFFERENT, survivable risk (a
712
+ * MUTABLE pointer that can later break); this is a pointer that cannot ever
713
+ * launch, from the moment it's written. Deliberately a general existsSync
714
+ * check rather than special-casing the .ts/.js mismatch specifically — it
715
+ * equally catches any OTHER future reason the computed path might not
716
+ * exist (a broken build, a relocated install, …). Called before ANY write
717
+ * (fresh registration or drift-heal) in both installMcp and installCodexMcp;
718
+ * never called from printMcpServerConfig()'s manual-fallback path, which
719
+ * must keep printing best-effort instructions to a human even when this
720
+ * check would fail (that path is presented, not executed).
721
+ */
722
+ function assertMcpServerPathExists(mcpConfig) {
723
+ const mcpJsPath = mcpConfig.args[0];
724
+ // Luna finding: existsSync() alone returns true for a DIRECTORY too — a
725
+ // directory happening to sit at the computed path (an odd but real
726
+ // possibility: a botched build, a stray mkdir) would pass this check and
727
+ // let a pointer that can never launch (Node can't `node <a directory>`)
728
+ // get written anyway, contradicting this function's whole purpose.
729
+ // statSync(...).isFile() is the real check; wrapped in try/catch since
730
+ // statSync throws on a path that doesn't exist at all, which must be
731
+ // treated identically to "not a file" (the existing not-found case below).
732
+ let isFile = false;
733
+ try {
734
+ isFile = statSync(mcpJsPath).isFile();
735
+ }
736
+ catch { /* doesn't exist — not a file either */ }
737
+ if (isFile)
738
+ return;
739
+ console.log(`${WARN} Computed codebus-mcp server path does not exist: ${mcpJsPath}`);
740
+ console.log(` Registering this pointer would leave Claude/Codex unable to start the MCP server on next launch — refusing to write it.`);
741
+ console.log(` This usually means the CLI is running from source with no build step (e.g. \`npm run dev\`) — run \`npm run build\` first, or install the published package (\`codebus upgrade\`).`);
742
+ process.exit(1);
743
+ }
349
744
  /**
350
745
  * Register the codebus MCP server in Codex's ~/.codex/config.toml
351
746
  * ([mcp_servers.codebus]). Append-only with a backup — Codex configs carry
352
747
  * hand-written sections (the user's real one has several mcp_servers already),
353
- * so we never parse/rewrite, only add our section when it's absent.
748
+ * so we never parse/rewrite the rest of the file, only add our section when
749
+ * it's GENUINELY absent (per findTopLevelTableBlock's lexically-aware
750
+ * search — never a raw substring match, AND never when the cheaper
751
+ * mightReferenceOurTable() fallback sniff finds a plausible-but-unsupported
752
+ * trace of the table, e.g. array-of-tables syntax) — or, when present but
753
+ * its pointer has drifted from the canonical one this install computes
754
+ * today, rewrite just the command/args VALUES of OUR OWN section in place,
755
+ * ALWAYS with explicit consent first (see decideMcpPointerAction()).
756
+ *
757
+ * Returns true when a write was NEEDED but skipped specifically because
758
+ * consent couldn't be obtained non-interactively (no TTY, no --yes) — sol
759
+ * round-5 finding #2: installCodex previously completed (and the overall
760
+ * process exited 0) in this case exactly as if registration had succeeded,
761
+ * which a script or CI run has no way to tell apart from success. Every
762
+ * OTHER skip reason (unrecognized shape, unreadable file, an interactive
763
+ * decline, an already-matching pointer, a write that failed for its own
764
+ * reason) returns false — those are deliberate, already-explained outcomes,
765
+ * not "we never even got to ask."
354
766
  */
355
- function installCodexMcp() {
767
+ async function installCodexMcp(o = {}) {
356
768
  const path = join(homedir(), ".codex", "config.toml");
357
769
  const cfg = buildMcpServerConfig();
770
+ const currentIsCheckout = isDevLinkedInstall();
771
+ printDevCheckoutWarning(currentIsCheckout);
772
+ assertMcpServerPathExists(cfg);
358
773
  const section = [
359
774
  "",
360
775
  "# CodeBus MCP server (written by `codebus install codex`)",
@@ -364,20 +779,673 @@ function installCodexMcp() {
364
779
  "",
365
780
  ].join("\n");
366
781
  let existing = "";
782
+ let fileExisted = false;
367
783
  try {
368
784
  existing = readFileSync(path, "utf8");
785
+ fileExisted = true;
369
786
  }
370
- catch { /* no config yet — we'll create it */ }
371
- if (existing.includes("[mcp_servers.codebus]")) {
372
- console.log(`${OK} codebus MCP already registered in ${path}`);
373
- return;
787
+ catch (e) {
788
+ const err = e;
789
+ if (err.code !== "ENOENT") {
790
+ // Only ENOENT may mean "no config yet, safe to create fresh" (sol
791
+ // round-2 finding #4). Anything else — EACCES, a corrupt/unreadable
792
+ // file, a symlink loop — must abort loudly: silently treating an
793
+ // existing-but-unreadable file as empty risks appending our section
794
+ // into what we WRONGLY believe is a brand-new file, potentially
795
+ // discarding everything already there.
796
+ console.log(`${WARN} Could not read ${path}: ${err.message} — leaving Codex MCP registration untouched.`);
797
+ console.log(` Manual fallback — add to ${path}:`);
798
+ console.log(` [mcp_servers.codebus]`);
799
+ console.log(` command = ${JSON.stringify(cfg.command)}`);
800
+ console.log(` args = [${cfg.args.map((a) => JSON.stringify(a)).join(", ")}]`);
801
+ return false; // soft — installCodex's remaining steps (hooks etc.) still run; not a consent-skip
802
+ }
803
+ // ENOENT: genuinely no config yet — the ONE case that skips consent below.
804
+ }
805
+ const block = findTopLevelTableBlock(existing, ["mcp_servers", "codebus"]);
806
+ if (block !== null) {
807
+ const blockText = existing.slice(block.blockStart, block.blockEnd);
808
+ const parsed = parseSimpleTomlTable(blockText); // null when the body isn't a simple key="string"/["string"] shape we can safely reason about
809
+ const decision = decideMcpPointerAction(parsed, cfg, currentIsCheckout);
810
+ if (decision.kind === "noop") {
811
+ console.log(`${OK} codebus MCP already registered in ${path}`);
812
+ return false;
813
+ }
814
+ if (decision.kind === "unrecognized-shape") {
815
+ console.log(`${WARN} [mcp_servers.codebus] already exists in ${path} but not in the shape \`codebus install codex\` writes (looks hand-edited, customized, or uses TOML syntax we don't safely rewrite) — leaving it untouched.`);
816
+ console.log(` If this should be reset, remove that section from ${path} and re-run \`codebus install codex\`.`);
817
+ return false;
818
+ }
819
+ // decision.kind === "consent-required" — ALWAYS, for every drift.
820
+ printDriftConsentExplanation(path, decision.oldPointer, cfg.args[0], decision.existingOwnership, decision.currentIsCheckout);
821
+ const driftConsent = await requestConsent(o, "Update the registered codebus MCP pointer? (y/n): ");
822
+ if (driftConsent === "non-interactive") {
823
+ console.log(`${WARN} non-interactive: re-run \`codebus install codex --yes\` to accept this pointer update`);
824
+ return true; // soft on installCodex's OTHER steps (hooks etc. still run) — but the OVERALL command must exit nonzero (sol round-5 finding #2)
825
+ }
826
+ if (driftConsent === "declined") {
827
+ console.log(`${WARN} Pointer left unchanged.`);
828
+ return false; // the human was asked, and answered — not a "never got to ask" outcome
829
+ }
830
+ const updatedBlock = replaceSimpleTomlValues(blockText, { command: cfg.command, args: cfg.args });
831
+ if (updatedBlock === null) {
832
+ // Shouldn't happen — parseSimpleTomlTable already proved this block
833
+ // tokenizes cleanly — but never fall back to a cruder rewrite; fail
834
+ // loudly instead of guessing at the file's structure.
835
+ console.log(`${WARN} Could not surgically patch ${path} — leaving it untouched. Please update [mcp_servers.codebus] by hand.`);
836
+ return false;
837
+ }
838
+ const updatedText = existing.slice(0, block.blockStart) + updatedBlock + existing.slice(block.blockEnd);
839
+ mkdirSync(join(homedir(), ".codex"), { recursive: true });
840
+ const backupPath = `${path}.codebus-backup`;
841
+ const result = writeIfUnchanged(path, existing, updatedText, backupPath);
842
+ if (!result.ok) {
843
+ console.log(`${WARN} ${describeWriteFailure(result, path)} Re-run \`codebus install codex\`.`);
844
+ return false;
845
+ }
846
+ console.log(`${OK} codebus-mcp pointer updated in ${path} (was pointing at ${decision.oldPointer}, drifted from the current install)`);
847
+ console.log(`${OK} Backup saved to ${backupPath}`);
848
+ return false;
849
+ }
850
+ // Our strict scanner found no genuine table — but "null" is also what a
851
+ // structurally-unsupported-but-real header produces (array-of-tables, a
852
+ // BOM/escape edge case, anything neither scanner anticipated). Appending
853
+ // our own section in that case risks invalid/duplicate/conflicting TOML
854
+ // (sol round-2 finding #3). Only conclude "genuinely absent" when there's
855
+ // no trace of the marker at all.
856
+ if (mightReferenceOurTable(existing)) {
857
+ console.log(`${WARN} ${path} seems to already reference an mcp_servers.codebus table in a form \`codebus install codex\` doesn't recognize (e.g. array-of-tables syntax) — leaving it untouched rather than risking a duplicate or conflicting table.`);
858
+ console.log(` Please check ${path} manually.`);
859
+ return false;
860
+ }
861
+ // The table is genuinely absent — but that's not on its own license to
862
+ // write unattended (sol round-4 finding #1): a config.toml that already
863
+ // EXISTS (any content, ours or not) gets the same consent gate as every
864
+ // other write in this file. Only a config.toml that doesn't exist AT ALL
865
+ // yet — the very first time this file is created — is exempt, matching
866
+ // Codex's long-standing "we create this file once, no confirmation
867
+ // needed" convention (unchanged since before this security review).
868
+ if (fileExisted) {
869
+ console.log(`\nWill register [mcp_servers.codebus] in ${path} (which already exists):\n`);
870
+ console.log(section.trim());
871
+ console.log();
872
+ const freshConsent = await requestConsent(o, "Add this section to the existing file? (y/n): ");
873
+ if (freshConsent === "non-interactive") {
874
+ console.log(`${WARN} non-interactive: re-run \`codebus install codex --yes\` to register codebus-mcp in ${path}`);
875
+ return true; // soft on installCodex's OTHER steps (hooks etc. still run) — but the OVERALL command must exit nonzero (sol round-5 finding #2)
876
+ }
877
+ if (freshConsent === "declined") {
878
+ console.log(`${WARN} Registration skipped; ${path} left unchanged.`);
879
+ return false; // the human was asked, and answered — not a "never got to ask" outcome
880
+ }
374
881
  }
375
- if (existing)
376
- copyFileSync(path, `${path}.codebus-backup`);
377
882
  mkdirSync(join(homedir(), ".codex"), { recursive: true });
378
- writeFileSync(path, existing + section);
379
- console.log(`${OK} Registered [mcp_servers.codebus] in ${path}${existing ? ` (backup: ${path}.codebus-backup)` : ""}`);
883
+ const backupPath = `${path}.codebus-backup`;
884
+ const freshResult = writeIfUnchanged(path, existing, existing + section, fileExisted ? backupPath : undefined);
885
+ if (!freshResult.ok) {
886
+ console.log(`${WARN} ${describeWriteFailure(freshResult, path)} Re-run \`codebus install codex\`.`);
887
+ return false;
888
+ }
889
+ console.log(`${OK} Registered [mcp_servers.codebus] in ${path}${fileExisted ? ` (backup: ${backupPath})` : ""}`);
380
890
  console.log(` Codex sessions get the ${TOOL_REGISTRY.length} codebus tools on next launch.`);
891
+ return false;
892
+ }
893
+ /**
894
+ * Resolves an MCP pointer's args[0] (an mcp.js path) to the package root that
895
+ * looks like it OWNS it, by resolving symlinks (tightening sol round-2
896
+ * finding #1's suggestion for prompt accuracy) and reading THAT root's own
897
+ * package.json for a matching name — not just trusting that the path shape
898
+ * "looks like ours" (a hand-written wrapper can trivially end its one arg
899
+ * in "mcp.js" too).
900
+ *
901
+ * IMPORTANT — this is now ADVISORY ONLY (sol round-2 finding #1): earlier
902
+ * revisions treated a successful resolution here as a security boundary
903
+ * that gated an UNATTENDED write. That was unsound — a copied manifest, a
904
+ * symlinked target, or an unrelated checkout of this same repo can all
905
+ * satisfy "package.json says @bragi-gmbh/codebus" without this installer
906
+ * having ever written the entry being examined. Since every drift heal now
907
+ * requires a human's explicit consent (decideMcpPointerAction never
908
+ * auto-writes), this function's result only colors the CONFIRMATION
909
+ * PROMPT'S wording ("this looks like our own install" vs. "unrecognized —
910
+ * proceed carefully") — a human makes the actual call either way. Returns
911
+ * null when the path doesn't resolve to what looks like a real
912
+ * `@bragi-gmbh/codebus` install: no package.json there, a parse failure, a
913
+ * different package's name, or (a moved/rebuilt/deleted checkout) the path
914
+ * simply no longer exists.
915
+ */
916
+ function resolveOwnedPackageRoot(mcpJsPath) {
917
+ try {
918
+ // Resolve symlinks where possible — a symlinked mcp.js pointing at a
919
+ // real codebus install should still be recognized as such. ENOENT (the
920
+ // common case: a moved/rebuilt/deleted checkout) falls back to the raw
921
+ // path, since the package root's OWN package.json may still exist even
922
+ // if the mcp.js file itself is currently missing.
923
+ let resolved = mcpJsPath;
924
+ try {
925
+ resolved = realpathSync(mcpJsPath);
926
+ }
927
+ catch { /* file may not currently exist — advisory only, not fatal */ }
928
+ // Our own writer always builds args[0] as <packageRoot>/(dist|src)/mcp.js
929
+ // (see buildMcpServerConfig) — so the package root is two levels up.
930
+ const pkgRoot = dirname(dirname(resolved));
931
+ const parsed = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8"));
932
+ if (isPlainObject(parsed) && parsed.name === "@bragi-gmbh/codebus")
933
+ return pkgRoot;
934
+ return null;
935
+ }
936
+ catch {
937
+ return null;
938
+ }
939
+ }
940
+ function classifyOwnership(mcpJsPath) {
941
+ const pkgRoot = resolveOwnedPackageRoot(mcpJsPath);
942
+ if (pkgRoot === null)
943
+ return "unknown";
944
+ return isCheckoutShapedPackageRoot(pkgRoot) ? "owned-checkout" : "owned-published";
945
+ }
946
+ /**
947
+ * Compares an EXISTING registered MCP server pointer (parsed out of
948
+ * settings.json's `mcpServers.codebus`, or config.toml's
949
+ * `[mcp_servers.codebus]` via toml-lite) against the CANONICAL pointer
950
+ * buildMcpServerConfig() computes right now for the currently-running
951
+ * install. See decideMcpPointerAction() for how callers turn this analysis
952
+ * into an action — this function only classifies, it never decides whether
953
+ * writing is safe.
954
+ */
955
+ export function analyzeMcpPointer(existing, canonical) {
956
+ if (!isPlainObject(existing) || Object.keys(existing).length !== 2) {
957
+ return { recognized: false, matches: false, ownership: "unknown" };
958
+ }
959
+ const { command, args } = existing;
960
+ if (typeof command !== "string" ||
961
+ !Array.isArray(args) ||
962
+ args.length !== 1 ||
963
+ typeof args[0] !== "string" ||
964
+ !args[0].endsWith("mcp.js")) {
965
+ return { recognized: false, matches: false, ownership: "unknown" };
966
+ }
967
+ const args0 = args[0];
968
+ const matches = command === canonical.command && args0 === canonical.args[0];
969
+ return { recognized: true, matches, ownership: classifyOwnership(args0) };
970
+ }
971
+ /**
972
+ * Turns an McpPointerAnalysis into an action.
973
+ *
974
+ * DESIGN (sol round-2): every drift — regardless of direction, regardless
975
+ * of whether ownership resolves — requires explicit consent, the SAME
976
+ * --yes/interactive-y-n gate installMcp's fresh registration already uses.
977
+ * An earlier revision tried to auto-heal one "unambiguous" direction
978
+ * unattended, gated on resolveOwnedPackageRoot(); sol correctly found that
979
+ * gate forgeable (see resolveOwnedPackageRoot's docstring) — a security
980
+ * boundary needs to be sound, not just "usually right." Requiring consent
981
+ * uniformly is a real simplification, not a workaround: it collapses two
982
+ * write paths into one, and shrinks the TOCTOU blast radius to "did the
983
+ * file change between the read and the (now human-paced) write" rather
984
+ * than "was this decision even safe to make unattended."
985
+ *
986
+ * "unrecognized-shape" is the one case that still never even OFFERS a
987
+ * prompt: the existing entry isn't in the shape any version of this
988
+ * installer has ever written (extra keys, wrong arg count, a completely
989
+ * different command) — we don't have a confident diff to show, so we leave
990
+ * it alone with a message rather than guessing at what "the same value,
991
+ * updated" would even mean for it.
992
+ */
993
+ export function decideMcpPointerAction(existing, canonical, currentIsCheckout) {
994
+ const analysis = analyzeMcpPointer(existing, canonical);
995
+ if (!analysis.recognized)
996
+ return { kind: "unrecognized-shape" };
997
+ if (analysis.matches)
998
+ return { kind: "noop" };
999
+ const oldPointer = existing.args[0];
1000
+ return { kind: "consent-required", oldPointer, existingOwnership: analysis.ownership, currentIsCheckout };
1001
+ }
1002
+ function describeInstallKind(isCheckout) {
1003
+ return isCheckout ? "a dev checkout" : "a published install";
1004
+ }
1005
+ function describeExistingOwnership(ownership) {
1006
+ switch (ownership) {
1007
+ case "owned-checkout": return "a dev checkout of codebus";
1008
+ case "owned-published": return "a published codebus install";
1009
+ case "unknown": return "an install we can't confirm is codebus (path doesn't resolve, or belongs to a different package)";
1010
+ }
1011
+ }
1012
+ /** Explains a drift before decideMcpPointerAction's caller asks for consent to write anything. */
1013
+ function printDriftConsentExplanation(path, oldPointer, newPointer, existingOwnership, currentIsCheckout) {
1014
+ console.log(`${WARN} codebus-mcp's registered pointer in ${path} has drifted from what this install would write:`);
1015
+ console.log(` currently registered: ${oldPointer} (${describeExistingOwnership(existingOwnership)})`);
1016
+ console.log(` this install would write: ${newPointer} (${describeInstallKind(currentIsCheckout)})`);
1017
+ if (existingOwnership === "unknown") {
1018
+ console.log(` Ownership of the current entry couldn't be confirmed (advisory check only — never a security`);
1019
+ console.log(` gate) — review before proceeding; this could be a hand-configured entry that merely LOOKS`);
1020
+ console.log(` like ours, not a genuinely drifted codebus pointer.`);
1021
+ }
1022
+ else if (currentIsCheckout && existingOwnership === "owned-published") {
1023
+ console.log(` This would replace a stable published pointer with one from a mutable dev checkout —`);
1024
+ console.log(` confirm that's intentional; it will break the next time this checkout changes.`);
1025
+ }
1026
+ else if (currentIsCheckout && existingOwnership === "owned-checkout") {
1027
+ console.log(` Both the registered pointer and this install are dev checkouts — confirm this move is intentional.`);
1028
+ }
1029
+ else if (existingOwnership === "owned-checkout") {
1030
+ console.log(` The registered pointer looks like a dev checkout that's since moved, rebuilt, or been removed —`);
1031
+ console.log(` this looks like the common case this check exists to catch.`);
1032
+ }
1033
+ else {
1034
+ console.log(` Both pointers look like real installs, just at different paths — confirm this move is`);
1035
+ console.log(` intentional (e.g. an npm reinstall elsewhere) before overwriting.`);
1036
+ }
1037
+ }
1038
+ /**
1039
+ * Lines to print when the running install is a dev/source checkout (see
1040
+ * isDevLinkedInstall() in version-check.ts, which this mirrors verbatim —
1041
+ * same "src/ next to package.json" signal docs/CLI-DISTRIBUTION.md already
1042
+ * uses to flag an arbitrary/unpinned CLI build). The MCP pointer this install
1043
+ * computes points INTO that checkout's dist/mcp.js, which is exactly the
1044
+ * mutable-path drift class decideMcpPointerAction() exists to catch — this
1045
+ * warning fires at the moment of registration, before drift can happen, so
1046
+ * the bug doesn't get reintroduced by a future `install codex`/`install mcp`
1047
+ * run against a dev checkout. Deliberately a warning, not a hard failure:
1048
+ * testing against a local checkout is a legitimate dev workflow.
1049
+ */
1050
+ export function devCheckoutWarningLines(devLinked) {
1051
+ if (!devLinked)
1052
+ return [];
1053
+ return [
1054
+ `${WARN} codebus install is running from a DEV CHECKOUT (src/ present next to package.json), not the published package.`,
1055
+ ` The MCP server pointer just registered points into THIS checkout's dist/mcp.js and will break the next`,
1056
+ ` time this checkout changes (rebuild, git pull/checkout, deletion). If that's not intentional, install`,
1057
+ ` the published CLI and re-run this command: codebus upgrade`,
1058
+ ];
1059
+ }
1060
+ function printDevCheckoutWarning(currentIsCheckout) {
1061
+ for (const line of devCheckoutWarningLines(currentIsCheckout))
1062
+ console.log(line);
1063
+ }
1064
+ /**
1065
+ * Same-directory temp file + fsync + atomic rename, instead of truncating
1066
+ * the live file in place with a plain writeFileSync(path, …). Protects a
1067
+ * concurrent reader (a Codex/Claude session reloading its config mid-write)
1068
+ * from ever observing a partially-written file, and protects the write
1069
+ * itself from a crash leaving `path` empty. The temp file must be in the
1070
+ * SAME directory as the file it will replace for the rename to be atomic
1071
+ * (no cross-filesystem rename, which the OS can't do atomically).
1072
+ *
1073
+ * Symlink-aware (PR #110 finding): if `path` is itself a symlink — common
1074
+ * with dotfile managers (chezmoi, GNU stow, rcm, …) that manage
1075
+ * ~/.claude/settings.json or ~/.codex/config.toml as a symlink into their
1076
+ * own store — a rename() onto the symlink's OWN path would replace the
1077
+ * symlink itself with a plain file, silently severing it from wherever it
1078
+ * pointed. The pre-existing plain `writeFileSync(path, …)` this replaced
1079
+ * followed the symlink and wrote through it, which is the behavior users
1080
+ * of those tools expect. So: resolve the symlink first (realpathSync) and
1081
+ * perform the WHOLE atomic dance (temp file + fsync + rename) against the
1082
+ * RESOLVED target path instead — including creating the temp file in the
1083
+ * TARGET's own directory, which may differ from the symlink's directory,
1084
+ * since that's what the rename needs to be atomic on the same filesystem.
1085
+ * The symlink itself is never touched; only the file it points at changes.
1086
+ *
1087
+ * Dangling symlinks (PR #110 follow-up finding): realpathSync throws when
1088
+ * the symlink's target doesn't currently exist. Falling back to writing at
1089
+ * `path` itself in that case would replace (sever) the symlink — the exact
1090
+ * bug this function exists to avoid, just reached via a different door.
1091
+ * Instead: resolve the symlink's own RAW target string (readlinkSync,
1092
+ * resolved against the symlink's directory if relative) and write there —
1093
+ * this creates the file the symlink already points at, preserving the
1094
+ * link — but only when that target's parent directory demonstrably
1095
+ * exists. If it doesn't, fail LOUD (a thrown Error naming the broken
1096
+ * symlink and its target) rather than silently converting the symlink into
1097
+ * a plain file. The old plain `writeFileSync` would likewise have errored
1098
+ * trying to open a dangling link's nonexistent target — this matches that,
1099
+ * rather than papering over it.
1100
+ *
1101
+ * Preserves the target file's POSIX mode bits (sol round-2 finding #6) —
1102
+ * a plain new 0600 inode would otherwise silently tighten permissions on
1103
+ * a file the user had, say, group-readable. Documented limitation: ACLs,
1104
+ * extended attributes, and ownership beyond the mode bits are NOT
1105
+ * preserved; acceptable for a small personal config file like this one. The
1106
+ * containing directory is not separately fsynced, so on a crash exactly at
1107
+ * the wrong instant the rename's durability (not its atomicity) is best-effort.
1108
+ */
1109
+ function atomicWriteFileSync(path, data) {
1110
+ // Resolve a symlink to its real target BEFORE doing anything else — every
1111
+ // subsequent step (temp file location, mode read, rename destination)
1112
+ // operates on the resolved path, so the symlink at `path` is left
1113
+ // completely untouched. A `path` that doesn't exist at all yet falls back
1114
+ // to operating on `path` itself, same as the non-symlink case.
1115
+ let targetPath = path;
1116
+ let lstat;
1117
+ try {
1118
+ lstat = lstatSync(path);
1119
+ }
1120
+ catch {
1121
+ lstat = null; // doesn't exist yet — ordinary "brand new file" case
1122
+ }
1123
+ if (lstat !== null && lstat.isSymbolicLink()) {
1124
+ try {
1125
+ targetPath = realpathSync(path);
1126
+ }
1127
+ catch {
1128
+ // Dangling: resolve the raw link target ourselves rather than ever
1129
+ // falling back to writing at the symlink's own path.
1130
+ const rawTarget = readlinkSync(path);
1131
+ const resolvedTarget = isAbsolute(rawTarget) ? rawTarget : join(dirname(path), rawTarget);
1132
+ if (!existsSync(dirname(resolvedTarget))) {
1133
+ throw new Error(`${path} is a symlink to ${rawTarget} (resolves to ${resolvedTarget}), but that target's directory doesn't exist — refusing to replace the symlink with a plain file. Create ${dirname(resolvedTarget)} (or fix the broken symlink) and try again.`);
1134
+ }
1135
+ targetPath = resolvedTarget;
1136
+ }
1137
+ }
1138
+ const dir = dirname(targetPath);
1139
+ let mode = 0o600;
1140
+ try {
1141
+ mode = statSync(targetPath).mode & 0o777;
1142
+ }
1143
+ catch { /* no existing file — default to a private mode */ }
1144
+ const { tmp, fd } = openUniqueTempFileExclusive(dir, basename(targetPath), mode);
1145
+ // Luna finding: a throw from writeFileSync/fsyncSync used to escape through
1146
+ // the finally below (which only closes the fd) without ever reaching the
1147
+ // unlink in the second try/catch — leaking the uniquely-created temp file
1148
+ // on disk. The whole write/fsync/chmod/rename sequence is now one try, so
1149
+ // ANY failure along it — write, fsync, chmod, or rename — falls through to
1150
+ // the same best-effort cleanup before rethrowing the original error.
1151
+ try {
1152
+ try {
1153
+ writeFileSync(fd, data);
1154
+ fsyncSync(fd);
1155
+ }
1156
+ finally {
1157
+ closeSync(fd);
1158
+ }
1159
+ chmodSync(tmp, mode); // openSync's mode argument is subject to umask; make it exact
1160
+ renameSync(tmp, targetPath);
1161
+ }
1162
+ catch (e) {
1163
+ try {
1164
+ unlinkSync(tmp);
1165
+ }
1166
+ catch { /* best-effort cleanup of the temp file; swallow a secondary error here so the original `e` is what propagates */ }
1167
+ throw e;
1168
+ }
1169
+ }
1170
+ const TEMP_FILE_MAX_ATTEMPTS = 5;
1171
+ /**
1172
+ * Opens a fresh, exclusively-created temp file in `dir` for the atomic-write
1173
+ * dance above, and returns both its path and open fd.
1174
+ *
1175
+ * Luna/PR#110 finding: the previous name — `.${basename}.codebus-tmp-${pid}-
1176
+ * ${Date.now()}` — is fully PREDICTABLE by anyone who can see the target
1177
+ * directory and roughly when the installer runs (pid space is small, and
1178
+ * Date.now() is guessable to within a small window). Worse, it was opened
1179
+ * with plain "w", which — like a plain writeFileSync — follows an existing
1180
+ * symlink at that path rather than refusing to touch it. Combined, a local
1181
+ * attacker could pre-create a symlink at the exact predictable name pointing
1182
+ * anywhere they choose (e.g. ~/.ssh/authorized_keys), and the installer
1183
+ * would then write its config data straight through that symlink to the
1184
+ * attacker's chosen target the next time it ran — an arbitrary-file-write
1185
+ * primitive via a config file write nobody would think to audit for it.
1186
+ *
1187
+ * Fixed two ways, both required: (a) "wx" (O_CREAT|O_EXCL) instead of "w" —
1188
+ * refuses to open if ANYTHING already exists at the path, symlink included,
1189
+ * so a pre-placed symlink is never followed, full stop; (b) an unpredictable
1190
+ * name (a crypto-random suffix, not just pid+timestamp) so an attacker can't
1191
+ * even aim a symlink at the right path in the first place. (a) alone would
1192
+ * already close the vulnerability, but without (b) an attacker who wins the
1193
+ * race to pre-create the (still predictable) name first would simply cause
1194
+ * our "wx" open to fail instead — a self-inflicted denial of service. Both
1195
+ * together close the write primitive AND keep the legitimate write reliable.
1196
+ *
1197
+ * EEXIST from "wx" should now be vanishingly rare (only a genuine random-
1198
+ * suffix collision, or another process/attacker independently guessing the
1199
+ * same random suffix) — retried a handful of times with a fresh suffix
1200
+ * before giving up LOUD; never silently falls back to a non-exclusive open.
1201
+ */
1202
+ function openUniqueTempFileExclusive(dir, targetBasename, mode) {
1203
+ let lastError;
1204
+ for (let attempt = 0; attempt < TEMP_FILE_MAX_ATTEMPTS; attempt++) {
1205
+ const suffix = randomBytes(8).toString("hex");
1206
+ const tmp = join(dir, `.${targetBasename}.codebus-tmp-${process.pid}-${suffix}`);
1207
+ try {
1208
+ const fd = openSync(tmp, "wx", mode);
1209
+ return { tmp, fd };
1210
+ }
1211
+ catch (e) {
1212
+ if (e.code !== "EEXIST")
1213
+ throw e;
1214
+ lastError = e; // extremely unlikely — a genuine random-suffix collision; try again with a fresh one
1215
+ }
1216
+ }
1217
+ throw new Error(`Could not create a unique temp file in ${dir} after ${TEMP_FILE_MAX_ATTEMPTS} attempts (each collided with something already at the generated path) — refusing to fall back to a non-exclusive open. Last error: ${lastError?.message ?? String(lastError)}`);
1218
+ }
1219
+ /** Busy-wait for `ms` milliseconds — see acquireConfigLock's retry loop for why. */
1220
+ function sleepSyncMs(ms) {
1221
+ const until = Date.now() + ms;
1222
+ while (Date.now() < until) { /* deliberately blocking: bounded, a handful of times at most */ }
1223
+ }
1224
+ const DEFAULT_LOCK_MAX_WAIT_MS = 8_000;
1225
+ const DEFAULT_LOCK_RETRY_DELAY_MS = 100;
1226
+ /**
1227
+ * Attempts to claim `lockPath` via exclusive create, writing `myPid` as its
1228
+ * content (a human diagnostic hint ONLY — see acquireConfigLock's docstring;
1229
+ * no code path ever reads this back to make a decision). Returns true on
1230
+ * success, false on EEXIST (someone else holds it), throws on any other
1231
+ * error.
1232
+ */
1233
+ function tryCreateLockFile(lockPath, myPid) {
1234
+ let fd;
1235
+ try {
1236
+ fd = openSync(lockPath, "wx");
1237
+ }
1238
+ catch (e) {
1239
+ if (e.code !== "EEXIST")
1240
+ throw e;
1241
+ return false; // someone else already holds it — we created nothing, nothing to clean up
1242
+ }
1243
+ // Luna finding: the exclusive create above already brought the lock FILE
1244
+ // into existence — a failure from here on (the write, or closeSync) used
1245
+ // to rethrow without ever removing it, leaving an empty/stale lock behind
1246
+ // that blocks every subsequent `codebus install` for the full retry
1247
+ // period and then needs a human to delete it manually. Mirrors the same
1248
+ // "clean up what we just created on failure" pattern atomicWriteFileSync
1249
+ // uses for its own temp file: best-effort unlink (swallowing a secondary
1250
+ // error from the unlink itself) before rethrowing the ORIGINAL error
1251
+ // unchanged.
1252
+ try {
1253
+ try {
1254
+ writeFileSync(fd, myPid);
1255
+ }
1256
+ finally {
1257
+ closeSync(fd);
1258
+ }
1259
+ return true;
1260
+ }
1261
+ catch (e) {
1262
+ try {
1263
+ unlinkSync(lockPath);
1264
+ }
1265
+ catch { /* best-effort cleanup of the lock file we just created */ }
1266
+ throw e;
1267
+ }
1268
+ }
1269
+ /**
1270
+ * Best-effort mutual exclusion between concurrent `codebus install`
1271
+ * invocations touching the SAME config file, via a sibling lockfile.
1272
+ *
1273
+ * DESIGN (sol round-6): this replaces TWO prior attempts at automatic
1274
+ * stale-lock reclamation — an mtime-based staleness heuristic, then a
1275
+ * pid-liveness check paired with an atomic-rename claim — each of which
1276
+ * left a residual race under closer scrutiny. The pid-liveness version's
1277
+ * gap: a DELAYED contender can read a dead pid, pause for a genuine
1278
+ * scheduling delay, and by the time it acts on that stale knowledge (even
1279
+ * via the atomic rename), a second contender has already reclaimed and
1280
+ * recreated the lock as live — `rename()`/`unlink()` operate on the PATH,
1281
+ * not the specific file the delayed contender originally inspected, so the
1282
+ * operation succeeds anyway and destroys the live successor's lock. This is
1283
+ * the classic ABA problem for file-based reclaim: there is no POSIX
1284
+ * primitive for "touch this path only if it still contains what I saw."
1285
+ * Two rounds of hardening the RECLAIM mechanism each left a version of this
1286
+ * same gap — the fix is to stop reclaiming automatically at all, not to
1287
+ * harden it a third time.
1288
+ *
1289
+ * The new contract: the ONLY thing that ever creates or destroys the
1290
+ * lockfile is (a) an exclusive create ('wx') by whoever successfully
1291
+ * acquires it, and (b) that SAME holder's own release() — never a third
1292
+ * party inspecting and acting on its content. If the exclusive create
1293
+ * fails (EEXIST), we simply wait and retry — no inspection, no decision,
1294
+ * nothing racy to get wrong — until either it frees up or a bounded total
1295
+ * wait (maxWaitMs) is exhausted, at which point we fail LOUD (see
1296
+ * describeWriteFailure) naming the exact lock file and telling the human to
1297
+ * delete it themselves if they're sure nothing else is running — never
1298
+ * deleting it for them.
1299
+ *
1300
+ * The pid written into the lockfile is now PURELY a human diagnostic hint
1301
+ * for that failure message (so it can say "held by pid NNNN, possibly a
1302
+ * crashed process" without lying about it) — no code path here ever reads
1303
+ * it back to decide anything.
1304
+ *
1305
+ * Honest scope (unchanged): this protects against a SECOND `codebus
1306
+ * install` process racing this one — a real, plausible scenario — not
1307
+ * against a non-cooperating writer (a hand edit, or Codex/Claude itself
1308
+ * rewriting its own config). Closing that gap completely would need
1309
+ * mandatory OS-level locking this file format doesn't support; we do not
1310
+ * claim atomicity we don't have.
1311
+ */
1312
+ export function acquireConfigLock(path, opts = {}) {
1313
+ const lockPath = `${path}.codebus-lock`;
1314
+ const myPid = String(process.pid);
1315
+ const maxWaitMs = opts.maxWaitMs ?? DEFAULT_LOCK_MAX_WAIT_MS;
1316
+ const retryDelayMs = opts.retryDelayMs ?? DEFAULT_LOCK_RETRY_DELAY_MS;
1317
+ // We are the ONLY legitimate creator/destroyer of our own lockfile via any
1318
+ // AUTOMATED path — no reclaim mechanism exists any more that could replace
1319
+ // it with a successor's while we still think we hold it. But the bound-
1320
+ // exhausted failure message (above) deliberately tells a stuck HUMAN to
1321
+ // delete the lock file themselves if they're sure nothing else is running.
1322
+ // If they do that while we are, in fact, still alive and holding it, a
1323
+ // second `codebus install` can now acquire a fresh, live lock at the same
1324
+ // path. Our own release() must not then blow that fresh lock away just
1325
+ // because it happens to sit at the path we remember — that's not "acting
1326
+ // on stale knowledge about someone else's file" in the old reclaim sense,
1327
+ // but it's the same shape of bug: a path-based unlink can hit a file we
1328
+ // didn't create. So we re-read the lockfile's content immediately before
1329
+ // unlinking and only proceed if it still holds OUR pid — a lightweight
1330
+ // courtesy check, not a decision mechanism (we never act on someone
1331
+ // ELSE's lock, only ever verify our own is still ours before touching it).
1332
+ // Luna finding: the read of currentHolder just below and the unlinkSync
1333
+ // further down are two separate syscalls, not one — there is no POSIX
1334
+ // call that atomically says "delete this path only if its content/inode
1335
+ // still matches what I just read". So a successor's lock created in the
1336
+ // gap between them (freed by the bound-exhausted human deletion above,
1337
+ // then immediately re-acquired by a second `codebus install`) could in
1338
+ // principle be unlinked by this stale holder anyway, pid check
1339
+ // notwithstanding — the check narrows the window, it does not close it.
1340
+ // We've tightened the gap as far as plain JS allows: the pid-match check
1341
+ // is the very next statement after the read, and the unlink is the very
1342
+ // next statement after that, with no logging or other I/O between either
1343
+ // pair. This is an ACCEPTED, INHERENT residual limitation of path-based
1344
+ // file locking, not something this code claims to fully solve — the same
1345
+ // honesty standard as the atomic-write comments above. Closing it
1346
+ // completely needs a different primitive: an OS-level flock() held on an
1347
+ // open file descriptor, tied to the open file description rather than
1348
+ // the path, so no unlink-race is even expressible. Node's core `fs`
1349
+ // doesn't expose that (it would need a native addon like fs-ext, or a
1350
+ // well-vetted lockfile library) — deliberately not pursued here given
1351
+ // this tool's actual risk profile: a human running `codebus install` on
1352
+ // their own machine, where the realistic collision is running the
1353
+ // command twice nearly simultaneously, not an adversarial multi-tenant
1354
+ // race. The realistic residual of this gap is therefore an adversarial
1355
+ // or extraordinarily-unlucky-timing scenario, not ordinary use.
1356
+ const release = () => {
1357
+ let currentHolder;
1358
+ try {
1359
+ currentHolder = readFileSync(lockPath, "utf8").trim();
1360
+ }
1361
+ catch {
1362
+ // Already gone — nothing more to do (the common, expected case).
1363
+ return;
1364
+ }
1365
+ if (currentHolder !== myPid) {
1366
+ // Someone else's lock now occupies this path — ours was already
1367
+ // removed out from under us (almost certainly a human manually
1368
+ // deleting it per the bound-exhausted failure message, while we were
1369
+ // still alive). Deleting it now would destroy a legitimate second
1370
+ // holder's lock. Leave it alone.
1371
+ console.log(`${WARN} Lock file ${lockPath} no longer matches this process (pid ${myPid}); leaving it in place — it now belongs to another process (pid ${currentHolder || "unknown"}).`);
1372
+ return;
1373
+ }
1374
+ try {
1375
+ unlinkSync(lockPath);
1376
+ }
1377
+ catch { /* already gone — nothing more to do */ }
1378
+ };
1379
+ const deadline = Date.now() + maxWaitMs;
1380
+ while (true) {
1381
+ if (tryCreateLockFile(lockPath, myPid))
1382
+ return { ok: true, release };
1383
+ if (Date.now() >= deadline) {
1384
+ let holderHint = null;
1385
+ try {
1386
+ holderHint = readFileSync(lockPath, "utf8").trim() || null;
1387
+ }
1388
+ catch { /* gone/unreadable — no hint available */ }
1389
+ return { ok: false, lockPath, holderHint };
1390
+ }
1391
+ sleepSyncMs(retryDelayMs);
1392
+ }
1393
+ }
1394
+ /**
1395
+ * TOCTOU guard + atomic write: writes `newText` to `path` (and, if
1396
+ * `backupPath` is given, the pre-write content to `backupPath`) ONLY IF the
1397
+ * file's on-disk content still equals `expectedCurrentText` — what was read
1398
+ * at the start of the calling operation — checked again right here,
1399
+ * immediately before committing, and guarded by acquireConfigLock() so a
1400
+ * second concurrent `codebus install` can't interleave with this check-and-
1401
+ * write. If something else modified the file in between (another process,
1402
+ * a hand edit, Codex itself), aborts instead of silently clobbering that
1403
+ * change — see WriteResult's `reason` for which case fired. Only ENOENT on
1404
+ * the re-read means "file doesn't exist, treat as empty" (sol round-2
1405
+ * finding #4); any OTHER read error aborts as `reason: "read-error"` rather
1406
+ * than being silently treated as an empty file. The lock is released via
1407
+ * `finally` unconditionally — on success, on a "changed"/"read-error"
1408
+ * abort, and on the write itself throwing — so a well-behaved holder never
1409
+ * leaves its lockfile behind.
1410
+ */
1411
+ export function writeIfUnchanged(path, expectedCurrentText, newText, backupPath, lockOpts) {
1412
+ const lock = acquireConfigLock(path, lockOpts);
1413
+ if (!lock.ok)
1414
+ return { ok: false, reason: "locked", lockPath: lock.lockPath, holderHint: lock.holderHint };
1415
+ try {
1416
+ let freshText;
1417
+ try {
1418
+ freshText = readFileSync(path, "utf8");
1419
+ }
1420
+ catch (e) {
1421
+ const err = e;
1422
+ if (err.code !== "ENOENT")
1423
+ return { ok: false, reason: "read-error", error: err };
1424
+ freshText = "";
1425
+ }
1426
+ if (freshText !== expectedCurrentText)
1427
+ return { ok: false, reason: "changed" };
1428
+ if (backupPath && freshText.length > 0) {
1429
+ atomicWriteFileSync(backupPath, freshText);
1430
+ }
1431
+ atomicWriteFileSync(path, newText);
1432
+ return { ok: true };
1433
+ }
1434
+ finally {
1435
+ lock.release();
1436
+ }
1437
+ }
1438
+ /** Human-readable explanation for a failed writeIfUnchanged() — see WriteResult. */
1439
+ export function describeWriteFailure(result, path) {
1440
+ if (result.reason === "changed")
1441
+ return `${path} changed since it was read — not writing, to avoid clobbering a concurrent edit.`;
1442
+ if (result.reason === "locked") {
1443
+ const hint = result.holderHint
1444
+ ? ` The lock file records pid ${result.holderHint} — it may still be running, or it may be a crashed process that never cleaned up.`
1445
+ : "";
1446
+ return `${path} is locked by another \`codebus install\` run (${result.lockPath} exists).${hint} If you're sure no other \`codebus install\` is running, delete that file yourself and re-run — this tool will not delete it for you.`;
1447
+ }
1448
+ return `Could not verify ${path} before writing (${result.error.message}) — aborting without writing.`;
381
1449
  }
382
1450
  function printMcpServerConfig() {
383
1451
  const config = buildMcpServerConfig();
@@ -401,8 +1469,41 @@ function getUserConfirmation(prompt) {
401
1469
  process.stdin.resume();
402
1470
  });
403
1471
  }
1472
+ /**
1473
+ * Shared by every write path in this file that needs sign-off —
1474
+ * installMcp's fresh registration, both installers' drift-heal, and
1475
+ * installCodexMcp's write into an existing-but-tableless config.toml — so
1476
+ * the prompt/--yes/non-interactive mechanics live in exactly one place.
1477
+ * (sol round-4 finding #1: these were hand-rolled slightly differently at
1478
+ * each call site, and the gap that opened up — one call site skipping the
1479
+ * gate entirely — came from that duplication, not from any one of the
1480
+ * individual gates being wrong in isolation.)
1481
+ */
1482
+ async function requestConsent(o, promptText) {
1483
+ if (o.yes)
1484
+ return "granted";
1485
+ if (!process.stdin.isTTY)
1486
+ return "non-interactive";
1487
+ const answer = await getUserConfirmation(promptText);
1488
+ return answer.toLowerCase().startsWith("y") ? "granted" : "declined";
1489
+ }
404
1490
  export async function installAll(o = {}) {
405
1491
  const repoRoot = o.cwd ?? process.cwd();
1492
+ // ── 0. T0 CI hooks (opt-in subcommand) ───────────────────────────────────
1493
+ // `codebus install --ci-hooks [--dry-run]`: pre-commit guards — lint,
1494
+ // affected unit tests, and the DIFF-SIZE GUARD (>2500 added lines in one
1495
+ // file vs merge base without an authorised ci/large-diff label -> stop).
1496
+ // Time-capped at 10s; every outage swallowed (never blocks coding).
1497
+ if (o.ciHooks) {
1498
+ const { runCiHooks } = await import("../ci-hooks/ci-hooks.js");
1499
+ const result = runCiHooks({ dryRun: o.dryRun, cwd: repoRoot });
1500
+ for (const note of result.notes)
1501
+ console.log(`${OK} ${note}`);
1502
+ console.log(`${OK} T0 hooks ${result.ok ? "pass" : "STOP"} (${result.elapsedMs}ms${result.dryRun ? ", dry-run" : ""})`);
1503
+ if (!result.ok)
1504
+ process.exit(2);
1505
+ return;
1506
+ }
406
1507
  // ── 1. Build graph out-of-band ───────────────────────────────────────────
407
1508
  if (!o.skipGraph) {
408
1509
  const isGitDir = existsSync(join(repoRoot, ".git"));
@@ -509,9 +1610,13 @@ export function registerInstall(program, g) {
509
1610
  const install = program
510
1611
  .command("install")
511
1612
  .description("wire CodeBus into agent launchers (run without subcommand for zero-config setup)")
1613
+ .option("--ci-hooks", "run T0 pre-commit guards (diff-size/lint/affected tests)")
1614
+ .option("--dry-run", "with --ci-hooks: report without executing lint/tests")
512
1615
  .action(installAll);
513
1616
  g(install.command("claude")).option("--force", "replace an existing statusLine").action(installClaude);
514
- g(install.command("codex")).action(installCodex);
1617
+ g(install.command("codex"))
1618
+ .option("--yes", "skip confirmation when an MCP pointer needs consent before updating")
1619
+ .action(installCodex);
515
1620
  g(install.command("mcp"))
516
1621
  .option("--yes", "skip confirmation prompt (opt-in gate)")
517
1622
  .description("register codebus-mcp server in Claude Code settings (opt-in only)")