aegis-bridge 2.6.0 → 2.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dashboard/dist/assets/index-9Hkkvm_I.css +32 -0
  2. package/dashboard/dist/assets/index-Bfabq3q-.js +262 -0
  3. package/dashboard/dist/index.html +2 -2
  4. package/dist/config.d.ts +2 -0
  5. package/dist/config.js +3 -0
  6. package/dist/continuation-pointer.d.ts +11 -0
  7. package/dist/continuation-pointer.js +64 -0
  8. package/dist/dashboard/assets/index-9Hkkvm_I.css +32 -0
  9. package/dist/dashboard/assets/index-Bfabq3q-.js +262 -0
  10. package/dist/dashboard/index.html +2 -2
  11. package/dist/diagnostics.d.ts +27 -0
  12. package/dist/diagnostics.js +95 -0
  13. package/dist/events.d.ts +14 -0
  14. package/dist/events.js +43 -14
  15. package/dist/fault-injection.d.ts +29 -0
  16. package/dist/fault-injection.js +115 -0
  17. package/dist/handshake.d.ts +21 -1
  18. package/dist/handshake.js +37 -3
  19. package/dist/hook-settings.d.ts +3 -2
  20. package/dist/hook-settings.js +6 -4
  21. package/dist/hook.js +10 -1
  22. package/dist/hooks.js +5 -0
  23. package/dist/logger.d.ts +35 -0
  24. package/dist/logger.js +65 -0
  25. package/dist/monitor.js +72 -11
  26. package/dist/permission-routes.d.ts +7 -0
  27. package/dist/permission-routes.js +28 -0
  28. package/dist/server.js +119 -44
  29. package/dist/session.d.ts +1 -0
  30. package/dist/session.js +41 -23
  31. package/dist/tmux.js +2 -2
  32. package/dist/utils/circular-buffer.d.ts +11 -0
  33. package/dist/utils/circular-buffer.js +37 -0
  34. package/dist/validation.d.ts +34 -0
  35. package/dist/validation.js +45 -3
  36. package/package.json +3 -2
  37. package/dashboard/dist/assets/index-B7DYf7vF.css +0 -32
  38. package/dashboard/dist/assets/index-DIyuyrlO.js +0 -262
  39. package/dist/dashboard/assets/index-B7DYf7vF.css +0 -32
  40. package/dist/dashboard/assets/index-DIyuyrlO.js +0 -262
package/dist/logger.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * logger.ts - structured logger that also emits sanitized diagnostics events.
3
+ */
4
+ import { diagnosticsBus, sanitizeDiagnosticsAttributes, } from './diagnostics.js';
5
+ const defaultSink = {
6
+ info: (record) => console.log(JSON.stringify(record)),
7
+ warn: (record) => console.warn(JSON.stringify(record)),
8
+ error: (record) => console.error(JSON.stringify(record)),
9
+ };
10
+ let sink = defaultSink;
11
+ export function setStructuredLogSink(nextSink) {
12
+ sink = {
13
+ info: nextSink.info ?? defaultSink.info,
14
+ warn: nextSink.warn ?? defaultSink.warn,
15
+ error: nextSink.error ?? defaultSink.error,
16
+ };
17
+ }
18
+ export class StructuredLogger {
19
+ bus;
20
+ constructor(bus = diagnosticsBus) {
21
+ this.bus = bus;
22
+ }
23
+ info(ctx) {
24
+ this.log('info', ctx);
25
+ }
26
+ warn(ctx) {
27
+ this.log('warn', ctx);
28
+ }
29
+ error(ctx) {
30
+ this.log('error', ctx);
31
+ }
32
+ log(level, ctx) {
33
+ const timestamp = new Date().toISOString();
34
+ const attributes = sanitizeDiagnosticsAttributes(ctx.attributes);
35
+ const record = {
36
+ timestamp,
37
+ level,
38
+ component: ctx.component,
39
+ operation: ctx.operation,
40
+ sessionId: ctx.sessionId,
41
+ errorCode: ctx.errorCode,
42
+ attributes,
43
+ };
44
+ if (level === 'error') {
45
+ sink.error?.(record);
46
+ }
47
+ else if (level === 'warn') {
48
+ sink.warn?.(record);
49
+ }
50
+ else {
51
+ sink.info?.(record);
52
+ }
53
+ this.bus.emit({
54
+ event: `${ctx.component}.${ctx.operation}`,
55
+ level,
56
+ component: ctx.component,
57
+ operation: ctx.operation,
58
+ sessionId: ctx.sessionId,
59
+ errorCode: ctx.errorCode,
60
+ timestamp,
61
+ attributes,
62
+ });
63
+ }
64
+ }
65
+ export const logger = new StructuredLogger();
package/dist/monitor.js CHANGED
@@ -12,6 +12,8 @@ import { join } from 'node:path';
12
12
  import { homedir } from 'node:os';
13
13
  import { stopSignalsSchema } from './validation.js';
14
14
  import { suppressedCatch } from './suppress.js';
15
+ import { logger } from './logger.js';
16
+ import { maybeInjectFault } from './fault-injection.js';
15
17
  /** Issue #89 L4: Debounce interval for status change broadcasts (ms). */
16
18
  const STATUS_CHANGE_DEBOUNCE_MS = 500;
17
19
  export const DEFAULT_MONITOR_CONFIG = {
@@ -93,7 +95,12 @@ export class SessionMonitor {
93
95
  await this.poll();
94
96
  }
95
97
  catch (e) {
96
- console.error('Monitor poll error:', e);
98
+ logger.error({
99
+ component: 'monitor',
100
+ operation: 'poll',
101
+ errorCode: 'MONITOR_POLL_ERROR',
102
+ attributes: { error: e instanceof Error ? e.message : String(e) },
103
+ });
97
104
  }
98
105
  // Issue #169 Phase 3: Adaptive polling — use fast interval if any session
99
106
  // hasn't received a hook recently (hooks may have stopped working).
@@ -227,7 +234,13 @@ export class SessionMonitor {
227
234
  if (!this.stallNotified.has(`${session.id}:stall:permission_timeout`)) {
228
235
  this.stallNotified.add(`${session.id}:stall:permission_timeout`);
229
236
  const minutes = Math.round(permDuration / 60000);
230
- console.warn(`Monitor: auto-rejecting permission for session ${session.windowName} after ${minutes}min`);
237
+ logger.warn({
238
+ component: 'monitor',
239
+ operation: 'permission_timeout_auto_reject',
240
+ sessionId: session.id,
241
+ errorCode: 'PERMISSION_TIMEOUT',
242
+ attributes: { windowName: session.windowName, timeoutMinutes: minutes },
243
+ });
231
244
  try {
232
245
  await this.sessions.reject(session.id);
233
246
  const detail = `Permission auto-rejected after ${minutes}min timeout (session ${session.windowName})`;
@@ -235,7 +248,13 @@ export class SessionMonitor {
235
248
  await this.channels.statusChange(this.makePayload('status.permission_timeout', session, detail));
236
249
  }
237
250
  catch (e) {
238
- console.error(`Monitor: auto-reject failed for session ${session.id}: ${e instanceof Error ? e.message : String(e)}`);
251
+ logger.error({
252
+ component: 'monitor',
253
+ operation: 'permission_timeout_auto_reject',
254
+ sessionId: session.id,
255
+ errorCode: 'AUTO_REJECT_FAILED',
256
+ attributes: { error: e instanceof Error ? e.message : String(e) },
257
+ });
239
258
  }
240
259
  }
241
260
  }
@@ -334,7 +353,11 @@ export class SessionMonitor {
334
353
  const raw = await readFile(signalFile, 'utf-8');
335
354
  const parsed = stopSignalsSchema.safeParse(JSON.parse(raw));
336
355
  if (!parsed.success) {
337
- console.warn('stop_signals.json failed validation in checkStopSignals');
356
+ logger.warn({
357
+ component: 'monitor',
358
+ operation: 'check_stop_signals',
359
+ errorCode: 'STOP_SIGNALS_INVALID',
360
+ });
338
361
  return;
339
362
  }
340
363
  const signals = parsed.data;
@@ -390,7 +413,13 @@ export class SessionMonitor {
390
413
  this.rateLimitedSessions.delete(event.sessionId);
391
414
  for (const msg of event.messages) {
392
415
  // Forward asynchronously (fire-and-forget) — catch to prevent unhandled rejection (#404)
393
- void this.forwardMessage(session, msg).catch(e => console.error(`Monitor: forwardMessage failed for ${session.id}:`, e));
416
+ void this.forwardMessage(session, msg).catch(e => logger.error({
417
+ component: 'monitor',
418
+ operation: 'forward_message',
419
+ sessionId: session.id,
420
+ errorCode: 'FORWARD_MESSAGE_FAILED',
421
+ attributes: { error: e instanceof Error ? e.message : String(e) },
422
+ }));
394
423
  }
395
424
  // Update last activity
396
425
  session.lastActivity = Date.now();
@@ -453,7 +482,13 @@ export class SessionMonitor {
453
482
  if (!this.lastStatus.has(session.id))
454
483
  return;
455
484
  void this.broadcastStatusChange(session, latestStatus, latestPrevStatus, latestResult)
456
- .catch(e => console.error(`Monitor: broadcastStatusChange failed for ${session.id}:`, e));
485
+ .catch(e => logger.error({
486
+ component: 'monitor',
487
+ operation: 'broadcast_status_change',
488
+ sessionId: session.id,
489
+ errorCode: 'BROADCAST_STATUS_CHANGE_FAILED',
490
+ attributes: { error: e instanceof Error ? e.message : String(e) },
491
+ }));
457
492
  }, STATUS_CHANGE_DEBOUNCE_MS));
458
493
  }
459
494
  this.lastStatus.set(session.id, result.status);
@@ -477,9 +512,11 @@ export class SessionMonitor {
477
512
  return;
478
513
  // Issue #32: Emit SSE message event (L11: include tool metadata)
479
514
  this.eventBus?.emitMessage(session.id, msg.role, msg.text, msg.contentType, msg.toolName || msg.toolUseId ? { tool_name: msg.toolName, tool_id: msg.toolUseId } : undefined);
515
+ await maybeInjectFault('monitor.forwardMessage.channels.message');
480
516
  await this.channels.message(this.makePayload(event, session, msg.text));
481
517
  }
482
518
  async broadcastStatusChange(session, status, prevStatus, result) {
519
+ await maybeInjectFault('monitor.broadcastStatusChange.start');
483
520
  if (status === 'permission_prompt' || status === 'bash_approval') {
484
521
  // Issue #32: Emit SSE approval event
485
522
  this.eventBus?.emitApproval(session.id, result.interactiveContent || 'Permission requested');
@@ -488,14 +525,25 @@ export class SessionMonitor {
488
525
  // acceptEdits, plan, auto all handle their own permissions).
489
526
  const AUTO_APPROVE_MODES = new Set(['bypassPermissions', 'dontAsk', 'acceptEdits', 'plan', 'auto']);
490
527
  if (session.permissionMode !== 'default' && AUTO_APPROVE_MODES.has(session.permissionMode)) {
491
- console.log(`[AUTO-APPROVED] Session ${session.windowName} (${session.id.slice(0, 8)}): ${result.interactiveContent || 'permission prompt'}`);
528
+ logger.info({
529
+ component: 'monitor',
530
+ operation: 'auto_approve_permission',
531
+ sessionId: session.id,
532
+ attributes: { windowName: session.windowName, mode: session.permissionMode },
533
+ });
492
534
  try {
493
535
  await this.sessions.approve(session.id);
494
536
  await this.channels.statusChange(this.makePayload('status.permission', session, `[AUTO-APPROVED] ${result.interactiveContent || 'Permission auto-approved'}`));
495
537
  }
496
538
  catch (e) {
497
539
  const errMsg = e instanceof Error ? e.message : String(e);
498
- console.error(`[AUTO-APPROVE FAILED] Session ${session.id}: ${errMsg}`);
540
+ logger.error({
541
+ component: 'monitor',
542
+ operation: 'auto_approve_permission',
543
+ sessionId: session.id,
544
+ errorCode: 'AUTO_APPROVE_FAILED',
545
+ attributes: { error: errMsg },
546
+ });
499
547
  await this.channels.statusChange(this.makePayload('status.permission', session, `[AUTO-APPROVE FAILED] ${result.interactiveContent || 'Permission requested'}: ${errMsg}`));
500
548
  }
501
549
  }
@@ -544,6 +592,7 @@ export class SessionMonitor {
544
592
  for (const session of sessions) {
545
593
  if (this.deadNotified.has(session.id))
546
594
  continue;
595
+ await maybeInjectFault('monitor.checkDeadSessions.isWindowAlive');
547
596
  const alive = await this.sessions.isWindowAlive(session.id);
548
597
  if (!alive) {
549
598
  this.deadNotified.add(session.id);
@@ -571,19 +620,31 @@ export class SessionMonitor {
571
620
  const { healthy } = await this.tmux.isServerHealthy();
572
621
  if (!healthy) {
573
622
  if (!this.tmuxWasDown) {
574
- console.warn('Monitor: tmux server is unreachable — sessions may be orphaned');
623
+ logger.warn({
624
+ component: 'monitor',
625
+ operation: 'tmux_health_check',
626
+ errorCode: 'TMUX_UNREACHABLE',
627
+ });
575
628
  this.tmuxWasDown = true;
576
629
  }
577
630
  return;
578
631
  }
579
632
  // Tmux is healthy now
580
633
  if (this.tmuxWasDown) {
581
- console.log('Monitor: tmux server recovered — triggering crash reconciliation');
634
+ logger.info({
635
+ component: 'monitor',
636
+ operation: 'tmux_health_check',
637
+ errorCode: 'TMUX_RECOVERED',
638
+ });
582
639
  this.tmuxWasDown = false;
583
640
  // Trigger crash reconciliation to re-attach or mark orphaned sessions
584
641
  const result = await this.sessions.reconcileTmuxCrash();
585
642
  if (result.recovered > 0 || result.orphaned > 0) {
586
- console.log(`Monitor: crash reconciliation complete — recovered: ${result.recovered}, orphaned: ${result.orphaned}`);
643
+ logger.info({
644
+ component: 'monitor',
645
+ operation: 'tmux_crash_reconciliation',
646
+ attributes: { recovered: result.recovered, orphaned: result.orphaned },
647
+ });
587
648
  // Notify channels about recovery
588
649
  for (const session of this.sessions.listSessions()) {
589
650
  await this.channels.statusChange(this.makePayload('status.recovered', session, `tmux server recovered. Session ${session.windowName} re-attached.`));
@@ -0,0 +1,7 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { SessionManager } from './session.js';
3
+ import type { MetricsCollector } from './metrics.js';
4
+ type PermissionSessions = Pick<SessionManager, 'approve' | 'reject' | 'getLatencyMetrics'>;
5
+ type PermissionMetrics = Pick<MetricsCollector, 'recordPermissionResponse'>;
6
+ export declare function registerPermissionRoutes(app: FastifyInstance, sessions: PermissionSessions, metrics: PermissionMetrics): void;
7
+ export {};
@@ -0,0 +1,28 @@
1
+ function createPermissionHandler(action, sessions, metrics) {
2
+ return async (req, reply) => {
3
+ try {
4
+ if (action === 'approve') {
5
+ await sessions.approve(req.params.id);
6
+ }
7
+ else {
8
+ await sessions.reject(req.params.id);
9
+ }
10
+ // Issue #87: Record permission response latency.
11
+ const lat = sessions.getLatencyMetrics(req.params.id);
12
+ if (lat !== null && lat.permission_response_ms !== null) {
13
+ metrics.recordPermissionResponse(req.params.id, lat.permission_response_ms);
14
+ }
15
+ return { ok: true };
16
+ }
17
+ catch (e) {
18
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
19
+ }
20
+ };
21
+ }
22
+ export function registerPermissionRoutes(app, sessions, metrics) {
23
+ for (const action of ['approve', 'reject']) {
24
+ const handler = createPermissionHandler(action, sessions, metrics);
25
+ app.post(`/v1/sessions/:id/${action}`, handler);
26
+ app.post(`/sessions/:id/${action}`, handler);
27
+ }
28
+ }
package/dist/server.js CHANGED
@@ -31,13 +31,16 @@ import { SSEConnectionLimiter } from './sse-limiter.js';
31
31
  import { PipelineManager } from './pipeline.js';
32
32
  import { AuthManager } from './auth.js';
33
33
  import { MetricsCollector } from './metrics.js';
34
+ import { registerPermissionRoutes } from './permission-routes.js';
34
35
  import { registerHookRoutes } from './hooks.js';
35
36
  import { registerWsTerminalRoute } from './ws-terminal.js';
36
37
  import { SwarmMonitor } from './swarm-monitor.js';
37
38
  import { killAllSessions } from './signal-cleanup-helper.js';
38
39
  import { execFileSync } from 'node:child_process';
39
40
  import { negotiate } from './handshake.js';
40
- import { authKeySchema, sendMessageSchema, commandSchema, bashSchema, screenshotSchema, permissionHookSchema, stopHookSchema, batchSessionSchema, pipelineSchema, parseIntSafe, isValidUUID, } from './validation.js';
41
+ import { diagnosticsBus } from './diagnostics.js';
42
+ import { setStructuredLogSink } from './logger.js';
43
+ import { authKeySchema, sendMessageSchema, commandSchema, bashSchema, screenshotSchema, permissionHookSchema, stopHookSchema, batchSessionSchema, pipelineSchema, handshakeRequestSchema, parseIntSafe, isValidUUID, compareSemver, extractCCVersion, MIN_CC_VERSION, } from './validation.js';
41
44
  const __filename = fileURLToPath(import.meta.url);
42
45
  const __dirname = path.dirname(__filename);
43
46
  // ── Configuration ────────────────────────────────────────────────────
@@ -92,6 +95,7 @@ async function handleInbound(cmd) {
92
95
  // ── HTTP Server ─────────────────────────────────────────────────────
93
96
  const app = Fastify({
94
97
  bodyLimit: 1048576, // 1MB — Issue #349: explicit body size limit
98
+ trustProxy: process.env.TRUST_PROXY === 'true', // #633: Only trust X-Forwarded-For when explicitly enabled
95
99
  logger: {
96
100
  // #230: Redact auth tokens from request logs
97
101
  serializers: {
@@ -108,6 +112,11 @@ const app = Fastify({
108
112
  },
109
113
  },
110
114
  });
115
+ setStructuredLogSink({
116
+ info: (record) => app.log.info(record),
117
+ warn: (record) => app.log.warn(record),
118
+ error: (record) => app.log.error(record),
119
+ });
111
120
  // #227: Security headers on all API responses (skip SSE)
112
121
  app.addHook('onSend', (req, reply, payload, done) => {
113
122
  const contentType = reply.getHeader('content-type');
@@ -158,6 +167,31 @@ function checkIpRateLimit(ip, isMaster) {
158
167
  const limit = isMaster ? IP_LIMIT_MASTER : IP_LIMIT_NORMAL;
159
168
  return activeCount > limit;
160
169
  }
170
+ const authFailLimits = new Map();
171
+ const AUTH_FAIL_WINDOW_MS = 60_000;
172
+ const AUTH_FAIL_MAX = 5;
173
+ function checkAuthFailRateLimit(ip) {
174
+ const now = Date.now();
175
+ const cutoff = now - AUTH_FAIL_WINDOW_MS;
176
+ const bucket = authFailLimits.get(ip) || { timestamps: [] };
177
+ // Prune expired entries
178
+ bucket.timestamps = bucket.timestamps.filter(t => t >= cutoff);
179
+ bucket.timestamps.push(now);
180
+ authFailLimits.set(ip, bucket);
181
+ return bucket.timestamps.length > AUTH_FAIL_MAX;
182
+ }
183
+ function recordAuthFailure(ip) {
184
+ checkAuthFailRateLimit(ip);
185
+ }
186
+ /** #632: Prune stale auth-failure buckets. */
187
+ function pruneAuthFailLimits() {
188
+ const cutoff = Date.now() - AUTH_FAIL_WINDOW_MS;
189
+ for (const [ip, bucket] of authFailLimits) {
190
+ bucket.timestamps = bucket.timestamps.filter(t => t >= cutoff);
191
+ if (bucket.timestamps.length === 0)
192
+ authFailLimits.delete(ip);
193
+ }
194
+ }
161
195
  /** #357: Prune IPs whose timestamp arrays are entirely outside the rate-limit window. */
162
196
  function pruneIpRateLimits() {
163
197
  const cutoff = Date.now() - IP_WINDOW_MS;
@@ -188,6 +222,7 @@ function setupAuth(authManager) {
188
222
  // Hook routes — exact match: /v1/hooks/{eventName} (alpha only, no path traversal)
189
223
  // Issue #394: Require valid X-Session-Id for known sessions instead of blanket bypass.
190
224
  // Issue #580: Validate UUID format before getSession lookup.
225
+ // Issue #629: Validate per-session hook secret to prevent replay with known session ID.
191
226
  // CC hooks run from localhost and always include the session ID they were started with.
192
227
  const hookMatch = /^\/v1\/hooks\/[A-Za-z]+$/.exec(urlPath);
193
228
  if (hookMatch) {
@@ -196,8 +231,16 @@ function setupAuth(authManager) {
196
231
  if (hookSessionId && !isValidUUID(hookSessionId)) {
197
232
  return reply.status(400).send({ error: 'Invalid session ID — must be a UUID' });
198
233
  }
199
- if (hookSessionId && sessions.getSession(hookSessionId)) {
200
- return; // valid session allow
234
+ if (hookSessionId) {
235
+ const session = sessions.getSession(hookSessionId);
236
+ if (session) {
237
+ // Issue #629: Validate hook secret from query param
238
+ const hookSecret = req.query?.secret;
239
+ if (!hookSecret || hookSecret !== session.hookSecret) {
240
+ return reply.status(401).send({ error: 'Unauthorized — invalid hook secret' });
241
+ }
242
+ return; // valid session + secret — allow
243
+ }
201
244
  }
202
245
  // No valid session context — reject even when auth is disabled
203
246
  return reply.status(401).send({ error: 'Unauthorized — hook endpoint requires valid session ID' });
@@ -224,24 +267,34 @@ function setupAuth(authManager) {
224
267
  if (!token) {
225
268
  return reply.status(401).send({ error: 'Unauthorized — Bearer token required' });
226
269
  }
270
+ // #633: Only use req.ip — trustProxy controls whether X-Forwarded-For is considered
271
+ const clientIp = req.ip ?? 'unknown';
272
+ // #632: Block IPs that exceeded auth failure rate limit (5 attempts/min)
273
+ if (checkAuthFailRateLimit(clientIp)) {
274
+ return reply.status(429).send({ error: 'Too many auth failures — try again later' });
275
+ }
227
276
  // #297: Check if this is a short-lived SSE token first
228
277
  if (isSSERoute && token.startsWith('sse_')) {
229
278
  if (await authManager.validateSSEToken(token)) {
230
279
  return; // authenticated via short-lived SSE token
231
280
  }
281
+ recordAuthFailure(clientIp);
232
282
  return reply.status(401).send({ error: 'Unauthorized — SSE token invalid or expired' });
233
283
  }
234
284
  const result = authManager.validate(token);
235
285
  if (!result.valid) {
286
+ recordAuthFailure(clientIp);
236
287
  return reply.status(401).send({ error: 'Unauthorized — invalid API key' });
237
288
  }
238
289
  if (result.rateLimited) {
239
290
  return reply.status(429).send({ error: 'Rate limit exceeded — 100 req/min per key' });
240
291
  }
241
292
  // #583: Store keyId for batch rate limiting
293
+ // #634: Store validated keyId for SSE token endpoint to reuse
242
294
  requestKeyMap.set(req.id, result.keyId ?? 'anonymous');
295
+ req.authKeyId = result.keyId;
243
296
  // #228: Per-IP rate limiting (applies to all authenticated requests)
244
- const clientIp = req.ip ?? req.headers['x-forwarded-for'] ?? 'unknown';
297
+ // #633: Only use req.ip trustProxy controls whether X-Forwarded-For is considered
245
298
  const isMaster = result.keyId === 'master';
246
299
  if (checkIpRateLimit(clientIp, isMaster)) {
247
300
  return reply.status(429).send({ error: 'Rate limit exceeded — IP throttled' });
@@ -287,14 +340,11 @@ async function healthHandler() {
287
340
  app.get('/v1/health', healthHandler);
288
341
  app.get('/health', healthHandler);
289
342
  app.post('/v1/handshake', async (req, reply) => {
290
- const { protocolVersion, clientCapabilities, clientVersion } = req.body ?? {};
291
- if (typeof protocolVersion !== 'string' || !protocolVersion.trim()) {
292
- return reply.status(400).send({ error: 'protocolVersion is required' });
293
- }
294
- if (clientCapabilities !== undefined && !Array.isArray(clientCapabilities)) {
295
- return reply.status(400).send({ error: 'clientCapabilities must be an array' });
343
+ const parsed = handshakeRequestSchema.safeParse(req.body ?? {});
344
+ if (!parsed.success) {
345
+ return reply.status(400).send({ error: 'Invalid handshake request', details: parsed.error.issues });
296
346
  }
297
- const result = negotiate({ protocolVersion, clientCapabilities, clientVersion });
347
+ const result = negotiate(parsed.data);
298
348
  return reply.status(result.compatible ? 200 : 409).send(result);
299
349
  });
300
350
  // Issue #81: Swarm awareness
@@ -330,18 +380,13 @@ app.delete('/v1/auth/keys/:id', async (req, reply) => {
330
380
  });
331
381
  // #297: SSE token endpoint — generates short-lived, single-use token
332
382
  // to avoid exposing long-lived bearer tokens in SSE URL query params.
333
- // Must be registered BEFORE setupAuth skips auth key routes.
383
+ // Issue #634: Reuse keyId from auth middleware result to avoid double-increment.
384
+ // of rate limit counter.
334
385
  app.post('/v1/auth/sse-token', async (req, reply) => {
335
386
  // This route goes through the onRequest auth hook, so the caller is
336
- // already authenticated. We just need to extract their keyId.
337
- const header = req.headers.authorization;
338
- let keyId = 'anonymous';
339
- if (header?.startsWith('Bearer ')) {
340
- const token = header.slice(7);
341
- const result = auth.validate(token);
342
- if (result.keyId)
343
- keyId = result.keyId;
344
- }
387
+ // already authenticated. Reuse stored keyId to avoid calling auth.validate() again.
388
+ const storedKeyId = req?.authKeyId;
389
+ const keyId = (typeof storedKeyId === 'string' ? storedKeyId : 'anonymous');
345
390
  try {
346
391
  const sseToken = await auth.generateSSEToken(keyId);
347
392
  return reply.status(201).send(sseToken);
@@ -350,8 +395,24 @@ app.post('/v1/auth/sse-token', async (req, reply) => {
350
395
  return reply.status(429).send({ error: e instanceof Error ? e.message : 'SSE token limit reached' });
351
396
  }
352
397
  });
398
+ const diagnosticsQuerySchema = z.object({
399
+ limit: z.coerce.number().int().min(1).max(100).optional(),
400
+ });
353
401
  // Global metrics (Issue #40)
354
402
  app.get('/v1/metrics', async () => metrics.getGlobalMetrics(sessions.listSessions().length));
403
+ // Bounded no-PII diagnostics channel (Issue #881)
404
+ app.get('/v1/diagnostics', async (req, reply) => {
405
+ const parsed = diagnosticsQuerySchema.safeParse(req.query ?? {});
406
+ if (!parsed.success) {
407
+ return reply.status(400).send({
408
+ error: 'Invalid diagnostics query params',
409
+ details: parsed.error.issues,
410
+ });
411
+ }
412
+ const limit = parsed.data.limit ?? 50;
413
+ const events = diagnosticsBus.getRecent(limit);
414
+ return { count: events.length, events };
415
+ });
355
416
  // Per-session metrics (Issue #40)
356
417
  app.get('/v1/sessions/:id/metrics', async (req, reply) => {
357
418
  const m = metrics.getSessionMetrics(req.params.id);
@@ -492,6 +553,21 @@ async function createSessionHandler(req, reply) {
492
553
  const { workDir, name, prompt, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove } = parsed.data;
493
554
  if (!workDir)
494
555
  return reply.status(400).send({ error: 'workDir is required' });
556
+ // Issue #564: Validate installed Claude Code version
557
+ try {
558
+ const raw = execFileSync('claude', ['--version'], { encoding: 'utf-8', timeout: 5000 });
559
+ const ccVer = extractCCVersion(raw);
560
+ if (ccVer !== null && compareSemver(ccVer, MIN_CC_VERSION) < 0) {
561
+ return reply.status(422).send({
562
+ error: `Claude Code version ${ccVer} is below minimum supported version ${MIN_CC_VERSION}. Please upgrade.`,
563
+ code: 'CC_VERSION_TOO_OLD',
564
+ upgrade: 'Run: claude update or npm install -g @anthropic-ai/claude-code@latest',
565
+ });
566
+ }
567
+ }
568
+ catch {
569
+ // claude CLI not found or timed out — skip version check (fails open)
570
+ }
495
571
  const safeWorkDir = await validateWorkDirWithConfig(workDir);
496
572
  if (typeof safeWorkDir === 'object')
497
573
  return reply.status(400).send({ error: safeWorkDir.error, code: safeWorkDir.code });
@@ -515,6 +591,8 @@ async function createSessionHandler(req, reply) {
515
591
  const session = await sessions.createSession({ workDir: safeWorkDir, name, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove });
516
592
  console.timeEnd("POST_CREATE_SESSION");
517
593
  console.time("POST_CHANNEL_CREATED");
594
+ // Issue #625: Track session in metrics so sessionsCreated counter is accurate
595
+ metrics.sessionCreated(session.id);
518
596
  // Issue #46: Create Telegram topic BEFORE sending prompt.
519
597
  // The monitor starts polling immediately after createSession().
520
598
  // If we wait for sendInitialPrompt (up to 15s), the monitor may find
@@ -615,29 +693,13 @@ async function readMessagesHandler(req, reply) {
615
693
  }
616
694
  app.get('/v1/sessions/:id/read', readMessagesHandler);
617
695
  app.get('/sessions/:id/read', readMessagesHandler);
618
- function makePermissionHandler(action) {
619
- return async (req, reply) => {
620
- try {
621
- const op = action === 'approve' ? sessions.approve.bind(sessions) : sessions.reject.bind(sessions);
622
- await op(req.params.id);
623
- // Issue #87: Record permission response latency
624
- const lat = sessions.getLatencyMetrics(req.params.id);
625
- if (lat !== null && lat.permission_response_ms !== null) {
626
- metrics.recordPermissionResponse(req.params.id, lat.permission_response_ms);
627
- }
628
- return { ok: true };
629
- }
630
- catch (e) {
631
- return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
632
- }
633
- };
634
- }
635
- const approveHandler = makePermissionHandler('approve');
636
- const rejectHandler = makePermissionHandler('reject');
637
- app.post('/v1/sessions/:id/reject', rejectHandler);
638
- app.post('/sessions/:id/reject', rejectHandler);
639
- app.post('/v1/sessions/:id/approve', approveHandler);
640
- app.post('/sessions/:id/approve', approveHandler);
696
+ registerPermissionRoutes(app, {
697
+ approve: async (id) => sessions.approve(id),
698
+ reject: async (id) => sessions.reject(id),
699
+ getLatencyMetrics: (id) => sessions.getLatencyMetrics(id),
700
+ }, {
701
+ recordPermissionResponse: (id, latencyMs) => metrics.recordPermissionResponse(id, latencyMs),
702
+ });
641
703
  // Issue #336: Answer pending AskUserQuestion
642
704
  app.post('/v1/sessions/:id/answer', async (req, reply) => {
643
705
  const { questionId, answer } = req.body || {};
@@ -994,6 +1056,16 @@ app.post('/v1/pipelines', async (req, reply) => {
994
1056
  return reply.status(400).send({ error: `Invalid workDir: ${safeWorkDir.error}`, code: safeWorkDir.code });
995
1057
  }
996
1058
  pipeConfig.workDir = safeWorkDir;
1059
+ // Validate per-stage workDir overrides for path traversal (#631)
1060
+ for (const stage of pipeConfig.stages) {
1061
+ if (stage.workDir) {
1062
+ const safeStageWorkDir = await validateWorkDirWithConfig(stage.workDir);
1063
+ if (typeof safeStageWorkDir === 'object') {
1064
+ return reply.status(400).send({ error: `Invalid workDir for stage "${stage.name}": ${safeStageWorkDir.error}`, code: safeStageWorkDir.code });
1065
+ }
1066
+ stage.workDir = safeStageWorkDir;
1067
+ }
1068
+ }
997
1069
  try {
998
1070
  const pipeline = await pipelines.createPipeline(pipeConfig);
999
1071
  return reply.status(201).send(pipeline);
@@ -1387,6 +1459,8 @@ async function main() {
1387
1459
  const metricsSaveInterval = setInterval(() => { void metrics.save(); }, 5 * 60 * 1000);
1388
1460
  // #357: Prune stale IP rate-limit entries every minute
1389
1461
  const ipPruneInterval = setInterval(pruneIpRateLimits, 60_000);
1462
+ // #632: Prune stale auth failure rate-limit buckets every minute
1463
+ const authFailPruneInterval = setInterval(pruneAuthFailLimits, 60_000);
1390
1464
  // #398: Sweep stale API key rate limit buckets every 5 minutes
1391
1465
  const authSweepInterval = setInterval(() => auth.sweepStaleRateLimits(), 5 * 60_000);
1392
1466
  // Issue #361: Graceful shutdown handler
@@ -1408,6 +1482,7 @@ async function main() {
1408
1482
  clearInterval(zombieReaperInterval);
1409
1483
  clearInterval(metricsSaveInterval);
1410
1484
  clearInterval(ipPruneInterval);
1485
+ clearInterval(authFailPruneInterval);
1411
1486
  clearInterval(authSweepInterval);
1412
1487
  // Issue #569: Kill all CC sessions and tmux windows before exit
1413
1488
  try {
package/dist/session.d.ts CHANGED
@@ -25,6 +25,7 @@ export interface SessionInfo {
25
25
  permissionMode: string;
26
26
  settingsPatched?: boolean;
27
27
  hookSettingsFile?: string;
28
+ hookSecret?: string;
28
29
  lastHookAt?: number;
29
30
  activeSubagents?: Set<string>;
30
31
  permissionPromptAt?: number;