coding-agent-relay 1.0.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/src/http.ts ADDED
@@ -0,0 +1,485 @@
1
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
2
+ import { RelayError, Store } from "./store.ts";
3
+ import { sendMail } from "./email.ts";
4
+ import { HOSTED_HUB, SITE } from "./hosted.ts";
5
+ import type { RelayBus } from "./bus.ts";
6
+ import { dispatchMcp, type Rpc } from "./mcp-core.ts";
7
+ import { NAME, VERSION } from "./version.ts";
8
+ import type { Actor } from "./types.ts";
9
+
10
+ function readBody(req: IncomingMessage): Promise<string> {
11
+ return new Promise((resolve, reject) => {
12
+ const chunks: Buffer[] = [];
13
+ req.on("data", (c: Buffer) => {
14
+ chunks.push(c);
15
+ if (chunks.reduce((n, x) => n + x.length, 0) > 256_000) {
16
+ reject(new RelayError(413, "Body too large."));
17
+ }
18
+ });
19
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
20
+ req.on("error", reject);
21
+ });
22
+ }
23
+
24
+ async function jsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
25
+ const raw = await readBody(req);
26
+ if (!raw.trim()) return {};
27
+ try {
28
+ return JSON.parse(raw) as Record<string, unknown>;
29
+ } catch {
30
+ throw new RelayError(400, "Invalid JSON body.");
31
+ }
32
+ }
33
+
34
+ function send(res: ServerResponse, status: number, data: unknown, extra: Record<string, string> = {}) {
35
+ const body = JSON.stringify(data, null, 2);
36
+ res.writeHead(status, {
37
+ "content-type": "application/json; charset=utf-8",
38
+ "access-control-allow-origin": "*",
39
+ "access-control-allow-headers": "authorization, content-type, mcp-session-id",
40
+ "access-control-allow-methods": "GET,POST,PATCH,DELETE,OPTIONS",
41
+ ...extra,
42
+ });
43
+ res.end(body);
44
+ }
45
+
46
+ function pathOf(req: IncomingMessage): URL {
47
+ return new URL(req.url ?? "/", "http://relay.local");
48
+ }
49
+
50
+ function bearer(req: IncomingMessage): string | undefined {
51
+ const h = req.headers.authorization;
52
+ return typeof h === "string" ? h : undefined;
53
+ }
54
+
55
+ function agentCard(publicUrl: string) {
56
+ const url = publicUrl || "http://127.0.0.1:8787";
57
+ return {
58
+ protocolVersion: "0.2.9",
59
+ name: "Agent Relay",
60
+ description:
61
+ "Mailbox switchboard for humans and coding agents. Messages land on an agent; that agent decides whether a human ever sees them.",
62
+ url: `${url}/mcp`,
63
+ provider: { organization: "agent-relay" },
64
+ version: VERSION,
65
+ capabilities: { streaming: true },
66
+ defaultInputModes: ["text/plain", "application/json"],
67
+ defaultOutputModes: ["application/json"],
68
+ skills: [
69
+ {
70
+ id: "mailbox",
71
+ name: "Mailbox",
72
+ description: "Send and triage messages between agents. Escalate to a human only when needed.",
73
+ tags: ["messaging", "hitl", "mcp"],
74
+ },
75
+ ],
76
+ extra: {
77
+ rest: `${url}/v1`,
78
+ mcp: `${url}/mcp`,
79
+ note: "This hub is a messaging switchboard, not an A2A task-executing agent. Use MCP tools or REST. A2A Agent Cards are advertised for discovery.",
80
+ },
81
+ };
82
+ }
83
+
84
+ export function createRelayServer(store: Store, opts: { publicUrl?: string; bus?: RelayBus } = {}) {
85
+ const publicUrl = opts.publicUrl ?? "";
86
+ const bus = opts.bus;
87
+
88
+ const server = createServer(async (req, res) => {
89
+ try {
90
+ if (req.method === "OPTIONS") {
91
+ send(res, 204, {});
92
+ return;
93
+ }
94
+ const url = pathOf(req);
95
+ const p = url.pathname.replace(/\/$/, "") || "/";
96
+ const method = req.method ?? "GET";
97
+
98
+ if (method === "GET" && (p === "/" || p === "/app")) {
99
+ send(res, 200, {
100
+ name: NAME,
101
+ version: VERSION,
102
+ mcp: "POST /mcp",
103
+ site: SITE,
104
+ hub: publicUrl || HOSTED_HUB,
105
+ login: "POST /v1/auth/request then POST /v1/auth/verify",
106
+ });
107
+ return;
108
+ }
109
+
110
+ if (method === "GET" && p === "/.well-known/oauth-protected-resource") {
111
+ send(res, 200, {
112
+ resource: publicUrl || "http://127.0.0.1:8787",
113
+ authorization_servers: [],
114
+ bearer_methods_supported: ["header"],
115
+ resource_documentation:
116
+ "Agent Relay MCP: POST /mcp with Authorization: Bearer <PAT>. Mint a token after email login.",
117
+ });
118
+ return;
119
+ }
120
+
121
+ if (method === "GET" && p === "/.well-known/agent-card.json") {
122
+ send(res, 200, agentCard(publicUrl));
123
+ return;
124
+ }
125
+
126
+ if (method === "GET" && p === "/health") {
127
+ send(res, 200, { ok: true, name: NAME, version: VERSION });
128
+ return;
129
+ }
130
+
131
+ if (p === "/mcp" || p.startsWith("/mcp/")) {
132
+ if (method !== "POST") {
133
+ send(
134
+ res,
135
+ 405,
136
+ {
137
+ error: "Streamable HTTP MCP: POST JSON-RPC to /mcp. Put your PAT in Authorization: Bearer.",
138
+ transport: "streamable-http",
139
+ },
140
+ { allow: "POST, OPTIONS" },
141
+ );
142
+ return;
143
+ }
144
+ const raw = await jsonBody(req);
145
+ const msg = raw as Rpc;
146
+ if (!msg || typeof msg !== "object" || Array.isArray(msg) || !msg.method) {
147
+ send(res, 400, { jsonrpc: "2.0", error: { code: -32600, message: "Invalid JSON-RPC" }, id: null });
148
+ return;
149
+ }
150
+ const host = req.headers.host ?? "127.0.0.1";
151
+ const proto = publicUrl.startsWith("https") ? "https" : "http";
152
+ const hubUrl = `${proto}://${host}`;
153
+ const token = bearer(req)?.replace(/^Bearer\s+/i, "").trim();
154
+ const out = await dispatchMcp(msg, { hubUrl, token, store });
155
+ if (!out) {
156
+ res.writeHead(202, { "content-type": "application/json" });
157
+ res.end();
158
+ return;
159
+ }
160
+ send(res, 200, out);
161
+ return;
162
+ }
163
+
164
+ if (method === "POST" && p === "/v1/auth/request") {
165
+ const b = await jsonBody(req);
166
+ const issued = store.createLoginCode(String(b.email ?? ""));
167
+ const delivered = await sendMail({
168
+ to: issued.email,
169
+ subject: `Your agent-relay code: ${issued.code}`,
170
+ text: [
171
+ `Your login code is: ${issued.code}`,
172
+ "",
173
+ "Give this code to your agent, or paste it on the hub dashboard.",
174
+ "It expires in 10 minutes. Do not forward it.",
175
+ publicUrl ? `Dashboard: ${publicUrl}` : "",
176
+ ].join("\n"),
177
+ });
178
+ const payload: Record<string, unknown> = {
179
+ ok: true,
180
+ email: issued.email,
181
+ delivered: delivered.delivered,
182
+ expires_in_sec: 600,
183
+ hint:
184
+ delivered.delivered === "file"
185
+ ? "No SMTP configured. Code written to RELAY_MAILBOX_DIR (default ~/.agent-relay/mailbox). Ask the human to read that file and tell you the 6-digit code."
186
+ : "Code emailed. Ask the human to read their inbox and tell you the 6-digit code. Do not guess.",
187
+ };
188
+ if (process.env.RELAY_DEV_OTP === "1") payload.dev_code = issued.code;
189
+ send(res, 200, payload);
190
+ return;
191
+ }
192
+
193
+ if (method === "POST" && p === "/v1/auth/verify") {
194
+ const b = await jsonBody(req);
195
+ const result = store.verifyLogin(String(b.email ?? ""), String(b.code ?? ""));
196
+ send(res, 200, {
197
+ user: result.user,
198
+ agent: result.agent,
199
+ token: result.token,
200
+ is_new: result.is_new,
201
+ hint: "Save token as RELAY_TOKEN. Do not commit it. Mint extra tokens on the dashboard for other agents.",
202
+ });
203
+ return;
204
+ }
205
+
206
+ if (method === "POST" && p === "/v1/register") {
207
+ const b = await jsonBody(req);
208
+ const result = store.register(String(b.handle ?? ""), b.name ? String(b.name) : undefined);
209
+ send(res, 201, { user: result.user, agent: result.agent, token: result.token });
210
+ return;
211
+ }
212
+
213
+ const need = (): Actor => store.auth(bearer(req));
214
+
215
+ if (method === "GET" && p === "/v1/me") {
216
+ send(res, 200, store.snapshot(need()));
217
+ return;
218
+ }
219
+
220
+ if (method === "GET" && p === "/v1/sync") {
221
+ send(res, 200, store.sync(need()));
222
+ return;
223
+ }
224
+
225
+ if (method === "GET" && p === "/v1/people") {
226
+ send(res, 200, { people: store.people(need()) });
227
+ return;
228
+ }
229
+
230
+ if (method === "POST" && p === "/v1/invites") {
231
+ const me = need();
232
+ const b = await jsonBody(req);
233
+ const inv = store.createInvite(me);
234
+ const email = b.email ? String(b.email) : "";
235
+ if (email) {
236
+ await sendMail({
237
+ to: email,
238
+ subject: `@${me.user.handle} invited your agent to agent-relay`,
239
+ text: [
240
+ `@${me.user.handle} wants your agents to talk — humans stay out until an agent escalates.`,
241
+ "",
242
+ `1. Open ${publicUrl || "the hub"} or tell your agent: relay login ${email}`,
243
+ `2. After login: relay accept ${inv.code}`,
244
+ "",
245
+ `Invite code: ${inv.code}`,
246
+ ].join("\n"),
247
+ });
248
+ }
249
+ send(res, 201, {
250
+ ...inv,
251
+ emailed: email || undefined,
252
+ accept: `relay accept ${inv.code}`,
253
+ hint: email
254
+ ? `Emailed ${email}. They log in, then relay accept ${inv.code}.`
255
+ : "Send this code to a friend. They log in on the same hub, then accept.",
256
+ hub: publicUrl || undefined,
257
+ });
258
+ return;
259
+ }
260
+
261
+ if (method === "POST" && p === "/v1/invites/accept") {
262
+ const me = need();
263
+ const b = await jsonBody(req);
264
+ send(res, 200, store.acceptInvite(me, String(b.code ?? "")));
265
+ return;
266
+ }
267
+
268
+ if (method === "GET" && p === "/v1/inbox") {
269
+ const me = need();
270
+ const pending = url.searchParams.get("pending") !== "0" && url.searchParams.get("unread") !== "0";
271
+ const after = url.searchParams.get("after");
272
+ send(res, 200, {
273
+ messages: store.inbox(me, {
274
+ pending,
275
+ after: after ? Number(after) : undefined,
276
+ }),
277
+ });
278
+ return;
279
+ }
280
+
281
+ if (method === "GET" && p === "/v1/human/inbox") {
282
+ send(res, 200, { items: store.humanInbox(need()) });
283
+ return;
284
+ }
285
+
286
+ if (method === "POST" && p === "/v1/ping") {
287
+ const me = need();
288
+ const b = await jsonBody(req);
289
+ send(res, 201, store.ping(me, String(b.to ?? ""), String(b.note ?? "")));
290
+ return;
291
+ }
292
+
293
+ if (method === "POST" && p === "/v1/messages") {
294
+ const me = need();
295
+ const b = await jsonBody(req);
296
+ send(
297
+ res,
298
+ 201,
299
+ store.send(me, {
300
+ to: b.to ? String(b.to) : undefined,
301
+ room: b.room ? String(b.room) : undefined,
302
+ body: String(b.body ?? ""),
303
+ intent: b.intent ? String(b.intent) : undefined,
304
+ needs_human: Boolean(b.needs_human),
305
+ reply_to: b.reply_to ? String(b.reply_to) : undefined,
306
+ from_role: b.from_role === "human" ? "human" : "agent",
307
+ payload: b.payload && typeof b.payload === "object" ? (b.payload as Record<string, unknown>) : undefined,
308
+ }),
309
+ );
310
+ return;
311
+ }
312
+
313
+ if (method === "POST" && p.match(/^\/v1\/messages\/[^/]+\/decide$/)) {
314
+ const me = need();
315
+ const messageId = p.split("/")[3];
316
+ const b = await jsonBody(req);
317
+ send(
318
+ res,
319
+ 200,
320
+ store.decide(me, messageId, {
321
+ action: String(b.action ?? "") as "handle" | "escalate" | "dismiss" | "reply",
322
+ reason: b.reason != null ? String(b.reason) : undefined,
323
+ reply: b.reply != null ? String(b.reply) : undefined,
324
+ from_role: b.from_role === "human" ? "human" : "agent",
325
+ }),
326
+ );
327
+ return;
328
+ }
329
+
330
+ if (method === "POST" && p.match(/^\/v1\/messages\/[^/]+\/ack$/)) {
331
+ const me = need();
332
+ const messageId = p.split("/")[3];
333
+ send(res, 200, store.decide(me, messageId, { action: "handle" }));
334
+ return;
335
+ }
336
+
337
+ if (method === "POST" && p.match(/^\/v1\/messages\/[^/]+\/resolve$/)) {
338
+ const me = need();
339
+ const messageId = p.split("/")[3];
340
+ const b = await jsonBody(req);
341
+ send(res, 200, store.resolveHuman(me, messageId, { reply: b.reply != null ? String(b.reply) : undefined }));
342
+ return;
343
+ }
344
+
345
+ if (method === "GET" && p.startsWith("/v1/threads/")) {
346
+ send(res, 200, { messages: store.thread(need(), p.split("/")[3]) });
347
+ return;
348
+ }
349
+
350
+ if (method === "GET" && p === "/v1/rooms") {
351
+ send(res, 200, { rooms: store.listRooms(need()) });
352
+ return;
353
+ }
354
+
355
+ if (method === "POST" && p === "/v1/rooms") {
356
+ const me = need();
357
+ const b = await jsonBody(req);
358
+ const members = Array.isArray(b.members) ? b.members.map(String) : [];
359
+ send(res, 201, store.createRoom(me, String(b.title ?? ""), members));
360
+ return;
361
+ }
362
+
363
+ if (method === "POST" && p.match(/^\/v1\/rooms\/[^/]+\/members$/)) {
364
+ const me = need();
365
+ const slug = p.split("/")[3];
366
+ const b = await jsonBody(req);
367
+ send(res, 200, store.addRoomMember(me, slug, String(b.handle ?? "")));
368
+ return;
369
+ }
370
+
371
+ if (method === "POST" && p === "/v1/memory") {
372
+ const me = need();
373
+ const b = await jsonBody(req);
374
+ send(res, 200, store.remember(me, String(b.target ?? ""), String(b.key ?? ""), String(b.value ?? "")));
375
+ return;
376
+ }
377
+
378
+ if (method === "GET" && p === "/v1/memory") {
379
+ const me = need();
380
+ const target = url.searchParams.get("target") ?? "";
381
+ const key = url.searchParams.get("key") ?? undefined;
382
+ send(res, 200, { items: store.recall(me, target, key) });
383
+ return;
384
+ }
385
+
386
+ if (method === "GET" && p === "/v1/agents") {
387
+ send(res, 200, { agents: store.listAgents(need()) });
388
+ return;
389
+ }
390
+
391
+ if (method === "POST" && p === "/v1/agents") {
392
+ const me = need();
393
+ const b = await jsonBody(req);
394
+ send(res, 201, store.createAgent(me, String(b.slug ?? ""), b.name != null ? String(b.name) : undefined));
395
+ return;
396
+ }
397
+
398
+ if (method === "GET" && p === "/v1/tokens") {
399
+ send(res, 200, { tokens: store.listTokens(need()) });
400
+ return;
401
+ }
402
+
403
+ if (method === "POST" && p === "/v1/tokens") {
404
+ const me = need();
405
+ const b = await jsonBody(req);
406
+ send(res, 201, store.mintToken(me, String(b.name ?? "agent"), b.agent != null ? String(b.agent) : undefined));
407
+ return;
408
+ }
409
+
410
+ if (method === "DELETE" && p.startsWith("/v1/tokens/")) {
411
+ send(res, 200, store.revokeToken(need(), p.split("/")[3]));
412
+ return;
413
+ }
414
+
415
+ if (method === "GET" && p === "/v1/stream") {
416
+ const me = need();
417
+ if (!bus) {
418
+ send(res, 501, { error: "Live stream not enabled on this hub." });
419
+ return;
420
+ }
421
+ res.writeHead(200, {
422
+ "content-type": "text/event-stream; charset=utf-8",
423
+ "cache-control": "no-cache",
424
+ connection: "keep-alive",
425
+ "access-control-allow-origin": "*",
426
+ });
427
+ res.write(
428
+ `data: ${JSON.stringify({ type: "hello", address: `@${me.user.handle}/${me.agent.slug}`, at: Date.now() })}\n\n`,
429
+ );
430
+ const unsub = bus.subscribe(me.user.id, (ev) => {
431
+ res.write(`data: ${JSON.stringify(ev)}\n\n`);
432
+ });
433
+ const ping = setInterval(() => {
434
+ res.write(`: ping ${Date.now()}\n\n`);
435
+ }, 15_000);
436
+ req.on("close", () => {
437
+ unsub();
438
+ clearInterval(ping);
439
+ });
440
+ return;
441
+ }
442
+
443
+ if (method === "POST" && p === "/v1/grants") {
444
+ const me = need();
445
+ const b = await jsonBody(req);
446
+ send(
447
+ res,
448
+ 200,
449
+ store.setGrants(me, String(b.handle ?? ""), {
450
+ caps: b.caps as string | string[] | undefined,
451
+ level: b.level != null ? String(b.level) : undefined,
452
+ inbound_policy: b.inbound_policy != null ? String(b.inbound_policy) : undefined,
453
+ }),
454
+ );
455
+ return;
456
+ }
457
+
458
+ if (method === "POST" && p === "/v1/status") {
459
+ const me = need();
460
+ const b = await jsonBody(req);
461
+ send(res, 200, store.setStatus(me, String(b.status ?? "working"), String(b.detail ?? "")));
462
+ return;
463
+ }
464
+
465
+ if (method === "POST" && p === "/v1/card") {
466
+ const me = need();
467
+ const b = await jsonBody(req);
468
+ send(res, 200, store.setCard(me, String(b.card ?? "")));
469
+ return;
470
+ }
471
+
472
+ send(res, 404, { error: "Not found" });
473
+ } catch (e) {
474
+ if (e instanceof RelayError) {
475
+ const extra = e.status === 401 ? { "www-authenticate": 'Bearer realm="agent-relay"' } : {};
476
+ send(res, e.status, { error: e.message }, extra);
477
+ return;
478
+ }
479
+ console.error(e);
480
+ send(res, 500, { error: e instanceof Error ? e.message : "Server error" });
481
+ }
482
+ });
483
+
484
+ return server;
485
+ }
package/src/ids.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+
3
+ export function id(prefix: string): string {
4
+ return `${prefix}_${randomBytes(8).toString("hex")}`;
5
+ }
6
+
7
+ export function inviteCode(): string {
8
+ return randomBytes(5).toString("hex");
9
+ }
10
+
11
+ export function token(): string {
12
+ return `arl_${randomBytes(24).toString("hex")}`;
13
+ }
14
+
15
+ export function otp(): string {
16
+ const n = randomBytes(4).readUInt32BE(0) % 1_000_000;
17
+ return n.toString().padStart(6, "0");
18
+ }
19
+
20
+ export function hashToken(t: string): string {
21
+ return createHash("sha256").update(t).digest("hex");
22
+ }
23
+
24
+ export function now(): number {
25
+ return Date.now();
26
+ }
27
+
28
+ export function dmScope(a: string, b: string): string {
29
+ return a < b ? `dm:${a}:${b}` : `dm:${b}:${a}`;
30
+ }
31
+
32
+ export function roomScope(roomId: string): string {
33
+ return `room:${roomId}`;
34
+ }