@yolo-labs/yolobridge 0.1.0 → 0.2.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.
@@ -0,0 +1,877 @@
1
+ /**
2
+ * Writes/removes a PROJECT-scoped `.mcp.json` entry for the local MCP proxy
3
+ * (mcp-proxy.ts) in the `yolo-bridge attach` spawn `cwd`.
4
+ *
5
+ * Deliberately NOT the user's global `~/.claude.json` — that's what
6
+ * `containers/services/container-api/mcp-config-writer.js` writes to
7
+ * in-pod, which is fine there because a pod is single-purpose and thrown
8
+ * away. A laptop is not: writing into the global config would leak a
9
+ * workspace-scoped MCP server into every unrelated Claude Code session the
10
+ * user runs on their own machine. `.mcp.json` in the attach directory is
11
+ * scoped to exactly that directory.
12
+ *
13
+ * Preserves any pre-existing `.mcp.json` content the same way the pod-side
14
+ * writer's own header comment describes ("preserves user-added entries") —
15
+ * adapted to a single project file rather than a global merge: only the key
16
+ * this module owns (`SERVER_NAME`) is ever added or removed; every other
17
+ * key in the file is left untouched.
18
+ *
19
+ * Refuses to write anything if `.mcp.json` is confirmed NOT git-ignored
20
+ * inside a real repo at `cwd` (Codex review, 2026-08-24, round 16 — this
21
+ * repo's OWN root tracks `.mcp.json`, verified with `git cat-file`, not
22
+ * assumed): round 12 already moved the actual SECRET out of this file, but
23
+ * the entry still carries a per-attach, machine-local loopback URL that is
24
+ * dead the moment this daemon exits. A spawned coding agent running with
25
+ * YOLO-mode autonomy could `git add -A && commit` while attached, and
26
+ * cleanup on detach only ever touches the WORKING TREE — it can't repair a
27
+ * commit already made, so every collaborator who pulls it inherits a
28
+ * `yolo-studio` server pointing at a port nothing is listening on. Same
29
+ * `riskyToCommit` check `local-mcp-trust.ts` uses (round 15), same
30
+ * degraded fallback: `ok: false` (no local MCP access this attach), never
31
+ * a hard failure.
32
+ */
33
+ import { readFileSync, writeFileSync, unlinkSync, existsSync, chmodSync, linkSync, renameSync, statSync } from 'node:fs';
34
+ import { join, basename, dirname } from 'node:path';
35
+ import { uptime } from 'node:os';
36
+ import { randomBytes } from 'node:crypto';
37
+ import { SECRET_HEADER, SECRET_ENV_VAR } from './mcp-proxy.js';
38
+ import { atomicWriteFileSync, unlinkWriteTarget, resolveWriteTarget } from './atomic-write.js';
39
+ import { riskyToCommit, ensureTempSiblingExcluded } from './git-safety.js';
40
+ /** The literal string written into `.mcp.json`'s `headers` value — a
41
+ * template, not the secret itself (Codex review, 2026-08-24, round 12).
42
+ * Claude Code expands `${VAR}` in `.mcp.json` string fields against its
43
+ * OWN process env at load time; `cli.ts` sets `SECRET_ENV_VAR` on
44
+ * `process.env` right before spawning the local agent, which inherits it.
45
+ * The real random secret this resolves to at runtime never touches any
46
+ * file this module writes. */
47
+ const SECRET_HEADER_TEMPLATE = `\${${SECRET_ENV_VAR}}`;
48
+ /** Matches the pod-side writer's own server name (agents.json's
49
+ * `mcp.servers.yolo-studio` key) — same identity, different transport. */
50
+ const SERVER_NAME = 'yolo-studio';
51
+ function mcpJsonPath(cwd) {
52
+ return join(cwd, '.mcp.json');
53
+ }
54
+ function readConfig(path) {
55
+ if (!existsSync(path))
56
+ return {};
57
+ let parsed;
58
+ try {
59
+ parsed = JSON.parse(readFileSync(path, 'utf-8'));
60
+ }
61
+ catch {
62
+ // Malformed existing file — do not clobber it silently by overwriting
63
+ // with a fresh one; treat as unreadable and refuse to touch it (see
64
+ // writeLocalMcpConfig's caller, which logs and skips on `false`).
65
+ throw new Error(`existing ${path} is not valid JSON`);
66
+ }
67
+ // A valid-JSON, non-object root (an array, or a bare scalar like `null`/
68
+ // a number/a string) is just as unsafe to treat as `{}` as malformed JSON
69
+ // is (Codex review, 2026-08-24): `typeof [] === 'object'` passed the old
70
+ // truthy-and-typeof-object check, so an array root would have been cast
71
+ // straight into `Record<string, unknown>` — `config.mcpServers = ...`
72
+ // then silently adds a property onto the operator's array, and the
73
+ // JSON.stringify write below would replace their original array content
74
+ // with an object. Same refuse-rather-than-clobber treatment as bad JSON.
75
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
76
+ throw new Error(`existing ${path} is not a JSON object`);
77
+ }
78
+ return parsed;
79
+ }
80
+ /**
81
+ * Sidecar file recording the proxy URL this module most recently wrote into
82
+ * `yolo-studio`, kept OUTSIDE `.mcp.json` itself.
83
+ *
84
+ * Round 7-8 tracked ownership with a marker key (`_yolobridge: true`)
85
+ * embedded directly in the `mcpServers.yolo-studio` entry. Codex review,
86
+ * 2026-08-24, round 9, correctly flagged that as broken: Claude Code
87
+ * (v2.0.21+) validates `mcpServers` entries strictly on some releases and
88
+ * rejects unknown fields (anthropics/claude-code#10606) — this repo's own
89
+ * pod-side writer (`containers/services/container-api/mcp-config-writer.js`)
90
+ * already hit exactly this and solved it with an external sidecar rather
91
+ * than an in-entry marker. This file now does the same, adapted to
92
+ * `.mcp.json`'s project-scoped (not home-scoped) design: the entry this
93
+ * module writes is a plain, spec-shaped `{ type: 'http', url, headers }`
94
+ * with no extra keys, so it can never trip strict validation, and
95
+ * ownership is instead established by comparing the entry's `url` against
96
+ * what this sidecar recorded us writing, AND its `headers[SECRET_HEADER]`
97
+ * against the fixed `SECRET_HEADER_TEMPLATE` this module always writes
98
+ * (Codex review, 2026-08-24, round 11 — see `looksLikeOurOwnEntry`'s own
99
+ * doc comment for why `url` alone wasn't enough).
100
+ *
101
+ * Holds only the URL, never the secret (round 12 moved the actual secret
102
+ * out of the project tree entirely — see `SECRET_HEADER_TEMPLATE` above)
103
+ * — still chmod'd owner-only regardless, since even the loopback URL alone
104
+ * is enough to attempt a request against this operator's specific running
105
+ * proxy instance.
106
+ */
107
+ function sidecarPath(cwd) {
108
+ return join(cwd, '.yolobridge-mcp-state.json');
109
+ }
110
+ /** Best-effort read: a missing or corrupt sidecar just means "we don't know
111
+ * what we last wrote", which correctly makes `looksLikeOurOwnEntry` refuse
112
+ * to reclaim rather than guess — fail closed, same as everywhere else in
113
+ * this file. Requires BOTH fields present and correctly typed — a sidecar
114
+ * missing `pid` (e.g. a half-written file) must not be treated as a
115
+ * partial match either. */
116
+ function readSidecar(cwd) {
117
+ const path = sidecarPath(cwd);
118
+ if (!existsSync(path))
119
+ return {};
120
+ try {
121
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
122
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && typeof parsed.proxyUrl === 'string' && typeof parsed.pid === 'number') {
123
+ return {
124
+ proxyUrl: parsed.proxyUrl,
125
+ pid: parsed.pid,
126
+ bootUptimeSec: typeof parsed.bootUptimeSec === 'number' ? parsed.bootUptimeSec : undefined,
127
+ };
128
+ }
129
+ }
130
+ catch {
131
+ // Corrupt sidecar — treated as absent above.
132
+ }
133
+ return {};
134
+ }
135
+ /**
136
+ * True if the process that recorded `pid` is (as far as we can tell) still
137
+ * running (Codex review, 2026-08-24, round 19): `looksLikeOurOwnEntry`
138
+ * alone answers "does the on-disk entry match what SOME attach from this
139
+ * module wrote," which is exactly as true for a crashed attach's stale
140
+ * leftover as it is for a SIBLING attach that's still live in the same
141
+ * directory (a real, supported scenario elsewhere in this codebase —
142
+ * "concurrent sibling attach"). Reclaiming the latter would point the
143
+ * shared `.mcp.json` at the wrong proxy for whichever sibling wrote it
144
+ * first, and a later detach could delete the entry out from under a still-
145
+ * running daemon. `process.kill(pid, 0)` is the standard POSIX liveness
146
+ * check (send no actual signal, just probe): ESRCH means no such process
147
+ * (dead — safe to reclaim); EPERM means it exists but we lack permission to
148
+ * signal it (still alive — NOT safe to reclaim); any other outcome is
149
+ * treated as "can't prove it's dead," which fails closed the same way.
150
+ */
151
+ function isPidAlive(pid) {
152
+ try {
153
+ process.kill(pid, 0);
154
+ return true;
155
+ }
156
+ catch (err) {
157
+ return err.code === 'EPERM';
158
+ }
159
+ }
160
+ /**
161
+ * True when the owner recorded by `pid` can be DEFINITIVELY proven to no
162
+ * longer be the same process that wrote it (Codex review, 2026-08-24,
163
+ * round 23) — `isPidAlive` alone can misreport "still alive" for a
164
+ * completely unrelated process: after a crash OR a machine REBOOT, the OS
165
+ * can hand the exact same pid number to a new, long-lived process (PIDs
166
+ * restart from low numbers after every boot, so an early-starting system
167
+ * daemon landing on an old sidecar/lock's exact pid is a real occurrence,
168
+ * not theoretical), and every LATER attach would then refuse to reclaim (or
169
+ * time out acquiring the lock) until that unrelated process happens to
170
+ * exit.
171
+ *
172
+ * `os.uptime()` only ever increases within a single boot session, so a
173
+ * CURRENT uptime smaller than what was recorded at write time can only mean
174
+ * the machine rebooted since — no process can survive that, so the
175
+ * recorded pid is provably stale regardless of what `isPidAlive` reports
176
+ * for whatever happens to hold that number now. `recordedBootUptimeSec`
177
+ * absent (an older record written before this field existed) degrades to
178
+ * the pre-round-23 pid-only check, not a hard failure.
179
+ *
180
+ * Does NOT close the (much rarer) case of an exact pid being recycled to an
181
+ * unrelated process WITHOUT an intervening reboot — doing that portably
182
+ * would need a per-platform process-START-TIME comparison (`/proc/<pid>/
183
+ * stat` on Linux, `ps -o lstart=` on macOS, WMI on Windows); disproportionate
184
+ * for a best-effort, never-hard-failing local guard.
185
+ */
186
+ function isDefinitivelyStale(pid, recordedBootUptimeSec) {
187
+ if (recordedBootUptimeSec !== undefined && uptime() < recordedBootUptimeSec)
188
+ return true;
189
+ return !isPidAlive(pid);
190
+ }
191
+ function lockPath(cwd) {
192
+ return sidecarPath(cwd) + '.lock';
193
+ }
194
+ function sleepSync(ms) {
195
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
196
+ }
197
+ /**
198
+ * Acquires an exclusive, CROSS-PROCESS lock over the read-check-write
199
+ * sequence in `writeLocalMcpConfig`/`removeLocalMcpConfig` (Codex review,
200
+ * 2026-08-24, round 20): two `attach` invocations starting in the same
201
+ * directory before either had written a sidecar could both observe "no
202
+ * existing entry," both pass every check in `writeLocalMcpConfig`, and then
203
+ * race to write — whichever process's `.mcp.json` write lands LAST wins,
204
+ * silently stranding the other's sidecar/proxy pairing (its proxy is still
205
+ * listening at a URL nothing in `.mcp.json` points to any more, and its
206
+ * later `removeLocalMcpConfig` could delete the entry out from under the
207
+ * survivor). An exclusive-create (`wx`) lock file makes the whole
208
+ * read-check-write section atomic across processes, not just within one.
209
+ *
210
+ * A stale lock (its own writer crashed mid-section) is reclaimed the same
211
+ * way a stale sidecar entry is (round 19): the lock file records the
212
+ * writer's pid, and a lock whose recorded pid is confirmed DEAD is deleted
213
+ * and retried immediately, rather than blocking every future attach in
214
+ * this directory forever. Gives up after a bounded wait — a genuinely live
215
+ * holder releases in low milliseconds, this isn't a long-held lock — and
216
+ * returns `null`, which callers treat as their existing degraded `ok:
217
+ * false`, never a hard failure/throw.
218
+ */
219
+ /** Parses a lock file's content into its recorded pid/bootUptimeSec. Accepts
220
+ * BOTH this module's own JSON-object format and the bare-pid-number-string
221
+ * format every lock predating round 23 was written in (a lock outlives its
222
+ * writer only when that writer crashed mid-section, so a lingering lock can
223
+ * legitimately have been written by an older CLI version) — falling back to
224
+ * the legacy shape keeps a pre-round-23 crash's stale lock reclaimable
225
+ * instead of stuck forever the moment this module upgrades. */
226
+ /** A pid is only ever a POSITIVE integer for a real Node/OS process — never
227
+ * 0 or negative (Codex review, 2026-08-24, round 24): `Number('') === 0`
228
+ * is a real JS quirk, so a lock file left EMPTY or truncated by a crash
229
+ * between the exclusive create and a completed write would otherwise
230
+ * parse as pid 0. `process.kill(0, 0)` targets the caller's own PROCESS
231
+ * GROUP on POSIX (not "process 0" — there is no such thing), which always
232
+ * succeeds, so `isPidAlive(0)` would misreport that as "alive" forever —
233
+ * bricking every future attach's lock acquisition until the file is
234
+ * removed by hand. */
235
+ function isValidPid(value) {
236
+ return Number.isInteger(value) && value >= 1;
237
+ }
238
+ function parseLockContent(raw) {
239
+ try {
240
+ const parsed = JSON.parse(raw);
241
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
242
+ const obj = parsed;
243
+ return {
244
+ pid: typeof obj.pid === 'number' && isValidPid(obj.pid) ? obj.pid : undefined,
245
+ bootUptimeSec: typeof obj.bootUptimeSec === 'number' ? obj.bootUptimeSec : undefined,
246
+ };
247
+ }
248
+ }
249
+ catch {
250
+ // Not JSON at all — fall through to the legacy bare-pid-string format.
251
+ }
252
+ const legacyPid = Number(raw);
253
+ return { pid: isValidPid(legacyPid) ? legacyPid : undefined };
254
+ }
255
+ /**
256
+ * Known, accepted residual race (Codex review, 2026-08-24, round 29):
257
+ * process A can peek a stale lock, pause, and by the time it resumes and
258
+ * renames `path` away, a DIFFERENT process has already run a full reclaim
259
+ * cycle and installed its OWN brand-new LIVE lock there. The inode recheck
260
+ * immediately before the rename (below) narrows this to a two-syscall gap
261
+ * but cannot close it entirely with pure POSIX primitives — if A's rename
262
+ * still lands on that live lock, A's fresh re-inspection correctly sees it
263
+ * as live and restores it (via `linkSync`, never a blind overwrite) rather
264
+ * than discarding it, since discarding would let a THIRD process enter the
265
+ * critical section concurrently with the still-running live holder — a
266
+ * safety violation, strictly worse than what this leaves: if the live
267
+ * holder's OWN release happens to run while A holds the lock claimed away
268
+ * (finding it already gone, a harmless no-op) and A then restores it, the
269
+ * restored lock can outlive its owner's own cleanup — a stuck lock,
270
+ * blocking every later config operation until that specific daemon process
271
+ * exits, not a data-corruption risk. Closing this fully would need
272
+ * OS-level advisory locking (`flock`, unavailable via Node's core `fs`) or
273
+ * a well-audited external dependency; disproportionate for a scenario this
274
+ * narrow (requires a live sibling attach, a stale-lock reclaim race
275
+ * against it, AND a scheduling pause landing in a two-syscall window, all
276
+ * at once) on a single local machine, matching this module's own
277
+ * established philosophy of narrowing rather than perfecting an
278
+ * astronomically rare edge case (see round 24's identical acceptance of
279
+ * the non-reboot pid-reuse case).
280
+ */
281
+ function acquireConfigLock(cwd) {
282
+ const path = lockPath(cwd);
283
+ const deadline = Date.now() + 2000;
284
+ for (;;) {
285
+ // Writes the FULL content to a private temp file FIRST, then claims
286
+ // `path` via a hard link (Codex review, 2026-08-24, round 26): a bare
287
+ // `writeFileSync(path, ..., { flag: 'wx' })` makes file CREATION atomic
288
+ // but not CONTENT — there's a real window where `path` exists but is
289
+ // still EMPTY (between the exclusive open and the write completing). A
290
+ // second, genuinely concurrent attach reading `path` in that window
291
+ // sees "no valid pid," which round 24's fix treats as reclaimable —
292
+ // letting it delete the FIRST process's still-being-written lock and
293
+ // acquire its own while the first ALSO proceeds, defeating this lock's
294
+ // whole purpose (both — and the first's later release can go on to
295
+ // delete the SECOND's lock too, compounding it). `linkSync` fails with
296
+ // EEXIST if `path` already exists, giving the SAME exclusivity
297
+ // guarantee `wx` does, but `path` only ever comes into existence
298
+ // pointing at content that was ALREADY fully written beforehand —
299
+ // there is no window where `path` exists with incomplete content at
300
+ // all, from any process's point of view.
301
+ const myPid = process.pid;
302
+ const myBootUptimeSec = uptime();
303
+ const claimTmpPath = `${path}.claim-${myPid}-${randomBytes(4).toString('hex')}`;
304
+ try {
305
+ writeFileSync(claimTmpPath, JSON.stringify({ pid: myPid, bootUptimeSec: myBootUptimeSec }));
306
+ try {
307
+ linkSync(claimTmpPath, path);
308
+ }
309
+ finally {
310
+ // The DATA now lives at `path` via the hard link (or the link
311
+ // failed and nobody ever pointed at this temp file) — either way,
312
+ // this second name is no longer needed.
313
+ try {
314
+ unlinkSync(claimTmpPath);
315
+ }
316
+ catch {
317
+ // Best-effort — a leftover claim-temp file is inert litter, never
318
+ // a correctness problem (nothing else ever looks for it by name).
319
+ }
320
+ }
321
+ return () => {
322
+ // Ownership-aware release (Codex review, 2026-08-24, round 29),
323
+ // not a blind `unlinkSync(path)`: matches the SAME "only ever
324
+ // touch exactly what you own" discipline every other cleanup path
325
+ // in this file already follows (`removeLocalMcpConfig`'s
326
+ // `expectedProxyUrl` check, `removeLocalMcpTrust`'s `attachId`
327
+ // check) — this was the one release in this file that never got
328
+ // it. Defense-in-depth: only deletes `path` if its CURRENT content
329
+ // still records exactly the identity THIS call wrote, so a lock
330
+ // that ended up holding something else by the time release fires
331
+ // (any race, not just one specific scenario) is never touched.
332
+ let current;
333
+ try {
334
+ current = parseLockContent(readFileSync(path, 'utf-8'));
335
+ }
336
+ catch {
337
+ return; // Already gone — nothing to clean up either way.
338
+ }
339
+ if (current.pid !== myPid || current.bootUptimeSec !== myBootUptimeSec)
340
+ return; // Not ours anymore — leave it alone.
341
+ try {
342
+ unlinkSync(path);
343
+ }
344
+ catch {
345
+ // Raced with someone else's cleanup — fine.
346
+ }
347
+ };
348
+ }
349
+ catch (err) {
350
+ if (err.code !== 'EEXIST')
351
+ return null;
352
+ let holder;
353
+ let holderIno;
354
+ try {
355
+ holder = parseLockContent(readFileSync(path, 'utf-8'));
356
+ holderIno = statSync(path).ino;
357
+ }
358
+ catch {
359
+ // Lock file vanished between our failed create and this read —
360
+ // another process's release raced us; loop around and retry.
361
+ continue;
362
+ }
363
+ // A MALFORMED lock (no valid pid at all — `holder.pid === undefined`,
364
+ // e.g. a writer that crashed between the exclusive create and a
365
+ // completed write, leaving it empty/truncated) is treated the SAME as
366
+ // a confirmed-stale record, not "can't tell, wait it out" (Codex
367
+ // review, 2026-08-24, round 24): there is no legitimate content this
368
+ // module ever writes that fails to parse a valid pid, so nothing
369
+ // genuine is ever at risk of being reclaimed here.
370
+ const isStaleOrInvalid = holder.pid === undefined || isDefinitivelyStale(holder.pid, holder.bootUptimeSec);
371
+ if (isStaleOrInvalid) {
372
+ // Atomically CLAIM `path` for inspection via `renameSync`, rather
373
+ // than re-read-then-compare-then-unlink (Codex review, 2026-08-24,
374
+ // round 27, replacing round 22/24's check-then-unlink entirely): no
375
+ // matter how many times a separate read is compared before the
376
+ // unlink, there's ALWAYS a residual gap between the LAST comparison
377
+ // and the actual delete syscall — one process can pause there while
378
+ // another deletes the same stale lock and acquires its own live
379
+ // one, and the first then resumes and unlinks THAT live lock too.
380
+ // `renameSync(path, reclaimTmpPath)` is atomic and exclusive by
381
+ // construction: at most ONE process can ever successfully rename a
382
+ // given source path away at a given moment (a second attempt gets
383
+ // ENOENT, since the source is already gone) — there is no gap to
384
+ // pause in between "decided to claim it" and "actually claimed it,"
385
+ // because those are the SAME syscall.
386
+ //
387
+ // Verified to still be the SAME lock INSTANCE originally inspected,
388
+ // immediately before that rename (Codex review, 2026-08-24, round
389
+ // 29, narrowing round 27's fix further): if THIS process paused
390
+ // between its peek above and here, a DIFFERENT process could have
391
+ // completed its OWN full reclaim cycle in the meantime — deleted
392
+ // the original stale lock and installed a brand-new LIVE one at
393
+ // `path`. Renaming unconditionally would then steal that live
394
+ // process's lock instead of the stale one this process actually
395
+ // decided to reclaim. Comparing the INODE NUMBER (not just content)
396
+ // is a stronger identity check than re-reading and comparing JSON —
397
+ // immune to a coincidental content collision across two distinct
398
+ // lock generations. This narrows, but does not eliminate, the
399
+ // TOCTOU: there is still a residual gap between THIS check and the
400
+ // rename syscall itself, just now two back-to-back synchronous
401
+ // calls instead of an arbitrary pause — see this function's own
402
+ // top-level doc note on the compound race that remains.
403
+ let recheckIno;
404
+ try {
405
+ recheckIno = statSync(path).ino;
406
+ }
407
+ catch {
408
+ continue; // Already gone — someone else's reclaim or release; retry.
409
+ }
410
+ if (recheckIno !== holderIno)
411
+ continue; // A different lock instance is there now — never touch it.
412
+ const reclaimTmpPath = `${path}.reclaim-${process.pid}-${randomBytes(4).toString('hex')}`;
413
+ try {
414
+ renameSync(path, reclaimTmpPath);
415
+ }
416
+ catch {
417
+ continue; // Someone else already reclaimed or released it; retry.
418
+ }
419
+ // We now EXCLUSIVELY possess whatever was at `path` — re-inspect
420
+ // FRESH content (not the earlier `holder` peek, which could be
421
+ // stale relative to what we just claimed).
422
+ let current;
423
+ try {
424
+ current = parseLockContent(readFileSync(reclaimTmpPath, 'utf-8'));
425
+ }
426
+ catch {
427
+ current = {};
428
+ }
429
+ const stillStaleOrInvalid = current.pid === undefined || isDefinitivelyStale(current.pid, current.bootUptimeSec);
430
+ if (stillStaleOrInvalid) {
431
+ try {
432
+ unlinkSync(reclaimTmpPath);
433
+ }
434
+ catch {
435
+ // Best-effort — see the surrounding cleanup's own philosophy.
436
+ }
437
+ continue;
438
+ }
439
+ // Turned out to be LIVE after all (changed between our initial peek
440
+ // and the rename) — put it back via an EXCLUSIVE `linkSync`, never
441
+ // a blind rename-back: a THIRD process could have already created
442
+ // a brand-new lock at `path` while we held it claimed, and
443
+ // overwriting that would reintroduce the exact class of bug this
444
+ // whole rewrite exists to close. If `path` is occupied again, our
445
+ // extracted copy is simply redundant — discard it.
446
+ try {
447
+ linkSync(reclaimTmpPath, path);
448
+ }
449
+ catch {
450
+ // `path` already has something again — nothing to restore.
451
+ }
452
+ try {
453
+ unlinkSync(reclaimTmpPath);
454
+ }
455
+ catch {
456
+ // Best-effort.
457
+ }
458
+ if (Date.now() >= deadline)
459
+ return null;
460
+ sleepSync(20);
461
+ continue;
462
+ }
463
+ if (Date.now() >= deadline)
464
+ return null;
465
+ sleepSync(20);
466
+ }
467
+ }
468
+ }
469
+ function writeSidecar(cwd, state) {
470
+ const path = sidecarPath(cwd);
471
+ // Atomic, not a direct overwrite (Codex review, 2026-08-24, round 14): a
472
+ // plain `writeFileSync` on an EXISTING sidecar opens with O_TRUNC, which
473
+ // empties the file BEFORE writing a single new byte — an ENOSPC or crash
474
+ // right there leaves a truncated/corrupt sidecar, which `readSidecar`
475
+ // treats as absent. During a RECLAIM, that "absent" read then means the
476
+ // (unchanged, still genuinely ours) `.mcp.json` entry is permanently
477
+ // misclassified as foreign on every later attach — the exact class of
478
+ // bug round 10/11 fixed for the `.mcp.json` entry itself, just one file
479
+ // over from where this module was already guarding against it.
480
+ atomicWriteFileSync(path, JSON.stringify(state, null, 2) + '\n');
481
+ // Best-effort permission tightening (Codex review, 2026-08-24, round 12):
482
+ // round 11 chmod'd `.mcp.json` but missed this sidecar, which is exactly
483
+ // as readable-by-any-local-account under a typical umask. It no longer
484
+ // carries the secret itself, but it does carry the exact loopback URL of
485
+ // this operator's live proxy instance.
486
+ try {
487
+ chmodSync(path, 0o600);
488
+ }
489
+ catch {
490
+ // Best-effort — a chmod failure leaves weaker-than-ideal permissions,
491
+ // not a broken write.
492
+ }
493
+ }
494
+ /** Restores whatever the sidecar recorded BEFORE this call started, or
495
+ * deletes it if nothing was recorded yet — used to undo `writeSidecar`
496
+ * when the `.mcp.json` write that was supposed to follow it fails (Codex
497
+ * review, 2026-08-24, round 11): without this, a failed config write
498
+ * after a successful sidecar write leaves the sidecar pointing at a URL
499
+ * that was never actually applied to `.mcp.json`, permanently
500
+ * misclassifying the file's REAL (unchanged) entry as foreign on every
501
+ * later attach. Best-effort: a failed rollback just leaves the next write
502
+ * more conservative than it needs to be, never an unsafe reclaim. */
503
+ function rollbackSidecar(cwd, prior) {
504
+ try {
505
+ if (prior.proxyUrl !== undefined && prior.pid !== undefined) {
506
+ writeSidecar(cwd, { proxyUrl: prior.proxyUrl, pid: prior.pid, bootUptimeSec: prior.bootUptimeSec });
507
+ }
508
+ else {
509
+ deleteSidecar(cwd);
510
+ }
511
+ }
512
+ catch {
513
+ // Best-effort — see doc comment above.
514
+ }
515
+ }
516
+ function deleteSidecar(cwd) {
517
+ const path = sidecarPath(cwd);
518
+ if (existsSync(path)) {
519
+ try {
520
+ // `unlinkWriteTarget`, not a bare `unlinkSync(path)` (Codex review,
521
+ // 2026-08-24, round 27) — `writeSidecar` writes THROUGH a symlink at
522
+ // this path via `atomicWriteFileSync` (round 16), same as `.mcp.json`
523
+ // itself; a bare unlink here would destroy the operator's symlink
524
+ // instead of the healed/written target, the same regression round 26
525
+ // fixed for `.mcp.json` and `settings.local.json` but missed here.
526
+ unlinkWriteTarget(path);
527
+ }
528
+ catch {
529
+ // Best-effort cleanup; a leftover sidecar only ever makes the NEXT
530
+ // write more conservative (it just won't match a differing URL), it
531
+ // never causes an unsafe reclaim.
532
+ }
533
+ }
534
+ }
535
+ /** Matches ONLY the exact URL shape this module itself ever generates. */
536
+ const OWN_ENTRY_URL_PATTERN = /^http:\/\/127\.0\.0\.1:\d+\/mcp$/;
537
+ /**
538
+ * True if an existing `yolo-studio` entry's `type`/`url`/secret header still
539
+ * exactly match what this module writes — `url` against what the sidecar
540
+ * recorded, and the secret header against the fixed `SECRET_HEADER_TEMPLATE`
541
+ * this module ALWAYS writes (round 12 moved the actual per-attach secret out
542
+ * of `.mcp.json` entirely, so there's no per-attach value left to compare
543
+ * the header against — the template string itself is the invariant) — AND
544
+ * that recorded URL still has the loopback shape this module generates
545
+ * (Codex review, 2026-08-24, round 8's "both signals required" reasoning
546
+ * still applies, now expressed as sidecar-match + shape instead of marker +
547
+ * shape): the sidecar alone isn't enough, because an operator can edit the
548
+ * entry's VALUE (point it somewhere else entirely) WHILE an attachment is
549
+ * still running, without knowing a sidecar exists — a sidecar-only check
550
+ * would then have the NEXT attach overwrite that intentional edit as though
551
+ * it were stale daemon state. Requiring the CURRENT entry to still equal the
552
+ * recorded URL closes that gap: an edited entry no longer matches, so it's
553
+ * correctly left alone even with a stale sidecar record.
554
+ *
555
+ * Round 11: comparing `url` alone missed an edit to `headers` (or another
556
+ * standard field) ONLY — `type`/`url` still matched, so the predicate still
557
+ * said "ours," and cleanup deleted the operator's edited entry wholesale
558
+ * despite this module's own stated "only ever touch exactly what we wrote"
559
+ * guarantee. Now compares the secret header too, so ANY edit to the parts of
560
+ * the entry this module actually controls breaks the match.
561
+ *
562
+ * Round 13: comparing individual field VALUES still missed an ADDITION —
563
+ * an operator (or another config tool) augmenting the entry with an extra
564
+ * header or another standard transport field, while leaving `type`/`url`/
565
+ * the secret header exactly as this module wrote them, still matched every
566
+ * check above. A later attach would then silently drop that addition on
567
+ * reclaim, and detach would delete the whole (augmented) entry. Now
568
+ * requires the entry's own key set, AND its `headers`' key set, to be
569
+ * EXACTLY what this module ever writes — nothing more, nothing less.
570
+ */
571
+ function looksLikeOurOwnEntry(value, recorded) {
572
+ if (!recorded.proxyUrl || !OWN_ENTRY_URL_PATTERN.test(recorded.proxyUrl))
573
+ return false;
574
+ if (!value || typeof value !== 'object' || Array.isArray(value))
575
+ return false;
576
+ const v = value;
577
+ if (Object.keys(v).sort().join(',') !== 'headers,type,url')
578
+ return false;
579
+ if (v.type !== 'http' || v.url !== recorded.proxyUrl)
580
+ return false;
581
+ const headers = v.headers;
582
+ if (!headers || typeof headers !== 'object' || Array.isArray(headers))
583
+ return false;
584
+ const headerKeys = Object.keys(headers);
585
+ if (headerKeys.length !== 1 || headerKeys[0] !== SECRET_HEADER)
586
+ return false;
587
+ return headers[SECRET_HEADER] === SECRET_HEADER_TEMPLATE;
588
+ }
589
+ /**
590
+ * Adds the `yolo-studio` entry pointing at the local proxy. `ok: false`
591
+ * (does nothing further) if an existing `.mcp.json` can't be parsed, OR if
592
+ * a `yolo-studio` entry is ALREADY there and does NOT look like our own
593
+ * (Codex review, 2026-08-24): a hand-authored entry with that name is the
594
+ * user's own config, not ours to overwrite — and `removeLocalMcpConfig`
595
+ * only ever deletes a value that still matches what was written, so
596
+ * overwriting a genuinely foreign entry here would mean detach later
597
+ * deletes the user's own entry, not just reverts ours. An entry that DOES
598
+ * look like ours (round 6: `looksLikeOurOwnEntry`) is instead treated as a
599
+ * stale leftover from an attachment that exited uncleanly (SIGKILL, crash,
600
+ * reboot — never reached its own `removeLocalMcpConfig` call) and is
601
+ * safely overwritten with the current proxy's URL; refusing unconditionally
602
+ * here would otherwise brick local MCP access on every subsequent attach
603
+ * until the operator manually edited the file.
604
+ */
605
+ export function writeLocalMcpConfig(cwd, proxyUrl) {
606
+ const path = mcpJsonPath(cwd);
607
+ // Checks the SIDECAR's own path too, not just `.mcp.json`'s (Codex
608
+ // review, 2026-08-24, round 18): a repo's `.gitignore` naming `.mcp.json`
609
+ // specifically says nothing about `.yolobridge-mcp-state.json` — a
610
+ // filename only this module invented, that no operator would think to
611
+ // add preemptively. Without this, a repo that DID think to gitignore
612
+ // `.mcp.json` could still have the sidecar itself swept into a commit,
613
+ // exposing the exact per-attach loopback URL this whole guard exists to
614
+ // keep out of Git.
615
+ if (riskyToCommit(cwd, path) || riskyToCommit(cwd, sidecarPath(cwd))) {
616
+ return { ok: false, createdFile: false };
617
+ }
618
+ // The DESTINATION is confirmed safe above, but `atomicWriteFileSync`'s own
619
+ // `.tmp-*` temp sibling has a DIFFERENT literal name an exact-match
620
+ // `.gitignore` entry doesn't cover (Codex review, 2026-08-24, round 25) —
621
+ // see `ensureTempSiblingExcluded`'s own doc comment for why refusing the
622
+ // write instead would break every correctly-configured repo.
623
+ //
624
+ // Derived from `resolveWriteTarget`, not the lexical path (Codex review,
625
+ // 2026-08-24, round 27): when `path`/the sidecar is a symlink to a
626
+ // DIFFERENTLY-NAMED target, `atomicWriteFileSync` creates its temp
627
+ // sibling next to the RESOLVED target, not the symlink — excluding the
628
+ // symlink's own basename would cover a temp filename that's never
629
+ // actually created, leaving the REAL one (at the resolved target's name)
630
+ // just as uncovered as before this fix.
631
+ // `?? path`/`?? sidecarPath(cwd)` (Codex review, 2026-08-24, round 31):
632
+ // `resolveWriteTarget` returning `null` means the write is about to
633
+ // THROW instead of creating anything at all (see its own doc comment) —
634
+ // nothing will exist to need excluding either way, so the lexical path
635
+ // is a harmless fallback here.
636
+ const resolvedMcpJsonPath = resolveWriteTarget(path) ?? path;
637
+ const resolvedSidecarPath = resolveWriteTarget(sidecarPath(cwd)) ?? sidecarPath(cwd);
638
+ ensureTempSiblingExcluded(cwd, dirname(resolvedMcpJsonPath), `${basename(resolvedMcpJsonPath)}.tmp-*`);
639
+ ensureTempSiblingExcluded(cwd, dirname(resolvedSidecarPath), `${basename(resolvedSidecarPath)}.tmp-*`);
640
+ // The LOCK ITSELF (and its own `.claim-*`/`.reclaim-*` ephemeral siblings,
641
+ // round 26/27) got NONE of this treatment before round 28 — reasoned at
642
+ // the time that it "only exists for the duration of a single synchronous
643
+ // critical section." That reasoning doesn't hold: a crash can leave it
644
+ // behind INDEFINITELY (the exact scenario rounds 20-27 built extensive
645
+ // stale-reclaim logic to handle), and even during the brief NORMAL
646
+ // window, a concurrently-running YOLO-mode agent can `git add -A` at any
647
+ // moment. Deliberately NOT a `riskyToCommit` refusal gate like `path`/the
648
+ // sidecar above — that would require the OPERATOR to have already
649
+ // gitignored a lock filename nobody documents them ever needing to,
650
+ // bricking local MCP config in every repo that hasn't (the same
651
+ // reasoning `ensureTempSiblingExcluded`'s own doc comment already gives
652
+ // for not refusing on an uncovered temp-sibling name). Proactively making
653
+ // the lock's exact name (and its own ephemeral siblings) actually
654
+ // git-ignored, the same way the temp-sibling gap was closed, needs no
655
+ // such refusal at all.
656
+ ensureTempSiblingExcluded(cwd, dirname(lockPath(cwd)), basename(lockPath(cwd)));
657
+ ensureTempSiblingExcluded(cwd, dirname(lockPath(cwd)), `${basename(lockPath(cwd))}.claim-*`);
658
+ ensureTempSiblingExcluded(cwd, dirname(lockPath(cwd)), `${basename(lockPath(cwd))}.reclaim-*`);
659
+ // Serializes the whole read-check-write sequence below across PROCESSES,
660
+ // not just within one (Codex review, 2026-08-24, round 20) — see
661
+ // `acquireConfigLock`'s doc comment for the race this closes.
662
+ const releaseLock = acquireConfigLock(cwd);
663
+ if (!releaseLock)
664
+ return { ok: false, createdFile: false };
665
+ try {
666
+ return writeLocalMcpConfigLocked(cwd, proxyUrl, path);
667
+ }
668
+ finally {
669
+ releaseLock();
670
+ }
671
+ }
672
+ function writeLocalMcpConfigLocked(cwd, proxyUrl, path) {
673
+ const createdFile = !existsSync(path);
674
+ let config;
675
+ try {
676
+ config = readConfig(path);
677
+ }
678
+ catch {
679
+ return { ok: false, createdFile: false };
680
+ }
681
+ // `config.mcpServers` gets the SAME root-validation treatment as the file
682
+ // itself (Codex review, 2026-08-24): the old `typeof === 'object'` check
683
+ // also accepts an array (assigning SERVER_NAME onto it is then silently
684
+ // dropped by JSON.stringify -- this would have returned `true` while
685
+ // writing nothing), and silently replaced a PRIMITIVE mcpServers value
686
+ // (e.g. a string) with a fresh `{}`, discarding it. Present-but-invalid
687
+ // is refused, same as an invalid root; only ABSENT defaults to `{}`.
688
+ if ('mcpServers' in config && (config.mcpServers === null || typeof config.mcpServers !== 'object' || Array.isArray(config.mcpServers))) {
689
+ return { ok: false, createdFile: false };
690
+ }
691
+ const servers = (config.mcpServers ?? {});
692
+ const priorSidecar = readSidecar(cwd);
693
+ if (SERVER_NAME in servers) {
694
+ if (!looksLikeOurOwnEntry(servers[SERVER_NAME], priorSidecar))
695
+ return { ok: false, createdFile: false };
696
+ // A content match alone doesn't distinguish a crashed attach's stale
697
+ // leftover from a SIBLING attach that's still live in the same
698
+ // directory — a real, supported scenario elsewhere in this codebase
699
+ // (Codex review, 2026-08-24, round 19). Reclaiming a live sibling's
700
+ // entry would point the shared `.mcp.json` at the WRONG proxy for
701
+ // whichever one wrote it first, and this attach's own later detach
702
+ // could delete the entry out from under that still-running daemon.
703
+ // `priorSidecar.pid` is guaranteed defined here — `looksLikeOurOwnEntry`
704
+ // already required it for the match above to succeed. `isDefinitivelyStale`
705
+ // (round 23) additionally recognizes a machine reboot since the sidecar
706
+ // was written as proof the recorded pid can't be this same sibling,
707
+ // regardless of what a bare `isPidAlive` reports for whoever holds that
708
+ // pid number now.
709
+ if (priorSidecar.pid !== undefined && !isDefinitivelyStale(priorSidecar.pid, priorSidecar.bootUptimeSec))
710
+ return { ok: false, createdFile: false };
711
+ }
712
+ // Sidecar written BEFORE the `.mcp.json` entry itself (Codex review,
713
+ // 2026-08-24, round 10): the original order wrote the entry first, so a
714
+ // sidecar-write failure (e.g. its path collides with a directory, or
715
+ // storage fills between the two writes) left a `yolo-studio` entry
716
+ // already persisted with nothing recording it as ours. The caller never
717
+ // sees `ok: true` in that case, so it never records `mcpConfigCleanup`
718
+ // and can't clean the entry up on detach — and no LATER attach could
719
+ // reclaim it either, since `looksLikeOurOwnEntry` requires a matching
720
+ // sidecar record that was never written. Permanently stranded. Writing
721
+ // the sidecar first means a failure here leaves `.mcp.json` completely
722
+ // untouched — nothing to roll back.
723
+ try {
724
+ writeSidecar(cwd, { proxyUrl, pid: process.pid, bootUptimeSec: uptime() });
725
+ }
726
+ catch {
727
+ return { ok: false, createdFile: false };
728
+ }
729
+ // Plain, spec-shaped entry — no ownership marker inside it (round 9): an
730
+ // unknown field here is exactly what a strict-validating Claude Code
731
+ // release rejects the whole server entry over. `headers` IS a standard
732
+ // field for an http-type entry (Claude Code's own docs; this repo's
733
+ // pod-side writer emits the identical shape,
734
+ // containers/services/container-api/mcp-config-writer.js:191). Its value
735
+ // is a `${VAR}` TEMPLATE, not the actual secret (round 12 — see
736
+ // `SECRET_HEADER_TEMPLATE`'s doc comment): the real per-attach secret the
737
+ // proxy requires on every request never touches this (often git-tracked)
738
+ // file.
739
+ servers[SERVER_NAME] = { type: 'http', url: proxyUrl, headers: { [SECRET_HEADER]: SECRET_HEADER_TEMPLATE } };
740
+ config.mcpServers = servers;
741
+ try {
742
+ // Atomic (temp file + rename), not a direct overwrite (Codex review,
743
+ // 2026-08-24, round 13): a direct `writeFileSync` on an EXISTING file
744
+ // truncates it before writing the new bytes, so ENOSPC or a crash
745
+ // mid-write can leave the OPERATOR's file half-written — unrecoverable,
746
+ // unlike every other failure mode this function already refuses to
747
+ // touch the file for.
748
+ atomicWriteFileSync(path, JSON.stringify(config, null, 2) + '\n');
749
+ }
750
+ catch {
751
+ // The sidecar-write-first ordering above is only harmless in the "no
752
+ // prior entry existed" case (round 10's own reasoning). When RECLAIMING
753
+ // a stale entry (`SERVER_NAME in servers` above), the sidecar already
754
+ // held a valid record matching the entry still on disk — round 11:
755
+ // overwriting it with the NEW url and then failing here would strand
756
+ // that valid record too, permanently misclassifying the (unchanged)
757
+ // on-disk entry as foreign. Roll back to whatever was there before this
758
+ // call.
759
+ rollbackSidecar(cwd, priorSidecar);
760
+ return { ok: false, createdFile: false };
761
+ }
762
+ // Best-effort permission tightening (Codex review, 2026-08-24, round 11;
763
+ // no longer strictly about the secret since round 12 moved that out of
764
+ // this file — kept as defense-in-depth against exposing the loopback
765
+ // port/URL itself to another local account). `writeFileSync`'s default
766
+ // mode only applies at file CREATION — an EXISTING file keeps whatever
767
+ // permissions it already had (commonly 0644/0664 under a typical umask).
768
+ // A chmod failure here (e.g. an FS that doesn't support it) does not roll
769
+ // back the write above: the entry and sidecar are already consistent
770
+ // with each other, just left at weaker-than-ideal permissions rather than
771
+ // an unrecoverable state.
772
+ try {
773
+ chmodSync(path, 0o600);
774
+ }
775
+ catch {
776
+ // Best-effort — see comment above.
777
+ }
778
+ return { ok: true, createdFile };
779
+ }
780
+ /**
781
+ * Removes exactly the `yolo-studio` entry this module added — but ONLY if
782
+ * its value still matches exactly what `writeLocalMcpConfig` wrote
783
+ * (`expectedProxyUrl`, the same one passed to that call) — Codex review,
784
+ * 2026-08-24, round 5: over a long-running attachment, the operator (or
785
+ * another `claude mcp add`/hand edit) could replace that entry with
786
+ * something else entirely; blind deletion keyed only on "did WE create
787
+ * this key originally" would destroy that newer, unrelated edit too. A
788
+ * changed value is left completely alone, matching an unparseable file's
789
+ * treatment — this function only ever removes the EXACT thing it added.
790
+ *
791
+ * If that leaves `.mcp.json` with no `mcpServers` entries and nothing else
792
+ * in the file, deletes the file entirely — but ONLY when `createdFile`
793
+ * (from `writeLocalMcpConfig`'s own return) says THIS attachment is the one
794
+ * that created it. Codex review, 2026-08-24, round 6: emptiness alone isn't
795
+ * proof of that — a repo that already had an empty `.mcp.json` or
796
+ * `{"mcpServers":{}}` looks identical, once our entry is removed, to one
797
+ * this module created from scratch, and unlinking it would delete a file
798
+ * the operator already had. When it's empty but NOT ours to delete, the
799
+ * (now-empty-of-our-stuff) config is written back instead, same as any
800
+ * other "file had other content" case.
801
+ *
802
+ * The sidecar (round 9) is only ever deleted when it still records exactly
803
+ * `expectedProxyUrl` — the same re-verify-before-touching discipline as the
804
+ * `.mcp.json` entry itself, so a concurrent sibling attach that already
805
+ * overwrote the sidecar with ITS OWN newer URL is never clobbered here.
806
+ */
807
+ export function removeLocalMcpConfig(cwd, expectedProxyUrl, createdFile) {
808
+ // Best-effort — never a refusal gate here, unlike `writeLocalMcpConfig`'s
809
+ // OWN `riskyToCommit` checks (Codex review, 2026-08-24, round 28): this
810
+ // function's whole job is best-effort CLEANUP, so blocking it over a
811
+ // risky lock path would be strictly worse than proceeding without this
812
+ // extra protection (a stale `.mcp.json` entry left behind is recoverable;
813
+ // skipping cleanup entirely isn't a safer outcome). Idempotent regardless
814
+ // — a repo where `writeLocalMcpConfig` already succeeded once already has
815
+ // these patterns; this only matters for the (unlikely but possible) case
816
+ // where this lock gets created for the very first time via a detach path.
817
+ ensureTempSiblingExcluded(cwd, dirname(lockPath(cwd)), basename(lockPath(cwd)));
818
+ ensureTempSiblingExcluded(cwd, dirname(lockPath(cwd)), `${basename(lockPath(cwd))}.claim-*`);
819
+ ensureTempSiblingExcluded(cwd, dirname(lockPath(cwd)), `${basename(lockPath(cwd))}.reclaim-*`);
820
+ // Same cross-process lock `writeLocalMcpConfig` takes (Codex review,
821
+ // 2026-08-24, round 20) — a concurrent attach's read-check-write could
822
+ // otherwise interleave with this read-modify-write of the same file. A
823
+ // lock we can't acquire degrades to leaving the file untouched, same as
824
+ // every other refusal path below: a missed cleanup is recoverable, an
825
+ // interleaved write that corrupts a sibling's entry is not.
826
+ const releaseLock = acquireConfigLock(cwd);
827
+ if (!releaseLock)
828
+ return;
829
+ try {
830
+ removeLocalMcpConfigLocked(cwd, expectedProxyUrl, createdFile);
831
+ }
832
+ finally {
833
+ releaseLock();
834
+ }
835
+ }
836
+ function removeLocalMcpConfigLocked(cwd, expectedProxyUrl, createdFile) {
837
+ const path = mcpJsonPath(cwd);
838
+ if (!existsSync(path)) {
839
+ if (readSidecar(cwd).proxyUrl === expectedProxyUrl)
840
+ deleteSidecar(cwd);
841
+ return;
842
+ }
843
+ let config;
844
+ try {
845
+ config = readConfig(path);
846
+ }
847
+ catch {
848
+ // Can't safely edit a file we can't parse — leave it alone.
849
+ return;
850
+ }
851
+ const servers = (config.mcpServers && typeof config.mcpServers === 'object' ? config.mcpServers : {});
852
+ const current = servers[SERVER_NAME];
853
+ const recordedSidecar = readSidecar(cwd);
854
+ if (!current || current.type !== 'http' || current.url !== expectedProxyUrl || !looksLikeOurOwnEntry(current, recordedSidecar))
855
+ return;
856
+ delete servers[SERVER_NAME];
857
+ const hasOtherServers = Object.keys(servers).length > 0;
858
+ const otherTopLevelKeys = Object.keys(config).filter((k) => k !== 'mcpServers');
859
+ if (createdFile && !hasOtherServers && otherTopLevelKeys.length === 0) {
860
+ // `unlinkWriteTarget`, not a bare `unlinkSync(path)` (Codex review,
861
+ // 2026-08-24, round 26) — see its own doc comment for why: `path` can
862
+ // be a symlink `atomicWriteFileSync` healed rather than the plain file
863
+ // `createdFile` implies.
864
+ unlinkWriteTarget(path);
865
+ if (recordedSidecar.proxyUrl === expectedProxyUrl)
866
+ deleteSidecar(cwd);
867
+ return;
868
+ }
869
+ config.mcpServers = servers;
870
+ // Atomic (temp file + rename) — see the doc comment on the equivalent
871
+ // write in `writeLocalMcpConfig` (Codex review, 2026-08-24, round 13): a
872
+ // direct overwrite could leave the operator's other, unrelated content
873
+ // in this file half-written on an ENOSPC or crash.
874
+ atomicWriteFileSync(path, JSON.stringify(config, null, 2) + '\n');
875
+ if (recordedSidecar.proxyUrl === expectedProxyUrl)
876
+ deleteSidecar(cwd);
877
+ }