@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.
- package/dist/atomic-write.js +297 -0
- package/dist/attach-cmd.js +74 -3
- package/dist/cli.js +285 -44
- package/dist/device-auth.js +10 -9
- package/dist/git-safety.js +151 -0
- package/dist/local-mcp-config.js +877 -0
- package/dist/local-mcp-trust.js +371 -0
- package/dist/login-cmd.js +12 -4
- package/dist/mcp-proxy.js +590 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|