ai-agent-session-center 2.3.4 → 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-DNlJdVi-.js → AnalyticsView-qY6U6hm6.js} +1 -1
- package/dist/client/assets/{Charts.module-5XeHGYqJ.js → Charts.module-DMrHdYE2.js} +1 -1
- package/dist/client/assets/{CyberdromeScene-aUW_s3U3.js → CyberdromeScene-BAHIVPjO.js} +1 -1
- package/dist/client/assets/{HistoryView-Ba84o8Ve.js → HistoryView-DBPWSMVy.js} +1 -1
- package/dist/client/assets/{ProjectBrowserView-BJQfa_55.js → ProjectBrowserView-BVsrZVHg.js} +1 -1
- package/dist/client/assets/{QueueView-BIBIaYnL.js → QueueView-t313VSQZ.js} +1 -1
- package/dist/client/assets/{TimelineView-BVA9wGii.js → TimelineView-COH0s3pN.js} +1 -1
- package/dist/client/assets/{index-07XhMfpT.js → index-DcItPQrq.js} +58 -57
- package/dist/client/assets/index-b03MoG49.css +1 -0
- package/dist/client/assets/{useQuery-DmgoN3Xy.js → useQuery-C6BUH11S.js} +1 -1
- package/dist/client/assets/{with-selector-By5unHnw.js → with-selector-Cw2vedS9.js} +1 -1
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/server/apiRouter.ts +89 -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-DuIxdiCi.css +0 -1
package/server/apiRouter.ts
CHANGED
|
@@ -19,7 +19,7 @@ import * as db from './db.js';
|
|
|
19
19
|
import { getMqStats } from './mqReader.js';
|
|
20
20
|
import { execFile } from 'child_process';
|
|
21
21
|
import { createReadStream, readFileSync, writeFileSync, readdirSync, existsSync, statSync, mkdirSync } from 'fs';
|
|
22
|
-
import { join, dirname, extname, basename } from 'path';
|
|
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
|
|
|
@@ -919,13 +944,30 @@ function isTextFile(name: string): boolean {
|
|
|
919
944
|
return ext === '' || TEXT_EXTENSIONS.has(ext);
|
|
920
945
|
}
|
|
921
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
|
+
|
|
922
961
|
/** Resolve and validate a requested path is within the project root. */
|
|
923
962
|
function resolveProjectPath(projectRoot: string, relPath: string): string | null {
|
|
924
|
-
// Prevent traversal:
|
|
925
|
-
|
|
926
|
-
const
|
|
927
|
-
|
|
928
|
-
|
|
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;
|
|
929
971
|
}
|
|
930
972
|
|
|
931
973
|
const filePathSchema = z.object({
|
|
@@ -936,6 +978,7 @@ const filePathSchema = z.object({
|
|
|
936
978
|
router.get('/files/list', (req: Request, res: Response) => {
|
|
937
979
|
const root = str(req.query.root);
|
|
938
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; }
|
|
939
982
|
|
|
940
983
|
const body = filePathSchema.safeParse({ path: str(req.query.path) || '/' });
|
|
941
984
|
if (!body.success) { res.status(400).json({ error: 'Invalid path' }); return; }
|
|
@@ -980,7 +1023,8 @@ router.get('/files/list', (req: Request, res: Response) => {
|
|
|
980
1023
|
res.json({ path: relPath, items });
|
|
981
1024
|
} catch (err: unknown) {
|
|
982
1025
|
const msg = err instanceof Error ? err.message : String(err);
|
|
983
|
-
|
|
1026
|
+
log.error('api', `File list failed: ${msg}`);
|
|
1027
|
+
res.status(500).json({ error: 'Failed to list directory' });
|
|
984
1028
|
}
|
|
985
1029
|
});
|
|
986
1030
|
|
|
@@ -988,6 +1032,7 @@ router.get('/files/list', (req: Request, res: Response) => {
|
|
|
988
1032
|
router.get('/files/read', (req: Request, res: Response) => {
|
|
989
1033
|
const root = str(req.query.root);
|
|
990
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; }
|
|
991
1036
|
|
|
992
1037
|
const body = filePathSchema.safeParse({ path: str(req.query.path) });
|
|
993
1038
|
if (!body.success) { res.status(400).json({ error: 'Invalid path' }); return; }
|
|
@@ -1026,7 +1071,8 @@ router.get('/files/read', (req: Request, res: Response) => {
|
|
|
1026
1071
|
res.json({ path: relPath, content, ext, size: stat.size, name });
|
|
1027
1072
|
} catch (err: unknown) {
|
|
1028
1073
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1029
|
-
|
|
1074
|
+
log.error('api', `File read failed: ${msg}`);
|
|
1075
|
+
res.status(500).json({ error: 'Failed to read file' });
|
|
1030
1076
|
}
|
|
1031
1077
|
});
|
|
1032
1078
|
|
|
@@ -1034,6 +1080,7 @@ router.get('/files/read', (req: Request, res: Response) => {
|
|
|
1034
1080
|
router.get('/files/stream', (req: Request, res: Response) => {
|
|
1035
1081
|
const root = str(req.query.root);
|
|
1036
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; }
|
|
1037
1084
|
|
|
1038
1085
|
const body = filePathSchema.safeParse({ path: str(req.query.path) });
|
|
1039
1086
|
if (!body.success) { res.status(400).json({ error: 'Invalid path' }); return; }
|
|
@@ -1057,7 +1104,8 @@ router.get('/files/stream', (req: Request, res: Response) => {
|
|
|
1057
1104
|
|
|
1058
1105
|
res.setHeader('Content-Type', contentType);
|
|
1059
1106
|
res.setHeader('Content-Length', stat.size);
|
|
1060
|
-
|
|
1107
|
+
const safeName = basename(fullPath).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
1108
|
+
res.setHeader('Content-Disposition', `inline; filename="${safeName}"`);
|
|
1061
1109
|
|
|
1062
1110
|
const stream = createReadStream(fullPath);
|
|
1063
1111
|
stream.pipe(res);
|
|
@@ -1066,7 +1114,8 @@ router.get('/files/stream', (req: Request, res: Response) => {
|
|
|
1066
1114
|
});
|
|
1067
1115
|
} catch (err: unknown) {
|
|
1068
1116
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1069
|
-
|
|
1117
|
+
log.error('api', `File stream failed: ${msg}`);
|
|
1118
|
+
if (!res.headersSent) res.status(500).json({ error: 'Failed to stream file' });
|
|
1070
1119
|
}
|
|
1071
1120
|
});
|
|
1072
1121
|
|
|
@@ -1082,6 +1131,7 @@ router.post('/files/write', (req: Request, res: Response) => {
|
|
|
1082
1131
|
if (!parsed.success) { res.status(400).json({ error: 'Invalid request body' }); return; }
|
|
1083
1132
|
|
|
1084
1133
|
const { root, path: relPath, content } = parsed.data;
|
|
1134
|
+
if (!isAllowedProjectRoot(root)) { res.status(400).json({ error: 'Invalid project root' }); return; }
|
|
1085
1135
|
const fullPath = resolveProjectPath(root, relPath);
|
|
1086
1136
|
if (!fullPath) { res.status(400).json({ error: 'Path outside project root' }); return; }
|
|
1087
1137
|
|
|
@@ -1096,7 +1146,8 @@ router.post('/files/write', (req: Request, res: Response) => {
|
|
|
1096
1146
|
res.json({ ok: true, path: relPath, size: stat.size });
|
|
1097
1147
|
} catch (err: unknown) {
|
|
1098
1148
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1099
|
-
|
|
1149
|
+
log.error('api', `File write failed: ${msg}`);
|
|
1150
|
+
res.status(500).json({ error: 'Failed to write file' });
|
|
1100
1151
|
}
|
|
1101
1152
|
});
|
|
1102
1153
|
|
|
@@ -1111,6 +1162,7 @@ router.post('/files/mkdir', (req: Request, res: Response) => {
|
|
|
1111
1162
|
if (!parsed.success) { res.status(400).json({ error: 'Invalid request body' }); return; }
|
|
1112
1163
|
|
|
1113
1164
|
const { root, path: relPath } = parsed.data;
|
|
1165
|
+
if (!isAllowedProjectRoot(root)) { res.status(400).json({ error: 'Invalid project root' }); return; }
|
|
1114
1166
|
const fullPath = resolveProjectPath(root, relPath);
|
|
1115
1167
|
if (!fullPath) { res.status(400).json({ error: 'Path outside project root' }); return; }
|
|
1116
1168
|
|
|
@@ -1120,15 +1172,22 @@ router.post('/files/mkdir', (req: Request, res: Response) => {
|
|
|
1120
1172
|
res.json({ ok: true, path: relPath });
|
|
1121
1173
|
} catch (err: unknown) {
|
|
1122
1174
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1123
|
-
|
|
1175
|
+
log.error('api', `Mkdir failed: ${msg}`);
|
|
1176
|
+
res.status(500).json({ error: 'Failed to create directory' });
|
|
1124
1177
|
}
|
|
1125
1178
|
});
|
|
1126
1179
|
|
|
1127
1180
|
/** GET /api/files/search?root=<projectPath>&q=<query> — fuzzy search file names */
|
|
1128
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
|
+
}
|
|
1129
1187
|
const root = str(req.query.root);
|
|
1130
1188
|
const query = str(req.query.q).toLowerCase();
|
|
1131
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; }
|
|
1132
1191
|
if (!query) { res.json({ results: [] }); return; }
|
|
1133
1192
|
|
|
1134
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)`);
|
package/server/sessionMatcher.ts
CHANGED
|
@@ -50,6 +50,29 @@ export function reKeyResumedSession(
|
|
|
50
50
|
oldSession.cachedPid = null;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// Archive the old session data into previousSessions before resetting.
|
|
54
|
+
// Check dedup: resumeSession() already archives before calling this function,
|
|
55
|
+
// so only archive if the last entry doesn't match the old session ID.
|
|
56
|
+
const hasData = oldSession.promptHistory.length > 0 || oldSession.toolLog?.length > 0 || oldSession.events?.length > 0;
|
|
57
|
+
if (hasData) {
|
|
58
|
+
const lastPrev = oldSession.previousSessions?.[oldSession.previousSessions.length - 1];
|
|
59
|
+
if (!lastPrev || lastPrev.sessionId !== oldSessionId) {
|
|
60
|
+
if (!oldSession.previousSessions) oldSession.previousSessions = [];
|
|
61
|
+
oldSession.previousSessions.push({
|
|
62
|
+
sessionId: oldSessionId,
|
|
63
|
+
startedAt: oldSession.startedAt,
|
|
64
|
+
endedAt: oldSession.endedAt,
|
|
65
|
+
promptHistory: [...oldSession.promptHistory],
|
|
66
|
+
toolLog: [...(oldSession.toolLog || [])],
|
|
67
|
+
responseLog: [...(oldSession.responseLog || [])],
|
|
68
|
+
events: [...oldSession.events],
|
|
69
|
+
toolUsage: { ...oldSession.toolUsage },
|
|
70
|
+
totalToolCalls: oldSession.totalToolCalls,
|
|
71
|
+
});
|
|
72
|
+
if (oldSession.previousSessions.length > 5) oldSession.previousSessions.shift();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
53
76
|
oldSession.replacesId = oldSessionId;
|
|
54
77
|
oldSession.sessionId = newSessionId;
|
|
55
78
|
oldSession.status = SESSION_STATUS.IDLE;
|
|
@@ -294,6 +317,22 @@ export function matchSession(
|
|
|
294
317
|
}
|
|
295
318
|
}
|
|
296
319
|
|
|
320
|
+
// Priority 1.5: Match by cached PID — when Claude resumes with a new session_id
|
|
321
|
+
// but the same process (e.g., `claude --resume` creates a new session internally),
|
|
322
|
+
// link back to the same SSH terminal session instead of creating a duplicate card.
|
|
323
|
+
if (!session && hookData.claude_pid && hook_event_name === EVENT_TYPES.SESSION_START) {
|
|
324
|
+
const pid = Number(hookData.claude_pid);
|
|
325
|
+
const existingSessionId = pidToSession.get(pid);
|
|
326
|
+
if (existingSessionId && existingSessionId !== session_id) {
|
|
327
|
+
const existingSession = sessions.get(existingSessionId);
|
|
328
|
+
if (existingSession && existingSession.terminalId) {
|
|
329
|
+
session = reKeyResumedSession(sessions, existingSession, session_id, existingSessionId, pidToSession);
|
|
330
|
+
consumePendingLink(existingSession.projectPath || '');
|
|
331
|
+
log.info('session', `Re-keyed session ${existingSessionId?.slice(0, 8)} -> ${session_id?.slice(0, 8)} (via cached PID=${pid}, same process new session_id)`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
297
336
|
// Priority 2: Match via pending workDir link
|
|
298
337
|
if (!session) {
|
|
299
338
|
const linkedTerminalId = tryLinkByWorkDir(cwd || '', session_id);
|
package/server/sessionStore.ts
CHANGED
|
@@ -141,9 +141,9 @@ export function saveSnapshot(mqOffset?: number): void {
|
|
|
141
141
|
pidToSession: pidObj,
|
|
142
142
|
pendingResume: pendingResumeObj,
|
|
143
143
|
};
|
|
144
|
-
mkdirSync(SNAPSHOT_DIR, { recursive: true });
|
|
144
|
+
mkdirSync(SNAPSHOT_DIR, { recursive: true, mode: 0o700 });
|
|
145
145
|
const tmpFile = SNAPSHOT_FILE + '.tmp';
|
|
146
|
-
writeFileSync(tmpFile, JSON.stringify(snapshot));
|
|
146
|
+
writeFileSync(tmpFile, JSON.stringify(snapshot), { mode: 0o600 });
|
|
147
147
|
renameSync(tmpFile, SNAPSHOT_FILE);
|
|
148
148
|
log.debug('session', `Snapshot saved: ${Object.keys(sessionsObj).length} sessions`);
|
|
149
149
|
} catch (err: unknown) {
|
|
@@ -770,7 +770,8 @@ export function handleEvent(hookData: HookPayload): HandleEventResult | null {
|
|
|
770
770
|
if (session.source === 'ssh') {
|
|
771
771
|
session.isHistorical = true;
|
|
772
772
|
session.lastTerminalId = session.terminalId;
|
|
773
|
-
|
|
773
|
+
// Keep terminalId alive — the PTY shell is still running even though Claude exited.
|
|
774
|
+
// terminalId is nulled when the PTY actually dies (registerTerminalExitCallback).
|
|
774
775
|
}
|
|
775
776
|
// Non-SSH sessions are also kept (no auto-delete)
|
|
776
777
|
break;
|