@agfpd/iapeer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/iapeer +25 -0
- package/package.json +37 -0
- package/src/cli/cli.test.ts +130 -0
- package/src/cli/index.ts +608 -0
- package/src/cli/listTui.test.ts +70 -0
- package/src/cli/listTui.ts +165 -0
- package/src/codec/codec.test.ts +271 -0
- package/src/codec/index.ts +217 -0
- package/src/core/constants.test.ts +21 -0
- package/src/core/constants.ts +180 -0
- package/src/core/errors.ts +20 -0
- package/src/core/index.ts +3 -0
- package/src/core/normalize.test.ts +98 -0
- package/src/core/normalize.ts +89 -0
- package/src/core/socket.ts +63 -0
- package/src/create/create.test.ts +143 -0
- package/src/create/index.ts +178 -0
- package/src/daemon/daemon-http.test.ts +114 -0
- package/src/daemon/daemon.test.ts +103 -0
- package/src/daemon/index.ts +439 -0
- package/src/daemon/main.test.ts +194 -0
- package/src/daemon/main.ts +230 -0
- package/src/enable/enable.test.ts +92 -0
- package/src/enable/index.ts +381 -0
- package/src/identity/identity.test.ts +262 -0
- package/src/identity/index.ts +603 -0
- package/src/index.ts +27 -0
- package/src/init/index.ts +408 -0
- package/src/init/init.test.ts +171 -0
- package/src/init/runtime-resolve.test.ts +49 -0
- package/src/install/index.ts +84 -0
- package/src/install/install.test.ts +31 -0
- package/src/launch/adapters/claude.ts +250 -0
- package/src/launch/adapters/codex.ts +329 -0
- package/src/launch/adapters/notifier.ts +90 -0
- package/src/launch/adapters/telegram.ts +130 -0
- package/src/launch/bootstrap.test.ts +56 -0
- package/src/launch/composeSystemPrompt.layers.test.ts +319 -0
- package/src/launch/composeSystemPrompt.test.ts +98 -0
- package/src/launch/composeSystemPrompt.ts +261 -0
- package/src/launch/index.ts +253 -0
- package/src/launch/launch.test.ts +233 -0
- package/src/launch/launchd.test.ts +363 -0
- package/src/launch/launchd.ts +375 -0
- package/src/launch/launchdRun.ts +168 -0
- package/src/launch/sockdir.test.ts +70 -0
- package/src/launch/types.ts +300 -0
- package/src/lifecycle/index.ts +840 -0
- package/src/lifecycle/lifecycle.test.ts +496 -0
- package/src/onboard/index.ts +135 -0
- package/src/onboard/onboard.test.ts +39 -0
- package/src/provision/index.ts +170 -0
- package/src/provision/provision.test.ts +104 -0
- package/src/registry/index.ts +453 -0
- package/src/registry/registry.test.ts +400 -0
- package/src/runtime/deploy.ts +230 -0
- package/src/runtime/index.ts +191 -0
- package/src/runtime/runtime.test.ts +226 -0
- package/src/storage/index.ts +331 -0
- package/src/storage/peers-home.test.ts +34 -0
- package/src/storage/storage.test.ts +65 -0
- package/src/transport/index.ts +522 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,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
|
+
})
|