aegis-bridge 0.1.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/LICENSE +21 -0
- package/README.md +404 -0
- package/dashboard/dist/assets/index-BoZwGLAx.css +32 -0
- package/dashboard/dist/assets/index-C61BkKH-.js +312 -0
- package/dashboard/dist/assets/index-C61BkKH-.js.map +1 -0
- package/dashboard/dist/index.html +14 -0
- package/dist/api-contracts.d.ts +229 -0
- package/dist/api-contracts.js +7 -0
- package/dist/api-contracts.typecheck.d.ts +14 -0
- package/dist/api-contracts.typecheck.js +1 -0
- package/dist/api-error-envelope.d.ts +15 -0
- package/dist/api-error-envelope.js +80 -0
- package/dist/auth.d.ts +87 -0
- package/dist/auth.js +276 -0
- package/dist/channels/index.d.ts +8 -0
- package/dist/channels/index.js +8 -0
- package/dist/channels/manager.d.ts +47 -0
- package/dist/channels/manager.js +115 -0
- package/dist/channels/telegram-style.d.ts +118 -0
- package/dist/channels/telegram-style.js +202 -0
- package/dist/channels/telegram.d.ts +91 -0
- package/dist/channels/telegram.js +1518 -0
- package/dist/channels/types.d.ts +77 -0
- package/dist/channels/types.js +8 -0
- package/dist/channels/webhook.d.ts +60 -0
- package/dist/channels/webhook.js +216 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +252 -0
- package/dist/config.d.ts +90 -0
- package/dist/config.js +214 -0
- package/dist/consensus.d.ts +16 -0
- package/dist/consensus.js +19 -0
- package/dist/continuation-pointer.d.ts +11 -0
- package/dist/continuation-pointer.js +65 -0
- package/dist/diagnostics.d.ts +27 -0
- package/dist/diagnostics.js +95 -0
- package/dist/error-categories.d.ts +39 -0
- package/dist/error-categories.js +73 -0
- package/dist/events.d.ts +133 -0
- package/dist/events.js +389 -0
- package/dist/fault-injection.d.ts +29 -0
- package/dist/fault-injection.js +115 -0
- package/dist/file-utils.d.ts +2 -0
- package/dist/file-utils.js +37 -0
- package/dist/handshake.d.ts +60 -0
- package/dist/handshake.js +124 -0
- package/dist/hook-settings.d.ts +80 -0
- package/dist/hook-settings.js +272 -0
- package/dist/hook.d.ts +19 -0
- package/dist/hook.js +231 -0
- package/dist/hooks.d.ts +32 -0
- package/dist/hooks.js +364 -0
- package/dist/jsonl-watcher.d.ts +59 -0
- package/dist/jsonl-watcher.js +166 -0
- package/dist/logger.d.ts +35 -0
- package/dist/logger.js +65 -0
- package/dist/mcp-server.d.ts +123 -0
- package/dist/mcp-server.js +869 -0
- package/dist/memory-bridge.d.ts +27 -0
- package/dist/memory-bridge.js +137 -0
- package/dist/memory-routes.d.ts +3 -0
- package/dist/memory-routes.js +100 -0
- package/dist/metrics.d.ts +126 -0
- package/dist/metrics.js +286 -0
- package/dist/model-router.d.ts +53 -0
- package/dist/model-router.js +150 -0
- package/dist/monitor.d.ts +103 -0
- package/dist/monitor.js +820 -0
- package/dist/path-utils.d.ts +11 -0
- package/dist/path-utils.js +21 -0
- package/dist/permission-evaluator.d.ts +10 -0
- package/dist/permission-evaluator.js +48 -0
- package/dist/permission-guard.d.ts +51 -0
- package/dist/permission-guard.js +196 -0
- package/dist/permission-request-manager.d.ts +12 -0
- package/dist/permission-request-manager.js +36 -0
- package/dist/permission-routes.d.ts +7 -0
- package/dist/permission-routes.js +28 -0
- package/dist/pipeline.d.ts +97 -0
- package/dist/pipeline.js +291 -0
- package/dist/process-utils.d.ts +4 -0
- package/dist/process-utils.js +73 -0
- package/dist/question-manager.d.ts +54 -0
- package/dist/question-manager.js +80 -0
- package/dist/retry.d.ts +11 -0
- package/dist/retry.js +34 -0
- package/dist/safe-json.d.ts +12 -0
- package/dist/safe-json.js +22 -0
- package/dist/screenshot.d.ts +28 -0
- package/dist/screenshot.js +60 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +1973 -0
- package/dist/session-cleanup.d.ts +18 -0
- package/dist/session-cleanup.js +11 -0
- package/dist/session.d.ts +379 -0
- package/dist/session.js +1568 -0
- package/dist/shutdown-utils.d.ts +5 -0
- package/dist/shutdown-utils.js +24 -0
- package/dist/signal-cleanup-helper.d.ts +48 -0
- package/dist/signal-cleanup-helper.js +117 -0
- package/dist/sse-limiter.d.ts +47 -0
- package/dist/sse-limiter.js +61 -0
- package/dist/sse-writer.d.ts +31 -0
- package/dist/sse-writer.js +94 -0
- package/dist/ssrf.d.ts +102 -0
- package/dist/ssrf.js +267 -0
- package/dist/startup.d.ts +6 -0
- package/dist/startup.js +162 -0
- package/dist/suppress.d.ts +33 -0
- package/dist/suppress.js +79 -0
- package/dist/swarm-monitor.d.ts +117 -0
- package/dist/swarm-monitor.js +300 -0
- package/dist/template-store.d.ts +45 -0
- package/dist/template-store.js +142 -0
- package/dist/terminal-parser.d.ts +16 -0
- package/dist/terminal-parser.js +346 -0
- package/dist/tmux-capture-cache.d.ts +18 -0
- package/dist/tmux-capture-cache.js +34 -0
- package/dist/tmux.d.ts +183 -0
- package/dist/tmux.js +906 -0
- package/dist/tool-registry.d.ts +40 -0
- package/dist/tool-registry.js +83 -0
- package/dist/transcript.d.ts +63 -0
- package/dist/transcript.js +284 -0
- package/dist/utils/circular-buffer.d.ts +11 -0
- package/dist/utils/circular-buffer.js +37 -0
- package/dist/utils/redact-headers.d.ts +13 -0
- package/dist/utils/redact-headers.js +54 -0
- package/dist/validation.d.ts +406 -0
- package/dist/validation.js +415 -0
- package/dist/verification.d.ts +2 -0
- package/dist/verification.js +72 -0
- package/dist/worktree-lookup.d.ts +24 -0
- package/dist/worktree-lookup.js +71 -0
- package/dist/ws-terminal.d.ts +32 -0
- package/dist/ws-terminal.js +348 -0
- package/package.json +83 -0
|
@@ -0,0 +1,415 @@
|
|
|
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
|
+
/** Maximum length for user-supplied prompts/commands (Issue #411). */
|
|
19
|
+
export const MAX_INPUT_LENGTH = 10_000;
|
|
20
|
+
/** POST /v1/sessions/:id/send */
|
|
21
|
+
export const sendMessageSchema = z.object({
|
|
22
|
+
text: z.string().min(1).max(MAX_INPUT_LENGTH),
|
|
23
|
+
}).strict();
|
|
24
|
+
/** POST /v1/sessions/:id/command */
|
|
25
|
+
export const commandSchema = z.object({
|
|
26
|
+
command: z.string().min(1).max(MAX_INPUT_LENGTH),
|
|
27
|
+
}).strict();
|
|
28
|
+
/** POST /v1/sessions/:id/bash */
|
|
29
|
+
export const bashSchema = z.object({
|
|
30
|
+
command: z.string().min(1).max(MAX_INPUT_LENGTH),
|
|
31
|
+
}).strict();
|
|
32
|
+
/** POST /v1/sessions/:id/screenshot */
|
|
33
|
+
export const screenshotSchema = z.object({
|
|
34
|
+
url: z.string().min(1),
|
|
35
|
+
fullPage: z.boolean().optional(),
|
|
36
|
+
width: z.number().int().positive().max(7680).optional(),
|
|
37
|
+
height: z.number().int().positive().max(4320).optional(),
|
|
38
|
+
}).strict();
|
|
39
|
+
/** Webhook endpoint — validates structure of each webhook entry */
|
|
40
|
+
export const webhookEndpointSchema = z.object({
|
|
41
|
+
url: z.string().min(1),
|
|
42
|
+
events: z.array(z.string()).optional(),
|
|
43
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
44
|
+
timeoutMs: z.number().int().positive().optional(),
|
|
45
|
+
}).strict();
|
|
46
|
+
/** POST /v1/hooks/:eventName — CC hook event payload (Issue #665). */
|
|
47
|
+
export const hookBodySchema = z.object({
|
|
48
|
+
session_id: z.string().optional(),
|
|
49
|
+
agent_name: z.string().optional(),
|
|
50
|
+
agent_type: z.string().optional(),
|
|
51
|
+
tool_name: z.string().optional(),
|
|
52
|
+
tool_input: z.object({ command: z.string().optional() }).passthrough().optional(),
|
|
53
|
+
tool_use_id: z.string().optional(),
|
|
54
|
+
permission_prompt: z.string().optional(),
|
|
55
|
+
permission_mode: z.string().optional(),
|
|
56
|
+
hook_event_name: z.string().optional(),
|
|
57
|
+
model: z.string().optional(),
|
|
58
|
+
timestamp: z.string().optional(),
|
|
59
|
+
stop_reason: z.string().optional(),
|
|
60
|
+
cwd: z.string().optional(),
|
|
61
|
+
command: z.string().optional(),
|
|
62
|
+
}).passthrough();
|
|
63
|
+
/** POST /v1/sessions/:id/hooks/permission */
|
|
64
|
+
export const permissionHookSchema = z.object({
|
|
65
|
+
session_id: z.string().optional(),
|
|
66
|
+
tool_name: z.string().optional(),
|
|
67
|
+
tool_input: z.unknown().optional(),
|
|
68
|
+
permission_mode: z.string().optional(),
|
|
69
|
+
hook_event_name: z.string().optional(),
|
|
70
|
+
}).strict();
|
|
71
|
+
/** POST /v1/sessions/:id/hooks/stop */
|
|
72
|
+
export const stopHookSchema = z.object({
|
|
73
|
+
session_id: z.string().optional(),
|
|
74
|
+
stop_reason: z.string().optional(),
|
|
75
|
+
hook_event_name: z.string().optional(),
|
|
76
|
+
}).strict();
|
|
77
|
+
const batchSessionSpecSchema = z.object({
|
|
78
|
+
name: z.string().max(200).optional(),
|
|
79
|
+
workDir: z.string().min(1),
|
|
80
|
+
prompt: z.string().max(100_000).optional(),
|
|
81
|
+
permissionMode: z.enum(['default', 'bypassPermissions', 'plan', 'acceptEdits', 'dontAsk', 'auto']).optional(),
|
|
82
|
+
autoApprove: z.boolean().optional(),
|
|
83
|
+
stallThresholdMs: z.number().int().positive().max(3_600_000).optional(),
|
|
84
|
+
});
|
|
85
|
+
/** POST /v1/sessions/batch — max 50 sessions per batch */
|
|
86
|
+
export const batchSessionSchema = z.object({
|
|
87
|
+
sessions: z.array(batchSessionSpecSchema).min(1).max(50),
|
|
88
|
+
}).strict();
|
|
89
|
+
const pipelineStageSchema = z.object({
|
|
90
|
+
name: z.string().min(1),
|
|
91
|
+
workDir: z.string().min(1).optional(),
|
|
92
|
+
prompt: z.string().min(1).max(MAX_INPUT_LENGTH),
|
|
93
|
+
dependsOn: z.array(z.string()).optional(),
|
|
94
|
+
permissionMode: z.enum(['default', 'bypassPermissions', 'plan', 'acceptEdits', 'dontAsk', 'auto']).optional(),
|
|
95
|
+
autoApprove: z.boolean().optional(),
|
|
96
|
+
});
|
|
97
|
+
/** POST /v1/pipelines */
|
|
98
|
+
export const pipelineSchema = z.object({
|
|
99
|
+
name: z.string().min(1),
|
|
100
|
+
workDir: z.string().min(1),
|
|
101
|
+
stages: z.array(pipelineStageSchema).min(1),
|
|
102
|
+
}).strict();
|
|
103
|
+
/** POST /v1/handshake */
|
|
104
|
+
export const handshakeRequestSchema = z.object({
|
|
105
|
+
protocolVersion: z.string().min(1),
|
|
106
|
+
clientCapabilities: z.array(z.string().min(1)).optional(),
|
|
107
|
+
clientVersion: z.string().min(1).optional(),
|
|
108
|
+
}).strict();
|
|
109
|
+
/** Clamp a numeric value to [min, max]. Returns default if input is NaN. */
|
|
110
|
+
export function clamp(value, min, max, fallback) {
|
|
111
|
+
if (Number.isNaN(value))
|
|
112
|
+
return fallback;
|
|
113
|
+
return Math.max(min, Math.min(max, value));
|
|
114
|
+
}
|
|
115
|
+
/** Parse an env string to integer with NaN/isFinite guard. Returns fallback on failure. */
|
|
116
|
+
export function parseIntSafe(value, fallback) {
|
|
117
|
+
if (value === undefined)
|
|
118
|
+
return fallback;
|
|
119
|
+
const parsed = parseInt(value, 10);
|
|
120
|
+
if (!Number.isFinite(parsed))
|
|
121
|
+
return fallback;
|
|
122
|
+
return parsed;
|
|
123
|
+
}
|
|
124
|
+
/** Validate that a string looks like a UUID. */
|
|
125
|
+
export function isValidUUID(id) {
|
|
126
|
+
return UUID_REGEX.test(id);
|
|
127
|
+
}
|
|
128
|
+
// ── JSON.parse boundary validation (Issue #410) ──────────────────
|
|
129
|
+
const UIStateEnum = z.enum([
|
|
130
|
+
'idle', 'working', 'compacting', 'context_warning', 'waiting_for_input',
|
|
131
|
+
'permission_prompt', 'bash_approval', 'plan_mode', 'ask_question',
|
|
132
|
+
'settings', 'error', 'unknown',
|
|
133
|
+
]);
|
|
134
|
+
/** Issue #700: Permission Policy Schema */
|
|
135
|
+
export const permissionRuleSchema = z.object({
|
|
136
|
+
source: z.enum(['userSettings', 'projectSettings', 'localSettings', 'flagSettings', 'aegisApi']),
|
|
137
|
+
ruleBehavior: z.enum(['allow', 'deny', 'ask']),
|
|
138
|
+
toolName: z.string().optional(),
|
|
139
|
+
commandPattern: z.string().optional(),
|
|
140
|
+
});
|
|
141
|
+
/** Issue #742: richer per-session permission profile. */
|
|
142
|
+
export const permissionConstraintSchema = z.object({
|
|
143
|
+
readOnly: z.boolean().optional(),
|
|
144
|
+
paths: z.array(z.string().min(1)).max(50).optional(),
|
|
145
|
+
maxFileSize: z.number().int().positive().max(10_000_000).optional(),
|
|
146
|
+
}).strict();
|
|
147
|
+
export const permissionProfileRuleSchema = z.object({
|
|
148
|
+
tool: z.string().min(1),
|
|
149
|
+
behavior: z.enum(['allow', 'deny', 'ask']),
|
|
150
|
+
pattern: z.string().optional(),
|
|
151
|
+
constraints: permissionConstraintSchema.optional(),
|
|
152
|
+
}).strict();
|
|
153
|
+
export const permissionProfileSchema = z.object({
|
|
154
|
+
defaultBehavior: z.enum(['allow', 'deny', 'ask']),
|
|
155
|
+
rules: z.array(permissionProfileRuleSchema).max(100),
|
|
156
|
+
}).strict();
|
|
157
|
+
/** Schema for persisted SessionState (sessions: { [id]: SessionInfo }). */
|
|
158
|
+
export const persistedStateSchema = z.record(z.string(), z.object({
|
|
159
|
+
id: z.string(),
|
|
160
|
+
windowId: z.string(),
|
|
161
|
+
windowName: z.string(),
|
|
162
|
+
workDir: z.string(),
|
|
163
|
+
claudeSessionId: z.string().optional(),
|
|
164
|
+
jsonlPath: z.string().optional(),
|
|
165
|
+
byteOffset: z.number(),
|
|
166
|
+
monitorOffset: z.number(),
|
|
167
|
+
status: UIStateEnum,
|
|
168
|
+
createdAt: z.number(),
|
|
169
|
+
lastActivity: z.number(),
|
|
170
|
+
stallThresholdMs: z.number(),
|
|
171
|
+
permissionStallMs: z.number().default(300_000),
|
|
172
|
+
permissionMode: z.enum(['default', 'bypassPermissions', 'plan', 'acceptEdits', 'dontAsk', 'auto']),
|
|
173
|
+
settingsPatched: z.boolean().optional(),
|
|
174
|
+
hookSettingsFile: z.string().optional(),
|
|
175
|
+
lastHookAt: z.number().optional(),
|
|
176
|
+
activeSubagents: z.array(z.string()).optional(),
|
|
177
|
+
permissionPromptAt: z.number().optional(),
|
|
178
|
+
permissionRespondedAt: z.number().optional(),
|
|
179
|
+
lastHookReceivedAt: z.number().optional(),
|
|
180
|
+
lastHookEventAt: z.number().optional(),
|
|
181
|
+
model: z.string().optional(),
|
|
182
|
+
lastDeadAt: z.number().optional(),
|
|
183
|
+
ccPid: z.number().optional(),
|
|
184
|
+
parentId: z.string().uuid().optional(),
|
|
185
|
+
children: z.array(z.string().uuid()).optional(),
|
|
186
|
+
permissionPolicy: z.array(z.object({
|
|
187
|
+
source: z.enum(['userSettings', 'projectSettings', 'localSettings', 'flagSettings', 'aegisApi']),
|
|
188
|
+
ruleBehavior: z.enum(['allow', 'deny', 'ask']),
|
|
189
|
+
toolName: z.string().optional(),
|
|
190
|
+
commandPattern: z.string().optional(),
|
|
191
|
+
})).optional(),
|
|
192
|
+
permissionProfile: permissionProfileSchema.optional(),
|
|
193
|
+
}));
|
|
194
|
+
/** Schema for a single continuation pointer entry in session_map.json (Issue #900). */
|
|
195
|
+
export const sessionMapEntrySchema = z.object({
|
|
196
|
+
session_id: z.string(),
|
|
197
|
+
cwd: z.string(),
|
|
198
|
+
window_name: z.string(),
|
|
199
|
+
transcript_path: z.string().nullable().optional(),
|
|
200
|
+
permission_mode: z.string().nullable().optional(),
|
|
201
|
+
agent_id: z.string().nullable().optional(),
|
|
202
|
+
source: z.string().nullable().optional(),
|
|
203
|
+
agent_type: z.string().nullable().optional(),
|
|
204
|
+
model: z.string().nullable().optional(),
|
|
205
|
+
written_at: z.number(),
|
|
206
|
+
schema_version: z.number().int().positive().optional(),
|
|
207
|
+
expires_at: z.number().optional(),
|
|
208
|
+
});
|
|
209
|
+
/** Schema for session_map.json entries. */
|
|
210
|
+
export const sessionMapSchema = z.record(z.string(), sessionMapEntrySchema);
|
|
211
|
+
/** Incoming Stop/StopFailure hook payload (Issue #515). */
|
|
212
|
+
export const stopPayloadSchema = z.object({
|
|
213
|
+
error: z.string().optional(),
|
|
214
|
+
message: z.string().optional(),
|
|
215
|
+
error_details: z.unknown().optional(),
|
|
216
|
+
last_assistant_message: z.unknown().optional(),
|
|
217
|
+
agent_id: z.string().optional(),
|
|
218
|
+
stop_reason: z.string().optional(),
|
|
219
|
+
}).passthrough();
|
|
220
|
+
/** Schema for stop_signals.json entries. */
|
|
221
|
+
export const stopSignalsSchema = z.record(z.string(), z.object({
|
|
222
|
+
event: z.string().optional(),
|
|
223
|
+
timestamp: z.number().optional(),
|
|
224
|
+
error: z.unknown().optional(),
|
|
225
|
+
error_details: z.unknown().optional(),
|
|
226
|
+
last_assistant_message: z.unknown().optional(),
|
|
227
|
+
agent_id: z.unknown().optional(),
|
|
228
|
+
stop_reason: z.string().optional(),
|
|
229
|
+
}));
|
|
230
|
+
/** Schema for persisted auth keys store (Issue #506). */
|
|
231
|
+
export const authStoreSchema = z.object({
|
|
232
|
+
keys: z.array(z.object({
|
|
233
|
+
id: z.string(),
|
|
234
|
+
name: z.string(),
|
|
235
|
+
hash: z.string(),
|
|
236
|
+
createdAt: z.number(),
|
|
237
|
+
lastUsedAt: z.number(),
|
|
238
|
+
rateLimit: z.number(),
|
|
239
|
+
})),
|
|
240
|
+
});
|
|
241
|
+
/** Schema for sessions-index.json entries (Issue #506). */
|
|
242
|
+
export const sessionsIndexSchema = z.object({
|
|
243
|
+
entries: z.array(z.object({
|
|
244
|
+
sessionId: z.string(),
|
|
245
|
+
fullPath: z.string(),
|
|
246
|
+
})).optional(),
|
|
247
|
+
});
|
|
248
|
+
/** Schema for persisted metrics file (Issue #506). */
|
|
249
|
+
export const metricsFileSchema = z.object({
|
|
250
|
+
global: z.object({
|
|
251
|
+
sessionsCreated: z.number().optional(),
|
|
252
|
+
sessionsCompleted: z.number().optional(),
|
|
253
|
+
sessionsFailed: z.number().optional(),
|
|
254
|
+
totalMessages: z.number().optional(),
|
|
255
|
+
totalToolCalls: z.number().optional(),
|
|
256
|
+
autoApprovals: z.number().optional(),
|
|
257
|
+
webhooksSent: z.number().optional(),
|
|
258
|
+
webhooksFailed: z.number().optional(),
|
|
259
|
+
screenshotsTaken: z.number().optional(),
|
|
260
|
+
pipelinesCreated: z.number().optional(),
|
|
261
|
+
batchesCreated: z.number().optional(),
|
|
262
|
+
promptsSent: z.number().optional(),
|
|
263
|
+
promptsDelivered: z.number().optional(),
|
|
264
|
+
promptsFailed: z.number().optional(),
|
|
265
|
+
}).passthrough().optional(),
|
|
266
|
+
savedAt: z.number().optional(),
|
|
267
|
+
}).passthrough();
|
|
268
|
+
/** Schema for WebSocket inbound messages (Issue #506). */
|
|
269
|
+
export const wsInboundMessageSchema = z.discriminatedUnion('type', [
|
|
270
|
+
z.object({ type: z.literal('input'), text: z.string() }).strict(),
|
|
271
|
+
z.object({ type: z.literal('resize'), cols: z.number().optional(), rows: z.number().optional() }).strict(),
|
|
272
|
+
z.object({ type: z.literal('auth'), token: z.string().optional() }).strict(),
|
|
273
|
+
]);
|
|
274
|
+
/** Schema for CC settings.json shape (Issue #506).
|
|
275
|
+
* Permissive — only validates the fields Aegis cares about. */
|
|
276
|
+
export const ccSettingsSchema = z.object({
|
|
277
|
+
permissions: z.object({
|
|
278
|
+
defaultMode: z.string().optional(),
|
|
279
|
+
}).passthrough().optional(),
|
|
280
|
+
}).passthrough();
|
|
281
|
+
/** Helper: extract error message from unknown catch value. */
|
|
282
|
+
export function getErrorMessage(e) {
|
|
283
|
+
if (e instanceof Error)
|
|
284
|
+
return e.message;
|
|
285
|
+
if (typeof e === 'string')
|
|
286
|
+
return e;
|
|
287
|
+
return String(e);
|
|
288
|
+
}
|
|
289
|
+
// ── CC version validation (Issue #564) ─────────────────────────────────
|
|
290
|
+
/** Minimum supported Claude Code version. */
|
|
291
|
+
export const MIN_CC_VERSION = '2.1.80';
|
|
292
|
+
/** Parse a semver string into [major, minor, patch], or null if invalid. */
|
|
293
|
+
export function parseSemver(v) {
|
|
294
|
+
const match = v.trim().match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
295
|
+
if (!match)
|
|
296
|
+
return null;
|
|
297
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Compare two semver strings.
|
|
301
|
+
* Returns -1 if a < b, 0 if equal or either is unparseable (fails open), 1 if a > b.
|
|
302
|
+
*/
|
|
303
|
+
export function compareSemver(a, b) {
|
|
304
|
+
const pa = parseSemver(a);
|
|
305
|
+
const pb = parseSemver(b);
|
|
306
|
+
if (!pa || !pb)
|
|
307
|
+
return 0;
|
|
308
|
+
for (let i = 0; i < 3; i++) {
|
|
309
|
+
if (pa[i] < pb[i])
|
|
310
|
+
return -1;
|
|
311
|
+
if (pa[i] > pb[i])
|
|
312
|
+
return 1;
|
|
313
|
+
}
|
|
314
|
+
return 0;
|
|
315
|
+
}
|
|
316
|
+
/** Extract version number from `claude --version` output. */
|
|
317
|
+
export function extractCCVersion(output) {
|
|
318
|
+
const match = output.match(/(\d+\.\d+\.\d+)/);
|
|
319
|
+
return match ? match[1] : null;
|
|
320
|
+
}
|
|
321
|
+
/** Default safe base directories used when allowedWorkDirs is not configured.
|
|
322
|
+
* Prevents sessions from running in system-critical directories. */
|
|
323
|
+
function getDefaultSafeDirs() {
|
|
324
|
+
return [
|
|
325
|
+
os.homedir(),
|
|
326
|
+
'/tmp',
|
|
327
|
+
'/var/tmp',
|
|
328
|
+
process.cwd(),
|
|
329
|
+
];
|
|
330
|
+
}
|
|
331
|
+
/** Returns true when any path segment resolves to "..".
|
|
332
|
+
* Checks raw, separator-normalized, and percent-decoded forms to catch
|
|
333
|
+
* encoded traversal like %2e%2e and mixed slash/backslash payloads. */
|
|
334
|
+
export function containsTraversalSegment(inputPath) {
|
|
335
|
+
const hasTraversalInSegments = (candidate) => {
|
|
336
|
+
const normalizedSeparators = candidate.replace(/[\\/]+/g, '/');
|
|
337
|
+
const segments = normalizedSeparators.split('/');
|
|
338
|
+
return segments.some((segment) => segment === '..');
|
|
339
|
+
};
|
|
340
|
+
let candidate = inputPath;
|
|
341
|
+
for (let i = 0; i < 4; i++) {
|
|
342
|
+
if (hasTraversalInSegments(candidate))
|
|
343
|
+
return true;
|
|
344
|
+
let decoded;
|
|
345
|
+
try {
|
|
346
|
+
decoded = decodeURIComponent(candidate);
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
decoded = candidate;
|
|
350
|
+
}
|
|
351
|
+
if (decoded === candidate)
|
|
352
|
+
break;
|
|
353
|
+
candidate = decoded;
|
|
354
|
+
}
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
/** Normalize path for consistent boundary comparisons. */
|
|
358
|
+
function normalizeForBoundaryCheck(inputPath) {
|
|
359
|
+
const resolved = path.normalize(path.resolve(inputPath));
|
|
360
|
+
const root = path.parse(resolved).root;
|
|
361
|
+
const trimmed = resolved.length > root.length
|
|
362
|
+
? resolved.replace(/[\\/]+$/g, '')
|
|
363
|
+
: resolved;
|
|
364
|
+
return process.platform === 'win32' ? trimmed.toLowerCase() : trimmed;
|
|
365
|
+
}
|
|
366
|
+
/** Check whether `childPath` is equal to or under `parentPath`. */
|
|
367
|
+
function isUnderOrEqual(childPath, parentPath) {
|
|
368
|
+
const normalizedChild = normalizeForBoundaryCheck(childPath);
|
|
369
|
+
const normalizedParent = normalizeForBoundaryCheck(parentPath);
|
|
370
|
+
if (normalizedChild === normalizedParent)
|
|
371
|
+
return true;
|
|
372
|
+
const relative = path.relative(normalizedParent, normalizedChild);
|
|
373
|
+
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
374
|
+
}
|
|
375
|
+
/** Validate workDir to prevent path traversal attacks (Issue #435).
|
|
376
|
+
* 1. Reject raw strings containing ".." before any normalization.
|
|
377
|
+
* 2. Resolve to absolute path and resolve symlinks via fs.realpath().
|
|
378
|
+
* 3. Verify the resolved path is under an allowed directory:
|
|
379
|
+
* - If allowedWorkDirs is configured, use that list.
|
|
380
|
+
* - Otherwise, use default safe dirs (home, /tmp, cwd).
|
|
381
|
+
* Returns the resolved real path on success, or an error object on failure. */
|
|
382
|
+
export async function validateWorkDir(workDir, allowedWorkDirs = []) {
|
|
383
|
+
if (typeof workDir !== 'string')
|
|
384
|
+
return { error: 'workDir must be a string', code: 'INVALID_WORKDIR' };
|
|
385
|
+
// Step 1: Reject path traversal in raw/mixed/decoded forms before resolution.
|
|
386
|
+
if (containsTraversalSegment(workDir)) {
|
|
387
|
+
return { error: 'workDir must not contain path traversal components (..)', code: 'INVALID_WORKDIR' };
|
|
388
|
+
}
|
|
389
|
+
// Step 2: Resolve to absolute path and follow symlinks.
|
|
390
|
+
const resolved = path.resolve(workDir);
|
|
391
|
+
let realPath;
|
|
392
|
+
try {
|
|
393
|
+
realPath = await fs.realpath(resolved);
|
|
394
|
+
}
|
|
395
|
+
catch { /* path does not exist on disk */
|
|
396
|
+
return { error: `workDir does not exist: ${resolved}`, code: 'INVALID_WORKDIR' };
|
|
397
|
+
}
|
|
398
|
+
// Step 3: Directory allowlist check.
|
|
399
|
+
const safeDirCandidates = allowedWorkDirs.length > 0
|
|
400
|
+
? allowedWorkDirs.map((dir) => path.resolve(dir))
|
|
401
|
+
: getDefaultSafeDirs().map((dir) => path.resolve(dir));
|
|
402
|
+
const safeDirs = await Promise.all(safeDirCandidates.map(async (dir) => {
|
|
403
|
+
try {
|
|
404
|
+
return await fs.realpath(dir);
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
return dir;
|
|
408
|
+
}
|
|
409
|
+
}));
|
|
410
|
+
const allowed = safeDirs.some((dir) => isUnderOrEqual(realPath, dir));
|
|
411
|
+
if (!allowed) {
|
|
412
|
+
return { error: 'workDir is not in the allowed directories list', code: 'INVALID_WORKDIR' };
|
|
413
|
+
}
|
|
414
|
+
return realPath;
|
|
415
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { exec } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { statSync } from 'fs';
|
|
5
|
+
const execAsync = promisify(exec);
|
|
6
|
+
async function runCmd(cmd, { cwd, timeoutMs }) {
|
|
7
|
+
try {
|
|
8
|
+
const { stdout, stderr } = await execAsync(cmd, { cwd, timeout: Math.floor(timeoutMs / 1000), killSignal: 'SIGKILL' });
|
|
9
|
+
return { stdout, stderr, exitCode: 0 };
|
|
10
|
+
}
|
|
11
|
+
catch (e) {
|
|
12
|
+
const err = e;
|
|
13
|
+
return { stdout: err.stdout ?? '', stderr: err.stderr ?? '', exitCode: err.code ?? 1 };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export async function runVerification(workDir, criticalOnly = false) {
|
|
17
|
+
const start = Date.now();
|
|
18
|
+
const hasPackageJson = statSync(join(workDir, 'package.json')).isFile();
|
|
19
|
+
if (!hasPackageJson) {
|
|
20
|
+
return {
|
|
21
|
+
ok: false,
|
|
22
|
+
steps: [],
|
|
23
|
+
totalDurationMs: Date.now() - start,
|
|
24
|
+
summary: 'No package.json found — cannot verify',
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const steps = [];
|
|
28
|
+
const timeoutMs = 120_000;
|
|
29
|
+
// Step 1: tsc
|
|
30
|
+
const tscStart = Date.now();
|
|
31
|
+
const tscResult = await runCmd('npx tsc --noEmit', { cwd: workDir, timeoutMs });
|
|
32
|
+
const tscDuration = Date.now() - tscStart;
|
|
33
|
+
const tscOk = tscResult.exitCode === 0;
|
|
34
|
+
steps.push({
|
|
35
|
+
name: 'tsc',
|
|
36
|
+
ok: tscOk,
|
|
37
|
+
durationMs: tscDuration,
|
|
38
|
+
output: tscResult.stdout.slice(0, 2000),
|
|
39
|
+
error: tscOk ? undefined : (tscResult.stderr || tscResult.stdout).slice(0, 2000),
|
|
40
|
+
});
|
|
41
|
+
// Step 2: build
|
|
42
|
+
const buildStart = Date.now();
|
|
43
|
+
const buildResult = await runCmd('npm run build', { cwd: workDir, timeoutMs });
|
|
44
|
+
const buildDuration = Date.now() - buildStart;
|
|
45
|
+
const buildOk = buildResult.exitCode === 0;
|
|
46
|
+
steps.push({
|
|
47
|
+
name: 'build',
|
|
48
|
+
ok: buildOk,
|
|
49
|
+
durationMs: buildDuration,
|
|
50
|
+
output: buildResult.stdout.slice(0, 2000),
|
|
51
|
+
error: buildOk ? undefined : (buildResult.stderr || buildResult.stdout).slice(0, 2000),
|
|
52
|
+
});
|
|
53
|
+
// Step 3: test (unless criticalOnly)
|
|
54
|
+
let testOk = true;
|
|
55
|
+
if (!criticalOnly) {
|
|
56
|
+
const testStart = Date.now();
|
|
57
|
+
const testResult = await runCmd('npm test', { cwd: workDir, timeoutMs: 180_000 });
|
|
58
|
+
const testDuration = Date.now() - testStart;
|
|
59
|
+
testOk = testResult.exitCode === 0;
|
|
60
|
+
steps.push({ name: 'test', ok: testOk, durationMs: testDuration, output: testResult.stdout.slice(0, 2000), error: testOk ? undefined : (testResult.stderr || testResult.stdout).slice(0, 2000) });
|
|
61
|
+
}
|
|
62
|
+
const ok = tscOk && buildOk && (!criticalOnly || testOk);
|
|
63
|
+
const totalDurationMs = Date.now() - start;
|
|
64
|
+
const summary = ok
|
|
65
|
+
? `Verification passed: tsc ✅, build ✅${criticalOnly ? '' : ', test ✅'} (${totalDurationMs}ms)`
|
|
66
|
+
: `Verification failed: ${[
|
|
67
|
+
!tscOk ? 'tsc ❌' : '',
|
|
68
|
+
!buildOk ? 'build ❌' : '',
|
|
69
|
+
!criticalOnly && !testOk ? 'test ❌' : '',
|
|
70
|
+
].filter(Boolean).join(', ')} (${totalDurationMs}ms)`;
|
|
71
|
+
return { ok, steps, totalDurationMs, summary };
|
|
72
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worktree-lookup.ts — Worktree-aware session file discovery.
|
|
3
|
+
*
|
|
4
|
+
* Issue #884: Extends the single-directory findSessionFile with bounded fanout
|
|
5
|
+
* across sibling worktree project directories. Returns the freshest (most
|
|
6
|
+
* recently modified) matching JSONL file across all candidate dirs.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Find the freshest JSONL file for a given sessionId across multiple
|
|
10
|
+
* Claude projects directories.
|
|
11
|
+
*
|
|
12
|
+
* Search order:
|
|
13
|
+
* 1. Primary directory (existing `claudeProjectsDir` — normal path)
|
|
14
|
+
* 2. Sibling directories (fanout, bounded by maxCandidates)
|
|
15
|
+
*
|
|
16
|
+
* Returns the path with the highest mtime, or null if not found.
|
|
17
|
+
* Silently ignores unreadable/missing directories.
|
|
18
|
+
*
|
|
19
|
+
* @param sessionId Claude session UUID
|
|
20
|
+
* @param primaryDir Primary `~/.claude/projects` directory (searched first)
|
|
21
|
+
* @param siblingDirs Additional directories to search (fanout)
|
|
22
|
+
* @param maxCandidates Upper bound on sibling candidates to evaluate (default: 5)
|
|
23
|
+
*/
|
|
24
|
+
export declare function findSessionFileWithFanout(sessionId: string, primaryDir: string, siblingDirs: string[], maxCandidates?: number): Promise<string | null>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worktree-lookup.ts — Worktree-aware session file discovery.
|
|
3
|
+
*
|
|
4
|
+
* Issue #884: Extends the single-directory findSessionFile with bounded fanout
|
|
5
|
+
* across sibling worktree project directories. Returns the freshest (most
|
|
6
|
+
* recently modified) matching JSONL file across all candidate dirs.
|
|
7
|
+
*/
|
|
8
|
+
import { stat, readdir } from 'node:fs/promises';
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
/** Expand leading ~ to home directory. */
|
|
13
|
+
function expandTilde(p) {
|
|
14
|
+
return p.startsWith('~') ? join(homedir(), p.slice(1)) : p;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Find the freshest JSONL file for a given sessionId across multiple
|
|
18
|
+
* Claude projects directories.
|
|
19
|
+
*
|
|
20
|
+
* Search order:
|
|
21
|
+
* 1. Primary directory (existing `claudeProjectsDir` — normal path)
|
|
22
|
+
* 2. Sibling directories (fanout, bounded by maxCandidates)
|
|
23
|
+
*
|
|
24
|
+
* Returns the path with the highest mtime, or null if not found.
|
|
25
|
+
* Silently ignores unreadable/missing directories.
|
|
26
|
+
*
|
|
27
|
+
* @param sessionId Claude session UUID
|
|
28
|
+
* @param primaryDir Primary `~/.claude/projects` directory (searched first)
|
|
29
|
+
* @param siblingDirs Additional directories to search (fanout)
|
|
30
|
+
* @param maxCandidates Upper bound on sibling candidates to evaluate (default: 5)
|
|
31
|
+
*/
|
|
32
|
+
export async function findSessionFileWithFanout(sessionId, primaryDir, siblingDirs, maxCandidates = 5) {
|
|
33
|
+
const candidates = [];
|
|
34
|
+
// Helper: scan one projects dir for sessionId.jsonl files
|
|
35
|
+
async function scanDir(dir) {
|
|
36
|
+
const expanded = expandTilde(dir);
|
|
37
|
+
if (!existsSync(expanded))
|
|
38
|
+
return;
|
|
39
|
+
let entries;
|
|
40
|
+
try {
|
|
41
|
+
entries = await readdir(expanded, { withFileTypes: true, encoding: 'utf8' });
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return; // unreadable directory — skip
|
|
45
|
+
}
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
if (!entry.isDirectory())
|
|
48
|
+
continue;
|
|
49
|
+
const jsonlPath = join(expanded, entry.name, `${sessionId}.jsonl`);
|
|
50
|
+
if (existsSync(jsonlPath)) {
|
|
51
|
+
try {
|
|
52
|
+
const { mtimeMs } = await stat(jsonlPath);
|
|
53
|
+
candidates.push({ path: jsonlPath, mtimeMs });
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// stat failed — entry may have been deleted between existsSync and stat
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Always scan primary first
|
|
62
|
+
await scanDir(primaryDir);
|
|
63
|
+
// Fanout to siblings (bounded)
|
|
64
|
+
const bounded = siblingDirs.slice(0, maxCandidates);
|
|
65
|
+
await Promise.all(bounded.map(d => scanDir(d)));
|
|
66
|
+
if (candidates.length === 0)
|
|
67
|
+
return null;
|
|
68
|
+
// Return path with the highest mtime (freshest)
|
|
69
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
70
|
+
return candidates[0].path;
|
|
71
|
+
}
|
|
@@ -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;
|