@agentlip/hub 0.1.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 +126 -0
- package/package.json +36 -0
- package/src/agentlipd.ts +309 -0
- package/src/apiV1.ts +1468 -0
- package/src/authMiddleware.ts +134 -0
- package/src/authToken.ts +32 -0
- package/src/bodyParser.ts +272 -0
- package/src/config.ts +273 -0
- package/src/derivedStaleness.ts +255 -0
- package/src/extractorDerived.ts +374 -0
- package/src/index.ts +878 -0
- package/src/linkifierDerived.ts +407 -0
- package/src/lock.ts +172 -0
- package/src/pluginRuntime.ts +402 -0
- package/src/pluginWorker.ts +296 -0
- package/src/rateLimiter.ts +286 -0
- package/src/serverJson.ts +138 -0
- package/src/ui.ts +843 -0
- package/src/wsEndpoint.ts +481 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory rate limiter supporting:
|
|
3
|
+
* - Global rate limiting (all requests)
|
|
4
|
+
* - Per-client rate limiting (keyed by auth token or 'anon')
|
|
5
|
+
*
|
|
6
|
+
* Uses token bucket algorithm with configurable window.
|
|
7
|
+
* Thread-safe for single-process usage.
|
|
8
|
+
*
|
|
9
|
+
* Security: client keys derived from auth tokens are never logged.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Bucket state for token bucket algorithm.
|
|
14
|
+
*/
|
|
15
|
+
interface Bucket {
|
|
16
|
+
tokens: number;
|
|
17
|
+
lastRefill: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Rate limiter configuration.
|
|
22
|
+
*/
|
|
23
|
+
export interface RateLimiterConfig {
|
|
24
|
+
/** Max requests per window */
|
|
25
|
+
limit: number;
|
|
26
|
+
/** Window size in milliseconds */
|
|
27
|
+
windowMs: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Rate limit check result.
|
|
32
|
+
*/
|
|
33
|
+
export interface RateLimitResult {
|
|
34
|
+
allowed: boolean;
|
|
35
|
+
limit: number;
|
|
36
|
+
remaining: number;
|
|
37
|
+
/** Reset time as Unix timestamp (seconds) */
|
|
38
|
+
resetAt: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* In-memory rate limiter using token bucket algorithm.
|
|
43
|
+
*/
|
|
44
|
+
export class RateLimiter {
|
|
45
|
+
private buckets: Map<string, Bucket> = new Map();
|
|
46
|
+
private readonly limit: number;
|
|
47
|
+
private readonly windowMs: number;
|
|
48
|
+
|
|
49
|
+
constructor(config: RateLimiterConfig) {
|
|
50
|
+
this.limit = config.limit;
|
|
51
|
+
this.windowMs = config.windowMs;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if a request is allowed for the given key.
|
|
56
|
+
* Consumes a token if allowed.
|
|
57
|
+
*
|
|
58
|
+
* @param key - Unique identifier (e.g., 'global' or hashed client key)
|
|
59
|
+
* @returns Rate limit result
|
|
60
|
+
*/
|
|
61
|
+
check(key: string): RateLimitResult {
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
let bucket = this.buckets.get(key);
|
|
64
|
+
|
|
65
|
+
if (!bucket) {
|
|
66
|
+
// Initialize new bucket
|
|
67
|
+
bucket = {
|
|
68
|
+
tokens: this.limit,
|
|
69
|
+
lastRefill: now,
|
|
70
|
+
};
|
|
71
|
+
this.buckets.set(key, bucket);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Calculate tokens to add based on elapsed time
|
|
75
|
+
const elapsed = now - bucket.lastRefill;
|
|
76
|
+
const tokensToAdd = (elapsed / this.windowMs) * this.limit;
|
|
77
|
+
|
|
78
|
+
// Refill bucket (capped at limit)
|
|
79
|
+
bucket.tokens = Math.min(this.limit, bucket.tokens + tokensToAdd);
|
|
80
|
+
bucket.lastRefill = now;
|
|
81
|
+
|
|
82
|
+
// Calculate reset time (when bucket would be full)
|
|
83
|
+
const tokensNeeded = this.limit - bucket.tokens;
|
|
84
|
+
const msUntilFull = tokensNeeded > 0 ? (tokensNeeded / this.limit) * this.windowMs : 0;
|
|
85
|
+
const resetAt = Math.ceil((now + msUntilFull) / 1000);
|
|
86
|
+
|
|
87
|
+
if (bucket.tokens >= 1) {
|
|
88
|
+
// Consume a token
|
|
89
|
+
bucket.tokens -= 1;
|
|
90
|
+
return {
|
|
91
|
+
allowed: true,
|
|
92
|
+
limit: this.limit,
|
|
93
|
+
remaining: Math.floor(bucket.tokens),
|
|
94
|
+
resetAt,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Rate limited
|
|
99
|
+
return {
|
|
100
|
+
allowed: false,
|
|
101
|
+
limit: this.limit,
|
|
102
|
+
remaining: 0,
|
|
103
|
+
resetAt,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Clean up expired buckets to prevent memory leaks.
|
|
109
|
+
* Call periodically (e.g., every minute).
|
|
110
|
+
*/
|
|
111
|
+
cleanup(): void {
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
const expireAfter = this.windowMs * 2; // Keep buckets for 2x window
|
|
114
|
+
|
|
115
|
+
for (const [key, bucket] of this.buckets) {
|
|
116
|
+
if (now - bucket.lastRefill > expireAfter) {
|
|
117
|
+
this.buckets.delete(key);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Reset all buckets (for testing).
|
|
124
|
+
*/
|
|
125
|
+
reset(): void {
|
|
126
|
+
this.buckets.clear();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Default rate limiter configurations (from plan).
|
|
132
|
+
*/
|
|
133
|
+
export const DEFAULT_RATE_LIMITS = {
|
|
134
|
+
/** Per-client rate limit: 100 req/s */
|
|
135
|
+
perClient: { limit: 100, windowMs: 1000 },
|
|
136
|
+
/** Global rate limit: 1000 req/s */
|
|
137
|
+
global: { limit: 1000, windowMs: 1000 },
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Hub rate limiter instance managing both global and per-client limits.
|
|
142
|
+
*/
|
|
143
|
+
export class HubRateLimiter {
|
|
144
|
+
private globalLimiter: RateLimiter;
|
|
145
|
+
private clientLimiter: RateLimiter;
|
|
146
|
+
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
|
147
|
+
|
|
148
|
+
constructor(
|
|
149
|
+
globalConfig: RateLimiterConfig = DEFAULT_RATE_LIMITS.global,
|
|
150
|
+
clientConfig: RateLimiterConfig = DEFAULT_RATE_LIMITS.perClient
|
|
151
|
+
) {
|
|
152
|
+
this.globalLimiter = new RateLimiter(globalConfig);
|
|
153
|
+
this.clientLimiter = new RateLimiter(clientConfig);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Start periodic cleanup of expired buckets.
|
|
158
|
+
* @param intervalMs - Cleanup interval (default: 60s)
|
|
159
|
+
*/
|
|
160
|
+
startCleanup(intervalMs = 60000): void {
|
|
161
|
+
if (this.cleanupInterval) return;
|
|
162
|
+
this.cleanupInterval = setInterval(() => {
|
|
163
|
+
this.globalLimiter.cleanup();
|
|
164
|
+
this.clientLimiter.cleanup();
|
|
165
|
+
}, intervalMs);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Stop cleanup timer.
|
|
170
|
+
*/
|
|
171
|
+
stopCleanup(): void {
|
|
172
|
+
if (this.cleanupInterval) {
|
|
173
|
+
clearInterval(this.cleanupInterval);
|
|
174
|
+
this.cleanupInterval = null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Derive a stable client key from request.
|
|
180
|
+
* Uses Authorization header if present, otherwise 'anon'.
|
|
181
|
+
*
|
|
182
|
+
* Security: We hash the token to avoid storing it directly.
|
|
183
|
+
* The key is never logged.
|
|
184
|
+
*
|
|
185
|
+
* @param req - HTTP request
|
|
186
|
+
* @returns Stable client key (never the raw token)
|
|
187
|
+
*/
|
|
188
|
+
private getClientKey(req: Request): string {
|
|
189
|
+
const authHeader = req.headers.get("Authorization");
|
|
190
|
+
if (!authHeader) {
|
|
191
|
+
return "anon";
|
|
192
|
+
}
|
|
193
|
+
// Use a hash of the auth header to create a stable key
|
|
194
|
+
// without storing the actual token
|
|
195
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
196
|
+
hasher.update(authHeader);
|
|
197
|
+
return `client:${hasher.digest("hex").substring(0, 16)}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Check rate limits for a request.
|
|
202
|
+
* Checks both global and per-client limits.
|
|
203
|
+
*
|
|
204
|
+
* @param req - HTTP request
|
|
205
|
+
* @returns Combined rate limit result
|
|
206
|
+
*/
|
|
207
|
+
check(req: Request): RateLimitResult {
|
|
208
|
+
// Check global limit first
|
|
209
|
+
const globalResult = this.globalLimiter.check("global");
|
|
210
|
+
if (!globalResult.allowed) {
|
|
211
|
+
return globalResult;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Check per-client limit
|
|
215
|
+
const clientKey = this.getClientKey(req);
|
|
216
|
+
const clientResult = this.clientLimiter.check(clientKey);
|
|
217
|
+
|
|
218
|
+
// Return the more restrictive result
|
|
219
|
+
return {
|
|
220
|
+
allowed: clientResult.allowed,
|
|
221
|
+
limit: clientResult.limit,
|
|
222
|
+
remaining: clientResult.remaining,
|
|
223
|
+
resetAt: clientResult.resetAt,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Reset all rate limiters (for testing).
|
|
229
|
+
*/
|
|
230
|
+
reset(): void {
|
|
231
|
+
this.globalLimiter.reset();
|
|
232
|
+
this.clientLimiter.reset();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Create a 429 Too Many Requests response with standard headers.
|
|
238
|
+
*
|
|
239
|
+
* @param result - Rate limit result
|
|
240
|
+
* @returns HTTP 429 response
|
|
241
|
+
*/
|
|
242
|
+
export function rateLimitedResponse(result: RateLimitResult): Response {
|
|
243
|
+
const retryAfter = Math.max(0, result.resetAt - Math.floor(Date.now() / 1000));
|
|
244
|
+
|
|
245
|
+
return new Response(
|
|
246
|
+
JSON.stringify({
|
|
247
|
+
error: "Rate limit exceeded",
|
|
248
|
+
code: "RATE_LIMITED",
|
|
249
|
+
details: {
|
|
250
|
+
limit: result.limit,
|
|
251
|
+
window: "1s",
|
|
252
|
+
retry_after: retryAfter,
|
|
253
|
+
},
|
|
254
|
+
}),
|
|
255
|
+
{
|
|
256
|
+
status: 429,
|
|
257
|
+
headers: {
|
|
258
|
+
"Content-Type": "application/json",
|
|
259
|
+
"X-RateLimit-Limit": String(result.limit),
|
|
260
|
+
"X-RateLimit-Remaining": "0",
|
|
261
|
+
"X-RateLimit-Reset": String(result.resetAt),
|
|
262
|
+
"Retry-After": String(retryAfter),
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Add rate limit headers to a successful response.
|
|
270
|
+
*
|
|
271
|
+
* @param response - Original response
|
|
272
|
+
* @param result - Rate limit result
|
|
273
|
+
* @returns Response with rate limit headers
|
|
274
|
+
*/
|
|
275
|
+
export function addRateLimitHeaders(response: Response, result: RateLimitResult): Response {
|
|
276
|
+
const newHeaders = new Headers(response.headers);
|
|
277
|
+
newHeaders.set("X-RateLimit-Limit", String(result.limit));
|
|
278
|
+
newHeaders.set("X-RateLimit-Remaining", String(result.remaining));
|
|
279
|
+
newHeaders.set("X-RateLimit-Reset", String(result.resetAt));
|
|
280
|
+
|
|
281
|
+
return new Response(response.body, {
|
|
282
|
+
status: response.status,
|
|
283
|
+
statusText: response.statusText,
|
|
284
|
+
headers: newHeaders,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdir,
|
|
3
|
+
writeFile,
|
|
4
|
+
readFile,
|
|
5
|
+
unlink,
|
|
6
|
+
chmod,
|
|
7
|
+
stat,
|
|
8
|
+
rename,
|
|
9
|
+
} from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
|
|
13
|
+
export interface ServerJsonData {
|
|
14
|
+
instance_id: string;
|
|
15
|
+
db_id: string;
|
|
16
|
+
port: number;
|
|
17
|
+
host: string;
|
|
18
|
+
auth_token: string;
|
|
19
|
+
pid: number;
|
|
20
|
+
started_at: string;
|
|
21
|
+
protocol_version: string;
|
|
22
|
+
schema_version?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function agentlipDir(workspaceRoot: string): string {
|
|
26
|
+
return join(workspaceRoot, ".agentlip");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function serverJsonPath(workspaceRoot: string): string {
|
|
30
|
+
return join(agentlipDir(workspaceRoot), "server.json");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function ensureMode0600(filePath: string): Promise<void> {
|
|
34
|
+
const mode = (await stat(filePath)).mode & 0o777;
|
|
35
|
+
if (mode === 0o600) return;
|
|
36
|
+
|
|
37
|
+
await chmod(filePath, 0o600);
|
|
38
|
+
const mode2 = (await stat(filePath)).mode & 0o777;
|
|
39
|
+
if (mode2 !== 0o600) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`Failed to set mode 0600 on ${filePath} (got ${mode2.toString(8)})`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Write `.agentlip/server.json` atomically.
|
|
48
|
+
*
|
|
49
|
+
* Requirements (AGENTLIP_PLAN.md §4.2 / Gate J):
|
|
50
|
+
* - atomic write (temp file in same dir + rename)
|
|
51
|
+
* - mode 0600 (owner read/write only)
|
|
52
|
+
* - never log auth_token
|
|
53
|
+
*/
|
|
54
|
+
export async function writeServerJson({
|
|
55
|
+
workspaceRoot,
|
|
56
|
+
data,
|
|
57
|
+
}: {
|
|
58
|
+
workspaceRoot: string;
|
|
59
|
+
data: ServerJsonData;
|
|
60
|
+
}): Promise<void> {
|
|
61
|
+
const dir = agentlipDir(workspaceRoot);
|
|
62
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
63
|
+
|
|
64
|
+
const finalPath = serverJsonPath(workspaceRoot);
|
|
65
|
+
const tmpPath = join(
|
|
66
|
+
dir,
|
|
67
|
+
`.server.json.tmp.${randomBytes(8).toString("hex")}`
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const content = JSON.stringify(data, null, 2);
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
// Write temp file first (same filesystem), then rename over final.
|
|
74
|
+
await writeFile(tmpPath, content, { mode: 0o600, flag: "wx" });
|
|
75
|
+
await ensureMode0600(tmpPath);
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
await rename(tmpPath, finalPath);
|
|
79
|
+
} catch (err: any) {
|
|
80
|
+
// Windows can fail to overwrite existing target; best-effort fallback.
|
|
81
|
+
if (err?.code === "EEXIST" || err?.code === "EPERM") {
|
|
82
|
+
try {
|
|
83
|
+
await unlink(finalPath);
|
|
84
|
+
} catch (unlinkErr: any) {
|
|
85
|
+
if (unlinkErr?.code !== "ENOENT") throw unlinkErr;
|
|
86
|
+
}
|
|
87
|
+
await rename(tmpPath, finalPath);
|
|
88
|
+
} else {
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Belt-and-suspenders: verify perms on final file.
|
|
94
|
+
await ensureMode0600(finalPath);
|
|
95
|
+
} finally {
|
|
96
|
+
// If rename failed mid-way, temp file may still exist.
|
|
97
|
+
try {
|
|
98
|
+
await unlink(tmpPath);
|
|
99
|
+
} catch (err: any) {
|
|
100
|
+
if (err?.code !== "ENOENT") throw err;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Read and parse `.agentlip/server.json`.
|
|
107
|
+
* Returns null if missing.
|
|
108
|
+
*/
|
|
109
|
+
export async function readServerJson({
|
|
110
|
+
workspaceRoot,
|
|
111
|
+
}: {
|
|
112
|
+
workspaceRoot: string;
|
|
113
|
+
}): Promise<ServerJsonData | null> {
|
|
114
|
+
try {
|
|
115
|
+
const content = await readFile(serverJsonPath(workspaceRoot), "utf-8");
|
|
116
|
+
return JSON.parse(content) as ServerJsonData;
|
|
117
|
+
} catch (err: any) {
|
|
118
|
+
if (err?.code === "ENOENT") return null;
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Remove `.agentlip/server.json`.
|
|
125
|
+
* No-op if missing.
|
|
126
|
+
*/
|
|
127
|
+
export async function removeServerJson({
|
|
128
|
+
workspaceRoot,
|
|
129
|
+
}: {
|
|
130
|
+
workspaceRoot: string;
|
|
131
|
+
}): Promise<void> {
|
|
132
|
+
try {
|
|
133
|
+
await unlink(serverJsonPath(workspaceRoot));
|
|
134
|
+
} catch (err: any) {
|
|
135
|
+
if (err?.code === "ENOENT") return;
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
}
|