aegis-bridge 2.5.1 → 2.5.3
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/channels/manager.d.ts +8 -0
- package/dist/channels/manager.js +15 -0
- package/dist/channels/webhook.js +8 -2
- package/dist/events.d.ts +2 -0
- package/dist/events.js +16 -1
- package/dist/hook-settings.js +8 -8
- package/dist/hooks.js +15 -4
- package/dist/screenshot.d.ts +2 -0
- package/dist/screenshot.js +5 -1
- package/dist/server.d.ts +6 -1
- package/dist/server.js +27 -8
- package/dist/session.d.ts +8 -2
- package/dist/session.js +56 -5
- package/dist/ssrf.d.ts +26 -2
- package/dist/ssrf.js +56 -11
- package/package.json +1 -1
|
@@ -6,6 +6,14 @@
|
|
|
6
6
|
* one broken channel never kills the bridge.
|
|
7
7
|
*/
|
|
8
8
|
import type { Channel, SessionEventPayload, InboundHandler } from './types.js';
|
|
9
|
+
/**
|
|
10
|
+
* Thrown for retriable failures (5xx server errors, network timeouts).
|
|
11
|
+
* Only these increment the circuit breaker failure count.
|
|
12
|
+
* 4xx client errors are thrown as plain Error and do NOT trip the breaker.
|
|
13
|
+
*/
|
|
14
|
+
export declare class RetriableError extends Error {
|
|
15
|
+
constructor(message: string);
|
|
16
|
+
}
|
|
9
17
|
export declare class ChannelManager {
|
|
10
18
|
private channels;
|
|
11
19
|
private inboundHandler;
|
package/dist/channels/manager.js
CHANGED
|
@@ -5,6 +5,17 @@
|
|
|
5
5
|
* to every registered channel, swallowing per-channel errors so
|
|
6
6
|
* one broken channel never kills the bridge.
|
|
7
7
|
*/
|
|
8
|
+
/**
|
|
9
|
+
* Thrown for retriable failures (5xx server errors, network timeouts).
|
|
10
|
+
* Only these increment the circuit breaker failure count.
|
|
11
|
+
* 4xx client errors are thrown as plain Error and do NOT trip the breaker.
|
|
12
|
+
*/
|
|
13
|
+
export class RetriableError extends Error {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'RetriableError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
8
19
|
export class ChannelManager {
|
|
9
20
|
channels = [];
|
|
10
21
|
inboundHandler = null;
|
|
@@ -86,6 +97,10 @@ export class ChannelManager {
|
|
|
86
97
|
}
|
|
87
98
|
catch (e) {
|
|
88
99
|
console.error(`Channel ${ch.name} error on ${payload.event}:`, e);
|
|
100
|
+
// Only count retriable errors (5xx, network) toward circuit breaker.
|
|
101
|
+
// 4xx client errors are non-retriable — the server is healthy.
|
|
102
|
+
if (!(e instanceof RetriableError))
|
|
103
|
+
return;
|
|
89
104
|
const h = this.health.get(ch.name) ?? { failCount: 0, disabledUntil: 0 };
|
|
90
105
|
h.failCount++;
|
|
91
106
|
if (h.failCount >= ChannelManager.FAILURE_THRESHOLD) {
|
package/dist/channels/webhook.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { webhookEndpointSchema, getErrorMessage } from '../validation.js';
|
|
8
8
|
import { validateWebhookUrl } from '../ssrf.js';
|
|
9
9
|
import { redactSecretsFromText } from '../utils/redact-headers.js';
|
|
10
|
+
import { RetriableError } from './manager.js';
|
|
10
11
|
export class WebhookChannel {
|
|
11
12
|
name = 'webhook';
|
|
12
13
|
endpoints;
|
|
@@ -141,8 +142,13 @@ export class WebhookChannel {
|
|
|
141
142
|
console.error(`Webhook ${ep.url} failed after ${maxRetries} attempts for ${event}: ${lastError}`);
|
|
142
143
|
this.addToDeadLetterQueue(ep.url, event, lastError, maxRetries);
|
|
143
144
|
}
|
|
144
|
-
// Final failure
|
|
145
|
-
|
|
145
|
+
// Final failure — throw so fire() can aggregate.
|
|
146
|
+
// Use RetriableError for 5xx/network (circuit breaker counts these),
|
|
147
|
+
// plain Error for 4xx (circuit breaker ignores these).
|
|
148
|
+
if (lastError.startsWith('HTTP ') && parseInt(lastError.slice(5)) < 500) {
|
|
149
|
+
throw new Error(lastError);
|
|
150
|
+
}
|
|
151
|
+
throw new RetriableError(lastError);
|
|
146
152
|
}
|
|
147
153
|
}
|
|
148
154
|
/** Issue #89 L14: Add a failed delivery to the dead letter queue. */
|
package/dist/events.d.ts
CHANGED
|
@@ -76,6 +76,8 @@ export declare class SessionEventBus {
|
|
|
76
76
|
private globalEmitter;
|
|
77
77
|
/** #689: Pending setImmediate timers for cleanup on destroy. */
|
|
78
78
|
private pendingTimers;
|
|
79
|
+
/** #834: Pending setTimeout timers for cleanup on destroy/cleanupSession. */
|
|
80
|
+
private pendingTimeouts;
|
|
79
81
|
/** Subscribe to events from ALL sessions (new and existing). Returns unsubscribe function. */
|
|
80
82
|
subscribeGlobal(handler: (event: GlobalSSEEvent) => void): () => void;
|
|
81
83
|
/** Emit a session created event to global subscribers. */
|
package/dist/events.js
CHANGED
|
@@ -178,12 +178,15 @@ export class SessionEventBus {
|
|
|
178
178
|
// Clean up after a short delay (let clients receive the event)
|
|
179
179
|
// Capture reference — only delete if it's still the same emitter
|
|
180
180
|
// #357: Also delete the per-session event buffer to prevent unbounded map growth
|
|
181
|
-
|
|
181
|
+
// #834: Track the timer so cleanupSession/destroy can cancel it
|
|
182
|
+
const timeout = setTimeout(() => {
|
|
183
|
+
this.pendingTimeouts.delete(timeout);
|
|
182
184
|
if (this.emitters.get(sessionId) === emitter) {
|
|
183
185
|
this.emitters.delete(sessionId);
|
|
184
186
|
}
|
|
185
187
|
this.eventBuffers.delete(sessionId);
|
|
186
188
|
}, 1000);
|
|
189
|
+
this.pendingTimeouts.add(timeout);
|
|
187
190
|
}
|
|
188
191
|
/** Emit a stall event. */
|
|
189
192
|
emitStall(sessionId, stallType, detail) {
|
|
@@ -227,6 +230,8 @@ export class SessionEventBus {
|
|
|
227
230
|
globalEmitter = null;
|
|
228
231
|
/** #689: Pending setImmediate timers for cleanup on destroy. */
|
|
229
232
|
pendingTimers = new Set();
|
|
233
|
+
/** #834: Pending setTimeout timers for cleanup on destroy/cleanupSession. */
|
|
234
|
+
pendingTimeouts = new Set();
|
|
230
235
|
/** Subscribe to events from ALL sessions (new and existing). Returns unsubscribe function. */
|
|
231
236
|
subscribeGlobal(handler) {
|
|
232
237
|
if (!this.globalEmitter) {
|
|
@@ -267,6 +272,11 @@ export class SessionEventBus {
|
|
|
267
272
|
}
|
|
268
273
|
/** #398: Clean up per-session state (call when session is killed). */
|
|
269
274
|
cleanupSession(sessionId) {
|
|
275
|
+
// #834: Clear pending setTimeout for this session's emitEnded cleanup
|
|
276
|
+
for (const timeout of this.pendingTimeouts) {
|
|
277
|
+
clearTimeout(timeout);
|
|
278
|
+
this.pendingTimeouts.delete(timeout);
|
|
279
|
+
}
|
|
270
280
|
this.eventBuffers.delete(sessionId);
|
|
271
281
|
const emitter = this.emitters.get(sessionId);
|
|
272
282
|
if (emitter) {
|
|
@@ -281,6 +291,11 @@ export class SessionEventBus {
|
|
|
281
291
|
clearImmediate(imm);
|
|
282
292
|
}
|
|
283
293
|
this.pendingTimers.clear();
|
|
294
|
+
// #834: Clear pending setTimeout timers
|
|
295
|
+
for (const timeout of this.pendingTimeouts) {
|
|
296
|
+
clearTimeout(timeout);
|
|
297
|
+
}
|
|
298
|
+
this.pendingTimeouts.clear();
|
|
284
299
|
for (const emitter of this.emitters.values()) {
|
|
285
300
|
emitter.removeAllListeners();
|
|
286
301
|
}
|
package/dist/hook-settings.js
CHANGED
|
@@ -105,14 +105,14 @@ export async function writeHookSettingsFile(baseUrl, sessionId, workDir) {
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
|
-
// Deep-merge: project settings as base,
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
};
|
|
108
|
+
// Deep-merge: project settings as base, hooks merged by event key so both
|
|
109
|
+
// project-level and Aegis hooks coexist (Issue #635).
|
|
110
|
+
const existingHooks = merged.hooks ?? {};
|
|
111
|
+
const mergedHooks = { ...existingHooks };
|
|
112
|
+
for (const [event, entries] of Object.entries(hookSettings.hooks)) {
|
|
113
|
+
mergedHooks[event] = [...(existingHooks[event] ?? []), ...entries];
|
|
114
|
+
}
|
|
115
|
+
const combined = { ...merged, hooks: mergedHooks };
|
|
116
116
|
// Issue #648: Use unpredictable directory name and restrictive permissions
|
|
117
117
|
// to prevent symlink attacks and information disclosure in /tmp.
|
|
118
118
|
const suffix = randomBytes(4).toString('hex');
|
package/dist/hooks.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* Issue #169: Phase 1 — HTTP hooks infrastructure.
|
|
15
15
|
* Issue #169: Phase 3 — Hook-driven status detection.
|
|
16
16
|
*/
|
|
17
|
-
import { isValidUUID, hookBodySchema } from './validation.js';
|
|
17
|
+
import { isValidUUID, hookBodySchema, parseIntSafe } from './validation.js';
|
|
18
18
|
/** CC hook events that require a decision response. */
|
|
19
19
|
const DECISION_EVENTS = new Set(['PreToolUse', 'PermissionRequest']);
|
|
20
20
|
/** Permission modes that should be auto-approved via hook response. */
|
|
@@ -22,7 +22,7 @@ const AUTO_APPROVE_MODES = new Set(['bypassPermissions', 'dontAsk', 'acceptEdits
|
|
|
22
22
|
/** Default timeout for waiting on client permission decision (ms). */
|
|
23
23
|
const PERMISSION_TIMEOUT_MS = 10_000;
|
|
24
24
|
/** Default timeout for waiting on external answer to AskUserQuestion (ms). */
|
|
25
|
-
const ANSWER_TIMEOUT_MS =
|
|
25
|
+
const ANSWER_TIMEOUT_MS = parseIntSafe(process.env.ANSWER_TIMEOUT_MS, 30_000);
|
|
26
26
|
/** Valid permission_mode values accepted by Claude Code. */
|
|
27
27
|
const VALID_PERMISSION_MODES = new Set(['default', 'plan', 'bypassPermissions']);
|
|
28
28
|
/** Valid CC hook event names (allow any for extensibility, but these are known). */
|
|
@@ -186,9 +186,20 @@ export function registerHookRoutes(app, deps) {
|
|
|
186
186
|
// Emit SSE status event only when the hook implies a state change
|
|
187
187
|
if (newStatus && prevStatus !== newStatus) {
|
|
188
188
|
switch (eventName) {
|
|
189
|
-
case 'Stop':
|
|
190
|
-
|
|
189
|
+
case 'Stop': {
|
|
190
|
+
// Issue #812: Check if CC is waiting for user input (text-only last assistant message)
|
|
191
|
+
const waiting = await deps.sessions.detectWaitingForInput(sessionId);
|
|
192
|
+
if (waiting) {
|
|
193
|
+
const session = deps.sessions.getSession(sessionId);
|
|
194
|
+
if (session)
|
|
195
|
+
session.status = 'waiting_for_input';
|
|
196
|
+
deps.eventBus.emitStatus(sessionId, 'waiting_for_input', 'Claude finished, waiting for input (hook: Stop)');
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
deps.eventBus.emitStatus(sessionId, 'idle', 'Claude finished (hook: Stop)');
|
|
200
|
+
}
|
|
191
201
|
break;
|
|
202
|
+
}
|
|
192
203
|
case 'PreToolUse':
|
|
193
204
|
case 'PostToolUse':
|
|
194
205
|
deps.eventBus.emitStatus(sessionId, 'working', 'Claude is working (hook: tool use)');
|
package/dist/screenshot.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface ScreenshotOptions {
|
|
|
9
9
|
fullPage?: boolean;
|
|
10
10
|
width?: number;
|
|
11
11
|
height?: number;
|
|
12
|
+
/** Chromium --host-resolver-rules value to pin DNS (prevents TOCTOU rebinding). */
|
|
13
|
+
hostResolverRule?: string;
|
|
12
14
|
}
|
|
13
15
|
export interface ScreenshotResult {
|
|
14
16
|
screenshot: string;
|
package/dist/screenshot.js
CHANGED
|
@@ -23,7 +23,11 @@ export async function captureScreenshot(opts) {
|
|
|
23
23
|
if (!playwrightAvailable || !chromium) {
|
|
24
24
|
throw new Error('Playwright is not installed. Install it with: npx playwright install chromium && npm install -D playwright');
|
|
25
25
|
}
|
|
26
|
-
const
|
|
26
|
+
const launchOptions = { headless: true };
|
|
27
|
+
if (opts.hostResolverRule) {
|
|
28
|
+
launchOptions.args = [`--host-resolver-rules=${opts.hostResolverRule}`];
|
|
29
|
+
}
|
|
30
|
+
const browser = await chromium.launch(launchOptions);
|
|
27
31
|
try {
|
|
28
32
|
const context = await browser.newContext({
|
|
29
33
|
viewport: {
|
package/dist/server.d.ts
CHANGED
|
@@ -7,4 +7,9 @@
|
|
|
7
7
|
* Notification channels (Telegram, webhooks, etc.) are pluggable —
|
|
8
8
|
* the server doesn't know which channels are active.
|
|
9
9
|
*/
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Read the parent PID from /proc/<pid>/status.
|
|
12
|
+
* Uses the PPid line instead of parsing /proc/<pid>/stat,
|
|
13
|
+
* which breaks when the comm field (process name) contains spaces.
|
|
14
|
+
*/
|
|
15
|
+
export declare function readPpid(pid: number): number;
|
package/dist/server.js
CHANGED
|
@@ -23,7 +23,7 @@ import { JsonlWatcher } from './jsonl-watcher.js';
|
|
|
23
23
|
import { ChannelManager, TelegramChannel, WebhookChannel, } from './channels/index.js';
|
|
24
24
|
import { loadConfig } from './config.js';
|
|
25
25
|
import { captureScreenshot, isPlaywrightAvailable } from './screenshot.js';
|
|
26
|
-
import { validateScreenshotUrl, resolveAndCheckIp } from './ssrf.js';
|
|
26
|
+
import { validateScreenshotUrl, resolveAndCheckIp, buildHostResolverRule } from './ssrf.js';
|
|
27
27
|
import { validateWorkDir } from './validation.js';
|
|
28
28
|
import { SessionEventBus } from './events.js';
|
|
29
29
|
import { SSEWriter } from './sse-writer.js';
|
|
@@ -461,7 +461,7 @@ async function createSessionHandler(req, reply) {
|
|
|
461
461
|
if (typeof safeWorkDir === 'object')
|
|
462
462
|
return reply.status(400).send({ error: safeWorkDir.error, code: safeWorkDir.code });
|
|
463
463
|
// Issue #607: Check for an existing idle session with the same workDir
|
|
464
|
-
const existing = sessions.findIdleSessionByWorkDir(safeWorkDir);
|
|
464
|
+
const existing = await sessions.findIdleSessionByWorkDir(safeWorkDir);
|
|
465
465
|
if (existing) {
|
|
466
466
|
// Send prompt to the existing session if provided
|
|
467
467
|
let promptDelivery;
|
|
@@ -746,10 +746,13 @@ async function screenshotHandler(req, reply) {
|
|
|
746
746
|
const urlError = validateScreenshotUrl(url);
|
|
747
747
|
if (urlError)
|
|
748
748
|
return reply.status(400).send({ error: urlError });
|
|
749
|
-
//
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
749
|
+
// DNS-resolution check: resolve hostname and reject private IPs.
|
|
750
|
+
// Returns the resolved IP so we can pin it via --host-resolver-rules to prevent
|
|
751
|
+
// DNS rebinding (TOCTOU) between validation and page.goto().
|
|
752
|
+
const hostname = new URL(url).hostname;
|
|
753
|
+
const dnsResult = await resolveAndCheckIp(hostname);
|
|
754
|
+
if (dnsResult.error)
|
|
755
|
+
return reply.status(400).send({ error: dnsResult.error });
|
|
753
756
|
// Validate session exists
|
|
754
757
|
const session = sessions.getSession(req.params.id);
|
|
755
758
|
if (!session)
|
|
@@ -761,7 +764,11 @@ async function screenshotHandler(req, reply) {
|
|
|
761
764
|
});
|
|
762
765
|
}
|
|
763
766
|
try {
|
|
764
|
-
|
|
767
|
+
// Pin the validated IP via host-resolver-rules to prevent DNS rebinding
|
|
768
|
+
const hostResolverRule = dnsResult.resolvedIp
|
|
769
|
+
? buildHostResolverRule(hostname, dnsResult.resolvedIp)
|
|
770
|
+
: undefined;
|
|
771
|
+
const result = await captureScreenshot({ url, fullPage, width, height, hostResolverRule });
|
|
765
772
|
return reply.status(200).send(result);
|
|
766
773
|
}
|
|
767
774
|
catch (e) {
|
|
@@ -1123,6 +1130,18 @@ function pidExists(pid) {
|
|
|
1123
1130
|
return false;
|
|
1124
1131
|
}
|
|
1125
1132
|
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Read the parent PID from /proc/<pid>/status.
|
|
1135
|
+
* Uses the PPid line instead of parsing /proc/<pid>/stat,
|
|
1136
|
+
* which breaks when the comm field (process name) contains spaces.
|
|
1137
|
+
*/
|
|
1138
|
+
export function readPpid(pid) {
|
|
1139
|
+
const status = readFileSync(`/proc/${pid}/status`, 'utf-8');
|
|
1140
|
+
const match = status.match(/^PPid:\s+(\d+)/m);
|
|
1141
|
+
if (!match)
|
|
1142
|
+
throw new Error(`no PPid line in /proc/${pid}/status`);
|
|
1143
|
+
return parseInt(match[1], 10);
|
|
1144
|
+
}
|
|
1126
1145
|
/**
|
|
1127
1146
|
* Check if a PID is an ancestor of the current process.
|
|
1128
1147
|
*/
|
|
@@ -1133,7 +1152,7 @@ function isAncestorPid(pid) {
|
|
|
1133
1152
|
if (current === pid)
|
|
1134
1153
|
return true;
|
|
1135
1154
|
try {
|
|
1136
|
-
current =
|
|
1155
|
+
current = readPpid(current);
|
|
1137
1156
|
}
|
|
1138
1157
|
catch { /* /proc unavailable or process gone — stop walking */
|
|
1139
1158
|
break;
|
package/dist/session.d.ts
CHANGED
|
@@ -56,6 +56,8 @@ export declare class SessionManager {
|
|
|
56
56
|
private stateFile;
|
|
57
57
|
private sessionMapFile;
|
|
58
58
|
private pollTimers;
|
|
59
|
+
/** #835: Discovery timeout timers — cleared in cleanupSession to prevent orphan callbacks. */
|
|
60
|
+
private discoveryTimeouts;
|
|
59
61
|
private saveQueue;
|
|
60
62
|
private saveDebounceTimer;
|
|
61
63
|
private static readonly SAVE_DEBOUNCE_MS;
|
|
@@ -132,6 +134,9 @@ export declare class SessionManager {
|
|
|
132
134
|
* Returns the previous status for change detection.
|
|
133
135
|
* Issue #87: Also records hook latency timestamps. */
|
|
134
136
|
updateStatusFromHook(id: string, hookEvent: string, hookTimestamp?: number): UIState | null;
|
|
137
|
+
/** Issue #812: Detect if CC is waiting for user input by analyzing the JSONL transcript.
|
|
138
|
+
* Returns true if the last assistant message has text content only (no tool_use). */
|
|
139
|
+
detectWaitingForInput(id: string): Promise<boolean>;
|
|
135
140
|
/** Issue #88: Add an active subagent to a session. */
|
|
136
141
|
addSubagent(id: string, name: string): void;
|
|
137
142
|
/** Issue #88: Remove an active subagent from a session. */
|
|
@@ -156,8 +161,9 @@ export declare class SessionManager {
|
|
|
156
161
|
listSessions(): SessionInfo[];
|
|
157
162
|
/** Issue #607: Find an idle session for the given workDir.
|
|
158
163
|
* Returns the most recently active idle session, or null if none found.
|
|
159
|
-
* Used to resume existing sessions instead of creating duplicates.
|
|
160
|
-
|
|
164
|
+
* Used to resume existing sessions instead of creating duplicates.
|
|
165
|
+
* Issue #636: Verifies tmux window is still alive before returning. */
|
|
166
|
+
findIdleSessionByWorkDir(workDir: string): Promise<SessionInfo | null>;
|
|
161
167
|
/** Get health info for a session.
|
|
162
168
|
* Issue #2: Returns comprehensive health status for orchestrators.
|
|
163
169
|
*/
|
package/dist/session.js
CHANGED
|
@@ -50,6 +50,8 @@ export class SessionManager {
|
|
|
50
50
|
stateFile;
|
|
51
51
|
sessionMapFile;
|
|
52
52
|
pollTimers = new Map();
|
|
53
|
+
/** #835: Discovery timeout timers — cleared in cleanupSession to prevent orphan callbacks. */
|
|
54
|
+
discoveryTimeouts = new Map();
|
|
53
55
|
saveQueue = Promise.resolve(); // #218: serialize concurrent saves
|
|
54
56
|
saveDebounceTimer = null;
|
|
55
57
|
static SAVE_DEBOUNCE_MS = 5_000; // #357: debounce offset-only saves
|
|
@@ -578,6 +580,34 @@ export class SessionManager {
|
|
|
578
580
|
}
|
|
579
581
|
return prevStatus;
|
|
580
582
|
}
|
|
583
|
+
/** Issue #812: Detect if CC is waiting for user input by analyzing the JSONL transcript.
|
|
584
|
+
* Returns true if the last assistant message has text content only (no tool_use). */
|
|
585
|
+
async detectWaitingForInput(id) {
|
|
586
|
+
const session = this.state.sessions[id];
|
|
587
|
+
if (!session?.jsonlPath)
|
|
588
|
+
return false;
|
|
589
|
+
try {
|
|
590
|
+
const { raw } = await readNewEntries(session.jsonlPath, 0);
|
|
591
|
+
// Walk backwards to find the last assistant JSONL entry
|
|
592
|
+
for (let i = raw.length - 1; i >= 0; i--) {
|
|
593
|
+
const entry = raw[i];
|
|
594
|
+
if (entry.type !== 'assistant' || !entry.message)
|
|
595
|
+
continue;
|
|
596
|
+
const content = entry.message.content;
|
|
597
|
+
if (typeof content === 'string')
|
|
598
|
+
return true; // text-only message
|
|
599
|
+
if (!Array.isArray(content))
|
|
600
|
+
return false;
|
|
601
|
+
// Check if any content block is a tool_use
|
|
602
|
+
const hasToolUse = content.some((block) => block.type === 'tool_use');
|
|
603
|
+
return !hasToolUse;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
// If we can't read the transcript, don't override status
|
|
608
|
+
}
|
|
609
|
+
return false;
|
|
610
|
+
}
|
|
581
611
|
/** Issue #88: Add an active subagent to a session. */
|
|
582
612
|
addSubagent(id, name) {
|
|
583
613
|
const session = this.state.sessions[id];
|
|
@@ -662,14 +692,21 @@ export class SessionManager {
|
|
|
662
692
|
}
|
|
663
693
|
/** Issue #607: Find an idle session for the given workDir.
|
|
664
694
|
* Returns the most recently active idle session, or null if none found.
|
|
665
|
-
* Used to resume existing sessions instead of creating duplicates.
|
|
666
|
-
|
|
695
|
+
* Used to resume existing sessions instead of creating duplicates.
|
|
696
|
+
* Issue #636: Verifies tmux window is still alive before returning. */
|
|
697
|
+
async findIdleSessionByWorkDir(workDir) {
|
|
667
698
|
const candidates = Object.values(this.state.sessions).filter((s) => s.workDir === workDir && s.status === 'idle');
|
|
668
699
|
if (candidates.length === 0)
|
|
669
700
|
return null;
|
|
670
701
|
// Return the most recently active session
|
|
671
702
|
candidates.sort((a, b) => b.lastActivity - a.lastActivity);
|
|
672
|
-
|
|
703
|
+
// Issue #636: verify tmux window exists before returning
|
|
704
|
+
for (const candidate of candidates) {
|
|
705
|
+
if (await this.tmux.windowExists(candidate.windowId)) {
|
|
706
|
+
return candidate;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
return null;
|
|
673
710
|
}
|
|
674
711
|
/** Get health info for a session.
|
|
675
712
|
* Issue #2: Returns comprehensive health status for orchestrators.
|
|
@@ -1115,6 +1152,14 @@ export class SessionManager {
|
|
|
1115
1152
|
this.pollTimers.delete(key);
|
|
1116
1153
|
}
|
|
1117
1154
|
}
|
|
1155
|
+
// #835: Clear discovery timeout timers to prevent orphan callbacks
|
|
1156
|
+
for (const key of [id, `fs-${id}`]) {
|
|
1157
|
+
const timeout = this.discoveryTimeouts.get(key);
|
|
1158
|
+
if (timeout) {
|
|
1159
|
+
clearTimeout(timeout);
|
|
1160
|
+
this.discoveryTimeouts.delete(key);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1118
1163
|
this.cleanupPendingPermission(id);
|
|
1119
1164
|
this.cleanupPendingQuestion(id);
|
|
1120
1165
|
this.parsedEntriesCache.delete(id);
|
|
@@ -1254,7 +1299,9 @@ export class SessionManager {
|
|
|
1254
1299
|
}, 2000);
|
|
1255
1300
|
this.pollTimers.set(id, interval);
|
|
1256
1301
|
// P3 fix: Stop after 5 minutes if not found, log timeout
|
|
1257
|
-
|
|
1302
|
+
// #835: Track the timeout so cleanupSession can cancel it
|
|
1303
|
+
const discoveryTimeout = setTimeout(() => {
|
|
1304
|
+
this.discoveryTimeouts.delete(id);
|
|
1258
1305
|
const timer = this.pollTimers.get(id);
|
|
1259
1306
|
const session = this.state.sessions[id];
|
|
1260
1307
|
if (timer) {
|
|
@@ -1266,6 +1313,7 @@ export class SessionManager {
|
|
|
1266
1313
|
}
|
|
1267
1314
|
}
|
|
1268
1315
|
}, 5 * 60 * 1000);
|
|
1316
|
+
this.discoveryTimeouts.set(id, discoveryTimeout);
|
|
1269
1317
|
}
|
|
1270
1318
|
/** Issue #16: Filesystem-based discovery for --bare mode (no hooks).
|
|
1271
1319
|
* Scans the Claude projects directory for new .jsonl files created after the session.
|
|
@@ -1313,13 +1361,16 @@ export class SessionManager {
|
|
|
1313
1361
|
}, 3000);
|
|
1314
1362
|
this.pollTimers.set(`fs-${id}`, interval);
|
|
1315
1363
|
// Timeout after 5 minutes
|
|
1316
|
-
|
|
1364
|
+
// #835: Track the timeout so cleanupSession can cancel it
|
|
1365
|
+
const fsDiscoveryTimeout = setTimeout(() => {
|
|
1366
|
+
this.discoveryTimeouts.delete(`fs-${id}`);
|
|
1317
1367
|
const timer = this.pollTimers.get(`fs-${id}`);
|
|
1318
1368
|
if (timer) {
|
|
1319
1369
|
clearInterval(timer);
|
|
1320
1370
|
this.pollTimers.delete(`fs-${id}`);
|
|
1321
1371
|
}
|
|
1322
1372
|
}, 5 * 60 * 1000);
|
|
1373
|
+
this.discoveryTimeouts.set(`fs-${id}`, fsDiscoveryTimeout);
|
|
1323
1374
|
}
|
|
1324
1375
|
/** Sync CC session IDs from the hook-written session_map.json. */
|
|
1325
1376
|
async syncSessionMap() {
|
package/dist/ssrf.d.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* - Current network: 0.0.0.0/8
|
|
9
9
|
* - Unspecified: ::
|
|
10
10
|
* - IPv6 unique-local: fc00::/7
|
|
11
|
+
* - IPv4-mapped IPv6: ::ffff:x.x.x.x (RFC 4291)
|
|
12
|
+
* - IPv4-compatible IPv6: ::x.x.x.x (deprecated)
|
|
11
13
|
* - CGNAT: 100.64.0.0/10 (RFC 6598)
|
|
12
14
|
* - Broadcast: 255.255.255.255
|
|
13
15
|
* - Multicast: 224.0.0.0/4 (RFC 5771)
|
|
@@ -35,16 +37,38 @@ export interface DnsLookupResult {
|
|
|
35
37
|
}
|
|
36
38
|
/** DNS lookup function type for dependency injection. */
|
|
37
39
|
export type DnsLookupFn = (hostname: string) => Promise<DnsLookupResult>;
|
|
40
|
+
/**
|
|
41
|
+
* Result of DNS resolution with SSRF check.
|
|
42
|
+
* On success, includes the resolved IP address for TOCTOU-safe pinning.
|
|
43
|
+
*/
|
|
44
|
+
export interface DnsCheckResult {
|
|
45
|
+
error: string | null;
|
|
46
|
+
resolvedIp: string | null;
|
|
47
|
+
}
|
|
38
48
|
/**
|
|
39
49
|
* Resolve a hostname via DNS and check if the resulting IP is private/internal.
|
|
40
50
|
*
|
|
41
51
|
* For literal IP addresses, checks directly without DNS resolution.
|
|
42
|
-
* Returns
|
|
52
|
+
* Returns a DnsCheckResult with error string if unsafe, or the resolved IP on success.
|
|
53
|
+
*
|
|
54
|
+
* The resolved IP should be used with Chromium --host-resolver-rules to pin the
|
|
55
|
+
* address and prevent DNS rebinding (TOCTOU) attacks between validation and page.goto().
|
|
43
56
|
*
|
|
44
57
|
* @param hostname - Hostname or literal IP to check
|
|
45
58
|
* @param lookupFn - Optional DNS lookup function (for testing)
|
|
46
59
|
*/
|
|
47
|
-
export declare function resolveAndCheckIp(hostname: string, lookupFn?: DnsLookupFn): Promise<
|
|
60
|
+
export declare function resolveAndCheckIp(hostname: string, lookupFn?: DnsLookupFn): Promise<DnsCheckResult>;
|
|
61
|
+
/**
|
|
62
|
+
* Build Chromium --host-resolver-rules argument to pin a hostname to a specific IP.
|
|
63
|
+
*
|
|
64
|
+
* This prevents DNS rebinding (TOCTOU) attacks between SSRF validation and page.goto()
|
|
65
|
+
* by ensuring Chromium resolves the hostname to the same IP that was validated.
|
|
66
|
+
*
|
|
67
|
+
* @param hostname - The original hostname from the URL
|
|
68
|
+
* @param resolvedIp - The IP address that was validated as safe
|
|
69
|
+
* @returns The --host-resolver-rules argument string
|
|
70
|
+
*/
|
|
71
|
+
export declare function buildHostResolverRule(hostname: string, resolvedIp: string): string;
|
|
48
72
|
/**
|
|
49
73
|
* Validate a URL for the screenshot endpoint to prevent SSRF attacks.
|
|
50
74
|
*
|
package/dist/ssrf.js
CHANGED
|
@@ -16,6 +16,8 @@ import net from 'node:net';
|
|
|
16
16
|
* - Current network: 0.0.0.0/8
|
|
17
17
|
* - Unspecified: ::
|
|
18
18
|
* - IPv6 unique-local: fc00::/7
|
|
19
|
+
* - IPv4-mapped IPv6: ::ffff:x.x.x.x (RFC 4291)
|
|
20
|
+
* - IPv4-compatible IPv6: ::x.x.x.x (deprecated)
|
|
19
21
|
* - CGNAT: 100.64.0.0/10 (RFC 6598)
|
|
20
22
|
* - Broadcast: 255.255.255.255
|
|
21
23
|
* - Multicast: 224.0.0.0/4 (RFC 5771)
|
|
@@ -70,6 +72,29 @@ export function isPrivateIP(ip) {
|
|
|
70
72
|
}
|
|
71
73
|
// IPv6
|
|
72
74
|
const lower = ip.toLowerCase();
|
|
75
|
+
// IPv4-mapped IPv6 (::ffff:x.x.x.x, RFC 4291 §2.5.5)
|
|
76
|
+
// Handles dotted-quad form (::ffff:127.0.0.1) and hex form (::ffff:7f00:1).
|
|
77
|
+
// Also handles IPv4-compatible IPv6 (::x.x.x.x, deprecated).
|
|
78
|
+
if (lower.startsWith('::ffff:')) {
|
|
79
|
+
const suffix = lower.slice(7);
|
|
80
|
+
// Dotted quad form: ::ffff:127.0.0.1
|
|
81
|
+
if (net.isIPv4(suffix)) {
|
|
82
|
+
return isPrivateIP(suffix);
|
|
83
|
+
}
|
|
84
|
+
// Hex form: ::ffff:7f00:1 → parse last 32 bits as IPv4
|
|
85
|
+
const hexGroups = suffix.split(':').map(h => parseInt(h, 16));
|
|
86
|
+
if (hexGroups.length === 2 && hexGroups.every(n => !isNaN(n))) {
|
|
87
|
+
const embedded = `${(hexGroups[0] >> 8) & 0xff}.${hexGroups[0] & 0xff}.${(hexGroups[1] >> 8) & 0xff}.${hexGroups[1] & 0xff}`;
|
|
88
|
+
if (net.isIPv4(embedded)) {
|
|
89
|
+
return isPrivateIP(embedded);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// IPv4-compatible IPv6 (::x.x.x.x, deprecated RFC 4291 §2.5.5)
|
|
94
|
+
const afterPrefix = lower.startsWith('::') && lower !== '::' && lower !== '::1' ? lower.slice(2) : null;
|
|
95
|
+
if (afterPrefix !== null && net.isIPv4(afterPrefix)) {
|
|
96
|
+
return isPrivateIP(afterPrefix);
|
|
97
|
+
}
|
|
73
98
|
// ::1 (loopback)
|
|
74
99
|
if (lower === '::1')
|
|
75
100
|
return true;
|
|
@@ -105,8 +130,10 @@ export function validateWebhookUrl(rawUrl) {
|
|
|
105
130
|
return 'Invalid URL';
|
|
106
131
|
}
|
|
107
132
|
const hostname = parsed.hostname;
|
|
133
|
+
// Strip brackets from IPv6 URLs: [::1] → ::1
|
|
134
|
+
const bareHost = hostname.replace(/^\[|\]$/g, '');
|
|
108
135
|
// Scheme check — must be HTTPS, or HTTP only for local dev
|
|
109
|
-
const isLocalDev =
|
|
136
|
+
const isLocalDev = bareHost === '127.0.0.1' || bareHost === '::1' || bareHost === 'localhost';
|
|
110
137
|
if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && isLocalDev)) {
|
|
111
138
|
if (parsed.protocol === 'http:') {
|
|
112
139
|
return 'Only HTTPS URLs are allowed for external hosts';
|
|
@@ -114,11 +141,11 @@ export function validateWebhookUrl(rawUrl) {
|
|
|
114
141
|
return 'Only HTTPS URLs are allowed';
|
|
115
142
|
}
|
|
116
143
|
// Reject *.local hostnames (but allow literal localhost for dev)
|
|
117
|
-
if (
|
|
144
|
+
if (bareHost.endsWith('.local')) {
|
|
118
145
|
return 'Localhost URLs are not allowed';
|
|
119
146
|
}
|
|
120
147
|
// Reject private/internal IPs (except 127.0.0.1/::1 which are allowed for dev over HTTP)
|
|
121
|
-
if (net.isIP(
|
|
148
|
+
if (net.isIP(bareHost) && isPrivateIP(bareHost) && !isLocalDev) {
|
|
122
149
|
return 'Private/internal IP addresses are not allowed';
|
|
123
150
|
}
|
|
124
151
|
return null;
|
|
@@ -129,7 +156,10 @@ const defaultLookup = (hostname) => dns.lookup(hostname);
|
|
|
129
156
|
* Resolve a hostname via DNS and check if the resulting IP is private/internal.
|
|
130
157
|
*
|
|
131
158
|
* For literal IP addresses, checks directly without DNS resolution.
|
|
132
|
-
* Returns
|
|
159
|
+
* Returns a DnsCheckResult with error string if unsafe, or the resolved IP on success.
|
|
160
|
+
*
|
|
161
|
+
* The resolved IP should be used with Chromium --host-resolver-rules to pin the
|
|
162
|
+
* address and prevent DNS rebinding (TOCTOU) attacks between validation and page.goto().
|
|
133
163
|
*
|
|
134
164
|
* @param hostname - Hostname or literal IP to check
|
|
135
165
|
* @param lookupFn - Optional DNS lookup function (for testing)
|
|
@@ -138,21 +168,34 @@ export async function resolveAndCheckIp(hostname, lookupFn = defaultLookup) {
|
|
|
138
168
|
// Literal IP — check directly
|
|
139
169
|
if (net.isIP(hostname)) {
|
|
140
170
|
if (isPrivateIP(hostname)) {
|
|
141
|
-
return `DNS resolution points to a private/internal IP: ${hostname}
|
|
171
|
+
return { error: `DNS resolution points to a private/internal IP: ${hostname}`, resolvedIp: null };
|
|
142
172
|
}
|
|
143
|
-
return null;
|
|
173
|
+
return { error: null, resolvedIp: hostname };
|
|
144
174
|
}
|
|
145
175
|
try {
|
|
146
176
|
const result = await lookupFn(hostname);
|
|
147
177
|
if (isPrivateIP(result.address)) {
|
|
148
|
-
return `DNS resolution points to a private/internal IP: ${result.address}
|
|
178
|
+
return { error: `DNS resolution points to a private/internal IP: ${result.address}`, resolvedIp: null };
|
|
149
179
|
}
|
|
150
|
-
return null;
|
|
180
|
+
return { error: null, resolvedIp: result.address };
|
|
151
181
|
}
|
|
152
182
|
catch { /* DNS lookup failed — treat as unsafe */
|
|
153
|
-
return `DNS resolution failed for ${hostname}
|
|
183
|
+
return { error: `DNS resolution failed for ${hostname}`, resolvedIp: null };
|
|
154
184
|
}
|
|
155
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* Build Chromium --host-resolver-rules argument to pin a hostname to a specific IP.
|
|
188
|
+
*
|
|
189
|
+
* This prevents DNS rebinding (TOCTOU) attacks between SSRF validation and page.goto()
|
|
190
|
+
* by ensuring Chromium resolves the hostname to the same IP that was validated.
|
|
191
|
+
*
|
|
192
|
+
* @param hostname - The original hostname from the URL
|
|
193
|
+
* @param resolvedIp - The IP address that was validated as safe
|
|
194
|
+
* @returns The --host-resolver-rules argument string
|
|
195
|
+
*/
|
|
196
|
+
export function buildHostResolverRule(hostname, resolvedIp) {
|
|
197
|
+
return `MAP ${hostname} ${resolvedIp}`;
|
|
198
|
+
}
|
|
156
199
|
/**
|
|
157
200
|
* Validate a URL for the screenshot endpoint to prevent SSRF attacks.
|
|
158
201
|
*
|
|
@@ -178,12 +221,14 @@ export function validateScreenshotUrl(rawUrl) {
|
|
|
178
221
|
return 'Only http and https URLs are allowed';
|
|
179
222
|
}
|
|
180
223
|
const hostname = parsed.hostname;
|
|
224
|
+
// Strip brackets from IPv6 URLs: [::1] → ::1
|
|
225
|
+
const bareHost = hostname.replace(/^\[|\]$/g, '');
|
|
181
226
|
// Reject localhost / *.local hostnames
|
|
182
|
-
if (
|
|
227
|
+
if (bareHost === 'localhost' || bareHost.endsWith('.local')) {
|
|
183
228
|
return 'Localhost URLs are not allowed';
|
|
184
229
|
}
|
|
185
230
|
// Reject private/internal IPs
|
|
186
|
-
if (net.isIP(
|
|
231
|
+
if (net.isIP(bareHost) && isPrivateIP(bareHost)) {
|
|
187
232
|
return 'Private/internal IP addresses are not allowed';
|
|
188
233
|
}
|
|
189
234
|
return null;
|