@qiaolei81/copilot-session-viewer 0.1.9 → 0.2.1

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.
@@ -11,14 +11,17 @@ class UploadController {
11
11
  constructor() {
12
12
  this.SESSION_DIR = process.env.SESSION_DIR || path.join(os.homedir(), '.copilot', 'session-state');
13
13
  this.uploadDir = path.join(os.tmpdir(), 'copilot-session-uploads');
14
- this.initializeUploadDir();
15
- this.upload = this.createMulterInstance();
16
- }
17
14
 
18
- initializeUploadDir() {
19
- if (!fs.existsSync(this.uploadDir)) {
20
- fs.mkdirSync(this.uploadDir, { recursive: true });
21
- }
15
+ // Multi-format session directories
16
+ this.SESSION_DIRS = {
17
+ copilot: this.SESSION_DIR,
18
+ claude: path.join(os.homedir(), '.claude', 'projects'),
19
+ 'pi-mono': path.join(os.homedir(), '.pi', 'agent', 'sessions')
20
+ };
21
+
22
+ // Don't create uploadDir here - multer's DiskStorage will handle it
23
+ // This avoids EEXIST errors when multiple tests run in parallel
24
+ this.upload = this.createMulterInstance();
22
25
  }
23
26
 
24
27
  createMulterInstance() {
@@ -99,6 +102,80 @@ class UploadController {
99
102
 
100
103
  await fs.promises.mkdir(extractDir, { recursive: true });
101
104
 
105
+ // ZIP bomb protection: Check compressed file size first
106
+ const MAX_COMPRESSED_SIZE = 50 * 1024 * 1024; // 50MB (already enforced by multer)
107
+ const MAX_UNCOMPRESSED_SIZE = 200 * 1024 * 1024; // 200MB
108
+ const MAX_FILE_COUNT = 1000; // Maximum number of files
109
+ const MAX_DEPTH = 5; // Maximum directory nesting depth
110
+
111
+ const stats = await fs.promises.stat(zipPath);
112
+ if (stats.size > MAX_COMPRESSED_SIZE) {
113
+ await fs.promises.unlink(zipPath);
114
+ return res.status(400).json({ error: 'Compressed file too large (max 50MB)' });
115
+ }
116
+
117
+ // First pass: List zip contents without extracting to check for bombs
118
+ const listProcess = spawn('unzip', ['-l', zipPath]);
119
+ let listOutput = '';
120
+
121
+ listProcess.stdout.on('data', (data) => {
122
+ listOutput += data.toString();
123
+ });
124
+
125
+ await new Promise((resolve, reject) => {
126
+ listProcess.on('close', (code) => {
127
+ if (code !== 0) {
128
+ reject(new Error('Failed to list zip contents'));
129
+ } else {
130
+ resolve();
131
+ }
132
+ });
133
+ listProcess.on('error', reject);
134
+ });
135
+
136
+ // Parse unzip output to check total size and file count
137
+ const lines = listOutput.split('\n');
138
+ let totalUncompressedSize = 0;
139
+ let fileCount = 0;
140
+ let maxDepth = 0;
141
+
142
+ for (const line of lines) {
143
+ const match = line.trim().match(/^\s*(\d+)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+(.+)$/);
144
+ if (match) {
145
+ const size = parseInt(match[1]);
146
+ const filename = match[2];
147
+ totalUncompressedSize += size;
148
+ fileCount++;
149
+
150
+ // Check directory depth
151
+ const depth = (filename.match(/\//g) || []).length;
152
+ maxDepth = Math.max(maxDepth, depth);
153
+ }
154
+ }
155
+
156
+ // Validate against ZIP bomb thresholds
157
+ if (totalUncompressedSize > MAX_UNCOMPRESSED_SIZE) {
158
+ await fs.promises.unlink(zipPath);
159
+ return res.status(400).json({
160
+ error: `Uncompressed size too large (${Math.round(totalUncompressedSize / 1024 / 1024)}MB > ${MAX_UNCOMPRESSED_SIZE / 1024 / 1024}MB)`
161
+ });
162
+ }
163
+
164
+ if (fileCount > MAX_FILE_COUNT) {
165
+ await fs.promises.unlink(zipPath);
166
+ return res.status(400).json({
167
+ error: `Too many files in archive (${fileCount} > ${MAX_FILE_COUNT})`
168
+ });
169
+ }
170
+
171
+ if (maxDepth > MAX_DEPTH) {
172
+ await fs.promises.unlink(zipPath);
173
+ return res.status(400).json({
174
+ error: `Directory nesting too deep (${maxDepth} > ${MAX_DEPTH})`
175
+ });
176
+ }
177
+
178
+ // If all checks pass, proceed with extraction
102
179
  const unzipProcess = spawn('unzip', ['-q', zipPath, '-d', extractDir]);
103
180
 
104
181
  processManager.register(unzipProcess, { name: 'unzip-import' });
@@ -172,6 +249,369 @@ class UploadController {
172
249
  getUploadMiddleware() {
173
250
  return this.upload.single('zipFile');
174
251
  }
252
+
253
+ /**
254
+ * Detect the format of a session from extracted directory
255
+ * @param {string} extractDir - Directory containing extracted session files
256
+ * @returns {Promise<Object|null>} Format information or null if unknown
257
+ */
258
+ async _detectFormat(extractDir) {
259
+ try {
260
+ const entries = await fs.promises.readdir(extractDir);
261
+
262
+ if (entries.length === 0) {
263
+ return null;
264
+ }
265
+
266
+ // Check for Pi-Mono format: timestamped filename pattern YYYY-MM-DDTHH-MM-SS-SSSZ_sessionId.jsonl
267
+ const piMonoPattern = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z_([a-zA-Z0-9_-]+)\.jsonl$/;
268
+ for (const entry of entries) {
269
+ const match = entry.match(piMonoPattern);
270
+ if (match) {
271
+ return {
272
+ format: 'pi-mono',
273
+ sessionId: match[1],
274
+ fileName: entry,
275
+ extractDir
276
+ };
277
+ }
278
+ }
279
+
280
+ // Check for Copilot format: directory with events.jsonl
281
+ for (const entry of entries) {
282
+ const entryPath = path.join(extractDir, entry);
283
+ const stat = await fs.promises.stat(entryPath);
284
+ if (stat.isDirectory()) {
285
+ const eventsFile = path.join(entryPath, 'events.jsonl');
286
+ if (fs.existsSync(eventsFile)) {
287
+ return {
288
+ format: 'copilot',
289
+ sessionId: entry,
290
+ directoryName: entry,
291
+ extractDir
292
+ };
293
+ }
294
+ }
295
+ }
296
+
297
+ // Check for Claude format: uuid.jsonl file
298
+ const claudePattern = /^([a-zA-Z0-9_-]+)\.jsonl$/;
299
+ for (const entry of entries) {
300
+ const entryPath = path.join(extractDir, entry);
301
+ const stat = await fs.promises.stat(entryPath);
302
+
303
+ if (stat.isFile()) {
304
+ const match = entry.match(claudePattern);
305
+ if (match) {
306
+ const sessionId = match[1];
307
+ // Check if there's an optional directory with the same name
308
+ const sessionDir = path.join(extractDir, sessionId);
309
+ const hasDirectory = fs.existsSync(sessionDir);
310
+
311
+ return {
312
+ format: 'claude',
313
+ sessionId,
314
+ fileName: entry,
315
+ hasDirectory,
316
+ directoryName: hasDirectory ? sessionId : undefined,
317
+ extractDir
318
+ };
319
+ }
320
+ }
321
+ }
322
+
323
+ return null;
324
+ } catch (err) {
325
+ console.error('Error detecting format:', err);
326
+ return null;
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Import Copilot format session
332
+ * @param {Object} formatInfo - Format detection result
333
+ * @param {string} extractDir - Extraction directory
334
+ * @returns {Promise<Object>} Import result
335
+ */
336
+ async _importCopilotSession(formatInfo, extractDir) {
337
+ try {
338
+ const { sessionId, directoryName } = formatInfo;
339
+
340
+ // Validate session ID
341
+ if (!isValidSessionId(sessionId)) {
342
+ return {
343
+ success: false,
344
+ error: 'Invalid session ID',
345
+ statusCode: 400
346
+ };
347
+ }
348
+
349
+ const sessionPath = path.join(extractDir, directoryName);
350
+ const targetPath = path.join(this.SESSION_DIRS.copilot, sessionId);
351
+
352
+ // Check for events.jsonl
353
+ const eventsFile = path.join(sessionPath, 'events.jsonl');
354
+ if (!fs.existsSync(eventsFile)) {
355
+ return {
356
+ success: false,
357
+ error: 'Invalid session structure (no events.jsonl)',
358
+ statusCode: 400
359
+ };
360
+ }
361
+
362
+ // Check if session already exists
363
+ if (fs.existsSync(targetPath)) {
364
+ return {
365
+ success: false,
366
+ error: 'Session already exists',
367
+ statusCode: 409
368
+ };
369
+ }
370
+
371
+ // Move session directory
372
+ await fs.promises.rename(sessionPath, targetPath);
373
+
374
+ // Mark as imported
375
+ await fs.promises.writeFile(path.join(targetPath, '.imported'), '');
376
+
377
+ return {
378
+ success: true,
379
+ sessionId,
380
+ format: 'copilot'
381
+ };
382
+ } catch (err) {
383
+ console.error('Error importing Copilot session:', err);
384
+ return {
385
+ success: false,
386
+ error: `Error importing Copilot session: ${err.message}`,
387
+ statusCode: 500
388
+ };
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Import Claude format session
394
+ * @param {Object} formatInfo - Format detection result
395
+ * @param {string} extractDir - Extraction directory
396
+ * @param {Object} req - Express request object
397
+ * @returns {Promise<Object>} Import result
398
+ */
399
+ async _importClaudeSession(formatInfo, extractDir, req) {
400
+ try {
401
+ const { sessionId, fileName, hasDirectory, directoryName } = formatInfo;
402
+
403
+ // Validate session ID
404
+ if (!isValidSessionId(sessionId)) {
405
+ return {
406
+ success: false,
407
+ error: 'Invalid session ID',
408
+ statusCode: 400
409
+ };
410
+ }
411
+
412
+ // Get project from query or use default
413
+ const project = req.query.project || 'imported-sessions';
414
+
415
+ // Create project directory
416
+ const projectPath = path.join(this.SESSION_DIRS.claude, project);
417
+ await fs.promises.mkdir(projectPath, { recursive: true });
418
+
419
+ // Move the .jsonl file
420
+ const sourceFile = path.join(extractDir, fileName);
421
+ const targetFile = path.join(projectPath, fileName);
422
+ await fs.promises.rename(sourceFile, targetFile);
423
+
424
+ // If there's a directory, move it too
425
+ if (hasDirectory && directoryName) {
426
+ const sourceDir = path.join(extractDir, directoryName);
427
+ const targetDir = path.join(projectPath, directoryName);
428
+ await fs.promises.rename(sourceDir, targetDir);
429
+ }
430
+
431
+ return {
432
+ success: true,
433
+ sessionId,
434
+ format: 'claude',
435
+ project
436
+ };
437
+ } catch (err) {
438
+ console.error('Error importing Claude session:', err);
439
+ return {
440
+ success: false,
441
+ error: `Error importing Claude session: ${err.message}`,
442
+ statusCode: 500
443
+ };
444
+ }
445
+ }
446
+
447
+ /**
448
+ * Import Pi-Mono format session
449
+ * @param {Object} formatInfo - Format detection result
450
+ * @param {string} extractDir - Extraction directory
451
+ * @param {Object} req - Express request object
452
+ * @returns {Promise<Object>} Import result
453
+ */
454
+ async _importPiMonoSession(formatInfo, extractDir, req) {
455
+ try {
456
+ const { sessionId, fileName } = formatInfo;
457
+
458
+ // Validate session ID
459
+ if (!isValidSessionId(sessionId)) {
460
+ return {
461
+ success: false,
462
+ error: 'Invalid session ID',
463
+ statusCode: 400
464
+ };
465
+ }
466
+
467
+ // Get project from query or use default
468
+ const project = req.query.project || 'imported-sessions';
469
+
470
+ // Create project directory
471
+ const projectPath = path.join(this.SESSION_DIRS['pi-mono'], project);
472
+ await fs.promises.mkdir(projectPath, { recursive: true });
473
+
474
+ // Move the .jsonl file
475
+ const sourceFile = path.join(extractDir, fileName);
476
+ const targetFile = path.join(projectPath, fileName);
477
+ await fs.promises.rename(sourceFile, targetFile);
478
+
479
+ return {
480
+ success: true,
481
+ sessionId,
482
+ format: 'pi-mono',
483
+ project
484
+ };
485
+ } catch (err) {
486
+ console.error('Error importing Pi-Mono session:', err);
487
+ return {
488
+ success: false,
489
+ error: `Error importing Pi-Mono session: ${err.message}`,
490
+ statusCode: 500
491
+ };
492
+ }
493
+ }
494
+
495
+ /**
496
+ * Import session by detected format
497
+ * @param {Object} formatInfo - Format detection result
498
+ * @param {string} extractDir - Extraction directory
499
+ * @param {Object} req - Express request object
500
+ * @returns {Promise<Object>} Import result
501
+ */
502
+ async _importByFormat(formatInfo, extractDir, req) {
503
+ // Validate session ID
504
+ if (!isValidSessionId(formatInfo.sessionId)) {
505
+ return {
506
+ success: false,
507
+ error: 'Invalid session ID',
508
+ statusCode: 400
509
+ };
510
+ }
511
+
512
+ switch (formatInfo.format) {
513
+ case 'copilot':
514
+ return await this._importCopilotSession(formatInfo, extractDir);
515
+ case 'claude':
516
+ return await this._importClaudeSession(formatInfo, extractDir, req);
517
+ case 'pi-mono':
518
+ return await this._importPiMonoSession(formatInfo, extractDir, req);
519
+ default:
520
+ return {
521
+ success: false,
522
+ error: `Unsupported format: ${formatInfo.format}`,
523
+ statusCode: 400
524
+ };
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Find session location across all session directories
530
+ * @param {string} sessionId - Session identifier
531
+ * @param {string} preferredSource - Preferred source to search first
532
+ * @returns {Promise<Object|null>} Session location info or null
533
+ */
534
+ async _findSessionLocation(sessionId, preferredSource = null) {
535
+ try {
536
+ // Define search order based on preference
537
+ const sources = preferredSource
538
+ ? [preferredSource, ...Object.keys(this.SESSION_DIRS).filter(s => s !== preferredSource)]
539
+ : Object.keys(this.SESSION_DIRS);
540
+
541
+ for (const source of sources) {
542
+ const baseDir = this.SESSION_DIRS[source];
543
+
544
+ if (source === 'copilot') {
545
+ // For Copilot, sessions are directly in SESSION_DIR
546
+ const sessionPath = path.join(baseDir, sessionId);
547
+ if (fs.existsSync(sessionPath)) {
548
+ const eventsFile = path.join(sessionPath, 'events.jsonl');
549
+ if (fs.existsSync(eventsFile)) {
550
+ return {
551
+ source: 'copilot',
552
+ sessionId,
553
+ sessionPath,
554
+ baseDir
555
+ };
556
+ }
557
+ }
558
+ } else if (source === 'claude') {
559
+ // For Claude, search in all project directories
560
+ if (fs.existsSync(baseDir)) {
561
+ const projects = await fs.promises.readdir(baseDir);
562
+ for (const project of projects) {
563
+ const projectPath = path.join(baseDir, project);
564
+ const stat = await fs.promises.stat(projectPath);
565
+ if (stat.isDirectory()) {
566
+ const sessionFile = path.join(projectPath, `${sessionId}.jsonl`);
567
+ if (fs.existsSync(sessionFile)) {
568
+ return {
569
+ source: 'claude',
570
+ sessionId,
571
+ sessionFile,
572
+ projectPath,
573
+ project,
574
+ baseDir
575
+ };
576
+ }
577
+ }
578
+ }
579
+ }
580
+ } else if (source === 'pi-mono') {
581
+ // For Pi-Mono, search in all project directories for timestamped files
582
+ if (fs.existsSync(baseDir)) {
583
+ const projects = await fs.promises.readdir(baseDir);
584
+ for (const project of projects) {
585
+ const projectPath = path.join(baseDir, project);
586
+ const stat = await fs.promises.stat(projectPath);
587
+ if (stat.isDirectory()) {
588
+ const files = await fs.promises.readdir(projectPath);
589
+ const piMonoPattern = new RegExp(`^\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z_${sessionId}\\.jsonl$`);
590
+ for (const file of files) {
591
+ if (piMonoPattern.test(file)) {
592
+ return {
593
+ source: 'pi-mono',
594
+ sessionId,
595
+ fileName: file,
596
+ sessionFile: path.join(projectPath, file),
597
+ projectPath,
598
+ project,
599
+ baseDir
600
+ };
601
+ }
602
+ }
603
+ }
604
+ }
605
+ }
606
+ }
607
+ }
608
+
609
+ return null;
610
+ } catch (err) {
611
+ console.error('Error finding session location:', err);
612
+ return null;
613
+ }
614
+ }
175
615
  }
176
616
 
177
617
  module.exports = UploadController;
@@ -1,13 +1,19 @@
1
1
  const rateLimit = require('express-rate-limit');
2
2
 
3
+ // Disable rate limiting in E2E tests (when NODE_ENV is test or when running via Playwright)
4
+ const isTestEnvironment = process.env.NODE_ENV === 'test' || process.env.PLAYWRIGHT === '1';
5
+
3
6
  // Global rate limiting for all routes
4
7
  const globalLimiter = rateLimit({
5
8
  windowMs: 15 * 60 * 1000, // 15 minutes
6
- max: 100, // 100 requests per window
9
+ max: isTestEnvironment ? 10000 : 100, // Much higher limit for tests
7
10
  message: { error: 'Too many requests. Please try again later.' },
8
11
  standardHeaders: true,
9
12
  legacyHeaders: false,
10
13
  skip: (req) => {
14
+ // Skip rate limiting entirely in test environment
15
+ if (isTestEnvironment) return true;
16
+
11
17
  // Skip static files
12
18
  if (req.path.startsWith('/public')) return true;
13
19
 
@@ -1,3 +1,5 @@
1
+ const path = require('path');
2
+
1
3
  /**
2
4
  * Session domain model
3
5
  */
@@ -5,6 +7,8 @@ class Session {
5
7
  constructor(id, type, options = {}) {
6
8
  this.id = id;
7
9
  this.type = type; // 'directory' or 'file'
10
+ this.source = options.source || 'copilot'; // 'copilot' or 'claude'
11
+ this.directory = options.directory || null; // Full path to session directory
8
12
  this.workspace = options.workspace || {};
9
13
  this.createdAt = options.createdAt;
10
14
  this.updatedAt = options.updatedAt;
@@ -36,6 +40,7 @@ class Session {
36
40
  */
37
41
  static fromDirectory(dirPath, id, stats, workspace, eventCount, duration, isImported, hasInsight, copilotVersion, selectedModel, sessionStatus) {
38
42
  return new Session(id, 'directory', {
43
+ directory: dirPath, // Add directory path
39
44
  workspace: workspace,
40
45
  createdAt: workspace?.created_at || stats.birthtime,
41
46
  updatedAt: workspace?.updated_at || stats.mtime,
@@ -66,6 +71,7 @@ class Session {
66
71
  */
67
72
  static fromFile(filePath, id, stats, eventCount, summary, duration, copilotVersion, selectedModel, sessionStatus) {
68
73
  return new Session(id, 'file', {
74
+ directory: path.dirname(filePath), // Directory containing the file
69
75
  createdAt: stats.birthtime,
70
76
  updatedAt: stats.mtime,
71
77
  summary: summary || 'Legacy session',
@@ -85,9 +91,16 @@ class Session {
85
91
  * @returns {object}
86
92
  */
87
93
  toJSON() {
94
+ // Generate display-ready source metadata (Violation #3 & #5 fix)
95
+ const sourceMetadata = this._getSourceDisplayMetadata(this.source);
96
+
88
97
  return {
89
98
  id: this.id,
90
99
  type: this.type,
100
+ source: this.source,
101
+ sourceName: sourceMetadata.name,
102
+ sourceBadgeClass: sourceMetadata.badgeClass,
103
+ directory: this.directory, // Include directory path
91
104
  workspace: this.workspace,
92
105
  createdAt: this.createdAt,
93
106
  updatedAt: this.updatedAt,
@@ -102,6 +115,19 @@ class Session {
102
115
  sessionStatus: this.sessionStatus
103
116
  };
104
117
  }
118
+
119
+ /**
120
+ * Get display metadata for source
121
+ * @private
122
+ */
123
+ _getSourceDisplayMetadata(source) {
124
+ const metadata = {
125
+ 'copilot': { name: 'Copilot', badgeClass: 'source-copilot' },
126
+ 'claude': { name: 'Claude', badgeClass: 'source-claude' },
127
+ 'pi-mono': { name: 'Pi', badgeClass: 'source-pi-mono' }
128
+ };
129
+ return metadata[source] || { name: source, badgeClass: 'source-unknown' };
130
+ }
105
131
  }
106
132
 
107
133
  module.exports = Session;
@@ -0,0 +1,73 @@
1
+ const { z } = require('zod');
2
+
3
+ /**
4
+ * Unified Event Schema for Copilot Session Viewer
5
+ *
6
+ * This schema defines the standardized event format that the API returns to the frontend.
7
+ * Both Copilot and Claude events are normalized to match this schema.
8
+ */
9
+
10
+ // Tool call schema (unified format for both Copilot and Claude)
11
+ const ToolSchema = z.object({
12
+ type: z.literal('tool_use'),
13
+ id: z.string(),
14
+ name: z.string(),
15
+ input: z.record(z.any()),
16
+ result: z.any().optional(), // Tool execution result (when matched)
17
+ status: z.enum(['success', 'error', 'running']).optional(),
18
+ error: z.string().optional(),
19
+ _matched: z.boolean().optional() // Internal flag: whether result was matched
20
+ });
21
+
22
+ // Subagent metadata (for events belonging to a subagent)
23
+ const SubagentMetadataSchema = z.object({
24
+ id: z.string(),
25
+ name: z.string()
26
+ }).optional();
27
+
28
+ // Event data schema (standardized data field)
29
+ const EventDataSchema = z.object({
30
+ // Message content (text)
31
+ message: z.string().optional(),
32
+ text: z.string().optional(), // Alternative field name (legacy)
33
+
34
+ // Tool calls (unified format)
35
+ tools: z.array(ToolSchema).optional(),
36
+
37
+ // Original fields preserved for reference
38
+ // (Copilot-specific fields)
39
+ messageId: z.string().optional(),
40
+ content: z.string().optional(), // Original Copilot content field
41
+ toolRequests: z.array(z.any()).optional(), // Original Copilot toolRequests
42
+
43
+ // (Claude-specific fields)
44
+ // ... other fields as needed
45
+ }).passthrough(); // Allow additional fields
46
+
47
+ // Base event schema
48
+ const EventSchema = z.object({
49
+ // Core fields
50
+ type: z.string(),
51
+ id: z.string().optional(),
52
+ timestamp: z.string(),
53
+ parentId: z.string().nullable().optional(),
54
+
55
+ // Standardized data
56
+ data: EventDataSchema.optional(),
57
+
58
+ // Metadata
59
+ _subagent: SubagentMetadataSchema,
60
+ _fileIndex: z.number().optional(),
61
+
62
+ // Virtual fields (computed by frontend)
63
+ stableId: z.string().optional(),
64
+ virtualIndex: z.number().optional()
65
+ }).passthrough(); // Allow additional fields for flexibility
66
+
67
+ // Export schemas
68
+ module.exports = {
69
+ EventSchema,
70
+ EventDataSchema,
71
+ ToolSchema,
72
+ SubagentMetadataSchema
73
+ };