@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,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
|
+
}
|
package/dist/attach-cmd.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|