@agfpd/iapeer 0.2.35 → 0.2.37

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.2.35",
3
+ "version": "0.2.37",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.ts CHANGED
@@ -1129,7 +1129,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1129
1129
  const { installIapeer } = await import('../install/index.ts')
1130
1130
  ensureGlobalIapScaffold({ env })
1131
1131
  const r = installIapeer(fileURLToPath(import.meta.url), env)
1132
- const plist = installDaemonPlist({ env })
1132
+ const { path: plist, changed: plistChanged } = installDaemonPlist({ env })
1133
1133
  const signingLine =
1134
1134
  r.signing == null
1135
1135
  ? ''
@@ -1142,7 +1142,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1142
1142
  `${r.size ? ` (${Math.round(r.size / 1e6)}M)` : ''}\n` +
1143
1143
  signingLine +
1144
1144
  ` scaffold: ~/.iapeer/ ensured (peers/, state, logs, cache, runtimes)\n` +
1145
- ` daemon plist written: ${plist}\n` +
1145
+ ` daemon plist ${plistChanged ? 'written' : 'unchanged (byte-identical — no write, no BTM notification)'}: ${plist}\n` +
1146
1146
  ` (NOT loaded — a live daemon migration is a separate step: launchctl bootstrap gui/$(id -u) ${plist})\n`,
1147
1147
  )
1148
1148
  return 0
@@ -1151,8 +1151,8 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1151
1151
  // Ф-F: the prod daemon entrypoint. The launchd plist runs `iapeer daemon`
1152
1152
  // (the INSTALLED binary), decoupling prod from the mutable src tree.
1153
1153
  if (flags['install-plist'] === true) {
1154
- const p = installDaemonPlist({ env })
1155
- out(`installed daemon plist: ${p}\nNOT loaded — to start: launchctl bootstrap gui/$(id -u) ${p}\n`)
1154
+ const { path: p, changed } = installDaemonPlist({ env })
1155
+ out(`daemon plist ${changed ? 'written' : 'unchanged'}: ${p}\nNOT loaded — to start: launchctl bootstrap gui/$(id -u) ${p}\n`)
1156
1156
  return 0
1157
1157
  }
1158
1158
  const handle = await startConfiguredDaemon({
@@ -75,17 +75,40 @@ describe('buildDaemonPlistSpec / installDaemonPlist (com.agfpd.iapeer)', () => {
75
75
  HOME: root,
76
76
  PATH: '/usr/bin:/bin',
77
77
  } as NodeJS.ProcessEnv
78
- const path = installDaemonPlist({ env, port: 8765, throttleIntervalSecs: 10 })
79
- expect(path).toBe(daemonPlistPath(env))
80
- expect(existsSync(path)).toBe(true)
81
- expect(isFoundationOwnedPlist(path)).toBe(true)
82
- expect(plutilLint(path)).toBe(true) // live plutil: valid plist
83
- const xml = readFileSync(path, 'utf8')
78
+ const r = installDaemonPlist({ env, port: 8765, throttleIntervalSecs: 10 })
79
+ expect(r.path).toBe(daemonPlistPath(env))
80
+ expect(r.changed).toBe(true) // first install on a clean host writes
81
+ expect(existsSync(r.path)).toBe(true)
82
+ expect(isFoundationOwnedPlist(r.path)).toBe(true)
83
+ expect(plutilLint(r.path)).toBe(true) // live plutil: valid plist
84
+ const xml = readFileSync(r.path, 'utf8')
84
85
  expect(xml).toContain(`<string>${DAEMON_PLIST_LABEL}</string>`)
85
86
  expect(xml).toContain('<key>RunAtLoad</key>')
86
87
  expect(xml).toContain('<key>KeepAlive</key>')
87
88
  })
88
89
 
90
+ test('idempotent by content: a re-install of the SAME plist does NOT rewrite the file (changed:false, mtime stable) — no BTM notification spam (owner-caught 11.06)', () => {
91
+ const root = mkTmp()
92
+ const env = {
93
+ IAPEER_LAUNCHAGENTS_DIR: join(root, 'LaunchAgents'),
94
+ IAPEER_ROOT: join(root, 'iapeer'),
95
+ HOME: root,
96
+ PATH: '/usr/bin:/bin',
97
+ } as NodeJS.ProcessEnv
98
+ const opts = { env, port: 8765, throttleIntervalSecs: 10 }
99
+ const first = installDaemonPlist(opts)
100
+ expect(first.changed).toBe(true)
101
+ const mtime1 = statSync(first.path).mtimeMs
102
+ // second identical install — must be a pure no-op (no write → BTM not tripped)
103
+ const second = installDaemonPlist(opts)
104
+ expect(second.changed).toBe(false)
105
+ expect(second.path).toBe(first.path)
106
+ expect(statSync(second.path).mtimeMs).toBe(mtime1) // file untouched, not just byte-equal
107
+ // a CHANGED spec (different port → different rendered plist) DOES rewrite
108
+ const third = installDaemonPlist({ env, port: 9999, throttleIntervalSecs: 10 })
109
+ expect(third.changed).toBe(true)
110
+ })
111
+
89
112
  test('REFUSES to overwrite a foreign com.agfpd.iapeer.plist (collision guard)', () => {
90
113
  const root = mkTmp()
91
114
  const laDir = join(root, 'LaunchAgents')
@@ -96,6 +119,8 @@ describe('buildDaemonPlistSpec / installDaemonPlist (com.agfpd.iapeer)', () => {
96
119
  writeFileSync(path, foreign)
97
120
  expect(() => installDaemonPlist({ env })).toThrow(/foundation-managed|refus/i)
98
121
  expect(readFileSync(path, 'utf8')).toBe(foreign) // untouched
122
+ // (the throw must fire BEFORE the write-if-changed read, so the foreign file
123
+ // is never even compared — the guard is the first gate.)
99
124
  })
100
125
  })
101
126
 
@@ -15,7 +15,7 @@
15
15
  // action (`--install-plist`); it writes the file but does NOT load it — a live
16
16
  // `launchctl bootstrap` stays a deliberate operator step.
17
17
 
18
- import { existsSync, mkdirSync, writeFileSync } from 'fs'
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
19
19
  import { homedir } from 'os'
20
20
  import { join } from 'path'
21
21
  import { fileURLToPath } from 'url'
@@ -301,17 +301,33 @@ export function buildDaemonPlistSpec(opts: InstallDaemonPlistOptions = {}): Laun
301
301
  }
302
302
  }
303
303
 
304
+ export interface InstallDaemonPlistResult {
305
+ /** The plist path (always returned, written or not). */
306
+ path: string
307
+ /** false when the on-disk plist already matched byte-for-byte → NO write was
308
+ * done. The deploy path calls this on EVERY `iapeer update`; a byte-identical
309
+ * rewrite serves nothing (the daemon never re-reads the file — launchd holds
310
+ * its loaded definition; a changed file takes effect only on an explicit
311
+ * bootout+bootstrap) yet each write into ~/Library/LaunchAgents trips macOS
312
+ * Background Task Management → a "background activity" notification to the
313
+ * owner on every deploy. Skipping the no-op write suppresses that noise. */
314
+ changed: boolean
315
+ }
316
+
304
317
  /**
305
318
  * Generate + install the daemon's always-on launchd plist at
306
319
  * ~/Library/LaunchAgents/com.agfpd.iapeer.plist (or IAPEER_LAUNCHAGENTS_DIR),
307
- * returning the path. Writes the FILE only — does NOT bootstrap it (load is a
308
- * deliberate operator step). Collision guard: refuses to overwrite an existing
309
- * com.agfpd.iapeer.plist that is not foundation-owned (lacks the sentinel) — the
310
- * same ownership proof the peer-plist installer uses. com.agfpd.* is the
311
- * foundation-exclusive daemon namespace, so this never touches the com.iapeer.*
312
- * persistent-peer fleet.
320
+ * returning {path, changed}. Writes the FILE only — does NOT bootstrap it (load
321
+ * is a deliberate operator step). IDEMPOTENT BY CONTENT: writes only when the
322
+ * rendered plist differs from what is already on disk (owner-caught 11.06: an
323
+ * unconditional rewrite on every deploy spams the owner's Background Task
324
+ * Management notification for zero benefit). Collision guard: refuses to
325
+ * overwrite an existing com.agfpd.iapeer.plist that is not foundation-owned
326
+ * (lacks the sentinel) — the same ownership proof the peer-plist installer uses.
327
+ * com.agfpd.* is the foundation-exclusive daemon namespace, so this never
328
+ * touches the com.iapeer.* persistent-peer fleet.
313
329
  */
314
- export function installDaemonPlist(opts: InstallDaemonPlistOptions = {}): string {
330
+ export function installDaemonPlist(opts: InstallDaemonPlistOptions = {}): InstallDaemonPlistResult {
315
331
  const env = opts.env ?? process.env
316
332
  const path = daemonPlistPath(env)
317
333
  if (existsSync(path) && !isFoundationOwnedPlist(path)) {
@@ -320,11 +336,20 @@ export function installDaemonPlist(opts: InstallDaemonPlistOptions = {}): string
320
336
  `(no ownership sentinel) — another manager owns it`,
321
337
  )
322
338
  }
323
- const spec = buildDaemonPlistSpec(opts)
339
+ const rendered = renderLaunchdPlist(buildDaemonPlistSpec(opts))
324
340
  mkdirSync(launchAgentsDir(env), { recursive: true })
325
341
  mkdirSync(pluginLogsDir('iapeer', { env }), { recursive: true, mode: 0o700 })
326
- writeFileSync(path, renderLaunchdPlist(spec), { mode: 0o644 })
327
- return path
342
+ // Write-if-changed: a byte-identical rewrite is a pure no-op for the daemon but
343
+ // trips the owner's BTM notification — skip it (see InstallDaemonPlistResult).
344
+ let existing: string | null = null
345
+ try {
346
+ existing = readFileSync(path, 'utf8')
347
+ } catch {
348
+ existing = null
349
+ }
350
+ if (existing === rendered) return { path, changed: false }
351
+ writeFileSync(path, rendered, { mode: 0o644 })
352
+ return { path, changed: true }
328
353
  }
329
354
 
330
355
  // ─────────────────────────────────────────────────────────────────────────────
@@ -337,9 +362,9 @@ if (import.meta.main) {
337
362
  process.stdout.write(renderLaunchdPlist(buildDaemonPlistSpec()))
338
363
  process.exit(0)
339
364
  } else if (arg === '--install-plist') {
340
- const p = installDaemonPlist()
365
+ const { path: p, changed } = installDaemonPlist()
341
366
  process.stdout.write(
342
- `installed daemon plist: ${p}\n` +
367
+ `daemon plist ${changed ? 'written' : 'unchanged'}: ${p}\n` +
343
368
  `NOT loaded. To start it live: launchctl bootstrap gui/$(id -u) ${p}\n`,
344
369
  )
345
370
  process.exit(0)
@@ -6,12 +6,13 @@
6
6
  // error (fail-open). The core never acts on heartbeat staleness — it only REPORTS
7
7
  // it (healing the provider's daemon is the provider's job, their ADR-010).
8
8
 
9
- import { readFileSync, statSync } from 'fs'
9
+ import { closeSync, openSync, readFileSync, statSync } from 'fs'
10
10
  import { join } from 'path'
11
11
  import { IAPEER_VERSION } from '../core/version.ts'
12
12
  import { resolveGlobalRoot } from '../storage/index.ts'
13
13
  import { daemonDiscoveryPath } from '../daemon/index.ts'
14
14
  import { waitForDaemonHealthy } from '../update/index.ts'
15
+ import { iapeerBinPath } from '../install/index.ts'
15
16
 
16
17
  /** The slot-declaration filename in the storage root (next to peers-profiles.json). */
17
18
  export const MEMORY_PROVIDER_FILE = 'memory-provider.json'
@@ -135,16 +136,48 @@ export function heartbeatAgeSecs(provider: MemoryProvider, nowMs: number = Date.
135
136
  }
136
137
  }
137
138
 
139
+ /**
140
+ * Probe whether the iapeer binary holds Full Disk Access (macOS TCC). Reads the
141
+ * user TCC.db — a canonical FDA-gated resource that is NEVER promptable (silent
142
+ * EPERM without the grant), so THE PROBE ITSELF raises no dialog (it must not be
143
+ * the very noise it detects). Byte-free: open+close to prove access, no content
144
+ * read. Returns:
145
+ * true — readable → FDA granted
146
+ * false — EPERM/EACCES → FDA NOT granted (peers will hit TCC prompts on
147
+ * protected folders + foreign app containers — see onboard contract)
148
+ * null — not macOS / no HOME / TCC.db absent → cannot determine (no claim)
149
+ */
150
+ export function probeFullDiskAccess(env: NodeJS.ProcessEnv = process.env): boolean | null {
151
+ if (process.platform !== 'darwin') return null
152
+ const home = env.HOME
153
+ if (!home) return null
154
+ const marker = join(home, 'Library', 'Application Support', 'com.apple.TCC', 'TCC.db')
155
+ try {
156
+ closeSync(openSync(marker, 'r'))
157
+ return true
158
+ } catch (e) {
159
+ const code = (e as NodeJS.ErrnoException).code
160
+ if (code === 'EPERM' || code === 'EACCES') return false
161
+ return null // ENOENT / unexpected → undeterminable, never a false claim
162
+ }
163
+ }
164
+
138
165
  export interface HostStatus {
139
166
  version: string
140
167
  daemon: { healthy: boolean; url: string | null; sock: string | null }
141
168
  memory: { provider: MemoryProvider | null; heartbeatAgeSecs: number | null }
169
+ /** Full Disk Access of the iapeer binary: true granted / false not-granted /
170
+ * null undeterminable (non-macOS or TCC.db unreadable for a non-TCC reason). */
171
+ fda: boolean | null
142
172
  }
143
173
 
144
174
  export interface HostStatusOptions {
145
175
  env?: NodeJS.ProcessEnv
146
176
  /** Injectable daemon probe (tests). Default: the real socket probe. */
147
177
  probe?: () => Promise<boolean>
178
+ /** Injectable FDA probe (tests — the default reads the real TCC.db, which a
179
+ * hermetic test must not touch). Default: probeFullDiskAccess(env). */
180
+ fdaProbe?: () => boolean | null
148
181
  }
149
182
 
150
183
  /** Assemble the host snapshot: baked version + daemon probe + memory slot. */
@@ -161,10 +194,12 @@ export async function hostStatus(opts: HostStatusOptions = {}): Promise<HostStat
161
194
  }
162
195
  const health = await waitForDaemonHealthy({ env, timeoutMs: 2500, needConsecutive: 1, probe: opts.probe })
163
196
  const provider = readMemoryProvider(env)
197
+ const fda = (opts.fdaProbe ?? (() => probeFullDiskAccess(env)))()
164
198
  return {
165
199
  version: IAPEER_VERSION,
166
200
  daemon: { healthy: health.healthy, url, sock },
167
201
  memory: { provider, heartbeatAgeSecs: provider ? heartbeatAgeSecs(provider) : null },
202
+ fda,
168
203
  }
169
204
  }
170
205
 
@@ -184,5 +219,19 @@ export function formatHostStatus(s: HostStatus): string {
184
219
  : ' (daemon not running — no heartbeat file)'
185
220
  }
186
221
  const memory = s.memory.provider ? `${s.memory.provider.provider} ${s.memory.provider.version}${hb}` : 'none'
187
- return `iapeer ${s.version}\ndaemon: ${daemon}\nmemory: ${memory}\n`
222
+ // FDA line: granted → terse OK; NOT granted → the actionable hint (a fresh host
223
+ // without FDA silently meets TCC prompts on every new protected class — peers
224
+ // read protected folders + foreign app containers, attributed to this binary;
225
+ // see onboard contract §INSTALL). undeterminable → say so, never a false claim.
226
+ let fda: string
227
+ if (s.fda === true) {
228
+ fda = 'fda: granted'
229
+ } else if (s.fda === false) {
230
+ fda =
231
+ `fda: NOT granted — peers will hit TCC prompts on protected paths.\n` +
232
+ ` grant: System Settings → Privacy & Security → Full Disk Access → + → ⇧⌘G → ${iapeerBinPath()}`
233
+ } else {
234
+ fda = 'fda: unknown (not macOS / undeterminable)'
235
+ }
236
+ return `iapeer ${s.version}\ndaemon: ${daemon}\nmemory: ${memory}\n${fda}\n`
188
237
  }
@@ -128,7 +128,7 @@ describe('heartbeatAgeSecs', () => {
128
128
  describe('hostStatus + formatHostStatus', () => {
129
129
  test('empty slot → memory: none; daemon health from the injected probe', async () => {
130
130
  const root = mkTmp()
131
- const s = await hostStatus({ env: envFor(root), probe: async () => true })
131
+ const s = await hostStatus({ env: envFor(root), probe: async () => true, fdaProbe: () => null })
132
132
  expect(s.daemon.healthy).toBe(true)
133
133
  expect(s.memory.provider).toBeNull()
134
134
  expect(formatHostStatus(s)).toContain('memory: none')
@@ -140,7 +140,7 @@ describe('hostStatus + formatHostStatus', () => {
140
140
  const hb = join(root, 'memoryd.heartbeat')
141
141
  writeFileSync(hb, '')
142
142
  writeFileSync(memoryProviderPath(env), JSON.stringify({ ...VALID, heartbeat: hb }))
143
- const s = await hostStatus({ env, probe: async () => true })
143
+ const s = await hostStatus({ env, probe: async () => true, fdaProbe: () => null })
144
144
  expect(formatHostStatus(s)).toMatch(/memory: iapeer-memory 0\.1\.0 \(heartbeat \d+s ago\)/)
145
145
  })
146
146
 
@@ -152,6 +152,7 @@ describe('hostStatus + formatHostStatus', () => {
152
152
  provider: { ...VALID, heartbeat: '/nope/hb' } as MemoryProvider,
153
153
  heartbeatAgeSecs: null,
154
154
  },
155
+ fda: null,
155
156
  }
156
157
  const text = formatHostStatus(s)
157
158
  expect(text).toContain('memory: iapeer-memory 0.1.0 (daemon not running — no heartbeat file)')
@@ -164,9 +165,48 @@ describe('hostStatus + formatHostStatus', () => {
164
165
  const stateDir = join(root, 'state', 'iapeer')
165
166
  mkdirSync(stateDir, { recursive: true })
166
167
  writeFileSync(join(stateDir, 'router.json'), JSON.stringify({ sock: '/x/router.sock', tcp: 'http://127.0.0.1:8765/mcp' }))
167
- const s = await hostStatus({ env, probe: async () => true })
168
+ const s = await hostStatus({ env, probe: async () => true, fdaProbe: () => null })
168
169
  const text = formatHostStatus(s)
169
170
  expect(text).toContain('@ http://127.0.0.1:8765/mcp')
170
171
  expect(text).toContain('+ /x/router.sock')
171
172
  })
172
173
  })
174
+
175
+ describe('FDA detection (iapeer status — fresh-host observability)', () => {
176
+ function base(fda: boolean | null): HostStatus {
177
+ return {
178
+ version: '0.0.0',
179
+ daemon: { healthy: true, url: null, sock: null },
180
+ memory: { provider: null, heartbeatAgeSecs: null },
181
+ fda,
182
+ }
183
+ }
184
+
185
+ test('granted → terse OK line, no instruction', () => {
186
+ const text = formatHostStatus(base(true))
187
+ expect(text).toContain('fda: granted')
188
+ expect(text).not.toContain('NOT granted')
189
+ expect(text).not.toContain('Full Disk Access')
190
+ })
191
+
192
+ test('NOT granted → actionable hint with the binary path', () => {
193
+ const text = formatHostStatus(base(false))
194
+ expect(text).toContain('fda: NOT granted')
195
+ expect(text).toContain('Full Disk Access')
196
+ expect(text).toMatch(/iapeer$|iapeer\n/m) // the grant path ends at the binary
197
+ })
198
+
199
+ test('undeterminable → explicit unknown, never a false claim', () => {
200
+ const text = formatHostStatus(base(null))
201
+ expect(text).toContain('fda: unknown')
202
+ expect(text).not.toContain('NOT granted')
203
+ expect(text).not.toContain('granted\n') // not the bare "granted" either
204
+ })
205
+
206
+ test('hostStatus threads the injected fdaProbe verbatim', async () => {
207
+ const root = mkTmp()
208
+ const s = await hostStatus({ env: envFor(root), probe: async () => true, fdaProbe: () => false })
209
+ expect(s.fda).toBe(false)
210
+ expect(formatHostStatus(s)).toContain('fda: NOT granted')
211
+ })
212
+ })