@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,297 @@
1
+ /**
2
+ * Writes `content` to `path` via a temp sibling file + atomic rename (Codex
3
+ * review, 2026-08-24, round 13): a direct `writeFileSync` on an EXISTING
4
+ * file truncates it before writing the new bytes, so a process crash,
5
+ * ENOSPC, or any other failure mid-write can leave the file half-written or
6
+ * empty — there is no way to recover the operator's original content from
7
+ * that state, unlike every OTHER failure `local-mcp-config.ts` and
8
+ * `local-mcp-trust.ts` already guard against (which all leave the ORIGINAL
9
+ * file untouched on failure — see e.g. `readConfig`'s malformed-JSON
10
+ * handling). `renameSync` within the same directory is atomic on POSIX
11
+ * filesystems (a single inode-table update, no partial-rename state
12
+ * observable by another process), so a reader always sees either the
13
+ * complete old file or the complete new one, never a partial write.
14
+ *
15
+ * The temp name includes the PID and a random suffix so two attach
16
+ * processes writing into the SAME project directory concurrently (a
17
+ * scenario this codebase already guards against elsewhere — concurrent
18
+ * sibling attach) never collide on the same temp path.
19
+ *
20
+ * Preserves the DESTINATION's existing permissions across the replacement
21
+ * (Codex review, 2026-08-24, round 14): a brand-new temp file is created
22
+ * with the process's default umask, and `renameSync` replaces the
23
+ * destination's inode entirely — it does not carry over the ORIGINAL
24
+ * file's mode. Without this, overwriting an EXISTING file that had been
25
+ * deliberately tightened (`.mcp.json`'s 0600 from round 11) would silently
26
+ * widen it back to whatever the umask gives (commonly 0644/0664) on every
27
+ * subsequent write, quietly undoing that fix through this one. When `path`
28
+ * doesn't exist yet, there is no permission to preserve — the new file
29
+ * gets the process's normal default, same as any other file creation (a
30
+ * caller that wants a specific mode on first create, like `.mcp.json`'s
31
+ * 0600, chmods explicitly afterward, same as before this change).
32
+ *
33
+ * Writes THROUGH a symlink at `path` instead of over it (Codex review,
34
+ * 2026-08-24, round 16): `renameSync` replaces whatever directory entry is
35
+ * AT `path`, symlink or not — a caller with `.mcp.json` symlinked in from a
36
+ * dotfiles manager (`stow`/`chezmoi`/a hand-made symlink) would have that
37
+ * symlink permanently destroyed and replaced with a plain file on the
38
+ * FIRST write, with no way back. Resolving to the real target first and
39
+ * writing/renaming there instead leaves the symlink itself untouched,
40
+ * still pointing at the same place. A broken symlink (target doesn't
41
+ * exist) falls back to writing at `path` directly — the same "create a
42
+ * plain file there" behavior this function already had before this fix,
43
+ * not a new regression.
44
+ */
45
+ import { writeFileSync, renameSync, unlinkSync, existsSync, statSync, chmodSync, lstatSync, realpathSync, readdirSync, readlinkSync } from 'node:fs';
46
+ import { dirname, basename, join, isAbsolute } from 'node:path';
47
+ import { randomBytes } from 'node:crypto';
48
+ /** Exported for `git-safety.ts` (Codex review, 2026-08-24, round 21): the
49
+ * git-ignore check must validate the SAME real target this function is
50
+ * about to write through, not just the (possibly symlinked) path the
51
+ * caller named — see that module's doc comment for the exact gap this
52
+ * closes.
53
+ *
54
+ * Resolves a symlinked PARENT DIRECTORY too, not just `path`'s own final
55
+ * component (Codex review, 2026-08-24, round 24): `lstatSync(path)` only
56
+ * reports whether the FINAL path segment is a symlink — an intermediate
57
+ * ancestor directory (e.g. `.claude` itself symlinked elsewhere) is
58
+ * transparently followed by every normal fs call (`writeFileSync`,
59
+ * `renameSync`, ...) but was invisible to this function, which returned
60
+ * the untouched LEXICAL path. `git check-ignore` on that lexical path then
61
+ * fails with "is beyond a symbolic link" (status 128, the same code this
62
+ * module already treats as a safe degrade for "outside the repository
63
+ * entirely") — reporting safe while the actual write still traverses the
64
+ * symlink and can land in a TRACKED file the git-ignore check never
65
+ * actually validated. `realpathSync` on the PARENT resolves the whole
66
+ * ancestor chain in one call; the file's own possible symlink-ness (round
67
+ * 16) is still resolved separately afterward, starting from that already-
68
+ * parent-resolved path. A parent that doesn't exist yet (nothing has been
69
+ * written here before) has no symlink layer to resolve either — falls
70
+ * back to the lexical path, same as before this fix, not a regression. */
71
+ /**
72
+ * Returns `null` specifically when `path` is a BROKEN symlink whose
73
+ * intended target's own parent directory ALSO doesn't exist — Codex
74
+ * review, 2026-08-24, round 31, correcting round 25's own fix: a real
75
+ * `writeFileSync` through such a symlink THROWS `ENOENT` and leaves the
76
+ * symlink completely untouched (verified empirically, not assumed — a
77
+ * symlink to `<missing-dir>/target.json` really does fail to open rather
78
+ * than silently falling back to writing at the symlink's own path).
79
+ * Falling back to the symlink's OWN path here (round 25's original
80
+ * behavior) instead let the caller's subsequent `renameSync` REPLACE the
81
+ * symlink with a plain file — worse than what this is supposed to
82
+ * degrade to, and the exact symlink-destroying regression round 16 exists
83
+ * to prevent, reintroduced for this one sub-case. Every other caller
84
+ * (`atomicWriteFileSync`, `riskyToCommit`, `unlinkWriteTarget`) must treat
85
+ * `null` as "cannot resolve — do not write through this symlink."
86
+ */
87
+ export function resolveWriteTarget(path) {
88
+ let realDir;
89
+ try {
90
+ realDir = realpathSync(dirname(path));
91
+ }
92
+ catch {
93
+ realDir = dirname(path); // Parent doesn't exist yet — nothing to resolve.
94
+ }
95
+ const parentResolvedPath = join(realDir, basename(path));
96
+ try {
97
+ if (!lstatSync(parentResolvedPath).isSymbolicLink())
98
+ return parentResolvedPath;
99
+ }
100
+ catch {
101
+ return parentResolvedPath; // Doesn't exist yet — nothing further to resolve.
102
+ }
103
+ try {
104
+ return realpathSync(parentResolvedPath);
105
+ }
106
+ catch {
107
+ // Broken symlink (its target doesn't exist YET) — resolve the link
108
+ // LEXICALLY via `readlinkSync` instead of giving up and writing over
109
+ // the symlink itself (Codex review, 2026-08-24, round 25): the
110
+ // ORIGINAL, pre-round-13 direct `writeFileSync` followed a symlink and
111
+ // CREATED its missing target when the target's own parent directory
112
+ // existed. A relative link target is resolved against the symlink's
113
+ // OWN directory, matching `readlink`'s documented semantics.
114
+ try {
115
+ const linkTarget = readlinkSync(parentResolvedPath);
116
+ const healedTarget = isAbsolute(linkTarget) ? linkTarget : join(dirname(parentResolvedPath), linkTarget);
117
+ // Only "heal" it if the intended target's OWN parent directory
118
+ // exists — the same constraint a plain `writeFileSync` would have
119
+ // been bound by too (it can't create a file in a directory that
120
+ // doesn't exist either).
121
+ if (existsSync(dirname(healedTarget)))
122
+ return healedTarget;
123
+ }
124
+ catch {
125
+ // `readlinkSync` failing means `parentResolvedPath` isn't actually a
126
+ // symlink after all (raced since the `lstatSync` check above) — fall
127
+ // through to the same "cannot resolve" signal.
128
+ }
129
+ return null;
130
+ }
131
+ }
132
+ /**
133
+ * Deletes the file DATA at `path` without ever deleting a symlink the
134
+ * operator placed there (Codex review, 2026-08-24, round 26): a full
135
+ * cleanup delete (`createdFile && now empty`, in both `local-mcp-config.ts`
136
+ * and `local-mcp-trust.ts`) previously always `unlinkSync(path)`'d the
137
+ * LEXICAL path. For a broken symlink `atomicWriteFileSync` healed (round
138
+ * 25), that path IS the symlink itself — `createdFile` was computed from
139
+ * `!existsSync(path)`, which is true for exactly this case since the
140
+ * broken symlink's target didn't exist yet — so this would delete the
141
+ * operator's OWN symlink and leave the newly-created (now orphaned) target
142
+ * behind, destroying something this module never owned: the exact
143
+ * regression round 25 exists to prevent, just on the CLEANUP side instead
144
+ * of the write side. Resolves through the SAME symlink-following logic
145
+ * `atomicWriteFileSync` itself uses before deleting, so cleanup can never
146
+ * diverge from what the write actually touched. A plain, non-symlink path
147
+ * (the common case) is unaffected — this degrades to a bare `unlinkSync`.
148
+ */
149
+ export function unlinkWriteTarget(path) {
150
+ let target = path;
151
+ try {
152
+ if (lstatSync(path).isSymbolicLink()) {
153
+ // `null` (Codex review, 2026-08-24, round 31) means
154
+ // `resolveWriteTarget` couldn't resolve a real target to delete
155
+ // instead — degrade to the symlink's own path, the same as every
156
+ // other "can't figure it out" case this function already falls
157
+ // back to below.
158
+ target = resolveWriteTarget(path) ?? path;
159
+ }
160
+ }
161
+ catch {
162
+ // Race: `path` vanished before this lstat — fall through to the
163
+ // original `path` (unlinkSync then simply no-ops/throws ENOENT, same
164
+ // as before this fix).
165
+ }
166
+ unlinkSync(target);
167
+ }
168
+ function escapeRegExp(s) {
169
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
170
+ }
171
+ /** Mirrors `local-mcp-config.ts`'s own `isPidAlive` (kept as an independent
172
+ * copy — see that module's own doc comment on why these stay separately
173
+ * usable/testable): `process.kill(pid, 0)` sends no actual signal, just
174
+ * probes. ESRCH = no such process (dead); EPERM = exists but no
175
+ * permission to signal (still alive); anything else fails closed as
176
+ * "alive," since this function's only job is to catch a CONFIRMED-dead
177
+ * writer, never to guess one into existence. */
178
+ function isPidAlive(pid) {
179
+ try {
180
+ process.kill(pid, 0);
181
+ return true;
182
+ }
183
+ catch (err) {
184
+ return err.code === 'EPERM';
185
+ }
186
+ }
187
+ /**
188
+ * Best-effort removal of a temp sibling THIS function itself could have
189
+ * left behind from a PRIOR call that crashed between creating it and
190
+ * either renaming or cleaning it up (Codex review, 2026-08-24, round 24) —
191
+ * see the temp-file-permissions doc comment on `atomicWriteFileSync` for
192
+ * the exposure this narrows.
193
+ *
194
+ * Matches the EXACT generated shape (`<name>.tmp-<pid>-<8 hex chars>`), not
195
+ * a bare prefix (Codex review, 2026-08-24, round 28): a prefix-only check
196
+ * would misclassify an OPERATOR-OWNED sibling that merely happens to start
197
+ * the same way (e.g. a hand-made `.mcp.json.tmp-backup`) as this module's
198
+ * own leftover and irreversibly delete it.
199
+ *
200
+ * Also extracts the embedded pid from a shape-matching name and skips it
201
+ * when that pid is still ALIVE (round 28): this same sweep runs at the
202
+ * start of every `atomicWriteFileSync` call, including one from a
203
+ * GENUINELY CONCURRENT writer to the same destination on an unguarded path
204
+ * (`local-mcp-trust.ts`'s writes aren't behind `local-mcp-config.ts`'s own
205
+ * cross-process lock) — without this, one process's sweep could delete
206
+ * ANOTHER process's still-being-written temp file out from under it.
207
+ */
208
+ function sweepStaleTempSiblings(targetPath) {
209
+ const dir = dirname(targetPath);
210
+ const pattern = new RegExp(`^${escapeRegExp(basename(targetPath))}\\.tmp-(\\d+)-[0-9a-f]{8}$`);
211
+ let entries;
212
+ try {
213
+ entries = readdirSync(dir);
214
+ }
215
+ catch {
216
+ return; // Directory doesn't exist (nothing written here yet) — nothing to sweep.
217
+ }
218
+ for (const name of entries) {
219
+ const match = pattern.exec(name);
220
+ if (!match)
221
+ continue;
222
+ const writerPid = Number(match[1]);
223
+ if (Number.isInteger(writerPid) && writerPid >= 1 && isPidAlive(writerPid))
224
+ continue; // Still being written by a live process — never touch it.
225
+ try {
226
+ unlinkSync(join(dir, name));
227
+ }
228
+ catch {
229
+ // Best-effort — a leftover temp file is only ever a tighter-than-this-
230
+ // call's-own risk window, never a correctness problem for THIS write.
231
+ }
232
+ }
233
+ }
234
+ export function atomicWriteFileSync(path, content) {
235
+ const targetPath = resolveWriteTarget(path);
236
+ if (targetPath === null) {
237
+ // Matches what a plain `writeFileSync` through this exact symlink
238
+ // shape would do (Codex review, 2026-08-24, round 31) — see
239
+ // `resolveWriteTarget`'s own doc comment. Throwing here, rather than
240
+ // writing through/over the symlink, is what keeps it untouched.
241
+ const err = new Error(`ENOENT: no such file or directory, open '${path}'`);
242
+ err.code = 'ENOENT';
243
+ throw err;
244
+ }
245
+ // Clears out anything a PRIOR crashed call left behind before adding a
246
+ // new one — see `sweepStaleTempSiblings`'s own doc comment.
247
+ sweepStaleTempSiblings(targetPath);
248
+ const tmpPath = `${targetPath}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
249
+ try {
250
+ // Owner-only from the moment of CREATION (Codex review, 2026-08-24,
251
+ // round 24), not after a separate chmod below: `writeFileSync`'s
252
+ // default mode (subject to the process umask, commonly 0644/0664) would
253
+ // otherwise leave a window — between this call returning and the
254
+ // `chmodSync` a few lines down — where a crash or SIGKILL leaves a
255
+ // WORLD-READABLE copy of the full new content (which, for an EXISTING
256
+ // destination being overwritten, is the operator's complete file, not
257
+ // just this module's own fragment) sitting on disk under a temp name
258
+ // `riskyToCommit` never validated on its own.
259
+ writeFileSync(tmpPath, content, { encoding: 'utf-8', mode: 0o600 });
260
+ let existingMode;
261
+ try {
262
+ existingMode = statSync(targetPath).mode & 0o777;
263
+ }
264
+ catch {
265
+ // `targetPath` doesn't exist yet — nothing to preserve.
266
+ }
267
+ if (existingMode !== undefined) {
268
+ chmodSync(tmpPath, existingMode);
269
+ }
270
+ else {
271
+ // No prior file to preserve permissions from — widen back to the
272
+ // process's NORMAL default (umask-derived) mode right before the
273
+ // rename, matching a plain `writeFileSync` with no explicit mode
274
+ // (same behavior this module already guaranteed pre-round-24 — see
275
+ // the "brand-new file" test below). The 0600 above only needs to
276
+ // hold DURING the write itself to close the crash-exposure window;
277
+ // a caller that never asked for owner-only on a brand-new file (e.g.
278
+ // `local-mcp-trust.ts`'s `settings.local.json`, which has no explicit
279
+ // chmod of its own) must not have that silently imposed on it as a
280
+ // side effect of this fix.
281
+ chmodSync(tmpPath, 0o666 & ~process.umask());
282
+ }
283
+ renameSync(tmpPath, targetPath);
284
+ }
285
+ catch (err) {
286
+ // Best-effort: don't leave a stray temp file behind on failure.
287
+ if (existsSync(tmpPath)) {
288
+ try {
289
+ unlinkSync(tmpPath);
290
+ }
291
+ catch {
292
+ // Best-effort.
293
+ }
294
+ }
295
+ throw err;
296
+ }
297
+ }
@@ -44,6 +44,7 @@ export async function runAttachDaemon(deps) {
44
44
  const shouldStop = deps.shouldStop ?? (() => false);
45
45
  const sleep = deps.sleep ?? defaultSleep;
46
46
  const log = deps.log ?? ((line) => process.stdout.write(`${line}\n`));
47
+ const clearScreen = deps.clearScreen ?? (() => process.stdout.write('\x1b[2J\x1b[3J\x1b[H'));
47
48
  const deliverPrompt = deps.deliverPrompt ?? deliverPromptToLocalAgent;
48
49
  const captureOutput = deps.captureOutput ?? captureLocalAgentOutput;
49
50
  const timers = deps.timers ?? defaultTimers;
@@ -114,10 +115,52 @@ export async function runAttachDaemon(deps) {
114
115
  // it believes the onExit path already handled detaching). Catch it here,
115
116
  // right after this call is the one that created it, so there's exactly
116
117
  // one place responsible for cleaning up what it made.
118
+ //
119
+ // Checked BEFORE `onAttached`, not just after (Codex review, 2026-08-24,
120
+ // round 25): `onAttached` can spend a real, possibly-many-second delay
121
+ // minting MCP credentials and starting a local proxy — running all of
122
+ // that for an attachment that's already guaranteed to be torn down the
123
+ // moment it returns makes a Ctrl+C feel like it did nothing for however
124
+ // long that setup takes. This check alone doesn't replace the one AFTER
125
+ // `onAttached` below — a stop can just as easily arrive WHILE that hook
126
+ // is still running, not only before it starts.
117
127
  if (shouldStop()) {
118
- await apiClient.detach(cfg, workspaceId, attachmentId).catch((err) => {
128
+ return detachAndReportStopped();
129
+ }
130
+ if (deps.onAttached) {
131
+ try {
132
+ await deps.onAttached({
133
+ tileId, attachmentId, workspaceId,
134
+ accessToken: currentAuth.accessToken,
135
+ getAccessToken: () => currentAuth.accessToken,
136
+ clearScreen,
137
+ });
138
+ }
139
+ catch (err) {
140
+ log(`onAttached hook failed: ${err instanceof Error ? err.message : String(err)}`);
141
+ }
142
+ }
143
+ if (shouldStop()) {
144
+ return detachAndReportStopped();
145
+ }
146
+ async function detachAndReportStopped() {
147
+ try {
148
+ await apiClient.detach(cfg, workspaceId, attachmentId);
149
+ }
150
+ catch (err) {
151
+ // Only clear `attachment.json` on a SUCCESSFUL (or already-gone —
152
+ // `apiClient.detach` itself treats a 404 as success) detach, not on
153
+ // a genuine failure (Codex review, 2026-08-24, round 26) — mirrors
154
+ // `runDetach`'s own established pattern. Clearing it unconditionally
155
+ // would strand the server-side attachment permanently: the caller's
156
+ // own post-return retry (`cli.ts`'s `if (stopRequested &&
157
+ // !localAgentExited) { await runDetach(...) }`) and a manual `yolo-
158
+ // bridge detach` both rely on `attachment.json` to know what to
159
+ // retry against, and the next `attach` would then create a SECOND
160
+ // server-side attachment/tile instead of ever cleaning up the first.
119
161
  log(`Cleanup detach failed: ${err instanceof Error ? err.message : String(err)}`);
120
- });
162
+ return { ok: true, reason: 'stopped' };
163
+ }
121
164
  clearAttachment(env, io);
122
165
  return { ok: true, reason: 'stopped' };
123
166
  }
@@ -136,6 +179,16 @@ export async function runAttachDaemon(deps) {
136
179
  break;
137
180
  }
138
181
  let sawDetached = false;
182
+ /** Set when the SSE stream itself (or any other call in this attempt)
183
+ * 404s -- the attachment/workspace no longer exists server-side
184
+ * (Codex review, 2026-08-24, round 4). Without this, a 404 fell
185
+ * through to the SAME backoff-and-retry path as a transient network
186
+ * error and looped FOREVER: `onAttached` now runs a bounded but real
187
+ * MCP-setup delay (up to STARTUP_MINT_TIMEOUT_MS) BEFORE the first
188
+ * `openStream` call, wide enough for the tile to be removed/detached
189
+ * server-side in that window with no stream open yet to receive the
190
+ * `detached` frame that would normally end this loop cleanly. */
191
+ let sawGone = false;
139
192
  try {
140
193
  const res = await apiClient.openStream(cfg, workspaceId, attachmentId);
141
194
  attempt = 0; // reset backoff on a successful connect
@@ -177,6 +230,22 @@ export async function runAttachDaemon(deps) {
177
230
  const action = actionForFrame(frame);
178
231
  switch (action.kind) {
179
232
  case 'connected':
233
+ // Deliberately NOT clearing here (reverted -- Codex
234
+ // review, 2026-08-24). The original reasoning was "the
235
+ // agent's own PTY was already started by cli.ts before
236
+ // this stream even began connecting, but its first
237
+ // rendered output consistently lands after this point in
238
+ // practice" -- that held for a cold Claude Code boot but
239
+ // not in general: a fast-booting `--agent`, or a slow SSE
240
+ // connect, can render BEFORE 'connected' ever arrives,
241
+ // and clearing after that wipes content straight off this
242
+ // process's stdout (not through the agent's PTY, which
243
+ // has no idea it needs to repaint) -- an apparently-blank
244
+ // session, not a clean one. `onAttached`'s `clearScreen`
245
+ // (see AttachDaemonDeps) now fires deterministically
246
+ // right before `startLocalAgent`, the one moment
247
+ // guaranteed to be before any agent output regardless of
248
+ // either timing race.
180
249
  log('Stream connected.');
181
250
  heartbeat?.stop();
182
251
  heartbeat = startHeartbeat(async () => {
@@ -223,10 +292,12 @@ export async function runAttachDaemon(deps) {
223
292
  }
224
293
  catch (err) {
225
294
  log(`Stream error: ${err instanceof Error ? err.message : String(err)}`);
295
+ if (err instanceof apiClient.YoloBridgeApiError && err.status === 404)
296
+ sawGone = true;
226
297
  }
227
298
  heartbeat?.stop();
228
299
  heartbeat = undefined;
229
- if (sawDetached) {
300
+ if (sawDetached || sawGone) {
230
301
  clearAttachment(env, io);
231
302
  return { ok: true, reason: 'detached-by-server' };
232
303
  }