@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.
- package/bin/iapeer +25 -0
- package/package.json +37 -0
- package/src/cli/cli.test.ts +130 -0
- package/src/cli/index.ts +608 -0
- package/src/cli/listTui.test.ts +70 -0
- package/src/cli/listTui.ts +165 -0
- package/src/codec/codec.test.ts +271 -0
- package/src/codec/index.ts +217 -0
- package/src/core/constants.test.ts +21 -0
- package/src/core/constants.ts +180 -0
- package/src/core/errors.ts +20 -0
- package/src/core/index.ts +3 -0
- package/src/core/normalize.test.ts +98 -0
- package/src/core/normalize.ts +89 -0
- package/src/core/socket.ts +63 -0
- package/src/create/create.test.ts +143 -0
- package/src/create/index.ts +178 -0
- package/src/daemon/daemon-http.test.ts +114 -0
- package/src/daemon/daemon.test.ts +103 -0
- package/src/daemon/index.ts +439 -0
- package/src/daemon/main.test.ts +194 -0
- package/src/daemon/main.ts +230 -0
- package/src/enable/enable.test.ts +92 -0
- package/src/enable/index.ts +381 -0
- package/src/identity/identity.test.ts +262 -0
- package/src/identity/index.ts +603 -0
- package/src/index.ts +27 -0
- package/src/init/index.ts +408 -0
- package/src/init/init.test.ts +171 -0
- package/src/init/runtime-resolve.test.ts +49 -0
- package/src/install/index.ts +84 -0
- package/src/install/install.test.ts +31 -0
- package/src/launch/adapters/claude.ts +250 -0
- package/src/launch/adapters/codex.ts +329 -0
- package/src/launch/adapters/notifier.ts +90 -0
- package/src/launch/adapters/telegram.ts +130 -0
- package/src/launch/bootstrap.test.ts +56 -0
- package/src/launch/composeSystemPrompt.layers.test.ts +319 -0
- package/src/launch/composeSystemPrompt.test.ts +98 -0
- package/src/launch/composeSystemPrompt.ts +261 -0
- package/src/launch/index.ts +253 -0
- package/src/launch/launch.test.ts +233 -0
- package/src/launch/launchd.test.ts +363 -0
- package/src/launch/launchd.ts +375 -0
- package/src/launch/launchdRun.ts +168 -0
- package/src/launch/sockdir.test.ts +70 -0
- package/src/launch/types.ts +300 -0
- package/src/lifecycle/index.ts +840 -0
- package/src/lifecycle/lifecycle.test.ts +496 -0
- package/src/onboard/index.ts +135 -0
- package/src/onboard/onboard.test.ts +39 -0
- package/src/provision/index.ts +170 -0
- package/src/provision/provision.test.ts +104 -0
- package/src/registry/index.ts +453 -0
- package/src/registry/registry.test.ts +400 -0
- package/src/runtime/deploy.ts +230 -0
- package/src/runtime/index.ts +191 -0
- package/src/runtime/runtime.test.ts +226 -0
- package/src/storage/index.ts +331 -0
- package/src/storage/peers-home.test.ts +34 -0
- package/src/storage/storage.test.ts +65 -0
- package/src/transport/index.ts +522 -0
- 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
|
+
}
|