@agfpd/iapeer 0.2.77 → 0.2.79
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/LICENSE +202 -0
- package/README.md +2 -2
- package/package.json +21 -2
- package/src/cli/index.ts +95 -78
- package/src/connect/index.ts +4 -4
- package/src/core/constants.ts +3 -3
- package/src/core/normalize.ts +2 -2
- package/src/create/index.ts +1 -1
- package/src/daemon/index.ts +5 -5
- package/src/daemon/main.ts +9 -9
- package/src/enable/index.ts +19 -18
- package/src/enable/provisionCommand.ts +10 -10
- package/src/identity/index.ts +2 -2
- package/src/identity/profileStandard.ts +8 -8
- package/src/init/index.ts +10 -10
- package/src/install/index.ts +2 -2
- package/src/install/signing.ts +3 -4
- package/src/launch/adapters/claude.ts +7 -7
- package/src/launch/adapters/codex.ts +5 -6
- package/src/launch/canary.ts +8 -8
- package/src/launch/cmdlog.ts +3 -3
- package/src/launch/composeSystemPrompt.ts +2 -1
- package/src/launch/index.ts +5 -6
- package/src/launch/invocation.ts +1 -1
- package/src/launch/launchd.ts +14 -15
- package/src/launch/launchdRun.ts +6 -6
- package/src/launch/nativeMemory.ts +9 -9
- package/src/launch/ptyHost.ts +1 -1
- package/src/launch/readyGateModel.ts +3 -3
- package/src/launch/types.ts +7 -7
- package/src/lifecycle/eventlog.ts +2 -2
- package/src/lifecycle/index.ts +21 -21
- package/src/lifecycle/queue.ts +1 -1
- package/src/onboard/index.ts +19 -13
- package/src/onboard/memory.ts +5 -5
- package/src/onboard/steps.ts +8 -9
- package/src/provision/index.ts +2 -3
- package/src/runtime/deploy.ts +2 -2
- package/src/runtime/index.ts +4 -4
- package/src/shadow/gate.ts +6 -6
- package/src/shadow/index.ts +2 -2
- package/src/status/index.ts +11 -11
- package/src/storage/index.ts +1 -1
- package/src/supervisor/daemon.ts +20 -13
- package/src/transport/index.ts +11 -11
- package/src/update/index.ts +4 -4
- package/src/cli/cli.test.ts +0 -603
- package/src/cli/listTui.test.ts +0 -71
- package/src/codec/codec.test.ts +0 -271
- package/src/connect/connect.test.ts +0 -163
- package/src/core/constants.test.ts +0 -32
- package/src/core/normalize.test.ts +0 -98
- package/src/create/create.test.ts +0 -143
- package/src/daemon/daemon-http.test.ts +0 -114
- package/src/daemon/daemon.test.ts +0 -267
- package/src/daemon/main.test.ts +0 -413
- package/src/enable/enable.test.ts +0 -95
- package/src/enable/provisionCommand.test.ts +0 -107
- package/src/identity/identity.test.ts +0 -279
- package/src/identity/profileStandard.test.ts +0 -204
- package/src/init/init.test.ts +0 -215
- package/src/init/runtime-resolve.test.ts +0 -49
- package/src/install/install.test.ts +0 -66
- package/src/install/signing.test.ts +0 -84
- package/src/launch/bootdeliver.test.ts +0 -108
- package/src/launch/bootstrap.test.ts +0 -56
- package/src/launch/canary.test.ts +0 -346
- package/src/launch/cmdlog.test.ts +0 -94
- package/src/launch/codexHooksTrust.test.ts +0 -248
- package/src/launch/composeSystemPrompt.layers.test.ts +0 -445
- package/src/launch/composeSystemPrompt.test.ts +0 -145
- package/src/launch/hostComposerOccupied.test.ts +0 -63
- package/src/launch/invocation.test.ts +0 -174
- package/src/launch/launch.test.ts +0 -359
- package/src/launch/launchd.test.ts +0 -499
- package/src/launch/nativeMemory.test.ts +0 -193
- package/src/launch/ptyHost.test.ts +0 -38
- package/src/launch/readyGateModel.test.ts +0 -72
- package/src/launch/sockdir.test.ts +0 -74
- package/src/lifecycle/eventlog.test.ts +0 -114
- package/src/lifecycle/lifecycle.test.ts +0 -1056
- package/src/lifecycle/queue.test.ts +0 -185
- package/src/onboard/memory.test.ts +0 -157
- package/src/onboard/onboard.test.ts +0 -39
- package/src/onboard/steps.test.ts +0 -201
- package/src/provision/provision.test.ts +0 -288
- package/src/registry/registry.test.ts +0 -432
- package/src/runtime/runtime.test.ts +0 -226
- package/src/runtime/update.test.ts +0 -192
- package/src/shadow/enumerate-skip.test.ts +0 -67
- package/src/shadow/gate.test.ts +0 -286
- package/src/shadow/install.test.ts +0 -153
- package/src/shadow/shadow.test.ts +0 -42
- package/src/status/status.test.ts +0 -208
- package/src/storage/peers-home.test.ts +0 -34
- package/src/storage/rotatelog.test.ts +0 -44
- package/src/storage/storage.test.ts +0 -65
- package/src/supervisor/attached-marker.test.ts +0 -84
- package/src/supervisor/boot.test.ts +0 -66
- package/src/supervisor/client.attach.test.ts +0 -107
- package/src/supervisor/daemon.test.ts +0 -213
- package/src/supervisor/deliver.test.ts +0 -58
- package/src/supervisor/panelog.test.ts +0 -95
- package/src/supervisor/protocol.test.ts +0 -222
- package/src/supervisor/selfargv.test.ts +0 -22
- package/src/supervisor/spinner-strip.test.ts +0 -52
- package/src/transport/compactDone.test.ts +0 -144
- package/src/transport/composerQueue.test.ts +0 -204
- package/src/transport/hostControl.test.ts +0 -87
- package/src/transport/hostDeliver.test.ts +0 -137
- package/src/transport/hostLive.test.ts +0 -50
- package/src/transport/routesend-wake.test.ts +0 -357
- 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
|
-
})
|