agent-relay-server 0.32.2 → 0.32.3

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.
@@ -0,0 +1,529 @@
1
+ // Auto-split from routes.ts (#299). Domain: messages.
2
+ import { MAX_BODY_BYTES } from "../config";
3
+ import { ValidationError, applyMessageDeliveryAction, claimMessage, deleteMessage, getAgent, getLatestMessageId, getMessage, getMessageDeliveryStatus, getThread, listAgents, listQueuedMessages, listRecentMessages, markRead, pollMessages, recordMessageDeliveryAttempt, renewMessageClaim, sendMessage, sendMessageWithResult, setMessageReaction } from "../db";
4
+ import { auditEvent, authorizeRoute, cleanAttachmentRefs, dispatchTaskCallbacks, emitCommand, error, json, memoryContext, normalizeAgentSessionGuard, parseBody, parseId, parseQueryInt, reactionEmoji, sendReactionNotificationToAuthor, withPayloadAttachments, type Handler } from "./_shared";
5
+ import { cleanMeta, cleanPositiveId, cleanString, optionalEnum } from "../validation";
6
+ import { emitMessageClaimed, emitMessageDeleted, emitMessageDeliveryUpdated, emitMessageQueued, emitMessageReactionUpdated, emitNewMessage, emitTaskChanged } from "../sse";
7
+ import { errMessage, isMechanicalMessageKind, isRecord, isReservedAgentId } from "agent-relay-sdk";
8
+ import { getLifecycleManager } from "../lifecycle-manager";
9
+ import { injectMemoryForMessageDelivery } from "../memory-service";
10
+ import { planSend } from "../agent-ref";
11
+ import { type AgentSessionGuard, type Message, type SendMessageInput } from "../types";
12
+
13
+ const VALID_DELIVERY_STATUSES = ["pending", "delivered", "queued", "failed", "dead"] as const;
14
+
15
+ const VALID_DELIVERY_ACTIONS = ["retry-now", "mark-dead", "clear"] as const;
16
+
17
+ function normalizeMessageInput(body: unknown): SendMessageInput {
18
+ if (!isRecord(body)) throw new ValidationError("JSON object body required");
19
+ const kind = cleanString(body.kind, "kind", { max: 40 });
20
+ if (kind && !VALID_MSG_KINDS.includes(kind)) {
21
+ throw new ValidationError(`kind must be one of: ${VALID_MSG_KINDS.join(", ")}`);
22
+ }
23
+ if (body.claimable !== undefined && typeof body.claimable !== "boolean") {
24
+ throw new ValidationError("claimable must be a boolean");
25
+ }
26
+ if (body.replyExpected !== undefined && typeof body.replyExpected !== "boolean") {
27
+ throw new ValidationError("replyExpected must be a boolean");
28
+ }
29
+
30
+ const input: SendMessageInput = {
31
+ from: cleanString(body.from, "from", { required: true, max: 200 })!,
32
+ to: cleanString(body.to, "to", { max: 200 }) ?? "",
33
+ body: cleanString(body.body, "body", { required: true, max: MAX_BODY_BYTES })!,
34
+ kind: kind as SendMessageInput["kind"] | undefined,
35
+ replyTo: cleanPositiveId(body.replyTo, "replyTo"),
36
+ replyExpected: body.replyExpected as boolean | undefined,
37
+ claimable: body.claimable as boolean | undefined,
38
+ idempotencyKey: cleanString(body.idempotencyKey, "idempotencyKey", { max: 240 }),
39
+ };
40
+ if (body.maxAgeSeconds !== undefined) {
41
+ if (typeof body.maxAgeSeconds !== "number" || !Number.isSafeInteger(body.maxAgeSeconds) || body.maxAgeSeconds < 0 || body.maxAgeSeconds > 2_592_000) {
42
+ throw new ValidationError("maxAgeSeconds must be an integer between 0 and 2592000");
43
+ }
44
+ input.maxAgeSeconds = body.maxAgeSeconds;
45
+ }
46
+ if (body.occurredAt !== undefined) {
47
+ if (typeof body.occurredAt !== "number" || !Number.isFinite(body.occurredAt) || body.occurredAt <= 0) {
48
+ throw new ValidationError("occurredAt must be a positive epoch-ms number");
49
+ }
50
+ input.occurredAt = body.occurredAt;
51
+ }
52
+
53
+ const channel = cleanString(body.channel, "channel", { max: 120 });
54
+ if (channel) input.channel = channel;
55
+ const subject = cleanString(body.subject, "subject", { max: 200 });
56
+ if (subject) input.subject = subject;
57
+ const meta = cleanMeta(body.meta);
58
+ if (meta) input.meta = meta;
59
+ const payload = cleanMeta(body.payload);
60
+ const attachments = cleanAttachmentRefs(body.attachments ?? body.artifacts, "attachments");
61
+ if (payload || attachments) {
62
+ input.payload = withPayloadAttachments(payload ?? {}, attachments as Array<Record<string, unknown>> | undefined);
63
+ }
64
+
65
+ return input;
66
+ }
67
+
68
+ const FANOUT_PREFIXES = ["tag:", "cap:", "label:"];
69
+
70
+ function isDirectTarget(to: string): boolean {
71
+ return to !== "broadcast" && !FANOUT_PREFIXES.some((p) => to.startsWith(p));
72
+ }
73
+
74
+ function applyReplyRouting(input: SendMessageInput): void {
75
+ if (input.to || !input.replyTo) return;
76
+ const parent = getMessage(input.replyTo);
77
+ if (!parent) return;
78
+ input.to = parent.from;
79
+ if (!input.channel && parent.channel) input.channel = parent.channel;
80
+ const parentPayload = parent.payload ?? {};
81
+ if (parentPayload.schema === "agent-relay.channel.v1" || parentPayload.conversation) {
82
+ const replyContext: Record<string, unknown> = {};
83
+ if (parent.channel) replyContext.channelId = parent.channel;
84
+ if (parentPayload.conversation && typeof parentPayload.conversation === "object") {
85
+ replyContext.conversationId = (parentPayload.conversation as Record<string, unknown>).id;
86
+ }
87
+ if (parentPayload.event && typeof parentPayload.event === "object") {
88
+ replyContext.parentEventId = (parentPayload.event as Record<string, unknown>).id;
89
+ }
90
+ if (parentPayload.source) replyContext.source = parentPayload.source;
91
+ input.payload = { ...input.payload, replyContext };
92
+ }
93
+ }
94
+
95
+ const VALID_MSG_KINDS = ["chat", "channel.event", "task", "pair", "control", "system", "session"];
96
+
97
+ export const getQueuedMessagesRoute: Handler = (req) => {
98
+ const url = new URL(req.url);
99
+ const target = cleanString(url.searchParams.get("for") ?? undefined, "for", { required: true, max: 240 })!;
100
+ const limitRaw = parseQueryInt(url.searchParams.get("limit"), { min: 1, max: 500 });
101
+ if (Number.isNaN(limitRaw)) return error("limit must be an integer between 1 and 500");
102
+ return json(listQueuedMessages(target, limitRaw ?? undefined));
103
+ };
104
+
105
+ export const getMessageStatusRoute: Handler = (_req, params) => {
106
+ const id = parseId(params.id);
107
+ if (id === null) return error("invalid message id");
108
+ const status = getMessageDeliveryStatus(id);
109
+ return status ? json(status) : error("message not found", 404);
110
+ };
111
+
112
+ export const getMessageDeliveryRoute: Handler = (_req, params) => {
113
+ const id = parseId(params.id);
114
+ if (id === null) return error("invalid message id");
115
+ const status = getMessageDeliveryStatus(id);
116
+ return status ? json(status) : error("message not found", 404);
117
+ };
118
+
119
+ export const postMessageDeliveryAttempt: Handler = async (req, params) => {
120
+ const id = parseId(params.id);
121
+ if (id === null) return error("invalid message id");
122
+ const parsed = await parseBody<unknown>(req);
123
+ if (!parsed.ok) return error(parsed.error, parsed.status);
124
+ try {
125
+ if (!isRecord(parsed.body)) throw new ValidationError("body must be an object");
126
+ const status = optionalEnum(parsed.body.status, "status", VALID_DELIVERY_STATUSES);
127
+ if (!status) throw new ValidationError("status required");
128
+ const nextRetryAt = cleanPositiveId(parsed.body.nextRetryAt, "nextRetryAt");
129
+ const result = recordMessageDeliveryAttempt(id, {
130
+ agentId: cleanString(parsed.body.agentId, "agentId", { max: 200 }),
131
+ status,
132
+ error: cleanString(parsed.body.error, "error", { max: 2000 }),
133
+ nextRetryAt,
134
+ poisonReason: cleanString(parsed.body.poisonReason, "poisonReason", { max: 2000 }),
135
+ });
136
+ if (!result.ok) return error(result.error ?? "message not found", 404);
137
+ emitMessageDeliveryUpdated(result.message!);
138
+ return json(getMessageDeliveryStatus(id), 201);
139
+ } catch (e) {
140
+ if (e instanceof ValidationError) return error(e.message, 400);
141
+ throw e;
142
+ }
143
+ };
144
+
145
+ export const postMessageDeliveryAction: Handler = async (req, params) => {
146
+ const id = parseId(params.id);
147
+ if (id === null) return error("invalid message id");
148
+ const parsed = await parseBody<unknown>(req);
149
+ if (!parsed.ok) return error(parsed.error, parsed.status);
150
+ try {
151
+ if (!isRecord(parsed.body)) throw new ValidationError("body must be an object");
152
+ const action = optionalEnum(parsed.body.action, "action", VALID_DELIVERY_ACTIONS);
153
+ if (!action) throw new ValidationError("action required");
154
+ const result = applyMessageDeliveryAction(id, {
155
+ action,
156
+ reason: cleanString(parsed.body.reason, "reason", { max: 2000 }),
157
+ agentId: cleanString(parsed.body.agentId, "agentId", { max: 200 }),
158
+ });
159
+ if (!result.ok) return error(result.error ?? "message not found", 404);
160
+ emitMessageDeliveryUpdated(result.message!);
161
+ return json(getMessageDeliveryStatus(id));
162
+ } catch (e) {
163
+ if (e instanceof ValidationError) return error(e.message, 400);
164
+ throw e;
165
+ }
166
+ };
167
+
168
+ export const postMessage: Handler = async (req) => {
169
+ const parsed = await parseBody<unknown>(req);
170
+ if (!parsed.ok) return error(parsed.error, parsed.status);
171
+ try {
172
+ const input = normalizeMessageInput(parsed.body);
173
+ if (!input.idempotencyKey) {
174
+ input.idempotencyKey = cleanString(req.headers.get("Idempotency-Key") ?? undefined, "idempotencyKey", { max: 240 });
175
+ }
176
+ applyReplyRouting(input);
177
+ if (!input.to) return error("to is required (or provide replyTo to auto-route)");
178
+ // Mechanical lifecycle/observability posts (system/control/session) addressed to a
179
+ // reserved sink ("user"/"system") are the relay's own lane, not agent-directed
180
+ // messaging. A managed token's recipient constraints (targets/policies/agents) gate
181
+ // which *agents* it may message — they must NOT gate a session-mirror capture to the
182
+ // reserved sink, or constrained tokens (telegram policy, codex steward) 403 → the
183
+ // runner's outbox retries 12× and poisons the record → the dashboard silently loses
184
+ // the turn (#284, same outbox-poison failure mode as #184). The message:send scope
185
+ // and any channel constraint still apply; we only drop the target/agentId predicate.
186
+ const reservedSinkPost = isMechanicalMessageKind(input.kind) && isReservedAgentId(input.to);
187
+ const denied = authorizeRoute(req, {
188
+ scope: "message:send",
189
+ resource: reservedSinkPost
190
+ ? { channel: input.channel }
191
+ : { target: input.to, channel: input.channel, agentId: input.from },
192
+ });
193
+ if (denied) return denied;
194
+ // Resolve the target through the shared planner — the SAME matcher the MCP send tool
195
+ // uses (planSend → matchAgents) — so a bare label / name / id-segment that matches an
196
+ // existing agent is rewritten to its canonical id. Poll-time matching is exact, so
197
+ // without this a bare label is stored verbatim and reaches no one (#234). An ambiguous
198
+ // direct ref can't be delivered, so reject it up front. Unknown targets are left
199
+ // untouched on purpose: sending to an id that isn't registered yet is a supported
200
+ // "store-ahead" pattern (delivered once that agent registers and polls). Fan-out and
201
+ // reserved/policy targets carry through unchanged (and may legitimately match zero now).
202
+ //
203
+ // "session" = observed assistant turn (Phase 1 live-session lane). It is captured
204
+ // from the provider transcript and stored for the dashboard chat; it must persist
205
+ // regardless of target liveness and never be re-delivered into a session.
206
+ if (!isMechanicalMessageKind(input.kind)) {
207
+ // excludeId: a bare ref must never resolve back to its own author — that self-loop
208
+ // silently swallows the message (the reddit-briefing → telegram bridge break, #290).
209
+ const plan = planSend(input.to, listAgents(), { excludeId: input.from });
210
+ if (plan.kind === "ambiguous") return error(plan.message, 409);
211
+ if (plan.kind !== "not_found") input.to = plan.to;
212
+ // Long-standing guard: refuse a direct send to a known-offline agent (now also
213
+ // catches a ref that resolved to an offline agent by label/segment).
214
+ const target = getAgent(input.to);
215
+ if (target && target.status === "offline") {
216
+ return error(`agent "${input.to}" is offline`, 422);
217
+ }
218
+ }
219
+ const result = sendMessageWithResult(input);
220
+ if (result.created) {
221
+ const autoMemoryTarget = automaticMemoryTarget(result.message);
222
+ if (autoMemoryTarget) {
223
+ try {
224
+ const memoryInjection = await injectMemoryForMessageDelivery(result.message, autoMemoryTarget, memoryContext(req));
225
+ if (memoryInjection) emitCommand(memoryInjection.command);
226
+ } catch (e) {
227
+ console.warn(`[memory] automatic message context assembly failed: ${errMessage(e)}`);
228
+ }
229
+ }
230
+ if (result.message.deliveryStatus === "queued") {
231
+ emitMessageQueued(result.message);
232
+ // Wake an on-demand managed policy that has no live agent attached yet.
233
+ if (result.message.to?.startsWith("policy:")) {
234
+ getLifecycleManager().onMessageForPolicy(result.message.to.slice("policy:".length));
235
+ }
236
+ } else emitNewMessage(result.message);
237
+ const isWork = result.message.kind === "task";
238
+ auditEvent({
239
+ clientId: "server-message-" + result.message.id,
240
+ kind: isWork ? "task" : "message",
241
+ title: isWork ? "Task message sent" : "Message sent",
242
+ body: result.message.subject || result.message.body,
243
+ meta: `${result.message.from} -> ${result.message.to}`,
244
+ icon: isWork ? "ti-hand-grab" : "ti-send",
245
+ view: "messages",
246
+ messageId: result.message.id,
247
+ agentId: result.message.from,
248
+ });
249
+ }
250
+ return json(result.message, result.created ? 201 : 200);
251
+ } catch (e) {
252
+ if (e instanceof ValidationError) return error(e.message, 400);
253
+ throw e;
254
+ }
255
+ };
256
+
257
+ function automaticMemoryTarget(message: { to: string; resolvedToAgent?: string; kind: string }): string | null {
258
+ if (isMechanicalMessageKind(message.kind)) return null;
259
+ const target = message.resolvedToAgent ?? message.to;
260
+ if (!isDirectTarget(target)) return null;
261
+ const agent = getAgent(target);
262
+ if (!agent || agent.kind !== "provider" || agent.status === "offline") return null;
263
+ return agent.id;
264
+ }
265
+
266
+ export const postSystemBroadcast: Handler = async (req) => {
267
+ const parsed = await parseBody<{ body: string; subject?: string }>(req);
268
+ if (!parsed.ok) return error(parsed.error, parsed.status);
269
+ const body = parsed.body;
270
+ if (!body?.body) return error("body required");
271
+ try {
272
+ const msg = sendMessage({
273
+ from: "system",
274
+ to: "broadcast",
275
+ kind: "system",
276
+ subject: body.subject ?? undefined,
277
+ body: body.body,
278
+ });
279
+ emitNewMessage(msg);
280
+ return json(msg, 201);
281
+ } catch (e) {
282
+ if (e instanceof ValidationError) return error(e.message, 400);
283
+ throw e;
284
+ }
285
+ };
286
+
287
+ export const getMessages: Handler = (req) => {
288
+ const url = new URL(req.url);
289
+ const forAgent = url.searchParams.get("for");
290
+ const limitRaw = parseQueryInt(url.searchParams.get("limit"), { min: 1, max: 500 });
291
+ if (Number.isNaN(limitRaw)) return error("limit must be an integer between 1 and 500");
292
+ const limit = limitRaw ?? undefined;
293
+
294
+ const sinceRaw = parseQueryInt(url.searchParams.get("since"), {
295
+ min: 0,
296
+ max: Number.MAX_SAFE_INTEGER,
297
+ });
298
+ if (Number.isNaN(sinceRaw)) return error("since must be a non-negative integer");
299
+ const since = sinceRaw ?? undefined;
300
+
301
+ const sinceIdRaw = parseQueryInt(url.searchParams.get("sinceId"), {
302
+ min: 0,
303
+ max: Number.MAX_SAFE_INTEGER,
304
+ });
305
+ if (Number.isNaN(sinceIdRaw)) return error("sinceId must be a non-negative integer");
306
+ const sinceId = sinceIdRaw ?? undefined;
307
+
308
+ const channel = url.searchParams.get("channel") ?? undefined;
309
+
310
+ // No agent filter — return all recent messages
311
+ if (!forAgent) {
312
+ return json(listRecentMessages(limit ?? 100, since, channel));
313
+ }
314
+
315
+ return json(pollMessages({
316
+ for: forAgent,
317
+ since,
318
+ sinceId,
319
+ unread: url.searchParams.get("unread") === "true",
320
+ channel,
321
+ limit,
322
+ }));
323
+ };
324
+
325
+ export const getMessageById: Handler = (_req, params) => {
326
+ const id = parseId(params.id);
327
+ if (id === null) return error("invalid message id");
328
+ const msg = getMessage(id);
329
+ return msg ? json(msg) : error("message not found", 404);
330
+ };
331
+
332
+ export const getMessageThread: Handler = (_req, params) => {
333
+ const id = parseId(params.id);
334
+ if (id === null) return error("invalid message id");
335
+ const thread = getThread(id);
336
+ if (!thread.length) return error("message not found", 404);
337
+ return json(thread);
338
+ };
339
+
340
+ export const postClaimMessage: Handler = async (req, params) => {
341
+ const id = parseId(params.id);
342
+ if (id === null) return error("invalid message id");
343
+ const parsed = await parseBody<{ agentId: string }>(req);
344
+ if (!parsed.ok) return error(parsed.error, parsed.status);
345
+ const body = parsed.body;
346
+ if (!body?.agentId) return error("agentId required");
347
+ let guard: AgentSessionGuard | undefined;
348
+ try {
349
+ guard = normalizeAgentSessionGuard(req, body);
350
+ } catch (e) {
351
+ if (e instanceof ValidationError) return error(e.message, 400);
352
+ throw e;
353
+ }
354
+ const result = claimMessage(id, body.agentId, guard);
355
+ if (result.ok) {
356
+ emitMessageClaimed(id, body.agentId, getMessage(id)?.claimExpiresAt);
357
+ auditEvent({
358
+ clientId: "server-message-" + id + "-claimed-" + body.agentId,
359
+ kind: "task",
360
+ title: "Message claimed",
361
+ body: "Message #" + id,
362
+ meta: "by " + body.agentId,
363
+ icon: "ti-user-check",
364
+ view: "work",
365
+ messageId: id,
366
+ agentId: body.agentId,
367
+ });
368
+ if (result.task) {
369
+ emitTaskChanged(result.task, "task.claimed");
370
+ void dispatchTaskCallbacks(result.task.id, "task.claimed");
371
+ }
372
+ return json({ ok: true, task: result.task, claimExpiresAt: getMessage(id)?.claimExpiresAt });
373
+ }
374
+ const status =
375
+ result.error === "message not found" ? 404 :
376
+ result.error === "claiming agent not found" ? 400 :
377
+ result.error === "message is not claimable" ? 400 :
378
+ 409;
379
+ return error(result.error!, status);
380
+ };
381
+
382
+ export const postRenewMessageClaim: Handler = async (req, params) => {
383
+ const id = parseId(params.id);
384
+ if (id === null) return error("invalid message id");
385
+ const parsed = await parseBody<{ agentId: string }>(req);
386
+ if (!parsed.ok) return error(parsed.error, parsed.status);
387
+ const agentId = parsed.body?.agentId;
388
+ if (!agentId) return error("agentId required");
389
+ let guard: AgentSessionGuard | undefined;
390
+ try {
391
+ guard = normalizeAgentSessionGuard(req, parsed.body);
392
+ } catch (e) {
393
+ if (e instanceof ValidationError) return error(e.message, 400);
394
+ throw e;
395
+ }
396
+ const result = renewMessageClaim(id, agentId, guard);
397
+ if (!result.ok) {
398
+ const status =
399
+ result.error === "message not found" ? 404 :
400
+ result.error === "claiming agent not found" ? 400 :
401
+ result.error === "message is not claimable" ? 400 :
402
+ 409;
403
+ return error(result.error!, status);
404
+ }
405
+ emitMessageClaimed(id, agentId, getMessage(id)?.claimExpiresAt);
406
+ if (result.task) emitTaskChanged(result.task, "task.updated");
407
+ return json({ ok: true, claimExpiresAt: getMessage(id)?.claimExpiresAt });
408
+ };
409
+
410
+ export const patchMessage: Handler = async (req, params) => {
411
+ const id = parseId(params.id);
412
+ if (id === null) return error("invalid message id");
413
+ const parsed = await parseBody<{ readBy: string }>(req);
414
+ if (!parsed.ok) return error(parsed.error, parsed.status);
415
+ const body = parsed.body;
416
+ if (!body?.readBy) return error("readBy required");
417
+ if (!markRead(id, body.readBy)) return error("message not found", 404);
418
+ const msg = getMessage(id);
419
+ if (msg) emitMessageDeliveryUpdated(msg);
420
+ return json({ ok: true });
421
+ };
422
+
423
+ function telegramTargetFromMessage(msg: Message): { chatId: string; messageId: string; accountId?: string } | null {
424
+ const source = msg.payload?.source;
425
+ const sourceTelegram = isRecord(source) && isRecord(source.telegram) ? source.telegram : undefined;
426
+ const replyContext = msg.payload?.replyContext;
427
+ const replySource = isRecord(replyContext) && isRecord(replyContext.source) ? replyContext.source : undefined;
428
+ const replyTelegram = replySource && isRecord(replySource.telegram) ? replySource.telegram : undefined;
429
+ const telegram = sourceTelegram ?? replyTelegram;
430
+ if (!telegram) return null;
431
+ const chatId = cleanString(telegram.chatId, "telegram.chatId", { max: 120 });
432
+ const messageId = cleanString(telegram.messageId, "telegram.messageId", { max: 120 });
433
+ if (!chatId || !messageId) return null;
434
+ const channel = isRecord(msg.payload?.channel) ? msg.payload.channel : undefined;
435
+ const accountId = channel ? cleanString(channel.accountId, "payload.channel.accountId", { max: 200 }) : undefined;
436
+ return { chatId, messageId, accountId };
437
+ }
438
+
439
+ function channelReactionActor(actorId: string): Record<string, unknown> {
440
+ const agent = getAgent(actorId);
441
+ return {
442
+ id: actorId,
443
+ kind: actorId === "user" ? "human" : "agent",
444
+ displayName: agent?.label ?? agent?.name ?? actorId,
445
+ };
446
+ }
447
+
448
+ function sendOutboundChannelReaction(parent: Message, actorId: string, emoji: string): void {
449
+ if (!parent.channel) return;
450
+ const target = telegramTargetFromMessage(parent);
451
+ if (!target) return;
452
+ const payloadChannel = isRecord(parent.payload?.channel) ? parent.payload.channel : {};
453
+ const accountId = target.accountId ?? cleanString(payloadChannel.accountId, "payload.channel.accountId", { max: 200 }) ?? "default";
454
+ const conversation = isRecord(parent.payload?.conversation) ? parent.payload.conversation : undefined;
455
+ const now = Date.now();
456
+ const result = sendMessageWithResult({
457
+ from: actorId,
458
+ to: parent.channel,
459
+ kind: "channel.event",
460
+ channel: parent.channel,
461
+ body: `Reaction: ${emoji}`,
462
+ idempotencyKey: `reaction:${parent.id}:${actorId}:${emoji}:${now}`,
463
+ payload: {
464
+ schema: "agent-relay.channel.v1",
465
+ channel: {
466
+ provider: cleanString(payloadChannel.provider, "payload.channel.provider", { max: 80 }) ?? "telegram",
467
+ accountId,
468
+ agentId: parent.channel,
469
+ },
470
+ direction: "outbound",
471
+ conversation: conversation ?? { id: `telegram:${accountId}:dm:${target.chatId}`, type: "dm" },
472
+ actor: channelReactionActor(actorId),
473
+ event: {
474
+ id: `relay:${parent.id}:reaction:${actorId}:${now}`,
475
+ type: "message.reaction",
476
+ ts: new Date(now).toISOString(),
477
+ },
478
+ reaction: {
479
+ emoji,
480
+ action: "add",
481
+ target: {
482
+ source: {
483
+ telegram: {
484
+ chatId: target.chatId,
485
+ messageId: target.messageId,
486
+ },
487
+ },
488
+ },
489
+ },
490
+ },
491
+ });
492
+ if (result.created) {
493
+ if (result.message.deliveryStatus === "queued") emitMessageQueued(result.message);
494
+ else emitNewMessage(result.message);
495
+ }
496
+ }
497
+
498
+ export const postMessageReaction: Handler = async (req, params) => {
499
+ const id = parseId(params.id);
500
+ if (id === null) return error("invalid message id");
501
+ const parsed = await parseBody<unknown>(req);
502
+ if (!parsed.ok) return error(parsed.error, parsed.status);
503
+ try {
504
+ if (!isRecord(parsed.body)) throw new ValidationError("reaction body must be an object");
505
+ const actorId = cleanString(parsed.body.actorId, "actorId", { max: 200 }) ?? "user";
506
+ const emoji = reactionEmoji(parsed.body.emoji);
507
+ const action = cleanString(parsed.body.action, "action", { max: 20 }) ?? "add";
508
+ if (action !== "add" && action !== "remove") throw new ValidationError("action must be add or remove");
509
+ const result = setMessageReaction({ messageId: id, actorId, emoji, action });
510
+ if (!result.ok) return error(result.error, result.error === "message not found" ? 404 : 400);
511
+ emitMessageReactionUpdated(result.message);
512
+ if (action === "add") sendOutboundChannelReaction(result.message, actorId, emoji);
513
+ sendReactionNotificationToAuthor(result.message, actorId, emoji, action);
514
+ return json(result.message);
515
+ } catch (e) {
516
+ if (e instanceof ValidationError) return error(e.message, 400);
517
+ throw e;
518
+ }
519
+ };
520
+
521
+ export const deleteMessageById: Handler = (_req, params) => {
522
+ const id = parseId(params.id);
523
+ if (id === null) return error("invalid message id");
524
+ if (!deleteMessage(id)) return error("message not found", 404);
525
+ emitMessageDeleted(id);
526
+ return json({ ok: true });
527
+ };
528
+
529
+ export const getCursorRoute: Handler = () => json({ latestId: getLatestMessageId() });
@@ -0,0 +1,100 @@
1
+ // Auto-split from routes.ts (#299). Domain: orchestrator-bootstrap.
2
+ import { SPAWN_PROVIDERS, isRecord } from "agent-relay-sdk";
3
+ import { VERSION } from "../config";
4
+ import { ValidationError } from "../db";
5
+ import { authorizeRoute, cleanSafeNumber, error, json, parseBody, type Handler } from "./_shared";
6
+ import { cleanString, cleanStringArray } from "../validation";
7
+ import { createToken, revokeToken } from "../token-db";
8
+ import { getComponentAuth } from "../security";
9
+ import { issueOrchestratorRuntimeToken } from "../runtime-tokens";
10
+ import { type SpawnProvider } from "../types";
11
+
12
+ export const postOrchestratorBootstrap: Handler = async (req) => {
13
+ const parsed = await parseBody<unknown>(req);
14
+ if (!parsed.ok) return error(parsed.error, parsed.status);
15
+ try {
16
+ if (!isRecord(parsed.body)) return error("body required");
17
+ const id = cleanString(parsed.body.id, "id", { required: true, max: 120 })!;
18
+ const baseDir = cleanString(parsed.body.baseDir, "baseDir", { required: true, max: 500 })!;
19
+ const providers = cleanStringArray(parsed.body.providers, "providers", { itemMax: 80, maxItems: 50 }) as SpawnProvider[] | undefined;
20
+ const apiPort = cleanSafeNumber(parsed.body.apiPort) ?? 4860;
21
+ if (!Number.isInteger(apiPort) || apiPort < 1 || apiPort > 65535) throw new ValidationError("apiPort must be an integer from 1 to 65535");
22
+ const relayUrl = cleanString(parsed.body.relayUrl, "relayUrl", { max: 500 }) ?? new URL(req.url).origin;
23
+ const version = cleanString(parsed.body.version, "version", { max: 80 }) ?? VERSION;
24
+ const pathPrefix = cleanStringArray(parsed.body.pathPrefix, "pathPrefix", { itemMax: 80, maxItems: 50 });
25
+ const providerList = providers?.length ? providers : ["claude", "codex"];
26
+ for (const p of providerList) {
27
+ if (!SPAWN_PROVIDERS.includes(p as any)) {
28
+ throw new ValidationError(`invalid provider: ${p}. Must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
29
+ }
30
+ }
31
+ const bootstrapToken = createToken({
32
+ sub: `orchestrator:${id}`,
33
+ role: "orchestrator-bootstrap",
34
+ scope: ["token:write"],
35
+ constraints: {
36
+ orchestrators: [id],
37
+ },
38
+ ttlSeconds: 15 * 60,
39
+ createdBy: "dashboard-bootstrap",
40
+ });
41
+ const args = [
42
+ "orchestrator", "install",
43
+ "--relay-url", relayUrl,
44
+ "--bootstrap-token", bootstrapToken.token,
45
+ "--id", id,
46
+ "--base-dir", baseDir,
47
+ "--providers", providerList.join(","),
48
+ "--api-port", String(apiPort),
49
+ "--version", version,
50
+ "--yes",
51
+ ];
52
+ for (const entry of pathPrefix ?? []) args.push("--path-prefix", entry);
53
+ const command = ["bunx", `agent-relay-server@${version}`, ...args].map(quoteInstallCommandArg).join(" ");
54
+ return json({
55
+ command,
56
+ relayUrl,
57
+ id,
58
+ baseDir,
59
+ providers: providerList,
60
+ apiPort,
61
+ version,
62
+ pathPrefix: pathPrefix ?? [],
63
+ token: bootstrapToken.record,
64
+ expiresAt: bootstrapToken.record.expiresAt,
65
+ }, 201);
66
+ } catch (e) {
67
+ if (e instanceof ValidationError) return error(e.message, 400);
68
+ throw e;
69
+ }
70
+ };
71
+
72
+ export const postOrchestratorBootstrapExchange: Handler = async (req) => {
73
+ const parsed = await parseBody<unknown>(req);
74
+ if (!parsed.ok) return error(parsed.error, parsed.status);
75
+ try {
76
+ if (!isRecord(parsed.body)) return error("body required");
77
+ const id = cleanString(parsed.body.id, "id", { required: true, max: 120 })!;
78
+ const baseDir = cleanString(parsed.body.baseDir, "baseDir", { required: true, max: 500 })!;
79
+ const denied = authorizeRoute(req, { scope: "token:write", resource: { orchestratorId: id } });
80
+ if (denied) return denied;
81
+ const auth = getComponentAuth(req);
82
+ if (!auth || auth.role !== "orchestrator-bootstrap") return error("bootstrap token required", 403);
83
+ const runtimeToken = issueOrchestratorRuntimeToken({
84
+ orchestratorId: id,
85
+ baseDir,
86
+ createdBy: auth.jti ? `bootstrap:${auth.jti}` : "bootstrap",
87
+ });
88
+ if (auth.jti) revokeToken(auth.jti);
89
+ return json(runtimeToken, 201);
90
+ } catch (e) {
91
+ if (e instanceof ValidationError) return error(e.message, 400);
92
+ throw e;
93
+ }
94
+ };
95
+
96
+ function quoteInstallCommandArg(value: string): string {
97
+ if (/^[A-Za-z0-9_./:=,@+-]+$/.test(value)) return value;
98
+ if (value.startsWith("$HOME/") && !value.includes("\"") && !value.includes("`") && !value.includes("\\")) return `"${value}"`;
99
+ return `'${value.replace(/'/g, `'\\''`)}'`;
100
+ }