feat(coderd/x/chatd): agent-created file attachments in chat (#24280)

Agents can already see workspace files and take screenshots, but users could not download those artifacts from chat. This PR adds durable chat attachments to chatd. `attach_file`, explicit `computer` screenshot actions (not the automatic post-action screenshots), and `propose_plan` now fetch bytes over the agent connection, store them in `chat_files`, link them to the chat, and carry attachment metadata in tool responses so `buildAssistantPartsForPersist` can materialize ordinary `type:"file"` assistant parts that the chat file APIs serve.

The same storage helpers are reused for other artifact-producing paths. `wait_agent` recordings and thumbnails are stored as chat files and linked back to the parent chat, with best-effort relinking so parent chats retain those artifacts without leaving orphaned rows when chat-file caps reject links. `storeChatAttachment` wraps insert + link in one transaction, files are capped at 10 MB each and 20 per chat, and serving defaults to `Content-Disposition: attachment` with an explicit inline-safe allowlist.

This PR also consolidates chat-file media policy in `coderd/chatfiles`. Uploads and tool-generated attachments share byte-based MIME detection, SVG blocking, inline-safety rules, and compatible `text/plain` refinement for JSON, CSV, and Markdown. Prompt construction still only inlines synthetic pasted text for model consumption; assistant-created attachments are persisted for the user and intentionally not replayed into later LLM turns.

UI follow-up lives in #24281.

Relates to CODAGT-91
This commit is contained in:
Ethan
2026-04-20 18:04:35 +10:00
committed by GitHub
parent 596e55b136
commit ef6969dd70
26 changed files with 3081 additions and 857 deletions
+250
View File
@@ -0,0 +1,250 @@
package chatfiles
import (
"bytes"
"encoding/json"
"encoding/xml"
"maps"
"mime"
"path/filepath"
"slices"
"strings"
"unicode"
"github.com/gabriel-vasile/mimetype"
"golang.org/x/xerrors"
)
const MaxStoredFileNameBytes = 255
var (
// ErrStoredFileNameRequired indicates that a durable file name is empty
// after normalization.
ErrStoredFileNameRequired = xerrors.New("stored file name is required")
// ErrUnsupportedStoredFileType indicates that classified file bytes do not
// map to an allowed durable file type.
ErrUnsupportedStoredFileType = xerrors.New("unsupported attachment type")
utf8BOM = []byte{0xEF, 0xBB, 0xBF}
allowedStoredMediaTypes = map[string]struct{}{
"image/png": {},
"image/jpeg": {},
"image/gif": {},
"image/webp": {},
"text/plain": {},
"text/markdown": {},
"text/csv": {},
"application/json": {},
"application/pdf": {},
}
recordingArtifactMediaTypes = map[string]struct{}{
"video/mp4": {},
"image/jpeg": {},
}
)
// DetectMediaType detects the base media type of the given file contents.
func DetectMediaType(data []byte) string {
return BaseMediaType(mimetype.Detect(data).String())
}
// BaseMediaType strips parameters from a media type.
func BaseMediaType(mediaType string) string {
if parsed, _, err := mime.ParseMediaType(mediaType); err == nil {
return parsed
}
return mediaType
}
// AllowedStoredMediaTypesString returns the supported durable chat file media
// types as a comma-separated list.
func AllowedStoredMediaTypesString() string {
return strings.Join(slices.Sorted(maps.Keys(allowedStoredMediaTypes)), ", ")
}
// IsAllowedStoredMediaType reports whether the media type is supported for
// durable chat file storage.
func IsAllowedStoredMediaType(mediaType string) bool {
_, ok := allowedStoredMediaTypes[BaseMediaType(mediaType)]
return ok
}
// IsInlineRenderableStoredMediaType reports whether a stored chat file may be
// served with Content-Disposition: inline. PDFs remain storable but
// download-only because browser PDF viewers have a broader active-content
// attack surface than the other media types we allow inline.
func IsInlineRenderableStoredMediaType(mediaType string) bool {
mediaType = BaseMediaType(mediaType)
if !IsAllowedStoredMediaType(mediaType) {
return false
}
return mediaType != "application/pdf"
}
// NormalizeStoredFileName trims surrounding whitespace, strips control
// characters, and truncates the name to the durable storage byte limit
// without splitting UTF-8 runes.
func NormalizeStoredFileName(name string) string {
name = strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, name)
name = strings.TrimSpace(name)
return truncateUTF8Bytes(name, MaxStoredFileNameBytes)
}
// PrepareStoredFile normalizes the display name, rejects empty normalized
// names, and classifies the file bytes using detectName when provided, so
// callers can preserve subtype detection even when the user-facing filename is
// overridden.
func PrepareStoredFile(name, detectName string, data []byte) (storedName, mediaType string, err error) {
storedName = NormalizeStoredFileName(name)
if storedName == "" {
return "", "", ErrStoredFileNameRequired
}
if strings.TrimSpace(detectName) == "" {
detectName = storedName
}
mediaType = ClassifyStoredMediaType(detectName, data)
if !IsAllowedStoredMediaType(mediaType) {
return "", "", xerrors.Errorf("%w %q", ErrUnsupportedStoredFileType, mediaType)
}
return storedName, mediaType, nil
}
// PrepareRecordingArtifact normalizes the recording artifact name, rejects
// empty normalized names, and verifies that the bytes match the expected
// recording media type.
func PrepareRecordingArtifact(name, expectedMediaType string, data []byte) (storedName, mediaType string, err error) {
expectedMediaType = BaseMediaType(expectedMediaType)
if _, ok := recordingArtifactMediaTypes[expectedMediaType]; !ok {
return "", "", xerrors.Errorf("unsupported recording artifact type %q", expectedMediaType)
}
storedName = NormalizeStoredFileName(name)
if storedName == "" {
return "", "", ErrStoredFileNameRequired
}
mediaType = DetectMediaType(data)
if mediaType != expectedMediaType {
return "", "", xerrors.Errorf("recording artifact type mismatch: expected %q, detected %q", expectedMediaType, mediaType)
}
return storedName, mediaType, nil
}
// IsCompatibleUploadMediaType reports whether an upload request that declared
// declaredMediaType may be stored as storedMediaType after byte
// classification. Exact matches are always compatible; the compatibility
// table only covers explicit refinements like text/plain uploads that safely
// store as richer text subtypes.
func IsCompatibleUploadMediaType(declaredMediaType, storedMediaType string) bool {
declaredMediaType = BaseMediaType(declaredMediaType)
storedMediaType = BaseMediaType(storedMediaType)
if declaredMediaType == storedMediaType {
return true
}
if declaredMediaType != "text/plain" {
return false
}
switch storedMediaType {
case "text/markdown", "text/csv", "application/json":
return true
default:
return false
}
}
// HasSVGRootElement reports whether the provided file bytes decode to an SVG
// root element. This catches SVG content even when generic sniffers classify it
// as text or XML.
func HasSVGRootElement(data []byte) bool {
data = bytes.TrimPrefix(data, utf8BOM)
if len(data) == 0 {
return false
}
decoder := xml.NewDecoder(bytes.NewReader(data))
for {
token, err := decoder.Token()
if err != nil {
return false
}
switch token := token.(type) {
case xml.ProcInst, xml.Directive, xml.Comment:
continue
case xml.CharData:
if len(bytes.TrimSpace(token)) == 0 {
continue
}
return false
case xml.StartElement:
return strings.EqualFold(token.Name.Local, "svg")
default:
return false
}
}
}
// ClassifyStoredMediaType returns the media type that durable chat storage
// would use for the given filename and bytes. Unsupported or blocked content is
// returned as its detected media type so callers can report the specific type.
func ClassifyStoredMediaType(name string, data []byte) string {
if HasSVGRootElement(data) {
return "image/svg+xml"
}
mediaType := DetectMediaType(data)
switch mediaType {
case "image/png", "image/jpeg", "image/gif", "image/webp",
"text/markdown", "text/csv", "application/json",
"application/pdf", "application/xml", "text/xml":
return mediaType
case "text/plain":
return refineTextMediaType(name, data)
default:
if strings.HasPrefix(mediaType, "text/") {
return "text/plain"
}
return mediaType
}
}
func refineTextMediaType(name string, data []byte) string {
switch strings.ToLower(filepath.Ext(name)) {
case ".json":
if json.Valid(data) {
return "application/json"
}
case ".md", ".markdown":
return "text/markdown"
case ".csv":
return "text/csv"
}
return "text/plain"
}
func truncateUTF8Bytes(value string, maxBytes int) string {
if maxBytes <= 0 || value == "" {
return ""
}
if len(value) <= maxBytes {
return value
}
cut := 0
for idx := range value {
if idx > maxBytes {
break
}
cut = idx
}
return value[:cut]
}
+345
View File
@@ -0,0 +1,345 @@
package chatfiles_test
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/chatfiles"
)
func TestDetectMediaType_WebP(t *testing.T) {
t.Parallel()
data := append([]byte("RIFF"), []byte{0x24, 0x00, 0x00, 0x00}...)
data = append(data, []byte("WEBPVP8 ")...)
require.Equal(t, "image/webp", chatfiles.DetectMediaType(data))
}
func TestClassifyStoredMediaType(t *testing.T) {
t.Parallel()
tests := []struct {
name string
fileName string
data []byte
want string
}{
{
name: "PlainText",
fileName: "build.log",
data: []byte("build succeeded\n"),
want: "text/plain",
},
{
name: "MarkdownFromExtension",
fileName: "notes.md",
data: []byte("# Release notes\n"),
want: "text/markdown",
},
{
name: "CSVFromDetector",
fileName: "report.txt",
data: []byte("name,count\nwidgets,3\n"),
want: "text/csv",
},
{
name: "JSONFromDetector",
fileName: "payload.txt",
data: []byte(`{"ok":true}`),
want: "application/json",
},
{
name: "UppercaseJSONExtension",
fileName: "data.JSON",
data: []byte(`{"ok":true}`),
want: "application/json",
},
{
name: "InvalidJSONExtensionFallsBackToPlainText",
fileName: "broken.json",
data: []byte("not json"),
want: "text/plain",
},
{
name: "UppercaseMDExtension",
fileName: "NOTES.MD",
data: []byte("# Notes\n"),
want: "text/markdown",
},
{
name: "PDF",
fileName: "report.pdf",
data: []byte("%PDF-1.7\n"),
want: "application/pdf",
},
{
name: "BinaryOctetStream",
fileName: "data.bin",
data: []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05},
want: "application/octet-stream",
},
{
name: "HTMLFallsBackToTextPlain",
fileName: "snippet.txt",
data: []byte("<!DOCTYPE html><html><body>hello</body></html>"),
want: "text/plain",
},
{
name: "XMLStaysBlocked",
fileName: "note.xml",
data: []byte(`<?xml version="1.0"?><note><to>Tove</to></note>`),
want: "text/xml",
},
{
name: "SVGBlockedEvenWhenNamedText",
fileName: "notes.txt",
data: []byte(`<svg xmlns="http://www.w3.org/2000/svg"><text>Hello</text></svg>`),
want: "image/svg+xml",
},
{
name: "MarkdownMentioningSVGStaysMarkdown",
fileName: "notes.md",
data: []byte("# SVG Example\n<svg width=\"100\">...</svg>"),
want: "text/markdown",
},
{
name: "CSVMentioningSVGStaysCSV",
fileName: "report.csv",
data: []byte("name,icon\nlogo,<svg><rect/></svg>\n"),
want: "text/csv",
},
{
name: "TextMentioningSVGStaysPlainText",
fileName: "main.go",
data: []byte("package main\n// renders <svg> tags\n"),
want: "text/plain",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, chatfiles.ClassifyStoredMediaType(tt.fileName, tt.data))
})
}
}
func TestPrepareStoredFile(t *testing.T) {
t.Parallel()
t.Run("UsesDetectNameForSubtypeRefinement", func(t *testing.T) {
t.Parallel()
name, mediaType, err := chatfiles.PrepareStoredFile(
"payload.txt",
"report.json",
[]byte(`{"ok":true}`),
)
require.NoError(t, err)
require.Equal(t, "payload.txt", name)
require.Equal(t, "application/json", mediaType)
})
t.Run("StripsControlCharactersAndTrimsExposedWhitespace", func(t *testing.T) {
t.Parallel()
name, mediaType, err := chatfiles.PrepareStoredFile(
"\x00 release\t notes.txt \x00",
"release-notes.txt",
[]byte("hello"),
)
require.NoError(t, err)
require.Equal(t, "release notes.txt", name)
require.Equal(t, "text/plain", mediaType)
})
t.Run("RejectsEmptyNormalizedName", func(t *testing.T) {
t.Parallel()
_, _, err := chatfiles.PrepareStoredFile(
" \r\n\t ",
"notes.txt",
[]byte("hello"),
)
require.ErrorIs(t, err, chatfiles.ErrStoredFileNameRequired)
})
t.Run("RejectsUnsupportedStoredFileType", func(t *testing.T) {
t.Parallel()
_, _, err := chatfiles.PrepareStoredFile(
"evil.svg",
"evil.svg",
[]byte(`<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>`),
)
require.ErrorIs(t, err, chatfiles.ErrUnsupportedStoredFileType)
require.ErrorContains(t, err, "image/svg+xml")
})
t.Run("TruncatesNamesAtRuneBoundaries", func(t *testing.T) {
t.Parallel()
name, _, err := chatfiles.PrepareStoredFile(
strings.Repeat("界", 100),
"notes.txt",
[]byte("hello"),
)
require.NoError(t, err)
require.Equal(t, strings.Repeat("界", 85), name)
require.Equal(t, 255, len(name))
})
}
func TestPrepareRecordingArtifact(t *testing.T) {
t.Parallel()
t.Run("MP4", func(t *testing.T) {
t.Parallel()
name, mediaType, err := chatfiles.PrepareRecordingArtifact(
"recording.mp4",
"video/mp4",
[]byte{0x00, 0x00, 0x00, 0x18, 'f', 't', 'y', 'p', 'm', 'p', '4', '2', 0x00, 0x00, 0x00, 0x00, 'm', 'p', '4', '1', 'i', 's', 'o', 'm'},
)
require.NoError(t, err)
require.Equal(t, "recording.mp4", name)
require.Equal(t, "video/mp4", mediaType)
})
t.Run("JPEG", func(t *testing.T) {
t.Parallel()
name, mediaType, err := chatfiles.PrepareRecordingArtifact(
"thumbnail.jpg",
"image/jpeg",
[]byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F', 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00},
)
require.NoError(t, err)
require.Equal(t, "thumbnail.jpg", name)
require.Equal(t, "image/jpeg", mediaType)
})
t.Run("TypeMismatch", func(t *testing.T) {
t.Parallel()
_, _, err := chatfiles.PrepareRecordingArtifact(
"recording.mp4",
"video/mp4",
[]byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F', 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00},
)
require.ErrorContains(t, err, "recording artifact type mismatch")
})
t.Run("RejectsEmptyNormalizedName", func(t *testing.T) {
t.Parallel()
_, _, err := chatfiles.PrepareRecordingArtifact(
" \r\n\t ",
"video/mp4",
[]byte{0x00, 0x00, 0x00, 0x18, 'f', 't', 'y', 'p', 'm', 'p', '4', '2', 0x00, 0x00, 0x00, 0x00, 'm', 'p', '4', '1', 'i', 's', 'o', 'm'},
)
require.ErrorIs(t, err, chatfiles.ErrStoredFileNameRequired)
})
t.Run("UnsupportedExpectedType", func(t *testing.T) {
t.Parallel()
_, _, err := chatfiles.PrepareRecordingArtifact(
"recording.webm",
"video/webm",
[]byte("webm"),
)
require.ErrorContains(t, err, "unsupported recording artifact type")
})
}
func TestIsCompatibleUploadMediaType(t *testing.T) {
t.Parallel()
tests := []struct {
name string
declared string
stored string
want bool
}{
{
name: "ExactMatch",
declared: "text/plain",
stored: "text/plain",
want: true,
},
{
name: "TextPlainRefinesToMarkdown",
declared: "text/plain",
stored: "text/markdown",
want: true,
},
{
name: "TextPlainRefinesToCSV",
declared: "text/plain",
stored: "text/csv",
want: true,
},
{
name: "TextPlainRefinesToJSON",
declared: "text/plain",
stored: "application/json",
want: true,
},
{
name: "TextPlainDoesNotRefineToPNG",
declared: "text/plain",
stored: "image/png",
want: false,
},
{
name: "JSONDoesNotRefineToPlainText",
declared: "application/json",
stored: "text/plain",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, chatfiles.IsCompatibleUploadMediaType(tt.declared, tt.stored))
})
}
}
func TestIsAllowedStoredMediaType(t *testing.T) {
t.Parallel()
require.True(t, chatfiles.IsAllowedStoredMediaType("text/plain; charset=utf-8"))
require.True(t, chatfiles.IsAllowedStoredMediaType("text/markdown"))
require.True(t, chatfiles.IsAllowedStoredMediaType("text/csv"))
require.True(t, chatfiles.IsAllowedStoredMediaType("application/json"))
require.True(t, chatfiles.IsAllowedStoredMediaType("application/pdf"))
require.True(t, chatfiles.IsAllowedStoredMediaType("image/png"))
require.False(t, chatfiles.IsAllowedStoredMediaType("image/svg+xml"))
require.False(t, chatfiles.IsAllowedStoredMediaType("image/avif"))
require.False(t, chatfiles.IsAllowedStoredMediaType("application/zip"))
}
func TestIsInlineRenderableStoredMediaType(t *testing.T) {
t.Parallel()
require.True(t, chatfiles.IsInlineRenderableStoredMediaType("text/plain; charset=utf-8"))
require.True(t, chatfiles.IsInlineRenderableStoredMediaType("text/markdown"))
require.True(t, chatfiles.IsInlineRenderableStoredMediaType("image/png"))
require.False(t, chatfiles.IsInlineRenderableStoredMediaType("application/pdf"))
require.False(t, chatfiles.IsInlineRenderableStoredMediaType("image/svg+xml"))
}
func TestHasSVGRootElement(t *testing.T) {
t.Parallel()
require.True(t, chatfiles.HasSVGRootElement([]byte(`<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"></svg>`)))
require.True(t, chatfiles.HasSVGRootElement([]byte("\xef\xbb\xbf<svg></svg>")))
require.False(t, chatfiles.HasSVGRootElement([]byte("<html><body>not svg</body></html>")))
require.False(t, chatfiles.HasSVGRootElement([]byte("# SVG Example\n<svg width=\"100\">...</svg>")))
require.False(t, chatfiles.HasSVGRootElement([]byte("name,icon\nlogo,<svg><rect/></svg>\n")))
}