@agfpd/iapeer 0.2.36 → 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.36",
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": {
@@ -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
+ })