@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,413 +0,0 @@
1
- // Daemon production main — the daemon's OWN launchd plist (com.agfpd.iapeer),
2
- // the composition smoke (startConfiguredDaemon returns a live TCP handle), and the
3
- // DORMANT H8 bearer seam (off by default → no auth; on only when a token is set).
4
- // All plist writes go under IAPEER_LAUNCHAGENTS_DIR so the suite never touches the
5
- // real ~/Library/LaunchAgents.
6
-
7
- import { afterEach, describe, expect, test } from 'bun:test'
8
- import { spawnSync } from 'child_process'
9
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs'
10
- import { tmpdir } from 'os'
11
- import { join } from 'path'
12
- import {
13
- buildDaemonPlistSpec,
14
- daemonPlistPath,
15
- installDaemonPlist,
16
- makeArmEphemeralOnDelivered,
17
- makeEphemeralRouteDeps,
18
- makeNoteLiveTopic,
19
- startConfiguredDaemon,
20
- DEFAULT_DAEMON_PORT,
21
- } from './main.ts'
22
- import { daemonDiscoveryPath, defaultDaemonSocketPath, startDaemon, type DaemonHandle } from './index.ts'
23
- import {
24
- ephemeralQueueDepth,
25
- hasEphemeralArmed,
26
- peekEphemeralTask,
27
- readTopic,
28
- resolveWakeMode,
29
- setIdleReaped,
30
- writeTopic,
31
- type LifecycleConfig,
32
- } from '../lifecycle/index.ts'
33
- import type { ResolvedCaller } from '../identity/index.ts'
34
- import { isFoundationOwnedPlist } from '../launch/index.ts'
35
- import { DAEMON_PLIST_LABEL } from '../core/constants.ts'
36
-
37
- const tmpDirs: string[] = []
38
- function mkTmp(): string {
39
- const d = mkdtempSync(join(tmpdir(), 'iapeer-daemon-main-'))
40
- tmpDirs.push(d)
41
- return d
42
- }
43
- afterEach(() => {
44
- while (tmpDirs.length) rmSync(tmpDirs.pop()!, { recursive: true, force: true })
45
- })
46
-
47
- function plutilLint(path: string): boolean {
48
- return spawnSync('plutil', ['-lint', path], { encoding: 'utf8' }).status === 0
49
- }
50
-
51
- describe('buildDaemonPlistSpec / installDaemonPlist (com.agfpd.iapeer)', () => {
52
- test('spec carries the daemon label, default port env, and the INSTALLED iapeer entrypoint (Ф-F)', () => {
53
- const spec = buildDaemonPlistSpec({ env: { HOME: '/Users/x' } as NodeJS.ProcessEnv })
54
- expect(spec.label).toBe(DAEMON_PLIST_LABEL) // com.agfpd.iapeer — NOT com.iapeer.*
55
- expect(spec.environment.IAPEER_PORT).toBe(String(DEFAULT_DAEMON_PORT))
56
- // Ф-F: runs the INSTALLED binary `iapeer daemon`, NOT `bun <src>/main.ts` — prod
57
- // decoupled from the mutable src tree.
58
- expect(spec.programArguments).toEqual(['/Users/x/.local/bin/iapeer', 'daemon'])
59
- })
60
-
61
- test('NO bearer token in the plist env by default (H8 seam dormant)', () => {
62
- expect(buildDaemonPlistSpec({ env: {} as NodeJS.ProcessEnv }).environment.IAPEER_BEARER_TOKEN).toBeUndefined()
63
- })
64
-
65
- test('bearer token is baked into the plist env only when explicitly provided', () => {
66
- const spec = buildDaemonPlistSpec({ bearerToken: 's3cret', env: {} as NodeJS.ProcessEnv })
67
- expect(spec.environment.IAPEER_BEARER_TOKEN).toBe('s3cret')
68
- })
69
-
70
- test('installs a valid, foundation-owned plist under IAPEER_LAUNCHAGENTS_DIR', () => {
71
- const root = mkTmp()
72
- const env = {
73
- IAPEER_LAUNCHAGENTS_DIR: join(root, 'LaunchAgents'),
74
- IAPEER_ROOT: join(root, 'iapeer'),
75
- HOME: root,
76
- PATH: '/usr/bin:/bin',
77
- } as NodeJS.ProcessEnv
78
- const r = installDaemonPlist({ env, port: 8765, throttleIntervalSecs: 10 })
79
- expect(r.path).toBe(daemonPlistPath(env))
80
- expect(r.changed).toBe(true) // first install on a clean host writes
81
- expect(existsSync(r.path)).toBe(true)
82
- expect(isFoundationOwnedPlist(r.path)).toBe(true)
83
- expect(plutilLint(r.path)).toBe(true) // live plutil: valid plist
84
- const xml = readFileSync(r.path, 'utf8')
85
- expect(xml).toContain(`<string>${DAEMON_PLIST_LABEL}</string>`)
86
- expect(xml).toContain('<key>RunAtLoad</key>')
87
- expect(xml).toContain('<key>KeepAlive</key>')
88
- })
89
-
90
- test('idempotent by content: a re-install of the SAME plist does NOT rewrite the file (changed:false, mtime stable) — no BTM notification spam (owner-caught 11.06)', () => {
91
- const root = mkTmp()
92
- const env = {
93
- IAPEER_LAUNCHAGENTS_DIR: join(root, 'LaunchAgents'),
94
- IAPEER_ROOT: join(root, 'iapeer'),
95
- HOME: root,
96
- PATH: '/usr/bin:/bin',
97
- } as NodeJS.ProcessEnv
98
- const opts = { env, port: 8765, throttleIntervalSecs: 10 }
99
- const first = installDaemonPlist(opts)
100
- expect(first.changed).toBe(true)
101
- const mtime1 = statSync(first.path).mtimeMs
102
- // second identical install — must be a pure no-op (no write → BTM not tripped)
103
- const second = installDaemonPlist(opts)
104
- expect(second.changed).toBe(false)
105
- expect(second.path).toBe(first.path)
106
- expect(statSync(second.path).mtimeMs).toBe(mtime1) // file untouched, not just byte-equal
107
- // a CHANGED spec (different port → different rendered plist) DOES rewrite
108
- const third = installDaemonPlist({ env, port: 9999, throttleIntervalSecs: 10 })
109
- expect(third.changed).toBe(true)
110
- })
111
-
112
- test('REFUSES to overwrite a foreign com.agfpd.iapeer.plist (collision guard)', () => {
113
- const root = mkTmp()
114
- const laDir = join(root, 'LaunchAgents')
115
- mkdirSync(laDir, { recursive: true })
116
- const env = { IAPEER_LAUNCHAGENTS_DIR: laDir, IAPEER_ROOT: join(root, 'iapeer'), HOME: root } as NodeJS.ProcessEnv
117
- const path = daemonPlistPath(env)
118
- const foreign = '<?xml version="1.0"?>\n<plist><dict><key>Label</key><string>com.agfpd.iapeer</string></dict></plist>\n'
119
- writeFileSync(path, foreign)
120
- expect(() => installDaemonPlist({ env })).toThrow(/foundation-managed|refus/i)
121
- expect(readFileSync(path, 'utf8')).toBe(foreign) // untouched
122
- // (the throw must fire BEFORE the write-if-changed read, so the foreign file
123
- // is never even compared — the guard is the first gate.)
124
- })
125
- })
126
-
127
- describe('startConfiguredDaemon (composition smoke — TCP loopback)', () => {
128
- test('returns a live http handle and closes cleanly', async () => {
129
- const root = mkTmp()
130
- const env = { IAPEER_ROOT: join(root, 'iapeer'), HOME: root, PATH: '/usr/bin:/bin' } as NodeJS.ProcessEnv
131
- const handle = await startConfiguredDaemon({ port: 0, host: '127.0.0.1', env })
132
- try {
133
- expect(handle.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/mcp$/)
134
- } finally {
135
- await handle.close()
136
- }
137
- })
138
- })
139
-
140
- // The H8 bearer seam: a validator LAYER on the listener, OFF unless a token is set
141
- // (Артур 07.06 — H8 DEFERRED, no token provisioning yet). These tests prove the
142
- // seam is dormant by default and would gate every request once a token is wired.
143
- describe('H8 bearer seam (dormant by default)', () => {
144
- const INIT = JSON.stringify({
145
- jsonrpc: '2.0', id: 1, method: 'initialize',
146
- params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 't', version: '0' } },
147
- })
148
- const headers = (auth?: string): Record<string, string> => ({
149
- 'content-type': 'application/json',
150
- accept: 'application/json, text/event-stream',
151
- ...(auth ? { authorization: auth } : {}),
152
- })
153
-
154
- async function withDaemon(opts: Parameters<typeof startDaemon>[0], fn: (h: DaemonHandle) => Promise<void>) {
155
- const h = await startDaemon(opts)
156
- try {
157
- await fn(h)
158
- } finally {
159
- await h.close()
160
- }
161
- }
162
-
163
- test('NO token → request is NOT 401 (auth layer off)', async () => {
164
- await withDaemon({ port: 0, host: '127.0.0.1' }, async h => {
165
- const res = await fetch(h.url!, { method: 'POST', headers: headers(), body: INIT })
166
- expect(res.status).not.toBe(401)
167
- })
168
- })
169
-
170
- test('token set → no Authorization is 401, correct Bearer passes the layer', async () => {
171
- await withDaemon({ port: 0, host: '127.0.0.1', bearerToken: 'secret' }, async h => {
172
- const noAuth = await fetch(h.url!, { method: 'POST', headers: headers(), body: INIT })
173
- expect(noAuth.status).toBe(401)
174
- const wrong = await fetch(h.url!, { method: 'POST', headers: headers('Bearer nope'), body: INIT })
175
- expect(wrong.status).toBe(401)
176
- const ok = await fetch(h.url!, { method: 'POST', headers: headers('Bearer secret'), body: INIT })
177
- expect(ok.status).not.toBe(401) // passed the auth layer (MCP handles the rest)
178
- await ok.body?.cancel()
179
- })
180
- })
181
- })
182
-
183
- // Dual-listen (Ф2 consolidation): the daemon serves a 0600 unix socket (local
184
- // same-uid callers: notifier/telegram/CLI) AND TCP (agent MCP) over ONE handler,
185
- // and writes router.json (both addresses) for daemon-aware `iap send`.
186
- describe('dual-listen + router.json discovery', () => {
187
- const INIT = JSON.stringify({
188
- jsonrpc: '2.0', id: 1, method: 'initialize',
189
- params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 't', version: '0' } },
190
- })
191
- const mcpHeaders = { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }
192
-
193
- test('binds socket + TCP over one handler; router.json carries both; close cleans up', async () => {
194
- const root = mkTmp()
195
- const env = { IAPEER_ROOT: join(root, 'iapeer') } as NodeJS.ProcessEnv
196
- const sockExpected = defaultDaemonSocketPath({ env })
197
- const h = await startDaemon({ port: 0, host: '127.0.0.1', socketPath: sockExpected, discovery: true, env })
198
- try {
199
- // TCP listener answers the MCP handler
200
- const tcp = await fetch(h.url!, { method: 'POST', headers: mcpHeaders, body: INIT })
201
- expect(tcp.ok).toBe(true)
202
- await tcp.body?.cancel()
203
- // unix-socket listener answers the SAME handler (bun fetch `unix` option)
204
- const unixInit = { unix: h.socketPath!, method: 'POST', headers: mcpHeaders, body: INIT } as unknown as RequestInit
205
- const sock = await fetch('http://localhost/mcp', unixInit)
206
- expect(sock.ok).toBe(true)
207
- await sock.body?.cancel()
208
- // socket is 0600 (same-uid)
209
- expect((statSync(h.socketPath!).mode & 0o777).toString(8)).toBe('600')
210
- // router.json carries BOTH addresses
211
- const rj = JSON.parse(readFileSync(daemonDiscoveryPath({ env }), 'utf8'))
212
- expect(rj.sock).toBe(sockExpected)
213
- expect(rj.tcp).toBe(h.url)
214
- } finally {
215
- await h.close()
216
- }
217
- // clean shutdown removed the socket file AND router.json
218
- expect(existsSync(sockExpected)).toBe(false)
219
- expect(existsSync(daemonDiscoveryPath({ env }))).toBe(false)
220
- })
221
-
222
- test('port-only stays TCP-only — no socket, no router.json (backward compat)', async () => {
223
- const root = mkTmp()
224
- const env = { IAPEER_ROOT: join(root, 'iapeer') } as NodeJS.ProcessEnv
225
- const h = await startDaemon({ port: 0, host: '127.0.0.1', env })
226
- try {
227
- expect(h.socketPath).toBeUndefined()
228
- expect(existsSync(daemonDiscoveryPath({ env }))).toBe(false)
229
- } finally {
230
- await h.close()
231
- }
232
- })
233
- })
234
-
235
- describe('startDaemon composerQueue shutdown seam', () => {
236
- test('close() fails pending busy-composer queue before tearing listeners down', async () => {
237
- const reasons: string[] = []
238
- const h = await startDaemon({
239
- port: 0,
240
- host: '127.0.0.1',
241
- composerQueue: {
242
- tryEnqueue: async () => null,
243
- failAll: async reason => {
244
- reasons.push(reason)
245
- },
246
- },
247
- })
248
- await h.close()
249
- expect(reasons).toEqual(['daemon shutting down/restarting before queued delivery completed'])
250
- })
251
- })
252
-
253
- // ─────────────────────────────────────────────────────────────────────────────
254
- // wake_policy:ephemeral M2 — makeArmEphemeralOnDelivered (the arm-on-outbound
255
- // composition: ephemeral caller's ok send ⇒ .ephemeral-armed for its identity)
256
- // ─────────────────────────────────────────────────────────────────────────────
257
-
258
- describe('makeArmEphemeralOnDelivered (M2 arm-on-outbound)', () => {
259
- function caller(cwd: string): ResolvedCaller {
260
- return {
261
- personality: 'w',
262
- runtime: 'claude',
263
- address: 'claude-w',
264
- description: '',
265
- intelligence: 'artificial',
266
- cwd,
267
- record: { personality: 'w', runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'artificial', cwd },
268
- } as ResolvedCaller
269
- }
270
-
271
- test('EPHEMERAL caller → armed for the CALLER identity; plain caller → no-op', () => {
272
- const stateDir = join(mkTmp(), 'lifecycle')
273
- const cfg = { stateDir } as LifecycleConfig
274
- const arm = makeArmEphemeralOnDelivered(cfg)
275
- // ephemeral worker cwd
276
- const eph = mkTmp()
277
- mkdirSync(join(eph, '.iapeer'), { recursive: true })
278
- writeFileSync(
279
- join(eph, '.iapeer', 'peer-profile.json'),
280
- JSON.stringify({ personality: 'w', runtime: 'claude', wake_policy: 'ephemeral' }),
281
- )
282
- arm(caller(eph))
283
- expect(hasEphemeralArmed(cfg, 'claude-w')).toBe(true)
284
- // plain peer cwd (no wake_policy) → never armed
285
- const plainCfg = { stateDir: join(mkTmp(), 'lifecycle') } as LifecycleConfig
286
- const plain = mkTmp()
287
- mkdirSync(join(plain, '.iapeer'), { recursive: true })
288
- writeFileSync(
289
- join(plain, '.iapeer', 'peer-profile.json'),
290
- JSON.stringify({ personality: 'w', runtime: 'claude' }),
291
- )
292
- makeArmEphemeralOnDelivered(plainCfg)(caller(plain))
293
- expect(hasEphemeralArmed(plainCfg, 'claude-w')).toBe(false)
294
- })
295
-
296
- test('missing profile / unreadable cwd → no-op, never throws (best-effort)', () => {
297
- const cfg = { stateDir: join(mkTmp(), 'lifecycle') } as LifecycleConfig
298
- const arm = makeArmEphemeralOnDelivered(cfg)
299
- expect(() => arm(caller('/tmp/no-such-peer-cwd-anywhere'))).not.toThrow()
300
- expect(hasEphemeralArmed(cfg, 'claude-w')).toBe(false)
301
- })
302
- })
303
-
304
- // ─────────────────────────────────────────────────────────────────────────────
305
- // wake_policy:"ephemeral" M3 — makeEphemeralRouteDeps (enqueue + async drain kick)
306
- // ─────────────────────────────────────────────────────────────────────────────
307
-
308
- describe('makeEphemeralRouteDeps (M3 serial-queue composition)', () => {
309
- const workerPeer = {
310
- personality: 'w',
311
- runtime: 'claude' as const,
312
- runtimes: ['claude' as const],
313
- description: '',
314
- intelligence: 'artificial' as const,
315
- cwd: '/tmp/w',
316
- }
317
-
318
- test('deliver = enqueue to disk + fast {queued, qd} ack + drain kick (NOT awaited wake)', async () => {
319
- const stateDir = join(mkTmp(), 'lifecycle')
320
- const cfg = { stateDir } as LifecycleConfig
321
- const kicks: string[] = []
322
- const deps = makeEphemeralRouteDeps(cfg, {} as NodeJS.ProcessEnv, (p, rt) => kicks.push(`${rt}-${p}`))
323
-
324
- const r1 = await deps.deliver({ peer: workerPeer, envelope: '<iap>task one</iap>', topic: 't1' })
325
- const r2 = await deps.deliver({ peer: workerPeer, envelope: '<iap>task two</iap>' })
326
-
327
- expect(r1.ok && r1.value.queued).toBe(true)
328
- expect(r1.ok && r1.value.queuedBy).toBe('ephemeral')
329
- expect(r1.ok && r1.value.queueDepth).toBe(1)
330
- expect(r1.ok && r1.value.woke).toBe(false)
331
- expect(r2.ok && r2.value.queueDepth).toBe(2)
332
- expect(kicks).toEqual(['claude-w', 'claude-w']) // drain kicked per enqueue
333
- // durable FIFO on disk, head intact
334
- expect(ephemeralQueueDepth(cfg, 'claude-w')).toBe(2)
335
- expect(peekEphemeralTask(cfg, 'claude-w')).toMatchObject({ task: '<iap>task one</iap>', topic: 't1' })
336
- })
337
-
338
- test('enqueue failure → fail-loud delivery error (never a false queued ack)', async () => {
339
- const root = mkTmp()
340
- const blocker = join(root, 'state-is-a-file')
341
- writeFileSync(blocker, 'not a dir')
342
- const cfg = { stateDir: join(blocker, 'lifecycle') } as LifecycleConfig // mkdir must fail
343
- const deps = makeEphemeralRouteDeps(cfg, {} as NodeJS.ProcessEnv, () => {})
344
- const r = await deps.deliver({ peer: workerPeer, envelope: 'x' })
345
- expect(r.ok).toBe(false)
346
- if (!r.ok) expect(r.error.message).toMatch(/enqueue failed/)
347
- })
348
-
349
- test('runtime override resolves through the registry gate (undeclared → fail-loud)', async () => {
350
- const cfg = { stateDir: join(mkTmp(), 'lifecycle') } as LifecycleConfig
351
- const deps = makeEphemeralRouteDeps(cfg, {} as NodeJS.ProcessEnv, () => {})
352
- const r = await deps.deliver({ peer: workerPeer, envelope: 'x', runtime: 'codex' })
353
- expect(r.ok).toBe(false) // 'codex' is not declared for this worker
354
- })
355
- })
356
-
357
- // ─────────────────────────────────────────────────────────────────────────────
358
- // makeNoteLiveTopic — the live-delivered-topic composition (fresh-vs-resume seam):
359
- // the .topic marker must track the topic the session last WORKED ON, not the one
360
- // it woke with (defect 11.06: a day-stale wake-time marker false-freshed the
361
- // stop→start resume of a long-lived executor).
362
- // ─────────────────────────────────────────────────────────────────────────────
363
-
364
- describe('makeNoteLiveTopic (live-delivered topic → .topic marker)', () => {
365
- function noteCfg(): LifecycleConfig {
366
- const root = mkTmp()
367
- return { stateDir: join(root, 'state'), eventLogDir: join(root, 'logs') } as LifecycleConfig
368
- }
369
-
370
- test('topic SHIFT → marker updated + ev=topic-note logged; same topic → no churn, no log spam', () => {
371
- const cfg = noteCfg()
372
- const note = makeNoteLiveTopic(cfg, {} as NodeJS.ProcessEnv)
373
- writeTopic(cfg, 'claude-x', 'topic-a') // the wake established topic A
374
- note('claude-x', 'topic-b') // a live delivery switches the work to B
375
- expect(readTopic(cfg, 'claude-x')).toBe('topic-b')
376
- const logPath = join(cfg.eventLogDir, 'lifecycle.log')
377
- const log = readFileSync(logPath, 'utf8')
378
- expect(log).toContain('ev=topic-note')
379
- expect(log).toContain('identity=claude-x')
380
- expect(log).toContain('topic=topic-b')
381
- // the common case — every further message continues topic B → strictly nothing
382
- note('claude-x', 'topic-b')
383
- note('claude-x', 'topic-b')
384
- expect(readFileSync(logPath, 'utf8').match(/ev=topic-note/g)?.length).toBe(1)
385
- })
386
-
387
- test('acceptance scenario (boris 11.06): woke with topic A → worked live-delivered topic B → clean stop-park → wake with topic B = RESUME, not fresh', () => {
388
- const cfg = noteCfg()
389
- const id = 'claude-x'
390
- // executor peer cwd: no telegram interface (not human-conversational), no wake_policy
391
- const cwd = mkTmp()
392
- mkdirSync(join(cwd, '.iapeer'), { recursive: true })
393
- writeFileSync(join(cwd, '.iapeer', 'peer-profile.json'), JSON.stringify({ personality: 'x', runtime: 'claude' }))
394
- writeTopic(cfg, id, 'topic-a') // session woke with topic A (the stale pre-fix marker)
395
- makeNoteLiveTopic(cfg)(id, 'topic-b') // ...then took topic-B messages in the LIVE session all day
396
- setIdleReaped(cfg, id) // deliberate stop = clean park (stopPeer's promise)
397
- const hasTranscript = () => ({ ok: true, ref: 'uuid-1' })
398
- // pre-fix this read the stale 'topic-a' → cause=idle-reaped-new-topic → FRESH (context lost)
399
- expect(resolveWakeMode(cfg, id, cwd, undefined, hasTranscript, 'topic-b')).toEqual({
400
- resume: true,
401
- resumeRef: 'uuid-1',
402
- cause: 'idle-reaped-resume',
403
- })
404
- })
405
-
406
- test('marker/log failure → no-op, never throws (a delivered message must stand)', () => {
407
- const root = mkTmp()
408
- const blocker = join(root, 'state-is-a-file')
409
- writeFileSync(blocker, 'not a dir')
410
- const cfg = { stateDir: join(blocker, 'state'), eventLogDir: join(blocker, 'logs') } as LifecycleConfig // mkdir must fail
411
- expect(() => makeNoteLiveTopic(cfg)('claude-x', 'topic-b')).not.toThrow()
412
- })
413
- })
@@ -1,95 +0,0 @@
1
- // enable — the PURE fleet-safety + idempotency logic: parse `claude plugin list
2
- // --json`, match the entry for THIS peer's projectPath (realpath), and detect a
3
- // plugin's `setup` from iapeer.json (SIMPLE vs СЛОЖНЫЙ). The spawn/install paths are
4
- // live-verified on a throwaway test-peer (project-scope is keyed by projectPath, so a
5
- // test install never touches a live peer's entry).
6
-
7
- import { describe, expect, test } from 'bun:test'
8
- import { mkdtempSync, rmSync, writeFileSync } from 'fs'
9
- import { tmpdir } from 'os'
10
- import { join } from 'path'
11
- import { findPeerScopedEntry, parseCodexPluginStatus, parseInstalledPlugins, readSetupDescriptor } from './index.ts'
12
-
13
- // a realistic `claude plugin list --json` array (the live shape: id/scope/enabled/
14
- // projectPath/installPath), two live peers + the same plugin user-scope.
15
- const LIST = JSON.stringify([
16
- { id: 'Inter-Agent-Protocol@agfpd', scope: 'project', enabled: true, projectPath: '/Users/x/agents/linus', installPath: '/c/iap/0.7.11' },
17
- { id: 'peer-voice@agfpd', scope: 'project', enabled: true, projectPath: '/Users/x/agents/boris', installPath: '/c/pv/0.1.8' },
18
- { id: 'peer-voice@agfpd', scope: 'user', enabled: false, installPath: '/c/pv/0.1.8' },
19
- ])
20
-
21
- describe('parseInstalledPlugins', () => {
22
- test('parses the flat array into typed entries; tolerates junk', () => {
23
- const e = parseInstalledPlugins(LIST)
24
- expect(e.length).toBe(3)
25
- expect(e[0]).toMatchObject({ id: 'Inter-Agent-Protocol@agfpd', scope: 'project', enabled: true, projectPath: '/Users/x/agents/linus' })
26
- expect(parseInstalledPlugins('not json')).toEqual([])
27
- expect(parseInstalledPlugins('{"not":"array"}')).toEqual([])
28
- expect(parseInstalledPlugins('[1, null, {"no":"id"}]')).toEqual([]) // entries without a string id are dropped
29
- })
30
- })
31
-
32
- describe('findPeerScopedEntry (per-peer, fleet-safe)', () => {
33
- test('matches ONLY the project-scope entry for this peer cwd', () => {
34
- const e = parseInstalledPlugins(LIST)
35
- expect(findPeerScopedEntry(e, 'peer-voice', 'agfpd', '/Users/x/agents/boris')?.enabled).toBe(true)
36
- // a DIFFERENT peer cwd → no match (never reports another peer's install as ours)
37
- expect(findPeerScopedEntry(e, 'peer-voice', 'agfpd', '/Users/x/agents/darwin')).toBeNull()
38
- // user-scope is not a per-peer match
39
- expect(findPeerScopedEntry(e, 'peer-voice', 'agfpd', '/Users/x/agents/boris')?.scope).toBe('project')
40
- })
41
- test('absent plugin for this peer → null (drives install, not false-skip)', () => {
42
- const e = parseInstalledPlugins(LIST)
43
- expect(findPeerScopedEntry(e, 'MergeMind', 'agfpd', '/Users/x/agents/linus')).toBeNull()
44
- })
45
- })
46
-
47
- // the real `codex plugin list --json` shape (codex-cli 0.138, live-captured fields)
48
- const CODEX_LIST = JSON.stringify({
49
- installed: [
50
- { pluginId: 'peer-voice@agfpd', installed: true, enabled: true },
51
- { pluginId: 'MergeMind@agfpd', installed: true, enabled: false },
52
- { pluginId: 'spawned-peer@agfpd', installed: false, enabled: false }, // marketplace-known, not installed
53
- ],
54
- })
55
-
56
- describe('parseCodexPluginStatus', () => {
57
- test('reads the installed/enabled flags per plugin id', () => {
58
- expect(parseCodexPluginStatus(CODEX_LIST, 'peer-voice@agfpd')).toBe('enabled')
59
- expect(parseCodexPluginStatus(CODEX_LIST, 'spawned-peer@agfpd')).toBe('absent') // installed:false
60
- expect(parseCodexPluginStatus(CODEX_LIST, 'MergeMind@agfpd')).toBe('disabled') // installed, not enabled
61
- expect(parseCodexPluginStatus(CODEX_LIST, 'totp-presence@agfpd')).toBe('absent') // not in array
62
- expect(parseCodexPluginStatus('', 'x@agfpd')).toBe('absent') // empty
63
- expect(parseCodexPluginStatus('not json', 'x@agfpd')).toBe('absent') // malformed → fail-safe
64
- expect(parseCodexPluginStatus('{"no":"installed-array"}', 'x@agfpd')).toBe('absent')
65
- })
66
- })
67
-
68
- describe('readSetupDescriptor (SIMPLE vs СЛОЖНЫЙ)', () => {
69
- test('no installPath / no iapeer.json → null (SIMPLE: install+enable only)', () => {
70
- expect(readSetupDescriptor(undefined)).toBeNull()
71
- const dir = mkdtempSync(join(tmpdir(), 'iapeer-enable-simple-'))
72
- try {
73
- expect(readSetupDescriptor(dir)).toBeNull() // peer-voice-like: no manifest
74
- } finally {
75
- rmSync(dir, { recursive: true, force: true })
76
- }
77
- })
78
- test('iapeer.json with setup string → returns it; with {command,args} → object', () => {
79
- const dir = mkdtempSync(join(tmpdir(), 'iapeer-enable-complex-'))
80
- try {
81
- writeFileSync(join(dir, 'iapeer.json'), JSON.stringify({ setup: 'bin/setup' }))
82
- expect(readSetupDescriptor(dir)).toBe('bin/setup')
83
- writeFileSync(join(dir, 'iapeer.json'), JSON.stringify({ setup: { command: 'node', args: ['setup.js'] } }))
84
- expect(readSetupDescriptor(dir)).toEqual({ command: 'node', args: ['setup.js'] })
85
- // requires present but NO setup → still SIMPLE (iapeer does not resolve requires)
86
- writeFileSync(join(dir, 'iapeer.json'), JSON.stringify({ requires: ['telegram'] }))
87
- expect(readSetupDescriptor(dir)).toBeNull()
88
- // malformed manifest → treated as SIMPLE, never throws
89
- writeFileSync(join(dir, 'iapeer.json'), '{ broken')
90
- expect(readSetupDescriptor(dir)).toBeNull()
91
- } finally {
92
- rmSync(dir, { recursive: true, force: true })
93
- }
94
- })
95
- })
@@ -1,107 +0,0 @@
1
- // Provision-command executor — the v1.2 inversion joint. Pins the four contract
2
- // requirements: per-arg placeholder substitution WITHOUT a shell (injection
3
- // class), absolute command, timeout, structured outcomes. The fake provider is
4
- // a tmp shell script that journals its argv — no real provider involved.
5
-
6
- import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
7
- import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
8
- import { tmpdir } from 'os'
9
- import { join } from 'path'
10
- import { runProvisionCommand } from './provisionCommand.ts'
11
-
12
- let dir: string
13
- beforeEach(() => {
14
- dir = mkdtempSync(join(tmpdir(), 'iapeer-provcmd-'))
15
- })
16
- afterEach(() => {
17
- rmSync(dir, { recursive: true, force: true })
18
- })
19
-
20
- /** A fake provider command journaling each argv element on its own line. */
21
- function fakeProvider(name: string, body?: string): string {
22
- const p = join(dir, name)
23
- writeFileSync(p, `#!/bin/sh\n${body ?? `printf '%s\\n' "$@" > '${dir}/journal.txt'`}\n`)
24
- chmodSync(p, 0o755)
25
- return p
26
- }
27
-
28
- describe('runProvisionCommand (v1.2 executor)', () => {
29
- test('per-arg substitution of {cwd}/{runtime}/{personality}/{occasion}, argv passed verbatim (no shell)', () => {
30
- const cmd = fakeProvider('prov.sh')
31
- const o = runProvisionCommand({
32
- block: {
33
- command: cmd,
34
- // both shapes: bare placeholder arg and embedded `--k={v}`; plus an arg
35
- // full of shell metacharacters that MUST stay literal (no shell layer)
36
- args: ['provision-peer', '--cwd', '{cwd}', '--runtime={runtime}', '--occasion', '{occasion}', '$(reboot) `id` ; rm -rf /'],
37
- },
38
- cwd: '/tmp/some peer dir',
39
- runtime: 'claude',
40
- personality: 'tw-prov',
41
- occasion: 'birth',
42
- })
43
- expect(o.state).toBe('ok')
44
- expect(o.exitCode).toBe(0)
45
- const journal = readFileSync(join(dir, 'journal.txt'), 'utf8').split('\n')
46
- expect(journal[0]).toBe('provision-peer')
47
- expect(journal[1]).toBe('--cwd')
48
- expect(journal[2]).toBe('/tmp/some peer dir') // spaces survive — single argv element
49
- expect(journal[3]).toBe('--runtime=claude') // embedded substitution
50
- expect(journal[4]).toBe('--occasion')
51
- expect(journal[5]).toBe('birth')
52
- expect(journal[6]).toBe('$(reboot) `id` ; rm -rf /') // LITERAL — never shell-parsed
53
- })
54
-
55
- test('{personality} placeholder is supported (optional for the provider)', () => {
56
- const cmd = fakeProvider('prov.sh')
57
- const o = runProvisionCommand({
58
- block: { command: cmd, args: ['{personality}'] },
59
- cwd: '/x',
60
- runtime: 'codex',
61
- personality: 'boris',
62
- occasion: 'sweep-on',
63
- })
64
- expect(o.state).toBe('ok')
65
- expect(readFileSync(join(dir, 'journal.txt'), 'utf8').trim()).toBe('boris')
66
- })
67
-
68
- test('non-zero exit → failed with exit code and stderr tail', () => {
69
- const cmd = fakeProvider('prov.sh', `echo 'no such peer' >&2; exit 3`)
70
- const o = runProvisionCommand({
71
- block: { command: cmd, args: [] },
72
- cwd: '/x',
73
- runtime: 'claude',
74
- personality: 'p',
75
- occasion: 'off-peer',
76
- })
77
- expect(o.state).toBe('failed')
78
- expect(o.exitCode).toBe(3)
79
- expect(o.detail).toContain('no such peer')
80
- })
81
-
82
- test('timeout → state=timeout, never hangs the joint', () => {
83
- const cmd = fakeProvider('prov.sh', 'sleep 30')
84
- const o = runProvisionCommand({
85
- block: { command: cmd, args: [] },
86
- cwd: '/x',
87
- runtime: 'claude',
88
- personality: 'p',
89
- occasion: 'remove',
90
- timeoutMs: 300,
91
- })
92
- expect(o.state).toBe('timeout')
93
- expect(o.durationMs).toBeLessThan(5000)
94
- })
95
-
96
- test('missing/non-executable command → not-executable (never throws)', () => {
97
- const o = runProvisionCommand({
98
- block: { command: join(dir, 'nope.sh'), args: [] },
99
- cwd: '/x',
100
- runtime: 'claude',
101
- personality: 'p',
102
- occasion: 'birth',
103
- })
104
- expect(o.state).toBe('not-executable')
105
- expect(o.exitCode).toBeNull()
106
- })
107
- })