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.
- package/dashboard/dist/assets/index-9Hkkvm_I.css +32 -0
- package/dashboard/dist/assets/index-Bfabq3q-.js +262 -0
- package/dashboard/dist/index.html +2 -2
- package/dist/config.d.ts +2 -0
- package/dist/config.js +3 -0
- package/dist/continuation-pointer.d.ts +11 -0
- package/dist/continuation-pointer.js +64 -0
- package/dist/dashboard/assets/index-9Hkkvm_I.css +32 -0
- package/dist/dashboard/assets/index-Bfabq3q-.js +262 -0
- package/dist/dashboard/index.html +2 -2
- package/dist/diagnostics.d.ts +27 -0
- package/dist/diagnostics.js +95 -0
- package/dist/events.d.ts +14 -0
- package/dist/events.js +43 -14
- package/dist/fault-injection.d.ts +29 -0
- package/dist/fault-injection.js +115 -0
- package/dist/handshake.d.ts +21 -1
- package/dist/handshake.js +37 -3
- package/dist/hook-settings.d.ts +3 -2
- package/dist/hook-settings.js +6 -4
- package/dist/hook.js +10 -1
- package/dist/hooks.js +5 -0
- package/dist/logger.d.ts +35 -0
- package/dist/logger.js +65 -0
- package/dist/monitor.js +72 -11
- package/dist/permission-routes.d.ts +7 -0
- package/dist/permission-routes.js +28 -0
- package/dist/server.js +119 -44
- package/dist/session.d.ts +1 -0
- package/dist/session.js +41 -23
- package/dist/tmux.js +2 -2
- package/dist/utils/circular-buffer.d.ts +11 -0
- package/dist/utils/circular-buffer.js +37 -0
- package/dist/validation.d.ts +34 -0
- package/dist/validation.js +45 -3
- package/package.json +3 -2
- package/dashboard/dist/assets/index-B7DYf7vF.css +0 -32
- package/dashboard/dist/assets/index-DIyuyrlO.js +0 -262
- package/dist/dashboard/assets/index-B7DYf7vF.css +0 -32
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =>
|
|
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 =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
200
|
-
|
|
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
|
-
|
|
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
|
|
291
|
-
if (
|
|
292
|
-
return reply.status(400).send({ error: '
|
|
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(
|
|
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
|
-
//
|
|
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.
|
|
337
|
-
const
|
|
338
|
-
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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