@agfpd/iapeer 0.4.34 → 0.4.35

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.34",
3
+ "version": "0.4.35",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
@@ -9,10 +9,10 @@
9
9
  // is no unlocked path to the registry file anywhere in the package.
10
10
 
11
11
  import * as lockfile from 'proper-lockfile'
12
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
13
13
  import { homedir } from 'os'
14
14
  import { join } from 'path'
15
- import { randomUUID } from 'crypto'
15
+ import { writeFileAtomicRaw } from '../storage/atomicWrite.ts'
16
16
  import {
17
17
  IAPEER_DIR,
18
18
  MAX_DESCRIPTION_LEN,
@@ -313,7 +313,6 @@ export async function withPeersLock<T>(
313
313
  * sanctioned write path for the registry file (#3).
314
314
  */
315
315
  function writePeersIndexAtomic(paths: PeersPaths, index: PeersIndex): void {
316
- const tmp = join(paths.tmpDir, `.${PEERS_PROFILES_FILE}.${process.pid}.${randomUUID()}.tmp`)
317
316
  // Persist the RAW intelligence verbatim (legacy-safe round-trip) and DROP the
318
317
  // intelligenceRaw shadow field from the on-disk shape — the file carries the
319
318
  // legacy vocab the live IAP reads, never the foundation's in-memory normalization.
@@ -333,8 +332,10 @@ function writePeersIndexAtomic(paths: PeersPaths, index: PeersIndex): void {
333
332
  ...(peer.interfaces ? { interfaces: peer.interfaces } : {}),
334
333
  }))
335
334
  const normalized = { version: PEERS_SCHEMA_VERSION, peers: peersForDisk }
336
- writeFileSync(tmp, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 })
337
- renameSync(tmp, paths.peersFile)
335
+ // Route through the SHARED atomic-write core (fsync-before-rename + looped writeSync) — the private
336
+ // registry writer no longer keeps its own copy that drifted off the durability protocol. tmpDir is
337
+ // paths.tmpDir (same filesystem as peersFile → the rename stays atomic).
338
+ writeFileAtomicRaw(paths.peersFile, `${JSON.stringify(normalized, null, 2)}\n`, 0o600, paths.tmpDir)
338
339
  }
339
340
 
340
341
  export async function updatePeersIndex(
@@ -0,0 +1,60 @@
1
+ // The guard-FREE atomic-write CORE — the single durability implementation shared by
2
+ // storage.writeFileAtomic (public, basename-guarded) and registry's private
3
+ // peers-profiles writer (which bypasses that guard legitimately: it is the sole writer
4
+ // under withPeersLock). Factored out because the registry kept its OWN copy of the
5
+ // atomic-rename, and that copy DRIFTED — it lost the fsync the storage primitive has,
6
+ // re-opening the power-loss hole on the most critical file of the host (the fleet
7
+ // registry). One implementation → the durability protocol can never drift again.
8
+
9
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, renameSync, unlinkSync, writeSync } from 'node:fs'
10
+ import { basename, dirname, join } from 'node:path'
11
+ import { randomUUID } from 'node:crypto'
12
+
13
+ const DIR_MODE = 0o700
14
+
15
+ /**
16
+ * Atomically write `data` to `target`:
17
+ * 1. write to a unique tmp in `tmpDir` (default: the target's directory — which MUST be the same
18
+ * filesystem as `target`, or the rename is not atomic),
19
+ * 2. fsync the tmp BEFORE the rename — rename is atomic on one fs, but without flushing the bytes
20
+ * first a power-loss can publish a zero-length / partial file,
21
+ * 3. rename the tmp over `target`.
22
+ *
23
+ * writeSync is LOOPED over a Buffer: a single writeSync can write FEWER bytes than requested (on
24
+ * ENOSPC / a signal, write(2) returns a partial count WITHOUT throwing), which would otherwise publish
25
+ * a truncated file — the exact failure the atomic primitive exists to prevent. The tmp is always
26
+ * cleaned up on failure so a partial write never leaks.
27
+ */
28
+ export function writeFileAtomicRaw(
29
+ target: string,
30
+ data: string,
31
+ mode: number,
32
+ tmpDir: string = dirname(target),
33
+ ): void {
34
+ mkdirSync(dirname(target), { recursive: true, mode: DIR_MODE })
35
+ if (tmpDir !== dirname(target)) mkdirSync(tmpDir, { recursive: true, mode: DIR_MODE })
36
+ const tmp = join(tmpDir, `.${basename(target)}.${process.pid}.${randomUUID()}.tmp`)
37
+ try {
38
+ const fd = openSync(tmp, 'w', mode)
39
+ try {
40
+ const buf = Buffer.from(data, 'utf8')
41
+ let written = 0
42
+ while (written < buf.length) {
43
+ const n = writeSync(fd, buf, written, buf.length - written)
44
+ if (n <= 0) throw new Error(`writeSync made no progress writing ${target} (${written}/${buf.length} bytes)`)
45
+ written += n
46
+ }
47
+ fsyncSync(fd)
48
+ } finally {
49
+ closeSync(fd)
50
+ }
51
+ renameSync(tmp, target)
52
+ } catch (e) {
53
+ try {
54
+ if (existsSync(tmp)) unlinkSync(tmp)
55
+ } catch {
56
+ /* best-effort cleanup */
57
+ }
58
+ throw e
59
+ }
60
+ }
@@ -10,21 +10,10 @@
10
10
  // reaching for the generic atomic-write primitive. The registry keeps its own
11
11
  // private writer (see registry/index.ts) used only inside withPeersLock.
12
12
 
13
- import {
14
- closeSync,
15
- existsSync,
16
- fsyncSync,
17
- mkdirSync,
18
- openSync,
19
- readFileSync,
20
- readdirSync,
21
- renameSync,
22
- unlinkSync,
23
- writeSync,
24
- } from 'fs'
13
+ import { existsSync, mkdirSync, readFileSync, readdirSync } from 'fs'
25
14
  import { homedir } from 'os'
26
15
  import { basename, dirname, join } from 'path'
27
- import { randomUUID } from 'crypto'
16
+ import { writeFileAtomicRaw } from './atomicWrite.ts'
28
17
  import {
29
18
  CACHE_DIR,
30
19
  IAPEER_DIR,
@@ -304,28 +293,6 @@ export function writeFileAtomic(path: string, data: string, mode: number = FILE_
304
293
  `refusing to write ${PEERS_PROFILES_FILE} via storage.writeFileAtomic — it is the registry's locked write target; use registry.upsertPeer/removePeer/renamePeer`,
305
294
  )
306
295
  }
307
- const dir = dirname(path)
308
- mkdirSync(dir, { recursive: true, mode: DIR_MODE })
309
- const tmp = join(dir, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`)
310
- try {
311
- // fsync the tmp file BEFORE the rename: rename is atomic on a single fs, but
312
- // without flushing the bytes first a power-loss can publish a zero-length /
313
- // partial shared file (codex config.toml, .mcp.json, router.json, plists).
314
- const fd = openSync(tmp, 'w', mode)
315
- try {
316
- writeSync(fd, data)
317
- fsyncSync(fd)
318
- } finally {
319
- closeSync(fd)
320
- }
321
- renameSync(tmp, path)
322
- } catch (e) {
323
- // Never leak the tmp file when the write/rename fails (#audit).
324
- try {
325
- if (existsSync(tmp)) unlinkSync(tmp)
326
- } catch {
327
- /* best-effort cleanup */
328
- }
329
- throw e
330
- }
296
+ // The durability core (fsync-before-rename + looped writeSync) lives in ONE place — see atomicWrite.ts.
297
+ writeFileAtomicRaw(path, data, mode)
331
298
  }
Binary file