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 +2 -0
- package/dist/auth.js +10 -0
- package/dist/events.d.ts +2 -0
- package/dist/events.js +9 -0
- package/dist/mcp-server.js +2 -1
- package/dist/monitor.js +31 -3
- package/dist/server.js +45 -4
- package/dist/session.d.ts +8 -0
- package/dist/session.js +48 -6
- package/dist/validation.d.ts +2 -0
- package/dist/validation.js +6 -4
- package/dist/ws-terminal.js +7 -1
- package/package.json +1 -1
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()) {
|
package/dist/mcp-server.js
CHANGED
|
@@ -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 —
|
|
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
|
-
|
|
369
|
-
|
|
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
|
-
//
|
|
297
|
-
//
|
|
298
|
-
|
|
299
|
-
|
|
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
|
package/dist/validation.d.ts
CHANGED
|
@@ -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;
|
package/dist/validation.js
CHANGED
|
@@ -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(),
|
package/dist/ws-terminal.js
CHANGED
|
@@ -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');
|