@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 +1 -1
- package/src/status/index.ts +51 -2
- package/src/status/status.test.ts +43 -3
package/package.json
CHANGED
package/src/status/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
})
|