@agfpd/iapeer 0.4.40 → 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 +1 -1
- package/src/cli/index.ts +2 -2
- package/src/daemon/index.ts +35 -1
- package/src/daemon/main.ts +22 -3
- package/src/identity/profileStandard.ts +11 -1
- package/src/registry/index.ts +43 -1
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -64,7 +64,7 @@ import {
|
|
|
64
64
|
validateProfileStandard,
|
|
65
65
|
} from '../identity/profileStandard.ts'
|
|
66
66
|
import { runAlwaysOn } from '../launch/launchdRun.ts'
|
|
67
|
-
import { ensureDaemonStarted, installDaemonPlist, startConfiguredDaemon } from '../daemon/main.ts'
|
|
67
|
+
import { ensureDaemonStarted, installDaemonPlist, parseDaemonPort, startConfiguredDaemon } from '../daemon/main.ts'
|
|
68
68
|
import { MARKETPLACE_NAME, onboardHost, runtimeAuthNote, tccFullDiskAccessNote } from '../onboard/index.ts'
|
|
69
69
|
import { probeFullDiskAccess, readMemoryProvider } from '../status/index.ts'
|
|
70
70
|
import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
|
|
@@ -2242,7 +2242,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2242
2242
|
return 0
|
|
2243
2243
|
}
|
|
2244
2244
|
const handle = await startConfiguredDaemon({
|
|
2245
|
-
port: env.IAPEER_PORT
|
|
2245
|
+
port: parseDaemonPort(env.IAPEER_PORT), // В29 — strict: NaN/out-of-range → default, never an ephemeral port
|
|
2246
2246
|
socketPath: env.IAPEER_DAEMON_SOCKET?.trim() || undefined,
|
|
2247
2247
|
env,
|
|
2248
2248
|
})
|
package/src/daemon/index.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
// same-uid and should add a bearer token before production exposure — OPEN.
|
|
22
22
|
|
|
23
23
|
import { createServer as createHttpServer, type IncomingMessage, type ServerResponse } from 'http'
|
|
24
|
+
import { createConnection } from 'net'
|
|
24
25
|
import { chmodSync, existsSync, mkdirSync, unlinkSync } from 'fs'
|
|
25
26
|
import { dirname, join } from 'path'
|
|
26
27
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
@@ -49,6 +50,30 @@ import { appendDeliveryEvent } from './deliverylog.ts'
|
|
|
49
50
|
export const CALLER_HEADER = 'x-iapeer-identity'
|
|
50
51
|
const SERVER_INFO = { name: 'iapeer', version: '0.0.0' }
|
|
51
52
|
|
|
53
|
+
/** Does a LIVE listener answer on this unix socket path? (В28) A successful connect ⇒ a daemon is
|
|
54
|
+
* running; ECONNREFUSED ⇒ a stale socket file (safe to unlink). Bounded by a short timeout so a wedged
|
|
55
|
+
* socket never hangs startup. */
|
|
56
|
+
function socketIsLive(socketPath: string, timeoutMs = 500): Promise<boolean> {
|
|
57
|
+
return new Promise<boolean>(resolve => {
|
|
58
|
+
let settled = false
|
|
59
|
+
const finish = (live: boolean): void => {
|
|
60
|
+
if (settled) return
|
|
61
|
+
settled = true
|
|
62
|
+
try {
|
|
63
|
+
c.destroy()
|
|
64
|
+
} catch {
|
|
65
|
+
/* */
|
|
66
|
+
}
|
|
67
|
+
resolve(live)
|
|
68
|
+
}
|
|
69
|
+
const c = createConnection(socketPath)
|
|
70
|
+
c.once('connect', () => finish(true))
|
|
71
|
+
c.once('error', () => finish(false))
|
|
72
|
+
const t = setTimeout(() => finish(false), timeoutMs)
|
|
73
|
+
if (typeof t.unref === 'function') t.unref()
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
52
77
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
53
78
|
// Caller identity from the request header
|
|
54
79
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -487,7 +512,16 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
|
|
|
487
512
|
// Unix-socket listener (H8 same-uid base, 0600).
|
|
488
513
|
if (socketPath) {
|
|
489
514
|
mkdirSync(dirname(socketPath), { recursive: true, mode: 0o700 })
|
|
490
|
-
if (existsSync(socketPath))
|
|
515
|
+
if (existsSync(socketPath)) {
|
|
516
|
+
// В28 — a LIVE daemon may already own this socket. Unlinking it out from under a live listener
|
|
517
|
+
// silently breaks every unix-socket caller (CLI/notifier/telegram) until a restart. Probe first:
|
|
518
|
+
// if a daemon answers, REFUSE to start (touch nothing). Only a STALE socket file (no listener,
|
|
519
|
+
// ECONNREFUSED) is removed. Guards the documented `iapeer daemon` foreground acceptance mode.
|
|
520
|
+
if (await socketIsLive(socketPath)) {
|
|
521
|
+
throw new Error(`a daemon is already listening on ${socketPath} — refusing to start a second instance`)
|
|
522
|
+
}
|
|
523
|
+
unlinkSync(socketPath)
|
|
524
|
+
}
|
|
491
525
|
const s = createHttpServer(handle)
|
|
492
526
|
await new Promise<void>((resolve, reject) => {
|
|
493
527
|
s.once('error', reject)
|
package/src/daemon/main.ts
CHANGED
|
@@ -72,6 +72,19 @@ import { defaultDaemonSocketPath, startDaemon, type DaemonHandle } from './index
|
|
|
72
72
|
* a STABLE port, not an ephemeral one. Override with IAPEER_PORT. */
|
|
73
73
|
export const DEFAULT_DAEMON_PORT = 8765
|
|
74
74
|
|
|
75
|
+
/** Parse IAPEER_PORT STRICTLY (В29). `Number('abc')` = NaN passes `?? DEFAULT` (NaN is not nullish) →
|
|
76
|
+
* listen(NaN) binds an EPHEMERAL port while the fleet's .mcp.json points at the fixed 8765 → the whole
|
|
77
|
+
* TCP-MCP surface is silently offline yet the daemon reads "healthy" (health probes only the unix
|
|
78
|
+
* socket). Unset/empty → the default; anything not an integer in 1–65535 → a LOUD warn + the default. */
|
|
79
|
+
export function parseDaemonPort(raw: string | undefined, dflt: number = DEFAULT_DAEMON_PORT): number {
|
|
80
|
+
const t = raw?.trim()
|
|
81
|
+
if (!t) return dflt
|
|
82
|
+
const n = Number(t)
|
|
83
|
+
if (Number.isInteger(n) && n >= 1 && n <= 65535) return n
|
|
84
|
+
process.stderr.write(`[iapeer-daemon] WARN invalid IAPEER_PORT=${JSON.stringify(raw)} — falling back to ${dflt}\n`)
|
|
85
|
+
return dflt
|
|
86
|
+
}
|
|
87
|
+
|
|
75
88
|
/** Default supervise-tick cadence (idle-reap / zombie-sweep). idleSecs (1h default)
|
|
76
89
|
* is the reap threshold; this is just how often the timer checks. */
|
|
77
90
|
export const DEFAULT_SUPERVISE_INTERVAL_MS = 60_000
|
|
@@ -213,7 +226,8 @@ function queuedFailureMessage(job: ComposerQueuedDelivery, reason: string): stri
|
|
|
213
226
|
export function makeComposerQueueRouteDeps(cfg: LifecycleConfig, env: NodeJS.ProcessEnv): ComposerQueueRouteDeps {
|
|
214
227
|
const noteLiveTopic = makeNoteLiveTopic(cfg, env)
|
|
215
228
|
const onDelivered = makeArmEphemeralOnDelivered(cfg)
|
|
216
|
-
|
|
229
|
+
// NB: no `wake` here — the composer-queue failure outlet (notifyFailed) deliberately does NOT wake an
|
|
230
|
+
// offline sender (В30); it fires on failAll during shutdown/update.
|
|
217
231
|
const ephemeral = makeEphemeralRouteDeps(cfg, env)
|
|
218
232
|
|
|
219
233
|
return createComposerDeliveryQueue({
|
|
@@ -269,7 +283,12 @@ export function makeComposerQueueRouteDeps(cfg: LifecycleConfig, env: NodeJS.Pro
|
|
|
269
283
|
// Do not pass composerQueue here: this path is the queue's failure outlet
|
|
270
284
|
// (including SIGTERM/update). Recursively queueing a failure notification
|
|
271
285
|
// could turn shutdown re-fail into another queued item.
|
|
272
|
-
|
|
286
|
+
// В30 — NO wake dep either: this outlet fires on failAll (SIGTERM/update). Waking an offline
|
|
287
|
+
// sender to notify it takes 30-60s (ready-gate), blowing past launchd's ExitTimeOut → SIGKILL
|
|
288
|
+
// drops the remaining notifications (the very silent loss failAll exists to prevent) and stalls
|
|
289
|
+
// the restart. An ephemeral sender still gets a DURABLE-queued notification (ephemeral dep); a
|
|
290
|
+
// non-ephemeral offline sender degrades to a delivery.log record (best-effort) — never a wake.
|
|
291
|
+
{ ephemeral, noteLiveTopic },
|
|
273
292
|
)
|
|
274
293
|
appendDeliveryEvent(cfg.eventLogDir, {
|
|
275
294
|
ev: 'composer-queue-failed-notify',
|
|
@@ -542,7 +561,7 @@ if (import.meta.main) {
|
|
|
542
561
|
} else {
|
|
543
562
|
const env = process.env
|
|
544
563
|
const handle = await startConfiguredDaemon({
|
|
545
|
-
port:
|
|
564
|
+
port: parseDaemonPort(env.IAPEER_PORT),
|
|
546
565
|
socketPath: env.IAPEER_DAEMON_SOCKET?.trim() || undefined,
|
|
547
566
|
env,
|
|
548
567
|
})
|
|
@@ -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
|
-
|
|
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,
|
package/src/registry/index.ts
CHANGED
|
@@ -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)
|