aegis-bridge 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/LICENSE +21 -0
- package/README.md +404 -0
- package/dashboard/dist/assets/index-BoZwGLAx.css +32 -0
- package/dashboard/dist/assets/index-C61BkKH-.js +312 -0
- package/dashboard/dist/assets/index-C61BkKH-.js.map +1 -0
- package/dashboard/dist/index.html +14 -0
- package/dist/api-contracts.d.ts +229 -0
- package/dist/api-contracts.js +7 -0
- package/dist/api-contracts.typecheck.d.ts +14 -0
- package/dist/api-contracts.typecheck.js +1 -0
- package/dist/api-error-envelope.d.ts +15 -0
- package/dist/api-error-envelope.js +80 -0
- package/dist/auth.d.ts +87 -0
- package/dist/auth.js +276 -0
- package/dist/channels/index.d.ts +8 -0
- package/dist/channels/index.js +8 -0
- package/dist/channels/manager.d.ts +47 -0
- package/dist/channels/manager.js +115 -0
- package/dist/channels/telegram-style.d.ts +118 -0
- package/dist/channels/telegram-style.js +202 -0
- package/dist/channels/telegram.d.ts +91 -0
- package/dist/channels/telegram.js +1518 -0
- package/dist/channels/types.d.ts +77 -0
- package/dist/channels/types.js +8 -0
- package/dist/channels/webhook.d.ts +60 -0
- package/dist/channels/webhook.js +216 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +252 -0
- package/dist/config.d.ts +90 -0
- package/dist/config.js +214 -0
- package/dist/consensus.d.ts +16 -0
- package/dist/consensus.js +19 -0
- package/dist/continuation-pointer.d.ts +11 -0
- package/dist/continuation-pointer.js +65 -0
- package/dist/diagnostics.d.ts +27 -0
- package/dist/diagnostics.js +95 -0
- package/dist/error-categories.d.ts +39 -0
- package/dist/error-categories.js +73 -0
- package/dist/events.d.ts +133 -0
- package/dist/events.js +389 -0
- package/dist/fault-injection.d.ts +29 -0
- package/dist/fault-injection.js +115 -0
- package/dist/file-utils.d.ts +2 -0
- package/dist/file-utils.js +37 -0
- package/dist/handshake.d.ts +60 -0
- package/dist/handshake.js +124 -0
- package/dist/hook-settings.d.ts +80 -0
- package/dist/hook-settings.js +272 -0
- package/dist/hook.d.ts +19 -0
- package/dist/hook.js +231 -0
- package/dist/hooks.d.ts +32 -0
- package/dist/hooks.js +364 -0
- package/dist/jsonl-watcher.d.ts +59 -0
- package/dist/jsonl-watcher.js +166 -0
- package/dist/logger.d.ts +35 -0
- package/dist/logger.js +65 -0
- package/dist/mcp-server.d.ts +123 -0
- package/dist/mcp-server.js +869 -0
- package/dist/memory-bridge.d.ts +27 -0
- package/dist/memory-bridge.js +137 -0
- package/dist/memory-routes.d.ts +3 -0
- package/dist/memory-routes.js +100 -0
- package/dist/metrics.d.ts +126 -0
- package/dist/metrics.js +286 -0
- package/dist/model-router.d.ts +53 -0
- package/dist/model-router.js +150 -0
- package/dist/monitor.d.ts +103 -0
- package/dist/monitor.js +820 -0
- package/dist/path-utils.d.ts +11 -0
- package/dist/path-utils.js +21 -0
- package/dist/permission-evaluator.d.ts +10 -0
- package/dist/permission-evaluator.js +48 -0
- package/dist/permission-guard.d.ts +51 -0
- package/dist/permission-guard.js +196 -0
- package/dist/permission-request-manager.d.ts +12 -0
- package/dist/permission-request-manager.js +36 -0
- package/dist/permission-routes.d.ts +7 -0
- package/dist/permission-routes.js +28 -0
- package/dist/pipeline.d.ts +97 -0
- package/dist/pipeline.js +291 -0
- package/dist/process-utils.d.ts +4 -0
- package/dist/process-utils.js +73 -0
- package/dist/question-manager.d.ts +54 -0
- package/dist/question-manager.js +80 -0
- package/dist/retry.d.ts +11 -0
- package/dist/retry.js +34 -0
- package/dist/safe-json.d.ts +12 -0
- package/dist/safe-json.js +22 -0
- package/dist/screenshot.d.ts +28 -0
- package/dist/screenshot.js +60 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +1973 -0
- package/dist/session-cleanup.d.ts +18 -0
- package/dist/session-cleanup.js +11 -0
- package/dist/session.d.ts +379 -0
- package/dist/session.js +1568 -0
- package/dist/shutdown-utils.d.ts +5 -0
- package/dist/shutdown-utils.js +24 -0
- package/dist/signal-cleanup-helper.d.ts +48 -0
- package/dist/signal-cleanup-helper.js +117 -0
- package/dist/sse-limiter.d.ts +47 -0
- package/dist/sse-limiter.js +61 -0
- package/dist/sse-writer.d.ts +31 -0
- package/dist/sse-writer.js +94 -0
- package/dist/ssrf.d.ts +102 -0
- package/dist/ssrf.js +267 -0
- package/dist/startup.d.ts +6 -0
- package/dist/startup.js +162 -0
- package/dist/suppress.d.ts +33 -0
- package/dist/suppress.js +79 -0
- package/dist/swarm-monitor.d.ts +117 -0
- package/dist/swarm-monitor.js +300 -0
- package/dist/template-store.d.ts +45 -0
- package/dist/template-store.js +142 -0
- package/dist/terminal-parser.d.ts +16 -0
- package/dist/terminal-parser.js +346 -0
- package/dist/tmux-capture-cache.d.ts +18 -0
- package/dist/tmux-capture-cache.js +34 -0
- package/dist/tmux.d.ts +183 -0
- package/dist/tmux.js +906 -0
- package/dist/tool-registry.d.ts +40 -0
- package/dist/tool-registry.js +83 -0
- package/dist/transcript.d.ts +63 -0
- package/dist/transcript.js +284 -0
- package/dist/utils/circular-buffer.d.ts +11 -0
- package/dist/utils/circular-buffer.js +37 -0
- package/dist/utils/redact-headers.d.ts +13 -0
- package/dist/utils/redact-headers.js +54 -0
- package/dist/validation.d.ts +406 -0
- package/dist/validation.js +415 -0
- package/dist/verification.d.ts +2 -0
- package/dist/verification.js +72 -0
- package/dist/worktree-lookup.d.ts +24 -0
- package/dist/worktree-lookup.js +71 -0
- package/dist/ws-terminal.d.ts +32 -0
- package/dist/ws-terminal.js +348 -0
- package/package.json +83 -0
package/dist/ssrf.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ssrf.ts — Shared SSRF (Server-Side Request Forgery) prevention utilities.
|
|
3
|
+
*
|
|
4
|
+
* Validates URLs by checking scheme, hostname, and DNS resolution against
|
|
5
|
+
* private/internal IP ranges. Used by webhook channel and screenshot endpoint.
|
|
6
|
+
*/
|
|
7
|
+
import dns from 'node:dns/promises';
|
|
8
|
+
import net from 'node:net';
|
|
9
|
+
/**
|
|
10
|
+
* Check if an IP address (v4 or v6) is private/internal.
|
|
11
|
+
*
|
|
12
|
+
* Rejects:
|
|
13
|
+
* - RFC 1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
|
|
14
|
+
* - Loopback: 127.0.0.0/8, ::1
|
|
15
|
+
* - Link-local: 169.254.0.0/16, fe80::/10
|
|
16
|
+
* - Current network: 0.0.0.0/8
|
|
17
|
+
* - Unspecified: ::
|
|
18
|
+
* - IPv6 unique-local: fc00::/7
|
|
19
|
+
* - IPv4-mapped IPv6: ::ffff:x.x.x.x (RFC 4291)
|
|
20
|
+
* - IPv4-compatible IPv6: ::x.x.x.x (deprecated)
|
|
21
|
+
* - CGNAT: 100.64.0.0/10 (RFC 6598)
|
|
22
|
+
* - Broadcast: 255.255.255.255
|
|
23
|
+
* - Multicast: 224.0.0.0/4 (RFC 5771)
|
|
24
|
+
* - Documentation: 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 (RFC 5737)
|
|
25
|
+
* - Benchmarking: 198.18.0.0/15 (RFC 2544)
|
|
26
|
+
*/
|
|
27
|
+
export function isPrivateIP(ip) {
|
|
28
|
+
// IPv4
|
|
29
|
+
if (net.isIPv4(ip)) {
|
|
30
|
+
const parts = ip.split('.').map(Number);
|
|
31
|
+
const [a, b, c] = parts;
|
|
32
|
+
// 0.0.0.0/8
|
|
33
|
+
if (a === 0)
|
|
34
|
+
return true;
|
|
35
|
+
// 10.0.0.0/8
|
|
36
|
+
if (a === 10)
|
|
37
|
+
return true;
|
|
38
|
+
// 127.0.0.0/8
|
|
39
|
+
if (a === 127)
|
|
40
|
+
return true;
|
|
41
|
+
// 169.254.0.0/16
|
|
42
|
+
if (a === 169 && b === 254)
|
|
43
|
+
return true;
|
|
44
|
+
// 172.16.0.0/12
|
|
45
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
46
|
+
return true;
|
|
47
|
+
// 192.168.0.0/16
|
|
48
|
+
if (a === 192 && b === 168)
|
|
49
|
+
return true;
|
|
50
|
+
// 100.64.0.0/10 (CGNAT)
|
|
51
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
52
|
+
return true;
|
|
53
|
+
// 255.255.255.255 (broadcast)
|
|
54
|
+
if (a === 255 && b === 255 && c === 255 && parts[3] === 255)
|
|
55
|
+
return true;
|
|
56
|
+
// 224.0.0.0/4 (multicast) — 224.0.0.0 to 239.255.255.255
|
|
57
|
+
if (a >= 224 && a <= 239)
|
|
58
|
+
return true;
|
|
59
|
+
// 192.0.2.0/24 (documentation, RFC 5737)
|
|
60
|
+
if (a === 192 && b === 0 && c === 2)
|
|
61
|
+
return true;
|
|
62
|
+
// 198.51.100.0/24 (documentation, RFC 5737)
|
|
63
|
+
if (a === 198 && b === 51 && c === 100)
|
|
64
|
+
return true;
|
|
65
|
+
// 203.0.113.0/24 (documentation, RFC 5737)
|
|
66
|
+
if (a === 203 && b === 0 && c === 113)
|
|
67
|
+
return true;
|
|
68
|
+
// 198.18.0.0/15 (benchmarking, RFC 2544) — 198.18.0.0 to 198.19.255.255
|
|
69
|
+
if (a === 198 && b >= 18 && b <= 19)
|
|
70
|
+
return true;
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
// IPv6
|
|
74
|
+
const lower = ip.toLowerCase();
|
|
75
|
+
// IPv4-mapped IPv6 (::ffff:x.x.x.x, RFC 4291 §2.5.5)
|
|
76
|
+
// Handles dotted-quad form (::ffff:127.0.0.1) and hex form (::ffff:7f00:1).
|
|
77
|
+
// Also handles IPv4-compatible IPv6 (::x.x.x.x, deprecated).
|
|
78
|
+
if (lower.startsWith('::ffff:')) {
|
|
79
|
+
const suffix = lower.slice(7);
|
|
80
|
+
// Dotted quad form: ::ffff:127.0.0.1
|
|
81
|
+
if (net.isIPv4(suffix)) {
|
|
82
|
+
return isPrivateIP(suffix);
|
|
83
|
+
}
|
|
84
|
+
// Hex form: ::ffff:7f00:1 → parse last 32 bits as IPv4
|
|
85
|
+
const hexGroups = suffix.split(':').map(h => parseInt(h, 16));
|
|
86
|
+
if (hexGroups.length === 2 && hexGroups.every(n => !isNaN(n))) {
|
|
87
|
+
const embedded = `${(hexGroups[0] >> 8) & 0xff}.${hexGroups[0] & 0xff}.${(hexGroups[1] >> 8) & 0xff}.${hexGroups[1] & 0xff}`;
|
|
88
|
+
if (net.isIPv4(embedded)) {
|
|
89
|
+
return isPrivateIP(embedded);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// IPv4-compatible IPv6 (::x.x.x.x, deprecated RFC 4291 §2.5.5)
|
|
94
|
+
const afterPrefix = lower.startsWith('::') && lower !== '::' && lower !== '::1' ? lower.slice(2) : null;
|
|
95
|
+
if (afterPrefix !== null && net.isIPv4(afterPrefix)) {
|
|
96
|
+
return isPrivateIP(afterPrefix);
|
|
97
|
+
}
|
|
98
|
+
// ::1 (loopback)
|
|
99
|
+
if (lower === '::1')
|
|
100
|
+
return true;
|
|
101
|
+
// :: (unspecified)
|
|
102
|
+
if (lower === '::')
|
|
103
|
+
return true;
|
|
104
|
+
// fe80::/10 (link-local)
|
|
105
|
+
if (lower.startsWith('fe8') || lower.startsWith('fe9') || lower.startsWith('fea') || lower.startsWith('feb'))
|
|
106
|
+
return true;
|
|
107
|
+
// fc00::/7 (unique-local) — includes fc and fd prefixes
|
|
108
|
+
if (lower.startsWith('fc') || lower.startsWith('fd'))
|
|
109
|
+
return true;
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Validate a URL for webhook configuration.
|
|
114
|
+
*
|
|
115
|
+
* Checks:
|
|
116
|
+
* 1. Valid URL format
|
|
117
|
+
* 2. HTTPS scheme required for external hosts
|
|
118
|
+
* 3. HTTP allowed only for localhost / 127.0.0.1
|
|
119
|
+
* 4. Rejects private/internal IP addresses (except 127.0.0.1 in dev mode)
|
|
120
|
+
* 5. Rejects *.local hostnames
|
|
121
|
+
*
|
|
122
|
+
* Returns null if valid, or an error string if invalid.
|
|
123
|
+
*/
|
|
124
|
+
export function validateWebhookUrl(rawUrl) {
|
|
125
|
+
let parsed;
|
|
126
|
+
try {
|
|
127
|
+
parsed = new URL(rawUrl);
|
|
128
|
+
}
|
|
129
|
+
catch { /* malformed URL string */
|
|
130
|
+
return 'Invalid URL';
|
|
131
|
+
}
|
|
132
|
+
const hostname = parsed.hostname;
|
|
133
|
+
// Strip brackets from IPv6 URLs: [::1] → ::1
|
|
134
|
+
const bareHost = hostname.replace(/^\[|\]$/g, '');
|
|
135
|
+
// Scheme check — must be HTTPS, or HTTP only for local dev
|
|
136
|
+
const isLocalDev = bareHost === '127.0.0.1' || bareHost === '::1' || bareHost === 'localhost';
|
|
137
|
+
if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && isLocalDev)) {
|
|
138
|
+
if (parsed.protocol === 'http:') {
|
|
139
|
+
return 'Only HTTPS URLs are allowed for external hosts';
|
|
140
|
+
}
|
|
141
|
+
return 'Only HTTPS URLs are allowed';
|
|
142
|
+
}
|
|
143
|
+
// Reject *.local hostnames (but allow literal localhost for dev)
|
|
144
|
+
if (bareHost.endsWith('.local')) {
|
|
145
|
+
return 'Localhost URLs are not allowed';
|
|
146
|
+
}
|
|
147
|
+
// Reject private/internal IPs (except 127.0.0.1/::1 which are allowed for dev over HTTP)
|
|
148
|
+
if (net.isIP(bareHost) && isPrivateIP(bareHost) && !isLocalDev) {
|
|
149
|
+
return 'Private/internal IP addresses are not allowed';
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
/** Default DNS lookup using node:dns/promises with { all: true } to resolve all addresses. */
|
|
154
|
+
const defaultLookup = (hostname) => dns.lookup(hostname, { all: true });
|
|
155
|
+
/**
|
|
156
|
+
* Resolve a hostname via DNS and check if the resulting IP is private/internal.
|
|
157
|
+
*
|
|
158
|
+
* For literal IP addresses, checks directly without DNS resolution.
|
|
159
|
+
* Returns a DnsCheckResult with error string if unsafe, or the resolved IP on success.
|
|
160
|
+
*
|
|
161
|
+
* The resolved IP should be used with Chromium --host-resolver-rules to pin the
|
|
162
|
+
* address and prevent DNS rebinding (TOCTOU) attacks between validation and page.goto().
|
|
163
|
+
*
|
|
164
|
+
* @param hostname - Hostname or literal IP to check
|
|
165
|
+
* @param lookupFn - Optional DNS lookup function (for testing)
|
|
166
|
+
*/
|
|
167
|
+
export async function resolveAndCheckIp(hostname, lookupFn = defaultLookup) {
|
|
168
|
+
// Literal IP — check directly
|
|
169
|
+
if (net.isIP(hostname)) {
|
|
170
|
+
if (isPrivateIP(hostname)) {
|
|
171
|
+
return { error: `DNS resolution points to a private/internal IP: ${hostname}`, resolvedIp: null };
|
|
172
|
+
}
|
|
173
|
+
return { error: null, resolvedIp: hostname };
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
const results = await lookupFn(hostname);
|
|
177
|
+
if (results.length === 0) {
|
|
178
|
+
return { error: `DNS resolution returned no addresses for ${hostname}`, resolvedIp: null };
|
|
179
|
+
}
|
|
180
|
+
// Check ALL resolved addresses — reject if ANY is private/internal.
|
|
181
|
+
// An attacker can configure DNS to return both public and private IPs;
|
|
182
|
+
// the HTTP client may connect to any of them.
|
|
183
|
+
for (const result of results) {
|
|
184
|
+
if (isPrivateIP(result.address)) {
|
|
185
|
+
return { error: `DNS resolution points to a private/internal IP: ${result.address}`, resolvedIp: null };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// All addresses safe — return first for TOCTOU-safe pinning via --host-resolver-rules.
|
|
189
|
+
return { error: null, resolvedIp: results[0].address };
|
|
190
|
+
}
|
|
191
|
+
catch { /* DNS lookup failed — treat as unsafe */
|
|
192
|
+
return { error: `DNS resolution failed for ${hostname}`, resolvedIp: null };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Build Chromium --host-resolver-rules argument to pin a hostname to a specific IP.
|
|
197
|
+
*
|
|
198
|
+
* This prevents DNS rebinding (TOCTOU) attacks between SSRF validation and page.goto()
|
|
199
|
+
* by ensuring Chromium resolves the hostname to the same IP that was validated.
|
|
200
|
+
*
|
|
201
|
+
* @param hostname - The original hostname from the URL
|
|
202
|
+
* @param resolvedIp - The IP address that was validated as safe
|
|
203
|
+
* @returns The --host-resolver-rules argument string
|
|
204
|
+
*/
|
|
205
|
+
export function buildHostResolverRule(hostname, resolvedIp) {
|
|
206
|
+
return `MAP ${hostname} ${resolvedIp}`;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Build a connection URL where the hostname is replaced by the resolved IP address.
|
|
210
|
+
*
|
|
211
|
+
* This prevents DNS rebinding (TOCTOU) attacks in HTTP clients (like Node fetch)
|
|
212
|
+
* by ensuring the connection goes to the validated IP, not a re-resolved address.
|
|
213
|
+
* The original hostname is returned separately so callers can set the Host header.
|
|
214
|
+
*
|
|
215
|
+
* For IPv6 addresses, wraps the IP in brackets per RFC 2732.
|
|
216
|
+
*
|
|
217
|
+
* @param originalUrl - The original URL (e.g. "https://example.com/path")
|
|
218
|
+
* @param resolvedIp - The validated IP address to connect to
|
|
219
|
+
* @returns Object with the connection URL and the original hostname for Host header
|
|
220
|
+
*/
|
|
221
|
+
export function buildConnectionUrl(originalUrl, resolvedIp) {
|
|
222
|
+
const parsed = new URL(originalUrl);
|
|
223
|
+
const originalHost = parsed.host; // includes port if non-default
|
|
224
|
+
// IPv6 literals need brackets in URLs
|
|
225
|
+
const ipForUrl = parsed.hostname.startsWith('[') || resolvedIp.includes(':')
|
|
226
|
+
? `[${resolvedIp}]`
|
|
227
|
+
: resolvedIp;
|
|
228
|
+
parsed.hostname = ipForUrl;
|
|
229
|
+
return { connectionUrl: parsed.toString(), hostHeader: originalHost };
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Validate a URL for the screenshot endpoint to prevent SSRF attacks.
|
|
233
|
+
*
|
|
234
|
+
* Checks:
|
|
235
|
+
* 1. Valid URL format
|
|
236
|
+
* 2. http: or https: scheme only
|
|
237
|
+
* 3. Rejects private/internal IP addresses (literal)
|
|
238
|
+
* 4. Rejects localhost / *.local hostnames
|
|
239
|
+
*
|
|
240
|
+
* For full DNS-resolution protection, call resolveAndCheckIp() separately.
|
|
241
|
+
*
|
|
242
|
+
* Returns null if valid, or an error string if invalid.
|
|
243
|
+
*/
|
|
244
|
+
export function validateScreenshotUrl(rawUrl) {
|
|
245
|
+
let parsed;
|
|
246
|
+
try {
|
|
247
|
+
parsed = new URL(rawUrl);
|
|
248
|
+
}
|
|
249
|
+
catch { /* malformed URL string */
|
|
250
|
+
return 'Invalid URL';
|
|
251
|
+
}
|
|
252
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
253
|
+
return 'Only http and https URLs are allowed';
|
|
254
|
+
}
|
|
255
|
+
const hostname = parsed.hostname;
|
|
256
|
+
// Strip brackets from IPv6 URLs: [::1] → ::1
|
|
257
|
+
const bareHost = hostname.replace(/^\[|\]$/g, '');
|
|
258
|
+
// Reject localhost / *.local hostnames
|
|
259
|
+
if (bareHost === 'localhost' || bareHost.endsWith('.local')) {
|
|
260
|
+
return 'Localhost URLs are not allowed';
|
|
261
|
+
}
|
|
262
|
+
// Reject private/internal IPs
|
|
263
|
+
if (net.isIP(bareHost) && isPrivateIP(bareHost)) {
|
|
264
|
+
return 'Private/internal IP addresses are not allowed';
|
|
265
|
+
}
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import Fastify from 'fastify';
|
|
2
|
+
export declare function writePidFile(stateDir: string): string;
|
|
3
|
+
export declare function removePidFile(pidFilePath: string): void;
|
|
4
|
+
/** Read parent PID with cross-platform fallback. */
|
|
5
|
+
export declare function readPpid(pid: number): Promise<number>;
|
|
6
|
+
export declare function listenWithRetry(app: ReturnType<typeof Fastify>, port: number, host: string, stateDir: string, maxRetries?: number): Promise<void>;
|
package/dist/startup.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { writeFileSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { findPidOnPort, readParentPid } from './process-utils.js';
|
|
5
|
+
export function writePidFile(stateDir) {
|
|
6
|
+
try {
|
|
7
|
+
const pidFilePath = path.join(stateDir, 'aegis.pid');
|
|
8
|
+
writeFileSync(pidFilePath, String(process.pid));
|
|
9
|
+
return pidFilePath;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return '';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function removePidFile(pidFilePath) {
|
|
16
|
+
try {
|
|
17
|
+
if (pidFilePath)
|
|
18
|
+
unlinkSync(pidFilePath);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// non-critical
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async function readPidFile(stateDir) {
|
|
25
|
+
try {
|
|
26
|
+
const p = path.join(stateDir, 'aegis.pid');
|
|
27
|
+
const content = (await fs.readFile(p, 'utf-8')).trim();
|
|
28
|
+
const pid = parseInt(content, 10);
|
|
29
|
+
return Number.isNaN(pid) ? null : pid;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function pidExists(pid) {
|
|
36
|
+
try {
|
|
37
|
+
process.kill(pid, 0);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Read parent PID with cross-platform fallback. */
|
|
45
|
+
export async function readPpid(pid) {
|
|
46
|
+
const parent = await readParentPid(pid);
|
|
47
|
+
if (parent === null) {
|
|
48
|
+
throw new Error(`no parent PID available for process ${pid}`);
|
|
49
|
+
}
|
|
50
|
+
return parent;
|
|
51
|
+
}
|
|
52
|
+
async function isAncestorPid(pid) {
|
|
53
|
+
try {
|
|
54
|
+
let current = process.ppid;
|
|
55
|
+
for (let depth = 0; depth < 10 && current > 1; depth++) {
|
|
56
|
+
if (current === pid)
|
|
57
|
+
return true;
|
|
58
|
+
try {
|
|
59
|
+
current = await readPpid(current);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// ignore
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
async function waitForPortRelease(port, maxWaitMs = 5000) {
|
|
72
|
+
const net = await import('node:net');
|
|
73
|
+
const start = Date.now();
|
|
74
|
+
let delay = 200;
|
|
75
|
+
while (Date.now() - start < maxWaitMs) {
|
|
76
|
+
try {
|
|
77
|
+
await new Promise((resolve, reject) => {
|
|
78
|
+
const sock = net.createServer();
|
|
79
|
+
sock.once('error', reject);
|
|
80
|
+
sock.listen(port, '127.0.0.1', () => {
|
|
81
|
+
sock.close();
|
|
82
|
+
reject(new Error('port free'));
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
if (err instanceof Error && err.message === 'port free')
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
91
|
+
delay = Math.min(delay * 1.5, 1000);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function killStalePortHolder(port, stateDir) {
|
|
95
|
+
await new Promise(resolve => setTimeout(resolve, 100 + Math.random() * 400));
|
|
96
|
+
try {
|
|
97
|
+
const pids = await findPidOnPort(port);
|
|
98
|
+
if (pids.length === 0)
|
|
99
|
+
return false;
|
|
100
|
+
let killed = false;
|
|
101
|
+
for (const pid of pids) {
|
|
102
|
+
if (pid === process.pid)
|
|
103
|
+
continue;
|
|
104
|
+
if (await isAncestorPid(pid)) {
|
|
105
|
+
console.warn(`EADDRINUSE recovery: skipping ancestor PID ${pid} on port ${port}`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const pidFilePid = await readPidFile(stateDir);
|
|
109
|
+
if (pidFilePid !== null && pid === pidFilePid && pid !== process.pid) {
|
|
110
|
+
console.warn(`EADDRINUSE recovery: skipping peer Aegis PID ${pid} (PID file match) on port ${port}`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (!pidExists(pid))
|
|
114
|
+
continue;
|
|
115
|
+
console.warn(`EADDRINUSE recovery: killing stale process PID ${pid} on port ${port}`);
|
|
116
|
+
try {
|
|
117
|
+
process.kill(pid, 'SIGTERM');
|
|
118
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
119
|
+
if (!pidExists(pid)) {
|
|
120
|
+
killed = true;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// process may have exited between checks
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
process.kill(pid, 'SIGKILL');
|
|
129
|
+
killed = true;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// already dead
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (killed) {
|
|
136
|
+
await waitForPortRelease(port);
|
|
137
|
+
}
|
|
138
|
+
return killed;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
export async function listenWithRetry(app, port, host, stateDir, maxRetries = 1) {
|
|
145
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
146
|
+
try {
|
|
147
|
+
await app.listen({ port, host });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
if (!(err instanceof Error && 'code' in err && err.code === 'EADDRINUSE') || attempt >= maxRetries) {
|
|
152
|
+
throw err;
|
|
153
|
+
}
|
|
154
|
+
console.error(`EADDRINUSE on port ${port} - attempting recovery (attempt ${attempt + 1}/${maxRetries})`);
|
|
155
|
+
const killed = await killStalePortHolder(port, stateDir);
|
|
156
|
+
if (!killed) {
|
|
157
|
+
console.error(`EADDRINUSE recovery failed: no stale process found on port ${port}`);
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* suppress.ts — Explicit suppression predicate for expected runtime races.
|
|
3
|
+
*
|
|
4
|
+
* Issue #882: Replaces silent empty catches with a documented, testable
|
|
5
|
+
* suppression contract. Suppressible errors (expected races, killed sessions,
|
|
6
|
+
* missing tmux panes) are forwarded as rate-limited diagnostics events.
|
|
7
|
+
* Non-suppressible errors are surfaced at warn level.
|
|
8
|
+
*/
|
|
9
|
+
/** Contexts where suppressible races may occur. */
|
|
10
|
+
export type SuppressContext = 'monitor.checkSession' | 'monitor.checkDeadSessions.killSession' | 'monitor.checkStopSignals.parseEntry' | 'session.cleanup' | 'tmux.capturePane' | string;
|
|
11
|
+
/** Exported for tests — clears all rate-limit counters. */
|
|
12
|
+
export declare function _resetSuppressRateLimit(): void;
|
|
13
|
+
/**
|
|
14
|
+
* Returns true if the error is an expected transient race that
|
|
15
|
+
* should be swallowed without surfacing a warning.
|
|
16
|
+
*
|
|
17
|
+
* Categories of suppressible errors:
|
|
18
|
+
* - Session killed while in-flight (SESSION_NOT_FOUND-class messages)
|
|
19
|
+
* - File not found (ENOENT) — session JSONL removed after kill
|
|
20
|
+
* - Tmux pane/window gone — dead-session race
|
|
21
|
+
* - SyntaxError from truncated JSONL reads during rotation
|
|
22
|
+
*/
|
|
23
|
+
export declare function isSuppressible(error: unknown, _context: SuppressContext): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Handle a caught error using the explicit suppression policy.
|
|
26
|
+
*
|
|
27
|
+
* - Suppressible errors: console.debug with rate limiting (max 10/min per context).
|
|
28
|
+
* - Non-suppressible errors: console.warn — always visible.
|
|
29
|
+
*
|
|
30
|
+
* Does NOT rethrow. Call isSuppressible() directly if you need rethrow control.
|
|
31
|
+
*/
|
|
32
|
+
export declare function suppressedCatch(error: unknown, context: SuppressContext): void;
|
|
33
|
+
export declare function _errorMessage(error: unknown): string;
|
package/dist/suppress.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* suppress.ts — Explicit suppression predicate for expected runtime races.
|
|
3
|
+
*
|
|
4
|
+
* Issue #882: Replaces silent empty catches with a documented, testable
|
|
5
|
+
* suppression contract. Suppressible errors (expected races, killed sessions,
|
|
6
|
+
* missing tmux panes) are forwarded as rate-limited diagnostics events.
|
|
7
|
+
* Non-suppressible errors are surfaced at warn level.
|
|
8
|
+
*/
|
|
9
|
+
/** Rate-limit state: max N suppressed debug events per context per minute. */
|
|
10
|
+
const suppressRateLimit = new Map();
|
|
11
|
+
/** Exported for tests — clears all rate-limit counters. */
|
|
12
|
+
export function _resetSuppressRateLimit() {
|
|
13
|
+
suppressRateLimit.clear();
|
|
14
|
+
}
|
|
15
|
+
const SUPPRESS_MAX_PER_MINUTE = 10;
|
|
16
|
+
/**
|
|
17
|
+
* Returns true if the error is an expected transient race that
|
|
18
|
+
* should be swallowed without surfacing a warning.
|
|
19
|
+
*
|
|
20
|
+
* Categories of suppressible errors:
|
|
21
|
+
* - Session killed while in-flight (SESSION_NOT_FOUND-class messages)
|
|
22
|
+
* - File not found (ENOENT) — session JSONL removed after kill
|
|
23
|
+
* - Tmux pane/window gone — dead-session race
|
|
24
|
+
* - SyntaxError from truncated JSONL reads during rotation
|
|
25
|
+
*/
|
|
26
|
+
export function isSuppressible(error, _context) {
|
|
27
|
+
if (error instanceof SyntaxError)
|
|
28
|
+
return true;
|
|
29
|
+
if (error instanceof Error) {
|
|
30
|
+
const code = error.code;
|
|
31
|
+
if (code === 'ENOENT')
|
|
32
|
+
return true;
|
|
33
|
+
const msg = error.message.toLowerCase();
|
|
34
|
+
if (msg.includes('session not found'))
|
|
35
|
+
return true;
|
|
36
|
+
if (msg.includes('no session with id'))
|
|
37
|
+
return true;
|
|
38
|
+
if (msg.includes('no such window'))
|
|
39
|
+
return true;
|
|
40
|
+
if (msg.includes('no such pane'))
|
|
41
|
+
return true;
|
|
42
|
+
if (msg.includes('no such session'))
|
|
43
|
+
return true;
|
|
44
|
+
if (msg.includes("can't find window"))
|
|
45
|
+
return true;
|
|
46
|
+
if (msg.includes('window already dead'))
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Handle a caught error using the explicit suppression policy.
|
|
53
|
+
*
|
|
54
|
+
* - Suppressible errors: console.debug with rate limiting (max 10/min per context).
|
|
55
|
+
* - Non-suppressible errors: console.warn — always visible.
|
|
56
|
+
*
|
|
57
|
+
* Does NOT rethrow. Call isSuppressible() directly if you need rethrow control.
|
|
58
|
+
*/
|
|
59
|
+
export function suppressedCatch(error, context) {
|
|
60
|
+
if (isSuppressible(error, context)) {
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
const state = suppressRateLimit.get(context);
|
|
63
|
+
if (!state || now >= state.resetAt) {
|
|
64
|
+
suppressRateLimit.set(context, { count: 1, resetAt: now + 60_000 });
|
|
65
|
+
console.debug(`[suppress] ${context}: ${_errorMessage(error)}`);
|
|
66
|
+
}
|
|
67
|
+
else if (state.count < SUPPRESS_MAX_PER_MINUTE) {
|
|
68
|
+
state.count++;
|
|
69
|
+
console.debug(`[suppress] ${context}: ${_errorMessage(error)}`);
|
|
70
|
+
}
|
|
71
|
+
// rate limit exceeded for this window — drop silently
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
console.warn(`[unexpected] ${context}: ${_errorMessage(error)}`, error);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function _errorMessage(error) {
|
|
78
|
+
return error instanceof Error ? error.message : String(error);
|
|
79
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* swarm-monitor.ts — Monitors Claude Code swarm sockets for teammate sessions.
|
|
3
|
+
*
|
|
4
|
+
* Issue #81: Agent Swarm Awareness.
|
|
5
|
+
*
|
|
6
|
+
* When CC spawns teammates/subagents, it creates them in tmux with:
|
|
7
|
+
* - Socket: -L claude-swarm-{pid} (isolated from main session)
|
|
8
|
+
* - Window naming: teammate-{name}
|
|
9
|
+
* - Env vars: CLAUDE_PARENT_SESSION_ID, --agent-id, --agent-name
|
|
10
|
+
*
|
|
11
|
+
* This module discovers those swarm sockets, lists their windows,
|
|
12
|
+
* cross-references with parent sessions, and tracks teammate status.
|
|
13
|
+
*/
|
|
14
|
+
import type { SessionManager, SessionInfo } from './session.js';
|
|
15
|
+
/** Information about a single teammate window in a swarm socket. */
|
|
16
|
+
export interface TeammateInfo {
|
|
17
|
+
/** Window ID (e.g. "@0") */
|
|
18
|
+
windowId: string;
|
|
19
|
+
/** Window name (e.g. "teammate-explore-agent") */
|
|
20
|
+
windowName: string;
|
|
21
|
+
/** Working directory of the teammate */
|
|
22
|
+
cwd: string;
|
|
23
|
+
/** Current process running in the pane */
|
|
24
|
+
paneCommand: string;
|
|
25
|
+
/** Whether the teammate process is alive (claude/node running) */
|
|
26
|
+
alive: boolean;
|
|
27
|
+
/** Inferred status from pane command */
|
|
28
|
+
status: 'running' | 'idle' | 'dead';
|
|
29
|
+
}
|
|
30
|
+
/** A detected swarm (parent + its teammates). */
|
|
31
|
+
export interface SwarmInfo {
|
|
32
|
+
/** Socket name (e.g. "claude-swarm-12345") */
|
|
33
|
+
socketName: string;
|
|
34
|
+
/** PID extracted from socket name */
|
|
35
|
+
pid: number;
|
|
36
|
+
/** The parent Aegis session, if found */
|
|
37
|
+
parentSession: SessionInfo | null;
|
|
38
|
+
/** Detected teammate windows */
|
|
39
|
+
teammates: TeammateInfo[];
|
|
40
|
+
/** Aggregated swarm status */
|
|
41
|
+
aggregatedStatus: 'all_idle' | 'some_working' | 'all_dead' | 'no_teammates';
|
|
42
|
+
/** When this swarm was last scanned */
|
|
43
|
+
lastScannedAt: number;
|
|
44
|
+
}
|
|
45
|
+
/** Result of scanning all swarm sockets. */
|
|
46
|
+
export interface SwarmScanResult {
|
|
47
|
+
swarms: SwarmInfo[];
|
|
48
|
+
totalSockets: number;
|
|
49
|
+
totalTeammates: number;
|
|
50
|
+
scannedAt: number;
|
|
51
|
+
}
|
|
52
|
+
export interface SwarmMonitorConfig {
|
|
53
|
+
/** How often to scan for swarm sockets (default: 10s) */
|
|
54
|
+
scanIntervalMs: number;
|
|
55
|
+
/** Glob pattern for swarm socket directories */
|
|
56
|
+
socketGlobPattern: string;
|
|
57
|
+
}
|
|
58
|
+
export declare const DEFAULT_SWARM_CONFIG: SwarmMonitorConfig;
|
|
59
|
+
/** Events emitted by SwarmMonitor when teammate state changes. */
|
|
60
|
+
export type SwarmEvent = {
|
|
61
|
+
type: 'teammate_spawned';
|
|
62
|
+
swarm: SwarmInfo;
|
|
63
|
+
teammate: TeammateInfo;
|
|
64
|
+
} | {
|
|
65
|
+
type: 'teammate_finished';
|
|
66
|
+
swarm: SwarmInfo;
|
|
67
|
+
teammate: TeammateInfo;
|
|
68
|
+
};
|
|
69
|
+
/** Callback for swarm events. */
|
|
70
|
+
export type SwarmEventHandler = (event: SwarmEvent) => void;
|
|
71
|
+
export declare class SwarmMonitor {
|
|
72
|
+
private sessions;
|
|
73
|
+
private config;
|
|
74
|
+
private running;
|
|
75
|
+
private lastResult;
|
|
76
|
+
private timer;
|
|
77
|
+
private eventHandlers;
|
|
78
|
+
private windowsDisabledLogged;
|
|
79
|
+
constructor(sessions: SessionManager, config?: SwarmMonitorConfig);
|
|
80
|
+
/** Register an event handler for teammate lifecycle events. */
|
|
81
|
+
onEvent(handler: SwarmEventHandler): void;
|
|
82
|
+
private emitEvent;
|
|
83
|
+
private isWindowsPlatform;
|
|
84
|
+
private logWindowsDisabled;
|
|
85
|
+
/** Start the periodic scan loop. */
|
|
86
|
+
start(): void;
|
|
87
|
+
/** Stop the periodic scan loop. */
|
|
88
|
+
stop(): void;
|
|
89
|
+
/** Get the most recent scan result. */
|
|
90
|
+
getLastResult(): SwarmScanResult | null;
|
|
91
|
+
/** Run a single scan and return the result. */
|
|
92
|
+
scan(): Promise<SwarmScanResult>;
|
|
93
|
+
/** Compare current scan result against previous to detect teammate changes. */
|
|
94
|
+
private detectChanges;
|
|
95
|
+
/** Snapshot of teammates from previous scan for diffing. */
|
|
96
|
+
private previousTeammates;
|
|
97
|
+
/** Cached /tmp listing to avoid redundant I/O on every scan. */
|
|
98
|
+
private cachedSocketNames;
|
|
99
|
+
private cachedSocketAt;
|
|
100
|
+
private static readonly SOCKET_CACHE_TTL_MS;
|
|
101
|
+
/** Discover swarm socket directories in /tmp. */
|
|
102
|
+
private discoverSwarmSockets;
|
|
103
|
+
/** Inspect a single swarm socket and return swarm info. */
|
|
104
|
+
inspectSwarmSocket(socketName: string): Promise<SwarmInfo>;
|
|
105
|
+
/** Extract PID from socket name "claude-swarm-{pid}". */
|
|
106
|
+
private extractPid;
|
|
107
|
+
/** List all windows in a swarm socket. */
|
|
108
|
+
private listSwarmWindows;
|
|
109
|
+
/** Find the parent Aegis session for a swarm by matching the CC process PID. */
|
|
110
|
+
private findParentSession;
|
|
111
|
+
/** Compute aggregated status for a swarm. */
|
|
112
|
+
private computeAggregatedStatus;
|
|
113
|
+
/** Find a specific swarm by parent session ID. */
|
|
114
|
+
findSwarmByParentSessionId(sessionId: string): SwarmInfo | null;
|
|
115
|
+
/** Find all swarms associated with any active session. */
|
|
116
|
+
findActiveSwarms(): SwarmInfo[];
|
|
117
|
+
}
|