@agfpd/iapeer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/bin/iapeer +25 -0
  2. package/package.json +37 -0
  3. package/src/cli/cli.test.ts +130 -0
  4. package/src/cli/index.ts +608 -0
  5. package/src/cli/listTui.test.ts +70 -0
  6. package/src/cli/listTui.ts +165 -0
  7. package/src/codec/codec.test.ts +271 -0
  8. package/src/codec/index.ts +217 -0
  9. package/src/core/constants.test.ts +21 -0
  10. package/src/core/constants.ts +180 -0
  11. package/src/core/errors.ts +20 -0
  12. package/src/core/index.ts +3 -0
  13. package/src/core/normalize.test.ts +98 -0
  14. package/src/core/normalize.ts +89 -0
  15. package/src/core/socket.ts +63 -0
  16. package/src/create/create.test.ts +143 -0
  17. package/src/create/index.ts +178 -0
  18. package/src/daemon/daemon-http.test.ts +114 -0
  19. package/src/daemon/daemon.test.ts +103 -0
  20. package/src/daemon/index.ts +439 -0
  21. package/src/daemon/main.test.ts +194 -0
  22. package/src/daemon/main.ts +230 -0
  23. package/src/enable/enable.test.ts +92 -0
  24. package/src/enable/index.ts +381 -0
  25. package/src/identity/identity.test.ts +262 -0
  26. package/src/identity/index.ts +603 -0
  27. package/src/index.ts +27 -0
  28. package/src/init/index.ts +408 -0
  29. package/src/init/init.test.ts +171 -0
  30. package/src/init/runtime-resolve.test.ts +49 -0
  31. package/src/install/index.ts +84 -0
  32. package/src/install/install.test.ts +31 -0
  33. package/src/launch/adapters/claude.ts +250 -0
  34. package/src/launch/adapters/codex.ts +329 -0
  35. package/src/launch/adapters/notifier.ts +90 -0
  36. package/src/launch/adapters/telegram.ts +130 -0
  37. package/src/launch/bootstrap.test.ts +56 -0
  38. package/src/launch/composeSystemPrompt.layers.test.ts +319 -0
  39. package/src/launch/composeSystemPrompt.test.ts +98 -0
  40. package/src/launch/composeSystemPrompt.ts +261 -0
  41. package/src/launch/index.ts +253 -0
  42. package/src/launch/launch.test.ts +233 -0
  43. package/src/launch/launchd.test.ts +363 -0
  44. package/src/launch/launchd.ts +375 -0
  45. package/src/launch/launchdRun.ts +168 -0
  46. package/src/launch/sockdir.test.ts +70 -0
  47. package/src/launch/types.ts +300 -0
  48. package/src/lifecycle/index.ts +840 -0
  49. package/src/lifecycle/lifecycle.test.ts +496 -0
  50. package/src/onboard/index.ts +135 -0
  51. package/src/onboard/onboard.test.ts +39 -0
  52. package/src/provision/index.ts +170 -0
  53. package/src/provision/provision.test.ts +104 -0
  54. package/src/registry/index.ts +453 -0
  55. package/src/registry/registry.test.ts +400 -0
  56. package/src/runtime/deploy.ts +230 -0
  57. package/src/runtime/index.ts +191 -0
  58. package/src/runtime/runtime.test.ts +226 -0
  59. package/src/storage/index.ts +331 -0
  60. package/src/storage/peers-home.test.ts +34 -0
  61. package/src/storage/storage.test.ts +65 -0
  62. package/src/transport/index.ts +522 -0
  63. package/tsconfig.json +17 -0
@@ -0,0 +1,439 @@
1
+ // Daemon — the always-on HTTP-MCP router. Serves the single agent-facing tool
2
+ // (send_to_peer) host-wide over MCP Streamable HTTP, resolving the caller identity
3
+ // PER REQUEST from the X-IAPeer-Identity header. (list_online_peers is deprecated by
4
+ // contract — it ate context; liveness is the CLI `list` verb, not an agent tool.)
5
+ //
6
+ // Transport: the CANONICAL SDK StreamableHTTPServerTransport in stateless mode
7
+ // (a fresh Server + transport per request, sessionIdGenerator: undefined). The
8
+ // caller header is read from `extra.requestInfo.headers` in the CallTool handler
9
+ // — verified end-to-end on @modelcontextprotocol/sdk@1.29.0 (the earlier
10
+ // "SDK does not surface per-request headers" was a wrong inference from grepping
11
+ // streamableHttp.js; requestInfo is built in the web-standard transport layer
12
+ // at webStandardStreamableHttp.js:388 and threaded through protocol.js:351).
13
+ // Using the canonical transport guarantees on-wire compatibility with real
14
+ // claude/codex http MCP clients, so there is no hand-rolled handshake to defend.
15
+ //
16
+ // Ф1 scope: route + deliver + liveness. Wake-on-miss / spawn (Ф2) are not wired
17
+ // yet — a miss returns an explicit "peer offline". H4 (READ-ONLY for launchd
18
+ // peers) concerns reap/respawn (Ф2 supervision); Ф1 routing delivers to any live
19
+ // peer. H8: a unix socket is the same-uid base; a TCP loopback listener (which
20
+ // real http MCP clients require, since they connect to a URL) is broader than
21
+ // same-uid and should add a bearer token before production exposure — OPEN.
22
+
23
+ import { createServer as createHttpServer, type IncomingMessage, type ServerResponse } from 'http'
24
+ import { chmodSync, existsSync, mkdirSync, unlinkSync } from 'fs'
25
+ import { dirname, join } from 'path'
26
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
27
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
28
+ import {
29
+ CallToolRequestSchema,
30
+ ListToolsRequestSchema,
31
+ type CallToolResult,
32
+ } from '@modelcontextprotocol/sdk/types.js'
33
+ import {
34
+ ALWAYS_LOAD_META,
35
+ MAX_ATTACHMENTS,
36
+ MAX_MESSAGE_LEN,
37
+ MAX_TOPIC_LEN,
38
+ NAME_RE_SOURCE,
39
+ RUNTIME_RE_SOURCE,
40
+ } from '../core/constants.ts'
41
+ import { parseSessionName } from '../core/socket.ts'
42
+ import { IapError } from '../core/errors.ts'
43
+ import { pluginStateDir, writeFileAtomic, type StorageOptions } from '../storage/index.ts'
44
+ import { publicPeerSummary, readPeersIndex, type PeersIndex } from '../registry/index.ts'
45
+ import { resolveCallerIdentity, type CallerIdentity, type ResolvedCaller } from '../identity/index.ts'
46
+ import { routeSend, type SendToPeerInput, type WakeFn } from '../transport/index.ts'
47
+
48
+ export const CALLER_HEADER = 'x-iapeer-identity'
49
+ const SERVER_INFO = { name: 'iapeer', version: '0.0.0' }
50
+
51
+ // ─────────────────────────────────────────────────────────────────────────────
52
+ // Caller identity from the request header
53
+ // ─────────────────────────────────────────────────────────────────────────────
54
+
55
+ /** Parse an `X-IAPeer-Identity: <runtime>-<personality>` header into a caller. */
56
+ export function parseCallerHeader(value: string | undefined): CallerIdentity | null {
57
+ const raw = value?.trim()
58
+ if (!raw) return null
59
+ const parsed = parseSessionName(raw) // splits on the first '-': runtime | personality
60
+ if (!parsed) return null
61
+ return { personality: parsed.personality, runtime: parsed.runtime }
62
+ }
63
+
64
+ export function resolveCallerFromHeader(value: string | undefined, index: PeersIndex): ResolvedCaller {
65
+ const caller = parseCallerHeader(value)
66
+ if (!caller) {
67
+ throw new IapError(
68
+ `missing or malformed ${CALLER_HEADER} header — expected "<runtime>-<personality>"`,
69
+ )
70
+ }
71
+ return resolveCallerIdentity(caller, index)
72
+ }
73
+
74
+ // ─────────────────────────────────────────────────────────────────────────────
75
+ // Tool definitions (ported verbatim from IAP server.ts)
76
+ // ─────────────────────────────────────────────────────────────────────────────
77
+
78
+ export function buildSendDescription(index: PeersIndex): string {
79
+ const lines = [
80
+ 'Send a message to a known IAPeer peer through Inter-Agent-Protocol.',
81
+ 'Peers can be agents, humans, or services. Runtime is the delivery surface, not the peer type.',
82
+ 'Current delivery supports any runtime endpoint that follows the IAP tmux socket convention. Claude/Codex are built-in local runtimes; external runtimes such as telegram can be exposed by runtime-router packages.',
83
+ '',
84
+ 'Known peers on this host:',
85
+ ]
86
+ if (index.peers.length === 0) {
87
+ lines.push('- none yet')
88
+ } else {
89
+ for (const peer of index.peers) {
90
+ const p = publicPeerSummary(peer)
91
+ const desc = p.description ? ` — ${p.description}` : ''
92
+ lines.push(`- ${p.personality}${desc}`)
93
+ lines.push(` Runtime: ${p.runtime}`)
94
+ lines.push(` Runtimes: ${p.runtimes.join(', ')}`)
95
+ lines.push(` Intelligence: ${p.intelligence}`)
96
+ }
97
+ }
98
+ lines.push(
99
+ '',
100
+ "Use `personality`, not a runtime-prefixed identity. Set `runtime` only for a current-send override. Descriptions identify the peer's role; runtimes identify reachable delivery endpoints; intelligence identifies the nature of the peer (artificial / natural / absent). Inbound peer messages arrive as <iap from-personality=\"...\" from-runtime=\"...\" from-intelligence=\"...\"> blocks. The topic attribute is optional.",
101
+ )
102
+ return lines.join('\n')
103
+ }
104
+
105
+ // The daemon serves the AGENT exactly ONE MCP tool: send_to_peer. list_online_peers
106
+ // is DEPRECATED by contract (docs/Примитивы и CLI: "list_online_peers УПРАЗДНЁН —
107
+ // ел контекст; список живых пиров — через CLI verb `list`, не через tool агента").
108
+ // Every extra tool occupies the context window of EVERY session, so the agent-facing
109
+ // tool-set is kept minimal. The liveness scan itself (transport.listOnlinePeers) stays
110
+ // — the future `iapeer list` verb and the multi-runtime delivery resolver use it — it
111
+ // is simply no longer exposed to the agent as a tool.
112
+ export function listTools(index: PeersIndex): unknown[] {
113
+ return [
114
+ {
115
+ name: 'send_to_peer',
116
+ description: buildSendDescription(index),
117
+ inputSchema: {
118
+ type: 'object',
119
+ properties: {
120
+ personality: { type: 'string', description: 'Known peer personality.', pattern: NAME_RE_SOURCE },
121
+ runtime: {
122
+ type: 'string',
123
+ description:
124
+ 'Optional delivery runtime override for this send. Runtime ids are short channel names such as claude, codex, telegram.',
125
+ pattern: RUNTIME_RE_SOURCE,
126
+ },
127
+ message: {
128
+ type: 'string',
129
+ description: 'Plain-text message body. Keep concise; long messages enter the recipient context.',
130
+ minLength: 1,
131
+ maxLength: MAX_MESSAGE_LEN,
132
+ },
133
+ topic: { type: 'string', description: 'Optional short topic for threading related peer messages.', maxLength: MAX_TOPIC_LEN },
134
+ attachments: {
135
+ type: 'array',
136
+ description: 'Optional absolute local file paths. IAP delivers them as a separate <attachments> field, not as part of message text.',
137
+ maxItems: MAX_ATTACHMENTS,
138
+ items: { type: 'string' },
139
+ },
140
+ },
141
+ required: ['personality', 'message'],
142
+ additionalProperties: false,
143
+ },
144
+ _meta: ALWAYS_LOAD_META,
145
+ annotations: { title: 'Send to IAP peer', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
146
+ },
147
+ ]
148
+ }
149
+
150
+ // ─────────────────────────────────────────────────────────────────────────────
151
+ // Tool dispatch
152
+ // ─────────────────────────────────────────────────────────────────────────────
153
+
154
+ interface ToolResult {
155
+ content: { type: 'text'; text: string }[]
156
+ isError?: boolean
157
+ structuredContent?: unknown
158
+ }
159
+
160
+ function jsonResult(value: unknown): ToolResult {
161
+ const result: ToolResult = { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] }
162
+ // MCP structuredContent must be a record (object), not an array. send_to_peer
163
+ // returns an object, so it travels in structuredContent; the guard stays as a
164
+ // belt-and-suspenders against a future array-returning result.
165
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
166
+ result.structuredContent = value
167
+ }
168
+ return result
169
+ }
170
+ function errResult(text: string): ToolResult {
171
+ return { isError: true, content: [{ type: 'text', text }] }
172
+ }
173
+
174
+ export async function callTool(
175
+ caller: ResolvedCaller,
176
+ name: string,
177
+ args: Record<string, unknown>,
178
+ wake?: WakeFn,
179
+ ): Promise<ToolResult> {
180
+ if (name === 'send_to_peer') {
181
+ const input: SendToPeerInput = {
182
+ personality: typeof args.personality === 'string' ? args.personality : '',
183
+ runtime: typeof args.runtime === 'string' ? args.runtime : undefined,
184
+ message: typeof args.message === 'string' ? args.message : '',
185
+ topic: typeof args.topic === 'string' ? args.topic : undefined,
186
+ attachments: Array.isArray(args.attachments) ? (args.attachments as string[]) : undefined,
187
+ }
188
+ const sent = await routeSend(caller, input, { wake })
189
+ return sent.ok ? jsonResult(sent.value) : errResult(sent.error.message)
190
+ }
191
+ return errResult(`unknown tool: ${name}`)
192
+ }
193
+
194
+ // ─────────────────────────────────────────────────────────────────────────────
195
+ // MCP server (canonical SDK) — one fresh instance per request (stateless)
196
+ // ─────────────────────────────────────────────────────────────────────────────
197
+
198
+ function headerFromRequestInfo(extra: { requestInfo?: { headers?: Record<string, unknown> } }): string | undefined {
199
+ const headers = extra.requestInfo?.headers
200
+ const value = headers?.[CALLER_HEADER]
201
+ return Array.isArray(value) ? (value[0] as string) : (value as string | undefined)
202
+ }
203
+
204
+ export function createMcpServer(wake?: WakeFn): Server {
205
+ const server = new Server(SERVER_INFO, { capabilities: { tools: {} } })
206
+
207
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: listTools(readPeersIndex()) }))
208
+
209
+ server.setRequestHandler(CallToolRequestSchema, async (req, extra): Promise<CallToolResult> => {
210
+ // Per-request identity: read the caller from THIS request's HTTP header,
211
+ // surfaced by the SDK transport as extra.requestInfo.headers.
212
+ const headerIdentity = headerFromRequestInfo(extra as never)
213
+ let caller: ResolvedCaller
214
+ try {
215
+ caller = resolveCallerFromHeader(headerIdentity, readPeersIndex())
216
+ } catch (e) {
217
+ // No silent default — reject the CallTool when identity is missing/invalid.
218
+ if (process.env.IAPEER_DAEMON_LOG) {
219
+ process.stderr.write(`[iapeer-daemon] tools/call REJECTED tool=${req.params.name} caller=${headerIdentity ?? '-'}\n`)
220
+ }
221
+ return errResult(e instanceof Error ? e.message : String(e)) as CallToolResult
222
+ }
223
+ if (process.env.IAPEER_DAEMON_LOG) {
224
+ process.stderr.write(`[iapeer-daemon] tools/call tool=${req.params.name} caller=${caller.address}\n`)
225
+ }
226
+ const args = (req.params.arguments ?? {}) as Record<string, unknown>
227
+ return (await callTool(caller, req.params.name, args, wake)) as CallToolResult
228
+ })
229
+
230
+ return server
231
+ }
232
+
233
+ // ─────────────────────────────────────────────────────────────────────────────
234
+ // HTTP server (unix socket base, or TCP loopback for real http MCP clients)
235
+ // ─────────────────────────────────────────────────────────────────────────────
236
+
237
+ export function defaultDaemonSocketPath(options: StorageOptions = {}): string {
238
+ return join(pluginStateDir('iapeer', options), 'router.sock')
239
+ }
240
+
241
+ /** The discovery file `<root>/state/iapeer/router.json` — a daemon-aware `iap send`
242
+ * reads it to route through the daemon. Sits next to router.sock. */
243
+ export function daemonDiscoveryPath(options: StorageOptions = {}): string {
244
+ return join(pluginStateDir('iapeer', options), 'router.json')
245
+ }
246
+
247
+ export interface DaemonHandle {
248
+ socketPath?: string
249
+ host?: string
250
+ port?: number
251
+ url?: string
252
+ close: () => Promise<void>
253
+ }
254
+
255
+ export interface StartDaemonOptions extends StorageOptions {
256
+ /**
257
+ * Unix-socket path (H8 base: 0600, same-uid local callers — CLI / pane /
258
+ * notifier / telegram). Bound when given; when NEITHER socketPath NOR port is
259
+ * given, a bare startDaemon() defaults to the same-uid router.sock.
260
+ */
261
+ socketPath?: string
262
+ /**
263
+ * TCP loopback port (0 = ephemeral). REQUIRED for real http MCP clients
264
+ * (claude/codex `--transport http <url>`), which connect to a URL, not a unix
265
+ * socket. NOT mutually exclusive with socketPath — give BOTH for dual-listen
266
+ * (local callers via the 0600 socket, agents via TCP). H8 on the TCP surface is
267
+ * gated by the optional bearer seam (off by default).
268
+ */
269
+ port?: number
270
+ /** TCP bind host, default 127.0.0.1 (loopback). */
271
+ host?: string
272
+ /**
273
+ * Wake-on-miss primitive (Ф2). When provided, a send to a DEAD peer wakes it
274
+ * (spawns the session, delivers the message as its boot first-message) instead
275
+ * of returning offline. Opt-in: omit it (Ф1) and a miss returns an explicit
276
+ * "offline" — so tests never spawn a real session by accident. The daemon is
277
+ * the composition point: it wires lifecycle.wakeOrSpawn here (§2).
278
+ */
279
+ wake?: WakeFn
280
+ /**
281
+ * Optional supervision timer (Ф2): `tick` runs every `intervalMs` — the daemon
282
+ * owns fleet supervision (idle-reap / zombie-sweep) instead of launchd. The
283
+ * caller wires `tick: () => superviseTick(cfg)` (H4-guarded inside).
284
+ */
285
+ supervise?: { intervalMs: number; tick: () => void | Promise<void> }
286
+ /**
287
+ * Optional bearer token (H8). When set, EVERY request must carry
288
+ * `Authorization: Bearer <token>` or it is rejected 401 BEFORE any MCP dispatch.
289
+ * Unset → no auth (the same-uid unix-socket base / loopback default). This is the
290
+ * structural layer for closing H8 on the TCP loopback listener — kept OFF until
291
+ * Артур enables it (the production main reads IAPEER_BEARER_TOKEN). Loopback is
292
+ * broader than same-uid, so a token gates it without changing the on-wire MCP.
293
+ */
294
+ bearerToken?: string
295
+ /**
296
+ * Write the discovery file (router.json) at <root>/state/iapeer/router.json with
297
+ * the active addresses `{sock, tcp}` — atomically on listen, removed on close.
298
+ * This is the contract a daemon-aware `iap send` reads to route through the
299
+ * daemon. OFF by default (library/test callers); the production main enables it.
300
+ */
301
+ discovery?: boolean
302
+ }
303
+
304
+ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<DaemonHandle> {
305
+ const tcpEnabled = opts.port !== undefined
306
+ // Bind a unix socket when one is given, OR when no TCP is requested at all
307
+ // (legacy default: a bare startDaemon() serves the same-uid router.sock).
308
+ const socketPath = opts.socketPath ?? (tcpEnabled ? undefined : defaultDaemonSocketPath(opts))
309
+ const host = opts.host ?? '127.0.0.1'
310
+ const bearer = opts.bearerToken?.trim() || undefined
311
+
312
+ // ONE MCP request handler, shared by EVERY listener (socket + TCP). Stateless:
313
+ // a fresh Server + transport per request, fully isolated.
314
+ const handle = (req: IncomingMessage, res: ServerResponse): void => {
315
+ // H8 bearer layer (FIRST, before any MCP work): when a token is configured,
316
+ // reject anything not carrying `Authorization: Bearer <token>`. Off when unset.
317
+ if (bearer && req.headers.authorization !== `Bearer ${bearer}`) {
318
+ res.writeHead(401, { 'content-type': 'application/json', 'www-authenticate': 'Bearer' })
319
+ res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'unauthorized' } }))
320
+ return
321
+ }
322
+ const server = createMcpServer(opts.wake)
323
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
324
+ res.on('close', () => {
325
+ transport.close()
326
+ server.close()
327
+ })
328
+ server
329
+ .connect(transport)
330
+ .then(() => transport.handleRequest(req, res))
331
+ .catch(() => {
332
+ if (!res.headersSent) {
333
+ res.writeHead(500, { 'content-type': 'application/json' })
334
+ res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32603, message: 'internal error' } }))
335
+ }
336
+ })
337
+ }
338
+
339
+ // Supervision timer (Ф2): the daemon drives idle-reap / zombie-sweep (one shared
340
+ // timer regardless of how many listeners are bound).
341
+ let supervisor: ReturnType<typeof setInterval> | undefined
342
+ if (opts.supervise) {
343
+ const { intervalMs, tick } = opts.supervise
344
+ supervisor = setInterval(() => {
345
+ try {
346
+ void Promise.resolve(tick()).catch(() => {})
347
+ } catch {
348
+ /* a supervise tick must never crash the daemon */
349
+ }
350
+ }, intervalMs)
351
+ supervisor.unref?.()
352
+ }
353
+
354
+ const servers: ReturnType<typeof createHttpServer>[] = []
355
+
356
+ // Unix-socket listener (H8 same-uid base, 0600).
357
+ if (socketPath) {
358
+ mkdirSync(dirname(socketPath), { recursive: true, mode: 0o700 })
359
+ if (existsSync(socketPath)) unlinkSync(socketPath)
360
+ const s = createHttpServer(handle)
361
+ await new Promise<void>((resolve, reject) => {
362
+ s.once('error', reject)
363
+ s.listen(socketPath, () => {
364
+ s.removeListener('error', reject)
365
+ resolve()
366
+ })
367
+ })
368
+ chmodSync(socketPath, 0o600)
369
+ servers.push(s)
370
+ }
371
+
372
+ // TCP loopback listener (real http MCP clients connect to a URL).
373
+ let port: number | undefined
374
+ let url: string | undefined
375
+ if (tcpEnabled) {
376
+ const s = createHttpServer(handle)
377
+ await new Promise<void>((resolve, reject) => {
378
+ s.once('error', reject)
379
+ s.listen(opts.port, host, () => {
380
+ s.removeListener('error', reject)
381
+ resolve()
382
+ })
383
+ })
384
+ const addr = s.address()
385
+ port = addr && typeof addr === 'object' ? addr.port : (opts.port as number)
386
+ url = `http://${host}:${port}/mcp`
387
+ servers.push(s)
388
+ }
389
+
390
+ // Discovery file (atomic temp+rename) — both active addresses for `iap send`.
391
+ let discoveryPath: string | undefined
392
+ if (opts.discovery) {
393
+ discoveryPath = daemonDiscoveryPath(opts)
394
+ writeFileAtomic(discoveryPath, `${JSON.stringify({ sock: socketPath ?? null, tcp: url ?? null })}\n`)
395
+ }
396
+
397
+ const removeArtifacts = (): void => {
398
+ if (socketPath && existsSync(socketPath)) {
399
+ try {
400
+ unlinkSync(socketPath)
401
+ } catch {
402
+ /* already gone */
403
+ }
404
+ }
405
+ if (discoveryPath && existsSync(discoveryPath)) {
406
+ try {
407
+ unlinkSync(discoveryPath)
408
+ } catch {
409
+ /* already gone */
410
+ }
411
+ }
412
+ }
413
+
414
+ return {
415
+ socketPath,
416
+ host: tcpEnabled ? host : undefined,
417
+ port,
418
+ url,
419
+ close: () =>
420
+ new Promise<void>(resolve => {
421
+ if (supervisor) clearInterval(supervisor)
422
+ for (const s of servers) s.closeAllConnections?.() // drop lingering keep-alive/SSE conns
423
+ let pending = servers.length
424
+ if (pending === 0) {
425
+ removeArtifacts()
426
+ resolve()
427
+ return
428
+ }
429
+ for (const s of servers) {
430
+ s.close(() => {
431
+ if (--pending === 0) {
432
+ removeArtifacts()
433
+ resolve()
434
+ }
435
+ })
436
+ }
437
+ }),
438
+ }
439
+ }
@@ -0,0 +1,194 @@
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
+ startConfiguredDaemon,
17
+ DEFAULT_DAEMON_PORT,
18
+ } from './main.ts'
19
+ import { daemonDiscoveryPath, defaultDaemonSocketPath, startDaemon, type DaemonHandle } from './index.ts'
20
+ import { isFoundationOwnedPlist } from '../launch/index.ts'
21
+ import { DAEMON_PLIST_LABEL } from '../core/constants.ts'
22
+
23
+ const tmpDirs: string[] = []
24
+ function mkTmp(): string {
25
+ const d = mkdtempSync(join(tmpdir(), 'iapeer-daemon-main-'))
26
+ tmpDirs.push(d)
27
+ return d
28
+ }
29
+ afterEach(() => {
30
+ while (tmpDirs.length) rmSync(tmpDirs.pop()!, { recursive: true, force: true })
31
+ })
32
+
33
+ function plutilLint(path: string): boolean {
34
+ return spawnSync('plutil', ['-lint', path], { encoding: 'utf8' }).status === 0
35
+ }
36
+
37
+ describe('buildDaemonPlistSpec / installDaemonPlist (com.agfpd.iapeer)', () => {
38
+ test('spec carries the daemon label, default port env, and the INSTALLED iapeer entrypoint (Ф-F)', () => {
39
+ const spec = buildDaemonPlistSpec({ env: { HOME: '/Users/x' } as NodeJS.ProcessEnv })
40
+ expect(spec.label).toBe(DAEMON_PLIST_LABEL) // com.agfpd.iapeer — NOT com.iapeer.*
41
+ expect(spec.environment.IAPEER_PORT).toBe(String(DEFAULT_DAEMON_PORT))
42
+ // Ф-F: runs the INSTALLED binary `iapeer daemon`, NOT `bun <src>/main.ts` — prod
43
+ // decoupled from the mutable src tree.
44
+ expect(spec.programArguments).toEqual(['/Users/x/.local/bin/iapeer', 'daemon'])
45
+ })
46
+
47
+ test('NO bearer token in the plist env by default (H8 seam dormant)', () => {
48
+ expect(buildDaemonPlistSpec({ env: {} as NodeJS.ProcessEnv }).environment.IAPEER_BEARER_TOKEN).toBeUndefined()
49
+ })
50
+
51
+ test('bearer token is baked into the plist env only when explicitly provided', () => {
52
+ const spec = buildDaemonPlistSpec({ bearerToken: 's3cret', env: {} as NodeJS.ProcessEnv })
53
+ expect(spec.environment.IAPEER_BEARER_TOKEN).toBe('s3cret')
54
+ })
55
+
56
+ test('installs a valid, foundation-owned plist under IAPEER_LAUNCHAGENTS_DIR', () => {
57
+ const root = mkTmp()
58
+ const env = {
59
+ IAPEER_LAUNCHAGENTS_DIR: join(root, 'LaunchAgents'),
60
+ IAPEER_ROOT: join(root, 'iapeer'),
61
+ HOME: root,
62
+ PATH: '/usr/bin:/bin',
63
+ } as NodeJS.ProcessEnv
64
+ const path = installDaemonPlist({ env, port: 8765, throttleIntervalSecs: 10 })
65
+ expect(path).toBe(daemonPlistPath(env))
66
+ expect(existsSync(path)).toBe(true)
67
+ expect(isFoundationOwnedPlist(path)).toBe(true)
68
+ expect(plutilLint(path)).toBe(true) // live plutil: valid plist
69
+ const xml = readFileSync(path, 'utf8')
70
+ expect(xml).toContain(`<string>${DAEMON_PLIST_LABEL}</string>`)
71
+ expect(xml).toContain('<key>RunAtLoad</key>')
72
+ expect(xml).toContain('<key>KeepAlive</key>')
73
+ })
74
+
75
+ test('REFUSES to overwrite a foreign com.agfpd.iapeer.plist (collision guard)', () => {
76
+ const root = mkTmp()
77
+ const laDir = join(root, 'LaunchAgents')
78
+ mkdirSync(laDir, { recursive: true })
79
+ const env = { IAPEER_LAUNCHAGENTS_DIR: laDir, IAPEER_ROOT: join(root, 'iapeer'), HOME: root } as NodeJS.ProcessEnv
80
+ const path = daemonPlistPath(env)
81
+ const foreign = '<?xml version="1.0"?>\n<plist><dict><key>Label</key><string>com.agfpd.iapeer</string></dict></plist>\n'
82
+ writeFileSync(path, foreign)
83
+ expect(() => installDaemonPlist({ env })).toThrow(/foundation-managed|refus/i)
84
+ expect(readFileSync(path, 'utf8')).toBe(foreign) // untouched
85
+ })
86
+ })
87
+
88
+ describe('startConfiguredDaemon (composition smoke — TCP loopback)', () => {
89
+ test('returns a live http handle and closes cleanly', async () => {
90
+ const root = mkTmp()
91
+ const env = { IAPEER_ROOT: join(root, 'iapeer'), HOME: root, PATH: '/usr/bin:/bin' } as NodeJS.ProcessEnv
92
+ const handle = await startConfiguredDaemon({ port: 0, host: '127.0.0.1', env })
93
+ try {
94
+ expect(handle.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/mcp$/)
95
+ } finally {
96
+ await handle.close()
97
+ }
98
+ })
99
+ })
100
+
101
+ // The H8 bearer seam: a validator LAYER on the listener, OFF unless a token is set
102
+ // (Артур 07.06 — H8 DEFERRED, no token provisioning yet). These tests prove the
103
+ // seam is dormant by default and would gate every request once a token is wired.
104
+ describe('H8 bearer seam (dormant by default)', () => {
105
+ const INIT = JSON.stringify({
106
+ jsonrpc: '2.0', id: 1, method: 'initialize',
107
+ params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 't', version: '0' } },
108
+ })
109
+ const headers = (auth?: string): Record<string, string> => ({
110
+ 'content-type': 'application/json',
111
+ accept: 'application/json, text/event-stream',
112
+ ...(auth ? { authorization: auth } : {}),
113
+ })
114
+
115
+ async function withDaemon(opts: Parameters<typeof startDaemon>[0], fn: (h: DaemonHandle) => Promise<void>) {
116
+ const h = await startDaemon(opts)
117
+ try {
118
+ await fn(h)
119
+ } finally {
120
+ await h.close()
121
+ }
122
+ }
123
+
124
+ test('NO token → request is NOT 401 (auth layer off)', async () => {
125
+ await withDaemon({ port: 0, host: '127.0.0.1' }, async h => {
126
+ const res = await fetch(h.url!, { method: 'POST', headers: headers(), body: INIT })
127
+ expect(res.status).not.toBe(401)
128
+ })
129
+ })
130
+
131
+ test('token set → no Authorization is 401, correct Bearer passes the layer', async () => {
132
+ await withDaemon({ port: 0, host: '127.0.0.1', bearerToken: 'secret' }, async h => {
133
+ const noAuth = await fetch(h.url!, { method: 'POST', headers: headers(), body: INIT })
134
+ expect(noAuth.status).toBe(401)
135
+ const wrong = await fetch(h.url!, { method: 'POST', headers: headers('Bearer nope'), body: INIT })
136
+ expect(wrong.status).toBe(401)
137
+ const ok = await fetch(h.url!, { method: 'POST', headers: headers('Bearer secret'), body: INIT })
138
+ expect(ok.status).not.toBe(401) // passed the auth layer (MCP handles the rest)
139
+ await ok.body?.cancel()
140
+ })
141
+ })
142
+ })
143
+
144
+ // Dual-listen (Ф2 consolidation): the daemon serves a 0600 unix socket (local
145
+ // same-uid callers: notifier/telegram/CLI) AND TCP (agent MCP) over ONE handler,
146
+ // and writes router.json (both addresses) for daemon-aware `iap send`.
147
+ describe('dual-listen + router.json discovery', () => {
148
+ const INIT = JSON.stringify({
149
+ jsonrpc: '2.0', id: 1, method: 'initialize',
150
+ params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 't', version: '0' } },
151
+ })
152
+ const mcpHeaders = { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }
153
+
154
+ test('binds socket + TCP over one handler; router.json carries both; close cleans up', async () => {
155
+ const root = mkTmp()
156
+ const env = { IAPEER_ROOT: join(root, 'iapeer') } as NodeJS.ProcessEnv
157
+ const sockExpected = defaultDaemonSocketPath({ env })
158
+ const h = await startDaemon({ port: 0, host: '127.0.0.1', socketPath: sockExpected, discovery: true, env })
159
+ try {
160
+ // TCP listener answers the MCP handler
161
+ const tcp = await fetch(h.url!, { method: 'POST', headers: mcpHeaders, body: INIT })
162
+ expect(tcp.ok).toBe(true)
163
+ await tcp.body?.cancel()
164
+ // unix-socket listener answers the SAME handler (bun fetch `unix` option)
165
+ const unixInit = { unix: h.socketPath!, method: 'POST', headers: mcpHeaders, body: INIT } as unknown as RequestInit
166
+ const sock = await fetch('http://localhost/mcp', unixInit)
167
+ expect(sock.ok).toBe(true)
168
+ await sock.body?.cancel()
169
+ // socket is 0600 (same-uid)
170
+ expect((statSync(h.socketPath!).mode & 0o777).toString(8)).toBe('600')
171
+ // router.json carries BOTH addresses
172
+ const rj = JSON.parse(readFileSync(daemonDiscoveryPath({ env }), 'utf8'))
173
+ expect(rj.sock).toBe(sockExpected)
174
+ expect(rj.tcp).toBe(h.url)
175
+ } finally {
176
+ await h.close()
177
+ }
178
+ // clean shutdown removed the socket file AND router.json
179
+ expect(existsSync(sockExpected)).toBe(false)
180
+ expect(existsSync(daemonDiscoveryPath({ env }))).toBe(false)
181
+ })
182
+
183
+ test('port-only stays TCP-only — no socket, no router.json (backward compat)', async () => {
184
+ const root = mkTmp()
185
+ const env = { IAPEER_ROOT: join(root, 'iapeer') } as NodeJS.ProcessEnv
186
+ const h = await startDaemon({ port: 0, host: '127.0.0.1', env })
187
+ try {
188
+ expect(h.socketPath).toBeUndefined()
189
+ expect(existsSync(daemonDiscoveryPath({ env }))).toBe(false)
190
+ } finally {
191
+ await h.close()
192
+ }
193
+ })
194
+ })