fix(chat): preserve attachment context across resume

This commit is contained in:
程序员阿江(Relakkes) 2026-07-21 04:05:01 +08:00
parent 7f6fdadaa3
commit 91829e91a4
4 changed files with 220 additions and 2 deletions

View File

@ -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')
})
})

View File

@ -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)`,

View File

@ -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<string> {
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 = {

View File

@ -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)