890 lines
33 KiB
TypeScript
890 lines
33 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
formatVisibleChatTranscript,
|
||
getToolVisibilityId,
|
||
} from '@/pages/Chat/chat-transcript';
|
||
import { mergeChatCommandCatalog } from '@/pages/Chat/chat-command-registry';
|
||
import type { RawMessage } from '@/types/chat';
|
||
|
||
const messages: RawMessage[] = [
|
||
{
|
||
id: 'user-1',
|
||
role: 'user',
|
||
timestamp: Date.UTC(2026, 6, 18, 8, 0, 0),
|
||
content: [
|
||
{ type: 'text', text: 'Inspect the attachment.' },
|
||
{ type: 'image', mimeType: 'image/png', data: 'VERY_SECRET_BASE64' },
|
||
],
|
||
_attachedFiles: [
|
||
{
|
||
fileName: 'diagram.png',
|
||
mimeType: 'image/png',
|
||
fileSize: 10,
|
||
preview: 'data:image/png;base64,ALSO_SECRET',
|
||
filePath: 'C:\\Users\\alice\\secret\\diagram.png',
|
||
runtimeUrl: 'http://127.0.0.1:43123/api/files?path=C%3A%5CUsers%5Calice%5Csecret%5Cdiagram.png',
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: 'assistant-1',
|
||
role: 'assistant',
|
||
content: [
|
||
{ type: 'thinking', thinking: 'Private chain fragment' },
|
||
{
|
||
type: 'tool_use',
|
||
id: 'tool-1',
|
||
name: 'read',
|
||
input: {
|
||
path: '/Users/alice/private/notes.txt',
|
||
screenshot: 'data:image/png;base64,TOOL_SECRET',
|
||
rawBytes: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||
},
|
||
},
|
||
{ type: 'text', text: 'Finished.' },
|
||
],
|
||
},
|
||
];
|
||
|
||
describe('formatVisibleChatTranscript', () => {
|
||
it('writes binary placeholders and never emits data, base64, loopback URLs, or absolute paths', () => {
|
||
const markdown = formatVisibleChatTranscript(messages, {
|
||
showThinking: true,
|
||
showTimestamps: true,
|
||
expandedToolIds: new Set([getToolVisibilityId('assistant-1', 'tool-1', 0)]),
|
||
});
|
||
|
||
expect(markdown).toContain('[Attached image/png: diagram.png]');
|
||
expect(markdown).toContain('[Attached image/png: attachment]');
|
||
expect(markdown).toContain('Private chain fragment');
|
||
expect(markdown).toContain('Tool: read');
|
||
expect(markdown).toContain('[Local path: notes.txt]');
|
||
expect(markdown).not.toMatch(/VERY_SECRET|ALSO_SECRET|TOOL_SECRET|A{64}|base64/i);
|
||
expect(markdown).not.toMatch(/127\.0\.0\.1|C:\\Users|\/Users\/alice/);
|
||
});
|
||
|
||
it('omits thinking and collapsed tool details while retaining visible answers and tool names', () => {
|
||
const markdown = formatVisibleChatTranscript(messages, {
|
||
showThinking: false,
|
||
showTimestamps: false,
|
||
expandedToolIds: new Set(),
|
||
});
|
||
|
||
expect(markdown).toContain('Inspect the attachment.');
|
||
expect(markdown).toContain('Finished.');
|
||
expect(markdown).toContain('Tool: read');
|
||
expect(markdown).not.toContain('Private chain fragment');
|
||
expect(markdown).not.toContain('notes.txt');
|
||
expect(markdown).not.toContain('2026-07-18');
|
||
});
|
||
|
||
it('redacts alternate binary encodings, file URLs, IPv6 loopback, and additional absolute roots', () => {
|
||
const base64Url = `${'a'.repeat(32)}-${'b'.repeat(32)}_${'c'.repeat(8)}`;
|
||
const wrappedBase64 = `${'A'.repeat(40)}\n${'B'.repeat(40)}`;
|
||
const markdown = formatVisibleChatTranscript([{
|
||
id: 'assistant-safety',
|
||
role: 'assistant',
|
||
content: [{
|
||
type: 'tool_use',
|
||
id: 'tool-safety',
|
||
name: 'inspect',
|
||
input: {
|
||
dataUrl: `data:image/png;charset=utf-8;name=secret.png;base64,${wrappedBase64}`,
|
||
base64Url,
|
||
ipv6Loopback: 'http://[::1]:43123/private/file.png',
|
||
fileUrl: 'file:///Users/alice/private/file.txt',
|
||
hostedFileUrl: 'file://localhost/Users/alice/private/hosted.txt',
|
||
remoteHostedFileUrl: 'file://server/share/remote-hosted.txt',
|
||
rootPath: '/root/.ssh/id_ed25519',
|
||
usrPath: '/usr/local/share/private.txt',
|
||
srvPath: '/srv/internal/secret.txt',
|
||
customPath: '/company/internal/private/roadmap.txt',
|
||
rootFile: '/secret.txt',
|
||
forwardWindowsPath: 'C:/secret.txt',
|
||
windowsPath: 'C:\\Users\\alice\\private\\windows.txt',
|
||
spacedWindowsPath: 'C:\\Users\\Alice\\Private Notes\\secret.txt',
|
||
uncPath: '\\\\fileserver\\private\\unc.txt',
|
||
},
|
||
}],
|
||
}], {
|
||
showThinking: true,
|
||
showTimestamps: true,
|
||
expandedToolIds: new Set([
|
||
getToolVisibilityId('assistant-safety', 'tool-safety', 0),
|
||
]),
|
||
});
|
||
|
||
expect(markdown).toContain('[Binary data omitted]');
|
||
expect(markdown).toContain('[Local path: file.txt]');
|
||
expect(markdown).toContain('[Local path: id_ed25519]');
|
||
expect(markdown).not.toContain(base64Url);
|
||
expect(markdown).not.toContain('A'.repeat(40));
|
||
expect(markdown).not.toContain('B'.repeat(40));
|
||
expect(markdown).not.toMatch(
|
||
/data:image|base64|http:\/\/\[::1\]|file:\/\/|\/root\/|\/usr\/|\/srv\/|\/company\/|\/secret\.txt|Private Notes/i,
|
||
);
|
||
expect(markdown).not.toMatch(/C:[/\\]|\\\\fileserver/);
|
||
});
|
||
|
||
it('preserves an extensionless slash token because it may be a dynamic project command', () => {
|
||
const markdown = formatVisibleChatTranscript([{
|
||
role: 'assistant',
|
||
content: [
|
||
'/single',
|
||
'/review.v2',
|
||
'Use /base64 now',
|
||
'[the guide](/docs/getting-started)',
|
||
'Call /api/v1/users',
|
||
].join('\n'),
|
||
}], {
|
||
showThinking: false,
|
||
showTimestamps: false,
|
||
expandedToolIds: new Set(),
|
||
});
|
||
|
||
expect(markdown).toMatch(/\n\/single\n/);
|
||
expect(markdown).toContain('/review.v2');
|
||
expect(markdown).toContain('Use /base64 now');
|
||
expect(markdown).toContain('[the guide](/docs/getting-started)');
|
||
expect(markdown).toContain('Call /api/v1/users');
|
||
});
|
||
|
||
it('preserves common root-relative routes without merging adjacent paths', () => {
|
||
const markdown = formatVisibleChatTranscript([{
|
||
role: 'assistant',
|
||
content: [
|
||
'GET /v1/users now',
|
||
'Load /assets/js/app.js and /health/check',
|
||
'Paths /Volumes/Private/file.txt and /data/private/cache.bin',
|
||
].join('\n'),
|
||
}], {
|
||
showThinking: false,
|
||
showTimestamps: false,
|
||
expandedToolIds: new Set(),
|
||
});
|
||
|
||
expect(markdown).toContain('GET /v1/users now');
|
||
expect(markdown).toContain('Load /assets/js/app.js and /health/check');
|
||
expect(markdown).toContain(
|
||
'Paths [Local path: file.txt] and [Local path: cache.bin]',
|
||
);
|
||
});
|
||
|
||
it('does not exempt an absolute local path merely because it is a Markdown link', () => {
|
||
const markdown = formatVisibleChatTranscript([{
|
||
role: 'assistant',
|
||
content: [
|
||
'See [guide](/docs/getting-started).',
|
||
'See [secret](/Users/alice/private/secret.txt).',
|
||
'See [volume](/Volumes/Private/volume.txt).',
|
||
].join('\n'),
|
||
}], {
|
||
showThinking: false,
|
||
showTimestamps: false,
|
||
expandedToolIds: new Set(),
|
||
});
|
||
|
||
expect(markdown).toContain('See [guide](/docs/getting-started).');
|
||
expect(markdown).toContain('See [secret]([Local path: secret.txt]).');
|
||
expect(markdown).toContain('See [volume]([Local path: volume.txt]).');
|
||
expect(markdown).not.toMatch(/\/Users\/alice|\/Volumes\/Private/);
|
||
});
|
||
|
||
it('preserves a known nested project command while sanitizing path arguments', () => {
|
||
const commandCatalog = mergeChatCommandCatalog([{
|
||
name: 'workspace/build',
|
||
hints: ['$ARGUMENTS'],
|
||
}]);
|
||
const slashCommandNames = new Set(commandCatalog.flatMap(
|
||
(command) => [command.inputName, ...command.aliases],
|
||
));
|
||
const markdown = formatVisibleChatTranscript([{
|
||
role: 'user',
|
||
content: '/workspace/build inspect /Users/alice/private/input.txt',
|
||
}], {
|
||
showThinking: false,
|
||
showTimestamps: false,
|
||
expandedToolIds: new Set(),
|
||
slashCommandNames,
|
||
});
|
||
|
||
expect(markdown).toContain('/workspace/build inspect');
|
||
expect(markdown).toContain('[Local path: input.txt]');
|
||
expect(markdown).not.toContain('/Users/alice/private');
|
||
});
|
||
|
||
it('redacts forward-slash UNC paths and backtick-wrapped custom-root paths', () => {
|
||
const markdown = formatVisibleChatTranscript([{
|
||
role: 'assistant',
|
||
content: '//server/share/private.txt\nGenerated at `/company/internal/secret.txt`',
|
||
}], {
|
||
showThinking: false,
|
||
showTimestamps: false,
|
||
expandedToolIds: new Set(),
|
||
});
|
||
|
||
expect(markdown).toContain('[Local path: private.txt]');
|
||
expect(markdown).toContain('`[Local path: secret.txt]`');
|
||
expect(markdown).not.toMatch(/\/\/server\/share|\/company\/internal/);
|
||
});
|
||
|
||
it('redacts encoded and non-base64 data URLs, shorthand loopback, and spaced POSIX paths', () => {
|
||
const oversizedDataHeader = 'a'.repeat(2_050);
|
||
const markdown = formatVisibleChatTranscript([{
|
||
role: 'assistant',
|
||
content: [
|
||
'data:;base64,U0VDUkVUX1BBWUxPQUQ=',
|
||
'data:;base64,U0VD UkVU',
|
||
'data:image/png;base64,%2FSECRET_PAYLOAD_12345%3D',
|
||
'data:text/plain,PLAIN_TEXT_SECRET',
|
||
'data:text/plain;name="foo bar";base64,U0VDUkVU',
|
||
'data:foo,HEADER_FOO_SECRET',
|
||
'data:text,HEADER_TEXT_SECRET',
|
||
'data:@,HEADER_SYMBOL_SECRET',
|
||
'metadata:text/plain,hello',
|
||
'The data: are stored, safely.',
|
||
'data:image/png;base64,U0VDUkVU Done safely.',
|
||
`data:text/plain;name=${oversizedDataHeader},HEADER_LIMIT_SECRET`,
|
||
'data:text/plain;name="foo,QUOTE_SECRET',
|
||
'http://127.1:43123/private/file.png',
|
||
'http://localhost.:43123/private/dotted.png',
|
||
'Open `/Users/Alice/Private Notes/secret.txt` safely.',
|
||
'Use /copy now',
|
||
'/copy',
|
||
].join('\n'),
|
||
}], {
|
||
showThinking: false,
|
||
showTimestamps: false,
|
||
expandedToolIds: new Set(),
|
||
});
|
||
|
||
expect(markdown).toContain('[Binary data omitted]');
|
||
expect(markdown).toContain('[Runtime attachment: file.png]');
|
||
expect(markdown).toContain('[Runtime attachment: dotted.png]');
|
||
expect(markdown).toContain('Open `[Local path: secret.txt]` safely.');
|
||
expect(markdown).toContain('Use /copy now');
|
||
expect(markdown).toMatch(/\n\/copy\n/);
|
||
expect(markdown).not.toMatch(
|
||
/127\.1|localhost\.|\/Users\/Alice|Private Notes\/secret\.txt/i,
|
||
);
|
||
expect(markdown).not.toMatch(/(^|\n)data:/im);
|
||
expect(markdown).not.toContain('U0VDUkVUX1BBWUxPQUQ=');
|
||
expect(markdown).not.toMatch(/U0VD|UkVU/);
|
||
expect(markdown).not.toContain('%2FSECRET_PAYLOAD_12345%3D');
|
||
expect(markdown).not.toContain('PLAIN_TEXT_SECRET');
|
||
expect(markdown).toContain('metadata:text/plain,hello');
|
||
expect(markdown).not.toMatch(
|
||
/HEADER_FOO_SECRET|HEADER_TEXT_SECRET|HEADER_SYMBOL_SECRET|are stored, safely/,
|
||
);
|
||
expect(markdown).toContain('[Binary data omitted] Done safely.');
|
||
expect(markdown).not.toMatch(/HEADER_LIMIT_SECRET|QUOTE_SECRET/);
|
||
});
|
||
|
||
it('redacts complete quoted data and loopback URL payloads with legal delimiters', () => {
|
||
const markdown = formatVisibleChatTranscript([{
|
||
id: 'assistant-delimited-urls',
|
||
role: 'assistant',
|
||
content: [{
|
||
type: 'tool_use',
|
||
id: 'tool-delimited-urls',
|
||
name: 'inspect',
|
||
input: {
|
||
parenthesized: 'data:text/plain,HEAD_SECRET)TAIL_SECRET',
|
||
apostrophe: 'data:text/plain,ONE_SECRET\'TWO_SECRET',
|
||
backtick: 'data:text/plain,LEFT_SECRET`RIGHT_SECRET',
|
||
spaced: 'data:text/plain,SEC RET',
|
||
loopbackParen:
|
||
'http://127.0.0.1:43123/private)TOP_SECRET_DIR/public.png',
|
||
loopbackApostrophe:
|
||
'http://localhost:43123/private\'SECOND_SECRET_DIR/public.png',
|
||
loopbackQuote:
|
||
'http://localhost:43123/private"HTTP_SECRET_DIR/public.png',
|
||
fileParen:
|
||
'file:///Users/alice/private)FILE_PAREN_SECRET/public.txt',
|
||
fileQuote:
|
||
'file:///Users/alice/private"FILE_SECRET_DIR/public.txt',
|
||
},
|
||
}],
|
||
}], {
|
||
showThinking: false,
|
||
showTimestamps: false,
|
||
expandedToolIds: new Set([
|
||
getToolVisibilityId(
|
||
'assistant-delimited-urls',
|
||
'tool-delimited-urls',
|
||
0,
|
||
),
|
||
]),
|
||
});
|
||
|
||
expect(markdown).toContain('[Binary data omitted]');
|
||
expect(markdown).toContain('[Runtime attachment: public.png]');
|
||
expect(markdown).toContain('[Local path: public.txt]');
|
||
expect(markdown).not.toMatch(
|
||
/HEAD_SECRET|TAIL_SECRET|ONE_SECRET|TWO_SECRET|LEFT_SECRET|RIGHT_SECRET|SEC RET|TOP_SECRET_DIR|SECOND_SECRET_DIR|HTTP_SECRET_DIR|FILE_PAREN_SECRET|FILE_SECRET_DIR/,
|
||
);
|
||
});
|
||
|
||
it('redacts malformed data headers and direct unquoted local URL suffixes', () => {
|
||
const markdown = formatVisibleChatTranscript([{
|
||
role: 'assistant',
|
||
content: [
|
||
'data: text/plain,LEADING_SPACE_SECRET',
|
||
'data:\ttext/plain,TAB_HEADER_SECRET',
|
||
'data:\ftext/plain,FORM_FEED_HEADER_SECRET',
|
||
'data:text/\nplain,NEWLINE_HEADER_SECRET',
|
||
'data:text/plain;name=foo`bar,HEADER_BACKTICK_SECRET',
|
||
'data:text/plain;name=foo\\,BACKSLASH_COMMA_SECRET',
|
||
'da\nta:text/plain,DATA_SCHEME_SECRET',
|
||
'ht\ntp://localhost:43123/private/HTTP_SCHEME_SECRET/public.png',
|
||
'fi\nle:///Users/Alice/private/FILE_SCHEME_SECRET/public.txt',
|
||
'http://127.0.0.1:43123/private)TOP_SECRET_DIR/public.png',
|
||
'file:///Users/alice/private)FILE_SECRET_DIR/public.txt',
|
||
'http://local\nhost:43123/private/NEWLINE_LOOP_SECRET/public.png',
|
||
'file:///Users/ali\nce/private/NEWLINE_FILE_SECRET/public.txt',
|
||
'http://localhost:43123/private/\nHTTP_FINAL_FOLD_SECRET.png',
|
||
'file:///Users/Alice/private/\r\nFILE_FINAL_FOLD_SECRET.txt',
|
||
'`http://localhost:43123/private/\nHTTP_WRAPPED_FOLD_SECRET.png`',
|
||
'`file:///Users/Alice/private/\nFILE_WRAPPED_FOLD_SECRET.txt`',
|
||
'http://localhost:43123/Private Notes/HTTP_SPACE_SECRET/public.png',
|
||
'file:///Users/Alice/Private Notes/FILE_SPACE_SECRET/public.txt',
|
||
].join('\n'),
|
||
}], {
|
||
showThinking: false,
|
||
showTimestamps: false,
|
||
expandedToolIds: new Set(),
|
||
});
|
||
|
||
expect(markdown).toContain('[Runtime attachment: public.png]');
|
||
expect(markdown).toContain('[Local path: public.txt]');
|
||
expect(markdown).toContain(
|
||
'[Runtime attachment: HTTP_FINAL_FOLD_SECRET.png]',
|
||
);
|
||
expect(markdown).toContain('[Local path: FILE_FINAL_FOLD_SECRET.txt]');
|
||
expect(markdown).toContain(
|
||
'`[Runtime attachment: HTTP_WRAPPED_FOLD_SECRET.png]`',
|
||
);
|
||
expect(markdown).toContain(
|
||
'`[Local path: FILE_WRAPPED_FOLD_SECRET.txt]`',
|
||
);
|
||
expect(markdown).not.toMatch(/private\/\r?\n(?:HTTP|FILE)_(?:FINAL|WRAPPED)_FOLD_SECRET/);
|
||
expect(markdown).not.toMatch(
|
||
/LEADING_SPACE_SECRET|TAB_HEADER_SECRET|FORM_FEED_HEADER_SECRET|NEWLINE_HEADER_SECRET|HEADER_BACKTICK_SECRET|BACKSLASH_COMMA_SECRET|DATA_SCHEME_SECRET|HTTP_SCHEME_SECRET|FILE_SCHEME_SECRET|TOP_SECRET_DIR|FILE_SECRET_DIR|NEWLINE_LOOP_SECRET|NEWLINE_FILE_SECRET|HTTP_SPACE_SECRET|FILE_SPACE_SECRET/,
|
||
);
|
||
});
|
||
|
||
it('stops a quoted data URL at its closing wrapper', () => {
|
||
const markdown = formatVisibleChatTranscript([{
|
||
role: 'assistant',
|
||
content: 'Open "data:text/plain,secret" and then "click Save".',
|
||
}], {
|
||
showThinking: false,
|
||
showTimestamps: false,
|
||
expandedToolIds: new Set(),
|
||
});
|
||
|
||
expect(markdown).toContain(
|
||
'Open "[Binary data omitted]" and then "click Save".',
|
||
);
|
||
expect(markdown).not.toContain('secret');
|
||
});
|
||
|
||
it.each(['\n', '\r\n'])(
|
||
'stops an unclosed quoted data URL at the current line for %j',
|
||
(lineBreak) => {
|
||
const markdown = formatVisibleChatTranscript([{
|
||
role: 'assistant',
|
||
content: `Open "data:text/plain,secret${lineBreak}Keep this visible.`,
|
||
}], {
|
||
showThinking: false,
|
||
showTimestamps: false,
|
||
expandedToolIds: new Set(),
|
||
});
|
||
|
||
expect(markdown).toContain('Open "[Binary data omitted]');
|
||
expect(markdown).toContain('Keep this visible.');
|
||
expect(markdown).not.toContain('secret');
|
||
},
|
||
);
|
||
|
||
it.each([
|
||
['"', '\n'],
|
||
['"', '\r\n'],
|
||
['\'', '\n'],
|
||
['\'', '\r\n'],
|
||
['`', '\n'],
|
||
['`', '\r\n'],
|
||
])(
|
||
'does not consume visible text after a line-ended Data URL (%s, %j)',
|
||
(wrapper, lineBreak) => {
|
||
const markdown = formatVisibleChatTranscript([{
|
||
role: 'assistant',
|
||
content:
|
||
`Open ${wrapper}data:text/plain,FIRST_SECRET${lineBreak}`
|
||
+ `Keep this visible ${wrapper}quoted text${wrapper}.`,
|
||
}], {
|
||
showThinking: false,
|
||
showTimestamps: false,
|
||
expandedToolIds: new Set(),
|
||
});
|
||
|
||
expect(markdown).toContain(`Open ${wrapper}[Binary data omitted]`);
|
||
expect(markdown).toContain(
|
||
`Keep this visible ${wrapper}quoted text${wrapper}.`,
|
||
);
|
||
expect(markdown).not.toContain('FIRST_SECRET');
|
||
},
|
||
);
|
||
|
||
it.each([
|
||
['http://localhost:43123/private/', '[Runtime attachment: private]', '', '\n'],
|
||
['http://localhost:43123/private/', '[Runtime attachment: private]', '"', '\r\n'],
|
||
['file:///Users/Alice/private/', '[Local path: private]', '', '\n'],
|
||
['file:///Users/Alice/private/', '[Local path: private]', '`', '\r\n'],
|
||
])(
|
||
'does not fold ordinary prose into a line-ended local URL (%s, %s, %j)',
|
||
(url, placeholder, wrapper, lineBreak) => {
|
||
const markdown = formatVisibleChatTranscript([{
|
||
role: 'assistant',
|
||
content:
|
||
`Open ${wrapper}${url}${lineBreak}`
|
||
+ `Keep this visible.${wrapper}`,
|
||
}], {
|
||
showThinking: false,
|
||
showTimestamps: false,
|
||
expandedToolIds: new Set(),
|
||
});
|
||
|
||
expect(markdown).toContain(`Open ${wrapper}${placeholder}`);
|
||
expect(markdown).toContain(`Keep this visible.${wrapper}`);
|
||
},
|
||
);
|
||
|
||
it('preserves surrounding Markdown and prose when redacting Windows and file URL paths', () => {
|
||
const markdown = formatVisibleChatTranscript([{
|
||
role: 'assistant',
|
||
content: [
|
||
'Open `C:\\Users\\alice\\secret\\file.txt` and retry.',
|
||
'See `file:///Users/Alice/Private Notes/file.txt` safely.',
|
||
'See file:///Users/alice/private/file.txt and retry.',
|
||
'Open /Users/alice/file.txt and retry.',
|
||
'Open /Users/Alice/Private Notes/direct-posix.txt safely.',
|
||
'Open /Volumes/Private/volume.txt safely.',
|
||
'Open /data/private/cache.bin safely.',
|
||
'Open /company/internal/roadmap.txt safely.',
|
||
'Open /acme/Private Notes/acme.txt safely.',
|
||
'Open /project/My Folder/project.txt safely.',
|
||
'Open /Custom Root/private/custom.txt safely.',
|
||
'Open C:\\Users\\alice\\file.txt and retry.',
|
||
'Open C:\\Users\\Alice\\Private Notes\\direct-windows.txt safely.',
|
||
'Open C:/Users/alice/file.txt and retry.',
|
||
'Open C:/Users/Alice/Private Notes/direct-forward.txt safely.',
|
||
'Open \\\\server\\share\\file.txt and retry.',
|
||
].join('\n'),
|
||
}], {
|
||
showThinking: false,
|
||
showTimestamps: false,
|
||
expandedToolIds: new Set(),
|
||
});
|
||
|
||
expect(markdown).toContain('Open `[Local path: file.txt]` and retry.');
|
||
expect(markdown).toContain('See `[Local path: file.txt]` safely.');
|
||
expect(markdown).toContain('See [Local path: file.txt] and retry.');
|
||
expect(markdown.match(/Open \[Local path: file\.txt\] and retry\./g))
|
||
.toHaveLength(4);
|
||
expect(markdown).toContain('Open [Local path: direct-posix.txt] safely.');
|
||
expect(markdown).toContain('Open [Local path: direct-windows.txt] safely.');
|
||
expect(markdown).toContain('Open [Local path: direct-forward.txt] safely.');
|
||
expect(markdown).toContain('Open [Local path: volume.txt] safely.');
|
||
expect(markdown).toContain('Open [Local path: cache.bin] safely.');
|
||
expect(markdown).toContain('Open [Local path: roadmap.txt] safely.');
|
||
expect(markdown).toContain('Open [Local path: acme.txt] safely.');
|
||
expect(markdown).toContain('Open [Local path: project.txt] safely.');
|
||
expect(markdown).toContain('Open [Local path: custom.txt] safely.');
|
||
expect(markdown).not.toMatch(
|
||
/Private Notes|My Folder|Custom Root|\/Volumes\/|\/data\/private|\/company\/internal/,
|
||
);
|
||
});
|
||
|
||
it('redacts a short Base64 payload in visible prose without changing the command token', () => {
|
||
const markdown = formatVisibleChatTranscript([{
|
||
role: 'assistant',
|
||
content: [
|
||
'Image base64=U0VDUkVU',
|
||
'Wrapped base64: `U0VDUkVU`',
|
||
'Spaced base64: U0VD UkVU',
|
||
'Folded base64: U0VD\nUkVU',
|
||
'CRLF base64: U0VD\r\nUkVU',
|
||
'Triple folded base64: QUJD\nREVG\nR0hJ',
|
||
'The data: analysis is complete.',
|
||
'Screenshot: attached below.',
|
||
'Preview: available tomorrow.',
|
||
'Use /base64 now',
|
||
].join('\n'),
|
||
}], {
|
||
showThinking: false,
|
||
showTimestamps: false,
|
||
expandedToolIds: new Set(),
|
||
});
|
||
|
||
expect(markdown).toContain('Image base64=[Binary data omitted]');
|
||
expect(markdown).toContain('Wrapped base64: `[Binary data omitted]`');
|
||
expect(markdown).toContain('Spaced base64: [Binary data omitted]');
|
||
expect(markdown).toContain('Folded base64: [Binary data omitted]');
|
||
expect(markdown).toContain('CRLF base64: [Binary data omitted]');
|
||
expect(markdown).toContain('Triple folded base64: [Binary data omitted]');
|
||
expect(markdown).toContain('The data: analysis is complete.');
|
||
expect(markdown).toContain('Screenshot: attached below.');
|
||
expect(markdown).toContain('Preview: available tomorrow.');
|
||
expect(markdown).toContain('Use /base64 now');
|
||
expect(markdown).not.toContain('U0VDUkVU');
|
||
});
|
||
|
||
it('keeps multiple local paths on one line as separate placeholders', () => {
|
||
const markdown = formatVisibleChatTranscript([{
|
||
role: 'assistant',
|
||
content: [
|
||
'Paths C:\\Users\\Alice\\one.txt and C:\\data\\two.txt done',
|
||
'Paths C:/Users/Alice/one.txt and C:/data/two.txt done',
|
||
'Paths \\\\server\\share\\one.txt and \\\\server\\share\\two.txt done',
|
||
'Paths //server/share/one.txt and //server/share/two.txt done',
|
||
].join('\n'),
|
||
}], {
|
||
showThinking: false,
|
||
showTimestamps: false,
|
||
expandedToolIds: new Set(),
|
||
});
|
||
|
||
expect(markdown.match(/\[Local path: one\.txt\]/g)).toHaveLength(4);
|
||
expect(markdown.match(/\[Local path: two\.txt\]/g)).toHaveLength(4);
|
||
expect(markdown.match(/ and /g)).toHaveLength(4);
|
||
expect(markdown).not.toMatch(/C:[/\\]|\/\/server|\\\\server/);
|
||
});
|
||
|
||
it('stops spaced paths and URLs at punctuation or a following absolute value', () => {
|
||
const markdown = formatVisibleChatTranscript([{
|
||
role: 'assistant',
|
||
content: [
|
||
'POSIX /Volumes/one.txt, /data/two.txt done',
|
||
'POSIX /Volumes/one.txt; /data/two.txt done',
|
||
'POSIX /Volumes/one.txt /data/two.txt done',
|
||
'POSIX /Volumes/one.txt,然后 /data/two.txt done',
|
||
'POSIX /Users/Alice/Private Notes/one.txt. Next /data/two.txt done',
|
||
'WIN C:\\Users\\A\\one.txt, C:\\Users\\B\\two.txt done',
|
||
'URL http://localhost:43123/Private Notes/one.png, http://localhost:43123/Private Notes/two.png done',
|
||
'URL file:///Users/Alice/Private Notes/one.txt; file:///Users/Alice/Private Notes/two.txt done',
|
||
].join('\n'),
|
||
}], {
|
||
showThinking: false,
|
||
showTimestamps: false,
|
||
expandedToolIds: new Set(),
|
||
});
|
||
|
||
expect(markdown.match(/\[Local path: one\.txt\]/g)).toHaveLength(7);
|
||
expect(markdown.match(/\[Local path: two\.txt\]/g)).toHaveLength(7);
|
||
expect(markdown.match(/\[Runtime attachment: one\.png\]/g)).toHaveLength(1);
|
||
expect(markdown.match(/\[Runtime attachment: two\.png\]/g)).toHaveLength(1);
|
||
expect(markdown).toContain(
|
||
'[Local path: one.txt]. Next [Local path: two.txt] done',
|
||
);
|
||
expect(markdown).not.toMatch(
|
||
/\/Volumes\/|\/data\/|\/Users\/|C:\\|localhost:43123|Private Notes/,
|
||
);
|
||
});
|
||
|
||
it('keeps punctuation inside one directory while redacting its full path', () => {
|
||
const markdown = formatVisibleChatTranscript([{
|
||
role: 'assistant',
|
||
content: [
|
||
'Open /Users/Alice/Smith, John/POSIX_COMMA_SECRET.txt safely.',
|
||
'Open C:\\Users\\Alice\\Smith, John\\WIN_COMMA_SECRET.txt safely.',
|
||
'Open C:/Users/Alice/R&D/WIN_AMP_SECRET.txt safely.',
|
||
'Open file:///Users/Alice/Smith, John/FILE_COMMA_SECRET.txt safely.',
|
||
'Open http://localhost:43123/Smith, John/HTTP_COMMA_SECRET.png safely.',
|
||
].join('\n'),
|
||
}], {
|
||
showThinking: false,
|
||
showTimestamps: false,
|
||
expandedToolIds: new Set(),
|
||
});
|
||
|
||
expect(markdown).toContain('[Local path: POSIX_COMMA_SECRET.txt]');
|
||
expect(markdown).toContain('[Local path: WIN_COMMA_SECRET.txt]');
|
||
expect(markdown).toContain('[Local path: WIN_AMP_SECRET.txt]');
|
||
expect(markdown).toContain('[Local path: FILE_COMMA_SECRET.txt]');
|
||
expect(markdown).toContain('[Runtime attachment: HTTP_COMMA_SECRET.png]');
|
||
expect(markdown).not.toMatch(/Smith, John|R&D|\/Users\/|C:[/\\]|localhost/);
|
||
});
|
||
|
||
it('bounds punctuation-heavy path scanning for long visible messages', () => {
|
||
const startedAt = performance.now();
|
||
const markdown = formatVisibleChatTranscript([{
|
||
role: 'assistant',
|
||
content: `Open /Custom Root/${'a,'.repeat(100_000)}tail/file.txt safely.`,
|
||
}], {
|
||
showThinking: false,
|
||
showTimestamps: false,
|
||
expandedToolIds: new Set(),
|
||
});
|
||
const elapsedMilliseconds = performance.now() - startedAt;
|
||
|
||
expect(markdown).toContain('[Local path: file.txt]');
|
||
expect(elapsedMilliseconds).toBeLessThan(1_500);
|
||
});
|
||
|
||
it.each([
|
||
['circular', (() => {
|
||
const value: Record<string, unknown> = {};
|
||
value.self = value;
|
||
return value;
|
||
})()],
|
||
['BigInt', { value: 1n }],
|
||
['throwing toJSON', { toJSON: () => { throw new Error('secret serializer failure'); } }],
|
||
])('uses a fixed placeholder when %s tool input cannot be serialized', (_label, input) => {
|
||
const markdown = formatVisibleChatTranscript([{
|
||
id: 'assistant-unserializable',
|
||
role: 'assistant',
|
||
content: [{
|
||
type: 'tool_use',
|
||
id: 'tool-unserializable',
|
||
name: 'inspect',
|
||
input,
|
||
}],
|
||
}], {
|
||
showThinking: true,
|
||
showTimestamps: false,
|
||
expandedToolIds: new Set([
|
||
getToolVisibilityId('assistant-unserializable', 'tool-unserializable', 0),
|
||
]),
|
||
});
|
||
|
||
expect(markdown).toContain('[Tool details unavailable]');
|
||
});
|
||
|
||
it('redacts short Base64 under explicit binary tool keys', () => {
|
||
const shortBase64 = 'U0VDUkVU';
|
||
const markdown = formatVisibleChatTranscript([{
|
||
id: 'assistant-short-binary',
|
||
role: 'assistant',
|
||
content: [{
|
||
type: 'tool_use',
|
||
id: 'tool-short-binary',
|
||
name: 'inspect',
|
||
input: {
|
||
screenshotBase64: shortBase64,
|
||
rawBytes: shortBase64,
|
||
label: 'ordinary short text',
|
||
},
|
||
}],
|
||
}], {
|
||
showThinking: false,
|
||
showTimestamps: false,
|
||
expandedToolIds: new Set([
|
||
getToolVisibilityId('assistant-short-binary', 'tool-short-binary', 0),
|
||
]),
|
||
});
|
||
|
||
expect(markdown).not.toContain(shortBase64);
|
||
expect(markdown.match(/\[Binary data omitted\]/g)).toHaveLength(2);
|
||
expect(markdown).toContain('ordinary short text');
|
||
});
|
||
|
||
it('redacts short Base64 from JSON-encoded string tool input', () => {
|
||
const shortBase64 = 'U0VDUkVU';
|
||
const markdown = formatVisibleChatTranscript([{
|
||
id: 'assistant-string-binary',
|
||
role: 'assistant',
|
||
content: [{
|
||
type: 'tool_use',
|
||
id: 'tool-string-binary',
|
||
name: 'inspect',
|
||
input: JSON.stringify({
|
||
screenshotBase64: shortBase64,
|
||
ordinary: 'visible text',
|
||
}),
|
||
}],
|
||
}], {
|
||
showThinking: false,
|
||
showTimestamps: false,
|
||
expandedToolIds: new Set([
|
||
getToolVisibilityId(
|
||
'assistant-string-binary',
|
||
'tool-string-binary',
|
||
0,
|
||
),
|
||
]),
|
||
});
|
||
|
||
expect(markdown).not.toContain(shortBase64);
|
||
expect(markdown).toContain('[Binary data omitted]');
|
||
expect(markdown).toContain('visible text');
|
||
});
|
||
|
||
it.each([
|
||
['plain labelled input', 'screenshotBase64: U0VDUkVU'],
|
||
['partial JSON input', '{"screenshotBase64":"U0VDUkVU"'],
|
||
['folded partial JSON input', '{"screenshotBase64":"U0VD\\nUkVU"'],
|
||
])('redacts short Base64 from %s', (_label, input) => {
|
||
const markdown = formatVisibleChatTranscript([{
|
||
id: 'assistant-partial-binary',
|
||
role: 'assistant',
|
||
content: [{
|
||
type: 'tool_use',
|
||
id: 'tool-partial-binary',
|
||
name: 'inspect',
|
||
input,
|
||
}],
|
||
}], {
|
||
showThinking: false,
|
||
showTimestamps: false,
|
||
expandedToolIds: new Set([
|
||
getToolVisibilityId(
|
||
'assistant-partial-binary',
|
||
'tool-partial-binary',
|
||
0,
|
||
),
|
||
]),
|
||
});
|
||
|
||
expect(markdown).not.toContain('U0VDUkVU');
|
||
expect(markdown).toContain('[Binary data omitted]');
|
||
});
|
||
|
||
it('redacts single-segment absolute paths in structured tool details', () => {
|
||
const markdown = formatVisibleChatTranscript([{
|
||
id: 'assistant-structured-paths',
|
||
role: 'assistant',
|
||
content: [{
|
||
type: 'tool_use',
|
||
id: 'tool-structured-paths',
|
||
name: 'inspect',
|
||
input: {
|
||
dotEnv: '/.env',
|
||
extensionlessPath: '/secret',
|
||
},
|
||
}],
|
||
}], {
|
||
showThinking: false,
|
||
showTimestamps: false,
|
||
expandedToolIds: new Set([
|
||
getToolVisibilityId(
|
||
'assistant-structured-paths',
|
||
'tool-structured-paths',
|
||
0,
|
||
),
|
||
]),
|
||
});
|
||
|
||
expect(markdown).toContain('[Local path: .env]');
|
||
expect(markdown).toContain('[Local path: secret]');
|
||
expect(markdown).not.toMatch(/"\/(?:\.env|secret)"/);
|
||
});
|
||
|
||
it('keeps every unnamed binary placeholder and deduplicates only a matching named attachment', () => {
|
||
const markdown = formatVisibleChatTranscript([{
|
||
id: 'user-binary',
|
||
role: 'user',
|
||
content: [
|
||
{ type: 'image', mimeType: 'image/png', data: 'FIRST' },
|
||
{ type: 'image', mimeType: 'image/png', data: 'SECOND' },
|
||
{ type: 'image', mimeType: 'image/png', data: 'THIRD', fileName: '/tmp/diagram.png' },
|
||
],
|
||
_attachedFiles: [{
|
||
fileName: 'diagram.png',
|
||
mimeType: 'image/png',
|
||
fileSize: 10,
|
||
preview: null,
|
||
}],
|
||
}], {
|
||
showThinking: false,
|
||
showTimestamps: false,
|
||
expandedToolIds: new Set(),
|
||
});
|
||
|
||
expect(markdown.match(/\[Attached image\/png: attachment\]/g)).toHaveLength(2);
|
||
expect(markdown.match(/\[Attached image\/png: diagram\.png\]/g)).toHaveLength(1);
|
||
});
|
||
|
||
it('sanitizes untrusted attachment names and MIME metadata before formatting placeholders', () => {
|
||
const encodedSecret = 'A'.repeat(80);
|
||
const shortDataUrlSecret = 'SHORT_SECRET_PAYLOAD_12345';
|
||
const fileUrlToken = 'TOP_SECRET_TOKEN';
|
||
const markdown = formatVisibleChatTranscript([{
|
||
id: 'user-attachment-metadata',
|
||
role: 'user',
|
||
content: [{
|
||
type: 'image',
|
||
mimeType: `data:image/png;base64,${encodedSecret}`,
|
||
data: 'BINARY',
|
||
fileName: encodedSecret,
|
||
}],
|
||
_attachedFiles: [
|
||
{
|
||
fileName: encodedSecret,
|
||
mimeType: 'image/png',
|
||
fileSize: 10,
|
||
preview: null,
|
||
},
|
||
{
|
||
fileName: 'safe.png',
|
||
mimeType: `data:image/png;base64,${encodedSecret}`,
|
||
fileSize: 10,
|
||
preview: null,
|
||
},
|
||
{
|
||
fileName: `data:image/png;base64,${shortDataUrlSecret}`,
|
||
mimeType: 'image/png',
|
||
fileSize: 10,
|
||
preview: null,
|
||
},
|
||
{
|
||
fileName: 'http://127.0.0.1:43123/api/files?path=C%3A%5CUsers%5CAlice%5Cprivate%5Csecret.png',
|
||
mimeType: 'image/png',
|
||
fileSize: 10,
|
||
preview: null,
|
||
},
|
||
{
|
||
fileName: `file://server/share/private.txt?token=${fileUrlToken}`,
|
||
mimeType: 'image/png',
|
||
fileSize: 10,
|
||
preview: null,
|
||
},
|
||
],
|
||
}], {
|
||
showThinking: false,
|
||
showTimestamps: false,
|
||
expandedToolIds: new Set(),
|
||
});
|
||
|
||
expect(markdown).toContain('[Attached image/png: [Binary data omitted]]');
|
||
expect(markdown).toContain('[Attached application/octet-stream: safe.png]');
|
||
expect(markdown).not.toContain(encodedSecret);
|
||
expect(markdown).not.toContain(shortDataUrlSecret);
|
||
expect(markdown).not.toContain(fileUrlToken);
|
||
expect(markdown).not.toMatch(/127\.0\.0\.1|%5CUsers|file:\/\/server/i);
|
||
expect(markdown).not.toMatch(/data:image|base64/i);
|
||
});
|
||
|
||
it('uses stable fallbacks for empty message and tool IDs', () => {
|
||
expect(getToolVisibilityId('', '', 3)).toBe('message:tool:3');
|
||
|
||
const markdown = formatVisibleChatTranscript([{
|
||
role: 'assistant',
|
||
content: [{
|
||
type: 'tool_use',
|
||
id: '',
|
||
name: 'read',
|
||
input: { file: 'README.md' },
|
||
}],
|
||
}], {
|
||
showThinking: false,
|
||
showTimestamps: false,
|
||
expandedToolIds: new Set([
|
||
getToolVisibilityId('assistant-0', undefined, 0),
|
||
]),
|
||
});
|
||
|
||
expect(markdown).toContain('README.md');
|
||
});
|
||
});
|