aegis-bridge 2.2.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/LICENSE +21 -0
- package/README.md +244 -0
- package/dashboard/dist/assets/index-CijFoeRu.css +32 -0
- package/dashboard/dist/assets/index-QtT4j0ht.js +262 -0
- package/dashboard/dist/index.html +14 -0
- package/dist/auth.d.ts +76 -0
- package/dist/auth.js +219 -0
- package/dist/channels/index.d.ts +8 -0
- package/dist/channels/index.js +9 -0
- package/dist/channels/manager.d.ts +39 -0
- package/dist/channels/manager.js +101 -0
- package/dist/channels/telegram-style.d.ts +118 -0
- package/dist/channels/telegram-style.js +203 -0
- package/dist/channels/telegram.d.ts +76 -0
- package/dist/channels/telegram.js +1396 -0
- package/dist/channels/types.d.ts +77 -0
- package/dist/channels/types.js +9 -0
- package/dist/channels/webhook.d.ts +58 -0
- package/dist/channels/webhook.js +162 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +223 -0
- package/dist/config.d.ts +60 -0
- package/dist/config.js +188 -0
- package/dist/dashboard/assets/index-CijFoeRu.css +32 -0
- package/dist/dashboard/assets/index-QtT4j0ht.js +262 -0
- package/dist/dashboard/index.html +14 -0
- package/dist/events.d.ts +86 -0
- package/dist/events.js +258 -0
- package/dist/hook-settings.d.ts +67 -0
- package/dist/hook-settings.js +138 -0
- package/dist/hook.d.ts +18 -0
- package/dist/hook.js +199 -0
- package/dist/hooks.d.ts +32 -0
- package/dist/hooks.js +279 -0
- package/dist/jsonl-watcher.d.ts +57 -0
- package/dist/jsonl-watcher.js +159 -0
- package/dist/mcp-server.d.ts +60 -0
- package/dist/mcp-server.js +788 -0
- package/dist/metrics.d.ts +104 -0
- package/dist/metrics.js +226 -0
- package/dist/monitor.d.ts +84 -0
- package/dist/monitor.js +553 -0
- package/dist/permission-guard.d.ts +51 -0
- package/dist/permission-guard.js +197 -0
- package/dist/pipeline.d.ts +84 -0
- package/dist/pipeline.js +218 -0
- package/dist/screenshot.d.ts +26 -0
- package/dist/screenshot.js +57 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +1577 -0
- package/dist/session.d.ts +297 -0
- package/dist/session.js +1275 -0
- package/dist/sse-limiter.d.ts +47 -0
- package/dist/sse-limiter.js +62 -0
- package/dist/sse-writer.d.ts +31 -0
- package/dist/sse-writer.js +95 -0
- package/dist/ssrf.d.ts +57 -0
- package/dist/ssrf.js +169 -0
- package/dist/swarm-monitor.d.ts +114 -0
- package/dist/swarm-monitor.js +267 -0
- package/dist/terminal-parser.d.ts +16 -0
- package/dist/terminal-parser.js +343 -0
- package/dist/tmux.d.ts +161 -0
- package/dist/tmux.js +725 -0
- package/dist/transcript.d.ts +47 -0
- package/dist/transcript.js +244 -0
- package/dist/validation.d.ts +222 -0
- package/dist/validation.js +268 -0
- package/dist/ws-terminal.d.ts +32 -0
- package/dist/ws-terminal.js +297 -0
- package/package.json +71 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* validation.ts — Zod schemas for API request body validation.
|
|
3
|
+
*
|
|
4
|
+
* Issue #359: Centralized validation for all POST route bodies.
|
|
5
|
+
* Issue #435: Path traversal defense in validateWorkDir.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
/** Regex for UUID v4 format: 8-4-4-4-12 hex digits */
|
|
12
|
+
export const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
13
|
+
/** POST /v1/auth/keys */
|
|
14
|
+
export const authKeySchema = z.object({
|
|
15
|
+
name: z.string().min(1),
|
|
16
|
+
rateLimit: z.number().int().positive().optional(),
|
|
17
|
+
}).strict();
|
|
18
|
+
/** POST /v1/sessions/:id/send */
|
|
19
|
+
export const sendMessageSchema = z.object({
|
|
20
|
+
text: z.string().min(1),
|
|
21
|
+
}).strict();
|
|
22
|
+
/** POST /v1/sessions/:id/command */
|
|
23
|
+
export const commandSchema = z.object({
|
|
24
|
+
command: z.string().min(1),
|
|
25
|
+
}).strict();
|
|
26
|
+
/** POST /v1/sessions/:id/bash */
|
|
27
|
+
export const bashSchema = z.object({
|
|
28
|
+
command: z.string().min(1),
|
|
29
|
+
}).strict();
|
|
30
|
+
/** POST /v1/sessions/:id/screenshot */
|
|
31
|
+
export const screenshotSchema = z.object({
|
|
32
|
+
url: z.string().min(1),
|
|
33
|
+
fullPage: z.boolean().optional(),
|
|
34
|
+
width: z.number().int().positive().max(7680).optional(),
|
|
35
|
+
height: z.number().int().positive().max(4320).optional(),
|
|
36
|
+
}).strict();
|
|
37
|
+
/** Webhook endpoint — validates structure of each webhook entry */
|
|
38
|
+
export const webhookEndpointSchema = z.object({
|
|
39
|
+
url: z.string().min(1),
|
|
40
|
+
events: z.array(z.string()).optional(),
|
|
41
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
42
|
+
timeoutMs: z.number().int().positive().optional(),
|
|
43
|
+
}).strict();
|
|
44
|
+
/** POST /v1/sessions/:id/hooks/permission */
|
|
45
|
+
export const permissionHookSchema = z.object({
|
|
46
|
+
session_id: z.string().optional(),
|
|
47
|
+
tool_name: z.string().optional(),
|
|
48
|
+
tool_input: z.unknown().optional(),
|
|
49
|
+
permission_mode: z.string().optional(),
|
|
50
|
+
hook_event_name: z.string().optional(),
|
|
51
|
+
}).strict();
|
|
52
|
+
/** POST /v1/sessions/:id/hooks/stop */
|
|
53
|
+
export const stopHookSchema = z.object({
|
|
54
|
+
session_id: z.string().optional(),
|
|
55
|
+
stop_reason: z.string().optional(),
|
|
56
|
+
hook_event_name: z.string().optional(),
|
|
57
|
+
}).strict();
|
|
58
|
+
const batchSessionSpecSchema = z.object({
|
|
59
|
+
name: z.string().max(200).optional(),
|
|
60
|
+
workDir: z.string().min(1),
|
|
61
|
+
prompt: z.string().max(100_000).optional(),
|
|
62
|
+
permissionMode: z.enum(['default', 'bypassPermissions', 'plan']).optional(),
|
|
63
|
+
autoApprove: z.boolean().optional(),
|
|
64
|
+
stallThresholdMs: z.number().int().positive().max(3_600_000).optional(),
|
|
65
|
+
});
|
|
66
|
+
/** POST /v1/sessions/batch — max 50 sessions per batch */
|
|
67
|
+
export const batchSessionSchema = z.object({
|
|
68
|
+
sessions: z.array(batchSessionSpecSchema).min(1).max(50),
|
|
69
|
+
}).strict();
|
|
70
|
+
const pipelineStageSchema = z.object({
|
|
71
|
+
name: z.string().min(1),
|
|
72
|
+
workDir: z.string().min(1).optional(),
|
|
73
|
+
prompt: z.string().min(1),
|
|
74
|
+
dependsOn: z.array(z.string()).optional(),
|
|
75
|
+
permissionMode: z.enum(['default', 'bypassPermissions', 'plan']).optional(),
|
|
76
|
+
autoApprove: z.boolean().optional(),
|
|
77
|
+
});
|
|
78
|
+
/** POST /v1/pipelines */
|
|
79
|
+
export const pipelineSchema = z.object({
|
|
80
|
+
name: z.string().min(1),
|
|
81
|
+
workDir: z.string().min(1),
|
|
82
|
+
stages: z.array(pipelineStageSchema).min(1),
|
|
83
|
+
}).strict();
|
|
84
|
+
/** Clamp a numeric value to [min, max]. Returns default if input is NaN. */
|
|
85
|
+
export function clamp(value, min, max, fallback) {
|
|
86
|
+
if (Number.isNaN(value))
|
|
87
|
+
return fallback;
|
|
88
|
+
return Math.max(min, Math.min(max, value));
|
|
89
|
+
}
|
|
90
|
+
/** Parse an env string to integer with NaN/isFinite guard. Returns fallback on failure. */
|
|
91
|
+
export function parseIntSafe(value, fallback) {
|
|
92
|
+
if (value === undefined)
|
|
93
|
+
return fallback;
|
|
94
|
+
const parsed = parseInt(value, 10);
|
|
95
|
+
if (!Number.isFinite(parsed))
|
|
96
|
+
return fallback;
|
|
97
|
+
return parsed;
|
|
98
|
+
}
|
|
99
|
+
/** Validate that a string looks like a UUID. */
|
|
100
|
+
export function isValidUUID(id) {
|
|
101
|
+
return UUID_REGEX.test(id);
|
|
102
|
+
}
|
|
103
|
+
// ── JSON.parse boundary validation (Issue #410) ──────────────────
|
|
104
|
+
const UIStateEnum = z.enum([
|
|
105
|
+
'idle', 'working', 'permission_prompt', 'bash_approval',
|
|
106
|
+
'plan_mode', 'ask_question', 'settings', 'unknown',
|
|
107
|
+
]);
|
|
108
|
+
/** Schema for persisted SessionState (sessions: { [id]: SessionInfo }). */
|
|
109
|
+
export const persistedStateSchema = z.record(z.string(), z.object({
|
|
110
|
+
id: z.string(),
|
|
111
|
+
windowId: z.string(),
|
|
112
|
+
windowName: z.string(),
|
|
113
|
+
workDir: z.string(),
|
|
114
|
+
claudeSessionId: z.string().optional(),
|
|
115
|
+
jsonlPath: z.string().optional(),
|
|
116
|
+
byteOffset: z.number(),
|
|
117
|
+
monitorOffset: z.number(),
|
|
118
|
+
status: UIStateEnum,
|
|
119
|
+
createdAt: z.number(),
|
|
120
|
+
lastActivity: z.number(),
|
|
121
|
+
stallThresholdMs: z.number(),
|
|
122
|
+
permissionStallMs: z.number().default(300_000),
|
|
123
|
+
permissionMode: z.string(),
|
|
124
|
+
settingsPatched: z.boolean().optional(),
|
|
125
|
+
hookSettingsFile: z.string().optional(),
|
|
126
|
+
lastHookAt: z.number().optional(),
|
|
127
|
+
activeSubagents: z.array(z.string()).optional(),
|
|
128
|
+
permissionPromptAt: z.number().optional(),
|
|
129
|
+
permissionRespondedAt: z.number().optional(),
|
|
130
|
+
lastHookReceivedAt: z.number().optional(),
|
|
131
|
+
lastHookEventAt: z.number().optional(),
|
|
132
|
+
model: z.string().optional(),
|
|
133
|
+
lastDeadAt: z.number().optional(),
|
|
134
|
+
ccPid: z.number().optional(),
|
|
135
|
+
}));
|
|
136
|
+
/** Schema for session_map.json entries. */
|
|
137
|
+
export const sessionMapSchema = z.record(z.string(), z.object({
|
|
138
|
+
session_id: z.string(),
|
|
139
|
+
cwd: z.string(),
|
|
140
|
+
window_name: z.string(),
|
|
141
|
+
transcript_path: z.string().nullable().optional(),
|
|
142
|
+
permission_mode: z.string().nullable().optional(),
|
|
143
|
+
agent_id: z.string().nullable().optional(),
|
|
144
|
+
source: z.string().nullable().optional(),
|
|
145
|
+
agent_type: z.string().nullable().optional(),
|
|
146
|
+
model: z.string().nullable().optional(),
|
|
147
|
+
written_at: z.number(),
|
|
148
|
+
}));
|
|
149
|
+
/** Schema for stop_signals.json entries. */
|
|
150
|
+
export const stopSignalsSchema = z.record(z.string(), z.object({
|
|
151
|
+
event: z.string().optional(),
|
|
152
|
+
timestamp: z.number().optional(),
|
|
153
|
+
error: z.unknown().optional(),
|
|
154
|
+
error_details: z.unknown().optional(),
|
|
155
|
+
last_assistant_message: z.unknown().optional(),
|
|
156
|
+
agent_id: z.unknown().optional(),
|
|
157
|
+
stop_reason: z.string().optional(),
|
|
158
|
+
}));
|
|
159
|
+
/** Schema for persisted auth keys store (Issue #506). */
|
|
160
|
+
export const authStoreSchema = z.object({
|
|
161
|
+
keys: z.array(z.object({
|
|
162
|
+
id: z.string(),
|
|
163
|
+
name: z.string(),
|
|
164
|
+
hash: z.string(),
|
|
165
|
+
createdAt: z.number(),
|
|
166
|
+
lastUsedAt: z.number(),
|
|
167
|
+
rateLimit: z.number(),
|
|
168
|
+
})),
|
|
169
|
+
});
|
|
170
|
+
/** Schema for sessions-index.json entries (Issue #506). */
|
|
171
|
+
export const sessionsIndexSchema = z.object({
|
|
172
|
+
entries: z.array(z.object({
|
|
173
|
+
sessionId: z.string(),
|
|
174
|
+
fullPath: z.string(),
|
|
175
|
+
})).optional(),
|
|
176
|
+
});
|
|
177
|
+
/** Schema for persisted metrics file (Issue #506). */
|
|
178
|
+
export const metricsFileSchema = z.object({
|
|
179
|
+
global: z.object({
|
|
180
|
+
sessionsCreated: z.number().optional(),
|
|
181
|
+
sessionsCompleted: z.number().optional(),
|
|
182
|
+
sessionsFailed: z.number().optional(),
|
|
183
|
+
totalMessages: z.number().optional(),
|
|
184
|
+
totalToolCalls: z.number().optional(),
|
|
185
|
+
autoApprovals: z.number().optional(),
|
|
186
|
+
webhooksSent: z.number().optional(),
|
|
187
|
+
webhooksFailed: z.number().optional(),
|
|
188
|
+
screenshotsTaken: z.number().optional(),
|
|
189
|
+
pipelinesCreated: z.number().optional(),
|
|
190
|
+
batchesCreated: z.number().optional(),
|
|
191
|
+
promptsSent: z.number().optional(),
|
|
192
|
+
promptsDelivered: z.number().optional(),
|
|
193
|
+
promptsFailed: z.number().optional(),
|
|
194
|
+
}).passthrough().optional(),
|
|
195
|
+
savedAt: z.number().optional(),
|
|
196
|
+
}).passthrough();
|
|
197
|
+
/** Schema for WebSocket inbound messages (Issue #506). */
|
|
198
|
+
export const wsInboundMessageSchema = z.discriminatedUnion('type', [
|
|
199
|
+
z.object({ type: z.literal('input'), text: z.string() }).strict(),
|
|
200
|
+
z.object({ type: z.literal('resize'), cols: z.number().optional(), rows: z.number().optional() }).strict(),
|
|
201
|
+
z.object({ type: z.literal('auth'), token: z.string().optional() }).strict(),
|
|
202
|
+
]);
|
|
203
|
+
/** Schema for CC settings.json shape (Issue #506).
|
|
204
|
+
* Permissive — only validates the fields Aegis cares about. */
|
|
205
|
+
export const ccSettingsSchema = z.object({
|
|
206
|
+
permissions: z.object({
|
|
207
|
+
defaultMode: z.string().optional(),
|
|
208
|
+
}).passthrough().optional(),
|
|
209
|
+
}).passthrough();
|
|
210
|
+
/** Helper: extract error message from unknown catch value. */
|
|
211
|
+
export function getErrorMessage(e) {
|
|
212
|
+
if (e instanceof Error)
|
|
213
|
+
return e.message;
|
|
214
|
+
if (typeof e === 'string')
|
|
215
|
+
return e;
|
|
216
|
+
return String(e);
|
|
217
|
+
}
|
|
218
|
+
/** Default safe base directories used when allowedWorkDirs is not configured.
|
|
219
|
+
* Prevents sessions from running in system-critical directories. */
|
|
220
|
+
function getDefaultSafeDirs() {
|
|
221
|
+
return [
|
|
222
|
+
os.homedir(),
|
|
223
|
+
'/tmp',
|
|
224
|
+
'/var/tmp',
|
|
225
|
+
process.cwd(),
|
|
226
|
+
];
|
|
227
|
+
}
|
|
228
|
+
/** Check whether `childPath` is equal to or under `parentPath`. */
|
|
229
|
+
function isUnderOrEqual(childPath, parentPath) {
|
|
230
|
+
if (childPath === parentPath)
|
|
231
|
+
return true;
|
|
232
|
+
return childPath.startsWith(parentPath + path.sep);
|
|
233
|
+
}
|
|
234
|
+
/** Validate workDir to prevent path traversal attacks (Issue #435).
|
|
235
|
+
* 1. Reject raw strings containing ".." before any normalization.
|
|
236
|
+
* 2. Resolve to absolute path and resolve symlinks via fs.realpath().
|
|
237
|
+
* 3. Verify the resolved path is under an allowed directory:
|
|
238
|
+
* - If allowedWorkDirs is configured, use that list.
|
|
239
|
+
* - Otherwise, use default safe dirs (home, /tmp, cwd).
|
|
240
|
+
* Returns the resolved real path on success, or an error object on failure. */
|
|
241
|
+
export async function validateWorkDir(workDir, allowedWorkDirs = []) {
|
|
242
|
+
if (typeof workDir !== 'string')
|
|
243
|
+
return { error: 'workDir must be a string', code: 'INVALID_WORKDIR' };
|
|
244
|
+
// Step 1: Reject path traversal in the raw string BEFORE any normalization.
|
|
245
|
+
// path.normalize() would resolve ".." components, making the check useless.
|
|
246
|
+
if (workDir.includes('..')) {
|
|
247
|
+
return { error: 'workDir must not contain path traversal components (..)', code: 'INVALID_WORKDIR' };
|
|
248
|
+
}
|
|
249
|
+
// Step 2: Resolve to absolute path and follow symlinks.
|
|
250
|
+
const resolved = path.resolve(workDir);
|
|
251
|
+
let realPath;
|
|
252
|
+
try {
|
|
253
|
+
realPath = await fs.realpath(resolved);
|
|
254
|
+
}
|
|
255
|
+
catch { /* path does not exist on disk */
|
|
256
|
+
return { error: `workDir does not exist: ${resolved}`, code: 'INVALID_WORKDIR' };
|
|
257
|
+
}
|
|
258
|
+
// Step 3: Directory allowlist check.
|
|
259
|
+
const safeDirs = allowedWorkDirs.length > 0
|
|
260
|
+
? allowedWorkDirs.map((d) => path.resolve(d))
|
|
261
|
+
: getDefaultSafeDirs();
|
|
262
|
+
const allowed = safeDirs.some((dir) => isUnderOrEqual(realPath, dir));
|
|
263
|
+
if (!allowed) {
|
|
264
|
+
return { error: 'workDir is not in the allowed directories list', code: 'INVALID_WORKDIR' };
|
|
265
|
+
}
|
|
266
|
+
return realPath;
|
|
267
|
+
}
|
|
268
|
+
//# sourceMappingURL=validation.js.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ws-terminal.ts — WebSocket endpoint for live terminal streaming.
|
|
3
|
+
*
|
|
4
|
+
* WS /v1/sessions/:id/terminal
|
|
5
|
+
*
|
|
6
|
+
* Protocol:
|
|
7
|
+
* Server → Client: { type: "pane", content: "..." }
|
|
8
|
+
* Server → Client: { type: "status", status: "idle" }
|
|
9
|
+
* Server → Client: { type: "error", message: "..." }
|
|
10
|
+
* Client → Server: { type: "input", text: "..." }
|
|
11
|
+
* Client → Server: { type: "resize", cols: 80, rows: 24 }
|
|
12
|
+
*
|
|
13
|
+
* Security (Issue #303, #503):
|
|
14
|
+
* - Auth validation via first-message handshake: client sends
|
|
15
|
+
* { type: "auth", token: "..." } as first message (#503)
|
|
16
|
+
* - Bearer header auth still works for non-browser clients
|
|
17
|
+
* - 5s auth timeout — connection dropped if not authenticated
|
|
18
|
+
* - Per-connection message rate limiting (10 msg/sec)
|
|
19
|
+
* - Shared tmux capture polls (one per session, not per connection)
|
|
20
|
+
* - Ping/pong keep-alive with dead connection detection
|
|
21
|
+
*/
|
|
22
|
+
import type { FastifyInstance } from 'fastify';
|
|
23
|
+
import type { SessionManager } from './session.js';
|
|
24
|
+
import type { TmuxManager } from './tmux.js';
|
|
25
|
+
import type { AuthManager } from './auth.js';
|
|
26
|
+
/** Reset all internal state (for testing). */
|
|
27
|
+
export declare function _resetForTesting(): void;
|
|
28
|
+
/** Get the number of active shared polls (for testing). */
|
|
29
|
+
export declare function _activePollCount(): number;
|
|
30
|
+
/** Get subscriber count for a session (for testing). */
|
|
31
|
+
export declare function _subscriberCount(sessionId: string): number;
|
|
32
|
+
export declare function registerWsTerminalRoute(app: FastifyInstance, sessions: SessionManager, tmux: TmuxManager, auth: AuthManager): void;
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ws-terminal.ts — WebSocket endpoint for live terminal streaming.
|
|
3
|
+
*
|
|
4
|
+
* WS /v1/sessions/:id/terminal
|
|
5
|
+
*
|
|
6
|
+
* Protocol:
|
|
7
|
+
* Server → Client: { type: "pane", content: "..." }
|
|
8
|
+
* Server → Client: { type: "status", status: "idle" }
|
|
9
|
+
* Server → Client: { type: "error", message: "..." }
|
|
10
|
+
* Client → Server: { type: "input", text: "..." }
|
|
11
|
+
* Client → Server: { type: "resize", cols: 80, rows: 24 }
|
|
12
|
+
*
|
|
13
|
+
* Security (Issue #303, #503):
|
|
14
|
+
* - Auth validation via first-message handshake: client sends
|
|
15
|
+
* { type: "auth", token: "..." } as first message (#503)
|
|
16
|
+
* - Bearer header auth still works for non-browser clients
|
|
17
|
+
* - 5s auth timeout — connection dropped if not authenticated
|
|
18
|
+
* - Per-connection message rate limiting (10 msg/sec)
|
|
19
|
+
* - Shared tmux capture polls (one per session, not per connection)
|
|
20
|
+
* - Ping/pong keep-alive with dead connection detection
|
|
21
|
+
*/
|
|
22
|
+
import { clamp, wsInboundMessageSchema } from './validation.js';
|
|
23
|
+
const POLL_INTERVAL_MS = 500;
|
|
24
|
+
const KEEPALIVE_INTERVAL_TICKS = 60; // 30s at 500ms intervals
|
|
25
|
+
const KEEPALIVE_TIMEOUT_MS = 35_000; // 30s interval + 5s grace
|
|
26
|
+
const RATE_LIMIT_WINDOW_MS = 1000;
|
|
27
|
+
const RATE_LIMIT_MAX_MESSAGES = 10;
|
|
28
|
+
const AUTH_TIMEOUT_MS = 5_000;
|
|
29
|
+
// ── Module state ───────────────────────────────────────────────────
|
|
30
|
+
const sessionPolls = new Map();
|
|
31
|
+
/** Reset all internal state (for testing). */
|
|
32
|
+
export function _resetForTesting() {
|
|
33
|
+
for (const poll of sessionPolls.values()) {
|
|
34
|
+
clearInterval(poll.timer);
|
|
35
|
+
}
|
|
36
|
+
sessionPolls.clear();
|
|
37
|
+
}
|
|
38
|
+
/** Get the number of active shared polls (for testing). */
|
|
39
|
+
export function _activePollCount() {
|
|
40
|
+
return sessionPolls.size;
|
|
41
|
+
}
|
|
42
|
+
/** Get subscriber count for a session (for testing). */
|
|
43
|
+
export function _subscriberCount(sessionId) {
|
|
44
|
+
return sessionPolls.get(sessionId)?.subscribers.size ?? 0;
|
|
45
|
+
}
|
|
46
|
+
// ── Route registration ─────────────────────────────────────────────
|
|
47
|
+
export function registerWsTerminalRoute(app, sessions, tmux, auth) {
|
|
48
|
+
app.get('/v1/sessions/:id/terminal', {
|
|
49
|
+
websocket: true,
|
|
50
|
+
preHandler: async (req, reply) => {
|
|
51
|
+
if (!auth.authEnabled)
|
|
52
|
+
return;
|
|
53
|
+
// Bearer header auth still works for non-browser clients
|
|
54
|
+
const header = req.headers.authorization;
|
|
55
|
+
if (header?.startsWith('Bearer ')) {
|
|
56
|
+
const token = header.slice(7);
|
|
57
|
+
const result = auth.validate(token);
|
|
58
|
+
if (!result.valid) {
|
|
59
|
+
return reply.status(401).send({ error: 'Unauthorized — invalid API key' });
|
|
60
|
+
}
|
|
61
|
+
if (result.rateLimited) {
|
|
62
|
+
return reply.status(429).send({ error: 'Rate limit exceeded' });
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// No Bearer header — allow connection through; auth will be validated
|
|
67
|
+
// via first-message handshake ({ type: "auth", token: "..." }).
|
|
68
|
+
// Issue #503: tokens must NOT appear in URLs.
|
|
69
|
+
},
|
|
70
|
+
}, (socket, req) => {
|
|
71
|
+
const sessionId = req.params.id;
|
|
72
|
+
const session = sessions.getSession(sessionId);
|
|
73
|
+
if (!session) {
|
|
74
|
+
sendError(socket, 'Session not found');
|
|
75
|
+
socket.close();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// Check if already authenticated via Bearer header in preHandler
|
|
79
|
+
const preAuthed = auth.authEnabled && req.headers?.authorization?.startsWith('Bearer ');
|
|
80
|
+
// Create subscriber
|
|
81
|
+
const subscriber = {
|
|
82
|
+
lastContent: '',
|
|
83
|
+
lastStatus: '',
|
|
84
|
+
closed: false,
|
|
85
|
+
lastPongAt: Date.now(),
|
|
86
|
+
messageTimestamps: [],
|
|
87
|
+
authenticated: !auth.authEnabled || !!preAuthed,
|
|
88
|
+
authTimer: null,
|
|
89
|
+
};
|
|
90
|
+
// If auth is required but not yet provided, set auth timeout
|
|
91
|
+
if (auth.authEnabled && !subscriber.authenticated) {
|
|
92
|
+
subscriber.authTimer = setTimeout(() => {
|
|
93
|
+
if (!subscriber.closed && !subscriber.authenticated) {
|
|
94
|
+
sendError(socket, 'Auth timeout — no auth message received');
|
|
95
|
+
evictSubscriber(sessionId, socket, subscriber);
|
|
96
|
+
}
|
|
97
|
+
}, AUTH_TIMEOUT_MS);
|
|
98
|
+
}
|
|
99
|
+
// Get or create shared session poll
|
|
100
|
+
let poll = sessionPolls.get(sessionId);
|
|
101
|
+
if (!poll) {
|
|
102
|
+
poll = {
|
|
103
|
+
timer: null,
|
|
104
|
+
tickCount: 0,
|
|
105
|
+
subscribers: new Map(),
|
|
106
|
+
};
|
|
107
|
+
sessionPolls.set(sessionId, poll);
|
|
108
|
+
// Start the shared poll timer
|
|
109
|
+
poll.timer = setInterval(async () => {
|
|
110
|
+
poll.tickCount++;
|
|
111
|
+
await tickPoll(sessionId, sessions, tmux, poll);
|
|
112
|
+
}, POLL_INTERVAL_MS);
|
|
113
|
+
}
|
|
114
|
+
poll.subscribers.set(socket, subscriber);
|
|
115
|
+
// Handle pong responses for keep-alive
|
|
116
|
+
socket.on('pong', () => {
|
|
117
|
+
const sub = poll?.subscribers.get(socket);
|
|
118
|
+
if (sub)
|
|
119
|
+
sub.lastPongAt = Date.now();
|
|
120
|
+
});
|
|
121
|
+
// Handle incoming messages with rate limiting
|
|
122
|
+
socket.on('message', async (data) => {
|
|
123
|
+
if (subscriber.closed)
|
|
124
|
+
return;
|
|
125
|
+
// Rate limit check
|
|
126
|
+
if (!checkRateLimit(subscriber)) {
|
|
127
|
+
sendError(socket, 'Rate limit exceeded — max 10 messages per second');
|
|
128
|
+
evictSubscriber(sessionId, socket, subscriber);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const parsed = wsInboundMessageSchema.safeParse(JSON.parse(data.toString()));
|
|
133
|
+
if (!parsed.success) {
|
|
134
|
+
sendError(socket, `Invalid message: ${parsed.error.issues.map(i => i.message).join(', ')}`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const msg = parsed.data;
|
|
138
|
+
// Handle auth handshake (Issue #503)
|
|
139
|
+
if (msg.type === 'auth') {
|
|
140
|
+
if (subscriber.authenticated) {
|
|
141
|
+
sendError(socket, 'Already authenticated');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (typeof msg.token !== 'string' || !msg.token) {
|
|
145
|
+
sendError(socket, 'Auth message requires a token field');
|
|
146
|
+
evictSubscriber(sessionId, socket, subscriber);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const result = auth.validate(msg.token);
|
|
150
|
+
if (!result.valid) {
|
|
151
|
+
sendError(socket, 'Unauthorized — invalid API key');
|
|
152
|
+
evictSubscriber(sessionId, socket, subscriber);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (result.rateLimited) {
|
|
156
|
+
sendError(socket, 'Rate limit exceeded');
|
|
157
|
+
evictSubscriber(sessionId, socket, subscriber);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
// Auth successful
|
|
161
|
+
subscriber.authenticated = true;
|
|
162
|
+
if (subscriber.authTimer) {
|
|
163
|
+
clearTimeout(subscriber.authTimer);
|
|
164
|
+
subscriber.authTimer = null;
|
|
165
|
+
}
|
|
166
|
+
send(socket, { type: 'status', status: 'authenticated' });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// Reject non-auth messages when not yet authenticated
|
|
170
|
+
if (!subscriber.authenticated) {
|
|
171
|
+
sendError(socket, 'Not authenticated — send { type: "auth", token: "..." } first');
|
|
172
|
+
evictSubscriber(sessionId, socket, subscriber);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (msg.type === 'input' && typeof msg.text === 'string') {
|
|
176
|
+
await sessions.sendMessage(sessionId, msg.text);
|
|
177
|
+
}
|
|
178
|
+
else if (msg.type === 'resize') {
|
|
179
|
+
const cols = clamp(msg.cols ?? 80, 1, 1000, 80);
|
|
180
|
+
const rows = clamp(msg.rows ?? 24, 1, 1000, 24);
|
|
181
|
+
await tmux.resizePane(session.windowId, cols, rows);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch (e) {
|
|
185
|
+
sendError(socket, `Invalid message: ${e instanceof Error ? e.message : String(e)}`);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
socket.on('close', () => {
|
|
189
|
+
evictSubscriber(sessionId, socket, subscriber);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
// ── Shared poll logic ──────────────────────────────────────────────
|
|
194
|
+
async function tickPoll(sessionId, sessions, tmux, poll) {
|
|
195
|
+
const session = sessions.getSession(sessionId);
|
|
196
|
+
if (!session) {
|
|
197
|
+
// Session gone — evict all subscribers
|
|
198
|
+
for (const [socket, sub] of [...poll.subscribers]) {
|
|
199
|
+
if (!sub.closed) {
|
|
200
|
+
sendError(socket, 'Session no longer exists');
|
|
201
|
+
evictSubscriber(sessionId, socket, sub);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
let content;
|
|
207
|
+
try {
|
|
208
|
+
content = await tmux.capturePane(session.windowId);
|
|
209
|
+
}
|
|
210
|
+
catch { /* pane gone — evict all subscribers */
|
|
211
|
+
for (const [socket, sub] of [...poll.subscribers]) {
|
|
212
|
+
if (!sub.closed) {
|
|
213
|
+
sendError(socket, 'Failed to capture pane — session may have ended');
|
|
214
|
+
evictSubscriber(sessionId, socket, sub);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const currentStatus = session.status;
|
|
220
|
+
// Fan out to all subscribers with per-subscriber deduplication
|
|
221
|
+
for (const [socket, sub] of [...poll.subscribers]) {
|
|
222
|
+
if (sub.closed || !sub.authenticated)
|
|
223
|
+
continue;
|
|
224
|
+
if (content !== sub.lastContent) {
|
|
225
|
+
sub.lastContent = content;
|
|
226
|
+
send(socket, { type: 'pane', content });
|
|
227
|
+
}
|
|
228
|
+
if (currentStatus !== sub.lastStatus) {
|
|
229
|
+
sub.lastStatus = currentStatus;
|
|
230
|
+
send(socket, { type: 'status', status: currentStatus });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// Keep-alive check (every 60 ticks ≈ 30s)
|
|
234
|
+
if (poll.tickCount % KEEPALIVE_INTERVAL_TICKS === 0) {
|
|
235
|
+
const now = Date.now();
|
|
236
|
+
for (const [socket, sub] of [...poll.subscribers]) {
|
|
237
|
+
if (sub.closed)
|
|
238
|
+
continue;
|
|
239
|
+
// Evict dead connections
|
|
240
|
+
if (now - sub.lastPongAt > KEEPALIVE_TIMEOUT_MS) {
|
|
241
|
+
evictSubscriber(sessionId, socket, sub);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
// Send ping
|
|
245
|
+
try {
|
|
246
|
+
socket.ping();
|
|
247
|
+
}
|
|
248
|
+
catch { /* socket already closed */
|
|
249
|
+
evictSubscriber(sessionId, socket, sub);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// ── Rate limiting ──────────────────────────────────────────────────
|
|
255
|
+
function checkRateLimit(sub) {
|
|
256
|
+
const now = Date.now();
|
|
257
|
+
sub.messageTimestamps = sub.messageTimestamps.filter(t => now - t < RATE_LIMIT_WINDOW_MS);
|
|
258
|
+
if (sub.messageTimestamps.length >= RATE_LIMIT_MAX_MESSAGES) {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
sub.messageTimestamps.push(now);
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
// ── Subscriber management ──────────────────────────────────────────
|
|
265
|
+
function evictSubscriber(sessionId, socket, sub) {
|
|
266
|
+
if (sub.closed)
|
|
267
|
+
return;
|
|
268
|
+
sub.closed = true;
|
|
269
|
+
// Clean up auth timer if pending
|
|
270
|
+
if (sub.authTimer) {
|
|
271
|
+
clearTimeout(sub.authTimer);
|
|
272
|
+
sub.authTimer = null;
|
|
273
|
+
}
|
|
274
|
+
const poll = sessionPolls.get(sessionId);
|
|
275
|
+
if (poll) {
|
|
276
|
+
poll.subscribers.delete(socket);
|
|
277
|
+
// If no more subscribers, clean up the poll timer
|
|
278
|
+
if (poll.subscribers.size === 0) {
|
|
279
|
+
clearInterval(poll.timer);
|
|
280
|
+
sessionPolls.delete(sessionId);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
socket.close();
|
|
285
|
+
}
|
|
286
|
+
catch { /* ignore */ }
|
|
287
|
+
}
|
|
288
|
+
// ── Helpers ────────────────────────────────────────────────────────
|
|
289
|
+
function send(ws, msg) {
|
|
290
|
+
if (ws.readyState === ws.OPEN) {
|
|
291
|
+
ws.send(JSON.stringify(msg));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function sendError(ws, message) {
|
|
295
|
+
send(ws, { type: 'error', message });
|
|
296
|
+
}
|
|
297
|
+
//# sourceMappingURL=ws-terminal.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "aegis-bridge",
|
|
3
|
+
"version": "2.2.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Orchestrate Claude Code sessions via API. Create, brief, monitor, refine, ship.",
|
|
6
|
+
"main": "dist/server.js",
|
|
7
|
+
"types": "dist/server.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/server.js",
|
|
11
|
+
"types": "./dist/server.d.ts"
|
|
12
|
+
},
|
|
13
|
+
"./cli": {
|
|
14
|
+
"import": "./dist/cli.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"bin": {
|
|
18
|
+
"aegis-bridge": "dist/cli.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"dashboard/dist",
|
|
23
|
+
"!dist/__tests__",
|
|
24
|
+
"!dist/**/*.map"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc && npm run build:copy-dashboard",
|
|
28
|
+
"build:copy-dashboard": "node scripts/copy-dashboard.mjs",
|
|
29
|
+
"build:dashboard": "cd dashboard && npm install && npm run build",
|
|
30
|
+
"start": "node dist/cli.js",
|
|
31
|
+
"dev": "tsc && node dist/cli.js",
|
|
32
|
+
"prepublishOnly": "npm run build:dashboard && npm run build",
|
|
33
|
+
"test": "vitest run"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"claude",
|
|
37
|
+
"claude-code",
|
|
38
|
+
"ai",
|
|
39
|
+
"orchestration",
|
|
40
|
+
"coding-agent",
|
|
41
|
+
"tmux",
|
|
42
|
+
"session-management"
|
|
43
|
+
],
|
|
44
|
+
"author": "Emanuele Santonastaso (@OneStepAt4time)",
|
|
45
|
+
"license": "MIT",
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@fastify/cors": "^11.2.0",
|
|
48
|
+
"@fastify/static": "^9.0.0",
|
|
49
|
+
"@fastify/websocket": "^11.2.0",
|
|
50
|
+
"@modelcontextprotocol/sdk": "^1.28.0",
|
|
51
|
+
"fastify": "^5.8.2",
|
|
52
|
+
"zod": "^4.3.6"
|
|
53
|
+
},
|
|
54
|
+
"repository": {
|
|
55
|
+
"type": "git",
|
|
56
|
+
"url": "https://github.com/OneStepAt4time/aegis.git"
|
|
57
|
+
},
|
|
58
|
+
"homepage": "https://github.com/OneStepAt4time/aegis#readme",
|
|
59
|
+
"bugs": {
|
|
60
|
+
"url": "https://github.com/OneStepAt4time/aegis/issues"
|
|
61
|
+
},
|
|
62
|
+
"engines": {
|
|
63
|
+
"node": ">=20.0.0"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@types/node": "^25.5.0",
|
|
67
|
+
"@types/ws": "^8.18.1",
|
|
68
|
+
"typescript": "^6.0.2",
|
|
69
|
+
"vitest": "^4.1.2"
|
|
70
|
+
}
|
|
71
|
+
}
|