ai-agent-session-center 2.3.3 → 2.3.5
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/client/assets/{AnalyticsView-DVDqblSH.js → AnalyticsView-qY6U6hm6.js} +1 -1
- package/dist/client/assets/{Charts.module-CQf7HQCb.js → Charts.module-DMrHdYE2.js} +1 -1
- package/dist/client/assets/{CyberdromeScene-w4bk6IXJ.js → CyberdromeScene-BAHIVPjO.js} +1 -1
- package/dist/client/assets/{HistoryView--XwKlCyd.js → HistoryView-DBPWSMVy.js} +1 -1
- package/dist/client/assets/{ProjectBrowserView-CTN5CmBa.js → ProjectBrowserView-BVsrZVHg.js} +1 -1
- package/dist/client/assets/{QueueView-Bj-e0m_U.js → QueueView-t313VSQZ.js} +1 -1
- package/dist/client/assets/{TimelineView-CoTsTAGd.js → TimelineView-COH0s3pN.js} +1 -1
- package/dist/client/assets/index-DcItPQrq.js +130 -0
- package/dist/client/assets/index-b03MoG49.css +1 -0
- package/dist/client/assets/{useQuery-5GNo2Ewt.js → useQuery-C6BUH11S.js} +1 -1
- package/dist/client/assets/{with-selector-DTnjuyBc.js → with-selector-Cw2vedS9.js} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/client/screenshot-mobile-history.png +0 -0
- package/dist/client/screenshot-mobile-home.png +0 -0
- package/dist/client/screenshot-mobile-project.png +0 -0
- package/dist/client/screenshot-mobile-terminal.png +0 -0
- package/package.json +1 -1
- package/server/apiRouter.ts +145 -30
- package/server/approvalDetector.ts +2 -2
- package/server/index.ts +51 -18
- package/server/mqReader.ts +4 -4
- package/server/processMonitor.ts +12 -6
- package/server/sessionMatcher.ts +39 -0
- package/server/sessionStore.ts +4 -3
- package/server/wsManager.ts +51 -10
- package/dist/client/assets/index-Dgi6T0Nt.js +0 -128
- package/dist/client/assets/index-DqtLpLIs.css +0 -1
package/server/apiRouter.ts
CHANGED
|
@@ -18,8 +18,8 @@ import { getStats as getHookStats, resetStats as resetHookStats } from './hookSt
|
|
|
18
18
|
import * as db from './db.js';
|
|
19
19
|
import { getMqStats } from './mqReader.js';
|
|
20
20
|
import { execFile } from 'child_process';
|
|
21
|
-
import { readFileSync, writeFileSync, readdirSync, existsSync, statSync, mkdirSync } from 'fs';
|
|
22
|
-
import { join, dirname, extname, basename } from 'path';
|
|
21
|
+
import { createReadStream, readFileSync, writeFileSync, readdirSync, existsSync, statSync, mkdirSync } from 'fs';
|
|
22
|
+
import { join, dirname, extname, basename, resolve, sep } from 'path';
|
|
23
23
|
import { homedir, userInfo } from 'os';
|
|
24
24
|
import { fileURLToPath } from 'url';
|
|
25
25
|
import { ALL_CLAUDE_HOOK_EVENTS, DENSITY_EVENTS, SESSION_STATUS, WS_TYPES } from './constants.js';
|
|
@@ -68,12 +68,12 @@ const terminalCreateSchema = z.object({
|
|
|
68
68
|
host: noShellMeta(255).optional(),
|
|
69
69
|
port: z.number().int().min(1).max(65535).optional(),
|
|
70
70
|
username: usernameSchema.optional(),
|
|
71
|
-
password: z.string().optional(),
|
|
71
|
+
password: z.string().max(256).optional(),
|
|
72
72
|
privateKeyPath: z.string().optional(),
|
|
73
73
|
authMethod: authMethodSchema,
|
|
74
74
|
workingDir: noShellMetaWorkDir.optional(),
|
|
75
75
|
command: noShellMeta(512).optional(),
|
|
76
|
-
apiKey: z.string().optional(),
|
|
76
|
+
apiKey: z.string().max(512).optional(),
|
|
77
77
|
tmuxSession: z.string().regex(/^[a-zA-Z0-9_.\-]+$/, 'must be alphanumeric, dash, underscore, or dot').optional(),
|
|
78
78
|
useTmux: z.boolean().optional(),
|
|
79
79
|
sessionTitle: z.string().max(500).optional(),
|
|
@@ -242,7 +242,8 @@ router.get('/hooks/status', (_req: Request, res: Response) => {
|
|
|
242
242
|
res.json({ installed: installedEvents.length > 0, density, events: installedEvents });
|
|
243
243
|
} catch (err: unknown) {
|
|
244
244
|
const msg = err instanceof Error ? err.message : String(err);
|
|
245
|
-
|
|
245
|
+
log.error('api', `Hook status check failed: ${msg}`);
|
|
246
|
+
res.status(500).json({ error: 'Failed to check hook status' });
|
|
246
247
|
}
|
|
247
248
|
});
|
|
248
249
|
|
|
@@ -285,11 +286,18 @@ router.post('/hooks/uninstall', (_req: Request, res: Response) => {
|
|
|
285
286
|
router.post('/sessions/:id/resume', async (req: Request, res: Response) => {
|
|
286
287
|
const sessionId = str(req.params.id);
|
|
287
288
|
|
|
289
|
+
// Validate session ID format to prevent command injection (only allow UUID-like chars)
|
|
290
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(sessionId)) {
|
|
291
|
+
res.status(400).json({ error: 'Invalid session ID format' });
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
288
295
|
const session = getSession(sessionId);
|
|
289
296
|
if (!session) { res.status(404).json({ error: 'Session not found' }); return; }
|
|
290
297
|
|
|
291
|
-
// Build resume command
|
|
292
|
-
const
|
|
298
|
+
// Build resume command with single-quoted session ID to prevent shell interpretation
|
|
299
|
+
const safeId = sessionId.replace(/'/g, "'\\''");
|
|
300
|
+
const resumeCmd = `claude --resume '${safeId}' || claude --continue`;
|
|
293
301
|
|
|
294
302
|
const allTerminals = getTerminals();
|
|
295
303
|
const terminalExists = session.lastTerminalId && allTerminals.some(t => t.terminalId === session.lastTerminalId);
|
|
@@ -357,7 +365,7 @@ router.post('/sessions/:id/resume', async (req: Request, res: Response) => {
|
|
|
357
365
|
} catch (err: unknown) {
|
|
358
366
|
const msg = err instanceof Error ? err.message : String(err);
|
|
359
367
|
log.error('api', `Resume with new terminal failed: ${msg}`);
|
|
360
|
-
res.status(500).json({ error:
|
|
368
|
+
res.status(500).json({ error: 'Failed to create new terminal' });
|
|
361
369
|
}
|
|
362
370
|
});
|
|
363
371
|
|
|
@@ -390,7 +398,7 @@ router.post('/sessions/:id/reconnect-terminal', async (req: Request, res: Respon
|
|
|
390
398
|
} catch (err: unknown) {
|
|
391
399
|
const msg = err instanceof Error ? err.message : String(err);
|
|
392
400
|
log.error('api', `Reconnect terminal failed: ${msg}`);
|
|
393
|
-
res.status(500).json({ error:
|
|
401
|
+
res.status(500).json({ error: 'Failed to reconnect terminal' });
|
|
394
402
|
}
|
|
395
403
|
});
|
|
396
404
|
|
|
@@ -418,7 +426,8 @@ router.post('/sessions/:id/kill', (req: Request, res: Response) => {
|
|
|
418
426
|
}, 3000);
|
|
419
427
|
} catch (e: unknown) {
|
|
420
428
|
const msg = e instanceof Error ? e.message : String(e);
|
|
421
|
-
|
|
429
|
+
log.error('api', `Failed to kill PID ${pid}: ${msg}`);
|
|
430
|
+
res.status(500).json({ error: 'Failed to terminate process' });
|
|
422
431
|
return;
|
|
423
432
|
}
|
|
424
433
|
}
|
|
@@ -536,7 +545,7 @@ router.post('/sessions/:id/summarize', async (req: Request, res: Response) => {
|
|
|
536
545
|
activeSummarizeRequests--;
|
|
537
546
|
const msg = err instanceof Error ? err.message : String(err);
|
|
538
547
|
log.error('api', `Summarize error: ${msg}`);
|
|
539
|
-
res.status(500).json({ success: false, error:
|
|
548
|
+
res.status(500).json({ success: false, error: 'Summarize failed' });
|
|
540
549
|
}
|
|
541
550
|
});
|
|
542
551
|
|
|
@@ -569,7 +578,8 @@ router.post('/tmux-sessions', async (req: Request, res: Response) => {
|
|
|
569
578
|
res.json({ sessions });
|
|
570
579
|
} catch (err: unknown) {
|
|
571
580
|
const msg = err instanceof Error ? err.message : String(err);
|
|
572
|
-
|
|
581
|
+
log.error('api', `Tmux session list failed: ${msg}`);
|
|
582
|
+
res.status(500).json({ error: 'Failed to list tmux sessions' });
|
|
573
583
|
}
|
|
574
584
|
});
|
|
575
585
|
|
|
@@ -623,7 +633,8 @@ router.post('/terminals', async (req: Request, res: Response) => {
|
|
|
623
633
|
res.json({ ok: true, terminalId });
|
|
624
634
|
} catch (err: unknown) {
|
|
625
635
|
const msg = err instanceof Error ? err.message : String(err);
|
|
626
|
-
|
|
636
|
+
log.error('api', `Terminal creation failed: ${msg}`);
|
|
637
|
+
res.status(500).json({ success: false, error: 'Failed to create terminal' });
|
|
627
638
|
}
|
|
628
639
|
});
|
|
629
640
|
|
|
@@ -644,6 +655,10 @@ router.post('/terminals/:id/write', (req: Request, res: Response) => {
|
|
|
644
655
|
res.status(400).json({ error: 'Missing or invalid "data" field' });
|
|
645
656
|
return;
|
|
646
657
|
}
|
|
658
|
+
if (data.length > 8192) {
|
|
659
|
+
res.status(400).json({ error: 'Data too large (max 8KB)' });
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
647
662
|
const terminals = getTerminals();
|
|
648
663
|
const exists = terminals.some((t) => t.terminalId === terminalId);
|
|
649
664
|
if (!exists) {
|
|
@@ -720,7 +735,7 @@ router.post('/teams/:teamId/members/:sessionId/terminal', async (req: Request, r
|
|
|
720
735
|
} catch (err: unknown) {
|
|
721
736
|
const msg = err instanceof Error ? err.message : String(err);
|
|
722
737
|
log.error('api', `Failed to attach to tmux pane ${tmuxPaneId}: ${msg}`);
|
|
723
|
-
res.status(500).json({ success: false, error:
|
|
738
|
+
res.status(500).json({ success: false, error: 'Failed to attach to tmux pane' });
|
|
724
739
|
}
|
|
725
740
|
});
|
|
726
741
|
|
|
@@ -738,8 +753,8 @@ router.get('/db/sessions', (req: Request, res: Response) => {
|
|
|
738
753
|
archived: (archived as string) || undefined,
|
|
739
754
|
sortBy: ((sortBy as string) || 'started_at') as 'started_at' | 'last_activity_at' | 'project_name' | 'status',
|
|
740
755
|
sortDir: ((sortDir as string) || 'desc') as 'asc' | 'desc',
|
|
741
|
-
page: page ?
|
|
742
|
-
pageSize: pageSize ?
|
|
756
|
+
page: Math.max(1, Math.min(1000, page ? parseInt(String(page), 10) || 1 : 1)),
|
|
757
|
+
pageSize: Math.max(1, Math.min(200, pageSize ? parseInt(String(pageSize), 10) || 50 : 50)),
|
|
743
758
|
});
|
|
744
759
|
res.json(result);
|
|
745
760
|
});
|
|
@@ -762,14 +777,19 @@ router.get('/db/projects', (_req: Request, res: Response) => {
|
|
|
762
777
|
res.json(db.getDistinctProjects());
|
|
763
778
|
});
|
|
764
779
|
|
|
765
|
-
// Full-text search across prompts and responses
|
|
780
|
+
// Full-text search across prompts and responses (rate-limited: expensive)
|
|
766
781
|
router.get('/db/search', (req: Request, res: Response) => {
|
|
782
|
+
const ip = req.ip || 'unknown';
|
|
783
|
+
if (isRateLimited(`db-search:${ip}`, 5)) {
|
|
784
|
+
res.status(429).json({ error: 'Rate limit exceeded' });
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
767
787
|
const { query, type, page, pageSize } = req.query;
|
|
768
788
|
res.json(db.fullTextSearch({
|
|
769
789
|
query: (query as string) || '',
|
|
770
790
|
type: (type as string) || 'all',
|
|
771
|
-
page: page ?
|
|
772
|
-
pageSize: pageSize ?
|
|
791
|
+
page: Math.max(1, Math.min(1000, page ? parseInt(String(page), 10) || 1 : 1)),
|
|
792
|
+
pageSize: Math.max(1, Math.min(200, pageSize ? parseInt(String(pageSize), 10) || 50 : 50)),
|
|
773
793
|
}));
|
|
774
794
|
});
|
|
775
795
|
|
|
@@ -805,7 +825,12 @@ router.get('/db/analytics/projects', (_req: Request, res: Response) => {
|
|
|
805
825
|
res.json(db.getActiveProjects());
|
|
806
826
|
});
|
|
807
827
|
|
|
808
|
-
router.get('/db/analytics/heatmap', (
|
|
828
|
+
router.get('/db/analytics/heatmap', (req: Request, res: Response) => {
|
|
829
|
+
const ip = req.ip || 'unknown';
|
|
830
|
+
if (isRateLimited(`heatmap:${ip}`, 2)) {
|
|
831
|
+
res.status(429).json({ error: 'Rate limit exceeded' });
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
809
834
|
res.json(db.getHeatmap());
|
|
810
835
|
});
|
|
811
836
|
|
|
@@ -902,6 +927,10 @@ const TEXT_NAMES = new Set([
|
|
|
902
927
|
]);
|
|
903
928
|
|
|
904
929
|
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
|
|
930
|
+
const MAX_STREAMABLE_SIZE = 100 * 1024 * 1024; // 100 MB (for PDF/image streaming)
|
|
931
|
+
|
|
932
|
+
/** Extensions that can be streamed directly to the browser (not read into JSON). */
|
|
933
|
+
const STREAMABLE_EXTENSIONS = new Set(['.pdf']);
|
|
905
934
|
|
|
906
935
|
/** Directories to skip when listing. */
|
|
907
936
|
const HIDDEN_DIRS = new Set([
|
|
@@ -915,13 +944,30 @@ function isTextFile(name: string): boolean {
|
|
|
915
944
|
return ext === '' || TEXT_EXTENSIONS.has(ext);
|
|
916
945
|
}
|
|
917
946
|
|
|
947
|
+
/** Validate that root is a known/safe project path — blocks dangerous roots like /. */
|
|
948
|
+
function isAllowedProjectRoot(root: string): boolean {
|
|
949
|
+
if (!root) return false;
|
|
950
|
+
// Must be an absolute path
|
|
951
|
+
if (!root.startsWith('/') && !/^[A-Z]:\\/.test(root)) return false;
|
|
952
|
+
// Block shallow roots: /, /etc, /home, /Users, etc.
|
|
953
|
+
const segments = root.split('/').filter(Boolean);
|
|
954
|
+
if (segments.length < 2) return false;
|
|
955
|
+
// Block specific dangerous roots
|
|
956
|
+
const blocked = ['/', '/etc', '/root', '/tmp', '/var', '/bin', '/sbin', '/usr', '/dev', '/proc', '/sys'];
|
|
957
|
+
if (blocked.includes(root)) return false;
|
|
958
|
+
return true;
|
|
959
|
+
}
|
|
960
|
+
|
|
918
961
|
/** Resolve and validate a requested path is within the project root. */
|
|
919
962
|
function resolveProjectPath(projectRoot: string, relPath: string): string | null {
|
|
920
|
-
// Prevent traversal:
|
|
921
|
-
|
|
922
|
-
const
|
|
923
|
-
|
|
924
|
-
|
|
963
|
+
// Prevent traversal: resolve to absolute, then ensure it's within root
|
|
964
|
+
// Strip leading '/' so path.resolve treats it as relative to projectRoot
|
|
965
|
+
const cleaned = relPath.replace(/^\/+/, '');
|
|
966
|
+
const rootWithSep = projectRoot.endsWith(sep) ? projectRoot : projectRoot + sep;
|
|
967
|
+
const resolved = resolve(projectRoot, cleaned);
|
|
968
|
+
// Must either equal the root exactly or be underneath it (with separator check)
|
|
969
|
+
if (resolved !== projectRoot && !resolved.startsWith(rootWithSep)) return null;
|
|
970
|
+
return resolved;
|
|
925
971
|
}
|
|
926
972
|
|
|
927
973
|
const filePathSchema = z.object({
|
|
@@ -932,6 +978,7 @@ const filePathSchema = z.object({
|
|
|
932
978
|
router.get('/files/list', (req: Request, res: Response) => {
|
|
933
979
|
const root = str(req.query.root);
|
|
934
980
|
if (!root) { res.status(400).json({ error: 'root query param required' }); return; }
|
|
981
|
+
if (!isAllowedProjectRoot(root)) { res.status(400).json({ error: 'Invalid project root' }); return; }
|
|
935
982
|
|
|
936
983
|
const body = filePathSchema.safeParse({ path: str(req.query.path) || '/' });
|
|
937
984
|
if (!body.success) { res.status(400).json({ error: 'Invalid path' }); return; }
|
|
@@ -976,7 +1023,8 @@ router.get('/files/list', (req: Request, res: Response) => {
|
|
|
976
1023
|
res.json({ path: relPath, items });
|
|
977
1024
|
} catch (err: unknown) {
|
|
978
1025
|
const msg = err instanceof Error ? err.message : String(err);
|
|
979
|
-
|
|
1026
|
+
log.error('api', `File list failed: ${msg}`);
|
|
1027
|
+
res.status(500).json({ error: 'Failed to list directory' });
|
|
980
1028
|
}
|
|
981
1029
|
});
|
|
982
1030
|
|
|
@@ -984,6 +1032,7 @@ router.get('/files/list', (req: Request, res: Response) => {
|
|
|
984
1032
|
router.get('/files/read', (req: Request, res: Response) => {
|
|
985
1033
|
const root = str(req.query.root);
|
|
986
1034
|
if (!root) { res.status(400).json({ error: 'root query param required' }); return; }
|
|
1035
|
+
if (!isAllowedProjectRoot(root)) { res.status(400).json({ error: 'Invalid project root' }); return; }
|
|
987
1036
|
|
|
988
1037
|
const body = filePathSchema.safeParse({ path: str(req.query.path) });
|
|
989
1038
|
if (!body.success) { res.status(400).json({ error: 'Invalid path' }); return; }
|
|
@@ -997,9 +1046,21 @@ router.get('/files/read', (req: Request, res: Response) => {
|
|
|
997
1046
|
|
|
998
1047
|
const stat = statSync(fullPath);
|
|
999
1048
|
if (stat.isDirectory()) { res.status(400).json({ error: 'Path is a directory, not a file' }); return; }
|
|
1049
|
+
const name = basename(fullPath);
|
|
1050
|
+
const fileExt = extname(name).toLowerCase();
|
|
1051
|
+
|
|
1052
|
+
// PDFs and other streamable files: return metadata with streamable flag (no size limit)
|
|
1053
|
+
if (STREAMABLE_EXTENSIONS.has(fileExt)) {
|
|
1054
|
+
if (stat.size > MAX_STREAMABLE_SIZE) {
|
|
1055
|
+
res.status(413).json({ error: `File too large (${(stat.size / 1024 / 1024).toFixed(1)} MB, max 100 MB)` });
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
res.json({ path: relPath, streamable: true, ext: fileExt.replace('.', ''), size: stat.size, name });
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1000
1062
|
if (stat.size > MAX_FILE_SIZE) { res.status(413).json({ error: `File too large (${(stat.size / 1024 / 1024).toFixed(1)} MB, max 2 MB)` }); return; }
|
|
1001
1063
|
|
|
1002
|
-
const name = basename(fullPath);
|
|
1003
1064
|
if (!isTextFile(name)) {
|
|
1004
1065
|
res.json({ path: relPath, binary: true, size: stat.size, name });
|
|
1005
1066
|
return;
|
|
@@ -1010,7 +1071,51 @@ router.get('/files/read', (req: Request, res: Response) => {
|
|
|
1010
1071
|
res.json({ path: relPath, content, ext, size: stat.size, name });
|
|
1011
1072
|
} catch (err: unknown) {
|
|
1012
1073
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1013
|
-
|
|
1074
|
+
log.error('api', `File read failed: ${msg}`);
|
|
1075
|
+
res.status(500).json({ error: 'Failed to read file' });
|
|
1076
|
+
}
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
/** GET /api/files/stream?root=<projectPath>&path=<relative> — stream a file (PDF, images) */
|
|
1080
|
+
router.get('/files/stream', (req: Request, res: Response) => {
|
|
1081
|
+
const root = str(req.query.root);
|
|
1082
|
+
if (!root) { res.status(400).json({ error: 'root query param required' }); return; }
|
|
1083
|
+
if (!isAllowedProjectRoot(root)) { res.status(400).json({ error: 'Invalid project root' }); return; }
|
|
1084
|
+
|
|
1085
|
+
const body = filePathSchema.safeParse({ path: str(req.query.path) });
|
|
1086
|
+
if (!body.success) { res.status(400).json({ error: 'Invalid path' }); return; }
|
|
1087
|
+
|
|
1088
|
+
const relPath = body.data.path;
|
|
1089
|
+
const fullPath = resolveProjectPath(root, relPath);
|
|
1090
|
+
if (!fullPath) { res.status(400).json({ error: 'Path outside project root' }); return; }
|
|
1091
|
+
|
|
1092
|
+
try {
|
|
1093
|
+
if (!existsSync(fullPath)) { res.status(404).json({ error: 'File not found' }); return; }
|
|
1094
|
+
|
|
1095
|
+
const stat = statSync(fullPath);
|
|
1096
|
+
if (stat.isDirectory()) { res.status(400).json({ error: 'Path is a directory' }); return; }
|
|
1097
|
+
if (stat.size > MAX_STREAMABLE_SIZE) { res.status(413).json({ error: 'File too large' }); return; }
|
|
1098
|
+
|
|
1099
|
+
const fileExt = extname(fullPath).toLowerCase();
|
|
1100
|
+
if (!STREAMABLE_EXTENSIONS.has(fileExt)) { res.status(400).json({ error: 'File type not streamable' }); return; }
|
|
1101
|
+
|
|
1102
|
+
const mimeMap: Record<string, string> = { '.pdf': 'application/pdf' };
|
|
1103
|
+
const contentType = mimeMap[fileExt] || 'application/octet-stream';
|
|
1104
|
+
|
|
1105
|
+
res.setHeader('Content-Type', contentType);
|
|
1106
|
+
res.setHeader('Content-Length', stat.size);
|
|
1107
|
+
const safeName = basename(fullPath).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
1108
|
+
res.setHeader('Content-Disposition', `inline; filename="${safeName}"`);
|
|
1109
|
+
|
|
1110
|
+
const stream = createReadStream(fullPath);
|
|
1111
|
+
stream.pipe(res);
|
|
1112
|
+
stream.on('error', () => {
|
|
1113
|
+
if (!res.headersSent) res.status(500).json({ error: 'Stream error' });
|
|
1114
|
+
});
|
|
1115
|
+
} catch (err: unknown) {
|
|
1116
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1117
|
+
log.error('api', `File stream failed: ${msg}`);
|
|
1118
|
+
if (!res.headersSent) res.status(500).json({ error: 'Failed to stream file' });
|
|
1014
1119
|
}
|
|
1015
1120
|
});
|
|
1016
1121
|
|
|
@@ -1026,6 +1131,7 @@ router.post('/files/write', (req: Request, res: Response) => {
|
|
|
1026
1131
|
if (!parsed.success) { res.status(400).json({ error: 'Invalid request body' }); return; }
|
|
1027
1132
|
|
|
1028
1133
|
const { root, path: relPath, content } = parsed.data;
|
|
1134
|
+
if (!isAllowedProjectRoot(root)) { res.status(400).json({ error: 'Invalid project root' }); return; }
|
|
1029
1135
|
const fullPath = resolveProjectPath(root, relPath);
|
|
1030
1136
|
if (!fullPath) { res.status(400).json({ error: 'Path outside project root' }); return; }
|
|
1031
1137
|
|
|
@@ -1040,7 +1146,8 @@ router.post('/files/write', (req: Request, res: Response) => {
|
|
|
1040
1146
|
res.json({ ok: true, path: relPath, size: stat.size });
|
|
1041
1147
|
} catch (err: unknown) {
|
|
1042
1148
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1043
|
-
|
|
1149
|
+
log.error('api', `File write failed: ${msg}`);
|
|
1150
|
+
res.status(500).json({ error: 'Failed to write file' });
|
|
1044
1151
|
}
|
|
1045
1152
|
});
|
|
1046
1153
|
|
|
@@ -1055,6 +1162,7 @@ router.post('/files/mkdir', (req: Request, res: Response) => {
|
|
|
1055
1162
|
if (!parsed.success) { res.status(400).json({ error: 'Invalid request body' }); return; }
|
|
1056
1163
|
|
|
1057
1164
|
const { root, path: relPath } = parsed.data;
|
|
1165
|
+
if (!isAllowedProjectRoot(root)) { res.status(400).json({ error: 'Invalid project root' }); return; }
|
|
1058
1166
|
const fullPath = resolveProjectPath(root, relPath);
|
|
1059
1167
|
if (!fullPath) { res.status(400).json({ error: 'Path outside project root' }); return; }
|
|
1060
1168
|
|
|
@@ -1064,15 +1172,22 @@ router.post('/files/mkdir', (req: Request, res: Response) => {
|
|
|
1064
1172
|
res.json({ ok: true, path: relPath });
|
|
1065
1173
|
} catch (err: unknown) {
|
|
1066
1174
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1067
|
-
|
|
1175
|
+
log.error('api', `Mkdir failed: ${msg}`);
|
|
1176
|
+
res.status(500).json({ error: 'Failed to create directory' });
|
|
1068
1177
|
}
|
|
1069
1178
|
});
|
|
1070
1179
|
|
|
1071
1180
|
/** GET /api/files/search?root=<projectPath>&q=<query> — fuzzy search file names */
|
|
1072
1181
|
router.get('/files/search', (req: Request, res: Response) => {
|
|
1182
|
+
const ip = req.ip || 'unknown';
|
|
1183
|
+
if (isRateLimited(`file-search:${ip}`, 5)) {
|
|
1184
|
+
res.status(429).json({ error: 'Rate limit exceeded' });
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1073
1187
|
const root = str(req.query.root);
|
|
1074
1188
|
const query = str(req.query.q).toLowerCase();
|
|
1075
1189
|
if (!root) { res.status(400).json({ error: 'root query param required' }); return; }
|
|
1190
|
+
if (!isAllowedProjectRoot(root)) { res.status(400).json({ error: 'Invalid project root' }); return; }
|
|
1076
1191
|
if (!query) { res.json({ results: [] }); return; }
|
|
1077
1192
|
|
|
1078
1193
|
const results: Array<{ path: string; name: string; type: 'dir' | 'file' }> = [];
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* If PostToolUse does not arrive within the timeout, the session transitions to approval/input status.
|
|
5
5
|
* PermissionRequest events provide a direct signal that bypasses the timeout heuristic.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { execFileSync } from 'child_process';
|
|
8
8
|
import { getToolTimeout, getToolCategory, getWaitingStatus, getWaitingLabel } from './config.js';
|
|
9
9
|
import { SESSION_STATUS, ANIMATION_STATE } from './constants.js';
|
|
10
10
|
import log from './logger.js';
|
|
@@ -28,7 +28,7 @@ export function hasChildProcesses(pid: number): boolean {
|
|
|
28
28
|
const validPid = validatePid(pid);
|
|
29
29
|
if (!validPid) return false;
|
|
30
30
|
try {
|
|
31
|
-
const out =
|
|
31
|
+
const out = execFileSync('pgrep', ['-P', String(validPid)], { encoding: 'utf-8', timeout: 2000 });
|
|
32
32
|
return out.trim().length > 0;
|
|
33
33
|
} catch (e: unknown) {
|
|
34
34
|
// #37: Return true on error as safer default — assume command is still running
|
package/server/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { createServer } from 'http';
|
|
|
5
5
|
import { WebSocketServer } from 'ws';
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
7
|
import { dirname, join } from 'path';
|
|
8
|
-
import {
|
|
8
|
+
import { execFile } from 'child_process';
|
|
9
9
|
import hookRouter from './hookRouter.js';
|
|
10
10
|
import { handleConnection, stopHeartbeat } from './wsManager.js';
|
|
11
11
|
import { getAllSessions, loadSnapshot, saveSnapshot, startPeriodicSave, stopPeriodicSave } from './sessionStore.js';
|
|
@@ -31,21 +31,25 @@ const noOpen = args.includes('--no-open');
|
|
|
31
31
|
|
|
32
32
|
const app = express();
|
|
33
33
|
const server = createServer(app);
|
|
34
|
-
const wss = new WebSocketServer({ server });
|
|
34
|
+
const wss = new WebSocketServer({ server, maxPayload: 64 * 1024 }); // 64KB max WS message
|
|
35
35
|
|
|
36
|
-
app.use(express.json({ limit: '
|
|
36
|
+
app.use(express.json({ limit: '2mb' }));
|
|
37
37
|
|
|
38
38
|
// -- Security headers --
|
|
39
|
-
app.use((
|
|
39
|
+
app.use((req, res, next) => {
|
|
40
40
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
41
41
|
res.setHeader('X-Frame-Options', 'DENY');
|
|
42
42
|
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
43
43
|
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
44
44
|
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
|
45
|
-
//
|
|
45
|
+
// HSTS when behind TLS terminating proxy
|
|
46
|
+
if (req.secure || req.headers['x-forwarded-proto'] === 'https') {
|
|
47
|
+
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
48
|
+
}
|
|
49
|
+
// CSP: restrict connect-src to self (covers ws:/wss: same-origin)
|
|
46
50
|
res.setHeader(
|
|
47
51
|
'Content-Security-Policy',
|
|
48
|
-
"default-src 'self'; script-src 'self' blob:; style-src 'self' 'unsafe-inline'; connect-src 'self'
|
|
52
|
+
"default-src 'self'; script-src 'self' blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' https://cdn.jsdelivr.net; img-src 'self' data: blob:; font-src 'self' data: https://cdn.jsdelivr.net; worker-src 'self' blob:; frame-src 'self' blob:",
|
|
49
53
|
);
|
|
50
54
|
next();
|
|
51
55
|
});
|
|
@@ -82,14 +86,18 @@ app.post('/api/auth/login', (req, res) => {
|
|
|
82
86
|
}
|
|
83
87
|
if (!verifyPassword(password, config.passwordHash ?? '')) {
|
|
84
88
|
recordLoginAttempt(ip);
|
|
89
|
+
log.warn('auth', `Failed login attempt from IP ${ip}`);
|
|
85
90
|
res.status(401).json({ error: 'Wrong password' });
|
|
86
91
|
return;
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
clearLoginAttempts(ip);
|
|
95
|
+
log.warn('auth', `Successful login from IP ${ip}`);
|
|
90
96
|
const token = createToken();
|
|
91
|
-
|
|
92
|
-
|
|
97
|
+
const isSecure = req.secure || req.headers['x-forwarded-proto'] === 'https';
|
|
98
|
+
const secureSuffix = isSecure ? '; Secure' : '';
|
|
99
|
+
res.setHeader('Set-Cookie', `auth_token=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${TOKEN_TTL_SECONDS}${secureSuffix}`);
|
|
100
|
+
res.json({ success: true, expiresIn: TOKEN_TTL_SECONDS });
|
|
93
101
|
});
|
|
94
102
|
|
|
95
103
|
app.post('/api/auth/refresh', (req, res) => {
|
|
@@ -103,8 +111,10 @@ app.post('/api/auth/refresh', (req, res) => {
|
|
|
103
111
|
res.status(401).json({ error: 'Token expired or invalid — please login again' });
|
|
104
112
|
return;
|
|
105
113
|
}
|
|
106
|
-
|
|
107
|
-
|
|
114
|
+
const isSecure = req.secure || req.headers['x-forwarded-proto'] === 'https';
|
|
115
|
+
const secureSuffix = isSecure ? '; Secure' : '';
|
|
116
|
+
res.setHeader('Set-Cookie', `auth_token=${newToken}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${TOKEN_TTL_SECONDS}${secureSuffix}`);
|
|
117
|
+
res.json({ success: true, expiresIn: TOKEN_TTL_SECONDS });
|
|
108
118
|
});
|
|
109
119
|
|
|
110
120
|
app.post('/api/auth/logout', (req, res) => {
|
|
@@ -128,12 +138,13 @@ app.get('/api/sessions', authMiddleware, (_req, res) => {
|
|
|
128
138
|
res.json(getAllSessions());
|
|
129
139
|
});
|
|
130
140
|
|
|
131
|
-
// Request logging middleware (debug mode only)
|
|
141
|
+
// Request logging middleware (debug mode only) — strip tokens from logged URLs
|
|
132
142
|
if (log.isDebug) {
|
|
133
143
|
app.use((req, res, next) => {
|
|
134
144
|
const start = Date.now();
|
|
135
145
|
res.on('finish', () => {
|
|
136
|
-
|
|
146
|
+
const sanitizedUrl = req.originalUrl.replace(/token=[^&]+/, 'token=***');
|
|
147
|
+
log.debug('http', `${req.method} ${sanitizedUrl} ${res.statusCode} ${Date.now() - start}ms`);
|
|
137
148
|
});
|
|
138
149
|
next();
|
|
139
150
|
});
|
|
@@ -144,10 +155,29 @@ app.get('/{*splat}', (_req, res) => {
|
|
|
144
155
|
res.sendFile(join(clientDir, 'index.html'));
|
|
145
156
|
});
|
|
146
157
|
|
|
147
|
-
// -- WebSocket with
|
|
158
|
+
// -- WebSocket with origin validation + auth --
|
|
148
159
|
wss.on('connection', (ws, req) => {
|
|
160
|
+
// Origin validation: only allow same-host connections to prevent CSWSH
|
|
161
|
+
const origin = req.headers.origin;
|
|
162
|
+
const host = req.headers.host;
|
|
163
|
+
if (origin && host) {
|
|
164
|
+
try {
|
|
165
|
+
const originHost = new URL(origin).host;
|
|
166
|
+
if (originHost !== host) {
|
|
167
|
+
log.warn('ws', `Rejected WebSocket from foreign origin: ${origin} (expected host: ${host})`);
|
|
168
|
+
ws.close(4003, 'Forbidden: origin mismatch');
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
log.warn('ws', `Rejected WebSocket with invalid origin: ${origin}`);
|
|
173
|
+
ws.close(4003, 'Forbidden: invalid origin');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
149
178
|
if (isPasswordEnabled()) {
|
|
150
|
-
|
|
179
|
+
// Prefer cookie-based auth (avoids token in URL query string)
|
|
180
|
+
const token = parseCookieToken(req.headers.cookie) ?? extractToken(req);
|
|
151
181
|
if (!validateToken(token)) {
|
|
152
182
|
log.debug('auth', 'Rejected unauthorized WebSocket connection');
|
|
153
183
|
ws.close(4001, 'Unauthorized');
|
|
@@ -170,7 +200,7 @@ function openBrowser(url: string): void {
|
|
|
170
200
|
const cmd = process.platform === 'darwin' ? 'open'
|
|
171
201
|
: process.platform === 'win32' ? 'start'
|
|
172
202
|
: 'xdg-open';
|
|
173
|
-
|
|
203
|
+
execFile(cmd, [url], { timeout: 5000 }, () => { /* ignore errors */ });
|
|
174
204
|
} catch {
|
|
175
205
|
// Browser open failed -- not critical
|
|
176
206
|
}
|
|
@@ -207,11 +237,14 @@ function onReady(): void {
|
|
|
207
237
|
if (isPasswordEnabled()) {
|
|
208
238
|
log.info('server', 'Password protection ENABLED -- login required (1h token TTL)');
|
|
209
239
|
} else {
|
|
210
|
-
// Warn if binding to all interfaces without password
|
|
240
|
+
// Warn/block if binding to all interfaces without password
|
|
211
241
|
const bindAddr = (server.address() as { address?: string } | null)?.address;
|
|
212
242
|
if (bindAddr === '0.0.0.0' || bindAddr === '::') {
|
|
213
|
-
log.
|
|
214
|
-
log.
|
|
243
|
+
log.error('server', '------------------------------------------------------------');
|
|
244
|
+
log.error('server', 'SECURITY: Server is publicly accessible WITHOUT a password!');
|
|
245
|
+
log.error('server', 'This is DANGEROUS. Anyone on the network has full access.');
|
|
246
|
+
log.error('server', 'Run `npm run setup` to set a password.');
|
|
247
|
+
log.error('server', '------------------------------------------------------------');
|
|
215
248
|
}
|
|
216
249
|
}
|
|
217
250
|
if (log.isDebug) {
|
package/server/mqReader.ts
CHANGED
|
@@ -60,12 +60,12 @@ export function startMqReader(options?: MqReaderOptions): void {
|
|
|
60
60
|
running = true;
|
|
61
61
|
mqStats.startedAt = Date.now();
|
|
62
62
|
|
|
63
|
-
// Ensure queue directory exists
|
|
64
|
-
mkdirSync(QUEUE_DIR, { recursive: true });
|
|
63
|
+
// Ensure queue directory exists with restrictive permissions (user-only)
|
|
64
|
+
mkdirSync(QUEUE_DIR, { recursive: true, mode: 0o700 });
|
|
65
65
|
|
|
66
|
-
// Create queue file if it doesn't exist
|
|
66
|
+
// Create queue file if it doesn't exist with restrictive permissions
|
|
67
67
|
if (!existsSync(QUEUE_FILE)) {
|
|
68
|
-
writeFileSync(QUEUE_FILE, '');
|
|
68
|
+
writeFileSync(QUEUE_FILE, '', { mode: 0o600 });
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
// Resume from snapshot offset or start from current EOF
|
package/server/processMonitor.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Auto-ends sessions whose processes have died (e.g., terminal closed abruptly).
|
|
5
5
|
* Also provides findClaudeProcess() with cached PID, pgrep, and lsof fallbacks.
|
|
6
6
|
*/
|
|
7
|
-
import { execSync } from 'child_process';
|
|
7
|
+
import { execSync, execFileSync } from 'child_process';
|
|
8
8
|
import { getTerminalForSession } from './sshManager.js';
|
|
9
9
|
import { SESSION_STATUS, ANIMATION_STATE, WS_TYPES } from './constants.js';
|
|
10
10
|
import { PROCESS_CHECK_INTERVAL } from './config.js';
|
|
@@ -167,7 +167,12 @@ export function findClaudeProcess(
|
|
|
167
167
|
if (pid) cachePid(pid, sessionId, session, pidToSession);
|
|
168
168
|
return pid || null;
|
|
169
169
|
} else {
|
|
170
|
-
|
|
170
|
+
let pidsOut: string;
|
|
171
|
+
try {
|
|
172
|
+
pidsOut = execFileSync('pgrep', ['-f', 'claude'], { encoding: 'utf-8', timeout: 5000 });
|
|
173
|
+
} catch {
|
|
174
|
+
pidsOut = ''; // pgrep exits non-zero when no matches
|
|
175
|
+
}
|
|
171
176
|
const pids = pidsOut.trim().split('\n')
|
|
172
177
|
.map(p => validatePid(p.trim()))
|
|
173
178
|
.filter((p): p is number => p !== null && p !== myPid);
|
|
@@ -185,10 +190,11 @@ export function findClaudeProcess(
|
|
|
185
190
|
try {
|
|
186
191
|
let cwd: string;
|
|
187
192
|
if (process.platform === 'darwin') {
|
|
188
|
-
const out =
|
|
189
|
-
|
|
193
|
+
const out = execFileSync('lsof', ['-a', '-d', 'cwd', '-Fn', '-p', String(pid)], { encoding: 'utf-8', timeout: 3000 });
|
|
194
|
+
const nLine = out.split('\n').find(l => l.startsWith('n'));
|
|
195
|
+
cwd = nLine ? nLine.slice(1).trim() : '';
|
|
190
196
|
} else {
|
|
191
|
-
cwd =
|
|
197
|
+
cwd = execFileSync('readlink', [`/proc/${pid}/cwd`], { encoding: 'utf-8', timeout: 3000 }).trim();
|
|
192
198
|
}
|
|
193
199
|
const match = cwd === projectPath;
|
|
194
200
|
log.debug('findProcess', `pid=${pid} cwd="${cwd}" ${match ? 'MATCH' : 'no match'}`);
|
|
@@ -207,7 +213,7 @@ export function findClaudeProcess(
|
|
|
207
213
|
for (const pid of pids) {
|
|
208
214
|
if (claimedPids.has(pid)) continue;
|
|
209
215
|
try {
|
|
210
|
-
const tty =
|
|
216
|
+
const tty = execFileSync('ps', ['-o', 'tty=', '-p', String(pid)], { encoding: 'utf-8', timeout: 3000 }).trim();
|
|
211
217
|
log.debug('findProcess', `fallback pid=${pid} tty=${tty || 'NONE'}`);
|
|
212
218
|
if (tty && tty !== '??' && tty !== '?') {
|
|
213
219
|
log.debug('findProcess', `FALLBACK returning pid=${pid} (first unclaimed with tty)`);
|