diff --git a/.gitignore b/.gitignore index 8b33222..3146c5a 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,14 @@ Thumbs.db # Session data .gnoma/sessions/ +# Pasted-image artifacts. New images go to the user cache dir +# (~/.cache/gnoma/pasted-images/); the pattern covers legacy +# files written into .gnoma/ before that change. +.gnoma/pasted_image_* + # Debug __debug_bin* .env .claude/ +log.txt +codex_out.jsonl diff --git a/internal/tui/app.go b/internal/tui/app.go index 34020fe..ca87d94 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -672,25 +672,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "ctrl+y": return m.copyLatestResponse() case "ctrl+v": - // Image paste writes bytes to .gnoma/pasted_image_*.png on disk, - // which violates the /incognito no-persistence contract — skip - // the image branch when incognito is active and fall through to - // the in-memory text fallback. + // Image paste writes the file to the user's cache directory + // (not the project workdir, which would pollute the repo). + // Skipped entirely under /incognito to honor the + // no-persistence contract; falls through to text clipboard. if !m.incognito { imgBytes, ext, err := pasteImageFromClipboard() if err == nil && len(imgBytes) > 0 { - timestamp := time.Now().UnixNano() - dir := filepath.Join(gnomacfg.ProjectRoot(), ".gnoma") - if errDir := os.MkdirAll(dir, 0o755); errDir == nil { - filename := fmt.Sprintf("pasted_image_%d%s", timestamp, ext) - path := filepath.Join(dir, filename) - if errWrite := os.WriteFile(path, imgBytes, 0o600); errWrite == nil { - id := fmt.Sprintf("#img%d", len(m.pastedImages)+1) - placeholder := fmt.Sprintf("[Pasted image %s]", id) - m.pastedImages[id] = path - m.input.InsertString(placeholder) - return m, nil - } + if path, errStore := storePastedImage(imgBytes, ext); errStore == nil { + id := fmt.Sprintf("#img%d", len(m.pastedImages)+1) + placeholder := fmt.Sprintf("[Pasted image %s]", id) + m.pastedImages[id] = path + m.input.InsertString(placeholder) + return m, nil } } } @@ -2276,6 +2270,73 @@ func savePromptHistory(input string) { } } +// pastedImageStaleAfter bounds how long a pasted-image file lives in the +// user cache before it is eligible for pruning. Long enough to survive +// any reasonable single turn (including provider retries and slow +// subprocess CLIs), short enough that files don't accumulate across +// sessions or days. +const pastedImageStaleAfter = 2 * time.Hour + +// pastedImageDir returns the on-disk location where Ctrl+V image pastes +// are written. Uses os.UserCacheDir() (XDG_CACHE_HOME on Linux, +// ~/Library/Caches on macOS, %LocalAppData% on Windows) so paste files +// stay out of the project workdir and live somewhere the OS knows is +// purgeable. The directory is created at mode 0700 because pasted +// images may contain screenshots with sensitive content. +func pastedImageDir() (string, error) { + base, err := os.UserCacheDir() + if err != nil { + return "", err + } + dir := filepath.Join(base, "gnoma", "pasted-images") + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + return dir, nil +} + +// pruneStalePastedImages removes pasted-image files older than +// pastedImageStaleAfter. Best-effort; errors are logged but not returned +// so a paste never fails because of cleanup trouble. +func pruneStalePastedImages(dir string) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + cutoff := time.Now().Add(-pastedImageStaleAfter) + for _, e := range entries { + if e.IsDir() { + continue + } + info, err := e.Info() + if err != nil { + continue + } + if info.ModTime().Before(cutoff) { + if rmErr := os.Remove(filepath.Join(dir, e.Name())); rmErr != nil { + slog.Debug("pasted-image prune failed", "name", e.Name(), "err", rmErr) + } + } + } +} + +// storePastedImage persists clipboard image bytes to the user cache and +// returns the absolute path. Prunes stale entries on each paste so the +// directory does not grow without bound across sessions. +func storePastedImage(data []byte, ext string) (string, error) { + dir, err := pastedImageDir() + if err != nil { + return "", err + } + pruneStalePastedImages(dir) + name := fmt.Sprintf("pasted_image_%d%s", time.Now().UnixNano(), ext) + path := filepath.Join(dir, name) + if err := os.WriteFile(path, data, 0o600); err != nil { + return "", err + } + return path, nil +} + func pasteImageFromClipboard() ([]byte, string, error) { // Try wl-paste if _, err := exec.LookPath("wl-paste"); err == nil { diff --git a/internal/tui/paste_image_test.go b/internal/tui/paste_image_test.go new file mode 100644 index 0000000..2a2a4e5 --- /dev/null +++ b/internal/tui/paste_image_test.go @@ -0,0 +1,102 @@ +package tui + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// stagePastedImageCache redirects os.UserCacheDir() to a temp dir by +// overriding XDG_CACHE_HOME. Returns the resolved cache root. +func stagePastedImageCache(t *testing.T) string { + t.Helper() + root := t.TempDir() + t.Setenv("XDG_CACHE_HOME", root) + return filepath.Join(root, "gnoma", "pasted-images") +} + +func TestStorePastedImage_WritesToUserCacheWithRestrictivePerms(t *testing.T) { + cacheDir := stagePastedImageCache(t) + + path, err := storePastedImage([]byte("png-bytes"), ".png") + if err != nil { + t.Fatalf("storePastedImage: %v", err) + } + if filepath.Dir(path) != cacheDir { + t.Errorf("path dir = %q, want %q", filepath.Dir(path), cacheDir) + } + if filepath.Ext(path) != ".png" { + t.Errorf("path ext = %q, want .png", filepath.Ext(path)) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if mode := info.Mode().Perm(); mode != 0o600 { + t.Errorf("file mode = %o, want 0600", mode) + } + if dirInfo, _ := os.Stat(cacheDir); dirInfo != nil { + if mode := dirInfo.Mode().Perm(); mode != 0o700 { + t.Errorf("dir mode = %o, want 0700", mode) + } + } +} + +func TestStorePastedImage_DoesNotPolluteProjectRoot(t *testing.T) { + // Make sure the cache dir lookup doesn't fall back to cwd / the + // project root for any reason. Stage XDG_CACHE_HOME and verify + // the returned path is under it, not under cwd. + cacheRoot := t.TempDir() + t.Setenv("XDG_CACHE_HOME", cacheRoot) + + cwd, _ := os.Getwd() + path, err := storePastedImage([]byte("x"), ".png") + if err != nil { + t.Fatal(err) + } + rel, err := filepath.Rel(cwd, path) + if err == nil && !filepath.IsAbs(rel) && rel[0] != '.' { + // path is inside cwd — that would mean we polluted the workdir + t.Errorf("storePastedImage wrote under cwd at %q", path) + } +} + +func TestPruneStalePastedImages_RemovesOldKeepsFresh(t *testing.T) { + cacheDir := stagePastedImageCache(t) + + // Manually create one stale + one fresh file (mtime via os.Chtimes). + stale := filepath.Join(cacheDir, "pasted_image_stale.png") + fresh := filepath.Join(cacheDir, "pasted_image_fresh.png") + if err := os.MkdirAll(cacheDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(stale, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fresh, []byte("new"), 0o600); err != nil { + t.Fatal(err) + } + old := time.Now().Add(-pastedImageStaleAfter - time.Minute) + if err := os.Chtimes(stale, old, old); err != nil { + t.Fatal(err) + } + + pruneStalePastedImages(cacheDir) + + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Errorf("stale file should be pruned, stat err = %v", err) + } + if _, err := os.Stat(fresh); err != nil { + t.Errorf("fresh file should survive, stat err = %v", err) + } +} + +func TestPruneStalePastedImages_MissingDirIsNoOp(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("prune panicked on missing dir: %v", r) + } + }() + pruneStalePastedImages(filepath.Join(t.TempDir(), "does", "not", "exist")) +}