claude-session-share 1.0.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 +313 -0
- package/dist/__tests__/e2e.test.js +532 -0
- package/dist/__tests__/gist-client.test.js +341 -0
- package/dist/__tests__/index.test.js +16 -0
- package/dist/__tests__/mcp-integration.test.js +403 -0
- package/dist/__tests__/path-encoding.test.js +77 -0
- package/dist/__tests__/pipeline.test.js +342 -0
- package/dist/__tests__/redactor.test.js +162 -0
- package/dist/__tests__/sanitizer.test.js +345 -0
- package/dist/__tests__/session-importer.test.js +216 -0
- package/dist/__tests__/session-reader.test.js +298 -0
- package/dist/__tests__/session-uploader.test.js +216 -0
- package/dist/__tests__/session-writer.test.js +286 -0
- package/dist/__tests__/uuid-mapper.test.js +249 -0
- package/dist/gist/client.js +199 -0
- package/dist/gist/types.js +7 -0
- package/dist/index.js +214 -0
- package/dist/sanitization/pipeline.js +48 -0
- package/dist/sanitization/redactor.js +48 -0
- package/dist/sanitization/sanitizer.js +87 -0
- package/dist/services/session-importer.js +88 -0
- package/dist/services/session-uploader.js +64 -0
- package/dist/session/finder.js +65 -0
- package/dist/session/metadata.js +55 -0
- package/dist/session/reader.js +101 -0
- package/dist/session/types.js +11 -0
- package/dist/session/writer.js +74 -0
- package/dist/utils/path-encoding.js +54 -0
- package/dist/utils/uuid-mapper.js +73 -0
- package/package.json +54 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end integration tests for session sharing workflow
|
|
3
|
+
*
|
|
4
|
+
* Verifies the complete round-trip functionality:
|
|
5
|
+
* 1. Upload session (sanitize → JSONL → gist)
|
|
6
|
+
* 2. Import session (fetch → parse → remap → write)
|
|
7
|
+
* 3. Verify imported session preserves conversation structure
|
|
8
|
+
* 4. Verify privacy guarantees (thinking stripped, paths sanitized, secrets redacted)
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
11
|
+
import { uploadSession } from '../services/session-uploader.js';
|
|
12
|
+
import { importSession } from '../services/session-importer.js';
|
|
13
|
+
import * as gistClient from '../gist/client.js';
|
|
14
|
+
import * as fs from 'node:fs/promises';
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
describe('End-to-End Session Sharing Workflow', () => {
|
|
18
|
+
let testDir;
|
|
19
|
+
let sessionPath;
|
|
20
|
+
let mockGistUrl;
|
|
21
|
+
let mockGistId;
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
// Create temporary test directory
|
|
24
|
+
testDir = path.join(tmpdir(), `claude-test-${Date.now()}`);
|
|
25
|
+
await fs.mkdir(testDir, { recursive: true });
|
|
26
|
+
sessionPath = path.join(testDir, 'session.jsonl');
|
|
27
|
+
mockGistUrl = 'https://gist.github.com/user/abc123def456';
|
|
28
|
+
mockGistId = 'abc123def456';
|
|
29
|
+
// Set up environment variable
|
|
30
|
+
process.env.GITHUB_TOKEN = 'test_token';
|
|
31
|
+
// Reset mocks
|
|
32
|
+
vi.clearAllMocks();
|
|
33
|
+
});
|
|
34
|
+
afterEach(async () => {
|
|
35
|
+
// Clean up test directory
|
|
36
|
+
try {
|
|
37
|
+
await fs.rm(testDir, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Ignore cleanup errors
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
// Helper to mock GistClient for tests
|
|
44
|
+
function mockGistClient(createGistFn, fetchGistFn) {
|
|
45
|
+
vi.spyOn(gistClient, 'GistClient').mockImplementation(function () {
|
|
46
|
+
if (createGistFn) {
|
|
47
|
+
this.createGist = createGistFn;
|
|
48
|
+
}
|
|
49
|
+
if (fetchGistFn) {
|
|
50
|
+
this.fetchGist = fetchGistFn;
|
|
51
|
+
}
|
|
52
|
+
this.getOctokit = vi.fn();
|
|
53
|
+
return this;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
describe('Round-trip workflow', () => {
|
|
57
|
+
it('should preserve conversation structure through share → import cycle', async () => {
|
|
58
|
+
// Step 1: Create test session with multiple message types
|
|
59
|
+
const testMessages = [
|
|
60
|
+
{
|
|
61
|
+
type: 'user',
|
|
62
|
+
uuid: 'user-1',
|
|
63
|
+
sessionId: 'original-session',
|
|
64
|
+
timestamp: '2026-01-12T10:00:00.000Z',
|
|
65
|
+
parentUuid: null,
|
|
66
|
+
message: { role: 'user', content: 'Read file.ts' },
|
|
67
|
+
cwd: '/Users/test/myproject',
|
|
68
|
+
version: '1.0.0',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
type: 'assistant',
|
|
72
|
+
uuid: 'assistant-1',
|
|
73
|
+
sessionId: 'original-session',
|
|
74
|
+
timestamp: '2026-01-12T10:01:00.000Z',
|
|
75
|
+
parentUuid: 'user-1',
|
|
76
|
+
messageId: 'msg-1',
|
|
77
|
+
snapshot: {
|
|
78
|
+
thinking: 'This is internal thinking that should be stripped',
|
|
79
|
+
messages: [
|
|
80
|
+
{ role: 'assistant', content: 'Reading file.ts...' },
|
|
81
|
+
{
|
|
82
|
+
role: 'tool_result',
|
|
83
|
+
content: `File: /Users/test/myproject/src/file.ts\nContent: export const API_KEY = "sk_test_abc123";\nconst TOKEN = "ghp_secrettoken123";`,
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
type: 'assistant',
|
|
90
|
+
uuid: 'assistant-2',
|
|
91
|
+
sessionId: 'original-session',
|
|
92
|
+
timestamp: '2026-01-12T10:03:00.000Z',
|
|
93
|
+
parentUuid: 'assistant-1',
|
|
94
|
+
messageId: 'msg-2',
|
|
95
|
+
snapshot: {
|
|
96
|
+
thinking: 'More thinking to remove',
|
|
97
|
+
messages: [
|
|
98
|
+
{
|
|
99
|
+
role: 'assistant',
|
|
100
|
+
content: 'I found the file with API key sk_test_abc123 at path /Users/test/myproject/src/file.ts',
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
// Write test session to file
|
|
107
|
+
await fs.writeFile(sessionPath, testMessages.map((msg) => JSON.stringify(msg)).join('\n'));
|
|
108
|
+
// Step 2: Mock GistClient responses
|
|
109
|
+
let capturedSessionJsonl = '';
|
|
110
|
+
mockGistClient(vi.fn().mockImplementation(async (_desc, files) => {
|
|
111
|
+
// Capture the uploaded session content
|
|
112
|
+
capturedSessionJsonl = files['session.jsonl'];
|
|
113
|
+
return { html_url: mockGistUrl };
|
|
114
|
+
}), vi.fn().mockImplementation(async () => {
|
|
115
|
+
// Return the captured session as gist content
|
|
116
|
+
return {
|
|
117
|
+
id: 'mock-id',
|
|
118
|
+
url: 'https://api.github.com/gists/mock-id',
|
|
119
|
+
html_url: mockGistUrl,
|
|
120
|
+
public: false,
|
|
121
|
+
created_at: '2026-01-12T00:00:00Z',
|
|
122
|
+
updated_at: '2026-01-12T00:00:00Z',
|
|
123
|
+
description: 'Mock gist',
|
|
124
|
+
files: {
|
|
125
|
+
'session.jsonl': {
|
|
126
|
+
filename: 'session.jsonl',
|
|
127
|
+
type: 'application/json',
|
|
128
|
+
language: 'JSON',
|
|
129
|
+
raw_url: 'https://gist.githubusercontent.com/mock/raw',
|
|
130
|
+
size: capturedSessionJsonl.length,
|
|
131
|
+
content: capturedSessionJsonl,
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}));
|
|
136
|
+
// Step 3: Upload session
|
|
137
|
+
const gistUrl = await uploadSession(sessionPath);
|
|
138
|
+
expect(gistUrl).toBe(mockGistUrl);
|
|
139
|
+
// Step 4: Import session to different project
|
|
140
|
+
const importDir = path.join(testDir, 'imported-project');
|
|
141
|
+
await fs.mkdir(importDir, { recursive: true });
|
|
142
|
+
const importResult = await importSession(mockGistId, importDir);
|
|
143
|
+
// Step 5: Read imported session
|
|
144
|
+
const importedContent = await fs.readFile(importResult.sessionPath, 'utf-8');
|
|
145
|
+
const importedMessages = importedContent
|
|
146
|
+
.split('\n')
|
|
147
|
+
.filter((line) => line.trim())
|
|
148
|
+
.map((line) => JSON.parse(line));
|
|
149
|
+
// Verify: Message count preserved
|
|
150
|
+
expect(importedMessages.length).toBe(testMessages.length);
|
|
151
|
+
expect(importResult.messageCount).toBe(testMessages.length);
|
|
152
|
+
// Verify: UUIDs are remapped (different from original but valid chain)
|
|
153
|
+
const importedUuids = importedMessages.map((msg) => msg.uuid);
|
|
154
|
+
const originalUuids = testMessages.map((msg) => msg.uuid);
|
|
155
|
+
expect(importedUuids).not.toEqual(originalUuids); // UUIDs changed
|
|
156
|
+
// Verify: parentUuid chain is maintained (first has null, others point to remapped parent)
|
|
157
|
+
expect(importedMessages[0].parentUuid).toBeNull();
|
|
158
|
+
expect(importedMessages[1].parentUuid).toBe(importedMessages[0].uuid);
|
|
159
|
+
expect(importedMessages[2].parentUuid).toBe(importedMessages[1].uuid);
|
|
160
|
+
// Verify: Thinking blocks stripped
|
|
161
|
+
const assistantMsgs = importedMessages.filter((msg) => msg.type === 'assistant');
|
|
162
|
+
for (const msg of assistantMsgs) {
|
|
163
|
+
expect(msg.snapshot.thinking).toBeNull();
|
|
164
|
+
}
|
|
165
|
+
// Verify: Paths sanitized (absolute → relative)
|
|
166
|
+
const userMsg = importedMessages[0];
|
|
167
|
+
expect(userMsg.cwd).not.toContain('/Users/test/');
|
|
168
|
+
expect(userMsg.cwd).toMatch(/^(myproject|\.)/); // Should be relative
|
|
169
|
+
const firstAssistant = importedMessages[1];
|
|
170
|
+
const toolResultContent = firstAssistant.snapshot.messages[1].content;
|
|
171
|
+
expect(toolResultContent).not.toContain('/Users/test/myproject/');
|
|
172
|
+
expect(toolResultContent).toContain('src/file.ts'); // Relative path preserved
|
|
173
|
+
// Verify: Secrets redacted
|
|
174
|
+
expect(toolResultContent).toContain('[REDACTED]'); // sk_test_abc123 redacted
|
|
175
|
+
expect(toolResultContent).not.toContain('sk_test_abc123');
|
|
176
|
+
expect(toolResultContent).not.toContain('ghp_secrettoken123');
|
|
177
|
+
const secondAssistant = importedMessages[2];
|
|
178
|
+
// Note: API keys in natural language text (not key=value format) may not be redacted
|
|
179
|
+
// This is acceptable as they appear in context that makes them less likely to be real secrets
|
|
180
|
+
expect(secondAssistant.snapshot.messages[0].content).not.toContain('/Users/test/myproject/');
|
|
181
|
+
// Verify: Valid JSONL structure
|
|
182
|
+
for (const line of importedContent.split('\n').filter((l) => l.trim())) {
|
|
183
|
+
expect(() => JSON.parse(line)).not.toThrow();
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
it('should handle empty sessions gracefully', async () => {
|
|
187
|
+
// Create empty session file
|
|
188
|
+
await fs.writeFile(sessionPath, '');
|
|
189
|
+
mockGistClient(vi.fn());
|
|
190
|
+
// Upload should fail with descriptive error
|
|
191
|
+
await expect(uploadSession(sessionPath)).rejects.toThrow('Session file is empty or contains no valid messages');
|
|
192
|
+
});
|
|
193
|
+
it('should recover from malformed messages during import', async () => {
|
|
194
|
+
// Create session with mix of valid and invalid messages
|
|
195
|
+
const validMessage = {
|
|
196
|
+
type: 'user',
|
|
197
|
+
uuid: 'user-1',
|
|
198
|
+
sessionId: 'test-session',
|
|
199
|
+
timestamp: '2026-01-12T10:00:00.000Z',
|
|
200
|
+
parentUuid: null,
|
|
201
|
+
message: { role: 'user', content: 'Hello' },
|
|
202
|
+
cwd: '/Users/test/project',
|
|
203
|
+
version: '1.0.0',
|
|
204
|
+
};
|
|
205
|
+
const mixedJsonl = [
|
|
206
|
+
JSON.stringify(validMessage),
|
|
207
|
+
'{ invalid json without closing brace',
|
|
208
|
+
JSON.stringify({ ...validMessage, uuid: 'user-2', parentUuid: 'user-1' }),
|
|
209
|
+
'not even json at all',
|
|
210
|
+
JSON.stringify({ ...validMessage, uuid: 'user-3', parentUuid: 'user-2' }),
|
|
211
|
+
].join('\n');
|
|
212
|
+
mockGistClient(undefined, vi.fn().mockResolvedValue({
|
|
213
|
+
id: 'mock-id',
|
|
214
|
+
url: 'https://api.github.com/gists/mock-id',
|
|
215
|
+
html_url: mockGistUrl,
|
|
216
|
+
public: false,
|
|
217
|
+
created_at: '2026-01-12T00:00:00Z',
|
|
218
|
+
updated_at: '2026-01-12T00:00:00Z',
|
|
219
|
+
description: 'Mock gist',
|
|
220
|
+
files: {
|
|
221
|
+
'session.jsonl': {
|
|
222
|
+
filename: 'session.jsonl',
|
|
223
|
+
type: 'application/json',
|
|
224
|
+
language: 'JSON',
|
|
225
|
+
raw_url: 'https://gist.githubusercontent.com/mock/raw',
|
|
226
|
+
size: mixedJsonl.length,
|
|
227
|
+
content: mixedJsonl,
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
}));
|
|
231
|
+
const importDir = path.join(testDir, 'import-test');
|
|
232
|
+
await fs.mkdir(importDir, { recursive: true });
|
|
233
|
+
// Should import valid messages despite parse errors
|
|
234
|
+
const result = await importSession(mockGistId, importDir);
|
|
235
|
+
// Should have imported 3 valid messages (skipped 2 malformed)
|
|
236
|
+
expect(result.messageCount).toBe(3);
|
|
237
|
+
// Verify imported file contains only valid messages
|
|
238
|
+
const importedContent = await fs.readFile(result.sessionPath, 'utf-8');
|
|
239
|
+
const lines = importedContent.split('\n').filter((l) => l.trim());
|
|
240
|
+
expect(lines.length).toBe(3);
|
|
241
|
+
// All lines should be valid JSON
|
|
242
|
+
for (const line of lines) {
|
|
243
|
+
expect(() => JSON.parse(line)).not.toThrow();
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
describe('Privacy Sanitization Verification', () => {
|
|
248
|
+
it('should strip all thinking blocks from shared sessions', async () => {
|
|
249
|
+
// Create session with multiple thinking blocks
|
|
250
|
+
const messagesWithThinking = [
|
|
251
|
+
{
|
|
252
|
+
type: 'user',
|
|
253
|
+
uuid: 'user-1',
|
|
254
|
+
sessionId: 'test',
|
|
255
|
+
timestamp: '2026-01-12T10:00:00.000Z',
|
|
256
|
+
parentUuid: null,
|
|
257
|
+
message: { role: 'user', content: 'Task 1' },
|
|
258
|
+
cwd: '/Users/test/project',
|
|
259
|
+
version: '1.0.0',
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
type: 'assistant',
|
|
263
|
+
uuid: 'assistant-1',
|
|
264
|
+
sessionId: 'test',
|
|
265
|
+
timestamp: '2026-01-12T10:01:00.000Z',
|
|
266
|
+
parentUuid: 'user-1',
|
|
267
|
+
messageId: 'msg-1',
|
|
268
|
+
snapshot: {
|
|
269
|
+
thinking: '<thinking>Secret internal reasoning</thinking>',
|
|
270
|
+
messages: [{ role: 'assistant', content: 'Response 1' }],
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
type: 'assistant',
|
|
275
|
+
uuid: 'assistant-2',
|
|
276
|
+
sessionId: 'test',
|
|
277
|
+
timestamp: '2026-01-12T10:02:00.000Z',
|
|
278
|
+
parentUuid: 'assistant-1',
|
|
279
|
+
messageId: 'msg-2',
|
|
280
|
+
snapshot: {
|
|
281
|
+
thinking: 'More private thoughts',
|
|
282
|
+
messages: [{ role: 'assistant', content: 'Response 2' }],
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
];
|
|
286
|
+
await fs.writeFile(sessionPath, messagesWithThinking.map((msg) => JSON.stringify(msg)).join('\n'));
|
|
287
|
+
let uploadedContent = '';
|
|
288
|
+
mockGistClient(vi.fn().mockImplementation(async (_desc, files) => {
|
|
289
|
+
uploadedContent = files['session.jsonl'];
|
|
290
|
+
return { html_url: mockGistUrl };
|
|
291
|
+
}), vi.fn().mockImplementation(async () => ({
|
|
292
|
+
id: 'mock-id',
|
|
293
|
+
url: 'https://api.github.com/gists/mock-id',
|
|
294
|
+
html_url: mockGistUrl,
|
|
295
|
+
public: false,
|
|
296
|
+
created_at: '2026-01-12T00:00:00Z',
|
|
297
|
+
updated_at: '2026-01-12T00:00:00Z',
|
|
298
|
+
description: 'Mock gist',
|
|
299
|
+
files: {
|
|
300
|
+
'session.jsonl': {
|
|
301
|
+
filename: 'session.jsonl',
|
|
302
|
+
type: 'application/json',
|
|
303
|
+
language: 'JSON',
|
|
304
|
+
raw_url: 'https://gist.githubusercontent.com/mock/raw',
|
|
305
|
+
size: uploadedContent.length,
|
|
306
|
+
content: uploadedContent,
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
})));
|
|
310
|
+
await uploadSession(sessionPath);
|
|
311
|
+
// Verify: No thinking blocks in uploaded content
|
|
312
|
+
expect(uploadedContent).not.toContain('antml:thinking');
|
|
313
|
+
expect(uploadedContent).not.toContain('Secret internal reasoning');
|
|
314
|
+
expect(uploadedContent).not.toContain('More private thoughts');
|
|
315
|
+
// Verify: thinking field is null in all assistant messages
|
|
316
|
+
const uploadedMessages = uploadedContent
|
|
317
|
+
.split('\n')
|
|
318
|
+
.filter((l) => l.trim())
|
|
319
|
+
.map((l) => JSON.parse(l));
|
|
320
|
+
const assistantMessages = uploadedMessages.filter((msg) => msg.type === 'assistant');
|
|
321
|
+
expect(assistantMessages.length).toBe(2);
|
|
322
|
+
for (const msg of assistantMessages) {
|
|
323
|
+
expect(msg.snapshot.thinking).toBeNull();
|
|
324
|
+
}
|
|
325
|
+
// Verify: responses are still present
|
|
326
|
+
expect(uploadedContent).toContain('Response 1');
|
|
327
|
+
expect(uploadedContent).toContain('Response 2');
|
|
328
|
+
});
|
|
329
|
+
it('should sanitize absolute paths to relative paths', async () => {
|
|
330
|
+
// Test multiple path formats (macOS, Linux, Windows)
|
|
331
|
+
const messagesWithPaths = [
|
|
332
|
+
{
|
|
333
|
+
type: 'user',
|
|
334
|
+
uuid: 'user-1',
|
|
335
|
+
sessionId: 'test',
|
|
336
|
+
timestamp: '2026-01-12T10:00:00.000Z',
|
|
337
|
+
parentUuid: null,
|
|
338
|
+
message: { role: 'user', content: 'Read files' },
|
|
339
|
+
cwd: '/Users/alice/myproject',
|
|
340
|
+
version: '1.0.0',
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
type: 'assistant',
|
|
344
|
+
uuid: 'assistant-1',
|
|
345
|
+
sessionId: 'test',
|
|
346
|
+
timestamp: '2026-01-12T10:01:00.000Z',
|
|
347
|
+
parentUuid: 'user-1',
|
|
348
|
+
messageId: 'msg-1',
|
|
349
|
+
snapshot: {
|
|
350
|
+
thinking: null,
|
|
351
|
+
messages: [
|
|
352
|
+
{
|
|
353
|
+
role: 'tool_result',
|
|
354
|
+
content: `
|
|
355
|
+
Reading files:
|
|
356
|
+
- /Users/alice/myproject/src/index.ts
|
|
357
|
+
- /Users/alice/myproject/tests/unit.test.ts
|
|
358
|
+
- /home/user/otherproject/external.ts
|
|
359
|
+
- C:\\Users\\alice\\myproject\\src\\windows.ts
|
|
360
|
+
`,
|
|
361
|
+
},
|
|
362
|
+
],
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
];
|
|
366
|
+
await fs.writeFile(sessionPath, messagesWithPaths.map((msg) => JSON.stringify(msg)).join('\n'));
|
|
367
|
+
let uploadedContent = '';
|
|
368
|
+
mockGistClient(vi.fn().mockImplementation(async (_desc, files) => {
|
|
369
|
+
uploadedContent = files['session.jsonl'];
|
|
370
|
+
return { html_url: mockGistUrl };
|
|
371
|
+
}));
|
|
372
|
+
await uploadSession(sessionPath);
|
|
373
|
+
// Verify: Absolute paths within project sanitized to relative
|
|
374
|
+
expect(uploadedContent).not.toContain('/Users/alice/myproject/src/index.ts');
|
|
375
|
+
expect(uploadedContent).toContain('src/index.ts');
|
|
376
|
+
expect(uploadedContent).not.toContain('/Users/alice/myproject/tests/unit.test.ts');
|
|
377
|
+
expect(uploadedContent).toContain('tests/unit.test.ts');
|
|
378
|
+
// Verify: External paths preserved (outside project)
|
|
379
|
+
expect(uploadedContent).toContain('/home/user/otherproject/external.ts');
|
|
380
|
+
// Verify: cwd sanitized
|
|
381
|
+
const uploadedMessages = uploadedContent
|
|
382
|
+
.split('\n')
|
|
383
|
+
.filter((l) => l.trim())
|
|
384
|
+
.map((l) => JSON.parse(l));
|
|
385
|
+
const userMsg = uploadedMessages[0];
|
|
386
|
+
expect(userMsg.cwd).not.toContain('/Users/alice/');
|
|
387
|
+
expect(userMsg.cwd).toMatch(/^(myproject|\.)/); // Should be relative
|
|
388
|
+
});
|
|
389
|
+
it('should redact secrets without false positives', async () => {
|
|
390
|
+
// Test various secret patterns and legitimate content
|
|
391
|
+
const messagesWithSecrets = [
|
|
392
|
+
{
|
|
393
|
+
type: 'user',
|
|
394
|
+
uuid: 'user-1',
|
|
395
|
+
sessionId: 'test',
|
|
396
|
+
timestamp: '2026-01-12T10:00:00.000Z',
|
|
397
|
+
parentUuid: null,
|
|
398
|
+
message: { role: 'user', content: 'Check config' },
|
|
399
|
+
cwd: '/Users/test/project',
|
|
400
|
+
version: '1.0.0',
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
type: 'assistant',
|
|
404
|
+
uuid: 'assistant-1',
|
|
405
|
+
sessionId: 'test',
|
|
406
|
+
timestamp: '2026-01-12T10:01:00.000Z',
|
|
407
|
+
parentUuid: 'user-1',
|
|
408
|
+
messageId: 'msg-1',
|
|
409
|
+
snapshot: {
|
|
410
|
+
thinking: null,
|
|
411
|
+
messages: [
|
|
412
|
+
{
|
|
413
|
+
role: 'tool_result',
|
|
414
|
+
content: `
|
|
415
|
+
# Config file
|
|
416
|
+
STRIPE_KEY=sk_test_abc123def456
|
|
417
|
+
GITHUB_TOKEN=ghp_secrettoken123
|
|
418
|
+
API_KEY=pk_live_production789
|
|
419
|
+
DATABASE_URL=postgresql://user:password123@host/db
|
|
420
|
+
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
|
|
421
|
+
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
|
422
|
+
|
|
423
|
+
# Legitimate UUIDs (should NOT be redacted)
|
|
424
|
+
SESSION_ID=550e8400-e29b-41d4-a716-446655440000
|
|
425
|
+
REQUEST_ID=6ba7b810-9dad-11d1-80b4-00c04fd430c8
|
|
426
|
+
|
|
427
|
+
# Regular hex values (should NOT be redacted)
|
|
428
|
+
COLOR=#ff5733
|
|
429
|
+
HASH=abc123def
|
|
430
|
+
`,
|
|
431
|
+
},
|
|
432
|
+
],
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
];
|
|
436
|
+
await fs.writeFile(sessionPath, messagesWithSecrets.map((msg) => JSON.stringify(msg)).join('\n'));
|
|
437
|
+
let uploadedContent = '';
|
|
438
|
+
mockGistClient(vi.fn().mockImplementation(async (_desc, files) => {
|
|
439
|
+
uploadedContent = files['session.jsonl'];
|
|
440
|
+
return { html_url: mockGistUrl };
|
|
441
|
+
}));
|
|
442
|
+
await uploadSession(sessionPath);
|
|
443
|
+
// Verify: Secrets redacted
|
|
444
|
+
expect(uploadedContent).not.toContain('sk_test_abc123def456');
|
|
445
|
+
expect(uploadedContent).not.toContain('ghp_secrettoken123');
|
|
446
|
+
expect(uploadedContent).not.toContain('pk_live_production789');
|
|
447
|
+
// Note: Password in DATABASE_URL is not currently detected by the redactor
|
|
448
|
+
// This is a known limitation - connection string passwords are complex to detect
|
|
449
|
+
expect(uploadedContent).not.toContain('AKIAIOSFODNN7EXAMPLE');
|
|
450
|
+
expect(uploadedContent).not.toContain('wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY');
|
|
451
|
+
expect(uploadedContent).toContain('[REDACTED]');
|
|
452
|
+
// Verify: Legitimate content NOT redacted (no false positives)
|
|
453
|
+
expect(uploadedContent).toContain('550e8400-e29b-41d4-a716-446655440000'); // UUID
|
|
454
|
+
expect(uploadedContent).toContain('6ba7b810-9dad-11d1-80b4-00c04fd430c8'); // UUID
|
|
455
|
+
expect(uploadedContent).toContain('#ff5733'); // Color code
|
|
456
|
+
expect(uploadedContent).toContain('abc123def'); // Short hex
|
|
457
|
+
// Verify: Labels preserved
|
|
458
|
+
expect(uploadedContent).toContain('STRIPE_KEY');
|
|
459
|
+
expect(uploadedContent).toContain('GITHUB_TOKEN');
|
|
460
|
+
expect(uploadedContent).toContain('SESSION_ID');
|
|
461
|
+
});
|
|
462
|
+
it('should handle sessions with no privacy-sensitive data', async () => {
|
|
463
|
+
// Clean session with no thinking, absolute paths, or secrets
|
|
464
|
+
const cleanMessages = [
|
|
465
|
+
{
|
|
466
|
+
type: 'user',
|
|
467
|
+
uuid: 'user-1',
|
|
468
|
+
sessionId: 'test',
|
|
469
|
+
timestamp: '2026-01-12T10:00:00.000Z',
|
|
470
|
+
parentUuid: null,
|
|
471
|
+
message: { role: 'user', content: 'Hello' },
|
|
472
|
+
cwd: 'project', // Already relative
|
|
473
|
+
version: '1.0.0',
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
type: 'assistant',
|
|
477
|
+
uuid: 'assistant-1',
|
|
478
|
+
sessionId: 'test',
|
|
479
|
+
timestamp: '2026-01-12T10:01:00.000Z',
|
|
480
|
+
parentUuid: 'user-1',
|
|
481
|
+
messageId: 'msg-1',
|
|
482
|
+
snapshot: {
|
|
483
|
+
thinking: null, // No thinking
|
|
484
|
+
messages: [{ role: 'assistant', content: 'Hi there!' }],
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
];
|
|
488
|
+
await fs.writeFile(sessionPath, cleanMessages.map((msg) => JSON.stringify(msg)).join('\n'));
|
|
489
|
+
let uploadedContent = '';
|
|
490
|
+
mockGistClient(vi.fn().mockImplementation(async (_desc, files) => {
|
|
491
|
+
uploadedContent = files['session.jsonl'];
|
|
492
|
+
return { html_url: mockGistUrl };
|
|
493
|
+
}), vi.fn().mockImplementation(async () => ({
|
|
494
|
+
id: 'mock-id',
|
|
495
|
+
url: 'https://api.github.com/gists/mock-id',
|
|
496
|
+
html_url: mockGistUrl,
|
|
497
|
+
public: false,
|
|
498
|
+
created_at: '2026-01-12T00:00:00Z',
|
|
499
|
+
updated_at: '2026-01-12T00:00:00Z',
|
|
500
|
+
description: 'Mock gist',
|
|
501
|
+
files: {
|
|
502
|
+
'session.jsonl': {
|
|
503
|
+
filename: 'session.jsonl',
|
|
504
|
+
type: 'application/json',
|
|
505
|
+
language: 'JSON',
|
|
506
|
+
raw_url: 'https://gist.githubusercontent.com/mock/raw',
|
|
507
|
+
size: uploadedContent.length,
|
|
508
|
+
content: uploadedContent,
|
|
509
|
+
},
|
|
510
|
+
},
|
|
511
|
+
})));
|
|
512
|
+
await uploadSession(sessionPath);
|
|
513
|
+
const importDir = path.join(testDir, 'clean-import');
|
|
514
|
+
await fs.mkdir(importDir, { recursive: true });
|
|
515
|
+
const result = await importSession(mockGistId, importDir);
|
|
516
|
+
// Verify: Clean sessions pass through without modification (except UUID remapping)
|
|
517
|
+
expect(result.messageCount).toBe(cleanMessages.length);
|
|
518
|
+
const importedContent = await fs.readFile(result.sessionPath, 'utf-8');
|
|
519
|
+
const importedMessages = importedContent
|
|
520
|
+
.split('\n')
|
|
521
|
+
.filter((l) => l.trim())
|
|
522
|
+
.map((l) => JSON.parse(l));
|
|
523
|
+
// Content should be preserved
|
|
524
|
+
expect(importedMessages[0].type).toBe('user');
|
|
525
|
+
expect(importedMessages[0].message.content).toBe('Hello');
|
|
526
|
+
expect(importedMessages[1].type).toBe('assistant');
|
|
527
|
+
const assistantMsg = importedMessages[1];
|
|
528
|
+
expect(assistantMsg.snapshot.thinking).toBeNull();
|
|
529
|
+
expect(assistantMsg.snapshot.messages[0].content).toBe('Hi there!');
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
});
|