@hivemagnus/ardent 1.1.5 → 1.1.8
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 +15 -0
- package/dist/a2a-idempotency.d.ts +3 -1
- package/dist/a2a-idempotency.js +58 -13
- package/dist/a2a-push-inbox.d.ts +9 -0
- package/dist/a2a-push-inbox.js +6 -1
- package/dist/a2a-rate-limit.d.ts +52 -0
- package/dist/a2a-rate-limit.js +136 -0
- package/dist/a2a-server.d.ts +9 -0
- package/dist/a2a-server.js +51 -24
- package/dist/config.d.ts +9 -0
- package/dist/config.js +4 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -149,8 +149,23 @@ a2a:
|
|
|
149
149
|
peers_file: peers.yaml
|
|
150
150
|
skill: { id: research, name: Research, description: Sourced answers, tags: [research] }
|
|
151
151
|
hermes_compat: false
|
|
152
|
+
rate_limit: # all optional; these are the defaults
|
|
153
|
+
enabled: true
|
|
154
|
+
ip_requests_per_minute: 600 # every HTTP request, before authentication
|
|
155
|
+
ip_burst: 120
|
|
156
|
+
peer_requests_per_minute: 300 # after authentication, per peer (also gRPC)
|
|
157
|
+
peer_burst: 60
|
|
158
|
+
max_concurrent_tasks_per_peer: 16 # queued and running tasks
|
|
159
|
+
trusted_proxies: [] # proxies whose X-Forwarded-For is believed
|
|
152
160
|
```
|
|
153
161
|
|
|
162
|
+
A limited request is answered with `429` and `Retry-After`. The client address
|
|
163
|
+
is the socket address unless the connecting address is listed in
|
|
164
|
+
`trusted_proxies` (addresses or CIDR ranges). Behind a reverse proxy, all clients
|
|
165
|
+
otherwise share the proxy's address and one bucket, so list the proxy there.
|
|
166
|
+
gRPC requests count against the peer limit and the task cap; the address limit
|
|
167
|
+
covers HTTP only. Limits are kept in memory, per process.
|
|
168
|
+
|
|
154
169
|
Peers are listed in `peers.yaml` with the environment variables holding their
|
|
155
170
|
inbound and outbound bearer tokens. An ordinary peer task can reach only the
|
|
156
171
|
public news tools. A peer that sends a validated team mission envelope gets
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { Message, type SendMessageRequest, type StreamResponse, type Task } from '@a2a-js/sdk';
|
|
2
2
|
import { DefaultRequestHandler, type ServerCallContext } from '@a2a-js/sdk/server';
|
|
3
3
|
import type Database from 'better-sqlite3';
|
|
4
|
+
import type { PeerTaskGate } from './a2a-rate-limit.js';
|
|
4
5
|
import type { SqliteA2ATaskStore } from './a2a-store.js';
|
|
5
6
|
/** Binds transport retries to the original task before invoking the executor. */
|
|
6
7
|
export declare class IdempotentA2AHandler extends DefaultRequestHandler {
|
|
7
8
|
#private;
|
|
8
9
|
readonly db: Database.Database;
|
|
9
10
|
readonly durableStore: SqliteA2ATaskStore;
|
|
10
|
-
|
|
11
|
+
readonly taskGate: PeerTaskGate;
|
|
12
|
+
constructor(db: Database.Database, durableStore: SqliteA2ATaskStore, taskGate: PeerTaskGate, ...args: ConstructorParameters<typeof DefaultRequestHandler>);
|
|
11
13
|
sendMessage(params: SendMessageRequest, context: ServerCallContext): Promise<Task | Message>;
|
|
12
14
|
sendMessageStream(params: SendMessageRequest, context: ServerCallContext): AsyncGenerator<StreamResponse, void, undefined>;
|
|
13
15
|
}
|
package/dist/a2a-idempotency.js
CHANGED
|
@@ -9,10 +9,12 @@ const terminal = new Set([TaskState.TASK_STATE_COMPLETED, TaskState.TASK_STATE_F
|
|
|
9
9
|
export class IdempotentA2AHandler extends DefaultRequestHandler {
|
|
10
10
|
db;
|
|
11
11
|
durableStore;
|
|
12
|
-
|
|
12
|
+
taskGate;
|
|
13
|
+
constructor(db, durableStore, taskGate, ...args) {
|
|
13
14
|
super(...args);
|
|
14
15
|
this.db = db;
|
|
15
16
|
this.durableStore = durableStore;
|
|
17
|
+
this.taskGate = taskGate;
|
|
16
18
|
}
|
|
17
19
|
#scope(context) {
|
|
18
20
|
if (!context.user?.isAuthenticated || !context.user.userName)
|
|
@@ -37,6 +39,25 @@ export class IdempotentA2AHandler extends DefaultRequestHandler {
|
|
|
37
39
|
return { prior: inserted.changes === 0, tenant, caller, messageId };
|
|
38
40
|
})();
|
|
39
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Runs before #claim: a refusal must not leave a receipt, or the peer's retry
|
|
44
|
+
* of this message would wait on an admission that never happened. Retries of
|
|
45
|
+
* known messages and team control messages start no new work and take no slot.
|
|
46
|
+
*/
|
|
47
|
+
#admit(params, context) {
|
|
48
|
+
const { tenant, caller } = this.#scope(context);
|
|
49
|
+
const messageId = params.message?.messageId;
|
|
50
|
+
if (!messageId || 'hermesTeamControl' in (params.message?.metadata ?? {}) || this.taskGate.holds(caller, messageId)
|
|
51
|
+
|| this.db.prepare('SELECT 1 FROM a2a_request_receipts WHERE tenant=? AND caller=? AND message_id=?').get(tenant, caller, messageId))
|
|
52
|
+
return null;
|
|
53
|
+
if (!this.taskGate.acquire(caller, messageId))
|
|
54
|
+
throw new UnsupportedOperationError('Peer task limit reached; retry after a running task finishes');
|
|
55
|
+
return { caller, messageId };
|
|
56
|
+
}
|
|
57
|
+
#release(slot) {
|
|
58
|
+
if (slot)
|
|
59
|
+
this.taskGate.release(slot.caller, slot.messageId);
|
|
60
|
+
}
|
|
40
61
|
#bind(receipt, taskId) {
|
|
41
62
|
this.db.prepare('UPDATE a2a_request_receipts SET task_id=? WHERE tenant=? AND caller=? AND message_id=? AND (task_id IS NULL OR task_id=?)')
|
|
42
63
|
.run(taskId, receipt.tenant, receipt.caller, receipt.messageId, taskId);
|
|
@@ -58,29 +79,53 @@ export class IdempotentA2AHandler extends DefaultRequestHandler {
|
|
|
58
79
|
async sendMessage(params, context) {
|
|
59
80
|
if ('hermesTeamReport' in (params.message?.metadata ?? {}))
|
|
60
81
|
throw new RequestMalformedError('Report intake is not supported by this profile');
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
82
|
+
const slot = this.#admit(params, context);
|
|
83
|
+
try {
|
|
84
|
+
const receipt = this.#claim(params, context);
|
|
85
|
+
if (receipt.prior) {
|
|
86
|
+
this.#release(slot);
|
|
87
|
+
return await this.#original(receipt, context);
|
|
88
|
+
}
|
|
89
|
+
const result = await super.sendMessage(params, context);
|
|
90
|
+
if ('id' in result)
|
|
91
|
+
this.#bind(receipt, result.id);
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
this.#release(slot);
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
68
98
|
}
|
|
69
99
|
async *sendMessageStream(params, context) {
|
|
70
100
|
if ('hermesTeamReport' in (params.message?.metadata ?? {}))
|
|
71
101
|
throw new UnsupportedOperationError('Report intake requires a terminal SendMessage receipt');
|
|
72
|
-
const
|
|
102
|
+
const slot = this.#admit(params, context);
|
|
103
|
+
let receipt;
|
|
104
|
+
try {
|
|
105
|
+
receipt = this.#claim(params, context);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
this.#release(slot);
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
73
111
|
if (receipt.prior) {
|
|
112
|
+
this.#release(slot);
|
|
74
113
|
const task = await this.#original(receipt, context);
|
|
75
114
|
yield { payload: { $case: 'task', value: task } };
|
|
76
115
|
if (task.status && !terminal.has(task.status.state))
|
|
77
116
|
yield* this.resubscribe({ tenant: context.tenant ?? '', id: task.id }, context);
|
|
78
117
|
return;
|
|
79
118
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
119
|
+
try {
|
|
120
|
+
for await (const event of super.sendMessageStream(params, context)) {
|
|
121
|
+
if (event.payload?.$case === 'task')
|
|
122
|
+
this.#bind(receipt, event.payload.value.id);
|
|
123
|
+
yield event;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
this.#release(slot);
|
|
128
|
+
throw error;
|
|
84
129
|
}
|
|
85
130
|
}
|
|
86
131
|
}
|
package/dist/a2a-push-inbox.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type Database from 'better-sqlite3';
|
|
2
2
|
import { type StreamResponse } from '@a2a-js/sdk';
|
|
3
|
+
import { z } from 'zod';
|
|
3
4
|
import type { PeerConfig } from './a2a-peers.js';
|
|
5
|
+
declare const Outbound: z.ZodObject<{
|
|
6
|
+
id: z.ZodString;
|
|
7
|
+
peer_id: z.ZodString;
|
|
8
|
+
task_id: z.ZodNullable<z.ZodString>;
|
|
9
|
+
}, z.core.$strip>;
|
|
4
10
|
export declare class A2APushInbox {
|
|
5
11
|
readonly db: Database.Database;
|
|
6
12
|
readonly peers: PeerConfig[];
|
|
@@ -13,6 +19,8 @@ export declare class A2APushInbox {
|
|
|
13
19
|
};
|
|
14
20
|
bind(id: string, taskId: string): void;
|
|
15
21
|
markFailed(id: string): void;
|
|
22
|
+
/** Checks the callback token alone, so an unauthorized sender cannot make the server parse its body. */
|
|
23
|
+
authorize(peerId: string, token: string | undefined): z.infer<typeof Outbound>;
|
|
16
24
|
accept(peerId: string, token: string | undefined, raw: unknown): {
|
|
17
25
|
duplicate: boolean;
|
|
18
26
|
outboundId: string;
|
|
@@ -20,3 +28,4 @@ export declare class A2APushInbox {
|
|
|
20
28
|
};
|
|
21
29
|
events(outboundId: string): StreamResponse[];
|
|
22
30
|
}
|
|
31
|
+
export {};
|
package/dist/a2a-push-inbox.js
CHANGED
|
@@ -46,7 +46,8 @@ export class A2APushInbox {
|
|
|
46
46
|
this.db.prepare("UPDATE a2a_outbound_calls SET task_id=?,state='bound',updated_ms=? WHERE id=?").run(taskId, Date.now(), id);
|
|
47
47
|
}
|
|
48
48
|
markFailed(id) { this.db.prepare("UPDATE a2a_outbound_calls SET state='failed',updated_ms=? WHERE id=?").run(Date.now(), id); }
|
|
49
|
-
|
|
49
|
+
/** Checks the callback token alone, so an unauthorized sender cannot make the server parse its body. */
|
|
50
|
+
authorize(peerId, token) {
|
|
50
51
|
if (!token || token.length > 256 || !this.peers.some(peer => peer.enabled && peer.id === peerId))
|
|
51
52
|
throw new Error('A2A push is unauthorized');
|
|
52
53
|
const row = this.db.prepare('SELECT id,peer_id,task_id FROM a2a_outbound_calls WHERE token_hash=?').get(digest(token));
|
|
@@ -55,6 +56,10 @@ export class A2APushInbox {
|
|
|
55
56
|
const call = Outbound.parse(row);
|
|
56
57
|
if (call.peer_id !== peerId)
|
|
57
58
|
throw new Error('A2A push is unauthorized');
|
|
59
|
+
return call;
|
|
60
|
+
}
|
|
61
|
+
accept(peerId, token, raw) {
|
|
62
|
+
const call = this.authorize(peerId, token);
|
|
58
63
|
const event = StreamCodec.fromJSON(raw);
|
|
59
64
|
const payload = event.payload;
|
|
60
65
|
if (!payload)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type AgentExecutor, type ExecutionEventBus, type RequestContext } from '@a2a-js/sdk/server';
|
|
2
|
+
import type { Request, RequestHandler, Response } from 'express';
|
|
3
|
+
export type TakeResult = {
|
|
4
|
+
allowed: true;
|
|
5
|
+
} | {
|
|
6
|
+
allowed: false;
|
|
7
|
+
retryAfterSeconds: number;
|
|
8
|
+
};
|
|
9
|
+
export interface BucketLimits {
|
|
10
|
+
perMinute: number;
|
|
11
|
+
burst: number;
|
|
12
|
+
maxKeys?: number;
|
|
13
|
+
now?: () => number;
|
|
14
|
+
}
|
|
15
|
+
/** Answers from memory only, and keeps at most maxKeys buckets so many clients cannot grow it. */
|
|
16
|
+
export declare class TokenBucketLimiter {
|
|
17
|
+
#private;
|
|
18
|
+
constructor(limits: BucketLimits);
|
|
19
|
+
take(key: string): TakeResult;
|
|
20
|
+
}
|
|
21
|
+
/** One bucket per client: an IPv4-mapped address is its IPv4 address, and IPv6 clients share a /64. */
|
|
22
|
+
export declare function clientKey(address: string | undefined): string;
|
|
23
|
+
export declare function tooManyRequests(res: Response, retryAfterSeconds: number, error?: string): void;
|
|
24
|
+
export declare function limitRequests(limiter: TokenBucketLimiter, key: (req: Request, res: Response) => string): RequestHandler;
|
|
25
|
+
/** Admitted peer tasks, from before the SDK creates the task until the executor settles it. */
|
|
26
|
+
export declare class PeerTaskGate {
|
|
27
|
+
#private;
|
|
28
|
+
readonly maxPerPeer: number;
|
|
29
|
+
constructor(maxPerPeer: number);
|
|
30
|
+
isFull(peer: string): boolean;
|
|
31
|
+
holds(peer: string, key: string): boolean;
|
|
32
|
+
acquire(peer: string, key: string): boolean;
|
|
33
|
+
release(peer: string, key: string): void;
|
|
34
|
+
}
|
|
35
|
+
/** Frees a task's slot when its execution ends, however it ends. */
|
|
36
|
+
export declare class GatedA2AExecutor implements AgentExecutor {
|
|
37
|
+
readonly inner: AgentExecutor;
|
|
38
|
+
readonly gate: PeerTaskGate;
|
|
39
|
+
constructor(inner: AgentExecutor, gate: PeerTaskGate);
|
|
40
|
+
execute(request: RequestContext, bus: ExecutionEventBus): Promise<void>;
|
|
41
|
+
cancelTask(taskId: string, bus: ExecutionEventBus): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
/** How long a peer at its task cap should wait; tasks have no fixed duration to report instead. */
|
|
44
|
+
export declare const TASK_CAP_RETRY_SECONDS = 5;
|
|
45
|
+
/**
|
|
46
|
+
* Answers an HTTP 429 before the transports turn the handler's refusal into an
|
|
47
|
+
* application error. The handler still enforces the cap atomically and for gRPC.
|
|
48
|
+
* Team control messages stay allowed so a peer at its cap can still cancel work.
|
|
49
|
+
*/
|
|
50
|
+
export declare function jsonRpcTaskCap(gate: PeerTaskGate, peerOf: (res: Response) => string): RequestHandler;
|
|
51
|
+
/** The REST body is not parsed yet here; cancel has its own route and stays available. */
|
|
52
|
+
export declare function restTaskCap(gate: PeerTaskGate, peerOf: (res: Response) => string): RequestHandler;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/** Bounds on the A2A surface: per-client and per-peer token buckets and a per-peer task cap. */
|
|
2
|
+
import { isIPv4, isIPv6 } from 'node:net';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
/** Answers from memory only, and keeps at most maxKeys buckets so many clients cannot grow it. */
|
|
5
|
+
export class TokenBucketLimiter {
|
|
6
|
+
#buckets = new Map();
|
|
7
|
+
#perMs;
|
|
8
|
+
#burst;
|
|
9
|
+
#maxKeys;
|
|
10
|
+
#now;
|
|
11
|
+
constructor(limits) {
|
|
12
|
+
this.#perMs = limits.perMinute / 60_000;
|
|
13
|
+
this.#burst = limits.burst;
|
|
14
|
+
this.#maxKeys = limits.maxKeys ?? 10_000;
|
|
15
|
+
this.#now = limits.now ?? Date.now;
|
|
16
|
+
}
|
|
17
|
+
take(key) {
|
|
18
|
+
const now = this.#now();
|
|
19
|
+
const bucket = this.#buckets.get(key) ?? { tokens: this.#burst, updated: now };
|
|
20
|
+
bucket.tokens = Math.min(this.#burst, bucket.tokens + Math.max(0, now - bucket.updated) * this.#perMs);
|
|
21
|
+
bucket.updated = now;
|
|
22
|
+
// Reinsert so the Map's order runs from least to most recently used.
|
|
23
|
+
this.#buckets.delete(key);
|
|
24
|
+
this.#buckets.set(key, bucket);
|
|
25
|
+
if (this.#buckets.size > this.#maxKeys) {
|
|
26
|
+
const oldest = this.#buckets.keys().next();
|
|
27
|
+
if (!oldest.done)
|
|
28
|
+
this.#buckets.delete(oldest.value);
|
|
29
|
+
}
|
|
30
|
+
if (bucket.tokens < 1)
|
|
31
|
+
return { allowed: false, retryAfterSeconds: Math.max(1, Math.ceil((1 - bucket.tokens) / this.#perMs / 1000)) };
|
|
32
|
+
bucket.tokens -= 1;
|
|
33
|
+
return { allowed: true };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** One bucket per client: an IPv4-mapped address is its IPv4 address, and IPv6 clients share a /64. */
|
|
37
|
+
export function clientKey(address) {
|
|
38
|
+
if (!address)
|
|
39
|
+
return 'unknown';
|
|
40
|
+
const bare = address.split('%')[0] ?? address;
|
|
41
|
+
const mapped = bare.toLowerCase().startsWith('::ffff:') ? bare.slice(7) : bare;
|
|
42
|
+
if (isIPv4(mapped) || !isIPv6(bare))
|
|
43
|
+
return mapped;
|
|
44
|
+
const [head = '', tail = ''] = bare.toLowerCase().split('::');
|
|
45
|
+
const before = head ? head.split(':') : [];
|
|
46
|
+
const after = tail ? tail.split(':') : [];
|
|
47
|
+
const groups = [...before, ...new Array(Math.max(0, 8 - before.length - after.length)).fill('0'), ...after];
|
|
48
|
+
return `${groups.slice(0, 4).map(group => group.replace(/^0+(?=.)/, '')).join(':')}::/64`;
|
|
49
|
+
}
|
|
50
|
+
export function tooManyRequests(res, retryAfterSeconds, error = 'Too many A2A requests') {
|
|
51
|
+
res.status(429).set({ 'Retry-After': String(retryAfterSeconds), 'Cache-Control': 'no-store' }).json({ error });
|
|
52
|
+
}
|
|
53
|
+
export function limitRequests(limiter, key) {
|
|
54
|
+
return (req, res, next) => {
|
|
55
|
+
const result = limiter.take(key(req, res));
|
|
56
|
+
if (result.allowed) {
|
|
57
|
+
next();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
tooManyRequests(res, result.retryAfterSeconds);
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Admitted peer tasks, from before the SDK creates the task until the executor settles it. */
|
|
64
|
+
export class PeerTaskGate {
|
|
65
|
+
maxPerPeer;
|
|
66
|
+
#active = new Map();
|
|
67
|
+
constructor(maxPerPeer) {
|
|
68
|
+
this.maxPerPeer = maxPerPeer;
|
|
69
|
+
}
|
|
70
|
+
isFull(peer) { return (this.#active.get(peer)?.size ?? 0) >= this.maxPerPeer; }
|
|
71
|
+
holds(peer, key) { return this.#active.get(peer)?.has(key) ?? false; }
|
|
72
|
+
acquire(peer, key) {
|
|
73
|
+
const active = this.#active.get(peer) ?? new Set();
|
|
74
|
+
if (!active.has(key) && active.size >= this.maxPerPeer)
|
|
75
|
+
return false;
|
|
76
|
+
active.add(key);
|
|
77
|
+
this.#active.set(peer, active);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
release(peer, key) {
|
|
81
|
+
const active = this.#active.get(peer);
|
|
82
|
+
if (!active?.delete(key))
|
|
83
|
+
return;
|
|
84
|
+
if (active.size === 0)
|
|
85
|
+
this.#active.delete(peer);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** Frees a task's slot when its execution ends, however it ends. */
|
|
89
|
+
export class GatedA2AExecutor {
|
|
90
|
+
inner;
|
|
91
|
+
gate;
|
|
92
|
+
constructor(inner, gate) {
|
|
93
|
+
this.inner = inner;
|
|
94
|
+
this.gate = gate;
|
|
95
|
+
}
|
|
96
|
+
async execute(request, bus) {
|
|
97
|
+
try {
|
|
98
|
+
await this.inner.execute(request, bus);
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
const peer = request.context.user?.userName;
|
|
102
|
+
if (peer)
|
|
103
|
+
this.gate.release(peer, request.userMessage.messageId);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
cancelTask(taskId, bus) { return this.inner.cancelTask(taskId, bus); }
|
|
107
|
+
}
|
|
108
|
+
const SendRpc = z.object({ method: z.string(), params: z.object({ message: z.object({ metadata: z.record(z.string(), z.unknown()).nullish() }).nullish() }).nullish() });
|
|
109
|
+
const SEND_METHODS = new Set(['SendMessage', 'SendStreamingMessage', 'message/send', 'message/stream']);
|
|
110
|
+
/** How long a peer at its task cap should wait; tasks have no fixed duration to report instead. */
|
|
111
|
+
export const TASK_CAP_RETRY_SECONDS = 5;
|
|
112
|
+
/**
|
|
113
|
+
* Answers an HTTP 429 before the transports turn the handler's refusal into an
|
|
114
|
+
* application error. The handler still enforces the cap atomically and for gRPC.
|
|
115
|
+
* Team control messages stay allowed so a peer at its cap can still cancel work.
|
|
116
|
+
*/
|
|
117
|
+
export function jsonRpcTaskCap(gate, peerOf) {
|
|
118
|
+
return (req, res, next) => {
|
|
119
|
+
const rpc = SendRpc.safeParse(req.body);
|
|
120
|
+
if (rpc.success && SEND_METHODS.has(rpc.data.method) && !('hermesTeamControl' in (rpc.data.params?.message?.metadata ?? {})) && gate.isFull(peerOf(res))) {
|
|
121
|
+
tooManyRequests(res, TASK_CAP_RETRY_SECONDS, 'Peer task limit reached');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
next();
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** The REST body is not parsed yet here; cancel has its own route and stays available. */
|
|
128
|
+
export function restTaskCap(gate, peerOf) {
|
|
129
|
+
return (req, res, next) => {
|
|
130
|
+
if (req.method === 'POST' && /\/message:(?:send|stream)$/.test(req.path) && gate.isFull(peerOf(res))) {
|
|
131
|
+
tooManyRequests(res, TASK_CAP_RETRY_SECONDS, 'Peer task limit reached');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
next();
|
|
135
|
+
};
|
|
136
|
+
}
|
package/dist/a2a-server.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { SafeA2APushSender } from './a2a-push.js';
|
|
|
6
6
|
import { A2APushInbox } from './a2a-push-inbox.js';
|
|
7
7
|
import type { ArdentRuntime } from './runtime.js';
|
|
8
8
|
import { hermesTeamCard } from './hermes-compat.js';
|
|
9
|
+
import { IdempotentA2AHandler } from './a2a-idempotency.js';
|
|
10
|
+
import { PeerTaskGate, TokenBucketLimiter } from './a2a-rate-limit.js';
|
|
9
11
|
export type A2ASurface = {
|
|
10
12
|
app: Express;
|
|
11
13
|
grpcServer: GrpcServer;
|
|
@@ -14,6 +16,8 @@ export type A2ASurface = {
|
|
|
14
16
|
grpcListenPort: number;
|
|
15
17
|
pushSender: SafeA2APushSender;
|
|
16
18
|
pushInbox: A2APushInbox;
|
|
19
|
+
handler: IdempotentA2AHandler;
|
|
20
|
+
taskGate: PeerTaskGate;
|
|
17
21
|
};
|
|
18
22
|
/** Refresh only discovery freshness; the reviewed runtime identity and digests stay pinned to this process. */
|
|
19
23
|
export declare function freshHermesTeamCard(source: Awaited<ReturnType<typeof hermesTeamCard>>, now?: number): {
|
|
@@ -33,5 +37,10 @@ export declare function freshHermesTeamCard(source: Awaited<ReturnType<typeof he
|
|
|
33
37
|
tags: string[];
|
|
34
38
|
}>;
|
|
35
39
|
};
|
|
40
|
+
/** gRPC bypasses Express, so its authenticated calls spend the same per-peer tokens here. */
|
|
41
|
+
export declare function limitedGrpcUser(peer: string | undefined, bucket: TokenBucketLimiter | undefined): {
|
|
42
|
+
isAuthenticated: boolean;
|
|
43
|
+
userName: string;
|
|
44
|
+
};
|
|
36
45
|
export declare function createA2ASurface(runtime: ArdentRuntime, config: ArdentConfig, env?: NodeJS.ProcessEnv): Promise<A2ASurface>;
|
|
37
46
|
export declare function startA2AGrpc(surface: A2ASurface, host?: string): Promise<void>;
|
package/dist/a2a-server.js
CHANGED
|
@@ -15,6 +15,7 @@ import { hermesTeamCard } from './hermes-compat.js';
|
|
|
15
15
|
import { IdempotentA2AHandler } from './a2a-idempotency.js';
|
|
16
16
|
import { hermesAlias } from './a2a-hermes-alias.js';
|
|
17
17
|
import { ARDENT_VERSION } from './version.js';
|
|
18
|
+
import { clientKey, GatedA2AExecutor, jsonRpcTaskCap, limitRequests, PeerTaskGate, restTaskCap, TokenBucketLimiter } from './a2a-rate-limit.js';
|
|
18
19
|
import { TeamArtifacts } from './team-artifacts.js';
|
|
19
20
|
/** Refresh only discovery freshness; the reviewed runtime identity and digests stay pinned to this process. */
|
|
20
21
|
export function freshHermesTeamCard(source, now = Date.now()) {
|
|
@@ -26,6 +27,12 @@ export function freshHermesTeamCard(source, now = Date.now()) {
|
|
|
26
27
|
return { ...source, metadata: { hermesTeam: { ...team,
|
|
27
28
|
discovery: { ...team.discovery, observedAt, expiresAt: observedAt + 300 } } } };
|
|
28
29
|
}
|
|
30
|
+
/** gRPC bypasses Express, so its authenticated calls spend the same per-peer tokens here. */
|
|
31
|
+
export function limitedGrpcUser(peer, bucket) {
|
|
32
|
+
if (peer && bucket && !bucket.take(peer).allowed)
|
|
33
|
+
throw new Error('Too many A2A requests');
|
|
34
|
+
return { isAuthenticated: Boolean(peer), userName: peer ?? '' };
|
|
35
|
+
}
|
|
29
36
|
export async function createA2ASurface(runtime, config, env = process.env) {
|
|
30
37
|
const publicAddress = resolveReference(config.a2a.public_url, env);
|
|
31
38
|
if (!publicAddress)
|
|
@@ -77,7 +84,9 @@ export async function createA2ASurface(runtime, config, env = process.env) {
|
|
|
77
84
|
const pushInbox = new A2APushInbox(runtime.store.db, peers, publicAddress);
|
|
78
85
|
const artifactTransfers = new TeamArtifacts(runtime, config, env);
|
|
79
86
|
const taskStore = new SqliteA2ATaskStore(runtime.store.db);
|
|
80
|
-
const
|
|
87
|
+
const limits = config.a2a.rate_limit;
|
|
88
|
+
const taskGate = new PeerTaskGate(limits.enabled ? limits.max_concurrent_tasks_per_peer : Number.MAX_SAFE_INTEGER);
|
|
89
|
+
const handler = new IdempotentA2AHandler(runtime.store.db, taskStore, taskGate, card, taskStore, new GatedA2AExecutor(new ArdentA2AExecutor(runtime, env), taskGate), undefined, pushStore, pushSender, async (context) => {
|
|
81
90
|
const peerId = context.user?.userName;
|
|
82
91
|
if (!context.user?.isAuthenticated || !peerId || !peers.some(peer => peer.id === peerId && peer.enabled))
|
|
83
92
|
throw new Error('Authenticated configured peer required');
|
|
@@ -88,14 +97,29 @@ export async function createA2ASurface(runtime, config, env = process.env) {
|
|
|
88
97
|
};
|
|
89
98
|
});
|
|
90
99
|
const app = express();
|
|
91
|
-
|
|
100
|
+
// Off by default: without an explicit proxy list req.ip is the socket address and X-Forwarded-For is ignored.
|
|
101
|
+
if (limits.trusted_proxies.length)
|
|
102
|
+
app.set('trust proxy', limits.trusted_proxies);
|
|
103
|
+
const pass = (_req, _res, next) => next();
|
|
104
|
+
const peerOf = (res) => String(res.locals.peer);
|
|
105
|
+
const peerBucket = new TokenBucketLimiter({ perMinute: limits.peer_requests_per_minute, burst: limits.peer_burst });
|
|
106
|
+
const peerLimit = limits.enabled ? limitRequests(peerBucket, (_req, res) => peerOf(res)) : pass;
|
|
107
|
+
const requirePeer = (message) => (req, res, next) => {
|
|
92
108
|
const peer = authenticate(req.header('Authorization'));
|
|
93
109
|
if (!peer) {
|
|
94
|
-
res.status(401).json({ error:
|
|
110
|
+
res.status(401).json({ error: message });
|
|
95
111
|
return;
|
|
96
112
|
}
|
|
113
|
+
res.locals.peer = peer;
|
|
114
|
+
next();
|
|
115
|
+
};
|
|
116
|
+
// First in the chain: every route below, authenticated or not, is limited by client address.
|
|
117
|
+
if (limits.enabled)
|
|
118
|
+
app.use(limitRequests(new TokenBucketLimiter({ perMinute: limits.ip_requests_per_minute, burst: limits.ip_burst }), req => clientKey(req.ip)));
|
|
119
|
+
app.get('/team/artifacts/:id', requirePeer('Authenticated A2A peer required'), peerLimit, async (req, res) => {
|
|
120
|
+
const peer = peerOf(res);
|
|
97
121
|
try {
|
|
98
|
-
const result = await artifactTransfers.serve(peer, req.params.id ?? '', String(req.query.context ?? ''));
|
|
122
|
+
const result = await artifactTransfers.serve(peer, String(req.params.id ?? ''), String(req.query.context ?? ''));
|
|
99
123
|
if (result.status !== 200 || !result.bytes || !result.descriptor) {
|
|
100
124
|
res.status(result.status).json({ error: result.status === 410 ? 'Artifact export revoked or expired' : 'Artifact unavailable' });
|
|
101
125
|
return;
|
|
@@ -110,9 +134,22 @@ export async function createA2ASurface(runtime, config, env = process.env) {
|
|
|
110
134
|
res.status(503).json({ error: 'Artifact export state is unavailable' });
|
|
111
135
|
}
|
|
112
136
|
});
|
|
113
|
-
|
|
137
|
+
const pushToken = (req, res, next) => {
|
|
138
|
+
const peerId = String(req.params.peerId ?? '');
|
|
139
|
+
try {
|
|
140
|
+
pushInbox.authorize(peerId, req.header('X-A2A-Notification-Token'));
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
res.status(401).json({ error: 'A2A push is unauthorized' });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
res.locals.peer = `push:${peerId}`;
|
|
147
|
+
next();
|
|
148
|
+
};
|
|
149
|
+
// The token is checked before up to 2 MB of JSON is parsed.
|
|
150
|
+
app.post('/a2a/push/:peerId', pushToken, peerLimit, express.json({ limit: '2mb', type: ['application/json', 'application/a2a+json'] }), (req, res) => {
|
|
114
151
|
try {
|
|
115
|
-
const result = pushInbox.accept(req.params.peerId ?? '', req.header('X-A2A-Notification-Token'), req.body);
|
|
152
|
+
const result = pushInbox.accept(String(req.params.peerId ?? ''), req.header('X-A2A-Notification-Token'), req.body);
|
|
116
153
|
res.status(202).json({ accepted: true, duplicate: result.duplicate });
|
|
117
154
|
}
|
|
118
155
|
catch (error) {
|
|
@@ -135,49 +172,39 @@ export async function createA2ASurface(runtime, config, env = process.env) {
|
|
|
135
172
|
});
|
|
136
173
|
app.use(`/${AGENT_CARD_PATH}`, legacyAgentCardRouter({ agentCardProvider: handler }));
|
|
137
174
|
if (teamCard)
|
|
138
|
-
app.get('/.well-known/hermes-team-card.json', (
|
|
139
|
-
if (!authenticate(req.header('Authorization'))) {
|
|
140
|
-
res.status(401).json({ error: 'Authenticated configured peer required' });
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
175
|
+
app.get('/.well-known/hermes-team-card.json', requirePeer('Authenticated configured peer required'), peerLimit, (_req, res) => {
|
|
143
176
|
const encoded = AgentCard.toJSON(card);
|
|
144
177
|
if (!encoded || typeof encoded !== 'object' || Array.isArray(encoded))
|
|
145
178
|
throw new Error('A2A Agent Card encoding failed');
|
|
146
179
|
res.set('Cache-Control', 'private, no-store').json({ ...encoded, ...freshHermesTeamCard(teamCard) });
|
|
147
180
|
});
|
|
148
|
-
const auth = (
|
|
149
|
-
|
|
150
|
-
res.status(401).json({ error: 'Authenticated A2A peer required' });
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
next();
|
|
154
|
-
};
|
|
181
|
+
const auth = requirePeer('Authenticated A2A peer required');
|
|
182
|
+
const jsonRpcCap = jsonRpcTaskCap(taskGate, peerOf);
|
|
155
183
|
const userBuilder = async (req) => {
|
|
156
184
|
const id = authenticate(req.header('Authorization'));
|
|
157
185
|
return { isAuthenticated: Boolean(id), userName: id ?? '' };
|
|
158
186
|
};
|
|
159
187
|
const aliases = config.a2a.hermes_compat ? hermesAlias : (_req, _res, next) => next();
|
|
160
188
|
if (config.a2a.bindings.includes('jsonrpc'))
|
|
161
|
-
app.use('/a2a/jsonrpc', auth, express.json({ limit: '1mb' }), aliases, jsonRpcHandler({ requestHandler: handler, userBuilder, legacyCompat: { enabled: true } }));
|
|
189
|
+
app.use('/a2a/jsonrpc', auth, peerLimit, express.json({ limit: '1mb' }), aliases, jsonRpcCap, jsonRpcHandler({ requestHandler: handler, userBuilder, legacyCompat: { enabled: true } }));
|
|
162
190
|
if (config.a2a.bindings.includes('http-json'))
|
|
163
|
-
app.use('/a2a/rest', auth, restHandler({ requestHandler: handler, userBuilder }));
|
|
191
|
+
app.use('/a2a/rest', auth, peerLimit, restTaskCap(taskGate, peerOf), restHandler({ requestHandler: handler, userBuilder }));
|
|
164
192
|
// Hermes Agent's team router posts to the peer origin itself. Keep that
|
|
165
193
|
// optional compatibility route separate from the advertised v1 interface.
|
|
166
194
|
if (config.a2a.hermes_compat && config.a2a.bindings.includes('jsonrpc')) {
|
|
167
195
|
const rootCompat = express.Router();
|
|
168
196
|
rootCompat.use((req, _res, next) => { if (!req.header('A2A-Version'))
|
|
169
197
|
req.headers['a2a-version'] = '1.0'; next(); });
|
|
170
|
-
rootCompat.use(auth, express.json({ limit: '1mb' }), hermesAlias, jsonRpcHandler({ requestHandler: handler, userBuilder, legacyCompat: { enabled: true } }));
|
|
198
|
+
rootCompat.use(auth, peerLimit, express.json({ limit: '1mb' }), hermesAlias, jsonRpcCap, jsonRpcHandler({ requestHandler: handler, userBuilder, legacyCompat: { enabled: true } }));
|
|
171
199
|
app.use('/', (req, res, next) => req.path === '/' ? rootCompat(req, res, next) : next());
|
|
172
200
|
}
|
|
173
201
|
const grpcServer = new GrpcServer();
|
|
174
202
|
if (config.a2a.bindings.includes('grpc'))
|
|
175
203
|
grpcServer.addService(A2AService, grpcService({ requestHandler: handler, userBuilder: async (call) => {
|
|
176
204
|
const raw = call.metadata.get('authorization')[0];
|
|
177
|
-
|
|
178
|
-
return { isAuthenticated: Boolean(id), userName: id ?? '' };
|
|
205
|
+
return limitedGrpcUser(authenticate(typeof raw === 'string' ? raw : undefined), limits.enabled ? peerBucket : undefined);
|
|
179
206
|
} }));
|
|
180
|
-
return { app, grpcServer, card, grpcAddress, grpcListenPort: listenPort + 1, pushSender, pushInbox };
|
|
207
|
+
return { app, grpcServer, card, grpcAddress, grpcListenPort: listenPort + 1, pushSender, pushInbox, handler, taskGate };
|
|
181
208
|
}
|
|
182
209
|
export async function startA2AGrpc(surface, host = '127.0.0.1') {
|
|
183
210
|
await new Promise((resolve, reject) => {
|
package/dist/config.d.ts
CHANGED
|
@@ -96,6 +96,15 @@ export declare const ConfigSchema: z.ZodObject<{
|
|
|
96
96
|
listen_port: z.ZodOptional<z.ZodNumber>;
|
|
97
97
|
peers_file: z.ZodDefault<z.ZodString>;
|
|
98
98
|
hermes_compat: z.ZodDefault<z.ZodBoolean>;
|
|
99
|
+
rate_limit: z.ZodDefault<z.ZodObject<{
|
|
100
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
101
|
+
ip_requests_per_minute: z.ZodDefault<z.ZodNumber>;
|
|
102
|
+
ip_burst: z.ZodDefault<z.ZodNumber>;
|
|
103
|
+
peer_requests_per_minute: z.ZodDefault<z.ZodNumber>;
|
|
104
|
+
peer_burst: z.ZodDefault<z.ZodNumber>;
|
|
105
|
+
max_concurrent_tasks_per_peer: z.ZodDefault<z.ZodNumber>;
|
|
106
|
+
trusted_proxies: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
107
|
+
}, z.core.$strict>>;
|
|
99
108
|
skill: z.ZodOptional<z.ZodObject<{
|
|
100
109
|
id: z.ZodString;
|
|
101
110
|
name: z.ZodString;
|
package/dist/config.js
CHANGED
|
@@ -43,7 +43,8 @@ const MattermostChannel = z.object({ enabled: z.boolean().default(false), base_u
|
|
|
43
43
|
/** A plugin module specifier: an npm package name, or an absolute path / file URL to an ES module. */
|
|
44
44
|
const pluginSpecifier = z.string().min(1).max(500).refine(value => /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(?:\/[A-Za-z0-9._-]+)*$/.test(value) || value.startsWith('/') || value.startsWith('file://'), 'Expected an npm package name, an absolute path, or a file:// URL');
|
|
45
45
|
const magnusControlDefault = { enabled: false, base_url: '${ARDENT_MAGNUS_API_URL}', token_env: 'ARDENT_MAGNUS_READ_TOKEN' };
|
|
46
|
-
const
|
|
46
|
+
const a2aRateLimitDefault = { enabled: true, ip_requests_per_minute: 600, ip_burst: 120, peer_requests_per_minute: 300, peer_burst: 60, max_concurrent_tasks_per_peer: 16, trusted_proxies: [] };
|
|
47
|
+
const a2aDefault = { rate_limit: a2aRateLimitDefault, enabled: false, protocol_version: '1.0', bindings: ['jsonrpc', 'http-json'], public_url: '${ARDENT_A2A_PUBLIC_URL}', listen_host: '127.0.0.1', peers_file: 'peers.yaml', hermes_compat: false };
|
|
47
48
|
const researchDefault = { enabled: false, searxng_url: '${ARDENT_SEARXNG_URL}', firecrawl_url: '${ARDENT_FIRECRAWL_URL}', firecrawl_token_env: 'FIRECRAWL_API_KEY' };
|
|
48
49
|
export const ConfigSchema = z.object({
|
|
49
50
|
schema_version: z.literal(1),
|
|
@@ -62,6 +63,8 @@ export const ConfigSchema = z.object({
|
|
|
62
63
|
a2a: z.object({ enabled: z.boolean().default(false), protocol_version: z.literal('1.0').default('1.0'), bindings: z.array(z.enum(['jsonrpc', 'http-json', 'grpc'])).min(1).default(['jsonrpc', 'http-json']), public_url: urlReference.default('${ARDENT_A2A_PUBLIC_URL}'), listen_host: z.string().min(1).default('127.0.0.1'), listen_port: z.number().int().min(1).max(65534).optional(), peers_file: z.string().regex(/^[A-Za-z0-9._-]+\.yaml$/).default('peers.yaml'),
|
|
63
64
|
/** Serve Hermes Agent team-card discovery and slash-style JSON-RPC method aliases. */
|
|
64
65
|
hermes_compat: z.boolean().default(false),
|
|
66
|
+
/** Request and task limits. `trusted_proxies` lists the reverse proxies whose X-Forwarded-For is believed; by default none is. */
|
|
67
|
+
rate_limit: z.object({ enabled: z.boolean().default(true), ip_requests_per_minute: z.number().int().min(1).max(1_000_000).default(600), ip_burst: z.number().int().min(1).max(1_000_000).default(120), peer_requests_per_minute: z.number().int().min(1).max(1_000_000).default(300), peer_burst: z.number().int().min(1).max(1_000_000).default(60), max_concurrent_tasks_per_peer: z.number().int().min(1).max(100_000).default(16), trusted_proxies: z.array(z.string().min(1).max(64)).max(64).default([]) }).strict().default(a2aRateLimitDefault),
|
|
65
68
|
/** Agent Card skill advertised to peers. */
|
|
66
69
|
skill: z.object({ id: z.string().regex(/^[a-z][a-z0-9-]{0,63}$/), name: z.string().min(1).max(100), description: z.string().min(1).max(500), tags: z.array(z.string().min(1).max(40)).max(10).default([]), example: z.string().min(1).max(300).optional() }).strict().optional() }).strict().default(a2aDefault),
|
|
67
70
|
channels: z.object({ telegram: TelegramChannel.default(TelegramChannel.parse({})), mattermost: MattermostChannel.default(MattermostChannel.parse({})) }).strict().default({ telegram: TelegramChannel.parse({}), mattermost: MattermostChannel.parse({}) }),
|