diff options
| -rw-r--r-- | README.es.md | 12 | ||||
| -rw-r--r-- | README.md | 11 | ||||
| -rw-r--r-- | claude_logbook/__init__.py | 2 | ||||
| -rw-r--r-- | claude_logbook/cli.py | 30 | ||||
| -rw-r--r-- | claude_logbook/sessions.py | 57 | ||||
| -rw-r--r-- | claude_logbook/template.html | 34 | ||||
| -rw-r--r-- | claude_logbook/terminal.py | 7 | ||||
| -rw-r--r-- | server/README.md | 6 | ||||
| -rw-r--r-- | server/home.html | 2 | ||||
| -rw-r--r-- | server/main.go | 4 | ||||
| -rw-r--r-- | server/render.go | 72 | ||||
| -rw-r--r-- | server/server_test.go | 30 | ||||
| -rw-r--r-- | tests/test_images.py | 82 | ||||
| -rw-r--r-- | tests/test_terminal.py | 12 |
14 files changed, 339 insertions, 22 deletions
diff --git a/README.es.md b/README.es.md index fe976a0..f8749a2 100644 --- a/README.es.md +++ b/README.es.md @@ -95,6 +95,7 @@ claude-logbook docker -s 2 # lee el 2º de esos tres | `--html [FILE]` | Genera la página autocontenida con la misma selección que `--json` (por defecto `sessions.html`, o `session-<id>.html` con `-s`). | | `--template FILE` | Usa tu propio template para `--html`. | | `--open` | Abre en el navegador lo que haya generado `--html`. | +| `--images` | Con `-s`: incluye las imágenes de la sesión en `--html`, `--json` o `--share`. | | `--no-cache` | Ignora el caché y re-parsea todo. | | `-m`, `--memory` | Trabaja sobre las memorias en vez de las sesiones. | | `--type TYPE` | Con `-m`: filtra por `project`, `user`, `feedback` o `reference`. | @@ -224,6 +225,7 @@ su link: ```bash claude-logbook -s 3 --share # muestra qué se sube y pregunta claude-logbook -s 3 --share --expire 7d # desaparece en una semana (default 30 días) +claude-logbook -s 3 --share --images # con las capturas e imágenes claude-logbook -s 3 --share # otra vez más tarde: mismo link, contenido nuevo claude-logbook --shares # qué compartiste y hasta cuándo claude-logbook --unshare 5d10f1ee # bajarlo (id, URL o sesión) @@ -250,6 +252,10 @@ Para tener en cuenta: devuelve un secret que se guarda en `$XDG_STATE_HOME/claude-logbook/shares.json` (`~/.local/state/...`), con modo 600. Si perdés ese archivo, el share queda hasta que vence. +- **Las imágenes van solo con `--images`**: las que pegaste y las que una + herramienta le dio a Claude (una captura, una imagen que leyó). El aviso no + mira dentro de ellas, así que revisalas antes. El servidor acepta hasta 20 MB + por share. - Las memorias nunca se comparten. El servidor está en [`server/`](server/README.md): Go, solo biblioteca estándar, @@ -309,6 +315,10 @@ Algunos detalles que conviene saber: muestra colapsada bajo "Inherited history", y solo lo que vino después cuenta para los mensajes, la duración y la fecha de inicio. En la tabla la sesión lleva un chip `branch`. +- **Imágenes.** La transcripción muestra dónde estaba cada una (`▣ image + attached`, `▣ image from the tool`). Las imágenes en sí van solo en el + `--html`, `--json` o `--share` de una sesión, con `--images`: PNG, JPEG, GIF y + WebP, nunca SVG. - **Cambios de directorio.** Cuando la sesión pasa a otro directorio (un `cd` que queda, `/cd`, un worktree) la transcripción muestra ahí `→ /ruta/nueva`, y `-p` también encuentra la sesión por cualquiera de los directorios donde trabajó. @@ -360,7 +370,7 @@ Cada sesión de `s`: | `u` / `a` | Cantidad de mensajes tuyos / de Claude. En una rama, solo su parte propia. | | `k` | Tamaño del archivo en KB. | | `v` | Versión de Claude Code. | -| `c` | Transcripción: `[{"r": "u"|"a"|"t"|"c"|"d", "x": texto}]`; `t` es una llamada a herramienta, `c` un resumen de compactación, `d` un cambio de directorio de trabajo. | +| `c` | Transcripción: `[{"r": "u"|"a"|"t"|"c"|"d"|"i"|"v", "x": texto}]`; `t` es una llamada a herramienta, `c` un resumen de compactación, `d` un cambio de directorio de trabajo, `i` una imagen que adjuntaste y `v` una que una herramienta le dio a Claude. Con `--images` esas dos llevan `src`, la imagen como URL `data:`. | | `h` | Cuántos bloques al principio de `c` se heredaron de la sesión de la que salió esta rama (0 si no es una rama). | | `o` / `ot` | Id y título de esa sesión (`null` si no es una rama; `ot` también si ya no está en disco). | @@ -95,6 +95,7 @@ claude-logbook docker -s 2 # reads the 2nd of those three | `--html [FILE]` | Build the standalone page with the same selection as `--json` (default `sessions.html`, or `session-<id>.html` with `-s`). | | `--template FILE` | Use your own template for `--html`. | | `--open` | Open whatever `--html` produced in your browser. | +| `--images` | With `-s`: include the session's images in `--html`, `--json` or `--share`. | | `--no-cache` | Ignore the cache and re-parse everything. | | `-m`, `--memory` | Work on memories instead of sessions. | | `--type KIND` | With `-m`: filter by `project`, `user`, `feedback` or `reference`. | @@ -223,6 +224,7 @@ prints its link: ```bash claude-logbook -s 3 --share # shows what goes up and asks claude-logbook -s 3 --share --expire 7d # gone in a week (default 30 days) +claude-logbook -s 3 --share --images # with the screenshots and pictures claude-logbook -s 3 --share # again later: same link, new content claude-logbook --shares # what you have shared, and until when claude-logbook --unshare 5d10f1ee # take it down (id, URL or session) @@ -249,6 +251,9 @@ What to keep in mind: `$XDG_STATE_HOME/claude-logbook/shares.json` (`~/.local/state/...`), mode 600. Lose that file and the share stays until it expires. - Memories are never shared. +- **Images go only with `--images`**: the ones you pasted and the ones a tool + handed to Claude (a screenshot, an image it read). The warning does not look + inside them, so check them first. The server takes up to 20 MB per share. The server lives in [`server/`](server/README.md): Go, standard library only, a `FROM scratch` Docker image. You can run your own and point @@ -309,6 +314,10 @@ A few details worth knowing: shown collapsed under "Inherited history", and only what came after counts towards the messages, the duration and the start date. In the table the session carries a `branch` chip. +- **Images.** The transcript shows where each one was (`▣ image attached`, + `▣ image from the tool`). The pictures themselves only go into a single + session's `--html`, `--json` or `--share` with `--images`: PNG, JPEG, GIF + and WebP, never SVG. - **Directory changes.** When the session moves to another directory (a `cd` that sticks, `/cd`, a worktree) the transcript shows `→ /new/path` there, and `-p` also finds the session by any directory it worked in. @@ -358,7 +367,7 @@ Each session in `s`: | `u` / `a` | Message counts, yours / Claude's. In a branch, only its own part. | | `k` | File size in KB. | | `v` | Claude Code version. | -| `c` | Transcript: `[{"r": "u"|"a"|"t"|"c"|"d", "x": text}]`; `t` is a tool call, `c` a compaction summary, `d` a change of working directory. | +| `c` | Transcript: `[{"r": "u"|"a"|"t"|"c"|"d"|"i"|"v", "x": text}]`; `t` is a tool call, `c` a compaction summary, `d` a change of working directory, `i` an image you attached and `v` one a tool gave Claude. With `--images` those two carry `src`, the picture as a `data:` URL. | | `h` | How many blocks at the start of `c` were inherited from the session this one branched from (0 if not a branch). | | `o` / `ot` | Id and title of that session (`null` if not a branch; `ot` also if it is no longer on disk). | diff --git a/claude_logbook/__init__.py b/claude_logbook/__init__.py index 334c56b..faa8544 100644 --- a/claude_logbook/__init__.py +++ b/claude_logbook/__init__.py @@ -3,6 +3,6 @@ No dependencies: standard library only. """ -__version__ = "1.6.0" +__version__ = "1.7.0" __all__ = ["__version__"] diff --git a/claude_logbook/cli.py b/claude_logbook/cli.py index 64c6c01..0802507 100644 --- a/claude_logbook/cli.py +++ b/claude_logbook/cli.py @@ -10,8 +10,8 @@ import webbrowser from . import __version__ from .sessions import ( - SessionError, apply_filters, default_root, drop_from_cache, latest_activity, - load_sessions, pick, public_records, session_path, + SessionError, apply_filters, count_images, default_root, drop_from_cache, + latest_activity, load_sessions, pick, public_records, session_path, with_images, ) from .terminal import ( Style, clip, fmt_date, fmt_size, plural, print_audit, print_chat, @@ -77,6 +77,7 @@ user settings unless -p names a project, then to its settings.local.json; sharing (one session, readable by anyone with the link; asks first unless -y): claude-logbook -s 3 --share upload #3, prints its link claude-logbook -s 3 --share --expire 7d gone in a week (default 30d) + claude-logbook -s 3 --share --images with the screenshots and pictures claude-logbook --shares what this machine has shared claude-logbook --unshare 5d10f1ee delete it (share id, URL or session) """ @@ -149,6 +150,9 @@ def build_parser(): help="use another template for --html") output_path.add_argument("--open", action="store_true", help="open what --html writes in the browser") + output_path.add_argument("--images", action="store_true", + help="with -s: include the session's images in --html, " + "--json or --share") sharing = ap.add_argument_group("share") sharing.add_argument("--share", action="store_true", @@ -296,7 +300,8 @@ def export_selection(sessions, args): only the memories of the projects that made it. A single session (-s) goes alone, as it would be shared: no memories.""" if args.show: - return [pick(filtered(sessions, args), args.show)], [] + s = pick(filtered(sessions, args), args.show) + return [with_images(s) if args.images else s], [] else: chosen = filtered(sessions, args) if args.limit: @@ -598,6 +603,9 @@ def cmd_share(sessions, args, st): if s["e"]: raise SessionError("that session has no messages, there is nothing to share") server = share.server_url(args.server) + if args.images: + s = with_images(s) + images, with_src = count_images(s) payload = webpage.build_payload(public_records([s]), []) size = len(json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode()) old = share.entry_for_session(share.load_registry(), s["id"], server) @@ -606,6 +614,12 @@ def cmd_share(sessions, args, st): print(f"{st.bold}{s['t'] or 'Untitled session'}{st.reset}", file=err) print(f" {short_home(s['p'])} · {s['u']} yours / {s['a']} from Claude · " f"{len(s['c'])} blocks · {fmt_size(size / 1024)}", file=err) + if with_src: + print(f" {plural(with_src, 'image', 'images')} included; they are not " + f"checked for secrets, look at them first", file=err) + elif images: + print(f" {plural(images, 'image', 'images')} left out (add --images " + f"to include them)", file=err) where = f"updates {old['url']}" if old else f"new link on {server}" print(f" {where}, expires in {args.expire}", file=err) for kind, blocks in share.scan_secrets(payload): @@ -623,7 +637,12 @@ def cmd_share(sessions, args, st): print("Nothing was uploaded.", file=err) return 1 - entry, created = share.publish(server, payload, args.expire, s["id"], s["t"]) + try: + entry, created = share.publish(server, payload, args.expire, s["id"], s["t"]) + except SessionError as e: + if with_src and "too large" in str(e): + raise SessionError(f"{e}; try it without --images") from None + raise print(f"{'Shared' if created else 'Updated'} · expires {fmt_expiry(entry)}", file=err) print(entry["url"]) print(f"{st.faint} {entry['url']}.txt · {entry['url']}.json{st.reset}", file=err) @@ -684,6 +703,9 @@ def run(args): if args.memory: return run_memory(sessions, args, st) + if args.images and not args.show: + raise SessionError("--images needs a session (-s REF)") + if args.share: return cmd_share(sessions, args, st) diff --git a/claude_logbook/sessions.py b/claude_logbook/sessions.py index 75db116..362b398 100644 --- a/claude_logbook/sessions.py +++ b/claude_logbook/sessions.py @@ -19,9 +19,11 @@ Schema of the record returned by `read_session`: u/a number of messages from you / from Claude k size of the .jsonl in KB v Claude Code version - c transcript: [{"r": "u" | "a" | "t" | "c" | "d", "x": text}] + c transcript: [{"r": "u" | "a" | "t" | "c" | "d" | "i" | "v", "x": text}] (t: tool call summary, c: compaction summary, d: the working - directory changed to x) + directory changed to x, i: an image you attached, v: an image a + tool gave Claude). Image blocks carry the picture itself in "src", + as a data: URL, only when read with `images=True` h how many blocks at the start of `c` were copied from the session this one branched from (/branch); 0 if it is not a branch o id of that session, or None @@ -42,7 +44,7 @@ from datetime import datetime, timezone # Bump it when the record schema changes: it invalidates old caches instead of # reading records with the previous shape. -CACHE_VERSION = 4 +CACHE_VERSION = 5 EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) @@ -136,6 +138,30 @@ def tool_summary(block): return f"{name}: {val}" if val else name +# The formats a browser shows that cannot carry a script (so no SVG). +IMAGE_TYPES = ("image/png", "image/jpeg", "image/gif", "image/webp") + + +def image_src(block): + """data: URL of an image block, or None if it is not one we can show.""" + src = block.get("source") + if not isinstance(src, dict) or src.get("type") != "base64": + return None + kind, data = src.get("media_type"), src.get("data") + if kind not in IMAGE_TYPES or not isinstance(data, str) or not data: + return None + return f"data:{kind};base64,{data}" + + +def image_block(role, block, images): + out = {"r": role, "x": "[image]"} + if images: + src = image_src(block) + if src: + out["src"] = src + return out + + def blocks_of(message): content = message.get("content") if isinstance(content, str): @@ -167,8 +193,10 @@ def active_minutes(stamps): return round(total) -def read_session(path): - """Parses a whole .jsonl and returns that session's record.""" +def read_session(path, images=False): + """Parses a whole .jsonl and returns that session's record. With images + the image blocks bring the pictures, which can weigh megabytes: that + record is for a single export, never for the cache.""" session_id = os.path.basename(path)[:-6] # without .jsonl first_ts = last_ts = cwd = here = git_branch = version = None origin = None # the session /branch copied the history from @@ -263,7 +291,12 @@ def read_session(path): fallback_title = text[:TITLE_MAX] convo.append({"r": "u", "x": text}) elif b.get("type") == "image": - convo.append({"r": "u", "x": "[imagen adjunta]"}) + convo.append(image_block("i", b, images)) + elif b.get("type") == "tool_result" and isinstance(b.get("content"), list): + # What a tool handed back to Claude: a screenshot, an image it read. + for part in b["content"]: + if isinstance(part, dict) and part.get("type") == "image": + convo.append(image_block("v", part, images)) else: counted = False for b in blocks_of(message): @@ -417,6 +450,18 @@ def load_sessions(root=None, cache_path=None, use_cache=True): return sessions +def with_images(s, root=None): + """The same record with its pictures, read again from its .jsonl.""" + full = read_session(session_path(s, root), images=True) + return {**s, "c": full["c"]} + + +def count_images(s): + """(images, of which with the picture) in a record.""" + imgs = [m for m in s["c"] if m["r"] in ("i", "v")] + return len(imgs), sum(1 for m in imgs if m.get("src")) + + def latest_activity(sessions): """The most recent instant in the data: the "now" relative dates are computed against, so they do not depend on the viewer's clock.""" diff --git a/claude_logbook/template.html b/claude_logbook/template.html index 39740ac..85edcb4 100644 --- a/claude_logbook/template.html +++ b/claude_logbook/template.html @@ -561,6 +561,25 @@ border-bottom: 1px dashed var(--line); } + .shot { margin: 8px 0 8px 12px; } + .shot.mine { margin-left: 0; } + .shot.mine img { border-color: var(--amber-lo); } + .shot img { + display: block; + max-width: min(100%, 360px); + max-height: 260px; + border: 1px solid var(--line); + border-radius: 8px; + cursor: zoom-in; + } + .shot.zoom img { max-width: 100%; max-height: none; cursor: zoom-out; } + .shot-missing { + font-family: var(--mono); + font-size: 11.5px; + color: var(--muted); + padding: 3px 0 3px 12px; + } + .cwd { font-family: var(--mono); font-size: 11.5px; @@ -1216,11 +1235,24 @@ function showReader() { if (!SINGLE) readerEl.focus(); } +// Only a data: URL of a raster image ever reaches an <img>: the server checks +// it, and this checks it again for pages written by hand. +const IMG_RE = /^data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9+/=\s]+$/; + +function imageHtml(m) { + const what = m.r === 'i' ? 'image attached' : 'image from the tool'; + if (!m.src || !IMG_RE.test(m.src)) + return `<div class="shot-missing">▣ ${what}${m.src ? '' : ' (not included)'}</div>`; + return `<div class="shot${m.r === 'i' ? ' mine' : ''}"><img src="${m.src}" alt="${what}" title="${what}" loading="lazy"></div>`; +} + function blockHtml(m) { return m.r === 't' ? `<div class="tool"><b>⚒</b><span>${esc(m.x)}</span></div>` : m.r === 'd' ? `<div class="cwd">→ ${esc(m.x)}</div>` + : m.r === 'i' || m.r === 'v' + ? imageHtml(m) : m.r === 'c' ? `<details class="compact"><summary>Context compacted here · the summary Claude kept</summary><div class="bubble">${renderBody(m.x)}</div></details>` : `<div class="turn turn-${m.r}"><span class="who">${WHO[m.r]}</span><div class="bubble">${renderBody(m.x)}</div></div>`; @@ -1392,6 +1424,8 @@ rowsEl.addEventListener('keydown', e => { // Inside the reader: [[...]] links between memories and jump to the source session. bodyEl.addEventListener('click', e => { + const shot = e.target.closest('.shot'); + if (shot) { shot.classList.toggle('zoom'); return; } const wl = e.target.closest('[data-wl]'); if (wl) { openMemory(wl.dataset.wl); return; } const jump = e.target.closest('[data-chat]'); diff --git a/claude_logbook/terminal.py b/claude_logbook/terminal.py index 351c9bf..1731f14 100644 --- a/claude_logbook/terminal.py +++ b/claude_logbook/terminal.py @@ -236,6 +236,13 @@ def print_chat(s, st, out, show_tools=True, width=None): first = False divider(" context compacted here ") continue + if m["r"] in ("i", "v"): + # An image cannot be printed here: only that it was there. + if m["r"] == "i" or show_tools: + what = "image attached" if m["r"] == "i" else "image from the tool" + print(f" {st.faint}▣ {what}{st.reset}", file=out) + first = False + continue if m["r"] == "d": print(f" {st.faint}→ {clip(m['x'], body - 4)}{st.reset}", file=out) first = False diff --git a/server/README.md b/server/README.md index 8e2de37..a74cb5c 100644 --- a/server/README.md +++ b/server/README.md @@ -26,6 +26,8 @@ creation can replace or delete it. The server keeps only the secret's SHA-256. two inline scripts by hash: no inline handlers, no external anything. - Share ids are 10 base62 characters from `crypto/rand`; pages carry `X-Robots-Tag: noindex` and `Referrer-Policy: no-referrer`. +- Images (`--images`) are accepted only as base64 `data:` URLs of PNG, JPEG, + GIF or WebP whose bytes match the format they claim; SVG never. - Limits: request body (`MAX_BYTES`), sessions per share (`MAX_SESSIONS`), and a token bucket per client IP for creations and for updates/deletes. - `X-Forwarded-For` is believed only when the connection comes from @@ -41,7 +43,7 @@ Content is **not encrypted**: whoever runs the server can read the shares. | `ADDR` | `-addr` | `:8181` | | `DATA_DIR` | `-data` | `/data` | | `PUBLIC_URL` | `-public-url` | `https://claude-logbook.all.ar` | -| `MAX_BYTES` | `-max-bytes` | `10485760` (10 MB) | +| `MAX_BYTES` | `-max-bytes` | `20971520` (20 MB) | | `MAX_SESSIONS` | `-max-sessions` | `1` | | `TRUSTED_PROXY` | `-trusted-proxy` | empty: trust no proxy (IPs or CIDRs, comma separated) | | `CREATES_PER_HOUR` | `-creates-per-hour` | `10` per IP | @@ -93,7 +95,7 @@ certificate (Let's Encrypt) and *Force SSL*. Add this under *Advanced* so uploads up to `MAX_BYTES` get through: ```nginx -client_max_body_size 10m; +client_max_body_size 20m; ``` `TRUSTED_PROXY` must be the address the connections from NPM arrive from. diff --git a/server/home.html b/server/home.html index 6ae6ad1..ffc559e 100644 --- a/server/home.html +++ b/server/home.html @@ -59,6 +59,7 @@ claude-logbook <span class="c"># table of every session</span> <p>Pick a session by its number in the table or by a prefix of its UUID, then share it:</p> <pre><code>claude-logbook -s 3 --share <span class="c"># asks before uploading</span> claude-logbook -s 5d10f1ee --share --expire 7d +claude-logbook -s 3 --share --images <span class="c"># with its images</span> claude-logbook --shares <span class="c"># what you have shared</span> claude-logbook --unshare 5d10f1ee <span class="c"># take it down</span></code></pre> <p>Sharing the same session again updates it in place: the link stays the same.</p> @@ -78,6 +79,7 @@ claude-logbook --unshare 5d10f1ee <span class="c"># take it down</span> </div> <ul> <li><code>--share</code> warns if it spots things that look like API keys, tokens or private keys.</li> + <li>Images go only with <code>--images</code>, and nothing checks what they show: look at them first.</li> <li>Shares expire after 30 days unless you pick <code>--expire 1d</code>, <code>7d</code> or <code>90d</code>.</li> <li>Only the machine that created a share holds its secret and can update or delete it (<code>~/.local/state/claude-logbook/shares.json</code>).</li> diff --git a/server/main.go b/server/main.go index 617a9b1..ab3c76d 100644 --- a/server/main.go +++ b/server/main.go @@ -392,7 +392,7 @@ func main() { flag.StringVar(&cfg.Addr, "addr", env("ADDR", ":8181"), "listen address") flag.StringVar(&cfg.DataDir, "data", env("DATA_DIR", "/data"), "where shares are stored") flag.StringVar(&cfg.PublicURL, "public-url", env("PUBLIC_URL", "https://claude-logbook.all.ar"), "base of the URLs handed out") - flag.Int64Var(&cfg.MaxBytes, "max-bytes", envInt("MAX_BYTES", 10<<20), "largest request body") + flag.Int64Var(&cfg.MaxBytes, "max-bytes", envInt("MAX_BYTES", 20<<20), "largest request body") maxSessions := flag.Int64("max-sessions", envInt("MAX_SESSIONS", 1), "sessions per share") flag.StringVar(&cfg.TrustedProxy, "trusted-proxy", env("TRUSTED_PROXY", ""), "IPs/CIDRs whose X-Forwarded-For is believed (comma separated)") creates := flag.Int64("creates-per-hour", envInt("CREATES_PER_HOUR", 10), "new shares per IP and hour") @@ -421,7 +421,7 @@ func main() { Addr: cfg.Addr, Handler: srv.Routes(), ReadHeaderTimeout: 10 * time.Second, - ReadTimeout: 60 * time.Second, + ReadTimeout: 180 * time.Second, // 20 MB of images on a slow uplink WriteTimeout: 60 * time.Second, IdleTimeout: 120 * time.Second, MaxHeaderBytes: 16 << 10, diff --git a/server/render.go b/server/render.go index 2c72c1a..6acb8aa 100644 --- a/server/render.go +++ b/server/render.go @@ -22,8 +22,9 @@ const dataMarker = "__DATA__" // ──────────────────────────────── validation ───────────────────────────── type Block struct { - R string `json:"r"` - X string `json:"x"` + R string `json:"r"` + X string `json:"x"` + Src string `json:"src,omitempty"` } // Session mirrors a record of sessions.py without its internal keys. @@ -84,7 +85,16 @@ func ParsePayload(raw []byte, maxSessions int) (*Payload, []byte, error) { return nil, nil, fmt.Errorf("payload: session %d: no c", i) } for j, b := range s.C { - if b.R != "u" && b.R != "a" && b.R != "t" && b.R != "c" && b.R != "d" { + switch b.R { + case "u", "a", "t", "c", "d": + if b.Src != "" { + return nil, nil, fmt.Errorf("payload: session %d block %d: src on a %q block", i, j, b.R) + } + case "i", "v": + if b.Src != "" && !validImage(b.Src) { + return nil, nil, fmt.Errorf("payload: session %d block %d: not a png, jpeg, gif or webp image", i, j) + } + default: return nil, nil, fmt.Errorf("payload: session %d block %d: bad r", i, j) } } @@ -100,6 +110,45 @@ func ParsePayload(raw []byte, maxSessions int) (*Payload, []byte, error) { return &p, clean, err } +// imageMagic is how each accepted format starts. SVG is not among them: it +// is a document that can carry a script. +var imageMagic = map[string][]string{ + "png": {"\x89PNG\r\n\x1a\n"}, + "jpeg": {"\xff\xd8\xff"}, + "gif": {"GIF87a", "GIF89a"}, + "webp": {"RIFF"}, +} + +// validImage accepts only a base64 data: URL whose bytes really are the +// raster format it claims. +func validImage(src string) bool { + rest, ok := strings.CutPrefix(src, "data:image/") + if !ok { + return false + } + kind, data, ok := strings.Cut(rest, ";base64,") + if !ok { + return false + } + magics, ok := imageMagic[kind] + if !ok { + return false + } + raw, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return false + } + if kind == "webp" && (len(raw) < 12 || string(raw[8:12]) != "WEBP") { + return false + } + for _, m := range magics { + if bytes.HasPrefix(raw, []byte(m)) { + return true + } + } + return false +} + // ──────────────────────────────── html ─────────────────────────────────── type Page struct { @@ -169,6 +218,10 @@ func fmtStamp(s *string) string { return t.UTC().Format("2006-01-02 15:04 UTC") } +// lineBlock are the blocks printed as one "> " line; a run of them goes +// together, after a blank line. +var lineBlock = map[string]bool{"t": true, "d": true, "i": true, "v": true} + // RenderText is the transcript in plain text, in the order the terminal // shows it with `claude-logbook -s`. func RenderText(p *Payload) []byte { @@ -208,14 +261,23 @@ func RenderText(p *Payload) []byte { } switch c.R { case "d": - if prev != "t" && prev != "d" { + if !lineBlock[prev] { b.WriteString("\n") } fmt.Fprintf(&b, "> cd: %s\n", c.X) case "c": b.WriteString("\n--- context compacted here ---\n") + case "i", "v": + if !lineBlock[prev] { + b.WriteString("\n") + } + what := "image attached" + if c.R == "v" { + what = "image from the tool" + } + fmt.Fprintf(&b, "> %s\n", what) case "t": - if prev != "t" && prev != "d" { + if !lineBlock[prev] { b.WriteString("\n") } fmt.Fprintf(&b, "> tool: %s\n", strings.ReplaceAll(c.X, "\n", " ")) diff --git a/server/server_test.go b/server/server_test.go index 1ad36ee..9adfa3a 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "encoding/base64" "encoding/json" "net/http" "net/http/httptest" @@ -145,6 +146,35 @@ func TestBranchAndDirectoryChange(t *testing.T) { } } +func TestImages(t *testing.T) { + h := newHarness(t, nil) + png := "data:image/png;base64," + base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\nrest")) + withImg := strings.Replace(session, `"c":[`, `"c":[{"r":"i","x":"[image]","src":"`+png+`"},{"r":"v","x":"[image]"},`, 1) + r := h.create(`{"payload":` + payload(withImg) + `}`) + if js := h.do("GET", "/share/"+r.ID+".json", "", "").Body.String(); !strings.Contains(js, png) { + t.Fatalf("image lost: %s", js) + } + txt := h.do("GET", "/share/"+r.ID+".txt", "", "").Body.String() + if !strings.Contains(txt, "> image attached\n> image from the tool\n") { + t.Fatalf("txt: %s", txt) + } + + svg := "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte("<svg onload=alert(1)>")) + fake := "data:image/png;base64," + base64.StdEncoding.EncodeToString([]byte("<script>")) + for name, block := range map[string]string{ + "svg": `{"r":"i","x":"","src":"` + svg + `"}`, + "wrong bytes": `{"r":"i","x":"","src":"` + fake + `"}`, + "not base64": `{"r":"i","x":"","src":"data:image/png;base64,%%%"}`, + "remote": `{"r":"i","x":"","src":"https://evil.test/x.png"}`, + "src on a text": `{"r":"u","x":"","src":"` + png + `"}`, + } { + bad := strings.Replace(session, `"c":[`, `"c":[`+block+`,`, 1) + if rec := h.do("POST", "/api/shares", `{"payload":`+payload(bad)+`}`, ""); rec.Code != 400 { + t.Errorf("%s: %d %s", name, rec.Code, rec.Body) + } + } +} + func TestUpdateAndDeleteNeedTheSecret(t *testing.T) { h := newHarness(t, nil) r := h.create(`{"payload":` + payload() + `}`) diff --git a/tests/test_images.py b/tests/test_images.py new file mode 100644 index 0000000..546be0e --- /dev/null +++ b/tests/test_images.py @@ -0,0 +1,82 @@ +import base64 +import json +import os + +from claude_logbook import sessions as S + +from .fixtures import assistant, user, write_session +from .test_share import ShareCase + +PNG = base64.b64encode(b"\x89PNG\r\n\x1a\nfake").decode() +SRC = "data:image/png;base64," + PNG + + +def image(kind="image/png", data=PNG): + return {"type": "image", "source": {"type": "base64", "media_type": kind, "data": data}} + + +def pasted(*blocks, **extra): + ev = user("x", **extra) + ev["message"]["content"] = list(blocks) + return ev + + +class TestImages(ShareCase): + ID = "eeeeeeee-0000-0000-0000-000000000001" + + def setUp(self): + super().setUp() + tool_result = {"type": "tool_result", "tool_use_id": "t1", "content": [ + {"type": "text", "text": "read it"}, image("image/jpeg")]} + self.path = write_session(self.root, "-home-u-proj", self.ID, [ + pasted({"type": "text", "text": "look at this"}, image()), + assistant("Reading the other one.", tools=[("Read", {"file_path": "/x/a.jpg"})]), + pasted(tool_result), + pasted(image("image/svg+xml", "PHN2Zz4=")), + ]) + + def test_the_record_has_no_pictures(self): + rec = S.read_session(self.path) + self.assertEqual([m["r"] for m in rec["c"]], ["u", "i", "a", "t", "v", "i"]) + self.assertFalse(any("src" in m for m in rec["c"])) + self.assertEqual(rec["u"], 1) + self.assertEqual(S.count_images(rec), (3, 0)) + + def test_with_images_brings_only_raster_ones(self): + rec = S.read_session(self.path, images=True) + srcs = [m.get("src") for m in rec["c"] if m["r"] in "iv"] + self.assertEqual(srcs[0], SRC) + self.assertTrue(srcs[1].startswith("data:image/jpeg;base64,")) + self.assertIsNone(srcs[2]) # svg is not shown + self.assertEqual(S.count_images(rec), (3, 2)) + + def test_json_and_html_only_with_the_flag(self): + _, out, _ = self.run_cli("-s", "eeeeeeee", "--json") + self.assertNotIn(PNG, out) + _, out, _ = self.run_cli("-s", "eeeeeeee", "--json", "--images") + self.assertIn(SRC, json.dumps(json.loads(out))) + page = os.path.join(self.home, "p.html") + self.run_cli("-s", "eeeeeeee", "--html", page, "--images") + with open(page, encoding="utf-8") as f: + self.assertIn(PNG, f.read()) + + def test_images_need_a_session(self): + code, _, err = self.run_cli("--json", "--images") + self.assertEqual(code, 2) + self.assertIn("needs a session", err) + + def test_share_leaves_them_out_unless_asked(self): + _, _, err = self.run_cli("-s", "eeeeeeee", "--share", "-y") + self.assertIn("3 images left out", err) + body = self.fake.requests[-1][2] + self.assertNotIn(PNG, json.dumps(body)) + + _, _, err = self.run_cli("-s", "eeeeeeee", "--share", "-y", "--images") + self.assertIn("2 images included", err) + self.assertIn(SRC, json.dumps(self.fake.requests[-1][2])) + + def test_too_large_suggests_without_images(self): + self.fake.force = (413, "payload larger than 20971520 bytes") + code, _, err = self.run_cli("-s", "eeeeeeee", "--share", "-y", "--images") + self.assertEqual(code, 2) + self.assertIn("try it without --images", err) diff --git a/tests/test_terminal.py b/tests/test_terminal.py index 7752e37..c9321e7 100644 --- a/tests/test_terminal.py +++ b/tests/test_terminal.py @@ -147,6 +147,18 @@ class TestOutput(unittest.TestCase): self.assertIn("→ /home/u/proj/sub", out) self.assertIn("new one", out) + def test_images_are_a_mark(self): + c = [{"r": "u", "x": "look"}, {"r": "i", "x": "[image]", "src": "data:x"}, + {"r": "v", "x": "[image]"}] + buf = io.StringIO() + T.print_chat(self.session_item(c=c), T.Style(False), buf) + self.assertIn("▣ image attached", buf.getvalue()) + self.assertIn("▣ image from the tool", buf.getvalue()) + self.assertNotIn("data:x", buf.getvalue()) + buf = io.StringIO() + T.print_chat(self.session_item(c=c), T.Style(False), buf, show_tools=False) + self.assertNotIn("from the tool", buf.getvalue()) + def test_empty_session_says_so(self): buf = io.StringIO() T.print_chat(self.session_item(c=[], t=None), T.Style(False), buf) |