@bragi-gmbh/codebus 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ci-hooks/ci-hooks.js +126 -0
- package/dist/ci-hooks/ci-hooks.js.map +1 -0
- package/dist/commands/agent.js +137 -0
- package/dist/commands/agent.js.map +1 -0
- package/dist/commands/as.js +1 -1
- package/dist/commands/as.js.map +1 -1
- package/dist/commands/ask.js +1 -1
- package/dist/commands/ask.js.map +1 -1
- package/dist/commands/ci-findings.js +129 -0
- package/dist/commands/ci-findings.js.map +1 -0
- package/dist/commands/ci-reply.js +117 -0
- package/dist/commands/ci-reply.js.map +1 -0
- package/dist/commands/ci-steer-escalate.js +65 -0
- package/dist/commands/ci-steer-escalate.js.map +1 -0
- package/dist/commands/cli-mcp-correlation.js +131 -0
- package/dist/commands/cli-mcp-correlation.js.map +1 -0
- package/dist/commands/connector-attach-detach.js +542 -0
- package/dist/commands/connector-attach-detach.js.map +1 -0
- package/dist/commands/connector.js +513 -0
- package/dist/commands/connector.js.map +1 -0
- package/dist/commands/correlation.js +194 -0
- package/dist/commands/correlation.js.map +1 -0
- package/dist/commands/decision.js +63 -0
- package/dist/commands/decision.js.map +1 -1
- package/dist/commands/digest.js +14 -3
- package/dist/commands/digest.js.map +1 -1
- package/dist/commands/do.js +127 -0
- package/dist/commands/do.js.map +1 -0
- package/dist/commands/factory-attention.js +106 -0
- package/dist/commands/factory-attention.js.map +1 -0
- package/dist/commands/factory-context-read.js +171 -0
- package/dist/commands/factory-context-read.js.map +1 -0
- package/dist/commands/factory-interventions.js +102 -0
- package/dist/commands/factory-interventions.js.map +1 -0
- package/dist/commands/factory.js +187 -0
- package/dist/commands/factory.js.map +1 -1
- package/dist/commands/graph-pull.js +130 -0
- package/dist/commands/graph-pull.js.map +1 -0
- package/dist/commands/graph.js +5 -1
- package/dist/commands/graph.js.map +1 -1
- package/dist/commands/inbox.js +7 -7
- package/dist/commands/inbox.js.map +1 -1
- package/dist/commands/ingest.js +1 -1
- package/dist/commands/ingest.js.map +1 -1
- package/dist/commands/install.js +1160 -55
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/integration-ledger.js +81 -0
- package/dist/commands/integration-ledger.js.map +1 -0
- package/dist/commands/knowledge.js +178 -0
- package/dist/commands/knowledge.js.map +1 -0
- package/dist/commands/links.js +1 -1
- package/dist/commands/links.js.map +1 -1
- package/dist/commands/morning.js +50 -1
- package/dist/commands/morning.js.map +1 -1
- package/dist/commands/plan-compiler.js +260 -0
- package/dist/commands/plan-compiler.js.map +1 -0
- package/dist/commands/plan-lint.js +275 -0
- package/dist/commands/plan-lint.js.map +1 -0
- package/dist/commands/plan-score.js +203 -0
- package/dist/commands/plan-score.js.map +1 -0
- package/dist/commands/plan.js +481 -15
- package/dist/commands/plan.js.map +1 -1
- package/dist/commands/product-validity.js +168 -0
- package/dist/commands/product-validity.js.map +1 -0
- package/dist/commands/question.js +1 -1
- package/dist/commands/question.js.map +1 -1
- package/dist/commands/read-policy.js +131 -0
- package/dist/commands/read-policy.js.map +1 -0
- package/dist/commands/read.js +1 -1
- package/dist/commands/read.js.map +1 -1
- package/dist/commands/repo.js +106 -8
- package/dist/commands/repo.js.map +1 -1
- package/dist/commands/repository-protection.js +209 -0
- package/dist/commands/repository-protection.js.map +1 -0
- package/dist/commands/resolve.js +2 -2
- package/dist/commands/resolve.js.map +1 -1
- package/dist/commands/self-healing.js +26 -0
- package/dist/commands/self-healing.js.map +1 -0
- package/dist/commands/send.js +1 -1
- package/dist/commands/send.js.map +1 -1
- package/dist/commands/serial-merge.js +303 -0
- package/dist/commands/serial-merge.js.map +1 -0
- package/dist/commands/session.js +1 -1
- package/dist/commands/session.js.map +1 -1
- package/dist/commands/task.js +92 -1
- package/dist/commands/task.js.map +1 -1
- package/dist/commands/verify.js +77 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/config/self-healing-analysis.json +158 -0
- package/dist/errors.js +18 -4
- package/dist/errors.js.map +1 -1
- package/dist/execution/client.js +53 -0
- package/dist/execution/client.js.map +1 -0
- package/dist/execution/local.js +24 -0
- package/dist/execution/local.js.map +1 -0
- package/dist/execution/ssh.js +38 -0
- package/dist/execution/ssh.js.map +1 -0
- package/dist/graph.js +78 -16
- package/dist/graph.js.map +1 -1
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -1
- package/dist/json-lite.js +248 -0
- package/dist/json-lite.js.map +1 -0
- package/dist/knowledge-drift.js +40 -0
- package/dist/knowledge-drift.js.map +1 -0
- package/dist/lint/parallelism.js +414 -0
- package/dist/lint/parallelism.js.map +1 -0
- package/dist/mail-latency.js +11 -1
- package/dist/mail-latency.js.map +1 -1
- package/dist/mcp-ci-findings.js +31 -0
- package/dist/mcp-ci-findings.js.map +1 -0
- package/dist/mcp-correlation.js +290 -0
- package/dist/mcp-correlation.js.map +1 -0
- package/dist/mcp-factory-context-read.js +81 -0
- package/dist/mcp-factory-context-read.js.map +1 -0
- package/dist/mcp-registry.js +128 -1
- package/dist/mcp-registry.js.map +1 -1
- package/dist/mcp.js +94 -5
- package/dist/mcp.js.map +1 -1
- package/dist/openrouter-reservation-guard.js +97 -0
- package/dist/openrouter-reservation-guard.js.map +1 -0
- package/dist/self-healing-analysis-loader.js +150 -0
- package/dist/self-healing-analysis-loader.js.map +1 -0
- package/dist/sender.js +11 -9
- package/dist/sender.js.map +1 -1
- package/dist/toml-lite.js +546 -0
- package/dist/toml-lite.js.map +1 -0
- package/dist/version-check.js +23 -7
- package/dist/version-check.js.map +1 -1
- package/package.json +5 -3
package/dist/commands/install.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync,
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
283
|
-
//
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
379
|
-
|
|
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"))
|
|
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)")
|