Skip to content

Commit 97cfdb2

Browse files
committed
feat(chat): inspect scratch files and preserve inline images
Resolve workspace, upload, and sandbox references through authorized file readers. Preserve explicit Markdown images privately with each chat turn without creating workspace files, and keep visual bytes out of bounded UI tool-status events. Keep sandbox CLI credentials scoped to the active callback so file provenance checks cover data entering the workbench.
1 parent b875035 commit 97cfdb2

61 files changed

Lines changed: 2913 additions & 153 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/sim/app/api/files/authorization.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ function grantAccess(cloudKey: string) {
6363
}
6464

6565
describe('verifyKBFileAccess (binding-only)', () => {
66+
it.each(['mothership', 'profile-pictures', 'general'] as const)(
67+
'refuses private chat image keys even with misleading %s context',
68+
async (context) => {
69+
await expect(
70+
verifyFileAccess('chat-images/chat/request/image.webp', USER_ID, undefined, context)
71+
).resolves.toBe(false)
72+
}
73+
)
74+
6675
it.each(['mothership', 'profile-pictures', 'general'] as const)(
6776
'refuses organization image keys through legacy %s authorization',
6877
async (context) => {

‎apps/sim/app/api/files/authorization.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ export async function verifyFileAccess(
151151
options?: { requireWrite?: boolean; knowledgeAccess?: KnowledgeFileAccess }
152152
): Promise<boolean> {
153153
/** Organization images require the Principal-aware Assistant application resolver. */
154-
if (cloudKey.startsWith('assistant/')) return false
154+
if (cloudKey.startsWith('assistant/') || cloudKey.startsWith('chat-images/')) return false
155155
const requireWrite = options?.requireWrite ?? false
156156
try {
157157
const keyContext = inferContextFromKey(cloudKey)

‎apps/sim/app/api/files/serve/[...path]/route.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,20 @@ describe('File Serve API Route', () => {
215215
})
216216
})
217217

218+
it.each(['', 's3/', 'blob/'])(
219+
'denies private chat images through generic %s serving',
220+
async (prefix) => {
221+
const key = `${prefix}chat-images/chat/request/image.webp`
222+
const response = await GET(
223+
new NextRequest(`http://localhost/api/files/serve/${key}?context=profile-pictures`),
224+
{ params: Promise.resolve({ path: key.split('/') }) }
225+
)
226+
expect(response.status).toBe(404)
227+
expect(mockReadOrganizationAssistantImage).not.toHaveBeenCalled()
228+
expect(mockCreateFileResponse).not.toHaveBeenCalled()
229+
}
230+
)
231+
218232
it('serves private Assistant images through session authorization and disables caching', async () => {
219233
authMockFns.mockGetSession.mockResolvedValue({
220234
user: { id: 'user-1' },

‎apps/sim/app/api/files/serve/[...path]/route.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,9 @@ export const GET = withRouteHandler(
220220
const isCloudPath = isS3Path || isBlobPath || isGcsPath
221221
const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath
222222

223+
/** Chat images are served only through the current private-chat owner boundary. */
224+
if (cloudKey.startsWith('chat-images/')) throw new FileNotFoundError('File not found')
225+
223226
if (cloudKey.startsWith('assistant/')) {
224227
const principal = await internalSessionAuth.authenticate()
225228
const image = await readOrganizationAssistantImage({
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { getInlineChatImageContract } from '@/lib/api/contracts/mothership-chat-images'
2+
import {
3+
defineInternalBinaryRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { internalOrchestrationErrorPolicy } from '@/lib/api/server/routes/internal-json-route'
8+
import { readInlineChatImage } from '@/lib/mothership/chat/application/inline-images'
9+
10+
export const dynamic = 'force-dynamic'
11+
export const GET = defineInternalBinaryRoute({
12+
contract: getInlineChatImageContract,
13+
auth: internalSessionAuth,
14+
operation: readInlineChatImage.operation,
15+
rateLimit: internalRateLimits.none({
16+
reason: 'Bounded private chat image delivery requires current owner authorization.',
17+
}),
18+
errorPolicy: internalOrchestrationErrorPolicy,
19+
mapInput: ({ params, query }) => ({ ...params, reference: query.path }),
20+
useCase: readInlineChatImage,
21+
present: ({ buffer, contentType }) => ({
22+
body: new Uint8Array(buffer),
23+
contentType,
24+
contentLength: buffer.length,
25+
contentDisposition: 'inline',
26+
headers: { 'Cache-Control': 'private, no-store', 'X-Content-Type-Options': 'nosniff' },
27+
}),
28+
})
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/** @vitest-environment jsdom */
2+
import { act } from 'react'
3+
import { createRoot, type Root } from 'react-dom/client'
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
vi.mock('@/lib/auth/auth-client', () => ({ useSession: () => ({ data: null, isPending: false }) }))
7+
vi.mock('@/app/workspace/[workspaceId]/home/components/chat-surface-context', () => ({
8+
useChatSurface: () => ({ chatId: 'chat' }),
9+
}))
10+
vi.mock('@/hooks/use-smooth-text', () => ({ useSmoothText: (text: string) => text }))
11+
12+
import {
13+
inlineChatImageUrl,
14+
isInlineFileReference,
15+
normalizeInlineFileReference,
16+
} from '@/lib/mothership/chat/inline-image-reference'
17+
import { collectMarkdownImageSources } from '@/lib/mothership/chat/markdown-images'
18+
import { ChatContent } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content'
19+
20+
let root: Root
21+
let container: HTMLDivElement
22+
beforeEach(() => {
23+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
24+
vi.stubGlobal(
25+
'matchMedia',
26+
vi
27+
.fn()
28+
.mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })
29+
)
30+
container = document.createElement('div')
31+
document.body.appendChild(container)
32+
root = createRoot(container)
33+
})
34+
afterEach(() => {
35+
act(() => root.unmount())
36+
container.remove()
37+
vi.unstubAllGlobals()
38+
})
39+
async function render(
40+
content: string,
41+
imageRequestId: string | undefined = 'request',
42+
isStreaming = false
43+
) {
44+
await act(async () =>
45+
root.render(
46+
<ChatContent
47+
content={content}
48+
messageId='unstable-message-id'
49+
imageRequestId={imageRequestId}
50+
isStreaming={isStreaming}
51+
/>
52+
)
53+
)
54+
}
55+
describe('standard Markdown private chat images', () => {
56+
it.each([
57+
'files/chart.png',
58+
'/tmp/chart.png',
59+
'uploads/chart.png',
60+
'files/my image.png',
61+
'files/échantillon.png',
62+
'files/price%.png',
63+
'files/literal%2520.png',
64+
'files/chart(1).png',
65+
'11111111-1111-4111-8111-111111111111',
66+
'wf_V1StGXR8z5jdHi6BmyT91',
67+
])(
68+
'renders %s through the canonical authenticated endpoint without changing source syntax',
69+
async (reference) => {
70+
const markdown = `![Chart](<${reference}>)`
71+
const astSource = collectMarkdownImageSources(markdown)[0]
72+
await render(markdown)
73+
const img = container.querySelector('img')
74+
expect(img?.getAttribute('src')).toBe(inlineChatImageUrl('chat', 'request', astSource))
75+
expect(img?.getAttribute('alt')).toBe('Chart')
76+
const path = new URL(img!.getAttribute('src')!, 'http://localhost').searchParams.get('path')!
77+
expect(normalizeInlineFileReference(path)).toBe(normalizeInlineFileReference(astSource))
78+
}
79+
)
80+
it.each(['files/' + 'x'.repeat(2100), 'files/\uD800.png', 'files/\u0000.png'])(
81+
'keeps prose when an image source is malformed',
82+
async (reference) => {
83+
await render(`Before ![Bad](<${reference}>) after.`)
84+
expect(container.textContent).toContain('Before')
85+
expect(container.textContent).toContain('after.')
86+
expect(isInlineFileReference(reference)).toBe(false)
87+
const src = container.querySelector('img')?.getAttribute('src')
88+
if (src?.startsWith('/api/mothership/chats/')) {
89+
// CommonMark may replace invalid Unicode before the renderer sees it.
90+
const path = new URL(src, 'http://localhost').searchParams.get('path')!
91+
expect(isInlineFileReference(path)).toBe(true)
92+
expect(normalizeInlineFileReference(path)).toBe(path)
93+
}
94+
}
95+
)
96+
97+
it('isolates cached Markdown processors across distinct assistant turns', async () => {
98+
await act(async () =>
99+
root.render(
100+
<>
101+
<ChatContent content='![First](files/same.png)' imageRequestId='first-turn' />
102+
<ChatContent content='![Second](files/same.png)' imageRequestId='second-turn' />
103+
</>
104+
)
105+
)
106+
expect(
107+
[...container.querySelectorAll('img')].map((image) => image.getAttribute('src'))
108+
).toEqual([
109+
inlineChatImageUrl('chat', 'first-turn', 'files/same.png'),
110+
inlineChatImageUrl('chat', 'second-turn', 'files/same.png'),
111+
])
112+
})
113+
it('keeps existing remote image rendering', async () => {
114+
await render('![Remote](https://example.com/chart.png)')
115+
expect(container.querySelector('img')?.getAttribute('src')).toBe(
116+
'https://example.com/chart.png'
117+
)
118+
})
119+
it('never displays image syntax inside fenced or inline code', async () => {
120+
await render('`![Code](/tmp/x.png)`\n\n```markdown\n![Example](files/x.png)\n```')
121+
expect(container.querySelector('img')).toBeNull()
122+
})
123+
it('does not request an incomplete streaming destination', async () => {
124+
await render('Before ![Chart](files/chart', 'request', true)
125+
expect(container.querySelector('img')).toBeNull()
126+
await render('Before ![Chart](files/chart.png)', 'request', true)
127+
expect(container.querySelector('img')?.getAttribute('src')).toBe(
128+
inlineChatImageUrl('chat', 'request', 'files/chart.png')
129+
)
130+
})
131+
it('keeps the same snapshot URL when live and persisted message IDs differ', async () => {
132+
await render('![Chart](/tmp/chart.png)')
133+
const src = container.querySelector('img')?.getAttribute('src')
134+
await act(async () =>
135+
root.render(
136+
<ChatContent
137+
content='![Chart](/tmp/chart.png)'
138+
messageId='new-persisted-id'
139+
imageRequestId='request'
140+
/>
141+
)
142+
)
143+
expect(container.querySelector('img')?.getAttribute('src')).toBe(src)
144+
})
145+
})

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import {
1010
useRef,
1111
useState,
1212
} from 'react'
13-
import { Streamdown } from 'streamdown'
13+
import type { Nodes } from 'hast'
14+
import { defaultRehypePlugins, Streamdown } from 'streamdown'
1415
import 'streamdown/styles.css'
1516
// prismjs core must load before its language components — they register on the
1617
// global `Prism` it installs (on `window`/`global`); fixes SSR + client order.
@@ -22,7 +23,12 @@ import 'prismjs/components/prism-markup'
2223
import '@sim/emcn/components/code/code.css'
2324
import { Checkbox, CopyCodeButton, cn, languages, highlight as prismHighlight } from '@sim/emcn'
2425
import { extractTextContent } from '@/lib/core/utils/react-node-text'
26+
import {
27+
inlineChatImageUrl,
28+
isInlineFileReference,
29+
} from '@/lib/mothership/chat/inline-image-reference'
2530
import { decodeVfsSegmentSafe } from '@/lib/mothership/vfs/path-utils'
31+
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
2632
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
2733
import {
2834
SourceChip,
@@ -455,6 +461,7 @@ const MARKDOWN_COMPONENTS = {
455461
interface ChatContentProps {
456462
content: string
457463
messageId?: string
464+
imageRequestId?: string
458465
requestMode?: 'agent' | 'assistant'
459466
isStreaming?: boolean
460467
/** Transcript-derived answers for this message's question card (renders the recap). */
@@ -476,9 +483,33 @@ interface ChatContentProps {
476483
onPendingTagChange?: (pending: boolean) => void
477484
}
478485

486+
/** Explicit options keep Streamdown's processor cache scoped to this chat and turn. */
487+
function privateImageUrls({
488+
chatId,
489+
imageRequestId,
490+
}: {
491+
chatId?: string | null
492+
imageRequestId?: string
493+
}) {
494+
return (tree: Nodes) => {
495+
function visit(node: Nodes): void {
496+
if (node.type === 'element' && node.tagName === 'img') {
497+
const src = node.properties.src
498+
if (typeof src === 'string' && isInlineFileReference(src)) {
499+
node.properties.src =
500+
chatId && imageRequestId ? inlineChatImageUrl(chatId, imageRequestId, src) : ''
501+
}
502+
}
503+
if ('children' in node) node.children.forEach(visit)
504+
}
505+
visit(tree)
506+
}
507+
}
508+
479509
function ChatContentInner({
480510
content,
481511
messageId,
512+
imageRequestId,
482513
requestMode,
483514
isStreaming = false,
484515
questionAnswers,
@@ -491,6 +522,13 @@ function ChatContentInner({
491522
onStreamActivityChange,
492523
onPendingTagChange,
493524
}: ChatContentProps) {
525+
const { chatId } = useChatSurface()
526+
const imageRehypePlugins = useMemo<
527+
NonNullable<ComponentPropsWithoutRef<typeof Streamdown>['rehypePlugins']>
528+
>(
529+
() => [[privateImageUrls, { chatId, imageRequestId }], ...Object.values(defaultRehypePlugins)],
530+
[chatId, imageRequestId]
531+
)
494532
const onWorkspaceResourceSelectRef = useRef(onWorkspaceResourceSelect)
495533
onWorkspaceResourceSelectRef.current = onWorkspaceResourceSelect
496534

@@ -713,6 +751,7 @@ function ChatContentInner({
713751
animated={fadeActive ? STREAM_ANIMATION : false}
714752
isAnimating={streamingTree}
715753
components={MARKDOWN_COMPONENTS}
754+
rehypePlugins={imageRehypePlugins}
716755
>
717756
{group.markdown}
718757
</Streamdown>

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,19 +31,19 @@ import {
3131
hasAgentGroupItemContent,
3232
hasPendingAgentGroup,
3333
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-content'
34+
import { isAgentGroupResolved } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
3435
import { getActivityStatusTool } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group'
3536
import type { CredentialSubmissionPayload } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
3637
import { TaskPill } from '@/app/workspace/[workspaceId]/home/components/message-content/components/task-pill'
3738
import { collectMessageSources } from '@/app/workspace/[workspaceId]/home/components/message-content/message-sources'
3839
import { resolveMessageCitations } from '@/app/workspace/[workspaceId]/home/components/message-content/resolve-citations'
40+
import { useToolResourceTitles } from '@/app/workspace/[workspaceId]/home/hooks/use-tool-resource-titles'
3941
import type {
4042
ContentBlock,
4143
OptionItem,
4244
ToolCallData,
4345
} from '@/app/workspace/[workspaceId]/home/types'
4446
import { SUBAGENT_LABELS } from '@/app/workspace/[workspaceId]/home/types'
45-
import { isAgentGroupResolved } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
46-
import { useToolResourceTitles } from '@/app/workspace/[workspaceId]/home/hooks/use-tool-resource-titles'
4747
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
4848
import type { AgentGroupItem } from './components'
4949
import {
@@ -959,6 +959,7 @@ interface MessageContentProps {
959959
blocks: ContentBlock[]
960960
fallbackContent: string
961961
messageId?: string
962+
imageRequestId?: string
962963
requestMode?: 'agent' | 'assistant'
963964
isStreaming: boolean
964965
/**
@@ -990,6 +991,7 @@ function MessageContentInner({
990991
blocks,
991992
fallbackContent,
992993
messageId,
994+
imageRequestId,
993995
requestMode,
994996
isStreaming = false,
995997
isLast = false,
@@ -1127,6 +1129,7 @@ function MessageContentInner({
11271129
key={segment.id}
11281130
content={segment.content}
11291131
messageId={messageId}
1132+
imageRequestId={imageRequestId}
11301133
requestMode={requestMode}
11311134
isStreaming={shouldSmoothTextSegment({
11321135
isStreaming,

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
315315
<div className={cn(rowClassName, showsInteractionCard && 'pb-3')}>
316316
<MessageContent
317317
messageId={message.id}
318+
imageRequestId={message.requestId}
318319
requestMode={message.requestMode ?? requestMode}
319320
blocks={blocks}
320321
fallbackContent={message.content}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { z } from 'zod'
2+
import { defineRouteContract } from '@/lib/api/contracts/types'
3+
import {
4+
inlineImageRequestIdSchema,
5+
inlineImageSourceSchema,
6+
} from '@/lib/mothership/chat/inline-image-reference'
7+
8+
export const inlineChatImageParamsSchema = z.object({
9+
chatId: inlineImageRequestIdSchema,
10+
requestId: inlineImageRequestIdSchema,
11+
})
12+
export type InlineChatImageParams = z.output<typeof inlineChatImageParamsSchema>
13+
export const inlineChatImageQuerySchema = z.object({ path: inlineImageSourceSchema })
14+
export type InlineChatImageQuery = z.output<typeof inlineChatImageQuerySchema>
15+
export const getInlineChatImageContract = defineRouteContract({
16+
method: 'GET',
17+
path: '/api/mothership/chats/[chatId]/images/[requestId]',
18+
params: inlineChatImageParamsSchema,
19+
query: inlineChatImageQuerySchema,
20+
response: { mode: 'binary' },
21+
})

0 commit comments

Comments
 (0)