aegis-bridge 2.2.2
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 +244 -0
- package/dashboard/dist/assets/index-CijFoeRu.css +32 -0
- package/dashboard/dist/assets/index-QtT4j0ht.js +262 -0
- package/dashboard/dist/index.html +14 -0
- package/dist/auth.d.ts +76 -0
- package/dist/auth.js +219 -0
- package/dist/channels/index.d.ts +8 -0
- package/dist/channels/index.js +9 -0
- package/dist/channels/manager.d.ts +39 -0
- package/dist/channels/manager.js +101 -0
- package/dist/channels/telegram-style.d.ts +118 -0
- package/dist/channels/telegram-style.js +203 -0
- package/dist/channels/telegram.d.ts +76 -0
- package/dist/channels/telegram.js +1396 -0
- package/dist/channels/types.d.ts +77 -0
- package/dist/channels/types.js +9 -0
- package/dist/channels/webhook.d.ts +58 -0
- package/dist/channels/webhook.js +162 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +223 -0
- package/dist/config.d.ts +60 -0
- package/dist/config.js +188 -0
- package/dist/dashboard/assets/index-CijFoeRu.css +32 -0
- package/dist/dashboard/assets/index-QtT4j0ht.js +262 -0
- package/dist/dashboard/index.html +14 -0
- package/dist/events.d.ts +86 -0
- package/dist/events.js +258 -0
- package/dist/hook-settings.d.ts +67 -0
- package/dist/hook-settings.js +138 -0
- package/dist/hook.d.ts +18 -0
- package/dist/hook.js +199 -0
- package/dist/hooks.d.ts +32 -0
- package/dist/hooks.js +279 -0
- package/dist/jsonl-watcher.d.ts +57 -0
- package/dist/jsonl-watcher.js +159 -0
- package/dist/mcp-server.d.ts +60 -0
- package/dist/mcp-server.js +788 -0
- package/dist/metrics.d.ts +104 -0
- package/dist/metrics.js +226 -0
- package/dist/monitor.d.ts +84 -0
- package/dist/monitor.js +553 -0
- package/dist/permission-guard.d.ts +51 -0
- package/dist/permission-guard.js +197 -0
- package/dist/pipeline.d.ts +84 -0
- package/dist/pipeline.js +218 -0
- package/dist/screenshot.d.ts +26 -0
- package/dist/screenshot.js +57 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +1577 -0
- package/dist/session.d.ts +297 -0
- package/dist/session.js +1275 -0
- package/dist/sse-limiter.d.ts +47 -0
- package/dist/sse-limiter.js +62 -0
- package/dist/sse-writer.d.ts +31 -0
- package/dist/sse-writer.js +95 -0
- package/dist/ssrf.d.ts +57 -0
- package/dist/ssrf.js +169 -0
- package/dist/swarm-monitor.d.ts +114 -0
- package/dist/swarm-monitor.js +267 -0
- package/dist/terminal-parser.d.ts +16 -0
- package/dist/terminal-parser.js +343 -0
- package/dist/tmux.d.ts +161 -0
- package/dist/tmux.js +725 -0
- package/dist/transcript.d.ts +47 -0
- package/dist/transcript.js +244 -0
- package/dist/validation.d.ts +222 -0
- package/dist/validation.js +268 -0
- package/dist/ws-terminal.d.ts +32 -0
- package/dist/ws-terminal.js +297 -0
- package/package.json +71 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,1577 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server.ts — HTTP API server for Aegis.
|
|
3
|
+
*
|
|
4
|
+
* Exposes RESTful endpoints for creating, managing, and interacting
|
|
5
|
+
* with Claude Code sessions running in tmux.
|
|
6
|
+
*
|
|
7
|
+
* Notification channels (Telegram, webhooks, etc.) are pluggable —
|
|
8
|
+
* the server doesn't know which channels are active.
|
|
9
|
+
*/
|
|
10
|
+
import Fastify from 'fastify';
|
|
11
|
+
import fs from 'node:fs/promises';
|
|
12
|
+
import { readFileSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
13
|
+
import fastifyStatic from '@fastify/static';
|
|
14
|
+
import fastifyWebsocket from '@fastify/websocket';
|
|
15
|
+
import fastifyCors from '@fastify/cors';
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { TmuxManager } from './tmux.js';
|
|
20
|
+
import { SessionManager } from './session.js';
|
|
21
|
+
import { SessionMonitor, DEFAULT_MONITOR_CONFIG } from './monitor.js';
|
|
22
|
+
import { JsonlWatcher } from './jsonl-watcher.js';
|
|
23
|
+
import { ChannelManager, TelegramChannel, WebhookChannel, } from './channels/index.js';
|
|
24
|
+
import { loadConfig } from './config.js';
|
|
25
|
+
import { captureScreenshot, isPlaywrightAvailable } from './screenshot.js';
|
|
26
|
+
import { validateScreenshotUrl, resolveAndCheckIp } from './ssrf.js';
|
|
27
|
+
import { validateWorkDir } from './validation.js';
|
|
28
|
+
import { SessionEventBus } from './events.js';
|
|
29
|
+
import { SSEWriter } from './sse-writer.js';
|
|
30
|
+
import { SSEConnectionLimiter } from './sse-limiter.js';
|
|
31
|
+
import { PipelineManager } from './pipeline.js';
|
|
32
|
+
import { AuthManager } from './auth.js';
|
|
33
|
+
import { MetricsCollector } from './metrics.js';
|
|
34
|
+
import { registerHookRoutes } from './hooks.js';
|
|
35
|
+
import { registerWsTerminalRoute } from './ws-terminal.js';
|
|
36
|
+
import { SwarmMonitor } from './swarm-monitor.js';
|
|
37
|
+
import { execSync } from 'node:child_process';
|
|
38
|
+
import { authKeySchema, sendMessageSchema, commandSchema, bashSchema, screenshotSchema, permissionHookSchema, stopHookSchema, batchSessionSchema, pipelineSchema, parseIntSafe, } from './validation.js';
|
|
39
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
40
|
+
const __dirname = path.dirname(__filename);
|
|
41
|
+
// ── Configuration ────────────────────────────────────────────────────
|
|
42
|
+
// Issue #349: CSP policy for dashboard responses (shared between static and SPA fallback)
|
|
43
|
+
const DASHBOARD_CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:";
|
|
44
|
+
// Config loaded at startup; env vars override file values
|
|
45
|
+
let config;
|
|
46
|
+
// These will be initialized after config is loaded
|
|
47
|
+
let tmux;
|
|
48
|
+
let sessions;
|
|
49
|
+
let monitor;
|
|
50
|
+
let jsonlWatcher;
|
|
51
|
+
const channels = new ChannelManager();
|
|
52
|
+
const eventBus = new SessionEventBus();
|
|
53
|
+
let sseLimiter;
|
|
54
|
+
let pipelines;
|
|
55
|
+
let auth;
|
|
56
|
+
let metrics;
|
|
57
|
+
let swarmMonitor;
|
|
58
|
+
// ── Inbound command handler ─────────────────────────────────────────
|
|
59
|
+
async function handleInbound(cmd) {
|
|
60
|
+
try {
|
|
61
|
+
switch (cmd.action) {
|
|
62
|
+
case 'approve':
|
|
63
|
+
await sessions.approve(cmd.sessionId);
|
|
64
|
+
break;
|
|
65
|
+
case 'reject':
|
|
66
|
+
await sessions.reject(cmd.sessionId);
|
|
67
|
+
break;
|
|
68
|
+
case 'escape':
|
|
69
|
+
await sessions.escape(cmd.sessionId);
|
|
70
|
+
break;
|
|
71
|
+
case 'kill':
|
|
72
|
+
await channels.sessionEnded(makePayload('session.ended', cmd.sessionId, 'killed'));
|
|
73
|
+
await sessions.killSession(cmd.sessionId);
|
|
74
|
+
monitor.removeSession(cmd.sessionId);
|
|
75
|
+
metrics.cleanupSession(cmd.sessionId);
|
|
76
|
+
break;
|
|
77
|
+
case 'message':
|
|
78
|
+
case 'command':
|
|
79
|
+
if (cmd.text)
|
|
80
|
+
await sessions.sendMessage(cmd.sessionId, cmd.text);
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
console.error(`Inbound command error [${cmd.action}]:`, e);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// ── HTTP Server ─────────────────────────────────────────────────────
|
|
89
|
+
const app = Fastify({
|
|
90
|
+
bodyLimit: 1048576, // 1MB — Issue #349: explicit body size limit
|
|
91
|
+
logger: {
|
|
92
|
+
// #230: Redact auth tokens from request logs
|
|
93
|
+
serializers: {
|
|
94
|
+
req(req) {
|
|
95
|
+
const url = req.url?.includes('token=')
|
|
96
|
+
? req.url.replace(/token=[^&]*/g, 'token=[REDACTED]')
|
|
97
|
+
: req.url;
|
|
98
|
+
return {
|
|
99
|
+
method: req.method,
|
|
100
|
+
url,
|
|
101
|
+
// ...rest intentionally omitted — prevents token leakage via headers
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
// #227: Security headers on all API responses (skip SSE)
|
|
108
|
+
app.addHook('onSend', (req, reply, payload, done) => {
|
|
109
|
+
const contentType = reply.getHeader('content-type');
|
|
110
|
+
if (typeof contentType === 'string' && contentType.includes('text/event-stream')) {
|
|
111
|
+
return done();
|
|
112
|
+
}
|
|
113
|
+
reply.header('X-Content-Type-Options', 'nosniff');
|
|
114
|
+
reply.header('X-Frame-Options', 'DENY');
|
|
115
|
+
reply.header('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
116
|
+
reply.header('Permissions-Policy', 'camera=(), microphone=()');
|
|
117
|
+
done();
|
|
118
|
+
});
|
|
119
|
+
// Auth middleware setup (Issue #39: multi-key auth with rate limiting)
|
|
120
|
+
// #228: Per-IP rate limiting (applies even with master token, with higher limits)
|
|
121
|
+
const ipRateLimits = new Map();
|
|
122
|
+
const IP_WINDOW_MS = 60_000;
|
|
123
|
+
const IP_LIMIT_NORMAL = 120; // per minute for regular keys
|
|
124
|
+
const IP_LIMIT_MASTER = 300; // per minute for master token
|
|
125
|
+
function checkIpRateLimit(ip, isMaster) {
|
|
126
|
+
const now = Date.now();
|
|
127
|
+
const timestamps = ipRateLimits.get(ip) || [];
|
|
128
|
+
// Prune old entries
|
|
129
|
+
while (timestamps.length > 0 && timestamps[0] < now - IP_WINDOW_MS) {
|
|
130
|
+
timestamps.shift();
|
|
131
|
+
}
|
|
132
|
+
timestamps.push(now);
|
|
133
|
+
ipRateLimits.set(ip, timestamps);
|
|
134
|
+
const limit = isMaster ? IP_LIMIT_MASTER : IP_LIMIT_NORMAL;
|
|
135
|
+
return timestamps.length > limit;
|
|
136
|
+
}
|
|
137
|
+
/** #357: Prune IPs whose timestamp arrays are entirely outside the rate-limit window. */
|
|
138
|
+
function pruneIpRateLimits() {
|
|
139
|
+
const cutoff = Date.now() - IP_WINDOW_MS;
|
|
140
|
+
for (const [ip, timestamps] of ipRateLimits) {
|
|
141
|
+
// All timestamps are old — remove the entry entirely
|
|
142
|
+
if (timestamps.length === 0 || timestamps[timestamps.length - 1] < cutoff) {
|
|
143
|
+
ipRateLimits.delete(ip);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function setupAuth(authManager) {
|
|
148
|
+
app.addHook('onRequest', async (req, reply) => {
|
|
149
|
+
// Skip auth for health endpoint and dashboard (Issue #349: exact path matching)
|
|
150
|
+
// #126: Dashboard is served as public static files; API endpoints are protected
|
|
151
|
+
const urlPath = req.url?.split('?')[0] ?? '';
|
|
152
|
+
if (urlPath === '/health' || urlPath === '/v1/health')
|
|
153
|
+
return;
|
|
154
|
+
if (urlPath === '/dashboard' || urlPath.startsWith('/dashboard/'))
|
|
155
|
+
return;
|
|
156
|
+
// Hook routes — exact match: /v1/hooks/{eventName} (alpha only, no path traversal)
|
|
157
|
+
// Issue #394: Require valid X-Session-Id for known sessions instead of blanket bypass.
|
|
158
|
+
// CC hooks run from localhost and always include the session ID they were started with.
|
|
159
|
+
const hookMatch = /^\/v1\/hooks\/[A-Za-z]+$/.exec(urlPath);
|
|
160
|
+
if (hookMatch) {
|
|
161
|
+
const hookSessionId = req.headers['x-session-id']
|
|
162
|
+
|| req.query?.sessionId;
|
|
163
|
+
if (hookSessionId && sessions.getSession(hookSessionId)) {
|
|
164
|
+
return; // valid session — allow
|
|
165
|
+
}
|
|
166
|
+
// No valid session context — reject even when auth is disabled
|
|
167
|
+
return reply.status(401).send({ error: 'Unauthorized — hook endpoint requires valid session ID' });
|
|
168
|
+
}
|
|
169
|
+
// #303: WS terminal routes have their own preHandler for auth (supports ?token=)
|
|
170
|
+
// Exact match: /v1/sessions/{id}/terminal
|
|
171
|
+
if (/^\/v1\/sessions\/[^/]+\/terminal$/.test(urlPath))
|
|
172
|
+
return;
|
|
173
|
+
// If no auth configured (no master token, no keys), allow all
|
|
174
|
+
if (!authManager.authEnabled)
|
|
175
|
+
return;
|
|
176
|
+
// #124/#125: Accept token from Authorization header; ?token= query param
|
|
177
|
+
// only on SSE routes where EventSource cannot set headers.
|
|
178
|
+
// #297: SSE routes also accept short-lived SSE tokens via ?token=.
|
|
179
|
+
const isSSERoute = req.url?.includes('/events');
|
|
180
|
+
let token;
|
|
181
|
+
const header = req.headers.authorization;
|
|
182
|
+
if (header?.startsWith('Bearer ')) {
|
|
183
|
+
token = header.slice(7);
|
|
184
|
+
}
|
|
185
|
+
else if (isSSERoute) {
|
|
186
|
+
token = req.query.token;
|
|
187
|
+
}
|
|
188
|
+
if (!token) {
|
|
189
|
+
return reply.status(401).send({ error: 'Unauthorized — Bearer token required' });
|
|
190
|
+
}
|
|
191
|
+
// #297: Check if this is a short-lived SSE token first
|
|
192
|
+
if (isSSERoute && token.startsWith('sse_')) {
|
|
193
|
+
if (authManager.validateSSEToken(token)) {
|
|
194
|
+
return; // authenticated via short-lived SSE token
|
|
195
|
+
}
|
|
196
|
+
return reply.status(401).send({ error: 'Unauthorized — SSE token invalid or expired' });
|
|
197
|
+
}
|
|
198
|
+
const result = authManager.validate(token);
|
|
199
|
+
if (!result.valid) {
|
|
200
|
+
return reply.status(401).send({ error: 'Unauthorized — invalid API key' });
|
|
201
|
+
}
|
|
202
|
+
if (result.rateLimited) {
|
|
203
|
+
return reply.status(429).send({ error: 'Rate limit exceeded — 100 req/min per key' });
|
|
204
|
+
}
|
|
205
|
+
// #228: Per-IP rate limiting (applies to all authenticated requests)
|
|
206
|
+
const clientIp = req.ip ?? req.headers['x-forwarded-for'] ?? 'unknown';
|
|
207
|
+
const isMaster = result.keyId === 'master';
|
|
208
|
+
if (checkIpRateLimit(clientIp, isMaster)) {
|
|
209
|
+
return reply.status(429).send({ error: 'Rate limit exceeded — IP throttled' });
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
// ── v1 API Routes ───────────────────────────────────────────────────
|
|
214
|
+
// #226: Zod schema for session creation
|
|
215
|
+
const createSessionSchema = z.object({
|
|
216
|
+
workDir: z.string().min(1),
|
|
217
|
+
name: z.string().max(200).optional(),
|
|
218
|
+
prompt: z.string().max(100_000).optional(),
|
|
219
|
+
resumeSessionId: z.string().uuid().optional(),
|
|
220
|
+
claudeCommand: z.string().max(10_000).optional(),
|
|
221
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
222
|
+
stallThresholdMs: z.number().int().positive().max(3_600_000).optional(),
|
|
223
|
+
permissionMode: z.enum(['default', 'bypassPermissions', 'plan']).optional(),
|
|
224
|
+
autoApprove: z.boolean().optional(),
|
|
225
|
+
}).strict();
|
|
226
|
+
// Health
|
|
227
|
+
app.get('/v1/health', async () => {
|
|
228
|
+
const pkg = await import('../package.json', { with: { type: 'json' } });
|
|
229
|
+
const activeCount = sessions.listSessions().length;
|
|
230
|
+
const totalCount = metrics.getTotalSessionsCreated();
|
|
231
|
+
return {
|
|
232
|
+
status: 'ok',
|
|
233
|
+
version: pkg.default.version,
|
|
234
|
+
uptime: process.uptime(),
|
|
235
|
+
sessions: { active: activeCount, total: totalCount },
|
|
236
|
+
timestamp: new Date().toISOString(),
|
|
237
|
+
};
|
|
238
|
+
});
|
|
239
|
+
// Backwards compat: unversioned health
|
|
240
|
+
app.get('/health', async () => {
|
|
241
|
+
const pkg = await import('../package.json', { with: { type: 'json' } });
|
|
242
|
+
const activeCount = sessions.listSessions().length;
|
|
243
|
+
const totalCount = metrics.getTotalSessionsCreated();
|
|
244
|
+
return {
|
|
245
|
+
status: 'ok',
|
|
246
|
+
version: pkg.default.version,
|
|
247
|
+
uptime: process.uptime(),
|
|
248
|
+
sessions: { active: activeCount, total: totalCount },
|
|
249
|
+
timestamp: new Date().toISOString(),
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
// Issue #81: Swarm awareness — list all detected CC swarms and their teammates
|
|
253
|
+
app.get('/v1/swarm', async () => {
|
|
254
|
+
const result = await swarmMonitor.scan();
|
|
255
|
+
return result;
|
|
256
|
+
});
|
|
257
|
+
// API key management (Issue #39)
|
|
258
|
+
// Security: reject all auth key operations when auth is not enabled
|
|
259
|
+
app.post('/v1/auth/keys', async (req, reply) => {
|
|
260
|
+
if (!auth.authEnabled)
|
|
261
|
+
return reply.status(403).send({ error: 'Auth is not enabled' });
|
|
262
|
+
const parsed = authKeySchema.safeParse(req.body);
|
|
263
|
+
if (!parsed.success)
|
|
264
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
265
|
+
const { name, rateLimit } = parsed.data;
|
|
266
|
+
const result = await auth.createKey(name, rateLimit);
|
|
267
|
+
return reply.status(201).send(result);
|
|
268
|
+
});
|
|
269
|
+
app.get('/v1/auth/keys', async (req, reply) => {
|
|
270
|
+
if (!auth.authEnabled)
|
|
271
|
+
return reply.status(403).send({ error: 'Auth is not enabled' });
|
|
272
|
+
return auth.listKeys();
|
|
273
|
+
});
|
|
274
|
+
app.delete('/v1/auth/keys/:id', async (req, reply) => {
|
|
275
|
+
if (!auth.authEnabled)
|
|
276
|
+
return reply.status(403).send({ error: 'Auth is not enabled' });
|
|
277
|
+
const revoked = await auth.revokeKey(req.params.id);
|
|
278
|
+
if (!revoked)
|
|
279
|
+
return reply.status(404).send({ error: 'Key not found' });
|
|
280
|
+
return { ok: true };
|
|
281
|
+
});
|
|
282
|
+
// #297: SSE token endpoint — generates short-lived, single-use token
|
|
283
|
+
// to avoid exposing long-lived bearer tokens in SSE URL query params.
|
|
284
|
+
// Must be registered BEFORE setupAuth skips auth key routes.
|
|
285
|
+
app.post('/v1/auth/sse-token', async (req, reply) => {
|
|
286
|
+
// This route goes through the onRequest auth hook, so the caller is
|
|
287
|
+
// already authenticated. We just need to extract their keyId.
|
|
288
|
+
const header = req.headers.authorization;
|
|
289
|
+
let keyId = 'anonymous';
|
|
290
|
+
if (header?.startsWith('Bearer ')) {
|
|
291
|
+
const token = header.slice(7);
|
|
292
|
+
const result = auth.validate(token);
|
|
293
|
+
if (result.keyId)
|
|
294
|
+
keyId = result.keyId;
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
const sseToken = await auth.generateSSEToken(keyId);
|
|
298
|
+
return reply.status(201).send(sseToken);
|
|
299
|
+
}
|
|
300
|
+
catch (e) {
|
|
301
|
+
return reply.status(429).send({ error: e instanceof Error ? e.message : 'SSE token limit reached' });
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
// Global metrics (Issue #40)
|
|
305
|
+
app.get('/v1/metrics', async () => metrics.getGlobalMetrics(sessions.listSessions().length));
|
|
306
|
+
// Per-session metrics (Issue #40)
|
|
307
|
+
app.get('/v1/sessions/:id/metrics', async (req, reply) => {
|
|
308
|
+
const m = metrics.getSessionMetrics(req.params.id);
|
|
309
|
+
if (!m)
|
|
310
|
+
return reply.status(404).send({ error: 'No metrics for this session' });
|
|
311
|
+
return m;
|
|
312
|
+
});
|
|
313
|
+
// Issue #89 L14: Webhook dead letter queue
|
|
314
|
+
app.get('/v1/webhooks/dead-letter', async () => {
|
|
315
|
+
for (const ch of channels.getChannels()) {
|
|
316
|
+
if (ch.name === 'webhook' && 'getDeadLetterQueue' in ch) {
|
|
317
|
+
return ch.getDeadLetterQueue();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return [];
|
|
321
|
+
});
|
|
322
|
+
// Issue #89 L15: Per-channel health reporting
|
|
323
|
+
app.get('/v1/channels/health', async () => {
|
|
324
|
+
return channels.getChannels().map(ch => {
|
|
325
|
+
const health = ch.getHealth?.();
|
|
326
|
+
if (health)
|
|
327
|
+
return health;
|
|
328
|
+
return { channel: ch.name, healthy: true, lastSuccess: null, lastError: null, pendingCount: 0 };
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
// Issue #87: Per-session latency metrics
|
|
332
|
+
app.get('/v1/sessions/:id/latency', async (req, reply) => {
|
|
333
|
+
const session = sessions.getSession(req.params.id);
|
|
334
|
+
if (!session)
|
|
335
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
336
|
+
const realtimeLatency = sessions.getLatencyMetrics(req.params.id);
|
|
337
|
+
const aggregatedLatency = metrics.getSessionLatency(req.params.id);
|
|
338
|
+
return {
|
|
339
|
+
sessionId: req.params.id,
|
|
340
|
+
realtime: realtimeLatency,
|
|
341
|
+
aggregated: aggregatedLatency,
|
|
342
|
+
};
|
|
343
|
+
});
|
|
344
|
+
// Global SSE event stream — aggregates events from ALL active sessions
|
|
345
|
+
app.get('/v1/events', async (req, reply) => {
|
|
346
|
+
const clientIp = req.ip;
|
|
347
|
+
const acquireResult = sseLimiter.acquire(clientIp);
|
|
348
|
+
if (!acquireResult.allowed) {
|
|
349
|
+
const status = acquireResult.reason === 'per_ip_limit' ? 429 : 503;
|
|
350
|
+
return reply.status(status).send({
|
|
351
|
+
error: acquireResult.reason === 'per_ip_limit'
|
|
352
|
+
? `Per-IP connection limit reached (${acquireResult.current}/${acquireResult.limit})`
|
|
353
|
+
: `Global connection limit reached (${acquireResult.current}/${acquireResult.limit})`,
|
|
354
|
+
reason: acquireResult.reason,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
// Issue #505: Subscribe BEFORE writing response headers so that if
|
|
358
|
+
// subscription fails, we can still return a proper HTTP error.
|
|
359
|
+
let unsubscribe;
|
|
360
|
+
const connectionId = acquireResult.connectionId;
|
|
361
|
+
let writer;
|
|
362
|
+
// Queue events that arrive between subscription and writer creation
|
|
363
|
+
const pendingEvents = [];
|
|
364
|
+
let subscriptionReady = false;
|
|
365
|
+
const handler = (event) => {
|
|
366
|
+
if (!subscriptionReady) {
|
|
367
|
+
pendingEvents.push(event);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const id = event.id != null ? `id: ${event.id}\n` : '';
|
|
371
|
+
writer.write(`${id}data: ${JSON.stringify(event)}\n\n`);
|
|
372
|
+
};
|
|
373
|
+
try {
|
|
374
|
+
unsubscribe = eventBus.subscribeGlobal(handler);
|
|
375
|
+
}
|
|
376
|
+
catch (err) {
|
|
377
|
+
req.log.error({ err }, 'Global SSE subscription failed');
|
|
378
|
+
sseLimiter.release(connectionId);
|
|
379
|
+
return reply.status(500).send({ error: 'Failed to create SSE subscription' });
|
|
380
|
+
}
|
|
381
|
+
reply.raw.writeHead(200, {
|
|
382
|
+
'Content-Type': 'text/event-stream',
|
|
383
|
+
'Cache-Control': 'no-cache',
|
|
384
|
+
'Connection': 'keep-alive',
|
|
385
|
+
'X-Accel-Buffering': 'no',
|
|
386
|
+
});
|
|
387
|
+
writer = new SSEWriter(reply.raw, req.raw, () => {
|
|
388
|
+
unsubscribe?.();
|
|
389
|
+
sseLimiter.release(connectionId);
|
|
390
|
+
});
|
|
391
|
+
// Now safe to deliver events — flush any queued during setup
|
|
392
|
+
subscriptionReady = true;
|
|
393
|
+
for (const event of pendingEvents) {
|
|
394
|
+
const id = event.id != null ? `id: ${event.id}\n` : '';
|
|
395
|
+
writer.write(`${id}data: ${JSON.stringify(event)}\n\n`);
|
|
396
|
+
}
|
|
397
|
+
writer.write(`data: ${JSON.stringify({
|
|
398
|
+
event: 'connected',
|
|
399
|
+
timestamp: new Date().toISOString(),
|
|
400
|
+
data: { activeSessions: sessions.listSessions().length },
|
|
401
|
+
})}\n\n`);
|
|
402
|
+
// Issue #301: Replay missed global events if client sends Last-Event-ID
|
|
403
|
+
const lastEventId = req.headers['last-event-id'];
|
|
404
|
+
if (lastEventId) {
|
|
405
|
+
const missed = eventBus.getGlobalEventsSince(parseInt(lastEventId, 10) || 0);
|
|
406
|
+
for (const { id, event: globalEvent } of missed) {
|
|
407
|
+
writer.write(`id: ${id}\ndata: ${JSON.stringify(globalEvent)}\n\n`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
writer.startHeartbeat(30_000, 90_000, () => `data: ${JSON.stringify({ event: 'heartbeat', timestamp: new Date().toISOString() })}\n\n`);
|
|
411
|
+
await reply;
|
|
412
|
+
});
|
|
413
|
+
// List sessions (with pagination and status filter)
|
|
414
|
+
app.get('/v1/sessions', async (req) => {
|
|
415
|
+
const page = Math.max(1, parseInt(req.query.page || '1', 10) || 1);
|
|
416
|
+
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit || '20', 10) || 20));
|
|
417
|
+
const statusFilter = req.query.status;
|
|
418
|
+
let all = sessions.listSessions();
|
|
419
|
+
if (statusFilter) {
|
|
420
|
+
all = all.filter(s => s.status === statusFilter);
|
|
421
|
+
}
|
|
422
|
+
// Sort by createdAt descending (newest first)
|
|
423
|
+
all.sort((a, b) => b.createdAt - a.createdAt);
|
|
424
|
+
const total = all.length;
|
|
425
|
+
const start = (page - 1) * limit;
|
|
426
|
+
const items = all.slice(start, start + limit);
|
|
427
|
+
return {
|
|
428
|
+
sessions: items,
|
|
429
|
+
total,
|
|
430
|
+
page,
|
|
431
|
+
limit,
|
|
432
|
+
};
|
|
433
|
+
});
|
|
434
|
+
// Backwards compat: /sessions (no prefix) returns raw array
|
|
435
|
+
app.get('/sessions', async () => sessions.listSessions());
|
|
436
|
+
/** Validate workDir — delegates to validation.ts (Issue #435). */
|
|
437
|
+
const validateWorkDirWithConfig = (workDir) => validateWorkDir(workDir, config.allowedWorkDirs);
|
|
438
|
+
// Create session
|
|
439
|
+
app.post('/v1/sessions', async (req, reply) => {
|
|
440
|
+
const parsed = createSessionSchema.safeParse(req.body);
|
|
441
|
+
if (!parsed.success) {
|
|
442
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
443
|
+
}
|
|
444
|
+
const { workDir, name, prompt, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove } = parsed.data;
|
|
445
|
+
console.time("POST_CREATE_SESSION");
|
|
446
|
+
if (!workDir)
|
|
447
|
+
return reply.status(400).send({ error: 'workDir is required' });
|
|
448
|
+
const safeWorkDir = await validateWorkDirWithConfig(workDir);
|
|
449
|
+
if (typeof safeWorkDir === 'object')
|
|
450
|
+
return reply.status(400).send({ error: safeWorkDir.error, code: safeWorkDir.code });
|
|
451
|
+
const session = await sessions.createSession({ workDir: safeWorkDir, name, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove });
|
|
452
|
+
console.timeEnd("POST_CREATE_SESSION");
|
|
453
|
+
console.time("POST_CHANNEL_CREATED");
|
|
454
|
+
// Issue #46: Create Telegram topic BEFORE sending prompt.
|
|
455
|
+
// The monitor starts polling immediately after createSession().
|
|
456
|
+
// If we wait for sendInitialPrompt (up to 15s), the monitor may find
|
|
457
|
+
// new messages but can't forward them because no topic exists yet.
|
|
458
|
+
// Those messages are lost forever (monitorOffset advances past them).
|
|
459
|
+
await channels.sessionCreated({
|
|
460
|
+
event: 'session.created',
|
|
461
|
+
timestamp: new Date().toISOString(),
|
|
462
|
+
session: { id: session.id, name: session.windowName, workDir },
|
|
463
|
+
detail: `Session created: ${session.windowName}`,
|
|
464
|
+
meta: prompt ? { prompt: prompt.slice(0, 200), permissionMode: permissionMode ?? (autoApprove ? 'bypassPermissions' : undefined) } : undefined,
|
|
465
|
+
});
|
|
466
|
+
console.timeEnd("POST_CHANNEL_CREATED");
|
|
467
|
+
console.time("POST_SEND_INITIAL_PROMPT");
|
|
468
|
+
// Now send the prompt (topic exists, monitor can forward messages)
|
|
469
|
+
let promptDelivery;
|
|
470
|
+
if (prompt) {
|
|
471
|
+
promptDelivery = await sessions.sendInitialPrompt(session.id, prompt);
|
|
472
|
+
console.timeEnd("POST_SEND_INITIAL_PROMPT");
|
|
473
|
+
metrics.promptSent(promptDelivery.delivered);
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
console.timeEnd("POST_SEND_INITIAL_PROMPT");
|
|
477
|
+
}
|
|
478
|
+
return reply.status(201).send({ ...session, promptDelivery });
|
|
479
|
+
});
|
|
480
|
+
// Backwards compat
|
|
481
|
+
app.post('/sessions', async (req, reply) => {
|
|
482
|
+
const parsed = createSessionSchema.safeParse(req.body);
|
|
483
|
+
if (!parsed.success) {
|
|
484
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
485
|
+
}
|
|
486
|
+
const { workDir, name, prompt, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove } = parsed.data;
|
|
487
|
+
if (!workDir)
|
|
488
|
+
return reply.status(400).send({ error: 'workDir is required' });
|
|
489
|
+
const safeWorkDir = await validateWorkDirWithConfig(workDir);
|
|
490
|
+
if (typeof safeWorkDir === 'object')
|
|
491
|
+
return reply.status(400).send({ error: safeWorkDir.error, code: safeWorkDir.code });
|
|
492
|
+
const session = await sessions.createSession({ workDir: safeWorkDir, name, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove });
|
|
493
|
+
// Issue #46: Topic first, then prompt (same fix as v1 route)
|
|
494
|
+
await channels.sessionCreated({
|
|
495
|
+
event: 'session.created',
|
|
496
|
+
timestamp: new Date().toISOString(),
|
|
497
|
+
session: { id: session.id, name: session.windowName, workDir },
|
|
498
|
+
detail: `Session created: ${session.windowName}`,
|
|
499
|
+
meta: prompt ? { prompt: prompt.slice(0, 200), permissionMode: permissionMode ?? (autoApprove ? 'bypassPermissions' : undefined) } : undefined,
|
|
500
|
+
});
|
|
501
|
+
let promptDelivery;
|
|
502
|
+
if (prompt) {
|
|
503
|
+
promptDelivery = await sessions.sendInitialPrompt(session.id, prompt);
|
|
504
|
+
metrics.promptSent(promptDelivery.delivered);
|
|
505
|
+
}
|
|
506
|
+
return reply.status(201).send({ ...session, promptDelivery });
|
|
507
|
+
});
|
|
508
|
+
// Get session (Issue #20: includes actionHints for interactive states)
|
|
509
|
+
app.get('/v1/sessions/:id', async (req, reply) => {
|
|
510
|
+
const session = sessions.getSession(req.params.id);
|
|
511
|
+
if (!session)
|
|
512
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
513
|
+
return addActionHints(session);
|
|
514
|
+
});
|
|
515
|
+
app.get('/sessions/:id', async (req, reply) => {
|
|
516
|
+
const session = sessions.getSession(req.params.id);
|
|
517
|
+
if (!session)
|
|
518
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
519
|
+
return addActionHints(session);
|
|
520
|
+
});
|
|
521
|
+
// #128: Bulk health check — returns health for all sessions in one request
|
|
522
|
+
app.get('/v1/sessions/health', async () => {
|
|
523
|
+
const allSessions = sessions.listSessions();
|
|
524
|
+
const results = {};
|
|
525
|
+
await Promise.all(allSessions.map(async (s) => {
|
|
526
|
+
try {
|
|
527
|
+
results[s.id] = await sessions.getHealth(s.id);
|
|
528
|
+
}
|
|
529
|
+
catch { /* health check failed — report error state */
|
|
530
|
+
results[s.id] = {
|
|
531
|
+
alive: false, windowExists: false, claudeRunning: false,
|
|
532
|
+
paneCommand: null, status: 'unknown', hasTranscript: false,
|
|
533
|
+
lastActivity: 0, lastActivityAgo: 0, sessionAge: 0,
|
|
534
|
+
details: 'Error fetching health',
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
}));
|
|
538
|
+
return results;
|
|
539
|
+
});
|
|
540
|
+
// Session health check (Issue #2)
|
|
541
|
+
app.get('/v1/sessions/:id/health', async (req, reply) => {
|
|
542
|
+
try {
|
|
543
|
+
return await sessions.getHealth(req.params.id);
|
|
544
|
+
}
|
|
545
|
+
catch (e) {
|
|
546
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
app.get('/sessions/:id/health', async (req, reply) => {
|
|
550
|
+
try {
|
|
551
|
+
return await sessions.getHealth(req.params.id);
|
|
552
|
+
}
|
|
553
|
+
catch (e) {
|
|
554
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
// Send message (with delivery verification — Issue #1)
|
|
558
|
+
app.post('/v1/sessions/:id/send', async (req, reply) => {
|
|
559
|
+
const parsed = sendMessageSchema.safeParse(req.body);
|
|
560
|
+
if (!parsed.success)
|
|
561
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
562
|
+
const { text } = parsed.data;
|
|
563
|
+
try {
|
|
564
|
+
const result = await sessions.sendMessage(req.params.id, text);
|
|
565
|
+
await channels.message({
|
|
566
|
+
event: 'message.user',
|
|
567
|
+
timestamp: new Date().toISOString(),
|
|
568
|
+
session: { id: req.params.id, name: '', workDir: '' },
|
|
569
|
+
detail: text,
|
|
570
|
+
});
|
|
571
|
+
return { ok: true, delivered: result.delivered, attempts: result.attempts };
|
|
572
|
+
}
|
|
573
|
+
catch (e) {
|
|
574
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
app.post('/sessions/:id/send', async (req, reply) => {
|
|
578
|
+
const parsed = sendMessageSchema.safeParse(req.body);
|
|
579
|
+
if (!parsed.success)
|
|
580
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
581
|
+
const { text } = parsed.data;
|
|
582
|
+
try {
|
|
583
|
+
const result = await sessions.sendMessage(req.params.id, text);
|
|
584
|
+
await channels.message({
|
|
585
|
+
event: 'message.user',
|
|
586
|
+
timestamp: new Date().toISOString(),
|
|
587
|
+
session: { id: req.params.id, name: '', workDir: '' },
|
|
588
|
+
detail: text,
|
|
589
|
+
});
|
|
590
|
+
return { ok: true, delivered: result.delivered, attempts: result.attempts };
|
|
591
|
+
}
|
|
592
|
+
catch (e) {
|
|
593
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
// Read messages
|
|
597
|
+
app.get('/v1/sessions/:id/read', async (req, reply) => {
|
|
598
|
+
try {
|
|
599
|
+
return await sessions.readMessages(req.params.id);
|
|
600
|
+
}
|
|
601
|
+
catch (e) {
|
|
602
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
app.get('/sessions/:id/read', async (req, reply) => {
|
|
606
|
+
try {
|
|
607
|
+
return await sessions.readMessages(req.params.id);
|
|
608
|
+
}
|
|
609
|
+
catch (e) {
|
|
610
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
// Approve
|
|
614
|
+
app.post('/v1/sessions/:id/approve', async (req, reply) => {
|
|
615
|
+
try {
|
|
616
|
+
await sessions.approve(req.params.id);
|
|
617
|
+
// Issue #87: Record permission response latency
|
|
618
|
+
const lat = sessions.getLatencyMetrics(req.params.id);
|
|
619
|
+
if (lat !== null && lat.permission_response_ms !== null) {
|
|
620
|
+
metrics.recordPermissionResponse(req.params.id, lat.permission_response_ms);
|
|
621
|
+
}
|
|
622
|
+
return { ok: true };
|
|
623
|
+
}
|
|
624
|
+
catch (e) {
|
|
625
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
app.post('/sessions/:id/approve', async (req, reply) => {
|
|
629
|
+
try {
|
|
630
|
+
await sessions.approve(req.params.id);
|
|
631
|
+
const lat = sessions.getLatencyMetrics(req.params.id);
|
|
632
|
+
if (lat !== null && lat.permission_response_ms !== null) {
|
|
633
|
+
metrics.recordPermissionResponse(req.params.id, lat.permission_response_ms);
|
|
634
|
+
}
|
|
635
|
+
return { ok: true };
|
|
636
|
+
}
|
|
637
|
+
catch (e) {
|
|
638
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
// Reject
|
|
642
|
+
app.post('/v1/sessions/:id/reject', async (req, reply) => {
|
|
643
|
+
try {
|
|
644
|
+
await sessions.reject(req.params.id);
|
|
645
|
+
const lat = sessions.getLatencyMetrics(req.params.id);
|
|
646
|
+
if (lat !== null && lat.permission_response_ms !== null) {
|
|
647
|
+
metrics.recordPermissionResponse(req.params.id, lat.permission_response_ms);
|
|
648
|
+
}
|
|
649
|
+
return { ok: true };
|
|
650
|
+
}
|
|
651
|
+
catch (e) {
|
|
652
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
app.post('/sessions/:id/reject', async (req, reply) => {
|
|
656
|
+
try {
|
|
657
|
+
await sessions.reject(req.params.id);
|
|
658
|
+
const lat = sessions.getLatencyMetrics(req.params.id);
|
|
659
|
+
if (lat !== null && lat.permission_response_ms !== null) {
|
|
660
|
+
metrics.recordPermissionResponse(req.params.id, lat.permission_response_ms);
|
|
661
|
+
}
|
|
662
|
+
return { ok: true };
|
|
663
|
+
}
|
|
664
|
+
catch (e) {
|
|
665
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
// Issue #336: Answer pending AskUserQuestion
|
|
669
|
+
app.post('/v1/sessions/:id/answer', async (req, reply) => {
|
|
670
|
+
const { questionId, answer } = req.body || {};
|
|
671
|
+
if (!questionId || answer === undefined || answer === null) {
|
|
672
|
+
return reply.status(400).send({ error: 'questionId and answer are required' });
|
|
673
|
+
}
|
|
674
|
+
const session = sessions.getSession(req.params.id);
|
|
675
|
+
if (!session)
|
|
676
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
677
|
+
const resolved = sessions.submitAnswer(req.params.id, questionId, answer);
|
|
678
|
+
if (!resolved) {
|
|
679
|
+
return reply.status(409).send({ error: 'No pending question matching this questionId' });
|
|
680
|
+
}
|
|
681
|
+
return { ok: true };
|
|
682
|
+
});
|
|
683
|
+
// Escape
|
|
684
|
+
app.post('/v1/sessions/:id/escape', async (req, reply) => {
|
|
685
|
+
try {
|
|
686
|
+
await sessions.escape(req.params.id);
|
|
687
|
+
return { ok: true };
|
|
688
|
+
}
|
|
689
|
+
catch (e) {
|
|
690
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
app.post('/sessions/:id/escape', async (req, reply) => {
|
|
694
|
+
try {
|
|
695
|
+
await sessions.escape(req.params.id);
|
|
696
|
+
return { ok: true };
|
|
697
|
+
}
|
|
698
|
+
catch (e) {
|
|
699
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
// Interrupt (Ctrl+C)
|
|
703
|
+
app.post('/v1/sessions/:id/interrupt', async (req, reply) => {
|
|
704
|
+
try {
|
|
705
|
+
await sessions.interrupt(req.params.id);
|
|
706
|
+
return { ok: true };
|
|
707
|
+
}
|
|
708
|
+
catch (e) {
|
|
709
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
app.post('/sessions/:id/interrupt', async (req, reply) => {
|
|
713
|
+
try {
|
|
714
|
+
await sessions.interrupt(req.params.id);
|
|
715
|
+
return { ok: true };
|
|
716
|
+
}
|
|
717
|
+
catch (e) {
|
|
718
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
// Kill session
|
|
722
|
+
app.delete('/v1/sessions/:id', async (req, reply) => {
|
|
723
|
+
if (!sessions.getSession(req.params.id)) {
|
|
724
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
725
|
+
}
|
|
726
|
+
try {
|
|
727
|
+
eventBus.emitEnded(req.params.id, 'killed');
|
|
728
|
+
await channels.sessionEnded(makePayload('session.ended', req.params.id, 'killed'));
|
|
729
|
+
await sessions.killSession(req.params.id);
|
|
730
|
+
monitor.removeSession(req.params.id);
|
|
731
|
+
metrics.cleanupSession(req.params.id);
|
|
732
|
+
return { ok: true };
|
|
733
|
+
}
|
|
734
|
+
catch (e) {
|
|
735
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
app.delete('/sessions/:id', async (req, reply) => {
|
|
739
|
+
if (!sessions.getSession(req.params.id)) {
|
|
740
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
741
|
+
}
|
|
742
|
+
try {
|
|
743
|
+
eventBus.emitEnded(req.params.id, 'killed');
|
|
744
|
+
await channels.sessionEnded(makePayload('session.ended', req.params.id, 'killed'));
|
|
745
|
+
await sessions.killSession(req.params.id);
|
|
746
|
+
monitor.removeSession(req.params.id);
|
|
747
|
+
metrics.cleanupSession(req.params.id);
|
|
748
|
+
return { ok: true };
|
|
749
|
+
}
|
|
750
|
+
catch (e) {
|
|
751
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
// Capture raw pane
|
|
755
|
+
app.get('/v1/sessions/:id/pane', async (req, reply) => {
|
|
756
|
+
const session = sessions.getSession(req.params.id);
|
|
757
|
+
if (!session)
|
|
758
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
759
|
+
const pane = await tmux.capturePane(session.windowId);
|
|
760
|
+
return { pane };
|
|
761
|
+
});
|
|
762
|
+
app.get('/sessions/:id/pane', async (req, reply) => {
|
|
763
|
+
const session = sessions.getSession(req.params.id);
|
|
764
|
+
if (!session)
|
|
765
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
766
|
+
const pane = await tmux.capturePane(session.windowId);
|
|
767
|
+
return { pane };
|
|
768
|
+
});
|
|
769
|
+
// Slash command
|
|
770
|
+
app.post('/v1/sessions/:id/command', async (req, reply) => {
|
|
771
|
+
const parsed = commandSchema.safeParse(req.body);
|
|
772
|
+
if (!parsed.success)
|
|
773
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
774
|
+
const { command } = parsed.data;
|
|
775
|
+
try {
|
|
776
|
+
const cmd = command.startsWith('/') ? command : `/${command}`;
|
|
777
|
+
await sessions.sendMessage(req.params.id, cmd);
|
|
778
|
+
return { ok: true };
|
|
779
|
+
}
|
|
780
|
+
catch (e) {
|
|
781
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
app.post('/sessions/:id/command', async (req, reply) => {
|
|
785
|
+
const parsed = commandSchema.safeParse(req.body);
|
|
786
|
+
if (!parsed.success)
|
|
787
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
788
|
+
const { command } = parsed.data;
|
|
789
|
+
try {
|
|
790
|
+
const cmd = command.startsWith('/') ? command : `/${command}`;
|
|
791
|
+
await sessions.sendMessage(req.params.id, cmd);
|
|
792
|
+
return { ok: true };
|
|
793
|
+
}
|
|
794
|
+
catch (e) {
|
|
795
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
// Bash mode
|
|
799
|
+
app.post('/v1/sessions/:id/bash', async (req, reply) => {
|
|
800
|
+
const parsed = bashSchema.safeParse(req.body);
|
|
801
|
+
if (!parsed.success)
|
|
802
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
803
|
+
const { command } = parsed.data;
|
|
804
|
+
try {
|
|
805
|
+
const cmd = command.startsWith('!') ? command : `!${command}`;
|
|
806
|
+
await sessions.sendMessage(req.params.id, cmd);
|
|
807
|
+
return { ok: true };
|
|
808
|
+
}
|
|
809
|
+
catch (e) {
|
|
810
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
app.post('/sessions/:id/bash', async (req, reply) => {
|
|
814
|
+
const parsed = bashSchema.safeParse(req.body);
|
|
815
|
+
if (!parsed.success)
|
|
816
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
817
|
+
const { command } = parsed.data;
|
|
818
|
+
try {
|
|
819
|
+
const cmd = command.startsWith('!') ? command : `!${command}`;
|
|
820
|
+
await sessions.sendMessage(req.params.id, cmd);
|
|
821
|
+
return { ok: true };
|
|
822
|
+
}
|
|
823
|
+
catch (e) {
|
|
824
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
// Session summary (Issue #35)
|
|
828
|
+
app.get('/v1/sessions/:id/summary', async (req, reply) => {
|
|
829
|
+
try {
|
|
830
|
+
return await sessions.getSummary(req.params.id);
|
|
831
|
+
}
|
|
832
|
+
catch (e) {
|
|
833
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
app.get('/sessions/:id/summary', async (req, reply) => {
|
|
837
|
+
try {
|
|
838
|
+
return await sessions.getSummary(req.params.id);
|
|
839
|
+
}
|
|
840
|
+
catch (e) {
|
|
841
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
842
|
+
}
|
|
843
|
+
});
|
|
844
|
+
// Paginated transcript read
|
|
845
|
+
app.get('/v1/sessions/:id/transcript', async (req, reply) => {
|
|
846
|
+
try {
|
|
847
|
+
const page = Math.max(1, parseInt(req.query.page || '1', 10) || 1);
|
|
848
|
+
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit || '50', 10) || 50));
|
|
849
|
+
const roleFilter = req.query.role;
|
|
850
|
+
return await sessions.readTranscript(req.params.id, page, limit, roleFilter);
|
|
851
|
+
}
|
|
852
|
+
catch (e) {
|
|
853
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
// Screenshot capture (Issue #22)
|
|
857
|
+
app.post('/v1/sessions/:id/screenshot', async (req, reply) => {
|
|
858
|
+
const parsed = screenshotSchema.safeParse(req.body);
|
|
859
|
+
if (!parsed.success)
|
|
860
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
861
|
+
const { url, fullPage, width, height } = parsed.data;
|
|
862
|
+
const urlError = validateScreenshotUrl(url);
|
|
863
|
+
if (urlError)
|
|
864
|
+
return reply.status(400).send({ error: urlError });
|
|
865
|
+
// Post-DNS-resolution check: resolve hostname and reject private IPs
|
|
866
|
+
const ipError = await resolveAndCheckIp(new URL(url).hostname);
|
|
867
|
+
if (ipError)
|
|
868
|
+
return reply.status(400).send({ error: ipError });
|
|
869
|
+
// Validate session exists
|
|
870
|
+
const session = sessions.getSession(req.params.id);
|
|
871
|
+
if (!session)
|
|
872
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
873
|
+
if (!isPlaywrightAvailable()) {
|
|
874
|
+
return reply.status(501).send({
|
|
875
|
+
error: 'Playwright is not installed',
|
|
876
|
+
message: 'Install Playwright to enable screenshots: npx playwright install chromium && npm install -D playwright',
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
try {
|
|
880
|
+
const result = await captureScreenshot({ url, fullPage, width, height });
|
|
881
|
+
return reply.status(200).send(result);
|
|
882
|
+
}
|
|
883
|
+
catch (e) {
|
|
884
|
+
return reply.status(500).send({ error: `Screenshot failed: ${e instanceof Error ? e.message : String(e)}` });
|
|
885
|
+
}
|
|
886
|
+
});
|
|
887
|
+
app.post('/sessions/:id/screenshot', async (req, reply) => {
|
|
888
|
+
const parsed = screenshotSchema.safeParse(req.body);
|
|
889
|
+
if (!parsed.success)
|
|
890
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
891
|
+
const { url, fullPage, width, height } = parsed.data;
|
|
892
|
+
const urlError = validateScreenshotUrl(url);
|
|
893
|
+
if (urlError)
|
|
894
|
+
return reply.status(400).send({ error: urlError });
|
|
895
|
+
// Post-DNS-resolution check: resolve hostname and reject private IPs
|
|
896
|
+
const dnsError = await resolveAndCheckIp(new URL(url).hostname);
|
|
897
|
+
if (dnsError)
|
|
898
|
+
return reply.status(400).send({ error: dnsError });
|
|
899
|
+
const session = sessions.getSession(req.params.id);
|
|
900
|
+
if (!session)
|
|
901
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
902
|
+
if (!isPlaywrightAvailable()) {
|
|
903
|
+
return reply.status(501).send({
|
|
904
|
+
error: 'Playwright is not installed',
|
|
905
|
+
message: 'Install Playwright to enable screenshots: npx playwright install chromium && npm install -D playwright',
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
try {
|
|
909
|
+
const result = await captureScreenshot({ url, fullPage, width, height });
|
|
910
|
+
return reply.status(200).send(result);
|
|
911
|
+
}
|
|
912
|
+
catch (e) {
|
|
913
|
+
return reply.status(500).send({ error: `Screenshot failed: ${e instanceof Error ? e.message : String(e)}` });
|
|
914
|
+
}
|
|
915
|
+
});
|
|
916
|
+
// SSE event stream (Issue #32)
|
|
917
|
+
app.get('/v1/sessions/:id/events', async (req, reply) => {
|
|
918
|
+
const session = sessions.getSession(req.params.id);
|
|
919
|
+
if (!session)
|
|
920
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
921
|
+
const clientIp = req.ip;
|
|
922
|
+
const acquireResult = sseLimiter.acquire(clientIp);
|
|
923
|
+
if (!acquireResult.allowed) {
|
|
924
|
+
const status = acquireResult.reason === 'per_ip_limit' ? 429 : 503;
|
|
925
|
+
return reply.status(status).send({
|
|
926
|
+
error: acquireResult.reason === 'per_ip_limit'
|
|
927
|
+
? `Per-IP connection limit reached (${acquireResult.current}/${acquireResult.limit})`
|
|
928
|
+
: `Global connection limit reached (${acquireResult.current}/${acquireResult.limit})`,
|
|
929
|
+
reason: acquireResult.reason,
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
// Issue #505: Subscribe BEFORE writing response headers so that if
|
|
933
|
+
// subscription fails, we can still return a proper HTTP error.
|
|
934
|
+
let unsubscribe;
|
|
935
|
+
const connectionId = acquireResult.connectionId;
|
|
936
|
+
let writer;
|
|
937
|
+
// Queue events that arrive between subscription and writer creation
|
|
938
|
+
const pendingEvents = [];
|
|
939
|
+
let subscriptionReady = false;
|
|
940
|
+
try {
|
|
941
|
+
const handler = (event) => {
|
|
942
|
+
if (!subscriptionReady) {
|
|
943
|
+
pendingEvents.push(event);
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
const id = event.id != null ? `id: ${event.id}\n` : '';
|
|
947
|
+
writer.write(`${id}data: ${JSON.stringify(event)}\n\n`);
|
|
948
|
+
};
|
|
949
|
+
unsubscribe = eventBus.subscribe(req.params.id, handler);
|
|
950
|
+
}
|
|
951
|
+
catch (err) {
|
|
952
|
+
req.log.error({ err, sessionId: req.params.id }, 'SSE subscription failed — unable to create event listener');
|
|
953
|
+
sseLimiter.release(connectionId);
|
|
954
|
+
return reply.status(500).send({ error: 'Failed to create SSE subscription' });
|
|
955
|
+
}
|
|
956
|
+
reply.raw.writeHead(200, {
|
|
957
|
+
'Content-Type': 'text/event-stream',
|
|
958
|
+
'Cache-Control': 'no-cache',
|
|
959
|
+
'Connection': 'keep-alive',
|
|
960
|
+
'X-Accel-Buffering': 'no',
|
|
961
|
+
});
|
|
962
|
+
writer = new SSEWriter(reply.raw, req.raw, () => {
|
|
963
|
+
unsubscribe?.();
|
|
964
|
+
sseLimiter.release(connectionId);
|
|
965
|
+
});
|
|
966
|
+
// Now safe to deliver events — flush any queued during setup
|
|
967
|
+
subscriptionReady = true;
|
|
968
|
+
for (const event of pendingEvents) {
|
|
969
|
+
const id = event.id != null ? `id: ${event.id}\n` : '';
|
|
970
|
+
writer.write(`${id}data: ${JSON.stringify(event)}\n\n`);
|
|
971
|
+
}
|
|
972
|
+
// Send initial connected event
|
|
973
|
+
writer.write(`data: ${JSON.stringify({ event: 'connected', sessionId: session.id, timestamp: new Date().toISOString() })}\n\n`);
|
|
974
|
+
// Issue #308: Replay missed events if client sends Last-Event-ID
|
|
975
|
+
const lastEventId = req.headers['last-event-id'];
|
|
976
|
+
if (lastEventId) {
|
|
977
|
+
const missed = eventBus.getEventsSince(req.params.id, parseInt(lastEventId, 10) || 0);
|
|
978
|
+
for (const event of missed) {
|
|
979
|
+
const id = event.id != null ? `id: ${event.id}\n` : '';
|
|
980
|
+
writer.write(`${id}data: ${JSON.stringify(event)}\n\n`);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
writer.startHeartbeat(30_000, 90_000, () => `data: ${JSON.stringify({ event: 'heartbeat', sessionId: session.id, timestamp: new Date().toISOString() })}\n\n`);
|
|
984
|
+
// Don't let Fastify auto-send (we manage the response manually)
|
|
985
|
+
await reply;
|
|
986
|
+
});
|
|
987
|
+
// ── Claude Code Hook Endpoints (Issue #161) ─────────────────────────
|
|
988
|
+
// POST /v1/sessions/:id/hooks/permission — PermissionRequest hook from CC
|
|
989
|
+
app.post('/v1/sessions/:id/hooks/permission', async (req, reply) => {
|
|
990
|
+
const session = sessions.getSession(req.params.id);
|
|
991
|
+
if (!session)
|
|
992
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
993
|
+
const parsed = permissionHookSchema.safeParse(req.body);
|
|
994
|
+
if (!parsed.success)
|
|
995
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
996
|
+
const { tool_name, tool_input, permission_mode } = parsed.data;
|
|
997
|
+
// Update session status
|
|
998
|
+
session.status = 'permission_prompt';
|
|
999
|
+
session.lastActivity = Date.now();
|
|
1000
|
+
await sessions.save();
|
|
1001
|
+
// Notify channels and SSE
|
|
1002
|
+
const detail = tool_name
|
|
1003
|
+
? `Permission request: ${tool_name}${permission_mode ? ` (${permission_mode})` : ''}`
|
|
1004
|
+
: 'Permission requested';
|
|
1005
|
+
await channels.statusChange({
|
|
1006
|
+
event: 'status.permission',
|
|
1007
|
+
timestamp: new Date().toISOString(),
|
|
1008
|
+
session: { id: session.id, name: session.windowName, workDir: session.workDir },
|
|
1009
|
+
detail,
|
|
1010
|
+
meta: { tool_name, tool_input, permission_mode },
|
|
1011
|
+
});
|
|
1012
|
+
eventBus.emitApproval(session.id, detail);
|
|
1013
|
+
return reply.status(200).send({});
|
|
1014
|
+
});
|
|
1015
|
+
// POST /v1/sessions/:id/hooks/stop — Stop hook from CC
|
|
1016
|
+
app.post('/v1/sessions/:id/hooks/stop', async (req, reply) => {
|
|
1017
|
+
const session = sessions.getSession(req.params.id);
|
|
1018
|
+
if (!session)
|
|
1019
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
1020
|
+
const parsed = stopHookSchema.safeParse(req.body);
|
|
1021
|
+
if (!parsed.success)
|
|
1022
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
1023
|
+
const { stop_reason } = parsed.data;
|
|
1024
|
+
// Update session status
|
|
1025
|
+
session.status = 'idle';
|
|
1026
|
+
session.lastActivity = Date.now();
|
|
1027
|
+
await sessions.save();
|
|
1028
|
+
// Notify channels and SSE
|
|
1029
|
+
const detail = stop_reason
|
|
1030
|
+
? `Claude Code stopped: ${stop_reason}`
|
|
1031
|
+
: 'Claude Code session ended normally';
|
|
1032
|
+
await channels.statusChange({
|
|
1033
|
+
event: 'status.idle',
|
|
1034
|
+
timestamp: new Date().toISOString(),
|
|
1035
|
+
session: { id: session.id, name: session.windowName, workDir: session.workDir },
|
|
1036
|
+
detail,
|
|
1037
|
+
meta: { stop_reason },
|
|
1038
|
+
});
|
|
1039
|
+
eventBus.emitStatus(session.id, 'idle', detail);
|
|
1040
|
+
return reply.status(200).send({});
|
|
1041
|
+
});
|
|
1042
|
+
// Batch create (Issue #36)
|
|
1043
|
+
app.post('/v1/sessions/batch', async (req, reply) => {
|
|
1044
|
+
const parsed = batchSessionSchema.safeParse(req.body);
|
|
1045
|
+
if (!parsed.success)
|
|
1046
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
1047
|
+
const specs = parsed.data.sessions;
|
|
1048
|
+
for (const spec of specs) {
|
|
1049
|
+
const safeWorkDir = await validateWorkDirWithConfig(spec.workDir);
|
|
1050
|
+
if (typeof safeWorkDir === 'object') {
|
|
1051
|
+
return reply.status(400).send({ error: `Invalid workDir "${spec.workDir}": ${safeWorkDir.error}`, code: safeWorkDir.code });
|
|
1052
|
+
}
|
|
1053
|
+
spec.workDir = safeWorkDir;
|
|
1054
|
+
}
|
|
1055
|
+
const result = await pipelines.batchCreate(specs);
|
|
1056
|
+
return reply.status(201).send(result);
|
|
1057
|
+
});
|
|
1058
|
+
// Pipeline create (Issue #36)
|
|
1059
|
+
app.post('/v1/pipelines', async (req, reply) => {
|
|
1060
|
+
const parsed = pipelineSchema.safeParse(req.body);
|
|
1061
|
+
if (!parsed.success)
|
|
1062
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
1063
|
+
const pipeConfig = parsed.data;
|
|
1064
|
+
const safeWorkDir = await validateWorkDirWithConfig(pipeConfig.workDir);
|
|
1065
|
+
if (typeof safeWorkDir === 'object') {
|
|
1066
|
+
return reply.status(400).send({ error: `Invalid workDir: ${safeWorkDir.error}`, code: safeWorkDir.code });
|
|
1067
|
+
}
|
|
1068
|
+
pipeConfig.workDir = safeWorkDir;
|
|
1069
|
+
try {
|
|
1070
|
+
const pipeline = await pipelines.createPipeline(pipeConfig);
|
|
1071
|
+
return reply.status(201).send(pipeline);
|
|
1072
|
+
}
|
|
1073
|
+
catch (e) {
|
|
1074
|
+
return reply.status(400).send({ error: e instanceof Error ? e.message : String(e) });
|
|
1075
|
+
}
|
|
1076
|
+
});
|
|
1077
|
+
// Pipeline status
|
|
1078
|
+
app.get('/v1/pipelines/:id', async (req, reply) => {
|
|
1079
|
+
const pipeline = pipelines.getPipeline(req.params.id);
|
|
1080
|
+
if (!pipeline)
|
|
1081
|
+
return reply.status(404).send({ error: 'Pipeline not found' });
|
|
1082
|
+
return pipeline;
|
|
1083
|
+
});
|
|
1084
|
+
// List pipelines
|
|
1085
|
+
app.get('/v1/pipelines', async () => pipelines.listPipelines());
|
|
1086
|
+
// ── Session Reaper ──────────────────────────────────────────────────
|
|
1087
|
+
async function reapStaleSessions(maxAgeMs) {
|
|
1088
|
+
const now = Date.now();
|
|
1089
|
+
// Snapshot list before iterating — killSession() modifies the sessions map
|
|
1090
|
+
const snapshot = [...sessions.listSessions()];
|
|
1091
|
+
for (const session of snapshot) {
|
|
1092
|
+
// Guard: session may have been deleted by DELETE handler between snapshot and here
|
|
1093
|
+
if (!sessions.getSession(session.id))
|
|
1094
|
+
continue;
|
|
1095
|
+
const age = now - session.createdAt;
|
|
1096
|
+
if (age > maxAgeMs) {
|
|
1097
|
+
const ageMin = Math.round(age / 60000);
|
|
1098
|
+
console.log(`Reaper: killing session ${session.windowName} (${session.id.slice(0, 8)}) — age ${ageMin}min`);
|
|
1099
|
+
try {
|
|
1100
|
+
await channels.sessionEnded({
|
|
1101
|
+
event: 'session.ended',
|
|
1102
|
+
timestamp: new Date().toISOString(),
|
|
1103
|
+
session: { id: session.id, name: session.windowName, workDir: session.workDir },
|
|
1104
|
+
detail: `Auto-killed: exceeded ${maxAgeMs / 3600000}h time limit`,
|
|
1105
|
+
});
|
|
1106
|
+
await sessions.killSession(session.id);
|
|
1107
|
+
monitor.removeSession(session.id);
|
|
1108
|
+
metrics.cleanupSession(session.id);
|
|
1109
|
+
}
|
|
1110
|
+
catch (e) {
|
|
1111
|
+
console.error(`Reaper: failed to kill session ${session.id}:`, e);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
// ── Zombie Reaper (Issue #283) ──────────────────────────────────────
|
|
1117
|
+
const ZOMBIE_REAP_DELAY_MS = parseIntSafe(process.env.ZOMBIE_REAP_DELAY_MS, 60000);
|
|
1118
|
+
const ZOMBIE_REAP_INTERVAL_MS = parseIntSafe(process.env.ZOMBIE_REAP_INTERVAL_MS, 60000);
|
|
1119
|
+
async function reapZombieSessions() {
|
|
1120
|
+
const now = Date.now();
|
|
1121
|
+
// Snapshot list before iterating — killSession() modifies the sessions map
|
|
1122
|
+
const snapshot = [...sessions.listSessions()];
|
|
1123
|
+
for (const session of snapshot) {
|
|
1124
|
+
// Guard: session may have been deleted between snapshot and here
|
|
1125
|
+
if (!sessions.getSession(session.id))
|
|
1126
|
+
continue;
|
|
1127
|
+
if (!session.lastDeadAt)
|
|
1128
|
+
continue;
|
|
1129
|
+
const deadDuration = now - session.lastDeadAt;
|
|
1130
|
+
if (deadDuration < ZOMBIE_REAP_DELAY_MS)
|
|
1131
|
+
continue;
|
|
1132
|
+
console.log(`Reaper: removing zombie session ${session.windowName} (${session.id.slice(0, 8)})`);
|
|
1133
|
+
try {
|
|
1134
|
+
monitor.removeSession(session.id);
|
|
1135
|
+
await sessions.killSession(session.id);
|
|
1136
|
+
metrics.cleanupSession(session.id);
|
|
1137
|
+
await channels.sessionEnded({
|
|
1138
|
+
event: 'session.ended',
|
|
1139
|
+
timestamp: new Date().toISOString(),
|
|
1140
|
+
session: { id: session.id, name: session.windowName, workDir: session.workDir },
|
|
1141
|
+
detail: `Zombie reaped: dead for ${Math.round(deadDuration / 1000)}s`,
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
catch (e) {
|
|
1145
|
+
console.error(`Reaper: failed to reap zombie session ${session.id}:`, e);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
1150
|
+
/** Issue #20: Add actionHints to session response for interactive states. */
|
|
1151
|
+
function addActionHints(session) {
|
|
1152
|
+
// #357: Convert Set to array for JSON serialization
|
|
1153
|
+
const result = {
|
|
1154
|
+
...session,
|
|
1155
|
+
activeSubagents: session.activeSubagents ? [...session.activeSubagents] : undefined,
|
|
1156
|
+
};
|
|
1157
|
+
if (session.status === 'permission_prompt' || session.status === 'bash_approval') {
|
|
1158
|
+
result.actionHints = {
|
|
1159
|
+
approve: { method: 'POST', url: `/v1/sessions/${session.id}/approve`, description: 'Approve the pending permission' },
|
|
1160
|
+
reject: { method: 'POST', url: `/v1/sessions/${session.id}/reject`, description: 'Reject the pending permission' },
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
return result;
|
|
1164
|
+
}
|
|
1165
|
+
function makePayload(event, sessionId, detail, meta) {
|
|
1166
|
+
const session = sessions.getSession(sessionId);
|
|
1167
|
+
return {
|
|
1168
|
+
event,
|
|
1169
|
+
timestamp: new Date().toISOString(),
|
|
1170
|
+
session: {
|
|
1171
|
+
id: sessionId,
|
|
1172
|
+
name: session?.windowName || 'unknown',
|
|
1173
|
+
workDir: session?.workDir || '',
|
|
1174
|
+
},
|
|
1175
|
+
detail,
|
|
1176
|
+
...(meta && { meta }),
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
// ── Start ────────────────────────────────────────────────────────────
|
|
1180
|
+
/** Register notification channels from config */
|
|
1181
|
+
function registerChannels(cfg) {
|
|
1182
|
+
// Telegram (optional)
|
|
1183
|
+
if (cfg.tgBotToken && cfg.tgGroupId) {
|
|
1184
|
+
channels.register(new TelegramChannel({
|
|
1185
|
+
botToken: cfg.tgBotToken,
|
|
1186
|
+
groupChatId: cfg.tgGroupId,
|
|
1187
|
+
allowedUserIds: cfg.tgAllowedUsers,
|
|
1188
|
+
}));
|
|
1189
|
+
}
|
|
1190
|
+
// Webhooks (optional)
|
|
1191
|
+
if (cfg.webhooks.length > 0) {
|
|
1192
|
+
const webhookChannel = new WebhookChannel({
|
|
1193
|
+
endpoints: cfg.webhooks.map(url => ({ url })),
|
|
1194
|
+
});
|
|
1195
|
+
channels.register(webhookChannel);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
// ── PID file (peer Aegis detection) ───────────────────────────────────
|
|
1199
|
+
let pidFilePath = '';
|
|
1200
|
+
function writePidFile() {
|
|
1201
|
+
try {
|
|
1202
|
+
pidFilePath = path.join(config.stateDir, 'aegis.pid');
|
|
1203
|
+
writeFileSync(pidFilePath, String(process.pid));
|
|
1204
|
+
}
|
|
1205
|
+
catch { /* non-critical */ }
|
|
1206
|
+
}
|
|
1207
|
+
function readPidFile() {
|
|
1208
|
+
try {
|
|
1209
|
+
const p = path.join(config.stateDir, 'aegis.pid');
|
|
1210
|
+
const content = readFileSync(p, 'utf-8').trim();
|
|
1211
|
+
const pid = parseInt(content, 10);
|
|
1212
|
+
return isNaN(pid) ? null : pid;
|
|
1213
|
+
}
|
|
1214
|
+
catch { /* pid file missing or unreadable */
|
|
1215
|
+
return null;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
// ── Port conflict recovery (Issue #99, #162) ──────────────────────────
|
|
1219
|
+
/**
|
|
1220
|
+
* Check if a PID exists using `process.kill(pid, 0)`.
|
|
1221
|
+
*/
|
|
1222
|
+
function pidExists(pid) {
|
|
1223
|
+
try {
|
|
1224
|
+
process.kill(pid, 0);
|
|
1225
|
+
return true;
|
|
1226
|
+
}
|
|
1227
|
+
catch { /* ESRCH — process does not exist */
|
|
1228
|
+
return false;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Check if a PID is an ancestor of the current process.
|
|
1233
|
+
*/
|
|
1234
|
+
function isAncestorPid(pid) {
|
|
1235
|
+
try {
|
|
1236
|
+
let current = process.ppid;
|
|
1237
|
+
for (let depth = 0; depth < 10 && current > 1; depth++) {
|
|
1238
|
+
if (current === pid)
|
|
1239
|
+
return true;
|
|
1240
|
+
try {
|
|
1241
|
+
current = parseInt(readFileSync(`/proc/${current}/stat`, 'utf-8').split(' ')[1], 10);
|
|
1242
|
+
}
|
|
1243
|
+
catch { /* /proc unavailable or process gone — stop walking */
|
|
1244
|
+
break;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
catch { /* ignore */ }
|
|
1249
|
+
return false;
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Wait for a port to be released with exponential backoff.
|
|
1253
|
+
*/
|
|
1254
|
+
async function waitForPortRelease(port, maxWaitMs = 5_000) {
|
|
1255
|
+
const net = await import('node:net');
|
|
1256
|
+
const start = Date.now();
|
|
1257
|
+
let delay = 200;
|
|
1258
|
+
while (Date.now() - start < maxWaitMs) {
|
|
1259
|
+
try {
|
|
1260
|
+
await new Promise((resolve, reject) => {
|
|
1261
|
+
const sock = net.createServer();
|
|
1262
|
+
sock.once('error', reject);
|
|
1263
|
+
sock.listen(port, '127.0.0.1', () => {
|
|
1264
|
+
sock.close();
|
|
1265
|
+
reject(new Error('port free')); // signal success
|
|
1266
|
+
});
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
catch (err) {
|
|
1270
|
+
if (err instanceof Error && err.message === 'port free')
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
1274
|
+
delay = Math.min(delay * 1.5, 1_000);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Kill stale process holding a port. Returns true if a process was killed.
|
|
1279
|
+
* Uses `lsof` to find the PID, verifies it exists, skips ancestors,
|
|
1280
|
+
* and tries SIGTERM before SIGKILL.
|
|
1281
|
+
*/
|
|
1282
|
+
async function killStalePortHolder(port) {
|
|
1283
|
+
// Small random delay to reduce race window with systemd restarts
|
|
1284
|
+
await new Promise(resolve => setTimeout(resolve, 100 + Math.random() * 400));
|
|
1285
|
+
try {
|
|
1286
|
+
const output = execSync(`lsof -ti tcp:${port}`, { encoding: 'utf-8', timeout: 5_000 }).trim();
|
|
1287
|
+
if (!output)
|
|
1288
|
+
return false;
|
|
1289
|
+
const pids = output.split('\n').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
|
|
1290
|
+
if (pids.length === 0)
|
|
1291
|
+
return false;
|
|
1292
|
+
let killed = false;
|
|
1293
|
+
for (const pid of pids) {
|
|
1294
|
+
// Skip own PID
|
|
1295
|
+
if (pid === process.pid)
|
|
1296
|
+
continue;
|
|
1297
|
+
// Skip ancestors to avoid killing parent process (e.g. systemd supervisor)
|
|
1298
|
+
if (isAncestorPid(pid)) {
|
|
1299
|
+
console.warn(`EADDRINUSE recovery: skipping ancestor PID ${pid} on port ${port}`);
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
1302
|
+
// Skip peer Aegis instance (another Aegis process that wrote the PID file)
|
|
1303
|
+
const pidFilePid = readPidFile();
|
|
1304
|
+
if (pidFilePid !== null && pid === pidFilePid && pid !== process.pid) {
|
|
1305
|
+
console.warn(`EADDRINUSE recovery: skipping peer Aegis PID ${pid} (PID file match) on port ${port}`);
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
1308
|
+
// Verify PID exists before attempting to kill
|
|
1309
|
+
if (!pidExists(pid))
|
|
1310
|
+
continue;
|
|
1311
|
+
console.warn(`EADDRINUSE recovery: killing stale process PID ${pid} on port ${port}`);
|
|
1312
|
+
// Try SIGTERM first for graceful shutdown
|
|
1313
|
+
try {
|
|
1314
|
+
process.kill(pid, 'SIGTERM');
|
|
1315
|
+
await new Promise(resolve => setTimeout(resolve, 2_000));
|
|
1316
|
+
// Check if process exited after SIGTERM
|
|
1317
|
+
if (!pidExists(pid)) {
|
|
1318
|
+
killed = true;
|
|
1319
|
+
continue;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
catch { /* process may have already exited */ }
|
|
1323
|
+
// Fallback to SIGKILL if SIGTERM didn't work
|
|
1324
|
+
try {
|
|
1325
|
+
process.kill(pid, 'SIGKILL');
|
|
1326
|
+
killed = true;
|
|
1327
|
+
}
|
|
1328
|
+
catch { /* already dead */ }
|
|
1329
|
+
}
|
|
1330
|
+
if (killed) {
|
|
1331
|
+
await waitForPortRelease(port);
|
|
1332
|
+
}
|
|
1333
|
+
return killed;
|
|
1334
|
+
}
|
|
1335
|
+
catch {
|
|
1336
|
+
// lsof not found or no process on port — that's fine
|
|
1337
|
+
return false;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* Listen with EADDRINUSE recovery: if port is taken, kill the stale holder and retry once.
|
|
1342
|
+
*/
|
|
1343
|
+
async function listenWithRetry(app, port, host, maxRetries = 1) {
|
|
1344
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
1345
|
+
try {
|
|
1346
|
+
await app.listen({ port, host });
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
catch (err) {
|
|
1350
|
+
if (!(err instanceof Error && 'code' in err && err.code === 'EADDRINUSE') || attempt >= maxRetries) {
|
|
1351
|
+
throw err;
|
|
1352
|
+
}
|
|
1353
|
+
console.error(`EADDRINUSE on port ${port} — attempting recovery (attempt ${attempt + 1}/${maxRetries})`);
|
|
1354
|
+
const killed = await killStalePortHolder(port);
|
|
1355
|
+
if (!killed) {
|
|
1356
|
+
console.error(`EADDRINUSE recovery failed: no stale process found on port ${port}`);
|
|
1357
|
+
throw err;
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
async function main() {
|
|
1363
|
+
// Load configuration
|
|
1364
|
+
config = await loadConfig();
|
|
1365
|
+
// Initialize core components with config
|
|
1366
|
+
tmux = new TmuxManager(config.tmuxSession);
|
|
1367
|
+
sessions = new SessionManager(tmux, config);
|
|
1368
|
+
sseLimiter = new SSEConnectionLimiter({ maxConnections: config.sseMaxConnections, maxPerIp: config.sseMaxPerIp });
|
|
1369
|
+
monitor = new SessionMonitor(sessions, channels, { ...DEFAULT_MONITOR_CONFIG, pollIntervalMs: 5000 });
|
|
1370
|
+
// Register channels
|
|
1371
|
+
registerChannels(config);
|
|
1372
|
+
// Setup auth (Issue #39: multi-key + backward compat)
|
|
1373
|
+
const { join } = await import('node:path');
|
|
1374
|
+
auth = new AuthManager(join(config.stateDir, 'keys.json'), config.authToken);
|
|
1375
|
+
await auth.load();
|
|
1376
|
+
setupAuth(auth);
|
|
1377
|
+
// Register WebSocket plugin for live terminal streaming (Issue #108)
|
|
1378
|
+
await app.register(fastifyWebsocket);
|
|
1379
|
+
registerWsTerminalRoute(app, sessions, tmux, auth);
|
|
1380
|
+
// #217: CORS configuration — restrictive by default
|
|
1381
|
+
const corsOrigin = process.env.CORS_ORIGIN;
|
|
1382
|
+
await app.register(fastifyCors, {
|
|
1383
|
+
origin: corsOrigin ? corsOrigin.split(',').map(s => s.trim()) : false,
|
|
1384
|
+
});
|
|
1385
|
+
// Load persisted sessions
|
|
1386
|
+
await sessions.load();
|
|
1387
|
+
await tmux.ensureSession();
|
|
1388
|
+
// Initialize channels
|
|
1389
|
+
await channels.init(handleInbound);
|
|
1390
|
+
// Wire SSE event bus (Issue #32)
|
|
1391
|
+
monitor.setEventBus(eventBus);
|
|
1392
|
+
// Issue #84: Wire JSONL watcher for fs.watch-based message detection
|
|
1393
|
+
jsonlWatcher = new JsonlWatcher();
|
|
1394
|
+
monitor.setJsonlWatcher(jsonlWatcher);
|
|
1395
|
+
// Start watching JSONL files for already-discovered sessions
|
|
1396
|
+
for (const session of sessions.listSessions()) {
|
|
1397
|
+
if (session.jsonlPath) {
|
|
1398
|
+
jsonlWatcher.watch(session.id, session.jsonlPath, session.monitorOffset);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
// Register HTTP hook receiver (Issue #169, Issue #87: pass metrics for latency tracking)
|
|
1402
|
+
registerHookRoutes(app, { sessions, eventBus, metrics });
|
|
1403
|
+
// Initialize pipeline manager (Issue #36)
|
|
1404
|
+
pipelines = new PipelineManager(sessions, eventBus);
|
|
1405
|
+
// Initialize metrics (Issue #40)
|
|
1406
|
+
metrics = new MetricsCollector(join(config.stateDir, 'metrics.json'));
|
|
1407
|
+
await metrics.load();
|
|
1408
|
+
// Issue #361: Store interval refs so graceful shutdown can clear them
|
|
1409
|
+
const reaperInterval = setInterval(() => reapStaleSessions(config.maxSessionAgeMs), config.reaperIntervalMs);
|
|
1410
|
+
const zombieReaperInterval = setInterval(() => reapZombieSessions(), ZOMBIE_REAP_INTERVAL_MS);
|
|
1411
|
+
const metricsSaveInterval = setInterval(() => { void metrics.save(); }, 5 * 60 * 1000);
|
|
1412
|
+
// #357: Prune stale IP rate-limit entries every minute
|
|
1413
|
+
const ipPruneInterval = setInterval(pruneIpRateLimits, 60_000);
|
|
1414
|
+
// Issue #361: Graceful shutdown handler
|
|
1415
|
+
// Issue #415: Reentrance guard at handler level prevents double execution on rapid SIGINT
|
|
1416
|
+
let shuttingDown = false;
|
|
1417
|
+
async function gracefulShutdown(signal) {
|
|
1418
|
+
console.log(`${signal} received, shutting down gracefully...`);
|
|
1419
|
+
// 1. Stop accepting new requests
|
|
1420
|
+
try {
|
|
1421
|
+
await app.close();
|
|
1422
|
+
}
|
|
1423
|
+
catch (e) {
|
|
1424
|
+
console.error('Error closing server:', e);
|
|
1425
|
+
}
|
|
1426
|
+
// 2. Stop background monitors and intervals
|
|
1427
|
+
monitor.stop();
|
|
1428
|
+
swarmMonitor.stop();
|
|
1429
|
+
clearInterval(reaperInterval);
|
|
1430
|
+
clearInterval(zombieReaperInterval);
|
|
1431
|
+
clearInterval(metricsSaveInterval);
|
|
1432
|
+
clearInterval(ipPruneInterval);
|
|
1433
|
+
// 3. Destroy channels (awaits Telegram poll loop)
|
|
1434
|
+
try {
|
|
1435
|
+
await channels.destroy();
|
|
1436
|
+
}
|
|
1437
|
+
catch (e) {
|
|
1438
|
+
console.error('Error destroying channels:', e);
|
|
1439
|
+
}
|
|
1440
|
+
// 4. Save session state
|
|
1441
|
+
try {
|
|
1442
|
+
await sessions.save();
|
|
1443
|
+
}
|
|
1444
|
+
catch (e) {
|
|
1445
|
+
console.error('Error saving sessions:', e);
|
|
1446
|
+
}
|
|
1447
|
+
// 5. Save metrics
|
|
1448
|
+
try {
|
|
1449
|
+
await metrics.save();
|
|
1450
|
+
}
|
|
1451
|
+
catch (e) {
|
|
1452
|
+
console.error('Error saving metrics:', e);
|
|
1453
|
+
}
|
|
1454
|
+
// 6. Cleanup PID file
|
|
1455
|
+
try {
|
|
1456
|
+
if (pidFilePath) {
|
|
1457
|
+
unlinkSync(pidFilePath);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
catch { /* non-critical */ }
|
|
1461
|
+
console.log('Graceful shutdown complete');
|
|
1462
|
+
process.exit(0);
|
|
1463
|
+
}
|
|
1464
|
+
process.on('SIGTERM', () => { if (!shuttingDown) {
|
|
1465
|
+
shuttingDown = true;
|
|
1466
|
+
void gracefulShutdown('SIGTERM');
|
|
1467
|
+
} });
|
|
1468
|
+
process.on('SIGINT', () => { if (!shuttingDown) {
|
|
1469
|
+
shuttingDown = true;
|
|
1470
|
+
void gracefulShutdown('SIGINT');
|
|
1471
|
+
} });
|
|
1472
|
+
process.on('unhandledRejection', (reason) => {
|
|
1473
|
+
console.error('unhandledRejection:', reason);
|
|
1474
|
+
});
|
|
1475
|
+
// Start monitor
|
|
1476
|
+
monitor.start();
|
|
1477
|
+
// Issue #81: Start swarm monitor for agent swarm awareness
|
|
1478
|
+
swarmMonitor = new SwarmMonitor(sessions);
|
|
1479
|
+
swarmMonitor.onEvent((event) => {
|
|
1480
|
+
if (!event.swarm.parentSession)
|
|
1481
|
+
return;
|
|
1482
|
+
const parentId = event.swarm.parentSession.id;
|
|
1483
|
+
const teammate = event.teammate;
|
|
1484
|
+
if (event.type === 'teammate_spawned') {
|
|
1485
|
+
const detail = `🔧 Teammate ${teammate.windowName} spawned`;
|
|
1486
|
+
eventBus.emit(parentId, {
|
|
1487
|
+
event: 'subagent_start',
|
|
1488
|
+
sessionId: parentId,
|
|
1489
|
+
timestamp: new Date().toISOString(),
|
|
1490
|
+
data: { teammate: teammate.windowName, windowId: teammate.windowId },
|
|
1491
|
+
});
|
|
1492
|
+
channels.swarmEvent(makePayload('swarm.teammate_spawned', parentId, detail, {
|
|
1493
|
+
teammateName: teammate.windowName,
|
|
1494
|
+
teammateWindowId: teammate.windowId,
|
|
1495
|
+
teammateCwd: teammate.cwd,
|
|
1496
|
+
}));
|
|
1497
|
+
}
|
|
1498
|
+
else if (event.type === 'teammate_finished') {
|
|
1499
|
+
const detail = `✅ Teammate ${teammate.windowName} finished`;
|
|
1500
|
+
eventBus.emit(parentId, {
|
|
1501
|
+
event: 'subagent_stop',
|
|
1502
|
+
sessionId: parentId,
|
|
1503
|
+
timestamp: new Date().toISOString(),
|
|
1504
|
+
data: { teammate: teammate.windowName },
|
|
1505
|
+
});
|
|
1506
|
+
channels.swarmEvent(makePayload('swarm.teammate_finished', parentId, detail, {
|
|
1507
|
+
teammateName: teammate.windowName,
|
|
1508
|
+
}));
|
|
1509
|
+
}
|
|
1510
|
+
});
|
|
1511
|
+
swarmMonitor.start();
|
|
1512
|
+
// Issue #71: Wire swarm monitor into Telegram channel for /swarm command
|
|
1513
|
+
for (const ch of channels.getChannels()) {
|
|
1514
|
+
if ('setSwarmMonitor' in ch && typeof ch.setSwarmMonitor === 'function') {
|
|
1515
|
+
ch.setSwarmMonitor(swarmMonitor);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
// Start reaper (intervals already created above with stored refs for graceful shutdown)
|
|
1519
|
+
console.log(`Session reaper active: max age ${config.maxSessionAgeMs / 3600000}h, check every ${config.reaperIntervalMs / 60000}min`);
|
|
1520
|
+
// Start zombie reaper (Issue #283)
|
|
1521
|
+
console.log(`Zombie reaper active: grace period ${ZOMBIE_REAP_DELAY_MS / 1000}s, check every ${ZOMBIE_REAP_INTERVAL_MS / 1000}s`);
|
|
1522
|
+
// #127: Serve dashboard static files (Issue #105) — graceful if missing
|
|
1523
|
+
// Issue #539: Dashboard is copied into dist/dashboard/ during build
|
|
1524
|
+
const dashboardRoot = path.join(__dirname, "dashboard");
|
|
1525
|
+
let dashboardAvailable = false;
|
|
1526
|
+
try {
|
|
1527
|
+
await fs.access(dashboardRoot);
|
|
1528
|
+
dashboardAvailable = true;
|
|
1529
|
+
}
|
|
1530
|
+
catch {
|
|
1531
|
+
console.warn("Dashboard directory not found — skipping dashboard serving. Run 'npm run build:dashboard' to enable.");
|
|
1532
|
+
}
|
|
1533
|
+
if (dashboardAvailable) {
|
|
1534
|
+
await app.register(fastifyStatic, {
|
|
1535
|
+
root: dashboardRoot,
|
|
1536
|
+
prefix: "/dashboard/",
|
|
1537
|
+
// #146: Cache hashed assets aggressively, no-cache for index.html
|
|
1538
|
+
setHeaders: (reply, pathname) => {
|
|
1539
|
+
// Security headers (#145)
|
|
1540
|
+
reply.setHeader('X-Frame-Options', 'DENY');
|
|
1541
|
+
reply.setHeader('X-Content-Type-Options', 'nosniff');
|
|
1542
|
+
reply.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
1543
|
+
// Issue #349: Content-Security-Policy for dashboard
|
|
1544
|
+
reply.setHeader('Content-Security-Policy', DASHBOARD_CSP);
|
|
1545
|
+
// Cache control (#146)
|
|
1546
|
+
if (pathname === '/index.html' || pathname === '/') {
|
|
1547
|
+
reply.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
1548
|
+
}
|
|
1549
|
+
else {
|
|
1550
|
+
reply.setHeader('Cache-Control', 'public, max-age=604800, immutable');
|
|
1551
|
+
}
|
|
1552
|
+
},
|
|
1553
|
+
});
|
|
1554
|
+
}
|
|
1555
|
+
// SPA fallback for dashboard routes (Issue #105)
|
|
1556
|
+
app.setNotFoundHandler(async (req, reply) => {
|
|
1557
|
+
if (dashboardAvailable && (req.url === "/dashboard" || req.url?.startsWith("/dashboard/") || req.url?.startsWith("/dashboard?"))) {
|
|
1558
|
+
// Issue #349: CSP header for SPA dashboard responses
|
|
1559
|
+
reply.header('Content-Security-Policy', DASHBOARD_CSP);
|
|
1560
|
+
return reply.sendFile("index.html", dashboardRoot);
|
|
1561
|
+
}
|
|
1562
|
+
return reply.status(404).send({ error: "Not found" });
|
|
1563
|
+
});
|
|
1564
|
+
await listenWithRetry(app, config.port, config.host);
|
|
1565
|
+
writePidFile();
|
|
1566
|
+
console.log(`Aegis running on http://${config.host}:${config.port}`);
|
|
1567
|
+
console.log(`Channels: ${channels.count} registered`);
|
|
1568
|
+
console.log(`State dir: ${config.stateDir}`);
|
|
1569
|
+
console.log(`Claude projects dir: ${config.claudeProjectsDir}`);
|
|
1570
|
+
if (config.authToken)
|
|
1571
|
+
console.log('Auth: Bearer token required');
|
|
1572
|
+
}
|
|
1573
|
+
main().catch(err => {
|
|
1574
|
+
console.error('Failed to start Aegis:', err);
|
|
1575
|
+
process.exit(1);
|
|
1576
|
+
});
|
|
1577
|
+
//# sourceMappingURL=server.js.map
|