Skip to content

Commit 2231659

Browse files
authored
Merge pull request #50 from Bin-Huang/dev
feat(agent): typed attachments with filenames; fix non-image vision leak
2 parents 66d31b2 + 07c304c commit 2231659

7 files changed

Lines changed: 643 additions & 38 deletions

File tree

internal/agent/attachments.go

Lines changed: 179 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
209356
func 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

Comments
 (0)