@agfpd/iapeer 0.4.41 → 0.4.42

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.41",
3
+ "version": "0.4.42",
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",
@@ -201,7 +201,17 @@ export function migrateProfileRuntimeField(cwd: string): boolean {
201
201
  * profile. Used by verify (compare against the live index) and reindex (rewrite it).
202
202
  */
203
203
  export function projectProfileToRecord(cwd: string): PeerRecord | null {
204
- const profile = readPeerProfile(cwd)
204
+ // В32 — a CORRUPT local profile (invalid JSON / invalid stored field) makes readPeerProfile THROW.
205
+ // Unguarded, one bad profile aborts the whole reconcile/reindex map → verify crashes for all ~30 peers
206
+ // (with a message that does not even name the peer), and verify --fix never reaches reindex. Treat a
207
+ // corrupt profile like a missing one: reconcileIndex reports null drift, and reindexFromLocals PRESERVES
208
+ // the existing registry record (its `if (!projected) return rec`), so nothing is crashed or removed.
209
+ let profile
210
+ try {
211
+ profile = readPeerProfile(cwd)
212
+ } catch {
213
+ return null
214
+ }
205
215
  if (!profile) return null
206
216
  return {
207
217
  personality: profile.personality,
@@ -9,7 +9,7 @@
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, writeFileSync } from 'fs'
12
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'fs'
13
13
  import { homedir } from 'os'
14
14
  import { join } from 'path'
15
15
  import { writeFileAtomicRaw } from '../storage/atomicWrite.ts'
@@ -345,6 +345,24 @@ function writePeersIndexAtomic(paths: PeersPaths, index: PeersIndex): void {
345
345
  writeFileAtomicRaw(paths.peersFile, `${JSON.stringify(normalized, null, 2)}\n`, 0o600, paths.tmpDir)
346
346
  }
347
347
 
348
+ /** В33 — assert the READ invariants BEFORE publishing. writePeersIndexAtomic does no validation, so a
349
+ * duplicate personality (e.g. a partial renamePeer + create, or reindex projecting two same-named local
350
+ * profiles) or an empty cwd would write SUCCESSFULLY and then make EVERY readPeersIndex throw — the whole
351
+ * fleet stops resolving until the file is hand-repaired. Fail LOUD here (the single-writer chokepoint) so
352
+ * the on-disk file is never left unreadable. */
353
+ function assertWritableIndex(index: PeersIndex): void {
354
+ const seen = new Set<string>()
355
+ for (const p of index.peers) {
356
+ if (!p.cwd || !p.cwd.trim()) {
357
+ throw new IapError(`refusing to write peers-profiles.json: peer "${p.personality}" has an empty cwd (would be unreadable)`)
358
+ }
359
+ if (seen.has(p.personality)) {
360
+ throw new IapError(`refusing to write peers-profiles.json: duplicate peer "${p.personality}" (would make the registry unreadable for the whole fleet)`)
361
+ }
362
+ seen.add(p.personality)
363
+ }
364
+ }
365
+
348
366
  export async function updatePeersIndex(
349
367
  updater: (index: PeersIndex) => PeersIndex,
350
368
  options: PeersUpdateOptions = {},
@@ -352,6 +370,7 @@ export async function updatePeersIndex(
352
370
  return withPeersLock(options, paths => {
353
371
  const current = readPeersIndex({ ...options, rootDir: paths.rootDir })
354
372
  const next = updater(current)
373
+ assertWritableIndex(next) // В33 — fail before publish; the file stays readable
355
374
  writePeersIndexAtomic(paths, next)
356
375
  return next
357
376
  })
@@ -371,6 +390,17 @@ export interface UpsertPeerArgs {
371
390
  cwd: string
372
391
  }
373
392
 
393
+ /** Two cwds refer to the SAME folder (realpath-tolerant so a symlinked path compares equal to its
394
+ * target). Falls back to a literal compare when a path can't be resolved (e.g. not yet created). */
395
+ function sameCwd(a: string, b: string): boolean {
396
+ if (a === b) return true
397
+ try {
398
+ return realpathSync(a) === realpathSync(b)
399
+ } catch {
400
+ return a === b
401
+ }
402
+ }
403
+
374
404
  export async function upsertPeer(
375
405
  args: UpsertPeerArgs,
376
406
  options: PeersUpdateOptions = {},
@@ -411,6 +441,18 @@ export async function upsertPeer(
411
441
  return updatePeersIndex(index => {
412
442
  const existing = index.peers.find(peer => peer.personality === args.personality)
413
443
 
444
+ // В35 — re-check the personality↔cwd collision UNDER THE LOCK (closing the TOCTOU: the pre-lock
445
+ // snapshot check in provisionPeer can be stale by the seconds-long birth-hook window). An existing
446
+ // peer at a DIFFERENT folder must NOT be silently re-pointed — that splits one identity across two
447
+ // folders (routing goes to whichever the entry point resolves). Legitimate folder moves are
448
+ // fail-closed in ensurePeerProfile; renames go through renamePeer — neither re-points via upsert.
449
+ if (existing && !sameCwd(existing.cwd, args.cwd)) {
450
+ throw new IapError(
451
+ `peer "${args.personality}" already exists at ${existing.cwd}; refusing to re-point it to ${args.cwd} ` +
452
+ `(one personality = one folder — use 'iapeer rename' to move it)`,
453
+ )
454
+ }
455
+
414
456
  // H1 merge-with-existing: a field ABSENT from args inherits from `existing`,
415
457
  // NOT from a runtime default. The runtime default applies ONLY to a brand-new
416
458
  // peer (no existing). This is what stops a claude-boot upsert (no intelligence)