fix(acp): harden session lifecycle against flooding

This commit is contained in:
Peter Steinberger
2026-02-19 14:50:02 +01:00
parent 4ddc4dfd76
commit e01011e3e4
6 changed files with 364 additions and 7 deletions

View File

@@ -1,4 +1,3 @@
import { randomUUID } from "node:crypto";
import type {
Agent,
AgentSideConnection,
@@ -20,6 +19,7 @@ import type {
StopReason,
} from "@agentclientprotocol/sdk";
import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
import { randomUUID } from "node:crypto";
import type { GatewayClient } from "../gateway/client.js";
import type { EventFrame } from "../gateway/protocol/index.js";
import type { SessionsListResult } from "../gateway/session-utils.js";
@@ -50,12 +50,50 @@ type AcpGatewayAgentOptions = AcpServerOptions & {
sessionStore?: AcpSessionStore;
};
const SESSION_CREATE_RATE_LIMIT_DEFAULT_MAX_REQUESTS = 120;
const SESSION_CREATE_RATE_LIMIT_DEFAULT_WINDOW_MS = 10_000;
class SessionCreateRateLimiter {
private count = 0;
private windowStartMs = 0;
constructor(
private readonly maxRequests: number,
private readonly windowMs: number,
private readonly now: () => number = Date.now,
) {}
consume(): { allowed: boolean; retryAfterMs: number; remaining: number } {
const nowMs = this.now();
if (nowMs - this.windowStartMs >= this.windowMs) {
this.windowStartMs = nowMs;
this.count = 0;
}
if (this.count >= this.maxRequests) {
return {
allowed: false,
retryAfterMs: Math.max(0, this.windowStartMs + this.windowMs - nowMs),
remaining: 0,
};
}
this.count += 1;
return {
allowed: true,
retryAfterMs: 0,
remaining: Math.max(0, this.maxRequests - this.count),
};
}
}
export class AcpGatewayAgent implements Agent {
private connection: AgentSideConnection;
private gateway: GatewayClient;
private opts: AcpGatewayAgentOptions;
private log: (msg: string) => void;
private sessionStore: AcpSessionStore;
private sessionCreateRateLimiter: SessionCreateRateLimiter;
private pendingPrompts = new Map<string, PendingPrompt>();
constructor(
@@ -68,6 +106,16 @@ export class AcpGatewayAgent implements Agent {
this.opts = opts;
this.log = opts.verbose ? (msg: string) => process.stderr.write(`[acp] ${msg}\n`) : () => {};
this.sessionStore = opts.sessionStore ?? defaultAcpSessionStore;
this.sessionCreateRateLimiter = new SessionCreateRateLimiter(
Math.max(
1,
opts.sessionCreateRateLimit?.maxRequests ?? SESSION_CREATE_RATE_LIMIT_DEFAULT_MAX_REQUESTS,
),
Math.max(
1_000,
opts.sessionCreateRateLimit?.windowMs ?? SESSION_CREATE_RATE_LIMIT_DEFAULT_WINDOW_MS,
),
);
}
start(): void {
@@ -124,6 +172,7 @@ export class AcpGatewayAgent implements Agent {
if (params.mcpServers.length > 0) {
this.log(`ignoring ${params.mcpServers.length} MCP servers`);
}
this.enforceSessionCreateRateLimit("newSession");
const sessionId = randomUUID();
const meta = parseSessionMeta(params._meta);
@@ -154,6 +203,9 @@ export class AcpGatewayAgent implements Agent {
if (params.mcpServers.length > 0) {
this.log(`ignoring ${params.mcpServers.length} MCP servers`);
}
if (!this.sessionStore.getSession(params.sessionId)) {
this.enforceSessionCreateRateLimit("loadSession");
}
const meta = parseSessionMeta(params._meta);
const sessionKey = await resolveSessionKey({
@@ -451,4 +503,14 @@ export class AcpGatewayAgent implements Agent {
},
});
}
private enforceSessionCreateRateLimit(method: "newSession" | "loadSession"): void {
const budget = this.sessionCreateRateLimiter.consume();
if (budget.allowed) {
return;
}
throw new Error(
`ACP session creation rate limit exceeded for ${method}; retry after ${Math.ceil(budget.retryAfterMs / 1_000)}s.`,
);
}
}