refactor(tui): store pasted images in user cache, not project workdir
Ctrl+V image paste used to write the file to .gnoma/pasted_image_*.png under the project root, which polluted the workdir and risked committing screenshots that may contain sensitive content. Now writes to os.UserCacheDir() / gnoma / pasted-images/ (XDG cache on Linux, ~/Library/Caches on macOS, %LocalAppData% on Windows). The directory is created at 0700 and files at 0600 since pasted content can be sensitive. Each paste prunes entries older than 2 hours best-effort, so the cache doesn't accumulate across sessions. The 2h window safely covers any single turn including provider retries and slow subprocess CLIs that need the file to still exist on disk when they ingest the path. .gitignore: cover the legacy `.gnoma/pasted_image_*` location for old checkouts; add log.txt and codex_out.jsonl which were tracked as runtime artifacts during the recent work. Tests cover cache-path placement, restrictive perms on both the directory and the file, the no-pollution-of-cwd invariant, and the prune behavior (stale removed, fresh kept, missing dir no-op).
This commit is contained in:
@@ -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
|
||||
|
||||
+77
-16
@@ -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 {
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
Reference in New Issue
Block a user