@agfpd/iapeer 0.1.0

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.
Files changed (63) hide show
  1. package/bin/iapeer +25 -0
  2. package/package.json +37 -0
  3. package/src/cli/cli.test.ts +130 -0
  4. package/src/cli/index.ts +608 -0
  5. package/src/cli/listTui.test.ts +70 -0
  6. package/src/cli/listTui.ts +165 -0
  7. package/src/codec/codec.test.ts +271 -0
  8. package/src/codec/index.ts +217 -0
  9. package/src/core/constants.test.ts +21 -0
  10. package/src/core/constants.ts +180 -0
  11. package/src/core/errors.ts +20 -0
  12. package/src/core/index.ts +3 -0
  13. package/src/core/normalize.test.ts +98 -0
  14. package/src/core/normalize.ts +89 -0
  15. package/src/core/socket.ts +63 -0
  16. package/src/create/create.test.ts +143 -0
  17. package/src/create/index.ts +178 -0
  18. package/src/daemon/daemon-http.test.ts +114 -0
  19. package/src/daemon/daemon.test.ts +103 -0
  20. package/src/daemon/index.ts +439 -0
  21. package/src/daemon/main.test.ts +194 -0
  22. package/src/daemon/main.ts +230 -0
  23. package/src/enable/enable.test.ts +92 -0
  24. package/src/enable/index.ts +381 -0
  25. package/src/identity/identity.test.ts +262 -0
  26. package/src/identity/index.ts +603 -0
  27. package/src/index.ts +27 -0
  28. package/src/init/index.ts +408 -0
  29. package/src/init/init.test.ts +171 -0
  30. package/src/init/runtime-resolve.test.ts +49 -0
  31. package/src/install/index.ts +84 -0
  32. package/src/install/install.test.ts +31 -0
  33. package/src/launch/adapters/claude.ts +250 -0
  34. package/src/launch/adapters/codex.ts +329 -0
  35. package/src/launch/adapters/notifier.ts +90 -0
  36. package/src/launch/adapters/telegram.ts +130 -0
  37. package/src/launch/bootstrap.test.ts +56 -0
  38. package/src/launch/composeSystemPrompt.layers.test.ts +319 -0
  39. package/src/launch/composeSystemPrompt.test.ts +98 -0
  40. package/src/launch/composeSystemPrompt.ts +261 -0
  41. package/src/launch/index.ts +253 -0
  42. package/src/launch/launch.test.ts +233 -0
  43. package/src/launch/launchd.test.ts +363 -0
  44. package/src/launch/launchd.ts +375 -0
  45. package/src/launch/launchdRun.ts +168 -0
  46. package/src/launch/sockdir.test.ts +70 -0
  47. package/src/launch/types.ts +300 -0
  48. package/src/lifecycle/index.ts +840 -0
  49. package/src/lifecycle/lifecycle.test.ts +496 -0
  50. package/src/onboard/index.ts +135 -0
  51. package/src/onboard/onboard.test.ts +39 -0
  52. package/src/provision/index.ts +170 -0
  53. package/src/provision/provision.test.ts +104 -0
  54. package/src/registry/index.ts +453 -0
  55. package/src/registry/registry.test.ts +400 -0
  56. package/src/runtime/deploy.ts +230 -0
  57. package/src/runtime/index.ts +191 -0
  58. package/src/runtime/runtime.test.ts +226 -0
  59. package/src/storage/index.ts +331 -0
  60. package/src/storage/peers-home.test.ts +34 -0
  61. package/src/storage/storage.test.ts +65 -0
  62. package/src/transport/index.ts +522 -0
  63. package/tsconfig.json +17 -0
@@ -0,0 +1,400 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'fs'
3
+ import { tmpdir } from 'os'
4
+ import { join } from 'path'
5
+ import {
6
+ findPeer,
7
+ readPeersIndex,
8
+ removePeer,
9
+ upsertPeer,
10
+ withPeersLock,
11
+ type PeerRecord,
12
+ } from './index.ts'
13
+ import { defaultIntelligenceForRuntime, type Intelligence } from '../core/constants.ts'
14
+ import { writeFileAtomic, resolvePeersPaths } from '../storage/index.ts'
15
+
16
+ let root: string
17
+ const opts = () => ({ rootDir: root })
18
+
19
+ beforeEach(() => {
20
+ root = mkdtempSync(join(tmpdir(), 'iapeer-registry-'))
21
+ })
22
+ afterEach(() => {
23
+ rmSync(root, { recursive: true, force: true })
24
+ })
25
+
26
+ // The live arthur record (from ~/.iapeer/peers-profiles.json): a human peer
27
+ // primarily on telegram, also present on claude, with a telegram interface.
28
+ async function seedArthur(): Promise<void> {
29
+ await upsertPeer(
30
+ {
31
+ personality: 'arthur',
32
+ runtime: 'telegram',
33
+ runtimes: ['telegram', 'claude'],
34
+ description: 'Артур — владелец хоста и команды агентов.',
35
+ intelligence: 'natural',
36
+ interfaces: { telegram: { user_id: '409502965' } },
37
+ cwd: '/Users/macmini/Peers/arthur',
38
+ },
39
+ opts(),
40
+ )
41
+ }
42
+
43
+ // ─────────────────────────────────────────────────────────────────────────────
44
+ // WITNESS: the OLD upsertPeer record-builder (peers.ts:342-358, full-replace).
45
+ // Pure reproduction so each H1 test shows a real delta — the witness produces
46
+ // the clobbered record, the new upsertPeer preserves the existing fields.
47
+ // ─────────────────────────────────────────────────────────────────────────────
48
+
49
+ function oldUpsertRecord(
50
+ args: {
51
+ personality: string
52
+ runtime: string
53
+ runtimes?: readonly string[]
54
+ description?: string
55
+ intelligence?: Intelligence
56
+ interfaces?: Record<string, unknown>
57
+ cwd: string
58
+ },
59
+ ): PeerRecord {
60
+ const runtime = args.runtime
61
+ const runtimes = [runtime, ...(args.runtimes ?? [])].filter((v, i, a) => a.indexOf(v) === i)
62
+ const description = args.description ?? '' // empty wipes existing
63
+ const intelligence =
64
+ args.intelligence !== undefined ? args.intelligence : defaultIntelligenceForRuntime(runtime) // default, not existing
65
+ return {
66
+ personality: args.personality,
67
+ runtime,
68
+ runtimes,
69
+ description,
70
+ intelligence,
71
+ cwd: args.cwd,
72
+ ...(args.interfaces ? { interfaces: args.interfaces } : {}),
73
+ }
74
+ }
75
+
76
+ // The claude-boot upsert (server.ts:266 path): no intelligence, empty
77
+ // description, single runtime claude, no interfaces.
78
+ const bootUpsertArgs = {
79
+ personality: 'arthur',
80
+ runtime: 'claude',
81
+ runtimes: ['claude'],
82
+ description: '',
83
+ cwd: '/Users/macmini/Peers/arthur',
84
+ } as const
85
+
86
+ // ─────────────────────────────────────────────────────────────────────────────
87
+ // H1: merge-with-existing
88
+ // ─────────────────────────────────────────────────────────────────────────────
89
+
90
+ describe('upsertPeer H1 merge-with-existing', () => {
91
+ test('claude-boot upsert WITHOUT intelligence does NOT downgrade a natural peer', async () => {
92
+ await seedArthur()
93
+
94
+ // WITNESS (before fix): old builder overwrites with the claude default (artificial)
95
+ expect(oldUpsertRecord(bootUpsertArgs).intelligence).toBe('artificial')
96
+
97
+ // NEW (after fix): registry preserves natural
98
+ await upsertPeer(bootUpsertArgs, opts())
99
+ const arthur = findPeer(readPeersIndex(opts()), 'arthur')!
100
+ expect(arthur.intelligence).toBe('natural')
101
+ })
102
+
103
+ test('runtimes are unioned, not replaced (telegram not dropped on claude boot)', async () => {
104
+ await seedArthur()
105
+
106
+ // WITNESS: old builder drops telegram, leaving only claude
107
+ expect(oldUpsertRecord(bootUpsertArgs).runtimes).toEqual(['claude'])
108
+
109
+ // NEW: union → telegram + claude both present
110
+ await upsertPeer(bootUpsertArgs, opts())
111
+ const arthur = findPeer(readPeersIndex(opts()), 'arthur')!
112
+ expect(arthur.runtimes).toContain('telegram')
113
+ expect(arthur.runtimes).toContain('claude')
114
+ })
115
+
116
+ test('empty description does NOT wipe a meaningful existing description', async () => {
117
+ await seedArthur()
118
+
119
+ // WITNESS: old builder wipes it to ''
120
+ expect(oldUpsertRecord(bootUpsertArgs).description).toBe('')
121
+
122
+ // NEW: existing description preserved
123
+ await upsertPeer(bootUpsertArgs, opts())
124
+ const arthur = findPeer(readPeersIndex(opts()), 'arthur')!
125
+ expect(arthur.description).toBe('Артур — владелец хоста и команды агентов.')
126
+ })
127
+
128
+ test('interfaces preserved when absent from args', async () => {
129
+ await seedArthur()
130
+
131
+ // WITNESS: old builder drops interfaces (not in args)
132
+ expect(oldUpsertRecord(bootUpsertArgs).interfaces).toBeUndefined()
133
+
134
+ // NEW: telegram interface preserved
135
+ await upsertPeer(bootUpsertArgs, opts())
136
+ const arthur = findPeer(readPeersIndex(opts()), 'arthur')!
137
+ expect(arthur.interfaces).toEqual({ telegram: { user_id: '409502965' } })
138
+ })
139
+
140
+ test('explicit intelligence in args DOES override existing', async () => {
141
+ await seedArthur()
142
+ await upsertPeer({ ...bootUpsertArgs, intelligence: 'artificial' }, opts())
143
+ const arthur = findPeer(readPeersIndex(opts()), 'arthur')!
144
+ expect(arthur.intelligence).toBe('artificial')
145
+ })
146
+
147
+ test('explicit non-empty description in args DOES override', async () => {
148
+ await seedArthur()
149
+ await upsertPeer({ ...bootUpsertArgs, description: 'new desc' }, opts())
150
+ expect(findPeer(readPeersIndex(opts()), 'arthur')!.description).toBe('new desc')
151
+ })
152
+ })
153
+
154
+ // Regression — the audit's CRITICAL finding: the only production caller (provisionPeer
155
+ // from `iapeer init`) forwards the READ-NORMALIZED contract value (profile.intelligence
156
+ // = 'natural' for a legacy 'human' peer) as an explicit upsert intelligence. The write
157
+ // boundary must treat that as a re-assertion of the SAME nature and keep the legacy raw
158
+ // — NOT migrate the on-disk vocab the live legacy-IAP fleet reads.
159
+ describe('upsertPeer vocab-preservation (registry self-defends the legacy raw)', () => {
160
+ const arthurCwd = '/Users/macmini/Peers/arthur'
161
+ async function seedLegacyHuman(): Promise<void> {
162
+ await upsertPeer({ personality: 'arthur', runtime: 'telegram', runtimes: ['telegram', 'claude'], intelligence: 'human' as never, cwd: arthurCwd }, opts())
163
+ }
164
+ test('seeding a legacy human value persists the raw verbatim; read normalizes', async () => {
165
+ await seedLegacyHuman()
166
+ const p = findPeer(readPeersIndex(opts()), 'arthur')!
167
+ expect(p.intelligence).toBe('natural') // READ-normalized contract value
168
+ expect(p.intelligenceRaw).toBe('human') // RAW on disk preserved
169
+ })
170
+ test('re-asserting the SAME nature (read-normalized natural) does NOT migrate human→natural', async () => {
171
+ await seedLegacyHuman()
172
+ // exactly what provisionPeer emits on a routine re-init (no explicit --intelligence):
173
+ await upsertPeer({ personality: 'arthur', runtime: 'telegram', intelligence: 'natural', cwd: arthurCwd }, opts())
174
+ expect(findPeer(readPeersIndex(opts()), 'arthur')!.intelligenceRaw).toBe('human') // STILL human
175
+ })
176
+ test('a GENUINE nature change DOES adopt the new value as the raw', async () => {
177
+ await seedLegacyHuman()
178
+ await upsertPeer({ personality: 'arthur', runtime: 'telegram', intelligence: 'artificial', cwd: arthurCwd }, opts())
179
+ expect(findPeer(readPeersIndex(opts()), 'arthur')!.intelligenceRaw).toBe('artificial')
180
+ })
181
+ test('legacy vocab accepted by the write path (read-path symmetry); garbage rejected', async () => {
182
+ await upsertPeer({ personality: 'srv', runtime: 'notifier', intelligence: 'scripted' as never, cwd: '/tmp/srv' }, opts())
183
+ const s = findPeer(readPeersIndex(opts()), 'srv')!
184
+ expect(s.intelligence).toBe('absent') // scripted → absent (normalized)
185
+ expect(s.intelligenceRaw).toBe('scripted') // raw preserved
186
+ await expect(upsertPeer({ personality: 'bad', runtime: 'claude', intelligence: 'sentient' as never, cwd: '/tmp/bad' }, opts())).rejects.toThrow(/artificial\|natural\|absent/)
187
+ })
188
+ })
189
+
190
+ // ─────────────────────────────────────────────────────────────────────────────
191
+ // New peer: runtime default DOES apply when there is no existing
192
+ // ─────────────────────────────────────────────────────────────────────────────
193
+
194
+ describe('upsertPeer new peer (no existing) — defaults apply', () => {
195
+ test('new claude peer without intelligence → artificial (runtime default)', async () => {
196
+ await upsertPeer({ personality: 'fresh', runtime: 'claude', cwd: '/tmp/fresh' }, opts())
197
+ expect(findPeer(readPeersIndex(opts()), 'fresh')!.intelligence).toBe('artificial')
198
+ })
199
+
200
+ test('new telegram peer without intelligence → natural (runtime default)', async () => {
201
+ await upsertPeer({ personality: 'someone', runtime: 'telegram', cwd: '/tmp/someone' }, opts())
202
+ expect(findPeer(readPeersIndex(opts()), 'someone')!.intelligence).toBe('natural')
203
+ })
204
+ })
205
+
206
+ // ─────────────────────────────────────────────────────────────────────────────
207
+ // Concurrency: single locked writer — no lost update
208
+ // ─────────────────────────────────────────────────────────────────────────────
209
+
210
+ describe('registry locked writer — concurrent upsert does not clobber', () => {
211
+ test('two parallel upserts of the same personality both survive (runtimes union)', async () => {
212
+ await upsertPeer({ personality: 'p', runtime: 'claude', cwd: '/tmp/p' }, opts())
213
+
214
+ // Both add a distinct runtime concurrently. Under the lock each reads the
215
+ // other's write; an unlocked read-modify-write would lose one.
216
+ await Promise.all([
217
+ upsertPeer({ personality: 'p', runtime: 'claude', runtimes: ['codex'], cwd: '/tmp/p' }, opts()),
218
+ upsertPeer({ personality: 'p', runtime: 'claude', runtimes: ['telegram'], cwd: '/tmp/p' }, opts()),
219
+ ])
220
+
221
+ const p = findPeer(readPeersIndex(opts()), 'p')!
222
+ expect(p.runtimes).toContain('claude')
223
+ expect(p.runtimes).toContain('codex')
224
+ expect(p.runtimes).toContain('telegram')
225
+
226
+ // exactly one record for the personality (no duplicate / no clobber)
227
+ expect(readPeersIndex(opts()).peers.filter(x => x.personality === 'p')).toHaveLength(1)
228
+ })
229
+
230
+ test('many parallel upserts of distinct peers all land', async () => {
231
+ const names = Array.from({ length: 12 }, (_, i) => `peer-${i}`)
232
+ await Promise.all(
233
+ names.map(n => upsertPeer({ personality: n, runtime: 'claude', cwd: `/tmp/${n}` }, opts())),
234
+ )
235
+ const index = readPeersIndex(opts())
236
+ for (const n of names) expect(findPeer(index, n)).not.toBeNull()
237
+ expect(index.peers).toHaveLength(12)
238
+ })
239
+ })
240
+
241
+ // ─────────────────────────────────────────────────────────────────────────────
242
+ // Structural invariant #3: peers file is unreachable past the locked API
243
+ // ─────────────────────────────────────────────────────────────────────────────
244
+
245
+ describe('registry single-writer is structural (#3)', () => {
246
+ test('storage.writeFileAtomic REFUSES the peers-profiles.json path', () => {
247
+ const { peersFile } = resolvePeersPaths(opts())
248
+ expect(() => writeFileAtomic(peersFile, '{"version":2,"peers":[]}')).toThrow(/registry/)
249
+ })
250
+
251
+ test('after a refused bypass, the registry file written by upsert is intact JSON', async () => {
252
+ await seedArthur()
253
+ const { peersFile } = resolvePeersPaths(opts())
254
+ expect(() => writeFileAtomic(peersFile, 'CLOBBER')).toThrow()
255
+ const parsed = JSON.parse(readFileSync(peersFile, 'utf8'))
256
+ expect(parsed.version).toBe(2)
257
+ expect(parsed.peers[0].personality).toBe('arthur')
258
+ })
259
+
260
+ test('removePeer also goes through the locked writer', async () => {
261
+ await seedArthur()
262
+ await removePeer('arthur', opts())
263
+ expect(findPeer(readPeersIndex(opts()), 'arthur')).toBeNull()
264
+ })
265
+ })
266
+
267
+ // ─────────────────────────────────────────────────────────────────────────────
268
+ // VOCAB read-compat: legacy human/scripted on disk → contract natural/absent
269
+ // ─────────────────────────────────────────────────────────────────────────────
270
+
271
+ describe('registry read-compat (legacy intelligence vocab)', () => {
272
+ test('legacy registry file (human/scripted) reads as natural/absent, does not crash', () => {
273
+ const { peersFile } = resolvePeersPaths(opts())
274
+ // a legacy file as it exists on the live host today (pre-migration)
275
+ writeFileSync(
276
+ peersFile,
277
+ JSON.stringify({
278
+ version: 2,
279
+ peers: [
280
+ { personality: 'arthur', runtime: 'telegram', runtimes: ['telegram', 'claude'], description: 'Артур', intelligence: 'human', cwd: '/Users/macmini/Peers/arthur' },
281
+ { personality: 'cronjob', runtime: 'cron', runtimes: ['cron'], description: '', intelligence: 'scripted', cwd: '/tmp/cronjob' },
282
+ { personality: 'boris', runtime: 'claude', runtimes: ['claude'], description: 'b', intelligence: 'artificial', cwd: '/tmp/boris' },
283
+ ],
284
+ }),
285
+ )
286
+ const index = readPeersIndex(opts())
287
+ expect(findPeer(index, 'arthur')!.intelligence).toBe('natural') // human → natural
288
+ expect(findPeer(index, 'cronjob')!.intelligence).toBe('absent') // scripted → absent
289
+ expect(findPeer(index, 'boris')!.intelligence).toBe('artificial') // pass-through
290
+ })
291
+
292
+ test('genuinely unknown intelligence value still throws', () => {
293
+ const { peersFile } = resolvePeersPaths(opts())
294
+ writeFileSync(
295
+ peersFile,
296
+ JSON.stringify({
297
+ version: 2,
298
+ peers: [{ personality: 'x', runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'bogus', cwd: '/tmp/x' }],
299
+ }),
300
+ )
301
+ expect(() => readPeersIndex(opts())).toThrow(/intelligence/)
302
+ })
303
+ })
304
+
305
+ // ─────────────────────────────────────────────────────────────────────────────
306
+ // Fix A — GOLDEN round-trip: a registry write PRESERVES every peer's RAW vocab
307
+ // verbatim (legacy-safe). The incident: removePeer rewrote the live registry,
308
+ // persisting the IN-MEMORY normalization (arthur human→natural) → the legacy IAP
309
+ // (human/artificial/scripted only) read "natural" as corrupted → fleet transport
310
+ // down. The fix persists the raw on-disk vocab; normalization is in-memory only.
311
+ // ─────────────────────────────────────────────────────────────────────────────
312
+
313
+ describe('Fix A — registry write preserves raw intelligence vocab (legacy-safe)', () => {
314
+ const seedFullVocab = () => {
315
+ const { peersFile } = resolvePeersPaths(opts())
316
+ writeFileSync(
317
+ peersFile,
318
+ JSON.stringify({
319
+ version: 2,
320
+ peers: [
321
+ { personality: 'phuman', runtime: 'telegram', runtimes: ['telegram'], description: '', intelligence: 'human', cwd: '/a' },
322
+ { personality: 'pscripted', runtime: 'notifier', runtimes: ['notifier'], description: '', intelligence: 'scripted', cwd: '/b' },
323
+ { personality: 'partificial', runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'artificial', cwd: '/c' },
324
+ { personality: 'pnatural', runtime: 'telegram', runtimes: ['telegram'], description: '', intelligence: 'natural', cwd: '/d' },
325
+ { personality: 'pabsent', runtime: 'notifier', runtimes: ['notifier'], description: '', intelligence: 'absent', cwd: '/e' },
326
+ ],
327
+ }),
328
+ )
329
+ }
330
+ const onDiskVocab = () => {
331
+ const disk = JSON.parse(readFileSync(resolvePeersPaths(opts()).peersFile, 'utf8'))
332
+ return Object.fromEntries((disk.peers as { personality: string; intelligence: string }[]).map(p => [p.personality, p.intelligence]))
333
+ }
334
+
335
+ test('in-memory read NORMALIZES (human→natural, scripted→absent) for foundation logic', () => {
336
+ seedFullVocab()
337
+ const idx = readPeersIndex(opts())
338
+ expect(findPeer(idx, 'phuman')!.intelligence).toBe('natural')
339
+ expect(findPeer(idx, 'pscripted')!.intelligence).toBe('absent')
340
+ expect(findPeer(idx, 'partificial')!.intelligence).toBe('artificial')
341
+ })
342
+
343
+ test('GOLDEN: an upsert write preserves EVERY peer raw vocab (human→human, scripted→scripted, …)', async () => {
344
+ seedFullVocab()
345
+ await upsertPeer({ personality: 'trigger', runtime: 'claude', cwd: '/t', intelligence: 'artificial' }, opts())
346
+ const v = onDiskVocab()
347
+ expect(v.phuman).toBe('human') // NOT 'natural' — preserved
348
+ expect(v.pscripted).toBe('scripted') // NOT 'absent' — preserved
349
+ expect(v.partificial).toBe('artificial')
350
+ expect(v.pnatural).toBe('natural')
351
+ expect(v.pabsent).toBe('absent')
352
+ expect(v.trigger).toBe('artificial')
353
+ // the intelligenceRaw shadow field never leaks to disk
354
+ const disk = JSON.parse(readFileSync(resolvePeersPaths(opts()).peersFile, 'utf8'))
355
+ expect((disk.peers as Record<string, unknown>[]).every(p => p.intelligenceRaw === undefined)).toBe(true)
356
+ })
357
+
358
+ test('INCIDENT scenario: removePeer of one peer preserves the legacy vocab of the others', async () => {
359
+ seedFullVocab()
360
+ await removePeer('partificial', opts()) // the exact op that broke the live registry
361
+ const v = onDiskVocab()
362
+ expect(v.partificial).toBeUndefined() // removed
363
+ expect(v.phuman).toBe('human') // arthur-like human peer: vocab INTACT (the fix)
364
+ expect(v.pscripted).toBe('scripted')
365
+ })
366
+ })
367
+
368
+ // ─────────────────────────────────────────────────────────────────────────────
369
+ // Companion fix — test/sandbox isolation is FAIL-CLOSED. The incident root cause
370
+ // (#1): a sandbox run resolved the registry to the REAL ~/.iapeer and rewrote it.
371
+ // Under IAPEER_TEST_SANDBOX=1, withPeersLock REFUSES to write the HOME-default
372
+ // root — a test/sandbox MUST divert via IAPEER_ROOT. (The whole `bun test` run is
373
+ // marked via package.json, so every locked write during tests is guarded.)
374
+ // ─────────────────────────────────────────────────────────────────────────────
375
+
376
+ describe('Companion fix — withPeersLock fail-closed sandbox isolation', () => {
377
+ test('THROWS when IAPEER_TEST_SANDBOX=1 and the root falls through to HOME/.iapeer', async () => {
378
+ // fake HOME, NO IAPEER_ROOT override → resolves to <fakeHome>/.iapeer (the
379
+ // "forgot to divert" case the incident hit). Guard fires BEFORE any FS write.
380
+ const fakeHome = mkdtempSync(join(tmpdir(), 'iapeer-guard-home-'))
381
+ const env = { HOME: fakeHome, IAPEER_TEST_SANDBOX: '1' } as NodeJS.ProcessEnv
382
+ await expect(withPeersLock({ env }, () => 'wrote')).rejects.toThrow(/refusing to write the REAL registry/i)
383
+ rmSync(fakeHome, { recursive: true, force: true })
384
+ })
385
+
386
+ test('ALLOWS the same run when IAPEER_ROOT diverts the root away from HOME/.iapeer', async () => {
387
+ const fakeHome = mkdtempSync(join(tmpdir(), 'iapeer-guard-home-'))
388
+ const env = { HOME: fakeHome, IAPEER_ROOT: join(fakeHome, 'sbx'), IAPEER_TEST_SANDBOX: '1' } as NodeJS.ProcessEnv
389
+ const out = await withPeersLock({ env }, () => 'ok')
390
+ expect(out).toBe('ok')
391
+ rmSync(fakeHome, { recursive: true, force: true })
392
+ })
393
+
394
+ test('NO guard when the sandbox flag is absent (rootDir-isolated tests are unaffected)', async () => {
395
+ // opts() = { rootDir: <mkdtemp> }, env defaults to process.env (no flag in the
396
+ // passed env object) → guard short-circuits, the normal locked write proceeds.
397
+ const out = await withPeersLock({ rootDir: root, env: {} as NodeJS.ProcessEnv }, () => 'ok')
398
+ expect(out).toBe('ok')
399
+ })
400
+ })
@@ -0,0 +1,230 @@
1
+ // deployRuntime — the onboard INFRA-DEPLOY orchestration (contract Установка §2 /
2
+ // Фаза §4). The foundation ORCHESTRATES; the package self-configures. This covers
3
+ // provision MODE (a) "declared-set": read the runtime package's manifest and provision
4
+ // the WHOLE declared peer-set (notifier → timer + watcher), each via `createPeer` —
5
+ // so each peer gets profile + registry + plist (installAlwaysOnPlist + H4 guard) +
6
+ // per-peer self-config hook + auto-bootstrap, uniformly.
7
+ //
8
+ // MODE (b) "operator-add" (telegram human) does NOT come through here — it is a direct
9
+ // `iapeer create maria --runtime telegram`. Both modes share the SAME per-peer
10
+ // self-config hook (invoked inside createPeer→initPeer); deployRuntime is just the
11
+ // enumeration of the declared set. "1 always-on infra peer = 1 plist", idempotent
12
+ // (re-deploy of an existing peer does not duplicate or clobber).
13
+ //
14
+ // The package's host-wide self-INSTALL (npx — puts the `<runtime>-runtime` bin on PATH
15
+ // and writes the manifest) is the package's job (self-deploy). The foundation invokes
16
+ // that installer (onboard delegates) and THEN calls deployRuntime; this module owns the
17
+ // second half (read manifest → provision the declared set).
18
+
19
+ import { spawnSync } from 'child_process'
20
+ import { homedir } from 'os'
21
+ import { type Runtime } from '../core/constants.ts'
22
+ import { IapError } from '../core/errors.ts'
23
+ import { createPeer, type CreatePeerResult } from '../create/index.ts'
24
+ import { readRuntimeManifest, type RuntimeManifest, type RuntimePeerDecl } from './index.ts'
25
+
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+ // Built-in runtime → npm package registry (§6, Артур 08.06): onboard AUTO-resolves
28
+ // the package for a known infra runtime and `npx`-installs it (the package self-
29
+ // deploys its bin + manifest). The operator overrides per-runtime with --package.
30
+ // FROZEN map; a runtime absent here needs an explicit --package.
31
+ // ─────────────────────────────────────────────────────────────────────────────
32
+ export const RUNTIME_PACKAGES: Readonly<Record<string, string>> = {
33
+ telegram: '@agfpd/telegram-runtime',
34
+ notifier: '@agfpd/notifier-runtime',
35
+ }
36
+
37
+ /** Resolve the npm package for a runtime: explicit override wins, else the built-in
38
+ * registry, else undefined (no mapping → caller must pass --package). */
39
+ export function resolveRuntimePackage(runtime: Runtime, override?: string): string | undefined {
40
+ return override?.trim() || RUNTIME_PACKAGES[runtime]
41
+ }
42
+
43
+ export interface DeployRuntimeOptions {
44
+ runtime: Runtime
45
+ /** Override the on-disk manifest (tests / a package handing it in directly). */
46
+ manifest?: RuntimeManifest
47
+ /** Override the peer-set to provision (default: manifest.peers). */
48
+ peers?: RuntimePeerDecl[]
49
+ /** Auto-bootstrap each provisioned plist (default true; per-peer, infra). */
50
+ bootstrap?: boolean
51
+ env?: NodeJS.ProcessEnv
52
+ warn?: (message: string) => void
53
+ }
54
+
55
+ export interface DeployedPeer {
56
+ personality: string
57
+ location: string
58
+ /** self-config hook state for this peer (configured / failed / absent). */
59
+ selfConfig?: string
60
+ /** bootstrap state for this peer (loaded / already-loaded / skipped-sandbox / …). */
61
+ bootstrap?: string
62
+ result: CreatePeerResult
63
+ }
64
+
65
+ export interface DeployRuntimeResult {
66
+ runtime: Runtime
67
+ /** The peers provisioned (the declared set, or the explicit override). */
68
+ peers: DeployedPeer[]
69
+ /** True when the runtime declares no fixed set (mode b — operator-add only). */
70
+ operatorAddOnly: boolean
71
+ }
72
+
73
+ /**
74
+ * Deploy a runtime's DECLARED peer-set (mode a). Reads the package's manifest from
75
+ * ~/.iapeer/runtimes/<runtime>/runtime.json (unless one is handed in), then provisions
76
+ * each declared peer via createPeer (provision + per-peer self-config + auto-bootstrap).
77
+ * A runtime with no declared peers (mode b — telegram) is `operatorAddOnly` (nothing
78
+ * to deploy here; humans are added with `iapeer create`). Throws when no manifest is
79
+ * found at all (the package is not installed — `npx <package>` runs first).
80
+ */
81
+ export async function deployRuntime(opts: DeployRuntimeOptions): Promise<DeployRuntimeResult> {
82
+ const env = opts.env ?? process.env
83
+ const manifest = opts.manifest ?? readRuntimeManifest(opts.runtime, { env })
84
+ if (!manifest) {
85
+ throw new IapError(
86
+ `no runtime manifest for "${opts.runtime}" at ~/.iapeer/runtimes/${opts.runtime}/runtime.json — ` +
87
+ `install the runtime package first (npx <package> self-deploys it)`,
88
+ )
89
+ }
90
+ const declared = opts.peers ?? manifest.peers ?? []
91
+ const peers: DeployedPeer[] = []
92
+ for (const decl of declared) {
93
+ const result = await createPeer({
94
+ personality: decl.personality,
95
+ runtime: opts.runtime,
96
+ intelligence: decl.intelligence,
97
+ description: decl.description,
98
+ path: decl.path,
99
+ runtimeBin: decl.runtimeBin,
100
+ bootstrap: opts.bootstrap,
101
+ env,
102
+ warn: opts.warn,
103
+ })
104
+ peers.push({
105
+ personality: result.personality,
106
+ location: result.location,
107
+ selfConfig: result.selfConfig?.state,
108
+ bootstrap: result.bootstrapped?.state,
109
+ result,
110
+ })
111
+ }
112
+ return { runtime: opts.runtime, peers, operatorAddOnly: declared.length === 0 }
113
+ }
114
+
115
+ // ─────────────────────────────────────────────────────────────────────────────
116
+ // §6 — package self-install (npx) + onboardRuntime (npx → deploy)
117
+ // ─────────────────────────────────────────────────────────────────────────────
118
+
119
+ export type NpxState =
120
+ | 'ran' // npx <package> ran and exited 0 (the package self-deployed)
121
+ | 'skipped' // a manifest is already present → package installed, no re-npx
122
+ | 'failed' // npx exited non-zero
123
+ | 'no-package' // no built-in mapping and no --package, AND no manifest present
124
+
125
+ export interface InstallRuntimePackageResult {
126
+ runtime: Runtime
127
+ package?: string
128
+ state: NpxState
129
+ detail?: string
130
+ }
131
+
132
+ /** Injectable npx runner (so tests / a sandbox proof can simulate the package's
133
+ * self-deploy without a published npm package). Default: `npx -y <package>`. */
134
+ export type NpxRunner = (pkg: string, env: NodeJS.ProcessEnv) => { ok: boolean; detail?: string }
135
+
136
+ const defaultNpxRunner: NpxRunner = (pkg, env) => {
137
+ // The package self-deploys on `npx` (puts its `<runtime>-runtime` bin on PATH and
138
+ // writes ~/.iapeer/runtimes/<r>/runtime.json). IAPEER_ROOT in env steers it to the
139
+ // right root (incl. a sandbox). cwd = HOME (the package is host-wide, cwd-agnostic).
140
+ const r = spawnSync('npx', ['-y', pkg], {
141
+ cwd: env.HOME?.trim() || homedir(),
142
+ encoding: 'utf8',
143
+ env: env as Record<string, string>,
144
+ })
145
+ if (r.error || (r.status ?? 1) !== 0) {
146
+ return { ok: false, detail: (r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim() }
147
+ }
148
+ return { ok: true }
149
+ }
150
+
151
+ export interface InstallRuntimePackageOptions {
152
+ runtime: Runtime
153
+ /** Override the built-in package mapping (--package). */
154
+ package?: string
155
+ /** Re-run npx even when a manifest is already present (force a package update). */
156
+ force?: boolean
157
+ env?: NodeJS.ProcessEnv
158
+ /** Injected npx runner (tests / sandbox proof). */
159
+ runNpx?: NpxRunner
160
+ }
161
+
162
+ /**
163
+ * Ensure a runtime PACKAGE is installed (the package self-deploys its bin + manifest
164
+ * via `npx`). IDEMPOTENT: when a manifest is already present (package installed) and
165
+ * not forced, it is a no-op (`skipped`). Otherwise resolves the package (override →
166
+ * built-in registry) and runs npx. No mapping + no --package + no manifest → `no-package`.
167
+ */
168
+ export function installRuntimePackage(opts: InstallRuntimePackageOptions): InstallRuntimePackageResult {
169
+ const env = opts.env ?? process.env
170
+ const pkg = resolveRuntimePackage(opts.runtime, opts.package)
171
+ const manifestPresent = readRuntimeManifest(opts.runtime, { env }) !== null
172
+ if (manifestPresent && !opts.force) {
173
+ return { runtime: opts.runtime, package: pkg, state: 'skipped' }
174
+ }
175
+ if (!pkg) {
176
+ return { runtime: opts.runtime, state: 'no-package' }
177
+ }
178
+ const run = opts.runNpx ?? defaultNpxRunner
179
+ const r = run(pkg, env)
180
+ return { runtime: opts.runtime, package: pkg, state: r.ok ? 'ran' : 'failed', detail: r.detail }
181
+ }
182
+
183
+ export interface OnboardRuntimeOptions extends DeployRuntimeOptions {
184
+ /** Override the built-in package mapping (--package). */
185
+ package?: string
186
+ /** Re-run npx even when the manifest is already present. */
187
+ npx?: boolean
188
+ /** Injected npx runner (tests / sandbox proof). */
189
+ runNpx?: NpxRunner
190
+ }
191
+
192
+ export interface OnboardRuntimeResult {
193
+ install: InstallRuntimePackageResult
194
+ deploy?: DeployRuntimeResult
195
+ }
196
+
197
+ /**
198
+ * §6 onboard a runtime END-TO-END: (1) ensure the package is installed (npx self-
199
+ * deploy — auto-resolved from the built-in registry, or --package), THEN (2) deploy
200
+ * its declared peer-set. FAIL-CLOSED: a failed npx (or no package AND no manifest)
201
+ * aborts before deploy — never provision against a missing package. A telegram-style
202
+ * runtime (manifest with no declared peers) installs the package and is then
203
+ * operator-add (`iapeer create <human> --runtime telegram`).
204
+ */
205
+ export async function onboardRuntime(opts: OnboardRuntimeOptions): Promise<OnboardRuntimeResult> {
206
+ const env = opts.env ?? process.env
207
+ const install = installRuntimePackage({
208
+ runtime: opts.runtime,
209
+ package: opts.package,
210
+ force: opts.npx,
211
+ env,
212
+ runNpx: opts.runNpx,
213
+ })
214
+ if (install.state === 'failed') {
215
+ throw new IapError(`npx install of runtime "${opts.runtime}" package ${install.package} failed: ${install.detail ?? ''}`)
216
+ }
217
+ if (install.state === 'no-package') {
218
+ throw new IapError(
219
+ `no package for runtime "${opts.runtime}" (no built-in mapping, no --package) and no manifest present — ` +
220
+ `pass --package <npm-package>`,
221
+ )
222
+ }
223
+ const deploy = await deployRuntime({
224
+ runtime: opts.runtime,
225
+ bootstrap: opts.bootstrap,
226
+ env,
227
+ warn: opts.warn,
228
+ })
229
+ return { install, deploy }
230
+ }