Files
coder/coderd/x/chatd/chattool/attachment.go
T
Ethan ef6969dd70 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
2026-04-20 18:04:35 +10:00

172 lines
4.9 KiB
Go

package chattool
import (
"context"
"encoding/base64"
"encoding/json"
"io"
"strings"
"charm.land/fantasy"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
const maxAttachmentSize = 10 << 20 // 10 MiB
// StoreFileFunc persists a chat attachment after classifying it for durable
// storage and returns the stored attachment metadata.
type StoreFileFunc func(ctx context.Context, name string, detectName string, data []byte) (AttachmentMetadata, error)
// AttachmentMetadata identifies a durable chat attachment that should be
// promoted into a standard file message part for the user.
type AttachmentMetadata struct {
FileID uuid.UUID `json:"file_id"`
MediaType string `json:"media_type"`
Name string `json:"name,omitempty"`
}
type attachmentResponseMetadata struct {
Attachments []AttachmentMetadata `json:"attachments,omitempty"`
}
func storeAttachmentData(
ctx context.Context,
storeFile StoreFileFunc,
name string,
detectName string,
data []byte,
) (AttachmentMetadata, error) {
if storeFile == nil {
return AttachmentMetadata{}, xerrors.New("file storage is not configured")
}
if len(data) == 0 {
return AttachmentMetadata{}, xerrors.New("attachment is empty")
}
if len(data) > maxAttachmentSize {
return AttachmentMetadata{}, xerrors.Errorf("attachment exceeds %d MiB size limit", maxAttachmentSize>>20)
}
name = strings.TrimSpace(name)
if name == "" {
return AttachmentMetadata{}, xerrors.New("attachment name is required")
}
if strings.TrimSpace(detectName) == "" {
detectName = name
}
attachment, err := storeFile(ctx, name, detectName, data)
if err != nil {
return AttachmentMetadata{}, err
}
if attachment.FileID == uuid.Nil {
return AttachmentMetadata{}, xerrors.New("stored attachment is missing file ID")
}
if attachment.MediaType == "" {
return AttachmentMetadata{}, xerrors.New("stored attachment is missing media type")
}
if attachment.Name == "" {
attachment.Name = name
}
return attachment, nil
}
func storeWorkspaceAttachment(
ctx context.Context,
conn workspacesdk.AgentConn,
path string,
name string,
storeFile StoreFileFunc,
) (AttachmentMetadata, int, error) {
if conn == nil {
return AttachmentMetadata{}, 0, xerrors.New("workspace connection is not configured")
}
if strings.TrimSpace(path) == "" {
return AttachmentMetadata{}, 0, xerrors.New("path is required")
}
reader, _, err := conn.ReadFile(ctx, path, 0, maxAttachmentSize+1)
if err != nil {
return AttachmentMetadata{}, 0, err
}
defer reader.Close()
data, err := io.ReadAll(io.LimitReader(reader, maxAttachmentSize+1))
if err != nil {
return AttachmentMetadata{}, 0, err
}
if strings.TrimSpace(name) == "" {
path = strings.TrimRight(path, "/\\")
if idx := strings.LastIndexAny(path, "/\\"); idx >= 0 {
name = path[idx+1:]
} else {
name = path
}
}
attachment, err := storeAttachmentData(ctx, storeFile, name, path, data)
if err != nil {
return AttachmentMetadata{}, 0, err
}
return attachment, len(data), nil
}
func storeScreenshotAttachment(
ctx context.Context,
storeFile StoreFileFunc,
name string,
encodedPNG string,
) (AttachmentMetadata, error) {
if strings.TrimSpace(encodedPNG) == "" {
return AttachmentMetadata{}, xerrors.New("screenshot data is empty")
}
decoder := base64.NewDecoder(base64.StdEncoding, strings.NewReader(encodedPNG))
data, err := io.ReadAll(io.LimitReader(decoder, maxAttachmentSize+1))
if err != nil {
return AttachmentMetadata{}, xerrors.Errorf("decode screenshot: %w", err)
}
if strings.TrimSpace(name) == "" {
name = "screenshot.png"
}
return storeAttachmentData(ctx, storeFile, name, name, data)
}
// WithAttachments stores durable attachment metadata on a tool response so the
// persistence layer can promote the files into assistant chat attachments.
func WithAttachments(
response fantasy.ToolResponse,
attachments ...AttachmentMetadata,
) fantasy.ToolResponse {
if len(attachments) == 0 {
return response
}
return fantasy.WithResponseMetadata(response, attachmentResponseMetadata{
Attachments: attachments,
})
}
// AttachmentsFromMetadata decodes durable attachment metadata from a tool
// response so the persistence layer can promote them into assistant file parts.
func AttachmentsFromMetadata(metadata string) ([]AttachmentMetadata, error) {
if strings.TrimSpace(metadata) == "" {
return nil, nil
}
var decoded attachmentResponseMetadata
if err := json.Unmarshal([]byte(metadata), &decoded); err != nil {
return nil, xerrors.Errorf("unmarshal attachment metadata: %w", err)
}
attachments := make([]AttachmentMetadata, 0, len(decoded.Attachments))
for i, attachment := range decoded.Attachments {
if attachment.FileID == uuid.Nil {
return nil, xerrors.Errorf("attachment %d is missing file_id", i)
}
if attachment.MediaType == "" {
return nil, xerrors.Errorf("attachment %d is missing media_type", i)
}
attachments = append(attachments, attachment)
}
return attachments, nil
}