agent-relay-server 0.4.15 → 0.4.17
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 +82 -3
- package/package.json +2 -2
- package/public/dashboard.js +100 -7
- package/public/index.html +26 -1
- package/src/cli.ts +384 -0
- package/src/config.ts +1 -0
- package/src/db.ts +597 -28
- package/src/index.ts +13 -3
- package/src/routes.ts +330 -13
- package/src/security.ts +31 -1
- package/src/sse.ts +8 -2
- package/src/types.ts +65 -0
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { initDb, reapStaleAgents, pruneOfflineAgents, pruneOldMessages } from "./db";
|
|
2
|
+
import { initDb, reapStaleAgents, pruneOfflineAgents, pruneOldMessages, releaseExpiredClaims } from "./db";
|
|
3
3
|
import { matchRoute } from "./routes";
|
|
4
|
-
import { emitAgentStatus, emitAgentRemoved } from "./sse";
|
|
4
|
+
import { emitAgentStatus, emitAgentRemoved, emitMessageClaimReleased, emitTaskChanged } from "./sse";
|
|
5
5
|
import { resolve, sep } from "path";
|
|
6
6
|
import {
|
|
7
7
|
REAP_INTERVAL_MS,
|
|
@@ -14,8 +14,10 @@ import {
|
|
|
14
14
|
applyCors,
|
|
15
15
|
assertSafeNetworkConfig,
|
|
16
16
|
corsPreflight,
|
|
17
|
+
forbidden,
|
|
17
18
|
getIntegrationAuth,
|
|
18
19
|
isAuthorized,
|
|
20
|
+
isScopedRequestAuthorized,
|
|
19
21
|
isOriginAllowed,
|
|
20
22
|
unauthorized,
|
|
21
23
|
} from "./security";
|
|
@@ -38,6 +40,13 @@ function startServer(): void {
|
|
|
38
40
|
initDb(DB_PATH);
|
|
39
41
|
|
|
40
42
|
setInterval(() => {
|
|
43
|
+
const released = releaseExpiredClaims();
|
|
44
|
+
if (released.messageIds.length > 0 || released.tasks.length > 0) {
|
|
45
|
+
console.log(`released ${released.messageIds.length} expired message claim(s), ${released.tasks.length} expired task claim(s)`);
|
|
46
|
+
for (const id of released.messageIds) emitMessageClaimReleased(id);
|
|
47
|
+
for (const task of released.tasks) emitTaskChanged(task, "task.updated");
|
|
48
|
+
}
|
|
49
|
+
|
|
41
50
|
const reaped = reapStaleAgents(STALE_TTL_MS);
|
|
42
51
|
if (reaped.length > 0) {
|
|
43
52
|
console.log(`reaped ${reaped.length} stale agent(s)`);
|
|
@@ -87,9 +96,10 @@ export function createFetchHandler(
|
|
|
87
96
|
if (matched) {
|
|
88
97
|
const integrationAuth = getIntegrationAuth(req);
|
|
89
98
|
if (!isAuthorized(req)) {
|
|
90
|
-
if (!integrationAuth
|
|
99
|
+
if (!integrationAuth) {
|
|
91
100
|
return unauthorized(req);
|
|
92
101
|
}
|
|
102
|
+
if (!isScopedRequestAuthorized(req)) return forbidden(req);
|
|
93
103
|
}
|
|
94
104
|
const response = await matched.handler(req, matched.params);
|
|
95
105
|
applyCors(req, response);
|
package/src/routes.ts
CHANGED
|
@@ -5,10 +5,12 @@ import {
|
|
|
5
5
|
findAgentsByCapability,
|
|
6
6
|
setStatus,
|
|
7
7
|
setLabel,
|
|
8
|
+
setTags,
|
|
8
9
|
markReady,
|
|
9
10
|
heartbeat,
|
|
10
11
|
deleteAgent,
|
|
11
12
|
sendMessage,
|
|
13
|
+
sendMessageWithResult,
|
|
12
14
|
getMessage,
|
|
13
15
|
getThread,
|
|
14
16
|
claimMessage,
|
|
@@ -17,18 +19,29 @@ import {
|
|
|
17
19
|
markRead,
|
|
18
20
|
deleteMessage,
|
|
19
21
|
getStats,
|
|
22
|
+
getHealth,
|
|
20
23
|
getLatestMessageId,
|
|
21
24
|
ingestIntegrationEvent,
|
|
22
25
|
listTasks,
|
|
23
26
|
getTask,
|
|
24
27
|
listTaskEvents,
|
|
25
28
|
claimTask,
|
|
29
|
+
renewTaskClaim,
|
|
26
30
|
updateTaskStatus,
|
|
31
|
+
renewMessageClaim,
|
|
32
|
+
createPair,
|
|
33
|
+
getPair,
|
|
34
|
+
listPairs,
|
|
35
|
+
acceptPair,
|
|
36
|
+
rejectPair,
|
|
37
|
+
endPair,
|
|
38
|
+
sendPairMessage,
|
|
27
39
|
createCallbackDelivery,
|
|
28
40
|
finishCallbackDelivery,
|
|
41
|
+
validateAgentSession,
|
|
29
42
|
ValidationError,
|
|
30
43
|
} from "./db";
|
|
31
|
-
import type { IntegrationEventInput, RegisterAgentInput, SendMessageInput, TaskStatus, TaskStatusInput } from "./types";
|
|
44
|
+
import type { AgentSessionGuard, CreatePairInput, IntegrationEventInput, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, SendMessageInput, TaskStatus, TaskStatusInput } from "./types";
|
|
32
45
|
import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES } from "./config";
|
|
33
46
|
import {
|
|
34
47
|
getIntegrationAuth,
|
|
@@ -129,6 +142,7 @@ function parseQueryInt(
|
|
|
129
142
|
const VALID_AGENT_STATUSES = ["online", "idle", "busy", "offline"] as const;
|
|
130
143
|
const VALID_TASK_SEVERITIES = ["info", "warning", "critical"] as const;
|
|
131
144
|
const VALID_TASK_STATUSES = ["open", "claimed", "in_progress", "blocked", "done", "failed", "canceled"] as const;
|
|
145
|
+
const VALID_PAIR_STATUSES = ["pending", "active", "ended", "rejected", "expired"] as const;
|
|
132
146
|
const integrationRateBuckets = new Map<string, { windowStart: number; count: number }>();
|
|
133
147
|
|
|
134
148
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -195,6 +209,33 @@ function cleanPositiveId(value: unknown, field: string): number | undefined {
|
|
|
195
209
|
return value;
|
|
196
210
|
}
|
|
197
211
|
|
|
212
|
+
function cleanEpoch(value: unknown, field: string): number | undefined {
|
|
213
|
+
if (value === undefined || value === null) return undefined;
|
|
214
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
215
|
+
throw new ValidationError(`${field} must be a non-negative integer`);
|
|
216
|
+
}
|
|
217
|
+
return value;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function normalizeAgentSessionGuard(req: Request, body: unknown): AgentSessionGuard | undefined {
|
|
221
|
+
const record = isRecord(body) ? body : {};
|
|
222
|
+
const headerInstance = req.headers.get("x-agent-relay-instance-id") ?? undefined;
|
|
223
|
+
const headerEpoch = req.headers.get("x-agent-relay-epoch") ?? undefined;
|
|
224
|
+
const instanceId = cleanString(headerInstance ?? record.instanceId, "instanceId", { max: 200 });
|
|
225
|
+
const rawEpoch = headerEpoch === undefined ? record.epoch : Number(headerEpoch);
|
|
226
|
+
const epoch = cleanEpoch(rawEpoch, "epoch");
|
|
227
|
+
|
|
228
|
+
if (!instanceId && epoch === undefined) return undefined;
|
|
229
|
+
if (!instanceId) throw new ValidationError("instanceId required when epoch is provided");
|
|
230
|
+
return { instanceId, epoch };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function agentSessionStatus(errorMessage: string | undefined): number {
|
|
234
|
+
if (errorMessage === "agent not found") return 404;
|
|
235
|
+
if (errorMessage === "stale agent instance") return 409;
|
|
236
|
+
return 400;
|
|
237
|
+
}
|
|
238
|
+
|
|
198
239
|
function normalizeAgentInput(body: unknown): RegisterAgentInput {
|
|
199
240
|
if (!isRecord(body)) throw new ValidationError("JSON object body required");
|
|
200
241
|
const status = cleanString(body.status, "status", { max: 20 });
|
|
@@ -222,6 +263,8 @@ function normalizeAgentInput(body: unknown): RegisterAgentInput {
|
|
|
222
263
|
if (machine) input.machine = machine;
|
|
223
264
|
const rig = cleanString(body.rig, "rig", { max: 120 });
|
|
224
265
|
if (rig) input.rig = rig;
|
|
266
|
+
const instanceId = cleanString(body.instanceId, "instanceId", { max: 200 });
|
|
267
|
+
if (instanceId) input.instanceId = instanceId;
|
|
225
268
|
const meta = cleanMeta(body.meta);
|
|
226
269
|
if (meta) input.meta = meta;
|
|
227
270
|
|
|
@@ -245,6 +288,7 @@ function normalizeMessageInput(body: unknown): SendMessageInput {
|
|
|
245
288
|
type: type as SendMessageInput["type"] | undefined,
|
|
246
289
|
replyTo: cleanPositiveId(body.replyTo, "replyTo"),
|
|
247
290
|
claimable: body.claimable as boolean | undefined,
|
|
291
|
+
idempotencyKey: cleanString(body.idempotencyKey, "idempotencyKey", { max: 240 }),
|
|
248
292
|
};
|
|
249
293
|
|
|
250
294
|
const channel = cleanString(body.channel, "channel", { max: 120 });
|
|
@@ -282,12 +326,66 @@ function normalizeTaskStatusInput(body: unknown): TaskStatusInput {
|
|
|
282
326
|
return {
|
|
283
327
|
status: status as TaskStatus,
|
|
284
328
|
agentId: cleanString(body.agentId, "agentId", { max: 200 }),
|
|
329
|
+
instanceId: cleanString(body.instanceId, "instanceId", { max: 200 }),
|
|
330
|
+
epoch: cleanEpoch(body.epoch, "epoch"),
|
|
285
331
|
result: cleanString(body.result, "result", { max: MAX_BODY_BYTES }),
|
|
286
332
|
body: cleanString(body.body, "body", { max: MAX_BODY_BYTES }),
|
|
287
333
|
metadata: cleanMeta(body.metadata),
|
|
288
334
|
};
|
|
289
335
|
}
|
|
290
336
|
|
|
337
|
+
function cleanTtlMs(value: unknown): number | undefined {
|
|
338
|
+
if (value === undefined || value === null) return undefined;
|
|
339
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
|
|
340
|
+
throw new ValidationError("ttlMs must be a positive integer");
|
|
341
|
+
}
|
|
342
|
+
return value;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function normalizeCreatePairInput(body: unknown): CreatePairInput {
|
|
346
|
+
if (!isRecord(body)) throw new ValidationError("JSON object body required");
|
|
347
|
+
return {
|
|
348
|
+
from: cleanString(body.from, "from", { required: true, max: 200 })!,
|
|
349
|
+
target: cleanString(body.target, "target", { required: true, max: 200 })!,
|
|
350
|
+
objective: cleanString(body.objective, "objective", { max: 2000 }),
|
|
351
|
+
ttlMs: cleanTtlMs(body.ttlMs),
|
|
352
|
+
meta: cleanMeta(body.meta),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function normalizePairActionInput(body: unknown): PairActionInput {
|
|
357
|
+
if (!isRecord(body)) throw new ValidationError("JSON object body required");
|
|
358
|
+
return {
|
|
359
|
+
agentId: cleanString(body.agentId, "agentId", { required: true, max: 200 })!,
|
|
360
|
+
reason: cleanString(body.reason, "reason", { max: 1000 }),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function normalizePairMessageInput(body: unknown): PairMessageInput {
|
|
365
|
+
if (!isRecord(body)) throw new ValidationError("JSON object body required");
|
|
366
|
+
return {
|
|
367
|
+
from: cleanString(body.from, "from", { required: true, max: 200 })!,
|
|
368
|
+
body: cleanString(body.body, "body", { required: true, max: MAX_BODY_BYTES })!,
|
|
369
|
+
subject: cleanString(body.subject, "subject", { max: 200 }),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function pairErrorStatus(code: string): number {
|
|
374
|
+
if (code === "not_found") return 404;
|
|
375
|
+
if (code === "busy") return 409;
|
|
376
|
+
if (code === "ambiguous") return 409;
|
|
377
|
+
if (code === "forbidden") return 403;
|
|
378
|
+
return 400;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function pairError(result: { error: string; code: string; matches?: unknown[]; busy?: unknown[] }): Response {
|
|
382
|
+
return json({
|
|
383
|
+
error: result.error,
|
|
384
|
+
...(result.matches ? { matches: result.matches } : {}),
|
|
385
|
+
...(result.busy ? { busy: result.busy } : {}),
|
|
386
|
+
}, pairErrorStatus(result.code));
|
|
387
|
+
}
|
|
388
|
+
|
|
291
389
|
function checkIntegrationRateLimit(name: string): boolean {
|
|
292
390
|
const now = Date.now();
|
|
293
391
|
const windowMs = 60_000;
|
|
@@ -377,13 +475,31 @@ const patchAgentStatus: Handler = async (req, params) => {
|
|
|
377
475
|
if (!body?.status) return error("status required");
|
|
378
476
|
const valid = ["online", "idle", "busy", "offline"];
|
|
379
477
|
if (!valid.includes(body.status)) return error(`status must be one of: ${valid.join(", ")}`);
|
|
380
|
-
|
|
478
|
+
try {
|
|
479
|
+
const guard = normalizeAgentSessionGuard(req, body);
|
|
480
|
+
const session = validateAgentSession(params.id!, guard);
|
|
481
|
+
if (!session.ok) return error(session.error!, agentSessionStatus(session.error));
|
|
482
|
+
if (!setStatus(params.id!, body.status as any, guard)) return error("agent not found", 404);
|
|
483
|
+
} catch (e) {
|
|
484
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
485
|
+
throw e;
|
|
486
|
+
}
|
|
381
487
|
emitAgentStatus(params.id!);
|
|
382
488
|
return json({ ok: true });
|
|
383
489
|
};
|
|
384
490
|
|
|
385
|
-
const postHeartbeat: Handler = (
|
|
386
|
-
|
|
491
|
+
const postHeartbeat: Handler = async (req, params) => {
|
|
492
|
+
const parsed = await parseBody<unknown>(req);
|
|
493
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
494
|
+
try {
|
|
495
|
+
const guard = normalizeAgentSessionGuard(req, parsed.body);
|
|
496
|
+
const session = validateAgentSession(params.id!, guard);
|
|
497
|
+
if (!session.ok) return error(session.error!, agentSessionStatus(session.error));
|
|
498
|
+
return heartbeat(params.id!, guard) ? json({ ok: true }) : error("agent not found", 404);
|
|
499
|
+
} catch (e) {
|
|
500
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
501
|
+
throw e;
|
|
502
|
+
}
|
|
387
503
|
};
|
|
388
504
|
|
|
389
505
|
const patchAgentLabel: Handler = async (req, params) => {
|
|
@@ -396,12 +512,36 @@ const patchAgentLabel: Handler = async (req, params) => {
|
|
|
396
512
|
return json({ ok: true });
|
|
397
513
|
};
|
|
398
514
|
|
|
515
|
+
const patchAgentTags: Handler = async (req, params) => {
|
|
516
|
+
const parsed = await parseBody<unknown>(req);
|
|
517
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
518
|
+
try {
|
|
519
|
+
const tags = isRecord(parsed.body) ? cleanStringArray(parsed.body.tags, "tags") : undefined;
|
|
520
|
+
if (!tags) return error("tags field required");
|
|
521
|
+
const agent = setTags(params.id!, tags);
|
|
522
|
+
if (!agent) return error("agent not found", 404);
|
|
523
|
+
emitAgentStatus(params.id!);
|
|
524
|
+
return json(agent);
|
|
525
|
+
} catch (e) {
|
|
526
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
527
|
+
throw e;
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
|
|
399
531
|
const patchAgentReady: Handler = async (req, params) => {
|
|
400
532
|
const parsed = await parseBody<{ ready: boolean }>(req);
|
|
401
533
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
402
534
|
const body = parsed.body;
|
|
403
535
|
if (body === null || typeof body.ready !== "boolean") return error("ready field required (boolean)");
|
|
404
|
-
|
|
536
|
+
try {
|
|
537
|
+
const guard = normalizeAgentSessionGuard(req, body);
|
|
538
|
+
const session = validateAgentSession(params.id!, guard);
|
|
539
|
+
if (!session.ok) return error(session.error!, agentSessionStatus(session.error));
|
|
540
|
+
if (!markReady(params.id!, body.ready, guard)) return error("agent not found", 404);
|
|
541
|
+
} catch (e) {
|
|
542
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
543
|
+
throw e;
|
|
544
|
+
}
|
|
405
545
|
emitAgentStatus(params.id!);
|
|
406
546
|
return json({ ok: true });
|
|
407
547
|
};
|
|
@@ -424,9 +564,13 @@ const postMessage: Handler = async (req) => {
|
|
|
424
564
|
const parsed = await parseBody<unknown>(req);
|
|
425
565
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
426
566
|
try {
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
567
|
+
const input = normalizeMessageInput(parsed.body);
|
|
568
|
+
if (!input.idempotencyKey) {
|
|
569
|
+
input.idempotencyKey = cleanString(req.headers.get("Idempotency-Key") ?? undefined, "idempotencyKey", { max: 240 });
|
|
570
|
+
}
|
|
571
|
+
const result = sendMessageWithResult(input);
|
|
572
|
+
if (result.created) emitNewMessage(result.message);
|
|
573
|
+
return json(result.message, result.created ? 201 : 200);
|
|
430
574
|
} catch (e) {
|
|
431
575
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
432
576
|
throw e;
|
|
@@ -514,9 +658,16 @@ const postClaimMessage: Handler = async (req, params) => {
|
|
|
514
658
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
515
659
|
const body = parsed.body;
|
|
516
660
|
if (!body?.agentId) return error("agentId required");
|
|
517
|
-
|
|
661
|
+
let guard: AgentSessionGuard | undefined;
|
|
662
|
+
try {
|
|
663
|
+
guard = normalizeAgentSessionGuard(req, body);
|
|
664
|
+
} catch (e) {
|
|
665
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
666
|
+
throw e;
|
|
667
|
+
}
|
|
668
|
+
const result = claimMessage(id, body.agentId, guard);
|
|
518
669
|
if (result.ok) {
|
|
519
|
-
emitMessageClaimed(id, body.agentId);
|
|
670
|
+
emitMessageClaimed(id, body.agentId, getMessage(id)?.claimExpiresAt);
|
|
520
671
|
if (result.task) {
|
|
521
672
|
emitTaskChanged(result.task, "task.claimed");
|
|
522
673
|
void dispatchTaskCallbacks(result.task.id, "task.claimed");
|
|
@@ -531,6 +682,34 @@ const postClaimMessage: Handler = async (req, params) => {
|
|
|
531
682
|
return error(result.error!, status);
|
|
532
683
|
};
|
|
533
684
|
|
|
685
|
+
const postRenewMessageClaim: Handler = async (req, params) => {
|
|
686
|
+
const id = parseId(params.id);
|
|
687
|
+
if (id === null) return error("invalid message id");
|
|
688
|
+
const parsed = await parseBody<{ agentId: string }>(req);
|
|
689
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
690
|
+
const agentId = parsed.body?.agentId;
|
|
691
|
+
if (!agentId) return error("agentId required");
|
|
692
|
+
let guard: AgentSessionGuard | undefined;
|
|
693
|
+
try {
|
|
694
|
+
guard = normalizeAgentSessionGuard(req, parsed.body);
|
|
695
|
+
} catch (e) {
|
|
696
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
697
|
+
throw e;
|
|
698
|
+
}
|
|
699
|
+
const result = renewMessageClaim(id, agentId, guard);
|
|
700
|
+
if (!result.ok) {
|
|
701
|
+
const status =
|
|
702
|
+
result.error === "message not found" ? 404 :
|
|
703
|
+
result.error === "claiming agent not found" ? 400 :
|
|
704
|
+
result.error === "message is not claimable" ? 400 :
|
|
705
|
+
409;
|
|
706
|
+
return error(result.error!, status);
|
|
707
|
+
}
|
|
708
|
+
emitMessageClaimed(id, agentId, getMessage(id)?.claimExpiresAt);
|
|
709
|
+
if (result.task) emitTaskChanged(result.task, "task.updated");
|
|
710
|
+
return json({ ok: true, claimExpiresAt: getMessage(id)?.claimExpiresAt });
|
|
711
|
+
};
|
|
712
|
+
|
|
534
713
|
const patchMessage: Handler = async (req, params) => {
|
|
535
714
|
const id = parseId(params.id);
|
|
536
715
|
if (id === null) return error("invalid message id");
|
|
@@ -612,16 +791,52 @@ const postClaimTask: Handler = async (req, params) => {
|
|
|
612
791
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
613
792
|
const agentId = parsed.body?.agentId;
|
|
614
793
|
if (!agentId) return error("agentId required");
|
|
615
|
-
|
|
794
|
+
let guard: AgentSessionGuard | undefined;
|
|
795
|
+
try {
|
|
796
|
+
guard = normalizeAgentSessionGuard(req, parsed.body);
|
|
797
|
+
} catch (e) {
|
|
798
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
799
|
+
throw e;
|
|
800
|
+
}
|
|
801
|
+
const result = claimTask(id, agentId, guard);
|
|
616
802
|
if (!result.ok) {
|
|
617
|
-
const status = result.error === "task not found" ? 404 : result.error?.includes("race") ? 409 : 400;
|
|
803
|
+
const status = result.error === "task not found" ? 404 : result.error?.includes("race") || result.error === "stale agent instance" ? 409 : 400;
|
|
618
804
|
return error(result.error!, status);
|
|
619
805
|
}
|
|
620
806
|
emitTaskChanged(result.task!, "task.claimed");
|
|
807
|
+
if (result.task!.messageId) {
|
|
808
|
+
emitMessageClaimed(result.task!.messageId, agentId, getMessage(result.task!.messageId)?.claimExpiresAt);
|
|
809
|
+
}
|
|
621
810
|
void dispatchTaskCallbacks(id, "task.claimed");
|
|
622
811
|
return json(result.task);
|
|
623
812
|
};
|
|
624
813
|
|
|
814
|
+
const postRenewTaskClaim: Handler = async (req, params) => {
|
|
815
|
+
const id = parseId(params.id);
|
|
816
|
+
if (id === null) return error("invalid task id");
|
|
817
|
+
const parsed = await parseBody<{ agentId: string }>(req);
|
|
818
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
819
|
+
const agentId = parsed.body?.agentId;
|
|
820
|
+
if (!agentId) return error("agentId required");
|
|
821
|
+
let guard: AgentSessionGuard | undefined;
|
|
822
|
+
try {
|
|
823
|
+
guard = normalizeAgentSessionGuard(req, parsed.body);
|
|
824
|
+
} catch (e) {
|
|
825
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
826
|
+
throw e;
|
|
827
|
+
}
|
|
828
|
+
const result = renewTaskClaim(id, agentId, guard);
|
|
829
|
+
if (!result.ok) {
|
|
830
|
+
const status = result.error === "task not found" ? 404 : 409;
|
|
831
|
+
return error(result.error!, status);
|
|
832
|
+
}
|
|
833
|
+
emitTaskChanged(result.task!, "task.updated");
|
|
834
|
+
if (result.task!.messageId) {
|
|
835
|
+
emitMessageClaimed(result.task!.messageId, agentId, getMessage(result.task!.messageId)?.claimExpiresAt);
|
|
836
|
+
}
|
|
837
|
+
return json(result.task);
|
|
838
|
+
};
|
|
839
|
+
|
|
625
840
|
const patchTaskStatus: Handler = async (req, params) => {
|
|
626
841
|
const id = parseId(params.id);
|
|
627
842
|
if (id === null) return error("invalid task id");
|
|
@@ -629,7 +844,7 @@ const patchTaskStatus: Handler = async (req, params) => {
|
|
|
629
844
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
630
845
|
try {
|
|
631
846
|
const result = updateTaskStatus(id, normalizeTaskStatusInput(parsed.body));
|
|
632
|
-
if (!result.ok) return error(result.error!, result.error === "task not found" ? 404 :
|
|
847
|
+
if (!result.ok) return error(result.error!, result.error === "task not found" ? 404 : agentSessionStatus(result.error));
|
|
633
848
|
emitTaskChanged(result.task!, "task.status");
|
|
634
849
|
void dispatchTaskCallbacks(id, "task.status");
|
|
635
850
|
return json({ task: result.task, event: result.event });
|
|
@@ -639,6 +854,95 @@ const patchTaskStatus: Handler = async (req, params) => {
|
|
|
639
854
|
}
|
|
640
855
|
};
|
|
641
856
|
|
|
857
|
+
// --- Pair sessions ---
|
|
858
|
+
|
|
859
|
+
const postPair: Handler = async (req) => {
|
|
860
|
+
const parsed = await parseBody<unknown>(req);
|
|
861
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
862
|
+
try {
|
|
863
|
+
const result = createPair(normalizeCreatePairInput(parsed.body));
|
|
864
|
+
if (!result.ok) return pairError(result);
|
|
865
|
+
emitNewMessage(result.invite);
|
|
866
|
+
return json({ pair: result.pair, invite: result.invite }, 201);
|
|
867
|
+
} catch (e) {
|
|
868
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
869
|
+
throw e;
|
|
870
|
+
}
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
const getPairs: Handler = (req) => {
|
|
874
|
+
const url = new URL(req.url);
|
|
875
|
+
const status = url.searchParams.get("status") ?? undefined;
|
|
876
|
+
if (status && !VALID_PAIR_STATUSES.includes(status as any)) {
|
|
877
|
+
return error(`status must be one of: ${VALID_PAIR_STATUSES.join(", ")}`);
|
|
878
|
+
}
|
|
879
|
+
return json(listPairs({
|
|
880
|
+
agentId: url.searchParams.get("agent") ?? undefined,
|
|
881
|
+
status: status as PairStatus | undefined,
|
|
882
|
+
}));
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
const getPairById: Handler = (_req, params) => {
|
|
886
|
+
const pair = getPair(params.id!);
|
|
887
|
+
return pair ? json(pair) : error("pair not found", 404);
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
const postAcceptPair: Handler = async (req, params) => {
|
|
891
|
+
const parsed = await parseBody<unknown>(req);
|
|
892
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
893
|
+
try {
|
|
894
|
+
const result = acceptPair(params.id!, normalizePairActionInput(parsed.body));
|
|
895
|
+
if (!result.ok) return pairError(result);
|
|
896
|
+
for (const notice of result.notices) emitNewMessage(notice);
|
|
897
|
+
return json(result.pair);
|
|
898
|
+
} catch (e) {
|
|
899
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
900
|
+
throw e;
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
const postRejectPair: Handler = async (req, params) => {
|
|
905
|
+
const parsed = await parseBody<unknown>(req);
|
|
906
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
907
|
+
try {
|
|
908
|
+
const result = rejectPair(params.id!, normalizePairActionInput(parsed.body));
|
|
909
|
+
if (!result.ok) return pairError(result);
|
|
910
|
+
emitNewMessage(result.notice);
|
|
911
|
+
return json(result.pair);
|
|
912
|
+
} catch (e) {
|
|
913
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
914
|
+
throw e;
|
|
915
|
+
}
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
const postHangupPair: Handler = async (req, params) => {
|
|
919
|
+
const parsed = await parseBody<unknown>(req);
|
|
920
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
921
|
+
try {
|
|
922
|
+
const result = endPair(params.id!, normalizePairActionInput(parsed.body));
|
|
923
|
+
if (!result.ok) return pairError(result);
|
|
924
|
+
if (result.notice) emitNewMessage(result.notice);
|
|
925
|
+
return json(result.pair);
|
|
926
|
+
} catch (e) {
|
|
927
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
928
|
+
throw e;
|
|
929
|
+
}
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
const postPairMessage: Handler = async (req, params) => {
|
|
933
|
+
const parsed = await parseBody<unknown>(req);
|
|
934
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
935
|
+
try {
|
|
936
|
+
const result = sendPairMessage(params.id!, normalizePairMessageInput(parsed.body));
|
|
937
|
+
if (!result.ok) return pairError(result);
|
|
938
|
+
emitNewMessage(result.message);
|
|
939
|
+
return json({ pair: result.pair, message: result.message }, 201);
|
|
940
|
+
} catch (e) {
|
|
941
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
942
|
+
throw e;
|
|
943
|
+
}
|
|
944
|
+
};
|
|
945
|
+
|
|
642
946
|
// --- SSE ---
|
|
643
947
|
|
|
644
948
|
const getEvents: Handler = (req) => {
|
|
@@ -650,6 +954,7 @@ const getEvents: Handler = (req) => {
|
|
|
650
954
|
// --- Stats ---
|
|
651
955
|
|
|
652
956
|
const getStatsRoute: Handler = () => json(getStats());
|
|
957
|
+
const getHealthRoute: Handler = () => json(getHealth());
|
|
653
958
|
|
|
654
959
|
// --- Router ---
|
|
655
960
|
|
|
@@ -681,6 +986,7 @@ const routes: Route[] = [
|
|
|
681
986
|
route("PATCH", "/api/agents/:id/status", patchAgentStatus),
|
|
682
987
|
route("PATCH", "/api/agents/:id/ready", patchAgentReady),
|
|
683
988
|
route("PATCH", "/api/agents/:id/label", patchAgentLabel),
|
|
989
|
+
route("PATCH", "/api/agents/:id/tags", patchAgentTags),
|
|
684
990
|
route("POST", "/api/agents/:id/heartbeat", postHeartbeat),
|
|
685
991
|
route("DELETE", "/api/agents/:id", deleteAgentById),
|
|
686
992
|
|
|
@@ -691,6 +997,7 @@ const routes: Route[] = [
|
|
|
691
997
|
route("GET", "/api/messages/:id", getMessageById),
|
|
692
998
|
route("GET", "/api/messages/:id/thread", getMessageThread),
|
|
693
999
|
route("POST", "/api/messages/:id/claim", postClaimMessage),
|
|
1000
|
+
route("POST", "/api/messages/:id/claim/renew", postRenewMessageClaim),
|
|
694
1001
|
route("PATCH", "/api/messages/:id", patchMessage),
|
|
695
1002
|
route("DELETE", "/api/messages/:id", deleteMessageById),
|
|
696
1003
|
|
|
@@ -699,10 +1006,20 @@ const routes: Route[] = [
|
|
|
699
1006
|
route("GET", "/api/tasks/:id", getTaskById),
|
|
700
1007
|
route("GET", "/api/tasks/:id/events", getTaskEvents),
|
|
701
1008
|
route("POST", "/api/tasks/:id/claim", postClaimTask),
|
|
1009
|
+
route("POST", "/api/tasks/:id/claim/renew", postRenewTaskClaim),
|
|
702
1010
|
route("PATCH", "/api/tasks/:id/status", patchTaskStatus),
|
|
703
1011
|
|
|
1012
|
+
route("POST", "/api/pairs", postPair),
|
|
1013
|
+
route("GET", "/api/pairs", getPairs),
|
|
1014
|
+
route("GET", "/api/pairs/:id", getPairById),
|
|
1015
|
+
route("POST", "/api/pairs/:id/accept", postAcceptPair),
|
|
1016
|
+
route("POST", "/api/pairs/:id/reject", postRejectPair),
|
|
1017
|
+
route("POST", "/api/pairs/:id/hangup", postHangupPair),
|
|
1018
|
+
route("POST", "/api/pairs/:id/messages", postPairMessage),
|
|
1019
|
+
|
|
704
1020
|
route("GET", "/api/events", getEvents),
|
|
705
1021
|
route("GET", "/api/stats", getStatsRoute),
|
|
1022
|
+
route("GET", "/api/health", getHealthRoute),
|
|
706
1023
|
];
|
|
707
1024
|
|
|
708
1025
|
export function matchRoute(
|
package/src/security.ts
CHANGED
|
@@ -45,7 +45,7 @@ export function corsPreflight(req: Request): Response {
|
|
|
45
45
|
const response = new Response(null, {
|
|
46
46
|
headers: {
|
|
47
47
|
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
|
|
48
|
-
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Agent-Relay-Token",
|
|
48
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Agent-Relay-Token, Idempotency-Key, X-Agent-Relay-Instance-Id, X-Agent-Relay-Epoch",
|
|
49
49
|
"Access-Control-Max-Age": "600",
|
|
50
50
|
},
|
|
51
51
|
});
|
|
@@ -80,6 +80,36 @@ export function isIntegrationAllowed(
|
|
|
80
80
|
return true;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
export function requiredScopeFor(method: string, pathname: string): string | null {
|
|
84
|
+
if (pathname === "/api/stats") return "stats:read";
|
|
85
|
+
if (pathname === "/api/health") return "health:read";
|
|
86
|
+
if (pathname === "/api/events") return "events:read";
|
|
87
|
+
if (pathname.startsWith("/api/integrations/")) return method === "GET" ? "integrations:read" : "integrations:write";
|
|
88
|
+
if (pathname.startsWith("/api/agents")) return method === "GET" ? "agents:read" : "agents:write";
|
|
89
|
+
if (pathname.startsWith("/api/messages")) return method === "GET" ? "messages:read" : "messages:write";
|
|
90
|
+
if (pathname.startsWith("/api/tasks")) return method === "GET" ? "tasks:read" : "tasks:write";
|
|
91
|
+
if (pathname.startsWith("/api/pairs")) return method === "GET" ? "pairs:read" : "pairs:write";
|
|
92
|
+
if (pathname.startsWith("/api/system/")) return "system:write";
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function isScopedRequestAuthorized(req: Request): boolean {
|
|
97
|
+
const auth = getIntegrationAuth(req);
|
|
98
|
+
if (!auth) return false;
|
|
99
|
+
const pathname = new URL(req.url).pathname;
|
|
100
|
+
if (pathname.startsWith("/api/integrations/") && req.method !== "GET") {
|
|
101
|
+
return hasIntegrationScope(auth, "integrations:write") ||
|
|
102
|
+
hasIntegrationScope(auth, "tasks:create") ||
|
|
103
|
+
hasIntegrationScope(auth, "events:create");
|
|
104
|
+
}
|
|
105
|
+
const scope = requiredScopeFor(req.method, pathname);
|
|
106
|
+
return scope ? hasIntegrationScope(auth, scope) : false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function forbidden(req: Request): Response {
|
|
110
|
+
return applyCors(req, Response.json({ error: "forbidden" }, { status: 403 }));
|
|
111
|
+
}
|
|
112
|
+
|
|
83
113
|
export function unauthorized(req: Request): Response {
|
|
84
114
|
const response = Response.json({ error: "unauthorized" }, { status: 401 });
|
|
85
115
|
response.headers.set("WWW-Authenticate", "Bearer");
|
package/src/sse.ts
CHANGED
|
@@ -97,9 +97,15 @@ export function emitAgentRemoved(agentId: string) {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
export function emitMessageClaimed(messageId: number, claimedBy: string) {
|
|
100
|
+
export function emitMessageClaimed(messageId: number, claimedBy: string, claimExpiresAt?: number) {
|
|
101
101
|
for (const conn of connections.values()) {
|
|
102
|
-
send(conn, "message.claimed", { messageId, claimedBy });
|
|
102
|
+
send(conn, "message.claimed", { messageId, claimedBy, claimExpiresAt });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function emitMessageClaimReleased(messageId: number) {
|
|
107
|
+
for (const conn of connections.values()) {
|
|
108
|
+
send(conn, "message.claim_released", { messageId });
|
|
103
109
|
}
|
|
104
110
|
}
|
|
105
111
|
|