aegis-bridge 2.5.2 → 2.5.4
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/dashboard/dist/assets/{index-DxAes2EQ.js → index-DIyuyrlO.js} +47 -47
- package/dashboard/dist/index.html +1 -1
- package/dist/auth.d.ts +3 -2
- package/dist/auth.js +42 -29
- package/dist/channels/manager.d.ts +8 -0
- package/dist/channels/manager.js +15 -0
- package/dist/channels/webhook.d.ts +2 -0
- package/dist/channels/webhook.js +57 -15
- package/dist/dashboard/assets/{index-DxAes2EQ.js → index-DIyuyrlO.js} +47 -47
- package/dist/dashboard/index.html +1 -1
- package/dist/events.d.ts +2 -0
- package/dist/events.js +16 -1
- package/dist/hook-settings.js +31 -11
- package/dist/hooks.js +2 -2
- package/dist/jsonl-watcher.js +9 -2
- package/dist/pipeline.js +10 -0
- package/dist/server.js +32 -7
- package/dist/session.d.ts +7 -1
- package/dist/session.js +93 -22
- package/dist/sse-writer.js +1 -1
- package/dist/ssrf.d.ts +19 -2
- package/dist/ssrf.js +38 -6
- package/dist/tmux.d.ts +8 -7
- package/dist/tmux.js +51 -36
- package/dist/transcript.js +12 -8
- package/dist/validation.d.ts +4 -0
- package/dist/validation.js +3 -2
- package/package.json +1 -1
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Aegis Dashboard</title>
|
|
7
7
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🛡️</text></svg>" />
|
|
8
|
-
<script type="module" crossorigin src="/dashboard/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/dashboard/assets/index-DIyuyrlO.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/dashboard/assets/index-B7DYf7vF.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body class="bg-[#0a0a0f] text-gray-200 antialiased">
|
package/dist/auth.d.ts
CHANGED
|
@@ -74,9 +74,10 @@ export declare class AuthManager {
|
|
|
74
74
|
/**
|
|
75
75
|
* Validate and consume a short-lived SSE token.
|
|
76
76
|
* Returns true if valid (and marks it as used), false otherwise.
|
|
77
|
-
*
|
|
77
|
+
* #826: Async with mutex to prevent concurrent validation/generation from
|
|
78
|
+
* racing on shared state (sseTokens, sseTokenCounts).
|
|
78
79
|
*/
|
|
79
|
-
validateSSEToken(token: string): boolean
|
|
80
|
+
validateSSEToken(token: string): Promise<boolean>;
|
|
80
81
|
/** Remove expired SSE tokens and recount per-key outstanding. */
|
|
81
82
|
private cleanExpiredSSETokens;
|
|
82
83
|
}
|
package/dist/auth.js
CHANGED
|
@@ -107,8 +107,6 @@ export class AuthManager {
|
|
|
107
107
|
if (!key) {
|
|
108
108
|
return { valid: false, keyId: null, rateLimited: false };
|
|
109
109
|
}
|
|
110
|
-
// Update last used
|
|
111
|
-
key.lastUsedAt = Date.now();
|
|
112
110
|
// Rate limiting
|
|
113
111
|
const bucket = this.rateLimits.get(key.id) || { count: 0, windowStart: Date.now() };
|
|
114
112
|
const now = Date.now();
|
|
@@ -125,6 +123,8 @@ export class AuthManager {
|
|
|
125
123
|
if (bucket.count > key.rateLimit) {
|
|
126
124
|
return { valid: true, keyId: key.id, rateLimited: true };
|
|
127
125
|
}
|
|
126
|
+
// Issue #841: Only update lastUsedAt for accepted requests, not rate-limited ones
|
|
127
|
+
key.lastUsedAt = Date.now();
|
|
128
128
|
return { valid: true, keyId: key.id, rateLimited: false };
|
|
129
129
|
}
|
|
130
130
|
/** Hash a key with SHA-256. */
|
|
@@ -199,37 +199,50 @@ export class AuthManager {
|
|
|
199
199
|
/**
|
|
200
200
|
* Validate and consume a short-lived SSE token.
|
|
201
201
|
* Returns true if valid (and marks it as used), false otherwise.
|
|
202
|
-
*
|
|
202
|
+
* #826: Async with mutex to prevent concurrent validation/generation from
|
|
203
|
+
* racing on shared state (sseTokens, sseTokenCounts).
|
|
203
204
|
*/
|
|
204
|
-
validateSSEToken(token) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const keyId = entry.keyId;
|
|
221
|
-
this.sseTokens.delete(token);
|
|
222
|
-
// #357: Decrement outstanding count so generateSSEToken doesn't over-limit
|
|
223
|
-
const count = this.sseTokenCounts.get(keyId);
|
|
224
|
-
if (count !== undefined) {
|
|
225
|
-
if (count <= 1) {
|
|
226
|
-
this.sseTokenCounts.delete(keyId);
|
|
205
|
+
async validateSSEToken(token) {
|
|
206
|
+
// Acquire mutex — chain onto the previous operation
|
|
207
|
+
let release = () => { };
|
|
208
|
+
const lock = new Promise((resolve) => { release = resolve; });
|
|
209
|
+
const previous = this.sseMutex;
|
|
210
|
+
this.sseMutex = lock;
|
|
211
|
+
// #573: catch prior rejection so it doesn't propagate and block subsequent callers
|
|
212
|
+
try {
|
|
213
|
+
await previous.catch(() => { });
|
|
214
|
+
const entry = this.sseTokens.get(token);
|
|
215
|
+
if (!entry)
|
|
216
|
+
return false;
|
|
217
|
+
// Already used
|
|
218
|
+
if (entry.used) {
|
|
219
|
+
this.sseTokens.delete(token);
|
|
220
|
+
return false;
|
|
227
221
|
}
|
|
228
|
-
|
|
229
|
-
|
|
222
|
+
// Expired
|
|
223
|
+
if (Date.now() > entry.expiresAt) {
|
|
224
|
+
this.sseTokens.delete(token);
|
|
225
|
+
return false;
|
|
230
226
|
}
|
|
227
|
+
// Valid — consume it
|
|
228
|
+
entry.used = true;
|
|
229
|
+
const keyId = entry.keyId;
|
|
230
|
+
this.sseTokens.delete(token);
|
|
231
|
+
// #357: Decrement outstanding count so generateSSEToken doesn't over-limit
|
|
232
|
+
const count = this.sseTokenCounts.get(keyId);
|
|
233
|
+
if (count !== undefined) {
|
|
234
|
+
if (count <= 1) {
|
|
235
|
+
this.sseTokenCounts.delete(keyId);
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
this.sseTokenCounts.set(keyId, count - 1);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
release();
|
|
231
245
|
}
|
|
232
|
-
return true;
|
|
233
246
|
}
|
|
234
247
|
/** Remove expired SSE tokens and recount per-key outstanding. */
|
|
235
248
|
cleanExpiredSSETokens() {
|
|
@@ -6,6 +6,14 @@
|
|
|
6
6
|
* one broken channel never kills the bridge.
|
|
7
7
|
*/
|
|
8
8
|
import type { Channel, SessionEventPayload, InboundHandler } from './types.js';
|
|
9
|
+
/**
|
|
10
|
+
* Thrown for retriable failures (5xx server errors, network timeouts).
|
|
11
|
+
* Only these increment the circuit breaker failure count.
|
|
12
|
+
* 4xx client errors are thrown as plain Error and do NOT trip the breaker.
|
|
13
|
+
*/
|
|
14
|
+
export declare class RetriableError extends Error {
|
|
15
|
+
constructor(message: string);
|
|
16
|
+
}
|
|
9
17
|
export declare class ChannelManager {
|
|
10
18
|
private channels;
|
|
11
19
|
private inboundHandler;
|
package/dist/channels/manager.js
CHANGED
|
@@ -5,6 +5,17 @@
|
|
|
5
5
|
* to every registered channel, swallowing per-channel errors so
|
|
6
6
|
* one broken channel never kills the bridge.
|
|
7
7
|
*/
|
|
8
|
+
/**
|
|
9
|
+
* Thrown for retriable failures (5xx server errors, network timeouts).
|
|
10
|
+
* Only these increment the circuit breaker failure count.
|
|
11
|
+
* 4xx client errors are thrown as plain Error and do NOT trip the breaker.
|
|
12
|
+
*/
|
|
13
|
+
export class RetriableError extends Error {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'RetriableError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
8
19
|
export class ChannelManager {
|
|
9
20
|
channels = [];
|
|
10
21
|
inboundHandler = null;
|
|
@@ -86,6 +97,10 @@ export class ChannelManager {
|
|
|
86
97
|
}
|
|
87
98
|
catch (e) {
|
|
88
99
|
console.error(`Channel ${ch.name} error on ${payload.event}:`, e);
|
|
100
|
+
// Only count retriable errors (5xx, network) toward circuit breaker.
|
|
101
|
+
// 4xx client errors are non-retriable — the server is healthy.
|
|
102
|
+
if (!(e instanceof RetriableError))
|
|
103
|
+
return;
|
|
89
104
|
const h = this.health.get(ch.name) ?? { failCount: 0, disabledUntil: 0 };
|
|
90
105
|
h.failCount++;
|
|
91
106
|
if (h.failCount >= ChannelManager.FAILURE_THRESHOLD) {
|
|
@@ -46,6 +46,8 @@ export declare class WebhookChannel implements Channel {
|
|
|
46
46
|
static readonly BASE_DELAY_MS = 1000;
|
|
47
47
|
/** Exponential backoff with jitter: delay * (0.5 + Math.random() * 0.5). */
|
|
48
48
|
static backoff(attempt: number): number;
|
|
49
|
+
/** Redact sensitive session metadata from webhook payloads. */
|
|
50
|
+
private static redactPayload;
|
|
49
51
|
private fire;
|
|
50
52
|
/** Issue #25: Deliver webhook with retry + exponential backoff. */
|
|
51
53
|
private deliverWithRetry;
|
package/dist/channels/webhook.js
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* Configure via AEGIS_WEBHOOKS (or legacy MANUS_WEBHOOKS) env var or config file.
|
|
6
6
|
*/
|
|
7
7
|
import { webhookEndpointSchema, getErrorMessage } from '../validation.js';
|
|
8
|
-
import { validateWebhookUrl } from '../ssrf.js';
|
|
8
|
+
import { validateWebhookUrl, resolveAndCheckIp, buildConnectionUrl } from '../ssrf.js';
|
|
9
9
|
import { redactSecretsFromText } from '../utils/redact-headers.js';
|
|
10
|
+
import { RetriableError } from './manager.js';
|
|
10
11
|
export class WebhookChannel {
|
|
11
12
|
name = 'webhook';
|
|
12
13
|
endpoints;
|
|
@@ -72,15 +73,25 @@ export class WebhookChannel {
|
|
|
72
73
|
const base = WebhookChannel.BASE_DELAY_MS * Math.pow(2, attempt - 1);
|
|
73
74
|
return base * (0.5 + Math.random() * 0.5);
|
|
74
75
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
/** Redact sensitive session metadata from webhook payloads. */
|
|
77
|
+
static redactPayload(payload) {
|
|
78
|
+
const { session, ...rest } = payload;
|
|
79
|
+
return {
|
|
80
|
+
...rest,
|
|
81
|
+
session: {
|
|
82
|
+
id: '[REDACTED]',
|
|
83
|
+
name: '[REDACTED]',
|
|
84
|
+
workDir: '[REDACTED]',
|
|
85
|
+
},
|
|
78
86
|
api: {
|
|
79
|
-
read:
|
|
80
|
-
send:
|
|
81
|
-
kill:
|
|
87
|
+
read: 'GET /sessions/[REDACTED]/read',
|
|
88
|
+
send: 'POST /sessions/[REDACTED]/send',
|
|
89
|
+
kill: 'DELETE /sessions/[REDACTED]',
|
|
82
90
|
},
|
|
83
|
-
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
async fire(payload) {
|
|
94
|
+
const body = JSON.stringify(WebhookChannel.redactPayload(payload));
|
|
84
95
|
const promises = this.endpoints.map(async (ep) => {
|
|
85
96
|
// Skip if endpoint filters and this event isn't in the list
|
|
86
97
|
if (ep.events && ep.events.length > 0 && !ep.events.includes(payload.event))
|
|
@@ -103,14 +114,40 @@ export class WebhookChannel {
|
|
|
103
114
|
/** Issue #25: Deliver webhook with retry + exponential backoff. */
|
|
104
115
|
async deliverWithRetry(ep, body, event, maxRetries = WebhookChannel.MAX_RETRIES) {
|
|
105
116
|
let lastError = '';
|
|
117
|
+
const hostname = new URL(ep.url).hostname;
|
|
118
|
+
const bareHost = hostname.replace(/^\[|\]$/g, '');
|
|
106
119
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
107
120
|
try {
|
|
108
|
-
|
|
121
|
+
// DNS rebinding protection: resolve and validate IP before each fetch.
|
|
122
|
+
// Skip for literal IPs (already validated at config time).
|
|
123
|
+
let fetchUrl = ep.url;
|
|
124
|
+
let headers = {
|
|
125
|
+
'Content-Type': 'application/json',
|
|
126
|
+
...(ep.headers || {}),
|
|
127
|
+
};
|
|
128
|
+
if (bareHost !== '127.0.0.1' && bareHost !== '::1' && bareHost !== 'localhost') {
|
|
129
|
+
const dnsResult = await resolveAndCheckIp(bareHost);
|
|
130
|
+
if (dnsResult.error) {
|
|
131
|
+
lastError = dnsResult.error;
|
|
132
|
+
if (attempt < maxRetries) {
|
|
133
|
+
const delay = WebhookChannel.backoff(attempt);
|
|
134
|
+
console.warn(`Webhook ${ep.url} DNS check failed for ${event} (attempt ${attempt}/${maxRetries}): ${lastError}, retrying in ${Math.round(delay)}ms`);
|
|
135
|
+
await new Promise(r => setTimeout(r, delay));
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
console.error(`Webhook ${ep.url} DNS check failed after ${maxRetries} attempts for ${event}: ${lastError}`);
|
|
139
|
+
this.addToDeadLetterQueue(ep.url, event, lastError, maxRetries);
|
|
140
|
+
throw new RetriableError(lastError);
|
|
141
|
+
}
|
|
142
|
+
if (dnsResult.resolvedIp) {
|
|
143
|
+
const { connectionUrl, hostHeader } = buildConnectionUrl(ep.url, dnsResult.resolvedIp);
|
|
144
|
+
fetchUrl = connectionUrl;
|
|
145
|
+
headers['Host'] = hostHeader;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const res = await fetch(fetchUrl, {
|
|
109
149
|
method: 'POST',
|
|
110
|
-
headers
|
|
111
|
-
'Content-Type': 'application/json',
|
|
112
|
-
...(ep.headers || {}),
|
|
113
|
-
},
|
|
150
|
+
headers,
|
|
114
151
|
body,
|
|
115
152
|
signal: AbortSignal.timeout(ep.timeoutMs || 5000),
|
|
116
153
|
});
|
|
@@ -141,8 +178,13 @@ export class WebhookChannel {
|
|
|
141
178
|
console.error(`Webhook ${ep.url} failed after ${maxRetries} attempts for ${event}: ${lastError}`);
|
|
142
179
|
this.addToDeadLetterQueue(ep.url, event, lastError, maxRetries);
|
|
143
180
|
}
|
|
144
|
-
// Final failure
|
|
145
|
-
|
|
181
|
+
// Final failure — throw so fire() can aggregate.
|
|
182
|
+
// Use RetriableError for 5xx/network (circuit breaker counts these),
|
|
183
|
+
// plain Error for 4xx (circuit breaker ignores these).
|
|
184
|
+
if (lastError.startsWith('HTTP ') && parseInt(lastError.slice(5)) < 500) {
|
|
185
|
+
throw new Error(lastError);
|
|
186
|
+
}
|
|
187
|
+
throw new RetriableError(lastError);
|
|
146
188
|
}
|
|
147
189
|
}
|
|
148
190
|
/** Issue #89 L14: Add a failed delivery to the dead letter queue. */
|