diff --git a/src/components/FeedbackSurvey/submitTranscriptShare.test.ts b/src/components/FeedbackSurvey/submitTranscriptShare.test.ts new file mode 100644 index 00000000..1ee63b0a --- /dev/null +++ b/src/components/FeedbackSurvey/submitTranscriptShare.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'bun:test' + +import { + filterMessagesForTranscriptShare, + filterRawTranscriptForShare, +} from './submitTranscriptShare.js' + +describe('transcript attachment privacy', () => { + test('removes locally persisted attachment content from normalized sharing input', () => { + const messages = [ + { + type: 'user', + uuid: '11111111-1111-4111-8111-111111111111', + message: { role: 'user', content: 'summarize the attachment' }, + }, + { + type: 'attachment', + uuid: '22222222-2222-4222-8222-222222222222', + attachment: { + type: 'file', + filename: '/workspace/private.md', + content: { + type: 'text', + file: { content: 'private attachment canary' }, + }, + }, + }, + ] as never[] + + expect(filterMessagesForTranscriptShare(messages)).toEqual([messages[0]]) + }) + + test('removes attachment records from raw JSONL while preserving other and malformed lines', () => { + const raw = [ + JSON.stringify({ type: 'user', message: { content: 'keep me' } }), + JSON.stringify({ + type: 'attachment', + attachment: { type: 'file', content: 'private attachment canary' }, + }), + 'legacy malformed line', + ].join('\n') + + const filtered = filterRawTranscriptForShare(raw) + expect(filtered).toContain('keep me') + expect(filtered).toContain('legacy malformed line') + expect(filtered).not.toContain('private attachment canary') + }) +}) diff --git a/src/components/FeedbackSurvey/submitTranscriptShare.ts b/src/components/FeedbackSurvey/submitTranscriptShare.ts index 52e14251..15a11f83 100644 --- a/src/components/FeedbackSurvey/submitTranscriptShare.ts +++ b/src/components/FeedbackSurvey/submitTranscriptShare.ts @@ -26,6 +26,24 @@ export type TranscriptShareTrigger = | 'frustration' | 'memory_survey' +export function filterMessagesForTranscriptShare(messages: Message[]): Message[] { + return messages.filter((message) => message.type !== 'attachment') +} + +export function filterRawTranscriptForShare(rawTranscriptJsonl: string): string { + return rawTranscriptJsonl + .split('\n') + .filter((line) => { + if (!line.trim()) return true + try { + return JSON.parse(line)?.type !== 'attachment' + } catch { + return true + } + }) + .join('\n') +} + export async function submitTranscriptShare( messages: Message[], trigger: TranscriptShareTrigger, @@ -34,7 +52,9 @@ export async function submitTranscriptShare( try { logForDebugging('Collecting transcript for sharing', { level: 'info' }) - const transcript = normalizeMessagesForAPI(messages) + const transcript = normalizeMessagesForAPI( + filterMessagesForTranscriptShare(messages), + ) // Collect subagent transcripts const agentIds = extractAgentIdsFromMessages(messages) @@ -46,7 +66,9 @@ export async function submitTranscriptShare( const transcriptPath = getTranscriptPath() const { size } = await stat(transcriptPath) if (size <= MAX_TRANSCRIPT_READ_BYTES) { - rawTranscriptJsonl = await readFile(transcriptPath, 'utf-8') + rawTranscriptJsonl = filterRawTranscriptForShare( + await readFile(transcriptPath, 'utf-8'), + ) } else { logForDebugging( `Skipping raw transcript read: file too large (${size} bytes)`, diff --git a/src/utils/__tests__/sessionStorageFlush.test.ts b/src/utils/__tests__/sessionStorageFlush.test.ts index be39e8f8..b1fd572a 100644 --- a/src/utils/__tests__/sessionStorageFlush.test.ts +++ b/src/utils/__tests__/sessionStorageFlush.test.ts @@ -7,6 +7,7 @@ import { enqueueSessionEntryAfterPendingForTesting, flushSessionStorage, getTranscriptPathForSession, + loadTranscriptFile, recordTranscript, resetProjectForTesting, } from '../sessionStorage.js' @@ -18,6 +19,7 @@ const originalConfigDir = process.env.CLAUDE_CONFIG_DIR const originalTranscriptEntrypoint = process.env.CC_HAHA_TRANSCRIPT_ENTRYPOINT const originalEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT const originalTestPersistence = process.env.TEST_ENABLE_SESSION_PERSISTENCE +const originalUserType = process.env.USER_TYPE async function createTmpDir(): Promise { const dir = path.join( @@ -35,6 +37,7 @@ describe('sessionStorage flush', () => { tmpDir = await createTmpDir() process.env.CLAUDE_CONFIG_DIR = tmpDir process.env.TEST_ENABLE_SESSION_PERSISTENCE = '1' + process.env.USER_TYPE = 'external' resetProjectForTesting() }) @@ -60,6 +63,11 @@ describe('sessionStorage flush', () => { } else { process.env.TEST_ENABLE_SESSION_PERSISTENCE = originalTestPersistence } + if (originalUserType === undefined) { + delete process.env.USER_TYPE + } else { + process.env.USER_TYPE = originalUserType + } await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}) }) @@ -88,6 +96,133 @@ describe('sessionStorage flush', () => { expect(process.env.CLAUDE_CODE_ENTRYPOINT).toBe('sdk-cli') }) + it('persists explicit desktop file attachments so resumed sessions retain their content', async () => { + const sessionId = '44444444-4444-4444-8444-444444444444' + const attachmentUuid = '55555555-5555-4555-8555-555555555555' + switchSession(sessionId as SessionId) + process.env.CLAUDE_CODE_ENTRYPOINT = 'sdk-cli' + process.env.CC_HAHA_TRANSCRIPT_ENTRYPOINT = 'claude-desktop' + resetProjectForTesting() + + await recordTranscript([ + { + type: 'user', + uuid: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + timestamp: '2026-07-21T00:00:00.000Z', + message: { role: 'user', content: '@"/workspace/README.md" summarize it' }, + } as never, + { + type: 'attachment', + uuid: attachmentUuid, + timestamp: '2026-07-21T00:00:01.000Z', + attachment: { + type: 'file', + filename: '/workspace/README.md', + displayPath: 'README.md', + content: { + type: 'text', + file: { + filePath: '/workspace/README.md', + content: 'desktop attachment resume canary', + numLines: 1, + startLine: 1, + totalLines: 1, + }, + }, + }, + }, + ] as never[]) + await flushSessionStorage() + + const transcriptPath = getTranscriptPathForSession(sessionId) + const transcript = await fs.readFile(transcriptPath, 'utf-8') + expect(transcript).toContain('desktop attachment resume canary') + + const restored = await loadTranscriptFile(transcriptPath) + expect(restored.messages.get(attachmentUuid as never)).toMatchObject({ + type: 'attachment', + attachment: { + type: 'file', + content: { + type: 'text', + file: { content: 'desktop attachment resume canary' }, + }, + }, + }) + }) + + it('keeps desktop internal attachments and sdk-cli file attachments out of external transcripts', async () => { + const desktopSessionId = '66666666-6666-4666-8666-666666666666' + switchSession(desktopSessionId as SessionId) + process.env.CC_HAHA_TRANSCRIPT_ENTRYPOINT = 'claude-desktop' + resetProjectForTesting() + + await recordTranscript([ + { + type: 'user', + uuid: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + timestamp: '2026-07-21T00:00:00.000Z', + message: { role: 'user', content: 'desktop internal attachment' }, + } as never, + { + type: 'attachment', + uuid: '77777777-7777-4777-8777-777777777777', + timestamp: '2026-07-21T00:00:01.000Z', + attachment: { + type: 'todo_reminder', + content: [], + itemCount: 0, + }, + }, + ] as never[]) + await flushSessionStorage() + const desktopTranscript = await fs.readFile( + getTranscriptPathForSession(desktopSessionId), + 'utf-8', + ) + expect(desktopTranscript).not.toContain('todo_reminder') + + const cliSessionId = '88888888-8888-4888-8888-888888888888' + switchSession(cliSessionId as SessionId) + process.env.CC_HAHA_TRANSCRIPT_ENTRYPOINT = 'sdk-cli' + resetProjectForTesting() + + await recordTranscript([ + { + type: 'user', + uuid: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + timestamp: '2026-07-21T00:00:00.000Z', + message: { role: 'user', content: '@"/workspace/private.md" summarize it' }, + } as never, + { + type: 'attachment', + uuid: '99999999-9999-4999-8999-999999999999', + timestamp: '2026-07-21T00:00:01.000Z', + attachment: { + type: 'file', + filename: '/workspace/private.md', + displayPath: 'private.md', + content: { + type: 'text', + file: { + filePath: '/workspace/private.md', + content: 'must remain filtered outside desktop', + numLines: 1, + startLine: 1, + totalLines: 1, + }, + }, + }, + }, + ] as never[]) + await flushSessionStorage() + const cliTranscript = await fs.readFile( + getTranscriptPathForSession(cliSessionId), + 'utf-8', + ) + expect(cliTranscript).not.toContain('must remain filtered outside desktop') + }) + it('drains writes that are queued by pending operations during flush', async () => { const transcriptPath = path.join(tmpDir, 'late-enqueue.jsonl') const entry: CustomTitleMessage = { diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index 8afc6313..1e103b50 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -4387,9 +4387,22 @@ export function isLoggableMessage(m: Message): boolean { if (m.type === 'progress') return false // IMPORTANT: We deliberately filter out most attachments for non-ants because // they have sensitive info for training that we don't want exposed to the public. + // Desktop sessions are the narrow exception: explicit file context must be + // present in the local transcript or --resume can only restore the @path and + // silently loses the content that the model saw before an app restart. // When enabled, we allow hook_additional_context through since it contains // user-configured hook output that is useful for session context on resume. if (m.type === 'attachment' && getUserType() !== 'ant') { + if ( + getEntrypoint() === 'claude-desktop' && + ( + m.attachment.type === 'file' || + m.attachment.type === 'directory' || + m.attachment.type === 'pdf_reference' + ) + ) { + return true + } if ( m.attachment.type === 'hook_additional_context' && isEnvTruthy(process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT)