agent-relay-server 0.5.0 → 0.6.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/README.md +8 -8
- package/package.json +1 -1
- package/public/dashboard.js +106 -17
- package/public/icons/agent-relay-192.png +0 -0
- package/public/icons/agent-relay-512.png +0 -0
- package/public/icons/agent-relay.svg +14 -0
- package/public/index.html +91 -4
- package/public/manifest.webmanifest +33 -0
- package/public/sw.js +58 -0
- package/src/cli.ts +80 -17
- package/src/connectors.ts +256 -0
- package/src/db.ts +413 -25
- package/src/index.ts +25 -1
- package/src/routes.ts +380 -32
- package/src/security.ts +2 -1
- package/src/sse.ts +6 -0
- package/src/types.ts +92 -3
package/src/index.ts
CHANGED
|
@@ -117,12 +117,36 @@ export function createFetchHandler(
|
|
|
117
117
|
return Response.json({ error: "not found" }, { status: 404 });
|
|
118
118
|
}
|
|
119
119
|
const file = Bun.file(resolved);
|
|
120
|
-
if (await file.exists())
|
|
120
|
+
if (await file.exists()) {
|
|
121
|
+
const headers = staticHeaders(requested);
|
|
122
|
+
return new Response(file, { headers });
|
|
123
|
+
}
|
|
121
124
|
|
|
122
125
|
return Response.json({ error: "not found" }, { status: 404 });
|
|
123
126
|
};
|
|
124
127
|
}
|
|
125
128
|
|
|
129
|
+
function staticHeaders(pathname: string): Headers {
|
|
130
|
+
const headers = new Headers();
|
|
131
|
+
const contentType = staticContentType(pathname);
|
|
132
|
+
if (contentType) headers.set("Content-Type", contentType);
|
|
133
|
+
if (pathname === "/sw.js") {
|
|
134
|
+
headers.set("Cache-Control", "no-cache");
|
|
135
|
+
} else if (pathname === "/manifest.webmanifest") {
|
|
136
|
+
headers.set("Cache-Control", "no-cache");
|
|
137
|
+
}
|
|
138
|
+
return headers;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function staticContentType(pathname: string): string | undefined {
|
|
142
|
+
if (pathname.endsWith(".webmanifest")) return "application/manifest+json; charset=utf-8";
|
|
143
|
+
if (pathname.endsWith(".js")) return "text/javascript; charset=utf-8";
|
|
144
|
+
if (pathname.endsWith(".html")) return "text/html; charset=utf-8";
|
|
145
|
+
if (pathname.endsWith(".svg")) return "image/svg+xml";
|
|
146
|
+
if (pathname.endsWith(".png")) return "image/png";
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
126
150
|
if (import.meta.main) {
|
|
127
151
|
main().catch((error) => {
|
|
128
152
|
console.error(error instanceof Error ? error.message : String(error));
|
package/src/routes.ts
CHANGED
|
@@ -42,6 +42,11 @@ import {
|
|
|
42
42
|
setInboxDraft,
|
|
43
43
|
deleteInboxDraft,
|
|
44
44
|
listActivityEvents,
|
|
45
|
+
listChannels,
|
|
46
|
+
listChannelBindings,
|
|
47
|
+
getChannel,
|
|
48
|
+
resolveChannelRoutes,
|
|
49
|
+
upsertChannelBinding,
|
|
45
50
|
createActivityEvent,
|
|
46
51
|
createCallbackDelivery,
|
|
47
52
|
finishCallbackDelivery,
|
|
@@ -57,7 +62,15 @@ import {
|
|
|
57
62
|
deleteOrchestrator,
|
|
58
63
|
ValidationError,
|
|
59
64
|
} from "./db";
|
|
60
|
-
import
|
|
65
|
+
import {
|
|
66
|
+
getConnector,
|
|
67
|
+
listConnectors,
|
|
68
|
+
readConnectorConfig,
|
|
69
|
+
registerConnectorManifest,
|
|
70
|
+
runConnectorAction,
|
|
71
|
+
writeConnectorConfig,
|
|
72
|
+
} from "./connectors";
|
|
73
|
+
import type { ActivityEventInput, ActivityKind, AgentCard, AgentKind, AgentSessionGuard, ChannelBinding, ChannelBindingMode, ChannelDirection, ChannelRouteTarget, ChannelSummary, CreatePairInput, IntegrationEventInput, IntegrationSummary, ManagedAgent, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, RegisterOrchestratorInput, SendMessageInput, SpawnApprovalMode, SpawnProvider, TaskStatus, TaskStatusInput } from "./types";
|
|
61
74
|
import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES } from "./config";
|
|
62
75
|
import { listHostDirectories, spawnCodexAgent, type CodexSpawnApprovalMode } from "./agent-spawn";
|
|
63
76
|
import {
|
|
@@ -74,6 +87,7 @@ import {
|
|
|
74
87
|
emitMessageClaimReleased,
|
|
75
88
|
emitMessageDeleted,
|
|
76
89
|
emitTaskChanged,
|
|
90
|
+
emitChannelActivity,
|
|
77
91
|
emitOrchestratorStatus,
|
|
78
92
|
emitOrchestratorRemoved,
|
|
79
93
|
} from "./sse";
|
|
@@ -160,9 +174,13 @@ function parseQueryInt(
|
|
|
160
174
|
}
|
|
161
175
|
|
|
162
176
|
const VALID_AGENT_STATUSES = ["online", "idle", "busy", "offline"] as const;
|
|
177
|
+
const VALID_AGENT_KINDS = ["provider", "channel", "orchestrator", "system", "user"] as const;
|
|
178
|
+
const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "tag", "capability", "broadcast", "orchestrator"] as const;
|
|
179
|
+
const VALID_CHANNEL_BINDING_MODES = ["exclusive", "claimable", "broadcast"] as const;
|
|
163
180
|
const VALID_AGENT_ACTIONS = ["restart", "shutdown"] as const;
|
|
164
181
|
const VALID_AGENT_SPAWN_PROVIDERS = ["codex"] as const;
|
|
165
182
|
const VALID_CODEX_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
|
|
183
|
+
const VALID_CONNECTOR_ACTIONS = ["install", "uninstall", "enable", "disable", "start", "stop", "restart", "status", "doctor"] as const;
|
|
166
184
|
const VALID_TASK_SEVERITIES = ["info", "warning", "critical"] as const;
|
|
167
185
|
const VALID_TASK_STATUSES = ["open", "claimed", "in_progress", "blocked", "done", "failed", "canceled"] as const;
|
|
168
186
|
const VALID_PAIR_STATUSES = ["pending", "active", "ended", "rejected", "expired"] as const;
|
|
@@ -282,6 +300,7 @@ function normalizeAgentInput(body: unknown): RegisterAgentInput {
|
|
|
282
300
|
const input: RegisterAgentInput = {
|
|
283
301
|
id: cleanString(body.id, "id", { required: true, max: 200 })!,
|
|
284
302
|
name: cleanString(body.name, "name", { required: true, max: 200 })!,
|
|
303
|
+
kind: cleanEnum(body.kind, "kind", VALID_AGENT_KINDS) as AgentKind | undefined,
|
|
285
304
|
status: status as RegisterAgentInput["status"] | undefined,
|
|
286
305
|
ready: body.ready as boolean | undefined,
|
|
287
306
|
};
|
|
@@ -306,9 +325,9 @@ function normalizeAgentInput(body: unknown): RegisterAgentInput {
|
|
|
306
325
|
|
|
307
326
|
function normalizeMessageInput(body: unknown): SendMessageInput {
|
|
308
327
|
if (!isRecord(body)) throw new ValidationError("JSON object body required");
|
|
309
|
-
const
|
|
310
|
-
if (
|
|
311
|
-
throw new ValidationError(`
|
|
328
|
+
const kind = cleanString(body.kind, "kind", { max: 40 });
|
|
329
|
+
if (kind && !VALID_MSG_KINDS.includes(kind)) {
|
|
330
|
+
throw new ValidationError(`kind must be one of: ${VALID_MSG_KINDS.join(", ")}`);
|
|
312
331
|
}
|
|
313
332
|
if (body.claimable !== undefined && typeof body.claimable !== "boolean") {
|
|
314
333
|
throw new ValidationError("claimable must be a boolean");
|
|
@@ -318,7 +337,7 @@ function normalizeMessageInput(body: unknown): SendMessageInput {
|
|
|
318
337
|
from: cleanString(body.from, "from", { required: true, max: 200 })!,
|
|
319
338
|
to: cleanString(body.to, "to", { required: true, max: 200 })!,
|
|
320
339
|
body: cleanString(body.body, "body", { required: true, max: MAX_BODY_BYTES })!,
|
|
321
|
-
|
|
340
|
+
kind: kind as SendMessageInput["kind"] | undefined,
|
|
322
341
|
replyTo: cleanPositiveId(body.replyTo, "replyTo"),
|
|
323
342
|
claimable: body.claimable as boolean | undefined,
|
|
324
343
|
idempotencyKey: cleanString(body.idempotencyKey, "idempotencyKey", { max: 240 }),
|
|
@@ -330,10 +349,168 @@ function normalizeMessageInput(body: unknown): SendMessageInput {
|
|
|
330
349
|
if (subject) input.subject = subject;
|
|
331
350
|
const meta = cleanMeta(body.meta);
|
|
332
351
|
if (meta) input.meta = meta;
|
|
352
|
+
const payload = cleanMeta(body.payload);
|
|
353
|
+
if (payload) input.payload = payload;
|
|
333
354
|
|
|
334
355
|
return input;
|
|
335
356
|
}
|
|
336
357
|
|
|
358
|
+
function normalizeChannelBindingInput(body: unknown): {
|
|
359
|
+
channelId: string;
|
|
360
|
+
conversationId?: string;
|
|
361
|
+
target: ChannelRouteTarget;
|
|
362
|
+
mode?: ChannelBindingMode;
|
|
363
|
+
priority?: number;
|
|
364
|
+
} {
|
|
365
|
+
if (!isRecord(body)) throw new ValidationError("JSON object body required");
|
|
366
|
+
const channelId = cleanString(body.channelId, "channelId", { required: true, max: 200 })!;
|
|
367
|
+
const conversationId = cleanString(body.conversationId, "conversationId", { max: 300 });
|
|
368
|
+
const mode = cleanEnum(body.mode, "mode", VALID_CHANNEL_BINDING_MODES, "exclusive") as ChannelBindingMode | undefined;
|
|
369
|
+
const priority = typeof body.priority === "number" && Number.isSafeInteger(body.priority) ? body.priority : undefined;
|
|
370
|
+
|
|
371
|
+
let target: ChannelRouteTarget | undefined;
|
|
372
|
+
if (typeof body.target === "string") {
|
|
373
|
+
target = routeTargetFromAddress(body.target);
|
|
374
|
+
} else if (isRecord(body.target)) {
|
|
375
|
+
const type = cleanEnum(body.target.type, "target.type", VALID_CHANNEL_BINDING_TARGET_TYPES)!;
|
|
376
|
+
const id = type === "broadcast" ? undefined : cleanString(body.target.id, "target.id", { required: true, max: 240 })!;
|
|
377
|
+
if (type === "orchestrator") throw new ValidationError("orchestrator channel targets are not supported yet");
|
|
378
|
+
target = type === "broadcast" ? { type: "broadcast" } : { type, id } as ChannelRouteTarget;
|
|
379
|
+
}
|
|
380
|
+
if (!target) throw new ValidationError("target required");
|
|
381
|
+
|
|
382
|
+
return { channelId, conversationId, target, mode, priority };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function routeTargetFromAddress(target: string): ChannelRouteTarget {
|
|
386
|
+
if (target.startsWith("label:")) return { type: "label", id: target.slice("label:".length) };
|
|
387
|
+
if (target.startsWith("tag:")) return { type: "tag", id: target.slice("tag:".length) };
|
|
388
|
+
if (target.startsWith("cap:")) return { type: "capability", id: target.slice("cap:".length) };
|
|
389
|
+
if (target === "broadcast") return { type: "broadcast" };
|
|
390
|
+
if (target.startsWith("orchestrator:")) throw new ValidationError("orchestrator channel targets are not supported yet");
|
|
391
|
+
return { type: "agent", id: target };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function messageTargetForChannelTarget(target: ChannelRouteTarget): string {
|
|
395
|
+
if (target.type === "orchestrator") throw new ValidationError("orchestrator channel targets are not supported yet");
|
|
396
|
+
if (target.type === "label") return `label:${target.id}`;
|
|
397
|
+
if (target.type === "tag") return `tag:${target.id}`;
|
|
398
|
+
if (target.type === "capability") return `cap:${target.id}`;
|
|
399
|
+
if (target.type === "broadcast") return "broadcast";
|
|
400
|
+
return target.id;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function payloadConversationId(payload: Record<string, unknown>): string | undefined {
|
|
404
|
+
const conversation = payload.conversation;
|
|
405
|
+
if (isRecord(conversation)) return cleanString(conversation.id, "payload.conversation.id", { max: 300 });
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function payloadDedupeKey(payload: Record<string, unknown>): string | undefined {
|
|
410
|
+
const dedupe = payload.dedupe;
|
|
411
|
+
if (isRecord(dedupe)) return cleanString(dedupe.key, "payload.dedupe.key", { max: 240 });
|
|
412
|
+
return undefined;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const EPHEMERAL_CHANNEL_EVENT_TYPES = new Set([
|
|
416
|
+
"typing.started",
|
|
417
|
+
"typing.stopped",
|
|
418
|
+
"message.read",
|
|
419
|
+
"message.delivered",
|
|
420
|
+
"presence.updated",
|
|
421
|
+
]);
|
|
422
|
+
|
|
423
|
+
function channelPayloadEventType(payload: Record<string, unknown>): string {
|
|
424
|
+
const event = payload.event;
|
|
425
|
+
if (!isRecord(event)) throw new ValidationError("payload.event must be an object");
|
|
426
|
+
return cleanString(event.type, "payload.event.type", { required: true, max: 80 })!;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function validateChannelEnvelope(payload: Record<string, unknown>, channel: ChannelSummary): void {
|
|
430
|
+
const schema = cleanString(payload.schema, "payload.schema", { required: true, max: 80 });
|
|
431
|
+
if (schema !== "agent-relay.channel.v1") throw new ValidationError("payload.schema must be agent-relay.channel.v1");
|
|
432
|
+
|
|
433
|
+
const payloadChannel = payload.channel;
|
|
434
|
+
if (!isRecord(payloadChannel)) throw new ValidationError("payload.channel must be an object");
|
|
435
|
+
const provider = cleanString(payloadChannel.provider, "payload.channel.provider", { required: true, max: 80 });
|
|
436
|
+
const accountId = cleanString(payloadChannel.accountId, "payload.channel.accountId", { required: true, max: 200 });
|
|
437
|
+
const agentId = cleanString(payloadChannel.agentId, "payload.channel.agentId", { required: true, max: 200 });
|
|
438
|
+
if (provider !== channel.type || accountId !== channel.accountId || agentId !== channel.agentId) {
|
|
439
|
+
throw new ValidationError("payload.channel does not match channel identity");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const direction = cleanString(payload.direction, "payload.direction", { required: true, max: 20 });
|
|
443
|
+
if (direction !== "inbound" && direction !== "outbound") throw new ValidationError("payload.direction must be inbound or outbound");
|
|
444
|
+
|
|
445
|
+
const conversation = payload.conversation;
|
|
446
|
+
if (!isRecord(conversation)) throw new ValidationError("payload.conversation must be an object");
|
|
447
|
+
cleanString(conversation.id, "payload.conversation.id", { required: true, max: 300 });
|
|
448
|
+
cleanString(conversation.type, "payload.conversation.type", { required: true, max: 80 });
|
|
449
|
+
|
|
450
|
+
const actor = payload.actor;
|
|
451
|
+
if (!isRecord(actor)) throw new ValidationError("payload.actor must be an object");
|
|
452
|
+
cleanString(actor.id, "payload.actor.id", { required: true, max: 240 });
|
|
453
|
+
cleanString(actor.kind, "payload.actor.kind", { required: true, max: 80 });
|
|
454
|
+
|
|
455
|
+
channelPayloadEventType(payload);
|
|
456
|
+
validateChannelAttachmentRefs(payload);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function validateChannelPayloadContent(payload: Record<string, unknown>, allowActivity: boolean): void {
|
|
460
|
+
const hasMessage = isRecord(payload.message);
|
|
461
|
+
const hasAttachments = Array.isArray(payload.attachments);
|
|
462
|
+
const hasReaction = isRecord(payload.reaction);
|
|
463
|
+
const hasInteraction = isRecord(payload.interaction);
|
|
464
|
+
const hasActivity = isRecord(payload.activity);
|
|
465
|
+
if (!hasMessage && !hasAttachments && !hasReaction && !hasInteraction && !hasActivity) {
|
|
466
|
+
throw new ValidationError("channel payload must include message, attachments, reaction, interaction, or activity");
|
|
467
|
+
}
|
|
468
|
+
if (!allowActivity && hasActivity) throw new ValidationError("channel activity must use /api/channels/:id/activities");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function validateChannelAttachmentRefs(payload: Record<string, unknown>): void {
|
|
472
|
+
if (payload.attachments === undefined) return;
|
|
473
|
+
if (!Array.isArray(payload.attachments)) throw new ValidationError("payload.attachments must be an array");
|
|
474
|
+
for (const [index, item] of payload.attachments.entries()) {
|
|
475
|
+
if (!isRecord(item)) throw new ValidationError(`payload.attachments[${index}] must be an object`);
|
|
476
|
+
const ref = item.ref;
|
|
477
|
+
if (!isRecord(ref)) throw new ValidationError(`payload.attachments[${index}].ref must be an object`);
|
|
478
|
+
const type = cleanString(ref.type, `payload.attachments[${index}].ref.type`, { required: true, max: 40 });
|
|
479
|
+
if (type !== "relay-blob" && type !== "external-url" && type !== "channel-file") {
|
|
480
|
+
throw new ValidationError(`payload.attachments[${index}].ref.type must be relay-blob, external-url, or channel-file`);
|
|
481
|
+
}
|
|
482
|
+
if (type === "external-url") cleanString(ref.url, `payload.attachments[${index}].ref.url`, { required: true, max: 2048 });
|
|
483
|
+
if (type === "relay-blob" || type === "channel-file") cleanString(ref.id, `payload.attachments[${index}].ref.id`, { required: true, max: 400 });
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function normalizeChannelEventBody(body: unknown): {
|
|
488
|
+
body: string;
|
|
489
|
+
payload: Record<string, unknown>;
|
|
490
|
+
conversationId?: string;
|
|
491
|
+
idempotencyKey?: string;
|
|
492
|
+
} {
|
|
493
|
+
if (!isRecord(body)) throw new ValidationError("JSON object body required");
|
|
494
|
+
const payload = cleanMeta(body.payload) ?? body;
|
|
495
|
+
const fallbackBody = cleanString(body.body, "body", { max: MAX_BODY_BYTES });
|
|
496
|
+
const text = isRecord(payload.message) ? cleanString(payload.message.text, "payload.message.text", { max: MAX_BODY_BYTES }) : undefined;
|
|
497
|
+
const conversationId = cleanString(body.conversationId, "conversationId", { max: 300 }) ?? payloadConversationId(payload);
|
|
498
|
+
return {
|
|
499
|
+
body: fallbackBody ?? text ?? "Channel event",
|
|
500
|
+
payload,
|
|
501
|
+
conversationId,
|
|
502
|
+
idempotencyKey: cleanString(body.idempotencyKey, "idempotencyKey", { max: 240 }) ?? payloadDedupeKey(payload),
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function requireChannelSession(req: Request, body: unknown, channel: ChannelSummary): Response | undefined {
|
|
507
|
+
const guard = normalizeAgentSessionGuard(req, body);
|
|
508
|
+
if (!guard || (!guard.instanceId && guard.epoch === undefined)) return error("channel session guard required", 400);
|
|
509
|
+
const session = validateAgentSession(channel.agentId, guard);
|
|
510
|
+
if (!session.ok) return error(session.error!, agentSessionStatus(session.error));
|
|
511
|
+
return undefined;
|
|
512
|
+
}
|
|
513
|
+
|
|
337
514
|
function normalizeIntegrationEvent(body: unknown): IntegrationEventInput {
|
|
338
515
|
if (!isRecord(body)) throw new ValidationError("JSON object body required");
|
|
339
516
|
const status = cleanEnum(body.status, "status", [...VALID_TASK_STATUSES, "resolved"] as const);
|
|
@@ -521,7 +698,8 @@ function metaDirection(meta: Record<string, unknown> | undefined): ChannelDirect
|
|
|
521
698
|
|
|
522
699
|
function isChannelAgent(agent: AgentCard): boolean {
|
|
523
700
|
const kind = metaString(agent.meta, "kind");
|
|
524
|
-
return kind === "channel" ||
|
|
701
|
+
return agent.kind === "channel" ||
|
|
702
|
+
kind === "channel" ||
|
|
525
703
|
kind === "communication-channel" ||
|
|
526
704
|
agent.tags.includes("channel") ||
|
|
527
705
|
agent.capabilities.includes("channel");
|
|
@@ -531,6 +709,7 @@ function agentToChannel(agent: AgentCard): ChannelSummary {
|
|
|
531
709
|
const type = metaString(agent.meta, "channelType") ?? metaString(agent.meta, "type") ?? agent.tags.find((tag) => tag.startsWith("channel:"))?.slice("channel:".length) ?? "custom";
|
|
532
710
|
const transport = metaString(agent.meta, "transport") ?? type;
|
|
533
711
|
const topicChannels = metaStringArray(agent.meta, "topicChannels");
|
|
712
|
+
const accountId = metaString(agent.meta, "accountId") ?? (agent.id.startsWith(`${type}:`) ? agent.id.slice(type.length + 1) : agent.id);
|
|
534
713
|
|
|
535
714
|
return {
|
|
536
715
|
id: metaString(agent.meta, "channelId") ?? agent.id,
|
|
@@ -538,6 +717,7 @@ function agentToChannel(agent: AgentCard): ChannelSummary {
|
|
|
538
717
|
type,
|
|
539
718
|
transport,
|
|
540
719
|
agentId: agent.id,
|
|
720
|
+
accountId,
|
|
541
721
|
status: agent.status,
|
|
542
722
|
ready: agent.ready,
|
|
543
723
|
direction: metaDirection(agent.meta),
|
|
@@ -773,17 +953,19 @@ const postAgentAction: Handler = async (req, params) => {
|
|
|
773
953
|
const msg = sendMessage({
|
|
774
954
|
from: "system",
|
|
775
955
|
to: agent.id,
|
|
776
|
-
|
|
956
|
+
kind: "control",
|
|
777
957
|
subject: title,
|
|
778
958
|
body: action === "restart"
|
|
779
959
|
? "Dashboard requested that this agent restart its relay-managed session now."
|
|
780
960
|
: "Dashboard requested that this agent shut down its relay-managed session now.",
|
|
781
|
-
|
|
961
|
+
payload: {
|
|
782
962
|
agentControl: {
|
|
783
963
|
action,
|
|
784
964
|
requestedBy: "dashboard",
|
|
785
965
|
requestedAt: Date.now(),
|
|
786
966
|
},
|
|
967
|
+
},
|
|
968
|
+
meta: {
|
|
787
969
|
delivery: "interrupt",
|
|
788
970
|
priority: "urgent",
|
|
789
971
|
},
|
|
@@ -829,11 +1011,11 @@ const postAgentSpawn: Handler = async (req) => {
|
|
|
829
1011
|
const msg = sendMessage({
|
|
830
1012
|
from: "system",
|
|
831
1013
|
to: orch.agentId,
|
|
832
|
-
|
|
1014
|
+
kind: "control",
|
|
833
1015
|
subject: "Spawn agent",
|
|
834
|
-
body:
|
|
1016
|
+
body: `Spawn ${provider} agent${label ? ` (${label})` : ""}`,
|
|
1017
|
+
payload: { orchestratorControl: { action: "spawn", provider, cwd, label, approvalMode, requestedBy: "dashboard", requestedAt: Date.now() } },
|
|
835
1018
|
meta: {
|
|
836
|
-
orchestratorControl: { action: "spawn", provider, cwd, label, approvalMode, requestedBy: "dashboard", requestedAt: Date.now() },
|
|
837
1019
|
delivery: "interrupt",
|
|
838
1020
|
priority: "urgent",
|
|
839
1021
|
},
|
|
@@ -969,7 +1151,7 @@ const patchOrchestratorAgents: Handler = async (req, params) => {
|
|
|
969
1151
|
const cleaned: ManagedAgent[] = agents.map((a: any) => {
|
|
970
1152
|
if (!isRecord(a)) throw new ValidationError("each agent must be an object");
|
|
971
1153
|
return {
|
|
972
|
-
agentId: cleanString(a.agentId, "agentId", {
|
|
1154
|
+
agentId: cleanString(a.agentId, "agentId", { max: 240 }) || "",
|
|
973
1155
|
provider: cleanEnum(a.provider, "provider", VALID_ORCHESTRATOR_PROVIDERS)! as SpawnProvider,
|
|
974
1156
|
tmuxSession: cleanString(a.tmuxSession, "tmuxSession", { required: true, max: 240 })!,
|
|
975
1157
|
cwd: cleanString(a.cwd, "cwd", { required: true, max: 500 })!,
|
|
@@ -980,6 +1162,7 @@ const patchOrchestratorAgents: Handler = async (req, params) => {
|
|
|
980
1162
|
};
|
|
981
1163
|
});
|
|
982
1164
|
const updated = updateManagedAgents(params.id!, cleaned);
|
|
1165
|
+
if (updated) emitOrchestratorStatus(params.id!);
|
|
983
1166
|
return json(updated);
|
|
984
1167
|
} catch (e) {
|
|
985
1168
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
@@ -1012,10 +1195,10 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
|
|
|
1012
1195
|
const msg = sendMessage({
|
|
1013
1196
|
from: "system",
|
|
1014
1197
|
to: orch.agentId,
|
|
1015
|
-
|
|
1198
|
+
kind: "control",
|
|
1016
1199
|
subject: "Spawn agent",
|
|
1017
|
-
body:
|
|
1018
|
-
|
|
1200
|
+
body: `Spawn ${provider} agent${label ? ` (${label})` : ""}`,
|
|
1201
|
+
payload: {
|
|
1019
1202
|
orchestratorControl: {
|
|
1020
1203
|
action: "spawn",
|
|
1021
1204
|
provider,
|
|
@@ -1026,6 +1209,8 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
|
|
|
1026
1209
|
requestedBy: "dashboard",
|
|
1027
1210
|
requestedAt: Date.now(),
|
|
1028
1211
|
},
|
|
1212
|
+
},
|
|
1213
|
+
meta: {
|
|
1029
1214
|
delivery: "interrupt",
|
|
1030
1215
|
priority: "urgent",
|
|
1031
1216
|
},
|
|
@@ -1063,16 +1248,18 @@ const postOrchestratorAction: Handler = async (req, params) => {
|
|
|
1063
1248
|
const msg = sendMessage({
|
|
1064
1249
|
from: "system",
|
|
1065
1250
|
to: orch.agentId,
|
|
1066
|
-
|
|
1251
|
+
kind: "control",
|
|
1067
1252
|
subject: action === "restart" ? "Restart agent" : "Shutdown agent",
|
|
1068
|
-
body:
|
|
1069
|
-
|
|
1253
|
+
body: agentId ? `${action} ${agentId}` : `${action} all managed agents`,
|
|
1254
|
+
payload: {
|
|
1070
1255
|
orchestratorControl: {
|
|
1071
1256
|
action,
|
|
1072
1257
|
agentId,
|
|
1073
1258
|
requestedBy: "dashboard",
|
|
1074
1259
|
requestedAt: Date.now(),
|
|
1075
1260
|
},
|
|
1261
|
+
},
|
|
1262
|
+
meta: {
|
|
1076
1263
|
delivery: "interrupt",
|
|
1077
1264
|
priority: "urgent",
|
|
1078
1265
|
},
|
|
@@ -1103,7 +1290,7 @@ const deleteOrchestratorById: Handler = (_req, params) => {
|
|
|
1103
1290
|
|
|
1104
1291
|
// --- Message routes ---
|
|
1105
1292
|
|
|
1106
|
-
const
|
|
1293
|
+
const VALID_MSG_KINDS = ["chat", "channel.event", "task", "pair", "control", "system"];
|
|
1107
1294
|
|
|
1108
1295
|
const postMessage: Handler = async (req) => {
|
|
1109
1296
|
const parsed = await parseBody<unknown>(req);
|
|
@@ -1116,13 +1303,14 @@ const postMessage: Handler = async (req) => {
|
|
|
1116
1303
|
const result = sendMessageWithResult(input);
|
|
1117
1304
|
if (result.created) {
|
|
1118
1305
|
emitNewMessage(result.message);
|
|
1306
|
+
const isWork = result.message.kind === "task";
|
|
1119
1307
|
auditEvent({
|
|
1120
1308
|
clientId: "server-message-" + result.message.id,
|
|
1121
|
-
kind:
|
|
1122
|
-
title:
|
|
1309
|
+
kind: isWork ? "task" : "message",
|
|
1310
|
+
title: isWork ? "Task message sent" : "Message sent",
|
|
1123
1311
|
body: result.message.subject || result.message.body,
|
|
1124
1312
|
meta: `${result.message.from} -> ${result.message.to}`,
|
|
1125
|
-
icon:
|
|
1313
|
+
icon: isWork ? "ti-hand-grab" : "ti-send",
|
|
1126
1314
|
view: "messages",
|
|
1127
1315
|
messageId: result.message.id,
|
|
1128
1316
|
agentId: result.message.from,
|
|
@@ -1144,7 +1332,7 @@ const postSystemBroadcast: Handler = async (req) => {
|
|
|
1144
1332
|
const msg = sendMessage({
|
|
1145
1333
|
from: "system",
|
|
1146
1334
|
to: "broadcast",
|
|
1147
|
-
|
|
1335
|
+
kind: "system",
|
|
1148
1336
|
subject: body.subject ?? undefined,
|
|
1149
1337
|
body: body.body,
|
|
1150
1338
|
});
|
|
@@ -1373,6 +1561,67 @@ const postActivityEvent: Handler = async (req) => {
|
|
|
1373
1561
|
}
|
|
1374
1562
|
};
|
|
1375
1563
|
|
|
1564
|
+
// --- Connectors ---
|
|
1565
|
+
|
|
1566
|
+
const getConnectors: Handler = () => json(listConnectors());
|
|
1567
|
+
|
|
1568
|
+
const getConnectorById: Handler = (_req, params) => {
|
|
1569
|
+
const connector = getConnector(params.id!);
|
|
1570
|
+
return connector ? json(connector) : error("connector not found", 404);
|
|
1571
|
+
};
|
|
1572
|
+
|
|
1573
|
+
const postConnectorRegister: Handler = async (req) => {
|
|
1574
|
+
const parsed = await parseBody<unknown>(req);
|
|
1575
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
1576
|
+
try {
|
|
1577
|
+
if (!isRecord(parsed.body)) throw new ValidationError("JSON object body required");
|
|
1578
|
+
const manifest = isRecord(parsed.body.manifest) ? parsed.body.manifest : parsed.body;
|
|
1579
|
+
const config = isRecord(parsed.body.config) ? parsed.body.config : undefined;
|
|
1580
|
+
const state = isRecord(parsed.body.state) ? parsed.body.state : undefined;
|
|
1581
|
+
return json(registerConnectorManifest(manifest as any, { config, state }), 201);
|
|
1582
|
+
} catch (e) {
|
|
1583
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
1584
|
+
throw e;
|
|
1585
|
+
}
|
|
1586
|
+
};
|
|
1587
|
+
|
|
1588
|
+
const postConnectorAction: Handler = async (req, params) => {
|
|
1589
|
+
const parsed = await parseBody<unknown>(req);
|
|
1590
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
1591
|
+
try {
|
|
1592
|
+
if (!isRecord(parsed.body)) throw new ValidationError("JSON object body required");
|
|
1593
|
+
const action = cleanEnum(parsed.body.action, "action", VALID_CONNECTOR_ACTIONS)!;
|
|
1594
|
+
const result = runConnectorAction(params.id!, action);
|
|
1595
|
+
return json(result, result.ok ? 200 : 502);
|
|
1596
|
+
} catch (e) {
|
|
1597
|
+
if (e instanceof ValidationError) return error(e.message, e.message.includes("not found") ? 404 : 400);
|
|
1598
|
+
throw e;
|
|
1599
|
+
}
|
|
1600
|
+
};
|
|
1601
|
+
|
|
1602
|
+
const getConnectorConfig: Handler = (_req, params) => {
|
|
1603
|
+
try {
|
|
1604
|
+
if (!getConnector(params.id!)) return error("connector not found", 404);
|
|
1605
|
+
return json(readConnectorConfig(params.id!));
|
|
1606
|
+
} catch (e) {
|
|
1607
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
1608
|
+
throw e;
|
|
1609
|
+
}
|
|
1610
|
+
};
|
|
1611
|
+
|
|
1612
|
+
const putConnectorConfig: Handler = async (req, params) => {
|
|
1613
|
+
const parsed = await parseBody<unknown>(req);
|
|
1614
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
1615
|
+
try {
|
|
1616
|
+
if (!getConnector(params.id!)) return error("connector not found", 404);
|
|
1617
|
+
if (!isRecord(parsed.body)) throw new ValidationError("connector config must be an object");
|
|
1618
|
+
return json(writeConnectorConfig(params.id!, parsed.body));
|
|
1619
|
+
} catch (e) {
|
|
1620
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
1621
|
+
throw e;
|
|
1622
|
+
}
|
|
1623
|
+
};
|
|
1624
|
+
|
|
1376
1625
|
// --- Tasks and integrations ---
|
|
1377
1626
|
|
|
1378
1627
|
const getIntegrations: Handler = () => {
|
|
@@ -1415,16 +1664,104 @@ const getIntegrations: Handler = () => {
|
|
|
1415
1664
|
};
|
|
1416
1665
|
|
|
1417
1666
|
const getChannels: Handler = () => {
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1667
|
+
return json(listChannels());
|
|
1668
|
+
};
|
|
1669
|
+
|
|
1670
|
+
const getChannelBindings: Handler = (req) => {
|
|
1671
|
+
const url = new URL(req.url);
|
|
1672
|
+
const channelId = cleanString(url.searchParams.get("channelId") ?? undefined, "channelId", { max: 200 });
|
|
1673
|
+
return json(listChannelBindings(channelId));
|
|
1674
|
+
};
|
|
1675
|
+
|
|
1676
|
+
const postChannelBinding: Handler = async (req) => {
|
|
1677
|
+
const parsed = await parseBody<unknown>(req);
|
|
1678
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
1679
|
+
try {
|
|
1680
|
+
const input = normalizeChannelBindingInput(parsed.body);
|
|
1681
|
+
const channel = getChannel(input.channelId);
|
|
1682
|
+
if (!channel) return error("channel not found", 404);
|
|
1683
|
+
const sessionError = requireChannelSession(req, parsed.body, channel);
|
|
1684
|
+
if (sessionError) return sessionError;
|
|
1685
|
+
const binding = upsertChannelBinding(input);
|
|
1686
|
+
return json(binding, 201);
|
|
1687
|
+
} catch (e) {
|
|
1688
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
1689
|
+
throw e;
|
|
1690
|
+
}
|
|
1691
|
+
};
|
|
1692
|
+
|
|
1693
|
+
function scopedChannelIdempotencyKey(key: string | undefined, binding: ChannelBinding): string | undefined {
|
|
1694
|
+
return key ? `${key}:${binding.id}` : undefined;
|
|
1695
|
+
}
|
|
1426
1696
|
|
|
1427
|
-
|
|
1697
|
+
const postChannelEvent: Handler = async (req, params) => {
|
|
1698
|
+
const parsed = await parseBody<unknown>(req);
|
|
1699
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
1700
|
+
try {
|
|
1701
|
+
const channel = getChannel(params.id!);
|
|
1702
|
+
if (!channel) return error("channel not found", 404);
|
|
1703
|
+
const sessionError = requireChannelSession(req, parsed.body, channel);
|
|
1704
|
+
if (sessionError) return sessionError;
|
|
1705
|
+
const input = normalizeChannelEventBody(parsed.body);
|
|
1706
|
+
validateChannelEnvelope(input.payload, channel);
|
|
1707
|
+
validateChannelPayloadContent(input.payload, false);
|
|
1708
|
+
const eventType = channelPayloadEventType(input.payload);
|
|
1709
|
+
if (EPHEMERAL_CHANNEL_EVENT_TYPES.has(eventType)) {
|
|
1710
|
+
return error("ephemeral channel activity must use /api/channels/:id/activities", 400);
|
|
1711
|
+
}
|
|
1712
|
+
const bindings = resolveChannelRoutes(channel.id, input.conversationId);
|
|
1713
|
+
if (!bindings.length) return error("channel has no binding", 409);
|
|
1714
|
+
const results = bindings.map((binding) => sendMessageWithResult({
|
|
1715
|
+
from: channel.agentId,
|
|
1716
|
+
to: messageTargetForChannelTarget(binding.target),
|
|
1717
|
+
kind: "channel.event",
|
|
1718
|
+
channel: channel.id,
|
|
1719
|
+
body: input.body,
|
|
1720
|
+
payload: input.payload,
|
|
1721
|
+
idempotencyKey: scopedChannelIdempotencyKey(input.idempotencyKey, binding),
|
|
1722
|
+
claimable: binding.mode === "claimable",
|
|
1723
|
+
}));
|
|
1724
|
+
for (const result of results) {
|
|
1725
|
+
if (result.created) emitNewMessage(result.message);
|
|
1726
|
+
}
|
|
1727
|
+
return json({
|
|
1728
|
+
messages: results.map((result) => result.message),
|
|
1729
|
+
bindings,
|
|
1730
|
+
created: results.some((result) => result.created),
|
|
1731
|
+
}, results.some((result) => result.created) ? 201 : 200);
|
|
1732
|
+
} catch (e) {
|
|
1733
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
1734
|
+
throw e;
|
|
1735
|
+
}
|
|
1736
|
+
};
|
|
1737
|
+
|
|
1738
|
+
const postChannelActivity: Handler = async (req, params) => {
|
|
1739
|
+
const parsed = await parseBody<unknown>(req);
|
|
1740
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
1741
|
+
try {
|
|
1742
|
+
const channel = getChannel(params.id!);
|
|
1743
|
+
if (!channel) return error("channel not found", 404);
|
|
1744
|
+
const sessionError = requireChannelSession(req, parsed.body, channel);
|
|
1745
|
+
if (sessionError) return sessionError;
|
|
1746
|
+
const input = normalizeChannelEventBody(parsed.body);
|
|
1747
|
+
validateChannelEnvelope(input.payload, channel);
|
|
1748
|
+
validateChannelPayloadContent(input.payload, true);
|
|
1749
|
+
const eventType = channelPayloadEventType(input.payload);
|
|
1750
|
+
if (!EPHEMERAL_CHANNEL_EVENT_TYPES.has(eventType) && !input.payload.activity) {
|
|
1751
|
+
return error("channel activity payload must include an ephemeral event type or activity object", 400);
|
|
1752
|
+
}
|
|
1753
|
+
const activity = {
|
|
1754
|
+
channelId: channel.id,
|
|
1755
|
+
conversationId: input.conversationId,
|
|
1756
|
+
payload: input.payload,
|
|
1757
|
+
createdAt: Date.now(),
|
|
1758
|
+
};
|
|
1759
|
+
emitChannelActivity(activity);
|
|
1760
|
+
return json({ ok: true, activity }, 202);
|
|
1761
|
+
} catch (e) {
|
|
1762
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
1763
|
+
throw e;
|
|
1764
|
+
}
|
|
1428
1765
|
};
|
|
1429
1766
|
|
|
1430
1767
|
const postIntegrationEvent: Handler = async (req) => {
|
|
@@ -1819,7 +2156,18 @@ const routes: Route[] = [
|
|
|
1819
2156
|
route("GET", "/api/activity", getActivityEvents),
|
|
1820
2157
|
route("POST", "/api/activity", postActivityEvent),
|
|
1821
2158
|
|
|
2159
|
+
route("GET", "/api/connectors", getConnectors),
|
|
2160
|
+
route("POST", "/api/connectors", postConnectorRegister),
|
|
2161
|
+
route("GET", "/api/connectors/:id", getConnectorById),
|
|
2162
|
+
route("POST", "/api/connectors/:id/actions", postConnectorAction),
|
|
2163
|
+
route("GET", "/api/connectors/:id/config", getConnectorConfig),
|
|
2164
|
+
route("PUT", "/api/connectors/:id/config", putConnectorConfig),
|
|
2165
|
+
|
|
1822
2166
|
route("GET", "/api/channels", getChannels),
|
|
2167
|
+
route("POST", "/api/channels/:id/events", postChannelEvent),
|
|
2168
|
+
route("POST", "/api/channels/:id/activities", postChannelActivity),
|
|
2169
|
+
route("GET", "/api/channel-bindings", getChannelBindings),
|
|
2170
|
+
route("POST", "/api/channel-bindings", postChannelBinding),
|
|
1823
2171
|
route("GET", "/api/integrations", getIntegrations),
|
|
1824
2172
|
route("POST", "/api/integrations/events", postIntegrationEvent),
|
|
1825
2173
|
route("GET", "/api/tasks", getTasks),
|
package/src/security.ts
CHANGED
|
@@ -95,7 +95,8 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
|
|
|
95
95
|
if (pathname === "/api/stats") return "stats:read";
|
|
96
96
|
if (pathname === "/api/health") return "health:read";
|
|
97
97
|
if (pathname === "/api/events") return "events:read";
|
|
98
|
-
if (pathname === "/api/
|
|
98
|
+
if (pathname === "/api/connectors" || pathname.startsWith("/api/connectors/")) return method === "GET" ? "connectors:read" : "system:write";
|
|
99
|
+
if (pathname.startsWith("/api/channels") || pathname.startsWith("/api/channel-bindings")) return method === "GET" ? "channels:read" : "channels:write";
|
|
99
100
|
if (pathname === "/api/integrations" || pathname.startsWith("/api/integrations/")) return method === "GET" ? "integrations:read" : "integrations:write";
|
|
100
101
|
if (pathname.startsWith("/api/agents")) return method === "GET" ? "agents:read" : "agents:write";
|
|
101
102
|
if (pathname.startsWith("/api/activity")) return method === "GET" ? "activity:read" : "activity:write";
|
package/src/sse.ts
CHANGED
|
@@ -122,6 +122,12 @@ export function emitTaskChanged(task: Task, eventType = "task.updated") {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
export function emitChannelActivity(activity: Record<string, unknown>) {
|
|
126
|
+
for (const conn of connections.values()) {
|
|
127
|
+
send(conn, "channel.activity", activity);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
125
131
|
export function getConnectionCount(): number {
|
|
126
132
|
return connections.size;
|
|
127
133
|
}
|