@@ -9,21 +9,54 @@ import (
99 "net/http"
1010 "net/url"
1111 "os"
12+ "path"
1213 "path/filepath"
1314 "strconv"
1415 "strings"
1516 "time"
17+ "unicode/utf8"
1618)
1719
18- // WriteSessionAttachments materializes user-attached image bytes into the
19- // agent's session workspace so skills (image-tool, etc.) can read them as
20- // /workspace/<filename>. Each url is one of:
20+ // maxAttachmentBytes caps a single attachment regardless of whether it
21+ // arrived as a data URL (in-memory base64) or an HTTPS URL (streamed
22+ // fetch). 25 MB matches Anthropic's per-image envelope and prevents a
23+ // pathological data URL from sinking the gateway.
24+ const maxAttachmentBytes = 25 * 1024 * 1024
25+
26+ // maxAttachmentNameLen caps caller-supplied filenames after sanitization.
27+ // 96 is roughly the longest a filename can be before terminals start
28+ // wrapping in chat bubbles, and well clear of any path-length limits.
29+ const maxAttachmentNameLen = 96
30+
31+ // Attachment is one item the caller wants materialized into /workspace
32+ // for the current turn. URL is required (data URL or http(s) URL); Name
33+ // is optional and, when given, is sanitized and used as the on-disk
34+ // filename so the LLM sees something readable like `quarterly.pdf`
35+ // instead of `image_3jk7l_0.pdf`.
36+ //
37+ // Same-Name semantics:
38+ // - Within one turn: a second attachment with the same Name is
39+ // disambiguated as `<stem>-<idx><ext>` (token-spliced if that also
40+ // collides). No silent loss.
41+ // - Across turns: re-uploading the same Name overwrites the prior
42+ // file in /workspace. This matches the "drag the same name onto a
43+ // folder" mental model and avoids unbounded `notes-1.md`,
44+ // `notes-2.md`, … buildup. Callers that need to preserve old
45+ // versions must vary the Name themselves.
46+ type Attachment struct {
47+ URL string
48+ Name string
49+ }
50+
51+ // WriteSessionAttachments materializes user-attached bytes into the
52+ // agent's session workspace so skills (image-tool, file readers, etc.)
53+ // can reach them via /workspace/<filename>. Each URL is one of:
2154// - data URL: "data:image/png;base64,iVBORw..."
22- // - HTTPS URL: "https://example.com/photo.jpg "
55+ // - HTTPS URL: "https://example.com/report.pdf "
2356//
24- // Per-image errors are logged and skipped — a single bad URL must not sink
25- // the whole turn. Returns the relative filenames (e.g. "in_<ts>_0.png") in
26- // input order, omitting any that failed.
57+ // Per-item errors are logged and skipped — a single bad URL must not
58+ // sink the whole turn. Returns the relative filenames in input order,
59+ // omitting any that failed.
2760//
2861// Why three writes:
2962//
@@ -39,8 +72,8 @@ import (
3972// Docker doesn't need the third write (bind mount makes host writes show
4073// up instantly), but calling it is harmless. The host write is also
4174// harmless for E2B (gateway-local bytes nobody reads).
42- func (a * Agent ) WriteSessionAttachments (ctx context.Context , sessionID , projectID string , urls []string ) []string {
43- if len (urls ) == 0 {
75+ func (a * Agent ) WriteSessionAttachments (ctx context.Context , sessionID , projectID string , atts []Attachment ) []string {
76+ if len (atts ) == 0 {
4477 return nil
4578 }
4679 var paths []string
@@ -56,13 +89,19 @@ func (a *Agent) WriteSessionAttachments(ctx context.Context, sessionID, projectI
5689 if len (token ) > 5 {
5790 token = token [len (token )- 5 :]
5891 }
59- for i , u := range urls {
60- data , ext , err := decodeAttachment (ctx , u )
92+ // Track names assigned in this batch so two attachments with the
93+ // same caller-provided Name don't clobber each other. Cross-turn
94+ // collisions are intentionally left to overwrite — re-uploading
95+ // `notes.md` should replace, not accumulate `notes-1.md` forever.
96+ used := make (map [string ]struct {}, len (atts ))
97+ for i , att := range atts {
98+ data , ext , err := decodeAttachment (ctx , att .URL )
6199 if err != nil {
62100 slog .Warn ("attachment decode failed" , "agent" , a .name , "session" , sessionID , "index" , i , "error" , err )
63101 continue
64102 }
65- name := fmt .Sprintf ("image_%s_%d%s" , token , i , ext )
103+ name := buildAttachmentName (att .Name , token , i , ext , used )
104+ used [name ] = struct {}{}
66105
67106 // 1. Host workspace dir (covers no-sandbox + docker via bind mount)
68107 if a .workspacePath != "" {
@@ -125,13 +164,12 @@ func decodeAttachment(ctx context.Context, u string) ([]byte, string, error) {
125164 if resp .StatusCode < 200 || resp .StatusCode >= 300 {
126165 return nil , "" , fmt .Errorf ("HTTP %d" , resp .StatusCode )
127166 }
128- const maxBytes = 25 * 1024 * 1024 // 25 MB ceiling — same envelope as Anthropic's per-image limit
129- body , err := io .ReadAll (io .LimitReader (resp .Body , maxBytes + 1 ))
167+ body , err := io .ReadAll (io .LimitReader (resp .Body , maxAttachmentBytes + 1 ))
130168 if err != nil {
131169 return nil , "" , err
132170 }
133- if len (body ) > maxBytes {
134- return nil , "" , fmt .Errorf ("attachment exceeds %d bytes" , maxBytes )
171+ if len (body ) > maxAttachmentBytes {
172+ return nil , "" , fmt .Errorf ("attachment exceeds %d bytes" , maxAttachmentBytes )
135173 }
136174 ext := extFromMIME (resp .Header .Get ("Content-Type" ))
137175 if ext == "" {
@@ -177,6 +215,9 @@ func decodeDataURL(u string) ([]byte, string, error) {
177215 }
178216 data = []byte (decoded )
179217 }
218+ if len (data ) > maxAttachmentBytes {
219+ return nil , "" , fmt .Errorf ("attachment exceeds %d bytes" , maxAttachmentBytes )
220+ }
180221 ext := extFromMIME (mime )
181222 if ext == "" {
182223 ext = ".bin"
@@ -190,6 +231,7 @@ func extFromMIME(ct string) string {
190231 ct = ct [:i ]
191232 }
192233 switch strings .TrimSpace (strings .ToLower (ct )) {
234+ // Images
193235 case "image/png" :
194236 return ".png"
195237 case "image/jpeg" , "image/jpg" :
@@ -202,10 +244,115 @@ func extFromMIME(ct string) string {
202244 return ".heic"
203245 case "image/svg+xml" :
204246 return ".svg"
247+ // Documents — landing as the real extension matters even for models
248+ // that can't natively read the bytes, because the LLM picks its
249+ // tool based on the extension. `.bin` makes it reach for
250+ // file/identify; `.pdf` makes it reach for the right reader.
251+ case "application/pdf" :
252+ return ".pdf"
253+ case "text/plain" :
254+ return ".txt"
255+ case "text/markdown" , "text/x-markdown" :
256+ return ".md"
257+ case "text/csv" :
258+ return ".csv"
259+ case "text/html" :
260+ return ".html"
261+ case "application/json" :
262+ return ".json"
263+ case "application/xml" , "text/xml" :
264+ return ".xml"
265+ case "application/zip" :
266+ return ".zip"
205267 }
206268 return ""
207269}
208270
271+ // buildAttachmentName turns the caller's optional Name into a safe
272+ // on-disk filename. If Name is empty (or sanitizes to empty), we fall
273+ // back to the historical `image_<token>_<i><ext>` shape so existing
274+ // callers see no behavior change. If Name is present, we keep it
275+ // (sanitized), append the MIME-derived ext when the user omitted one,
276+ // and disambiguate within-batch duplicates by suffixing `-<i>`.
277+ func buildAttachmentName (raw , token string , idx int , ext string , used map [string ]struct {}) string {
278+ clean := sanitizeAttachmentName (raw )
279+ if clean == "" {
280+ return fmt .Sprintf ("image_%s_%d%s" , token , idx , ext )
281+ }
282+ if path .Ext (clean ) == "" && ext != "" {
283+ clean += ext
284+ }
285+ if _ , dup := used [clean ]; ! dup {
286+ return clean
287+ }
288+ stem := strings .TrimSuffix (clean , path .Ext (clean ))
289+ tail := path .Ext (clean )
290+ // First disambiguation: `<stem>-<idx><ext>`. Usually unique, but
291+ // can collide if the user explicitly named an earlier attachment
292+ // `report-2.pdf` and a later one with the same Name happens to
293+ // land at idx=2.
294+ candidate := fmt .Sprintf ("%s-%d%s" , stem , idx , tail )
295+ if _ , dup := used [candidate ]; ! dup {
296+ return candidate
297+ }
298+ // Final fallback: splice in the per-turn token. token is unique
299+ // per WriteSessionAttachments call, so `<stem>-<token>-<idx><ext>`
300+ // is collision-free within the batch.
301+ return fmt .Sprintf ("%s-%s-%d%s" , stem , token , idx , tail )
302+ }
303+
304+ // sanitizeAttachmentName strips path separators, parent-dir tokens,
305+ // control characters, and leading dots from a caller-supplied filename.
306+ // Returns "" if nothing usable remains so the caller can fall back.
307+ // Uses path.Base (not filepath.Base) so Windows-style paths from the
308+ // browser are handled identically on a Linux gateway.
309+ func sanitizeAttachmentName (raw string ) string {
310+ if raw == "" {
311+ return ""
312+ }
313+ // Normalize Windows separators to / so path.Base reliably extracts
314+ // the last component regardless of which side of the wire we run on.
315+ raw = strings .ReplaceAll (raw , `\` , "/" )
316+ raw = path .Base (raw )
317+ // `path.Base("..") == ".."`; reject explicitly.
318+ if raw == "." || raw == ".." {
319+ return ""
320+ }
321+ var b strings.Builder
322+ for _ , r := range raw {
323+ switch {
324+ case r < 0x20 , r == 0x7f :
325+ // control char — drop
326+ case r == '/' , r == '\\' , r == ':' , r == 0 :
327+ // path separator / drive prefix / NUL — drop
328+ default :
329+ b .WriteRune (r )
330+ }
331+ }
332+ out := strings .TrimSpace (b .String ())
333+ out = strings .TrimLeft (out , "." ) // hidden-dotfile prefix is rarely intended
334+ if len (out ) > maxAttachmentNameLen {
335+ // Truncate from the stem so we preserve the extension. Byte-
336+ // slicing on UTF-8 would chop multi-byte runes (CJK filenames
337+ // are 3 bytes/char) and yield invalid UTF-8 on disk, so back
338+ // off to the nearest rune boundary at or below the byte budget.
339+ ext := path .Ext (out )
340+ stem := strings .TrimSuffix (out , ext )
341+ keep := maxAttachmentNameLen - len (ext )
342+ if keep < 1 {
343+ keep = 1
344+ }
345+ if len (stem ) > keep {
346+ for keep > 0 && ! utf8 .RuneStart (stem [keep ]) {
347+ keep --
348+ }
349+ stem = stem [:keep ]
350+ }
351+ out = stem + ext
352+ }
353+ return out
354+ }
355+
209356func contentTypeFromExt (ext string ) string {
210357 switch strings .ToLower (ext ) {
211358 case ".png" :
@@ -220,6 +367,22 @@ func contentTypeFromExt(ext string) string {
220367 return "image/heic"
221368 case ".svg" :
222369 return "image/svg+xml"
370+ case ".pdf" :
371+ return "application/pdf"
372+ case ".txt" :
373+ return "text/plain"
374+ case ".md" :
375+ return "text/markdown"
376+ case ".csv" :
377+ return "text/csv"
378+ case ".html" :
379+ return "text/html"
380+ case ".json" :
381+ return "application/json"
382+ case ".xml" :
383+ return "application/xml"
384+ case ".zip" :
385+ return "application/zip"
223386 }
224387 return ""
225388}
0 commit comments