@agfpd/iapeer 0.2.76 → 0.2.78

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 (113) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +2 -2
  3. package/package.json +21 -2
  4. package/src/cli/index.ts +47 -48
  5. package/src/connect/index.ts +4 -4
  6. package/src/core/constants.ts +3 -3
  7. package/src/core/normalize.ts +2 -2
  8. package/src/create/index.ts +1 -1
  9. package/src/daemon/index.ts +5 -5
  10. package/src/daemon/main.ts +9 -9
  11. package/src/enable/index.ts +19 -18
  12. package/src/enable/provisionCommand.ts +10 -10
  13. package/src/identity/index.ts +2 -2
  14. package/src/identity/profileStandard.ts +8 -8
  15. package/src/init/index.ts +20 -13
  16. package/src/install/index.ts +2 -2
  17. package/src/install/signing.ts +3 -4
  18. package/src/launch/adapters/claude.ts +7 -7
  19. package/src/launch/adapters/codex.ts +5 -6
  20. package/src/launch/canary.ts +8 -8
  21. package/src/launch/cmdlog.ts +3 -3
  22. package/src/launch/composeSystemPrompt.ts +2 -1
  23. package/src/launch/index.ts +5 -6
  24. package/src/launch/invocation.ts +1 -1
  25. package/src/launch/launchd.ts +14 -15
  26. package/src/launch/launchdRun.ts +6 -6
  27. package/src/launch/nativeMemory.ts +9 -9
  28. package/src/launch/ptyHost.ts +1 -1
  29. package/src/launch/readyGateModel.ts +3 -3
  30. package/src/launch/types.ts +7 -7
  31. package/src/lifecycle/eventlog.ts +2 -2
  32. package/src/lifecycle/index.ts +21 -21
  33. package/src/lifecycle/queue.ts +1 -1
  34. package/src/onboard/index.ts +19 -13
  35. package/src/onboard/memory.ts +5 -5
  36. package/src/onboard/steps.ts +8 -9
  37. package/src/provision/index.ts +2 -3
  38. package/src/runtime/deploy.ts +2 -2
  39. package/src/runtime/index.ts +4 -4
  40. package/src/shadow/gate.ts +6 -6
  41. package/src/shadow/index.ts +2 -2
  42. package/src/status/index.ts +11 -11
  43. package/src/storage/index.ts +1 -1
  44. package/src/supervisor/daemon.ts +14 -8
  45. package/src/transport/index.ts +11 -11
  46. package/src/update/index.ts +4 -4
  47. package/src/cli/cli.test.ts +0 -603
  48. package/src/cli/listTui.test.ts +0 -71
  49. package/src/codec/codec.test.ts +0 -271
  50. package/src/connect/connect.test.ts +0 -163
  51. package/src/core/constants.test.ts +0 -32
  52. package/src/core/normalize.test.ts +0 -98
  53. package/src/create/create.test.ts +0 -143
  54. package/src/daemon/daemon-http.test.ts +0 -114
  55. package/src/daemon/daemon.test.ts +0 -267
  56. package/src/daemon/main.test.ts +0 -413
  57. package/src/enable/enable.test.ts +0 -95
  58. package/src/enable/provisionCommand.test.ts +0 -107
  59. package/src/identity/identity.test.ts +0 -279
  60. package/src/identity/profileStandard.test.ts +0 -204
  61. package/src/init/init.test.ts +0 -213
  62. package/src/init/runtime-resolve.test.ts +0 -49
  63. package/src/install/install.test.ts +0 -66
  64. package/src/install/signing.test.ts +0 -84
  65. package/src/launch/bootdeliver.test.ts +0 -108
  66. package/src/launch/bootstrap.test.ts +0 -56
  67. package/src/launch/canary.test.ts +0 -346
  68. package/src/launch/cmdlog.test.ts +0 -94
  69. package/src/launch/codexHooksTrust.test.ts +0 -248
  70. package/src/launch/composeSystemPrompt.layers.test.ts +0 -445
  71. package/src/launch/composeSystemPrompt.test.ts +0 -145
  72. package/src/launch/hostComposerOccupied.test.ts +0 -63
  73. package/src/launch/invocation.test.ts +0 -174
  74. package/src/launch/launch.test.ts +0 -359
  75. package/src/launch/launchd.test.ts +0 -499
  76. package/src/launch/nativeMemory.test.ts +0 -193
  77. package/src/launch/ptyHost.test.ts +0 -38
  78. package/src/launch/readyGateModel.test.ts +0 -72
  79. package/src/launch/sockdir.test.ts +0 -74
  80. package/src/lifecycle/eventlog.test.ts +0 -114
  81. package/src/lifecycle/lifecycle.test.ts +0 -1056
  82. package/src/lifecycle/queue.test.ts +0 -185
  83. package/src/onboard/memory.test.ts +0 -157
  84. package/src/onboard/onboard.test.ts +0 -39
  85. package/src/onboard/steps.test.ts +0 -201
  86. package/src/provision/provision.test.ts +0 -288
  87. package/src/registry/registry.test.ts +0 -432
  88. package/src/runtime/runtime.test.ts +0 -226
  89. package/src/runtime/update.test.ts +0 -192
  90. package/src/shadow/enumerate-skip.test.ts +0 -67
  91. package/src/shadow/gate.test.ts +0 -286
  92. package/src/shadow/install.test.ts +0 -153
  93. package/src/shadow/shadow.test.ts +0 -42
  94. package/src/status/status.test.ts +0 -208
  95. package/src/storage/peers-home.test.ts +0 -34
  96. package/src/storage/rotatelog.test.ts +0 -44
  97. package/src/storage/storage.test.ts +0 -65
  98. package/src/supervisor/attached-marker.test.ts +0 -84
  99. package/src/supervisor/boot.test.ts +0 -66
  100. package/src/supervisor/client.attach.test.ts +0 -107
  101. package/src/supervisor/daemon.test.ts +0 -213
  102. package/src/supervisor/deliver.test.ts +0 -58
  103. package/src/supervisor/panelog.test.ts +0 -95
  104. package/src/supervisor/protocol.test.ts +0 -222
  105. package/src/supervisor/selfargv.test.ts +0 -22
  106. package/src/supervisor/spinner-strip.test.ts +0 -52
  107. package/src/transport/compactDone.test.ts +0 -144
  108. package/src/transport/composerQueue.test.ts +0 -204
  109. package/src/transport/hostControl.test.ts +0 -87
  110. package/src/transport/hostDeliver.test.ts +0 -137
  111. package/src/transport/hostLive.test.ts +0 -50
  112. package/src/transport/routesend-wake.test.ts +0 -357
  113. package/src/update/update.test.ts +0 -208
@@ -1,432 +0,0 @@
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
- clampDescription,
7
- findPeer,
8
- readPeersIndex,
9
- removePeer,
10
- upsertPeer,
11
- withPeersLock,
12
- type PeerRecord,
13
- } from './index.ts'
14
- import { MAX_DESCRIPTION_LEN, defaultIntelligenceForRuntime, type Intelligence } from '../core/constants.ts'
15
- import { writeFileAtomic, resolvePeersPaths } from '../storage/index.ts'
16
-
17
- let root: string
18
- const opts = () => ({ rootDir: root })
19
-
20
- beforeEach(() => {
21
- root = mkdtempSync(join(tmpdir(), 'iapeer-registry-'))
22
- })
23
- afterEach(() => {
24
- rmSync(root, { recursive: true, force: true })
25
- })
26
-
27
- // The live arthur record (from ~/.iapeer/peers-profiles.json): a human peer
28
- // primarily on telegram, also present on claude, with a telegram interface.
29
- async function seedArthur(): Promise<void> {
30
- await upsertPeer(
31
- {
32
- personality: 'arthur',
33
- runtime: 'telegram',
34
- runtimes: ['telegram', 'claude'],
35
- description: 'Артур — владелец хоста и команды агентов.',
36
- intelligence: 'natural',
37
- interfaces: { telegram: { user_id: '409502965' } },
38
- cwd: '/Users/macmini/Peers/arthur',
39
- },
40
- opts(),
41
- )
42
- }
43
-
44
- // ─────────────────────────────────────────────────────────────────────────────
45
- // WITNESS: the OLD upsertPeer record-builder (peers.ts:342-358, full-replace).
46
- // Pure reproduction so each H1 test shows a real delta — the witness produces
47
- // the clobbered record, the new upsertPeer preserves the existing fields.
48
- // ─────────────────────────────────────────────────────────────────────────────
49
-
50
- function oldUpsertRecord(
51
- args: {
52
- personality: string
53
- runtime: string
54
- runtimes?: readonly string[]
55
- description?: string
56
- intelligence?: Intelligence
57
- interfaces?: Record<string, unknown>
58
- cwd: string
59
- },
60
- ): PeerRecord {
61
- const runtime = args.runtime
62
- const runtimes = [runtime, ...(args.runtimes ?? [])].filter((v, i, a) => a.indexOf(v) === i)
63
- const description = args.description ?? '' // empty wipes existing
64
- const intelligence =
65
- args.intelligence !== undefined ? args.intelligence : defaultIntelligenceForRuntime(runtime) // default, not existing
66
- return {
67
- personality: args.personality,
68
- runtime,
69
- runtimes,
70
- description,
71
- intelligence,
72
- cwd: args.cwd,
73
- ...(args.interfaces ? { interfaces: args.interfaces } : {}),
74
- }
75
- }
76
-
77
- // The claude-boot upsert (server.ts:266 path): no intelligence, empty
78
- // description, single runtime claude, no interfaces.
79
- const bootUpsertArgs = {
80
- personality: 'arthur',
81
- runtime: 'claude',
82
- runtimes: ['claude'],
83
- description: '',
84
- cwd: '/Users/macmini/Peers/arthur',
85
- } as const
86
-
87
- // ─────────────────────────────────────────────────────────────────────────────
88
- // H1: merge-with-existing
89
- // ─────────────────────────────────────────────────────────────────────────────
90
-
91
- describe('upsertPeer H1 merge-with-existing', () => {
92
- test('claude-boot upsert WITHOUT intelligence does NOT downgrade a natural peer', async () => {
93
- await seedArthur()
94
-
95
- // WITNESS (before fix): old builder overwrites with the claude default (artificial)
96
- expect(oldUpsertRecord(bootUpsertArgs).intelligence).toBe('artificial')
97
-
98
- // NEW (after fix): registry preserves natural
99
- await upsertPeer(bootUpsertArgs, opts())
100
- const arthur = findPeer(readPeersIndex(opts()), 'arthur')!
101
- expect(arthur.intelligence).toBe('natural')
102
- })
103
-
104
- test('runtimes are unioned, not replaced (telegram not dropped on claude boot)', async () => {
105
- await seedArthur()
106
-
107
- // WITNESS: old builder drops telegram, leaving only claude
108
- expect(oldUpsertRecord(bootUpsertArgs).runtimes).toEqual(['claude'])
109
-
110
- // NEW: union → telegram + claude both present
111
- await upsertPeer(bootUpsertArgs, opts())
112
- const arthur = findPeer(readPeersIndex(opts()), 'arthur')!
113
- expect(arthur.runtimes).toContain('telegram')
114
- expect(arthur.runtimes).toContain('claude')
115
- })
116
-
117
- test('empty description does NOT wipe a meaningful existing description', async () => {
118
- await seedArthur()
119
-
120
- // WITNESS: old builder wipes it to ''
121
- expect(oldUpsertRecord(bootUpsertArgs).description).toBe('')
122
-
123
- // NEW: existing description preserved
124
- await upsertPeer(bootUpsertArgs, opts())
125
- const arthur = findPeer(readPeersIndex(opts()), 'arthur')!
126
- expect(arthur.description).toBe('Артур — владелец хоста и команды агентов.')
127
- })
128
-
129
- test('interfaces preserved when absent from args', async () => {
130
- await seedArthur()
131
-
132
- // WITNESS: old builder drops interfaces (not in args)
133
- expect(oldUpsertRecord(bootUpsertArgs).interfaces).toBeUndefined()
134
-
135
- // NEW: telegram interface preserved
136
- await upsertPeer(bootUpsertArgs, opts())
137
- const arthur = findPeer(readPeersIndex(opts()), 'arthur')!
138
- expect(arthur.interfaces).toEqual({ telegram: { user_id: '409502965' } })
139
- })
140
-
141
- test('explicit intelligence in args DOES override existing', async () => {
142
- await seedArthur()
143
- await upsertPeer({ ...bootUpsertArgs, intelligence: 'artificial' }, opts())
144
- const arthur = findPeer(readPeersIndex(opts()), 'arthur')!
145
- expect(arthur.intelligence).toBe('artificial')
146
- })
147
-
148
- test('explicit non-empty description in args DOES override', async () => {
149
- await seedArthur()
150
- await upsertPeer({ ...bootUpsertArgs, description: 'new desc' }, opts())
151
- expect(findPeer(readPeersIndex(opts()), 'arthur')!.description).toBe('new desc')
152
- })
153
- })
154
-
155
- // Regression — the audit's CRITICAL finding: the only production caller (provisionPeer
156
- // from `iapeer init`) forwards the READ-NORMALIZED contract value (profile.intelligence
157
- // = 'natural' for a legacy 'human' peer) as an explicit upsert intelligence. The write
158
- // boundary must treat that as a re-assertion of the SAME nature and keep the legacy raw
159
- // — NOT migrate the on-disk vocab the live legacy-IAP fleet reads.
160
- describe('upsertPeer vocab-preservation (registry self-defends the legacy raw)', () => {
161
- const arthurCwd = '/Users/macmini/Peers/arthur'
162
- async function seedLegacyHuman(): Promise<void> {
163
- await upsertPeer({ personality: 'arthur', runtime: 'telegram', runtimes: ['telegram', 'claude'], intelligence: 'human' as never, cwd: arthurCwd }, opts())
164
- }
165
- test('seeding a legacy human value persists the raw verbatim; read normalizes', async () => {
166
- await seedLegacyHuman()
167
- const p = findPeer(readPeersIndex(opts()), 'arthur')!
168
- expect(p.intelligence).toBe('natural') // READ-normalized contract value
169
- expect(p.intelligenceRaw).toBe('human') // RAW on disk preserved
170
- })
171
- test('re-asserting the SAME nature (read-normalized natural) does NOT migrate human→natural', async () => {
172
- await seedLegacyHuman()
173
- // exactly what provisionPeer emits on a routine re-init (no explicit --intelligence):
174
- await upsertPeer({ personality: 'arthur', runtime: 'telegram', intelligence: 'natural', cwd: arthurCwd }, opts())
175
- expect(findPeer(readPeersIndex(opts()), 'arthur')!.intelligenceRaw).toBe('human') // STILL human
176
- })
177
- test('a GENUINE nature change DOES adopt the new value as the raw', async () => {
178
- await seedLegacyHuman()
179
- await upsertPeer({ personality: 'arthur', runtime: 'telegram', intelligence: 'artificial', cwd: arthurCwd }, opts())
180
- expect(findPeer(readPeersIndex(opts()), 'arthur')!.intelligenceRaw).toBe('artificial')
181
- })
182
- test('legacy vocab accepted by the write path (read-path symmetry); garbage rejected', async () => {
183
- await upsertPeer({ personality: 'srv', runtime: 'notifier', intelligence: 'scripted' as never, cwd: '/tmp/srv' }, opts())
184
- const s = findPeer(readPeersIndex(opts()), 'srv')!
185
- expect(s.intelligence).toBe('absent') // scripted → absent (normalized)
186
- expect(s.intelligenceRaw).toBe('scripted') // raw preserved
187
- await expect(upsertPeer({ personality: 'bad', runtime: 'claude', intelligence: 'sentient' as never, cwd: '/tmp/bad' }, opts())).rejects.toThrow(/artificial\|natural\|absent/)
188
- })
189
- })
190
-
191
- // ─────────────────────────────────────────────────────────────────────────────
192
- // New peer: runtime default DOES apply when there is no existing
193
- // ─────────────────────────────────────────────────────────────────────────────
194
-
195
- describe('upsertPeer new peer (no existing) — defaults apply', () => {
196
- test('new claude peer without intelligence → artificial (runtime default)', async () => {
197
- await upsertPeer({ personality: 'fresh', runtime: 'claude', cwd: '/tmp/fresh' }, opts())
198
- expect(findPeer(readPeersIndex(opts()), 'fresh')!.intelligence).toBe('artificial')
199
- })
200
-
201
- test('new telegram peer without intelligence → natural (runtime default)', async () => {
202
- await upsertPeer({ personality: 'someone', runtime: 'telegram', cwd: '/tmp/someone' }, opts())
203
- expect(findPeer(readPeersIndex(opts()), 'someone')!.intelligence).toBe('natural')
204
- })
205
- })
206
-
207
- // ─────────────────────────────────────────────────────────────────────────────
208
- // Concurrency: single locked writer — no lost update
209
- // ─────────────────────────────────────────────────────────────────────────────
210
-
211
- describe('registry locked writer — concurrent upsert does not clobber', () => {
212
- test('two parallel upserts of the same personality both survive (runtimes union)', async () => {
213
- await upsertPeer({ personality: 'p', runtime: 'claude', cwd: '/tmp/p' }, opts())
214
-
215
- // Both add a distinct runtime concurrently. Under the lock each reads the
216
- // other's write; an unlocked read-modify-write would lose one.
217
- await Promise.all([
218
- upsertPeer({ personality: 'p', runtime: 'claude', runtimes: ['codex'], cwd: '/tmp/p' }, opts()),
219
- upsertPeer({ personality: 'p', runtime: 'claude', runtimes: ['telegram'], cwd: '/tmp/p' }, opts()),
220
- ])
221
-
222
- const p = findPeer(readPeersIndex(opts()), 'p')!
223
- expect(p.runtimes).toContain('claude')
224
- expect(p.runtimes).toContain('codex')
225
- expect(p.runtimes).toContain('telegram')
226
-
227
- // exactly one record for the personality (no duplicate / no clobber)
228
- expect(readPeersIndex(opts()).peers.filter(x => x.personality === 'p')).toHaveLength(1)
229
- })
230
-
231
- test('many parallel upserts of distinct peers all land', async () => {
232
- const names = Array.from({ length: 12 }, (_, i) => `peer-${i}`)
233
- await Promise.all(
234
- names.map(n => upsertPeer({ personality: n, runtime: 'claude', cwd: `/tmp/${n}` }, opts())),
235
- )
236
- const index = readPeersIndex(opts())
237
- for (const n of names) expect(findPeer(index, n)).not.toBeNull()
238
- expect(index.peers).toHaveLength(12)
239
- })
240
- })
241
-
242
- // ─────────────────────────────────────────────────────────────────────────────
243
- // Structural invariant #3: peers file is unreachable past the locked API
244
- // ─────────────────────────────────────────────────────────────────────────────
245
-
246
- describe('registry single-writer is structural (#3)', () => {
247
- test('storage.writeFileAtomic REFUSES the peers-profiles.json path', () => {
248
- const { peersFile } = resolvePeersPaths(opts())
249
- expect(() => writeFileAtomic(peersFile, '{"version":2,"peers":[]}')).toThrow(/registry/)
250
- })
251
-
252
- test('after a refused bypass, the registry file written by upsert is intact JSON', async () => {
253
- await seedArthur()
254
- const { peersFile } = resolvePeersPaths(opts())
255
- expect(() => writeFileAtomic(peersFile, 'CLOBBER')).toThrow()
256
- const parsed = JSON.parse(readFileSync(peersFile, 'utf8'))
257
- expect(parsed.version).toBe(2)
258
- expect(parsed.peers[0].personality).toBe('arthur')
259
- })
260
-
261
- test('removePeer also goes through the locked writer', async () => {
262
- await seedArthur()
263
- await removePeer('arthur', opts())
264
- expect(findPeer(readPeersIndex(opts()), 'arthur')).toBeNull()
265
- })
266
- })
267
-
268
- // ─────────────────────────────────────────────────────────────────────────────
269
- // VOCAB read-compat: legacy human/scripted on disk → contract natural/absent
270
- // ─────────────────────────────────────────────────────────────────────────────
271
-
272
- describe('registry read-compat (legacy intelligence vocab)', () => {
273
- test('legacy registry file (human/scripted) reads as natural/absent, does not crash', () => {
274
- const { peersFile } = resolvePeersPaths(opts())
275
- // a legacy file as it exists on the live host today (pre-migration)
276
- writeFileSync(
277
- peersFile,
278
- JSON.stringify({
279
- version: 2,
280
- peers: [
281
- { personality: 'arthur', runtime: 'telegram', runtimes: ['telegram', 'claude'], description: 'Артур', intelligence: 'human', cwd: '/Users/macmini/Peers/arthur' },
282
- { personality: 'cronjob', runtime: 'cron', runtimes: ['cron'], description: '', intelligence: 'scripted', cwd: '/tmp/cronjob' },
283
- { personality: 'boris', runtime: 'claude', runtimes: ['claude'], description: 'b', intelligence: 'artificial', cwd: '/tmp/boris' },
284
- ],
285
- }),
286
- )
287
- const index = readPeersIndex(opts())
288
- expect(findPeer(index, 'arthur')!.intelligence).toBe('natural') // human → natural
289
- expect(findPeer(index, 'cronjob')!.intelligence).toBe('absent') // scripted → absent
290
- expect(findPeer(index, 'boris')!.intelligence).toBe('artificial') // pass-through
291
- })
292
-
293
- test('genuinely unknown intelligence value still throws', () => {
294
- const { peersFile } = resolvePeersPaths(opts())
295
- writeFileSync(
296
- peersFile,
297
- JSON.stringify({
298
- version: 2,
299
- peers: [{ personality: 'x', runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'bogus', cwd: '/tmp/x' }],
300
- }),
301
- )
302
- expect(() => readPeersIndex(opts())).toThrow(/intelligence/)
303
- })
304
- })
305
-
306
- // ─────────────────────────────────────────────────────────────────────────────
307
- // Fix A — GOLDEN round-trip: a registry write PRESERVES every peer's RAW vocab
308
- // verbatim (legacy-safe). The incident: removePeer rewrote the live registry,
309
- // persisting the IN-MEMORY normalization (arthur human→natural) → the legacy IAP
310
- // (human/artificial/scripted only) read "natural" as corrupted → fleet transport
311
- // down. The fix persists the raw on-disk vocab; normalization is in-memory only.
312
- // ─────────────────────────────────────────────────────────────────────────────
313
-
314
- describe('Fix A — registry write preserves raw intelligence vocab (legacy-safe)', () => {
315
- const seedFullVocab = () => {
316
- const { peersFile } = resolvePeersPaths(opts())
317
- writeFileSync(
318
- peersFile,
319
- JSON.stringify({
320
- version: 2,
321
- peers: [
322
- { personality: 'phuman', runtime: 'telegram', runtimes: ['telegram'], description: '', intelligence: 'human', cwd: '/a' },
323
- { personality: 'pscripted', runtime: 'notifier', runtimes: ['notifier'], description: '', intelligence: 'scripted', cwd: '/b' },
324
- { personality: 'partificial', runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'artificial', cwd: '/c' },
325
- { personality: 'pnatural', runtime: 'telegram', runtimes: ['telegram'], description: '', intelligence: 'natural', cwd: '/d' },
326
- { personality: 'pabsent', runtime: 'notifier', runtimes: ['notifier'], description: '', intelligence: 'absent', cwd: '/e' },
327
- ],
328
- }),
329
- )
330
- }
331
- const onDiskVocab = () => {
332
- const disk = JSON.parse(readFileSync(resolvePeersPaths(opts()).peersFile, 'utf8'))
333
- return Object.fromEntries((disk.peers as { personality: string; intelligence: string }[]).map(p => [p.personality, p.intelligence]))
334
- }
335
-
336
- test('in-memory read NORMALIZES (human→natural, scripted→absent) for foundation logic', () => {
337
- seedFullVocab()
338
- const idx = readPeersIndex(opts())
339
- expect(findPeer(idx, 'phuman')!.intelligence).toBe('natural')
340
- expect(findPeer(idx, 'pscripted')!.intelligence).toBe('absent')
341
- expect(findPeer(idx, 'partificial')!.intelligence).toBe('artificial')
342
- })
343
-
344
- test('GOLDEN: an upsert write preserves EVERY peer raw vocab (human→human, scripted→scripted, …)', async () => {
345
- seedFullVocab()
346
- await upsertPeer({ personality: 'trigger', runtime: 'claude', cwd: '/t', intelligence: 'artificial' }, opts())
347
- const v = onDiskVocab()
348
- expect(v.phuman).toBe('human') // NOT 'natural' — preserved
349
- expect(v.pscripted).toBe('scripted') // NOT 'absent' — preserved
350
- expect(v.partificial).toBe('artificial')
351
- expect(v.pnatural).toBe('natural')
352
- expect(v.pabsent).toBe('absent')
353
- expect(v.trigger).toBe('artificial')
354
- // the intelligenceRaw shadow field never leaks to disk
355
- const disk = JSON.parse(readFileSync(resolvePeersPaths(opts()).peersFile, 'utf8'))
356
- expect((disk.peers as Record<string, unknown>[]).every(p => p.intelligenceRaw === undefined)).toBe(true)
357
- })
358
-
359
- test('INCIDENT scenario: removePeer of one peer preserves the legacy vocab of the others', async () => {
360
- seedFullVocab()
361
- await removePeer('partificial', opts()) // the exact op that broke the live registry
362
- const v = onDiskVocab()
363
- expect(v.partificial).toBeUndefined() // removed
364
- expect(v.phuman).toBe('human') // arthur-like human peer: vocab INTACT (the fix)
365
- expect(v.pscripted).toBe('scripted')
366
- })
367
- })
368
-
369
- // ─────────────────────────────────────────────────────────────────────────────
370
- // Companion fix — test/sandbox isolation is FAIL-CLOSED. The incident root cause
371
- // (#1): a sandbox run resolved the registry to the REAL ~/.iapeer and rewrote it.
372
- // Under IAPEER_TEST_SANDBOX=1, withPeersLock REFUSES to write the HOME-default
373
- // root — a test/sandbox MUST divert via IAPEER_ROOT. (The whole `bun test` run is
374
- // marked via package.json, so every locked write during tests is guarded.)
375
- // ─────────────────────────────────────────────────────────────────────────────
376
-
377
- describe('Companion fix — withPeersLock fail-closed sandbox isolation', () => {
378
- test('THROWS when IAPEER_TEST_SANDBOX=1 and the root falls through to HOME/.iapeer', async () => {
379
- // fake HOME, NO IAPEER_ROOT override → resolves to <fakeHome>/.iapeer (the
380
- // "forgot to divert" case the incident hit). Guard fires BEFORE any FS write.
381
- const fakeHome = mkdtempSync(join(tmpdir(), 'iapeer-guard-home-'))
382
- const env = { HOME: fakeHome, IAPEER_TEST_SANDBOX: '1' } as NodeJS.ProcessEnv
383
- await expect(withPeersLock({ env }, () => 'wrote')).rejects.toThrow(/refusing to write the REAL registry/i)
384
- rmSync(fakeHome, { recursive: true, force: true })
385
- })
386
-
387
- test('ALLOWS the same run when IAPEER_ROOT diverts the root away from HOME/.iapeer', async () => {
388
- const fakeHome = mkdtempSync(join(tmpdir(), 'iapeer-guard-home-'))
389
- const env = { HOME: fakeHome, IAPEER_ROOT: join(fakeHome, 'sbx'), IAPEER_TEST_SANDBOX: '1' } as NodeJS.ProcessEnv
390
- const out = await withPeersLock({ env }, () => 'ok')
391
- expect(out).toBe('ok')
392
- rmSync(fakeHome, { recursive: true, force: true })
393
- })
394
-
395
- test('NO guard when the sandbox flag is absent (rootDir-isolated tests are unaffected)', async () => {
396
- // opts() = { rootDir: <mkdtemp> }, env defaults to process.env (no flag in the
397
- // passed env object) → guard short-circuits, the normal locked write proceeds.
398
- const out = await withPeersLock({ rootDir: root, env: {} as NodeJS.ProcessEnv }, () => 'ok')
399
- expect(out).toBe('ok')
400
- })
401
- })
402
-
403
- // ─────────────────────────────────────────────────────────────────────────────
404
- // clampDescription boundary — the limit was raised 250 → 450 so self-documenting
405
- // API-peer descriptions (notifier timer/watcher, ~408 chars) survive intact. The
406
- // boundary is exact: length == MAX passes untouched, length == MAX+1 truncates.
407
- // ─────────────────────────────────────────────────────────────────────────────
408
-
409
- describe('clampDescription — MAX_DESCRIPTION_LEN boundary (450)', () => {
410
- test('the limit is 450', () => {
411
- expect(MAX_DESCRIPTION_LEN).toBe(450)
412
- })
413
- test('a 450-char description passes through untouched', () => {
414
- const at = 'x'.repeat(450)
415
- const r = clampDescription(at)
416
- expect(r.truncated).toBe(false)
417
- expect(r.description).toBe(at)
418
- expect(r.description.length).toBe(450)
419
- })
420
- test('a 451-char description is truncated to 450', () => {
421
- const over = 'y'.repeat(451)
422
- const r = clampDescription(over)
423
- expect(r.truncated).toBe(true)
424
- expect(r.description.length).toBe(450)
425
- expect(r.description).toBe('y'.repeat(450))
426
- })
427
- test('upsertPeer persists a full 450-char description (no clamp at the boundary)', async () => {
428
- const desc = 'z'.repeat(450)
429
- await upsertPeer({ personality: 'verbose', runtime: 'claude', cwd: '/tmp/verbose', description: desc }, opts())
430
- expect(findPeer(readPeersIndex(opts()), 'verbose')!.description).toBe(desc)
431
- })
432
- })
@@ -1,226 +0,0 @@
1
- // runtime contract — manifest read/write, the per-peer self-config hook (env
2
- // passthrough + fail states), and deployRuntime (declared-set provisioning). All under
3
- // IAPEER_ROOT / IAPEER_LAUNCHAGENTS_DIR temp dirs; IAPEER_TEST_SANDBOX skips the real
4
- // launchctl, so deploy provisions (folder + registry + plist) without loading a job.
5
-
6
- import { afterEach, describe, expect, test } from 'bun:test'
7
- import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
8
- import { tmpdir } from 'os'
9
- import { join } from 'path'
10
- import { readRuntimeManifest, runtimeSelfConfig, writeRuntimeManifest, type RuntimeManifest } from './index.ts'
11
- import {
12
- deployRuntime,
13
- installRuntimePackage,
14
- onboardRuntime,
15
- resolveRuntimePackage,
16
- RUNTIME_PACKAGES,
17
- } from './deploy.ts'
18
- import { findPeer, readPeersIndex } from '../registry/index.ts'
19
- import { launchdPlistPath } from '../launch/index.ts'
20
-
21
- const roots: string[] = []
22
- function mkTmp(): string {
23
- const d = mkdtempSync(join(tmpdir(), 'iapeer-rtm-'))
24
- roots.push(d)
25
- return d
26
- }
27
- afterEach(() => {
28
- while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
29
- })
30
-
31
- function envFor(root: string, path?: string): NodeJS.ProcessEnv {
32
- return {
33
- IAPEER_ROOT: join(root, 'iapeer'),
34
- IAPEER_LAUNCHAGENTS_DIR: join(root, 'LA'),
35
- IAPEER_TEST_SANDBOX: '1',
36
- HOME: root,
37
- ...(path ? { PATH: path } : {}),
38
- } as NodeJS.ProcessEnv
39
- }
40
-
41
- /** A stub runtime launcher (so provision's resolveExecutable succeeds) + a stub
42
- * self-config hook that records the per-peer env it was handed. */
43
- function stubBins(): { dir: string; launcher: string; hook: string; marker: (p: string) => string } {
44
- const dir = mkTmp()
45
- const launcher = join(dir, 'notifier-runtime')
46
- writeFileSync(launcher, '#!/bin/sh\nexec sleep 1\n', { mode: 0o755 })
47
- const hook = join(dir, 'self-config.sh')
48
- // record IAPEER_PEER_* to a marker file named by personality → proves env passthrough
49
- writeFileSync(
50
- hook,
51
- '#!/bin/sh\nprintf "%s|%s|%s" "$IAPEER_PEER_PERSONALITY" "$IAPEER_PEER_RUNTIME" "$IAPEER_PEER_INTELLIGENCE" > "$IAPEER_ROOT/sc-$IAPEER_PEER_PERSONALITY"\nexit 0\n',
52
- { mode: 0o755 },
53
- )
54
- return { dir, launcher, hook, marker: p => join(dir, p) }
55
- }
56
-
57
- describe('runtime manifest', () => {
58
- test('write → read round-trip preserves runtime / selfConfig / peers', () => {
59
- const env = envFor(mkTmp())
60
- writeRuntimeManifest(
61
- { runtime: 'notifier', selfConfig: { command: 'notifier-runtime', args: ['self-config'] }, peers: [{ personality: 'timer', intelligence: 'absent' }] },
62
- { env },
63
- )
64
- const m = readRuntimeManifest('notifier', { env })!
65
- expect(m.runtime).toBe('notifier')
66
- expect(m.selfConfig).toEqual({ command: 'notifier-runtime', args: ['self-config'] })
67
- expect(m.peers?.[0].personality).toBe('timer')
68
- })
69
-
70
- test('absent manifest → null; runtime mismatch → throws', () => {
71
- const env = envFor(mkTmp())
72
- expect(readRuntimeManifest('notifier', { env })).toBeNull()
73
- // hand-write a manifest whose runtime field disagrees with its folder
74
- const path = join(env.IAPEER_ROOT as string, 'runtimes', 'notifier')
75
- writeFileSync(join(mkTmp(), 'ignore'), '') // keep tmp tracking happy
76
- rmSync(path, { recursive: true, force: true })
77
- writeRuntimeManifest({ runtime: 'notifier' }, { env })
78
- // corrupt the on-disk runtime field
79
- const file = join(path, 'runtime.json')
80
- writeFileSync(file, JSON.stringify({ runtime: 'telegram' }))
81
- expect(() => readRuntimeManifest('notifier', { env })).toThrow(/mismatch/)
82
- })
83
- })
84
-
85
- describe('runtimeSelfConfig (per-peer hook)', () => {
86
- test('absent when no manifest declares a hook', () => {
87
- const env = envFor(mkTmp())
88
- const r = runtimeSelfConfig({ personality: 'timer', cwd: mkTmp(), runtime: 'notifier', intelligence: 'absent' }, { env })
89
- expect(r.state).toBe('absent')
90
- })
91
-
92
- test('configured: hook runs with IAPEER_PEER_* env (NOT bare PEER_*)', () => {
93
- const root = mkTmp()
94
- const env = envFor(root)
95
- const { hook } = stubBins()
96
- writeRuntimeManifest({ runtime: 'notifier', selfConfig: hook }, { env })
97
- const cwd = mkTmp()
98
- const r = runtimeSelfConfig({ personality: 'timer', cwd, runtime: 'notifier', intelligence: 'absent' }, { env })
99
- expect(r.state).toBe('configured')
100
- // the hook wrote the per-peer env it received
101
- const recorded = readFileSync(join(env.IAPEER_ROOT as string, 'sc-timer'), 'utf8')
102
- expect(recorded).toBe('timer|notifier|absent')
103
- })
104
-
105
- test('failed: a non-zero hook is reported (caller decides fail-closed)', () => {
106
- const root = mkTmp()
107
- const env = envFor(root)
108
- const dir = mkTmp()
109
- const hook = join(dir, 'bad.sh')
110
- writeFileSync(hook, '#!/bin/sh\necho boom >&2\nexit 3\n', { mode: 0o755 })
111
- writeRuntimeManifest({ runtime: 'notifier', selfConfig: hook }, { env })
112
- const r = runtimeSelfConfig({ personality: 'timer', cwd: mkTmp(), runtime: 'notifier', intelligence: 'absent' }, { env })
113
- expect(r.state).toBe('failed')
114
- expect(r.detail).toContain('boom')
115
- })
116
- })
117
-
118
- describe('deployRuntime (declared-set, mode a)', () => {
119
- test('provisions the whole declared peer-set (folder + registry + plist + self-config)', async () => {
120
- const root = mkTmp()
121
- const { dir: bindir, launcher, hook } = stubBins()
122
- const env = envFor(root, bindir)
123
- writeRuntimeManifest(
124
- {
125
- runtime: 'notifier',
126
- selfConfig: hook,
127
- peers: [
128
- { personality: 'timer', intelligence: 'absent' },
129
- { personality: 'watcher', intelligence: 'absent' },
130
- ],
131
- },
132
- { env },
133
- )
134
-
135
- void launcher // resolved by provision via env.PATH (bindir holds notifier-runtime)
136
- const r = await deployRuntime({ runtime: 'notifier', env })
137
- expect(r.operatorAddOnly).toBe(false)
138
- expect(r.peers.map(p => p.personality).sort()).toEqual(['timer', 'watcher'])
139
- for (const p of r.peers) {
140
- expect(p.selfConfig).toBe('configured')
141
- expect(p.bootstrap).toBe('skipped-sandbox') // sandbox: not loaded
142
- expect(findPeer(readPeersIndex({ env }), p.personality)).not.toBeNull()
143
- expect(existsSync(launchdPlistPath(p.personality, env))).toBe(true)
144
- }
145
- })
146
-
147
- test('a runtime with no declared peers → operatorAddOnly (telegram = mode b)', async () => {
148
- const root = mkTmp()
149
- const env = envFor(root)
150
- writeRuntimeManifest({ runtime: 'telegram', selfConfig: 'telegram-runtime self-config' }, { env })
151
- const r = await deployRuntime({ runtime: 'telegram', env })
152
- expect(r.operatorAddOnly).toBe(true)
153
- expect(r.peers.length).toBe(0)
154
- })
155
- })
156
-
157
- describe('§6 — package registry + npx install + onboardRuntime', () => {
158
- test('resolveRuntimePackage: built-in registry, --package override', () => {
159
- expect(resolveRuntimePackage('telegram')).toBe(RUNTIME_PACKAGES.telegram)
160
- expect(resolveRuntimePackage('notifier')).toBe('@agfpd/notifier-runtime')
161
- expect(resolveRuntimePackage('telegram', '@me/fork')).toBe('@me/fork') // override wins
162
- expect(resolveRuntimePackage('webhook')).toBeUndefined() // no mapping
163
- })
164
-
165
- test('installRuntimePackage: skipped when manifest present (idempotent)', () => {
166
- const env = envFor(mkTmp())
167
- writeRuntimeManifest({ runtime: 'notifier' }, { env })
168
- let called = false
169
- const r = installRuntimePackage({ runtime: 'notifier', env, runNpx: () => ((called = true), { ok: true }) })
170
- expect(r.state).toBe('skipped')
171
- expect(called).toBe(false) // never ran npx — package already installed
172
- })
173
-
174
- test('installRuntimePackage: ran (npx invoked) when no manifest', () => {
175
- const env = envFor(mkTmp())
176
- let gotPkg = ''
177
- const r = installRuntimePackage({
178
- runtime: 'notifier',
179
- env,
180
- runNpx: pkg => ((gotPkg = pkg), { ok: true }),
181
- })
182
- expect(r.state).toBe('ran')
183
- expect(gotPkg).toBe('@agfpd/notifier-runtime') // resolved from the registry
184
- })
185
-
186
- test('installRuntimePackage: no-package when no mapping + no --package + no manifest', () => {
187
- const env = envFor(mkTmp())
188
- const r = installRuntimePackage({ runtime: 'webhook', env, runNpx: () => ({ ok: true }) })
189
- expect(r.state).toBe('no-package')
190
- })
191
-
192
- test('onboardRuntime: npx (self-deploy manifest) → deploy declared set', async () => {
193
- const root = mkTmp()
194
- const bindir = mkTmp()
195
- writeFileSync(join(bindir, 'notifier-runtime'), '#!/bin/sh\nexec sleep 1\n', { mode: 0o755 })
196
- const hook = join(bindir, 'sc.sh')
197
- writeFileSync(hook, '#!/bin/sh\nexit 0\n', { mode: 0o755 })
198
- const env = envFor(root, bindir)
199
-
200
- // simulate the package self-deploying its manifest on npx (bin pre-staged on PATH)
201
- const runNpx = (_pkg: string, e: NodeJS.ProcessEnv) => {
202
- const m: RuntimeManifest = {
203
- runtime: 'notifier',
204
- selfConfig: hook,
205
- peers: [{ personality: 'sbxt', intelligence: 'absent' }],
206
- }
207
- writeRuntimeManifest(m, { env: e })
208
- return { ok: true }
209
- }
210
-
211
- const r = await onboardRuntime({ runtime: 'notifier', env, runNpx })
212
- expect(r.install.state).toBe('ran')
213
- expect(r.deploy?.peers.map(p => p.personality)).toEqual(['sbxt'])
214
- expect(r.deploy?.peers[0].selfConfig).toBe('configured')
215
- expect(r.deploy?.peers[0].bootstrap).toBe('skipped-sandbox')
216
- expect(findPeer(readPeersIndex({ env }), 'sbxt')).not.toBeNull()
217
- })
218
-
219
- test('onboardRuntime: failed npx aborts before deploy (fail-closed)', async () => {
220
- const env = envFor(mkTmp())
221
- await expect(
222
- onboardRuntime({ runtime: 'notifier', env, runNpx: () => ({ ok: false, detail: 'network' }) }),
223
- ).rejects.toThrow(/npx install.*failed/)
224
- expect(findPeer(readPeersIndex({ env }), 'sbxt')).toBeNull()
225
- })
226
- })