@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 +1 -1
- package/src/cli/index.ts +4 -4
- package/src/daemon/main.test.ts +31 -6
- package/src/daemon/main.ts +38 -13
- package/src/status/index.ts +51 -2
- package/src/status/status.test.ts +43 -3
package/package.json
CHANGED
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(`
|
|
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({
|
package/src/daemon/main.test.ts
CHANGED
|
@@ -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
|
|
79
|
-
expect(path).toBe(daemonPlistPath(env))
|
|
80
|
-
expect(
|
|
81
|
-
expect(
|
|
82
|
-
expect(
|
|
83
|
-
|
|
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
|
|
package/src/daemon/main.ts
CHANGED
|
@@ -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
|
|
308
|
-
* deliberate operator step).
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
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 = {}):
|
|
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
|
|
339
|
+
const rendered = renderLaunchdPlist(buildDaemonPlistSpec(opts))
|
|
324
340
|
mkdirSync(launchAgentsDir(env), { recursive: true })
|
|
325
341
|
mkdirSync(pluginLogsDir('iapeer', { env }), { recursive: true, mode: 0o700 })
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
`
|
|
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)
|
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
|
+
})
|