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/server.js
ADDED
|
@@ -0,0 +1,1973 @@
|
|
|
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 fastifyStatic from '@fastify/static';
|
|
13
|
+
import fastifyWebsocket from '@fastify/websocket';
|
|
14
|
+
import fastifyCors from '@fastify/cors';
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { TmuxManager } from './tmux.js';
|
|
19
|
+
import { SessionManager } from './session.js';
|
|
20
|
+
import { SessionMonitor, DEFAULT_MONITOR_CONFIG } from './monitor.js';
|
|
21
|
+
import { JsonlWatcher } from './jsonl-watcher.js';
|
|
22
|
+
import { ChannelManager, TelegramChannel, WebhookChannel, } from './channels/index.js';
|
|
23
|
+
import { loadConfig } from './config.js';
|
|
24
|
+
import { captureScreenshot, isPlaywrightAvailable } from './screenshot.js';
|
|
25
|
+
import { validateScreenshotUrl, resolveAndCheckIp, buildHostResolverRule } from './ssrf.js';
|
|
26
|
+
import { validateWorkDir, permissionRuleSchema } from './validation.js';
|
|
27
|
+
import { SessionEventBus } from './events.js';
|
|
28
|
+
import { runVerification } from './verification.js';
|
|
29
|
+
import { SSEWriter } from './sse-writer.js';
|
|
30
|
+
import { SSEConnectionLimiter } from './sse-limiter.js';
|
|
31
|
+
import { PipelineManager } from './pipeline.js';
|
|
32
|
+
import { ToolRegistry } from './tool-registry.js';
|
|
33
|
+
import { AuthManager, classifyBearerTokenForRoute } from './auth.js';
|
|
34
|
+
import { MetricsCollector } from './metrics.js';
|
|
35
|
+
import { registerPermissionRoutes } from './permission-routes.js';
|
|
36
|
+
import { registerHookRoutes } from './hooks.js';
|
|
37
|
+
import { registerWsTerminalRoute } from './ws-terminal.js';
|
|
38
|
+
import { registerMemoryRoutes } from './memory-routes.js';
|
|
39
|
+
import { registerModelRouterRoutes } from './model-router.js';
|
|
40
|
+
import { buildConsensusPrompt } from './consensus.js';
|
|
41
|
+
import * as templateStore from './template-store.js';
|
|
42
|
+
import { SwarmMonitor } from './swarm-monitor.js';
|
|
43
|
+
import { killAllSessions } from './signal-cleanup-helper.js';
|
|
44
|
+
import { execFileSync } from 'node:child_process';
|
|
45
|
+
import { negotiate } from './handshake.js';
|
|
46
|
+
import { diagnosticsBus } from './diagnostics.js';
|
|
47
|
+
import { setStructuredLogSink } from './logger.js';
|
|
48
|
+
import { MemoryBridge } from './memory-bridge.js';
|
|
49
|
+
import { cleanupTerminatedSessionState } from './session-cleanup.js';
|
|
50
|
+
import { normalizeApiErrorPayload } from './api-error-envelope.js';
|
|
51
|
+
import { listenWithRetry, removePidFile, writePidFile } from './startup.js';
|
|
52
|
+
import { isWindowsShutdownMessage, parseShutdownTimeoutMs } from './shutdown-utils.js';
|
|
53
|
+
import { authKeySchema, sendMessageSchema, commandSchema, bashSchema, screenshotSchema, permissionHookSchema, stopHookSchema, batchSessionSchema, pipelineSchema, handshakeRequestSchema, parseIntSafe, isValidUUID, compareSemver, extractCCVersion, MIN_CC_VERSION, permissionProfileSchema, } from './validation.js';
|
|
54
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
55
|
+
const __dirname = path.dirname(__filename);
|
|
56
|
+
const consensusRequests = new Map();
|
|
57
|
+
/** #1091: TTL for consensus request entries (1 hour) */
|
|
58
|
+
const CONSENSUS_REQUEST_TTL_MS = 60 * 60 * 1000;
|
|
59
|
+
/** #1091: Prune consensus requests older than the TTL to prevent unbounded memory growth. */
|
|
60
|
+
function pruneConsensusRequests() {
|
|
61
|
+
const cutoff = Date.now() - CONSENSUS_REQUEST_TTL_MS;
|
|
62
|
+
for (const [id, request] of consensusRequests) {
|
|
63
|
+
if (request.createdAt < cutoff) {
|
|
64
|
+
consensusRequests.delete(id);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// ── Configuration ────────────────────────────────────────────────────
|
|
69
|
+
// Issue #349: CSP policy for dashboard responses (shared between static and SPA fallback)
|
|
70
|
+
const DASHBOARD_CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss: https://registry.npmjs.org";
|
|
71
|
+
// Config loaded at startup; env vars override file values
|
|
72
|
+
let config;
|
|
73
|
+
// These will be initialized after config is loaded
|
|
74
|
+
let tmux;
|
|
75
|
+
let sessions;
|
|
76
|
+
let monitor;
|
|
77
|
+
let jsonlWatcher;
|
|
78
|
+
const channels = new ChannelManager();
|
|
79
|
+
const eventBus = new SessionEventBus();
|
|
80
|
+
let memoryBridge = null;
|
|
81
|
+
let sseLimiter;
|
|
82
|
+
let pipelines;
|
|
83
|
+
let toolRegistry;
|
|
84
|
+
let auth;
|
|
85
|
+
let metrics;
|
|
86
|
+
let swarmMonitor;
|
|
87
|
+
// ── Inbound command handler ─────────────────────────────────────────
|
|
88
|
+
async function handleInbound(cmd) {
|
|
89
|
+
try {
|
|
90
|
+
switch (cmd.action) {
|
|
91
|
+
case 'approve':
|
|
92
|
+
await sessions.approve(cmd.sessionId);
|
|
93
|
+
break;
|
|
94
|
+
case 'reject':
|
|
95
|
+
await sessions.reject(cmd.sessionId);
|
|
96
|
+
break;
|
|
97
|
+
case 'escape':
|
|
98
|
+
await sessions.escape(cmd.sessionId);
|
|
99
|
+
break;
|
|
100
|
+
case 'kill':
|
|
101
|
+
// #842: killSession first, then notify — avoids race where channels
|
|
102
|
+
// reference a session that is still being destroyed.
|
|
103
|
+
await sessions.killSession(cmd.sessionId);
|
|
104
|
+
await channels.sessionEnded(makePayload('session.ended', cmd.sessionId, 'killed'));
|
|
105
|
+
cleanupTerminatedSessionState(cmd.sessionId, { monitor, metrics, toolRegistry });
|
|
106
|
+
break;
|
|
107
|
+
case 'message':
|
|
108
|
+
case 'command':
|
|
109
|
+
if (cmd.text)
|
|
110
|
+
await sessions.sendMessage(cmd.sessionId, cmd.text);
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
console.error(`Inbound command error [${cmd.action}]:`, e);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// ── HTTP Server ─────────────────────────────────────────────────────
|
|
119
|
+
const app = Fastify({
|
|
120
|
+
bodyLimit: 1048576, // 1MB — Issue #349: explicit body size limit
|
|
121
|
+
trustProxy: process.env.TRUST_PROXY === 'true', // #633: Only trust X-Forwarded-For when explicitly enabled
|
|
122
|
+
logger: {
|
|
123
|
+
// #230: Redact auth tokens from request logs
|
|
124
|
+
serializers: {
|
|
125
|
+
req(req) {
|
|
126
|
+
const url = req.url?.includes('token=')
|
|
127
|
+
? req.url.replace(/token=[^&]*/g, 'token=[REDACTED]')
|
|
128
|
+
: req.url;
|
|
129
|
+
return {
|
|
130
|
+
method: req.method,
|
|
131
|
+
url,
|
|
132
|
+
// ...rest intentionally omitted — prevents token leakage via headers
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
setStructuredLogSink({
|
|
139
|
+
info: (record) => app.log.info(record),
|
|
140
|
+
warn: (record) => app.log.warn(record),
|
|
141
|
+
error: (record) => app.log.error(record),
|
|
142
|
+
});
|
|
143
|
+
// #227: Security headers on all API responses (skip SSE)
|
|
144
|
+
app.addHook('onSend', (req, reply, payload, done) => {
|
|
145
|
+
const contentType = reply.getHeader('content-type');
|
|
146
|
+
if (typeof contentType === 'string' && contentType.includes('text/event-stream')) {
|
|
147
|
+
return done();
|
|
148
|
+
}
|
|
149
|
+
reply.header('X-Content-Type-Options', 'nosniff');
|
|
150
|
+
reply.header('X-Frame-Options', 'DENY');
|
|
151
|
+
reply.header('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
152
|
+
reply.header('Permissions-Policy', 'camera=(), microphone=()');
|
|
153
|
+
const normalizedPayload = normalizeApiErrorPayload({
|
|
154
|
+
payload,
|
|
155
|
+
statusCode: reply.statusCode,
|
|
156
|
+
requestId: req.id,
|
|
157
|
+
contentType: typeof contentType === 'string' ? contentType : undefined,
|
|
158
|
+
});
|
|
159
|
+
done(null, normalizedPayload);
|
|
160
|
+
});
|
|
161
|
+
const ipRateLimits = new Map();
|
|
162
|
+
const IP_WINDOW_MS = 60_000;
|
|
163
|
+
const IP_LIMIT_NORMAL = 120; // per minute for regular keys
|
|
164
|
+
const IP_LIMIT_MASTER = 300; // per minute for master token
|
|
165
|
+
const MAX_IP_ENTRIES = 10_000; // #844: Cap tracked IPs to prevent memory exhaustion
|
|
166
|
+
function checkIpRateLimit(ip, isMaster) {
|
|
167
|
+
const now = Date.now();
|
|
168
|
+
const cutoff = now - IP_WINDOW_MS;
|
|
169
|
+
const bucket = ipRateLimits.get(ip) || { entries: [], start: 0 };
|
|
170
|
+
// O(1) prune: advance start index past expired entries
|
|
171
|
+
while (bucket.start < bucket.entries.length && bucket.entries[bucket.start] < cutoff) {
|
|
172
|
+
bucket.start++;
|
|
173
|
+
}
|
|
174
|
+
// Compact when the leading garbage exceeds 50% of the allocated array
|
|
175
|
+
if (bucket.start > bucket.entries.length >>> 1) {
|
|
176
|
+
bucket.entries = bucket.entries.slice(bucket.start);
|
|
177
|
+
bucket.start = 0;
|
|
178
|
+
}
|
|
179
|
+
bucket.entries.push(now);
|
|
180
|
+
ipRateLimits.set(ip, bucket);
|
|
181
|
+
// #844: Evict oldest IPs when map exceeds cap to prevent unbounded memory growth
|
|
182
|
+
if (ipRateLimits.size > MAX_IP_ENTRIES) {
|
|
183
|
+
let oldestIp = '';
|
|
184
|
+
let oldestTime = Infinity;
|
|
185
|
+
for (const [trackedIp, trackedBucket] of ipRateLimits) {
|
|
186
|
+
const lastTs = trackedBucket.entries[trackedBucket.entries.length - 1];
|
|
187
|
+
if (lastTs !== undefined && lastTs < oldestTime) {
|
|
188
|
+
oldestTime = lastTs;
|
|
189
|
+
oldestIp = trackedIp;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (oldestIp)
|
|
193
|
+
ipRateLimits.delete(oldestIp);
|
|
194
|
+
}
|
|
195
|
+
const activeCount = bucket.entries.length - bucket.start;
|
|
196
|
+
const limit = isMaster ? IP_LIMIT_MASTER : IP_LIMIT_NORMAL;
|
|
197
|
+
return activeCount > limit;
|
|
198
|
+
}
|
|
199
|
+
const authFailLimits = new Map();
|
|
200
|
+
const AUTH_FAIL_WINDOW_MS = 60_000;
|
|
201
|
+
const AUTH_FAIL_MAX = 5;
|
|
202
|
+
const MAX_AUTH_FAIL_IP_ENTRIES = 10_000;
|
|
203
|
+
function checkAuthFailRateLimit(ip) {
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
const cutoff = now - AUTH_FAIL_WINDOW_MS;
|
|
206
|
+
const bucket = authFailLimits.get(ip) || { timestamps: [] };
|
|
207
|
+
// Prune expired entries
|
|
208
|
+
bucket.timestamps = bucket.timestamps.filter(t => t >= cutoff);
|
|
209
|
+
bucket.timestamps.push(now);
|
|
210
|
+
authFailLimits.set(ip, bucket);
|
|
211
|
+
if (authFailLimits.size > MAX_AUTH_FAIL_IP_ENTRIES) {
|
|
212
|
+
let oldestIp = '';
|
|
213
|
+
let oldestTime = Infinity;
|
|
214
|
+
for (const [trackedIp, trackedBucket] of authFailLimits) {
|
|
215
|
+
const lastTs = trackedBucket.timestamps[trackedBucket.timestamps.length - 1];
|
|
216
|
+
if (lastTs !== undefined && lastTs < oldestTime) {
|
|
217
|
+
oldestTime = lastTs;
|
|
218
|
+
oldestIp = trackedIp;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (oldestIp)
|
|
222
|
+
authFailLimits.delete(oldestIp);
|
|
223
|
+
}
|
|
224
|
+
return bucket.timestamps.length > AUTH_FAIL_MAX;
|
|
225
|
+
}
|
|
226
|
+
function recordAuthFailure(ip) {
|
|
227
|
+
checkAuthFailRateLimit(ip);
|
|
228
|
+
}
|
|
229
|
+
/** #632: Prune stale auth-failure buckets. */
|
|
230
|
+
function pruneAuthFailLimits() {
|
|
231
|
+
const cutoff = Date.now() - AUTH_FAIL_WINDOW_MS;
|
|
232
|
+
for (const [ip, bucket] of authFailLimits) {
|
|
233
|
+
bucket.timestamps = bucket.timestamps.filter(t => t >= cutoff);
|
|
234
|
+
if (bucket.timestamps.length === 0)
|
|
235
|
+
authFailLimits.delete(ip);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** #357: Prune IPs whose timestamp arrays are entirely outside the rate-limit window. */
|
|
239
|
+
function pruneIpRateLimits() {
|
|
240
|
+
const cutoff = Date.now() - IP_WINDOW_MS;
|
|
241
|
+
for (const [ip, bucket] of ipRateLimits) {
|
|
242
|
+
// All timestamps are old — remove the entry entirely
|
|
243
|
+
const last = bucket.entries[bucket.entries.length - 1];
|
|
244
|
+
if (bucket.entries.length - bucket.start === 0 || (last !== undefined && last < cutoff)) {
|
|
245
|
+
ipRateLimits.delete(ip);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** #583: Track keyId per request for batch rate limiting. */
|
|
250
|
+
const requestKeyMap = new Map();
|
|
251
|
+
// #839: Clean up requestKeyMap entries after response to prevent unbounded memory leak.
|
|
252
|
+
app.addHook('onResponse', (req, _reply, done) => {
|
|
253
|
+
requestKeyMap.delete(req.id);
|
|
254
|
+
done();
|
|
255
|
+
});
|
|
256
|
+
function setupAuth(authManager) {
|
|
257
|
+
app.addHook('onRequest', async (req, reply) => {
|
|
258
|
+
// Skip auth for health endpoint and dashboard (Issue #349: exact path matching)
|
|
259
|
+
// #126: Dashboard is served as public static files; API endpoints are protected
|
|
260
|
+
const urlPath = req.url?.split('?')[0] ?? '';
|
|
261
|
+
if (urlPath === '/health' || urlPath === '/v1/health')
|
|
262
|
+
return;
|
|
263
|
+
if (urlPath === '/dashboard' || urlPath.startsWith('/dashboard/'))
|
|
264
|
+
return;
|
|
265
|
+
// Hook routes — exact match: /v1/hooks/{eventName} (alpha only, no path traversal)
|
|
266
|
+
// Issue #394: Require valid X-Session-Id for known sessions instead of blanket bypass.
|
|
267
|
+
// Issue #580: Validate UUID format before getSession lookup.
|
|
268
|
+
// Issue #629: Validate per-session hook secret to prevent replay with known session ID.
|
|
269
|
+
// CC hooks run from localhost and always include the session ID they were started with.
|
|
270
|
+
const hookMatch = /^\/v1\/hooks\/[A-Za-z]+$/.exec(urlPath);
|
|
271
|
+
if (hookMatch) {
|
|
272
|
+
const hookSessionId = req.headers['x-session-id']
|
|
273
|
+
|| req.query?.sessionId;
|
|
274
|
+
if (hookSessionId && !isValidUUID(hookSessionId)) {
|
|
275
|
+
return reply.status(400).send({ error: 'Invalid session ID — must be a UUID' });
|
|
276
|
+
}
|
|
277
|
+
if (hookSessionId) {
|
|
278
|
+
const session = sessions.getSession(hookSessionId);
|
|
279
|
+
if (session) {
|
|
280
|
+
// Issue #629/#1131: Validate hook secret from X-Hook-Secret header (query param fallback)
|
|
281
|
+
const hookSecret = req.headers['x-hook-secret']
|
|
282
|
+
|| req.query?.secret;
|
|
283
|
+
if (!hookSecret || hookSecret !== session.hookSecret) {
|
|
284
|
+
return reply.status(401).send({ error: 'Unauthorized — invalid hook secret' });
|
|
285
|
+
}
|
|
286
|
+
return; // valid session + secret — allow
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// No valid session context — reject even when auth is disabled
|
|
290
|
+
return reply.status(401).send({ error: 'Unauthorized — hook endpoint requires valid session ID' });
|
|
291
|
+
}
|
|
292
|
+
// #303: WS terminal routes have their own preHandler for auth (supports ?token=)
|
|
293
|
+
// Exact match: /v1/sessions/{id}/terminal
|
|
294
|
+
if (/^\/v1\/sessions\/[^/]+\/terminal$/.test(urlPath))
|
|
295
|
+
return;
|
|
296
|
+
// If no auth configured (no master token, no keys), allow all
|
|
297
|
+
if (!authManager.authEnabled)
|
|
298
|
+
return;
|
|
299
|
+
// #124/#125: Accept token from Authorization header; ?token= query param
|
|
300
|
+
// only on SSE routes where EventSource cannot set headers.
|
|
301
|
+
// #297: SSE routes also accept short-lived SSE tokens via ?token=.
|
|
302
|
+
const isSSERoute = req.url?.includes('/events');
|
|
303
|
+
let token;
|
|
304
|
+
const header = req.headers.authorization;
|
|
305
|
+
if (header?.startsWith('Bearer ')) {
|
|
306
|
+
token = header.slice(7);
|
|
307
|
+
}
|
|
308
|
+
else if (isSSERoute) {
|
|
309
|
+
token = req.query.token;
|
|
310
|
+
}
|
|
311
|
+
if (!token) {
|
|
312
|
+
return reply.status(401).send({ error: 'Unauthorized — Bearer token required' });
|
|
313
|
+
}
|
|
314
|
+
// #633: Only use req.ip — trustProxy controls whether X-Forwarded-For is considered
|
|
315
|
+
const clientIp = req.ip ?? 'unknown';
|
|
316
|
+
// #632: Block IPs that exceeded auth failure rate limit (5 attempts/min)
|
|
317
|
+
if (checkAuthFailRateLimit(clientIp)) {
|
|
318
|
+
return reply.status(429).send({ error: 'Too many auth failures — try again later' });
|
|
319
|
+
}
|
|
320
|
+
const tokenMode = classifyBearerTokenForRoute(token, !!isSSERoute);
|
|
321
|
+
// #408: SSE endpoints require short-lived single-use SSE tokens.
|
|
322
|
+
// Do not fall back to validating long-lived bearer/master tokens on /events.
|
|
323
|
+
if (tokenMode === 'sse') {
|
|
324
|
+
if (await authManager.validateSSEToken(token)) {
|
|
325
|
+
return; // authenticated via short-lived SSE token
|
|
326
|
+
}
|
|
327
|
+
recordAuthFailure(clientIp);
|
|
328
|
+
return reply.status(401).send({ error: 'Unauthorized — SSE token invalid or expired' });
|
|
329
|
+
}
|
|
330
|
+
if (tokenMode === 'reject') {
|
|
331
|
+
recordAuthFailure(clientIp);
|
|
332
|
+
return reply.status(401).send({ error: 'Unauthorized — SSE token required for event streams' });
|
|
333
|
+
}
|
|
334
|
+
const result = authManager.validate(token);
|
|
335
|
+
if (!result.valid) {
|
|
336
|
+
recordAuthFailure(clientIp);
|
|
337
|
+
return reply.status(401).send({ error: 'Unauthorized — invalid API key' });
|
|
338
|
+
}
|
|
339
|
+
if (result.rateLimited) {
|
|
340
|
+
return reply.status(429).send({ error: 'Rate limit exceeded — 100 req/min per key' });
|
|
341
|
+
}
|
|
342
|
+
// #583: Store keyId for batch rate limiting
|
|
343
|
+
// #634: Store validated keyId for SSE token endpoint to reuse
|
|
344
|
+
requestKeyMap.set(req.id, result.keyId ?? 'anonymous');
|
|
345
|
+
req.authKeyId = result.keyId;
|
|
346
|
+
// #228: Per-IP rate limiting (applies to all authenticated requests)
|
|
347
|
+
// #633: Only use req.ip — trustProxy controls whether X-Forwarded-For is considered
|
|
348
|
+
const isMaster = result.keyId === 'master';
|
|
349
|
+
if (checkIpRateLimit(clientIp, isMaster)) {
|
|
350
|
+
return reply.status(429).send({ error: 'Rate limit exceeded — IP throttled' });
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
// ── v1 API Routes ───────────────────────────────────────────────────
|
|
355
|
+
// #412: Reject non-UUID session IDs at the routing layer
|
|
356
|
+
app.addHook('onRequest', async (req, reply) => {
|
|
357
|
+
const id = req.params.id;
|
|
358
|
+
if (id !== undefined && !isValidUUID(id)) {
|
|
359
|
+
return reply.status(400).send({ error: 'Invalid session ID — must be a UUID' });
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
// #226: Zod schema for session creation
|
|
363
|
+
const createSessionSchema = z.object({
|
|
364
|
+
workDir: z.string().min(1),
|
|
365
|
+
name: z.string().max(200).optional(),
|
|
366
|
+
prompt: z.string().max(100_000).optional(),
|
|
367
|
+
prd: z.string().max(100_000).optional(),
|
|
368
|
+
resumeSessionId: z.string().uuid().optional(),
|
|
369
|
+
claudeCommand: z.string().max(10_000).optional(),
|
|
370
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
371
|
+
stallThresholdMs: z.number().int().positive().max(3_600_000).optional(),
|
|
372
|
+
permissionMode: z.enum(['default', 'bypassPermissions', 'plan', 'acceptEdits', 'dontAsk', 'auto']).optional(),
|
|
373
|
+
autoApprove: z.boolean().optional(),
|
|
374
|
+
parentId: z.string().uuid().optional(),
|
|
375
|
+
memoryKeys: z.array(z.string()).max(50).optional(),
|
|
376
|
+
}).strict();
|
|
377
|
+
// Health — Issue #397: includes tmux server health check
|
|
378
|
+
async function healthHandler() {
|
|
379
|
+
const pkg = await import('../package.json', { with: { type: 'json' } });
|
|
380
|
+
const activeCount = sessions.listSessions().length;
|
|
381
|
+
const totalCount = metrics.getTotalSessionsCreated();
|
|
382
|
+
const tmuxHealth = await tmux.isServerHealthy();
|
|
383
|
+
const status = tmuxHealth.healthy ? 'ok' : 'degraded';
|
|
384
|
+
return {
|
|
385
|
+
status,
|
|
386
|
+
version: pkg.default.version,
|
|
387
|
+
platform: process.platform,
|
|
388
|
+
uptime: process.uptime(),
|
|
389
|
+
sessions: { active: activeCount, total: totalCount },
|
|
390
|
+
tmux: tmuxHealth,
|
|
391
|
+
timestamp: new Date().toISOString(),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
app.get('/v1/health', healthHandler);
|
|
395
|
+
app.get('/health', healthHandler);
|
|
396
|
+
app.post('/v1/handshake', async (req, reply) => {
|
|
397
|
+
const parsed = handshakeRequestSchema.safeParse(req.body ?? {});
|
|
398
|
+
if (!parsed.success) {
|
|
399
|
+
return reply.status(400).send({ error: 'Invalid handshake request', details: parsed.error.issues });
|
|
400
|
+
}
|
|
401
|
+
const result = negotiate(parsed.data);
|
|
402
|
+
return reply.status(result.compatible ? 200 : 409).send(result);
|
|
403
|
+
});
|
|
404
|
+
// Issue #81: Swarm awareness
|
|
405
|
+
// Issue #81: Swarm awareness — list all detected CC swarms and their teammates
|
|
406
|
+
app.get('/v1/swarm', async () => {
|
|
407
|
+
const result = await swarmMonitor.scan();
|
|
408
|
+
return result;
|
|
409
|
+
});
|
|
410
|
+
// API key management (Issue #39)
|
|
411
|
+
// Security: reject all auth key operations when auth is not enabled
|
|
412
|
+
app.post('/v1/auth/keys', async (req, reply) => {
|
|
413
|
+
if (!auth.authEnabled)
|
|
414
|
+
return reply.status(403).send({ error: 'Auth is not enabled' });
|
|
415
|
+
const parsed = authKeySchema.safeParse(req.body);
|
|
416
|
+
if (!parsed.success)
|
|
417
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
418
|
+
const { name, rateLimit } = parsed.data;
|
|
419
|
+
const result = await auth.createKey(name, rateLimit);
|
|
420
|
+
return reply.status(201).send(result);
|
|
421
|
+
});
|
|
422
|
+
app.get('/v1/auth/keys', async (req, reply) => {
|
|
423
|
+
if (!auth.authEnabled)
|
|
424
|
+
return reply.status(403).send({ error: 'Auth is not enabled' });
|
|
425
|
+
return auth.listKeys();
|
|
426
|
+
});
|
|
427
|
+
app.delete('/v1/auth/keys/:id', async (req, reply) => {
|
|
428
|
+
if (!auth.authEnabled)
|
|
429
|
+
return reply.status(403).send({ error: 'Auth is not enabled' });
|
|
430
|
+
const revoked = await auth.revokeKey(req.params.id);
|
|
431
|
+
if (!revoked)
|
|
432
|
+
return reply.status(404).send({ error: 'Key not found' });
|
|
433
|
+
return { ok: true };
|
|
434
|
+
});
|
|
435
|
+
// #297: SSE token endpoint — generates short-lived, single-use token
|
|
436
|
+
// to avoid exposing long-lived bearer tokens in SSE URL query params.
|
|
437
|
+
// Issue #634: Reuse keyId from auth middleware result to avoid double-increment.
|
|
438
|
+
// of rate limit counter.
|
|
439
|
+
app.post('/v1/auth/sse-token', async (req, reply) => {
|
|
440
|
+
// This route goes through the onRequest auth hook, so the caller is
|
|
441
|
+
// already authenticated. Reuse stored keyId to avoid calling auth.validate() again.
|
|
442
|
+
const storedKeyId = req?.authKeyId;
|
|
443
|
+
const keyId = (typeof storedKeyId === 'string' ? storedKeyId : 'anonymous');
|
|
444
|
+
try {
|
|
445
|
+
const sseToken = await auth.generateSSEToken(keyId);
|
|
446
|
+
return reply.status(201).send(sseToken);
|
|
447
|
+
}
|
|
448
|
+
catch (e) {
|
|
449
|
+
return reply.status(429).send({ error: e instanceof Error ? e.message : 'SSE token limit reached' });
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
const diagnosticsQuerySchema = z.object({
|
|
453
|
+
limit: z.coerce.number().int().min(1).max(100).optional(),
|
|
454
|
+
});
|
|
455
|
+
// Global metrics (Issue #40)
|
|
456
|
+
app.get('/v1/metrics', async () => metrics.getGlobalMetrics(sessions.listSessions().length));
|
|
457
|
+
// Bounded no-PII diagnostics channel (Issue #881)
|
|
458
|
+
app.get('/v1/diagnostics', async (req, reply) => {
|
|
459
|
+
const parsed = diagnosticsQuerySchema.safeParse(req.query ?? {});
|
|
460
|
+
if (!parsed.success) {
|
|
461
|
+
return reply.status(400).send({
|
|
462
|
+
error: 'Invalid diagnostics query params',
|
|
463
|
+
details: parsed.error.issues,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
const limit = parsed.data.limit ?? 50;
|
|
467
|
+
const events = diagnosticsBus.getRecent(limit);
|
|
468
|
+
return { count: events.length, events };
|
|
469
|
+
});
|
|
470
|
+
// Per-session metrics (Issue #40)
|
|
471
|
+
app.get('/v1/sessions/:id/metrics', async (req, reply) => {
|
|
472
|
+
const m = metrics.getSessionMetrics(req.params.id);
|
|
473
|
+
if (!m)
|
|
474
|
+
return reply.status(404).send({ error: 'No metrics for this session' });
|
|
475
|
+
return m;
|
|
476
|
+
});
|
|
477
|
+
// Issue #704: Tool usage endpoints
|
|
478
|
+
app.get('/v1/sessions/:id/tools', async (req, reply) => {
|
|
479
|
+
const sessionId = req.params.id;
|
|
480
|
+
const session = sessions.getSession(sessionId);
|
|
481
|
+
if (!session)
|
|
482
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
483
|
+
// Parse JSONL on-demand for tool usage
|
|
484
|
+
const { readNewEntries } = await import('./transcript.js');
|
|
485
|
+
if (session.jsonlPath) {
|
|
486
|
+
try {
|
|
487
|
+
const result = await readNewEntries(session.jsonlPath, 0);
|
|
488
|
+
const entries = result.entries;
|
|
489
|
+
toolRegistry.processEntries(req.params.id, entries);
|
|
490
|
+
}
|
|
491
|
+
catch { /* JSONL not available */ }
|
|
492
|
+
}
|
|
493
|
+
const tools = toolRegistry.getSessionTools(req.params.id);
|
|
494
|
+
return { sessionId: req.params.id, tools, totalCalls: tools.reduce((sum, t) => sum + t.count, 0) };
|
|
495
|
+
});
|
|
496
|
+
app.get('/v1/tools', async () => {
|
|
497
|
+
const definitions = toolRegistry.getToolDefinitions();
|
|
498
|
+
const categories = [...new Set(definitions.map(t => t.category))];
|
|
499
|
+
return { tools: definitions, categories, totalTools: definitions.length };
|
|
500
|
+
});
|
|
501
|
+
// Issue #89 L14: Webhook dead letter queue
|
|
502
|
+
app.get('/v1/webhooks/dead-letter', async () => {
|
|
503
|
+
for (const ch of channels.getChannels()) {
|
|
504
|
+
if (ch.name === 'webhook' && typeof ch.getDeadLetterQueue === 'function') {
|
|
505
|
+
return ch.getDeadLetterQueue();
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return [];
|
|
509
|
+
});
|
|
510
|
+
// Issue #89 L15: Per-channel health reporting
|
|
511
|
+
app.get('/v1/channels/health', async () => {
|
|
512
|
+
return channels.getChannels().map(ch => {
|
|
513
|
+
const health = ch.getHealth?.();
|
|
514
|
+
if (health)
|
|
515
|
+
return health;
|
|
516
|
+
return { channel: ch.name, healthy: true, lastSuccess: null, lastError: null, pendingCount: 0 };
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
// Issue #87: Per-session latency metrics
|
|
520
|
+
app.get('/v1/sessions/:id/latency', async (req, reply) => {
|
|
521
|
+
const sessionId = req.params.id;
|
|
522
|
+
const session = sessions.getSession(sessionId);
|
|
523
|
+
if (!session)
|
|
524
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
525
|
+
const realtimeLatency = sessions.getLatencyMetrics(req.params.id);
|
|
526
|
+
const aggregatedLatency = metrics.getSessionLatency(req.params.id);
|
|
527
|
+
return {
|
|
528
|
+
sessionId: req.params.id,
|
|
529
|
+
realtime: realtimeLatency,
|
|
530
|
+
aggregated: aggregatedLatency,
|
|
531
|
+
};
|
|
532
|
+
});
|
|
533
|
+
// Global SSE event stream — aggregates events from ALL active sessions
|
|
534
|
+
app.get('/v1/events', async (req, reply) => {
|
|
535
|
+
const clientIp = req.ip;
|
|
536
|
+
const acquireResult = sseLimiter.acquire(clientIp);
|
|
537
|
+
if (!acquireResult.allowed) {
|
|
538
|
+
const status = acquireResult.reason === 'per_ip_limit' ? 429 : 503;
|
|
539
|
+
return reply.status(status).send({
|
|
540
|
+
error: acquireResult.reason === 'per_ip_limit'
|
|
541
|
+
? `Per-IP connection limit reached (${acquireResult.current}/${acquireResult.limit})`
|
|
542
|
+
: `Global connection limit reached (${acquireResult.current}/${acquireResult.limit})`,
|
|
543
|
+
reason: acquireResult.reason,
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
// Issue #505: Subscribe BEFORE writing response headers so that if
|
|
547
|
+
// subscription fails, we can still return a proper HTTP error.
|
|
548
|
+
let unsubscribe;
|
|
549
|
+
const connectionId = acquireResult.connectionId;
|
|
550
|
+
let writer;
|
|
551
|
+
// Queue events that arrive between subscription and writer creation
|
|
552
|
+
const pendingEvents = [];
|
|
553
|
+
let subscriptionReady = false;
|
|
554
|
+
const handler = (event) => {
|
|
555
|
+
if (!subscriptionReady) {
|
|
556
|
+
pendingEvents.push(event);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const id = event.id != null ? `id: ${event.id}\n` : '';
|
|
560
|
+
writer.write(`${id}data: ${JSON.stringify(event)}\n\n`);
|
|
561
|
+
};
|
|
562
|
+
try {
|
|
563
|
+
unsubscribe = eventBus.subscribeGlobal(handler);
|
|
564
|
+
}
|
|
565
|
+
catch (err) {
|
|
566
|
+
req.log.error({ err }, 'Global SSE subscription failed');
|
|
567
|
+
sseLimiter.release(connectionId);
|
|
568
|
+
return reply.status(500).send({ error: 'Failed to create SSE subscription' });
|
|
569
|
+
}
|
|
570
|
+
reply.raw.writeHead(200, {
|
|
571
|
+
'Content-Type': 'text/event-stream',
|
|
572
|
+
'Cache-Control': 'no-cache',
|
|
573
|
+
'Connection': 'keep-alive',
|
|
574
|
+
'X-Accel-Buffering': 'no',
|
|
575
|
+
});
|
|
576
|
+
writer = new SSEWriter(reply.raw, req.raw, () => {
|
|
577
|
+
unsubscribe?.();
|
|
578
|
+
sseLimiter.release(connectionId);
|
|
579
|
+
});
|
|
580
|
+
// Now safe to deliver events — flush any queued during setup
|
|
581
|
+
subscriptionReady = true;
|
|
582
|
+
for (const event of pendingEvents) {
|
|
583
|
+
const id = event.id != null ? `id: ${event.id}\n` : '';
|
|
584
|
+
writer.write(`${id}data: ${JSON.stringify(event)}\n\n`);
|
|
585
|
+
}
|
|
586
|
+
writer.write(`data: ${JSON.stringify({
|
|
587
|
+
event: 'connected',
|
|
588
|
+
timestamp: new Date().toISOString(),
|
|
589
|
+
data: { activeSessions: sessions.listSessions().length },
|
|
590
|
+
})}\n\n`);
|
|
591
|
+
// Issue #301: Replay missed global events if client sends Last-Event-ID
|
|
592
|
+
const lastEventId = req.headers['last-event-id'];
|
|
593
|
+
if (lastEventId) {
|
|
594
|
+
const missed = eventBus.getGlobalEventsSince(parseInt(lastEventId, 10) || 0);
|
|
595
|
+
for (const { id, event: globalEvent } of missed) {
|
|
596
|
+
writer.write(`id: ${id}\ndata: ${JSON.stringify(globalEvent)}\n\n`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
writer.startHeartbeat(30_000, 90_000, () => `data: ${JSON.stringify({ event: 'heartbeat', timestamp: new Date().toISOString() })}\n\n`);
|
|
600
|
+
await reply;
|
|
601
|
+
});
|
|
602
|
+
// List sessions (with pagination, status filter, and project filter)
|
|
603
|
+
app.get('/v1/sessions', async (req) => {
|
|
604
|
+
const page = Math.max(1, parseInt(req.query.page || '1', 10) || 1);
|
|
605
|
+
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit || '20', 10) || 20));
|
|
606
|
+
const statusFilter = req.query.status;
|
|
607
|
+
const projectFilter = req.query.project;
|
|
608
|
+
let all = sessions.listSessions();
|
|
609
|
+
if (statusFilter) {
|
|
610
|
+
all = all.filter(s => s.status === statusFilter);
|
|
611
|
+
}
|
|
612
|
+
// Issue #754: filter by project (workDir prefix/substring match)
|
|
613
|
+
if (projectFilter) {
|
|
614
|
+
const lower = projectFilter.toLowerCase();
|
|
615
|
+
all = all.filter(s => s.workDir.toLowerCase().includes(lower));
|
|
616
|
+
}
|
|
617
|
+
// Sort by createdAt descending (newest first)
|
|
618
|
+
all.sort((a, b) => b.createdAt - a.createdAt);
|
|
619
|
+
const total = all.length;
|
|
620
|
+
const start = (page - 1) * limit;
|
|
621
|
+
const items = all.slice(start, start + limit);
|
|
622
|
+
const totalPages = Math.ceil(total / limit);
|
|
623
|
+
return {
|
|
624
|
+
sessions: items,
|
|
625
|
+
pagination: { page, limit, total, totalPages },
|
|
626
|
+
};
|
|
627
|
+
});
|
|
628
|
+
// Issue #754: Session statistics endpoint
|
|
629
|
+
app.get('/v1/sessions/stats', async () => {
|
|
630
|
+
const all = sessions.listSessions();
|
|
631
|
+
const byStatus = {};
|
|
632
|
+
for (const s of all) {
|
|
633
|
+
byStatus[s.status] = (byStatus[s.status] ?? 0) + 1;
|
|
634
|
+
}
|
|
635
|
+
const global = metrics.getGlobalMetrics(all.length);
|
|
636
|
+
return {
|
|
637
|
+
active: all.length,
|
|
638
|
+
byStatus,
|
|
639
|
+
totalCreated: global.sessions.total_created,
|
|
640
|
+
totalCompleted: global.sessions.completed,
|
|
641
|
+
totalFailed: global.sessions.failed,
|
|
642
|
+
};
|
|
643
|
+
});
|
|
644
|
+
// Issue #754: Bulk-delete sessions by IDs and/or status
|
|
645
|
+
const batchDeleteSchema = z.object({
|
|
646
|
+
ids: z.array(z.string().uuid()).max(100).optional(),
|
|
647
|
+
status: z.enum([
|
|
648
|
+
'idle', 'working', 'compacting', 'context_warning', 'waiting_for_input',
|
|
649
|
+
'permission_prompt', 'plan_mode', 'ask_question', 'bash_approval',
|
|
650
|
+
'settings', 'error', 'unknown',
|
|
651
|
+
]).optional(),
|
|
652
|
+
}).refine(d => d.ids !== undefined || d.status !== undefined, {
|
|
653
|
+
message: 'At least one of "ids" or "status" is required',
|
|
654
|
+
});
|
|
655
|
+
app.delete('/v1/sessions/batch', async (req, reply) => {
|
|
656
|
+
const parsed = batchDeleteSchema.safeParse(req.body);
|
|
657
|
+
if (!parsed.success) {
|
|
658
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
659
|
+
}
|
|
660
|
+
const { ids, status } = parsed.data;
|
|
661
|
+
// Collect target session IDs
|
|
662
|
+
const targets = new Set(ids ?? []);
|
|
663
|
+
if (status) {
|
|
664
|
+
for (const s of sessions.listSessions()) {
|
|
665
|
+
if (s.status === status)
|
|
666
|
+
targets.add(s.id);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
let deleted = 0;
|
|
670
|
+
const notFound = [];
|
|
671
|
+
const errors = [];
|
|
672
|
+
for (const id of targets) {
|
|
673
|
+
if (!sessions.getSession(id)) {
|
|
674
|
+
notFound.push(id);
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
try {
|
|
678
|
+
await sessions.killSession(id);
|
|
679
|
+
eventBus.emitEnded(id, 'killed');
|
|
680
|
+
void channels.sessionEnded(makePayload('session.ended', id, 'killed'));
|
|
681
|
+
cleanupTerminatedSessionState(id, { monitor, metrics, toolRegistry });
|
|
682
|
+
deleted++;
|
|
683
|
+
}
|
|
684
|
+
catch (e) {
|
|
685
|
+
errors.push(`${id}: ${e instanceof Error ? e.message : String(e)}`);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return reply.status(200).send({ deleted, notFound, errors });
|
|
689
|
+
});
|
|
690
|
+
// Backwards compat: /sessions (no prefix) returns raw array
|
|
691
|
+
app.get('/sessions', async () => sessions.listSessions());
|
|
692
|
+
/** Validate workDir — delegates to validation.ts (Issue #435). */
|
|
693
|
+
const validateWorkDirWithConfig = (workDir) => validateWorkDir(workDir, config.allowedWorkDirs);
|
|
694
|
+
// Create session (Issue #607: reuse idle session for same workDir)
|
|
695
|
+
async function createSessionHandler(req, reply) {
|
|
696
|
+
const parsed = createSessionSchema.safeParse(req.body);
|
|
697
|
+
if (!parsed.success) {
|
|
698
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
699
|
+
}
|
|
700
|
+
const { workDir, name, prompt, prd, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove, parentId, memoryKeys } = parsed.data;
|
|
701
|
+
if (!workDir)
|
|
702
|
+
return reply.status(400).send({ error: 'workDir is required' });
|
|
703
|
+
// Issue #564: Validate installed Claude Code version
|
|
704
|
+
try {
|
|
705
|
+
const raw = execFileSync('claude', ['--version'], { encoding: 'utf-8', timeout: 5000 });
|
|
706
|
+
const ccVer = extractCCVersion(raw);
|
|
707
|
+
if (ccVer !== null && compareSemver(ccVer, MIN_CC_VERSION) < 0) {
|
|
708
|
+
return reply.status(422).send({
|
|
709
|
+
error: `Claude Code version ${ccVer} is below minimum supported version ${MIN_CC_VERSION}. Please upgrade.`,
|
|
710
|
+
code: 'CC_VERSION_TOO_OLD',
|
|
711
|
+
upgrade: 'Run: claude update or npm install -g @anthropic-ai/claude-code@latest',
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
catch {
|
|
716
|
+
// claude CLI not found or timed out — skip version check (fails open)
|
|
717
|
+
}
|
|
718
|
+
const safeWorkDir = await validateWorkDirWithConfig(workDir);
|
|
719
|
+
if (typeof safeWorkDir === 'object')
|
|
720
|
+
return reply.status(400).send({ error: safeWorkDir.error, code: safeWorkDir.code });
|
|
721
|
+
// Issue #607: Check for an existing idle session with the same workDir
|
|
722
|
+
const existing = await sessions.findIdleSessionByWorkDir(safeWorkDir);
|
|
723
|
+
if (existing) {
|
|
724
|
+
try {
|
|
725
|
+
// Send prompt to the existing session if provided
|
|
726
|
+
let promptDelivery;
|
|
727
|
+
if (prompt) {
|
|
728
|
+
let finalPrompt = prompt;
|
|
729
|
+
if (memoryKeys && memoryKeys.length > 0 && memoryBridge) {
|
|
730
|
+
const resolved = memoryBridge.resolveKeys(memoryKeys);
|
|
731
|
+
if (resolved.size > 0) {
|
|
732
|
+
const lines = ['[Memory context]'];
|
|
733
|
+
for (const [k, v] of resolved)
|
|
734
|
+
lines.push(`${k}: ${v}`);
|
|
735
|
+
lines.push('', prompt);
|
|
736
|
+
finalPrompt = lines.join('\n');
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
promptDelivery = await sessions.sendInitialPrompt(existing.id, finalPrompt);
|
|
740
|
+
metrics.promptSent(promptDelivery.delivered);
|
|
741
|
+
}
|
|
742
|
+
return reply.status(200).send({ ...existing, reused: true, promptDelivery });
|
|
743
|
+
}
|
|
744
|
+
finally {
|
|
745
|
+
sessions.releaseSessionClaim(existing.id);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
console.time("POST_CREATE_SESSION");
|
|
749
|
+
const session = await sessions.createSession({ workDir: safeWorkDir, name, prd, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove, parentId });
|
|
750
|
+
console.timeEnd("POST_CREATE_SESSION");
|
|
751
|
+
console.time("POST_CHANNEL_CREATED");
|
|
752
|
+
// Issue #625: Track session in metrics so sessionsCreated counter is accurate
|
|
753
|
+
metrics.sessionCreated(session.id);
|
|
754
|
+
// Issue #46: Create Telegram topic BEFORE sending prompt.
|
|
755
|
+
// The monitor starts polling immediately after createSession().
|
|
756
|
+
// If we wait for sendInitialPrompt (up to 15s), the monitor may find
|
|
757
|
+
// new messages but can't forward them because no topic exists yet.
|
|
758
|
+
// Those messages are lost forever (monitorOffset advances past them).
|
|
759
|
+
await channels.sessionCreated({
|
|
760
|
+
event: 'session.created',
|
|
761
|
+
timestamp: new Date().toISOString(),
|
|
762
|
+
session: { id: session.id, name: session.windowName, workDir },
|
|
763
|
+
detail: `Session created: ${session.windowName}`,
|
|
764
|
+
meta: prompt ? { prompt: prompt.slice(0, 200), permissionMode: permissionMode ?? (autoApprove ? 'bypassPermissions' : undefined) } : undefined,
|
|
765
|
+
});
|
|
766
|
+
console.timeEnd("POST_CHANNEL_CREATED");
|
|
767
|
+
console.time("POST_SEND_INITIAL_PROMPT");
|
|
768
|
+
// Now send the prompt (topic exists, monitor can forward messages)
|
|
769
|
+
let promptDelivery;
|
|
770
|
+
if (prompt) {
|
|
771
|
+
// Issue #783: Inject resolved memory values into prompt
|
|
772
|
+
let finalPrompt = prompt;
|
|
773
|
+
if (memoryKeys && memoryKeys.length > 0 && memoryBridge) {
|
|
774
|
+
const resolved = memoryBridge.resolveKeys(memoryKeys);
|
|
775
|
+
if (resolved.size > 0) {
|
|
776
|
+
const lines = ['[Memory context]'];
|
|
777
|
+
for (const [k, v] of resolved)
|
|
778
|
+
lines.push(`${k}: ${v}`);
|
|
779
|
+
lines.push('', prompt);
|
|
780
|
+
finalPrompt = lines.join('\n');
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
promptDelivery = await sessions.sendInitialPrompt(session.id, finalPrompt);
|
|
784
|
+
console.timeEnd("POST_SEND_INITIAL_PROMPT");
|
|
785
|
+
metrics.promptSent(promptDelivery.delivered);
|
|
786
|
+
}
|
|
787
|
+
else {
|
|
788
|
+
console.timeEnd("POST_SEND_INITIAL_PROMPT");
|
|
789
|
+
}
|
|
790
|
+
return reply.status(201).send({ ...session, promptDelivery });
|
|
791
|
+
}
|
|
792
|
+
app.post('/v1/sessions', createSessionHandler);
|
|
793
|
+
app.post('/sessions', createSessionHandler);
|
|
794
|
+
// Get session (Issue #20: includes actionHints for interactive states)
|
|
795
|
+
async function getSessionHandler(req, reply) {
|
|
796
|
+
const sessionId = req.params.id;
|
|
797
|
+
const session = sessions.getSession(sessionId);
|
|
798
|
+
if (!session)
|
|
799
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
800
|
+
return addActionHints(session, sessions);
|
|
801
|
+
}
|
|
802
|
+
app.get('/v1/sessions/:id', getSessionHandler);
|
|
803
|
+
app.get('/sessions/:id', getSessionHandler);
|
|
804
|
+
// #128: Bulk health check — returns health for all sessions in one request
|
|
805
|
+
app.get('/v1/sessions/health', async () => {
|
|
806
|
+
const allSessions = sessions.listSessions();
|
|
807
|
+
const results = {};
|
|
808
|
+
await Promise.all(allSessions.map(async (s) => {
|
|
809
|
+
try {
|
|
810
|
+
results[s.id] = await sessions.getHealth(s.id);
|
|
811
|
+
}
|
|
812
|
+
catch { /* health check failed — report error state */
|
|
813
|
+
results[s.id] = {
|
|
814
|
+
alive: false, windowExists: false, claudeRunning: false,
|
|
815
|
+
paneCommand: null, status: 'unknown', hasTranscript: false,
|
|
816
|
+
lastActivity: 0, lastActivityAgo: 0, sessionAge: 0,
|
|
817
|
+
details: 'Error fetching health',
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
}));
|
|
821
|
+
return results;
|
|
822
|
+
});
|
|
823
|
+
// Session health check (Issue #2)
|
|
824
|
+
async function sessionHealthHandler(req, reply) {
|
|
825
|
+
try {
|
|
826
|
+
return await sessions.getHealth(req.params.id);
|
|
827
|
+
}
|
|
828
|
+
catch (e) {
|
|
829
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
app.get('/v1/sessions/:id/health', sessionHealthHandler);
|
|
833
|
+
app.get('/sessions/:id/health', sessionHealthHandler);
|
|
834
|
+
// Send message (with delivery verification — Issue #1)
|
|
835
|
+
async function sendMessageHandler(req, reply) {
|
|
836
|
+
const parsed = sendMessageSchema.safeParse(req.body);
|
|
837
|
+
if (!parsed.success)
|
|
838
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
839
|
+
const { text } = parsed.data;
|
|
840
|
+
try {
|
|
841
|
+
const result = await sessions.sendMessage(req.params.id, text);
|
|
842
|
+
await channels.message({
|
|
843
|
+
event: 'message.user',
|
|
844
|
+
timestamp: new Date().toISOString(),
|
|
845
|
+
session: { id: req.params.id, name: '', workDir: '' },
|
|
846
|
+
detail: text,
|
|
847
|
+
});
|
|
848
|
+
return { ok: true, delivered: result.delivered, attempts: result.attempts };
|
|
849
|
+
}
|
|
850
|
+
catch (e) {
|
|
851
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
app.post('/v1/sessions/:id/send', sendMessageHandler);
|
|
855
|
+
app.post('/sessions/:id/send', sendMessageHandler);
|
|
856
|
+
// Issue #702: GET children sessions
|
|
857
|
+
async function getChildrenHandler(req, reply) {
|
|
858
|
+
const sessionId = req.params.id;
|
|
859
|
+
const session = sessions.getSession(sessionId);
|
|
860
|
+
if (!session)
|
|
861
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
862
|
+
const children = (session.children ?? []).map(id => {
|
|
863
|
+
const child = sessions.getSession(id);
|
|
864
|
+
if (!child)
|
|
865
|
+
return null;
|
|
866
|
+
return { id: child.id, windowName: child.windowName, status: child.status, createdAt: child.createdAt };
|
|
867
|
+
}).filter(Boolean);
|
|
868
|
+
return { children };
|
|
869
|
+
}
|
|
870
|
+
app.get('/v1/sessions/:id/children', getChildrenHandler);
|
|
871
|
+
app.get('/sessions/:id/children', getChildrenHandler);
|
|
872
|
+
async function spawnChildHandler(req, reply) {
|
|
873
|
+
const parentId = req.params.id;
|
|
874
|
+
const parent = sessions.getSession(parentId);
|
|
875
|
+
if (!parent)
|
|
876
|
+
return reply.status(404).send({ error: 'Parent session not found' });
|
|
877
|
+
const { name, prompt, workDir, permissionMode } = req.body ?? {};
|
|
878
|
+
const childName = name ?? `${parent.windowName ?? 'session'}-child`;
|
|
879
|
+
const requestedWorkDir = workDir ?? parent.workDir;
|
|
880
|
+
const safeChildWorkDir = await validateWorkDirWithConfig(requestedWorkDir);
|
|
881
|
+
if (typeof safeChildWorkDir === 'object') {
|
|
882
|
+
return reply.status(400).send({ error: `Invalid workDir: ${safeChildWorkDir.error}`, code: safeChildWorkDir.code });
|
|
883
|
+
}
|
|
884
|
+
const childPermMode = permissionMode ?? parent.permissionMode ?? 'default';
|
|
885
|
+
const childSession = await sessions.createSession({ workDir: safeChildWorkDir, name: childName, parentId, permissionMode: childPermMode });
|
|
886
|
+
let promptDelivery;
|
|
887
|
+
if (prompt) {
|
|
888
|
+
promptDelivery = await sessions.sendInitialPrompt(childSession.id, prompt);
|
|
889
|
+
}
|
|
890
|
+
return reply.status(201).send({ ...childSession, promptDelivery });
|
|
891
|
+
}
|
|
892
|
+
app.post('/v1/sessions/:id/spawn', spawnChildHandler);
|
|
893
|
+
app.post('/sessions/:id/spawn', spawnChildHandler);
|
|
894
|
+
async function forkSessionHandler(req, reply) {
|
|
895
|
+
const parentId = req.params.id;
|
|
896
|
+
const parent = sessions.getSession(parentId);
|
|
897
|
+
if (!parent)
|
|
898
|
+
return reply.status(404).send({ error: 'Parent session not found' });
|
|
899
|
+
const { name, prompt } = req.body ?? {};
|
|
900
|
+
const forkName = name ?? `${parent.windowName ?? 'session'}-fork`;
|
|
901
|
+
// Inherit: workDir, permissionMode, env (collect from parent's env vars if stored)
|
|
902
|
+
// Note: Parent's env vars are passed during creation but not stored in SessionInfo
|
|
903
|
+
// For now, we inherit structural settings (workDir, permissionMode)
|
|
904
|
+
const forkedSession = await sessions.createSession({
|
|
905
|
+
workDir: parent.workDir,
|
|
906
|
+
name: forkName,
|
|
907
|
+
permissionMode: parent.permissionMode,
|
|
908
|
+
});
|
|
909
|
+
let promptDelivery;
|
|
910
|
+
if (prompt) {
|
|
911
|
+
promptDelivery = await sessions.sendInitialPrompt(forkedSession.id, prompt);
|
|
912
|
+
}
|
|
913
|
+
await channels.sessionCreated({
|
|
914
|
+
event: 'session.created',
|
|
915
|
+
timestamp: new Date().toISOString(),
|
|
916
|
+
session: { id: forkedSession.id, name: forkedSession.windowName, workDir: parent.workDir },
|
|
917
|
+
detail: `Session forked from ${parentId}`,
|
|
918
|
+
});
|
|
919
|
+
return reply.status(201).send({ ...forkedSession, forkedFrom: parentId, promptDelivery });
|
|
920
|
+
}
|
|
921
|
+
app.post('/v1/sessions/:id/fork', forkSessionHandler);
|
|
922
|
+
app.post('/sessions/:id/fork', forkSessionHandler);
|
|
923
|
+
async function createConsensusHandler(req, reply) {
|
|
924
|
+
const targetSessionId = req.params.id;
|
|
925
|
+
const target = sessions.getSession(targetSessionId);
|
|
926
|
+
if (!target)
|
|
927
|
+
return reply.status(404).send({ error: 'Target session not found' });
|
|
928
|
+
const focusAreas = (req.body?.focusAreas && req.body.focusAreas.length > 0)
|
|
929
|
+
? req.body.focusAreas
|
|
930
|
+
: ['correctness', 'security', 'performance'];
|
|
931
|
+
const reviewerCount = Math.min(5, Math.max(1, req.body?.reviewerCount ?? focusAreas.length));
|
|
932
|
+
const selectedFocus = focusAreas.slice(0, reviewerCount);
|
|
933
|
+
const reviewerIds = [];
|
|
934
|
+
for (let i = 0; i < selectedFocus.length; i += 1) {
|
|
935
|
+
const focus = selectedFocus[i];
|
|
936
|
+
const child = await sessions.createSession({
|
|
937
|
+
workDir: target.workDir,
|
|
938
|
+
name: `consensus-${focus}-${targetSessionId.slice(0, 6)}`,
|
|
939
|
+
parentId: targetSessionId,
|
|
940
|
+
permissionMode: target.permissionMode,
|
|
941
|
+
});
|
|
942
|
+
reviewerIds.push(child.id);
|
|
943
|
+
await sessions.sendInitialPrompt(child.id, buildConsensusPrompt(targetSessionId, focus));
|
|
944
|
+
}
|
|
945
|
+
const consensusId = crypto.randomUUID();
|
|
946
|
+
const record = {
|
|
947
|
+
id: consensusId,
|
|
948
|
+
targetSessionId,
|
|
949
|
+
reviewerIds,
|
|
950
|
+
focusAreas: selectedFocus,
|
|
951
|
+
status: 'running',
|
|
952
|
+
createdAt: Date.now(),
|
|
953
|
+
};
|
|
954
|
+
consensusRequests.set(consensusId, record);
|
|
955
|
+
return reply.status(202).send(record);
|
|
956
|
+
}
|
|
957
|
+
function getConsensusHandler(req, reply) {
|
|
958
|
+
const item = consensusRequests.get(req.params.id);
|
|
959
|
+
if (!item)
|
|
960
|
+
return reply.status(404).send({ error: 'Consensus request not found' });
|
|
961
|
+
return item;
|
|
962
|
+
}
|
|
963
|
+
app.post('/v1/sessions/:id/consensus', createConsensusHandler);
|
|
964
|
+
app.get('/v1/consensus/:id', getConsensusHandler);
|
|
965
|
+
async function getPermissionPolicyHandler(req, reply) {
|
|
966
|
+
const sessionId = req.params.id;
|
|
967
|
+
const session = sessions.getSession(sessionId);
|
|
968
|
+
if (!session)
|
|
969
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
970
|
+
return { permissionPolicy: session.permissionPolicy ?? [] };
|
|
971
|
+
}
|
|
972
|
+
async function updatePermissionPolicyHandler(req, reply) {
|
|
973
|
+
const sessionId = req.params.id;
|
|
974
|
+
const session = sessions.getSession(sessionId);
|
|
975
|
+
if (!session)
|
|
976
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
977
|
+
const policy = req.body ?? [];
|
|
978
|
+
const result = permissionRuleSchema.array().safeParse(policy);
|
|
979
|
+
if (!result.success)
|
|
980
|
+
return reply.status(400).send({ error: 'Invalid permission policy', details: result.error.issues });
|
|
981
|
+
session.permissionPolicy = policy;
|
|
982
|
+
await sessions.save();
|
|
983
|
+
return { permissionPolicy: policy };
|
|
984
|
+
}
|
|
985
|
+
app.get('/v1/sessions/:id/permissions', getPermissionPolicyHandler);
|
|
986
|
+
app.put('/v1/sessions/:id/permissions', updatePermissionPolicyHandler);
|
|
987
|
+
app.get('/sessions/:id/permissions', getPermissionPolicyHandler);
|
|
988
|
+
app.put('/sessions/:id/permissions', updatePermissionPolicyHandler);
|
|
989
|
+
async function getPermissionProfileHandler(req, reply) {
|
|
990
|
+
const sessionId = req.params.id;
|
|
991
|
+
const session = sessions.getSession(sessionId);
|
|
992
|
+
if (!session)
|
|
993
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
994
|
+
return { permissionProfile: session.permissionProfile ?? null };
|
|
995
|
+
}
|
|
996
|
+
async function updatePermissionProfileHandler(req, reply) {
|
|
997
|
+
const sessionId = req.params.id;
|
|
998
|
+
const session = sessions.getSession(sessionId);
|
|
999
|
+
if (!session)
|
|
1000
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
1001
|
+
const parsed = permissionProfileSchema.safeParse(req.body ?? {});
|
|
1002
|
+
if (!parsed.success)
|
|
1003
|
+
return reply.status(400).send({ error: 'Invalid permission profile', details: parsed.error.issues });
|
|
1004
|
+
session.permissionProfile = parsed.data;
|
|
1005
|
+
await sessions.save();
|
|
1006
|
+
return { permissionProfile: parsed.data };
|
|
1007
|
+
}
|
|
1008
|
+
app.get('/v1/sessions/:id/permission-profile', getPermissionProfileHandler);
|
|
1009
|
+
app.put('/v1/sessions/:id/permission-profile', updatePermissionProfileHandler);
|
|
1010
|
+
app.get('/sessions/:id/permission-profile', getPermissionProfileHandler);
|
|
1011
|
+
app.put('/sessions/:id/permission-profile', updatePermissionProfileHandler);
|
|
1012
|
+
// Read messages
|
|
1013
|
+
async function readMessagesHandler(req, reply) {
|
|
1014
|
+
try {
|
|
1015
|
+
return await sessions.readMessages(req.params.id);
|
|
1016
|
+
}
|
|
1017
|
+
catch (e) {
|
|
1018
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
app.get('/v1/sessions/:id/read', readMessagesHandler);
|
|
1022
|
+
app.get('/sessions/:id/read', readMessagesHandler);
|
|
1023
|
+
registerPermissionRoutes(app, {
|
|
1024
|
+
approve: async (id) => sessions.approve(id),
|
|
1025
|
+
reject: async (id) => sessions.reject(id),
|
|
1026
|
+
getLatencyMetrics: (id) => sessions.getLatencyMetrics(id),
|
|
1027
|
+
}, {
|
|
1028
|
+
recordPermissionResponse: (id, latencyMs) => metrics.recordPermissionResponse(id, latencyMs),
|
|
1029
|
+
});
|
|
1030
|
+
// Issue #336: Answer pending AskUserQuestion
|
|
1031
|
+
app.post('/v1/sessions/:id/answer', async (req, reply) => {
|
|
1032
|
+
const { questionId, answer } = req.body || {};
|
|
1033
|
+
if (!questionId || answer === undefined || answer === null) {
|
|
1034
|
+
return reply.status(400).send({ error: 'questionId and answer are required' });
|
|
1035
|
+
}
|
|
1036
|
+
const sessionId = req.params.id;
|
|
1037
|
+
const session = sessions.getSession(sessionId);
|
|
1038
|
+
if (!session)
|
|
1039
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
1040
|
+
const resolved = sessions.submitAnswer(req.params.id, questionId, answer);
|
|
1041
|
+
if (!resolved) {
|
|
1042
|
+
return reply.status(409).send({ error: 'No pending question matching this questionId' });
|
|
1043
|
+
}
|
|
1044
|
+
return { ok: true };
|
|
1045
|
+
});
|
|
1046
|
+
// Escape
|
|
1047
|
+
async function escapeHandler(req, reply) {
|
|
1048
|
+
try {
|
|
1049
|
+
await sessions.escape(req.params.id);
|
|
1050
|
+
return { ok: true };
|
|
1051
|
+
}
|
|
1052
|
+
catch (e) {
|
|
1053
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
app.post('/v1/sessions/:id/escape', escapeHandler);
|
|
1057
|
+
app.post('/sessions/:id/escape', escapeHandler);
|
|
1058
|
+
// Interrupt (Ctrl+C)
|
|
1059
|
+
async function interruptHandler(req, reply) {
|
|
1060
|
+
try {
|
|
1061
|
+
await sessions.interrupt(req.params.id);
|
|
1062
|
+
return { ok: true };
|
|
1063
|
+
}
|
|
1064
|
+
catch (e) {
|
|
1065
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
app.post('/v1/sessions/:id/interrupt', interruptHandler);
|
|
1069
|
+
app.post('/sessions/:id/interrupt', interruptHandler);
|
|
1070
|
+
// Kill session
|
|
1071
|
+
async function killSessionHandler(req, reply) {
|
|
1072
|
+
if (!sessions.getSession(req.params.id)) {
|
|
1073
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
1074
|
+
}
|
|
1075
|
+
try {
|
|
1076
|
+
// #842: killSession first, then notify — avoids race where channels
|
|
1077
|
+
// reference a session that is still being destroyed.
|
|
1078
|
+
await sessions.killSession(req.params.id);
|
|
1079
|
+
eventBus.emitEnded(req.params.id, 'killed');
|
|
1080
|
+
await channels.sessionEnded(makePayload('session.ended', req.params.id, 'killed'));
|
|
1081
|
+
cleanupTerminatedSessionState(req.params.id, { monitor, metrics, toolRegistry });
|
|
1082
|
+
return { ok: true };
|
|
1083
|
+
}
|
|
1084
|
+
catch (e) {
|
|
1085
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
app.delete('/v1/sessions/:id', killSessionHandler);
|
|
1089
|
+
app.delete('/sessions/:id', killSessionHandler);
|
|
1090
|
+
// Capture raw pane
|
|
1091
|
+
async function capturePaneHandler(req, reply) {
|
|
1092
|
+
const sessionId = req.params.id;
|
|
1093
|
+
const session = sessions.getSession(sessionId);
|
|
1094
|
+
if (!session)
|
|
1095
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
1096
|
+
const pane = await tmux.capturePane(session.windowId);
|
|
1097
|
+
return { pane };
|
|
1098
|
+
}
|
|
1099
|
+
app.get('/v1/sessions/:id/pane', capturePaneHandler);
|
|
1100
|
+
app.get('/sessions/:id/pane', capturePaneHandler);
|
|
1101
|
+
// Slash command
|
|
1102
|
+
async function commandHandler(req, reply) {
|
|
1103
|
+
const parsed = commandSchema.safeParse(req.body);
|
|
1104
|
+
if (!parsed.success)
|
|
1105
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
1106
|
+
const { command } = parsed.data;
|
|
1107
|
+
try {
|
|
1108
|
+
const cmd = command.startsWith('/') ? command : `/${command}`;
|
|
1109
|
+
await sessions.sendMessage(req.params.id, cmd);
|
|
1110
|
+
return { ok: true };
|
|
1111
|
+
}
|
|
1112
|
+
catch (e) {
|
|
1113
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
app.post('/v1/sessions/:id/command', commandHandler);
|
|
1117
|
+
app.post('/sessions/:id/command', commandHandler);
|
|
1118
|
+
// Bash mode
|
|
1119
|
+
async function bashHandler(req, reply) {
|
|
1120
|
+
const parsed = bashSchema.safeParse(req.body);
|
|
1121
|
+
if (!parsed.success)
|
|
1122
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
1123
|
+
const { command } = parsed.data;
|
|
1124
|
+
try {
|
|
1125
|
+
const cmd = command.startsWith('!') ? command : `!${command}`;
|
|
1126
|
+
await sessions.sendMessage(req.params.id, cmd);
|
|
1127
|
+
return { ok: true };
|
|
1128
|
+
}
|
|
1129
|
+
catch (e) {
|
|
1130
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
app.post('/v1/sessions/:id/bash', bashHandler);
|
|
1134
|
+
app.post('/sessions/:id/bash', bashHandler);
|
|
1135
|
+
// Session summary (Issue #35)
|
|
1136
|
+
async function summaryHandler(req, reply) {
|
|
1137
|
+
try {
|
|
1138
|
+
return await sessions.getSummary(req.params.id);
|
|
1139
|
+
}
|
|
1140
|
+
catch (e) {
|
|
1141
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
app.get('/v1/sessions/:id/summary', summaryHandler);
|
|
1145
|
+
app.get('/sessions/:id/summary', summaryHandler);
|
|
1146
|
+
// Paginated transcript read
|
|
1147
|
+
app.get('/v1/sessions/:id/transcript', async (req, reply) => {
|
|
1148
|
+
try {
|
|
1149
|
+
const page = Math.max(1, parseInt(req.query.page || '1', 10) || 1);
|
|
1150
|
+
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit || '50', 10) || 50));
|
|
1151
|
+
const allowedRoles = new Set(['user', 'assistant', 'system']);
|
|
1152
|
+
const roleFilter = req.query.role;
|
|
1153
|
+
if (roleFilter && !allowedRoles.has(roleFilter)) {
|
|
1154
|
+
return reply.status(400).send({ error: `Invalid role filter: ${roleFilter}. Allowed values: user, assistant, system` });
|
|
1155
|
+
}
|
|
1156
|
+
return await sessions.readTranscript(req.params.id, page, limit, roleFilter);
|
|
1157
|
+
}
|
|
1158
|
+
catch (e) {
|
|
1159
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
1160
|
+
}
|
|
1161
|
+
});
|
|
1162
|
+
// Cursor-based transcript replay (Issue #883): stable pagination under concurrent appends.
|
|
1163
|
+
// GET /v1/sessions/:id/transcript/cursor?before_id=N&limit=50&role=user|assistant|system
|
|
1164
|
+
app.get('/v1/sessions/:id/transcript/cursor', async (req, reply) => {
|
|
1165
|
+
try {
|
|
1166
|
+
const rawBeforeId = req.query.before_id;
|
|
1167
|
+
const beforeId = rawBeforeId !== undefined ? parseInt(rawBeforeId, 10) : undefined;
|
|
1168
|
+
if (beforeId !== undefined && (!Number.isInteger(beforeId) || beforeId < 1)) {
|
|
1169
|
+
return reply.status(400).send({ error: 'before_id must be a positive integer' });
|
|
1170
|
+
}
|
|
1171
|
+
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit || '50', 10) || 50));
|
|
1172
|
+
const allowedRoles = new Set(['user', 'assistant', 'system']);
|
|
1173
|
+
const roleFilter = req.query.role;
|
|
1174
|
+
if (roleFilter && !allowedRoles.has(roleFilter)) {
|
|
1175
|
+
return reply.status(400).send({ error: `Invalid role filter: ${roleFilter}. Allowed values: user, assistant, system` });
|
|
1176
|
+
}
|
|
1177
|
+
return await sessions.readTranscriptCursor(req.params.id, beforeId, limit, roleFilter);
|
|
1178
|
+
}
|
|
1179
|
+
catch (e) {
|
|
1180
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
1181
|
+
}
|
|
1182
|
+
});
|
|
1183
|
+
// Screenshot capture (Issue #22)
|
|
1184
|
+
async function screenshotHandler(req, reply) {
|
|
1185
|
+
const parsed = screenshotSchema.safeParse(req.body);
|
|
1186
|
+
if (!parsed.success)
|
|
1187
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
1188
|
+
const { url, fullPage, width, height } = parsed.data;
|
|
1189
|
+
const urlError = validateScreenshotUrl(url);
|
|
1190
|
+
if (urlError)
|
|
1191
|
+
return reply.status(400).send({ error: urlError });
|
|
1192
|
+
// DNS-resolution check: resolve hostname and reject private IPs.
|
|
1193
|
+
// Returns the resolved IP so we can pin it via --host-resolver-rules to prevent
|
|
1194
|
+
// DNS rebinding (TOCTOU) between validation and page.goto().
|
|
1195
|
+
const hostname = new URL(url).hostname;
|
|
1196
|
+
const dnsResult = await resolveAndCheckIp(hostname);
|
|
1197
|
+
if (dnsResult.error)
|
|
1198
|
+
return reply.status(400).send({ error: dnsResult.error });
|
|
1199
|
+
// Validate session exists
|
|
1200
|
+
const sessionId = req.params.id;
|
|
1201
|
+
const session = sessions.getSession(sessionId);
|
|
1202
|
+
if (!session)
|
|
1203
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
1204
|
+
if (!isPlaywrightAvailable()) {
|
|
1205
|
+
return reply.status(501).send({
|
|
1206
|
+
error: 'Playwright is not installed',
|
|
1207
|
+
message: 'Install Playwright to enable screenshots: npx playwright install chromium && npm install -D playwright',
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
try {
|
|
1211
|
+
// Pin the validated IP via host-resolver-rules to prevent DNS rebinding
|
|
1212
|
+
const hostResolverRule = dnsResult.resolvedIp
|
|
1213
|
+
? buildHostResolverRule(hostname, dnsResult.resolvedIp)
|
|
1214
|
+
: undefined;
|
|
1215
|
+
const result = await captureScreenshot({ url, fullPage, width, height, hostResolverRule });
|
|
1216
|
+
return reply.status(200).send(result);
|
|
1217
|
+
}
|
|
1218
|
+
catch (e) {
|
|
1219
|
+
return reply.status(500).send({ error: `Screenshot failed: ${e instanceof Error ? e.message : String(e)}` });
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
app.post('/v1/sessions/:id/screenshot', screenshotHandler);
|
|
1223
|
+
app.post('/sessions/:id/screenshot', screenshotHandler);
|
|
1224
|
+
// SSE event stream (Issue #32)
|
|
1225
|
+
app.get('/v1/sessions/:id/events', async (req, reply) => {
|
|
1226
|
+
const sessionId = req.params.id;
|
|
1227
|
+
const session = sessions.getSession(sessionId);
|
|
1228
|
+
if (!session)
|
|
1229
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
1230
|
+
const clientIp = req.ip;
|
|
1231
|
+
const acquireResult = sseLimiter.acquire(clientIp);
|
|
1232
|
+
if (!acquireResult.allowed) {
|
|
1233
|
+
const status = acquireResult.reason === 'per_ip_limit' ? 429 : 503;
|
|
1234
|
+
return reply.status(status).send({
|
|
1235
|
+
error: acquireResult.reason === 'per_ip_limit'
|
|
1236
|
+
? `Per-IP connection limit reached (${acquireResult.current}/${acquireResult.limit})`
|
|
1237
|
+
: `Global connection limit reached (${acquireResult.current}/${acquireResult.limit})`,
|
|
1238
|
+
reason: acquireResult.reason,
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
// Issue #505: Subscribe BEFORE writing response headers so that if
|
|
1242
|
+
// subscription fails, we can still return a proper HTTP error.
|
|
1243
|
+
let unsubscribe;
|
|
1244
|
+
const connectionId = acquireResult.connectionId;
|
|
1245
|
+
let writer;
|
|
1246
|
+
// Queue events that arrive between subscription and writer creation
|
|
1247
|
+
const pendingEvents = [];
|
|
1248
|
+
let subscriptionReady = false;
|
|
1249
|
+
try {
|
|
1250
|
+
const handler = (event) => {
|
|
1251
|
+
if (!subscriptionReady) {
|
|
1252
|
+
pendingEvents.push(event);
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
const id = event.id != null ? `id: ${event.id}\n` : '';
|
|
1256
|
+
writer.write(`${id}data: ${JSON.stringify(event)}\n\n`);
|
|
1257
|
+
};
|
|
1258
|
+
unsubscribe = eventBus.subscribe(req.params.id, handler);
|
|
1259
|
+
}
|
|
1260
|
+
catch (err) {
|
|
1261
|
+
req.log.error({ err, sessionId: req.params.id }, 'SSE subscription failed — unable to create event listener');
|
|
1262
|
+
sseLimiter.release(connectionId);
|
|
1263
|
+
return reply.status(500).send({ error: 'Failed to create SSE subscription' });
|
|
1264
|
+
}
|
|
1265
|
+
reply.raw.writeHead(200, {
|
|
1266
|
+
'Content-Type': 'text/event-stream',
|
|
1267
|
+
'Cache-Control': 'no-cache',
|
|
1268
|
+
'Connection': 'keep-alive',
|
|
1269
|
+
'X-Accel-Buffering': 'no',
|
|
1270
|
+
});
|
|
1271
|
+
writer = new SSEWriter(reply.raw, req.raw, () => {
|
|
1272
|
+
unsubscribe?.();
|
|
1273
|
+
sseLimiter.release(connectionId);
|
|
1274
|
+
});
|
|
1275
|
+
// Now safe to deliver events — flush any queued during setup
|
|
1276
|
+
subscriptionReady = true;
|
|
1277
|
+
for (const event of pendingEvents) {
|
|
1278
|
+
const id = event.id != null ? `id: ${event.id}\n` : '';
|
|
1279
|
+
writer.write(`${id}data: ${JSON.stringify(event)}\n\n`);
|
|
1280
|
+
}
|
|
1281
|
+
// Send initial connected event
|
|
1282
|
+
writer.write(`data: ${JSON.stringify({ event: 'connected', sessionId: session.id, timestamp: new Date().toISOString() })}\n\n`);
|
|
1283
|
+
// Issue #308: Replay missed events if client sends Last-Event-ID
|
|
1284
|
+
const lastEventId = req.headers['last-event-id'];
|
|
1285
|
+
if (lastEventId) {
|
|
1286
|
+
const missed = eventBus.getEventsSince(req.params.id, parseInt(lastEventId, 10) || 0);
|
|
1287
|
+
for (const event of missed) {
|
|
1288
|
+
const id = event.id != null ? `id: ${event.id}\n` : '';
|
|
1289
|
+
writer.write(`${id}data: ${JSON.stringify(event)}\n\n`);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
writer.startHeartbeat(30_000, 90_000, () => `data: ${JSON.stringify({ event: 'heartbeat', sessionId: session.id, timestamp: new Date().toISOString() })}\n\n`);
|
|
1293
|
+
// Don't let Fastify auto-send (we manage the response manually)
|
|
1294
|
+
await reply;
|
|
1295
|
+
});
|
|
1296
|
+
// ── Claude Code Hook Endpoints (Issue #161) ─────────────────────────
|
|
1297
|
+
// POST /v1/sessions/:id/hooks/permission — PermissionRequest hook from CC
|
|
1298
|
+
app.post('/v1/sessions/:id/hooks/permission', async (req, reply) => {
|
|
1299
|
+
const sessionId = req.params.id;
|
|
1300
|
+
const session = sessions.getSession(sessionId);
|
|
1301
|
+
if (!session)
|
|
1302
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
1303
|
+
const parsed = permissionHookSchema.safeParse(req.body);
|
|
1304
|
+
if (!parsed.success)
|
|
1305
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
1306
|
+
const { tool_name, tool_input, permission_mode } = parsed.data;
|
|
1307
|
+
// Update session status
|
|
1308
|
+
session.status = 'permission_prompt';
|
|
1309
|
+
session.lastActivity = Date.now();
|
|
1310
|
+
await sessions.save();
|
|
1311
|
+
// Notify channels and SSE
|
|
1312
|
+
const detail = tool_name
|
|
1313
|
+
? `Permission request: ${tool_name}${permission_mode ? ` (${permission_mode})` : ''}`
|
|
1314
|
+
: 'Permission requested';
|
|
1315
|
+
await channels.statusChange({
|
|
1316
|
+
event: 'status.permission',
|
|
1317
|
+
timestamp: new Date().toISOString(),
|
|
1318
|
+
session: { id: session.id, name: session.windowName, workDir: session.workDir },
|
|
1319
|
+
detail,
|
|
1320
|
+
meta: { tool_name, tool_input, permission_mode },
|
|
1321
|
+
});
|
|
1322
|
+
eventBus.emitApproval(session.id, detail);
|
|
1323
|
+
return reply.status(200).send({});
|
|
1324
|
+
});
|
|
1325
|
+
// POST /v1/sessions/:id/hooks/stop — Stop hook from CC
|
|
1326
|
+
app.post('/v1/sessions/:id/hooks/stop', async (req, reply) => {
|
|
1327
|
+
const sessionId = req.params.id;
|
|
1328
|
+
const session = sessions.getSession(sessionId);
|
|
1329
|
+
if (!session)
|
|
1330
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
1331
|
+
const parsed = stopHookSchema.safeParse(req.body);
|
|
1332
|
+
if (!parsed.success)
|
|
1333
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
1334
|
+
const { stop_reason } = parsed.data;
|
|
1335
|
+
// Update session status
|
|
1336
|
+
session.status = 'idle';
|
|
1337
|
+
session.lastActivity = Date.now();
|
|
1338
|
+
await sessions.save();
|
|
1339
|
+
// Notify channels and SSE
|
|
1340
|
+
const detail = stop_reason
|
|
1341
|
+
? `Claude Code stopped: ${stop_reason}`
|
|
1342
|
+
: 'Claude Code session ended normally';
|
|
1343
|
+
await channels.statusChange({
|
|
1344
|
+
event: 'status.idle',
|
|
1345
|
+
timestamp: new Date().toISOString(),
|
|
1346
|
+
session: { id: session.id, name: session.windowName, workDir: session.workDir },
|
|
1347
|
+
detail,
|
|
1348
|
+
meta: { stop_reason },
|
|
1349
|
+
});
|
|
1350
|
+
eventBus.emitStatus(session.id, 'idle', detail);
|
|
1351
|
+
return reply.status(200).send({});
|
|
1352
|
+
});
|
|
1353
|
+
// Batch create (Issue #36, #583: per-key batch rate limit + global session cap)
|
|
1354
|
+
const MAX_CONCURRENT_SESSIONS = 200;
|
|
1355
|
+
app.post('/v1/sessions/batch', async (req, reply) => {
|
|
1356
|
+
const parsed = batchSessionSchema.safeParse(req.body);
|
|
1357
|
+
if (!parsed.success)
|
|
1358
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
1359
|
+
const specs = parsed.data.sessions;
|
|
1360
|
+
// #583: Per-key batch rate limit (max 1 batch per 5 seconds)
|
|
1361
|
+
const keyId = requestKeyMap.get(req.id) ?? 'anonymous';
|
|
1362
|
+
if (auth.checkBatchRateLimit(keyId)) {
|
|
1363
|
+
return reply.status(429).send({ error: 'Batch rate limit exceeded — 1 batch per 5 seconds per key' });
|
|
1364
|
+
}
|
|
1365
|
+
// #583: Global concurrent session cap
|
|
1366
|
+
const currentCount = sessions.listSessions().length;
|
|
1367
|
+
if (currentCount + specs.length > MAX_CONCURRENT_SESSIONS) {
|
|
1368
|
+
return reply.status(429).send({ error: `Session cap exceeded — ${currentCount} active, max ${MAX_CONCURRENT_SESSIONS}` });
|
|
1369
|
+
}
|
|
1370
|
+
for (const spec of specs) {
|
|
1371
|
+
const safeWorkDir = await validateWorkDirWithConfig(spec.workDir);
|
|
1372
|
+
if (typeof safeWorkDir === 'object') {
|
|
1373
|
+
return reply.status(400).send({ error: `Invalid workDir "${spec.workDir}": ${safeWorkDir.error}`, code: safeWorkDir.code });
|
|
1374
|
+
}
|
|
1375
|
+
spec.workDir = safeWorkDir;
|
|
1376
|
+
}
|
|
1377
|
+
const result = await pipelines.batchCreate(specs);
|
|
1378
|
+
return reply.status(201).send(result);
|
|
1379
|
+
});
|
|
1380
|
+
// Issue #740: Verification Protocol — run quality gate (tsc + build + test) on a session's workDir
|
|
1381
|
+
app.post('/v1/sessions/:id/verify', async (req, reply) => {
|
|
1382
|
+
const sessionId = req.params.id;
|
|
1383
|
+
const session = sessions.getSession(sessionId);
|
|
1384
|
+
if (!session)
|
|
1385
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
1386
|
+
const { workDir } = session;
|
|
1387
|
+
if (!workDir)
|
|
1388
|
+
return reply.status(400).send({ error: 'Session has no workDir' });
|
|
1389
|
+
const criticalOnly = config.verificationProtocol?.criticalOnly ?? false;
|
|
1390
|
+
eventBus.emitStatus(sessionId, 'working', `Running verification (criticalOnly=${criticalOnly})…`);
|
|
1391
|
+
try {
|
|
1392
|
+
const result = await runVerification(workDir, criticalOnly);
|
|
1393
|
+
eventBus.emitVerification(sessionId, result);
|
|
1394
|
+
const httpStatus = result.ok ? 200 : 422;
|
|
1395
|
+
return reply.status(httpStatus).send(result);
|
|
1396
|
+
}
|
|
1397
|
+
catch (e) {
|
|
1398
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1399
|
+
return reply.status(500).send({ ok: false, summary: `Verification error: ${msg}` });
|
|
1400
|
+
}
|
|
1401
|
+
});
|
|
1402
|
+
// Pipeline create (Issue #36)
|
|
1403
|
+
app.post('/v1/pipelines', async (req, reply) => {
|
|
1404
|
+
const parsed = pipelineSchema.safeParse(req.body);
|
|
1405
|
+
if (!parsed.success)
|
|
1406
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
1407
|
+
const pipeConfig = parsed.data;
|
|
1408
|
+
const safeWorkDir = await validateWorkDirWithConfig(pipeConfig.workDir);
|
|
1409
|
+
if (typeof safeWorkDir === 'object') {
|
|
1410
|
+
return reply.status(400).send({ error: `Invalid workDir: ${safeWorkDir.error}`, code: safeWorkDir.code });
|
|
1411
|
+
}
|
|
1412
|
+
pipeConfig.workDir = safeWorkDir;
|
|
1413
|
+
// Validate per-stage workDir overrides for path traversal (#631)
|
|
1414
|
+
for (const stage of pipeConfig.stages) {
|
|
1415
|
+
if (stage.workDir) {
|
|
1416
|
+
const safeStageWorkDir = await validateWorkDirWithConfig(stage.workDir);
|
|
1417
|
+
if (typeof safeStageWorkDir === 'object') {
|
|
1418
|
+
return reply.status(400).send({ error: `Invalid workDir for stage "${stage.name}": ${safeStageWorkDir.error}`, code: safeStageWorkDir.code });
|
|
1419
|
+
}
|
|
1420
|
+
stage.workDir = safeStageWorkDir;
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
try {
|
|
1424
|
+
const pipeline = await pipelines.createPipeline(pipeConfig);
|
|
1425
|
+
return reply.status(201).send(pipeline);
|
|
1426
|
+
}
|
|
1427
|
+
catch (e) {
|
|
1428
|
+
return reply.status(400).send({ error: e instanceof Error ? e.message : String(e) });
|
|
1429
|
+
}
|
|
1430
|
+
});
|
|
1431
|
+
// Pipeline status
|
|
1432
|
+
app.get('/v1/pipelines/:id', async (req, reply) => {
|
|
1433
|
+
const pipeline = pipelines.getPipeline(req.params.id);
|
|
1434
|
+
if (!pipeline)
|
|
1435
|
+
return reply.status(404).send({ error: 'Pipeline not found' });
|
|
1436
|
+
return pipeline;
|
|
1437
|
+
});
|
|
1438
|
+
// List pipelines
|
|
1439
|
+
app.get('/v1/pipelines', async () => pipelines.listPipelines());
|
|
1440
|
+
const createTemplateSchema = z.object({
|
|
1441
|
+
name: z.string().max(100),
|
|
1442
|
+
description: z.string().max(500).optional(),
|
|
1443
|
+
sessionId: z.string().uuid().optional(),
|
|
1444
|
+
workDir: z.string().min(1).optional(),
|
|
1445
|
+
prompt: z.string().max(100_000).optional(),
|
|
1446
|
+
claudeCommand: z.string().max(10_000).optional(),
|
|
1447
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
1448
|
+
stallThresholdMs: z.number().int().positive().max(3_600_000).optional(),
|
|
1449
|
+
permissionMode: z.enum(['default', 'bypassPermissions', 'plan', 'acceptEdits', 'dontAsk', 'auto']).optional(),
|
|
1450
|
+
autoApprove: z.boolean().optional(),
|
|
1451
|
+
memoryKeys: z.array(z.string()).max(50).optional(),
|
|
1452
|
+
}).strict();
|
|
1453
|
+
// POST /v1/templates — Create a new template
|
|
1454
|
+
app.post('/v1/templates', async (req, reply) => {
|
|
1455
|
+
const parsed = createTemplateSchema.safeParse(req.body);
|
|
1456
|
+
if (!parsed.success) {
|
|
1457
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
1458
|
+
}
|
|
1459
|
+
const { name, description, sessionId, ...templateData } = parsed.data;
|
|
1460
|
+
// If sessionId is provided, fill in missing fields from the session
|
|
1461
|
+
let finalData = { ...templateData };
|
|
1462
|
+
if (sessionId) {
|
|
1463
|
+
const session = sessions.getSession(sessionId);
|
|
1464
|
+
if (!session) {
|
|
1465
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
1466
|
+
}
|
|
1467
|
+
// Use session's workDir if not explicitly provided
|
|
1468
|
+
if (!finalData.workDir) {
|
|
1469
|
+
finalData.workDir = session.workDir;
|
|
1470
|
+
}
|
|
1471
|
+
if (!finalData.stallThresholdMs && session.stallThresholdMs) {
|
|
1472
|
+
finalData.stallThresholdMs = session.stallThresholdMs;
|
|
1473
|
+
}
|
|
1474
|
+
if (!finalData.permissionMode && session.permissionMode !== 'default') {
|
|
1475
|
+
finalData.permissionMode = session.permissionMode;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
if (!finalData.workDir) {
|
|
1479
|
+
return reply.status(400).send({ error: 'workDir is required (provide sessionId or explicit workDir)' });
|
|
1480
|
+
}
|
|
1481
|
+
// Issue #1125: Validate workDir for path traversal at template creation time
|
|
1482
|
+
const safeWorkDir = await validateWorkDirWithConfig(finalData.workDir);
|
|
1483
|
+
if (typeof safeWorkDir === 'object') {
|
|
1484
|
+
return reply.status(400).send({ error: `Invalid workDir: ${safeWorkDir.error}`, code: safeWorkDir.code });
|
|
1485
|
+
}
|
|
1486
|
+
try {
|
|
1487
|
+
const template = await templateStore.createTemplate({
|
|
1488
|
+
name,
|
|
1489
|
+
description,
|
|
1490
|
+
workDir: safeWorkDir,
|
|
1491
|
+
prompt: finalData.prompt,
|
|
1492
|
+
claudeCommand: finalData.claudeCommand,
|
|
1493
|
+
env: finalData.env,
|
|
1494
|
+
stallThresholdMs: finalData.stallThresholdMs,
|
|
1495
|
+
permissionMode: finalData.permissionMode,
|
|
1496
|
+
autoApprove: finalData.autoApprove,
|
|
1497
|
+
memoryKeys: finalData.memoryKeys,
|
|
1498
|
+
});
|
|
1499
|
+
return reply.status(201).send(template);
|
|
1500
|
+
}
|
|
1501
|
+
catch (e) {
|
|
1502
|
+
return reply.status(500).send({ error: e instanceof Error ? e.message : 'Failed to create template' });
|
|
1503
|
+
}
|
|
1504
|
+
});
|
|
1505
|
+
// GET /v1/templates — List all templates
|
|
1506
|
+
app.get('/v1/templates', async () => {
|
|
1507
|
+
try {
|
|
1508
|
+
return await templateStore.listTemplates();
|
|
1509
|
+
}
|
|
1510
|
+
catch (e) {
|
|
1511
|
+
return [];
|
|
1512
|
+
}
|
|
1513
|
+
});
|
|
1514
|
+
// GET /v1/templates/:id — Get a specific template
|
|
1515
|
+
app.get('/v1/templates/:id', async (req, reply) => {
|
|
1516
|
+
try {
|
|
1517
|
+
const template = await templateStore.getTemplate(req.params.id);
|
|
1518
|
+
if (!template) {
|
|
1519
|
+
return reply.status(404).send({ error: 'Template not found' });
|
|
1520
|
+
}
|
|
1521
|
+
return template;
|
|
1522
|
+
}
|
|
1523
|
+
catch (e) {
|
|
1524
|
+
return reply.status(500).send({ error: e instanceof Error ? e.message : 'Failed to get template' });
|
|
1525
|
+
}
|
|
1526
|
+
});
|
|
1527
|
+
// PUT /v1/templates/:id — Update a template
|
|
1528
|
+
app.put('/v1/templates/:id', async (req, reply) => {
|
|
1529
|
+
try {
|
|
1530
|
+
const updates = createTemplateSchema.partial().safeParse(req.body);
|
|
1531
|
+
if (!updates.success) {
|
|
1532
|
+
return reply.status(400).send({ error: 'Invalid request body', details: updates.error.issues });
|
|
1533
|
+
}
|
|
1534
|
+
const template = await templateStore.updateTemplate(req.params.id, updates.data);
|
|
1535
|
+
if (!template) {
|
|
1536
|
+
return reply.status(404).send({ error: 'Template not found' });
|
|
1537
|
+
}
|
|
1538
|
+
return template;
|
|
1539
|
+
}
|
|
1540
|
+
catch (e) {
|
|
1541
|
+
return reply.status(500).send({ error: e instanceof Error ? e.message : 'Failed to update template' });
|
|
1542
|
+
}
|
|
1543
|
+
});
|
|
1544
|
+
// DELETE /v1/templates/:id — Delete a template
|
|
1545
|
+
app.delete('/v1/templates/:id', async (req, reply) => {
|
|
1546
|
+
try {
|
|
1547
|
+
const deleted = await templateStore.deleteTemplate(req.params.id);
|
|
1548
|
+
if (!deleted) {
|
|
1549
|
+
return reply.status(404).send({ error: 'Template not found' });
|
|
1550
|
+
}
|
|
1551
|
+
return { ok: true };
|
|
1552
|
+
}
|
|
1553
|
+
catch (e) {
|
|
1554
|
+
return reply.status(500).send({ error: e instanceof Error ? e.message : 'Failed to delete template' });
|
|
1555
|
+
}
|
|
1556
|
+
});
|
|
1557
|
+
// ── Session Reaper ──────────────────────────────────────────────────
|
|
1558
|
+
async function reapStaleSessions(maxAgeMs) {
|
|
1559
|
+
const now = Date.now();
|
|
1560
|
+
// Snapshot list before iterating — killSession() modifies the sessions map
|
|
1561
|
+
const snapshot = [...sessions.listSessions()];
|
|
1562
|
+
for (const session of snapshot) {
|
|
1563
|
+
// Guard: session may have been deleted by DELETE handler between snapshot and here
|
|
1564
|
+
if (!sessions.getSession(session.id))
|
|
1565
|
+
continue;
|
|
1566
|
+
const age = now - session.createdAt;
|
|
1567
|
+
if (age > maxAgeMs) {
|
|
1568
|
+
const ageMin = Math.round(age / 60000);
|
|
1569
|
+
console.log(`Reaper: killing session ${session.windowName} (${session.id.slice(0, 8)}) — age ${ageMin}min`);
|
|
1570
|
+
try {
|
|
1571
|
+
// #842: killSession first, then notify — avoids race where channels
|
|
1572
|
+
// reference a session that is still being destroyed.
|
|
1573
|
+
await sessions.killSession(session.id);
|
|
1574
|
+
eventBus.cleanupSession(session.id);
|
|
1575
|
+
await channels.sessionEnded({
|
|
1576
|
+
event: 'session.ended',
|
|
1577
|
+
timestamp: new Date().toISOString(),
|
|
1578
|
+
session: { id: session.id, name: session.windowName, workDir: session.workDir },
|
|
1579
|
+
detail: `Auto-killed: exceeded ${maxAgeMs / 3600000}h time limit`,
|
|
1580
|
+
});
|
|
1581
|
+
cleanupTerminatedSessionState(session.id, { monitor, metrics, toolRegistry });
|
|
1582
|
+
}
|
|
1583
|
+
catch (e) {
|
|
1584
|
+
console.error(`Reaper: failed to kill session ${session.id}:`, e);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
// ── Zombie Reaper (Issue #283) ──────────────────────────────────────
|
|
1590
|
+
const ZOMBIE_REAP_DELAY_MS = parseIntSafe(process.env.ZOMBIE_REAP_DELAY_MS, 60000);
|
|
1591
|
+
const ZOMBIE_REAP_INTERVAL_MS = parseIntSafe(process.env.ZOMBIE_REAP_INTERVAL_MS, 60000);
|
|
1592
|
+
async function reapZombieSessions() {
|
|
1593
|
+
const now = Date.now();
|
|
1594
|
+
// Snapshot list before iterating — killSession() modifies the sessions map
|
|
1595
|
+
const snapshot = [...sessions.listSessions()];
|
|
1596
|
+
for (const session of snapshot) {
|
|
1597
|
+
// Guard: session may have been deleted between snapshot and here
|
|
1598
|
+
if (!sessions.getSession(session.id))
|
|
1599
|
+
continue;
|
|
1600
|
+
if (!session.lastDeadAt)
|
|
1601
|
+
continue;
|
|
1602
|
+
const deadDuration = now - session.lastDeadAt;
|
|
1603
|
+
if (deadDuration < ZOMBIE_REAP_DELAY_MS)
|
|
1604
|
+
continue;
|
|
1605
|
+
console.log(`Reaper: removing zombie session ${session.windowName} (${session.id.slice(0, 8)})`);
|
|
1606
|
+
try {
|
|
1607
|
+
eventBus.cleanupSession(session.id);
|
|
1608
|
+
await sessions.killSession(session.id);
|
|
1609
|
+
cleanupTerminatedSessionState(session.id, { monitor, metrics, toolRegistry });
|
|
1610
|
+
await channels.sessionEnded({
|
|
1611
|
+
event: 'session.ended',
|
|
1612
|
+
timestamp: new Date().toISOString(),
|
|
1613
|
+
session: { id: session.id, name: session.windowName, workDir: session.workDir },
|
|
1614
|
+
detail: `Zombie reaped: dead for ${Math.round(deadDuration / 1000)}s`,
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
catch (e) {
|
|
1618
|
+
console.error(`Reaper: failed to reap zombie session ${session.id}:`, e);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
1623
|
+
/** Issue #20: Add actionHints to session response for interactive states. */
|
|
1624
|
+
function addActionHints(session, sessions) {
|
|
1625
|
+
// #357: Convert Set to array for JSON serialization
|
|
1626
|
+
const result = {
|
|
1627
|
+
...session,
|
|
1628
|
+
activeSubagents: session.activeSubagents ? [...session.activeSubagents] : undefined,
|
|
1629
|
+
};
|
|
1630
|
+
if (session.status === 'permission_prompt' || session.status === 'bash_approval') {
|
|
1631
|
+
result.actionHints = {
|
|
1632
|
+
approve: { method: 'POST', url: `/v1/sessions/${session.id}/approve`, description: 'Approve the pending permission' },
|
|
1633
|
+
reject: { method: 'POST', url: `/v1/sessions/${session.id}/reject`, description: 'Reject the pending permission' },
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
// #599: Expose pending question data for MCP/REST callers
|
|
1637
|
+
if (session.status === 'ask_question' && sessions) {
|
|
1638
|
+
const info = sessions.getPendingQuestionInfo(session.id);
|
|
1639
|
+
if (info) {
|
|
1640
|
+
result.pendingQuestion = {
|
|
1641
|
+
toolUseId: info.toolUseId,
|
|
1642
|
+
content: info.question,
|
|
1643
|
+
options: extractQuestionOptions(info.question),
|
|
1644
|
+
since: info.timestamp,
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
return result;
|
|
1649
|
+
}
|
|
1650
|
+
/** #599: Extract selectable options from AskUserQuestion text. */
|
|
1651
|
+
function extractQuestionOptions(text) {
|
|
1652
|
+
// Numbered options: "1. Foo\n2. Bar"
|
|
1653
|
+
const numberedRegex = /^\s*(\d+)\.\s+(.+)$/gm;
|
|
1654
|
+
const options = [];
|
|
1655
|
+
let m;
|
|
1656
|
+
while ((m = numberedRegex.exec(text)) !== null) {
|
|
1657
|
+
options.push(m[2].trim());
|
|
1658
|
+
}
|
|
1659
|
+
if (options.length >= 2)
|
|
1660
|
+
return options.slice(0, 4);
|
|
1661
|
+
return null;
|
|
1662
|
+
}
|
|
1663
|
+
function makePayload(event, sessionId, detail, meta) {
|
|
1664
|
+
const session = sessions.getSession(sessionId);
|
|
1665
|
+
return {
|
|
1666
|
+
event,
|
|
1667
|
+
timestamp: new Date().toISOString(),
|
|
1668
|
+
session: {
|
|
1669
|
+
id: sessionId,
|
|
1670
|
+
name: session?.windowName || 'unknown',
|
|
1671
|
+
workDir: session?.workDir || '',
|
|
1672
|
+
},
|
|
1673
|
+
detail,
|
|
1674
|
+
...(meta && { meta }),
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
// ── Start ────────────────────────────────────────────────────────────
|
|
1678
|
+
/** Register notification channels from config */
|
|
1679
|
+
function registerChannels(cfg) {
|
|
1680
|
+
// Telegram (optional)
|
|
1681
|
+
if (cfg.tgBotToken && cfg.tgGroupId) {
|
|
1682
|
+
channels.register(new TelegramChannel({
|
|
1683
|
+
botToken: cfg.tgBotToken,
|
|
1684
|
+
groupChatId: cfg.tgGroupId,
|
|
1685
|
+
allowedUserIds: cfg.tgAllowedUsers,
|
|
1686
|
+
topicTtlMs: cfg.tgTopicTtlMs,
|
|
1687
|
+
}));
|
|
1688
|
+
}
|
|
1689
|
+
// Webhooks (optional)
|
|
1690
|
+
if (cfg.webhooks.length > 0) {
|
|
1691
|
+
const webhookChannel = new WebhookChannel({
|
|
1692
|
+
endpoints: cfg.webhooks.map(url => ({ url })),
|
|
1693
|
+
});
|
|
1694
|
+
channels.register(webhookChannel);
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
// Preserve public export used by tests and external imports.
|
|
1698
|
+
export { readParentPid as readPpid } from './process-utils.js';
|
|
1699
|
+
async function main() {
|
|
1700
|
+
// Load configuration
|
|
1701
|
+
config = await loadConfig();
|
|
1702
|
+
// Initialize core components with config
|
|
1703
|
+
tmux = new TmuxManager(config.tmuxSession);
|
|
1704
|
+
sessions = new SessionManager(tmux, config);
|
|
1705
|
+
// Memory bridge (Issue #783)
|
|
1706
|
+
if (config.memoryBridge?.enabled) {
|
|
1707
|
+
const persistPath = config.memoryBridge.persistPath ?? path.join(config.stateDir, 'memory.json');
|
|
1708
|
+
memoryBridge = new MemoryBridge(persistPath, config.memoryBridge.reaperIntervalMs);
|
|
1709
|
+
await memoryBridge.load();
|
|
1710
|
+
memoryBridge.startReaper();
|
|
1711
|
+
registerMemoryRoutes(app, memoryBridge);
|
|
1712
|
+
console.error(`Memory bridge enabled, persisted at: ${persistPath}`);
|
|
1713
|
+
}
|
|
1714
|
+
sseLimiter = new SSEConnectionLimiter({ maxConnections: config.sseMaxConnections, maxPerIp: config.sseMaxPerIp });
|
|
1715
|
+
monitor = new SessionMonitor(sessions, channels, { ...DEFAULT_MONITOR_CONFIG, pollIntervalMs: 5000 });
|
|
1716
|
+
// Register channels
|
|
1717
|
+
registerChannels(config);
|
|
1718
|
+
// Setup auth (Issue #39: multi-key + backward compat)
|
|
1719
|
+
const { join } = await import('node:path');
|
|
1720
|
+
auth = new AuthManager(path.join(config.stateDir, 'keys.json'), config.authToken);
|
|
1721
|
+
await auth.load();
|
|
1722
|
+
setupAuth(auth);
|
|
1723
|
+
// Register WebSocket plugin for live terminal streaming (Issue #108)
|
|
1724
|
+
await app.register(fastifyWebsocket);
|
|
1725
|
+
registerWsTerminalRoute(app, sessions, tmux, auth);
|
|
1726
|
+
// #217: CORS configuration — restrictive by default
|
|
1727
|
+
// #413: Reject wildcard CORS_ORIGIN — * is insecure and allows any origin
|
|
1728
|
+
const corsOrigin = process.env.CORS_ORIGIN;
|
|
1729
|
+
if (corsOrigin === '*') {
|
|
1730
|
+
throw new Error('CORS_ORIGIN=* wildcard is not allowed. Specify explicit origins (comma-separated) or leave unset to disable CORS.');
|
|
1731
|
+
}
|
|
1732
|
+
await app.register(fastifyCors, {
|
|
1733
|
+
origin: corsOrigin ? corsOrigin.split(',').map(s => s.trim()) : false,
|
|
1734
|
+
});
|
|
1735
|
+
// Load persisted sessions
|
|
1736
|
+
await sessions.load();
|
|
1737
|
+
await tmux.ensureSession();
|
|
1738
|
+
// Initialize channels
|
|
1739
|
+
await channels.init(handleInbound);
|
|
1740
|
+
// Wire SSE event bus (Issue #32)
|
|
1741
|
+
monitor.setEventBus(eventBus);
|
|
1742
|
+
// Issue #397: Wire TmuxManager for tmux health monitoring
|
|
1743
|
+
monitor.setTmuxManager(tmux);
|
|
1744
|
+
// Issue #84: Wire JSONL watcher for fs.watch-based message detection
|
|
1745
|
+
jsonlWatcher = new JsonlWatcher();
|
|
1746
|
+
monitor.setJsonlWatcher(jsonlWatcher);
|
|
1747
|
+
// Issue #488: Accumulate token usage from JSONL events into per-session metrics.
|
|
1748
|
+
jsonlWatcher.onEntries((event) => {
|
|
1749
|
+
const { tokenUsageDelta } = event;
|
|
1750
|
+
if (tokenUsageDelta.inputTokens > 0 || tokenUsageDelta.outputTokens > 0) {
|
|
1751
|
+
if (metrics) {
|
|
1752
|
+
const model = sessions.getSession(event.sessionId)?.model;
|
|
1753
|
+
metrics.recordTokenUsage(event.sessionId, tokenUsageDelta, model);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
});
|
|
1757
|
+
// Start watching JSONL files for already-discovered sessions
|
|
1758
|
+
for (const session of sessions.listSessions()) {
|
|
1759
|
+
if (session.jsonlPath) {
|
|
1760
|
+
jsonlWatcher.watch(session.id, session.jsonlPath, session.monitorOffset);
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
// Register HTTP hook receiver (Issue #169, Issue #87: pass metrics for latency tracking)
|
|
1764
|
+
registerHookRoutes(app, { sessions, eventBus, metrics });
|
|
1765
|
+
// Issue #743: Register model-routing endpoints
|
|
1766
|
+
registerModelRouterRoutes(app);
|
|
1767
|
+
// Initialize pipeline manager (Issue #36)
|
|
1768
|
+
pipelines = new PipelineManager(sessions, eventBus);
|
|
1769
|
+
// Initialize batch rate limiter (Issue #583)
|
|
1770
|
+
// Initialize metrics (Issue #40)
|
|
1771
|
+
metrics = new MetricsCollector(path.join(config.stateDir, 'metrics.json'));
|
|
1772
|
+
await metrics.load();
|
|
1773
|
+
// Issue #361: Store interval refs so graceful shutdown can clear them
|
|
1774
|
+
const reaperInterval = setInterval(() => reapStaleSessions(config.maxSessionAgeMs), config.reaperIntervalMs);
|
|
1775
|
+
const zombieReaperInterval = setInterval(() => reapZombieSessions(), ZOMBIE_REAP_INTERVAL_MS);
|
|
1776
|
+
const metricsSaveInterval = setInterval(() => { void metrics.save(); }, 5 * 60 * 1000);
|
|
1777
|
+
// #357: Prune stale IP rate-limit entries every minute
|
|
1778
|
+
const ipPruneInterval = setInterval(pruneIpRateLimits, 60_000);
|
|
1779
|
+
// #632: Prune stale auth failure rate-limit buckets every minute
|
|
1780
|
+
const authFailPruneInterval = setInterval(pruneAuthFailLimits, 60_000);
|
|
1781
|
+
// #398: Sweep stale API key rate limit buckets every 5 minutes
|
|
1782
|
+
const authSweepInterval = setInterval(() => auth.sweepStaleRateLimits(), 5 * 60_000);
|
|
1783
|
+
// #1091: Prune stale consensus requests every minute
|
|
1784
|
+
const consensusPruneInterval = setInterval(pruneConsensusRequests, 60_000);
|
|
1785
|
+
let pidFilePath = '';
|
|
1786
|
+
// Issue #361: Graceful shutdown handler
|
|
1787
|
+
// Issue #415: Reentrance guard at handler level prevents double execution on rapid SIGINT
|
|
1788
|
+
let shuttingDown = false;
|
|
1789
|
+
const shutdownTimeoutMs = parseShutdownTimeoutMs(process.env.AEGIS_SHUTDOWN_TIMEOUT_MS);
|
|
1790
|
+
async function gracefulShutdown(signal) {
|
|
1791
|
+
console.log(`${signal} received, shutting down gracefully...`);
|
|
1792
|
+
const forceExitTimer = setTimeout(() => {
|
|
1793
|
+
console.error(`Graceful shutdown timed out after ${shutdownTimeoutMs}ms — forcing process exit`);
|
|
1794
|
+
process.exit(1);
|
|
1795
|
+
}, shutdownTimeoutMs);
|
|
1796
|
+
forceExitTimer.unref?.();
|
|
1797
|
+
try {
|
|
1798
|
+
// 1. Stop accepting new requests
|
|
1799
|
+
try {
|
|
1800
|
+
await app.close();
|
|
1801
|
+
}
|
|
1802
|
+
catch (e) {
|
|
1803
|
+
console.error('Error closing server:', e);
|
|
1804
|
+
}
|
|
1805
|
+
// 2. Stop background monitors and intervals
|
|
1806
|
+
monitor.stop();
|
|
1807
|
+
swarmMonitor.stop();
|
|
1808
|
+
clearInterval(reaperInterval);
|
|
1809
|
+
clearInterval(zombieReaperInterval);
|
|
1810
|
+
clearInterval(metricsSaveInterval);
|
|
1811
|
+
clearInterval(ipPruneInterval);
|
|
1812
|
+
clearInterval(authFailPruneInterval);
|
|
1813
|
+
clearInterval(authSweepInterval);
|
|
1814
|
+
clearInterval(consensusPruneInterval);
|
|
1815
|
+
// Issue #569: Kill all CC sessions and tmux windows before exit
|
|
1816
|
+
try {
|
|
1817
|
+
await killAllSessions(sessions, tmux);
|
|
1818
|
+
}
|
|
1819
|
+
catch (e) {
|
|
1820
|
+
console.error('Error killing sessions:', e);
|
|
1821
|
+
}
|
|
1822
|
+
// 3. Destroy channels (awaits Telegram poll loop)
|
|
1823
|
+
try {
|
|
1824
|
+
await channels.destroy();
|
|
1825
|
+
}
|
|
1826
|
+
catch (e) {
|
|
1827
|
+
console.error('Error destroying channels:', e);
|
|
1828
|
+
}
|
|
1829
|
+
// 4. Save session state
|
|
1830
|
+
try {
|
|
1831
|
+
await sessions.save();
|
|
1832
|
+
}
|
|
1833
|
+
catch (e) {
|
|
1834
|
+
console.error('Error saving sessions:', e);
|
|
1835
|
+
}
|
|
1836
|
+
// 5. Save metrics
|
|
1837
|
+
try {
|
|
1838
|
+
await metrics.save();
|
|
1839
|
+
}
|
|
1840
|
+
catch (e) {
|
|
1841
|
+
console.error('Error saving metrics:', e);
|
|
1842
|
+
}
|
|
1843
|
+
// 6. Cleanup PID file
|
|
1844
|
+
removePidFile(pidFilePath);
|
|
1845
|
+
console.log('Graceful shutdown complete');
|
|
1846
|
+
process.exit(0);
|
|
1847
|
+
}
|
|
1848
|
+
finally {
|
|
1849
|
+
clearTimeout(forceExitTimer);
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
process.on('SIGTERM', () => { if (!shuttingDown) {
|
|
1853
|
+
shuttingDown = true;
|
|
1854
|
+
void gracefulShutdown('SIGTERM');
|
|
1855
|
+
} });
|
|
1856
|
+
process.on('SIGINT', () => { if (!shuttingDown) {
|
|
1857
|
+
shuttingDown = true;
|
|
1858
|
+
void gracefulShutdown('SIGINT');
|
|
1859
|
+
} });
|
|
1860
|
+
if (process.platform === 'win32') {
|
|
1861
|
+
process.on('message', (message) => {
|
|
1862
|
+
if (!shuttingDown && isWindowsShutdownMessage(message)) {
|
|
1863
|
+
shuttingDown = true;
|
|
1864
|
+
void gracefulShutdown('WINMSG');
|
|
1865
|
+
}
|
|
1866
|
+
});
|
|
1867
|
+
}
|
|
1868
|
+
process.on('unhandledRejection', (reason) => {
|
|
1869
|
+
console.error('unhandledRejection:', reason);
|
|
1870
|
+
});
|
|
1871
|
+
// Start monitor
|
|
1872
|
+
monitor.start();
|
|
1873
|
+
// Issue #81: Start swarm monitor for agent swarm awareness
|
|
1874
|
+
swarmMonitor = new SwarmMonitor(sessions);
|
|
1875
|
+
toolRegistry = new ToolRegistry();
|
|
1876
|
+
swarmMonitor.onEvent((event) => {
|
|
1877
|
+
if (!event.swarm.parentSession)
|
|
1878
|
+
return;
|
|
1879
|
+
const parentId = event.swarm.parentSession.id;
|
|
1880
|
+
const teammate = event.teammate;
|
|
1881
|
+
if (event.type === 'teammate_spawned') {
|
|
1882
|
+
const detail = `🔧 Teammate ${teammate.windowName} spawned`;
|
|
1883
|
+
eventBus.emit(parentId, {
|
|
1884
|
+
event: 'subagent_start',
|
|
1885
|
+
sessionId: parentId,
|
|
1886
|
+
timestamp: new Date().toISOString(),
|
|
1887
|
+
data: { teammate: teammate.windowName, windowId: teammate.windowId },
|
|
1888
|
+
});
|
|
1889
|
+
channels.swarmEvent(makePayload('swarm.teammate_spawned', parentId, detail, {
|
|
1890
|
+
teammateName: teammate.windowName,
|
|
1891
|
+
teammateWindowId: teammate.windowId,
|
|
1892
|
+
teammateCwd: teammate.cwd,
|
|
1893
|
+
}));
|
|
1894
|
+
}
|
|
1895
|
+
else if (event.type === 'teammate_finished') {
|
|
1896
|
+
const detail = `✅ Teammate ${teammate.windowName} finished`;
|
|
1897
|
+
eventBus.emit(parentId, {
|
|
1898
|
+
event: 'subagent_stop',
|
|
1899
|
+
sessionId: parentId,
|
|
1900
|
+
timestamp: new Date().toISOString(),
|
|
1901
|
+
data: { teammate: teammate.windowName },
|
|
1902
|
+
});
|
|
1903
|
+
channels.swarmEvent(makePayload('swarm.teammate_finished', parentId, detail, {
|
|
1904
|
+
teammateName: teammate.windowName,
|
|
1905
|
+
}));
|
|
1906
|
+
}
|
|
1907
|
+
});
|
|
1908
|
+
swarmMonitor.start();
|
|
1909
|
+
// Issue #71: Wire swarm monitor into Telegram channel for /swarm command
|
|
1910
|
+
for (const ch of channels.getChannels()) {
|
|
1911
|
+
if ('setSwarmMonitor' in ch && typeof ch.setSwarmMonitor === 'function') {
|
|
1912
|
+
ch.setSwarmMonitor(swarmMonitor);
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
// Start reaper (intervals already created above with stored refs for graceful shutdown)
|
|
1916
|
+
console.log(`Session reaper active: max age ${config.maxSessionAgeMs / 3600000}h, check every ${config.reaperIntervalMs / 60000}min`);
|
|
1917
|
+
// Start zombie reaper (Issue #283)
|
|
1918
|
+
console.log(`Zombie reaper active: grace period ${ZOMBIE_REAP_DELAY_MS / 1000}s, check every ${ZOMBIE_REAP_INTERVAL_MS / 1000}s`);
|
|
1919
|
+
// #127: Serve dashboard static files (Issue #105) — graceful if missing
|
|
1920
|
+
// Issue #539: Dashboard is copied into dist/dashboard/ during build
|
|
1921
|
+
const dashboardRoot = path.join(__dirname, "dashboard");
|
|
1922
|
+
let dashboardAvailable = false;
|
|
1923
|
+
try {
|
|
1924
|
+
await fs.access(dashboardRoot);
|
|
1925
|
+
dashboardAvailable = true;
|
|
1926
|
+
}
|
|
1927
|
+
catch {
|
|
1928
|
+
console.warn("Dashboard directory not found — skipping dashboard serving. Run 'npm run build:dashboard' to enable.");
|
|
1929
|
+
}
|
|
1930
|
+
if (dashboardAvailable) {
|
|
1931
|
+
await app.register(fastifyStatic, {
|
|
1932
|
+
root: dashboardRoot,
|
|
1933
|
+
prefix: "/dashboard/",
|
|
1934
|
+
// #146: Cache hashed assets aggressively, no-cache for index.html
|
|
1935
|
+
setHeaders: (reply, pathname) => {
|
|
1936
|
+
// Security headers (#145)
|
|
1937
|
+
reply.setHeader('X-Frame-Options', 'DENY');
|
|
1938
|
+
reply.setHeader('X-Content-Type-Options', 'nosniff');
|
|
1939
|
+
reply.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
1940
|
+
// Issue #349: Content-Security-Policy for dashboard
|
|
1941
|
+
reply.setHeader('Content-Security-Policy', DASHBOARD_CSP);
|
|
1942
|
+
// Cache control (#146)
|
|
1943
|
+
if (pathname === '/index.html' || pathname === '/') {
|
|
1944
|
+
reply.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
1945
|
+
}
|
|
1946
|
+
else {
|
|
1947
|
+
reply.setHeader('Cache-Control', 'public, max-age=604800, immutable');
|
|
1948
|
+
}
|
|
1949
|
+
},
|
|
1950
|
+
});
|
|
1951
|
+
}
|
|
1952
|
+
// SPA fallback for dashboard routes (Issue #105)
|
|
1953
|
+
app.setNotFoundHandler(async (req, reply) => {
|
|
1954
|
+
if (dashboardAvailable && (req.url === "/dashboard" || req.url?.startsWith("/dashboard/") || req.url?.startsWith("/dashboard?"))) {
|
|
1955
|
+
// Issue #349: CSP header for SPA dashboard responses
|
|
1956
|
+
reply.header('Content-Security-Policy', DASHBOARD_CSP);
|
|
1957
|
+
return reply.sendFile("index.html", dashboardRoot);
|
|
1958
|
+
}
|
|
1959
|
+
return reply.status(404).send({ error: "Not found" });
|
|
1960
|
+
});
|
|
1961
|
+
await listenWithRetry(app, config.port, config.host, config.stateDir);
|
|
1962
|
+
pidFilePath = writePidFile(config.stateDir);
|
|
1963
|
+
console.log(`Aegis running on http://${config.host}:${config.port}`);
|
|
1964
|
+
console.log(`Channels: ${channels.count} registered`);
|
|
1965
|
+
console.log(`State dir: ${config.stateDir}`);
|
|
1966
|
+
console.log(`Claude projects dir: ${config.claudeProjectsDir}`);
|
|
1967
|
+
if (config.authToken)
|
|
1968
|
+
console.log('Auth: Bearer token required');
|
|
1969
|
+
}
|
|
1970
|
+
main().catch(err => {
|
|
1971
|
+
console.error('Failed to start Aegis:', err);
|
|
1972
|
+
process.exit(1);
|
|
1973
|
+
});
|