@yolo-labs/yolobridge 0.1.0 → 0.7.1
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/api-client.js +88 -3
- package/dist/atomic-write.js +297 -0
- package/dist/attach-cmd.js +584 -40
- package/dist/cli.js +335 -43
- package/dist/config-store.js +58 -4
- package/dist/connection-state.js +108 -0
- package/dist/detach-cmd.js +42 -4
- 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 +591 -0
- package/dist/status-cmd.js +30 -0
- package/package.json +1 -1
package/dist/api-client.js
CHANGED
|
@@ -29,12 +29,40 @@ async function parseErrorBody(res) {
|
|
|
29
29
|
return { message: `HTTP ${res.status}` };
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
-
|
|
32
|
+
/**
|
|
33
|
+
* `POST /v1/workspaces/:workspaceId/yolobridge/attach`.
|
|
34
|
+
*
|
|
35
|
+
* `scopedToken` / `scopedTokenExpiresAt` are the workspace-scoped daemon
|
|
36
|
+
* credential common-api mints at attach
|
|
37
|
+
* (`docs/YOLOBRIDGE_SCOPED_CREDENTIAL_PLAN.md`): a token confined to THIS
|
|
38
|
+
* workspace's YoloBridge surface, which the post-attach calls
|
|
39
|
+
* (`detach`/`openStream`/`postHeartbeat`/`postReadOutputReply`) use instead of
|
|
40
|
+
* the full-account token. `scopedTokenExpiresAt` is absolute epoch-ms computed
|
|
41
|
+
* server-side at mint, so the refresh schedule never requires decoding the JWT.
|
|
42
|
+
*
|
|
43
|
+
* REQUIRED since 0.7.0 (card 09, D6 — "no backwards support"). Both fields or
|
|
44
|
+
* neither is still the rule; what changed is that "neither" is now an ERROR
|
|
45
|
+
* rather than a degrade. This used to be optional to protect a daemon binary
|
|
46
|
+
* frozen on a laptop against a common-api predating the mint — but Boundary B
|
|
47
|
+
* now refuses an account token on every post-attach route, so a daemon that
|
|
48
|
+
* attaches without a scoped credential cannot do anything afterwards. Accepting
|
|
49
|
+
* the response would buy it exactly one successful call and then a 403 loop
|
|
50
|
+
* with no diagnosis; failing here names the real problem at the one moment the
|
|
51
|
+
* operator is still watching the terminal.
|
|
52
|
+
*
|
|
53
|
+
* Every other exported function's signature is unchanged: they still take "the
|
|
54
|
+
* bearer token to send" via `ApiClientConfig`, and which token that is remains
|
|
55
|
+
* the caller's decision.
|
|
56
|
+
*/
|
|
57
|
+
export async function attach(cfg, workspaceId, hostLabel, remoteHost) {
|
|
33
58
|
const fetchImpl = cfg.fetchImpl ?? fetch;
|
|
34
59
|
const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/attach`, {
|
|
35
60
|
method: 'POST',
|
|
36
61
|
headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
|
|
37
|
-
body: JSON.stringify(
|
|
62
|
+
body: JSON.stringify({
|
|
63
|
+
...(hostLabel ? { hostLabel } : {}),
|
|
64
|
+
...(remoteHost && Object.values(remoteHost).some(Boolean) ? { remoteHost } : {}),
|
|
65
|
+
}),
|
|
38
66
|
});
|
|
39
67
|
if (!res.ok) {
|
|
40
68
|
const { message, code } = await parseErrorBody(res);
|
|
@@ -44,7 +72,54 @@ export async function attach(cfg, workspaceId, hostLabel) {
|
|
|
44
72
|
if (typeof body?.tileId !== 'string' || typeof body?.attachmentId !== 'string') {
|
|
45
73
|
throw new YoloBridgeApiError('attach returned an unexpected shape', res.status);
|
|
46
74
|
}
|
|
47
|
-
|
|
75
|
+
// Both-or-neither, and "neither" is a failure (see the doc comment). A token
|
|
76
|
+
// with no expiry cannot be renewed on time and an expiry with no token is
|
|
77
|
+
// nothing, so a half-pair is refused by the same check — there is no shape
|
|
78
|
+
// here that yields a usable-but-unschedulable credential.
|
|
79
|
+
if (typeof body?.scopedToken !== 'string' || typeof body?.scopedTokenExpiresAt !== 'number') {
|
|
80
|
+
throw new YoloBridgeApiError('attach returned no workspace-scoped credential — this server cannot host a YoloBridge daemon', res.status);
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
tileId: body.tileId,
|
|
84
|
+
attachmentId: body.attachmentId,
|
|
85
|
+
scopedToken: body.scopedToken,
|
|
86
|
+
scopedTokenExpiresAt: body.scopedTokenExpiresAt,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* `POST /v1/workspaces/:workspaceId/yolobridge/attach/:attachmentId/refresh` —
|
|
91
|
+
* renew the workspace-scoped daemon credential (card 07,
|
|
92
|
+
* docs/YOLOBRIDGE_SCOPED_CREDENTIAL_PLAN.md, D1).
|
|
93
|
+
*
|
|
94
|
+
* `cfg.accessToken` MUST be the scoped token being renewed: this endpoint
|
|
95
|
+
* authenticates by the presented credential itself ("proof of recent prior
|
|
96
|
+
* possession"), so the token IS the request's identity. There is no refresh
|
|
97
|
+
* credential — deliberately. The daemon never holds a long-lived one, which is
|
|
98
|
+
* the entire point of the scoping work: a stolen laptop yields a credential
|
|
99
|
+
* that expires in an hour and can only be renewed while it is still fresh.
|
|
100
|
+
*
|
|
101
|
+
* The server accepts a token that has JUST expired, within a narrow grace
|
|
102
|
+
* window (15 minutes server-side), so a clock skew or a short sleep across the
|
|
103
|
+
* scheduled renewal recovers instead of forcing a re-attach. Past that, the
|
|
104
|
+
* refusal is terminal and the remedy is `yolo-bridge attach`.
|
|
105
|
+
*
|
|
106
|
+
* Unlike `attach`, the response shape is validated STRICTLY: this call only
|
|
107
|
+
* ever reaches a server that already issued a scoped token, so a reply missing
|
|
108
|
+
* one is a genuine protocol disagreement, not an old-server degrade. Returning
|
|
109
|
+
* a half-pair would leave the caller unable to schedule the next renewal.
|
|
110
|
+
*/
|
|
111
|
+
export async function refreshScopedToken(cfg, workspaceId, attachmentId) {
|
|
112
|
+
const fetchImpl = cfg.fetchImpl ?? fetch;
|
|
113
|
+
const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/attach/${encodeURIComponent(attachmentId)}/refresh`, { method: 'POST', headers: authHeaders(cfg) });
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
const { message, code } = await parseErrorBody(res);
|
|
116
|
+
throw new YoloBridgeApiError(`scoped credential refresh failed: ${message}`, res.status, code);
|
|
117
|
+
}
|
|
118
|
+
const body = (await res.json());
|
|
119
|
+
if (typeof body?.scopedToken !== 'string' || typeof body?.scopedTokenExpiresAt !== 'number') {
|
|
120
|
+
throw new YoloBridgeApiError('scoped credential refresh returned an unexpected shape', res.status);
|
|
121
|
+
}
|
|
122
|
+
return { scopedToken: body.scopedToken, scopedTokenExpiresAt: body.scopedTokenExpiresAt };
|
|
48
123
|
}
|
|
49
124
|
/**
|
|
50
125
|
* `GET /v1/workspaces/selectable` — slim `{id,name,status}` list of the
|
|
@@ -78,6 +153,16 @@ export async function listSelectableWorkspaces(cfg) {
|
|
|
78
153
|
}
|
|
79
154
|
return workspaces;
|
|
80
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* `DELETE /v1/workspaces/:workspaceId/yolobridge/attach/:attachmentId`.
|
|
158
|
+
*
|
|
159
|
+
* `cfg.accessToken` must be the WORKSPACE-SCOPED credential, not the account
|
|
160
|
+
* token: this is one of the daemon-only routes Boundary B guards, and an
|
|
161
|
+
* account token is refused there with 403 YOLOBRIDGE_SCOPED_TOKEN_REQUIRED
|
|
162
|
+
* (card 09). Both callers comply — the daemon's own cleanup path via
|
|
163
|
+
* `scopedCfg()`, and standalone `yolo-bridge detach` via the credential
|
|
164
|
+
* `attachment.json` persisted at attach.
|
|
165
|
+
*/
|
|
81
166
|
export async function detach(cfg, workspaceId, attachmentId) {
|
|
82
167
|
const fetchImpl = cfg.fetchImpl ?? fetch;
|
|
83
168
|
const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/attach/${attachmentId}`, {
|
|
@@ -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
|
+
}
|