aegis-bridge 2.2.2 → 2.3.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/dist/auth.d.ts CHANGED
@@ -53,6 +53,8 @@ export declare class AuthManager {
53
53
  };
54
54
  /** Hash a key with SHA-256. */
55
55
  static hashKey(key: string): string;
56
+ /** #398: Sweep stale rate limit buckets. Prune entries with expired windows. */
57
+ sweepStaleRateLimits(): void;
56
58
  /** Check if auth is enabled (master token or any keys). */
57
59
  get authEnabled(): boolean;
58
60
  /**
package/dist/auth.js CHANGED
@@ -127,6 +127,16 @@ export class AuthManager {
127
127
  static hashKey(key) {
128
128
  return createHash('sha256').update(key).digest('hex');
129
129
  }
130
+ /** #398: Sweep stale rate limit buckets. Prune entries with expired windows. */
131
+ sweepStaleRateLimits() {
132
+ const now = Date.now();
133
+ const windowMs = 60_000; // 1 minute
134
+ for (const [keyId, bucket] of this.rateLimits) {
135
+ if (now - bucket.windowStart > windowMs) {
136
+ this.rateLimits.delete(keyId);
137
+ }
138
+ }
139
+ }
130
140
  /** Check if auth is enabled (master token or any keys). */
131
141
  get authEnabled() {
132
142
  return !!this.masterToken || this.store.keys.length > 0;
package/dist/events.d.ts CHANGED
@@ -81,6 +81,8 @@ export declare class SessionEventBus {
81
81
  id: number;
82
82
  event: GlobalSSEEvent;
83
83
  }>;
84
+ /** #398: Clean up per-session state (call when session is killed). */
85
+ cleanupSession(sessionId: string): void;
84
86
  /** Clean up all emitters. */
85
87
  destroy(): void;
86
88
  }
package/dist/events.js CHANGED
@@ -243,6 +243,15 @@ export class SessionEventBus {
243
243
  getGlobalEventsSince(lastEventId) {
244
244
  return this.globalEventBuffer.filter(e => e.id > lastEventId);
245
245
  }
246
+ /** #398: Clean up per-session state (call when session is killed). */
247
+ cleanupSession(sessionId) {
248
+ this.eventBuffers.delete(sessionId);
249
+ const emitter = this.emitters.get(sessionId);
250
+ if (emitter) {
251
+ emitter.removeAllListeners();
252
+ this.emitters.delete(sessionId);
253
+ }
254
+ }
246
255
  /** Clean up all emitters. */
247
256
  destroy() {
248
257
  for (const emitter of this.emitters.values()) {
@@ -36,8 +36,9 @@ export class AegisClient {
36
36
  }
37
37
  }
38
38
  async request(path, opts) {
39
+ const hasBody = opts?.body !== undefined;
39
40
  const headers = {
40
- 'Content-Type': 'application/json',
41
+ ...(hasBody ? { 'Content-Type': 'application/json' } : {}),
41
42
  ...(this.authToken ? { Authorization: `Bearer ${this.authToken}` } : {}),
42
43
  };
43
44
  let res;
package/dist/monitor.js CHANGED
@@ -256,6 +256,23 @@ export class SessionMonitor {
256
256
  }
257
257
  }
258
258
  }
259
+ // --- Type 5: Extended working stall (working too long regardless of byte changes, ---
260
+ // Catches CC stuck in "Misting" state where internal loop detection
261
+ if (currentStatus === 'working') {
262
+ const entry = this.stateSince.get(session.id);
263
+ if (entry && entry.state === 'working') {
264
+ const workingDuration = now - entry.since;
265
+ const maxWorkingMs = this.config.stallThresholdMs * 3; // 15 min default
266
+ if (workingDuration >= maxWorkingMs && !this.stallNotified.has(`${session.id}:stall:extended_working`)) {
267
+ this.stallNotified.add(`${session.id}:stall:extended_working`);
268
+ const minutes = Math.round(workingDuration / 60000);
269
+ const detail = `Session stalled: in "working" state for ${minutes}min. ` +
270
+ `CC may be stuck in an internal loop (e.g., Misting). Consider: POST /v1/sessions/${session.id}/interrupt or /kill`;
271
+ this.eventBus?.emitStall(session.id, 'extended_working', detail);
272
+ await this.channels.statusChange(this.makePayload('status.stall', session, detail));
273
+ }
274
+ }
275
+ }
259
276
  // Clean up stall notifications on state transitions (using prevStallStatus)
260
277
  if (prevStallStatus && prevStallStatus !== currentStatus) {
261
278
  const exitedPermission = prevStallStatus === 'permission_prompt' || prevStallStatus === 'bash_approval';
@@ -361,12 +378,20 @@ export class SessionMonitor {
361
378
  // Update last activity
362
379
  session.lastActivity = Date.now();
363
380
  }
364
- // Update JSONL stall tracking — always initialize on watcher events
381
+ // Update JSONL stall tracking — only reset stall timer when real messages arrive
382
+ // When no messages, only update bytes tracking (keep timestamp)
365
383
  const now = Date.now();
366
384
  const prev = this.lastBytesSeen.get(event.sessionId);
367
385
  if (event.newOffset > (prev?.bytes ?? -1)) {
368
- this.lastBytesSeen.set(event.sessionId, { bytes: event.newOffset, at: now });
369
- this.stallNotified.delete(`${event.sessionId}:stall:jsonl`);
386
+ if (event.messages.length > 0) {
387
+ // Real output — reset stall timer
388
+ this.lastBytesSeen.set(event.sessionId, { bytes: event.newOffset, at: now });
389
+ this.stallNotified.delete(`${event.sessionId}:stall:jsonl`);
390
+ }
391
+ else {
392
+ // File grew but no messages — only update bytes, keep timestamp
393
+ this.lastBytesSeen.set(event.sessionId, { bytes: event.newOffset, at: prev?.at ?? now });
394
+ }
370
395
  }
371
396
  }
372
397
  async checkSession(session) {
@@ -407,6 +432,9 @@ export class SessionMonitor {
407
432
  const latestResult = { statusText: result.statusText, interactiveContent: result.interactiveContent };
408
433
  this.statusChangeDebounce.set(session.id, setTimeout(() => {
409
434
  this.statusChangeDebounce.delete(session.id);
435
+ // #511: Skip broadcast if session was killed while debounce was pending
436
+ if (!this.lastStatus.has(session.id))
437
+ return;
410
438
  void this.broadcastStatusChange(session, latestStatus, latestPrevStatus, latestResult)
411
439
  .catch(e => console.error(`Monitor: broadcastStatusChange failed for ${session.id}:`, e));
412
440
  }, STATUS_CHANGE_DEBOUNCE_MS));
package/dist/server.js CHANGED
@@ -35,7 +35,7 @@ import { registerHookRoutes } from './hooks.js';
35
35
  import { registerWsTerminalRoute } from './ws-terminal.js';
36
36
  import { SwarmMonitor } from './swarm-monitor.js';
37
37
  import { execSync } from 'node:child_process';
38
- import { authKeySchema, sendMessageSchema, commandSchema, bashSchema, screenshotSchema, permissionHookSchema, stopHookSchema, batchSessionSchema, pipelineSchema, parseIntSafe, } from './validation.js';
38
+ import { authKeySchema, sendMessageSchema, commandSchema, bashSchema, screenshotSchema, permissionHookSchema, stopHookSchema, batchSessionSchema, pipelineSchema, parseIntSafe, isValidUUID, } from './validation.js';
39
39
  const __filename = fileURLToPath(import.meta.url);
40
40
  const __dirname = path.dirname(__filename);
41
41
  // ── Configuration ────────────────────────────────────────────────────
@@ -211,6 +211,13 @@ function setupAuth(authManager) {
211
211
  });
212
212
  }
213
213
  // ── v1 API Routes ───────────────────────────────────────────────────
214
+ // #412: Reject non-UUID session IDs at the routing layer
215
+ app.addHook('onRequest', async (req, reply) => {
216
+ const id = req.params.id;
217
+ if (id !== undefined && !isValidUUID(id)) {
218
+ return reply.status(400).send({ error: 'Invalid session ID — must be a UUID' });
219
+ }
220
+ });
214
221
  // #226: Zod schema for session creation
215
222
  const createSessionSchema = z.object({
216
223
  workDir: z.string().min(1),
@@ -510,13 +517,13 @@ app.get('/v1/sessions/:id', async (req, reply) => {
510
517
  const session = sessions.getSession(req.params.id);
511
518
  if (!session)
512
519
  return reply.status(404).send({ error: 'Session not found' });
513
- return addActionHints(session);
520
+ return addActionHints(session, sessions);
514
521
  });
515
522
  app.get('/sessions/:id', async (req, reply) => {
516
523
  const session = sessions.getSession(req.params.id);
517
524
  if (!session)
518
525
  return reply.status(404).send({ error: 'Session not found' });
519
- return addActionHints(session);
526
+ return addActionHints(session, sessions);
520
527
  });
521
528
  // #128: Bulk health check — returns health for all sessions in one request
522
529
  app.get('/v1/sessions/health', async () => {
@@ -1103,6 +1110,7 @@ async function reapStaleSessions(maxAgeMs) {
1103
1110
  session: { id: session.id, name: session.windowName, workDir: session.workDir },
1104
1111
  detail: `Auto-killed: exceeded ${maxAgeMs / 3600000}h time limit`,
1105
1112
  });
1113
+ eventBus.cleanupSession(session.id);
1106
1114
  await sessions.killSession(session.id);
1107
1115
  monitor.removeSession(session.id);
1108
1116
  metrics.cleanupSession(session.id);
@@ -1132,6 +1140,7 @@ async function reapZombieSessions() {
1132
1140
  console.log(`Reaper: removing zombie session ${session.windowName} (${session.id.slice(0, 8)})`);
1133
1141
  try {
1134
1142
  monitor.removeSession(session.id);
1143
+ eventBus.cleanupSession(session.id);
1135
1144
  await sessions.killSession(session.id);
1136
1145
  metrics.cleanupSession(session.id);
1137
1146
  await channels.sessionEnded({
@@ -1148,7 +1157,7 @@ async function reapZombieSessions() {
1148
1157
  }
1149
1158
  // ── Helpers ──────────────────────────────────────────────────────────
1150
1159
  /** Issue #20: Add actionHints to session response for interactive states. */
1151
- function addActionHints(session) {
1160
+ function addActionHints(session, sessions) {
1152
1161
  // #357: Convert Set to array for JSON serialization
1153
1162
  const result = {
1154
1163
  ...session,
@@ -1160,8 +1169,33 @@ function addActionHints(session) {
1160
1169
  reject: { method: 'POST', url: `/v1/sessions/${session.id}/reject`, description: 'Reject the pending permission' },
1161
1170
  };
1162
1171
  }
1172
+ // #599: Expose pending question data for MCP/REST callers
1173
+ if (session.status === 'ask_question' && sessions) {
1174
+ const info = sessions.getPendingQuestionInfo(session.id);
1175
+ if (info) {
1176
+ result.pendingQuestion = {
1177
+ toolUseId: info.toolUseId,
1178
+ content: info.question,
1179
+ options: extractQuestionOptions(info.question),
1180
+ since: info.timestamp,
1181
+ };
1182
+ }
1183
+ }
1163
1184
  return result;
1164
1185
  }
1186
+ /** #599: Extract selectable options from AskUserQuestion text. */
1187
+ function extractQuestionOptions(text) {
1188
+ // Numbered options: "1. Foo\n2. Bar"
1189
+ const numberedRegex = /^\s*(\d+)\.\s+(.+)$/gm;
1190
+ const options = [];
1191
+ let m;
1192
+ while ((m = numberedRegex.exec(text)) !== null) {
1193
+ options.push(m[2].trim());
1194
+ }
1195
+ if (options.length >= 2)
1196
+ return options.slice(0, 4);
1197
+ return null;
1198
+ }
1165
1199
  function makePayload(event, sessionId, detail, meta) {
1166
1200
  const session = sessions.getSession(sessionId);
1167
1201
  return {
@@ -1378,7 +1412,11 @@ async function main() {
1378
1412
  await app.register(fastifyWebsocket);
1379
1413
  registerWsTerminalRoute(app, sessions, tmux, auth);
1380
1414
  // #217: CORS configuration — restrictive by default
1415
+ // #413: Reject wildcard CORS_ORIGIN — * is insecure and allows any origin
1381
1416
  const corsOrigin = process.env.CORS_ORIGIN;
1417
+ if (corsOrigin === '*') {
1418
+ throw new Error('CORS_ORIGIN=* wildcard is not allowed. Specify explicit origins (comma-separated) or leave unset to disable CORS.');
1419
+ }
1382
1420
  await app.register(fastifyCors, {
1383
1421
  origin: corsOrigin ? corsOrigin.split(',').map(s => s.trim()) : false,
1384
1422
  });
@@ -1411,6 +1449,8 @@ async function main() {
1411
1449
  const metricsSaveInterval = setInterval(() => { void metrics.save(); }, 5 * 60 * 1000);
1412
1450
  // #357: Prune stale IP rate-limit entries every minute
1413
1451
  const ipPruneInterval = setInterval(pruneIpRateLimits, 60_000);
1452
+ // #398: Sweep stale API key rate limit buckets every 5 minutes
1453
+ const authSweepInterval = setInterval(() => auth.sweepStaleRateLimits(), 5 * 60_000);
1414
1454
  // Issue #361: Graceful shutdown handler
1415
1455
  // Issue #415: Reentrance guard at handler level prevents double execution on rapid SIGINT
1416
1456
  let shuttingDown = false;
@@ -1430,6 +1470,7 @@ async function main() {
1430
1470
  clearInterval(zombieReaperInterval);
1431
1471
  clearInterval(metricsSaveInterval);
1432
1472
  clearInterval(ipPruneInterval);
1473
+ clearInterval(authSweepInterval);
1433
1474
  // 3. Destroy channels (awaits Telegram poll loop)
1434
1475
  try {
1435
1476
  await channels.destroy();
package/dist/session.d.ts CHANGED
@@ -61,6 +61,7 @@ export declare class SessionManager {
61
61
  private static readonly SAVE_DEBOUNCE_MS;
62
62
  private pendingPermissions;
63
63
  private pendingQuestions;
64
+ private static readonly MAX_CACHE_ENTRIES_PER_SESSION;
64
65
  private parsedEntriesCache;
65
66
  constructor(tmux: TmuxManager, config: Config);
66
67
  /** Validate that parsed data looks like a valid SessionState. */
@@ -99,6 +100,12 @@ export declare class SessionManager {
99
100
  }>;
100
101
  /** Wait for CC idle prompt, then send. Single attempt. */
101
102
  private waitForReadyAndSend;
103
+ /**
104
+ * Issue #561: After sending an initial prompt, verify CC actually accepted it
105
+ * by polling for a state transition away from idle/unknown.
106
+ * Returns true if CC transitions to a recognized active state within the timeout.
107
+ */
108
+ private verifyPromptAccepted;
102
109
  createSession(opts: {
103
110
  workDir: string;
104
111
  name?: string;
@@ -221,6 +228,7 @@ export declare class SessionManager {
221
228
  getPendingQuestionInfo(sessionId: string): {
222
229
  toolUseId: string;
223
230
  question: string;
231
+ timestamp: number;
224
232
  } | null;
225
233
  /** Issue #336: Clean up any pending question for a session. */
226
234
  cleanupPendingQuestion(sessionId: string): void;
package/dist/session.js CHANGED
@@ -43,6 +43,8 @@ export class SessionManager {
43
43
  pendingPermissions = new Map();
44
44
  pendingQuestions = new Map();
45
45
  // #357: Cache of all parsed JSONL entries per session to avoid re-reading from offset 0
46
+ // #424: Evict oldest entries when cache exceeds max to prevent unbounded growth
47
+ static MAX_CACHE_ENTRIES_PER_SESSION = 10_000;
46
48
  parsedEntriesCache = new Map();
47
49
  constructor(tmux, config) {
48
50
  this.tmux = tmux;
@@ -293,16 +295,52 @@ export class SessionManager {
293
295
  // At session creation, no other code is writing to this pane,
294
296
  // so queue serialization is unnecessary and adds latency.
295
297
  const paneText = await this.tmux.capturePaneDirect(session.windowId);
296
- // CC shows (U+276F) when ready for input. Avoid checking for plain >
297
- // which appears frequently in tool output, diffs, and prompts.
298
- if (paneText && paneText.includes('❯')) {
299
- return this.sendMessageDirect(sessionId, prompt);
298
+ // Issue #561: Use detectUIState for robust readiness detection.
299
+ // Requires both prompt AND chrome separators (─────) to confirm idle.
300
+ // Naive includes('❯') matched splash/startup output, causing premature sends.
301
+ if (paneText && detectUIState(paneText) === 'idle') {
302
+ const result = await this.sendMessageDirect(sessionId, prompt);
303
+ if (!result.delivered)
304
+ return result;
305
+ // Issue #561: Post-send verification. Wait for CC to transition to a
306
+ // recognized active state. If CC stays in idle/unknown, the prompt was
307
+ // swallowed — report as undelivered so the retry loop can re-attempt.
308
+ const verified = await this.verifyPromptAccepted(session.windowId);
309
+ return verified
310
+ ? result
311
+ : { delivered: false, attempts: result.attempts };
300
312
  }
301
313
  await new Promise(r => setTimeout(r, pollInterval));
302
314
  pollInterval = Math.min(pollInterval * 2, MAX_POLL_MS);
303
315
  }
304
316
  return { delivered: false, attempts: 0 };
305
317
  }
318
+ /**
319
+ * Issue #561: After sending an initial prompt, verify CC actually accepted it
320
+ * by polling for a state transition away from idle/unknown.
321
+ * Returns true if CC transitions to a recognized active state within the timeout.
322
+ */
323
+ async verifyPromptAccepted(windowId) {
324
+ const VERIFY_TIMEOUT_MS = 5_000;
325
+ const VERIFY_POLL_MS = 500;
326
+ const verifyStart = Date.now();
327
+ while (Date.now() - verifyStart < VERIFY_TIMEOUT_MS) {
328
+ const paneText = await this.tmux.capturePaneDirect(windowId);
329
+ const state = detectUIState(paneText);
330
+ // Active states mean CC received and is processing the prompt.
331
+ // waiting_for_input = CC accepted prompt, awaiting follow-up (no chrome yet).
332
+ if (state === 'working' || state === 'permission_prompt' ||
333
+ state === 'bash_approval' || state === 'plan_mode' ||
334
+ state === 'ask_question' || state === 'compacting' ||
335
+ state === 'context_warning' || state === 'waiting_for_input') {
336
+ return true;
337
+ }
338
+ // idle or unknown — keep polling
339
+ await new Promise(r => setTimeout(r, VERIFY_POLL_MS));
340
+ }
341
+ console.warn(`verifyPromptAccepted: CC did not transition from idle/unknown within ${VERIFY_TIMEOUT_MS}ms`);
342
+ return false;
343
+ }
306
344
  async createSession(opts) {
307
345
  const id = crypto.randomUUID();
308
346
  const windowName = opts.name || `cc-${id.slice(0, 8)}`;
@@ -772,7 +810,7 @@ export class SessionManager {
772
810
  console.log(`Hooks: AskUserQuestion timeout for session ${sessionId} — allowing without answer`);
773
811
  resolve(null);
774
812
  }, timeoutMs);
775
- this.pendingQuestions.set(sessionId, { resolve, timer, toolUseId, question });
813
+ this.pendingQuestions.set(sessionId, { resolve, timer, toolUseId, question, timestamp: Date.now() });
776
814
  });
777
815
  }
778
816
  /** Issue #336: Submit an answer to a pending question. Returns true if resolved. */
@@ -794,7 +832,7 @@ export class SessionManager {
794
832
  /** Issue #336: Get info about a pending question. */
795
833
  getPendingQuestionInfo(sessionId) {
796
834
  const pending = this.pendingQuestions.get(sessionId);
797
- return pending ? { toolUseId: pending.toolUseId, question: pending.question } : null;
835
+ return pending ? { toolUseId: pending.toolUseId, question: pending.question, timestamp: pending.timestamp } : null;
798
836
  }
799
837
  /** Issue #336: Clean up any pending question for a session. */
800
838
  cleanupPendingQuestion(sessionId) {
@@ -910,6 +948,10 @@ export class SessionManager {
910
948
  if (cached) {
911
949
  cached.entries.push(...result.entries);
912
950
  cached.offset = result.newOffset;
951
+ // #424: Evict oldest entries when cache exceeds per-session cap
952
+ if (cached.entries.length > SessionManager.MAX_CACHE_ENTRIES_PER_SESSION) {
953
+ cached.entries.splice(0, cached.entries.length - SessionManager.MAX_CACHE_ENTRIES_PER_SESSION);
954
+ }
913
955
  return cached.entries;
914
956
  }
915
957
  // First read — cache it
@@ -12,6 +12,8 @@ export declare const authKeySchema: z.ZodObject<{
12
12
  name: z.ZodString;
13
13
  rateLimit: z.ZodOptional<z.ZodNumber>;
14
14
  }, z.core.$strict>;
15
+ /** Maximum length for user-supplied prompts/commands (Issue #411). */
16
+ export declare const MAX_INPUT_LENGTH = 10000;
15
17
  /** POST /v1/sessions/:id/send */
16
18
  export declare const sendMessageSchema: z.ZodObject<{
17
19
  text: z.ZodString;
@@ -15,17 +15,19 @@ export const authKeySchema = z.object({
15
15
  name: z.string().min(1),
16
16
  rateLimit: z.number().int().positive().optional(),
17
17
  }).strict();
18
+ /** Maximum length for user-supplied prompts/commands (Issue #411). */
19
+ export const MAX_INPUT_LENGTH = 10_000;
18
20
  /** POST /v1/sessions/:id/send */
19
21
  export const sendMessageSchema = z.object({
20
- text: z.string().min(1),
22
+ text: z.string().min(1).max(MAX_INPUT_LENGTH),
21
23
  }).strict();
22
24
  /** POST /v1/sessions/:id/command */
23
25
  export const commandSchema = z.object({
24
- command: z.string().min(1),
26
+ command: z.string().min(1).max(MAX_INPUT_LENGTH),
25
27
  }).strict();
26
28
  /** POST /v1/sessions/:id/bash */
27
29
  export const bashSchema = z.object({
28
- command: z.string().min(1),
30
+ command: z.string().min(1).max(MAX_INPUT_LENGTH),
29
31
  }).strict();
30
32
  /** POST /v1/sessions/:id/screenshot */
31
33
  export const screenshotSchema = z.object({
@@ -70,7 +72,7 @@ export const batchSessionSchema = z.object({
70
72
  const pipelineStageSchema = z.object({
71
73
  name: z.string().min(1),
72
74
  workDir: z.string().min(1).optional(),
73
- prompt: z.string().min(1),
75
+ prompt: z.string().min(1).max(MAX_INPUT_LENGTH),
74
76
  dependsOn: z.array(z.string()).optional(),
75
77
  permissionMode: z.enum(['default', 'bypassPermissions', 'plan']).optional(),
76
78
  autoApprove: z.boolean().optional(),
@@ -19,7 +19,7 @@
19
19
  * - Shared tmux capture polls (one per session, not per connection)
20
20
  * - Ping/pong keep-alive with dead connection detection
21
21
  */
22
- import { clamp, wsInboundMessageSchema } from './validation.js';
22
+ import { clamp, wsInboundMessageSchema, isValidUUID } from './validation.js';
23
23
  const POLL_INTERVAL_MS = 500;
24
24
  const KEEPALIVE_INTERVAL_TICKS = 60; // 30s at 500ms intervals
25
25
  const KEEPALIVE_TIMEOUT_MS = 35_000; // 30s interval + 5s grace
@@ -69,6 +69,12 @@ export function registerWsTerminalRoute(app, sessions, tmux, auth) {
69
69
  },
70
70
  }, (socket, req) => {
71
71
  const sessionId = req.params.id;
72
+ // #412: Validate session ID is a UUID before lookup
73
+ if (!isValidUUID(sessionId)) {
74
+ sendError(socket, 'Invalid session ID — must be a UUID');
75
+ socket.close();
76
+ return;
77
+ }
72
78
  const session = sessions.getSession(sessionId);
73
79
  if (!session) {
74
80
  sendError(socket, 'Session not found');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aegis-bridge",
3
- "version": "2.2.2",
3
+ "version": "2.3.0",
4
4
  "type": "module",
5
5
  "description": "Orchestrate Claude Code sessions via API. Create, brief, monitor, refine, ship.",
6
6
  "main": "dist/server.js",